mattermost-mobile/app/utils/deep_link/index.ts
2023-11-17 11:18:05 -08:00

255 lines
9.9 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {match} from 'path-to-regexp';
import {createIntl, type IntlShape} from 'react-intl';
import urlParse from 'url-parse';
import {makeDirectChannel, switchToChannelByName} from '@actions/remote/channel';
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, t} from '@i18n';
import WebsocketManager from '@managers/websocket_manager';
import {getActiveServerUrl} from '@queries/app/servers';
import {getCurrentUser, queryUsersByUsername} from '@queries/servers/user';
import {dismissAllModalsAndPopToRoot} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import NavigationStore from '@store/navigation_store';
import {alertErrorWithFallback, errorBadChannel, errorUnkownUser} from '@utils/draft';
import {logError} from '@utils/log';
import {escapeRegex} from '@utils/markdown';
import {addNewServer} from '@utils/server';
import {
TEAM_NAME_PATH_PATTERN,
IDENTIFIER_PATH_PATTERN,
ID_PATH_PATTERN,
PLUGIN_ID_PATH_PATTERN,
} from '@utils/url/path';
import type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkWithData, LaunchProps} from '@typings/launch';
import type {AvailableScreens} from '@typings/screens/navigation';
const deepLinkScreens: AvailableScreens[] = [Screens.HOME, Screens.CHANNEL, Screens.GLOBAL_THREADS, Screens.THREAD];
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);
WebsocketManager.initializeClient(existingServerUrl);
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() ||
!deepLinkScreens.includes(NavigationStore.getVisibleScreen())
) {
await dismissAllModalsAndPopToRoot();
}
showPermalink(existingServerUrl, deepLinkData.teamName, deepLinkData.postId);
break;
}
case DeepLink.Plugin: {
// https://mattermost.atlassian.net/browse/MM-49846
// 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, location);
return {error: true};
}
}
const matcherOpts: Parameters<typeof match>[1] = {decode: decodeURIComponent};
type ChannelPathParams = {
hostname: string;
subpath?: string;
teamName: string;
path: 'channels' | 'messages';
identifier: string;
postId?: string;
};
const CHANNEL_PATH = `/:subpath*/:teamName(${TEAM_NAME_PATH_PATTERN})/:path(channels|messages)/:identifier(${IDENTIFIER_PATH_PATTERN})/:postId(${ID_PATH_PATTERN})?`;
export const matchChannelDeeplink = match<ChannelPathParams>(CHANNEL_PATH, matcherOpts);
type PermalinkPathParams = {
hostname: string;
subpath?: string;
teamName: string;
postId: string;
};
const PERMALINK_PATH = `/:subpath*/:teamName(${TEAM_NAME_PATH_PATTERN})/pl/:postId(${ID_PATH_PATTERN})`;
export const matchPermalinkDeeplink = match<PermalinkPathParams>(PERMALINK_PATH, matcherOpts);
const getServerUrl = (host: string, subpath?: string) => host + (subpath ? '/' + subpath : '');
export function parseDeepLink(deepLinkUrl: string): DeepLinkWithData {
try {
const {host, pathname} = urlParse(deepLinkUrl);
const channelMatch = matchChannelDeeplink(pathname);
if (channelMatch) {
const {params: {subpath, teamName, path, identifier, postId}} = channelMatch;
const serverUrl = getServerUrl(host, subpath);
if (path === 'channels') {
return {type: DeepLink.Channel, url: deepLinkUrl, data: {serverUrl, teamName, channelName: identifier, postId}};
}
if (path === 'messages') {
if (identifier.startsWith('@')) {
return {type: DeepLink.DirectMessage, url: deepLinkUrl, data: {serverUrl, teamName, userName: identifier}};
}
return {type: DeepLink.GroupMessage, url: deepLinkUrl, data: {serverUrl, teamName, channelId: identifier}};
}
}
const permalinkMatch = matchPermalinkDeeplink(pathname);
if (permalinkMatch) {
const {params: {subpath, teamName, postId}} = permalinkMatch;
const serverUrl = getServerUrl(host, subpath);
return {type: DeepLink.Permalink, url: deepLinkUrl, data: {serverUrl, teamName, postId}};
}
const pluginMatch = match<{subpath?: string; id: string}>(`/:subpath*/plugins/:id(${PLUGIN_ID_PATH_PATTERN})`, matcherOpts)(pathname);
if (pluginMatch) {
const {params: {subpath, id}} = pluginMatch;
const serverUrl = getServerUrl(host, subpath);
return {type: DeepLink.Plugin, url: deepLinkUrl, data: {serverUrl, teamName: '', id}};
}
} catch {
// do nothing just return invalid deeplink
}
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 || '';
const parsedUrl = urlParse(url);
const parsedBase = urlParse(urlBase);
if (!parsedUrl.protocol) {
// If url doesn't contain site or server URL, tack it on.
// e.g. <jump to convo> URLs from autolink plugin.
const deepLinkMatch = new RegExp(escapeRegex(urlBase)).exec(url);
if (!deepLinkMatch) {
urlToMatch = urlBase + url;
}
}
const finalUrl = urlParse(urlToMatch);
const baseSubpath = parsedBase.pathname.replace('/', '');
const baseHostname = parsedBase.hostname;
const urlSubpath = finalUrl.pathname.split('/')[1];
const urlHostname = finalUrl.hostname;
if (baseSubpath) {
// if the server is in a subpath
if (urlHostname === baseHostname && urlSubpath === baseSubpath) {
return urlToMatch;
}
} else if (urlHostname === baseHostname) {
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;
};
export function alertInvalidDeepLink(intl: IntlShape) {
const message = {
id: t('mobile.deep_link.invalid'),
defaultMessage: 'This link you are trying to open is invalid.',
};
return alertErrorWithFallback(intl, {}, message);
}