MM-28105 do not crash when in-app notification shows (#4915)
* MM-28105 do not crash when in-app notification shows * Use index.tsx to register the notification screen * Fix notification e2e and some import cleanup * Update iOS notification json * e2e: Show the add reaction screen before receiving a notification * Update snapshots * Patch react-native-notifications to prevent iOS crash when opening * Apply TM4J id Co-authored-by: Joseph Baylon <joseph.baylon@mattermost.com>
This commit is contained in:
parent
8dac3ce9b9
commit
0569479398
19 changed files with 510 additions and 526 deletions
|
|
@ -230,6 +230,7 @@ exports[`Reactions Should match snapshot with default emojis 1`] = `
|
|||
/>
|
||||
<TouchableWithoutFeedback
|
||||
onPress={[MockFunction]}
|
||||
testID="reaction_picker.open"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
|
|
@ -491,6 +492,7 @@ exports[`Reactions Should match snapshot with default emojis 2`] = `
|
|||
/>
|
||||
<TouchableWithoutFeedback
|
||||
onPress={[MockFunction]}
|
||||
testID="reaction_picker.open"
|
||||
>
|
||||
<View
|
||||
style={
|
||||
|
|
|
|||
|
|
@ -81,7 +81,10 @@ export default class ReactionPicker extends PureComponent {
|
|||
return (
|
||||
<View style={[style.reactionListContainer, paddingRes]}>
|
||||
{list}
|
||||
<TouchableWithoutFeedback onPress={this.props.openReactionScreen}>
|
||||
<TouchableWithoutFeedback
|
||||
onPress={this.props.openReactionScreen}
|
||||
testID='reaction_picker.open'
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
style.reactionContainer,
|
||||
|
|
|
|||
|
|
@ -67,10 +67,11 @@ class PushNotificationUtils {
|
|||
|
||||
onPushNotification = async (deviceNotification) => {
|
||||
const {dispatch} = Store.redux;
|
||||
const {data, foreground, message, userInteraction} = deviceNotification;
|
||||
const {data, foreground, message, userInfo, userInteraction} = deviceNotification;
|
||||
const notification = {
|
||||
data,
|
||||
message,
|
||||
userInfo,
|
||||
};
|
||||
|
||||
waitForHydration(Store.redux, () => {
|
||||
|
|
@ -84,7 +85,7 @@ class PushNotificationUtils {
|
|||
|
||||
if (foreground) {
|
||||
EventEmitter.emit(ViewTypes.NOTIFICATION_IN_APP, notification);
|
||||
} else if (userInteraction && !notification?.data?.localNotification) {
|
||||
} else if (userInteraction && !notification?.userInfo?.localNotification && !notification?.data?.localNotification) {
|
||||
this.loadFromNotification(notification);
|
||||
}
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export default class AddReaction extends PureComponent {
|
|||
};
|
||||
|
||||
leftButton = {
|
||||
id: 'close-edit-post',
|
||||
id: 'close-add-reaction',
|
||||
testID: 'screen.add_reaction.close',
|
||||
};
|
||||
|
||||
constructor(props) {
|
||||
|
|
@ -42,7 +43,7 @@ export default class AddReaction extends PureComponent {
|
|||
}
|
||||
|
||||
navigationButtonPressed({buttonId}) {
|
||||
if (buttonId === 'close-edit-post') {
|
||||
if (buttonId === 'close-add-reaction') {
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ export function registerScreens(store, Provider) {
|
|||
Navigation.registerComponent('MoreChannels', () => wrapper(require('app/screens/more_channels').default), () => require('app/screens/more_channels').default);
|
||||
Navigation.registerComponent('MoreDirectMessages', () => wrapper(require('app/screens/more_dms').default), () => require('app/screens/more_dms').default);
|
||||
if (Platform.OS === 'android') {
|
||||
Navigation.registerComponent('Notification', () => gestureHandlerRootHOC(wrapper(require('app/screens/notification').default), {flex: undefined, height: 100}), () => require('app/screens/notification').default);
|
||||
Navigation.registerComponent('Notification', () => gestureHandlerRootHOC(wrapper(require('app/screens/notification/index.tsx').default), {flex: undefined, height: 100}), () => require('app/screens/notification/index.tsx').default);
|
||||
} else {
|
||||
Navigation.registerComponent('Notification', () => wrapper(require('app/screens/notification').default), () => require('app/screens/notification').default);
|
||||
Navigation.registerComponent('Notification', () => wrapper(require('app/screens/notification/index.tsx').default), () => require('app/screens/notification/index.tsx').default);
|
||||
}
|
||||
Navigation.registerComponent('NotificationSettings', () => wrapper(require('app/screens/settings/notification_settings').default), () => require('app/screens/settings/notification_settings').default);
|
||||
Navigation.registerComponent('NotificationSettingsAutoResponder', () => wrapper(require('app/screens/settings/notification_settings_auto_responder').default), () => require('app/screens/settings/notification_settings_auto_responder').default);
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Notification should match snapshot 1`] = `null`;
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {bindActionCreators} from 'redux';
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {loadFromPushNotification} from 'app/actions/views/root';
|
||||
|
||||
import {getChannel} from '@mm-redux/selectors/entities/channels';
|
||||
import {getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getUser} from '@mm-redux/selectors/entities/users';
|
||||
import {getConfig} from '@mm-redux/selectors/entities/general';
|
||||
|
||||
import Notification from './notification';
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
const {data} = ownProps.notification;
|
||||
|
||||
let user;
|
||||
if (data.sender_id) {
|
||||
user = getUser(state, data.sender_id);
|
||||
}
|
||||
|
||||
let channel;
|
||||
if (data.channel_id) {
|
||||
channel = getChannel(state, data.channel_id);
|
||||
}
|
||||
const config = getConfig(state);
|
||||
return {
|
||||
config,
|
||||
channel,
|
||||
user,
|
||||
teammateNameDisplay: getTeammateNameDisplaySetting(state),
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
loadFromPushNotification,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(Notification);
|
||||
210
app/screens/notification/index.tsx
Normal file
210
app/screens/notification/index.tsx
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
import {Platform, StyleSheet, Text, TouchableOpacity, View} from 'react-native';
|
||||
import * as Animatable from 'react-native-animatable';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
import {PanGestureHandler} from 'react-native-gesture-handler';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import {popToRoot, dismissAllModals, dismissOverlay} from '@actions/navigation';
|
||||
import {loadFromPushNotification} from '@actions/views/root';
|
||||
import {NavigationTypes} from '@constants';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
import NotificationIcon from './notification_icon';
|
||||
import NotificationTitle from './notification_title';
|
||||
|
||||
interface NotificationProps {
|
||||
componentId: string;
|
||||
notification: PushNotificationData;
|
||||
}
|
||||
|
||||
interface SlideAnimation extends Animatable.CustomAnimation {
|
||||
from: {
|
||||
translateY: number;
|
||||
};
|
||||
to: {
|
||||
translateY: number;
|
||||
};
|
||||
}
|
||||
|
||||
const AUTO_DISMISS_TIME_MILLIS = 5000;
|
||||
|
||||
const initialAnimation: SlideAnimation = {
|
||||
from: {
|
||||
translateY: -100,
|
||||
},
|
||||
to: {
|
||||
translateY: 0,
|
||||
},
|
||||
};
|
||||
|
||||
const Notification = ({componentId, notification}: NotificationProps) => {
|
||||
const [animation, setAnimation] = useState<SlideAnimation>(initialAnimation);
|
||||
const dispatch = useDispatch();
|
||||
const dismissTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const tapped = useRef<boolean>(false);
|
||||
|
||||
const animateDismissOverlay = () => {
|
||||
cancelDismissTimer();
|
||||
setAnimation({
|
||||
from: {
|
||||
translateY: 0,
|
||||
},
|
||||
to: {
|
||||
translateY: -100,
|
||||
},
|
||||
});
|
||||
dismissTimerRef.current = setTimeout(dismiss, 1000);
|
||||
};
|
||||
|
||||
const cancelDismissTimer = () => {
|
||||
if (dismissTimerRef.current) {
|
||||
clearTimeout(dismissTimerRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
const dismiss = () => {
|
||||
cancelDismissTimer();
|
||||
dismissOverlay(componentId);
|
||||
};
|
||||
|
||||
const notificationTapped = () => {
|
||||
tapped.current = true;
|
||||
EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEBAR);
|
||||
EventEmitter.emit(NavigationTypes.CLOSE_SETTINGS_SIDEBAR);
|
||||
dismiss();
|
||||
|
||||
if (!notification.userInfo?.localNotification) {
|
||||
dispatch(loadFromPushNotification(notification));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dismissTimerRef.current = setTimeout(() => {
|
||||
if (!tapped.current) {
|
||||
animateDismissOverlay();
|
||||
}
|
||||
}, AUTO_DISMISS_TIME_MILLIS);
|
||||
|
||||
return cancelDismissTimer;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const didDismissListener = Navigation.events().registerComponentDidDisappearListener(async ({componentId: screen}) => {
|
||||
if (componentId === screen && tapped.current) {
|
||||
await dismissAllModals();
|
||||
await popToRoot();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
didDismissListener.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
EventEmitter.on(NavigationTypes.NAVIGATION_SHOW_OVERLAY, dismiss);
|
||||
|
||||
return () => EventEmitter.off(NavigationTypes.NAVIGATION_SHOW_OVERLAY, dismiss);
|
||||
}, []);
|
||||
|
||||
let message = notification.message;
|
||||
if (typeof notification.message === 'object') {
|
||||
message = notification.message.body;
|
||||
}
|
||||
|
||||
return (
|
||||
<PanGestureHandler
|
||||
onGestureEvent={animateDismissOverlay}
|
||||
minOffsetY={-20}
|
||||
>
|
||||
<Animatable.View
|
||||
duration={250}
|
||||
style={styles.container}
|
||||
useNativeDriver={true}
|
||||
animation={animation}
|
||||
testID='in_app_notification'
|
||||
>
|
||||
<View style={styles.flex}>
|
||||
<TouchableOpacity
|
||||
style={styles.touchable}
|
||||
onPress={notificationTapped}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<NotificationIcon
|
||||
fromWebhook={notification.data?.from_webhook === 'true'}
|
||||
senderId={notification.data?.sender_id || ''}
|
||||
useUserIcon={notification.data?.use_user_icon === 'true'}
|
||||
overrideIconUrl={notification.data?.override_icon_url}
|
||||
/>
|
||||
<View style={styles.titleContainer}>
|
||||
<NotificationTitle channelName={notification.data?.channel_name || ''}/>
|
||||
<View style={styles.flex}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={styles.message}
|
||||
testID='in_app_notification.message'
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animatable.View>
|
||||
</PanGestureHandler>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: changeOpacity('#000', 1),
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
paddingHorizontal: 10,
|
||||
width: '100%',
|
||||
...Platform.select({
|
||||
android: {
|
||||
height: 68,
|
||||
},
|
||||
ios: {
|
||||
height: 88,
|
||||
},
|
||||
}),
|
||||
},
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
message: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
},
|
||||
titleContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
alignSelf: 'stretch',
|
||||
alignItems: 'flex-start',
|
||||
marginLeft: 10,
|
||||
...Platform.select({
|
||||
android: {
|
||||
marginTop: 17,
|
||||
height: 50,
|
||||
},
|
||||
ios: {
|
||||
paddingTop: 37,
|
||||
},
|
||||
}),
|
||||
},
|
||||
touchable: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
});
|
||||
|
||||
export default Notification;
|
||||
|
|
@ -1,407 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
InteractionManager,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import {Navigation} from 'react-native-navigation';
|
||||
import * as Animatable from 'react-native-animatable';
|
||||
import {PanGestureHandler} from 'react-native-gesture-handler';
|
||||
|
||||
import {popToRoot, dismissAllModals, dismissOverlay} from '@actions/navigation';
|
||||
import logo from '@assets/images/icon.png';
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import FormattedText from '@components/formatted_text';
|
||||
import ProfilePicture from '@components/profile_picture';
|
||||
import {NavigationTypes} from '@constants';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {isDirectChannel} from '@mm-redux/utils/channel_utils';
|
||||
import EventEmitter from '@mm-redux/utils/event_emitter';
|
||||
import {displayUsername} from '@mm-redux/utils/user_utils';
|
||||
import {changeOpacity} from '@utils/theme';
|
||||
|
||||
const IMAGE_SIZE = 32;
|
||||
const AUTO_DISMISS_TIME_MILLIS = 5000;
|
||||
|
||||
export default class Notification extends PureComponent {
|
||||
static propTypes = {
|
||||
actions: PropTypes.shape({
|
||||
loadFromPushNotification: PropTypes.func.isRequired,
|
||||
}).isRequired,
|
||||
componentId: PropTypes.string.isRequired,
|
||||
channel: PropTypes.object,
|
||||
config: PropTypes.object,
|
||||
notification: PropTypes.object.isRequired,
|
||||
teammateNameDisplay: PropTypes.string,
|
||||
user: PropTypes.object,
|
||||
};
|
||||
|
||||
state = {
|
||||
keyFrames: {
|
||||
from: {
|
||||
translateY: -100,
|
||||
},
|
||||
to: {
|
||||
translateY: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tapped = false;
|
||||
|
||||
componentDidMount() {
|
||||
this.setDismissTimer();
|
||||
this.setDidDisappearListener();
|
||||
this.setShowOverlayListener();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.clearDismissTimer();
|
||||
this.clearDidDisappearListener();
|
||||
this.clearShowOverlayListener();
|
||||
}
|
||||
|
||||
setDismissTimer = () => {
|
||||
this.dismissTimer = setTimeout(() => {
|
||||
if (!this.tapped) {
|
||||
this.animateDismissOverlay();
|
||||
}
|
||||
}, AUTO_DISMISS_TIME_MILLIS);
|
||||
}
|
||||
|
||||
clearDismissTimer = () => {
|
||||
if (this.dismissTimer) {
|
||||
clearTimeout(this.dismissTimer);
|
||||
this.dismissTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
setDidDisappearListener = () => {
|
||||
this.didDismissListener = Navigation.events().registerComponentDidDisappearListener(async ({componentId}) => {
|
||||
if (componentId === this.props.componentId && this.tapped) {
|
||||
await dismissAllModals();
|
||||
await popToRoot();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearDidDisappearListener = () => {
|
||||
this.didDismissListener.remove();
|
||||
}
|
||||
|
||||
setShowOverlayListener = () => {
|
||||
EventEmitter.on(NavigationTypes.NAVIGATION_SHOW_OVERLAY, this.onNewOverlay);
|
||||
}
|
||||
|
||||
clearShowOverlayListener = () => {
|
||||
EventEmitter.off(NavigationTypes.NAVIGATION_SHOW_OVERLAY, this.onNewOverlay);
|
||||
}
|
||||
|
||||
onNewOverlay = () => {
|
||||
// Dismiss this overlay so that there is only ever one.
|
||||
this.dismissOverlay();
|
||||
}
|
||||
|
||||
dismissOverlay = () => {
|
||||
this.clearDismissTimer();
|
||||
|
||||
const {componentId} = this.props;
|
||||
dismissOverlay(componentId);
|
||||
}
|
||||
|
||||
animateDismissOverlay = () => {
|
||||
this.clearDismissTimer();
|
||||
|
||||
this.setState({
|
||||
keyFrames: {
|
||||
from: {
|
||||
translateY: 0,
|
||||
},
|
||||
to: {
|
||||
translateY: -100,
|
||||
},
|
||||
},
|
||||
});
|
||||
setTimeout(() => this.dismissOverlay(), 1000);
|
||||
}
|
||||
|
||||
notificationTapped = () => {
|
||||
this.tapped = true;
|
||||
this.clearDismissTimer();
|
||||
|
||||
const {actions, notification} = this.props;
|
||||
|
||||
EventEmitter.emit(NavigationTypes.CLOSE_MAIN_SIDEABR);
|
||||
EventEmitter.emit(NavigationTypes.CLOSE_SETTINGS_SIDEBAR);
|
||||
this.dismissOverlay();
|
||||
InteractionManager.runAfterInteractions(() => {
|
||||
if (!notification.localNotification) {
|
||||
actions.loadFromPushNotification(notification);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
getNotificationIcon = () => {
|
||||
const {config, notification, user} = this.props;
|
||||
const {data} = notification;
|
||||
|
||||
let icon = (
|
||||
<FastImage
|
||||
source={logo}
|
||||
style={style.icon}
|
||||
/>
|
||||
);
|
||||
|
||||
if (data.from_webhook && config.EnablePostIconOverride === 'true' && data.use_user_icon !== 'true') {
|
||||
if (data.override_icon_url) { // eslint-disable-line camelcase
|
||||
const source = {uri: Client4.getAbsoluteUrl(data.override_icon_url)}; // eslint-disable-line camelcase
|
||||
icon = (
|
||||
<FastImage
|
||||
source={source}
|
||||
style={style.icon}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
icon = (
|
||||
<CompassIcon
|
||||
name='webhook'
|
||||
style={style.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (user) {
|
||||
icon = (
|
||||
<ProfilePicture
|
||||
userId={user.id}
|
||||
size={IMAGE_SIZE}
|
||||
iconSize={24}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return icon;
|
||||
};
|
||||
|
||||
getNotificationTitle = (notification) => {
|
||||
const {channel} = this.props;
|
||||
const {message, data} = notification;
|
||||
|
||||
if (data.version === 'v2') {
|
||||
if (data.channel_name) {
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={style.title}
|
||||
>
|
||||
{data.channel_name}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const msg = message.split(':');
|
||||
const titleText = msg.shift();
|
||||
|
||||
let title = (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={style.title}
|
||||
>
|
||||
{titleText}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const userName = this.getNotificationUserName();
|
||||
if (userName && channel) {
|
||||
let channelName;
|
||||
let inText;
|
||||
if (!isDirectChannel(channel)) {
|
||||
inText = (
|
||||
<FormattedText
|
||||
id='mobile.notification.in'
|
||||
defaultMessage=' in '
|
||||
style={style.message}
|
||||
/>
|
||||
);
|
||||
|
||||
channelName = (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={[style.title, style.channelName]}
|
||||
>
|
||||
{channel.display_name}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
title = (
|
||||
<View style={{flex: 1, flexDirection: 'row'}}>
|
||||
{userName}
|
||||
{inText}
|
||||
{channelName}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return title;
|
||||
};
|
||||
|
||||
getNotificationUserName = () => {
|
||||
const {config, notification, teammateNameDisplay, user} = this.props;
|
||||
const {data} = notification;
|
||||
|
||||
let userName;
|
||||
if (data.override_username && config.EnablePostUsernameOverride === 'true') {
|
||||
userName = (
|
||||
<Text style={style.title}>
|
||||
{data.override_username}
|
||||
</Text>
|
||||
);
|
||||
} else if (user) {
|
||||
userName = (
|
||||
<Text style={style.title}>
|
||||
{displayUsername(user, teammateNameDisplay)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return userName;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {notification} = this.props;
|
||||
const {data, message} = notification;
|
||||
|
||||
if (message) {
|
||||
let messageText;
|
||||
if (data.version !== 'v2') {
|
||||
const msg = message.split(':');
|
||||
messageText = msg.join('').trim();
|
||||
} else if (Platform.OS === 'ios') {
|
||||
messageText = message.body || message;
|
||||
} else {
|
||||
messageText = message;
|
||||
}
|
||||
|
||||
const title = this.getNotificationTitle(notification);
|
||||
const icon = this.getNotificationIcon();
|
||||
|
||||
return (
|
||||
<PanGestureHandler
|
||||
onGestureEvent={this.animateDismissOverlay}
|
||||
minOffsetY={-20}
|
||||
>
|
||||
<Animatable.View
|
||||
duration={250}
|
||||
style={style.container}
|
||||
useNativeDriver={true}
|
||||
animation={this.state.keyFrames}
|
||||
>
|
||||
<View style={{flex: 1}}>
|
||||
<TouchableOpacity
|
||||
style={{flex: 1, flexDirection: 'row'}}
|
||||
onPress={this.notificationTapped}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<View style={style.iconContainer}>
|
||||
{icon}
|
||||
</View>
|
||||
<View style={style.textContainer}>
|
||||
{title}
|
||||
<View style={{flex: 1}}>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={style.message}
|
||||
>
|
||||
{messageText}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animatable.View>
|
||||
</PanGestureHandler>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const style = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: changeOpacity('#000', 1),
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
paddingHorizontal: 10,
|
||||
width: '100%',
|
||||
...Platform.select({
|
||||
android: {
|
||||
height: 68,
|
||||
},
|
||||
ios: {
|
||||
height: 88,
|
||||
},
|
||||
}),
|
||||
},
|
||||
iconContainer: {
|
||||
...Platform.select({
|
||||
android: {
|
||||
paddingTop: 17,
|
||||
},
|
||||
ios: {
|
||||
paddingTop: 37,
|
||||
},
|
||||
}),
|
||||
},
|
||||
icon: {
|
||||
borderRadius: (IMAGE_SIZE / 2),
|
||||
height: IMAGE_SIZE,
|
||||
width: IMAGE_SIZE,
|
||||
},
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'column',
|
||||
alignSelf: 'stretch',
|
||||
alignItems: 'flex-start',
|
||||
marginLeft: 10,
|
||||
...Platform.select({
|
||||
android: {
|
||||
marginTop: 17,
|
||||
height: 50,
|
||||
},
|
||||
ios: {
|
||||
paddingTop: 37,
|
||||
},
|
||||
}),
|
||||
},
|
||||
title: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
channelName: {
|
||||
alignSelf: 'stretch',
|
||||
alignItems: 'flex-start',
|
||||
flex: 1,
|
||||
},
|
||||
message: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {shallow} from 'enzyme';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
|
||||
import Notification from './notification.js';
|
||||
|
||||
jest.mock('react-native-navigation', () => ({
|
||||
Navigation: {
|
||||
events: jest.fn(() => ({
|
||||
registerComponentDidDisappearListener: jest.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Notification', () => {
|
||||
const baseProps = {
|
||||
actions: {
|
||||
loadFromPushNotification: jest.fn(),
|
||||
},
|
||||
componentId: 'component-id',
|
||||
deviceWidth: 100,
|
||||
notification: {},
|
||||
theme: Preferences.THEMES.default,
|
||||
};
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const wrapper = shallow(
|
||||
<Notification {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should call loadFromPushNotification on notification tap for non-local notification', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
notification: {
|
||||
localNotification: true,
|
||||
},
|
||||
};
|
||||
const wrapper = shallow(
|
||||
<Notification {...props}/>,
|
||||
);
|
||||
const instance = wrapper.instance();
|
||||
|
||||
instance.notificationTapped();
|
||||
expect(baseProps.actions.loadFromPushNotification).not.toHaveBeenCalled();
|
||||
|
||||
const notification = {
|
||||
localNotification: false,
|
||||
};
|
||||
wrapper.setProps({notification});
|
||||
instance.notificationTapped();
|
||||
expect(baseProps.actions.loadFromPushNotification).toHaveBeenCalledWith(notification);
|
||||
});
|
||||
});
|
||||
93
app/screens/notification/notification_icon.tsx
Normal file
93
app/screens/notification/notification_icon.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {Platform, StyleSheet, View} from 'react-native';
|
||||
import FastImage from 'react-native-fast-image';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import CompassIcon from '@components/compass_icon';
|
||||
import ProfilePicture from '@components/profile_picture';
|
||||
import {Client4} from '@mm-redux/client';
|
||||
import {getConfig} from '@mm-redux/selectors/entities/general';
|
||||
import {getUser} from '@mm-redux/selectors/entities/users';
|
||||
import type {GlobalState} from '@mm-redux/types/store';
|
||||
|
||||
interface NotificationIconProps {
|
||||
fromWebhook: boolean;
|
||||
senderId: string;
|
||||
useUserIcon: boolean;
|
||||
overrideIconUrl?: string;
|
||||
}
|
||||
|
||||
const IMAGE_SIZE = 32;
|
||||
const logo = require('@assets/images/icon.png');
|
||||
|
||||
const NotificationIcon = ({fromWebhook, overrideIconUrl, senderId, useUserIcon}: NotificationIconProps) => {
|
||||
const config = useSelector(getConfig);
|
||||
const user = useSelector((state: GlobalState) => getUser(state, senderId));
|
||||
|
||||
let icon;
|
||||
if (fromWebhook && useUserIcon && config.EnablePostIconOverride === 'true') {
|
||||
if (overrideIconUrl) {
|
||||
const source = {uri: Client4.getAbsoluteUrl(overrideIconUrl)};
|
||||
icon = (
|
||||
<FastImage
|
||||
source={source}
|
||||
style={styles.icon}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
icon = (
|
||||
<CompassIcon
|
||||
name='webhook'
|
||||
style={styles.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
} else if (user) {
|
||||
icon = (
|
||||
<ProfilePicture
|
||||
userId={user.id}
|
||||
size={IMAGE_SIZE}
|
||||
iconSize={24}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
icon = (
|
||||
<FastImage
|
||||
source={logo}
|
||||
style={styles.icon}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={styles.iconContainer}
|
||||
testID='in_app_notification.icon'
|
||||
>
|
||||
{icon}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
iconContainer: {
|
||||
...Platform.select({
|
||||
android: {
|
||||
paddingTop: 17,
|
||||
},
|
||||
ios: {
|
||||
paddingTop: 37,
|
||||
},
|
||||
}),
|
||||
},
|
||||
icon: {
|
||||
borderRadius: (IMAGE_SIZE / 2),
|
||||
height: IMAGE_SIZE,
|
||||
width: IMAGE_SIZE,
|
||||
},
|
||||
});
|
||||
|
||||
export default NotificationIcon;
|
||||
32
app/screens/notification/notification_title.tsx
Normal file
32
app/screens/notification/notification_title.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
import {StyleSheet, Text} from 'react-native';
|
||||
|
||||
interface NotificationTitleProps {
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
const NotificationTitle = ({channelName}: NotificationTitleProps) => {
|
||||
return (
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
ellipsizeMode='tail'
|
||||
style={styles.title}
|
||||
testID='in_app_notification.title'
|
||||
>
|
||||
{channelName}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
title: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default NotificationTitle;
|
||||
36
app/types/push_notification.d.ts
vendored
Normal file
36
app/types/push_notification.d.ts
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
interface NotificationUserInfo {
|
||||
localNotification: boolean;
|
||||
localTest: boolean;
|
||||
channel_id?: string;
|
||||
}
|
||||
|
||||
interface NotificationData {
|
||||
channel_id: string;
|
||||
channel_name?: string;
|
||||
from_webhook?: string;
|
||||
post_id: string;
|
||||
root_id?: string;
|
||||
type: string;
|
||||
use_user_icon?: string;
|
||||
override_icon_url?: string;
|
||||
override_username?: string;
|
||||
version: string;
|
||||
sender_id?: string;
|
||||
}
|
||||
|
||||
interface NotificationMessage {
|
||||
body: string;
|
||||
}
|
||||
|
||||
interface PushNotificationData {
|
||||
date?: Date;
|
||||
data?: NotificationData;
|
||||
fireDate?: string;
|
||||
foreground: boolean;
|
||||
message: NotificationMessage | string;
|
||||
userInfo?: NotificationUserInfo;
|
||||
userInteraction: boolean;
|
||||
}
|
||||
|
|
@ -368,7 +368,6 @@
|
|||
"mobile.notification_settings.ooo_auto_responder": "Automatic Direct Message Replies",
|
||||
"mobile.notification_settings.save_failed_description": "The notification settings failed to save due to a connection issue, please try again.",
|
||||
"mobile.notification_settings.save_failed_title": "Connection issue",
|
||||
"mobile.notification.in": " in ",
|
||||
"mobile.offlineIndicator.connected": "Connected",
|
||||
"mobile.offlineIndicator.connecting": "Connecting...",
|
||||
"mobile.offlineIndicator.offline": "No internet connection",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@
|
|||
|
||||
import {logoutUser, toChannelScreen} from '@support/ui/screen';
|
||||
import {timeouts, wait} from '@support/utils';
|
||||
|
||||
import {Setup} from '@support/server_api';
|
||||
|
||||
describe('Channel Info Header', () => {
|
||||
|
|
|
|||
109
detox/e2e/test/in_app_notification/notification.e2e.js
Normal file
109
detox/e2e/test/in_app_notification/notification.e2e.js
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
// *******************************************************************
|
||||
// - [#] indicates a test step (e.g. # Go to a screen)
|
||||
// - [*] indicates an assertion (e.g. * Check the title)
|
||||
// - Use element testID when selecting an element. Create one if none.
|
||||
// *******************************************************************
|
||||
|
||||
import {logoutUser, toChannelScreen} from '@support/ui/screen';
|
||||
import {isAndroid, timeouts, wait} from '@support/utils';
|
||||
import {Setup} from '@support/server_api';
|
||||
|
||||
const DetoxConstants = require('detox').DetoxConstants;
|
||||
|
||||
function getNotification(channel, team, user) {
|
||||
if (isAndroid()) {
|
||||
return {
|
||||
payload: {
|
||||
ack_id: 'ack-e2e-test-id',
|
||||
type: 'message',
|
||||
badge: 1,
|
||||
version: '2',
|
||||
channel_id: channel.id,
|
||||
channel_name: channel.name,
|
||||
team_id: team.id,
|
||||
sender_id: user.id,
|
||||
sender_name: user.name,
|
||||
message: 'This is an e2e test message',
|
||||
post_id: 'post-e2e-test-id',
|
||||
root_id: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
trigger: {
|
||||
type: DetoxConstants.userNotificationTriggers.push,
|
||||
},
|
||||
badge: 1,
|
||||
body: 'This is an e2e test message',
|
||||
payload: {
|
||||
ack_id: 'ack-e2e-test-id',
|
||||
type: 'message',
|
||||
version: '2',
|
||||
channel_id: channel.id,
|
||||
channel_name: channel.name,
|
||||
team_id: team.id,
|
||||
sender_id: user.id,
|
||||
sender_name: user.name,
|
||||
post_id: 'post-e2e-test-id',
|
||||
root_id: '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('in-app Notification', () => {
|
||||
let testNotification;
|
||||
let testChannel;
|
||||
|
||||
beforeAll(async () => {
|
||||
const {channel, team, user} = await Setup.apiInit();
|
||||
testChannel = channel;
|
||||
testNotification = getNotification(channel, team, user);
|
||||
await toChannelScreen(user);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await logoutUser();
|
||||
});
|
||||
|
||||
it('MM-T3440 should render an in-app notification', async () => {
|
||||
const message = Date.now().toString();
|
||||
|
||||
// # Type a message
|
||||
const postInput = await element(by.id('post_input'));
|
||||
await postInput.tap();
|
||||
await postInput.typeText(message);
|
||||
|
||||
// # Tap the send button
|
||||
await element(by.id('send_button')).tap();
|
||||
|
||||
// # Open Add reaction screen
|
||||
await element(by.text(message)).longPress();
|
||||
await element(by.id('reaction_picker.open')).tap();
|
||||
await element(by.id('screen.add_reaction.close')).tap();
|
||||
|
||||
if (isAndroid()) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Skipping on Android until https://github.com/wix/Detox/issues/2141');
|
||||
return;
|
||||
}
|
||||
|
||||
// # When a push notification is received
|
||||
await device.sendUserNotification(testNotification);
|
||||
await wait(timeouts.HALF_SEC);
|
||||
|
||||
// * in-app notification shows
|
||||
await expect(element(by.id('in_app_notification'))).toBeVisible();
|
||||
await expect(element(by.id('in_app_notification.icon'))).toBeVisible();
|
||||
await expect(element(by.id('in_app_notification.title'))).toHaveText(testChannel.name);
|
||||
await expect(element(by.id('in_app_notification.message'))).toHaveText('This is an e2e test message');
|
||||
|
||||
// # Wait for some profiles to load
|
||||
await wait(5 * timeouts.ONE_SEC);
|
||||
|
||||
// * in-app notification hides
|
||||
await expect(element(by.id('in_app_notification'))).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,6 @@
|
|||
// *******************************************************************
|
||||
|
||||
import {logoutUser, toChannelScreen} from '@support/ui/screen';
|
||||
|
||||
import {Setup} from '@support/server_api';
|
||||
|
||||
describe('Messaging', () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,18 @@
|
|||
diff --git a/node_modules/react-native-notifications/RNNotifications/RNNotificationsStore.m b/node_modules/react-native-notifications/RNNotifications/RNNotificationsStore.m
|
||||
index d5e953a..08d4d46 100644
|
||||
--- a/node_modules/react-native-notifications/RNNotifications/RNNotificationsStore.m
|
||||
+++ b/node_modules/react-native-notifications/RNNotifications/RNNotificationsStore.m
|
||||
@@ -40,7 +40,9 @@ - (void)setPresentationCompletionHandler:(void (^)(UNNotificationPresentationOpt
|
||||
- (void)completeAction:(NSString *)completionKey {
|
||||
void (^completionHandler)() = (void (^)())[_actionCompletionHandlers valueForKey:completionKey];
|
||||
if (completionHandler) {
|
||||
- completionHandler();
|
||||
+ dispatch_async(dispatch_get_main_queue(), ^{
|
||||
+ completionHandler();
|
||||
+ });
|
||||
[_actionCompletionHandlers removeObjectForKey:completionKey];
|
||||
}
|
||||
}
|
||||
diff --git a/node_modules/react-native-notifications/android/app/src/main/AndroidManifest.xml b/node_modules/react-native-notifications/android/app/src/main/AndroidManifest.xml
|
||||
index 7053040..ad63eff 100644
|
||||
--- a/node_modules/react-native-notifications/android/app/src/main/AndroidManifest.xml
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/bin/sh
|
||||
|
||||
jsfiles=$(git diff --cached --name-only --diff-filter=ACM | grep -E '.js$|.ts$')
|
||||
jsfiles=$(git diff --cached --name-only --diff-filter=ACM | grep -E '.js$|.ts$|.tsx$')
|
||||
|
||||
if [ -z "jsfiles" ]; then
|
||||
exit 0
|
||||
|
|
|
|||
Loading…
Reference in a new issue