Add DeepLink support (#6869)

This commit is contained in:
Elias Nahum
2022-12-16 18:57:15 +02:00
committed by GitHub
parent aff0de5a13
commit e294b07418
25 changed files with 295 additions and 252 deletions

View File

@@ -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);

View File

@@ -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(

View File

@@ -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;

View File

@@ -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]);

View File

@@ -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;
}

View File

@@ -8,6 +8,7 @@ const DeepLinkType = {
Invalid: 'invalid',
Permalink: 'permalink',
Plugin: 'plugin',
Redirect: '_redirect',
} as const;
export default DeepLinkType;

View File

@@ -3,6 +3,7 @@
const LaunchType = {
AddServer: 'add-server',
AddServerFromDeepLink: 'add-server-deeplink',
Normal: 'normal',
DeepLink: 'deeplink',
Notification: 'notification',

View File

@@ -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<LaunchProps> => {

View File

@@ -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);
}
};

View File

@@ -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 {

View File

@@ -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]);

View File

@@ -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;

View File

@@ -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 (
<>
<NavigationContainer
@@ -111,7 +121,7 @@ export default function HomeScreen(props: HomeProps) {
}}
>
<Tab.Navigator
screenOptions={{headerShown: false, lazy: true, unmountOnBlur: false}}
screenOptions={{headerShown: false, unmountOnBlur: false, lazy: true}}
backBehavior='none'
tabBar={(tabProps: BottomTabBarProps) => (
<TabBar
@@ -121,29 +131,29 @@ export default function HomeScreen(props: HomeProps) {
>
<Tab.Screen
name={Screens.HOME}
options={{title: 'Channel', unmountOnBlur: false, tabBarTestID: 'tab_bar.home.tab', freezeOnBlur: true}}
options={{tabBarTestID: 'tab_bar.home.tab', unmountOnBlur: false, freezeOnBlur: true}}
>
{() => <ChannelList {...props}/>}
</Tab.Screen>
<Tab.Screen
name={Screens.SEARCH}
component={Search}
options={{unmountOnBlur: false, lazy: true, tabBarTestID: 'tab_bar.search.tab', freezeOnBlur: true}}
options={{tabBarTestID: 'tab_bar.search.tab', unmountOnBlur: false, freezeOnBlur: true, lazy: true}}
/>
<Tab.Screen
name={Screens.MENTIONS}
component={RecentMentions}
options={{tabBarTestID: 'tab_bar.mentions.tab', lazy: true, unmountOnBlur: false, freezeOnBlur: true}}
options={{tabBarTestID: 'tab_bar.mentions.tab', freezeOnBlur: true, lazy: true}}
/>
<Tab.Screen
name={Screens.SAVED_MESSAGES}
component={SavedMessages}
options={{unmountOnBlur: false, lazy: true, tabBarTestID: 'tab_bar.saved_messages.tab', freezeOnBlur: true}}
options={{tabBarTestID: 'tab_bar.saved_messages.tab', freezeOnBlur: true, lazy: true}}
/>
<Tab.Screen
name={Screens.ACCOUNT}
component={Account}
options={{tabBarTestID: 'tab_bar.account.tab', lazy: true, unmountOnBlur: false, freezeOnBlur: true}}
options={{tabBarTestID: 'tab_bar.account.tab', freezeOnBlur: true, lazy: true}}
/>
</Tab.Navigator>
</NavigationContainer>

View File

@@ -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]);

View File

@@ -209,6 +209,7 @@ const LoginOptions = ({
{hasLoginForm &&
<Form
config={config}
extra={extra}
keyboardAwareRef={keyboardAwareRef}
license={license}
launchError={launchError}

View File

@@ -247,12 +247,15 @@ export function resetToHome(passProps: LaunchProps = {launchType: Launch.Normal}
const isDark = tinyColor(theme.sidebarBg).isDark();
StatusBar.setBarStyle(isDark ? 'light-content' : 'dark-content');
if (passProps.launchType === Launch.AddServer) {
if (passProps.launchType === Launch.AddServer || passProps.launchType === Launch.AddServerFromDeepLink) {
dismissModal({componentId: Screens.SERVER});
dismissModal({componentId: Screens.LOGIN});
dismissModal({componentId: Screens.SSO});
dismissModal({componentId: Screens.BOTTOM_SHEET});
DeviceEventEmitter.emit(Events.FETCHING_POSTS, false);
if (passProps.launchType === Launch.AddServerFromDeepLink) {
Navigation.updateProps(Screens.HOME, {launchType: Launch.DeepLink, extra: passProps.extra});
}
return '';
}

View File

@@ -49,6 +49,7 @@ const AnimatedSafeArea = Animated.createAnimatedComponent(SafeAreaView);
const Onboarding = ({
theme,
...props
}: OnboardingProps) => {
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(() => {

View File

@@ -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;

View File

@@ -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}
>
<ServerHeader
additionalServer={launchType === Launch.AddServer}
additionalServer={additionalServer}
theme={theme}
/>
<ServerForm
autoFocus={launchType === Launch.AddServer}
autoFocus={additionalServer}
buttonDisabled={buttonDisabled}
connecting={connecting}
displayName={displayName}

View File

@@ -20,7 +20,7 @@ const OpenInChannelOption = ({threadId}: Props) => {
const onHandlePress = useCallback(async () => {
await dismissBottomSheet(Screens.THREAD_OPTIONS);
showPermalink(serverUrl, '', threadId, intl);
showPermalink(serverUrl, '', threadId);
}, [intl, serverUrl, threadId]);
return (

View File

@@ -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. <jump to convo> 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;
};

View File

@@ -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);

View File

@@ -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. <jump to convo> 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=<id>
let match = (/youtube\.com\/watch\?\S*\bv=([a-zA-Z0-9_-]{6,11})/g).exec(link);

View File

@@ -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);
});
}
});

View File

@@ -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;
}