diff --git a/app/actions/websocket/apps.ts b/app/actions/websocket/apps.ts index 3fbce855f..755728ad4 100644 --- a/app/actions/websocket/apps.ts +++ b/app/actions/websocket/apps.ts @@ -1,7 +1,8 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {fetchAppBindings} from '@mm-redux/actions/apps'; +import {fetchAppBindings, fetchThreadAppBindings} from '@mm-redux/actions/apps'; +import {getThreadAppsBindingsChannelId} from '@mm-redux/selectors/entities/apps'; import {getCurrentChannelId} from '@mm-redux/selectors/entities/common'; import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions'; @@ -10,9 +11,17 @@ import {appsEnabled} from '@utils/apps'; export function handleRefreshAppsBindings() { return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => { const state = getState(); - if (appsEnabled(state)) { - dispatch(fetchAppBindings(getCurrentUserId(state), getCurrentChannelId(state))); + if (!appsEnabled(state)) { + return {data: true}; } + + dispatch(fetchAppBindings(getCurrentUserId(state), getCurrentChannelId(state))); + + const threadChannelID = getThreadAppsBindingsChannelId(state); + if (threadChannelID) { + dispatch(fetchThreadAppBindings(getCurrentUserId(state), threadChannelID)); + } + return {data: true}; }; } diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts index 1572fc453..2c7d3b798 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.test.ts @@ -33,7 +33,11 @@ describe('AppCommandParser', () => { ...reduxTestState, entities: { ...reduxTestState.entities, - apps: {bindings}, + apps: { + bindings, + threadBindings: bindings, + threadBindingsForms: {}, + }, }, } as any; const testStore = await mockStore(initialState); diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts index cac67bb4a..981621398 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser.ts @@ -4,6 +4,7 @@ /* eslint-disable max-lines */ import { + AppsTypes, AppCallRequest, AppBinding, AppField, @@ -49,6 +50,9 @@ import { getChannelSuggestions, getUserSuggestions, inTextMentionSuggestions, + getAppCommandForm, + getAppRHSCommandForm, + makeRHSAppBindingSelector, } from './app_command_parser_dependencies'; export interface Store { @@ -85,6 +89,7 @@ interface Intl { } const getCommandBindings = makeAppBindingsSelector(AppBindingLocations.COMMAND); +const getRHSCommandBindings = makeRHSAppBindingSelector(AppBindingLocations.COMMAND); export class ParsedCommand { state = ParseState.Start; @@ -572,8 +577,6 @@ export class AppCommandParser { private rootPostID?: string; private intl: Intl; - forms: {[location: string]: AppForm} = {}; - constructor(store: Store|null, intl: Intl, channelID: string, teamID = '', rootPostID = '') { this.store = store || getStore() as Store; this.channelID = channelID; @@ -926,8 +929,11 @@ export class AppCommandParser { // getCommandBindings returns the commands in the redux store. // They are grouped by app id since each app has one base command private getCommandBindings = (): AppBinding[] => { - const bindings = getCommandBindings(this.store.getState()); - return bindings; + const state = this.store.getState(); + if (this.rootPostID) { + return getRHSCommandBindings(state); + } + return getCommandBindings(state); } // getChannel gets the channel in which the user is typing the command @@ -937,9 +943,6 @@ export class AppCommandParser { } public setChannelContext = (channelID: string, teamID = '', rootPostID?: string) => { - if (this.channelID !== channelID || this.rootPostID !== rootPostID || this.teamID !== teamID) { - this.forms = {}; - } this.channelID = channelID; this.rootPostID = rootPostID; this.teamID = teamID; @@ -1033,15 +1036,21 @@ export class AppCommandParser { public getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => { const rootID = this.rootPostID || ''; const key = `${this.channelID}-${rootID}-${location}`; - const form = this.forms[key]; + const form = this.rootPostID ? getAppRHSCommandForm(this.store.getState(), key) : getAppCommandForm(this.store.getState(), key); if (form) { return {form}; } - this.forms = {}; const fetched = await this.fetchForm(binding); if (fetched?.form) { - this.forms[key] = fetched.form; + let actionType: string = AppsTypes.RECEIVED_APP_COMMAND_FORM; + if (this.rootPostID) { + actionType = AppsTypes.RECEIVED_APP_RHS_COMMAND_FORM; + } + this.store.dispatch({ + data: {form: fetched.form, location: key}, + type: actionType, + }); } return fetched; } diff --git a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts index 821d01ddd..ef423d0c6 100644 --- a/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts +++ b/app/components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies.ts @@ -37,6 +37,8 @@ export type { DoAppCallResult, } from 'types/actions/apps'; +export {AppsTypes} from '@mm-redux/action_types'; + export type {AutocompleteSuggestion}; export type { @@ -65,7 +67,7 @@ export { COMMAND_SUGGESTION_USER, } from '@mm-redux/constants/apps'; -export {makeAppBindingsSelector} from '@mm-redux/selectors/entities/apps'; +export {makeAppBindingsSelector, makeRHSAppBindingSelector, getAppCommandForm, getAppRHSCommandForm} from '@mm-redux/selectors/entities/apps'; export {getPost} from '@mm-redux/selectors/entities/posts'; export {getChannel as selectChannel, getCurrentChannel, getChannelByName as selectChannelByName} from '@mm-redux/selectors/entities/channels'; diff --git a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.test.tsx b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.test.tsx index 9b431d0be..9e71499ea 100644 --- a/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.test.tsx +++ b/app/components/autocomplete/slash_suggestion/app_slash_suggestion/app_slash_suggestion.test.tsx @@ -27,7 +27,12 @@ const makeStore = async (bindings: AppBinding[]) => { ...reduxTestState, entities: { ...reduxTestState.entities, - apps: {bindings}, + apps: { + bindings, + bindingsForms: {}, + threadBindings: bindings, + threadBindingsForms: {}, + }, }, } as any; const testStore = await mockStore(initialState); diff --git a/app/mm-redux/action_types/apps.ts b/app/mm-redux/action_types/apps.ts index 196c4f602..32bedb4d6 100644 --- a/app/mm-redux/action_types/apps.ts +++ b/app/mm-redux/action_types/apps.ts @@ -4,4 +4,9 @@ import keyMirror from '@mm-redux/utils/key_mirror'; export default keyMirror({ RECEIVED_APP_BINDINGS: null, + RECEIVED_THREAD_APP_BINDINGS: null, + CLEAR_APP_BINDINGS: null, + CLEAR_THREAD_APP_BINDINGS: null, + RECEIVED_APP_COMMAND_FORM: null, + RECEIVED_APP_RHS_COMMAND_FORM: null, }); diff --git a/app/mm-redux/actions/apps.ts b/app/mm-redux/actions/apps.ts index adedb5006..c3159c371 100644 --- a/app/mm-redux/actions/apps.ts +++ b/app/mm-redux/actions/apps.ts @@ -16,6 +16,30 @@ export function fetchAppBindings(userID: string, channelID: string): ActionFunc return dispatch(bindClientFunc({ clientFunc: () => Client4.getAppsBindings(userID, channelID, teamID), onSuccess: AppsTypes.RECEIVED_APP_BINDINGS, + onFailure: AppsTypes.CLEAR_APP_BINDINGS, })); }; } + +export function fetchThreadAppBindings(userID: string, channelID: string): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const channel = getChannel(getState(), channelID); + const teamID = channel?.team_id || ''; + + return dispatch(bindClientFunc({ + clientFunc: async () => { + const bindings = await Client4.getAppsBindings(userID, channelID, teamID); + return {bindings, channelID}; + }, + onSuccess: AppsTypes.RECEIVED_THREAD_APP_BINDINGS, + onRequest: AppsTypes.CLEAR_THREAD_APP_BINDINGS, + })); + }; +} + +export function clearThreadAppBindings() { + return { + type: AppsTypes.CLEAR_THREAD_APP_BINDINGS, + data: true, + }; +} diff --git a/app/mm-redux/reducers/entities/apps.ts b/app/mm-redux/reducers/entities/apps.ts index 7b6398c31..f2eddd34b 100644 --- a/app/mm-redux/reducers/entities/apps.ts +++ b/app/mm-redux/reducers/entities/apps.ts @@ -5,7 +5,7 @@ import {combineReducers} from 'redux'; import {AppsTypes} from '@mm-redux/action_types'; import {GenericAction} from '@mm-redux/types/actions'; -import {AppBinding, AppsState} from '@mm-redux/types/apps'; +import {AppBinding, AppCommandFormMap, AppsState} from '@mm-redux/types/apps'; import {validateBindings} from '@utils/apps'; export function bindings(state: AppBinding[] = [], action: GenericAction): AppBinding[] { @@ -17,6 +17,92 @@ export function bindings(state: AppBinding[] = [], action: GenericAction): AppBi } return newBindings; } + case AppsTypes.CLEAR_APP_BINDINGS: + if (state.length > 0) { + return []; + } + return state; + default: + return state; + } +} + +export function bindingsForms(state: AppCommandFormMap = {}, action: GenericAction): AppCommandFormMap { + switch (action.type) { + case AppsTypes.RECEIVED_APP_BINDINGS: + if (Object.keys(state).length) { + return {}; + } + return state; + case AppsTypes.RECEIVED_APP_COMMAND_FORM: { + const {form, location} = action.data; + const newState = { + ...state, + [location]: form, + }; + return newState; + } + case AppsTypes.CLEAR_APP_BINDINGS: { + if (Object.keys(state).length) { + return {}; + } + return state; + } + default: + return state; + } +} + +export function threadBindings(state: AppBinding[] = [], action: GenericAction): AppBinding[] { + switch (action.type) { + case AppsTypes.RECEIVED_THREAD_APP_BINDINGS: { + return validateBindings(action.data.bindings) || []; + } + case AppsTypes.CLEAR_THREAD_APP_BINDINGS: + if (state.length > 0) { + return []; + } + return state; + default: + return state; + } +} + +export function threadBindingsChannelId(state = '', action: GenericAction): string { + switch (action.type) { + case AppsTypes.RECEIVED_THREAD_APP_BINDINGS: { + return action.data.channelID || ''; + } + case AppsTypes.CLEAR_THREAD_APP_BINDINGS: + if (state.length > 0) { + return ''; + } + return state; + default: + return state; + } +} + +export function threadBindingsForms(state: AppCommandFormMap = {}, action: GenericAction): AppCommandFormMap { + switch (action.type) { + case AppsTypes.RECEIVED_THREAD_APP_BINDINGS: + if (Object.keys(state).length) { + return {}; + } + return state; + case AppsTypes.RECEIVED_APP_RHS_COMMAND_FORM: { + const {form, location} = action.data; + const newState = { + ...state, + [location]: form, + }; + return newState; + } + case AppsTypes.CLEAR_THREAD_APP_BINDINGS: + if (Object.keys(state).length) { + return {}; + } + return state; default: return state; } @@ -24,4 +110,8 @@ export function bindings(state: AppBinding[] = [], action: GenericAction): AppBi export default (combineReducers({ bindings, + bindingsForms, + threadBindings, + threadBindingsForms, + threadBindingsChannelId, }) as (b: AppsState, a: GenericAction) => AppsState); diff --git a/app/mm-redux/selectors/entities/apps.ts b/app/mm-redux/selectors/entities/apps.ts index b45f6b00f..58fe41da2 100644 --- a/app/mm-redux/selectors/entities/apps.ts +++ b/app/mm-redux/selectors/entities/apps.ts @@ -7,16 +7,8 @@ import {AppBinding} from '@mm-redux/types/apps'; import {GlobalState} from '@mm-redux/types/store'; import {appsEnabled} from '@utils/apps'; -export function getAppsBindings(state: GlobalState, location?: string): AppBinding[] { - if (!state.entities.apps.bindings) { - return []; - } - - if (location) { - const bindings = state.entities.apps.bindings.filter((b) => b.location === location); - return bindings.reduce((accum: AppBinding[], current: AppBinding) => accum.concat(current.bindings || []), []); - } - return state.entities.apps.bindings; +export function getThreadAppsBindingsChannelId(state: GlobalState): string { + return state.entities.apps.threadBindingsChannelId; } export const makeAppBindingsSelector = (location: string) => { @@ -33,3 +25,26 @@ export const makeAppBindingsSelector = (location: string) => { }, ); }; + +export const makeRHSAppBindingSelector = (location: string) => { + return createSelector( + (state: GlobalState) => state.entities.apps.threadBindings, + (state: GlobalState) => appsEnabled(state), + (bindings: AppBinding[], areAppsEnabled: boolean) => { + if (!areAppsEnabled || !bindings) { + return []; + } + + const headerBindings = bindings.filter((b) => b.location === location); + return headerBindings.reduce((accum: AppBinding[], current: AppBinding) => accum.concat(current.bindings || []), []); + }, + ); +}; + +export const getAppCommandForm = (state: GlobalState, location: string) => { + return state.entities.apps.bindingsForms[location]; +}; + +export const getAppRHSCommandForm = (state: GlobalState, location: string) => { + return state.entities.apps.threadBindingsForms[location]; +}; diff --git a/app/mm-redux/types/apps.ts b/app/mm-redux/types/apps.ts index b29bb65e7..876ae339d 100644 --- a/app/mm-redux/types/apps.ts +++ b/app/mm-redux/types/apps.ts @@ -18,6 +18,10 @@ export type AppModalState = { export type AppsState = { bindings: AppBinding[]; + bindingsForms: AppCommandFormMap; + threadBindings: AppBinding[]; + threadBindingsForms: AppCommandFormMap; + threadBindingsChannelId: string; }; export type AppBinding = { @@ -219,3 +223,5 @@ export type FormResponseData = { export type AppLookupResponse = { items: AppSelectOption[]; } + +export type AppCommandFormMap = {[location: string]: AppForm} diff --git a/app/screens/channel_info/bindings/bindings.tsx b/app/screens/channel_info/bindings/bindings.tsx index adad3dd94..befcf5ab2 100644 --- a/app/screens/channel_info/bindings/bindings.tsx +++ b/app/screens/channel_info/bindings/bindings.tsx @@ -20,7 +20,7 @@ import ChannelInfoRow from '../channel_info_row'; type Props = { bindings: AppBinding[]; theme: Theme; - currentChannel: Channel; + currentChannel?: Channel; appsEnabled: boolean; intl: typeof intlShape; currentTeamId: string; @@ -32,11 +32,15 @@ type Props = { } const Bindings: React.FC = injectIntl((props: Props) => { - if (!props.appsEnabled) { + const {bindings, currentChannel, appsEnabled, ...optionProps} = props; + if (!appsEnabled) { + return null; + } + + if (!currentChannel) { return null; } - const {bindings, ...optionProps} = props; if (bindings.length === 0) { return null; } @@ -45,6 +49,7 @@ const Bindings: React.FC = injectIntl((props: Props) => {