forked from Ivasoft/mattermost-mobile
App framework - Post menu and channel info bindings, App forms (#6735)
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
61
app/components/option_item/option_icon.tsx
Normal file
61
app/components/option_item/option_icon.tsx
Normal 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
94
app/hooks/apps.ts
Normal 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]);
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
94
app/screens/channel_info/app_bindings/index.tsx
Normal file
94
app/screens/channel_info/app_bindings/index.tsx
Normal 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)));
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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)));
|
||||
|
||||
|
||||
100
app/screens/post_options/options/app_bindings_post_option.tsx
Normal file
100
app/screens/post_options/options/app_bindings_post_option.tsx
Normal 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)));
|
||||
@@ -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);
|
||||
|
||||
@@ -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
4
types/api/apps.d.ts
vendored
@@ -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 = {
|
||||
|
||||
4
types/api/integrations.d.ts
vendored
4
types/api/integrations.d.ts
vendored
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user