From 835322768e750ebc16ca4825afed2634f61be6b7 Mon Sep 17 00:00:00 2001 From: Mattermost Build Date: Tue, 29 Jul 2025 18:22:00 +0300 Subject: [PATCH] Playbooks update (#9039) (#9043) --- app/actions/remote/command.test.ts | 2 +- app/actions/remote/command.ts | 2 +- app/components/post_list/post/body/index.tsx | 10 ++ app/constants/deep_linking.ts | 2 + app/database/migration/server/index.ts | 6 + app/managers/global_event_handler.ts | 4 +- app/products/playbooks/actions/remote/runs.ts | 31 +++- app/products/playbooks/client/rest.ts | 14 +- .../components/status_update_post/index.tsx | 169 ++++++++++++++++++ .../status_update_post/participants/index.ts | 22 +++ .../participants/participants.tsx | 128 +++++++++++++ app/products/playbooks/constants/plugin.ts | 1 + app/screens/channel_info/channel_info.tsx | 3 + app/screens/channel_info/index.ts | 4 + .../channel_info/options/index.test.tsx | 1 + app/screens/channel_info/options/index.tsx | 4 + app/screens/home/index.tsx | 4 +- app/utils/deep_link/index.test.ts | 29 +-- app/utils/deep_link/index.ts | 112 ++++++++++-- app/utils/url/links.test.ts | 18 +- app/utils/url/links.ts | 10 +- assets/base/i18n/en.json | 12 ++ fastlane/.env.ios.beta | 2 + fastlane/.env.ios.pr | 2 + fastlane/.env.ios.simulator | 2 + types/api/posts.d.ts | 3 +- types/launch/index.ts | 10 +- 27 files changed, 548 insertions(+), 59 deletions(-) create mode 100644 app/products/playbooks/components/status_update_post/index.tsx create mode 100644 app/products/playbooks/components/status_update_post/participants/index.ts create mode 100644 app/products/playbooks/components/status_update_post/participants/participants.tsx diff --git a/app/actions/remote/command.test.ts b/app/actions/remote/command.test.ts index 3e565ea5c..858ef7990 100644 --- a/app/actions/remote/command.test.ts +++ b/app/actions/remote/command.test.ts @@ -382,7 +382,7 @@ describe('handleGotoLocation', () => { expect(getConfig).toHaveBeenCalledWith(mockOperator.database); expect(matchDeepLink).toHaveBeenCalledWith(location, serverUrl, serverUrl); - expect(handleDeepLink).toHaveBeenCalledWith(location, intl, location); + expect(handleDeepLink).toHaveBeenCalledWith({url: location}, intl, location); expect(result).toEqual({data: true}); }); diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts index 68721c5a0..f907955ba 100644 --- a/app/actions/remote/command.ts +++ b/app/actions/remote/command.ts @@ -143,7 +143,7 @@ export const handleGotoLocation = async (serverUrl: string, intl: IntlShape, loc const match = matchDeepLink(location, serverUrl, config?.SiteURL); if (match) { - handleDeepLink(match.url, intl, location); + handleDeepLink(match, intl, location); } else { const {formatMessage} = intl; const onError = () => Alert.alert( diff --git a/app/components/post_list/post/body/index.tsx b/app/components/post_list/post/body/index.tsx index df41b6e7d..d17436390 100644 --- a/app/components/post_list/post/body/index.tsx +++ b/app/components/post_list/post/body/index.tsx @@ -9,6 +9,8 @@ import FormattedText from '@components/formatted_text'; import JumboEmoji from '@components/jumbo_emoji'; import {Screens} from '@constants'; 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 {makeStyleSheetFromTheme} from '@utils/theme'; @@ -139,6 +141,14 @@ const Body = ({ defaultMessage='(message deleted)' /> ); + } else if (post.type === PLAYBOOKS_UPDATE_STATUS_POST_TYPE && post.props != null) { + message = ( + + ); } else if (isPostAddChannelMember) { message = ( => { + 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}; + } +}; diff --git a/app/products/playbooks/client/rest.ts b/app/products/playbooks/client/rest.ts index 6765fed3e..c3f7f657a 100644 --- a/app/products/playbooks/client/rest.ts +++ b/app/products/playbooks/client/rest.ts @@ -10,7 +10,7 @@ export interface ClientPlaybooksMix { // Playbook Runs fetchPlaybookRuns: (params: FetchPlaybookRunsParams, groupLabel?: RequestGroupLabel) => Promise; - // fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise; + fetchPlaybookRun: (id: string, groupLabel?: RequestGroupLabel) => Promise; // Run Management // finishRun: (playbookRunId: string) => Promise; @@ -54,12 +54,12 @@ const ClientPlaybooks = >(superclass: TBas } }; - // fetchPlaybookRun = async (id: string, groupLabel?: RequestGroupLabel) => { - // return this.doFetch( - // `${this.getPlaybookRunRoute(id)}`, - // {method: 'get', groupLabel}, - // ); - // }; + fetchPlaybookRun = async (id: string, groupLabel?: RequestGroupLabel) => { + return this.doFetch( + `${this.getPlaybookRunRoute(id)}`, + {method: 'get', groupLabel}, + ); + }; // Run Management // finishRun = async (playbookRunId: string) => { diff --git a/app/products/playbooks/components/status_update_post/index.tsx b/app/products/playbooks/components/status_update_post/index.tsx new file mode 100644 index 000000000..ceacc8f93 --- /dev/null +++ b/app/products/playbooks/components/status_update_post/index.tsx @@ -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 ( + + + + + + + + + + + + + {'•'} + + + + + + + ); +}; + +export default StatusUpdatePost; diff --git a/app/products/playbooks/components/status_update_post/participants/index.ts b/app/products/playbooks/components/status_update_post/participants/index.ts new file mode 100644 index 000000000..97845b297 --- /dev/null +++ b/app/products/playbooks/components/status_update_post/participants/index.ts @@ -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)); diff --git a/app/products/playbooks/components/status_update_post/participants/participants.tsx b/app/products/playbooks/components/status_update_post/participants/participants.tsx new file mode 100644 index 000000000..ab58e196f --- /dev/null +++ b/app/products/playbooks/components/status_update_post/participants/participants.tsx @@ -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; + 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 && ( + + + {bottomSheetTitle} + + + )} + + + ); + + 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 = [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 ( + + + + + ); +}; + +export default Participants; diff --git a/app/products/playbooks/constants/plugin.ts b/app/products/playbooks/constants/plugin.ts index 33f1f38b9..0a5ee32eb 100644 --- a/app/products/playbooks/constants/plugin.ts +++ b/app/products/playbooks/constants/plugin.ts @@ -2,3 +2,4 @@ // See LICENSE.txt for license information. export const PLAYBOOKS_PLUGIN_ID = 'playbooks'; +export const PLAYBOOKS_UPDATE_STATUS_POST_TYPE = 'custom_run_update'; diff --git a/app/screens/channel_info/channel_info.tsx b/app/screens/channel_info/channel_info.tsx index 5358f8374..da0ef2475 100644 --- a/app/screens/channel_info/channel_info.tsx +++ b/app/screens/channel_info/channel_info.tsx @@ -35,6 +35,7 @@ type Props = { componentId: AvailableScreens; isBookmarksEnabled: boolean; isCallsEnabledInChannel: boolean; + isPlaybooksEnabled: boolean; groupCallsAllowed: boolean; canManageMembers: boolean; isConvertGMFeatureAvailable: boolean; @@ -70,6 +71,7 @@ const ChannelInfo = ({ componentId, isBookmarksEnabled, isCallsEnabledInChannel, + isPlaybooksEnabled, groupCallsAllowed, isConvertGMFeatureAvailable, isCRTEnabled, @@ -139,6 +141,7 @@ const ChannelInfo = ({ canManageMembers={canManageMembers} isCRTEnabled={isCRTEnabled} canManageSettings={canManageSettings} + isPlaybooksEnabled={isPlaybooksEnabled} /> {convertGMOptionAvailable && diff --git a/app/screens/channel_info/index.ts b/app/screens/channel_info/index.ts index 20c964ea1..b3a7aa8ec 100644 --- a/app/screens/channel_info/index.ts +++ b/app/screens/channel_info/index.ts @@ -8,6 +8,7 @@ import {distinctUntilChanged, switchMap, combineLatestWith} from 'rxjs/operators import {observeIsCallsEnabledInChannel} from '@calls/observers'; import {observeCallsConfig} from '@calls/state'; import {withServerUrl} from '@context/server'; +import {observeIsPlaybooksEnabled} from '@playbooks/database/queries/version'; import {observeCurrentChannel} from '@queries/servers/channel'; import {observeCanAddBookmarks} from '@queries/servers/channel_bookmark'; import {observeCanManageChannelMembers, observeCanManageChannelSettings} from '@queries/servers/role'; @@ -146,6 +147,8 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => { }), ); + const isPlaybooksEnabled = observeIsPlaybooksEnabled(database); + return { type, canEnableDisableCalls, @@ -158,6 +161,7 @@ const enhanced = withObservables([], ({serverUrl, database}: Props) => { isCRTEnabled: observeIsCRTEnabled(database), isGuestUser, isConvertGMFeatureAvailable, + isPlaybooksEnabled, }; }); diff --git a/app/screens/channel_info/options/index.test.tsx b/app/screens/channel_info/options/index.test.tsx index 3a679566c..b4b11ec36 100644 --- a/app/screens/channel_info/options/index.test.tsx +++ b/app/screens/channel_info/options/index.test.tsx @@ -28,6 +28,7 @@ describe('ChannelInfoOptions', () => { canManageMembers: false, isCRTEnabled: false, canManageSettings: false, + isPlaybooksEnabled: true, }; } beforeEach(async () => { diff --git a/app/screens/channel_info/options/index.tsx b/app/screens/channel_info/options/index.tsx index 68f424bc3..40eba982d 100644 --- a/app/screens/channel_info/options/index.tsx +++ b/app/screens/channel_info/options/index.tsx @@ -23,6 +23,7 @@ type Props = { callsEnabled: boolean; canManageMembers: boolean; isCRTEnabled: boolean; + isPlaybooksEnabled: boolean; canManageSettings: boolean; } @@ -32,6 +33,7 @@ const Options = ({ callsEnabled, canManageMembers, isCRTEnabled, + isPlaybooksEnabled, canManageSettings, }: Props) => { const isDMorGM = isTypeDMorGM(type); @@ -49,10 +51,12 @@ const Options = ({ + {isPlaybooksEnabled && + } {type !== General.DM_CHANNEL && } diff --git a/app/screens/home/index.tsx b/app/screens/home/index.tsx index 9aa6e6678..7620f7e89 100644 --- a/app/screens/home/index.tsx +++ b/app/screens/home/index.tsx @@ -19,7 +19,7 @@ import SecurityManager from '@managers/security_manager'; import {getAllServers} from '@queries/app/servers'; import {findChannels, popToRoot} from '@screens/navigation'; 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 {alertChannelArchived, alertChannelRemove, alertTeamRemove} from '@utils/navigation'; import {notificationError} from '@utils/notification'; @@ -136,7 +136,7 @@ export function HomeScreen(props: HomeProps) { const deepLink = props.extra as DeepLinkWithData; if (deepLink?.url) { - handleDeepLink(deepLink.url, intl, props.componentId, true).then((result) => { + parseAndHandleDeepLink(deepLink.url, intl, props.componentId, true).then((result) => { if (result.error) { alertInvalidDeepLink(intl); } diff --git a/app/utils/deep_link/index.test.ts b/app/utils/deep_link/index.test.ts index 55a18d832..4185c7fb1 100644 --- a/app/utils/deep_link/index.test.ts +++ b/app/utils/deep_link/index.test.ts @@ -20,7 +20,7 @@ import {addNewServer} from '@utils/server'; import {alertErrorWithFallback, errorBadChannel, errorUnkownUser} from '../draft'; -import {alertInvalidDeepLink, extractServerUrl, getLaunchPropsFromDeepLink, handleDeepLink} from '.'; +import {alertInvalidDeepLink, extractServerUrl, getLaunchPropsFromDeepLink, parseAndHandleDeepLink} from '.'; jest.mock('@actions/remote/user', () => ({ fetchUsersByUsernames: jest.fn(), @@ -94,7 +94,7 @@ describe('extractServerUrl', () => { }); }); -describe('handleDeepLink', () => { +describe('parseAndHandleDeepLink', () => { const intl = createIntl({locale: 'en', messages: {}}); beforeEach(() => { @@ -102,14 +102,14 @@ describe('handleDeepLink', () => { }); 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}); }); it('should add new server if not existing', async () => { (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.com'); (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, data: { serverUrl: 'newserver.com', @@ -124,7 +124,7 @@ describe('handleDeepLink', () => { it('should handle existing server and switch to home screen', async () => { (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://currentserver.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(DatabaseManager.setActiveServerDatabase).toHaveBeenCalledWith('https://existingserver.com'); expect(WebsocketManager.initializeClient).toHaveBeenCalledWith('https://existingserver.com', 'DeepLink'); @@ -136,7 +136,8 @@ describe('handleDeepLink', () => { (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce(null); (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'); expect(spyOnUpdateProps).toHaveBeenCalledWith(Screens.SERVER, {serverUrl: 'currentserver.com'}); expect(result).toEqual({error: false}); @@ -148,7 +149,7 @@ describe('handleDeepLink', () => { (NavigationStore.getVisibleScreen as jest.Mock).mockReturnValueOnce(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(result).toEqual({error: false}); }); @@ -156,7 +157,7 @@ describe('handleDeepLink', () => { it('should switch to channel by name for Channel deep link', async () => { (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('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(result).toEqual({error: false}); }); @@ -165,7 +166,7 @@ describe('handleDeepLink', () => { (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); (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(result).toEqual({error: false}); }); @@ -175,7 +176,7 @@ describe('handleDeepLink', () => { (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => [])}); (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(result).toEqual({error: false}); }); @@ -185,7 +186,7 @@ describe('handleDeepLink', () => { (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); (queryUsersByUsername as jest.Mock).mockReturnValueOnce({fetchIds: jest.fn(() => [])}); (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(result).toEqual({error: false}); }); @@ -193,7 +194,7 @@ describe('handleDeepLink', () => { it('should switch to group message channel for GroupMessage deep link', async () => { (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('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(result).toEqual({error: false}); }); @@ -202,7 +203,7 @@ describe('handleDeepLink', () => { (DatabaseManager.searchUrl as jest.Mock).mockReturnValueOnce('https://existingserver.com'); (getActiveServerUrl as jest.Mock).mockResolvedValueOnce('https://existingserver.com'); 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(result).toEqual({error: false}); }); @@ -211,7 +212,7 @@ describe('handleDeepLink', () => { (getActiveServerUrl as jest.Mock).mockImplementationOnce(() => { 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(result).toEqual({error: true}); }); diff --git a/app/utils/deep_link/index.ts b/app/utils/deep_link/index.ts index ab37a1aef..24342eea5 100644 --- a/app/utils/deep_link/index.ts +++ b/app/utils/deep_link/index.ts @@ -3,6 +3,7 @@ import {match} from 'path-to-regexp'; import {type IntlShape} from 'react-intl'; +import {Alert} from 'react-native'; import {Navigation} from 'react-native-navigation'; import urlParse from 'url-parse'; @@ -15,6 +16,10 @@ import {getDefaultThemeByAppearance} from '@context/theme'; import DatabaseManager from '@database/manager'; import {DEFAULT_LOCALE, t} from '@i18n'; 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 {getCurrentUser, queryUsersByUsername} from '@queries/servers/user'; import {dismissAllModalsAndPopToRoot} from '@screens/navigation'; @@ -32,28 +37,27 @@ import { ID_PATH_PATTERN, } 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'; 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 { - const parsed = parseDeepLink(deepLinkUrl, asServer); - if (parsed.type === DeepLink.Invalid || !parsed.data || !parsed.data.serverUrl) { + if (deepLink.type === DeepLink.Invalid || !deepLink.data || !deepLink.data.serverUrl) { return {error: true}; } 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 if (!existingServerUrl) { const theme = EphemeralStore.theme || getDefaultThemeByAppearance(); 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)) { - addNewServer(theme, parsed.data.serverUrl, undefined, parsed); + addNewServer(theme, deepLink.data.serverUrl, undefined, deepLink); } return {error: false}; } @@ -70,14 +74,14 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, const locale = currentUser?.locale || DEFAULT_LOCALE; const intl = intlShape || getIntlShape(locale); - switch (parsed.type) { + switch (deepLink.type) { case DeepLink.Channel: { - const deepLinkData = parsed.data as DeepLinkChannel; + const deepLinkData = deepLink.data as DeepLinkChannel; switchToChannelByName(existingServerUrl, deepLinkData.channelName, deepLinkData.teamName, errorBadChannel, intl); break; } case DeepLink.DirectMessage: { - const deepLinkData = parsed.data as DeepLinkDM; + const deepLinkData = deepLink.data as DeepLinkDM; const userIds = await queryUsersByUsername(database, [deepLinkData.userName]).fetchIds(); let userId = userIds.length ? userIds[0] : undefined; if (!userId) { @@ -95,12 +99,12 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, break; } case DeepLink.GroupMessage: { - const deepLinkData = parsed.data as DeepLinkGM; + const deepLinkData = deepLink.data as DeepLinkGM; switchToChannelByName(existingServerUrl, deepLinkData.channelName, deepLinkData.teamName, errorBadChannel, intl); break; } case DeepLink.Permalink: { - const deepLinkData = parsed.data as DeepLinkPermalink; + const deepLinkData = deepLink.data as DeepLinkPermalink; if ( NavigationStore.hasModalsOpened() || !deepLinkScreens.includes(NavigationStore.getVisibleScreen()) @@ -110,6 +114,49 @@ export async function handleDeepLink(deepLinkUrl: string, intlShape?: IntlShape, showPermalink(existingServerUrl, deepLinkData.teamName, deepLinkData.postId); 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}; } 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 = { hostname: string; serverUrl: string[]; @@ -129,6 +181,22 @@ type ChannelPathParams = { const CHANNEL_PATH = '*serverUrl/:teamName/:path/:identifier'; export const matchChannelDeeplink = match(CHANNEL_PATH); +type PlaybooksPathParams = { + serverUrl: string[]; + playbookId: string; +}; + +const PLAYBOOKS_PATH = '*serverUrl/playbooks/playbooks/:playbookId'; +export const matchPlaybooksDeeplink = match(PLAYBOOKS_PATH); + +type PlaybookRunsPathParams = { + serverUrl: string[]; + playbookRunId: string; +}; + +const PLAYBOOK_RUNS_PATH = '*serverUrl/playbooks/runs/:playbookRunId'; +export const matchPlaybookRunsDeeplink = match(PLAYBOOK_RUNS_PATH); + type PermalinkPathParams = { serverUrl: string[]; teamName: string; @@ -204,14 +272,16 @@ function isValidIdentifierPathPattern(id: string): boolean { return regex.test(id); } -function isValidPostId(id: string): boolean { +function isValidId(id: string): boolean { const regex = new RegExp(`^${ID_PATH_PATTERN}$`); return regex.test(id); } export function parseDeepLink(deepLinkUrl: string, asServer = false): DeepLinkWithData { 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); 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); - 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; 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) { const serverMatch = extractServerUrl(url); if (serverMatch) { diff --git a/app/utils/url/links.test.ts b/app/utils/url/links.test.ts index 631fe491a..a314f3a42 100644 --- a/app/utils/url/links.test.ts +++ b/app/utils/url/links.test.ts @@ -4,7 +4,7 @@ import {createIntl} from 'react-intl'; import {Alert} from 'react-native'; -import {onOpenLinkError, openLink} from './links'; +import * as Links from './links'; describe('onOpenLinkError', () => { const intl = createIntl({ @@ -26,7 +26,7 @@ describe('onOpenLinkError', () => { mockReturnValueOnce('Error'). mockReturnValueOnce('Unable to open the link.'); // Mock message - onOpenLinkError(intl); + Links.onOpenLinkError(intl); expect(intl.formatMessage).toHaveBeenCalledTimes(2); expect(intl.formatMessage).toHaveBeenCalledWith({ @@ -55,7 +55,7 @@ describe('openLink', () => { it('should call normalizeProtocol and return early if the URL is invalid', async () => { 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(''); normalizeProtocolMock.mockRestore(); @@ -66,11 +66,11 @@ describe('openLink', () => { 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}); - 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(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(); matchDeepLinkMock.mockRestore(); @@ -83,9 +83,9 @@ describe('openLink', () => { const handleDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'handleDeepLink').mockResolvedValue({error: true}); 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(); matchDeepLinkMock.mockRestore(); @@ -98,9 +98,9 @@ describe('openLink', () => { const matchDeepLinkMock = jest.spyOn(require('@utils/deep_link'), 'matchDeepLink').mockReturnValue(null); 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(); matchDeepLinkMock.mockRestore(); diff --git a/app/utils/url/links.ts b/app/utils/url/links.ts index e165ef5c8..b0c257b97 100644 --- a/app/utils/url/links.ts +++ b/app/utils/url/links.ts @@ -31,11 +31,15 @@ export const openLink = async (link: string, serverUrl: string, siteURL: string, const match = matchDeepLink(url, serverUrl, siteURL); if (match) { - const {error} = await handleDeepLink(match.url, intl); + const {error} = await handleDeepLink(match, intl); if (error) { - tryOpenURL(match.url, onOpenLinkError); + tryOpenURL(match.url, () => { + onOpenLinkError(intl); + }); } } else { - tryOpenURL(url, onOpenLinkError); + tryOpenURL(url, () => { + onOpenLinkError(intl); + }); } }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 1e92afcde..c1cf33cf8 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -996,6 +996,15 @@ "playbook.participants": "Run Participants", "playbook.runs.finished": "Finished", "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.title": "Unable to fetch run details", "playbooks.playbook_run.finished": "Finished", @@ -1015,6 +1024,9 @@ "playbooks.runs.in_progress.description": "When a run starts in this channel, you’ll see it here.", "playbooks.runs.in_progress.title": "No in progress runs", "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.create_new_channel.title": "Create New Channel", "plus_menu.invite_people_to_team.title": "Invite people to the team", diff --git a/fastlane/.env.ios.beta b/fastlane/.env.ios.beta index a534a7472..d867b472e 100644 --- a/fastlane/.env.ios.beta +++ b/fastlane/.env.ios.beta @@ -8,6 +8,8 @@ BETA_BUILD=true BUILD_FOR_RELEASE=true COLLECT_NETWORK_METRICS=true 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_BUILD_EXPORT_METHOD=app-store IOS_ICLOUD_CONTAINER=iCloud.com.mattermost.rnbeta diff --git a/fastlane/.env.ios.pr b/fastlane/.env.ios.pr index 29d79ac8a..29c813ed7 100644 --- a/fastlane/.env.ios.pr +++ b/fastlane/.env.ios.pr @@ -9,6 +9,8 @@ BUILD_FOR_RELEASE=true BUILD_PR=true COLLECT_NETWORK_METRICS=true 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_BUILD_EXPORT_METHOD=ad-hoc IOS_ICLOUD_CONTAINER=iCloud.com.mattermost.rnbeta diff --git a/fastlane/.env.ios.simulator b/fastlane/.env.ios.simulator index d39f555f6..4a439312a 100644 --- a/fastlane/.env.ios.simulator +++ b/fastlane/.env.ios.simulator @@ -2,3 +2,5 @@ AWS_BUCKET_NAME=releases.mattermost.com AWS_FOLDER_NAME=mattermost-mobile-beta AWS_REGION=us-east-1 +FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT=120 +FASTLANE_XCODEBUILD_SETTINGS_RETRIES=6 diff --git a/types/api/posts.d.ts b/types/api/posts.d.ts index bedbbb090..0b8699dd3 100644 --- a/types/api/posts.d.ts +++ b/types/api/posts.d.ts @@ -27,7 +27,8 @@ type PostType = | 'add_bot_teams_channels' | 'system_auto_responder' | 'custom_calls' - | 'custom_calls_recording'; + | 'custom_calls_recording' + | 'custom_run_update'; type PostEmbedType = 'image' | 'message_attachment' | 'opengraph'; diff --git a/types/launch/index.ts b/types/launch/index.ts index 30fa52292..4a179208a 100644 --- a/types/launch/index.ts +++ b/types/launch/index.ts @@ -33,12 +33,20 @@ export interface DeepLinkPlugin extends DeepLink { 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 interface DeepLinkWithData { type: DeepLinkType; 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];