diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ec1522f83..58303e7b6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -33,11 +33,19 @@ android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenSize" + android:launchMode="singleTop" android:windowSoftInputMode="adjustResize"> + + + + + + + diff --git a/app/actions/actionsTypes.js b/app/actions/actionsTypes.js index c6cbe5daf..a125553a4 100644 --- a/app/actions/actionsTypes.js +++ b/app/actions/actionsTypes.js @@ -93,6 +93,7 @@ export const PINNED_MESSAGES = createRequestTypes('PINNED_MESSAGES', ['OPEN', 'R export const MENTIONED_MESSAGES = createRequestTypes('MENTIONED_MESSAGES', ['OPEN', 'READY', 'CLOSE', 'MESSAGES_RECEIVED']); export const SNIPPETED_MESSAGES = createRequestTypes('SNIPPETED_MESSAGES', ['OPEN', 'READY', 'CLOSE', 'MESSAGES_RECEIVED']); export const ROOM_FILES = createRequestTypes('ROOM_FILES', ['OPEN', 'READY', 'CLOSE', 'MESSAGES_RECEIVED']); +export const DEEP_LINKING = createRequestTypes('DEEP_LINKING', ['OPEN']); export const INCREMENT = 'INCREMENT'; export const DECREMENT = 'DECREMENT'; diff --git a/app/actions/deepLinking.js b/app/actions/deepLinking.js new file mode 100644 index 000000000..dfd540b6f --- /dev/null +++ b/app/actions/deepLinking.js @@ -0,0 +1,8 @@ +import * as types from './actionsTypes'; + +export function deepLinkingOpen(params) { + return { + type: types.DEEP_LINKING.OPEN, + params + }; +} diff --git a/app/containers/Routes.js b/app/containers/Routes.js index 067327970..bb20c90b8 100644 --- a/app/containers/Routes.js +++ b/app/containers/Routes.js @@ -1,13 +1,16 @@ import PropTypes from 'prop-types'; import React from 'react'; +import { Linking } from 'react-native'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import SplashScreen from 'react-native-splash-screen'; -import { appInit } from '../actions'; +import { appInit } from '../actions'; +import { deepLinkingOpen } from '../actions/deepLinking'; import AuthRoutes from './routes/AuthRoutes'; import PublicRoutes from './routes/PublicRoutes'; import * as NavigationService from './routes/NavigationService'; +import parseQuery from '../lib/methods/helpers/parseQuery'; @connect( state => ({ @@ -16,7 +19,7 @@ import * as NavigationService from './routes/NavigationService'; background: state.app.background }), dispatch => bindActionCreators({ - appInit + appInit, deepLinkingOpen }, dispatch) ) export default class Routes extends React.Component { @@ -26,11 +29,22 @@ export default class Routes extends React.Component { appInit: PropTypes.func.isRequired } + constructor(props) { + super(props); + this.handleOpenURL = this.handleOpenURL.bind(this); + } + componentDidMount() { if (this.props.app.ready) { return SplashScreen.hide(); } this.props.appInit(); + + Linking + .getInitialURL() + .then(url => this.handleOpenURL({ url })) + .catch(console.error); + Linking.addEventListener('url', this.handleOpenURL); } componentWillReceiveProps(nextProps) { @@ -43,6 +57,18 @@ export default class Routes extends React.Component { NavigationService.setNavigator(this.navigator); } + handleOpenURL({ url }) { + if (url) { + url = url.replace(/rocketchat:\/\/|https:\/\/go.rocket.chat\//, ''); + const regex = /^(room|auth)\?/; + if (url.match(regex)) { + url = url.replace(regex, ''); + const params = parseQuery(url); + this.props.deepLinkingOpen(params); + } + } + } + render() { const { login } = this.props; diff --git a/app/containers/routes/NavigationService.js b/app/containers/routes/NavigationService.js index 11de166e9..ad53eb8ed 100644 --- a/app/containers/routes/NavigationService.js +++ b/app/containers/routes/NavigationService.js @@ -34,7 +34,7 @@ export function goRoomsList() { export function goRoom({ rid, name }, counter = 0) { // about counter: we can call this method before navigator be set. so we have to wait, if we tried a lot, we give up ... - if (!rid || !name || counter > 10) { + if (!rid || counter > 10) { return; } if (!config.navigator) { diff --git a/app/lib/ddp.js b/app/lib/ddp.js index a6fd91c34..e42ec8666 100644 --- a/app/lib/ddp.js +++ b/app/lib/ddp.js @@ -224,6 +224,8 @@ export default class Socket extends EventEmitter { } disconnect() { this._close(); + this._login = null; + this.subscriptions = {}; } async reconnect() { if (this._timer) { diff --git a/app/lib/methods/canOpenRoom.js b/app/lib/methods/canOpenRoom.js new file mode 100644 index 000000000..a7625b97a --- /dev/null +++ b/app/lib/methods/canOpenRoom.js @@ -0,0 +1,51 @@ +import { post } from './helpers/rest'; +import database from '../realm'; + +// TODO: api fix +const ddpTypes = { + channel: 'c', direct: 'd', group: 'p' +}; +const restTypes = { + channel: 'channels', direct: 'im', group: 'groups' +}; + +async function canOpenRoomREST({ type, rid }) { + try { + const { token, id } = this.ddp._login; + const server = this.ddp.url.replace('ws', 'http'); + await post({ token, id, server }, `${ restTypes[type] }.open`, { roomId: rid }); + return true; + } catch (error) { + // TODO: workround for 'already open for the sender' error + if (!error.errorType) { + return true; + } + return false; + } +} + +async function canOpenRoomDDP(...args) { + try { + const [{ type, name }] = args; + await this.ddp.call('getRoomByTypeAndName', ddpTypes[type], name); + return true; + } catch (error) { + if (error.isClientSafe) { + return false; + } + return canOpenRoomREST.call(this, ...args); + } +} + +export default async function canOpenRoom({ rid, path }) { + const { database: db } = database; + const room = db.objects('subscriptions').filtered('rid == $0', rid); + if (room.length) { + return true; + } + + const [type, name] = path.split('/'); + // eslint-disable-next-line + const data = await (this.ddp && this.ddp.status ? canOpenRoomDDP.call(this, { rid, type, name }) : canOpenRoomREST.call(this, { type, rid })); + return data; +} diff --git a/app/lib/methods/helpers/parseQuery.js b/app/lib/methods/helpers/parseQuery.js new file mode 100644 index 000000000..d60610e8a --- /dev/null +++ b/app/lib/methods/helpers/parseQuery.js @@ -0,0 +1,9 @@ +export default function(query) { + return (/^[?#]/.test(query) ? query.slice(1) : query) + .split('&') + .reduce((params, param) => { + const [key, value] = param.split('='); + params[key] = value ? decodeURIComponent(value.replace(/\+/g, ' ')) : ''; + return params; + }, { }); +} diff --git a/app/lib/methods/subscriptions/room.js b/app/lib/methods/subscriptions/room.js index 6c045e6b8..b54d0091a 100644 --- a/app/lib/methods/subscriptions/room.js +++ b/app/lib/methods/subscriptions/room.js @@ -21,8 +21,10 @@ const stop = (ddp) => { promises = false; } - ddp.removeListener('logged', logged); - ddp.removeListener('disconnected', disconnected); + if (ddp) { + ddp.removeListener('logged', logged); + ddp.removeListener('disconnected', disconnected); + } logged = false; disconnected = false; @@ -50,18 +52,21 @@ export default async function subscribeRoom({ rid, t }) { }, 5000); }; - - logged = this.ddp.on('logged', () => { - clearTimeout(timer); - timer = false; - promises = subscribe(this.ddp, rid); - }); - - disconnected = this.ddp.on('disconnected', () => { loop(); }); - - if (!this.ddp.status) { + if (!this.ddp || !this.ddp.status) { loop(); } else { + logged = this.ddp.on('logged', () => { + clearTimeout(timer); + timer = false; + promises = subscribe(this.ddp, rid); + }); + + disconnected = this.ddp.on('disconnected', () => { + if (this._login) { + loop(); + } + }); + promises = subscribe(this.ddp, rid); } diff --git a/app/lib/methods/subscriptions/rooms.js b/app/lib/methods/subscriptions/rooms.js index 4302568b2..8ac87806e 100644 --- a/app/lib/methods/subscriptions/rooms.js +++ b/app/lib/methods/subscriptions/rooms.js @@ -24,35 +24,41 @@ export default async function subscribeRooms(id) { }, 5000); }; - this.ddp.on('logged', () => { - clearTimeout(timer); - timer = false; - }); + if (this.ddp) { + this.ddp.on('logged', () => { + clearTimeout(timer); + timer = false; + }); - this.ddp.on('logout', () => { - clearTimeout(timer); - timer = true; - }); + this.ddp.on('logout', () => { + clearTimeout(timer); + timer = true; + }); - this.ddp.on('disconnected', () => { loop(); }); + this.ddp.on('disconnected', () => { + if (this._login) { + loop(); + } + }); - this.ddp.on('stream-notify-user', (ddpMessage) => { - const [type, data] = ddpMessage.fields.args; - const [, ev] = ddpMessage.fields.eventName.split('/'); - if (/subscriptions/.test(ev)) { - const tpm = merge(data); - return database.write(() => { - database.create('subscriptions', tpm, true); - }); - } - if (/rooms/.test(ev) && type === 'updated') { - const [sub] = database.objects('subscriptions').filtered('rid == $0', data._id); - database.write(() => { - merge(sub, data); - }); - } - }); + this.ddp.on('stream-notify-user', (ddpMessage) => { + const [type, data] = ddpMessage.fields.args; + const [, ev] = ddpMessage.fields.eventName.split('/'); + if (/subscriptions/.test(ev)) { + const tpm = merge(data); + return database.write(() => { + database.create('subscriptions', tpm, true); + }); + } + if (/rooms/.test(ev) && type === 'updated') { + const [sub] = database.objects('subscriptions').filtered('rid == $0', data._id); + database.write(() => { + merge(sub, data); + }); + } + }); + } await subscriptions; - console.log(this.ddp.subscriptions); + // console.log(this.ddp.subscriptions); } diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 834c9dd98..c8f08750f 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -35,7 +35,7 @@ import getSettings from './methods/getSettings'; import getRooms from './methods/getRooms'; import getPermissions from './methods/getPermissions'; import getCustomEmoji from './methods/getCustomEmojis'; - +import canOpenRoom from './methods/canOpenRoom'; import _buildMessage from './methods/helpers/buildMessage'; import loadMessagesForRoom from './methods/loadMessagesForRoom'; @@ -51,6 +51,7 @@ const RocketChat = { TOKEN_KEY, subscribeRooms, subscribeRoom, + canOpenRoom, createChannel({ name, users, type }) { return call(type ? 'createChannel' : 'createPrivateGroup', name, users, type); }, diff --git a/app/sagas/connect.js b/app/sagas/connect.js index 7c494275d..e7495efd6 100644 --- a/app/sagas/connect.js +++ b/app/sagas/connect.js @@ -17,7 +17,10 @@ const getToken = function* getToken() { } return JSON.parse(user); } - return yield put(setToken()); + + yield AsyncStorage.removeItem(RocketChat.TOKEN_KEY); + yield put(setToken()); + return null; }; diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js new file mode 100644 index 000000000..59b3e7d2f --- /dev/null +++ b/app/sagas/deepLinking.js @@ -0,0 +1,57 @@ +import { AsyncStorage } from 'react-native'; +import { delay } from 'redux-saga'; +import { takeLatest, take, select, call, put } from 'redux-saga/effects'; +import * as types from '../actions/actionsTypes'; +import { setServer, addServer } from '../actions/server'; +import * as NavigationService from '../containers/routes/NavigationService'; +import database from '../lib/realm'; +import RocketChat from '../lib/rocketchat'; + +const navigate = function* go({ server, params, sameServer = true }) { + const user = yield AsyncStorage.getItem(`${ RocketChat.TOKEN_KEY }-${ server }`); + if (user) { + const { rid, path } = params; + if (rid) { + const canOpenRoom = yield RocketChat.canOpenRoom({ rid, path }); + if (canOpenRoom) { + return yield call(NavigationService.goRoom, { rid: params.rid }); + } + } + if (!sameServer) { + yield call(NavigationService.goRoomsList); + } + } +}; + +const handleOpen = function* handleOpen({ params }) { + const isReady = yield select(state => state.app.ready); + const server = yield select(state => state.server.server); + + if (!isReady) { + yield take(types.APP.READY); + } + + const host = `https://${ params.host }`; + + // TODO: needs better test + // if deep link is from same server + if (server === host) { + yield navigate({ server, params }); + } else { // if deep link is from a different server + // search if deep link's server already exists + const servers = yield database.databases.serversDB.objects('servers').filtered('id = $0', host); // TODO: need better test + if (servers.length) { + // if server exists, select it + yield put(setServer(servers[0].id)); + yield delay(2000); + yield navigate({ server: servers[0].id, params, sameServer: false }); + } else { + yield put(addServer(host)); + } + } +}; + +const root = function* root() { + yield takeLatest(types.DEEP_LINKING.OPEN, handleOpen); +}; +export default root; diff --git a/app/sagas/index.js b/app/sagas/index.js index 6489d7a32..7547ed382 100644 --- a/app/sagas/index.js +++ b/app/sagas/index.js @@ -12,6 +12,7 @@ import pinnedMessages from './pinnedMessages'; import mentionedMessages from './mentionedMessages'; import snippetedMessages from './snippetedMessages'; import roomFiles from './roomFiles'; +import deepLinking from './deepLinking'; const root = function* root() { yield all([ @@ -27,7 +28,8 @@ const root = function* root() { pinnedMessages(), mentionedMessages(), snippetedMessages(), - roomFiles() + roomFiles(), + deepLinking() ]); }; diff --git a/app/sagas/selectServer.js b/app/sagas/selectServer.js index 6aaf7ae3f..8513138fb 100644 --- a/app/sagas/selectServer.js +++ b/app/sagas/selectServer.js @@ -1,14 +1,14 @@ -import { put, call, takeLatest, race, take } from 'redux-saga/effects'; +import { put, call, takeLatest, take } from 'redux-saga/effects'; import { delay } from 'redux-saga'; import { AsyncStorage } from 'react-native'; -import { SERVER } from '../actions/actionsTypes'; +import { SERVER, LOGIN } from '../actions/actionsTypes'; import * as actions from '../actions'; import { connectRequest } from '../actions/connect'; -import { serverSuccess, serverFailure, serverRequest, setServer } from '../actions/server'; +import { serverSuccess, serverFailure, setServer } from '../actions/server'; import { setRoles } from '../actions/roles'; import RocketChat from '../lib/rocketchat'; import database from '../lib/realm'; -import * as NavigationService from '../containers/routes/NavigationService'; +import { navigate } from '../containers/routes/NavigationService'; const validate = function* validate(server) { return yield RocketChat.testServer(server); @@ -21,6 +21,7 @@ const selectServer = function* selectServer({ server }) { // yield RocketChat.disconnect(); yield call([AsyncStorage, 'setItem'], 'currentServer', server); + // yield AsyncStorage.removeItem(RocketChat.TOKEN_KEY); const settings = database.objects('settings'); yield put(actions.setAllSettings(RocketChat.parseSettings(settings.slice(0, settings.length)))); const permissions = database.objects('permissions'); @@ -33,7 +34,7 @@ const selectServer = function* selectServer({ server }) { return result; }, {}))); - yield put(connectRequest(server)); + yield put(connectRequest()); } catch (e) { console.warn('selectServer', e); } @@ -51,23 +52,17 @@ const validateServer = function* validateServer({ server }) { }; const addServer = function* addServer({ server }) { - yield put(serverRequest(server)); - - const { error } = yield race({ - error: take(SERVER.FAILURE), - success: take(SERVER.SUCCESS) + database.databases.serversDB.write(() => { + database.databases.serversDB.create('servers', { id: server, current: false }, true); }); - if (!error) { - database.databases.serversDB.write(() => { - database.databases.serversDB.create('servers', { id: server, current: false }, true); - }); - yield put(setServer(server)); - } + yield put(setServer(server)); + yield take(LOGIN.SET_TOKEN); + navigate('LoginSignup'); }; const handleGotoAddServer = function* handleGotoAddServer() { yield call(AsyncStorage.removeItem, RocketChat.TOKEN_KEY); - yield call(NavigationService.navigate, 'AddServer'); + yield call(navigate, 'AddServer'); }; const root = function* root() { diff --git a/app/views/NewServerView.js b/app/views/NewServerView.js index 86e2bc579..8d536b48a 100644 --- a/app/views/NewServerView.js +++ b/app/views/NewServerView.js @@ -49,7 +49,6 @@ export default class NewServerView extends LoggedView { submit = () => { this.props.addServer(this.completeUrl(this.state.text)); - this.props.navigation.navigate('LoginSignup'); } completeUrl = (url) => { diff --git a/app/views/RoomView/Header/index.js b/app/views/RoomView/Header/index.js index 7d21e00c0..d7cb05cba 100644 --- a/app/views/RoomView/Header/index.js +++ b/app/views/RoomView/Header/index.js @@ -54,16 +54,22 @@ export default class RoomHeaderView extends React.PureComponent { constructor(props) { super(props); this.state = { - room: realm.objects('subscriptions').filtered('rid = $0', this.rid)[0] || {}, - roomName: props.navigation.state.params.room.name + room: props.navigation.state.params.room }; - this.rid = props.navigation.state.params.room.rid; - this.room = realm.objects('subscriptions').filtered('rid = $0', this.rid); - this.room.addListener(this.updateState); + this.room = realm.objects('subscriptions').filtered('rid = $0', this.state.room.rid); } componentDidMount() { this.updateState(); + this.room.addListener(this.updateState); + } + + componentWillReceiveProps(nextProps) { + if (nextProps.navigation.state.params.room !== this.props.navigation.state.params.room) { + this.room.removeAllListeners(); + this.room = realm.objects('subscriptions').filtered('rid = $0', nextProps.navigation.state.params.room.rid); + this.room.addListener(this.updateState); + } } componentWillUnmount() { @@ -71,7 +77,7 @@ export default class RoomHeaderView extends React.PureComponent { } getUserStatus() { - const userId = this.rid.replace(this.props.user.id, '').trim(); + const userId = this.state.room.rid.replace(this.props.user.id, '').trim(); const userInfo = this.props.activeUsers[userId]; return (userInfo && userInfo.status) || 'offline'; } @@ -82,7 +88,9 @@ export default class RoomHeaderView extends React.PureComponent { } updateState = () => { - this.setState({ room: this.room[0] }); + if (this.room.length > 0) { + this.setState({ room: this.room[0] }); + } }; isDirect = () => this.state.room && this.state.room.t === 'd'; @@ -98,11 +106,11 @@ export default class RoomHeaderView extends React.PureComponent { />); renderCenter() { - if (!this.state.roomName) { + if (!this.state.room.name) { return null; } - let accessibilityLabel = this.state.roomName; + let accessibilityLabel = this.state.room.name; if (this.isDirect()) { accessibilityLabel += `, ${ this.getUserStatusLabel() }`; @@ -125,11 +133,11 @@ export default class RoomHeaderView extends React.PureComponent { style={styles.titleContainer} accessibilityLabel={accessibilityLabel} accessibilityTraits='header' - onPress={() => this.props.navigation.navigate({ key: 'RoomInfo', routeName: 'RoomInfo', params: { rid: this.rid } })} + onPress={() => this.props.navigation.navigate({ key: 'RoomInfo', routeName: 'RoomInfo', params: { rid: this.state.rid } })} > - {this.state.roomName} + {this.state.room.name} { t && {t}} diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index 8b0eab1b0..29f481c09 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -65,22 +65,17 @@ export default class RoomView extends LoggedView { this.rid = props.rid || props.navigation.state.params.room.rid; - this.name = props.name || - props.navigation.state.params.name || - props.navigation.state.params.room.name; this.rooms = database.objects('subscriptions').filtered('rid = $0', this.rid); this.state = { loaded: true, joined: typeof props.rid === 'undefined', - room: JSON.parse(JSON.stringify(this.rooms[0])) + room: {} }; this.onReactionPress = this.onReactionPress.bind(this); } async componentDidMount() { - this.props.navigation.setParams({ - title: this.name - }); + await this.updateRoom(); await this.props.openRoom({ ...this.state.room }); @@ -89,7 +84,6 @@ export default class RoomView extends LoggedView { } else { this.props.setLastOpen(null); } - this.rooms.addListener(this.updateRoom); } shouldComponentUpdate(nextProps, nextState) { @@ -129,8 +123,10 @@ export default class RoomView extends LoggedView { RocketChat.setReaction(shortname, messageId); }; - updateRoom = () => { - this.setState({ room: JSON.parse(JSON.stringify(this.rooms[0])) }); + updateRoom = async() => { + if (this.rooms.length > 0) { + await this.setState({ room: JSON.parse(JSON.stringify(this.rooms[0])) }); + } } sendMessage = (message) => { diff --git a/app/views/RoomsListView/index.js b/app/views/RoomsListView/index.js index 52949b126..776b4a5ea 100644 --- a/app/views/RoomsListView/index.js +++ b/app/views/RoomsListView/index.js @@ -123,13 +123,17 @@ export default class RoomsListView extends LoggedView { } _onPressItem = async(item = {}) => { - // if user is using the search we need first to join/create room if (!item.search) { - return this.props.navigation.navigate({ key: `Room-${ item._id }`, routeName: 'Room', params: { room: item, ...item } }); + return goRoom({ rid: item.rid }); } if (item.t === 'd') { - const sub = await RocketChat.createDirectMessageAndWait(item.username); - return goRoom({ room: sub, name: sub.name }); + // if user is using the search we need first to join/create room + try { + const sub = await RocketChat.createDirectMessage(item.username); + return goRoom(sub); + } catch (error) { + console.warn('_onPressItem', error); + } } return goRoom(item); } diff --git a/ios/RocketChatRN/AppDelegate.m b/ios/RocketChatRN/AppDelegate.m index d95a78284..99fba08a3 100644 --- a/ios/RocketChatRN/AppDelegate.m +++ b/ios/RocketChatRN/AppDelegate.m @@ -15,6 +15,7 @@ #import "SplashScreen.h" #import #import +#import @implementation AppDelegate @@ -70,4 +71,20 @@ { [RCTPushNotificationManager didReceiveLocalNotification:notification]; } + +- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url + sourceApplication:(NSString *)sourceApplication annotation:(id)annotation +{ + return [RCTLinkingManager application:application openURL:url + sourceApplication:sourceApplication annotation:annotation]; +} + +// Only if your app is using [Universal Links](https://developer.apple.com/library/prerelease/ios/documentation/General/Conceptual/AppSearch/UniversalLinks.html). +- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity + restorationHandler:(void (^)(NSArray * _Nullable))restorationHandler +{ + return [RCTLinkingManager application:application + continueUserActivity:userActivity + restorationHandler:restorationHandler]; +} @end diff --git a/ios/RocketChatRN/Info.plist b/ios/RocketChatRN/Info.plist index 18e208096..accf9760b 100644 --- a/ios/RocketChatRN/Info.plist +++ b/ios/RocketChatRN/Info.plist @@ -20,6 +20,20 @@ 1.0.0 CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + rocketchat + CFBundleURLSchemes + + rocketchat + https://go.rocket.chat + + + CFBundleVersion 100 Fabric