Playbooks update (#9039)

* Playbook run status update post

* Show Playbooks button in Channel Info screen only if playbooks is enabled

* Handle Playbook links

* Fetch playbook if needed

* fix playbooks migration

* fix deeplinks using parsedUrl

* update last time playbooks where fetched if no errors

* show participants for run status update post

* fix tests

* remove console.log in test

* feedback review

* wrap participants footer

* add fastlane FASTLANE_XCODEBUILD_SETTINGS_RETRIES env vars
This commit is contained in:
Elias Nahum 2025-07-29 21:55:41 +08:00 committed by GitHub
parent 21708aa2ee
commit 40d8a0cbb4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 548 additions and 59 deletions

View file

@ -382,7 +382,7 @@ describe('handleGotoLocation', () => {
expect(getConfig).toHaveBeenCalledWith(mockOperator.database); expect(getConfig).toHaveBeenCalledWith(mockOperator.database);
expect(matchDeepLink).toHaveBeenCalledWith(location, serverUrl, serverUrl); expect(matchDeepLink).toHaveBeenCalledWith(location, serverUrl, serverUrl);
expect(handleDeepLink).toHaveBeenCalledWith(location, intl, location); expect(handleDeepLink).toHaveBeenCalledWith({url: location}, intl, location);
expect(result).toEqual({data: true}); expect(result).toEqual({data: true});
}); });

View file

@ -143,7 +143,7 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc
const match = matchDeepLink(location, serverUrl, config?.SiteURL); const match = matchDeepLink(location, serverUrl, config?.SiteURL);
if (match) { if (match) {
handleDeepLink(match.url, intl, location); handleDeepLink(match, intl, location);
} else { } else {
const {formatMessage} = intl; const {formatMessage} = intl;
const onError = () => Alert.alert( const onError = () => Alert.alert(

View file

@ -9,6 +9,8 @@ import FormattedText from '@components/formatted_text';
import JumboEmoji from '@components/jumbo_emoji'; import JumboEmoji from '@components/jumbo_emoji';
import {Screens} from '@constants'; import {Screens} from '@constants';
import {THREAD} from '@constants/screens'; import {THREAD} from '@constants/screens';
import StatusUpdatePost from '@playbooks/components/status_update_post';
import {PLAYBOOKS_UPDATE_STATUS_POST_TYPE} from '@playbooks/constants/plugin';
import {isEdited as postEdited, isPostFailed} from '@utils/post'; import {isEdited as postEdited, isPostFailed} from '@utils/post';
import {makeStyleSheetFromTheme} from '@utils/theme'; import {makeStyleSheetFromTheme} from '@utils/theme';
@ -139,6 +141,14 @@ const Body = ({
defaultMessage='(message deleted)' defaultMessage='(message deleted)'
/> />
); );
} else if (post.type === PLAYBOOKS_UPDATE_STATUS_POST_TYPE && post.props != null) {
message = (
<StatusUpdatePost
location={location}
post={post}
theme={theme}
/>
);
} else if (isPostAddChannelMember) { } else if (isPostAddChannelMember) {
message = ( message = (
<AddMembers <AddMembers

View file

@ -7,6 +7,8 @@ const DeepLinkType = {
GroupMessage: 'gm', GroupMessage: 'gm',
Invalid: 'invalid', Invalid: 'invalid',
Permalink: 'permalink', Permalink: 'permalink',
Playbooks: 'playbooks',
PlaybookRuns: 'playbook_runs',
Redirect: '_redirect', Redirect: '_redirect',
Server: 'server', Server: 'server',
} as const; } as const;

View file

@ -53,6 +53,8 @@ export default schemaMigrations({migrations: [
{name: 'retrospective', type: 'string'}, {name: 'retrospective', type: 'string'},
{name: 'retrospective_published_at', type: 'number'}, {name: 'retrospective_published_at', type: 'number'},
{name: 'update_at', type: 'number'}, {name: 'update_at', type: 'number'},
{name: 'sync', type: 'string', isIndexed: true, isOptional: true},
{name: 'last_sync_at', type: 'number', isOptional: true},
], ],
}), }),
createTable({ createTable({
@ -62,6 +64,8 @@ export default schemaMigrations({migrations: [
{name: 'items_order', type: 'string'}, {name: 'items_order', type: 'string'},
{name: 'title', type: 'string'}, {name: 'title', type: 'string'},
{name: 'update_at', type: 'number'}, {name: 'update_at', type: 'number'},
{name: 'sync', type: 'string', isIndexed: true, isOptional: true},
{name: 'last_sync_at', type: 'number', isOptional: true},
], ],
}), }),
createTable({ createTable({
@ -80,6 +84,8 @@ export default schemaMigrations({migrations: [
{name: 'completed_at', type: 'number'}, {name: 'completed_at', type: 'number'},
{name: 'task_actions', type: 'string', isOptional: true}, // JSON string {name: 'task_actions', type: 'string', isOptional: true}, // JSON string
{name: 'update_at', type: 'number'}, {name: 'update_at', type: 'number'},
{name: 'sync', type: 'string', isIndexed: true, isOptional: true},
{name: 'last_sync_at', type: 'number', isOptional: true},
], ],
}), }),
addColumns({ addColumns({

View file

@ -16,7 +16,7 @@ import {queryTeamDefaultChannel} from '@queries/servers/channel';
import {getCommonSystemValues} from '@queries/servers/system'; import {getCommonSystemValues} from '@queries/servers/system';
import {getTeamChannelHistory} from '@queries/servers/team'; import {getTeamChannelHistory} from '@queries/servers/team';
import {setScreensOrientation} from '@screens/navigation'; import {setScreensOrientation} from '@screens/navigation';
import {alertInvalidDeepLink, handleDeepLink} from '@utils/deep_link'; import {alertInvalidDeepLink, parseAndHandleDeepLink} from '@utils/deep_link';
import {getIntlShape} from '@utils/general'; import {getIntlShape} from '@utils/general';
type LinkingCallbackArg = {url: string}; type LinkingCallbackArg = {url: string};
@ -43,7 +43,7 @@ class GlobalEventHandlerSingleton {
} }
if (event.url) { if (event.url) {
const {error} = await handleDeepLink(event.url, undefined, undefined, true); const {error} = await parseAndHandleDeepLink(event.url, undefined, undefined, true);
if (error) { if (error) {
alertInvalidDeepLink(getIntlShape(DEFAULT_LOCALE)); alertInvalidDeepLink(getIntlShape(DEFAULT_LOCALE));
} }

View file

@ -48,13 +48,17 @@ export const fetchPlaybookRunsForChannel = async (serverUrl: string, channelId:
return {runs: []}; return {runs: []};
} }
updateLastPlaybookRunsFetchAt(serverUrl, channelId, getMaxRunUpdateAt(allRuns));
if (!fetchOnly) { if (!fetchOnly) {
EphemeralStore.setChannelPlaybooksSynced(serverUrl, channelId); const result = await handlePlaybookRuns(serverUrl, allRuns, false, true);
handlePlaybookRuns(serverUrl, allRuns, false, true); if (result && result.error) {
throw result.error;
} }
EphemeralStore.setChannelPlaybooksSynced(serverUrl, channelId);
}
updateLastPlaybookRunsFetchAt(serverUrl, channelId, getMaxRunUpdateAt(allRuns));
return {runs: allRuns}; return {runs: allRuns};
} catch (error) { } catch (error) {
logDebug('error on fetchPlaybookRunsForChannel', getFullErrorMessage(error)); logDebug('error on fetchPlaybookRunsForChannel', getFullErrorMessage(error));
@ -83,3 +87,24 @@ export const fetchFinishedRunsForChannel = async (serverUrl: string, channelId:
return {error}; return {error};
} }
}; };
export const fetchPlaybookRun = async (serverUrl: string, runId: string, fetchOnly = false): Promise<PlaybookRunsRequest> => {
try {
const client = NetworkManager.getClient(serverUrl);
const run = await client.fetchPlaybookRun(runId);
if (!fetchOnly) {
const result = await handlePlaybookRuns(serverUrl, [run], false, true);
if (result.error) {
logDebug('error on handlePlaybookRuns', getFullErrorMessage(result.error));
return {error: result.error};
}
}
return {runs: [run]};
} catch (error) {
logDebug('error on fetchPlaybookRun', getFullErrorMessage(error));
forceLogoutIfNecessary(serverUrl, error);
return {error};
}
};

View file

@ -10,7 +10,7 @@ export interface ClientPlaybooksMix {
// Playbook Runs // Playbook Runs
fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>; fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise<FetchPlaybookRunsReturn>;
// fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>; fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise<PlaybookRun>;
// Run Management // Run Management
// finishRun: (playbookRunId: string) => Promise<any>; // finishRun: (playbookRunId: string) => Promise<any>;
@ -54,12 +54,12 @@ const ClientPlaybooks = <TBase extends Constructor<ClientBase>>(superclass: TBas
} }
}; };
// fetchPlaybookRun = async (id: string, groupLabel?: RequestGroupLabel) => { fetchPlaybookRun = async (id: string, groupLabel?: RequestGroupLabel) => {
// return this.doFetch( return this.doFetch(
// `${this.getPlaybookRunRoute(id)}`, `${this.getPlaybookRunRoute(id)}`,
// {method: 'get', groupLabel}, {method: 'get', groupLabel},
// ); );
// }; };
// Run Management // Run Management
// finishRun = async (playbookRunId: string) => { // finishRun = async (playbookRunId: string) => {

View file

@ -0,0 +1,169 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import {fetchUsersByIds} from '@actions/remote/user';
import CompassIcon from '@components/compass_icon';
import Markdown from '@components/markdown';
import {useServerUrl} from '@context/server';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import Participants from './participants';
import type PostModel from '@typings/database/models/servers/post';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
location: AvailableScreens;
post: PostModel;
theme: Theme;
};
type StatusUpdatePostProps = {
authorUsername: string;
numTasks: number;
numTasksChecked: number;
participantIds: string[];
playbookRunId: string;
runName: string;
};
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
messageContainer: {
width: '100%',
},
icon: {
marginRight: 5,
},
reply: {
paddingRight: 10,
},
message: {
color: theme.centerChannelColor,
...typography('Body', 200),
lineHeight: undefined, // remove line height, not needed and causes problems with md images
},
pendingPost: {
opacity: 0.5,
},
updateContainer: {
marginTop: 5,
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
padding: 12,
borderRadius: 4,
},
updateSeparator: {
width: '100%',
height: 1,
backgroundColor: changeOpacity(theme.centerChannelColor, 0.16),
marginVertical: 10,
},
detailsContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
},
row: {
flexDirection: 'row',
},
detailsText: {
color: changeOpacity(theme.centerChannelColor, 0.64),
...typography('Body', 75),
},
detailsSeparator: {
color: changeOpacity(theme.centerChannelColor, 0.24),
...typography('Body', 75),
},
};
});
const StatusUpdatePost = ({location, post, theme}: Props) => {
const {authorUsername, numTasks, numTasksChecked, participantIds, playbookRunId, runName} = post.props as StatusUpdatePostProps;
const serverUrl = useServerUrl();
const style = getStyleSheet(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
const intl = useIntl();
useEffect(() => {
fetchUsersByIds(serverUrl, participantIds);
// Only do this on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const updatePosted = intl.formatMessage({
id: 'playbooks.status_update_post.update',
defaultMessage: '@{authorUsername} posted an update for [{runName}]({link})',
}, {authorUsername, runName, link: `/playbooks/runs/${playbookRunId}`});
const tasks = intl.formatMessage({
id: 'playbooks.status_update_post.num_tasks',
defaultMessage: '**{numTasksChecked, number}** of **{numTasks, number}** {numTasks, plural, =1 {task} other {tasks}} checked',
}, {numTasksChecked, numTasks});
return (
<View style={style.messageContainer}>
<Markdown
baseTextStyle={style.message}
blockStyles={blockStyles}
channelId={post.channelId}
postId={post.id}
textStyles={textStyles}
value={updatePosted}
mentionKeys={authorUsername ? [{key: authorUsername, caseSensitive: false}] : []}
theme={theme}
location={location}
/>
<View style={style.updateContainer}>
<Markdown
baseTextStyle={style.message}
blockStyles={blockStyles}
channelId={post.channelId}
postId={post.id}
textStyles={textStyles}
value={post.message}
theme={theme}
location={location}
/>
<View style={style.updateSeparator}/>
<View style={style.detailsContainer}>
<View style={style.row}>
<CompassIcon
name='check-all'
size={14}
color={changeOpacity(theme.centerChannelColor, 0.64)}
style={style.icon}
/>
<Markdown
baseTextStyle={style.detailsText}
value={tasks}
textStyles={textStyles}
theme={theme}
location={location}
/>
</View>
<Text style={style.detailsSeparator}>
{'•'}
</Text>
<Participants
participantIds={participantIds}
location={location}
baseTextStyle={style.detailsText}
textStyles={textStyles}
/>
</View>
</View>
</View>
);
};
export default StatusUpdatePost;

View file

@ -0,0 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {queryUsersById} from '@queries/servers/user';
import Participants from './participants';
import type {WithDatabaseArgs} from '@typings/database/database';
type Props = WithDatabaseArgs & {
participantIds: string[];
}
const enhanced = withObservables(['participantIds'], ({database, participantIds}: Props) => {
return {
users: queryUsersById(database, participantIds).observe(),
};
});
export default withDatabase(enhanced(Participants));

View file

@ -0,0 +1,128 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Platform, Text, TouchableOpacity, View, type StyleProp, type TextStyle} from 'react-native';
import CompassIcon from '@components/compass_icon';
import Markdown from '@components/markdown';
import UsersList from '@components/user_avatars_stack/users_list';
import {useTheme} from '@context/theme';
import {useIsTablet} from '@hooks/device';
import {usePreventDoubleTap} from '@hooks/utils';
import {BOTTOM_SHEET_ANDROID_OFFSET} from '@screens/bottom_sheet';
import {TITLE_HEIGHT} from '@screens/bottom_sheet/content';
import {bottomSheet} from '@screens/navigation';
import {bottomSheetSnapPoint} from '@utils/helpers';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import type {UserModel} from '@database/models/server';
import type {MarkdownTextStyles} from '@typings/global/markdown';
import type {AvailableScreens} from '@typings/screens/navigation';
type Props = {
participantIds: string[];
users: UserModel[];
location: AvailableScreens;
baseTextStyle: StyleProp<TextStyle>;
textStyles: MarkdownTextStyles;
}
const USER_ROW_HEIGHT = 40;
const MAX_USERS_DISPLAYED = 5;
const BOTTOM_SHEET_MAX_HEIGHT = '80%';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
participantsContainer: {
flexDirection: 'row',
},
icon: {
marginRight: 5,
},
listHeader: {
marginBottom: 12,
},
listHeaderText: {
color: theme.centerChannelColor,
...typography('Heading', 600, 'SemiBold'),
},
}));
const Participants = ({baseTextStyle, participantIds, users, location, textStyles}: Props) => {
const intl = useIntl();
const theme = useTheme();
const isTablet = useIsTablet();
const style = getStyleSheet(theme);
const participants = intl.formatMessage({
id: 'playbooks.status_update_post.participants',
defaultMessage: '{numParticipants, number} {numParticipants, plural, =1 {participant} other {participants}}',
}, {numParticipants: participantIds.length});
const showParticipantsList = usePreventDoubleTap(useCallback(() => {
const bottomSheetTitle = intl.formatMessage({
id: 'playbooks.playbook_run.participants_title',
defaultMessage: 'Run Participants',
});
const renderContent = () => (
<>
{!isTablet && (
<View style={style.listHeader}>
<Text style={style.listHeaderText}>
{bottomSheetTitle}
</Text>
</View>
)}
<UsersList
location={location}
users={users}
/>
</>
);
let height = bottomSheetSnapPoint(Math.min(users.length, MAX_USERS_DISPLAYED), USER_ROW_HEIGHT) + TITLE_HEIGHT;
if (Platform.OS === 'android') {
height += BOTTOM_SHEET_ANDROID_OFFSET;
}
const snapPoints: Array<string | number> = [1, height];
if (users.length > MAX_USERS_DISPLAYED) {
snapPoints.push(BOTTOM_SHEET_MAX_HEIGHT);
}
bottomSheet({
closeButtonId: 'close-set-user-status',
renderContent,
initialSnapIndex: 1,
snapPoints,
title: bottomSheetTitle,
theme,
});
}, [users, intl, theme, isTablet, style.listHeader, style.listHeaderText, location]));
return (
<TouchableOpacity
onPress={showParticipantsList}
style={style.participantsContainer}
>
<CompassIcon
name='account-multiple-outline'
size={14}
color={changeOpacity(theme.centerChannelColor, 0.64)}
style={style.icon}
/>
<Markdown
baseTextStyle={baseTextStyle}
value={participants}
textStyles={textStyles}
theme={theme}
location={location}
/>
</TouchableOpacity>
);
};
export default Participants;

View file

@ -2,3 +2,4 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
export const PLAYBOOKS_PLUGIN_ID = 'playbooks'; export const PLAYBOOKS_PLUGIN_ID = 'playbooks';
export const PLAYBOOKS_UPDATE_STATUS_POST_TYPE = 'custom_run_update';

View file

@ -35,6 +35,7 @@ type Props = {
componentId: AvailableScreens; componentId: AvailableScreens;
isBookmarksEnabled: boolean; isBookmarksEnabled: boolean;
isCallsEnabledInChannel: boolean; isCallsEnabledInChannel: boolean;
isPlaybooksEnabled: boolean;
groupCallsAllowed: boolean; groupCallsAllowed: boolean;
canManageMembers: boolean; canManageMembers: boolean;
isConvertGMFeatureAvailable: boolean; isConvertGMFeatureAvailable: boolean;
@ -70,6 +71,7 @@ const ChannelInfo = ({
componentId, componentId,
isBookmarksEnabled, isBookmarksEnabled,
isCallsEnabledInChannel, isCallsEnabledInChannel,
isPlaybooksEnabled,
groupCallsAllowed, groupCallsAllowed,
isConvertGMFeatureAvailable, isConvertGMFeatureAvailable,
isCRTEnabled, isCRTEnabled,
@ -139,6 +141,7 @@ const ChannelInfo = ({
canManageMembers={canManageMembers} canManageMembers={canManageMembers}
isCRTEnabled={isCRTEnabled} isCRTEnabled={isCRTEnabled}
canManageSettings={canManageSettings} canManageSettings={canManageSettings}
isPlaybooksEnabled={isPlaybooksEnabled}
/> />
<View style={styles.separator}/> <View style={styles.separator}/>
{convertGMOptionAvailable && {convertGMOptionAvailable &&

View file

@ -8,6 +8,7 @@ import {distinctUntilChanged, switchMap, combineLatestWith} from 'rxjs/operators
import {observeIsCallsEnabledInChannel} from '@calls/observers'; import {observeIsCallsEnabledInChannel} from '@calls/observers';
import {observeCallsConfig} from '@calls/state'; import {observeCallsConfig} from '@calls/state';
import {withServerUrl} from '@context/server'; import {withServerUrl} from '@context/server';
import {observeIsPlaybooksEnabled} from '@playbooks/database/queries/version';
import {observeCurrentChannel} from '@queries/servers/channel'; import {observeCurrentChannel} from '@queries/servers/channel';
import {observeCanAddBookmarks} from '@queries/servers/channel_bookmark'; import {observeCanAddBookmarks} from '@queries/servers/channel_bookmark';
import {observeCanManageChannelMembers, observeCanManageChannelSettings} from '@queries/servers/role'; import {observeCanManageChannelMembers, observeCanManageChannelSettings} from '@queries/servers/role';
@ -146,6 +147,8 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => {
}), }),
); );
const isPlaybooksEnabled = observeIsPlaybooksEnabled(database);
return { return {
type, type,
canEnableDisableCalls, canEnableDisableCalls,
@ -158,6 +161,7 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => {
isCRTEnabled: observeIsCRTEnabled(database), isCRTEnabled: observeIsCRTEnabled(database),
isGuestUser, isGuestUser,
isConvertGMFeatureAvailable, isConvertGMFeatureAvailable,
isPlaybooksEnabled,
}; };
}); });

View file

@ -28,6 +28,7 @@ describe('ChannelInfoOptions', () => {
canManageMembers: false, canManageMembers: false,
isCRTEnabled: false, isCRTEnabled: false,
canManageSettings: false, canManageSettings: false,
isPlaybooksEnabled: true,
}; };
} }
beforeEach(async () => { beforeEach(async () => {

View file

@ -23,6 +23,7 @@ type Props = {
callsEnabled: boolean; callsEnabled: boolean;
canManageMembers: boolean; canManageMembers: boolean;
isCRTEnabled: boolean; isCRTEnabled: boolean;
isPlaybooksEnabled: boolean;
canManageSettings: boolean; canManageSettings: boolean;
} }
@ -32,6 +33,7 @@ const Options = ({
callsEnabled, callsEnabled,
canManageMembers, canManageMembers,
isCRTEnabled, isCRTEnabled,
isPlaybooksEnabled,
canManageSettings, canManageSettings,
}: Props) => { }: Props) => {
const isDMorGM = isTypeDMorGM(type); const isDMorGM = isTypeDMorGM(type);
@ -49,10 +51,12 @@ const Options = ({
<NotificationPreference channelId={channelId}/> <NotificationPreference channelId={channelId}/>
<PinnedMessages channelId={channelId}/> <PinnedMessages channelId={channelId}/>
<ChannelFiles channelId={channelId}/> <ChannelFiles channelId={channelId}/>
{isPlaybooksEnabled &&
<PlaybookRunsOption <PlaybookRunsOption
channelId={channelId} channelId={channelId}
location='channel_actions' location='channel_actions'
/> />
}
{type !== General.DM_CHANNEL && {type !== General.DM_CHANNEL &&
<Members channelId={channelId}/> <Members channelId={channelId}/>
} }

View file

@ -19,7 +19,7 @@ import SecurityManager from '@managers/security_manager';
import {getAllServers} from '@queries/app/servers'; import {getAllServers} from '@queries/app/servers';
import {findChannels, popToRoot} from '@screens/navigation'; import {findChannels, popToRoot} from '@screens/navigation';
import NavigationStore from '@store/navigation_store'; import NavigationStore from '@store/navigation_store';
import {alertInvalidDeepLink, handleDeepLink} from '@utils/deep_link'; import {alertInvalidDeepLink, parseAndHandleDeepLink} from '@utils/deep_link';
import {logError} from '@utils/log'; import {logError} from '@utils/log';
import {alertChannelArchived, alertChannelRemove, alertTeamRemove} from '@utils/navigation'; import {alertChannelArchived, alertChannelRemove, alertTeamRemove} from '@utils/navigation';
import {notificationError} from '@utils/notification'; import {notificationError} from '@utils/notification';
@ -136,7 +136,7 @@ export function HomeScreen(props: HomeProps) {
const deepLink = props.extra as DeepLinkWithData; const deepLink = props.extra as DeepLinkWithData;
if (deepLink?.url) { if (deepLink?.url) {
handleDeepLink(deepLink.url, intl, props.componentId, true).then((result) => { parseAndHandleDeepLink(deepLink.url, intl, props.componentId, true).then((result) => {
if (result.error) { if (result.error) {
alertInvalidDeepLink(intl); alertInvalidDeepLink(intl);
} }

View file

@ -20,7 +20,7 @@ import {addNewServer} from '@utils/server';
import {alertErrorWithFallback, errorBadChannel, errorUnkownUser} from '../draft'; import {alertErrorWithFallback, errorBadChannel, errorUnkownUser} from '../draft';
import {alertInvalidDeepLink, extractServerUrl, getLaunchPropsFromDeepLink, handleDeepLink} from '.'; import {alertInvalidDeepLink, extractServerUrl, getLaunchPropsFromDeepLink, parseAndHandleDeepLink} from '.';
jest.mock('@actions/remote/user', () => ({ jest.mock('@actions/remote/user', () => ({
fetchUsersByUsernames: jest.fn(), fetchUsersByUsernames: jest.fn(),
@ -94,7 +94,7 @@ describe('extractServerUrl', () => {
}); });
}); });
describe('handleDeepLink', () => { describe('parseAndHandleDeepLink', () => {
const intl = createIntl({locale: 'en', messages: {}}); const intl = createIntl({locale: 'en', messages: {}});
beforeEach(() => { beforeEach(() => {
@ -102,14 +102,14 @@ describe('handleDeepLink', () => {
}); });
it('should return error for invalid deep link', async () => { it('should return error for invalid deep link', async () => {
const result = await handleDeepLink('invalid-url'); const result = await parseAndHandleDeepLink('invalid-url');
expect(result).toEqual({error: true}); expect(result).toEqual({error: true});
}); });
it('should add new server if not existing', async () => { it('should add new server if not existing', async () => {
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com'); (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com');
(DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce(''); (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('');
const result = await handleDeepLink('https://newserver.com/team/channels/town-square'); const result = await parseAndHandleDeepLink('https://newserver.com/team/channels/town-square');
expect(addNewServer).toHaveBeenCalledWith(Preferences.THEMES.denim, 'newserver.com', undefined, {type: DeepLink.Channel, expect(addNewServer).toHaveBeenCalledWith(Preferences.THEMES.denim, 'newserver.com', undefined, {type: DeepLink.Channel,
data: { data: {
serverUrl: 'newserver.com', serverUrl: 'newserver.com',
@ -124,7 +124,7 @@ describe('handleDeepLink', () => {
it('should handle existing server and switch to home screen', async () => { it('should handle existing server and switch to home screen', async () => {
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com'); (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com');
(DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com');
const result = await handleDeepLink('https://existingserver.com/team/channels/town-square'); const result = await parseAndHandleDeepLink('https://existingserver.com/team/channels/town-square');
expect(dismissAllModalsAndPopToRoot).toHaveBeenCalled(); expect(dismissAllModalsAndPopToRoot).toHaveBeenCalled();
expect(DatabaseManager.setActiveServerDatabase).toHaveBeenCalledWith('https://existingserver.com'); expect(DatabaseManager.setActiveServerDatabase).toHaveBeenCalledWith('https://existingserver.com');
expect(WebsocketManager.initializeClient).toHaveBeenCalledWith('https://existingserver.com', 'DeepLink'); expect(WebsocketManager.initializeClient).toHaveBeenCalledWith('https://existingserver.com', 'DeepLink');
@ -136,7 +136,8 @@ describe('handleDeepLink', () => {
(DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce(null); (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce(null);
(NavigationStore.getVisibleScreen as jest.Mock).mockReturnValueOnce(Screens.SERVER); (NavigationStore.getVisibleScreen as jest.Mock).mockReturnValueOnce(Screens.SERVER);
const result = await handleDeepLink('https://currentserver.com/team/channels/town-square', undefined, undefined, true); const result = await parseAndHandleDeepLink('https://currentserver.com/team/channels/town-square', undefined, undefined, true);
console.log('result', JSON.stringify(result, null, 2));
const spyOnUpdateProps = jest.spyOn(Navigation, 'updateProps'); const spyOnUpdateProps = jest.spyOn(Navigation, 'updateProps');
expect(spyOnUpdateProps).toHaveBeenCalledWith(Screens.SERVER, {serverUrl: 'currentserver.com'}); expect(spyOnUpdateProps).toHaveBeenCalledWith(Screens.SERVER, {serverUrl: 'currentserver.com'});
expect(result).toEqual({error: false}); expect(result).toEqual({error: false});
@ -148,7 +149,7 @@ describe('handleDeepLink', () => {
(NavigationStore.getVisibleScreen as jest.Mock).mockReturnValueOnce(Screens.LOGIN); (NavigationStore.getVisibleScreen as jest.Mock).mockReturnValueOnce(Screens.LOGIN);
(NavigationStore.getScreensInStack as jest.Mock).mockReturnValueOnce([Screens.SERVER, Screens.LOGIN]); (NavigationStore.getScreensInStack as jest.Mock).mockReturnValueOnce([Screens.SERVER, Screens.LOGIN]);
const result = await handleDeepLink('https://currentserver.com/team/channels/town-square', undefined, undefined, true); const result = await parseAndHandleDeepLink('https://currentserver.com/team/channels/town-square', undefined, undefined, true);
expect(addNewServer).not.toHaveBeenCalled(); expect(addNewServer).not.toHaveBeenCalled();
expect(result).toEqual({error: false}); expect(result).toEqual({error: false});
}); });
@ -156,7 +157,7 @@ describe('handleDeepLink', () => {
it('should switch to channel by name for Channel deep link', async () => { it('should switch to channel by name for Channel deep link', async () => {
(DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com');
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com');
const result = await handleDeepLink('https://existingserver.com/team/channels/town-square', intl); const result = await parseAndHandleDeepLink('https://existingserver.com/team/channels/town-square', intl);
expect(switchToChannelByName).toHaveBeenCalledWith('https://existingserver.com', 'town-square', 'team', errorBadChannel, intl); expect(switchToChannelByName).toHaveBeenCalledWith('https://existingserver.com', 'town-square', 'team', errorBadChannel, intl);
expect(result).toEqual({error: false}); expect(result).toEqual({error: false});
}); });
@ -165,7 +166,7 @@ describe('handleDeepLink', () => {
(DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com');
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com');
(queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => ['user-id'])}); (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => ['user-id'])});
const result = await handleDeepLink('https://existingserver.com/team/messages/@user-id', intl); const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/@user-id', intl);
expect(makeDirectChannel).toHaveBeenCalledWith('https://existingserver.com', 'user-id', '', true); expect(makeDirectChannel).toHaveBeenCalledWith('https://existingserver.com', 'user-id', '', true);
expect(result).toEqual({error: false}); expect(result).toEqual({error: false});
}); });
@ -175,7 +176,7 @@ describe('handleDeepLink', () => {
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com');
(queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => [])}); (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => [])});
(fetchUsersByUsernames as jest.Mock).mockResolvedValueOnce({users: [{id: 'user-id'}]}); (fetchUsersByUsernames as jest.Mock).mockResolvedValueOnce({users: [{id: 'user-id'}]});
const result = await handleDeepLink('https://existingserver.com/team/messages/@user-id', intl); const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/@user-id', intl);
expect(makeDirectChannel).toHaveBeenCalledWith('https://existingserver.com', 'user-id', '', true); expect(makeDirectChannel).toHaveBeenCalledWith('https://existingserver.com', 'user-id', '', true);
expect(result).toEqual({error: false}); expect(result).toEqual({error: false});
}); });
@ -185,7 +186,7 @@ describe('handleDeepLink', () => {
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com');
(queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => [])}); (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => [])});
(fetchUsersByUsernames as jest.Mock).mockResolvedValueOnce({users: []}); (fetchUsersByUsernames as jest.Mock).mockResolvedValueOnce({users: []});
const result = await handleDeepLink('https://existingserver.com/team/messages/@user-id', intl); const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/@user-id', intl);
expect(errorUnkownUser).toHaveBeenCalledWith(intl); expect(errorUnkownUser).toHaveBeenCalledWith(intl);
expect(result).toEqual({error: false}); expect(result).toEqual({error: false});
}); });
@ -193,7 +194,7 @@ describe('handleDeepLink', () => {
it('should switch to group message channel for GroupMessage deep link', async () => { it('should switch to group message channel for GroupMessage deep link', async () => {
(DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com');
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com');
const result = await handleDeepLink('https://existingserver.com/team/messages/7b35c77a645e1906e03a2c330f89203385db102f', intl); const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/7b35c77a645e1906e03a2c330f89203385db102f', intl);
expect(switchToChannelByName).toHaveBeenCalledWith('https://existingserver.com', '7b35c77a645e1906e03a2c330f89203385db102f', 'team', errorBadChannel, intl); expect(switchToChannelByName).toHaveBeenCalledWith('https://existingserver.com', '7b35c77a645e1906e03a2c330f89203385db102f', 'team', errorBadChannel, intl);
expect(result).toEqual({error: false}); expect(result).toEqual({error: false});
}); });
@ -202,7 +203,7 @@ describe('handleDeepLink', () => {
(DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com');
(getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com');
const postid = '7b35c77a645e1906e03a2c330f'; const postid = '7b35c77a645e1906e03a2c330f';
const result = await handleDeepLink(`https://existingserver.com/team/pl/${postid}`, intl); const result = await parseAndHandleDeepLink(`https://existingserver.com/team/pl/${postid}`, intl);
expect(showPermalink).toHaveBeenCalledWith('https://existingserver.com', 'team', postid); expect(showPermalink).toHaveBeenCalledWith('https://existingserver.com', 'team', postid);
expect(result).toEqual({error: false}); expect(result).toEqual({error: false});
}); });
@ -211,7 +212,7 @@ describe('handleDeepLink', () => {
(getActiveServerUrl as jest.Mock).mockImplementationOnce(() => { (getActiveServerUrl as jest.Mock).mockImplementationOnce(() => {
throw new Error('DB does not exist error'); throw new Error('DB does not exist error');
}); });
const result = await handleDeepLink('https://existingserver.com/team/messages/7b35c77a645e1906e03a2c330f89203385db102f'); const result = await parseAndHandleDeepLink('https://existingserver.com/team/messages/7b35c77a645e1906e03a2c330f89203385db102f');
expect(logError).toHaveBeenCalledWith('Failed to open channel from deeplink', expect.any(Error), undefined); expect(logError).toHaveBeenCalledWith('Failed to open channel from deeplink', expect.any(Error), undefined);
expect(result).toEqual({error: true}); expect(result).toEqual({error: true});
}); });

View file

@ -3,6 +3,7 @@
import {match} from 'path-to-regexp'; import {match} from 'path-to-regexp';
import {type IntlShape} from 'react-intl'; import {type IntlShape} from 'react-intl';
import {Alert} from 'react-native';
import {Navigation} from 'react-native-navigation'; import {Navigation} from 'react-native-navigation';
import urlParse from 'url-parse'; import urlParse from 'url-parse';
@ -15,6 +16,10 @@ import {getDefaultThemeByAppearance} from '@context/theme';
import DatabaseManager from '@database/manager'; import DatabaseManager from '@database/manager';
import {DEFAULT_LOCALE, t} from '@i18n'; import {DEFAULT_LOCALE, t} from '@i18n';
import WebsocketManager from '@managers/websocket_manager'; import WebsocketManager from '@managers/websocket_manager';
import {fetchPlaybookRun} from '@playbooks/actions/remote/runs';
import {getPlaybookRunById} from '@playbooks/database/queries/run';
import {fetchIsPlaybooksEnabled} from '@playbooks/database/queries/version';
import {goToPlaybookRun} from '@playbooks/screens/navigation';
import {getActiveServerUrl} from '@queries/app/servers'; import {getActiveServerUrl} from '@queries/app/servers';
import {getCurrentUser, queryUsersByUsername} from '@queries/servers/user'; import {getCurrentUser, queryUsersByUsername} from '@queries/servers/user';
import {dismissAllModalsAndPopToRoot} from '@screens/navigation'; import {dismissAllModalsAndPopToRoot} from '@screens/navigation';
@ -32,28 +37,27 @@ import {
ID_PATH_PATTERN, ID_PATH_PATTERN,
} from '@utils/url/path'; } from '@utils/url/path';
import type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkWithData, LaunchProps} from '@typings/launch'; import type {DeepLinkChannel, DeepLinkDM, DeepLinkGM, DeepLinkPermalink, DeepLinkPlaybookRuns, DeepLinkWithData, LaunchProps} from '@typings/launch';
import type {AvailableScreens} from '@typings/screens/navigation'; import type {AvailableScreens} from '@typings/screens/navigation';
const deepLinkScreens: AvailableScreens[] = [Screens.HOME, Screens.CHANNEL, Screens.GLOBAL_THREADS, Screens.THREAD]; const deepLinkScreens: AvailableScreens[] = [Screens.HOME, Screens.CHANNEL, Screens.GLOBAL_THREADS, Screens.THREAD];
export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, location?: string, asServer = false) { export async function handleDeepLink(deepLink: DeepLinkWithData, intlShape?: IntlShape, location?: string) {
try { try {
const parsed = parseDeepLink(deepLinkUrl, asServer); if (deepLink.type === DeepLink.Invalid || !deepLink.data || !deepLink.data.serverUrl) {
if (parsed.type === DeepLink.Invalid || !parsed.data || !parsed.data.serverUrl) {
return {error: true}; return {error: true};
} }
const currentServerUrl = await getActiveServerUrl(); const currentServerUrl = await getActiveServerUrl();
const existingServerUrl = DatabaseManager.searchUrl(parsed.data.serverUrl); const existingServerUrl = DatabaseManager.searchUrl(deepLink.data.serverUrl);
// After checking the server for http & https then we add it // After checking the server for http & https then we add it
if (!existingServerUrl) { if (!existingServerUrl) {
const theme = EphemeralStore.theme || getDefaultThemeByAppearance(); const theme = EphemeralStore.theme || getDefaultThemeByAppearance();
if (NavigationStore.getVisibleScreen() === Screens.SERVER) { if (NavigationStore.getVisibleScreen() === Screens.SERVER) {
Navigation.updateProps(Screens.SERVER, {serverUrl: parsed.data.serverUrl}); Navigation.updateProps(Screens.SERVER, {serverUrl: deepLink.data.serverUrl});
} else if (!NavigationStore.getScreensInStack().includes(Screens.SERVER)) { } else if (!NavigationStore.getScreensInStack().includes(Screens.SERVER)) {
addNewServer(theme, parsed.data.serverUrl, undefined, parsed); addNewServer(theme, deepLink.data.serverUrl, undefined, deepLink);
} }
return {error: false}; return {error: false};
} }
@ -70,14 +74,14 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape,
const locale = currentUser?.locale || DEFAULT_LOCALE; const locale = currentUser?.locale || DEFAULT_LOCALE;
const intl = intlShape || getIntlShape(locale); const intl = intlShape || getIntlShape(locale);
switch (parsed.type) { switch (deepLink.type) {
case DeepLink.Channel: { case DeepLink.Channel: {
const deepLinkData = parsed.data as DeepLinkChannel; const deepLinkData = deepLink.data as DeepLinkChannel;
switchToChannelByName(existingServerUrl, deepLinkData.channelName, deepLinkData.teamName, errorBadChannel, intl); switchToChannelByName(existingServerUrl, deepLinkData.channelName, deepLinkData.teamName, errorBadChannel, intl);
break; break;
} }
case DeepLink.DirectMessage: { case DeepLink.DirectMessage: {
const deepLinkData = parsed.data as DeepLinkDM; const deepLinkData = deepLink.data as DeepLinkDM;
const userIds = await queryUsersByUsername(database, [deepLinkData.userName]).fetchIds(); const userIds = await queryUsersByUsername(database, [deepLinkData.userName]).fetchIds();
let userId = userIds.length ? userIds[0] : undefined; let userId = userIds.length ? userIds[0] : undefined;
if (!userId) { if (!userId) {
@ -95,12 +99,12 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape,
break; break;
} }
case DeepLink.GroupMessage: { case DeepLink.GroupMessage: {
const deepLinkData = parsed.data as DeepLinkGM; const deepLinkData = deepLink.data as DeepLinkGM;
switchToChannelByName(existingServerUrl, deepLinkData.channelName, deepLinkData.teamName, errorBadChannel, intl); switchToChannelByName(existingServerUrl, deepLinkData.channelName, deepLinkData.teamName, errorBadChannel, intl);
break; break;
} }
case DeepLink.Permalink: { case DeepLink.Permalink: {
const deepLinkData = parsed.data as DeepLinkPermalink; const deepLinkData = deepLink.data as DeepLinkPermalink;
if ( if (
NavigationStore.hasModalsOpened() || NavigationStore.hasModalsOpened() ||
!deepLinkScreens.includes(NavigationStore.getVisibleScreen()) !deepLinkScreens.includes(NavigationStore.getVisibleScreen())
@ -110,6 +114,49 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape,
showPermalink(existingServerUrl, deepLinkData.teamName, deepLinkData.postId); showPermalink(existingServerUrl, deepLinkData.teamName, deepLinkData.postId);
break; break;
} }
case DeepLink.Playbooks: {
// Alert that playbooks should be access from the webapp or desktop app
Alert.alert(
intl.formatMessage({id: 'playbooks.only_runs_available.title', defaultMessage: 'Playbooks not available'}),
intl.formatMessage({id: 'playbooks.only_runs_available.description', defaultMessage: 'Only Playbook Runs are available on mobile. To access the Playbook, please use the desktop or web app.'}),
[{
text: intl.formatMessage({id: 'playbooks.only_runs_available.ok', defaultMessage: 'OK'}),
}],
);
break;
}
case DeepLink.PlaybookRuns: {
const deepLinkData = deepLink.data as DeepLinkPlaybookRuns;
const playbookEnabled = await fetchIsPlaybooksEnabled(database);
if (playbookEnabled) {
// Go to playbook Run
const playbook = await getPlaybookRunById(database, deepLinkData.playbookRunId);
if (!playbook) {
const {error} = await fetchPlaybookRun(existingServerUrl, deepLinkData.playbookRunId);
if (error) {
Alert.alert(
intl.formatMessage({id: 'playbooks.fetch_error.title', defaultMessage: 'Unable to open Run'}),
intl.formatMessage({id: 'playbooks.fetch_error.description', defaultMessage: "You don't have permission to view this run, or it may no longer exist."}),
[{
text: intl.formatMessage({id: 'playbooks.fetch_error.OK', defaultMessage: 'Okay'}),
}],
);
break;
}
}
goToPlaybookRun(intl, deepLinkData.playbookRunId);
} else {
// Alert playbooks not enabled or version not supported
Alert.alert(
intl.formatMessage({id: 'playbooks.not_enabled_or_unsupported.title', defaultMessage: 'Playbooks not available'}),
intl.formatMessage({id: 'playbooks.not_enabled_or_unsupported.description', defaultMessage: 'Playbooks are either not enabled on this server or the Playbooks version is not supported. Please contact your system administrator.'}),
[{
text: intl.formatMessage({id: 'playbooks.not_enabled_or_unsupported.OK', defaultMessage: 'OK'}),
}],
);
}
break;
}
} }
return {error: false}; return {error: false};
} catch (error) { } catch (error) {
@ -118,6 +165,11 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape,
} }
} }
export async function parseAndHandleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, location?: string, asServer = false) {
const parsed = parseDeepLink(deepLinkUrl, asServer);
return handleDeepLink(parsed, intlShape, location);
}
type ChannelPathParams = { type ChannelPathParams = {
hostname: string; hostname: string;
serverUrl: string[]; serverUrl: string[];
@ -129,6 +181,22 @@ type ChannelPathParams = {
const CHANNEL_PATH = '*serverUrl/:teamName/:path/:identifier'; const CHANNEL_PATH = '*serverUrl/:teamName/:path/:identifier';
export const matchChannelDeeplink = match<ChannelPathParams>(CHANNEL_PATH); export const matchChannelDeeplink = match<ChannelPathParams>(CHANNEL_PATH);
type PlaybooksPathParams = {
serverUrl: string[];
playbookId: string;
};
const PLAYBOOKS_PATH = '*serverUrl/playbooks/playbooks/:playbookId';
export const matchPlaybooksDeeplink = match<PlaybooksPathParams>(PLAYBOOKS_PATH);
type PlaybookRunsPathParams = {
serverUrl: string[];
playbookRunId: string;
};
const PLAYBOOK_RUNS_PATH = '*serverUrl/playbooks/runs/:playbookRunId';
export const matchPlaybookRunsDeeplink = match<PlaybookRunsPathParams>(PLAYBOOK_RUNS_PATH);
type PermalinkPathParams = { type PermalinkPathParams = {
serverUrl: string[]; serverUrl: string[];
teamName: string; teamName: string;
@ -204,14 +272,16 @@ function isValidIdentifierPathPattern(id: string): boolean {
return regex.test(id); return regex.test(id);
} }
function isValidPostId(id: string): boolean { function isValidId(id: string): boolean {
const regex = new RegExp(`^${ID_PATH_PATTERN}$`); const regex = new RegExp(`^${ID_PATH_PATTERN}$`);
return regex.test(id); return regex.test(id);
} }
export function parseDeepLink(deepLinkUrl: string, asServer = false): DeepLinkWithData { export function parseDeepLink(deepLinkUrl: string, asServer = false): DeepLinkWithData {
try { try {
const url = removeProtocol(deepLinkUrl); const parsedUrl = urlParse(deepLinkUrl);
const urlWithoutQuery = stripTrailingSlashes(parsedUrl.protocol + '//' + parsedUrl.host + parsedUrl.pathname);
const url = removeProtocol(urlWithoutQuery);
const channelMatch = matchChannelDeeplink(url); const channelMatch = matchChannelDeeplink(url);
if (channelMatch && isValidTeamName(channelMatch.params.teamName) && isValidIdentifierPathPattern(channelMatch.params.identifier)) { if (channelMatch && isValidTeamName(channelMatch.params.teamName) && isValidIdentifierPathPattern(channelMatch.params.identifier)) {
@ -231,11 +301,23 @@ export function parseDeepLink(deepLinkUrl: string, asServer = false): DeepLinkWi
} }
const permalinkMatch = matchPermalinkDeeplink(url); const permalinkMatch = matchPermalinkDeeplink(url);
if (permalinkMatch && isValidTeamName(permalinkMatch.params.teamName) && isValidPostId(permalinkMatch.params.postId)) { if (permalinkMatch && isValidTeamName(permalinkMatch.params.teamName) && isValidId(permalinkMatch.params.postId)) {
const {params: {serverUrl, teamName, postId}} = permalinkMatch; const {params: {serverUrl, teamName, postId}} = permalinkMatch;
return {type: DeepLink.Permalink, url: deepLinkUrl, data: {serverUrl: serverUrl.join('/'), teamName, postId}}; return {type: DeepLink.Permalink, url: deepLinkUrl, data: {serverUrl: serverUrl.join('/'), teamName, postId}};
} }
const playbooksMatch = matchPlaybooksDeeplink(url);
if (playbooksMatch && isValidId(playbooksMatch.params.playbookId)) {
const {params: {serverUrl, playbookId}} = playbooksMatch;
return {type: DeepLink.Playbooks, url: deepLinkUrl, data: {serverUrl: serverUrl.join('/'), playbookId}};
}
const playbooksRunsMatch = matchPlaybookRunsDeeplink(url);
if (playbooksRunsMatch && isValidId(playbooksRunsMatch.params.playbookRunId)) {
const {params: {serverUrl, playbookRunId}} = playbooksRunsMatch;
return {type: DeepLink.PlaybookRuns, url: deepLinkUrl, data: {serverUrl: serverUrl.join('/'), playbookRunId}};
}
if (asServer) { if (asServer) {
const serverMatch = extractServerUrl(url); const serverMatch = extractServerUrl(url);
if (serverMatch) { if (serverMatch) {

View file

@ -4,7 +4,7 @@
import {createIntl} from 'react-intl'; import {createIntl} from 'react-intl';
import {Alert} from 'react-native'; import {Alert} from 'react-native';
import {onOpenLinkError, openLink} from './links'; import * as Links from './links';
describe('onOpenLinkError', () => { describe('onOpenLinkError', () => {
const intl = createIntl({ const intl = createIntl({
@ -26,7 +26,7 @@ describe('onOpenLinkError', () => {
mockReturnValueOnce('Error'). mockReturnValueOnce('Error').
mockReturnValueOnce('Unable to open the link.'); // Mock message mockReturnValueOnce('Unable to open the link.'); // Mock message
onOpenLinkError(intl); Links.onOpenLinkError(intl);
expect(intl.formatMessage).toHaveBeenCalledTimes(2); expect(intl.formatMessage).toHaveBeenCalledTimes(2);
expect(intl.formatMessage).toHaveBeenCalledWith({ expect(intl.formatMessage).toHaveBeenCalledWith({
@ -55,7 +55,7 @@ describe('openLink', () => {
it('should call normalizeProtocol and return early if the URL is invalid', async () => { it('should call normalizeProtocol and return early if the URL is invalid', async () => {
const normalizeProtocolMock = jest.spyOn(require('.'), 'normalizeProtocol').mockReturnValue(null); const normalizeProtocolMock = jest.spyOn(require('.'), 'normalizeProtocol').mockReturnValue(null);
await openLink('', 'https://server-url.com', 'https://site-url.com', intl); await Links.openLink('', 'https://server-url.com', 'https://site-url.com', intl);
expect(normalizeProtocolMock).toHaveBeenCalledWith(''); expect(normalizeProtocolMock).toHaveBeenCalledWith('');
normalizeProtocolMock.mockRestore(); normalizeProtocolMock.mockRestore();
@ -66,11 +66,11 @@ describe('openLink', () => {
const matchDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'matchDeepLink').mockReturnValue({url: 'https://example.com'}); const matchDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'matchDeepLink').mockReturnValue({url: 'https://example.com'});
const handleDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'handleDeepLink').mockResolvedValue({error: null}); const handleDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'handleDeepLink').mockResolvedValue({error: null});
await openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl); await Links.openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl);
expect(normalizeProtocolMock).toHaveBeenCalledWith('https://example.com'); expect(normalizeProtocolMock).toHaveBeenCalledWith('https://example.com');
expect(matchDeepLinkMock).toHaveBeenCalledWith('https://example.com', 'https://server-url.com', 'https://site-url.com'); expect(matchDeepLinkMock).toHaveBeenCalledWith('https://example.com', 'https://server-url.com', 'https://site-url.com');
expect(handleDeepLinkMock).toHaveBeenCalledWith('https://example.com', intl); expect(handleDeepLinkMock).toHaveBeenCalledWith({url: 'https://example.com'}, intl);
normalizeProtocolMock.mockRestore(); normalizeProtocolMock.mockRestore();
matchDeepLinkMock.mockRestore(); matchDeepLinkMock.mockRestore();
@ -83,9 +83,9 @@ describe('openLink', () => {
const handleDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'handleDeepLink').mockResolvedValue({error: true}); const handleDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'handleDeepLink').mockResolvedValue({error: true});
const tryOpenURLMock = jest.spyOn(require('.'), 'tryOpenURL').mockImplementation(() => {}); const tryOpenURLMock = jest.spyOn(require('.'), 'tryOpenURL').mockImplementation(() => {});
await openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl); await Links.openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl);
expect(tryOpenURLMock).toHaveBeenCalledWith('https://example.com', onOpenLinkError); expect(tryOpenURLMock).toHaveBeenCalledWith('https://example.com', expect.any(Function));
normalizeProtocolMock.mockRestore(); normalizeProtocolMock.mockRestore();
matchDeepLinkMock.mockRestore(); matchDeepLinkMock.mockRestore();
@ -98,9 +98,9 @@ describe('openLink', () => {
const matchDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'matchDeepLink').mockReturnValue(null); const matchDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'matchDeepLink').mockReturnValue(null);
const tryOpenURLMock = jest.spyOn(require('.'), 'tryOpenURL').mockImplementation(() => {}); const tryOpenURLMock = jest.spyOn(require('.'), 'tryOpenURL').mockImplementation(() => {});
await openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl); await Links.openLink('https://example.com', 'https://server-url.com', 'https://site-url.com', intl);
expect(tryOpenURLMock).toHaveBeenCalledWith('https://example.com', onOpenLinkError); expect(tryOpenURLMock).toHaveBeenCalledWith('https://example.com', expect.any(Function));
normalizeProtocolMock.mockRestore(); normalizeProtocolMock.mockRestore();
matchDeepLinkMock.mockRestore(); matchDeepLinkMock.mockRestore();

View file

@ -31,11 +31,15 @@ export const openLink = async (link: string, serverUrl: string, siteURL: string,
const match = matchDeepLink(url, serverUrl, siteURL); const match = matchDeepLink(url, serverUrl, siteURL);
if (match) { if (match) {
const {error} = await handleDeepLink(match.url, intl); const {error} = await handleDeepLink(match, intl);
if (error) { if (error) {
tryOpenURL(match.url, onOpenLinkError); tryOpenURL(match.url, () => {
onOpenLinkError(intl);
});
} }
} else { } else {
tryOpenURL(url, onOpenLinkError); tryOpenURL(url, () => {
onOpenLinkError(intl);
});
} }
}; };

View file

@ -996,6 +996,15 @@
"playbook.participants": "Run Participants", "playbook.participants": "Run Participants",
"playbook.runs.finished": "Finished", "playbook.runs.finished": "Finished",
"playbook.runs.in-progress": "In Progress", "playbook.runs.in-progress": "In Progress",
"playbooks.fetch_error.description": "You don't have permission to view this run, or it may no longer exist.",
"playbooks.fetch_error.OK": "Okay",
"playbooks.fetch_error.title": "Unable to open Run",
"playbooks.not_enabled_or_unsupported.description": "Playbooks are either not enabled on this server or the Playbooks version is not supported. Please contact your system administrator.",
"playbooks.not_enabled_or_unsupported.OK": "OK",
"playbooks.not_enabled_or_unsupported.title": "Playbooks not available",
"playbooks.only_runs_available.description": "Only Playbook Runs are available on mobile. To access the Playbook, please use the desktop or web app.",
"playbooks.only_runs_available.ok": "OK",
"playbooks.only_runs_available.title": "Playbooks not available",
"playbooks.playbook_run.error.description": "Please check your network connection or try again later.", "playbooks.playbook_run.error.description": "Please check your network connection or try again later.",
"playbooks.playbook_run.error.title": "Unable to fetch run details", "playbooks.playbook_run.error.title": "Unable to fetch run details",
"playbooks.playbook_run.finished": "Finished", "playbooks.playbook_run.finished": "Finished",
@ -1015,6 +1024,9 @@
"playbooks.runs.in_progress.description": "When a run starts in this channel, youll see it here.", "playbooks.runs.in_progress.description": "When a run starts in this channel, youll see it here.",
"playbooks.runs.in_progress.title": "No in progress runs", "playbooks.runs.in_progress.title": "No in progress runs",
"playbooks.runs.show_more": "Show More", "playbooks.runs.show_more": "Show More",
"playbooks.status_update_post.num_tasks": "**{numTasksChecked, number}** of **{numTasks, number}** {numTasks, plural, =1 {task} other {tasks}} checked",
"playbooks.status_update_post.participants": "{numParticipants, number} {numParticipants, plural, =1 {participant} other {participants}}",
"playbooks.status_update_post.update": "@{authorUsername} posted an update for [{runName}]({link})",
"plus_menu.browse_channels.title": "Browse Channels", "plus_menu.browse_channels.title": "Browse Channels",
"plus_menu.create_new_channel.title": "Create New Channel", "plus_menu.create_new_channel.title": "Create New Channel",
"plus_menu.invite_people_to_team.title": "Invite people to the team", "plus_menu.invite_people_to_team.title": "Invite people to the team",

View file

@ -8,6 +8,8 @@ BETA_BUILD=true
BUILD_FOR_RELEASE=true BUILD_FOR_RELEASE=true
COLLECT_NETWORK_METRICS=true COLLECT_NETWORK_METRICS=true
EXTENSION_APP_IDENTIFIER=com.mattermost.rnbeta.MattermostShare EXTENSION_APP_IDENTIFIER=com.mattermost.rnbeta.MattermostShare
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT=120
FASTLANE_XCODEBUILD_SETTINGS_RETRIES=6
IOS_APP_GROUP=group.com.mattermost.rnbeta IOS_APP_GROUP=group.com.mattermost.rnbeta
IOS_BUILD_EXPORT_METHOD=app-store IOS_BUILD_EXPORT_METHOD=app-store
IOS_ICLOUD_CONTAINER=iCloud.com.mattermost.rnbeta IOS_ICLOUD_CONTAINER=iCloud.com.mattermost.rnbeta

View file

@ -9,6 +9,8 @@ BUILD_FOR_RELEASE=true
BUILD_PR=true BUILD_PR=true
COLLECT_NETWORK_METRICS=true COLLECT_NETWORK_METRICS=true
EXTENSION_APP_IDENTIFIER=com.mattermost.rnbeta.MattermostShare EXTENSION_APP_IDENTIFIER=com.mattermost.rnbeta.MattermostShare
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT=120
FASTLANE_XCODEBUILD_SETTINGS_RETRIES=6
IOS_APP_GROUP=group.com.mattermost.rnbeta IOS_APP_GROUP=group.com.mattermost.rnbeta
IOS_BUILD_EXPORT_METHOD=ad-hoc IOS_BUILD_EXPORT_METHOD=ad-hoc
IOS_ICLOUD_CONTAINER=iCloud.com.mattermost.rnbeta IOS_ICLOUD_CONTAINER=iCloud.com.mattermost.rnbeta

View file

@ -2,3 +2,5 @@
AWS_BUCKET_NAME=releases.mattermost.com AWS_BUCKET_NAME=releases.mattermost.com
AWS_FOLDER_NAME=mattermost-mobile-beta AWS_FOLDER_NAME=mattermost-mobile-beta
AWS_REGION=us-east-1 AWS_REGION=us-east-1
FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT=120
FASTLANE_XCODEBUILD_SETTINGS_RETRIES=6

View file

@ -27,7 +27,8 @@ type PostType =
| 'add_bot_teams_channels' | 'add_bot_teams_channels'
| 'system_auto_responder' | 'system_auto_responder'
| 'custom_calls' | 'custom_calls'
| 'custom_calls_recording'; | 'custom_calls_recording'
| 'custom_run_update';
type PostEmbedType = 'image' | 'message_attachment' | 'opengraph'; type PostEmbedType = 'image' | 'message_attachment' | 'opengraph';

View file

@ -33,12 +33,20 @@ export interface DeepLinkPlugin extends DeepLink {
route?: string; route?: string;
} }
export interface DeepLinkPlaybooks extends DeepLink {
playbookId: string;
}
export interface DeepLinkPlaybookRuns extends DeepLink {
playbookRunId: string;
}
export type DeepLinkType = typeof DeepLinkConstant[keyof typeof DeepLinkConstant]; export type DeepLinkType = typeof DeepLinkConstant[keyof typeof DeepLinkConstant];
export interface DeepLinkWithData { export interface DeepLinkWithData {
type: DeepLinkType; type: DeepLinkType;
url: string; url: string;
data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink | DeepLinkPlugin | DeepLinkServer; data?: DeepLinkChannel | DeepLinkDM | DeepLinkGM | DeepLinkPermalink | DeepLinkPlugin | DeepLinkServer | DeepLinkPlaybooks | DeepLinkPlaybookRuns;
} }
export type LaunchType = typeof Launch[keyof typeof Launch]; export type LaunchType = typeof Launch[keyof typeof Launch];