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 ( + + + + + ); + } +} + 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 ( + + + + + + {name} + {message} + + + + + + + + ); + } +} 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() {