diff --git a/app/actions/remote/apps.ts b/app/actions/remote/apps.ts index c6776443e6..8333d77fea 100644 --- a/app/actions/remote/apps.ts +++ b/app/actions/remote/apps.ts @@ -207,7 +207,7 @@ export function postEphemeralCallResponseForPost(serverUrl: string, response: Ap serverUrl, message, post.channelId, - post.rootId, + post.rootId || post.id, response.app_metadata?.bot_user_id, ); } diff --git a/app/actions/remote/command.ts b/app/actions/remote/command.ts index b6a5f1cff4..4cb475925f 100644 --- a/app/actions/remote/command.ts +++ b/app/actions/remote/command.ts @@ -113,7 +113,7 @@ const executeAppCommand = async (serverUrl: string, intl: IntlShape, parser: App return {data: {}}; case AppCallResponseTypes.FORM: if (callResp.form) { - showAppForm(callResp.form); + showAppForm(callResp.form, creq.context); } return {data: {}}; case AppCallResponseTypes.NAVIGATE: diff --git a/app/actions/remote/user.ts b/app/actions/remote/user.ts index e04a795e83..925288d1da 100644 --- a/app/actions/remote/user.ts +++ b/app/actions/remote/user.ts @@ -407,7 +407,7 @@ export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetc return {users: [], existingUsers}; } const users = await client.getProfilesByIds([...new Set(usersToLoad)]); - if (!fetchOnly) { + if (!fetchOnly && users.length) { await operator.handleUsers({ users, prepareRecordsOnly: false, 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 375be1c8a4..df080fbd04 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 @@ -984,6 +984,9 @@ export class AppCommandParser { break; } user = res.users[0] || res.existingUsers[0]; + if (!user) { + break; + } } parsed.values[f.name] = user.username; break; @@ -998,6 +1001,9 @@ export class AppCommandParser { break; } channel = res.channel; + if (!channel) { + break; + } } parsed.values[f.name] = channel.name; break; @@ -1176,14 +1182,21 @@ export class AppCommandParser { const errors: {[key: string]: string} = {}; await Promise.all(parsed.resolvedForm.fields.map(async (f) => { - if (!values[f.name]) { + const fieldValue = values[f.name]; + if (!fieldValue) { return; } switch (f.type) { case AppFieldTypes.DYNAMIC_SELECT: - if (f.multiselect && Array.isArray(values[f.name])) { + if (f.multiselect) { + let commandValues: string[] = []; + if (Array.isArray(fieldValue)) { + commandValues = fieldValue as string[]; + } else { + commandValues = [fieldValue] as string[]; + } + const options: AppSelectOption[] = []; - const commandValues = values[f.name] as string[]; for (const value of commandValues) { if (options.find((o) => o.value === value)) { errors[f.name] = this.intl.formatMessage({ @@ -1199,7 +1212,7 @@ export class AppCommandParser { break; } - values[f.name] = {label: values[f.name], value: values[f.name]}; + values[f.name] = {label: fieldValue, value: fieldValue}; break; case AppFieldTypes.STATIC_SELECT: { const getOption = (value: string) => { @@ -1217,9 +1230,15 @@ export class AppCommandParser { values[f.name] = undefined; }; - if (f.multiselect && Array.isArray(values[f.name])) { + if (f.multiselect) { + let commandValues: string[] = []; + if (Array.isArray(fieldValue)) { + commandValues = fieldValue as string[]; + } else { + commandValues = [fieldValue] as string[]; + } + const options: AppSelectOption[] = []; - const commandValues = values[f.name] as string[]; for (const value of commandValues) { const option = getOption(value); if (!option) { @@ -1241,9 +1260,9 @@ export class AppCommandParser { break; } - const option = getOption(values[f.name]); + const option = getOption(fieldValue); if (!option) { - setOptionError(values[f.name]); + setOptionError(fieldValue); return; } values[f.name] = option; @@ -1272,9 +1291,15 @@ export class AppCommandParser { }); }; - if (f.multiselect && Array.isArray(values[f.name])) { + if (f.multiselect) { + let commandValues: string[] = []; + if (Array.isArray(fieldValue)) { + commandValues = fieldValue as string[]; + } else { + commandValues = [fieldValue] as string[]; + } + const options: AppSelectOption[] = []; - const commandValues = values[f.name] as string[]; /* eslint-disable no-await-in-loop */ for (const value of commandValues) { let userName = value; @@ -1338,9 +1363,15 @@ export class AppCommandParser { }); }; - if (f.multiselect && Array.isArray(values[f.name])) { + if (f.multiselect) { + let commandValues: string[] = []; + if (Array.isArray(fieldValue)) { + commandValues = fieldValue as string[]; + } else { + commandValues = [fieldValue] as string[]; + } + const options: AppSelectOption[] = []; - const commandValues = values[f.name] as string[]; /* eslint-disable no-await-in-loop */ for (const value of commandValues) { let channelName = value; @@ -1432,8 +1463,7 @@ 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 bindings = AppsManager.getBindings(this.serverUrl, AppBindingLocations.COMMAND, Boolean(this.rootPostID)); - return bindings.reduce((acc, v) => (v.bindings ? acc.concat(v.bindings) : acc), []); + return AppsManager.getBindings(this.serverUrl, AppBindingLocations.COMMAND, Boolean(this.rootPostID)); }; // getChannel gets the channel in which the user is typing the command @@ -1685,7 +1715,13 @@ export class AppCommandParser { prefix = ''; } - const applicable = parsed.resolvedForm.fields.filter((field) => field.label && field.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()) && !parsed.values[field.name]); + const applicable = parsed.resolvedForm.fields.filter((field) => ( + field.label && + field.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()) && + !parsed.values[field.name] && + !field.readonly && + field.type !== AppFieldTypes.MARKDOWN + )); if (applicable) { return applicable.map((f) => { return { 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 c4cb57e744..3abae6636d 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 @@ -15,9 +15,6 @@ import analytics from '@managers/analytics'; import {AppCommandParser, ExtendedAutocompleteSuggestion} from '../app_command_parser/app_command_parser'; import SlashSuggestionItem from '../slash_suggestion_item'; -import type ChannelModel from '@typings/database/models/servers/channel'; -import type UserModel from '@typings/database/models/servers/user'; - export type Props = { currentTeamId: string; isSearch?: boolean; @@ -31,7 +28,20 @@ export type Props = { listStyle: StyleProp; }; -const keyExtractor = (item: ExtendedAutocompleteSuggestion): string => item.Suggestion + item.type + item.item; +const keyExtractor = (item: ExtendedAutocompleteSuggestion): string => { + switch (item.type) { + case COMMAND_SUGGESTION_USER: { + const user = item.item as UserProfile; + return user.id; + } + case COMMAND_SUGGESTION_CHANNEL: { + const channel = item.item as Channel; + return channel.id; + } + default: + return item.Suggestion; + } +}; const emptySuggestonList: AutocompleteSuggestion[] = []; @@ -96,28 +106,34 @@ const AppSlashSuggestion = ({ const renderItem = useCallback(({item}: {item: ExtendedAutocompleteSuggestion}) => { switch (item.type) { - case COMMAND_SUGGESTION_USER: - if (!item.item) { + case COMMAND_SUGGESTION_USER: { + const user = item.item as UserProfile | undefined; + if (!user) { return null; } + return ( ); - case COMMAND_SUGGESTION_CHANNEL: - if (!item.item) { + } + case COMMAND_SUGGESTION_CHANNEL: { + const channel = item.item as Channel | undefined; + if (!channel) { return null; } + return ( ); + } default: return ( Promise; helpText?: string; label?: string; - onSelected?: (value: string | string[]) => void; + onSelected?: (value: SelectedDialogOption) => void; optional?: boolean; - options?: PostActionOption[]; + options?: DialogOption[]; placeholder?: string; roundedBorders?: boolean; - selected?: string | string[]; + selected?: SelectedDialogValue; showRequiredAsterisk?: boolean; teammateNameDisplay: string; isMultiselect?: boolean; @@ -89,7 +89,11 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { }; }); -async function getItemName(serverUrl: string, selected: string, teammateNameDisplay: string, intl: IntlShape, dataSource?: string, options?: PostActionOption[]) { +async function getItemName(serverUrl: string, selected: string, teammateNameDisplay: string, intl: IntlShape, dataSource?: string, options?: DialogOption[]): Promise { + if (!selected) { + return ''; + } + const database = DatabaseManager.serverDatabases[serverUrl]?.database; switch (dataSource) { @@ -97,6 +101,7 @@ async function getItemName(serverUrl: string, selected: string, teammateNameDisp if (!database) { return intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'}); } + const user = await getUserById(database, selected); return displayUsername(user, intl.locale, teammateNameDisplay, true); } @@ -104,15 +109,17 @@ async function getItemName(serverUrl: string, selected: string, teammateNameDisp if (!database) { return intl.formatMessage({id: 'autocomplete_selector.unknown_channel', defaultMessage: 'Unknown channel'}); } + const channel = await getChannelById(database, selected); return channel?.displayName || intl.formatMessage({id: 'autocomplete_selector.unknown_channel', defaultMessage: 'Unknown channel'}); } - default: - return options?.find((o) => o.value === selected)?.text || selected; } + + const option = options?.find((opt) => opt.value === selected); + return option?.text || ''; } -function getTextAndValueFromSelectedItem(item: DialogOption | Channel | UserProfile, teammateNameDisplay: string, locale: string, dataSource?: string) { +function getTextAndValueFromSelectedItem(item: Selection, teammateNameDisplay: string, locale: string, dataSource?: string) { if (dataSource === ViewConstants.DATA_SOURCE_USERS) { const user = item as UserProfile; return {text: displayUsername(user, locale, teammateNameDisplay), value: user.id}; @@ -120,8 +127,7 @@ function getTextAndValueFromSelectedItem(item: DialogOption | Channel | UserProf const channel = item as Channel; return {text: channel.display_name, value: channel.id}; } - const option = item as DialogOption; - return option; + return item as DialogOption; } function AutoCompleteSelector({ @@ -140,31 +146,25 @@ function AutoCompleteSelector({ goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect, teammateNameDisplay}); }), [dataSource, options, getDynamicOptions]); - const handleSelect = useCallback((item?: Selection) => { - if (!item) { + const handleSelect = useCallback((newSelection?: Selection) => { + if (!newSelection) { return; } - if (!Array.isArray(item)) { - const {text: selectedText, value: selectedValue} = getTextAndValueFromSelectedItem(item, teammateNameDisplay, intl.locale, dataSource); - setItemText(selectedText); + if (!Array.isArray(newSelection)) { + const selectedOption = getTextAndValueFromSelectedItem(newSelection, teammateNameDisplay, intl.locale, dataSource); + setItemText(selectedOption.text); if (onSelected) { - onSelected(selectedValue); + onSelected(selectedOption); } return; } - const allSelectedTexts = []; - const allSelectedValues = []; - for (const i of item) { - const {text: selectedText, value: selectedValue} = getTextAndValueFromSelectedItem(i, teammateNameDisplay, intl.locale, dataSource); - allSelectedTexts.push(selectedText); - allSelectedValues.push(selectedValue); - } - setItemText(allSelectedTexts.join(', ')); + const selectedOptions = newSelection.map((option) => getTextAndValueFromSelectedItem(option, teammateNameDisplay, intl.locale, dataSource)); + setItemText(selectedOptions.map((option) => option.text).join(', ')); if (onSelected) { - onSelected(allSelectedValues); + onSelected(selectedOptions); } }, [teammateNameDisplay, intl, dataSource]); diff --git a/app/components/option_item/index.tsx b/app/components/option_item/index.tsx index 33a4ebd4df..1915e9ae39 100644 --- a/app/components/option_item/index.tsx +++ b/app/components/option_item/index.tsx @@ -10,6 +10,7 @@ import {useTheme} from '@context/theme'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import {typography} from '@utils/typography'; +import OptionIcon from './option_icon'; import RadioItem, {RadioItemProps} from './radio_item'; const TouchableOptionTypes = { @@ -238,10 +239,10 @@ const OptionItem = ({ {Boolean(icon) && ( - )} diff --git a/app/components/option_item/option_icon.tsx b/app/components/option_item/option_icon.tsx new file mode 100644 index 0000000000..3b8919e24d --- /dev/null +++ b/app/components/option_item/option_icon.tsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useCallback, useMemo, useState} from 'react'; +import FastImage from 'react-native-fast-image'; + +import CompassIcon from '@components/compass_icon'; +import {useTheme} from '@context/theme'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {isValidUrl} from '@utils/url'; + +type OptionIconProps = { + icon: string; + iconColor?: string; + destructive?: boolean; +}; + +const getStyleSheet = makeStyleSheetFromTheme(() => { + return { + icon: { + fontSize: 24, + width: 24, + height: 24, + }, + }; +}); + +const OptionIcon = ({icon, iconColor, destructive}: OptionIconProps) => { + const theme = useTheme(); + const styles = getStyleSheet(theme); + + const [failedToLoadImage, setFailedToLoadImage] = useState(false); + const onErrorLoadingIcon = useCallback(() => { + setFailedToLoadImage(true); + }, []); + + const iconAsSource = useMemo(() => { + return {uri: icon}; + }, [icon]); + + if (isValidUrl(icon) && !failedToLoadImage) { + return ( + + ); + } + + const iconName = failedToLoadImage ? 'power-plugin-outline' : icon; + return ( + + ); +}; + +export default OptionIcon; diff --git a/app/components/post_draft/send_handler/send_handler.tsx b/app/components/post_draft/send_handler/send_handler.tsx index b17b6c7217..6ffb5fb51d 100644 --- a/app/components/post_draft/send_handler/send_handler.tsx +++ b/app/components/post_draft/send_handler/send_handler.tsx @@ -217,11 +217,6 @@ export default function SendHandler({ clearDraft(); - // TODO Apps related https://mattermost.atlassian.net/browse/MM-41233 - // if (data?.form) { - // showAppForm(data.form, data.call, theme); - // } - if (data?.goto_location && !value.startsWith('/leave')) { handleGotoLocation(serverUrl, intl, data.goto_location); } 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 9c67de3bf7..117098c75e 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 @@ -105,7 +105,7 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) => return; case AppCallResponseTypes.FORM: if (callResp.form) { - showAppForm(callResp.form); + showAppForm(callResp.form, context); } return; default: { 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 a58afbf72e..db9d89a994 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 @@ -3,18 +3,14 @@ import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; import withObservables from '@nozbe/with-observables'; -import React, {useCallback, useState} from 'react'; -import {useIntl} from 'react-intl'; +import React, {useCallback, useMemo, useState} from 'react'; import {map} from 'rxjs/operators'; -import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/remote/apps'; -import {handleGotoLocation} from '@actions/remote/command'; +import {postEphemeralCallResponseForPost} from '@actions/remote/apps'; import AutocompleteSelector from '@components/autocomplete_selector'; -import {AppBindingLocations, AppCallResponseTypes} from '@constants/apps'; import {useServerUrl} from '@context/server'; +import {useAppBinding} from '@hooks/apps'; import {observeCurrentTeamId} from '@queries/servers/system'; -import {showAppForm} from '@screens/navigation'; -import {createCallContext} from '@utils/apps'; import {logDebug} from '@utils/log'; import type {WithDatabaseArgs} from '@typings/database/database'; @@ -30,70 +26,46 @@ type Props = { const MenuBinding = ({binding, currentTeamId, post, teamID}: Props) => { const [selected, setSelected] = useState(); - const intl = useIntl(); const serverUrl = useServerUrl(); - const onSelect = useCallback(async (picked?: string | string[]) => { - if (!picked || Array.isArray(picked)) { // We are sure AutocompleteSelector only returns one, since it is not multiselect. + const onCallResponse = useCallback((callResp: AppCallResponse, message: string) => { + postEphemeralCallResponseForPost(serverUrl, callResp, message, post); + }, [serverUrl, post]); + + const context = useMemo(() => ({ + channel_id: post.channelId, + team_id: teamID || currentTeamId, + post_id: post.id, + root_id: post.rootId || post.id, + }), [post, teamID, currentTeamId]); + + const config = useMemo(() => ({ + onSuccess: onCallResponse, + onError: onCallResponse, + }), [onCallResponse]); + + const handleBindingSubmit = useAppBinding(context, config); + + const onSelect = useCallback(async (picked: SelectedDialogOption) => { + if (!picked || Array.isArray(picked)) { return; } - setSelected(picked); + setSelected(picked.value); - const bind = binding.bindings?.find((b) => b.location === picked); + const bind = binding.bindings?.find((b) => b.location === picked.value); if (!bind) { logDebug('Trying to select element not present in binding.'); return; } - const context = createCallContext( - bind.app_id, - AppBindingLocations.IN_POST + bind.location, - post.channelId, - teamID || currentTeamId, - post.id, - ); + const finish = await handleBindingSubmit(bind); + finish(); + }, [handleBindingSubmit, binding.bindings]); - const res = await handleBindingClick(serverUrl, bind, context, intl); - if (res.error) { - const errorResponse = res.error; - const errorMessage = errorResponse.text || intl.formatMessage({ - id: 'apps.error.unknown', - defaultMessage: 'Unknown error occurred.', - }); - postEphemeralCallResponseForPost(serverUrl, errorResponse, errorMessage, post); - return; - } - - const callResp = res.data!; - switch (callResp.type) { - case AppCallResponseTypes.OK: - if (callResp.text) { - postEphemeralCallResponseForPost(serverUrl, callResp, callResp.text, post); - } - 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({ - id: 'apps.error.responses.unknown_type', - defaultMessage: 'App response type not supported. Response type: {type}.', - }, { - type: callResp.type, - }); - postEphemeralCallResponseForPost(serverUrl, callResp, errorMessage, post); - } - } - }, []); - - const options = binding.bindings?.map((b: AppBinding) => ({text: b.label, value: b.location || ''})); + const options = useMemo(() => binding.bindings?.map((b: AppBinding) => ({ + text: b.label, + value: b.location || '', + })), [binding.bindings]); return ( { - if (Array.isArray(selectedItem)) { // Since AutocompleteSelector is not multiselect, we are sure we only receive a string + const handleSelect = useCallback(async (selectedItem: SelectedDialogOption) => { + if (!selectedItem || Array.isArray(selectedItem)) { return; } - const result = await selectAttachmentMenuAction(serverUrl, postId, id, selectedItem); + const result = await selectAttachmentMenuAction(serverUrl, postId, id, selectedItem.value); if (result.data?.trigger_id) { - setSelected(selectedItem); + setSelected(selectedItem.value); } }, []); @@ -40,6 +40,7 @@ const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, pos void; + onError: (callResponse: AppCallResponse, message: string) => void; + onForm?: (form: AppForm) => void; + onNavigate?: (callResp: AppCallResponse) => void; +} + +export const useAppBinding = (context: UseAppBindingContext, config: UseAppBindingConfig) => { + const serverUrl = useServerUrl(); + const intl = useIntl(); + + return useCallback(async (binding: AppBinding) => { + const callContext = createCallContext( + binding.app_id, + binding.location, + context.channel_id, + context.team_id, + context.post_id, + context.root_id, + ); + + const res = await handleBindingClick(serverUrl, binding, callContext, intl); + + return async () => { + if (res.error) { + const errorResponse = res.error; + const errorMessage = errorResponse.text || intl.formatMessage({ + id: 'apps.error.unknown', + defaultMessage: 'Unknown error occurred.', + }); + + config.onError(errorResponse, errorMessage); + return; + } + + const callResp = res.data!; + switch (callResp.type) { + case AppCallResponseTypes.OK: + if (callResp.text) { + config.onSuccess(callResp, callResp.text); + } + return; + case AppCallResponseTypes.NAVIGATE: + if (callResp.navigate_to_url) { + if (config.onNavigate) { + config.onNavigate(callResp); + } else { + await handleGotoLocation(serverUrl, intl, callResp.navigate_to_url); + } + } + return; + case AppCallResponseTypes.FORM: + if (callResp.form) { + if (config.onForm) { + config.onForm(callResp.form); + } else { + await showAppForm(callResp.form, callContext); + } + } + return; + default: { + const errorMessage = intl.formatMessage({ + id: 'apps.error.responses.unknown_type', + defaultMessage: 'App response type not supported. Response type: {type}.', + }, { + type: callResp.type, + }); + + config.onError(callResp, errorMessage); + } + } + }; + }, [context, config, serverUrl, intl]); +}; diff --git a/app/managers/apps_manager.ts b/app/managers/apps_manager.ts index 960e992013..dfb9b03b14 100644 --- a/app/managers/apps_manager.ts +++ b/app/managers/apps_manager.ts @@ -14,6 +14,8 @@ import NetworkManager from './network_manager'; const emptyBindings: AppBinding[] = []; +const normalizeBindings = (bindings: AppBinding[]) => bindings.reduce((acc, v) => (v.bindings ? acc.concat(v.bindings) : acc), []); + class AppsManager { private enabled: {[serverUrl: string]: BehaviorSubject} = {}; @@ -177,22 +179,23 @@ class AppsManager { return combineLatest([isEnabled, bindings]).pipe( switchMap(([e, bb]) => of$(e ? bb : emptyBindings)), switchMap((bb) => { - const result = location ? bb.filter((b) => b.location === location) : bb; + let result = location ? bb.filter((b) => b.location === location) : bb; + result = normalizeBindings(result); return of$(result.length ? result : emptyBindings); }), ); }; getBindings = (serverUrl: string, location?: string, forThread = false) => { - const bindings = forThread ? + let bindings = forThread ? this.getThreadsBindingsSubject(serverUrl).value.bindings : this.getBindingsSubject(serverUrl).value; if (location) { - return bindings.filter((b) => b.location === location); + bindings = bindings.filter((b) => b.location === location); } - return bindings; + return normalizeBindings(bindings); }; getCommandForm = (serverUrl: string, key: string, forThread = false) => { diff --git a/app/screens/apps_form/apps_form_component.tsx b/app/screens/apps_form/apps_form_component.tsx index e71a1ce3d2..ef6c7dcccb 100644 --- a/app/screens/apps_form/apps_form_component.tsx +++ b/app/screens/apps_form/apps_form_component.tsx @@ -49,6 +49,11 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => { color: (theme.errorTextColor || '#DA4A4A'), }, button: buttonBackgroundStyle(theme, 'lg', 'primary', 'default'), + buttonContainer: { + paddingTop: 20, + paddingLeft: 50, + paddingRight: 50, + }, buttonText: buttonTextStyle(theme, 'lg', 'primary', 'default'), }; }); @@ -75,7 +80,7 @@ export type Props = { form: AppForm; componentId: string; refreshOnSelect: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise>; - submit: (submission: {values: AppFormValues}) => Promise>; + submit: (values: AppFormValues) => Promise>; performLookupCall: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise>; } @@ -94,9 +99,9 @@ function valuesReducer(state: AppFormValues, action: ValuesAction) { return {...state, [action.name]: action.value}; } -function initValues(elements?: AppField[]) { +function initValues(fields?: AppField[]) { const values: AppFormValues = {}; - elements?.forEach((e) => { + fields?.forEach((e) => { if (e.type === 'bool') { values[e.name] = (e.value === true || String(e.value).toLowerCase() === 'true'); } else if (e.value) { @@ -126,15 +131,6 @@ function AppsFormComponent({ const theme = useTheme(); const style = getStyleFromTheme(theme); - const onHandleSubmit = useCallback(() => { - if (!submitting) { - handleSubmit(); - } - }, [serverUrl, componentId, submitting]); - - useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, [close]); - useNavButtonPressed(SUBMIT_BUTTON_ID, componentId, onHandleSubmit, [onHandleSubmit]); - useDidUpdate(() => { dispatchValues({elements: form.fields}); }, [form]); @@ -153,26 +149,62 @@ function AppsFormComponent({ undefined, intl.formatMessage({id: 'interactive_dialog.submit', defaultMessage: 'Submit'}), ); - base.enabled = submitting; + base.enabled = !submitting; base.showAsAction = 'always'; base.color = theme.sidebarHeaderTextColor; return base; - }, [theme.sidebarHeaderTextColor, intl, Boolean(submitButtons)]); + }, [theme.sidebarHeaderTextColor, Boolean(submitButtons), submitting, intl]); useEffect(() => { setButtons(componentId, { rightButtons: rightButton ? [rightButton] : [], }); - }, [rightButton, componentId]); + }, [componentId, rightButton]); useEffect(() => { const icon = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor); setButtons(componentId, { leftButtons: [makeCloseButton(icon)], }); - }, [theme]); + }, [componentId, theme]); - const onChange = useCallback((name: string, value: any) => { + const updateErrors = useCallback((elements: DialogElement[], fieldErrors?: {[x: string]: string}, formError?: string): boolean => { + let hasErrors = false; + let hasHeaderError = false; + if (formError) { + hasErrors = true; + hasHeaderError = true; + setError(formError); + } else { + setError(''); + } + + if (fieldErrors && Object.keys(fieldErrors).length > 0) { + hasErrors = true; + if (checkIfErrorsMatchElements(fieldErrors, elements)) { + setErrors(fieldErrors); + } else if (!hasHeaderError) { + hasHeaderError = true; + const field = Object.keys(fieldErrors)[0]; + setError(intl.formatMessage({ + id: 'apps.error.responses.unknown_field_error', + defaultMessage: 'Received an error for an unknown field. Field name: `{field}`. Error: `{error}`.', + }, { + field, + error: fieldErrors[field], + })); + } + } + + if (hasErrors) { + if (hasHeaderError && scrollView.current) { + scrollView.current.scrollTo({x: 0, y: 0}); + } + } + return hasErrors; + }, [intl]); + + const onChange = useCallback((name: string, value: AppFormValue) => { const field = form.fields?.find((f) => f.name === name); if (!field) { return; @@ -216,43 +248,13 @@ function AppsFormComponent({ } dispatchValues({name, value}); - }, []); - - const updateErrors = (elements: DialogElement[], fieldErrors?: {[x: string]: string}, formError?: string): boolean => { - let hasErrors = false; - let hasHeaderError = false; - if (formError) { - hasErrors = true; - hasHeaderError = true; - setError(formError); - } - - if (fieldErrors && Object.keys(fieldErrors).length > 0) { - hasErrors = true; - if (checkIfErrorsMatchElements(fieldErrors, elements)) { - setErrors(fieldErrors); - } else if (!hasHeaderError) { - hasHeaderError = true; - const field = Object.keys(fieldErrors)[0]; - setError(intl.formatMessage({ - id: 'apps.error.responses.unknown_field_error', - defaultMessage: 'Received an error for an unknown field. Field name: `{field}`. Error: `{error}`.', - }, { - field, - error: fieldErrors[field], - })); - } - } - - if (hasErrors) { - if (hasHeaderError && scrollView.current) { - scrollView.current.scrollTo({x: 0, y: 0}); - } - } - return hasErrors; - }; + }, [form, values, refreshOnSelect, updateErrors, intl]); const handleSubmit = useCallback(async (button?: string) => { + if (submitting) { + return; + } + const {fields} = form; const fieldErrors: {[name: string]: string} = {}; @@ -269,21 +271,19 @@ function AppsFormComponent({ } }); - setErrors(hasErrors ? fieldErrors : emptyErrorsState); - if (hasErrors) { + setErrors(fieldErrors); return; } - const submission = { - values, - }; + const submission = {...values}; if (button && form.submit_buttons) { - submission.values[form.submit_buttons] = button; + submission[form.submit_buttons] = button; } setSubmitting(true); + const res = await submit(submission); if (res.error) { @@ -298,6 +298,9 @@ function AppsFormComponent({ return; } + setError(''); + setErrors(emptyErrorsState); + const callResponse = res.data!; switch (callResponse.type) { case AppCallResponseTypes.OK: @@ -319,7 +322,7 @@ function AppsFormComponent({ })); setSubmitting(false); } - }, []); + }, [form, values, submit, submitting, updateErrors, serverUrl, intl]); const performLookup = useCallback(async (name: string, userInput: string): Promise => { const field = form.fields?.find((f) => f.name === name); @@ -369,7 +372,10 @@ function AppsFormComponent({ return []; } } - }, []); + }, [form, values, performLookupCall, intl]); + + useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, [close]); + useNavButtonPressed(SUBMIT_BUTTON_ID, componentId, handleSubmit, [handleSubmit]); return ( {submitButtons?.options?.map((o) => ( - + + ))} diff --git a/app/screens/apps_form/apps_form_field.tsx b/app/screens/apps_form/apps_form_field.tsx index f7ab384108..be10a6b1d1 100644 --- a/app/screens/apps_form/apps_form_field.tsx +++ b/app/screens/apps_form/apps_form_field.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useCallback} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import AutocompleteSelector from '@components/autocomplete_selector'; @@ -9,7 +9,7 @@ import Markdown from '@components/markdown'; import BoolSetting from '@components/settings/bool_setting'; import TextSetting from '@components/settings/text_setting'; import {View as ViewConstants} from '@constants'; -import {AppFieldTypes} from '@constants/apps'; +import {AppFieldTypes, SelectableAppFieldTypes} from '@constants/apps'; import {useTheme} from '@context/theme'; import {selectKeyboardType} from '@utils/integrations'; import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown'; @@ -23,10 +23,20 @@ export type Props = { name: string; errorText?: string; value: AppFormValue; - onChange: (name: string, value: string | string[] | boolean) => void; + onChange: (name: string, value: AppFormValue) => void; performLookup: (name: string, userInput: string) => Promise; } +const dialogOptionToAppSelectOption = (option: DialogOption): AppSelectOption => ({ + label: option.text, + value: option.value, +}); + +const appSelectOptionToDialogOption = (option: AppSelectOption): DialogOption => ({ + text: option.label, + value: option.value, +}); + const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => { return { markdownFieldContainer: { @@ -69,18 +79,68 @@ function AppsFormField({ const placeholder = field.hint || ''; const displayName = field.modal_label || field.label || ''; - const handleChange = useCallback((newValue: string | boolean | string[]) => { + const handleChange = useCallback((newValue: string | boolean) => { onChange(name, newValue); }, [name]); + const handleSelect = useCallback((newValue: SelectedDialogOption) => { + if (!newValue) { + const emptyValue = field.multiselect ? [] : null; + onChange(name, emptyValue); + return; + } + + if (Array.isArray(newValue)) { + const selectedOptions = newValue.map(dialogOptionToAppSelectOption); + onChange(name, selectedOptions); + return; + } + + onChange(name, dialogOptionToAppSelectOption(newValue)); + }, [onChange, field, name]); + const getDynamicOptions = useCallback(async (userInput = ''): Promise => { const options = await performLookup(field.name, userInput); - return options.map((option) => ({ - text: option.label, - value: option.value, - })); + return options.map(appSelectOptionToDialogOption); }, [performLookup, field]); + const options = useMemo(() => { + if (field.type === AppFieldTypes.STATIC_SELECT) { + return field.options?.map(appSelectOptionToDialogOption); + } + + if (field.type === AppFieldTypes.DYNAMIC_SELECT) { + if (!value) { + return undefined; + } + + if (Array.isArray(value)) { + return value.map(appSelectOptionToDialogOption); + } + + const selectedOption = value as AppSelectOption; + return [appSelectOptionToDialogOption(selectedOption)]; + } + + return undefined; + }, [field, value]); + + const selectedValue = useMemo(() => { + if (!value || !SelectableAppFieldTypes.includes(field.type)) { + return undefined; + } + + if (!value) { + return undefined; + } + + if (Array.isArray(value)) { + return value.map((v) => v.value); + } + + return value as string; + }, [field, value]); + switch (field.type) { case AppFieldTypes.TEXT: { return ( @@ -105,24 +165,19 @@ function AppsFormField({ case AppFieldTypes.CHANNEL: case AppFieldTypes.STATIC_SELECT: case AppFieldTypes.DYNAMIC_SELECT: { - let options: DialogOption[] | undefined; - if (field.type === AppFieldTypes.STATIC_SELECT && field.options) { - options = field.options.map((option) => ({text: option.label, value: option.value})); - } - return ( ; error?: AppCallResponse}> => { + const submit = useCallback(async (submission: AppFormValues): Promise<{data?: AppCallResponse; error?: AppCallResponse}> => { const makeErrorMsg = (msg: string) => { return intl.formatMessage( { @@ -60,7 +60,7 @@ function AppsFormContainer({ return {error: makeCallErrorResponse('unreachable: empty context')}; } - const creq = createCallRequest(currentForm.submit, context, {}, submission.values); + const creq = createCallRequest(currentForm.submit, context, {}, submission); const res = await doAppSubmit(serverUrl, creq, intl); if (res.error) { @@ -93,7 +93,7 @@ function AppsFormContainer({ )))}; } return res; - }, []); + }, [currentForm, setCurrentForm, context, serverUrl, intl]); const refreshOnSelect = useCallback(async (field: AppField, values: AppFormValues): Promise> => { const makeErrorMsg = (message: string) => intl.formatMessage( @@ -161,7 +161,7 @@ function AppsFormContainer({ )))}; } return res; - }, []); + }, [currentForm, setCurrentForm, context, serverUrl, intl]); const performLookupCall = useCallback(async (field: AppField, values: AppFormValues, userInput: string): Promise> => { const makeErrorMsg = (message: string) => intl.formatMessage( @@ -187,7 +187,7 @@ function AppsFormContainer({ creq.query = userInput; return doAppLookup(serverUrl, creq, intl); - }, []); + }, [context, serverUrl, intl]); if (!currentForm?.submit || !context) { return null; diff --git a/app/screens/channel_info/app_bindings/index.tsx b/app/screens/channel_info/app_bindings/index.tsx new file mode 100644 index 0000000000..e9dcffb976 --- /dev/null +++ b/app/screens/channel_info/app_bindings/index.tsx @@ -0,0 +1,94 @@ +// 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 React, {useCallback, useMemo} from 'react'; + +import {postEphemeralCallResponseForChannel} from '@actions/remote/apps'; +import OptionItem from '@components/option_item'; +import {AppBindingLocations} from '@constants/apps'; +import {useAppBinding} from '@hooks/apps'; +import AppsManager from '@managers/apps_manager'; +import {observeCurrentTeamId} from '@queries/servers/system'; +import {WithDatabaseArgs} from '@typings/database/database'; +import {preventDoubleTap} from '@utils/tap'; + +type Props = { + channelId: string; + teamId: string; + serverUrl: string; + bindings: AppBinding[]; + dismissChannelInfo: () => Promise; +}; + +const ChannelInfoAppBindings = ({channelId, teamId, dismissChannelInfo, serverUrl, bindings}: Props) => { + const onCallResponse = useCallback((callResp: AppCallResponse, message: string) => { + postEphemeralCallResponseForChannel(serverUrl, callResp, message, channelId); + }, [serverUrl, channelId]); + + const context = useMemo(() => ({ + channel_id: channelId, + team_id: teamId, + }), [channelId, teamId]); + + const config = useMemo(() => ({ + onSuccess: onCallResponse, + onError: onCallResponse, + }), [onCallResponse]); + + const handleBindingSubmit = useAppBinding(context, config); + + const onPress = useCallback(preventDoubleTap(async (binding: AppBinding) => { + const submitPromise = handleBindingSubmit(binding); + await dismissChannelInfo(); + + const finish = await submitPromise; + await finish(); + }), [handleBindingSubmit]); + + const options = bindings.map((binding) => ( + + )); + + return <>{options}; +}; + +const BindingOptionItem = ({binding, onPress}: {binding: AppBinding; onPress: (binding: AppBinding) => void}) => { + const handlePress = useCallback(preventDoubleTap(() => { + onPress(binding); + }), [binding, onPress]); + + return ( + + ); +}; + +type OwnProps = { + channelId: string; + serverUrl: string; +} + +const enhanced = withObservables([], (ownProps: WithDatabaseArgs & OwnProps) => { + const {database} = ownProps; + const teamId = observeCurrentTeamId(database); + + const bindings = AppsManager.observeBindings(ownProps.serverUrl, AppBindingLocations.CHANNEL_HEADER_ICON); + + return { + teamId, + bindings, + }; +}); + +export default React.memo(withDatabase(enhanced(ChannelInfoAppBindings))); diff --git a/app/screens/channel_info/channel_info.tsx b/app/screens/channel_info/channel_info.tsx index 2d44f1bb91..cf44f619c9 100644 --- a/app/screens/channel_info/channel_info.tsx +++ b/app/screens/channel_info/channel_info.tsx @@ -5,6 +5,7 @@ import React, {useCallback} from 'react'; import {ScrollView, View} from 'react-native'; import {Edge, SafeAreaView} from 'react-native-safe-area-context'; +import {useServerUrl} from '@app/context/server'; import ChannelInfoEnableCalls from '@calls/components/channel_info_enable_calls'; import ChannelActions from '@components/channel_actions'; import {useTheme} from '@context/theme'; @@ -12,6 +13,7 @@ import useNavButtonPressed from '@hooks/navigation_button_pressed'; import {dismissModal} from '@screens/navigation'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import ChannelInfoAppBindings from './app_bindings'; import DestructiveOptions from './destructive_options'; import Extra from './extra'; import Options from './options'; @@ -54,11 +56,12 @@ const ChannelInfo = ({ isCallsFeatureRestricted, }: Props) => { const theme = useTheme(); + const serverUrl = useServerUrl(); const styles = getStyleSheet(theme); const callsAvailable = isCallsEnabledInChannel && !isCallsFeatureRestricted; const onPressed = useCallback(() => { - dismissModal({componentId}); + return dismissModal({componentId}); }, [componentId]); useNavButtonPressed(closeButtonId, componentId, onPressed, []); @@ -103,6 +106,11 @@ const ChannelInfo = ({ } + void; isMultiselect?: boolean; - selected?: DialogOption[]; + selected: SelectedDialogValue; theme: Theme; teammateNameDisplay: string; componentId: string; @@ -390,18 +391,17 @@ function IntegrationSelector( useEffect(() => { const multiselectItems: MultiselectSelectedMap = {}; - if (multiselectSelected) { - return; - } - - if (isMultiselect && selected && !([ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_CHANNELS].includes(dataSource))) { - selected.forEach((opt) => { - multiselectItems[opt.value] = opt; - }); + if (isMultiselect && Array.isArray(selected) && !([ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_CHANNELS].includes(dataSource))) { + for (const value of selected) { + const option = options?.find((opt) => opt.value === value); + if (option) { + multiselectItems[value] = option; + } + } setMultiselectSelected(multiselectItems); } - }, [multiselectSelected]); + }, []); // Renders const renderLoading = useCallback(() => { @@ -413,19 +413,19 @@ function IntegrationSelector( switch (dataSource) { case ViewConstants.DATA_SOURCE_USERS: text = { - id: intl.formatMessage({id: 'mobile.integration_selector.loading_users'}), + id: t('mobile.integration_selector.loading_users'), defaultMessage: 'Loading Users...', }; break; case ViewConstants.DATA_SOURCE_CHANNELS: text = { - id: intl.formatMessage({id: 'mobile.integration_selector.loading_channels'}), + id: t('mobile.integration_selector.loading_channels'), defaultMessage: 'Loading Channels...', }; break; default: text = { - id: intl.formatMessage({id: 'mobile.integration_selector.loading_options'}), + id: t('mobile.integration_selector.loading_options'), defaultMessage: 'Loading Options...', }; break; diff --git a/app/screens/interactive_dialog/dialog_element.tsx b/app/screens/interactive_dialog/dialog_element.tsx index 506abcc9c7..1b29c664e4 100644 --- a/app/screens/interactive_dialog/dialog_element.tsx +++ b/app/screens/interactive_dialog/dialog_element.tsx @@ -60,6 +60,15 @@ function DialogElement({ onChange(name, newValue); }, [onChange, type, subtype]); + const handleSelect = useCallback((newValue: DialogOption | undefined) => { + if (!newValue) { + onChange(name, ''); + return; + } + + onChange(name, newValue.value); + }, [onChange]); + switch (type) { case 'text': case 'textarea': @@ -87,12 +96,12 @@ function DialogElement({ dataSource={dataSource} options={options} optional={optional} - onSelected={handleChange} + onSelected={handleSelect} helpText={helpText} errorText={errorText} placeholder={placeholder} showRequiredAsterisk={true} - selected={value as string | string[]} + selected={value as string} roundedBorders={false} testID={testID} /> diff --git a/app/screens/interactive_dialog/index.tsx b/app/screens/interactive_dialog/index.tsx index e9d6edc6e6..b89f2a646f 100644 --- a/app/screens/interactive_dialog/index.tsx +++ b/app/screens/interactive_dialog/index.tsx @@ -66,9 +66,9 @@ function valuesReducer(state: Values, action: ValuesAction) { } return {...state, [action.name]: action.value}; } -function initValues(elements: DialogElement[]) { +function initValues(elements?: DialogElement[]) { const values: Values = {}; - elements.forEach((e) => { + elements?.forEach((e) => { if (e.type === 'bool') { values[e.name] = (e.default === true || String(e.default).toLowerCase() === 'true'); } else if (e.default) { @@ -115,11 +115,11 @@ function InteractiveDialog({ undefined, submitLabel || intl.formatMessage({id: 'interactive_dialog.submit', defaultMessage: 'Submit'}), ); - base.enabled = submitting; + base.enabled = !submitting; base.showAsAction = 'always'; base.color = theme.sidebarHeaderTextColor; return base; - }, [theme.sidebarHeaderTextColor, intl]); + }, [intl, submitting, theme]); useEffect(() => { setButtons(componentId, { @@ -132,7 +132,7 @@ function InteractiveDialog({ setButtons(componentId, { leftButtons: [makeCloseButton(icon)], }); - }, [theme.sidebarHeaderTextColor]); + }, [componentId, theme]); const handleSubmit = useCallback(async () => { const newErrors: Errors = {}; @@ -147,7 +147,7 @@ function InteractiveDialog({ }); } - setErrors(hasErrors ? errors : emptyErrorsState); + setErrors(hasErrors ? newErrors : emptyErrorsState); if (hasErrors) { return; diff --git a/app/screens/navigation.ts b/app/screens/navigation.ts index 843e9b64e9..04f2943b96 100644 --- a/app/screens/navigation.ts +++ b/app/screens/navigation.ts @@ -755,8 +755,8 @@ export async function openAsBottomSheet({closeButtonId, screen, theme, title, pr } } -export const showAppForm = async (form: AppForm) => { - const passProps = {form}; +export const showAppForm = async (form: AppForm, context: AppContext) => { + const passProps = {form, context}; showModal(Screens.APPS_FORM, form.title || '', passProps); }; diff --git a/app/screens/post_options/index.ts b/app/screens/post_options/index.ts index 78253bb9c0..3f573e892e 100644 --- a/app/screens/post_options/index.ts +++ b/app/screens/post_options/index.ts @@ -7,7 +7,9 @@ import {combineLatest, of as of$, Observable} from 'rxjs'; import {switchMap} from 'rxjs/operators'; import {General, Permissions, Post, Screens} from '@constants'; +import {AppBindingLocations} from '@constants/apps'; import {MAX_ALLOWED_REACTIONS} from '@constants/emoji'; +import AppsManager from '@managers/apps_manager'; import {observePost, observePostSaved} from '@queries/servers/post'; import {observePermissionForChannel, observePermissionForPost} from '@queries/servers/role'; import {observeConfigBooleanValue, observeConfigIntValue, observeConfigValue, observeLicense} from '@queries/servers/system'; @@ -33,6 +35,7 @@ type EnhancedProps = WithDatabaseArgs & { post: PostModel; showAddReaction: boolean; location: string; + serverUrl: string; } const observeCanEditPost = (database: Database, isOwner: boolean, post: PostModel, postEditTimeLimit: number, isLicensed: boolean, channel: ChannelModel, user: UserModel) => { @@ -58,7 +61,7 @@ const observeCanEditPost = (database: Database, isOwner: boolean, post: PostMode const withPost = withObservables([], ({post, database}: {post: Post | PostModel} & WithDatabaseArgs) => { let id: string | undefined; let combinedPost: Observable = of$(undefined); - if (post.type === Post.POST_TYPES.COMBINED_USER_ACTIVITY && post.props?.system_post_ids) { + if (post?.type === Post.POST_TYPES.COMBINED_USER_ACTIVITY && post.props?.system_post_ids) { const systemPostIds = getPostIdsForCombinedUserActivityPost(post.id); id = systemPostIds?.pop(); combinedPost = of$(post); @@ -70,7 +73,7 @@ const withPost = withObservables([], ({post, database}: {post: Post | PostModel} }; }); -const enhanced = withObservables([], ({combinedPost, post, showAddReaction, location, database}: EnhancedProps) => { +const enhanced = withObservables([], ({combinedPost, post, showAddReaction, location, database, serverUrl}: EnhancedProps) => { const channel = post.channel.observe(); const channelIsArchived = channel.pipe(switchMap((ch: ChannelModel) => of$(ch.deleteAt !== 0))); const currentUser = observeCurrentUser(database); @@ -78,6 +81,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, loca const allowEditPost = observeConfigValue(database, 'AllowEditPost'); const serverVersion = observeConfigValue(database, 'Version'); const postEditTimeLimit = observeConfigIntValue(database, 'PostEditTimeLimit', -1); + const bindings = AppsManager.observeBindings(serverUrl, AppBindingLocations.POST_MENU_ITEM); const canPostPermission = combineLatest([channel, currentUser]).pipe(switchMap(([c, u]) => observePermissionForChannel(database, c, u, Permissions.CREATE_POST, false))); const hasAddReactionPermission = currentUser.pipe(switchMap((u) => observePermissionForPost(database, post, u, Permissions.ADD_REACTION, true))); @@ -156,8 +160,8 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, loca canEdit, post, thread, + bindings, }; }); export default withDatabase(withPost(enhanced(PostOptions))); - diff --git a/app/screens/post_options/options/app_bindings_post_option.tsx b/app/screens/post_options/options/app_bindings_post_option.tsx new file mode 100644 index 0000000000..43064f1029 --- /dev/null +++ b/app/screens/post_options/options/app_bindings_post_option.tsx @@ -0,0 +1,100 @@ +// 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 React, {useCallback, useMemo} from 'react'; +import {of as of$} from 'rxjs'; +import {switchMap, distinctUntilChanged} from 'rxjs/operators'; + +import {postEphemeralCallResponseForPost} from '@actions/remote/apps'; +import OptionItem from '@components/option_item'; +import {Screens} from '@constants'; +import {useAppBinding} from '@hooks/apps'; +import {observeChannel} from '@queries/servers/channel'; +import {observeCurrentTeamId} from '@queries/servers/system'; +import {dismissBottomSheet} from '@screens/navigation'; +import {WithDatabaseArgs} from '@typings/database/database'; +import {isSystemMessage} from '@utils/post'; +import {preventDoubleTap} from '@utils/tap'; + +import type PostModel from '@typings/database/models/servers/post'; + +type Props = { + bindings: AppBinding[]; + post: PostModel; + serverUrl: string; + teamId: string; +} + +const AppBindingsPostOptions = ({serverUrl, post, teamId, bindings}: Props) => { + const onCallResponse = useCallback((callResp: AppCallResponse, message: string) => { + postEphemeralCallResponseForPost(serverUrl, callResp, message, post); + }, [serverUrl, post]); + + const context = useMemo(() => ({ + channel_id: post.channelId, + team_id: teamId, + post_id: post.id, + root_id: post.rootId || post.id, + }), [post, teamId]); + + const config = useMemo(() => ({ + onSuccess: onCallResponse, + onError: onCallResponse, + }), [onCallResponse]); + + const handleBindingSubmit = useAppBinding(context, config); + + const onPress = useCallback(async (binding: AppBinding) => { + const submitPromise = handleBindingSubmit(binding); + await dismissBottomSheet(Screens.POST_OPTIONS); + + const finish = await submitPromise; + await finish(); + }, [handleBindingSubmit]); + + if (isSystemMessage(post)) { + return null; + } + + const options = bindings.map((binding) => ( + + )); + + return <>{options}; +}; + +const BindingOptionItem = ({binding, onPress}: {binding: AppBinding; onPress: (binding: AppBinding) => void}) => { + const handlePress = useCallback(preventDoubleTap(() => { + onPress(binding); + }), [binding, onPress]); + + return ( + + ); +}; + +type OwnProps = { + post: PostModel; + bindings: AppBinding[]; +} + +const withTeamId = withObservables(['post'], ({database, post}: WithDatabaseArgs & OwnProps) => ({ + teamId: post.channelId ? observeChannel(database, post.channelId).pipe( + switchMap((c) => (c?.teamId ? of$(c.teamId) : observeCurrentTeamId(database))), + distinctUntilChanged(), + ) : of$(''), +})); + +export default React.memo(withDatabase(withTeamId(AppBindingsPostOptions))); diff --git a/app/screens/post_options/post_options.tsx b/app/screens/post_options/post_options.tsx index a8b978689d..c14a9e73dd 100644 --- a/app/screens/post_options/post_options.tsx +++ b/app/screens/post_options/post_options.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import {useManagedConfig} from '@mattermost/react-native-emm'; -import React from 'react'; +import React, {useMemo} from 'react'; import {CopyPermalinkOption, FollowThreadOption, ReplyOption, SaveOption} from '@components/common_post_options'; import {ITEM_HEIGHT} from '@components/option_item'; @@ -12,6 +12,7 @@ import BottomSheet from '@screens/bottom_sheet'; import {dismissModal} from '@screens/navigation'; import {isSystemMessage} from '@utils/post'; +import AppBindingsPostOptions from './options/app_bindings_post_option'; import CopyTextOption from './options/copy_text_option'; import DeletePostOption from './options/delete_post_option'; import EditOption from './options/edit_option'; @@ -37,12 +38,14 @@ type PostOptionsProps = { post: PostModel; thread?: ThreadModel; componentId: string; + bindings: AppBinding[]; + serverUrl: string; }; const PostOptions = ({ canAddReaction, canDelete, canEdit, canMarkAsUnread, canPin, canReply, combinedPost, componentId, isSaved, - sourceScreen, post, thread, + sourceScreen, post, thread, bindings, serverUrl, }: PostOptionsProps) => { const managedConfig = useManagedConfig(); @@ -58,6 +61,7 @@ const PostOptions = ({ const canCopyText = canCopyPermalink && post.message; const shouldRenderFollow = !(sourceScreen !== Screens.CHANNEL || !thread); + const shouldShowBindings = bindings.length > 0 && !isSystemPost; const snapPoints = [ canAddReaction, canCopyPermalink, canCopyText, @@ -73,64 +77,83 @@ const PostOptions = ({ {canAddReaction && } {canReply && sourceScreen !== Screens.THREAD && } {shouldRenderFollow && - + } {canMarkAsUnread && !isSystemPost && - + } {canCopyPermalink && - + } {!isSystemPost && - + } {Boolean(canCopyText && post.message) && - } + } {canPin && - + } {canEdit && - + } {canDelete && } + {shouldShowBindings && + + } ); }; - const additionalSnapPoints = 2; + const finalSnapPoints = useMemo(() => { + const additionalSnapPoints = 2; + + const lowerSnapPoints = snapPoints + additionalSnapPoints; + if (!shouldShowBindings) { + return [lowerSnapPoints * ITEM_HEIGHT, 10]; + } + + const upperSnapPoints = lowerSnapPoints + bindings.length; + return [upperSnapPoints * ITEM_HEIGHT, lowerSnapPoints * ITEM_HEIGHT, 10]; + }, [snapPoints, shouldShowBindings, bindings.length]); + + const initialSnapIndex = shouldShowBindings ? 1 : 0; return ( ); }; -export default PostOptions; +export default React.memo(PostOptions); diff --git a/app/utils/apps.ts b/app/utils/apps.ts index 15182e6ed9..cbdb26148a 100644 --- a/app/utils/apps.ts +++ b/app/utils/apps.ts @@ -2,6 +2,8 @@ // See LICENSE.txt for license information. import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@constants/apps'; +import {generateId} from './general'; + export function cleanBinding(binding: AppBinding, topLocation: string): AppBinding { return cleanBindingRec(binding, topLocation, 0); } @@ -23,6 +25,10 @@ function cleanBindingRec(binding: AppBinding, topLocation: string, depth: number b.label = b.location || ''; } + if (!b.location) { + b.location = generateId(); + } + b.location = binding.location + '/' + b.location; // Validation diff --git a/types/api/apps.d.ts b/types/api/apps.d.ts index 0d1bf6ff14..f68fa85448 100644 --- a/types/api/apps.d.ts +++ b/types/api/apps.d.ts @@ -24,7 +24,7 @@ type AppsState = { type AppBinding = { app_id: string; - location?: string; + location: string; icon?: string; // Label is the (usually short) primary text to display at the location. @@ -148,7 +148,7 @@ type AppForm = { depends_on?: string[]; }; -type AppFormValue = string | boolean | number | AppSelectOption | AppSelectOption[]; +type AppFormValue = string | boolean | number | AppSelectOption | AppSelectOption[] | null; type AppFormValues = {[name: string]: AppFormValue}; type AppSelectOption = { diff --git a/types/api/integrations.d.ts b/types/api/integrations.d.ts index 209398f088..a51ba3182f 100644 --- a/types/api/integrations.d.ts +++ b/types/api/integrations.d.ts @@ -56,6 +56,10 @@ type DialogOption = { value: string; }; +type SelectedDialogOption = DialogOption | DialogOption[] | undefined; + +type SelectedDialogValue = string | string[] | undefined; + type DialogElement = { display_name: string; name: string;