diff --git a/NOTICE.txt b/NOTICE.txt index 84257df96..e03f8bebd 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -735,3 +735,69 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --- + +## react-native-push-notification + +This product contains 'react-native-push-notification', a library to provide React Native local and remote notifications for iOS and Android. + +* HOMEPAGE + * https://github.com/zo0r/react-native-push-notification + +* LICENSE: + +The MIT License (MIT) + +Copyright (c) 2015 Dieam + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +## react-native-message-bar + +This product contains 'react-native-message-bar', a library for displaying a message bar notification component at the top of the screen for React Native (Android and iOS) projects. + +* HOMEPAGE + * https://github.com/KBLNY/react-native-message-bar + +* LICENSE: + +The MIT License (MIT) + +Copyright (c) 2016 KBLNY + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- diff --git a/android/app/build.gradle b/android/app/build.gradle index 3a0c7bb43..62b3a66c8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -143,6 +143,10 @@ android { } dependencies { + compile project(':react-native-push-notification') + compile ('com.google.android.gms:play-services-gcm:9.0.0') { + force = true; + } compile project(':react-native-cookies') compile project(':react-native-vector-icons') compile project(':react-native-svg') diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 000000000..775b64e25 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,42 @@ +{ + "project_info": { + "project_number": "184930218130", + "firebase_url": "https://api-7231322553409637977-752355.firebaseio.com", + "project_id": "api-7231322553409637977-752355", + "storage_bucket": "api-7231322553409637977-752355.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:184930218130:android:a0e553d1a1f043b5", + "android_client_info": { + "package_name": "com.mattermost.react.native" + } + }, + "oauth_client": [ + { + "client_id": "184930218130-8nahrspll1opff0uogtkh2qsv8coqngo.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyCkZkU2ECVg-mmdCG5OoHHQXtKiENuvWPE" + } + ], + "services": { + "analytics_service": { + "status": 1 + }, + "appinvite_service": { + "status": 1, + "other_platform_oauth_client": [] + }, + "ads_service": { + "status": 2 + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 80df7330b..b937917a1 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,13 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/mattermost/MainApplication.java b/android/app/src/main/java/com/mattermost/MainApplication.java index 762667882..f37c04780 100644 --- a/android/app/src/main/java/com/mattermost/MainApplication.java +++ b/android/app/src/main/java/com/mattermost/MainApplication.java @@ -5,6 +5,7 @@ import android.util.Log; import com.facebook.react.ReactApplication; import com.psykar.cookiemanager.CookieManagerPackage; +import com.dieam.reactnativepushnotification.ReactNativePushNotificationPackage; import com.oblador.vectoricons.VectorIconsPackage; import com.horcrux.svg.RNSvgPackage; import com.facebook.react.ReactInstanceManager; @@ -29,6 +30,7 @@ public class MainApplication extends Application implements ReactApplication { return Arrays.asList( new MainReactPackage(), new CookieManagerPackage(), + new ReactNativePushNotificationPackage(), new VectorIconsPackage(), new RNSvgPackage() ); diff --git a/android/build.gradle b/android/build.gradle index fcba4c587..10fddbb49 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -6,6 +6,7 @@ buildscript { } dependencies { classpath 'com.android.tools.build:gradle:1.3.1' + classpath 'com.google.gms:google-services:3.0.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files diff --git a/android/settings.gradle b/android/settings.gradle index 6e1445e11..c57483643 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,6 +1,8 @@ rootProject.name = 'Mattermost' include ':react-native-cookies' project(':react-native-cookies').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-cookies/android') +include ':react-native-push-notification' +project(':react-native-push-notification').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-push-notification/android') include ':react-native-vector-icons' project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android') diff --git a/app/actions/storage/index.js b/app/actions/storage/index.js index e4d14eabd..ebd26e67a 100644 --- a/app/actions/storage/index.js +++ b/app/actions/storage/index.js @@ -19,11 +19,17 @@ export function loadStorage() { const actions = [ {type: GeneralTypes.RECEIVED_APP_CREDENTIALS, data: credentials}, - {type: GeneralTypes.RECEIVED_SERVER_VERSION, data: serverVersion}, - {type: TeamsTypes.SELECT_TEAM, data: (currentTeamId || '')}, - {type: ChannelTypes.SELECT_CHANNEL, data: currentChannelId} + {type: GeneralTypes.RECEIVED_SERVER_VERSION, data: serverVersion} ]; + if (currentTeamId) { + actions.push({type: TeamsTypes.SELECT_TEAM, data: currentTeamId}); + } + + if (currentChannelId) { + actions.push({type: ChannelTypes.SELECT_CHANNEL, data: currentChannelId}); + } + // Load post drafts if there are any if (otherStorage.postDrafts) { Object.keys(otherStorage.postDrafts).forEach((d) => { diff --git a/app/actions/views/root.js b/app/actions/views/root.js index bae2bfd92..cf0bad390 100644 --- a/app/actions/views/root.js +++ b/app/actions/views/root.js @@ -1,11 +1,19 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import {NavigationTypes} from 'app/constants'; +import {NavigationTypes, ViewTypes} from 'app/constants'; import Routes from 'app/navigation/routes'; +import { + handleSelectChannel, + loadChannelsIfNecessary, + loadProfilesAndTeamMembersForDMSidebar +} from 'app/actions/views/channel'; +import {goToChannelView} from 'app/actions/views/load_team'; +import {handleTeamChange, selectFirstAvailableTeam} from 'app/actions/views/select_team'; import {updateStorage} from 'app/actions/storage'; import Client from 'service/client'; +import {markChannelAsRead, viewChannel} from 'service/actions/channels'; import {getClientConfig, getLicenseConfig, setServerVersion} from 'service/actions/general'; import {loadMe} from 'service/actions/users'; @@ -37,8 +45,45 @@ export function loadConfigAndLicense(serverVersion) { }; } +export function queueNotification(notification) { + return async (dispatch, getState) => { + dispatch({type: ViewTypes.NOTIFICATION_CHANGED, data: notification}, getState); + }; +} + +export function clearNotification() { + return async (dispatch, getState) => { + dispatch({type: ViewTypes.NOTIFICATION_CHANGED, data: null}, getState); + }; +} + +export function goToNotification(notification) { + return async (dispatch, getState) => { + const {data} = notification; + let teamId = data.team_id; + if (teamId) { + const {teams} = getState().entities.teams; + await handleTeamChange(teams[teamId])(dispatch, getState); + loadChannelsIfNecessary(teamId)(dispatch, getState); + } else { + await selectFirstAvailableTeam()(dispatch, getState); + teamId = getState().entities.teams.currentId; + } + + const channelId = data.channel_id; + viewChannel(teamId, channelId)(dispatch, getState); + loadProfilesAndTeamMembersForDMSidebar(teamId)(dispatch, getState); + await handleSelectChannel(channelId)(dispatch, getState); + goToChannelView()(dispatch, getState); + markChannelAsRead(teamId, channelId)(dispatch, getState); + }; +} + export default { goToSelectServer, loadConfigAndLicense, - setStoreFromLocalData + setStoreFromLocalData, + queueNotification, + clearNotification, + goToNotification }; diff --git a/app/components/push_notification/index.js b/app/components/push_notification/index.js new file mode 100644 index 000000000..6a44f3874 --- /dev/null +++ b/app/components/push_notification/index.js @@ -0,0 +1,35 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; + +import {goToNotification, queueNotification} from 'app/actions/views/root'; +import {setDeviceToken} from 'service/actions/general'; +import {getUnreads} from 'service/selectors/entities/channels'; + +import PushNotification from './push_notification'; + +function mapStateToProps(state, ownProps) { + const {currentId: currentTeamId} = state.entities.teams; + const {currentId: currentChannelId} = state.entities.channels; + + return { + ...ownProps, + currentTeamId, + currentChannelId, + ...getUnreads(state) + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + goToNotification, + queueNotification, + setDeviceToken + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(PushNotification); diff --git a/app/components/push_notification/push_notification.js b/app/components/push_notification/push_notification.js new file mode 100644 index 000000000..48489b1f2 --- /dev/null +++ b/app/components/push_notification/push_notification.js @@ -0,0 +1,123 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React, {PropTypes, PureComponent} from 'react'; +import {Platform} from 'react-native'; +import DeviceNotification from 'react-native-push-notification'; +import {MessageBar, MessageBarManager} from 'react-native-message-bar'; + +import {changeOpacity} from 'app/utils/theme'; +import icon from 'assets/images/icon.png'; +import {GooglePlaySenderId} from 'assets/config.json'; + +import {Constants} from 'service/constants'; + +export default class PushNotification extends PureComponent { + static propTypes = { + currentTeamId: PropTypes.string, + currentChannelId: PropTypes.string, + actions: PropTypes.shape({ + goToNotification: PropTypes.func.isRequired, + queueNotification: PropTypes.func.isRequired, + setDeviceToken: PropTypes.func.isRequired + }).isRequired + }; + + constructor(props) { + super(props); + + this.isConfigured = false; + } + + componentDidMount() { + MessageBarManager.registerMessageBar(this.refs.notification); + this.configurePushNotifications(); + } + + componentWillUnmount() { + MessageBarManager.unregisterMessageBar(); + DeviceNotification.unregister(); + } + + componentWillReceiveProps(props) { + if (this.isConfigured) { + DeviceNotification.setApplicationIconBadgeNumber(props.mentionCount || 0); + } + } + + configurePushNotifications = () => { + DeviceNotification.configure({ + onRegister: this.onRegisterDevice, + onNotification: this.onPushNotification, + senderID: GooglePlaySenderId, + popInitialNotification: true, + requestPermissions: true + }); + }; + + onRegisterDevice = (data) => { + const prefix = Platform.OS === 'ios' ? Constants.PUSH_NOTIFY_APPLE_REACT_NATIVE : Constants.PUSH_NOTIFY_ANDROID_REACT_NATIVE; + this.props.actions.setDeviceToken(`${prefix}:${data.token}`); + this.isConfigured = true; + }; + + onPushNotification = (deviceNotification) => { + const {foreground, userInteraction, data, message} = deviceNotification; + let notification; + + if (Platform.OS === 'android') { + notification = { + data: { + channel_id: deviceNotification.channel_id, + team_id: deviceNotification.team_id + }, + message + }; + } else { + notification = { + data, + message + }; + } + + if (foreground) { + this.handleInAppNotification(notification); + } else if (userInteraction) { + this.onNotificationTapped(notification); + } + }; + + onNotificationTapped = (notification) => { + const {currentTeamId, currentChannelId} = this.props; + + if (currentTeamId && currentChannelId) { + // this means that the store has the necessary data + this.props.actions.goToNotification(notification); + } else { + this.props.actions.queueNotification(notification); + } + }; + + handleInAppNotification = (notification) => { + const {message} = notification; + + MessageBarManager.showAlert({ + alertType: 'info', + avatar: icon, + avatarStyle: {borderRadius: 10, width: 20, height: 20}, + message, + stylesheetInfo: {backgroundColor: changeOpacity('#000', 0.9)}, + messageStyle: {color: 'white', fontSize: 13}, + viewTopInset: 15, + viewBottomInset: 15, + duration: 5000, + onTapped: () => this.onNotificationTapped(notification) + }); + }; + + render() { + return ( + + ); + } +} diff --git a/app/components/root/root.js b/app/components/root/root.js index 4f3995f31..ce719c179 100644 --- a/app/components/root/root.js +++ b/app/components/root/root.js @@ -1,20 +1,25 @@ // Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; -import {AppState} from 'react-native'; +import React, {PropTypes, PureComponent} from 'react'; +import {AppState, View} from 'react-native'; import {IntlProvider} from 'react-intl'; +import PushNotification from 'app/components/push_notification'; + import {Constants} from 'service/constants'; import {getTranslations} from 'service/i18n'; import EventEmitter from 'service/utils/event_emitter'; -export default class Root extends React.Component { +export default class Root extends PureComponent { static propTypes = { - children: React.PropTypes.node, - locale: React.PropTypes.string.isRequired, - actions: React.PropTypes.shape({ - loadConfigAndLicense: React.PropTypes.func.isRequired, - setAppState: React.PropTypes.func.isRequired + children: PropTypes.node, + currentTeamId: PropTypes.string, + currentChannelId: PropTypes.string, + locale: PropTypes.string.isRequired, + actions: PropTypes.shape({ + loadConfigAndLicense: PropTypes.func.isRequired, + setAppState: PropTypes.func.isRequired, + flushToStorage: PropTypes.func.isRequired }).isRequired }; @@ -38,6 +43,10 @@ export default class Root extends React.Component { handleAppStateChange(appState) { this.props.actions.setAppState(appState === 'active'); + + if (appState === 'inactive') { + this.props.actions.flushToStorage(); + } } handleConfigChanged = (serverVersion) => { @@ -52,7 +61,10 @@ export default class Root extends React.Component { locale={locale} messages={getTranslations(locale)} > - {this.props.children} + + {this.props.children} + + ); } diff --git a/app/components/root/root_container.js b/app/components/root/root_container.js index 3e17153af..e9b08cbdb 100644 --- a/app/components/root/root_container.js +++ b/app/components/root/root_container.js @@ -6,14 +6,17 @@ import {connect} from 'react-redux'; import Config from 'assets/config.json'; -import {loadConfigAndLicense} from 'app/actions/views/root'; -import {setAppState} from 'service/actions/general'; +import {flushToStorage} from 'app/actions/storage'; +import {goToNotification, loadConfigAndLicense, queueNotification} from 'app/actions/views/root'; +import {setAppState, setDeviceToken} from 'service/actions/general'; import Root from './root'; function mapStateToProps(state, ownProps) { const users = state.entities.users; const currentUserId = users.currentId; + const {currentId: currentTeamId} = state.entities.teams; + const {currentId: currentChannelId} = state.entities.channels; let locale = Config.DefaultLocale; if (currentUserId && users.profiles[currentUserId]) { @@ -22,6 +25,8 @@ function mapStateToProps(state, ownProps) { return { ...ownProps, + currentTeamId, + currentChannelId, locale }; } @@ -30,7 +35,11 @@ function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ loadConfigAndLicense, - setAppState + goToNotification, + queueNotification, + setAppState, + setDeviceToken, + flushToStorage }, dispatch) }; } diff --git a/app/constants/view.js b/app/constants/view.js index d206ba46c..b302fb62b 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -12,7 +12,9 @@ const ViewTypes = keyMirror({ POST_DRAFT_CHANGED: null, COMMENT_DRAFT_CHANGED: null, - OPTIONS_MODAL_CHANGED: null + OPTIONS_MODAL_CHANGED: null, + + NOTIFICATION_CHANGED: null }); export default ViewTypes; diff --git a/app/initial_state.js b/app/initial_state.js index bc88f1e9a..8fc99245e 100644 --- a/app/initial_state.js +++ b/app/initial_state.js @@ -11,7 +11,8 @@ const state = { appState: false, credentials: {}, config: {}, - license: {} + license: {}, + deviceToken: '' }, users: { currentId: '', @@ -281,6 +282,7 @@ const state = { loginId: '', password: '' }, + notification: null, optionsModal: { title: '', options: [], diff --git a/app/reducers/views/index.js b/app/reducers/views/index.js index f1ef9724a..986129e99 100644 --- a/app/reducers/views/index.js +++ b/app/reducers/views/index.js @@ -6,6 +6,7 @@ import {combineReducers} from 'redux'; import channel from './channel'; import i18n from './i18n'; import login from './login'; +import notification from './notification'; import optionsModal from './options_modal'; import selectServer from './select_server'; import thread from './thread'; @@ -14,6 +15,7 @@ export default combineReducers({ channel, i18n, login, + notification, optionsModal, selectServer, thread diff --git a/app/reducers/views/notification.js b/app/reducers/views/notification.js new file mode 100644 index 000000000..03d73a563 --- /dev/null +++ b/app/reducers/views/notification.js @@ -0,0 +1,13 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {ViewTypes} from 'app/constants'; + +export default function notification(state = null, action) { + switch (action.type) { + case ViewTypes.NOTIFICATION_CHANGED: + return action.data; + default: + return state; + } +} diff --git a/app/scenes/channel/channel.js b/app/scenes/channel/channel.js index ab752fc9c..a75f78f07 100644 --- a/app/scenes/channel/channel.js +++ b/app/scenes/channel/channel.js @@ -62,9 +62,11 @@ export default class Channel extends React.PureComponent { this.props.subscribeToHeaderEvent('open_right_menu', this.openRightMenuDrawer); this.props.subscribeToHeaderEvent('show_channel_info', this.props.actions.goToChannelInfo); EventEmitter.on('leave_team', this.handleLeaveTeam); - const teamId = this.props.currentTeam.id; this.props.actions.initWebSocket(Platform.OS); - this.loadChannels(teamId); + if (this.props.currentTeam) { + const teamId = this.props.currentTeam.id; + this.loadChannels(teamId); + } } componentDidMount() { @@ -72,7 +74,7 @@ export default class Channel extends React.PureComponent { } componentWillReceiveProps(nextProps) { - if (nextProps.currentTeam && this.props.currentTeam.id !== nextProps.currentTeam.id) { + if (this.props.currentTeam && nextProps.currentTeam && this.props.currentTeam.id !== nextProps.currentTeam.id) { const teamId = nextProps.currentTeam.id; this.loadChannels(teamId); } diff --git a/app/scenes/channel/channel_drawer_button.js b/app/scenes/channel/channel_drawer_button.js index 83297d11f..757e794eb 100644 --- a/app/scenes/channel/channel_drawer_button.js +++ b/app/scenes/channel/channel_drawer_button.js @@ -16,6 +16,7 @@ import {getTheme} from 'service/selectors/entities/preferences'; function ChannelDrawerButton(props) { let badge; let badgeCount = props.mentionCount; + if (!badgeCount && props.messageCount) { badgeCount = -1; } diff --git a/app/scenes/load_team/load_team.js b/app/scenes/load_team/load_team.js index 4ff8fb898..4d97803c4 100644 --- a/app/scenes/load_team/load_team.js +++ b/app/scenes/load_team/load_team.js @@ -1,28 +1,35 @@ // Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import React from 'react'; +import React, {PropTypes, PureComponent} from 'react'; import Loading from 'app/components/loading'; import {RequestStatus} from 'service/constants'; -export default class LoadTeam extends React.Component { +export default class LoadTeam extends PureComponent { static propTypes = { - teams: React.PropTypes.object.isRequired, - myMembers: React.PropTypes.object.isRequired, - teamsRequest: React.PropTypes.object.isRequired, - currentTeam: React.PropTypes.object, - actions: React.PropTypes.shape({ - goToChannelView: React.PropTypes.func.isRequired, - handleTeamChange: React.PropTypes.func.isRequired + notification: PropTypes.object, + teams: PropTypes.object.isRequired, + myMembers: PropTypes.object.isRequired, + teamsRequest: PropTypes.object.isRequired, + currentTeam: PropTypes.object, + actions: PropTypes.shape({ + clearNotification: PropTypes.func.isRequired, + goToChannelView: PropTypes.func.isRequired, + goToNotification: PropTypes.func.isRequired, + handleTeamChange: PropTypes.func.isRequired }).isRequired }; componentDidMount() { - const {currentTeam, myMembers, teams} = this.props; + const {notification, currentTeam, myMembers, teams} = this.props; + const {clearNotification, goToNotification} = this.props.actions; - if (currentTeam) { + if (notification) { + clearNotification(); + goToNotification(notification); + } else if (currentTeam) { this.onSelectTeam(currentTeam); } else if (!currentTeam) { this.selectFirstTeam(teams, myMembers); diff --git a/app/scenes/load_team/load_team_container.js b/app/scenes/load_team/load_team_container.js index 9c606b497..583ddd1d8 100644 --- a/app/scenes/load_team/load_team_container.js +++ b/app/scenes/load_team/load_team_container.js @@ -5,6 +5,7 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {goToChannelView} from 'app/actions/views/load_team'; +import {clearNotification, goToNotification} from 'app/actions/views/root'; import {handleTeamChange} from 'app/actions/views/select_team'; import {getCurrentTeam} from 'service/selectors/entities/teams'; @@ -16,14 +17,17 @@ function mapStateToProps(state) { teamsRequest: state.requests.teams.allTeams, teams: state.entities.teams.teams, currentTeam: getCurrentTeam(state), - myMembers: state.entities.teams.myMembers + myMembers: state.entities.teams.myMembers, + notification: state.views.notification }; } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ + clearNotification, goToChannelView, + goToNotification, handleTeamChange }, dispatch) }; diff --git a/app/scenes/root/root.js b/app/scenes/root/root.js index 90284ef67..054d7c546 100644 --- a/app/scenes/root/root.js +++ b/app/scenes/root/root.js @@ -2,7 +2,7 @@ // See License.txt for license information. import React, {PropTypes, PureComponent} from 'react'; -import {AppState, AsyncStorage} from 'react-native'; +import {AsyncStorage} from 'react-native'; import Loading from 'app/components/loading'; import {RequestStatus} from 'service/constants'; @@ -10,16 +10,15 @@ import {RequestStatus} from 'service/constants'; export default class Root extends PureComponent { static propTypes = { credentials: PropTypes.object, - logoutRequest: PropTypes.object.isRequired, - loginRequest: PropTypes.object.isRequired, + logoutRequest: PropTypes.object, + loginRequest: PropTypes.object, actions: PropTypes.shape({ - goToLoadTeam: PropTypes.func.isRequired, - goToSelectServer: PropTypes.func.isRequired, + goToLoadTeam: PropTypes.func, + goToSelectServer: PropTypes.func, handleServerUrlChanged: PropTypes.func.isRequired, - flushToStorage: PropTypes.func.isRequired, - loadStorage: PropTypes.func.isRequired, - removeStorage: PropTypes.func.isRequired, - setStoreFromLocalData: PropTypes.func.isRequired + loadStorage: PropTypes.func, + removeStorage: PropTypes.func, + setStoreFromLocalData: PropTypes.func }).isRequired }; @@ -30,7 +29,6 @@ export default class Root extends PureComponent { componentDidMount() { // Any initialization logic for navigation, setting up the client, etc should go here this.init(); - AppState.addEventListener('change', this.handleAppStateChange); } componentWillReceiveProps(nextProps) { @@ -43,13 +41,6 @@ export default class Root extends PureComponent { } } - handleAppStateChange = (event) => { - // App gets killed, phone call comes in, app is pushed to the background, etc... - if (event === 'inactive') { - this.props.actions.flushToStorage(); - } - }; - init = () => { if (this.props.logoutRequest.status === RequestStatus.SUCCESS) { this.props.actions.removeStorage().then(() => { diff --git a/app/scenes/root/root_container.js b/app/scenes/root/root_container.js index 85061dc6a..25cb07ece 100644 --- a/app/scenes/root/root_container.js +++ b/app/scenes/root/root_container.js @@ -5,7 +5,7 @@ import {bindActionCreators} from 'redux'; import navigationSceneConnect from '../navigationSceneConnect'; -import {flushToStorage, loadStorage, removeStorage} from 'app/actions/storage'; +import {loadStorage, removeStorage} from 'app/actions/storage'; import {goToSelectServer, setStoreFromLocalData} from 'app/actions/views/root'; import {handleServerUrlChanged} from 'app/actions/views/select_server'; import {goToLoadTeam} from 'app/actions/navigation'; @@ -27,7 +27,6 @@ function mapDispatchToProps(dispatch) { goToLoadTeam, goToSelectServer, handleServerUrlChanged, - flushToStorage, loadStorage, removeStorage, setStoreFromLocalData diff --git a/assets/base/config.json b/assets/base/config.json index 571a76abe..95169a5b6 100644 --- a/assets/base/config.json +++ b/assets/base/config.json @@ -3,5 +3,6 @@ "TestServerUrl": "http://localhost:8065", "DefaultLocale": "en", "DefaultTheme": "default", - "ShowErrorsList": false + "ShowErrorsList": false, + "GooglePlaySenderId": "184930218130" } diff --git a/assets/base/images/icon.png b/assets/base/images/icon.png new file mode 100644 index 000000000..8cd3500d8 Binary files /dev/null and b/assets/base/images/icon.png differ diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index b4931e97a..8bf828b42 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 420A7328E12C4B72AEF420CE /* Octicons.ttf in Resources */ = {isa = PBXBuildFile; fileRef = EDC04CBCF81642219D199CBB /* Octicons.ttf */; }; 5E1AF7B72B8D4A4E9E53FF9D /* FontAwesome.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 005346E5C0E542BFABAE1411 /* FontAwesome.ttf */; }; 5E9157361DD0AC6A00FF2AA8 /* libRCTAnimation.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 5E9157331DD0AC6500FF2AA8 /* libRCTAnimation.a */; }; + 7F63D2841E6C9585001FAE12 /* libRCTPushNotification.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7F63D2811E6C957C001FAE12 /* libRCTPushNotification.a */; }; 7FBB5E9A1E1F5A4B000DE18A /* libRNSVG.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FDF28E11E1F4B1F00DBBE56 /* libRNSVG.a */; }; 7FBB5E9B1E1F5A4B000DE18A /* libRNVectorIcons.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 7FDF290C1E1F4B4E00DBBE56 /* libRNVectorIcons.a */; }; 832341BD1AAA6AB300B99B32 /* libRCTText.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 832341B51AAA6A8300B99B32 /* libRCTText.a */; }; @@ -230,6 +231,20 @@ remoteGlobalIDString = 1BD725DA1CF77A8B005DBD79; remoteInfo = RNCookieManagerIOS; }; + 7F63D2801E6C957C001FAE12 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7F63D27B1E6C957C001FAE12 /* RCTPushNotification.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = 134814201AA4EA6300B7C361; + remoteInfo = RCTPushNotification; + }; + 7F63D2821E6C957C001FAE12 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 7F63D27B1E6C957C001FAE12 /* RCTPushNotification.xcodeproj */; + proxyType = 2; + remoteGlobalIDString = 3D05745F1DE6004600184BB4; + remoteInfo = "RCTPushNotification-tvOS"; + }; 7FDF28E01E1F4B1F00DBBE56 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 2CBE9C0FB56E4FDA96C30792 /* RNSVG.xcodeproj */; @@ -285,6 +300,8 @@ 59954D479A89488091EB588F /* RNSearchBar.xcodeproj */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = "wrapper.pb-project"; name = RNSearchBar.xcodeproj; path = "../node_modules/react-native-search-bar/RNSearchBar.xcodeproj"; sourceTree = ""; }; 5E91572D1DD0AC6500FF2AA8 /* RCTAnimation.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTAnimation.xcodeproj; path = "../node_modules/react-native/Libraries/NativeAnimation/RCTAnimation.xcodeproj"; sourceTree = ""; }; 78C398B01ACF4ADC00677621 /* RCTLinking.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTLinking.xcodeproj; path = "../node_modules/react-native/Libraries/LinkingIOS/RCTLinking.xcodeproj"; sourceTree = ""; }; + 7F63D27B1E6C957C001FAE12 /* RCTPushNotification.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTPushNotification.xcodeproj; path = "../node_modules/react-native/Libraries/PushNotificationIOS/RCTPushNotification.xcodeproj"; sourceTree = ""; }; + 7F63D2C21E6DD98A001FAE12 /* Mattermost.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = Mattermost.entitlements; path = Mattermost/Mattermost.entitlements; sourceTree = ""; }; 832341B01AAA6A8300B99B32 /* RCTText.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTText.xcodeproj; path = "../node_modules/react-native/Libraries/Text/RCTText.xcodeproj"; sourceTree = ""; }; AC6EB561E1F64C17A69D2FAD /* Entypo.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Entypo.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Entypo.ttf"; sourceTree = ""; }; CE45EB909FB44F3597EF3C60 /* libRNCookieManagerIOS.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNCookieManagerIOS.a; sourceTree = ""; }; @@ -319,6 +336,7 @@ 832341BD1AAA6AB300B99B32 /* libRCTText.a in Frameworks */, 00C302EA1ABCBA2D00DB3ED1 /* libRCTVibration.a in Frameworks */, 139FDEF61B0652A700C62182 /* libRCTWebSocket.a in Frameworks */, + 7F63D2841E6C9585001FAE12 /* libRCTPushNotification.a in Frameworks */, B50561A109B4442299F82327 /* libRNSearchBar.a in Frameworks */, 9906AC672C594EEB9D5EE503 /* libRNCookieManagerIOS.a in Frameworks */, ); @@ -424,6 +442,7 @@ 13B07FAE1A68108700A75B9A /* Mattermost */ = { isa = PBXGroup; children = ( + 7F63D2C21E6DD98A001FAE12 /* Mattermost.entitlements */, 13B07FAF1A68108700A75B9A /* AppDelegate.h */, 13B07FB01A68108700A75B9A /* AppDelegate.m */, 13B07FB51A68108700A75B9A /* Images.xcassets */, @@ -483,6 +502,15 @@ name = Products; sourceTree = ""; }; + 7F63D27C1E6C957C001FAE12 /* Products */ = { + isa = PBXGroup; + children = ( + 7F63D2811E6C957C001FAE12 /* libRCTPushNotification.a */, + 7F63D2831E6C957C001FAE12 /* libRCTPushNotification-tvOS.a */, + ); + name = Products; + sourceTree = ""; + }; 7FDF28C41E1F4B1F00DBBE56 /* Products */ = { isa = PBXGroup; children = ( @@ -502,6 +530,7 @@ 832341AE1AAA6A7D00B99B32 /* Libraries */ = { isa = PBXGroup; children = ( + 7F63D27B1E6C957C001FAE12 /* RCTPushNotification.xcodeproj */, 5E91572D1DD0AC6500FF2AA8 /* RCTAnimation.xcodeproj */, 146833FF1AC3E56700842450 /* React.xcodeproj */, 00C302A71ABCB8CE00DB3ED1 /* RCTActionSheet.xcodeproj */, @@ -607,6 +636,11 @@ 13B07F861A680F5B00A75B9A = { DevelopmentTeam = UQ8HT4Q2XM; ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Push = { + enabled = 1; + }; + }; }; }; }; @@ -646,6 +680,10 @@ ProductGroup = 00C302D41ABCB9D200DB3ED1 /* Products */; ProjectRef = 00C302D31ABCB9D200DB3ED1 /* RCTNetwork.xcodeproj */; }, + { + ProductGroup = 7F63D27C1E6C957C001FAE12 /* Products */; + ProjectRef = 7F63D27B1E6C957C001FAE12 /* RCTPushNotification.xcodeproj */; + }, { ProductGroup = 139105B71AF99BAD00B5F7CC /* Products */; ProjectRef = 139105B61AF99BAD00B5F7CC /* RCTSettings.xcodeproj */; @@ -874,6 +912,20 @@ remoteRef = 7F906D961E6A1ADF00B8F49A /* PBXContainerItemProxy */; sourceTree = BUILT_PRODUCTS_DIR; }; + 7F63D2811E6C957C001FAE12 /* libRCTPushNotification.a */ = { + isa = PBXReferenceProxy; + fileType = archive.ar; + path = libRCTPushNotification.a; + remoteRef = 7F63D2801E6C957C001FAE12 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; + 7F63D2831E6C957C001FAE12 /* libRCTPushNotification-tvOS.a */ = { + isa = PBXReferenceProxy; + fileType = archive.ar; + path = "libRCTPushNotification-tvOS.a"; + remoteRef = 7F63D2821E6C957C001FAE12 /* PBXContainerItemProxy */; + sourceTree = BUILT_PRODUCTS_DIR; + }; 7FDF28E11E1F4B1F00DBBE56 /* libRNSVG.a */ = { isa = PBXReferenceProxy; fileType = archive.ar; @@ -988,7 +1040,6 @@ isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - DEVELOPMENT_TEAM = UQ8HT4Q2XM; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", @@ -1011,7 +1062,6 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; COPY_PHASE_STRIP = NO; - DEVELOPMENT_TEAM = UQ8HT4Q2XM; INFOPLIST_FILE = MattermostTests/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 8.0; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; @@ -1029,10 +1079,11 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CURRENT_PROJECT_VERSION = 12; DEAD_CODE_STRIPPING = NO; - DEVELOPMENT_TEAM = UQ8HT4Q2XM; + HEADER_SEARCH_PATHS = "$(SRCROOT)/../node_modules/react-native/Libraries/PushNotificationIOS/**"; INFOPLIST_FILE = Mattermost/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; OTHER_LDFLAGS = ( @@ -1052,10 +1103,11 @@ isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = Mattermost/Mattermost.entitlements; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CURRENT_PROJECT_VERSION = 12; DEAD_CODE_STRIPPING = NO; - DEVELOPMENT_TEAM = UQ8HT4Q2XM; + HEADER_SEARCH_PATHS = "$(SRCROOT)/../node_modules/react-native/Libraries/PushNotificationIOS/**"; INFOPLIST_FILE = Mattermost/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; OTHER_LDFLAGS = ( diff --git a/ios/Mattermost.xcodeproj/xcshareddata/xcschemes/Mattermost.xcscheme b/ios/Mattermost.xcodeproj/xcshareddata/xcschemes/Mattermost.xcscheme index d389d9b75..bc78e5103 100644 --- a/ios/Mattermost.xcodeproj/xcshareddata/xcschemes/Mattermost.xcscheme +++ b/ios/Mattermost.xcodeproj/xcshareddata/xcschemes/Mattermost.xcscheme @@ -80,7 +80,7 @@ #import @@ -34,4 +34,31 @@ return YES; } +// Required to register for notifications +- (void)application:(UIApplication *)application didRegisterUserNotificationSettings:(UIUserNotificationSettings *)notificationSettings +{ + [RCTPushNotificationManager didRegisterUserNotificationSettings:notificationSettings]; +} +// Required for the register event. +- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken +{ + [RCTPushNotificationManager didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; +} +// Required for the notification event. You must call the completion handler after handling the remote notification. +- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo +fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler +{ + [RCTPushNotificationManager didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler]; +} +// Required for the registrationError event. +- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error +{ + [RCTPushNotificationManager didFailToRegisterForRemoteNotificationsWithError:error]; +} +// Required for the localNotification event. +- (void)application:(UIApplication *)application didReceiveLocalNotification:(UILocalNotification *)notification +{ + [RCTPushNotificationManager didReceiveLocalNotification:notification]; +} + @end diff --git a/ios/Mattermost/Info.plist b/ios/Mattermost/Info.plist index 33fa1501d..f5c7e6318 100644 --- a/ios/Mattermost/Info.plist +++ b/ios/Mattermost/Info.plist @@ -20,10 +20,14 @@ ???? CFBundleVersion 12 + ITSAppUsesNonExemptEncryption + LSRequiresIPhoneOS NSAppTransportSecurity + NSAllowsArbitraryLoads + NSExceptionDomains localhost @@ -72,7 +76,5 @@ UIViewControllerBasedStatusBarAppearance - ITSAppUsesNonExemptEncryption - diff --git a/ios/Mattermost/Mattermost.entitlements b/ios/Mattermost/Mattermost.entitlements new file mode 100644 index 000000000..903def2af --- /dev/null +++ b/ios/Mattermost/Mattermost.entitlements @@ -0,0 +1,8 @@ + + + + + aps-environment + development + + diff --git a/package.json b/package.json index a3ce02678..08b624a29 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "react-native-drawer": "2.3.0", "react-native-keyboard-aware-scroll-view": "0.2.7", "react-native-keyboard-spacer": "0.3.1", + "react-native-message-bar": "1.6.0", + "react-native-push-notification": "2.2.1", "react-native-search-bar": "enahum/react-native-search-bar.git", "react-native-svg": "4.5.0", "react-native-vector-icons": "4.0.0", diff --git a/service/actions/general.js b/service/actions/general.js index f4c1c01ca..0b980e6d4 100644 --- a/service/actions/general.js +++ b/service/actions/general.js @@ -94,11 +94,18 @@ export function setServerVersion(serverVersion) { }; } +export function setDeviceToken(token) { + return async (dispatch, getState) => { + dispatch({type: GeneralTypes.RECEIVED_APP_DEVICE_TOKEN, data: token}, getState); + }; +} + export default { getPing, getClientConfig, getLicenseConfig, logClientError, setAppState, - setServerVersion + setServerVersion, + setDeviceToken }; diff --git a/service/actions/users.js b/service/actions/users.js index d192efeda..dea45322b 100644 --- a/service/actions/users.js +++ b/service/actions/users.js @@ -29,7 +29,9 @@ export function login(loginId, password, mfaToken = '') { return async (dispatch, getState) => { dispatch({type: UsersTypes.LOGIN_REQUEST}, getState); - return Client.login(loginId, password, mfaToken). + const deviceId = getState().entities.general.deviceToken; + + return Client.login(loginId, password, mfaToken, deviceId). then(async (data) => { let teamMembers; let preferences; @@ -101,6 +103,11 @@ export function loadMe() { return; } + const deviceId = getState().entities.general.deviceToken; + if (deviceId) { + Client.attachDevice(deviceId); + } + let preferences; dispatch({type: PreferencesTypes.MY_PREFERENCES_REQUEST}, getState); try { diff --git a/service/client/client.js b/service/client/client.js index 7cfdae539..4f3f92129 100644 --- a/service/client/client.js +++ b/service/client/client.js @@ -213,11 +213,12 @@ export default class Client { ); }; - login = async (loginId, password, token = '') => { + login = async (loginId, password, token = '', deviceId = '') => { const body = { login_id: loginId, password, - token + token, + device_id: deviceId }; const {headers, data} = await this.doFetchWithResponse( @@ -244,6 +245,13 @@ export default class Client { return response; }; + attachDevice = async (deviceId) => { + return this.doFetch( + `${this.getUsersRoute()}/attach_device`, + {method: 'post', body: JSON.stringify({device_id: deviceId})} + ); + }; + updateUser = async (user) => { return this.doFetch( `${this.getUsersRoute()}/update`, diff --git a/service/constants/constants.js b/service/constants/constants.js index 236953cc5..677611f87 100644 --- a/service/constants/constants.js +++ b/service/constants/constants.js @@ -39,7 +39,10 @@ const Constants = { START_OF_NEW_MESSAGES: 'start-of-new-messages', POST_HEADER_CHANGE: 'system_header_change', - POST_PURPOSE_CHANGE: 'system_purpose_change' + POST_PURPOSE_CHANGE: 'system_purpose_change', + + PUSH_NOTIFY_APPLE_REACT_NATIVE: 'apple_rn', + PUSH_NOTIFY_ANDROID_REACT_NATIVE: 'android_rn' }; const FileConstants = { diff --git a/service/constants/general.js b/service/constants/general.js index de40343da..be814a2f2 100644 --- a/service/constants/general.js +++ b/service/constants/general.js @@ -7,6 +7,7 @@ const GeneralTypes = keyMirror({ RECEIVED_APP_STATE: null, RECEIVED_APP_CREDENTIALS: null, REMOVED_APP_CREDENTIALS: null, + RECEIVED_APP_DEVICE_TOKEN: null, PING_REQUEST: null, PING_SUCCESS: null, diff --git a/service/reducers/entities/general.js b/service/reducers/entities/general.js index aa3967d84..1c34dd62f 100644 --- a/service/reducers/entities/general.js +++ b/service/reducers/entities/general.js @@ -59,10 +59,23 @@ function serverVersion(state = '', action) { } } +function deviceToken(state = '', action) { + switch (action.type) { + case GeneralTypes.RECEIVED_APP_DEVICE_TOKEN: + return action.data; + + case UsersTypes.LOGOUT_SUCCESS: + return ''; + default: + return state; + } +} + export default combineReducers({ appState, credentials, config, license, - serverVersion + serverVersion, + deviceToken });