From 26950abb615d837d891fa6895dc7901ed137af4d Mon Sep 17 00:00:00 2001 From: Chris Duarte Date: Tue, 31 Jul 2018 05:43:54 -0700 Subject: [PATCH] Add deep linking support (#1805) --- app/actions/views/root.js | 7 ++++++ app/app.js | 15 +++++++++++- .../markdown/markdown_link/markdown_link.js | 13 ++-------- app/components/post_list/index.js | 6 +++++ app/components/post_list/post_list.js | 24 ++++++++++++++++++- app/constants/view.js | 2 ++ app/initial_state.js | 1 + app/mattermost.js | 4 ++-- app/reducers/views/root.js | 13 ++++++++++ .../channel_post_list/channel_post_list.js | 13 ++++++++-- app/utils/url.js | 9 +++++++ ios/Mattermost/AppDelegate.m | 19 +++++++++++++++ 12 files changed, 109 insertions(+), 17 deletions(-) diff --git a/app/actions/views/root.js b/app/actions/views/root.js index 2de0d7164..3a831f54e 100644 --- a/app/actions/views/root.js +++ b/app/actions/views/root.js @@ -135,6 +135,13 @@ export function recordLoadTime(screenName, category) { }; } +export function setDeepLinkURL(url) { + return { + type: ViewTypes.SET_DEEP_LINK_URL, + url, + }; +} + export default { loadConfigAndLicense, loadFromPushNotification, diff --git a/app/app.js b/app/app.js index 58fd2b263..1096ec779 100644 --- a/app/app.js +++ b/app/app.js @@ -2,13 +2,14 @@ // See LICENSE.txt for license information. /* eslint-disable global-require*/ -import {AsyncStorage, NativeModules} from 'react-native'; +import {AsyncStorage, Linking, NativeModules} from 'react-native'; import {setGenericPassword, getGenericPassword, resetGenericPassword} from 'react-native-keychain'; import {loadMe} from 'mattermost-redux/actions/users'; import {Client4} from 'mattermost-redux/client'; import EventEmitter from 'mattermost-redux/utils/event_emitter'; +import {setDeepLinkURL} from 'app/actions/views/root'; import {ViewTypes} from 'app/constants'; import tracker from 'app/utils/time_tracker'; import {getCurrentLocale} from 'app/selectors/i18n'; @@ -50,6 +51,9 @@ export default class App { this.token = null; this.url = null; + // Usage deeplinking + Linking.addEventListener('url', this.handleDeepLink); + this.getStartupThemes(); this.getAppCredentials(); } @@ -223,6 +227,11 @@ export default class App { ]); }; + handleDeepLink = (event) => { + const {url} = event; + store.dispatch(setDeepLinkURL(url)); + } + launchApp = async () => { const shouldStart = await handleManagedConfig(); if (shouldStart) { @@ -237,6 +246,10 @@ export default class App { const {dispatch} = store; + Linking.getInitialURL().then((url) => { + dispatch(setDeepLinkURL(url)); + }); + let screen = 'SelectServer'; if (this.token && this.url) { screen = 'Channel'; diff --git a/app/components/markdown/markdown_link/markdown_link.js b/app/components/markdown/markdown_link/markdown_link.js index 16fd35b27..52867c020 100644 --- a/app/components/markdown/markdown_link/markdown_link.js +++ b/app/components/markdown/markdown_link/markdown_link.js @@ -12,9 +12,8 @@ import mattermostManaged from 'app/mattermost_managed'; import Config from 'assets/config'; -import {escapeRegex} from 'app/utils/markdown'; import {preventDoubleTap} from 'app/utils/tap'; -import {normalizeProtocol} from 'app/utils/url'; +import {matchPermalink, normalizeProtocol} from 'app/utils/url'; export default class MarkdownLink extends PureComponent { static propTypes = { @@ -43,7 +42,7 @@ export default class MarkdownLink extends PureComponent { return; } - const match = this.matchPermalink(url, serverURL) || this.matchPermalink(url, siteURL); + const match = matchPermalink(url, serverURL) || matchPermalink(url, siteURL); if (match) { const teamName = match[1]; @@ -58,14 +57,6 @@ export default class MarkdownLink extends PureComponent { } }); - matchPermalink = (link, rootURL) => { - if (!rootURL) { - return null; - } - - return new RegExp('^' + escapeRegex(rootURL) + '\\/([^\\/]+)\\/pl\\/(\\w+)').exec(link); - } - parseLinkLiteral = (literal) => { let nextLiteral = literal; diff --git a/app/components/post_list/index.js b/app/components/post_list/index.js index 4122ee46a..e7bfdde17 100644 --- a/app/components/post_list/index.js +++ b/app/components/post_list/index.js @@ -5,9 +5,11 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import {selectFocusedPostId} from 'mattermost-redux/actions/posts'; +import {getConfig, getCurrentUrl} from 'mattermost-redux/selectors/entities/general'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {loadChannelsByTeamName, refreshChannelWithRetry} from 'app/actions/views/channel'; +import {setDeepLinkURL} from 'app/actions/views/root'; import {makePreparePostIdsForPostList, START_OF_NEW_MESSAGES} from 'app/selectors/post_list'; import PostList from './post_list'; @@ -21,9 +23,12 @@ function makeMapStateToProps() { const {deviceHeight} = state.device.dimension; return { + deepLinkURL: state.views.root.deepLinkURL, deviceHeight, measureCellLayout, postIds, + serverURL: getCurrentUrl(state), + siteURL: getConfig(state).SiteURL, theme: getTheme(state), }; }; @@ -35,6 +40,7 @@ function mapDispatchToProps(dispatch) { loadChannelsByTeamName, refreshChannelWithRetry, selectFocusedPostId, + setDeepLinkURL, }, dispatch), }; } diff --git a/app/components/post_list/post_list.js b/app/components/post_list/post_list.js index 32994fc5d..af86fad6d 100644 --- a/app/components/post_list/post_list.js +++ b/app/components/post_list/post_list.js @@ -17,6 +17,7 @@ import {START_OF_NEW_MESSAGES} from 'app/selectors/post_list'; import mattermostManaged from 'app/mattermost_managed'; import {makeExtraData} from 'app/utils/list_view'; import {changeOpacity} from 'app/utils/theme'; +import {matchPermalink} from 'app/utils/url'; import DateHeader from './date_header'; import {isDateLine} from './date_header/utils'; @@ -35,9 +36,11 @@ export default class PostList extends PureComponent { loadChannelsByTeamName: PropTypes.func.isRequired, refreshChannelWithRetry: PropTypes.func.isRequired, selectFocusedPostId: PropTypes.func.isRequired, + setDeepLinkURL: PropTypes.func.isRequired, }).isRequired, channelId: PropTypes.string, currentUserId: PropTypes.string, + deepLinkURL: PropTypes.string, deviceHeight: PropTypes.number.isRequired, extraData: PropTypes.any, highlightPostId: PropTypes.string, @@ -54,7 +57,9 @@ export default class PostList extends PureComponent { postIds: PropTypes.array.isRequired, renderFooter: PropTypes.func, renderReplies: PropTypes.bool, + serverURL: PropTypes.string.isRequired, shouldRenderReplyButton: PropTypes.bool, + siteURL: PropTypes.string.isRequired, theme: PropTypes.object.isRequired, }; @@ -99,6 +104,11 @@ export default class PostList extends PureComponent { if ((this.props.measureCellLayout || this.props.isSearchResult) && this.state.scrollToMessage) { this.scrollListToMessageOffset(); } + + if (this.props.deepLinkURL) { + this.handleDeepLink(this.props.deepLinkURL); + this.props.actions.setDeepLinkURL(''); + } } componentWillUnmount() { @@ -112,6 +122,18 @@ export default class PostList extends PureComponent { this.showingPermalink = false; }; + handleDeepLink = (url) => { + const {serverURL, siteURL} = this.props; + + const match = matchPermalink(url, serverURL) || matchPermalink(url, siteURL); + + if (match) { + const teamName = match[1]; + const postId = match[2]; + this.handlePermalinkPress(postId, teamName); + } + }; + handlePermalinkPress = (postId, teamName) => { const {actions, onPermalinkPress} = this.props; @@ -179,7 +201,7 @@ export default class PostList extends PureComponent { scrollListToMessageOffset = () => { const index = this.moreNewMessages ? this.props.postIds.length - 1 : this.newMessagesIndex; if (index !== -1) { - let offset = this.getMeasurementOffset(index) - (3 * this.itemMeasurements[index]); + let offset = this.getMeasurementOffset(index) + this.itemMeasurements[index]; const windowHeight = this.state.postListHeight; if (offset < windowHeight) { diff --git a/app/constants/view.js b/app/constants/view.js index d66d8997d..6fb81576e 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -67,6 +67,8 @@ const ViewTypes = keyMirror({ LAUNCH_LOGIN: null, LAUNCH_CHANNEL: null, + + SET_DEEP_LINK_URL: null, }); export default { diff --git a/app/initial_state.js b/app/initial_state.js index cb8645330..e6a0e451b 100644 --- a/app/initial_state.js +++ b/app/initial_state.js @@ -271,6 +271,7 @@ const state = { password: '', }, root: { + deepLinkURL: '', hydrationComplete: false, purge: false, }, diff --git a/app/mattermost.js b/app/mattermost.js index fe6a5bf61..704a3b939 100644 --- a/app/mattermost.js +++ b/app/mattermost.js @@ -7,9 +7,9 @@ import { AppState, Dimensions, InteractionManager, - Platform, - NativeModules, Keyboard, + NativeModules, + Platform, } from 'react-native'; const {StatusBarManager, MattermostShare, Initialization} = NativeModules; diff --git a/app/reducers/views/root.js b/app/reducers/views/root.js index 6c000c4a3..e66408d2e 100644 --- a/app/reducers/views/root.js +++ b/app/reducers/views/root.js @@ -5,6 +5,18 @@ import {combineReducers} from 'redux'; import {General} from 'mattermost-redux/constants'; +import {ViewTypes} from 'app/constants'; + +function deepLinkURL(state = '', action) { + switch (action.type) { + case ViewTypes.SET_DEEP_LINK_URL: { + return action.url; + } + default: + return state; + } +} + function hydrationComplete(state = false, action) { switch (action.type) { case General.STORE_REHYDRATION_COMPLETE: @@ -24,6 +36,7 @@ function purge(state = false, action) { } export default combineReducers({ + deepLinkURL, hydrationComplete, purge, }); diff --git a/app/screens/channel/channel_post_list/channel_post_list.js b/app/screens/channel/channel_post_list/channel_post_list.js index a79f425c2..774ddb659 100644 --- a/app/screens/channel/channel_post_list/channel_post_list.js +++ b/app/screens/channel/channel_post_list/channel_post_list.js @@ -58,6 +58,15 @@ export default class ChannelPostList extends PureComponent { this.contentHeight = 0; } + componentDidMount() { + this.mounted = true; + InteractionManager.runAfterInteractions(() => { + if (this.mounted === true) { + this.setState({loading: false}); + } + }); + } + componentWillReceiveProps(nextProps) { const {postIds: nextPostIds} = nextProps; @@ -84,8 +93,8 @@ export default class ChannelPostList extends PureComponent { } } - componentDidMount() { - InteractionManager.runAfterInteractions(() => this.setState({loading: false})); + componentWillUnmount() { + this.mounted = false; } getVisiblePostIds = (props) => { diff --git a/app/utils/url.js b/app/utils/url.js index 02077fd72..d33fc118b 100644 --- a/app/utils/url.js +++ b/app/utils/url.js @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {latinise} from './latinise.js'; +import {escapeRegex} from './markdown'; import {Files} from 'mattermost-redux/constants'; @@ -95,6 +96,14 @@ export function getScheme(url) { return match && match[1]; } +export function matchPermalink(link, rootURL) { + if (!rootURL) { + return null; + } + + return new RegExp('^' + escapeRegex(rootURL) + '\\/([^\\/]+)\\/pl\\/(\\w+)').exec(link); +} + export function getYouTubeVideoId(link) { // https://youtube.com/watch?v= let match = (/youtube\.com\/watch\?\S*\bv=([a-zA-Z0-9_-]{6,11})/g).exec(link); diff --git a/ios/Mattermost/AppDelegate.m b/ios/Mattermost/AppDelegate.m index 8d929e9e1..9bc2aa240 100644 --- a/ios/Mattermost/AppDelegate.m +++ b/ios/Mattermost/AppDelegate.m @@ -11,6 +11,7 @@ #import #import #import +#import #if __has_include() #import // This is used for versions of react >= 0.40 #else @@ -95,4 +96,22 @@ [[SessionManager sharedSession] createSessionForRequestRequest:identifier]; } +// Required for deeplinking + +- (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