[IMPROVEMENT] Use react-native-notifier for in-app notifications (#2139)

Signed-off-by: Ezequiel De Oliveira <ezequiel1de1oliveira@gmail.com>

Co-authored-by: Diego Mello <diegolmello@gmail.com>
This commit is contained in:
Ezequiel de Oliveira 2020-06-16 17:32:30 -03:00 committed by GitHub
parent 089e4bf3eb
commit 2632ef50f5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 273 additions and 398 deletions

View File

@ -3,14 +3,12 @@ import PropTypes from 'prop-types';
import { NavigationContainer } from '@react-navigation/native';
import { createStackNavigator } from '@react-navigation/stack';
import { connect } from 'react-redux';
import { SafeAreaProvider, initialWindowMetrics } from 'react-native-safe-area-context';
import Navigation from './lib/Navigation';
import { defaultHeader, getActiveRouteName, navigationTheme } from './utils/navigation';
import {
ROOT_LOADING, ROOT_OUTSIDE, ROOT_NEW_SERVER, ROOT_INSIDE, ROOT_SET_USERNAME, ROOT_BACKGROUND
} from './actions/app';
import { ActionSheetProvider } from './containers/ActionSheet';
// Stacks
import AuthLoadingView from './views/AuthLoadingView';
@ -53,57 +51,53 @@ const App = React.memo(({ root, isMasterDetail }) => {
}, []);
return (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<ActionSheetProvider>
<NavigationContainer
theme={navTheme}
ref={Navigation.navigationRef}
onStateChange={(state) => {
const previousRouteName = Navigation.routeNameRef.current;
const currentRouteName = getActiveRouteName(state);
if (previousRouteName !== currentRouteName) {
setCurrentScreen(currentRouteName);
}
Navigation.routeNameRef.current = currentRouteName;
}}
>
<Stack.Navigator screenOptions={{ headerShown: false, animationEnabled: false }}>
<>
{root === ROOT_LOADING || root === ROOT_BACKGROUND ? (
<Stack.Screen
name='AuthLoading'
component={AuthLoadingView}
/>
) : null}
{root === ROOT_OUTSIDE || root === ROOT_NEW_SERVER ? (
<Stack.Screen
name='OutsideStack'
component={OutsideStack}
/>
) : null}
{root === ROOT_INSIDE && isMasterDetail ? (
<Stack.Screen
name='MasterDetailStack'
component={MasterDetailStack}
/>
) : null}
{root === ROOT_INSIDE && !isMasterDetail ? (
<Stack.Screen
name='InsideStack'
component={InsideStack}
/>
) : null}
{root === ROOT_SET_USERNAME ? (
<Stack.Screen
name='SetUsernameStack'
component={SetUsernameStack}
/>
) : null}
</>
</Stack.Navigator>
</NavigationContainer>
</ActionSheetProvider>
</SafeAreaProvider>
<NavigationContainer
theme={navTheme}
ref={Navigation.navigationRef}
onStateChange={(state) => {
const previousRouteName = Navigation.routeNameRef.current;
const currentRouteName = getActiveRouteName(state);
if (previousRouteName !== currentRouteName) {
setCurrentScreen(currentRouteName);
}
Navigation.routeNameRef.current = currentRouteName;
}}
>
<Stack.Navigator screenOptions={{ headerShown: false, animationEnabled: false }}>
<>
{root === ROOT_LOADING || root === ROOT_BACKGROUND ? (
<Stack.Screen
name='AuthLoading'
component={AuthLoadingView}
/>
) : null}
{root === ROOT_OUTSIDE || root === ROOT_NEW_SERVER ? (
<Stack.Screen
name='OutsideStack'
component={OutsideStack}
/>
) : null}
{root === ROOT_INSIDE && isMasterDetail ? (
<Stack.Screen
name='MasterDetailStack'
component={MasterDetailStack}
/>
) : null}
{root === ROOT_INSIDE && !isMasterDetail ? (
<Stack.Screen
name='InsideStack'
component={InsideStack}
/>
) : null}
{root === ROOT_SET_USERNAME ? (
<Stack.Screen
name='SetUsernameStack'
component={SetUsernameStack}
/>
) : null}
</>
</Stack.Navigator>
</NavigationContainer>
);
});
const mapStateToProps = state => ({

View File

@ -51,7 +51,6 @@ 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_CRASH_REPORT = 'TOGGLE_CRASH_REPORT';
export const SET_CUSTOM_EMOJIS = 'SET_CUSTOM_EMOJIS';
export const SET_ACTIVE_USERS = 'SET_ACTIVE_USERS';

View File

@ -1,19 +0,0 @@
import { NOTIFICATION } from './actionsTypes';
export function notificationReceived(params) {
return {
type: NOTIFICATION.RECEIVED,
payload: {
title: params.title,
avatar: params.avatar,
message: params.text,
payload: params.payload
}
};
}
export function removeNotification() {
return {
type: NOTIFICATION.REMOVE
};
}

View File

@ -0,0 +1,147 @@
import React from 'react';
import { StyleSheet, View, Text } from 'react-native';
import PropTypes from 'prop-types';
import Touchable from 'react-native-platform-touchable';
import { connect } from 'react-redux';
import { Notifier } from 'react-native-notifier';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useDeviceOrientation } from '@react-native-community/hooks';
import Avatar from '../Avatar';
import { CustomIcon } from '../../lib/Icons';
import sharedStyles from '../../views/Styles';
import { themes } from '../../constants/colors';
import { useTheme } from '../../theme';
import { getUserSelector } from '../../selectors/login';
import { ROW_HEIGHT } from '../../presentation/RoomItem';
import { goRoom } from '../../utils/goRoom';
import Navigation from '../../lib/Navigation';
const AVATAR_SIZE = 48;
const BUTTON_HIT_SLOP = {
top: 12, right: 12, bottom: 12, left: 12
};
const styles = StyleSheet.create({
container: {
height: ROW_HEIGHT,
paddingHorizontal: 14,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginHorizontal: 10,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 4
},
content: {
flex: 1,
flexDirection: 'row',
alignItems: 'center'
},
inner: {
flex: 1
},
avatar: {
marginRight: 10
},
roomName: {
fontSize: 17,
lineHeight: 20,
...sharedStyles.textMedium
},
message: {
fontSize: 14,
lineHeight: 17,
...sharedStyles.textRegular
},
close: {
marginLeft: 10
},
small: {
width: '50%',
alignSelf: 'center'
}
});
const hideNotification = () => Notifier.hideNotification();
const NotifierComponent = React.memo(({
baseUrl, user, notification, isMasterDetail
}) => {
const { theme } = useTheme();
const insets = useSafeAreaInsets();
const { landscape } = useDeviceOrientation();
const { id: userId, token } = user;
const { text, payload } = notification;
const { type } = payload;
const name = type === 'd' ? payload.sender.username : payload.name;
// if sub is not on local database, title and avatar will be null, so we use payload from notification
const { title = name, avatar = name } = notification;
const onPress = () => {
const { rid, prid } = payload;
if (!rid) {
return;
}
const item = {
rid, name: title, t: type, prid
};
if (isMasterDetail) {
Navigation.navigate('DrawerNavigator');
}
goRoom({ item, isMasterDetail });
hideNotification();
};
return (
<View style={[
styles.container,
(isMasterDetail || landscape) && styles.small,
{
backgroundColor: themes[theme].focusedBackground,
borderColor: themes[theme].separatorColor,
marginTop: insets.top
}
]}
>
<Touchable
style={styles.content}
onPress={onPress}
hitSlop={BUTTON_HIT_SLOP}
background={Touchable.SelectableBackgroundBorderless()}
>
<>
<Avatar text={avatar} size={AVATAR_SIZE} type={type} baseUrl={baseUrl} style={styles.avatar} userId={userId} token={token} />
<View style={styles.inner}>
<Text style={[styles.roomName, { color: themes[theme].titleText }]} numberOfLines={1}>{title}</Text>
<Text style={[styles.message, { color: themes[theme].titleText }]} numberOfLines={1}>{text}</Text>
</View>
</>
</Touchable>
<Touchable
onPress={hideNotification}
hitSlop={BUTTON_HIT_SLOP}
background={Touchable.SelectableBackgroundBorderless()}
>
<CustomIcon name='Cross' style={[styles.close, { color: themes[theme].titleText }]} size={20} />
</Touchable>
</View>
);
});
NotifierComponent.propTypes = {
baseUrl: PropTypes.string,
user: PropTypes.object,
notification: PropTypes.object,
isMasterDetail: PropTypes.bool
};
const mapStateToProps = state => ({
user: getUserSelector(state),
baseUrl: state.server.server,
isMasterDetail: state.app.isMasterDetail
});
export default connect(mapStateToProps)(NotifierComponent);

View File

@ -0,0 +1,40 @@
import React, { memo, useEffect } from 'react';
import { NotifierRoot, Notifier, Easing } from 'react-native-notifier';
import NotifierComponent from './NotifierComponent';
import EventEmitter from '../../utils/events';
import Navigation from '../../lib/Navigation';
import { getActiveRoute } from '../../utils/navigation';
export const INAPP_NOTIFICATION_EMITTER = 'NotificationInApp';
const InAppNotification = memo(() => {
const show = (notification) => {
const { payload } = notification;
const state = Navigation.navigationRef.current.getRootState();
const route = getActiveRoute(state);
if (payload.rid) {
if (route?.name === 'RoomView' && route.params?.rid === payload.rid) {
return;
}
Notifier.showNotification({
showEasing: Easing.inOut(Easing.quad),
Component: NotifierComponent,
componentProps: {
notification
}
});
}
};
useEffect(() => {
EventEmitter.addEventListener(INAPP_NOTIFICATION_EMITTER, show);
return () => {
EventEmitter.removeListener(INAPP_NOTIFICATION_EMITTER);
};
}, []);
return <NotifierRoot />;
});
export default InAppNotification;

View File

@ -5,6 +5,7 @@ import { Provider } from 'react-redux';
import RNUserDefaults from 'rn-user-defaults';
import { KeyCommandsEmitter } from 'react-native-keycommands';
import RNScreens from 'react-native-screens';
import { SafeAreaProvider, initialWindowMetrics } from 'react-native-safe-area-context';
import {
defaultTheme,
@ -30,6 +31,10 @@ import AppContainer from './AppContainer';
import TwoFactor from './containers/TwoFactor';
import ScreenLockedView from './views/ScreenLockedView';
import ChangePasscodeView from './views/ChangePasscodeView';
import Toast from './containers/Toast';
import InAppNotification from './containers/InAppNotification';
import { ActionSheetProvider } from './containers/ActionSheet';
RNScreens.enableScreens();
@ -151,22 +156,28 @@ export default class Root extends React.Component {
render() {
const { themePreferences, theme } = this.state;
return (
<AppearanceProvider>
<Provider store={store}>
<ThemeContext.Provider
value={{
theme,
themePreferences,
setTheme: this.setTheme
}}
>
<AppContainer />
<TwoFactor />
<ScreenLockedView />
<ChangePasscodeView />
</ThemeContext.Provider>
</Provider>
</AppearanceProvider>
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<AppearanceProvider>
<Provider store={store}>
<ThemeContext.Provider
value={{
theme,
themePreferences,
setTheme: this.setTheme
}}
>
<ActionSheetProvider>
<AppContainer />
<TwoFactor />
<ScreenLockedView />
<ChangePasscodeView />
<InAppNotification />
<Toast />
</ActionSheetProvider>
</ThemeContext.Provider>
</Provider>
</AppearanceProvider>
</SafeAreaProvider>
);
}
}

View File

@ -9,12 +9,12 @@ import log from '../../../utils/log';
import random from '../../../utils/random';
import store from '../../createStore';
import { roomsRequest } from '../../../actions/rooms';
import { notificationReceived } from '../../../actions/notification';
import { handlePayloadUserInteraction } from '../actions';
import buildMessage from '../helpers/buildMessage';
import RocketChat from '../../rocketchat';
import EventEmmiter from '../../../utils/events';
import EventEmitter from '../../../utils/events';
import { removedRoom } from '../../../actions/room';
import { INAPP_NOTIFICATION_EMITTER } from '../../../containers/InAppNotification';
const removeListener = listener => listener.stop();
@ -267,7 +267,7 @@ export default function subscribeRooms() {
if (data.rid === roomState.rid && roomState.isDeleting) {
store.dispatch(removedRoom());
} else {
EventEmmiter.emit('ROOM_REMOVED', { rid: data.rid });
EventEmitter.emit('ROOM_REMOVED', { rid: data.rid });
}
} catch (e) {
log(e);
@ -320,7 +320,7 @@ export default function subscribeRooms() {
} catch (e) {
// do nothing
}
store.dispatch(notificationReceived(notification));
EventEmitter.emit(INAPP_NOTIFICATION_EMITTER, notification);
}
if (/uiInteraction/.test(ev)) {
const { type: eventType, ...args } = type;

View File

@ -1,248 +0,0 @@
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 { hasNotch, isIOS, isTablet } from '../../utils/deviceInfo';
import { CustomIcon } from '../../lib/Icons';
import { themes } 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';
import { withTheme } from '../../theme';
import { getUserSelector } from '../../selectors/login';
import { getActiveRoute } from '../../utils/navigation';
import Navigation from '../../lib/Navigation';
import { goRoom } from '../../utils/goRoom';
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,
width: '100%',
borderBottomWidth: StyleSheet.hairlineWidth
},
content: {
flex: 1,
flexDirection: 'row',
alignItems: 'center'
},
inner: {
flex: 1
},
avatar: {
marginRight: 10
},
roomName: {
fontSize: 17,
lineHeight: 20,
...sharedStyles.textMedium
},
message: {
fontSize: 14,
lineHeight: 17,
...sharedStyles.textRegular
},
close: {
marginLeft: 10
}
});
class NotificationBadge extends React.Component {
static propTypes = {
isMasterDetail: PropTypes.bool,
baseUrl: PropTypes.string,
user: PropTypes.object,
notification: PropTypes.object,
window: PropTypes.object,
removeNotification: PropTypes.func,
theme: PropTypes.string
}
constructor(props) {
super(props);
this.animatedValue = new Animated.Value(0);
}
shouldComponentUpdate(nextProps) {
const { notification: nextNotification } = nextProps;
const {
notification: { payload }, window, theme
} = this.props;
if (nextProps.theme !== theme) {
return true;
}
if (!equal(nextNotification.payload, payload)) {
return true;
}
if (nextProps.window.width !== window.width) {
return true;
}
return false;
}
componentDidUpdate(prevProps) {
const { notification: { payload } } = this.props;
const { notification: { payload: prevPayload } } = prevProps;
if (!equal(prevPayload, payload)) {
const state = Navigation.navigationRef.current.getRootState();
const route = getActiveRoute(state);
if (payload.rid) {
if (route?.name === 'RoomView' && route.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);
}
}
goToRoom = () => {
const { notification, isMasterDetail, baseUrl } = this.props;
const { payload } = notification;
const { rid, type, prid } = payload;
if (!rid) {
return;
}
const name = type === 'd' ? payload.sender.username : payload.name;
// if sub is not on local database, title will be null, so we use payload from notification
const { title = name } = notification;
const item = {
rid, name: title, t: type, prid, baseUrl
};
if (isMasterDetail) {
Navigation.navigate('DrawerNavigator');
}
goRoom({ item, isMasterDetail });
this.hide();
}
render() {
const {
baseUrl, user: { id: userId, token }, notification, window, theme
} = this.props;
const { message, payload } = notification;
const { type } = payload;
const name = type === 'd' ? payload.sender.username : payload.name;
// if sub is not on local database, title and avatar will be null, so we use payload from notification
const { title = name, avatar = name } = notification;
let top = 0;
if (isIOS) {
const portrait = window.height > window.width;
if (portrait) {
top = hasNotch ? 45 : 20;
} else {
top = isTablet ? 20 : 0;
}
}
const translateY = this.animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [-top - ROW_HEIGHT, top]
});
return (
<Animated.View
style={[
styles.container,
{
transform: [{ translateY }],
backgroundColor: themes[theme].focusedBackground,
borderColor: themes[theme].separatorColor
}
]}
>
<Touchable
style={styles.content}
onPress={this.goToRoom}
hitSlop={BUTTON_HIT_SLOP}
background={Touchable.SelectableBackgroundBorderless()}
>
<>
<Avatar text={avatar} size={AVATAR_SIZE} type={type} baseUrl={baseUrl} style={styles.avatar} userId={userId} token={token} />
<View style={styles.inner}>
<Text style={[styles.roomName, { color: themes[theme].titleText }]} numberOfLines={1}>{title}</Text>
<Text style={[styles.message, { color: themes[theme].titleText }]} numberOfLines={1}>{message}</Text>
</View>
</>
</Touchable>
<TouchableOpacity onPress={this.hide}>
<CustomIcon name='cancel' style={[styles.close, { color: themes[theme].titleText }]} size={20} />
</TouchableOpacity>
</Animated.View>
);
}
}
const mapStateToProps = state => ({
user: getUserSelector(state),
baseUrl: state.server.server,
notification: state.notification,
isMasterDetail: PropTypes.bool
});
const mapDispatchToProps = dispatch => ({
removeNotification: () => dispatch(removeNotificationAction())
});
export default responsive(connect(mapStateToProps, mapDispatchToProps)(withTheme(NotificationBadge)));

View File

@ -9,7 +9,6 @@ import selectedUsers from './selectedUsers';
import createChannel from './createChannel';
import app from './app';
import sortPreferences from './sortPreferences';
import notification from './notification';
import share from './share';
import crashReport from './crashReport';
import customEmojis from './customEmojis';
@ -29,7 +28,6 @@ export default combineReducers({
room,
rooms,
sortPreferences,
notification,
share,
crashReport,
customEmojis,

View File

@ -1,24 +0,0 @@
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;
}
}

View File

@ -1,5 +1,4 @@
import React from 'react';
import PropTypes from 'prop-types';
import { createStackNavigator } from '@react-navigation/stack';
import { createDrawerNavigator } from '@react-navigation/drawer';
@ -7,9 +6,7 @@ import { ThemeContext } from '../theme';
import {
defaultHeader, themedHeader, ModalAnimation, StackAnimation
} from '../utils/navigation';
import Toast from '../containers/Toast';
import Sidebar from '../views/SidebarView';
import NotificationBadge from '../notifications/inApp';
// Chats Stack
import RoomView from '../views/RoomView';
@ -320,16 +317,4 @@ const InsideStackNavigator = () => {
);
};
const RootInsideStack = ({ navigation, route }) => (
<>
<InsideStackNavigator navigation={navigation} />
<NotificationBadge navigation={navigation} route={route} />
<Toast />
</>
);
RootInsideStack.propTypes = {
navigation: PropTypes.object,
route: PropTypes.object
};
export default RootInsideStack;
export default InsideStackNavigator;

View File

@ -8,8 +8,6 @@ import { ThemeContext } from '../../theme';
import {
defaultHeader, themedHeader, StackAnimation, FadeFromCenterModal
} from '../../utils/navigation';
import Toast from '../../containers/Toast';
import NotificationBadge from '../../notifications/inApp';
import { ModalContainer } from './ModalContainer';
// Chats Stack
@ -292,16 +290,4 @@ const InsideStackNavigator = React.memo(() => {
);
});
const RootInsideStack = React.memo(({ navigation, route }) => (
<>
<InsideStackNavigator navigation={navigation} />
<NotificationBadge navigation={navigation} route={route} />
<Toast />
</>
));
RootInsideStack.propTypes = {
navigation: PropTypes.object,
route: PropTypes.object
};
export default RootInsideStack;
export default InsideStackNavigator;

View File

@ -83,6 +83,7 @@
"react-native-modal": "11.5.6",
"react-native-navigation-bar-color": "2.0.1",
"react-native-notifications": "2.1.7",
"react-native-notifier": "^1.3.1",
"react-native-orientation-locker": "1.1.8",
"react-native-picker-select": "7.0.0",
"react-native-platform-touchable": "^1.1.1",

View File

@ -11895,6 +11895,11 @@ react-native-notifications@2.1.7:
core-js "^1.0.0"
uuid "^2.0.3"
react-native-notifier@^1.3.1:
version "1.3.1"
resolved "https://registry.yarnpkg.com/react-native-notifier/-/react-native-notifier-1.3.1.tgz#a878c82c8ee99b04d57818401b1f084232729afd"
integrity sha512-w7KOTF5WOYzbhCXQHz6p9tbosOVxhOW+Sh7VAdIuW6r7PSoryRNkF4P6Bzq1+2NPtMK7L6xnojCdKJ+nVnwh+A==
react-native-orientation-locker@1.1.8:
version "1.1.8"
resolved "https://registry.yarnpkg.com/react-native-orientation-locker/-/react-native-orientation-locker-1.1.8.tgz#45d1c9e002496b8d286ec8932d6e3e7d341f9c85"