Make attachments and app bindings more robust yet secure (#8570)

* Make attachments and app bindings more robust yet secure

* Fix tests

* i18n extract

* Address feedback

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Daniel Espino García 2025-03-14 12:02:32 +01:00 committed by GitHub
parent 78997d059d
commit 782b1c69a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 265 additions and 173 deletions

View file

@ -31,7 +31,7 @@ export async function handleBindingClick<Res=unknown>(serverUrl: string, binding
id: 'apps.error.malformed_binding',
defaultMessage: 'This binding is not properly formed. Contact the App developer.',
});
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
const res: AppCallResponse<Res> = {
@ -48,7 +48,7 @@ export async function handleBindingClick<Res=unknown>(serverUrl: string, binding
id: 'apps.error.malformed_binding',
defaultMessage: 'This binding is not properly formed. Contact the App developer.',
});
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
const callRequest = createCallRequest(
@ -83,7 +83,7 @@ export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCal
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no valid form was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
cleanForm(res.form);
@ -96,7 +96,7 @@ export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCal
id: 'apps.error.responses.navigate.no_url',
defaultMessage: 'Response type is `navigate`, but no url was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
return {data: res};
@ -107,7 +107,7 @@ export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCal
}, {
type: responseType,
});
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
}
} catch (error) {
@ -116,7 +116,7 @@ export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCal
defaultMessage: 'Received an unexpected error.',
});
logDebug('error on doAppSubmit', getFullErrorMessage(error));
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
}
@ -135,7 +135,7 @@ export async function doAppFetchForm<Res=unknown>(serverUrl: string, call: AppCa
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no valid form was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
cleanForm(res.form);
return {data: res};
@ -144,7 +144,7 @@ export async function doAppFetchForm<Res=unknown>(serverUrl: string, call: AppCa
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {type: responseType});
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
}
} catch (error) {
@ -153,7 +153,7 @@ export async function doAppFetchForm<Res=unknown>(serverUrl: string, call: AppCa
defaultMessage: 'Received an unexpected error.',
});
logDebug('error on doAppFetchForm', getFullErrorMessage(error));
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
}
@ -175,7 +175,7 @@ export async function doAppLookup<Res=unknown>(serverUrl: string, call: AppCallR
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {type: responseType});
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
}
} catch (error: any) {
@ -184,7 +184,7 @@ export async function doAppLookup<Res=unknown>(serverUrl: string, call: AppCallR
defaultMessage: 'Received an unexpected error.',
});
logDebug('error on doAppLookup', getFullErrorMessage(error));
return {error: makeCallErrorResponse(errMsg)};
return {error: makeCallErrorResponse<Res>(errMsg)};
}
}

View file

@ -112,7 +112,7 @@ export class ParsedCommand {
};
private findBindings(b: AppBinding) {
return b.label.toLowerCase() === this.incomplete.toLowerCase();
return b.label?.toLowerCase() === this.incomplete.toLowerCase();
}
// matchBinding finds the closest matching command binding.
@ -321,7 +321,7 @@ export class ParsedCommand {
let field = fields.find((f: AppField) => f.position === this.position);
if (!field) {
field = fields.find((f) => f.position === -1 && f.type === AppFieldTypes.TEXT);
if (!field || this.values[field.name]) {
if (!field?.name || this.values[field.name]) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.no_argument_pos_x',
defaultMessage: 'Unable to identify argument.',
@ -349,6 +349,13 @@ export class ParsedCommand {
}));
}
if (!this.field.name) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.missing_field_name',
defaultMessage: 'Field name is missing.',
}));
}
if (autocompleteMode && c === '') {
return this;
}
@ -595,6 +602,13 @@ export class ParsedCommand {
}));
}
if (!this.field.name) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.missing_field_name',
defaultMessage: 'Field name is missing.',
}));
}
// special handling for optional BOOL values ('--boolflag true'
// vs '--boolflag next-positional' vs '--boolflag
// --next-flag...')
@ -628,7 +642,14 @@ export class ParsedCommand {
}));
}
this.values![this.field.name] = [];
if (!this.field.name) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.missing_field_name',
defaultMessage: 'Field name is missing.',
}));
}
this.values![this.field.name] = [];
switch (c) {
case ' ':
case '\t':
@ -784,6 +805,13 @@ export class ParsedCommand {
}));
}
if (!this.field.name) {
return this.asError(this.intl.formatMessage({
id: 'apps.error.parser.missing_field_name',
defaultMessage: 'Field name is missing.',
}));
}
if (autocompleteMode && c === '') {
return this;
}
@ -961,7 +989,7 @@ export class AppCommandParser {
}
await Promise.all(parsed.resolvedForm?.fields.map(async (f) => {
if (!f.value) {
if (!f.value || !f.name) {
return;
}
@ -975,6 +1003,9 @@ export class AppCommandParser {
break;
case AppFieldTypes.USER: {
const userID = (f.value as AppSelectOption).value;
if (!userID) {
return;
}
let user: UserModel | UserProfile | undefined = await getUserById(this.database, userID);
if (!user) {
const res = await fetchUsersByIds(this.serverUrl, [userID]);
@ -992,6 +1023,9 @@ export class AppCommandParser {
}
case AppFieldTypes.CHANNEL: {
const channelID = (f.value as AppSelectOption).label;
if (!channelID) {
return;
}
let channel: ChannelModel | Channel | undefined = await getChannelById(this.database, channelID);
if (!channel) {
const res = await fetchChannelById(this.serverUrl, channelID);
@ -1008,9 +1042,14 @@ export class AppCommandParser {
break;
}
case AppFieldTypes.STATIC_SELECT:
case AppFieldTypes.DYNAMIC_SELECT:
parsed.values[f.name] = (f.value as AppSelectOption).value;
case AppFieldTypes.DYNAMIC_SELECT: {
const optionValue = (f.value as AppSelectOption).value;
if (!optionValue) {
return;
}
parsed.values[f.name] = optionValue;
break;
}
case AppFieldTypes.MARKDOWN:
// Do nothing
@ -1026,11 +1065,12 @@ export class AppCommandParser {
const bindings = this.getCommandBindings();
for (const binding of bindings) {
let base = binding.label;
if (!base) {
if (!binding.label) {
continue;
}
let base = binding.label;
if (base[0] !== '/') {
base = '/' + base;
}
@ -1100,7 +1140,7 @@ export class AppCommandParser {
];
const call = parsed.resolvedForm?.submit || parsed.binding?.form?.submit;
const hasRequired = this.getMissingFields(parsed).length === 0;
const hasValue = (parsed.state !== ParseState.EndValue || (parsed.field && parsed.values[parsed.field.name] !== undefined));
const hasValue = (parsed.state !== ParseState.EndValue || (parsed.field && parsed.field.name && parsed.values[parsed.field.name] !== undefined));
if (executableStates.includes(parsed.state) && call && hasRequired && hasValue) {
const execute = getExecuteSuggestion(parsed);
@ -1181,7 +1221,11 @@ export class AppCommandParser {
const errors: {[key: string]: string} = {};
await Promise.all(parsed.resolvedForm.fields.map(async (f) => {
const fieldValue = values[f.name];
const fieldName = f.name;
if (!fieldName) {
return;
}
const fieldValue = values[fieldName];
if (!fieldValue) {
return;
}
@ -1198,20 +1242,20 @@ export class AppCommandParser {
const options: AppSelectOption[] = [];
for (const value of commandValues) {
if (options.find((o) => o.value === value)) {
errors[f.name] = this.intl.formatMessage({
errors[fieldName] = this.intl.formatMessage({
id: 'apps.error.command.same_option',
defaultMessage: 'Option repeated for field `{fieldName}`: `{option}`.',
}, {
fieldName: f.name,
fieldName,
option: value,
});
}
}
values[f.name] = options;
values[fieldName] = options;
break;
}
values[f.name] = {label: fieldValue, value: fieldValue};
values[fieldName] = {label: fieldValue, value: fieldValue};
break;
case AppFieldTypes.STATIC_SELECT: {
const getOption = (value: string) => {
@ -1219,14 +1263,14 @@ export class AppCommandParser {
};
const setOptionError = (value: string) => {
errors[f.name] = this.intl.formatMessage({
errors[fieldName] = this.intl.formatMessage({
id: 'apps.error.command.unknown_option',
defaultMessage: 'Unknown option for field `{fieldName}`: `{option}`.',
}, {
fieldName: f.name,
fieldName,
option: value,
});
values[f.name] = undefined;
values[fieldName] = undefined;
};
if (f.multiselect) {
@ -1245,17 +1289,17 @@ export class AppCommandParser {
return;
}
if (options.find((o) => o.value === option.value)) {
errors[f.name] = this.intl.formatMessage({
errors[fieldName] = this.intl.formatMessage({
id: 'apps.error.command.same_option',
defaultMessage: 'Option repeated for field `{fieldName}`: `{option}`.',
}, {
fieldName: f.name,
fieldName,
option: value,
});
}
options.push(option);
}
values[f.name] = options;
values[fieldName] = options;
break;
}
@ -1264,7 +1308,7 @@ export class AppCommandParser {
setOptionError(fieldValue);
return;
}
values[f.name] = option;
values[fieldName] = option;
break;
}
case AppFieldTypes.USER: {
@ -1281,11 +1325,11 @@ export class AppCommandParser {
};
const setUserError = (username: string) => {
errors[f.name] = this.intl.formatMessage({
errors[fieldName] = this.intl.formatMessage({
id: 'apps.error.command.unknown_user',
defaultMessage: 'Unknown user for field `{fieldName}`: `{option}`.',
}, {
fieldName: f.name,
fieldName,
option: username,
});
};
@ -1312,22 +1356,22 @@ export class AppCommandParser {
}
if (options.find((o) => o.value === user?.id)) {
errors[f.name] = this.intl.formatMessage({
errors[fieldName] = this.intl.formatMessage({
id: 'apps.error.command.same_user',
defaultMessage: 'User repeated for field `{fieldName}`: `{option}`.',
}, {
fieldName: f.name,
fieldName,
option: userName,
});
}
options.push({label: user.username, value: user.id});
}
/* eslint-enable no-await-in-loop */
values[f.name] = options;
values[fieldName] = options;
break;
}
let userName = values[f.name] as string;
let userName = values[fieldName] as string;
if (userName[0] === '@') {
userName = userName.substr(1);
}
@ -1336,7 +1380,7 @@ export class AppCommandParser {
setUserError(userName);
return;
}
values[f.name] = {label: user.username, value: user.id};
values[fieldName] = {label: user.username, value: user.id};
break;
}
case AppFieldTypes.CHANNEL: {
@ -1353,11 +1397,11 @@ export class AppCommandParser {
};
const setChannelError = (channelName: string) => {
errors[f.name] = this.intl.formatMessage({
errors[fieldName] = this.intl.formatMessage({
id: 'apps.error.command.unknown_channel',
defaultMessage: 'Unknown channel for field `{fieldName}`: `{option}`.',
}, {
fieldName: f.name,
fieldName,
option: channelName,
});
};
@ -1384,11 +1428,11 @@ export class AppCommandParser {
}
if (options.find((o) => o.value === channel.id)) {
errors[f.name] = this.intl.formatMessage({
errors[fieldName] = this.intl.formatMessage({
id: 'apps.error.command.same_channel',
defaultMessage: 'Channel repeated for field `{fieldName}`: `{option}`.',
}, {
fieldName: f.name,
fieldName,
option: channelName,
});
}
@ -1397,11 +1441,11 @@ export class AppCommandParser {
options.push({label, value: channel.id});
}
/* eslint-enable no-await-in-loop */
values[f.name] = options;
values[fieldName] = options;
break;
}
let channelName = values[f.name] as string;
let channelName = values[fieldName] as string;
if (channelName[0] === '~') {
channelName = channelName.substr(1);
}
@ -1411,15 +1455,15 @@ export class AppCommandParser {
return;
}
const label = 'display_name' in channel ? channel.display_name : channel.displayName;
values[f.name] = {label, value: channel.id};
values[fieldName] = {label, value: channel.id};
break;
}
case AppFieldTypes.BOOL: {
const strValue = values[f.name] as string;
const strValue = values[fieldName] as string;
if (strValue.toLowerCase() === 'true') {
values[f.name] = true;
values[fieldName] = true;
} else {
values[f.name] = false;
values[fieldName] = false;
}
}
}
@ -1501,7 +1545,7 @@ export class AppCommandParser {
// getAppContext collects post/channel/team info for performing calls
private getAppContext = async (binding: AppBinding): Promise<AppContext> => {
const context: AppContext = {
app_id: binding.app_id,
app_id: binding.app_id!, // At this point is safe to assume we have an app_id
location: binding.location,
root_id: this.rootPostID,
};
@ -1591,7 +1635,7 @@ export class AppCommandParser {
const result: AutocompleteSuggestion[] = [];
bindings.forEach((b) => {
if (b.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase())) {
if (b.label?.toLowerCase().startsWith(parsed.incomplete.toLowerCase())) {
result.push({
Complete: b.label,
Suggestion: b.label,
@ -1690,7 +1734,7 @@ export class AppCommandParser {
const values = parsed.values || [];
const fields = form.fields || [];
for (const field of fields) {
if (field.is_required && !values[field.name]) {
if (field.is_required && field.name && !values[field.name]) { // fields without names shouldn't be considered
missing.push(field);
}
}
@ -1717,6 +1761,7 @@ export class AppCommandParser {
const applicable = parsed.resolvedForm.fields.filter((field) => (
field.label &&
field.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()) &&
field.name &&
!parsed.values[field.name] &&
!field.readonly &&
field.type !== AppFieldTypes.MARKDOWN
@ -1780,7 +1825,7 @@ export class AppCommandParser {
// getStaticSelectSuggestions returns suggestions specified in the field's options property
private getStaticSelectSuggestions = (parsed: ParsedCommand, delimiter?: string): AutocompleteSuggestion[] => {
const f = parsed.field as AutocompleteStaticSelect;
const opts = f.options?.filter((opt) => opt.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()));
const opts = f.options?.filter((opt) => opt.label?.toLowerCase().startsWith(parsed.incomplete.toLowerCase()));
if (!opts?.length) {
return [{
Complete: '',
@ -1794,15 +1839,15 @@ export class AppCommandParser {
}];
}
return opts.map((opt) => {
let complete = opt.value;
let complete = opt.value || '';
if (delimiter) {
complete = delimiter + complete + delimiter;
} else if (isMultiword(opt.value)) {
} else if (isMultiword(complete)) {
complete = '`' + complete + '`';
}
return {
Complete: complete,
Suggestion: opt.label,
Suggestion: opt.label || '',
Hint: f.hint || '',
Description: f.description || '',
IconData: opt.icon_data || parsed.binding?.icon || '',
@ -1883,16 +1928,16 @@ export class AppCommandParser {
}
return items.map((s): AutocompleteSuggestion => {
let complete = s.value;
let complete = s.value || '';
if (delimiter) {
complete = delimiter + complete + delimiter;
} else if (isMultiword(s.value)) {
} else if (isMultiword(complete)) {
complete = '`' + complete + '`';
}
return ({
Complete: complete,
Description: s.label || s.value,
Suggestion: s.value,
Description: s.label || s.value || '',
Suggestion: s.value || '',
Hint: '',
IconData: s.icon_data || parsed.binding?.icon || '',
});

View file

@ -70,7 +70,7 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) =>
pressed.current = true;
const context = createCallContext(
binding.app_id,
binding.app_id!,
AppBindingLocations.IN_POST + binding.location,
post.channelId,
teamID || currentTeamId,
@ -127,7 +127,7 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) =>
onPress={onPress}
>
<ButtonBindingText
message={binding.label}
message={binding.label || intl.formatMessage({id: 'apps.binding.default_button_name', defaultMessage: 'Submit'})}
style={style.text}
/>
</Button>

View file

@ -51,7 +51,7 @@ const EmbeddedBinding = ({embed, location, post, theme}: Props) => {
return (
<>
<View style={style.container}>
{Boolean(embed.label) &&
{embed.label &&
<EmbedTitle
channelId={post.channelId}
location={location}
@ -59,11 +59,11 @@ const EmbeddedBinding = ({embed, location, post, theme}: Props) => {
value={embed.label}
/>
}
{Boolean(embed.description) &&
{embed.description &&
<EmbedText
channelId={post.channelId}
location={location}
value={embed.description!}
value={embed.description}
theme={theme}
/>
}

View file

@ -61,8 +61,8 @@ const MenuBinding = ({binding, currentTeamId, post, teamID}: Props) => {
finish();
}, [handleBindingSubmit, binding.bindings]);
const options = useMemo(() => binding.bindings?.map<PostActionOption>((b: AppBinding) => ({
text: b.label,
const options = useMemo(() => binding.bindings?.map<DialogOption>((b: AppBinding) => ({
text: b.label || '',
value: b.location || '',
})), [binding.bindings]);

View file

@ -1,11 +1,12 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import React, {useCallback, useMemo, useState} from 'react';
import {selectAttachmentMenuAction} from '@actions/remote/integrations';
import AutocompleteSelector from '@components/autocomplete_selector';
import {useServerUrl} from '@context/server';
import {filterOptions} from '@utils/message_attachment';
type Props = {
dataSource?: string;
@ -18,12 +19,18 @@ type Props = {
}
const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, postId}: Props) => {
let isSelected: PostActionOption | undefined;
const serverUrl = useServerUrl();
if (defaultOption && options) {
isSelected = options.find((option) => option.value === defaultOption);
}
const [selected, setSelected] = useState(isSelected?.value);
const filteredOptions = useMemo(() => {
return filterOptions(options);
}, [options]);
const [selected, setSelected] = useState(() => {
if (defaultOption && options) {
return options.find((option) => option.value === defaultOption)?.value;
}
return undefined;
});
const handleSelect = useCallback(async (selectedItem: SelectedDialogOption) => {
if (!selectedItem || Array.isArray(selectedItem)) {
@ -34,14 +41,14 @@ const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, pos
if (result.data?.trigger_id) {
setSelected(selectedItem.value);
}
}, []);
}, [id, postId, serverUrl]);
return (
<AutocompleteSelector
placeholder={name}
dataSource={dataSource}
isMultiselect={false}
options={options}
options={filteredOptions}
selected={selected}
onSelected={handleSelect}
disabled={disabled}

View file

@ -98,7 +98,7 @@ export default function MessageAttachment({attachment, channelId, layoutWidth, l
value={attachment.title}
/>
}
{isValidUrl(attachment.thumb_url) &&
{attachment.thumb_url && isValidUrl(attachment.thumb_url) &&
<AttachmentThumbnail uri={attachment.thumb_url}/>
}
{Boolean(attachment.text) &&
@ -114,7 +114,7 @@ export default function MessageAttachment({attachment, channelId, layoutWidth, l
theme={theme}
/>
}
{Boolean(attachment.fields?.length) &&
{attachment.fields && attachment.fields?.length &&
<AttachmentFields
baseTextStyle={style.message}
blockStyles={blockStyles}
@ -126,21 +126,21 @@ export default function MessageAttachment({attachment, channelId, layoutWidth, l
theme={theme}
/>
}
{Boolean(attachment.footer) &&
{attachment.footer &&
<AttachmentFooter
icon={attachment.footer_icon}
text={attachment.footer}
theme={theme}
/>
}
{Boolean(attachment.actions?.length) &&
{attachment.actions && attachment.actions.length &&
<AttachmentActions
actions={attachment.actions!}
postId={postId}
theme={theme}
/>
}
{Boolean(metadata?.images?.[attachment.image_url]) &&
{attachment.image_url && Boolean(metadata?.images?.[attachment.image_url]) &&
<AttachmentImage
imageUrl={attachment.image_url}
imageMetadata={metadata!.images![attachment.image_url]!}

View file

@ -26,7 +26,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
type Props = {
label: string;
options?: PostActionOption[];
options?: DialogOption[];
onChange: (value: string) => void;
helpText?: string;
errorText?: string;

View file

@ -31,7 +31,7 @@ export const useAppBinding = (context: UseAppBindingContext, config: UseAppBindi
return useCallback(async (binding: AppBinding) => {
const callContext = createCallContext(
binding.app_id,
binding.app_id!,
binding.location,
context.channel_id,
context.team_id,

View file

@ -104,6 +104,9 @@ function valuesReducer(state: AppFormValues, action: ValuesAction) {
function initValues(fields?: AppField[]) {
const values: AppFormValues = {};
fields?.forEach((e) => {
if (!e.name) {
return;
}
if (e.type === 'bool') {
values[e.name] = (e.value === true || String(e.value).toLowerCase() === 'true');
} else if (e.value) {
@ -328,7 +331,7 @@ function AppsFormComponent({
const performLookup = useCallback(async (name: string, userInput: string): Promise<AppSelectOption[]> => {
const field = form.fields?.find((f) => f.name === name);
if (!field) {
if (!field?.name) {
return [];
}
@ -411,6 +414,9 @@ function AppsFormComponent({
/>
}
{form.fields && form.fields.filter((f) => f.name !== form.submit_buttons).map((field) => {
if (!field.name) {
return null;
}
const value = secureGetFromRecord(values, field.name);
if (!value) {
return null;

View file

@ -33,8 +33,8 @@ const dialogOptionToAppSelectOption = (option: DialogOption): AppSelectOption =>
});
const appSelectOptionToDialogOption = (option: AppSelectOption): DialogOption => ({
text: option.label,
value: option.value,
text: option.label || '',
value: option.value || '',
});
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -100,6 +100,9 @@ function AppsFormField({
}, [onChange, field, name]);
const getDynamicOptions = useCallback(async (userInput = ''): Promise<DialogOption[]> => {
if (!field.name) {
return [];
}
const options = await performLookup(field.name, userInput);
return options.map(appSelectOptionToDialogOption);
}, [performLookup, field]);
@ -126,7 +129,7 @@ function AppsFormField({
}, [field, value]);
const selectedValue = useMemo(() => {
if (!value || !SelectableAppFieldTypes.includes(field.type)) {
if (!value || !SelectableAppFieldTypes.includes(field.type || '')) {
return undefined;
}
@ -135,7 +138,7 @@ function AppsFormField({
}
if (Array.isArray(value)) {
return value.map((v) => v.value);
return value.map((v) => v.value || '');
}
return value as string;

View file

@ -49,7 +49,7 @@ const ChannelInfoAppBindings = ({channelId, teamId, dismissChannelInfo, serverUr
const options = bindings.map((binding) => (
<BindingOptionItem
key={binding.app_id + binding.location}
key={(binding.app_id || '') + (binding.location || '')}
binding={binding}
onPress={onPress}
/>
@ -65,7 +65,7 @@ const BindingOptionItem = ({binding, onPress}: {binding: AppBinding; onPress: (b
return (
<OptionItem
label={binding.label}
label={binding.label || ''}
icon={binding.icon}
action={handlePress}
type='default'

View file

@ -24,6 +24,7 @@ import {
popTopScreen, setButtons,
} from '@screens/navigation';
import {filterChannelsMatchingTerm} from '@utils/channel';
import {filterOptions} from '@utils/message_attachment';
import {changeOpacity, getKeyboardAppearanceFromTheme, makeStyleSheetFromTheme} from '@utils/theme';
import {secureGetFromRecord} from '@utils/types';
import {typography} from '@utils/typography';
@ -190,6 +191,10 @@ function IntegrationSelector(
const page = useRef<number>(-1);
const next = useRef<boolean>(VALID_DATASOURCES.includes(dataSource));
const filteredOptions = useMemo(() => {
return filterOptions(options) || [];
}, [options]);
// Callbacks
const clearSearch = useCallback(() => {
setTerm('');
@ -276,8 +281,8 @@ function IntegrationSelector(
}, [getChannels, dataSource]);
const searchDynamicOptions = useCallback(async (searchTerm = '') => {
if (options && options !== integrationData && !searchTerm) {
setIntegrationData(options);
if (filteredOptions && filteredOptions !== integrationData && !searchTerm) {
setIntegrationData(filteredOptions);
}
if (!getDynamicOptions) {
@ -292,7 +297,7 @@ function IntegrationSelector(
} else {
setIntegrationData(searchData);
}
}, [options, getDynamicOptions, integrationData]);
}, [filteredOptions, getDynamicOptions, integrationData]);
const handleSelectProfile = useCallback((user: UserProfile): void => {
if (!isMultiselect) {
@ -301,7 +306,7 @@ function IntegrationSelector(
}
setSelectedIds((current) => handleIdSelection(dataSource, current, user));
}, [isMultiselect, handleIdSelection, handleSelect, close, dataSource]);
}, [isMultiselect, handleSelect, dataSource]);
const onHandleMultiselectSubmit = useCallback(() => {
if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
@ -312,7 +317,7 @@ function IntegrationSelector(
handleSelect(Object.values(multiselectSelected));
}
close();
}, [multiselectSelected, selectedIds, handleSelect]);
}, [dataSource, handleSelect, selectedIds, multiselectSelected]);
const onSearch = useCallback((text: string) => {
if (!text) {
@ -348,7 +353,7 @@ function IntegrationSelector(
setLoading(false);
}, General.SEARCH_TIMEOUT_MILLISECONDS);
}, [dataSource, integrationData, currentTeamId]);
}, [clearSearch, dataSource, integrationData, serverUrl, currentTeamId, searchDynamicOptions]);
// Effects
useNavButtonPressed(SUBMIT_BUTTON_ID, componentId, onHandleMultiselectSubmit, [onHandleMultiselectSubmit]);
@ -401,7 +406,7 @@ function IntegrationSelector(
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);
const option = filteredOptions?.find((opt) => opt.value === value);
if (option) {
multiselectItems[value] = option;
}
@ -441,7 +446,7 @@ function IntegrationSelector(
/>
</View>
);
}, [style, dataSource, loading, intl]);
}, [style, dataSource, loading]);
const renderNoResults = useCallback((): JSX.Element | null => {
if (loading || page.current === -1) {
@ -519,7 +524,7 @@ function IntegrationSelector(
<View style={style.separator}/>
</>
);
}, [multiselectSelected, selectedIds, style, theme]);
}, [dataSource, handleRemoveOption, multiselectSelected, selectedIds, style.separator, theme]);
const userFetchFunction = useCallback(async (userFetchPage: number) => {
const result = await fetchProfiles(serverUrl, userFetchPage, General.PROFILE_CHUNK_SIZE);

View file

@ -1,13 +1,14 @@
// 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 AutocompleteSelector from '@components/autocomplete_selector';
import BoolSetting from '@components/settings/bool_setting';
import RadioSetting from '@components/settings/radio_setting';
import TextSetting from '@components/settings/text_setting';
import {selectKeyboardType as selectKB} from '@utils/integrations';
import {filterOptions} from '@utils/message_attachment';
import type {KeyboardTypeOptions} from 'react-native';
@ -86,6 +87,10 @@ function DialogElement({
onChange(name, newValue.value);
}, [name, onChange]);
const filteredOptions = useMemo(() => {
return filterOptions(options);
}, [options]);
switch (type) {
case 'text':
case 'textarea':
@ -111,7 +116,7 @@ function DialogElement({
<AutocompleteSelector
label={displayName}
dataSource={dataSource}
options={options}
options={filteredOptions}
optional={optional}
onSelected={handleSelect}
helpText={helpText}
@ -129,7 +134,7 @@ function DialogElement({
label={displayName}
helpText={helpText}
errorText={errorText}
options={options}
options={filteredOptions}
onChange={handleChange}
testID={testID}
value={getStringValue(value)}

View file

@ -9,11 +9,11 @@ import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {postEphemeralCallResponseForPost} from '@actions/remote/apps';
import OptionItem from '@components/option_item';
import {useAppBinding} from '@hooks/apps';
import {usePreventDoubleTap} from '@hooks/utils';
import {observeChannel} from '@queries/servers/channel';
import {observeCurrentTeamId} from '@queries/servers/system';
import {dismissBottomSheet} from '@screens/navigation';
import {isSystemMessage} from '@utils/post';
import {preventDoubleTap} from '@utils/tap';
import type {WithDatabaseArgs} from '@typings/database/database';
import type PostModel from '@typings/database/models/servers/post';
@ -70,13 +70,13 @@ const AppBindingsPostOptions = ({bottomSheetId, serverUrl, post, teamId, binding
};
const BindingOptionItem = ({binding, onPress}: {binding: AppBinding; onPress: (binding: AppBinding) => void}) => {
const handlePress = useCallback(preventDoubleTap(() => {
const handlePress = usePreventDoubleTap(useCallback(() => {
onPress(binding);
}), [binding, onPress]);
}, [binding, onPress]));
return (
<OptionItem
label={binding.label}
label={binding.label || ''}
icon={binding.icon}
action={handlePress}
type='default'

View file

@ -190,18 +190,28 @@ function cleanStaticSelect(field: AppField): void {
return;
}
let value = option.value;
if (!value) {
value = option.label;
}
if (!value) {
toRemove.unshift(i);
return;
}
if (usedLabels[label]) {
toRemove.unshift(i);
return;
}
if (usedValues[option.value]) {
if (usedValues[value]) {
toRemove.unshift(i);
return;
}
usedLabels[label] = true;
usedValues[option.value] = true;
usedValues[value] = true;
});
toRemove.forEach((i) => {
@ -246,7 +256,7 @@ export function createCallRequest(
};
}
export const makeCallErrorResponse = (errMessage: string): AppCallResponse<any> => {
export const makeCallErrorResponse = <T=unknown>(errMessage: string): AppCallResponse<T> => {
return {
type: AppCallResponseTypes.ERROR,
text: errMessage,
@ -312,7 +322,7 @@ function isAppCall(obj: unknown): obj is AppCall {
const call = obj as AppCall;
if (typeof call.path !== 'string') {
if (call.path !== undefined && typeof call.path !== 'string') {
return false;
}
@ -348,7 +358,11 @@ function isAppSelectOption(v: unknown): v is AppSelectOption {
const option = v as AppSelectOption;
if (typeof option.label !== 'string' || typeof option.value !== 'string') {
if (option.label !== undefined && typeof option.label !== 'string') {
return false;
}
if (option.value !== undefined && typeof option.value !== 'string') {
return false;
}
@ -366,7 +380,11 @@ function isAppField(v: unknown): v is AppField {
const field = v as AppField;
if (typeof field.name !== 'string' || typeof field.type !== 'string') {
if (field.name !== undefined && typeof field.name !== 'string') {
return false;
}
if (field.type !== undefined && typeof field.type !== 'string') {
return false;
}
@ -494,7 +512,11 @@ export function isAppBinding(obj: unknown): obj is AppBinding {
const binding = obj as AppBinding;
if (typeof binding.app_id !== 'string' || typeof binding.label !== 'string') {
if (binding.app_id !== undefined && typeof binding.app_id !== 'string') {
return false;
}
if (binding.label !== undefined && typeof binding.label !== 'string') {
return false;
}

View file

@ -92,13 +92,13 @@ describe('isPostAction', () => {
expect(isPostAction(nonObjectInput)).toBe(false);
});
test('returns false for missing id', () => {
const invalidPostAction = {name: 'name'};
test('returns false for wrong id type', () => {
const invalidPostAction = {id: {}, name: 'name'};
expect(isPostAction(invalidPostAction)).toBe(false);
});
test('returns false for missing name', () => {
const invalidPostAction = {id: 'id'};
test('returns false for wrong name type', () => {
const invalidPostAction = {id: 'id', name: {}};
expect(isPostAction(invalidPostAction)).toBe(false);
});
@ -154,11 +154,6 @@ describe('isMessageAttachmentField', () => {
expect(isMessageAttachmentField(nonObjectInput)).toBe(false);
});
test('returns false for missing title', () => {
const invalidField = {value: 'value', short: true};
expect(isMessageAttachmentField(invalidField)).toBe(false);
});
test('returns false for non-string title', () => {
const invalidField = {title: 123, value: 'value', short: true};
expect(isMessageAttachmentField(invalidField)).toBe(false);
@ -169,8 +164,8 @@ describe('isMessageAttachmentField', () => {
expect(isMessageAttachmentField(invalidField)).toBe(false);
});
test('returns false for missing value', () => {
const invalidField = {title: 'title', short: true};
test('returns false for invalid value', () => {
const invalidField = {title: 'title', value: {toString: 123}, short: true};
expect(isMessageAttachmentField(invalidField)).toBe(false);
});

View file

@ -39,19 +39,11 @@ function isPostAction(v: unknown): v is PostAction {
return false;
}
if (!('id' in v)) {
if ('id' in v && typeof v.id !== 'string') {
return false;
}
if (typeof v.id !== 'string') {
return false;
}
if (!('name' in v)) {
return false;
}
if (typeof v.name !== 'string') {
if ('name' in v && typeof v.name !== 'string') {
return false;
}
@ -86,7 +78,7 @@ function isPostAction(v: unknown): v is PostAction {
return true;
}
function isMessageAttachmentField(v: unknown) {
function isMessageAttachmentField(v: unknown): v is MessageAttachmentField {
if (typeof v !== 'object') {
return false;
}
@ -95,19 +87,11 @@ function isMessageAttachmentField(v: unknown) {
return false;
}
if (!('title' in v)) {
if ('title' in v && typeof v.title !== 'string') {
return false;
}
if (typeof v.title !== 'string') {
return false;
}
if (!('value' in v)) {
return false;
}
if (typeof v.value === 'object' && v.value && 'toString' in v.value && typeof v.value.toString !== 'function') {
if ('value' in v && typeof v.value === 'object' && v.value && 'toString' in v.value && typeof v.value.toString !== 'function') {
return false;
}
@ -194,6 +178,24 @@ function isMessageAttachment(v: unknown): v is MessageAttachment {
return true;
}
export function filterOptions(options: PostActionOption[] | undefined) {
return options?.reduce((acc: DialogOption[], option) => {
let optionText = option.text;
let optionValue = option.value;
if (optionText && !optionValue) {
optionValue = optionText;
} else if (optionValue && !optionText) {
optionText = optionValue;
}
if (optionText && optionValue) {
acc.push({text: optionText, value: optionValue});
}
return acc;
}, []);
}
export const testExports = {
isMessageAttachment,
isMessageAttachmentField,

View file

@ -29,6 +29,7 @@
"api.channel.add_guest.added": "{addedUsername} added to the channel as a guest by {username}.",
"api.channel.add_member.added": "{addedUsername} added to the channel by {username}.",
"api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.",
"apps.binding.default_button_name": "Submit",
"apps.error": "Error: {error}",
"apps.error.command.field_missing": "Required fields missing: `{fieldName}`.",
"apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.",
@ -50,6 +51,7 @@
"apps.error.parser.empty_value": "Empty values are not allowed.",
"apps.error.parser.execute_non_leaf": "You must select a subcommand.",
"apps.error.parser.missing_binding": "Missing command bindings.",
"apps.error.parser.missing_field_name": "Field name is missing.",
"apps.error.parser.missing_field_value": "Field value is missing.",
"apps.error.parser.missing_list_end": "Expected list closing token.",
"apps.error.parser.missing_quote": "Matching double quote expected before end of input.",

16
types/api/apps.d.ts vendored
View file

@ -23,15 +23,15 @@ type AppsState = {
};
type AppBinding = {
app_id: string;
location: string;
app_id?: string;
location?: string;
icon?: string;
// Label is the (usually short) primary text to display at the location.
// - For LocationPostMenu is the menu item text.
// - For LocationChannelHeader is the dropdown text.
// - For LocationCommand is the name of the command
label: string;
label?: string;
// Hint is the secondary text to display
// - LocationPostMenu: not used
@ -60,7 +60,7 @@ type AppCallValues = {
};
type AppCall = {
path: string;
path?: string;
expand?: AppExpand;
state?: any;
};
@ -152,8 +152,8 @@ type AppFormValue = string | boolean | number | AppSelectOption | AppSelectOptio
type AppFormValues = {[name: string]: AppFormValue};
type AppSelectOption = {
label: string;
value: string;
label?: string;
value?: string;
icon_data?: string;
};
@ -163,8 +163,8 @@ type AppFieldType = string;
type AppField = {
// Name is the name of the JSON field to use.
name: string;
type: AppFieldType;
name?: string;
type?: AppFieldType;
is_required?: boolean;
readonly?: boolean;

View file

@ -105,8 +105,8 @@ type PostAction = {
};
type PostActionOption = {
text: string;
value: string;
text?: string;
value?: string;
};
type PostActionIntegration = {

38
types/api/posts.d.ts vendored
View file

@ -107,29 +107,29 @@ type ProcessedPosts = {
}
type MessageAttachment = {
id: number;
fallback: string;
color: string;
pretext: string;
author_name: string;
author_link: string;
author_icon: string;
title: string;
title_link: string;
text: string;
fields: MessageAttachmentField[];
image_url: string;
thumb_url: string;
footer: string;
footer_icon: string;
timestamp: number | string;
id?: number;
fallback?: string;
color?: string;
pretext?: string;
author_name?: string;
author_link?: string;
author_icon?: string;
title?: string;
title_link?: string;
text?: string;
fields?: MessageAttachmentField[];
image_url?: string;
thumb_url?: string;
footer?: string;
footer_icon?: string;
timestamp?: number | string;
actions?: PostAction[];
};
type MessageAttachmentField = {
title: string;
value: any;
short: boolean;
title?: string;
value?: any;
short?: boolean;
}
type PostSearchParams = {