diff --git a/app/actions/local/post.ts b/app/actions/local/post.ts index 8378ab932f..98fe62ad51 100644 --- a/app/actions/local/post.ts +++ b/app/actions/local/post.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {fetchPostAuthors} from '@actions/remote/post'; import {ActionType, Post} from '@constants'; import DatabaseManager from '@database/manager'; import {getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post'; @@ -78,7 +79,7 @@ export const sendEphemeralPost = async (serverUrl: string, message: string, chan user_id: authorId, channel_id: channeId, message, - type: Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL as PostType, + type: Post.POST_TYPES.EPHEMERAL as PostType, create_at: timestamp, edit_at: 0, update_at: timestamp, @@ -94,6 +95,7 @@ export const sendEphemeralPost = async (serverUrl: string, message: string, chan props: {}, } as Post; + await fetchPostAuthors(serverUrl, [post], false); await operator.handlePosts({ actionType: ActionType.POSTS.RECEIVED_NEW, order: [post.id], diff --git a/app/actions/remote/channel.ts b/app/actions/remote/channel.ts index c76127a685..f034b621e3 100644 --- a/app/actions/remote/channel.ts +++ b/app/actions/remote/channel.ts @@ -13,6 +13,7 @@ import {Events, General, Preferences, Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {privateChannelJoinPrompt} from '@helpers/api/channel'; import {getTeammateNameDisplaySetting} from '@helpers/api/preference'; +import AppsManager from '@managers/apps_manager'; import NetworkManager from '@managers/network_manager'; import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo, queryMyChannelSettingsByIds, getMembersCountByChannelsId} from '@queries/servers/channel'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; @@ -1023,6 +1024,10 @@ export async function switchToChannelById(serverUrl: string, channelId: string, DeviceEventEmitter.emit(Events.CHANNEL_SWITCH, false); + if (await AppsManager.isAppsEnabled(serverUrl)) { + AppsManager.fetchBindings(serverUrl, channelId); + } + return {}; } diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts index bf50cd3288..b6a5f1cff4 100644 --- a/app/actions/remote/command.ts +++ b/app/actions/remote/command.ts @@ -4,16 +4,20 @@ import {IntlShape} from 'react-intl'; import {Alert} from 'react-native'; +import {doAppSubmit, postEphemeralCallResponseForCommandArgs} from '@actions/remote/apps'; import {showPermalink} from '@actions/remote/permalink'; import {Client} from '@client/rest'; +import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser'; +import {AppCallResponseTypes} from '@constants/apps'; import DeepLinkType from '@constants/deep_linking'; import DatabaseManager from '@database/manager'; +import AppsManager from '@managers/apps_manager'; import IntegrationsManager from '@managers/integrations_manager'; import NetworkManager from '@managers/network_manager'; import {getChannelById} from '@queries/servers/channel'; import {getConfig, getCurrentTeamId} from '@queries/servers/system'; import {getTeammateNameDisplay, queryUsersByUsername} from '@queries/servers/user'; -import {showModal} from '@screens/navigation'; +import {showAppForm, showModal} from '@screens/navigation'; import * as DraftUtils from '@utils/draft'; import {matchDeepLink, tryOpenURL} from '@utils/url'; import {displayUsername} from '@utils/user'; @@ -22,7 +26,7 @@ import {makeDirectChannel, switchToChannelById, switchToChannelByName} from './c import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkDM, DeepLinkGM, DeepLinkPlugin} from '@typings/launch'; -export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string) => { +export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string): Promise<{data?: CommandResponse; error?: string | {message: string}}> => { const operator = DatabaseManager.serverDatabases[serverUrl]?.operator; if (!operator) { return {error: `${serverUrl} database not found`}; @@ -35,14 +39,6 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message return {error: error as ClientErrorProps}; } - // const config = await queryConfig(operator.database) - // if (config.FeatureFlagAppsEnabled) { - // const parser = new AppCommandParser(serverUrl, intl, channelId, rootId); - // if (parser.isAppCommand(msg)) { - // return executeAppCommand(serverUrl, intl, parser); - // } - // } - const channel = await getChannelById(operator.database, channelId); const teamId = channel?.teamId || (await getCurrentTeamId(operator.database)); @@ -53,6 +49,14 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message parent_id: rootId, }; + const appsEnabled = await AppsManager.isAppsEnabled(serverUrl); + if (appsEnabled) { + const parser = new AppCommandParser(serverUrl, intl, channelId, teamId, rootId); + if (parser.isAppCommand(message)) { + return executeAppCommand(serverUrl, intl, parser, message, args); + } + } + let msg = filterEmDashForCommand(message); let cmdLength = msg.indexOf(' '); @@ -81,44 +85,51 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message return {data}; }; -// TODO https://mattermost.atlassian.net/browse/MM-41234 -// const executeAppCommand = (serverUrl: string, intl: IntlShape, parser: any) => { -// const {call, errorMessage} = await parser.composeCallFromCommand(msg); -// const createErrorMessage = (errMessage: string) => { -// return {error: {message: errMessage}}; -// }; +const executeAppCommand = async (serverUrl: string, intl: IntlShape, parser: AppCommandParser, msg: string, args: CommandArgs) => { + const {creq, errorMessage} = await parser.composeCommandSubmitCall(msg); + const createErrorMessage = (errMessage: string) => { + return {error: {message: errMessage}}; + }; -// if (!call) { -// return createErrorMessage(errorMessage!); -// } + if (!creq) { + return createErrorMessage(errorMessage!); + } -// const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intl)); -// if (res.error) { -// const errorResponse = res.error as AppCallResponse; -// return createErrorMessage(errorResponse.error || intl.formatMessage({ -// id: 'apps.error.unknown', -// defaultMessage: 'Unknown error.', -// })); -// } -// const callResp = res.data as AppCallResponse; -// switch (callResp.type) { -// case AppCallResponseTypes.OK: -// if (callResp.markdown) { -// dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.markdown, args)); -// } -// return {data: {}}; -// case AppCallResponseTypes.FORM: -// case AppCallResponseTypes.NAVIGATE: -// return {data: {}}; -// default: -// return createErrorMessage(intl.formatMessage({ -// id: 'apps.error.responses.unknown_type', -// defaultMessage: 'App response type not supported. Response type: {type}.', -// }, { -// type: callResp.type, -// })); -// } -// }; + const res = await doAppSubmit(serverUrl, creq, intl); + if (res.error) { + const errorResponse = res.error as AppCallResponse; + return createErrorMessage(errorResponse.text || intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error.', + })); + } + const callResp = res.data as AppCallResponse; + + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.text) { + postEphemeralCallResponseForCommandArgs(serverUrl, callResp, callResp.text, args); + } + return {data: {}}; + case AppCallResponseTypes.FORM: + if (callResp.form) { + showAppForm(callResp.form); + } + return {data: {}}; + case AppCallResponseTypes.NAVIGATE: + if (callResp.navigate_to_url) { + handleGotoLocation(serverUrl, intl, callResp.navigate_to_url); + } + return {data: {}}; + default: + return createErrorMessage(intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResp.type, + })); + } +}; const filterEmDashForCommand = (command: string): string => { return command.replace(/\u2014/g, '--'); diff --git a/app/actions/remote/thread.ts b/app/actions/remote/thread.ts index fa7c20755a..0a1c57b678 100644 --- a/app/actions/remote/thread.ts +++ b/app/actions/remote/thread.ts @@ -6,9 +6,10 @@ import {fetchPostThread} from '@actions/remote/post'; import {General} from '@constants'; import DatabaseManager from '@database/manager'; import PushNotifications from '@init/push_notifications'; +import AppsManager from '@managers/apps_manager'; import NetworkManager from '@managers/network_manager'; import {getPostById} from '@queries/servers/post'; -import {getCommonSystemValues, getCurrentTeamId} from '@queries/servers/system'; +import {getCommonSystemValues, getCurrentChannelId, getCurrentTeamId} from '@queries/servers/system'; import {getIsCRTEnabled, getNewestThreadInTeam, getThreadById} from '@queries/servers/thread'; import {getCurrentUser} from '@queries/servers/user'; @@ -56,6 +57,20 @@ export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string, await switchToThread(serverUrl, rootId, isFromNotification); + if (await AppsManager.isAppsEnabled(serverUrl)) { + // Getting the post again in case we didn't had it at the beginning + const post = await getPostById(database, rootId); + const currentChannelId = await getCurrentChannelId(database); + + if (post) { + if (currentChannelId === post?.channelId) { + AppsManager.copyMainBindingsToThread(serverUrl, currentChannelId); + } else { + AppsManager.fetchBindings(serverUrl, post.channelId, true); + } + } + } + return {}; }; diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index fa9d981973..7d526d0486 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -27,6 +27,7 @@ import {isSupportedServerCalls} from '@calls/utils'; import {Events, Screens, WebsocketEvents} from '@constants'; import {SYSTEM_IDENTIFIERS} from '@constants/database'; import DatabaseManager from '@database/manager'; +import AppsManager from '@managers/apps_manager'; import {getActiveServerUrl, queryActiveServer} from '@queries/app/servers'; import {getCurrentChannel} from '@queries/servers/channel'; import { @@ -184,7 +185,7 @@ async function doReconnect(serverUrl: string) { loadConfigAndCalls(serverUrl, currentUserId); } - // https://mattermost.atlassian.net/browse/MM-41520 + AppsManager.refreshAppBindings(serverUrl); } export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { diff --git a/app/components/autocomplete/autocomplete.tsx b/app/components/autocomplete/autocomplete.tsx index f92a45bfaa..f56f8a3bd4 100644 --- a/app/components/autocomplete/autocomplete.tsx +++ b/app/components/autocomplete/autocomplete.tsx @@ -189,6 +189,7 @@ const Autocomplete = ({ nestedScrollEnabled={nestedScrollEnabled} channelId={channelId} rootId={rootId} + isAppsEnabled={isAppsEnabled} /> } {/* {(isSearch && enableDateSuggestion) && diff --git a/app/components/autocomplete/index.ts b/app/components/autocomplete/index.ts index 4513679be4..531a617ed9 100644 --- a/app/components/autocomplete/index.ts +++ b/app/components/autocomplete/index.ts @@ -1,17 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {observeConfigBooleanValue} from '@queries/servers/system'; +import AppsManager from '@managers/apps_manager'; import Autocomplete from './autocomplete'; -import type {WithDatabaseArgs} from '@typings/database/database'; +type OwnProps = { + serverUrl?: string; +} -const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ - isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), +const enhanced = withObservables(['serverUrl'], ({serverUrl}: OwnProps) => ({ + isAppsEnabled: serverUrl ? AppsManager.observeIsAppsEnabled(serverUrl) : false, })); -export default withDatabase(enhanced(Autocomplete)); +export default enhanced(Autocomplete); diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts index 5ba220d4dd..375be1c8a4 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts @@ -7,9 +7,9 @@ import {IntlShape} from 'react-intl'; import {doAppFetchForm, doAppLookup} from '@actions/remote/apps'; import {fetchChannelById, fetchChannelByName, searchChannels} from '@actions/remote/channel'; import {fetchUsersByIds, fetchUsersByUsernames, searchUsers} from '@actions/remote/user'; -import {AppCallResponseTypes, AppFieldTypes, COMMAND_SUGGESTION_ERROR} from '@constants/apps'; +import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes, COMMAND_SUGGESTION_ERROR} from '@constants/apps'; import DatabaseManager from '@database/manager'; -import IntegrationsManager from '@managers/integrations_manager'; +import AppsManager from '@managers/apps_manager'; import {getChannelById, getChannelByName} from '@queries/servers/channel'; import {getCurrentTeamId} from '@queries/servers/system'; import {getUserById, queryUsersByUsername} from '@queries/servers/user'; @@ -173,7 +173,7 @@ export class ParsedCommand { } case ParseState.EndCommand: { - const binding = bindings.find(this.findBindings); + const binding = bindings.find(this.findBindings, this); if (!binding) { // gone as far as we could, this token doesn't match a sub-command. // return the state from the last matching binding @@ -856,15 +856,13 @@ export class AppCommandParser { private teamID: string; private rootPostID?: string; private intl: IntlShape; - private theme: Theme; - constructor(serverUrl: string, intl: IntlShape, channelID: string, teamID = '', rootPostID = '', theme: Theme) { + constructor(serverUrl: string, intl: IntlShape, channelID: string, teamID = '', rootPostID = '') { this.serverUrl = serverUrl; this.channelID = channelID; this.rootPostID = rootPostID; this.teamID = teamID; this.intl = intl; - this.theme = theme; // We are making the assumption the database is always present at this level. // This assumption may not be correct. Please review. @@ -1022,7 +1020,6 @@ export class AppCommandParser { const result: AutocompleteSuggestion[] = []; const bindings = this.getCommandBindings(); - for (const binding of bindings) { let base = binding.label; if (!base) { @@ -1435,11 +1432,8 @@ export class AppCommandParser { // getCommandBindings returns the commands in the redux store. // They are grouped by app id since each app has one base command private getCommandBindings = (): AppBinding[] => { - const manager = IntegrationsManager.getManager(this.serverUrl); - if (this.rootPostID) { - return manager.getRHSCommandBindings(); - } - return manager.getCommandBindings(); + const bindings = AppsManager.getBindings(this.serverUrl, AppBindingLocations.COMMAND, Boolean(this.rootPostID)); + return bindings.reduce((acc, v) => (v.bindings ? acc.concat(v.bindings) : acc), []); }; // getChannel gets the channel in which the user is typing the command @@ -1538,10 +1532,9 @@ export class AppCommandParser { }; public getSubmittableForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { - const manager = IntegrationsManager.getManager(this.serverUrl); const rootID = this.rootPostID || ''; const key = `${this.channelID}-${rootID}-${location}`; - const submittableForm = this.rootPostID ? manager.getAppRHSCommandForm(key) : manager.getAppCommandForm(key); + const submittableForm = AppsManager.getCommandForm(this.serverUrl, key, Boolean(this.rootPostID)); if (submittableForm) { return {form: submittableForm}; } @@ -1555,11 +1548,7 @@ export class AppCommandParser { const context = await this.getAppContext(binding); const fetched = await this.fetchSubmittableForm(binding.form.source, context); if (fetched?.form) { - if (this.rootPostID) { - manager.setAppRHSCommandForm(key, fetched.form); - } else { - manager.setAppCommandForm(key, fetched.form); - } + AppsManager.setCommandForm(this.serverUrl, key, fetched.form, Boolean(this.rootPostID)); } return fetched; }; diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx index 227b0fa4e6..c4cb57e744 100644 --- a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.tsx @@ -10,7 +10,6 @@ import AtMentionItem from '@components/autocomplete/at_mention_item'; import ChannelMentionItem from '@components/autocomplete/channel_mention_item'; import {COMMAND_SUGGESTION_CHANNEL, COMMAND_SUGGESTION_USER} from '@constants/apps'; import {useServerUrl} from '@context/server'; -import {useTheme} from '@context/theme'; import analytics from '@managers/analytics'; import {AppCommandParser, ExtendedAutocompleteSuggestion} from '../app_command_parser/app_command_parser'; @@ -48,9 +47,8 @@ const AppSlashSuggestion = ({ listStyle, }: Props) => { const intl = useIntl(); - const theme = useTheme(); const serverUrl = useServerUrl(); - const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); + const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId)); const [dataSource, setDataSource] = useState(emptySuggestonList); const active = isAppsEnabled && Boolean(dataSource.length); const mounted = useRef(false); diff --git a/app/components/autocomplete/slash_suggestion/index.ts b/app/components/autocomplete/slash_suggestion/index.ts index 2a65f39795..c087e095b3 100644 --- a/app/components/autocomplete/slash_suggestion/index.ts +++ b/app/components/autocomplete/slash_suggestion/index.ts @@ -4,7 +4,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import {observeConfigBooleanValue, observeCurrentTeamId} from '@queries/servers/system'; +import {observeCurrentTeamId} from '@queries/servers/system'; import SlashSuggestion from './slash_suggestion'; @@ -12,7 +12,6 @@ import type {WithDatabaseArgs} from '@typings/database/database'; const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({ currentTeamId: observeCurrentTeamId(database), - isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'), })); export default withDatabase(enhanced(SlashSuggestion)); diff --git a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx index 65d5bf929c..af152fe58a 100644 --- a/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx +++ b/app/components/autocomplete/slash_suggestion/slash_suggestion.tsx @@ -13,7 +13,6 @@ import { import {fetchSuggestions} from '@actions/remote/command'; import {useServerUrl} from '@context/server'; -import {useTheme} from '@context/theme'; import analytics from '@managers/analytics'; import IntegrationsManager from '@managers/integrations_manager'; @@ -78,9 +77,8 @@ const SlashSuggestion = ({ listStyle, }: Props) => { const intl = useIntl(); - const theme = useTheme(); const serverUrl = useServerUrl(); - const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme)); + const appCommandParser = useRef(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId)); const mounted = useRef(false); const [noResultsTerm, setNoResultsTerm] = useState(null); diff --git a/app/components/post_draft/post_draft.tsx b/app/components/post_draft/post_draft.tsx index 21976b3b93..8ea30064e4 100644 --- a/app/components/post_draft/post_draft.tsx +++ b/app/components/post_draft/post_draft.tsx @@ -8,6 +8,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'; import Autocomplete from '@components/autocomplete'; import {View as ViewConstants} from '@constants'; +import {useServerUrl} from '@context/server'; import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete'; import {useIsTablet, useKeyboardHeight} from '@hooks/device'; import {useDefaultHeaderHeight} from '@hooks/header'; @@ -62,6 +63,7 @@ function PostDraft({ const keyboardHeight = useKeyboardHeight(keyboardTracker); const insets = useSafeAreaInsets(); const headerHeight = useDefaultHeaderHeight(); + const serverUrl = useServerUrl(); // Update draft in case we switch channels or threads useEffect(() => { @@ -127,6 +129,7 @@ function PostDraft({ hasFilesAttached={Boolean(files?.length)} inPost={true} availableSpace={animatedAutocompleteAvailableSpace} + serverUrl={serverUrl} /> ) : null; diff --git a/app/components/post_list/post/avatar/avatar.tsx b/app/components/post_list/post/avatar/avatar.tsx index 5da7add59e..99725e2db5 100644 --- a/app/components/post_list/post/avatar/avatar.tsx +++ b/app/components/post_list/post/avatar/avatar.tsx @@ -20,7 +20,7 @@ import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; type AvatarProps = { - author: UserModel; + author?: UserModel; enablePostIconOverride?: boolean; isAutoReponse: boolean; location: string; @@ -91,6 +91,9 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}: } const openUserProfile = preventDoubleTap(() => { + if (!author) { + return; + } const screen = Screens.USER_PROFILE; const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}); const closeButtonId = 'close-user-profile'; @@ -112,8 +115,8 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}: author={author} size={ViewConstant.PROFILE_PICTURE_SIZE} iconSize={24} - showStatus={!isAutoReponse || author.isBot} - testID={`post_avatar.${author.id}.profile_picture`} + showStatus={!isAutoReponse || author?.isBot} + testID={`post_avatar.${author?.id}.profile_picture`} /> ); diff --git a/app/components/post_list/post/avatar/index.ts b/app/components/post_list/post/avatar/index.ts index 58b17970b3..659a8e9978 100644 --- a/app/components/post_list/post/avatar/index.ts +++ b/app/components/post_list/post/avatar/index.ts @@ -4,6 +4,7 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import enhance from '@nozbe/with-observables'; +import {observePostAuthor} from '@queries/servers/post'; import {observeConfigBooleanValue} from '@queries/servers/system'; import Avatar from './avatar'; @@ -15,7 +16,7 @@ const withPost = enhance(['post'], ({database, post}: {post: PostModel} & WithDa const enablePostIconOverride = observeConfigBooleanValue(database, 'EnablePostIconOverride'); return { - author: post.author.observe(), + author: observePostAuthor(database, post), enablePostIconOverride, }; }); diff --git a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx index a15a37d247..9c67de3bf7 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/button_binding/index.tsx @@ -9,9 +9,11 @@ import Button from 'react-native-button'; import {map} from 'rxjs/operators'; import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/remote/apps'; +import {handleGotoLocation} from '@actions/remote/command'; import {AppBindingLocations, AppCallResponseTypes} from '@constants/apps'; import {useServerUrl} from '@context/server'; import {observeCurrentTeamId} from '@queries/servers/system'; +import {showAppForm} from '@screens/navigation'; import {createCallContext} from '@utils/apps'; import {getStatusColors} from '@utils/message_attachment_colors'; import {preventDoubleTap} from '@utils/tap'; @@ -97,7 +99,14 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) => } return; case AppCallResponseTypes.NAVIGATE: + if (callResp.navigate_to_url) { + handleGotoLocation(serverUrl, intl, callResp.navigate_to_url); + } + return; case AppCallResponseTypes.FORM: + if (callResp.form) { + showAppForm(callResp.form); + } return; default: { const errorMessage = intl.formatMessage({ diff --git a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx index 7a36f6e77f..a58afbf72e 100644 --- a/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx +++ b/app/components/post_list/post/body/content/embedded_bindings/menu_binding/index.tsx @@ -8,10 +8,12 @@ import {useIntl} from 'react-intl'; import {map} from 'rxjs/operators'; import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/remote/apps'; +import {handleGotoLocation} from '@actions/remote/command'; import AutocompleteSelector from '@components/autocomplete_selector'; import {AppBindingLocations, AppCallResponseTypes} from '@constants/apps'; import {useServerUrl} from '@context/server'; import {observeCurrentTeamId} from '@queries/servers/system'; +import {showAppForm} from '@screens/navigation'; import {createCallContext} from '@utils/apps'; import {logDebug} from '@utils/log'; @@ -70,7 +72,14 @@ const MenuBinding = ({binding, currentTeamId, post, teamID}: Props) => { } return; case AppCallResponseTypes.NAVIGATE: + if (callResp.navigate_to_url) { + handleGotoLocation(serverUrl, intl, callResp.navigate_to_url); + } + return; case AppCallResponseTypes.FORM: + if (callResp.form) { + showAppForm(callResp.form); + } return; default: { const errorMessage = intl.formatMessage({ diff --git a/app/components/post_list/post/body/content/index.tsx b/app/components/post_list/post/body/content/index.tsx index a11a6f1db5..d755f3c495 100644 --- a/app/components/post_list/post/body/content/index.tsx +++ b/app/components/post_list/post/body/content/index.tsx @@ -31,7 +31,7 @@ const contentType: Record = { const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps) => { let type: string | undefined = post.metadata?.embeds?.[0].type; - if (!type && post.props?.attachments?.length) { + if (!type && post.props?.app_bindings?.length) { type = contentType.app_bindings; } diff --git a/app/components/post_list/post/header/header.tsx b/app/components/post_list/post/header/header.tsx index b209dba6fd..700cf1370a 100644 --- a/app/components/post_list/post/header/header.tsx +++ b/app/components/post_list/post/header/header.tsx @@ -23,7 +23,7 @@ import type PostModel from '@typings/database/models/servers/post'; import type UserModel from '@typings/database/models/servers/user'; type HeaderProps = { - author: UserModel; + author?: UserModel; commentCount: number; currentUser: UserModel; enablePostUsernameOverride: boolean; @@ -92,7 +92,7 @@ const Header = (props: HeaderProps) => { const customStatusExpired = isCustomStatusExpired(author); const showCustomStatusEmoji = Boolean( isCustomStatusEnabled && displayName && customStatus && - !(isSystemPost || author.isBot || isAutoResponse || isWebHook), + !(isSystemPost || author?.isBot || isAutoResponse || isWebHook), ); return ( @@ -121,8 +121,8 @@ const Header = (props: HeaderProps) => { {(!isSystemPost || isAutoResponse) && } { const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS). observeWithColumns(['value']); - const author = post.author.observe(); + const author = observePostAuthor(database, post); const enablePostUsernameOverride = observeConfigBooleanValue(database, 'EnablePostUsernameOverride'); const isTimezoneEnabled = observeConfigBooleanValue(database, 'ExperimentalTimezone'); const isMilitaryTime = preferences.pipe(map((prefs) => getPreferenceAsBool(prefs, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false))); diff --git a/app/components/post_list/post/index.ts b/app/components/post_list/post/index.ts index 5e8343b699..88883ca94d 100644 --- a/app/components/post_list/post/index.ts +++ b/app/components/post_list/post/index.ts @@ -4,12 +4,12 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; import React from 'react'; -import {of as of$, combineLatest} from 'rxjs'; +import {of as of$, combineLatest, Observable} from 'rxjs'; import {switchMap, distinctUntilChanged} from 'rxjs/operators'; import {Permissions, Preferences} from '@constants'; import {queryAllCustomEmojis} from '@queries/servers/custom_emoji'; -import {queryPostsBetween} from '@queries/servers/post'; +import {observePostAuthor, queryPostsBetween} from '@queries/servers/post'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {observeCanManageChannelMembers, observePermissionForPost} from '@queries/servers/role'; import {observeIsPostPriorityEnabled, observeConfigBooleanValue} from '@queries/servers/system'; @@ -101,7 +101,7 @@ const withPost = withObservables( let isLastReply = of$(true); let isPostAddChannelMember = of$(false); const isOwner = currentUser.id === post.userId; - const author = post.userId ? post.author.observe() : of$(null); + const author: Observable = observePostAuthor(database, post); const canDelete = observePermissionForPost(database, post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false); const isEphemeral = of$(isPostEphemeral(post)); const isSaved = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, post.id). diff --git a/app/components/post_list/post/post.tsx b/app/components/post_list/post/post.tsx index c2938cf943..9d31a13994 100644 --- a/app/components/post_list/post/post.tsx +++ b/app/components/post_list/post/post.tsx @@ -144,13 +144,13 @@ const Post = ({ } const isValidSystemMessage = isAutoResponder || !isSystemPost; - if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) { + if (isEphemeral || hasBeenDeleted) { + removePost(serverUrl, post); + } else if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) { if ([Screens.CHANNEL, Screens.PERMALINK].includes(location)) { const postRootId = post.rootId || post.id; fetchAndSwitchToThread(serverUrl, postRootId); } - } else if ((isEphemeral || hasBeenDeleted)) { - removePost(serverUrl, post); } setTimeout(() => { diff --git a/app/managers/apps_manager.ts b/app/managers/apps_manager.ts new file mode 100644 index 0000000000..960e992013 --- /dev/null +++ b/app/managers/apps_manager.ts @@ -0,0 +1,215 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {BehaviorSubject, combineLatest, of as of$} from 'rxjs'; +import {switchMap, distinctUntilChanged} from 'rxjs/operators'; + +import DatabaseManager from '@database/manager'; +import {getChannelById} from '@queries/servers/channel'; +import {getConfig, getCurrentChannelId, getCurrentTeamId, getCurrentUserId, observeConfigBooleanValue} from '@queries/servers/system'; +import {validateBindings} from '@utils/apps'; +import {logDebug} from '@utils/log'; + +import NetworkManager from './network_manager'; + +const emptyBindings: AppBinding[] = []; + +class AppsManager { + private enabled: {[serverUrl: string]: BehaviorSubject} = {}; + + private bindings: {[serverUrl: string]: BehaviorSubject} = {}; + private threadBindings: {[serverUrl: string]: BehaviorSubject<{channelId: string; bindings: AppBinding[]}>} = {}; + + private commandForms: {[serverUrl: string]: {[location: string]: AppForm}} = {}; + private threadCommandForms: {[serverUrl: string]: {[location: string]: AppForm}} = {}; + + private getEnabledSubject = (serverUrl: string) => { + if (!this.enabled[serverUrl]) { + this.enabled[serverUrl] = new BehaviorSubject(true); + } + + return this.enabled[serverUrl]; + }; + + private getBindingsSubject = (serverUrl: string) => { + if (!this.bindings[serverUrl]) { + this.bindings[serverUrl] = new BehaviorSubject([]); + } + + return this.bindings[serverUrl]; + }; + + private getThreadsBindingsSubject = (serverUrl: string) => { + if (!this.threadBindings[serverUrl]) { + this.threadBindings[serverUrl] = new BehaviorSubject({channelId: '', bindings: emptyBindings}); + } + + return this.threadBindings[serverUrl]; + }; + + private handleError = (serverUrl: string) => { + const enabled = this.getEnabledSubject(serverUrl); + if (enabled.value) { + enabled.next(false); + } + this.getBindingsSubject(serverUrl).next(emptyBindings); + this.getThreadsBindingsSubject(serverUrl).next({channelId: '', bindings: emptyBindings}); + + this.commandForms[serverUrl] = {}; + this.threadCommandForms[serverUrl] = {}; + }; + + removeServer = (serverUrl: string) => { + delete (this.enabled[serverUrl]); + + delete (this.bindings[serverUrl]); + delete (this.threadBindings[serverUrl]); + + delete (this.commandForms[serverUrl]); + delete (this.threadCommandForms[serverUrl]); + }; + + clearServer = (serverUrl: string) => { + this.clearBindings(serverUrl); + this.clearBindings(serverUrl, true); + this.commandForms[serverUrl] = {}; + this.threadCommandForms[serverUrl] = {}; + }; + + isAppsEnabled = async (serverUrl: string) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const config = await getConfig(database); + return this.getEnabledSubject(serverUrl).value && config?.FeatureFlagAppsEnabled === 'true'; + } catch { + return false; + } + }; + + observeIsAppsEnabled = (serverUrl: string) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const enabled = this.getEnabledSubject(serverUrl).asObservable(); + const config = observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'); + return combineLatest([enabled, config]).pipe( + switchMap(([e, cfg]) => of$(e && cfg)), + distinctUntilChanged(), + ); + } catch { + return of$(false); + } + }; + + fetchBindings = async (serverUrl: string, channelId: string, forThread = false) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const userId = await getCurrentUserId(database); + const channel = await getChannelById(database, channelId); + let teamId = channel?.teamId; + if (!teamId) { + teamId = await getCurrentTeamId(database); + } + + const client = NetworkManager.getClient(serverUrl); + const fetchedBindings = await client.getAppsBindings(userId, channelId, teamId); + const validatedBindings = validateBindings(fetchedBindings); + const bindingsToStore = validatedBindings.length ? validatedBindings : emptyBindings; + + const enabled = this.getEnabledSubject(serverUrl); + if (!enabled.value) { + enabled.next(true); + } + if (forThread) { + this.getThreadsBindingsSubject(serverUrl).next({channelId, bindings: bindingsToStore}); + this.threadCommandForms[serverUrl] = {}; + } else { + this.getBindingsSubject(serverUrl).next(bindingsToStore); + this.commandForms[serverUrl] = {}; + } + } catch (error) { + logDebug('Error fetching apps', error); + this.handleError(serverUrl); + } + }; + + refreshAppBindings = async (serverUrl: string) => { + try { + const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl); + const appsEnabled = (await getConfig(database))?.FeatureFlagAppsEnabled === 'true'; + if (!appsEnabled) { + this.getEnabledSubject(serverUrl).next(false); + this.clearServer(serverUrl); + } + + const channelId = await getCurrentChannelId(database); + + // We await here, since errors on this call may clear the thread bindings + await this.fetchBindings(serverUrl, channelId); + + const threadChannelId = this.getThreadsBindingsSubject(serverUrl).value.channelId; + if (threadChannelId) { + await this.fetchBindings(serverUrl, threadChannelId, true); + } + } catch (error) { + logDebug('Error refreshing apps', error); + this.handleError(serverUrl); + } + }; + + copyMainBindingsToThread = async (serverUrl: string, channelId: string) => { + this.getThreadsBindingsSubject(serverUrl).next({channelId, bindings: this.getBindingsSubject(serverUrl).value}); + }; + + clearBindings = async (serverUrl: string, forThread = false) => { + if (forThread) { + this.getThreadsBindingsSubject(serverUrl).next({channelId: '', bindings: emptyBindings}); + } else { + this.getBindingsSubject(serverUrl).next(emptyBindings); + } + }; + + observeBindings = (serverUrl: string, location?: string, forThread = false) => { + const isEnabled = this.observeIsAppsEnabled(serverUrl); + const bindings = forThread ? + this.getThreadsBindingsSubject(serverUrl).asObservable().pipe(switchMap(({bindings: bb}) => of$(bb))) : + this.getBindingsSubject(serverUrl).asObservable(); + + return combineLatest([isEnabled, bindings]).pipe( + switchMap(([e, bb]) => of$(e ? bb : emptyBindings)), + switchMap((bb) => { + const result = location ? bb.filter((b) => b.location === location) : bb; + return of$(result.length ? result : emptyBindings); + }), + ); + }; + + getBindings = (serverUrl: string, location?: string, forThread = false) => { + const bindings = forThread ? + this.getThreadsBindingsSubject(serverUrl).value.bindings : + this.getBindingsSubject(serverUrl).value; + + if (location) { + return bindings.filter((b) => b.location === location); + } + + return bindings; + }; + + getCommandForm = (serverUrl: string, key: string, forThread = false) => { + return forThread ? + this.threadCommandForms[serverUrl]?.[key] : + this.commandForms[serverUrl]?.[key]; + }; + + setCommandForm = (serverUrl: string, key: string, form: AppForm, forThread = false) => { + const toStore = forThread ? + this.threadCommandForms : + this.commandForms; + if (!toStore[serverUrl]) { + toStore[serverUrl] = {}; + } + toStore[serverUrl][key] = form; + }; +} + +export default new AppsManager(); diff --git a/app/managers/integrations_manager.ts b/app/managers/integrations_manager.ts index 003c5d1979..4380573503 100644 --- a/app/managers/integrations_manager.ts +++ b/app/managers/integrations_manager.ts @@ -14,12 +14,6 @@ class ServerIntegrationsManager { private triggerId = ''; private storedDialog?: InteractiveDialogConfig; - private bindings: AppBinding[] = []; - private rhsBindings: AppBinding[] = []; - - private commandForms: {[key: string]: AppForm | undefined} = {}; - private rhsCommandForms: {[key: string]: AppForm | undefined} = {}; - constructor(serverUrl: string) { this.serverUrl = serverUrl; } @@ -44,29 +38,6 @@ class ServerIntegrationsManager { } } - public getCommandBindings() { - // TODO filter bindings - return this.bindings; - } - - public getRHSCommandBindings() { - // TODO filter bindings - return this.rhsBindings; - } - - public getAppRHSCommandForm(key: string) { - return this.rhsCommandForms[key]; - } - public getAppCommandForm(key: string) { - return this.commandForms[key]; - } - public setAppRHSCommandForm(key: string, form: AppForm) { - this.rhsCommandForms[key] = form; - } - public setAppCommandForm(key: string, form: AppForm) { - this.commandForms[key] = form; - } - public setTriggerId(id: string) { this.triggerId = id; if (this.storedDialog?.trigger_id === id) { diff --git a/app/queries/servers/post.ts b/app/queries/servers/post.ts index 176cb999eb..c5a39cc8c7 100644 --- a/app/queries/servers/post.ts +++ b/app/queries/servers/post.ts @@ -9,6 +9,7 @@ import {Preferences} from '@constants'; import {MM_TABLES} from '@constants/database'; import {queryPreferencesByCategoryAndName} from './preference'; +import {observeUser} from './user'; import type PostModel from '@typings/database/models/servers/post'; import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel'; @@ -72,6 +73,10 @@ export const observePost = (database: Database, postId: string) => { ); }; +export const observePostAuthor = (database: Database, post: PostModel) => { + return post.userId ? observeUser(database, post.userId) : of$(null); +}; + export const observePostSaved = (database: Database, postId: string) => { return queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, postId). observeWithColumns(['value']).pipe( diff --git a/app/screens/apps_form/index.tsx b/app/screens/apps_form/index.tsx index e1486fd4c8..1b528af68d 100644 --- a/app/screens/apps_form/index.tsx +++ b/app/screens/apps_form/index.tsx @@ -5,6 +5,7 @@ import React, {useCallback, useState} from 'react'; import {useIntl} from 'react-intl'; import {doAppFetchForm, doAppLookup, doAppSubmit, postEphemeralCallResponseForContext} from '@actions/remote/apps'; +import {handleGotoLocation} from '@actions/remote/command'; import {AppCallResponseTypes} from '@constants/apps'; import {useServerUrl} from '@context/server'; import {createCallRequest, makeCallErrorResponse} from '@utils/apps'; @@ -77,6 +78,9 @@ function AppsFormContainer({ setCurrentForm(callResp.form); break; case AppCallResponseTypes.NAVIGATE: + if (callResp.navigate_to_url) { + handleGotoLocation(serverUrl, intl, callResp.navigate_to_url); + } break; default: return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage( diff --git a/app/screens/edit_post/edit_post.tsx b/app/screens/edit_post/edit_post.tsx index 4036b608fc..9aa8abe602 100644 --- a/app/screens/edit_post/edit_post.tsx +++ b/app/screens/edit_post/edit_post.tsx @@ -254,6 +254,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach position={animatedAutocompletePosition} availableSpace={animatedAutocompleteAvailableSpace} inPost={false} + serverUrl={serverUrl} /> ); diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 42f95a8708..7eb8fda449 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -689,8 +689,8 @@ export async function openAsBottomSheet({closeButtonId, screen, theme, title, pr } } -export const showAppForm = async (form: AppForm, call: AppCallRequest) => { - const passProps = {form, call}; +export const showAppForm = async (form: AppForm) => { + const passProps = {form}; showModal(Screens.APPS_FORM, form.title || '', passProps); };