From e294b07418dced6b79dd5eb47743a76c3d7e8709 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Fri, 16 Dec 2022 18:57:15 +0200 Subject: [PATCH] Add DeepLink support (#6869) --- app/actions/remote/channel.ts | 5 +- app/actions/remote/command.ts | 69 +------ app/actions/remote/permalink.ts | 7 +- .../markdown/markdown_link/markdown_link.tsx | 46 ++--- app/components/post_list/post/post.tsx | 2 +- app/constants/deep_linking.ts | 1 + app/constants/launch.ts | 1 + app/init/launch.ts | 76 ++------ app/managers/global_event_handler.ts | 5 +- app/managers/session_manager.ts | 4 +- .../components/options/logout/index.tsx | 3 + .../servers_list/server_item/server_item.tsx | 6 +- app/screens/home/index.tsx | 24 ++- .../option_menus/option_menus.tsx | 2 +- app/screens/login/index.tsx | 1 + app/screens/navigation.ts | 5 +- app/screens/onboarding/index.tsx | 3 +- app/screens/permalink/permalink.tsx | 3 +- app/screens/server/index.tsx | 9 +- .../options/open_in_channel_option.tsx | 2 +- app/utils/deep_link/index.ts | 183 ++++++++++++++++++ app/utils/server/index.ts | 6 +- app/utils/url/index.ts | 65 +------ app/utils/url/test.ts | 18 +- types/launch/index.ts | 1 + 25 files changed, 295 insertions(+), 252 deletions(-) create mode 100644 app/utils/deep_link/index.ts diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index 8d8085da07..780ebf190d 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -10,7 +10,7 @@ import {addChannelToDefaultCategory, storeCategories} from '@actions/local/categ import {removeCurrentUserFromChannel, setChannelDeleteAt, storeMyChannelsForTeam, switchToChannel} from '@actions/local/channel'; import {switchToGlobalThreads} from '@actions/local/thread'; import {loadCallForChannel} from '@calls/actions/calls'; -import {Events, General, Preferences, Screens} from '@constants'; +import {DeepLink, Events, General, Preferences, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {privateChannelJoinPrompt} from '@helpers/api/channel'; import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; @@ -28,7 +28,6 @@ import {generateChannelNameFromDisplayName, getDirectChannelName, isDMorGM} from import {isTablet} from '@utils/helpers'; import {logDebug, logError, logInfo} from '@utils/log'; import {showMuteChannelSnackbar} from '@utils/snack_bar'; -import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import {displayGroupMessageName, displayUsername} from '@utils/user'; import {fetchGroupsForChannelIfConstrained} from './groups'; @@ -655,7 +654,7 @@ export async function switchToChannelByName(serverUrl: string, channelName: stri let joinedTeam = false; let teamId = ''; try { - if (teamName === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) { + if (teamName === DeepLink.Redirect) { teamId = await getCurrentTeamId(database); } else { const team = await getTeamByName(database, teamName); diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts index 4cb475925f..6ecd317fe3 100644 --- a/app/actions/remote/command.ts +++ b/app/actions/remote/command.ts @@ -5,26 +5,18 @@ import {IntlShape} from 'react-intl'; import {Alert} from 'react-native'; import {doAppSubmit, postEphemeralCallResponseForCommandArgs} from '@actions/remote/apps'; -import {showPermalink} from '@actions/remote/permalink'; import {Client} from '@client/rest'; import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser'; import {AppCallResponseTypes} from '@constants/apps'; -import DeepLinkType from '@constants/deep_linking'; import DatabaseManager from '@database/manager'; import AppsManager from '@managers/apps_manager'; import IntegrationsManager from '@managers/integrations_manager'; import NetworkManager from '@managers/network_manager'; import {getChannelById} from '@queries/servers/channel'; import {getConfig, getCurrentTeamId} from '@queries/servers/system'; -import {getTeammateNameDisplay, queryUsersByUsername} from '@queries/servers/user'; -import {showAppForm, showModal} from '@screens/navigation'; -import * as DraftUtils from '@utils/draft'; -import {matchDeepLink, tryOpenURL} from '@utils/url'; -import {displayUsername} from '@utils/user'; - -import {makeDirectChannel, switchToChannelById, switchToChannelByName} from './channel'; - -import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkDM, DeepLinkGM, DeepLinkPlugin} from '@typings/launch'; +import {showAppForm} from '@screens/navigation'; +import {handleDeepLink, matchDeepLink} from '@utils/deep_link'; +import {tryOpenURL} from '@utils/url'; export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string): Promise<{data?: CommandResponse; error?: string | {message: string}}> => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; @@ -144,60 +136,9 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc const config = await getConfig(database); const match = matchDeepLink(location, serverUrl, config?.SiteURL); - let linkServerUrl: string | undefined; - if (match?.data?.serverUrl) { - linkServerUrl = DatabaseManager.searchUrl(match.data.serverUrl); - } - if (match && linkServerUrl) { - switch (match.type) { - case DeepLinkType.Channel: { - const data = match.data as DeepLinkChannel; - switchToChannelByName(linkServerUrl, data.channelName, data.teamName, DraftUtils.errorBadChannel, intl); - break; - } - case DeepLinkType.Permalink: { - const data = match.data as DeepLinkPermalink; - showPermalink(linkServerUrl, data.teamName, data.postId, intl); - break; - } - case DeepLinkType.DirectMessage: { - const data = match.data as DeepLinkDM; - if (!data.userName) { - DraftUtils.errorUnkownUser(intl); - return {data: false}; - } - - if (data.serverUrl !== serverUrl) { - if (!database) { - return {error: `${serverUrl} database not found`}; - } - } - const user = (await queryUsersByUsername(database, [data.userName]).fetch())[0]; - if (!user) { - DraftUtils.errorUnkownUser(intl); - return {data: false}; - } - - makeDirectChannel(linkServerUrl, user.id, displayUsername(user, intl.locale, await getTeammateNameDisplay(database)), true); - break; - } - case DeepLinkType.GroupMessage: { - const data = match.data as DeepLinkGM; - if (!data.channelId) { - DraftUtils.errorBadChannel(intl); - return {data: false}; - } - - switchToChannelById(linkServerUrl, data.channelId); - break; - } - case DeepLinkType.Plugin: { - const data = match.data as DeepLinkPlugin; - showModal('PluginInternal', data.id, {link: location}); - break; - } - } + if (match) { + handleDeepLink(location, intl, location); } else { const {formatMessage} = intl; const onError = () => Alert.alert( diff --git a/app/actions/remote/permalink.ts b/app/actions/remote/permalink.ts index 19aa9b3b42..19ef622ccb 100644 --- a/app/actions/remote/permalink.ts +++ b/app/actions/remote/permalink.ts @@ -1,15 +1,14 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {DeepLink} from '@constants'; import DatabaseManager from '@database/manager'; import {getCurrentTeam} from '@queries/servers/team'; import {displayPermalink} from '@utils/permalink'; -import {PERMALINK_GENERIC_TEAM_NAME_REDIRECT} from '@utils/url'; import type TeamModel from '@typings/database/models/servers/team'; -import type {IntlShape} from 'react-intl'; -export const showPermalink = async (serverUrl: string, teamName: string, postId: string, intl: IntlShape, openAsPermalink = true) => { +export const showPermalink = async (serverUrl: string, teamName: string, postId: string, openAsPermalink = true) => { const database = DatabaseManager.serverDatabases[serverUrl]?.database; if (!database) { return {error: `${serverUrl} database not found`}; @@ -18,7 +17,7 @@ export const showPermalink = async (serverUrl: string, teamName: string, postId: try { let name = teamName; let team: TeamModel | undefined; - if (!name || name === PERMALINK_GENERIC_TEAM_NAME_REDIRECT) { + if (!name || name === DeepLink.Redirect) { team = await getCurrentTeam(database); if (team) { name = team.name; diff --git a/app/components/markdown/markdown_link/markdown_link.tsx b/app/components/markdown/markdown_link/markdown_link.tsx index 1189b4a92f..9bab60b803 100644 --- a/app/components/markdown/markdown_link/markdown_link.tsx +++ b/app/components/markdown/markdown_link/markdown_link.tsx @@ -9,19 +9,14 @@ import {Alert, StyleSheet, Text, View} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import urlParse from 'url-parse'; -import {switchToChannelByName} from '@actions/remote/channel'; -import {showPermalink} from '@actions/remote/permalink'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; -import DeepLinkType from '@constants/deep_linking'; import {useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {bottomSheet, dismissBottomSheet} from '@screens/navigation'; -import {errorBadChannel} from '@utils/draft'; +import {handleDeepLink, matchDeepLink} from '@utils/deep_link'; import {bottomSheetSnapPoint} from '@utils/helpers'; import {preventDoubleTap} from '@utils/tap'; -import {matchDeepLink, normalizeProtocol, tryOpenURL} from '@utils/url'; - -import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkWithData} from '@typings/launch'; +import {normalizeProtocol, tryOpenURL} from '@utils/url'; type MarkdownLinkProps = { children: ReactElement; @@ -65,28 +60,27 @@ const MarkdownLink = ({children, experimentalNormalizeMarkdownLinks, href, siteU return; } - const match: DeepLinkWithData | null = matchDeepLink(url, serverUrl, siteURL); + const onError = () => { + Alert.alert( + formatMessage({ + id: 'mobile.link.error.title', + defaultMessage: 'Error', + }), + formatMessage({ + id: 'mobile.link.error.text', + defaultMessage: 'Unable to open the link.', + }), + ); + }; - if (match && match.data?.teamName) { - if (match.type === DeepLinkType.Channel) { - await switchToChannelByName(serverUrl, (match?.data as DeepLinkChannel).channelName, match.data?.teamName, errorBadChannel, intl); - } else if (match.type === DeepLinkType.Permalink) { - showPermalink(serverUrl, match.data.teamName, (match.data as DeepLinkPermalink).postId, intl); + const match = matchDeepLink(url, serverUrl, siteURL); + + if (match) { + const {error} = await handleDeepLink(url, intl); + if (error) { + tryOpenURL(url, onError); } } else { - const onError = () => { - Alert.alert( - formatMessage({ - id: 'mobile.link.error.title', - defaultMessage: 'Error', - }), - formatMessage({ - id: 'mobile.link.error.text', - defaultMessage: 'Unable to open the link.', - }), - ); - }; - tryOpenURL(url, onError); } }), [href, intl.locale, serverUrl, siteURL]); diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index a3a812483f..a1e6f1107a 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -139,7 +139,7 @@ const Post = ({ const handlePostPress = () => { if ([Screens.SAVED_MESSAGES, Screens.MENTIONS, Screens.SEARCH, Screens.PINNED_MESSAGES].includes(location)) { - showPermalink(serverUrl, '', post.id, intl); + showPermalink(serverUrl, '', post.id); return; } diff --git a/app/constants/deep_linking.ts b/app/constants/deep_linking.ts index 61ffa56cb4..f2090831c4 100644 --- a/app/constants/deep_linking.ts +++ b/app/constants/deep_linking.ts @@ -8,6 +8,7 @@ const DeepLinkType = { Invalid: 'invalid', Permalink: 'permalink', Plugin: 'plugin', + Redirect: '_redirect', } as const; export default DeepLinkType; diff --git a/app/constants/launch.ts b/app/constants/launch.ts index d31bc52bcf..696785c528 100644 --- a/app/constants/launch.ts +++ b/app/constants/launch.ts @@ -3,6 +3,7 @@ const LaunchType = { AddServer: 'add-server', + AddServerFromDeepLink: 'add-server-deeplink', Normal: 'normal', DeepLink: 'deeplink', Notification: 'notification', diff --git a/app/init/launch.ts b/app/init/launch.ts index 885705374c..fe8484b990 100644 --- a/app/init/launch.ts +++ b/app/init/launch.ts @@ -7,20 +7,20 @@ import {Notifications} from 'react-native-notifications'; import {appEntry, pushNotificationEntry, upgradeEntry} from '@actions/remote/entry'; import LocalConfig from '@assets/config.json'; -import {Screens, DeepLink, Events, Launch, PushNotification} from '@constants'; +import {DeepLink, Events, Launch, PushNotification} from '@constants'; import DatabaseManager from '@database/manager'; import {getActiveServerUrl, getServerCredentials, removeServerCredentials} from '@init/credentials'; import {getOnboardingViewed} from '@queries/app/global'; import {getThemeForCurrentTeam} from '@queries/servers/preference'; import {getCurrentUserId} from '@queries/servers/system'; import {queryMyTeams} from '@queries/servers/team'; -import {goToScreen, resetToHome, resetToSelectServer, resetToTeams, resetToOnboarding} from '@screens/navigation'; +import {resetToHome, resetToSelectServer, resetToTeams, resetToOnboarding} from '@screens/navigation'; import EphemeralStore from '@store/ephemeral_store'; +import {getLaunchPropsFromDeepLink} from '@utils/deep_link'; import {logInfo} from '@utils/log'; import {convertToNotificationData} from '@utils/notification'; -import {parseDeepLink} from '@utils/url'; -import type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkWithData, LaunchProps} from '@typings/launch'; +import type {DeepLinkWithData, LaunchProps} from '@typings/launch'; const initialNotificationTypes = [PushNotification.NOTIFICATION_TYPE.MESSAGE, PushNotification.NOTIFICATION_TYPE.SESSION]; @@ -67,13 +67,18 @@ const launchAppFromNotification = async (notification: NotificationWithData, col * @returns a redirection to a screen, either onboarding, add_server, login or home depending on the scenario */ -const launchApp = async (props: LaunchProps, resetNavigation = true) => { +const launchApp = async (props: LaunchProps) => { let serverUrl: string | undefined; switch (props?.launchType) { case Launch.DeepLink: if (props.extra?.type !== DeepLink.Invalid) { const extra = props.extra as DeepLinkWithData; - serverUrl = extra.data?.serverUrl; + const existingServer = DatabaseManager.searchUrl(extra.data!.serverUrl); + serverUrl = existingServer; + props.serverUrl = serverUrl || extra.data?.serverUrl; + if (!serverUrl) { + props.launchError = true; + } } break; case Launch.Notification: { @@ -142,17 +147,17 @@ const launchApp = async (props: LaunchProps, resetNavigation = true) => { return resetToOnboarding(props); } - return launchToServer(props, resetNavigation); + return resetToSelectServer(props); }; const launchToHome = async (props: LaunchProps) => { let openPushNotification = false; switch (props.launchType) { - case Launch.DeepLink: - // TODO: - // deepLinkEntry({props.serverUrl, props.extra}); + case Launch.DeepLink: { + appEntry(props.serverUrl!); break; + } case Launch.Notification: { const extra = props.extra as NotificationWithData; openPushNotification = Boolean(props.serverUrl && !props.launchError && extra.userInteraction && extra.payload?.channel_id && !extra.payload?.userInfo?.local); @@ -185,55 +190,8 @@ const launchToHome = async (props: LaunchProps) => { return resetToTeams(); }; -const launchToServer = (props: LaunchProps, resetNavigation: Boolean) => { - if (resetNavigation) { - return resetToSelectServer(props); - } - - // This is being called for Deeplinks, but needs to be revisited when - // the implementation of deep links is complete - const title = ''; - return goToScreen(Screens.SERVER, title, {...props}); -}; - -export const relaunchApp = (props: LaunchProps, resetNavigation = false) => { - return launchApp(props, resetNavigation); -}; - -export const getLaunchPropsFromDeepLink = (deepLinkUrl: string, coldStart = false): LaunchProps => { - const parsed = parseDeepLink(deepLinkUrl); - const launchProps: LaunchProps = { - launchType: Launch.DeepLink, - coldStart, - }; - - switch (parsed.type) { - case DeepLink.Invalid: - launchProps.launchError = true; - break; - case DeepLink.Channel: { - const parsedData = parsed.data as DeepLinkChannel; - (launchProps.extra as DeepLinkWithData).data = parsedData; - break; - } - case DeepLink.DirectMessage: { - const parsedData = parsed.data as DeepLinkDM; - (launchProps.extra as DeepLinkWithData).data = parsedData; - break; - } - case DeepLink.GroupMessage: { - const parsedData = parsed.data as DeepLinkGM; - (launchProps.extra as DeepLinkWithData).data = parsedData; - break; - } - case DeepLink.Permalink: { - const parsedData = parsed.data as DeepLinkPermalink; - (launchProps.extra as DeepLinkWithData).data = parsedData; - break; - } - } - - return launchProps; +export const relaunchApp = (props: LaunchProps) => { + return launchApp(props); }; export const getLaunchPropsFromNotification = async (notification: NotificationWithData, coldStart = false): Promise => { diff --git a/app/managers/global_event_handler.ts b/app/managers/global_event_handler.ts index 7c0f75af4f..30f1f78b2a 100644 --- a/app/managers/global_event_handler.ts +++ b/app/managers/global_event_handler.ts @@ -11,9 +11,9 @@ import {Events, Sso} from '@constants'; import {MIN_REQUIRED_VERSION} from '@constants/supported_server'; import {DEFAULT_LOCALE, getTranslations, t} from '@i18n'; import {getServerCredentials} from '@init/credentials'; -import {getLaunchPropsFromDeepLink, relaunchApp} from '@init/launch'; import * as analytics from '@managers/analytics'; import {getAllServers} from '@queries/app/servers'; +import {handleDeepLink} from '@utils/deep_link'; import {logError} from '@utils/log'; import type {jsAndNativeErrorHandler} from '@typings/global/error_handling'; @@ -64,8 +64,7 @@ class GlobalEventHandler { } if (event.url) { - const props = getLaunchPropsFromDeepLink(event.url); - relaunchApp(props); + handleDeepLink(event.url); } }; diff --git a/app/managers/session_manager.ts b/app/managers/session_manager.ts index 542be18001..2b3627173c 100644 --- a/app/managers/session_manager.ts +++ b/app/managers/session_manager.ts @@ -185,7 +185,7 @@ class SessionManager { await storeOnboardingViewedValue(false); } - relaunchApp({launchType, serverUrl, displayName}, true); + relaunchApp({launchType, serverUrl, displayName}); } }; @@ -197,7 +197,7 @@ class SessionManager { const activeServerUrl = await DatabaseManager.getActiveServerUrl(); const serverDisplayName = await getServerDisplayName(serverUrl); - await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}, true); + await relaunchApp({launchType: Launch.Normal, serverUrl, displayName: serverDisplayName}); if (activeServerUrl) { addNewServer(getThemeFromState(), serverUrl, serverDisplayName); } else { diff --git a/app/screens/home/account/components/options/logout/index.tsx b/app/screens/home/account/components/options/logout/index.tsx index 0f998a904e..742e3d5114 100644 --- a/app/screens/home/account/components/options/logout/index.tsx +++ b/app/screens/home/account/components/options/logout/index.tsx @@ -3,9 +3,11 @@ import React, {useCallback} from 'react'; import {useIntl} from 'react-intl'; +import {Navigation} from 'react-native-navigation'; import {logout} from '@actions/remote/session'; import OptionItem from '@components/option_item'; +import {Screens} from '@constants'; import {useServerDisplayName, useServerUrl} from '@context/server'; import {useTheme} from '@context/theme'; import {alertServerLogout} from '@utils/server'; @@ -30,6 +32,7 @@ const LogOut = () => { const serverDisplayName = useServerDisplayName(); const onLogout = useCallback(preventDoubleTap(() => { + Navigation.updateProps(Screens.HOME, {extra: undefined}); alertServerLogout(serverDisplayName, () => logout(serverUrl), intl); }), [serverDisplayName, serverUrl, intl]); diff --git a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx index 81856c084a..8c75f4e30a 100644 --- a/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx +++ b/app/screens/home/channel_list/servers/servers_list/server_item/server_item.tsx @@ -6,6 +6,7 @@ import {useIntl} from 'react-intl'; import {Animated, DeviceEventEmitter, Platform, StyleProp, Text, View, ViewStyle} from 'react-native'; import {RectButton} from 'react-native-gesture-handler'; import Swipeable from 'react-native-gesture-handler/Swipeable'; +import {Navigation} from 'react-native-navigation'; import {storeMultiServerTutorial} from '@actions/app/global'; import {appEntry} from '@actions/remote/entry'; @@ -17,7 +18,7 @@ import Loading from '@components/loading'; import ServerIcon from '@components/server_icon'; import TutorialHighlight from '@components/tutorial_highlight'; import TutorialSwipeLeft from '@components/tutorial_highlight/swipe_left'; -import {Events} from '@constants'; +import {Events, Screens} from '@constants'; import {PUSH_PROXY_STATUS_NOT_AVAILABLE, PUSH_PROXY_STATUS_VERIFIED} from '@constants/push_proxy'; import {useTheme} from '@context/theme'; import DatabaseManager from '@database/manager'; @@ -178,6 +179,7 @@ const ServerItem = ({ }; const logoutServer = async () => { + Navigation.updateProps(Screens.HOME, {extra: undefined}); await logout(server.url); if (isActive) { @@ -190,6 +192,7 @@ const ServerItem = ({ const removeServer = async () => { const skipLogoutFromServer = server.lastActiveAt === 0; await dismissBottomSheet(); + Navigation.updateProps(Screens.HOME, {extra: undefined}); await logout(server.url, skipLogoutFromServer, true); }; @@ -286,6 +289,7 @@ const ServerItem = ({ if (server.lastActiveAt) { setSwitching(true); await dismissBottomSheet(); + Navigation.updateProps(Screens.HOME, {extra: undefined}); DatabaseManager.setActiveServerDatabase(server.url); await appEntry(server.url, Date.now()); return; diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index ca08e7e66a..78598d743b 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -14,6 +14,7 @@ import {Events, Screens} from '@constants'; import {useTheme} from '@context/theme'; import {findChannels, popToRoot} from '@screens/navigation'; import NavigationStore from '@store/navigation_store'; +import {handleDeepLink} from '@utils/deep_link'; import {alertChannelArchived, alertChannelRemove, alertTeamRemove} from '@utils/navigation'; import {notificationError} from '@utils/notification'; @@ -24,7 +25,7 @@ import SavedMessages from './saved_messages'; import Search from './search'; import TabBar from './tab_bar'; -import type {LaunchProps} from '@typings/launch'; +import type {DeepLinkWithData, LaunchProps} from '@typings/launch'; if (Platform.OS === 'ios') { // We do this on iOS to avoid conflicts betwen ReactNavigation & Wix ReactNativeNavigation @@ -95,6 +96,15 @@ export default function HomeScreen(props: HomeProps) { }; }, [intl.locale]); + useEffect(() => { + if (props.launchType === 'deeplink') { + const deepLink = props.extra as DeepLinkWithData; + if (deepLink?.url) { + handleDeepLink(deepLink.url); + } + } + }, []); + return ( <> ( {() => } diff --git a/app/screens/home/search/results/file_options/option_menus/option_menus.tsx b/app/screens/home/search/results/file_options/option_menus/option_menus.tsx index 2c254273c3..64df7a38fc 100644 --- a/app/screens/home/search/results/file_options/option_menus/option_menus.tsx +++ b/app/screens/home/search/results/file_options/option_menus/option_menus.tsx @@ -42,7 +42,7 @@ const OptionMenus = ({ const handlePermalink = useCallback(() => { if (fileInfo.post_id) { - showPermalink(serverUrl, '', fileInfo.post_id, intl); + showPermalink(serverUrl, '', fileInfo.post_id); setAction('opening'); } }, [intl, serverUrl, fileInfo.post_id, setAction]); diff --git a/app/screens/login/index.tsx b/app/screens/login/index.tsx index a787bb1448..df2c3ca6e3 100644 --- a/app/screens/login/index.tsx +++ b/app/screens/login/index.tsx @@ -209,6 +209,7 @@ const LoginOptions = ({ {hasLoginForm &&
{ const {width} = useWindowDimensions(); const {slidesData} = useSlidesData(); @@ -73,7 +74,7 @@ const Onboarding = ({ // mark the onboarding as already viewed storeOnboardingViewedValue(); - goToScreen(Screens.SERVER, '', {animated: true, theme}, loginAnimationOptions()); + goToScreen(Screens.SERVER, '', {animated: true, theme, ...props}, loginAnimationOptions()); }, []); const nextSlide = useCallback(() => { diff --git a/app/screens/permalink/permalink.tsx b/app/screens/permalink/permalink.tsx index b0a57c6602..cab55a22e1 100644 --- a/app/screens/permalink/permalink.tsx +++ b/app/screens/permalink/permalink.tsx @@ -406,7 +406,8 @@ function Permalink({ function processThreadPosts(posts: PostModel[], postId: string) { posts.sort((a, b) => b.createAt - a.createAt); const postIndex = posts.findIndex((p) => p.id === postId); - return posts.slice(postIndex - POSTS_LIMIT, postIndex + POSTS_LIMIT + 1); + const start = postIndex - POSTS_LIMIT; + return posts.slice(start < 0 ? postIndex : start, postIndex + POSTS_LIMIT + 1); } export default Permalink; diff --git a/app/screens/server/index.tsx b/app/screens/server/index.tsx index 9431f9431a..c45feab09c 100644 --- a/app/screens/server/index.tsx +++ b/app/screens/server/index.tsx @@ -90,15 +90,16 @@ const Server = ({ const styles = getStyleSheet(theme); const {formatMessage} = intl; const disableServerUrl = Boolean(managedConfig?.allowOtherServers === 'false' && managedConfig?.serverUrl); + const additionalServer = launchType === Launch.AddServerFromDeepLink || launchType === Launch.AddServer; useEffect(() => { let serverName: string | undefined = defaultDisplayName || managedConfig?.serverName || LocalConfig.DefaultServerName; let serverUrl: string | undefined = defaultServerUrl || managedConfig?.serverUrl || LocalConfig.DefaultServerUrl; let autoconnect = managedConfig?.allowOtherServers === 'false' || LocalConfig.AutoSelectServerUrl; - if (launchType === Launch.DeepLink) { + if (launchType === Launch.DeepLink || launchType === Launch.AddServerFromDeepLink) { const deepLinkServerUrl = (extra as DeepLinkWithData).data?.serverUrl; - if (managedConfig) { + if (managedConfig.serverUrl) { autoconnect = (managedConfig.allowOtherServers === 'false' && managedConfig.serverUrl === deepLinkServerUrl); if (managedConfig.serverUrl !== deepLinkServerUrl || launchError) { Alert.alert('', intl.formatMessage({ @@ -343,11 +344,11 @@ const Server = ({ style={styles.flex} > { const onHandlePress = useCallback(async () => { await dismissBottomSheet(Screens.THREAD_OPTIONS); - showPermalink(serverUrl, '', threadId, intl); + showPermalink(serverUrl, '', threadId); }, [intl, serverUrl, threadId]); return ( diff --git a/app/utils/deep_link/index.ts b/app/utils/deep_link/index.ts new file mode 100644 index 0000000000..61c03acf62 --- /dev/null +++ b/app/utils/deep_link/index.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {createIntl, IntlShape} from 'react-intl'; +import urlParse from 'url-parse'; + +import {makeDirectChannel, switchToChannelByName} from '@actions/remote/channel'; +import {appEntry} from '@actions/remote/entry'; +import {showPermalink} from '@actions/remote/permalink'; +import {fetchUsersByUsernames} from '@actions/remote/user'; +import {DeepLink, Launch, Screens} from '@constants'; +import {getDefaultThemeByAppearance} from '@context/theme'; +import DatabaseManager from '@database/manager'; +import {DEFAULT_LOCALE, getTranslations} from '@i18n'; +import {getActiveServerUrl} from '@queries/app/servers'; +import {getCurrentUser, queryUsersByUsername} from '@queries/servers/user'; +import {dismissAllModalsAndPopToRoot, showModal} from '@screens/navigation'; +import EphemeralStore from '@store/ephemeral_store'; +import NavigationStore from '@store/navigation_store'; +import {errorBadChannel, errorUnkownUser} from '@utils/draft'; +import {logError} from '@utils/log'; +import {escapeRegex} from '@utils/markdown'; +import {addNewServer} from '@utils/server'; +import {removeProtocol} from '@utils/url'; + +import type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkPlugin, DeepLinkWithData, LaunchProps} from '@typings/launch'; + +export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, location?: string) { + try { + const parsed = parseDeepLink(deepLinkUrl); + if (parsed.type === DeepLink.Invalid || !parsed.data || !parsed.data.serverUrl) { + return {error: true}; + } + + const currentServerUrl = await getActiveServerUrl(); + const existingServerUrl = DatabaseManager.searchUrl(parsed.data.serverUrl); + + // After checking the server for http & https then we add it + if (!existingServerUrl) { + const theme = EphemeralStore.theme || getDefaultThemeByAppearance(); + addNewServer(theme, parsed.data.serverUrl, undefined, parsed); + return {error: false}; + } + + if (existingServerUrl !== currentServerUrl && NavigationStore.getVisibleScreen()) { + await dismissAllModalsAndPopToRoot(); + DatabaseManager.setActiveServerDatabase(existingServerUrl); + appEntry(existingServerUrl, Date.now()); + await NavigationStore.waitUntilScreenHasLoaded(Screens.HOME); + } + + const {database} = DatabaseManager.getServerDatabaseAndOperator(existingServerUrl); + const currentUser = await getCurrentUser(database); + const locale = currentUser?.locale || DEFAULT_LOCALE; + const intl = intlShape || createIntl({ + locale, + messages: getTranslations(locale), + }); + + switch (parsed.type) { + case DeepLink.Channel: { + const deepLinkData = parsed.data as DeepLinkChannel; + switchToChannelByName(existingServerUrl, deepLinkData.channelName, deepLinkData.teamName, errorBadChannel, intl); + break; + } + case DeepLink.DirectMessage: { + const deepLinkData = parsed.data as DeepLinkDM; + const userIds = await queryUsersByUsername(database, [deepLinkData.userName]).fetchIds(); + let userId = userIds.length ? userIds[0] : undefined; + if (!userId) { + const {users} = await fetchUsersByUsernames(existingServerUrl, [deepLinkData.userName], false); + if (users?.length) { + userId = users[0].id; + } + } + + if (userId) { + makeDirectChannel(existingServerUrl, userId, '', true); + } else { + errorUnkownUser(intl); + } + break; + } + case DeepLink.GroupMessage: { + const deepLinkData = parsed.data as DeepLinkGM; + switchToChannelByName(existingServerUrl, deepLinkData.channelId, deepLinkData.teamName, errorBadChannel, intl); + break; + } + case DeepLink.Permalink: { + const deepLinkData = parsed.data as DeepLinkPermalink; + if (NavigationStore.hasModalsOpened() || ![Screens.HOME, Screens.CHANNEL, Screens.GLOBAL_THREADS, Screens.THREAD].includes(NavigationStore.getVisibleScreen())) { + await dismissAllModalsAndPopToRoot(); + } + showPermalink(existingServerUrl, deepLinkData.teamName, deepLinkData.postId); + break; + } + case DeepLink.Plugin: { + const deepLinkData = parsed.data as DeepLinkPlugin; + showModal('PluginInternal', deepLinkData.id, {link: location}); + break; + } + } + return {error: false}; + } catch (error) { + logError('Failed to open channel from deeplink', error); + return {error: true}; + } +} + +export function parseDeepLink(deepLinkUrl: string): DeepLinkWithData { + const url = removeProtocol(deepLinkUrl); + + let match = new RegExp('(.*)\\/([^\\/]+)\\/channels\\/(\\S+)').exec(url); + if (match) { + return {type: DeepLink.Channel, url: deepLinkUrl, data: {serverUrl: match[1], teamName: match[2], channelName: match[3]}}; + } + + match = new RegExp('(.*)\\/([^\\/]+)\\/pl\\/(\\w+)').exec(url); + if (match) { + return {type: DeepLink.Permalink, url: deepLinkUrl, data: {serverUrl: match[1], teamName: match[2], postId: match[3]}}; + } + + match = new RegExp('(.*)\\/([^\\/]+)\\/messages\\/@(\\S+)').exec(url); + if (match) { + return {type: DeepLink.DirectMessage, url: deepLinkUrl, data: {serverUrl: match[1], teamName: match[2], userName: match[3]}}; + } + + match = new RegExp('(.*)\\/([^\\/]+)\\/messages\\/(\\S+)').exec(url); + if (match) { + return {type: DeepLink.GroupMessage, url: deepLinkUrl, data: {serverUrl: match[1], teamName: match[2], channelId: match[3]}}; + } + + match = new RegExp('(.*)\\/plugins\\/([^\\/]+)\\/(\\S+)').exec(url); + if (match) { + return {type: DeepLink.Plugin, url: deepLinkUrl, data: {serverUrl: match[1], id: match[2], teamName: ''}}; + } + + return {type: DeepLink.Invalid, url: deepLinkUrl}; +} + +export function matchDeepLink(url?: string, serverURL?: string, siteURL?: string) { + if (!url || (!serverURL && !siteURL)) { + return ''; + } + + let urlToMatch = url; + const urlBase = serverURL || siteURL || ''; + + if (!url.startsWith('mattermost://')) { + // If url doesn't contain site or server URL, tack it on. + // e.g. URLs from autolink plugin. + const match = new RegExp(escapeRegex(urlBase)).exec(url); + if (!match) { + urlToMatch = urlBase + url; + } + } + + if (urlParse(urlToMatch).hostname === urlParse(urlBase).hostname) { + return urlToMatch; + } + + return ''; +} + +export const getLaunchPropsFromDeepLink = (deepLinkUrl: string, coldStart = false): LaunchProps => { + const parsed = parseDeepLink(deepLinkUrl); + const launchProps: LaunchProps = { + launchType: Launch.DeepLink, + coldStart, + }; + + switch (parsed.type) { + case DeepLink.Invalid: + launchProps.launchError = true; + break; + default: { + launchProps.extra = parsed; + break; + } + } + + return launchProps; +}; diff --git a/app/utils/server/index.ts b/app/utils/server/index.ts index f3130385a0..debff00faf 100644 --- a/app/utils/server/index.ts +++ b/app/utils/server/index.ts @@ -13,6 +13,7 @@ import {changeOpacity} from '@utils/theme'; import {tryOpenURL} from '@utils/url'; import type ServersModel from '@typings/database/models/app/servers'; +import type {DeepLinkWithData} from '@typings/launch'; export function isSupportedServer(currentVersion: string) { return isMinimumServerVersion(currentVersion, SupportedServer.MAJOR_VERSION, SupportedServer.MIN_VERSION, SupportedServer.PATCH_VERSION); @@ -39,15 +40,16 @@ export function semverFromServerVersion(value: string) { return `${major}.${minor}.${patch}`; } -export async function addNewServer(theme: Theme, serverUrl?: string, displayName?: string) { +export async function addNewServer(theme: Theme, serverUrl?: string, displayName?: string, deepLinkProps?: DeepLinkWithData) { await dismissBottomSheet(); const closeButtonId = 'close-server'; const props = { closeButtonId, displayName, - launchType: Launch.AddServer, + launchType: deepLinkProps ? Launch.AddServerFromDeepLink : Launch.AddServer, serverUrl, theme, + extra: deepLinkProps, }; const options = buildServerModalOptions(theme, closeButtonId); diff --git a/app/utils/url/index.ts b/app/utils/url/index.ts index 71d06da3ed..4208f2eeea 100644 --- a/app/utils/url/index.ts +++ b/app/utils/url/index.ts @@ -5,14 +5,11 @@ import GenericClient from '@mattermost/react-native-network-client'; import {Linking} from 'react-native'; import urlParse from 'url-parse'; -import {Files, DeepLink} from '@constants'; +import {Files} from '@constants'; import {emptyFunction} from '@utils/general'; -import {escapeRegex} from '@utils/markdown'; import {latinise} from './latinise'; -import type {DeepLinkWithData} from '@typings/launch'; - const ytRegex = /(?:http|https):\/\/(?:www\.|m\.)?(?:(?:youtube\.com\/(?:(?:v\/)|(?:(?:watch|embed\/watch)(?:\/|.*v=))|(?:embed\/)|(?:user\/[^/]+\/u\/[0-9]\/)))|(?:youtu\.be\/))([^#&?]*)/; export function isValidUrl(url = '') { @@ -153,66 +150,6 @@ export function getScheme(url: string) { return match && match[1]; } -export const PERMALINK_GENERIC_TEAM_NAME_REDIRECT = '_redirect'; - -export function parseDeepLink(deepLinkUrl: string): DeepLinkWithData { - const url = removeProtocol(deepLinkUrl); - - let match = new RegExp('(.*)\\/([^\\/]+)\\/channels\\/(\\S+)').exec(url); - if (match) { - return {type: DeepLink.Channel, data: {serverUrl: match[1], teamName: match[2], channelName: match[3]}}; - } - - match = new RegExp('(.*)\\/([^\\/]+)\\/pl\\/(\\w+)').exec(url); - if (match) { - return {type: DeepLink.Permalink, data: {serverUrl: match[1], teamName: match[2], postId: match[3]}}; - } - - match = new RegExp('(.*)\\/([^\\/]+)\\/messages\\/@(\\S+)').exec(url); - if (match) { - return {type: DeepLink.DirectMessage, data: {serverUrl: match[1], teamName: match[2], userName: match[3]}}; - } - - match = new RegExp('(.*)\\/([^\\/]+)\\/messages\\/(\\S+)').exec(url); - if (match) { - return {type: DeepLink.GroupMessage, data: {serverUrl: match[1], teamName: match[2], channelId: match[3]}}; - } - - match = new RegExp('(.*)\\/plugins\\/([^\\/]+)\\/(\\S+)').exec(url); - if (match) { - return {type: DeepLink.Plugin, data: {serverUrl: match[1], id: match[2], teamName: ''}}; - } - - return {type: DeepLink.Invalid}; -} - -export function matchDeepLink(url?: string, serverURL?: string, siteURL?: string) { - if (!url || (!serverURL && !siteURL)) { - return null; - } - - let urlToMatch = url; - const urlBase = serverURL || siteURL || ''; - - if (!url.startsWith('mattermost://')) { - // If url doesn't contain site or server URL, tack it on. - // e.g. URLs from autolink plugin. - const match = new RegExp(escapeRegex(urlBase)).exec(url); - if (!match) { - urlToMatch = urlBase + url; - } - } - - if (urlParse(urlToMatch).hostname === urlParse(urlBase).hostname) { - const parsedDeepLink = parseDeepLink(urlToMatch); - if (parsedDeepLink.type !== DeepLink.Invalid) { - return parsedDeepLink; - } - } - - return null; -} - export function getYouTubeVideoId(link: string) { // https://youtube.com/watch?v= let match = (/youtube\.com\/watch\?\S*\bv=([a-zA-Z0-9_-]{6,11})/g).exec(link); diff --git a/app/utils/url/test.ts b/app/utils/url/test.ts index f1f5ee48d2..ba8f951b66 100644 --- a/app/utils/url/test.ts +++ b/app/utils/url/test.ts @@ -5,6 +5,7 @@ import {Linking} from 'react-native'; import DeepLinkType from '@constants/deep_linking'; import TestHelper from '@test/test_helper'; +import {matchDeepLink, parseDeepLink} from '@utils/deep_link'; import * as UrlUtils from '@utils/url'; /* eslint-disable max-nested-callbacks */ @@ -136,22 +137,22 @@ describe('UrlUtils', () => { { name: 'should return null if all inputs are empty', input: {url: '', serverURL: '', siteURL: ''}, - expected: null, + expected: {type: 'invalid'}, }, { name: 'should return null if any of the input is null', input: {url: '', serverURL: '', siteURL: null}, - expected: null, + expected: {type: 'invalid'}, }, { name: 'should return null if any of the input is null', input: {url: '', serverURL: null, siteURL: ''}, - expected: null, + expected: {type: 'invalid'}, }, { name: 'should return null if any of the input is null', input: {url: null, serverURL: '', siteURL: ''}, - expected: null, + expected: {type: 'invalid'}, }, { name: 'should return null for not supported link', @@ -160,12 +161,12 @@ describe('UrlUtils', () => { serverURL: SERVER_URL, siteURL: SITE_URL, }, - expected: null, + expected: {type: 'invalid'}, }, { name: 'should return null despite url subset match', input: {url: 'http://myserver.com', serverURL: 'http://myserver.co'}, - expected: null, + expected: {type: 'invalid'}, }, { name: 'should match despite no server URL in input link', @@ -253,7 +254,10 @@ describe('UrlUtils', () => { const {name, input, expected} = test; it(name, () => { - expect(UrlUtils.matchDeepLink(input.url!, input.serverURL!, input.siteURL!)).toEqual(expected); + const match = matchDeepLink(input.url!, input.serverURL!, input.siteURL!); + const parsed = parseDeepLink(match); + Reflect.deleteProperty(parsed, 'url'); + expect(parsed).toEqual(expected); }); } }); diff --git a/types/launch/index.ts b/types/launch/index.ts index 7033f126c3..1d4986cae0 100644 --- a/types/launch/index.ts +++ b/types/launch/index.ts @@ -32,6 +32,7 @@ export type DeepLinkType = typeof DeepLink[keyof typeof DeepLink]; export interface DeepLinkWithData { type: DeepLinkType; + url: string; data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink | DeepLinkPlugin; }