forked from Ivasoft/mattermost-mobile
Compare commits
58 Commits
onboarding
...
app-framew
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e37e8fda | ||
|
|
d32287afca | ||
|
|
459c3b4ad7 | ||
|
|
ae79117449 | ||
|
|
9c39b064aa | ||
|
|
d760fd66b5 | ||
|
|
0b4d7bb9bc | ||
|
|
f44dd315af | ||
|
|
7a834b35bd | ||
|
|
76ef73b1d0 | ||
|
|
d162fda830 | ||
|
|
1d4cfb7d5d | ||
|
|
ed7f3eaddf | ||
|
|
567c1489ca | ||
|
|
07f31061ce | ||
|
|
ab3694da18 | ||
|
|
007a261cf5 | ||
|
|
da12fef464 | ||
|
|
fce04ba23a | ||
|
|
7bb7a32d3a | ||
|
|
eeb89c7f5e | ||
|
|
32885f3dba | ||
|
|
f47091608d | ||
|
|
e2a0e7a4f3 | ||
|
|
642eb9b8d8 | ||
|
|
730d92438e | ||
|
|
7410877983 | ||
|
|
ab8f8d6154 | ||
|
|
ffb84fe4d9 | ||
|
|
cbfc032432 | ||
|
|
3981248aff | ||
|
|
375be63609 | ||
|
|
1ab5c5232d | ||
|
|
47e3c22a68 | ||
|
|
413af650eb | ||
|
|
7d91be3367 | ||
|
|
dc878defcd | ||
|
|
a2067d5389 | ||
|
|
1176c9f54e | ||
|
|
600a86661c | ||
|
|
b310ad26a0 | ||
|
|
f08aef92c0 | ||
|
|
afcad52b98 | ||
|
|
58b84ae1bb | ||
|
|
8cfb754132 | ||
|
|
e14d6982a9 | ||
|
|
c964ee55ed | ||
|
|
ab4729452c | ||
|
|
f91f02ddbc | ||
|
|
2006a59cf1 | ||
|
|
2fd42a5871 | ||
|
|
d0cbe56aac | ||
|
|
28765a9aaf | ||
|
|
5e634a549d | ||
|
|
f205a11ad1 | ||
|
|
5a00655fd0 | ||
|
|
ed590c11e7 | ||
|
|
cc31565cef |
@@ -207,7 +207,7 @@ export function postEphemeralCallResponseForPost(serverUrl: string, response: Ap
|
|||||||
serverUrl,
|
serverUrl,
|
||||||
message,
|
message,
|
||||||
post.channelId,
|
post.channelId,
|
||||||
post.rootId,
|
post.rootId || post.id,
|
||||||
response.app_metadata?.bot_user_id,
|
response.app_metadata?.bot_user_id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ const executeAppCommand = async (serverUrl: string, intl: IntlShape, parser: App
|
|||||||
return {data: {}};
|
return {data: {}};
|
||||||
case AppCallResponseTypes.FORM:
|
case AppCallResponseTypes.FORM:
|
||||||
if (callResp.form) {
|
if (callResp.form) {
|
||||||
showAppForm(callResp.form);
|
showAppForm(callResp.form, creq.context);
|
||||||
}
|
}
|
||||||
return {data: {}};
|
return {data: {}};
|
||||||
case AppCallResponseTypes.NAVIGATE:
|
case AppCallResponseTypes.NAVIGATE:
|
||||||
|
|||||||
@@ -407,7 +407,7 @@ export const fetchUsersByIds = async (serverUrl: string, userIds: string[], fetc
|
|||||||
return {users: [], existingUsers};
|
return {users: [], existingUsers};
|
||||||
}
|
}
|
||||||
const users = await client.getProfilesByIds([...new Set(usersToLoad)]);
|
const users = await client.getProfilesByIds([...new Set(usersToLoad)]);
|
||||||
if (!fetchOnly) {
|
if (!fetchOnly && users.length) {
|
||||||
await operator.handleUsers({
|
await operator.handleUsers({
|
||||||
users,
|
users,
|
||||||
prepareRecordsOnly: false,
|
prepareRecordsOnly: false,
|
||||||
|
|||||||
@@ -984,6 +984,9 @@ export class AppCommandParser {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
user = res.users[0] || res.existingUsers[0];
|
user = res.users[0] || res.existingUsers[0];
|
||||||
|
if (!user) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
parsed.values[f.name] = user.username;
|
parsed.values[f.name] = user.username;
|
||||||
break;
|
break;
|
||||||
@@ -998,6 +1001,9 @@ export class AppCommandParser {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
channel = res.channel;
|
channel = res.channel;
|
||||||
|
if (!channel) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
parsed.values[f.name] = channel.name;
|
parsed.values[f.name] = channel.name;
|
||||||
break;
|
break;
|
||||||
@@ -1176,14 +1182,21 @@ export class AppCommandParser {
|
|||||||
|
|
||||||
const errors: {[key: string]: string} = {};
|
const errors: {[key: string]: string} = {};
|
||||||
await Promise.all(parsed.resolvedForm.fields.map(async (f) => {
|
await Promise.all(parsed.resolvedForm.fields.map(async (f) => {
|
||||||
if (!values[f.name]) {
|
const fieldValue = values[f.name];
|
||||||
|
if (!fieldValue) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
switch (f.type) {
|
switch (f.type) {
|
||||||
case AppFieldTypes.DYNAMIC_SELECT:
|
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 options: AppSelectOption[] = [];
|
||||||
const commandValues = values[f.name] as string[];
|
|
||||||
for (const value of commandValues) {
|
for (const value of commandValues) {
|
||||||
if (options.find((o) => o.value === value)) {
|
if (options.find((o) => o.value === value)) {
|
||||||
errors[f.name] = this.intl.formatMessage({
|
errors[f.name] = this.intl.formatMessage({
|
||||||
@@ -1199,7 +1212,7 @@ export class AppCommandParser {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
values[f.name] = {label: values[f.name], value: values[f.name]};
|
values[f.name] = {label: fieldValue, value: fieldValue};
|
||||||
break;
|
break;
|
||||||
case AppFieldTypes.STATIC_SELECT: {
|
case AppFieldTypes.STATIC_SELECT: {
|
||||||
const getOption = (value: string) => {
|
const getOption = (value: string) => {
|
||||||
@@ -1217,9 +1230,15 @@ export class AppCommandParser {
|
|||||||
values[f.name] = undefined;
|
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 options: AppSelectOption[] = [];
|
||||||
const commandValues = values[f.name] as string[];
|
|
||||||
for (const value of commandValues) {
|
for (const value of commandValues) {
|
||||||
const option = getOption(value);
|
const option = getOption(value);
|
||||||
if (!option) {
|
if (!option) {
|
||||||
@@ -1241,9 +1260,9 @@ export class AppCommandParser {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const option = getOption(values[f.name]);
|
const option = getOption(fieldValue);
|
||||||
if (!option) {
|
if (!option) {
|
||||||
setOptionError(values[f.name]);
|
setOptionError(fieldValue);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
values[f.name] = option;
|
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 options: AppSelectOption[] = [];
|
||||||
const commandValues = values[f.name] as string[];
|
|
||||||
/* eslint-disable no-await-in-loop */
|
/* eslint-disable no-await-in-loop */
|
||||||
for (const value of commandValues) {
|
for (const value of commandValues) {
|
||||||
let userName = value;
|
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 options: AppSelectOption[] = [];
|
||||||
const commandValues = values[f.name] as string[];
|
|
||||||
/* eslint-disable no-await-in-loop */
|
/* eslint-disable no-await-in-loop */
|
||||||
for (const value of commandValues) {
|
for (const value of commandValues) {
|
||||||
let channelName = value;
|
let channelName = value;
|
||||||
@@ -1432,8 +1463,7 @@ export class AppCommandParser {
|
|||||||
// getCommandBindings returns the commands in the redux store.
|
// getCommandBindings returns the commands in the redux store.
|
||||||
// They are grouped by app id since each app has one base command
|
// They are grouped by app id since each app has one base command
|
||||||
private getCommandBindings = (): AppBinding[] => {
|
private getCommandBindings = (): AppBinding[] => {
|
||||||
const bindings = AppsManager.getBindings(this.serverUrl, AppBindingLocations.COMMAND, Boolean(this.rootPostID));
|
return AppsManager.getBindings(this.serverUrl, AppBindingLocations.COMMAND, Boolean(this.rootPostID));
|
||||||
return bindings.reduce<AppBinding[]>((acc, v) => (v.bindings ? acc.concat(v.bindings) : acc), []);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// getChannel gets the channel in which the user is typing the command
|
// getChannel gets the channel in which the user is typing the command
|
||||||
@@ -1685,7 +1715,13 @@ export class AppCommandParser {
|
|||||||
prefix = '';
|
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) {
|
if (applicable) {
|
||||||
return applicable.map((f) => {
|
return applicable.map((f) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -15,9 +15,6 @@ import analytics from '@managers/analytics';
|
|||||||
import {AppCommandParser, ExtendedAutocompleteSuggestion} from '../app_command_parser/app_command_parser';
|
import {AppCommandParser, ExtendedAutocompleteSuggestion} from '../app_command_parser/app_command_parser';
|
||||||
import SlashSuggestionItem from '../slash_suggestion_item';
|
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 = {
|
export type Props = {
|
||||||
currentTeamId: string;
|
currentTeamId: string;
|
||||||
isSearch?: boolean;
|
isSearch?: boolean;
|
||||||
@@ -31,7 +28,20 @@ export type Props = {
|
|||||||
listStyle: StyleProp<ViewStyle>;
|
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[] = [];
|
const emptySuggestonList: AutocompleteSuggestion[] = [];
|
||||||
|
|
||||||
@@ -96,28 +106,34 @@ const AppSlashSuggestion = ({
|
|||||||
|
|
||||||
const renderItem = useCallback(({item}: {item: ExtendedAutocompleteSuggestion}) => {
|
const renderItem = useCallback(({item}: {item: ExtendedAutocompleteSuggestion}) => {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case COMMAND_SUGGESTION_USER:
|
case COMMAND_SUGGESTION_USER: {
|
||||||
if (!item.item) {
|
const user = item.item as UserProfile | undefined;
|
||||||
|
if (!user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AtMentionItem
|
<AtMentionItem
|
||||||
user={item.item as UserProfile | UserModel}
|
user={user}
|
||||||
onPress={completeIgnoringSuggestion(item.Complete)}
|
onPress={completeIgnoringSuggestion(item.Complete)}
|
||||||
testID='autocomplete.slash_suggestion.at_mention_item'
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChannelMentionItem
|
<ChannelMentionItem
|
||||||
channel={item.item as Channel | ChannelModel}
|
channel={channel}
|
||||||
onPress={completeIgnoringSuggestion(item.Complete)}
|
onPress={completeIgnoringSuggestion(item.Complete)}
|
||||||
testID='autocomplete.slash_suggestion.channel_mention_item'
|
testID='autocomplete.slash_suggestion.channel_mention_item'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<SlashSuggestionItem
|
<SlashSuggestionItem
|
||||||
|
|||||||
@@ -33,12 +33,12 @@ type AutoCompleteSelectorProps = {
|
|||||||
getDynamicOptions?: (userInput?: string) => Promise<DialogOption[]>;
|
getDynamicOptions?: (userInput?: string) => Promise<DialogOption[]>;
|
||||||
helpText?: string;
|
helpText?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
onSelected?: (value: string | string[]) => void;
|
onSelected?: (value: SelectedDialogOption) => void;
|
||||||
optional?: boolean;
|
optional?: boolean;
|
||||||
options?: PostActionOption[];
|
options?: DialogOption[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
roundedBorders?: boolean;
|
roundedBorders?: boolean;
|
||||||
selected?: string | string[];
|
selected?: SelectedDialogValue;
|
||||||
showRequiredAsterisk?: boolean;
|
showRequiredAsterisk?: boolean;
|
||||||
teammateNameDisplay: string;
|
teammateNameDisplay: string;
|
||||||
isMultiselect?: boolean;
|
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;
|
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
|
||||||
|
|
||||||
switch (dataSource) {
|
switch (dataSource) {
|
||||||
@@ -97,6 +101,7 @@ async function getItemName(serverUrl: string, selected: string, teammateNameDisp
|
|||||||
if (!database) {
|
if (!database) {
|
||||||
return intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
|
return intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await getUserById(database, selected);
|
const user = await getUserById(database, selected);
|
||||||
return displayUsername(user, intl.locale, teammateNameDisplay, true);
|
return displayUsername(user, intl.locale, teammateNameDisplay, true);
|
||||||
}
|
}
|
||||||
@@ -104,15 +109,17 @@ async function getItemName(serverUrl: string, selected: string, teammateNameDisp
|
|||||||
if (!database) {
|
if (!database) {
|
||||||
return intl.formatMessage({id: 'autocomplete_selector.unknown_channel', defaultMessage: 'Unknown channel'});
|
return intl.formatMessage({id: 'autocomplete_selector.unknown_channel', defaultMessage: 'Unknown channel'});
|
||||||
}
|
}
|
||||||
|
|
||||||
const channel = await getChannelById(database, selected);
|
const channel = await getChannelById(database, selected);
|
||||||
return channel?.displayName || intl.formatMessage({id: 'autocomplete_selector.unknown_channel', defaultMessage: 'Unknown channel'});
|
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) {
|
if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
|
||||||
const user = item as UserProfile;
|
const user = item as UserProfile;
|
||||||
return {text: displayUsername(user, locale, teammateNameDisplay), value: user.id};
|
return {text: displayUsername(user, locale, teammateNameDisplay), value: user.id};
|
||||||
@@ -120,8 +127,7 @@ function getTextAndValueFromSelectedItem(item: DialogOption | Channel | UserProf
|
|||||||
const channel = item as Channel;
|
const channel = item as Channel;
|
||||||
return {text: channel.display_name, value: channel.id};
|
return {text: channel.display_name, value: channel.id};
|
||||||
}
|
}
|
||||||
const option = item as DialogOption;
|
return item as DialogOption;
|
||||||
return option;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AutoCompleteSelector({
|
function AutoCompleteSelector({
|
||||||
@@ -140,31 +146,25 @@ function AutoCompleteSelector({
|
|||||||
goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect, teammateNameDisplay});
|
goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect, teammateNameDisplay});
|
||||||
}), [dataSource, options, getDynamicOptions]);
|
}), [dataSource, options, getDynamicOptions]);
|
||||||
|
|
||||||
const handleSelect = useCallback((item?: Selection) => {
|
const handleSelect = useCallback((newSelection?: Selection) => {
|
||||||
if (!item) {
|
if (!newSelection) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Array.isArray(item)) {
|
if (!Array.isArray(newSelection)) {
|
||||||
const {text: selectedText, value: selectedValue} = getTextAndValueFromSelectedItem(item, teammateNameDisplay, intl.locale, dataSource);
|
const selectedOption = getTextAndValueFromSelectedItem(newSelection, teammateNameDisplay, intl.locale, dataSource);
|
||||||
setItemText(selectedText);
|
setItemText(selectedOption.text);
|
||||||
|
|
||||||
if (onSelected) {
|
if (onSelected) {
|
||||||
onSelected(selectedValue);
|
onSelected(selectedOption);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allSelectedTexts = [];
|
const selectedOptions = newSelection.map((option) => getTextAndValueFromSelectedItem(option, teammateNameDisplay, intl.locale, dataSource));
|
||||||
const allSelectedValues = [];
|
setItemText(selectedOptions.map((option) => option.text).join(', '));
|
||||||
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(', '));
|
|
||||||
if (onSelected) {
|
if (onSelected) {
|
||||||
onSelected(allSelectedValues);
|
onSelected(selectedOptions);
|
||||||
}
|
}
|
||||||
}, [teammateNameDisplay, intl, dataSource]);
|
}, [teammateNameDisplay, intl, dataSource]);
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {useTheme} from '@context/theme';
|
|||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
import {typography} from '@utils/typography';
|
import {typography} from '@utils/typography';
|
||||||
|
|
||||||
|
import OptionIcon from './option_icon';
|
||||||
import RadioItem, {RadioItemProps} from './radio_item';
|
import RadioItem, {RadioItemProps} from './radio_item';
|
||||||
|
|
||||||
const TouchableOptionTypes = {
|
const TouchableOptionTypes = {
|
||||||
@@ -238,10 +239,10 @@ const OptionItem = ({
|
|||||||
<View style={styles.labelContainer}>
|
<View style={styles.labelContainer}>
|
||||||
{Boolean(icon) && (
|
{Boolean(icon) && (
|
||||||
<View style={styles.iconContainer}>
|
<View style={styles.iconContainer}>
|
||||||
<CompassIcon
|
<OptionIcon
|
||||||
name={icon!}
|
icon={icon!}
|
||||||
size={24}
|
iconColor={iconColor}
|
||||||
color={iconColor || (destructive ? theme.dndIndicator : changeOpacity(theme.centerChannelColor, 0.64))}
|
destructive={destructive}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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();
|
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')) {
|
if (data?.goto_location && !value.startsWith('/leave')) {
|
||||||
handleGotoLocation(serverUrl, intl, data.goto_location);
|
handleGotoLocation(serverUrl, intl, data.goto_location);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) =>
|
|||||||
return;
|
return;
|
||||||
case AppCallResponseTypes.FORM:
|
case AppCallResponseTypes.FORM:
|
||||||
if (callResp.form) {
|
if (callResp.form) {
|
||||||
showAppForm(callResp.form);
|
showAppForm(callResp.form, context);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
default: {
|
default: {
|
||||||
|
|||||||
@@ -3,18 +3,14 @@
|
|||||||
|
|
||||||
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
|
||||||
import withObservables from '@nozbe/with-observables';
|
import withObservables from '@nozbe/with-observables';
|
||||||
import React, {useCallback, useState} from 'react';
|
import React, {useCallback, useMemo, useState} from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
|
||||||
import {map} from 'rxjs/operators';
|
import {map} from 'rxjs/operators';
|
||||||
|
|
||||||
import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/remote/apps';
|
import {postEphemeralCallResponseForPost} from '@actions/remote/apps';
|
||||||
import {handleGotoLocation} from '@actions/remote/command';
|
|
||||||
import AutocompleteSelector from '@components/autocomplete_selector';
|
import AutocompleteSelector from '@components/autocomplete_selector';
|
||||||
import {AppBindingLocations, AppCallResponseTypes} from '@constants/apps';
|
|
||||||
import {useServerUrl} from '@context/server';
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useAppBinding} from '@hooks/apps';
|
||||||
import {observeCurrentTeamId} from '@queries/servers/system';
|
import {observeCurrentTeamId} from '@queries/servers/system';
|
||||||
import {showAppForm} from '@screens/navigation';
|
|
||||||
import {createCallContext} from '@utils/apps';
|
|
||||||
import {logDebug} from '@utils/log';
|
import {logDebug} from '@utils/log';
|
||||||
|
|
||||||
import type {WithDatabaseArgs} from '@typings/database/database';
|
import type {WithDatabaseArgs} from '@typings/database/database';
|
||||||
@@ -30,70 +26,46 @@ type Props = {
|
|||||||
|
|
||||||
const MenuBinding = ({binding, currentTeamId, post, teamID}: Props) => {
|
const MenuBinding = ({binding, currentTeamId, post, teamID}: Props) => {
|
||||||
const [selected, setSelected] = useState<string>();
|
const [selected, setSelected] = useState<string>();
|
||||||
const intl = useIntl();
|
|
||||||
const serverUrl = useServerUrl();
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
const onSelect = useCallback(async (picked?: string | string[]) => {
|
const onCallResponse = useCallback((callResp: AppCallResponse, message: string) => {
|
||||||
if (!picked || Array.isArray(picked)) { // We are sure AutocompleteSelector only returns one, since it is not multiselect.
|
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;
|
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) {
|
if (!bind) {
|
||||||
logDebug('Trying to select element not present in binding.');
|
logDebug('Trying to select element not present in binding.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = createCallContext(
|
const finish = await handleBindingSubmit(bind);
|
||||||
bind.app_id,
|
finish();
|
||||||
AppBindingLocations.IN_POST + bind.location,
|
}, [handleBindingSubmit, binding.bindings]);
|
||||||
post.channelId,
|
|
||||||
teamID || currentTeamId,
|
|
||||||
post.id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const res = await handleBindingClick(serverUrl, bind, context, intl);
|
const options = useMemo(() => binding.bindings?.map<PostActionOption>((b: AppBinding) => ({
|
||||||
if (res.error) {
|
text: b.label,
|
||||||
const errorResponse = res.error;
|
value: b.location || '',
|
||||||
const errorMessage = errorResponse.text || intl.formatMessage({
|
})), [binding.bindings]);
|
||||||
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 || ''}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AutocompleteSelector
|
<AutocompleteSelector
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, pos
|
|||||||
}
|
}
|
||||||
const [selected, setSelected] = useState(isSelected?.value);
|
const [selected, setSelected] = useState(isSelected?.value);
|
||||||
|
|
||||||
const handleSelect = useCallback(async (selectedItem: string | string[]) => {
|
const handleSelect = useCallback(async (selectedItem: SelectedDialogOption) => {
|
||||||
if (Array.isArray(selectedItem)) { // Since AutocompleteSelector is not multiselect, we are sure we only receive a string
|
if (!selectedItem || Array.isArray(selectedItem)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await selectAttachmentMenuAction(serverUrl, postId, id, selectedItem);
|
const result = await selectAttachmentMenuAction(serverUrl, postId, id, selectedItem.value);
|
||||||
if (result.data?.trigger_id) {
|
if (result.data?.trigger_id) {
|
||||||
setSelected(selectedItem);
|
setSelected(selectedItem.value);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -40,6 +40,7 @@ const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, pos
|
|||||||
<AutocompleteSelector
|
<AutocompleteSelector
|
||||||
placeholder={name}
|
placeholder={name}
|
||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
|
isMultiselect={false}
|
||||||
options={options}
|
options={options}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
onSelected={handleSelect}
|
onSelected={handleSelect}
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ const Post = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
Keyboard.dismiss();
|
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'}) : '';
|
const title = isTablet ? intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}) : '';
|
||||||
|
|
||||||
if (isTablet) {
|
if (isTablet) {
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ export const AppFieldTypes: { [name: string]: AppFieldType } = {
|
|||||||
MARKDOWN: 'markdown',
|
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_ERROR = 'error';
|
||||||
export const COMMAND_SUGGESTION_CHANNEL = 'channel';
|
export const COMMAND_SUGGESTION_CHANNEL = 'channel';
|
||||||
export const COMMAND_SUGGESTION_USER = 'user';
|
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 emptyBindings: AppBinding[] = [];
|
||||||
|
|
||||||
|
const normalizeBindings = (bindings: AppBinding[]) => bindings.reduce<AppBinding[]>((acc, v) => (v.bindings ? acc.concat(v.bindings) : acc), []);
|
||||||
|
|
||||||
class AppsManager {
|
class AppsManager {
|
||||||
private enabled: {[serverUrl: string]: BehaviorSubject<boolean>} = {};
|
private enabled: {[serverUrl: string]: BehaviorSubject<boolean>} = {};
|
||||||
|
|
||||||
@@ -177,22 +179,23 @@ class AppsManager {
|
|||||||
return combineLatest([isEnabled, bindings]).pipe(
|
return combineLatest([isEnabled, bindings]).pipe(
|
||||||
switchMap(([e, bb]) => of$(e ? bb : emptyBindings)),
|
switchMap(([e, bb]) => of$(e ? bb : emptyBindings)),
|
||||||
switchMap((bb) => {
|
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);
|
return of$(result.length ? result : emptyBindings);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
getBindings = (serverUrl: string, location?: string, forThread = false) => {
|
getBindings = (serverUrl: string, location?: string, forThread = false) => {
|
||||||
const bindings = forThread ?
|
let bindings = forThread ?
|
||||||
this.getThreadsBindingsSubject(serverUrl).value.bindings :
|
this.getThreadsBindingsSubject(serverUrl).value.bindings :
|
||||||
this.getBindingsSubject(serverUrl).value;
|
this.getBindingsSubject(serverUrl).value;
|
||||||
|
|
||||||
if (location) {
|
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) => {
|
getCommandForm = (serverUrl: string, key: string, forThread = false) => {
|
||||||
|
|||||||
@@ -49,6 +49,11 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
|||||||
color: (theme.errorTextColor || '#DA4A4A'),
|
color: (theme.errorTextColor || '#DA4A4A'),
|
||||||
},
|
},
|
||||||
button: buttonBackgroundStyle(theme, 'lg', 'primary', 'default'),
|
button: buttonBackgroundStyle(theme, 'lg', 'primary', 'default'),
|
||||||
|
buttonContainer: {
|
||||||
|
paddingTop: 20,
|
||||||
|
paddingLeft: 50,
|
||||||
|
paddingRight: 50,
|
||||||
|
},
|
||||||
buttonText: buttonTextStyle(theme, 'lg', 'primary', 'default'),
|
buttonText: buttonTextStyle(theme, 'lg', 'primary', 'default'),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -75,7 +80,7 @@ export type Props = {
|
|||||||
form: AppForm;
|
form: AppForm;
|
||||||
componentId: string;
|
componentId: string;
|
||||||
refreshOnSelect: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<DoAppCallResult<FormResponseData>>;
|
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>>;
|
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};
|
return {...state, [action.name]: action.value};
|
||||||
}
|
}
|
||||||
|
|
||||||
function initValues(elements?: AppField[]) {
|
function initValues(fields?: AppField[]) {
|
||||||
const values: AppFormValues = {};
|
const values: AppFormValues = {};
|
||||||
elements?.forEach((e) => {
|
fields?.forEach((e) => {
|
||||||
if (e.type === 'bool') {
|
if (e.type === 'bool') {
|
||||||
values[e.name] = (e.value === true || String(e.value).toLowerCase() === 'true');
|
values[e.name] = (e.value === true || String(e.value).toLowerCase() === 'true');
|
||||||
} else if (e.value) {
|
} else if (e.value) {
|
||||||
@@ -126,15 +131,6 @@ function AppsFormComponent({
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const style = getStyleFromTheme(theme);
|
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(() => {
|
useDidUpdate(() => {
|
||||||
dispatchValues({elements: form.fields});
|
dispatchValues({elements: form.fields});
|
||||||
}, [form]);
|
}, [form]);
|
||||||
@@ -153,26 +149,62 @@ function AppsFormComponent({
|
|||||||
undefined,
|
undefined,
|
||||||
intl.formatMessage({id: 'interactive_dialog.submit', defaultMessage: 'Submit'}),
|
intl.formatMessage({id: 'interactive_dialog.submit', defaultMessage: 'Submit'}),
|
||||||
);
|
);
|
||||||
base.enabled = submitting;
|
base.enabled = !submitting;
|
||||||
base.showAsAction = 'always';
|
base.showAsAction = 'always';
|
||||||
base.color = theme.sidebarHeaderTextColor;
|
base.color = theme.sidebarHeaderTextColor;
|
||||||
return base;
|
return base;
|
||||||
}, [theme.sidebarHeaderTextColor, intl, Boolean(submitButtons)]);
|
}, [theme.sidebarHeaderTextColor, Boolean(submitButtons), submitting, intl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setButtons(componentId, {
|
setButtons(componentId, {
|
||||||
rightButtons: rightButton ? [rightButton] : [],
|
rightButtons: rightButton ? [rightButton] : [],
|
||||||
});
|
});
|
||||||
}, [rightButton, componentId]);
|
}, [componentId, rightButton]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const icon = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
|
const icon = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
|
||||||
setButtons(componentId, {
|
setButtons(componentId, {
|
||||||
leftButtons: [makeCloseButton(icon)],
|
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);
|
const field = form.fields?.find((f) => f.name === name);
|
||||||
if (!field) {
|
if (!field) {
|
||||||
return;
|
return;
|
||||||
@@ -216,43 +248,13 @@ function AppsFormComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
dispatchValues({name, value});
|
dispatchValues({name, value});
|
||||||
}, []);
|
}, [form, values, refreshOnSelect, updateErrors, intl]);
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(async (button?: string) => {
|
const handleSubmit = useCallback(async (button?: string) => {
|
||||||
|
if (submitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const {fields} = form;
|
const {fields} = form;
|
||||||
const fieldErrors: {[name: string]: string} = {};
|
const fieldErrors: {[name: string]: string} = {};
|
||||||
|
|
||||||
@@ -269,21 +271,19 @@ function AppsFormComponent({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
setErrors(hasErrors ? fieldErrors : emptyErrorsState);
|
|
||||||
|
|
||||||
if (hasErrors) {
|
if (hasErrors) {
|
||||||
|
setErrors(fieldErrors);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submission = {
|
const submission = {...values};
|
||||||
values,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (button && form.submit_buttons) {
|
if (button && form.submit_buttons) {
|
||||||
submission.values[form.submit_buttons] = button;
|
submission[form.submit_buttons] = button;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
const res = await submit(submission);
|
const res = await submit(submission);
|
||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
@@ -298,6 +298,9 @@ function AppsFormComponent({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setError('');
|
||||||
|
setErrors(emptyErrorsState);
|
||||||
|
|
||||||
const callResponse = res.data!;
|
const callResponse = res.data!;
|
||||||
switch (callResponse.type) {
|
switch (callResponse.type) {
|
||||||
case AppCallResponseTypes.OK:
|
case AppCallResponseTypes.OK:
|
||||||
@@ -319,7 +322,7 @@ function AppsFormComponent({
|
|||||||
}));
|
}));
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [form, values, submit, submitting, updateErrors, serverUrl, intl]);
|
||||||
|
|
||||||
const performLookup = useCallback(async (name: string, userInput: string): Promise<AppSelectOption[]> => {
|
const performLookup = useCallback(async (name: string, userInput: string): Promise<AppSelectOption[]> => {
|
||||||
const field = form.fields?.find((f) => f.name === name);
|
const field = form.fields?.find((f) => f.name === name);
|
||||||
@@ -369,7 +372,10 @@ function AppsFormComponent({
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, [form, values, performLookupCall, intl]);
|
||||||
|
|
||||||
|
useNavButtonPressed(CLOSE_BUTTON_ID, componentId, close, [close]);
|
||||||
|
useNavButtonPressed(SUBMIT_BUTTON_ID, componentId, handleSubmit, [handleSubmit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
@@ -415,13 +421,17 @@ function AppsFormComponent({
|
|||||||
style={{marginHorizontal: 5}}
|
style={{marginHorizontal: 5}}
|
||||||
>
|
>
|
||||||
{submitButtons?.options?.map((o) => (
|
{submitButtons?.options?.map((o) => (
|
||||||
<Button
|
<View
|
||||||
key={o.value}
|
key={o.value}
|
||||||
onPress={() => handleSubmit(o.value)}
|
style={style.buttonContainer}
|
||||||
containerStyle={style.button}
|
|
||||||
>
|
>
|
||||||
<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>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import React, {useCallback} from 'react';
|
import React, {useCallback, useMemo} from 'react';
|
||||||
import {View} from 'react-native';
|
import {View} from 'react-native';
|
||||||
|
|
||||||
import AutocompleteSelector from '@components/autocomplete_selector';
|
import AutocompleteSelector from '@components/autocomplete_selector';
|
||||||
@@ -9,7 +9,7 @@ import Markdown from '@components/markdown';
|
|||||||
import BoolSetting from '@components/settings/bool_setting';
|
import BoolSetting from '@components/settings/bool_setting';
|
||||||
import TextSetting from '@components/settings/text_setting';
|
import TextSetting from '@components/settings/text_setting';
|
||||||
import {View as ViewConstants} from '@constants';
|
import {View as ViewConstants} from '@constants';
|
||||||
import {AppFieldTypes} from '@constants/apps';
|
import {AppFieldTypes, SelectableAppFieldTypes} from '@constants/apps';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
import {selectKeyboardType} from '@utils/integrations';
|
import {selectKeyboardType} from '@utils/integrations';
|
||||||
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
|
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
|
||||||
@@ -23,10 +23,20 @@ export type Props = {
|
|||||||
name: string;
|
name: string;
|
||||||
errorText?: string;
|
errorText?: string;
|
||||||
value: AppFormValue;
|
value: AppFormValue;
|
||||||
onChange: (name: string, value: string | string[] | boolean) => void;
|
onChange: (name: string, value: AppFormValue) => void;
|
||||||
performLookup: (name: string, userInput: string) => Promise<AppSelectOption[]>;
|
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) => {
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
return {
|
return {
|
||||||
markdownFieldContainer: {
|
markdownFieldContainer: {
|
||||||
@@ -69,18 +79,68 @@ function AppsFormField({
|
|||||||
const placeholder = field.hint || '';
|
const placeholder = field.hint || '';
|
||||||
const displayName = field.modal_label || field.label || '';
|
const displayName = field.modal_label || field.label || '';
|
||||||
|
|
||||||
const handleChange = useCallback((newValue: string | boolean | string[]) => {
|
const handleChange = useCallback((newValue: string | boolean) => {
|
||||||
onChange(name, newValue);
|
onChange(name, newValue);
|
||||||
}, [name]);
|
}, [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 getDynamicOptions = useCallback(async (userInput = ''): Promise<DialogOption[]> => {
|
||||||
const options = await performLookup(field.name, userInput);
|
const options = await performLookup(field.name, userInput);
|
||||||
return options.map((option) => ({
|
return options.map(appSelectOptionToDialogOption);
|
||||||
text: option.label,
|
|
||||||
value: option.value,
|
|
||||||
}));
|
|
||||||
}, [performLookup, field]);
|
}, [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) {
|
switch (field.type) {
|
||||||
case AppFieldTypes.TEXT: {
|
case AppFieldTypes.TEXT: {
|
||||||
return (
|
return (
|
||||||
@@ -105,24 +165,19 @@ function AppsFormField({
|
|||||||
case AppFieldTypes.CHANNEL:
|
case AppFieldTypes.CHANNEL:
|
||||||
case AppFieldTypes.STATIC_SELECT:
|
case AppFieldTypes.STATIC_SELECT:
|
||||||
case AppFieldTypes.DYNAMIC_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 (
|
return (
|
||||||
<AutocompleteSelector
|
<AutocompleteSelector
|
||||||
label={displayName}
|
label={displayName}
|
||||||
dataSource={selectDataSource(field.type)}
|
dataSource={selectDataSource(field.type)}
|
||||||
options={options}
|
options={options}
|
||||||
optional={!field.is_required}
|
optional={!field.is_required}
|
||||||
onSelected={handleChange}
|
onSelected={handleSelect}
|
||||||
getDynamicOptions={getDynamicOptions}
|
getDynamicOptions={field.type === AppFieldTypes.DYNAMIC_SELECT ? getDynamicOptions : undefined}
|
||||||
helpText={field.description}
|
helpText={field.description}
|
||||||
errorText={errorText}
|
errorText={errorText}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
showRequiredAsterisk={true}
|
showRequiredAsterisk={true}
|
||||||
selected={value as string | string[]}
|
selected={selectedValue}
|
||||||
roundedBorders={false}
|
roundedBorders={false}
|
||||||
disabled={field.readonly}
|
disabled={field.readonly}
|
||||||
isMultiselect={field.multiselect}
|
isMultiselect={field.multiselect}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function AppsFormContainer({
|
|||||||
const [currentForm, setCurrentForm] = useState(form);
|
const [currentForm, setCurrentForm] = useState(form);
|
||||||
const serverUrl = useServerUrl();
|
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) => {
|
const makeErrorMsg = (msg: string) => {
|
||||||
return intl.formatMessage(
|
return intl.formatMessage(
|
||||||
{
|
{
|
||||||
@@ -60,7 +60,7 @@ function AppsFormContainer({
|
|||||||
return {error: makeCallErrorResponse('unreachable: empty context')};
|
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);
|
const res = await doAppSubmit<FormResponseData>(serverUrl, creq, intl);
|
||||||
|
|
||||||
if (res.error) {
|
if (res.error) {
|
||||||
@@ -93,7 +93,7 @@ function AppsFormContainer({
|
|||||||
)))};
|
)))};
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}, []);
|
}, [currentForm, setCurrentForm, context, serverUrl, intl]);
|
||||||
|
|
||||||
const refreshOnSelect = useCallback(async (field: AppField, values: AppFormValues): Promise<DoAppCallResult<FormResponseData>> => {
|
const refreshOnSelect = useCallback(async (field: AppField, values: AppFormValues): Promise<DoAppCallResult<FormResponseData>> => {
|
||||||
const makeErrorMsg = (message: string) => intl.formatMessage(
|
const makeErrorMsg = (message: string) => intl.formatMessage(
|
||||||
@@ -161,7 +161,7 @@ function AppsFormContainer({
|
|||||||
)))};
|
)))};
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}, []);
|
}, [currentForm, setCurrentForm, context, serverUrl, intl]);
|
||||||
|
|
||||||
const performLookupCall = useCallback(async (field: AppField, values: AppFormValues, userInput: string): Promise<DoAppCallResult<AppLookupResponse>> => {
|
const performLookupCall = useCallback(async (field: AppField, values: AppFormValues, userInput: string): Promise<DoAppCallResult<AppLookupResponse>> => {
|
||||||
const makeErrorMsg = (message: string) => intl.formatMessage(
|
const makeErrorMsg = (message: string) => intl.formatMessage(
|
||||||
@@ -187,7 +187,7 @@ function AppsFormContainer({
|
|||||||
creq.query = userInput;
|
creq.query = userInput;
|
||||||
|
|
||||||
return doAppLookup<AppLookupResponse>(serverUrl, creq, intl);
|
return doAppLookup<AppLookupResponse>(serverUrl, creq, intl);
|
||||||
}, []);
|
}, [context, serverUrl, intl]);
|
||||||
|
|
||||||
if (!currentForm?.submit || !context) {
|
if (!currentForm?.submit || !context) {
|
||||||
return null;
|
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 {ScrollView, View} from 'react-native';
|
||||||
import {Edge, SafeAreaView} from 'react-native-safe-area-context';
|
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 ChannelInfoEnableCalls from '@calls/components/channel_info_enable_calls';
|
||||||
import ChannelActions from '@components/channel_actions';
|
import ChannelActions from '@components/channel_actions';
|
||||||
import {useTheme} from '@context/theme';
|
import {useTheme} from '@context/theme';
|
||||||
@@ -12,6 +13,7 @@ import useNavButtonPressed from '@hooks/navigation_button_pressed';
|
|||||||
import {dismissModal} from '@screens/navigation';
|
import {dismissModal} from '@screens/navigation';
|
||||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
import ChannelInfoAppBindings from './app_bindings';
|
||||||
import DestructiveOptions from './destructive_options';
|
import DestructiveOptions from './destructive_options';
|
||||||
import Extra from './extra';
|
import Extra from './extra';
|
||||||
import Options from './options';
|
import Options from './options';
|
||||||
@@ -54,11 +56,12 @@ const ChannelInfo = ({
|
|||||||
isCallsFeatureRestricted,
|
isCallsFeatureRestricted,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
const styles = getStyleSheet(theme);
|
const styles = getStyleSheet(theme);
|
||||||
const callsAvailable = isCallsEnabledInChannel && !isCallsFeatureRestricted;
|
const callsAvailable = isCallsEnabledInChannel && !isCallsFeatureRestricted;
|
||||||
|
|
||||||
const onPressed = useCallback(() => {
|
const onPressed = useCallback(() => {
|
||||||
dismissModal({componentId});
|
return dismissModal({componentId});
|
||||||
}, [componentId]);
|
}, [componentId]);
|
||||||
|
|
||||||
useNavButtonPressed(closeButtonId, componentId, onPressed, []);
|
useNavButtonPressed(closeButtonId, componentId, onPressed, []);
|
||||||
@@ -103,6 +106,11 @@ const ChannelInfo = ({
|
|||||||
<View style={styles.separator}/>
|
<View style={styles.separator}/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
<ChannelInfoAppBindings
|
||||||
|
channelId={channelId}
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
dismissChannelInfo={onPressed}
|
||||||
|
/>
|
||||||
<DestructiveOptions
|
<DestructiveOptions
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
componentId={componentId}
|
componentId={componentId}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {SafeAreaView} from 'react-native-safe-area-context';
|
|||||||
|
|
||||||
import {fetchChannels, searchChannels} from '@actions/remote/channel';
|
import {fetchChannels, searchChannels} from '@actions/remote/channel';
|
||||||
import {fetchProfiles, searchProfiles} from '@actions/remote/user';
|
import {fetchProfiles, searchProfiles} from '@actions/remote/user';
|
||||||
|
import {t} from '@app/i18n';
|
||||||
import FormattedText from '@components/formatted_text';
|
import FormattedText from '@components/formatted_text';
|
||||||
import SearchBar from '@components/search';
|
import SearchBar from '@components/search';
|
||||||
import {createProfilesSections} from '@components/user_list';
|
import {createProfilesSections} from '@components/user_list';
|
||||||
@@ -104,7 +105,7 @@ export type Props = {
|
|||||||
dataSource: string;
|
dataSource: string;
|
||||||
handleSelect: (opt: Selection) => void;
|
handleSelect: (opt: Selection) => void;
|
||||||
isMultiselect?: boolean;
|
isMultiselect?: boolean;
|
||||||
selected?: DialogOption[];
|
selected: SelectedDialogValue;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
teammateNameDisplay: string;
|
teammateNameDisplay: string;
|
||||||
componentId: string;
|
componentId: string;
|
||||||
@@ -390,18 +391,17 @@ function IntegrationSelector(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const multiselectItems: MultiselectSelectedMap = {};
|
const multiselectItems: MultiselectSelectedMap = {};
|
||||||
|
|
||||||
if (multiselectSelected) {
|
if (isMultiselect && Array.isArray(selected) && !([ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_CHANNELS].includes(dataSource))) {
|
||||||
return;
|
for (const value of selected) {
|
||||||
}
|
const option = options?.find((opt) => opt.value === value);
|
||||||
|
if (option) {
|
||||||
if (isMultiselect && selected && !([ViewConstants.DATA_SOURCE_USERS, ViewConstants.DATA_SOURCE_CHANNELS].includes(dataSource))) {
|
multiselectItems[value] = option;
|
||||||
selected.forEach((opt) => {
|
}
|
||||||
multiselectItems[opt.value] = opt;
|
}
|
||||||
});
|
|
||||||
|
|
||||||
setMultiselectSelected(multiselectItems);
|
setMultiselectSelected(multiselectItems);
|
||||||
}
|
}
|
||||||
}, [multiselectSelected]);
|
}, []);
|
||||||
|
|
||||||
// Renders
|
// Renders
|
||||||
const renderLoading = useCallback(() => {
|
const renderLoading = useCallback(() => {
|
||||||
@@ -413,19 +413,19 @@ function IntegrationSelector(
|
|||||||
switch (dataSource) {
|
switch (dataSource) {
|
||||||
case ViewConstants.DATA_SOURCE_USERS:
|
case ViewConstants.DATA_SOURCE_USERS:
|
||||||
text = {
|
text = {
|
||||||
id: intl.formatMessage({id: 'mobile.integration_selector.loading_users'}),
|
id: t('mobile.integration_selector.loading_users'),
|
||||||
defaultMessage: 'Loading Users...',
|
defaultMessage: 'Loading Users...',
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case ViewConstants.DATA_SOURCE_CHANNELS:
|
case ViewConstants.DATA_SOURCE_CHANNELS:
|
||||||
text = {
|
text = {
|
||||||
id: intl.formatMessage({id: 'mobile.integration_selector.loading_channels'}),
|
id: t('mobile.integration_selector.loading_channels'),
|
||||||
defaultMessage: 'Loading Channels...',
|
defaultMessage: 'Loading Channels...',
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
text = {
|
text = {
|
||||||
id: intl.formatMessage({id: 'mobile.integration_selector.loading_options'}),
|
id: t('mobile.integration_selector.loading_options'),
|
||||||
defaultMessage: 'Loading Options...',
|
defaultMessage: 'Loading Options...',
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -60,6 +60,15 @@ function DialogElement({
|
|||||||
onChange(name, newValue);
|
onChange(name, newValue);
|
||||||
}, [onChange, type, subtype]);
|
}, [onChange, type, subtype]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback((newValue: DialogOption | undefined) => {
|
||||||
|
if (!newValue) {
|
||||||
|
onChange(name, '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange(name, newValue.value);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'textarea':
|
case 'textarea':
|
||||||
@@ -87,12 +96,12 @@ function DialogElement({
|
|||||||
dataSource={dataSource}
|
dataSource={dataSource}
|
||||||
options={options}
|
options={options}
|
||||||
optional={optional}
|
optional={optional}
|
||||||
onSelected={handleChange}
|
onSelected={handleSelect}
|
||||||
helpText={helpText}
|
helpText={helpText}
|
||||||
errorText={errorText}
|
errorText={errorText}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
showRequiredAsterisk={true}
|
showRequiredAsterisk={true}
|
||||||
selected={value as string | string[]}
|
selected={value as string}
|
||||||
roundedBorders={false}
|
roundedBorders={false}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ function valuesReducer(state: Values, action: ValuesAction) {
|
|||||||
}
|
}
|
||||||
return {...state, [action.name]: action.value};
|
return {...state, [action.name]: action.value};
|
||||||
}
|
}
|
||||||
function initValues(elements: DialogElement[]) {
|
function initValues(elements?: DialogElement[]) {
|
||||||
const values: Values = {};
|
const values: Values = {};
|
||||||
elements.forEach((e) => {
|
elements?.forEach((e) => {
|
||||||
if (e.type === 'bool') {
|
if (e.type === 'bool') {
|
||||||
values[e.name] = (e.default === true || String(e.default).toLowerCase() === 'true');
|
values[e.name] = (e.default === true || String(e.default).toLowerCase() === 'true');
|
||||||
} else if (e.default) {
|
} else if (e.default) {
|
||||||
@@ -115,11 +115,11 @@ function InteractiveDialog({
|
|||||||
undefined,
|
undefined,
|
||||||
submitLabel || intl.formatMessage({id: 'interactive_dialog.submit', defaultMessage: 'Submit'}),
|
submitLabel || intl.formatMessage({id: 'interactive_dialog.submit', defaultMessage: 'Submit'}),
|
||||||
);
|
);
|
||||||
base.enabled = submitting;
|
base.enabled = !submitting;
|
||||||
base.showAsAction = 'always';
|
base.showAsAction = 'always';
|
||||||
base.color = theme.sidebarHeaderTextColor;
|
base.color = theme.sidebarHeaderTextColor;
|
||||||
return base;
|
return base;
|
||||||
}, [theme.sidebarHeaderTextColor, intl]);
|
}, [intl, submitting, theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setButtons(componentId, {
|
setButtons(componentId, {
|
||||||
@@ -132,7 +132,7 @@ function InteractiveDialog({
|
|||||||
setButtons(componentId, {
|
setButtons(componentId, {
|
||||||
leftButtons: [makeCloseButton(icon)],
|
leftButtons: [makeCloseButton(icon)],
|
||||||
});
|
});
|
||||||
}, [theme.sidebarHeaderTextColor]);
|
}, [componentId, theme]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
const newErrors: Errors = {};
|
const newErrors: Errors = {};
|
||||||
@@ -147,7 +147,7 @@ function InteractiveDialog({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setErrors(hasErrors ? errors : emptyErrorsState);
|
setErrors(hasErrors ? newErrors : emptyErrorsState);
|
||||||
|
|
||||||
if (hasErrors) {
|
if (hasErrors) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -755,8 +755,8 @@ export async function openAsBottomSheet({closeButtonId, screen, theme, title, pr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const showAppForm = async (form: AppForm) => {
|
export const showAppForm = async (form: AppForm, context: AppContext) => {
|
||||||
const passProps = {form};
|
const passProps = {form, context};
|
||||||
showModal(Screens.APPS_FORM, form.title || '', passProps);
|
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 {switchMap} from 'rxjs/operators';
|
||||||
|
|
||||||
import {General, Permissions, Post, Screens} from '@constants';
|
import {General, Permissions, Post, Screens} from '@constants';
|
||||||
|
import {AppBindingLocations} from '@constants/apps';
|
||||||
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
|
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
|
||||||
|
import AppsManager from '@managers/apps_manager';
|
||||||
import {observePost, observePostSaved} from '@queries/servers/post';
|
import {observePost, observePostSaved} from '@queries/servers/post';
|
||||||
import {observePermissionForChannel, observePermissionForPost} from '@queries/servers/role';
|
import {observePermissionForChannel, observePermissionForPost} from '@queries/servers/role';
|
||||||
import {observeConfigBooleanValue, observeConfigIntValue, observeConfigValue, observeLicense} from '@queries/servers/system';
|
import {observeConfigBooleanValue, observeConfigIntValue, observeConfigValue, observeLicense} from '@queries/servers/system';
|
||||||
@@ -33,6 +35,7 @@ type EnhancedProps = WithDatabaseArgs & {
|
|||||||
post: PostModel;
|
post: PostModel;
|
||||||
showAddReaction: boolean;
|
showAddReaction: boolean;
|
||||||
location: string;
|
location: string;
|
||||||
|
serverUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const observeCanEditPost = (database: Database, isOwner: boolean, post: PostModel, postEditTimeLimit: number, isLicensed: boolean, channel: ChannelModel, user: UserModel) => {
|
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) => {
|
const withPost = withObservables([], ({post, database}: {post: Post | PostModel} & WithDatabaseArgs) => {
|
||||||
let id: string | undefined;
|
let id: string | undefined;
|
||||||
let combinedPost: Observable<Post | PostModel | undefined> = of$(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);
|
const systemPostIds = getPostIdsForCombinedUserActivityPost(post.id);
|
||||||
id = systemPostIds?.pop();
|
id = systemPostIds?.pop();
|
||||||
combinedPost = of$(post);
|
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 channel = post.channel.observe();
|
||||||
const channelIsArchived = channel.pipe(switchMap((ch: ChannelModel) => of$(ch.deleteAt !== 0)));
|
const channelIsArchived = channel.pipe(switchMap((ch: ChannelModel) => of$(ch.deleteAt !== 0)));
|
||||||
const currentUser = observeCurrentUser(database);
|
const currentUser = observeCurrentUser(database);
|
||||||
@@ -78,6 +81,7 @@ const enhanced = withObservables([], ({combinedPost, post, showAddReaction, loca
|
|||||||
const allowEditPost = observeConfigValue(database, 'AllowEditPost');
|
const allowEditPost = observeConfigValue(database, 'AllowEditPost');
|
||||||
const serverVersion = observeConfigValue(database, 'Version');
|
const serverVersion = observeConfigValue(database, 'Version');
|
||||||
const postEditTimeLimit = observeConfigIntValue(database, 'PostEditTimeLimit', -1);
|
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 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)));
|
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,
|
canEdit,
|
||||||
post,
|
post,
|
||||||
thread,
|
thread,
|
||||||
|
bindings,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export default withDatabase(withPost(enhanced(PostOptions)));
|
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.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {useManagedConfig} from '@mattermost/react-native-emm';
|
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 {CopyPermalinkOption, FollowThreadOption, ReplyOption, SaveOption} from '@components/common_post_options';
|
||||||
import {ITEM_HEIGHT} from '@components/option_item';
|
import {ITEM_HEIGHT} from '@components/option_item';
|
||||||
@@ -12,6 +12,7 @@ import BottomSheet from '@screens/bottom_sheet';
|
|||||||
import {dismissModal} from '@screens/navigation';
|
import {dismissModal} from '@screens/navigation';
|
||||||
import {isSystemMessage} from '@utils/post';
|
import {isSystemMessage} from '@utils/post';
|
||||||
|
|
||||||
|
import AppBindingsPostOptions from './options/app_bindings_post_option';
|
||||||
import CopyTextOption from './options/copy_text_option';
|
import CopyTextOption from './options/copy_text_option';
|
||||||
import DeletePostOption from './options/delete_post_option';
|
import DeletePostOption from './options/delete_post_option';
|
||||||
import EditOption from './options/edit_option';
|
import EditOption from './options/edit_option';
|
||||||
@@ -37,12 +38,14 @@ type PostOptionsProps = {
|
|||||||
post: PostModel;
|
post: PostModel;
|
||||||
thread?: ThreadModel;
|
thread?: ThreadModel;
|
||||||
componentId: string;
|
componentId: string;
|
||||||
|
bindings: AppBinding[];
|
||||||
|
serverUrl: string;
|
||||||
};
|
};
|
||||||
const PostOptions = ({
|
const PostOptions = ({
|
||||||
canAddReaction, canDelete, canEdit,
|
canAddReaction, canDelete, canEdit,
|
||||||
canMarkAsUnread, canPin, canReply,
|
canMarkAsUnread, canPin, canReply,
|
||||||
combinedPost, componentId, isSaved,
|
combinedPost, componentId, isSaved,
|
||||||
sourceScreen, post, thread,
|
sourceScreen, post, thread, bindings, serverUrl,
|
||||||
}: PostOptionsProps) => {
|
}: PostOptionsProps) => {
|
||||||
const managedConfig = useManagedConfig<ManagedConfig>();
|
const managedConfig = useManagedConfig<ManagedConfig>();
|
||||||
|
|
||||||
@@ -58,6 +61,7 @@ const PostOptions = ({
|
|||||||
const canCopyText = canCopyPermalink && post.message;
|
const canCopyText = canCopyPermalink && post.message;
|
||||||
|
|
||||||
const shouldRenderFollow = !(sourceScreen !== Screens.CHANNEL || !thread);
|
const shouldRenderFollow = !(sourceScreen !== Screens.CHANNEL || !thread);
|
||||||
|
const shouldShowBindings = bindings.length > 0 && !isSystemPost;
|
||||||
|
|
||||||
const snapPoints = [
|
const snapPoints = [
|
||||||
canAddReaction, canCopyPermalink, canCopyText,
|
canAddReaction, canCopyPermalink, canCopyText,
|
||||||
@@ -73,64 +77,83 @@ const PostOptions = ({
|
|||||||
{canAddReaction && <ReactionBar postId={post.id}/>}
|
{canAddReaction && <ReactionBar postId={post.id}/>}
|
||||||
{canReply && sourceScreen !== Screens.THREAD && <ReplyOption post={post}/>}
|
{canReply && sourceScreen !== Screens.THREAD && <ReplyOption post={post}/>}
|
||||||
{shouldRenderFollow &&
|
{shouldRenderFollow &&
|
||||||
<FollowThreadOption thread={thread}/>
|
<FollowThreadOption thread={thread}/>
|
||||||
}
|
}
|
||||||
{canMarkAsUnread && !isSystemPost &&
|
{canMarkAsUnread && !isSystemPost &&
|
||||||
<MarkAsUnreadOption
|
<MarkAsUnreadOption
|
||||||
post={post}
|
post={post}
|
||||||
sourceScreen={sourceScreen}
|
sourceScreen={sourceScreen}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{canCopyPermalink &&
|
{canCopyPermalink &&
|
||||||
<CopyPermalinkOption
|
<CopyPermalinkOption
|
||||||
post={post}
|
post={post}
|
||||||
sourceScreen={sourceScreen}
|
sourceScreen={sourceScreen}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{!isSystemPost &&
|
{!isSystemPost &&
|
||||||
<SaveOption
|
<SaveOption
|
||||||
isSaved={isSaved}
|
isSaved={isSaved}
|
||||||
postId={post.id}
|
postId={post.id}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{Boolean(canCopyText && post.message) &&
|
{Boolean(canCopyText && post.message) &&
|
||||||
<CopyTextOption
|
<CopyTextOption
|
||||||
postMessage={post.message}
|
postMessage={post.message}
|
||||||
sourceScreen={sourceScreen}
|
sourceScreen={sourceScreen}
|
||||||
/>}
|
/>}
|
||||||
{canPin &&
|
{canPin &&
|
||||||
<PinChannelOption
|
<PinChannelOption
|
||||||
isPostPinned={post.isPinned}
|
isPostPinned={post.isPinned}
|
||||||
postId={post.id}
|
postId={post.id}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{canEdit &&
|
{canEdit &&
|
||||||
<EditOption
|
<EditOption
|
||||||
post={post}
|
post={post}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
{canDelete &&
|
{canDelete &&
|
||||||
<DeletePostOption
|
<DeletePostOption
|
||||||
combinedPost={combinedPost}
|
combinedPost={combinedPost}
|
||||||
post={post}
|
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 (
|
return (
|
||||||
<BottomSheet
|
<BottomSheet
|
||||||
renderContent={renderContent}
|
renderContent={renderContent}
|
||||||
closeButtonId={POST_OPTIONS_BUTTON}
|
closeButtonId={POST_OPTIONS_BUTTON}
|
||||||
componentId={Screens.POST_OPTIONS}
|
componentId={Screens.POST_OPTIONS}
|
||||||
initialSnapIndex={0}
|
initialSnapIndex={initialSnapIndex}
|
||||||
snapPoints={[((snapPoints + additionalSnapPoints) * ITEM_HEIGHT), 10]}
|
snapPoints={finalSnapPoints}
|
||||||
testID='post_options'
|
testID='post_options'
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PostOptions;
|
export default React.memo(PostOptions);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@constants/apps';
|
import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@constants/apps';
|
||||||
|
|
||||||
|
import {generateId} from './general';
|
||||||
|
|
||||||
export function cleanBinding(binding: AppBinding, topLocation: string): AppBinding {
|
export function cleanBinding(binding: AppBinding, topLocation: string): AppBinding {
|
||||||
return cleanBindingRec(binding, topLocation, 0);
|
return cleanBindingRec(binding, topLocation, 0);
|
||||||
}
|
}
|
||||||
@@ -23,6 +25,10 @@ function cleanBindingRec(binding: AppBinding, topLocation: string, depth: number
|
|||||||
b.label = b.location || '';
|
b.label = b.location || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!b.location) {
|
||||||
|
b.location = generateId();
|
||||||
|
}
|
||||||
|
|
||||||
b.location = binding.location + '/' + b.location;
|
b.location = binding.location + '/' + b.location;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
|
|||||||
4
types/api/apps.d.ts
vendored
4
types/api/apps.d.ts
vendored
@@ -24,7 +24,7 @@ type AppsState = {
|
|||||||
|
|
||||||
type AppBinding = {
|
type AppBinding = {
|
||||||
app_id: string;
|
app_id: string;
|
||||||
location?: string;
|
location: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
|
||||||
// Label is the (usually short) primary text to display at the location.
|
// Label is the (usually short) primary text to display at the location.
|
||||||
@@ -148,7 +148,7 @@ type AppForm = {
|
|||||||
depends_on?: string[];
|
depends_on?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type AppFormValue = string | boolean | number | AppSelectOption | AppSelectOption[];
|
type AppFormValue = string | boolean | number | AppSelectOption | AppSelectOption[] | null;
|
||||||
type AppFormValues = {[name: string]: AppFormValue};
|
type AppFormValues = {[name: string]: AppFormValue};
|
||||||
|
|
||||||
type AppSelectOption = {
|
type AppSelectOption = {
|
||||||
|
|||||||
4
types/api/integrations.d.ts
vendored
4
types/api/integrations.d.ts
vendored
@@ -56,6 +56,10 @@ type DialogOption = {
|
|||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SelectedDialogOption = DialogOption | DialogOption[] | undefined;
|
||||||
|
|
||||||
|
type SelectedDialogValue = string | string[] | undefined;
|
||||||
|
|
||||||
type DialogElement = {
|
type DialogElement = {
|
||||||
display_name: string;
|
display_name: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user