App framework - Post menu and channel info bindings, App forms (#6735)

This commit is contained in:
Michael Kochell
2022-11-30 14:25:08 -05:00
committed by GitHub
parent dd3e62daf0
commit c1f480de31
31 changed files with 776 additions and 277 deletions

View File

@@ -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,
);
}

View File

@@ -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:

View File

@@ -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,

View File

@@ -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<AppBinding[]>((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 {

View File

@@ -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<ViewStyle>;
};
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 (
<AtMentionItem
user={item.item as UserProfile | UserModel}
user={user}
onPress={completeIgnoringSuggestion(item.Complete)}
testID='autocomplete.slash_suggestion.at_mention_item'
/>
);
case COMMAND_SUGGESTION_CHANNEL:
if (!item.item) {
}
case COMMAND_SUGGESTION_CHANNEL: {
const channel = item.item as Channel | undefined;
if (!channel) {
return null;
}
return (
<ChannelMentionItem
channel={item.item as Channel | ChannelModel}
channel={channel}
onPress={completeIgnoringSuggestion(item.Complete)}
testID='autocomplete.slash_suggestion.channel_mention_item'
/>
);
}
default:
return (
<SlashSuggestionItem

View File

@@ -33,12 +33,12 @@ type AutoCompleteSelectorProps = {
getDynamicOptions?: (userInput?: string) => Promise<DialogOption[]>;
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<string> {
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]);

View File

@@ -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 = ({
<View style={styles.labelContainer}>
{Boolean(icon) && (
<View style={styles.iconContainer}>
<CompassIcon
name={icon!}
size={24}
color={iconColor || (destructive ? theme.dndIndicator : changeOpacity(theme.centerChannelColor, 0.64))}
<OptionIcon
icon={icon!}
iconColor={iconColor}
destructive={destructive}
/>
</View>
)}

View File

@@ -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 (
<FastImage
source={iconAsSource}
style={styles.icon}
onError={onErrorLoadingIcon}
/>
);
}
const iconName = failedToLoadImage ? 'power-plugin-outline' : icon;
return (
<CompassIcon
name={iconName}
size={24}
color={iconColor || (destructive ? theme.dndIndicator : changeOpacity(theme.centerChannelColor, 0.64))}
/>
);
};
export default OptionIcon;

View File

@@ -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);
}

View File

@@ -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: {

View File

@@ -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<string>();
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<PostActionOption>((b: AppBinding) => ({text: b.label, value: b.location || ''}));
const options = useMemo(() => binding.bindings?.map<PostActionOption>((b: AppBinding) => ({
text: b.label,
value: b.location || '',
})), [binding.bindings]);
return (
<AutocompleteSelector

View File

@@ -25,14 +25,14 @@ const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, pos
}
const [selected, setSelected] = useState(isSelected?.value);
const handleSelect = useCallback(async (selectedItem: string | string[]) => {
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
<AutocompleteSelector
placeholder={name}
dataSource={dataSource}
isMultiselect={false}
options={options}
selected={selected}
onSelected={handleSelect}

View File

@@ -182,7 +182,7 @@ const Post = ({
}
Keyboard.dismiss();
const passProps = {sourceScreen: location, post, showAddReaction};
const passProps = {sourceScreen: location, post, showAddReaction, serverUrl};
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
if (isTablet) {

View File

@@ -37,6 +37,13 @@ export const AppFieldTypes: { [name: string]: AppFieldType } = {
MARKDOWN: 'markdown',
};
export const SelectableAppFieldTypes = [
AppFieldTypes.CHANNEL,
AppFieldTypes.USER,
AppFieldTypes.STATIC_SELECT,
AppFieldTypes.DYNAMIC_SELECT,
];
export const COMMAND_SUGGESTION_ERROR = 'error';
export const COMMAND_SUGGESTION_CHANNEL = 'channel';
export const COMMAND_SUGGESTION_USER = 'user';

94
app/hooks/apps.ts Normal file
View File

@@ -0,0 +1,94 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {handleBindingClick} from '@actions/remote/apps';
import {handleGotoLocation} from '@actions/remote/command';
import {AppCallResponseTypes} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {showAppForm} from '@screens/navigation';
import {createCallContext} from '@utils/apps';
export type UseAppBindingContext = {
channel_id: string;
team_id: string;
post_id?: string;
root_id?: string;
}
export type UseAppBindingConfig = {
onSuccess: (callResponse: AppCallResponse, message: string) => 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]);
};

View File

@@ -14,6 +14,8 @@ import NetworkManager from './network_manager';
const emptyBindings: AppBinding[] = [];
const normalizeBindings = (bindings: AppBinding[]) => bindings.reduce<AppBinding[]>((acc, v) => (v.bindings ? acc.concat(v.bindings) : acc), []);
class AppsManager {
private enabled: {[serverUrl: string]: BehaviorSubject<boolean>} = {};
@@ -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) => {

View File

@@ -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<DoAppCallResult<FormResponseData>>;
submit: (submission: {values: AppFormValues}) => Promise<DoAppCallResult<FormResponseData>>;
submit: (values: AppFormValues) => Promise<DoAppCallResult<FormResponseData>>;
performLookupCall: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<DoAppCallResult<AppLookupResponse>>;
}
@@ -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<AppSelectOption[]> => {
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 (
<SafeAreaView
@@ -415,13 +421,17 @@ function AppsFormComponent({
style={{marginHorizontal: 5}}
>
{submitButtons?.options?.map((o) => (
<Button
<View
key={o.value}
onPress={() => handleSubmit(o.value)}
containerStyle={style.button}
style={style.buttonContainer}
>
<Text style={style.buttonText}>{o.label}</Text>
</Button>
<Button
onPress={() => handleSubmit(o.value)}
containerStyle={style.button}
>
<Text style={style.buttonText}>{o.label}</Text>
</Button>
</View>
))}
</View>
</ScrollView>

View File

@@ -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<AppSelectOption[]>;
}
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<DialogOption[]> => {
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 (
<AutocompleteSelector
label={displayName}
dataSource={selectDataSource(field.type)}
options={options}
optional={!field.is_required}
onSelected={handleChange}
getDynamicOptions={getDynamicOptions}
onSelected={handleSelect}
getDynamicOptions={field.type === AppFieldTypes.DYNAMIC_SELECT ? getDynamicOptions : undefined}
helpText={field.description}
errorText={errorText}
placeholder={placeholder}
showRequiredAsterisk={true}
selected={value as string | string[]}
selected={selectedValue}
roundedBorders={false}
disabled={field.readonly}
isMultiselect={field.multiselect}

View File

@@ -27,7 +27,7 @@ function AppsFormContainer({
const [currentForm, setCurrentForm] = useState(form);
const serverUrl = useServerUrl();
const submit = useCallback(async (submission: {values: AppFormValues}): Promise<{data?: AppCallResponse<FormResponseData>; error?: AppCallResponse<FormResponseData>}> => {
const submit = useCallback(async (submission: AppFormValues): Promise<{data?: AppCallResponse<FormResponseData>; error?: AppCallResponse<FormResponseData>}> => {
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<FormResponseData>(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<DoAppCallResult<FormResponseData>> => {
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<DoAppCallResult<AppLookupResponse>> => {
const makeErrorMsg = (message: string) => intl.formatMessage(
@@ -187,7 +187,7 @@ function AppsFormContainer({
creq.query = userInput;
return doAppLookup<AppLookupResponse>(serverUrl, creq, intl);
}, []);
}, [context, serverUrl, intl]);
if (!currentForm?.submit || !context) {
return null;

View File

@@ -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<void>;
};
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) => (
<BindingOptionItem
key={binding.app_id + binding.location}
binding={binding}
onPress={onPress}
/>
));
return <>{options}</>;
};
const BindingOptionItem = ({binding, onPress}: {binding: AppBinding; onPress: (binding: AppBinding) => void}) => {
const handlePress = useCallback(preventDoubleTap(() => {
onPress(binding);
}), [binding, onPress]);
return (
<OptionItem
label={binding.label}
icon={binding.icon}
action={handlePress}
type='default'
testID={`channel_info.options.app_binding.option.${binding.location}`}
/>
);
};
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)));

View File

@@ -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 = ({
<View style={styles.separator}/>
</>
}
<ChannelInfoAppBindings
channelId={channelId}
serverUrl={serverUrl}
dismissChannelInfo={onPressed}
/>
<DestructiveOptions
channelId={channelId}
componentId={componentId}

View File

@@ -8,6 +8,7 @@ import {SafeAreaView} from 'react-native-safe-area-context';
import {fetchChannels, searchChannels} from '@actions/remote/channel';
import {fetchProfiles, searchProfiles} from '@actions/remote/user';
import {t} from '@app/i18n';
import FormattedText from '@components/formatted_text';
import SearchBar from '@components/search';
import {createProfilesSections} from '@components/user_list';
@@ -104,7 +105,7 @@ export type Props = {
dataSource: string;
handleSelect: (opt: Selection) => 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;

View File

@@ -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}
/>

View File

@@ -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;

View File

@@ -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);
};

View File

@@ -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<Post | PostModel | undefined> = 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)));

View File

@@ -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) => (
<BindingOptionItem
key={binding.location}
binding={binding}
onPress={onPress}
/>
));
return <>{options}</>;
};
const BindingOptionItem = ({binding, onPress}: {binding: AppBinding; onPress: (binding: AppBinding) => void}) => {
const handlePress = useCallback(preventDoubleTap(() => {
onPress(binding);
}), [binding, onPress]);
return (
<OptionItem
label={binding.label}
icon={binding.icon}
action={handlePress}
type='default'
testID={`post_options.app_binding.option.${binding.location}`}
/>
);
};
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)));

View File

@@ -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<ManagedConfig>();
@@ -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 && <ReactionBar postId={post.id}/>}
{canReply && sourceScreen !== Screens.THREAD && <ReplyOption post={post}/>}
{shouldRenderFollow &&
<FollowThreadOption thread={thread}/>
<FollowThreadOption thread={thread}/>
}
{canMarkAsUnread && !isSystemPost &&
<MarkAsUnreadOption
post={post}
sourceScreen={sourceScreen}
/>
<MarkAsUnreadOption
post={post}
sourceScreen={sourceScreen}
/>
}
{canCopyPermalink &&
<CopyPermalinkOption
post={post}
sourceScreen={sourceScreen}
/>
<CopyPermalinkOption
post={post}
sourceScreen={sourceScreen}
/>
}
{!isSystemPost &&
<SaveOption
isSaved={isSaved}
postId={post.id}
/>
<SaveOption
isSaved={isSaved}
postId={post.id}
/>
}
{Boolean(canCopyText && post.message) &&
<CopyTextOption
postMessage={post.message}
sourceScreen={sourceScreen}
/>}
<CopyTextOption
postMessage={post.message}
sourceScreen={sourceScreen}
/>}
{canPin &&
<PinChannelOption
isPostPinned={post.isPinned}
postId={post.id}
/>
<PinChannelOption
isPostPinned={post.isPinned}
postId={post.id}
/>
}
{canEdit &&
<EditOption
post={post}
canDelete={canDelete}
/>
<EditOption
post={post}
canDelete={canDelete}
/>
}
{canDelete &&
<DeletePostOption
combinedPost={combinedPost}
post={post}
/>}
{shouldShowBindings &&
<AppBindingsPostOptions
post={post}
serverUrl={serverUrl}
bindings={bindings}
/>
}
</>
);
};
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 (
<BottomSheet
renderContent={renderContent}
closeButtonId={POST_OPTIONS_BUTTON}
componentId={Screens.POST_OPTIONS}
initialSnapIndex={0}
snapPoints={[((snapPoints + additionalSnapPoints) * ITEM_HEIGHT), 10]}
initialSnapIndex={initialSnapIndex}
snapPoints={finalSnapPoints}
testID='post_options'
/>
);
};
export default PostOptions;
export default React.memo(PostOptions);

View File

@@ -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

4
types/api/apps.d.ts vendored
View File

@@ -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 = {

View File

@@ -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;