From 467a2d400215b7df6c285a2960af4b1cc17456ed Mon Sep 17 00:00:00 2001
From: Diego Mello <diegolmello@gmail.com>
Date: Mon, 10 Jun 2019 13:23:19 -0300
Subject: [PATCH] [NEW] In-app notification (#964)

* added Notification badge

* added notification to state

* added condition not see notification of current room

* fixed lint

* fixed some bugs

* fixed some bugs

* removed navigation prop

* fixed navigation bug

* removed unessary changes

* done requested chamges

* made separate notification for ios and android

* merged notification

* Removed unnecessary sub

* Animation

* Layout changes

* Refactor
---
 app/actions/actionsTypes.js                  |   1 +
 app/actions/notification.js                  |  17 ++
 app/constants/colors.js                      |   1 +
 app/index.js                                 |  24 +-
 app/lib/methods/subscriptions/rooms.js       |   5 +
 app/lib/rocketchat.js                        |   2 +-
 app/notifications/inApp/index.js             | 229 +++++++++++++++++++
 app/{ => notifications}/push/index.js        |   4 +-
 app/{ => notifications}/push/push.android.js |   0
 app/{ => notifications}/push/push.ios.js     |   0
 app/reducers/index.js                        |   2 +
 app/reducers/notification.js                 |  24 ++
 app/sagas/state.js                           |   2 +-
 13 files changed, 305 insertions(+), 6 deletions(-)
 create mode 100644 app/actions/notification.js
 create mode 100644 app/notifications/inApp/index.js
 rename app/{ => notifications}/push/index.js (89%)
 rename app/{ => notifications}/push/push.android.js (100%)
 rename app/{ => notifications}/push/push.ios.js (100%)
 create mode 100644 app/reducers/notification.js

diff --git a/app/actions/actionsTypes.js b/app/actions/actionsTypes.js
index 29545dc22..6e704861b 100644
--- a/app/actions/actionsTypes.js
+++ b/app/actions/actionsTypes.js
@@ -66,4 +66,5 @@ export const LOGOUT = 'LOGOUT'; // logout is always success
 export const SNIPPETED_MESSAGES = createRequestTypes('SNIPPETED_MESSAGES', ['OPEN', 'READY', 'CLOSE', 'MESSAGES_RECEIVED']);
 export const DEEP_LINKING = createRequestTypes('DEEP_LINKING', ['OPEN']);
 export const SORT_PREFERENCES = createRequestTypes('SORT_PREFERENCES', ['SET_ALL', 'SET']);
+export const NOTIFICATION = createRequestTypes('NOTIFICATION', ['RECEIVED', 'REMOVE']);
 export const TOGGLE_MARKDOWN = 'TOGGLE_MARKDOWN';
diff --git a/app/actions/notification.js b/app/actions/notification.js
new file mode 100644
index 000000000..44f03d8a0
--- /dev/null
+++ b/app/actions/notification.js
@@ -0,0 +1,17 @@
+import { NOTIFICATION } from './actionsTypes';
+
+export function notificationReceived(params) {
+	return {
+		type: NOTIFICATION.RECEIVED,
+		payload: {
+			message: params.text,
+			payload: params.payload
+		}
+	};
+}
+
+export function removeNotification() {
+	return {
+		type: NOTIFICATION.REMOVE
+	};
+}
diff --git a/app/constants/colors.js b/app/constants/colors.js
index 57d5a0f92..e572bd278 100644
--- a/app/constants/colors.js
+++ b/app/constants/colors.js
@@ -10,6 +10,7 @@ export const COLOR_TEXT = '#2F343D';
 export const COLOR_TEXT_DESCRIPTION = '#9ca2a8';
 export const COLOR_SEPARATOR = '#A7A7AA';
 export const COLOR_BACKGROUND_CONTAINER = '#f3f4f5';
+export const COLOR_BACKGROUND_NOTIFICATION = '#f8f8f8';
 export const COLOR_BORDER = '#e1e5e8';
 export const COLOR_UNREAD = '#e1e5e8';
 export const COLOR_TOAST = '#0C0D0F';
diff --git a/app/index.js b/app/index.js
index 33c491561..7a08db17d 100644
--- a/app/index.js
+++ b/app/index.js
@@ -6,6 +6,7 @@ import { Provider } from 'react-redux';
 import { useScreens } from 'react-native-screens'; // eslint-disable-line import/no-unresolved
 import { Linking } from 'react-native';
 import firebase from 'react-native-firebase';
+import PropTypes from 'prop-types';
 
 import { appInit } from './actions';
 import { deepLinkingOpen } from './actions/deepLinking';
@@ -39,8 +40,9 @@ import OAuthView from './views/OAuthView';
 import SetUsernameView from './views/SetUsernameView';
 import { HEADER_BACKGROUND, HEADER_TITLE, HEADER_BACK } from './constants/colors';
 import parseQuery from './lib/methods/helpers/parseQuery';
-import { initializePushNotifications, onNotification } from './push';
+import { initializePushNotifications, onNotification } from './notifications/push';
 import store from './lib/createStore';
+import NotificationBadge from './notifications/inApp';
 
 useScreens();
 
@@ -195,10 +197,28 @@ const SetUsernameStack = createStackNavigator({
 	SetUsernameView
 });
 
+class CustomInsideStack extends React.Component {
+	static router = InsideStackModal.router;
+
+	static propTypes = {
+		navigation: PropTypes.object
+	}
+
+	render() {
+		const { navigation } = this.props;
+		return (
+			<React.Fragment>
+				<InsideStackModal navigation={navigation} />
+				<NotificationBadge navigation={navigation} />
+			</React.Fragment>
+		);
+	}
+}
+
 const App = createAppContainer(createSwitchNavigator(
 	{
 		OutsideStack: OutsideStackModal,
-		InsideStack: InsideStackModal,
+		InsideStack: CustomInsideStack,
 		AuthLoading: AuthLoadingView,
 		SetUsernameStack
 	},
diff --git a/app/lib/methods/subscriptions/rooms.js b/app/lib/methods/subscriptions/rooms.js
index aee57b1ee..50a35c551 100644
--- a/app/lib/methods/subscriptions/rooms.js
+++ b/app/lib/methods/subscriptions/rooms.js
@@ -6,6 +6,7 @@ import log from '../../../utils/log';
 import random from '../../../utils/random';
 import store from '../../createStore';
 import { roomsRequest } from '../../../actions/rooms';
+import { notificationReceived } from '../../../actions/notification';
 
 const removeListener = listener => listener.stop();
 
@@ -120,6 +121,10 @@ export default async function subscribeRooms() {
 				}
 			});
 		}
+		if (/notification/.test(ev)) {
+			const [notification] = ddpMessage.fields.args;
+			store.dispatch(notificationReceived(notification));
+		}
 	});
 
 	const stop = () => {
diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js
index 3c7f622e5..737e58b8f 100644
--- a/app/lib/rocketchat.js
+++ b/app/lib/rocketchat.js
@@ -35,7 +35,7 @@ import loadThreadMessages from './methods/loadThreadMessages';
 import sendMessage, { getMessage, sendMessageCall } from './methods/sendMessage';
 import { sendFileMessage, cancelUpload, isUploadActive } from './methods/sendFileMessage';
 
-import { getDeviceToken } from '../push';
+import { getDeviceToken } from '../notifications/push';
 import { roomsRequest } from '../actions/rooms';
 
 const TOKEN_KEY = 'reactnativemeteor_usertoken';
diff --git a/app/notifications/inApp/index.js b/app/notifications/inApp/index.js
new file mode 100644
index 000000000..f99cdc5ab
--- /dev/null
+++ b/app/notifications/inApp/index.js
@@ -0,0 +1,229 @@
+import React from 'react';
+import {
+	View, Text, StyleSheet, TouchableOpacity, Animated, Easing
+} from 'react-native';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import equal from 'deep-equal';
+import { responsive } from 'react-native-responsive-ui';
+import Touchable from 'react-native-platform-touchable';
+
+import { isNotch, isIOS } from '../../utils/deviceInfo';
+import { CustomIcon } from '../../lib/Icons';
+import { COLOR_BACKGROUND_NOTIFICATION, COLOR_SEPARATOR, COLOR_TEXT } from '../../constants/colors';
+import Avatar from '../../containers/Avatar';
+import { removeNotification as removeNotificationAction } from '../../actions/notification';
+import sharedStyles from '../../views/Styles';
+import { ROW_HEIGHT } from '../../presentation/RoomItem';
+
+const AVATAR_SIZE = 48;
+const ANIMATION_DURATION = 300;
+const NOTIFICATION_DURATION = 3000;
+const BUTTON_HIT_SLOP = {
+	top: 12, right: 12, bottom: 12, left: 12
+};
+const ANIMATION_PROPS = {
+	duration: ANIMATION_DURATION,
+	easing: Easing.inOut(Easing.quad),
+	useNativeDriver: true
+};
+
+const styles = StyleSheet.create({
+	container: {
+		height: ROW_HEIGHT,
+		paddingHorizontal: 14,
+		flex: 1,
+		flexDirection: 'row',
+		alignItems: 'center',
+		justifyContent: 'space-between',
+		position: 'absolute',
+		zIndex: 2,
+		backgroundColor: COLOR_BACKGROUND_NOTIFICATION,
+		width: '100%',
+		borderBottomWidth: StyleSheet.hairlineWidth,
+		borderColor: COLOR_SEPARATOR
+	},
+	content: {
+		flex: 1,
+		flexDirection: 'row',
+		alignItems: 'center'
+	},
+	avatar: {
+		marginRight: 10
+	},
+	roomName: {
+		fontSize: 17,
+		lineHeight: 20,
+		...sharedStyles.textColorNormal,
+		...sharedStyles.textMedium
+	},
+	message: {
+		fontSize: 14,
+		lineHeight: 17,
+		...sharedStyles.textRegular,
+		...sharedStyles.textColorNormal
+	},
+	close: {
+		color: COLOR_TEXT,
+		marginLeft: 10
+	}
+});
+
+@responsive
+@connect(
+	state => ({
+		userId: state.login.user && state.login.user.id,
+		baseUrl: state.settings.Site_Url || state.server ? state.server.server : '',
+		token: state.login.user && state.login.user.token,
+		notification: state.notification
+	}),
+	dispatch => ({
+		removeNotification: () => dispatch(removeNotificationAction())
+	})
+)
+export default class NotificationBadge extends React.Component {
+	static propTypes = {
+		navigation: PropTypes.object,
+		baseUrl: PropTypes.string,
+		token: PropTypes.string,
+		userId: PropTypes.string,
+		notification: PropTypes.object,
+		window: PropTypes.object,
+		removeNotification: PropTypes.func
+	}
+
+	constructor(props) {
+		super(props);
+		this.animatedValue = new Animated.Value(0);
+	}
+
+	shouldComponentUpdate(nextProps) {
+		const { notification: nextNotification } = nextProps;
+		const {
+			notification: { payload }, window
+		} = this.props;
+		if (!equal(nextNotification.payload, payload)) {
+			return true;
+		}
+		if (nextProps.window.width !== window.width) {
+			return true;
+		}
+		return false;
+	}
+
+	componentDidUpdate() {
+		const { notification: { payload }, navigation } = this.props;
+		const navState = this.getNavState(navigation.state);
+		if (payload.rid) {
+			if (navState && navState.routeName === 'RoomView' && navState.params && navState.params.rid === payload.rid) {
+				return;
+			}
+			this.show();
+		}
+	}
+
+	componentWillUnmount() {
+		this.clearTimeout();
+	}
+
+	show = () => {
+		Animated.timing(
+			this.animatedValue,
+			{
+				toValue: 1,
+				...ANIMATION_PROPS
+			},
+		).start(() => {
+			this.clearTimeout();
+			this.timeout = setTimeout(() => {
+				this.hide();
+			}, NOTIFICATION_DURATION);
+		});
+	}
+
+	hide = () => {
+		const { removeNotification } = this.props;
+		Animated.timing(
+			this.animatedValue,
+			{
+				toValue: 0,
+				...ANIMATION_PROPS
+			},
+		).start();
+		setTimeout(removeNotification, ANIMATION_DURATION);
+	}
+
+	clearTimeout = () => {
+		if	(this.timeout) {
+			clearTimeout(this.timeout);
+		}
+	}
+
+	getNavState = (routes) => {
+		if (!routes.routes) {
+			return routes;
+		}
+		return this.getNavState(routes.routes[routes.index]);
+	}
+
+	goToRoom = async() => {
+		const { notification: { payload }, navigation } = this.props;
+		const { rid, type, prid } = payload;
+		if (!rid) {
+			return;
+		}
+		const name = type === 'p' ? payload.name : payload.sender.username;
+		await navigation.navigate('RoomsListView');
+		navigation.navigate('RoomView', {
+			rid, name, t: type, prid
+		});
+		this.hide();
+	}
+
+	render() {
+		const {
+			baseUrl, token, userId, notification, window
+		} = this.props;
+		const { message, payload } = notification;
+		const { type } = payload;
+		const name = type === 'p' ? payload.name : payload.sender.username;
+
+		let top = 0;
+		if (isIOS) {
+			const portrait = window.height > window.width;
+			if (portrait) {
+				top = isNotch ? 45 : 20;
+			} else {
+				top = 0;
+			}
+		}
+
+		const maxWidthMessage = window.width - 110;
+
+		const translateY = this.animatedValue.interpolate({
+			inputRange: [0, 1],
+			outputRange: [-top - ROW_HEIGHT, top]
+		});
+		return (
+			<Animated.View style={[styles.container, { transform: [{ translateY }] }]}>
+				<Touchable
+					style={styles.content}
+					onPress={this.goToRoom}
+					hitSlop={BUTTON_HIT_SLOP}
+					background={Touchable.SelectableBackgroundBorderless()}
+				>
+					<React.Fragment>
+						<Avatar text={name} size={AVATAR_SIZE} type={type} baseUrl={baseUrl} style={styles.avatar} userId={userId} token={token} />
+						<View>
+							<Text style={styles.roomName}>{name}</Text>
+							<Text style={[styles.message, { maxWidth: maxWidthMessage }]} numberOfLines={1}>{message}</Text>
+						</View>
+					</React.Fragment>
+				</Touchable>
+				<TouchableOpacity onPress={this.hide}>
+					<CustomIcon name='circle-cross' style={styles.close} size={20} />
+				</TouchableOpacity>
+			</Animated.View>
+		);
+	}
+}
diff --git a/app/push/index.js b/app/notifications/push/index.js
similarity index 89%
rename from app/push/index.js
rename to app/notifications/push/index.js
index d78af4c7b..37b2b0391 100644
--- a/app/push/index.js
+++ b/app/notifications/push/index.js
@@ -1,8 +1,8 @@
 import EJSON from 'ejson';
 
 import PushNotification from './push';
-import store from '../lib/createStore';
-import { deepLinkingOpen } from '../actions/deepLinking';
+import store from '../../lib/createStore';
+import { deepLinkingOpen } from '../../actions/deepLinking';
 
 export const onNotification = (notification) => {
 	if (notification) {
diff --git a/app/push/push.android.js b/app/notifications/push/push.android.js
similarity index 100%
rename from app/push/push.android.js
rename to app/notifications/push/push.android.js
diff --git a/app/push/push.ios.js b/app/notifications/push/push.ios.js
similarity index 100%
rename from app/push/push.ios.js
rename to app/notifications/push/push.ios.js
diff --git a/app/reducers/index.js b/app/reducers/index.js
index d33c26abc..e7bfb41d1 100644
--- a/app/reducers/index.js
+++ b/app/reducers/index.js
@@ -9,6 +9,7 @@ import selectedUsers from './selectedUsers';
 import createChannel from './createChannel';
 import app from './app';
 import sortPreferences from './sortPreferences';
+import notification from './notification';
 import markdown from './markdown';
 
 export default combineReducers({
@@ -22,5 +23,6 @@ export default combineReducers({
 	app,
 	rooms,
 	sortPreferences,
+	notification,
 	markdown
 });
diff --git a/app/reducers/notification.js b/app/reducers/notification.js
new file mode 100644
index 000000000..5b1d07c9b
--- /dev/null
+++ b/app/reducers/notification.js
@@ -0,0 +1,24 @@
+import { NOTIFICATION } from '../actions/actionsTypes';
+
+const initialState = {
+	message: '',
+	payload: {
+		type: 'p',
+		name: '',
+		rid: ''
+	}
+};
+
+export default function notification(state = initialState, action) {
+	switch (action.type) {
+		case NOTIFICATION.RECEIVED:
+			return {
+				...state,
+				...action.payload
+			};
+		case NOTIFICATION.REMOVE:
+			return initialState;
+		default:
+			return state;
+	}
+}
diff --git a/app/sagas/state.js b/app/sagas/state.js
index 27b09483b..7d5ece8c1 100644
--- a/app/sagas/state.js
+++ b/app/sagas/state.js
@@ -2,7 +2,7 @@ import { takeLatest, select } from 'redux-saga/effects';
 import { FOREGROUND, BACKGROUND } from 'redux-enhancer-react-native-appstate';
 
 import RocketChat from '../lib/rocketchat';
-import { setBadgeCount } from '../push';
+import { setBadgeCount } from '../notifications/push';
 import log from '../utils/log';
 
 const appHasComeBackToForeground = function* appHasComeBackToForeground() {
-- 
GitLab