Fetch and store bindings (#6660)

* Fetch and store bindings

* Fix navigate and form opening
This commit is contained in:
Daniel Espino García
2022-10-28 23:08:28 +02:00
committed by GitHub
parent b3daa5e3f4
commit bb0322321e
27 changed files with 371 additions and 130 deletions

View File

@@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchPostAuthors} from '@actions/remote/post';
import {ActionType, Post} from '@constants';
import DatabaseManager from '@database/manager';
import {getPostById, prepareDeletePost, queryPostsById} from '@queries/servers/post';
@@ -78,7 +79,7 @@ export const sendEphemeralPost = async (serverUrl: string, message: string, chan
user_id: authorId,
channel_id: channeId,
message,
type: Post.POST_TYPES.EPHEMERAL_ADD_TO_CHANNEL as PostType,
type: Post.POST_TYPES.EPHEMERAL as PostType,
create_at: timestamp,
edit_at: 0,
update_at: timestamp,
@@ -94,6 +95,7 @@ export const sendEphemeralPost = async (serverUrl: string, message: string, chan
props: {},
} as Post;
await fetchPostAuthors(serverUrl, [post], false);
await operator.handlePosts({
actionType: ActionType.POSTS.RECEIVED_NEW,
order: [post.id],

View File

@@ -13,6 +13,7 @@ import {Events, General, Preferences, Screens} from '@constants';
import DatabaseManager from '@database/manager';
import {privateChannelJoinPrompt} from '@helpers/api/channel';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import AppsManager from '@managers/apps_manager';
import NetworkManager from '@managers/network_manager';
import {prepareMyChannelsForTeam, getChannelById, getChannelByName, getMyChannel, getChannelInfo, queryMyChannelSettingsByIds, getMembersCountByChannelsId} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
@@ -1023,6 +1024,10 @@ export async function switchToChannelById(serverUrl: string, channelId: string,
DeviceEventEmitter.emit(Events.CHANNEL_SWITCH, false);
if (await AppsManager.isAppsEnabled(serverUrl)) {
AppsManager.fetchBindings(serverUrl, channelId);
}
return {};
}

View File

@@ -4,16 +4,20 @@
import {IntlShape} from 'react-intl';
import {Alert} from 'react-native';
import {doAppSubmit, postEphemeralCallResponseForCommandArgs} from '@actions/remote/apps';
import {showPermalink} from '@actions/remote/permalink';
import {Client} from '@client/rest';
import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser';
import {AppCallResponseTypes} from '@constants/apps';
import DeepLinkType from '@constants/deep_linking';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import IntegrationsManager from '@managers/integrations_manager';
import NetworkManager from '@managers/network_manager';
import {getChannelById} from '@queries/servers/channel';
import {getConfig, getCurrentTeamId} from '@queries/servers/system';
import {getTeammateNameDisplay, queryUsersByUsername} from '@queries/servers/user';
import {showModal} from '@screens/navigation';
import {showAppForm, showModal} from '@screens/navigation';
import * as DraftUtils from '@utils/draft';
import {matchDeepLink, tryOpenURL} from '@utils/url';
import {displayUsername} from '@utils/user';
@@ -22,7 +26,7 @@ import {makeDirectChannel, switchToChannelById, switchToChannelByName} from './c
import type {DeepLinkChannel, DeepLinkPermalink, DeepLinkDM, DeepLinkGM, DeepLinkPlugin} from '@typings/launch';
export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string) => {
export const executeCommand = async (serverUrl: string, intl: IntlShape, message: string, channelId: string, rootId?: string): Promise<{data?: CommandResponse; error?: string | {message: string}}> => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
@@ -35,14 +39,6 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message
return {error: error as ClientErrorProps};
}
// const config = await queryConfig(operator.database)
// if (config.FeatureFlagAppsEnabled) {
// const parser = new AppCommandParser(serverUrl, intl, channelId, rootId);
// if (parser.isAppCommand(msg)) {
// return executeAppCommand(serverUrl, intl, parser);
// }
// }
const channel = await getChannelById(operator.database, channelId);
const teamId = channel?.teamId || (await getCurrentTeamId(operator.database));
@@ -53,6 +49,14 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message
parent_id: rootId,
};
const appsEnabled = await AppsManager.isAppsEnabled(serverUrl);
if (appsEnabled) {
const parser = new AppCommandParser(serverUrl, intl, channelId, teamId, rootId);
if (parser.isAppCommand(message)) {
return executeAppCommand(serverUrl, intl, parser, message, args);
}
}
let msg = filterEmDashForCommand(message);
let cmdLength = msg.indexOf(' ');
@@ -81,44 +85,51 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message
return {data};
};
// TODO https://mattermost.atlassian.net/browse/MM-41234
// const executeAppCommand = (serverUrl: string, intl: IntlShape, parser: any) => {
// const {call, errorMessage} = await parser.composeCallFromCommand(msg);
// const createErrorMessage = (errMessage: string) => {
// return {error: {message: errMessage}};
// };
const executeAppCommand = async (serverUrl: string, intl: IntlShape, parser: AppCommandParser, msg: string, args: CommandArgs) => {
const {creq, errorMessage} = await parser.composeCommandSubmitCall(msg);
const createErrorMessage = (errMessage: string) => {
return {error: {message: errMessage}};
};
// if (!call) {
// return createErrorMessage(errorMessage!);
// }
if (!creq) {
return createErrorMessage(errorMessage!);
}
// const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intl));
// if (res.error) {
// const errorResponse = res.error as AppCallResponse;
// return createErrorMessage(errorResponse.error || intl.formatMessage({
// id: 'apps.error.unknown',
// defaultMessage: 'Unknown error.',
// }));
// }
// const callResp = res.data as AppCallResponse;
// switch (callResp.type) {
// case AppCallResponseTypes.OK:
// if (callResp.markdown) {
// dispatch(postEphemeralCallResponseForCommandArgs(callResp, callResp.markdown, args));
// }
// return {data: {}};
// case AppCallResponseTypes.FORM:
// case AppCallResponseTypes.NAVIGATE:
// return {data: {}};
// default:
// return createErrorMessage(intl.formatMessage({
// id: 'apps.error.responses.unknown_type',
// defaultMessage: 'App response type not supported. Response type: {type}.',
// }, {
// type: callResp.type,
// }));
// }
// };
const res = await doAppSubmit(serverUrl, creq, intl);
if (res.error) {
const errorResponse = res.error as AppCallResponse;
return createErrorMessage(errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
}));
}
const callResp = res.data as AppCallResponse;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.text) {
postEphemeralCallResponseForCommandArgs(serverUrl, callResp, callResp.text, args);
}
return {data: {}};
case AppCallResponseTypes.FORM:
if (callResp.form) {
showAppForm(callResp.form);
}
return {data: {}};
case AppCallResponseTypes.NAVIGATE:
if (callResp.navigate_to_url) {
handleGotoLocation(serverUrl, intl, callResp.navigate_to_url);
}
return {data: {}};
default:
return createErrorMessage(intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
}));
}
};
const filterEmDashForCommand = (command: string): string => {
return command.replace(/\u2014/g, '--');

View File

@@ -6,9 +6,10 @@ import {fetchPostThread} from '@actions/remote/post';
import {General} from '@constants';
import DatabaseManager from '@database/manager';
import PushNotifications from '@init/push_notifications';
import AppsManager from '@managers/apps_manager';
import NetworkManager from '@managers/network_manager';
import {getPostById} from '@queries/servers/post';
import {getCommonSystemValues, getCurrentTeamId} from '@queries/servers/system';
import {getCommonSystemValues, getCurrentChannelId, getCurrentTeamId} from '@queries/servers/system';
import {getIsCRTEnabled, getNewestThreadInTeam, getThreadById} from '@queries/servers/thread';
import {getCurrentUser} from '@queries/servers/user';
@@ -56,6 +57,20 @@ export const fetchAndSwitchToThread = async (serverUrl: string, rootId: string,
await switchToThread(serverUrl, rootId, isFromNotification);
if (await AppsManager.isAppsEnabled(serverUrl)) {
// Getting the post again in case we didn't had it at the beginning
const post = await getPostById(database, rootId);
const currentChannelId = await getCurrentChannelId(database);
if (post) {
if (currentChannelId === post?.channelId) {
AppsManager.copyMainBindingsToThread(serverUrl, currentChannelId);
} else {
AppsManager.fetchBindings(serverUrl, post.channelId, true);
}
}
}
return {};
};

View File

@@ -27,6 +27,7 @@ import {isSupportedServerCalls} from '@calls/utils';
import {Events, Screens, WebsocketEvents} from '@constants';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import AppsManager from '@managers/apps_manager';
import {getActiveServerUrl, queryActiveServer} from '@queries/app/servers';
import {getCurrentChannel} from '@queries/servers/channel';
import {
@@ -184,7 +185,7 @@ async function doReconnect(serverUrl: string) {
loadConfigAndCalls(serverUrl, currentUserId);
}
// https://mattermost.atlassian.net/browse/MM-41520
AppsManager.refreshAppBindings(serverUrl);
}
export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {

View File

@@ -189,6 +189,7 @@ const Autocomplete = ({
nestedScrollEnabled={nestedScrollEnabled}
channelId={channelId}
rootId={rootId}
isAppsEnabled={isAppsEnabled}
/>
}
{/* {(isSearch && enableDateSuggestion) &&

View File

@@ -1,17 +1,18 @@
// 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 {observeConfigBooleanValue} from '@queries/servers/system';
import AppsManager from '@managers/apps_manager';
import Autocomplete from './autocomplete';
import type {WithDatabaseArgs} from '@typings/database/database';
type OwnProps = {
serverUrl?: string;
}
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'),
const enhanced = withObservables(['serverUrl'], ({serverUrl}: OwnProps) => ({
isAppsEnabled: serverUrl ? AppsManager.observeIsAppsEnabled(serverUrl) : false,
}));
export default withDatabase(enhanced(Autocomplete));
export default enhanced(Autocomplete);

View File

@@ -7,9 +7,9 @@ import {IntlShape} from 'react-intl';
import {doAppFetchForm, doAppLookup} from '@actions/remote/apps';
import {fetchChannelById, fetchChannelByName, searchChannels} from '@actions/remote/channel';
import {fetchUsersByIds, fetchUsersByUsernames, searchUsers} from '@actions/remote/user';
import {AppCallResponseTypes, AppFieldTypes, COMMAND_SUGGESTION_ERROR} from '@constants/apps';
import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes, COMMAND_SUGGESTION_ERROR} from '@constants/apps';
import DatabaseManager from '@database/manager';
import IntegrationsManager from '@managers/integrations_manager';
import AppsManager from '@managers/apps_manager';
import {getChannelById, getChannelByName} from '@queries/servers/channel';
import {getCurrentTeamId} from '@queries/servers/system';
import {getUserById, queryUsersByUsername} from '@queries/servers/user';
@@ -173,7 +173,7 @@ export class ParsedCommand {
}
case ParseState.EndCommand: {
const binding = bindings.find(this.findBindings);
const binding = bindings.find(this.findBindings, this);
if (!binding) {
// gone as far as we could, this token doesn't match a sub-command.
// return the state from the last matching binding
@@ -856,15 +856,13 @@ export class AppCommandParser {
private teamID: string;
private rootPostID?: string;
private intl: IntlShape;
private theme: Theme;
constructor(serverUrl: string, intl: IntlShape, channelID: string, teamID = '', rootPostID = '', theme: Theme) {
constructor(serverUrl: string, intl: IntlShape, channelID: string, teamID = '', rootPostID = '') {
this.serverUrl = serverUrl;
this.channelID = channelID;
this.rootPostID = rootPostID;
this.teamID = teamID;
this.intl = intl;
this.theme = theme;
// We are making the assumption the database is always present at this level.
// This assumption may not be correct. Please review.
@@ -1022,7 +1020,6 @@ export class AppCommandParser {
const result: AutocompleteSuggestion[] = [];
const bindings = this.getCommandBindings();
for (const binding of bindings) {
let base = binding.label;
if (!base) {
@@ -1435,11 +1432,8 @@ export class AppCommandParser {
// getCommandBindings returns the commands in the redux store.
// They are grouped by app id since each app has one base command
private getCommandBindings = (): AppBinding[] => {
const manager = IntegrationsManager.getManager(this.serverUrl);
if (this.rootPostID) {
return manager.getRHSCommandBindings();
}
return manager.getCommandBindings();
const bindings = 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
@@ -1538,10 +1532,9 @@ export class AppCommandParser {
};
public getSubmittableForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
const manager = IntegrationsManager.getManager(this.serverUrl);
const rootID = this.rootPostID || '';
const key = `${this.channelID}-${rootID}-${location}`;
const submittableForm = this.rootPostID ? manager.getAppRHSCommandForm(key) : manager.getAppCommandForm(key);
const submittableForm = AppsManager.getCommandForm(this.serverUrl, key, Boolean(this.rootPostID));
if (submittableForm) {
return {form: submittableForm};
}
@@ -1555,11 +1548,7 @@ export class AppCommandParser {
const context = await this.getAppContext(binding);
const fetched = await this.fetchSubmittableForm(binding.form.source, context);
if (fetched?.form) {
if (this.rootPostID) {
manager.setAppRHSCommandForm(key, fetched.form);
} else {
manager.setAppCommandForm(key, fetched.form);
}
AppsManager.setCommandForm(this.serverUrl, key, fetched.form, Boolean(this.rootPostID));
}
return fetched;
};

View File

@@ -10,7 +10,6 @@ import AtMentionItem from '@components/autocomplete/at_mention_item';
import ChannelMentionItem from '@components/autocomplete/channel_mention_item';
import {COMMAND_SUGGESTION_CHANNEL, COMMAND_SUGGESTION_USER} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import analytics from '@managers/analytics';
import {AppCommandParser, ExtendedAutocompleteSuggestion} from '../app_command_parser/app_command_parser';
@@ -48,9 +47,8 @@ const AppSlashSuggestion = ({
listStyle,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const serverUrl = useServerUrl();
const appCommandParser = useRef<AppCommandParser>(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme));
const appCommandParser = useRef<AppCommandParser>(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId));
const [dataSource, setDataSource] = useState<AutocompleteSuggestion[]>(emptySuggestonList);
const active = isAppsEnabled && Boolean(dataSource.length);
const mounted = useRef(false);

View File

@@ -4,7 +4,7 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import {observeConfigBooleanValue, observeCurrentTeamId} from '@queries/servers/system';
import {observeCurrentTeamId} from '@queries/servers/system';
import SlashSuggestion from './slash_suggestion';
@@ -12,7 +12,6 @@ import type {WithDatabaseArgs} from '@typings/database/database';
const enhanced = withObservables([], ({database}: WithDatabaseArgs) => ({
currentTeamId: observeCurrentTeamId(database),
isAppsEnabled: observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled'),
}));
export default withDatabase(enhanced(SlashSuggestion));

View File

@@ -13,7 +13,6 @@ import {
import {fetchSuggestions} from '@actions/remote/command';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import analytics from '@managers/analytics';
import IntegrationsManager from '@managers/integrations_manager';
@@ -78,9 +77,8 @@ const SlashSuggestion = ({
listStyle,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
const serverUrl = useServerUrl();
const appCommandParser = useRef<AppCommandParser>(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId, theme));
const appCommandParser = useRef<AppCommandParser>(new AppCommandParser(serverUrl, intl, channelId, currentTeamId, rootId));
const mounted = useRef(false);
const [noResultsTerm, setNoResultsTerm] = useState<string|null>(null);

View File

@@ -8,6 +8,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Autocomplete from '@components/autocomplete';
import {View as ViewConstants} from '@constants';
import {useServerUrl} from '@context/server';
import {useAutocompleteDefaultAnimatedValues} from '@hooks/autocomplete';
import {useIsTablet, useKeyboardHeight} from '@hooks/device';
import {useDefaultHeaderHeight} from '@hooks/header';
@@ -62,6 +63,7 @@ function PostDraft({
const keyboardHeight = useKeyboardHeight(keyboardTracker);
const insets = useSafeAreaInsets();
const headerHeight = useDefaultHeaderHeight();
const serverUrl = useServerUrl();
// Update draft in case we switch channels or threads
useEffect(() => {
@@ -127,6 +129,7 @@ function PostDraft({
hasFilesAttached={Boolean(files?.length)}
inPost={true}
availableSpace={animatedAutocompleteAvailableSpace}
serverUrl={serverUrl}
/>
) : null;

View File

@@ -20,7 +20,7 @@ import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
type AvatarProps = {
author: UserModel;
author?: UserModel;
enablePostIconOverride?: boolean;
isAutoReponse: boolean;
location: string;
@@ -91,6 +91,9 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}:
}
const openUserProfile = preventDoubleTap(() => {
if (!author) {
return;
}
const screen = Screens.USER_PROFILE;
const title = intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'});
const closeButtonId = 'close-user-profile';
@@ -112,8 +115,8 @@ const Avatar = ({author, enablePostIconOverride, isAutoReponse, location, post}:
author={author}
size={ViewConstant.PROFILE_PICTURE_SIZE}
iconSize={24}
showStatus={!isAutoReponse || author.isBot}
testID={`post_avatar.${author.id}.profile_picture`}
showStatus={!isAutoReponse || author?.isBot}
testID={`post_avatar.${author?.id}.profile_picture`}
/>
);

View File

@@ -4,6 +4,7 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import enhance from '@nozbe/with-observables';
import {observePostAuthor} from '@queries/servers/post';
import {observeConfigBooleanValue} from '@queries/servers/system';
import Avatar from './avatar';
@@ -15,7 +16,7 @@ const withPost = enhance(['post'], ({database, post}: {post: PostModel} & WithDa
const enablePostIconOverride = observeConfigBooleanValue(database, 'EnablePostIconOverride');
return {
author: post.author.observe(),
author: observePostAuthor(database, post),
enablePostIconOverride,
};
});

View File

@@ -9,9 +9,11 @@ import Button from 'react-native-button';
import {map} from 'rxjs/operators';
import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/remote/apps';
import {handleGotoLocation} from '@actions/remote/command';
import {AppBindingLocations, AppCallResponseTypes} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {observeCurrentTeamId} from '@queries/servers/system';
import {showAppForm} from '@screens/navigation';
import {createCallContext} from '@utils/apps';
import {getStatusColors} from '@utils/message_attachment_colors';
import {preventDoubleTap} from '@utils/tap';
@@ -97,7 +99,14 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) =>
}
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({

View File

@@ -8,10 +8,12 @@ import {useIntl} from 'react-intl';
import {map} from 'rxjs/operators';
import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/remote/apps';
import {handleGotoLocation} from '@actions/remote/command';
import AutocompleteSelector from '@components/autocomplete_selector';
import {AppBindingLocations, AppCallResponseTypes} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {observeCurrentTeamId} from '@queries/servers/system';
import {showAppForm} from '@screens/navigation';
import {createCallContext} from '@utils/apps';
import {logDebug} from '@utils/log';
@@ -70,7 +72,14 @@ const MenuBinding = ({binding, currentTeamId, post, teamID}: Props) => {
}
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({

View File

@@ -31,7 +31,7 @@ const contentType: Record<string, string> = {
const Content = ({isReplyPost, layoutWidth, location, post, theme}: ContentProps) => {
let type: string | undefined = post.metadata?.embeds?.[0].type;
if (!type && post.props?.attachments?.length) {
if (!type && post.props?.app_bindings?.length) {
type = contentType.app_bindings;
}

View File

@@ -23,7 +23,7 @@ import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
type HeaderProps = {
author: UserModel;
author?: UserModel;
commentCount: number;
currentUser: UserModel;
enablePostUsernameOverride: boolean;
@@ -92,7 +92,7 @@ const Header = (props: HeaderProps) => {
const customStatusExpired = isCustomStatusExpired(author);
const showCustomStatusEmoji = Boolean(
isCustomStatusEnabled && displayName && customStatus &&
!(isSystemPost || author.isBot || isAutoResponse || isWebHook),
!(isSystemPost || author?.isBot || isAutoResponse || isWebHook),
);
return (
@@ -121,8 +121,8 @@ const Header = (props: HeaderProps) => {
{(!isSystemPost || isAutoResponse) &&
<HeaderTag
isAutoResponder={isAutoResponse}
isAutomation={isWebHook || author.isBot}
isGuest={author.isGuest}
isAutomation={isWebHook || author?.isBot}
isGuest={author?.isGuest}
/>
}
<FormattedTime

View File

@@ -8,7 +8,7 @@ import {map, switchMap} from 'rxjs/operators';
import {Preferences} from '@constants';
import {getPreferenceAsBool} from '@helpers/api/preference';
import {queryPostReplies} from '@queries/servers/post';
import {observePostAuthor, queryPostReplies} from '@queries/servers/post';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeConfigBooleanValue} from '@queries/servers/system';
import {observeTeammateNameDisplay} from '@queries/servers/user';
@@ -28,7 +28,7 @@ const withHeaderProps = withObservables(
({post, database, differentThreadSequence}: WithDatabaseArgs & HeaderInputProps) => {
const preferences = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS).
observeWithColumns(['value']);
const author = post.author.observe();
const author = observePostAuthor(database, post);
const enablePostUsernameOverride = observeConfigBooleanValue(database, 'EnablePostUsernameOverride');
const isTimezoneEnabled = observeConfigBooleanValue(database, 'ExperimentalTimezone');
const isMilitaryTime = preferences.pipe(map((prefs) => getPreferenceAsBool(prefs, Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', false)));

View File

@@ -4,12 +4,12 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React from 'react';
import {of as of$, combineLatest} from 'rxjs';
import {of as of$, combineLatest, Observable} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import {Permissions, Preferences} from '@constants';
import {queryAllCustomEmojis} from '@queries/servers/custom_emoji';
import {queryPostsBetween} from '@queries/servers/post';
import {observePostAuthor, queryPostsBetween} from '@queries/servers/post';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {observeCanManageChannelMembers, observePermissionForPost} from '@queries/servers/role';
import {observeIsPostPriorityEnabled, observeConfigBooleanValue} from '@queries/servers/system';
@@ -101,7 +101,7 @@ const withPost = withObservables(
let isLastReply = of$(true);
let isPostAddChannelMember = of$(false);
const isOwner = currentUser.id === post.userId;
const author = post.userId ? post.author.observe() : of$(null);
const author: Observable<UserModel | undefined | null> = observePostAuthor(database, post);
const canDelete = observePermissionForPost(database, post, currentUser, isOwner ? Permissions.DELETE_POST : Permissions.DELETE_OTHERS_POSTS, false);
const isEphemeral = of$(isPostEphemeral(post));
const isSaved = queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, post.id).

View File

@@ -144,13 +144,13 @@ const Post = ({
}
const isValidSystemMessage = isAutoResponder || !isSystemPost;
if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) {
if (isEphemeral || hasBeenDeleted) {
removePost(serverUrl, post);
} else if (isValidSystemMessage && !hasBeenDeleted && !isPendingOrFailed) {
if ([Screens.CHANNEL, Screens.PERMALINK].includes(location)) {
const postRootId = post.rootId || post.id;
fetchAndSwitchToThread(serverUrl, postRootId);
}
} else if ((isEphemeral || hasBeenDeleted)) {
removePost(serverUrl, post);
}
setTimeout(() => {

View File

@@ -0,0 +1,215 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {BehaviorSubject, combineLatest, of as of$} from 'rxjs';
import {switchMap, distinctUntilChanged} from 'rxjs/operators';
import DatabaseManager from '@database/manager';
import {getChannelById} from '@queries/servers/channel';
import {getConfig, getCurrentChannelId, getCurrentTeamId, getCurrentUserId, observeConfigBooleanValue} from '@queries/servers/system';
import {validateBindings} from '@utils/apps';
import {logDebug} from '@utils/log';
import NetworkManager from './network_manager';
const emptyBindings: AppBinding[] = [];
class AppsManager {
private enabled: {[serverUrl: string]: BehaviorSubject<boolean>} = {};
private bindings: {[serverUrl: string]: BehaviorSubject<AppBinding[]>} = {};
private threadBindings: {[serverUrl: string]: BehaviorSubject<{channelId: string; bindings: AppBinding[]}>} = {};
private commandForms: {[serverUrl: string]: {[location: string]: AppForm}} = {};
private threadCommandForms: {[serverUrl: string]: {[location: string]: AppForm}} = {};
private getEnabledSubject = (serverUrl: string) => {
if (!this.enabled[serverUrl]) {
this.enabled[serverUrl] = new BehaviorSubject(true);
}
return this.enabled[serverUrl];
};
private getBindingsSubject = (serverUrl: string) => {
if (!this.bindings[serverUrl]) {
this.bindings[serverUrl] = new BehaviorSubject([]);
}
return this.bindings[serverUrl];
};
private getThreadsBindingsSubject = (serverUrl: string) => {
if (!this.threadBindings[serverUrl]) {
this.threadBindings[serverUrl] = new BehaviorSubject({channelId: '', bindings: emptyBindings});
}
return this.threadBindings[serverUrl];
};
private handleError = (serverUrl: string) => {
const enabled = this.getEnabledSubject(serverUrl);
if (enabled.value) {
enabled.next(false);
}
this.getBindingsSubject(serverUrl).next(emptyBindings);
this.getThreadsBindingsSubject(serverUrl).next({channelId: '', bindings: emptyBindings});
this.commandForms[serverUrl] = {};
this.threadCommandForms[serverUrl] = {};
};
removeServer = (serverUrl: string) => {
delete (this.enabled[serverUrl]);
delete (this.bindings[serverUrl]);
delete (this.threadBindings[serverUrl]);
delete (this.commandForms[serverUrl]);
delete (this.threadCommandForms[serverUrl]);
};
clearServer = (serverUrl: string) => {
this.clearBindings(serverUrl);
this.clearBindings(serverUrl, true);
this.commandForms[serverUrl] = {};
this.threadCommandForms[serverUrl] = {};
};
isAppsEnabled = async (serverUrl: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const config = await getConfig(database);
return this.getEnabledSubject(serverUrl).value && config?.FeatureFlagAppsEnabled === 'true';
} catch {
return false;
}
};
observeIsAppsEnabled = (serverUrl: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const enabled = this.getEnabledSubject(serverUrl).asObservable();
const config = observeConfigBooleanValue(database, 'FeatureFlagAppsEnabled');
return combineLatest([enabled, config]).pipe(
switchMap(([e, cfg]) => of$(e && cfg)),
distinctUntilChanged(),
);
} catch {
return of$(false);
}
};
fetchBindings = async (serverUrl: string, channelId: string, forThread = false) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const userId = await getCurrentUserId(database);
const channel = await getChannelById(database, channelId);
let teamId = channel?.teamId;
if (!teamId) {
teamId = await getCurrentTeamId(database);
}
const client = NetworkManager.getClient(serverUrl);
const fetchedBindings = await client.getAppsBindings(userId, channelId, teamId);
const validatedBindings = validateBindings(fetchedBindings);
const bindingsToStore = validatedBindings.length ? validatedBindings : emptyBindings;
const enabled = this.getEnabledSubject(serverUrl);
if (!enabled.value) {
enabled.next(true);
}
if (forThread) {
this.getThreadsBindingsSubject(serverUrl).next({channelId, bindings: bindingsToStore});
this.threadCommandForms[serverUrl] = {};
} else {
this.getBindingsSubject(serverUrl).next(bindingsToStore);
this.commandForms[serverUrl] = {};
}
} catch (error) {
logDebug('Error fetching apps', error);
this.handleError(serverUrl);
}
};
refreshAppBindings = async (serverUrl: string) => {
try {
const {database} = DatabaseManager.getServerDatabaseAndOperator(serverUrl);
const appsEnabled = (await getConfig(database))?.FeatureFlagAppsEnabled === 'true';
if (!appsEnabled) {
this.getEnabledSubject(serverUrl).next(false);
this.clearServer(serverUrl);
}
const channelId = await getCurrentChannelId(database);
// We await here, since errors on this call may clear the thread bindings
await this.fetchBindings(serverUrl, channelId);
const threadChannelId = this.getThreadsBindingsSubject(serverUrl).value.channelId;
if (threadChannelId) {
await this.fetchBindings(serverUrl, threadChannelId, true);
}
} catch (error) {
logDebug('Error refreshing apps', error);
this.handleError(serverUrl);
}
};
copyMainBindingsToThread = async (serverUrl: string, channelId: string) => {
this.getThreadsBindingsSubject(serverUrl).next({channelId, bindings: this.getBindingsSubject(serverUrl).value});
};
clearBindings = async (serverUrl: string, forThread = false) => {
if (forThread) {
this.getThreadsBindingsSubject(serverUrl).next({channelId: '', bindings: emptyBindings});
} else {
this.getBindingsSubject(serverUrl).next(emptyBindings);
}
};
observeBindings = (serverUrl: string, location?: string, forThread = false) => {
const isEnabled = this.observeIsAppsEnabled(serverUrl);
const bindings = forThread ?
this.getThreadsBindingsSubject(serverUrl).asObservable().pipe(switchMap(({bindings: bb}) => of$(bb))) :
this.getBindingsSubject(serverUrl).asObservable();
return combineLatest([isEnabled, bindings]).pipe(
switchMap(([e, bb]) => of$(e ? bb : emptyBindings)),
switchMap((bb) => {
const result = location ? bb.filter((b) => b.location === location) : bb;
return of$(result.length ? result : emptyBindings);
}),
);
};
getBindings = (serverUrl: string, location?: string, forThread = false) => {
const bindings = forThread ?
this.getThreadsBindingsSubject(serverUrl).value.bindings :
this.getBindingsSubject(serverUrl).value;
if (location) {
return bindings.filter((b) => b.location === location);
}
return bindings;
};
getCommandForm = (serverUrl: string, key: string, forThread = false) => {
return forThread ?
this.threadCommandForms[serverUrl]?.[key] :
this.commandForms[serverUrl]?.[key];
};
setCommandForm = (serverUrl: string, key: string, form: AppForm, forThread = false) => {
const toStore = forThread ?
this.threadCommandForms :
this.commandForms;
if (!toStore[serverUrl]) {
toStore[serverUrl] = {};
}
toStore[serverUrl][key] = form;
};
}
export default new AppsManager();

View File

@@ -14,12 +14,6 @@ class ServerIntegrationsManager {
private triggerId = '';
private storedDialog?: InteractiveDialogConfig;
private bindings: AppBinding[] = [];
private rhsBindings: AppBinding[] = [];
private commandForms: {[key: string]: AppForm | undefined} = {};
private rhsCommandForms: {[key: string]: AppForm | undefined} = {};
constructor(serverUrl: string) {
this.serverUrl = serverUrl;
}
@@ -44,29 +38,6 @@ class ServerIntegrationsManager {
}
}
public getCommandBindings() {
// TODO filter bindings
return this.bindings;
}
public getRHSCommandBindings() {
// TODO filter bindings
return this.rhsBindings;
}
public getAppRHSCommandForm(key: string) {
return this.rhsCommandForms[key];
}
public getAppCommandForm(key: string) {
return this.commandForms[key];
}
public setAppRHSCommandForm(key: string, form: AppForm) {
this.rhsCommandForms[key] = form;
}
public setAppCommandForm(key: string, form: AppForm) {
this.commandForms[key] = form;
}
public setTriggerId(id: string) {
this.triggerId = id;
if (this.storedDialog?.trigger_id === id) {

View File

@@ -9,6 +9,7 @@ import {Preferences} from '@constants';
import {MM_TABLES} from '@constants/database';
import {queryPreferencesByCategoryAndName} from './preference';
import {observeUser} from './user';
import type PostModel from '@typings/database/models/servers/post';
import type PostInChannelModel from '@typings/database/models/servers/posts_in_channel';
@@ -72,6 +73,10 @@ export const observePost = (database: Database, postId: string) => {
);
};
export const observePostAuthor = (database: Database, post: PostModel) => {
return post.userId ? observeUser(database, post.userId) : of$(null);
};
export const observePostSaved = (database: Database, postId: string) => {
return queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_SAVED_POST, postId).
observeWithColumns(['value']).pipe(

View File

@@ -5,6 +5,7 @@ import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {doAppFetchForm, doAppLookup, doAppSubmit, postEphemeralCallResponseForContext} from '@actions/remote/apps';
import {handleGotoLocation} from '@actions/remote/command';
import {AppCallResponseTypes} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {createCallRequest, makeCallErrorResponse} from '@utils/apps';
@@ -77,6 +78,9 @@ function AppsFormContainer({
setCurrentForm(callResp.form);
break;
case AppCallResponseTypes.NAVIGATE:
if (callResp.navigate_to_url) {
handleGotoLocation(serverUrl, intl, callResp.navigate_to_url);
}
break;
default:
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage(

View File

@@ -254,6 +254,7 @@ const EditPost = ({componentId, maxPostSize, post, closeButtonId, hasFilesAttach
position={animatedAutocompletePosition}
availableSpace={animatedAutocompleteAvailableSpace}
inPost={false}
serverUrl={serverUrl}
/>
</>
);

View File

@@ -689,8 +689,8 @@ export async function openAsBottomSheet({closeButtonId, screen, theme, title, pr
}
}
export const showAppForm = async (form: AppForm, call: AppCallRequest) => {
const passProps = {form, call};
export const showAppForm = async (form: AppForm) => {
const passProps = {form};
showModal(Screens.APPS_FORM, form.title || '', passProps);
};