diff --git a/.maestro/scripts/data-setup.js b/.maestro/scripts/data-setup.js
index bf8ab505c05..167b373862a 100644
--- a/.maestro/scripts/data-setup.js
+++ b/.maestro/scripts/data-setup.js
@@ -195,6 +195,43 @@ const reactAsNewUsers = (count, messageId, emoji) => {
return reactors;
};
+const appsApiGet = (path) => {
+ const response = getWithRetry(`${data.server}/api/apps/${path}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ ...headers
+ }
+ });
+
+ return json(response.body);
+};
+
+const findAppActionButton = (username, password, context, actionId) => {
+ login(username, password);
+
+ const button = appsApiGet('actionButtons').find(b => b.context === context && b.actionId === actionId);
+
+ if (!button) {
+ throw new Error(`No ${context} action button "${actionId}" installed on ${data.server}`);
+ }
+
+ return button;
+};
+
+// App labels ship with the app, not with the mobile bundle, so resolve them from the server.
+const getAppTranslation = (username, password, appId, key) => {
+ login(username, password);
+
+ const app = appsApiGet('languages').apps.find(a => a.id === appId);
+ const translation = app && app.languages && app.languages.en && app.languages.en[key];
+
+ if (!translation) {
+ throw new Error(`App ${appId} has no en translation for "${key}"`);
+ }
+
+ return translation;
+};
+
const createDM = (username, password, otherUsername) => {
login(username, password);
@@ -294,6 +331,8 @@ output.utils = {
login,
getDeepLink,
createDM,
+ findAppActionButton,
+ getAppTranslation,
sleep,
groupMessageCount
};
\ No newline at end of file
diff --git a/.maestro/tests/uikit/poll.yaml b/.maestro/tests/uikit/poll.yaml
new file mode 100644
index 00000000000..0cde535afe4
--- /dev/null
+++ b/.maestro/tests/uikit/poll.yaml
@@ -0,0 +1,117 @@
+appId: ${APP_ID}
+name: Apps Engine Poll
+onFlowStart:
+ - runFlow: '../../helpers/setup.yaml'
+onFlowComplete:
+ - evalScript: ${output.utils.deleteCreatedUsers()}
+tags:
+ - test-9
+
+---
+- evalScript: ${output.user = output.utils.createUser()}
+- evalScript: ${output.room = output.utils.createRandomRoom(output.user.username, output.user.password)}
+- evalScript: ${output.pollButton = output.utils.findAppActionButton(output.user.username, output.user.password, 'messageBoxAction', 'message-box-create-poll')}
+- evalScript: ${output.pollLabel = output.utils.getAppTranslation(output.user.username, output.user.password, output.pollButton.appId, 'create_poll_button')}
+- evalScript: ${output.pollAction = 'message-composer-app-action-' + output.pollButton.appId + '/' + output.pollButton.actionId}
+- evalScript: ${output.question = 'Poll ' + output.random()}
+
+- runFlow:
+ file: '../../helpers/login-with-deeplink.yaml'
+ env:
+ USERNAME: ${output.user.username}
+ PASSWORD: ${output.user.password}
+
+- runFlow:
+ file: '../../helpers/navigate-to-room.yaml'
+ env:
+ ROOM: ${output.room.name}
+
+# should list the Poll app messageBoxAction in the composer actions, labeled with the app's own i18n
+- extendedWaitUntil:
+ visible:
+ id: 'message-composer-actions'
+ timeout: 60000
+- tapOn:
+ id: 'message-composer-actions'
+- extendedWaitUntil:
+ visible:
+ id: 'action-sheet'
+ timeout: 60000
+- extendedWaitUntil:
+ visible:
+ id: '${output.pollAction}'
+ timeout: 60000
+- assertVisible:
+ text: '${output.pollLabel}'
+
+# should open the app's modal and dismiss it without creating a poll
+- tapOn:
+ id: '${output.pollAction}'
+- extendedWaitUntil:
+ visible:
+ id: 'uikit-input-question'
+ timeout: 60000
+- assertVisible:
+ id: 'submit-modal-uikit'
+- tapOn:
+ id: 'close-modal-uikit'
+- extendedWaitUntil:
+ visible:
+ id: 'room-view-title-${output.room.name}'
+ timeout: 60000
+
+# should create a poll from the modal
+- tapOn:
+ id: 'message-composer-actions'
+- extendedWaitUntil:
+ visible:
+ id: 'action-sheet'
+ timeout: 60000
+- tapOn:
+ id: '${output.pollAction}'
+- extendedWaitUntil:
+ visible:
+ id: 'uikit-input-question'
+ timeout: 60000
+- tapOn:
+ id: 'uikit-input-question'
+- inputText: ${output.question}
+- tapOn:
+ id: 'uikit-input-option-0'
+- inputText: 'Option A'
+- tapOn:
+ id: 'uikit-input-option-1'
+- inputText: 'Option B'
+- tapOn:
+ id: 'submit-modal-uikit'
+
+# should render the poll blocks the app posted back
+- extendedWaitUntil:
+ visible:
+ text: '${output.question}'
+ timeout: 60000
+- assertVisible:
+ text: 'Option A'
+- assertVisible:
+ text: 'Option B'
+- assertVisible:
+ text: '.*0.00% \(0\).*'
+
+# should vote and see the results
+- tapOn:
+ text: '(poll_vote|Vote)'
+ index: 0
+- tapOn:
+ id: 'header-back'
+- extendedWaitUntil:
+ visible:
+ id: 'rooms-list-view'
+ timeout: 60000
+- runFlow:
+ file: '../../helpers/navigate-to-room.yaml'
+ env:
+ ROOM: ${output.room.name}
+- extendedWaitUntil:
+ visible:
+ text: '.*100.00% \(1\).*'
+ timeout: 60000
diff --git a/.sniffler/test-map.json b/.sniffler/test-map.json
index 4ac3ebed352..89e73207723 100644
--- a/.sniffler/test-map.json
+++ b/.sniffler/test-map.json
@@ -444,6 +444,15 @@
},
{
"test": ".maestro/tests/uikit/button-kit.yaml",
- "dependsOn": ["app/containers/UIKit/**"]
+ "dependsOn": ["app/containers/UIKit/**", "app/lib/apps/**"]
+ },
+ {
+ "test": ".maestro/tests/uikit/poll.yaml",
+ "dependsOn": [
+ "app/containers/UIKit/**",
+ "app/lib/apps/**",
+ "app/containers/MessageComposer/**",
+ "app/views/ModalBlockView.tsx"
+ ]
}
]
diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx
index c94d46f4c7f..2bfa45314be 100644
--- a/app/containers/MessageComposer/MessageComposer.tsx
+++ b/app/containers/MessageComposer/MessageComposer.tsx
@@ -20,7 +20,7 @@ import { EventTypes } from '../EmojiPicker/interfaces';
import { type IEmoji } from '~/definitions';
import database from '~/lib/database';
import { sanitizeLikeString } from '~/lib/database/utils';
-import { generateTriggerId } from '~/lib/methods/actions';
+import { generateTriggerId } from '~/lib/apps/actions';
import { runSlashCommand } from '~/lib/services/restApi';
import log from '~/lib/methods/helpers/log';
import { prepareQuoteMessage, insertEmojiAtCursor, lastGlyphLength } from './helpers';
diff --git a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx
index 5647826eab0..3880f3527be 100644
--- a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx
+++ b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx
@@ -10,12 +10,20 @@ import { useMasterDetail } from '~/lib/hooks/useMasterDetail';
import { usePermissions } from '~/lib/hooks/usePermissions';
import { useCanUploadFile, useChooseMedia } from '~/containers/MessageComposer/hooks';
import { useComposerRid, useComposerTmid, useComposerType } from '~/containers/MessageComposer/ComposerStore';
+import { UIActionButtonContext } from '~/lib/apps/definitions';
+import { useAppActionButtons } from '~/lib/apps/useAppActionButtons';
+import { triggerAppActionButton } from '~/lib/apps/triggerAppActionButton';
+
+const COMPOSER_APP_FILTERS = [
+ { context: UIActionButtonContext.ROOM_ACTION, category: 'ai' as const },
+ { context: UIActionButtonContext.MESSAGE_BOX_ACTION }
+];
export const ActionsButton = () => {
const rid = useComposerRid();
const tmid = useComposerTmid();
const t = useComposerType();
- const { closeEmojiKeyboardAndAction } = useContext(MessageInnerContext);
+ const { closeEmojiKeyboardAndAction, getText } = useContext(MessageInnerContext);
const permissionToUpload = useCanUploadFile(rid);
const [permissionToViewCannedResponses] = usePermissions(['view-canned-responses'], rid);
const { takePhoto, takeVideo, chooseFromLibrary, chooseFile } = useChooseMedia({
@@ -25,6 +33,7 @@ export const ActionsButton = () => {
});
const { showActionSheet, hideActionSheet } = useActionSheet();
const isMasterDetail = useMasterDetail();
+ const [aiActions, appActions] = useAppActionButtons({ filters: COMPOSER_APP_FILTERS, rid });
const createDiscussion = async () => {
if (!rid) return;
@@ -95,6 +104,30 @@ export const ActionsButton = () => {
onPress: () => createDiscussion()
});
+ aiActions.forEach(({ id, label, button }) => {
+ options.push({
+ title: label,
+ icon: 'stars',
+ danger: button.variant === 'danger',
+ testID: `message-composer-ai-action-${id}`,
+ onPress: () => {
+ triggerAppActionButton({ button, rid, tmid });
+ }
+ });
+ });
+
+ appActions.forEach(({ id, label, button }) => {
+ options.push({
+ title: label,
+ icon: 'apps',
+ danger: button.variant === 'danger',
+ testID: `message-composer-app-action-${id}`,
+ onPress: () => {
+ triggerAppActionButton({ button, rid, tmid, message: getText() ?? '' });
+ }
+ });
+ });
+
closeEmojiKeyboardAndAction(showActionSheet, { options });
};
diff --git a/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx b/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx
new file mode 100644
index 00000000000..b22518cc3ab
--- /dev/null
+++ b/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx
@@ -0,0 +1,125 @@
+import { render, screen } from '@testing-library/react-native';
+
+import { type IAppActionButtonItem } from '~/lib/apps/useAppActionButtons';
+import { ActionsButton } from '../ActionsButton';
+
+const mockUseAppActionButtons = jest.fn((_params: unknown): IAppActionButtonItem[] => []);
+jest.mock('~/lib/apps/useAppActionButtons', () => ({
+ useAppActionButtons: ({ filters, rid }: { filters: { context: string; category?: string }[]; rid?: string }) =>
+ filters.map(filter => mockUseAppActionButtons({ ...filter, rid }))
+}));
+
+const mockTriggerAppActionButton = jest.fn();
+jest.mock('~/lib/apps/triggerAppActionButton', () => ({
+ triggerAppActionButton: (params: unknown) => mockTriggerAppActionButton(params)
+}));
+
+const mockShowActionSheet = jest.fn();
+jest.mock('~/containers/ActionSheet', () => ({
+ useActionSheet: () => ({ showActionSheet: mockShowActionSheet, hideActionSheet: jest.fn() })
+}));
+
+jest.mock('~/containers/MessageComposer/ComposerStore', () => ({
+ useComposerRid: () => 'rid-1',
+ useComposerTmid: () => 'tmid-1',
+ useComposerType: () => 'c'
+}));
+
+jest.mock('~/containers/MessageComposer/context', () => {
+ const ReactActual = jest.requireActual('react');
+ return {
+ MessageInnerContext: ReactActual.createContext({
+ closeEmojiKeyboardAndAction: (action: Function, params: unknown) => action(params),
+ getText: () => 'draft text'
+ })
+ };
+});
+
+jest.mock('~/containers/MessageComposer/hooks', () => ({
+ useCanUploadFile: () => false,
+ useChooseMedia: () => ({ takePhoto: jest.fn(), takeVideo: jest.fn(), chooseFromLibrary: jest.fn(), chooseFile: jest.fn() })
+}));
+jest.mock('~/lib/hooks/usePermissions', () => ({ usePermissions: () => [false] }));
+jest.mock('~/lib/hooks/useMasterDetail', () => ({ useMasterDetail: () => false }));
+jest.mock('~/lib/database/services/Subscription', () => ({ getSubscriptionByRoomId: jest.fn() }));
+
+jest.mock('../BaseButton', () => {
+ const ReactActual = jest.requireActual('react');
+ return {
+ BaseButton: ({ onPress, testID }: { onPress: () => void; testID: string }) =>
+ ReactActual.createElement('BaseButton', { onPress, testID })
+ };
+});
+
+const item = (id: string, overrides = {}): IAppActionButtonItem => ({
+ id,
+ label: `Label ${id}`,
+ button: { appId: 'app-id', actionId: id, context: 'messageBoxAction', labelI18n: 'label', ...overrides }
+});
+
+const openSheet = () => {
+ render();
+ screen.getByTestId('message-composer-actions').props.onPress();
+ return mockShowActionSheet.mock.calls[0][0].options as { title: string; testID?: string; onPress: () => void }[];
+};
+
+describe('ActionsButton', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUseAppActionButtons.mockImplementation(() => []);
+ });
+
+ it('shows only the built-in actions when no app registered any', () => {
+ const options = openSheet();
+
+ expect(options.map(option => option.title)).toEqual(['Create discussion']);
+ });
+
+ it('lists ai room actions then message box actions after the built-in ones', () => {
+ mockUseAppActionButtons.mockImplementation(params =>
+ (params as { context: string }).context === 'messageBoxAction'
+ ? [item('translate')]
+ : [item('summarize', { context: 'roomAction', category: 'ai' })]
+ );
+
+ const options = openSheet();
+
+ expect(options.map(option => option.title)).toEqual(['Create discussion', 'Label summarize', 'Label translate']);
+ expect(options[1].testID).toBe('message-composer-ai-action-summarize');
+ expect(options[2].testID).toBe('message-composer-app-action-translate');
+ });
+
+ it('asks for the ai category of the room action context', () => {
+ openSheet();
+
+ expect(mockUseAppActionButtons).toHaveBeenCalledWith({ context: 'messageBoxAction', rid: 'rid-1' });
+ expect(mockUseAppActionButtons).toHaveBeenCalledWith({ context: 'roomAction', category: 'ai', rid: 'rid-1' });
+ });
+
+ it('sends the thread and the composer draft with a message box action', () => {
+ const messageBoxItem = item('translate');
+ mockUseAppActionButtons.mockImplementation(params =>
+ (params as { context: string }).context === 'messageBoxAction' ? [messageBoxItem] : []
+ );
+
+ openSheet()[1].onPress();
+
+ expect(mockTriggerAppActionButton).toHaveBeenCalledWith({
+ button: messageBoxItem.button,
+ rid: 'rid-1',
+ tmid: 'tmid-1',
+ message: 'draft text'
+ });
+ });
+
+ it('sends the thread but not the composer draft with an ai action', () => {
+ const aiItem = item('summarize', { context: 'roomAction', category: 'ai' });
+ mockUseAppActionButtons.mockImplementation(params =>
+ (params as { context: string }).context === 'roomAction' ? [aiItem] : []
+ );
+
+ openSheet()[1].onPress();
+
+ expect(mockTriggerAppActionButton).toHaveBeenCalledWith({ button: aiItem.button, rid: 'rid-1', tmid: 'tmid-1' });
+ });
+});
diff --git a/app/containers/MessageComposer/components/ComposerInput.tsx b/app/containers/MessageComposer/components/ComposerInput.tsx
index b69dc7c2d2e..5e0626a2212 100644
--- a/app/containers/MessageComposer/components/ComposerInput.tsx
+++ b/app/containers/MessageComposer/components/ComposerInput.tsx
@@ -34,7 +34,7 @@ import { emitter } from '~/lib/methods/helpers/emitter';
import { useComposerRid, useComposerRoomTitle, useComposerSharing, useComposerTmid, useComposerType } from '../ComposerStore';
import { useMessageAction, useMessageActionStoreApi } from '~/containers/message/stores/MessageActionStore';
import { getMessageById } from '~/lib/database/services/Message';
-import { generateTriggerId } from '~/lib/methods/actions';
+import { generateTriggerId } from '~/lib/apps/actions';
import { executeCommandPreview } from '~/lib/services/restApi';
import log from '~/lib/methods/helpers/log';
import { useMasterDetail } from '~/lib/hooks/useMasterDetail';
diff --git a/app/containers/UIKit/__snapshots__/UiKitModal.test.tsx.snap b/app/containers/UIKit/__snapshots__/UiKitModal.test.tsx.snap
index dae7366564f..c28129476af 100644
--- a/app/containers/UIKit/__snapshots__/UiKitModal.test.tsx.snap
+++ b/app/containers/UIKit/__snapshots__/UiKitModal.test.tsx.snap
@@ -4228,6 +4228,7 @@ exports[`Story Snapshots: ModalInputWithAddField should match snapshot 1`] = `
},
]
}
+ testID="uikit-input-input-1"
underlineColorAndroid="transparent"
/>
@@ -4338,6 +4339,7 @@ exports[`Story Snapshots: ModalInputWithAddField should match snapshot 1`] = `
},
]
}
+ testID="uikit-input-input-2"
underlineColorAndroid="transparent"
/>
@@ -4510,6 +4512,7 @@ exports[`Story Snapshots: ModalInputWithError should match snapshot 1`] = `
},
]
}
+ testID="uikit-input-input-test"
underlineColorAndroid="transparent"
/>
@@ -5194,6 +5197,7 @@ exports[`Story Snapshots: ModalMultilneWithError should match snapshot 1`] = `
},
]
}
+ testID="uikit-input-input-test"
underlineColorAndroid="transparent"
/>
diff --git a/app/containers/UIKit/index.tsx b/app/containers/UIKit/index.tsx
index 77f8825e2fa..0af088fadc9 100644
--- a/app/containers/UIKit/index.tsx
+++ b/app/containers/UIKit/index.tsx
@@ -229,6 +229,7 @@ class ModalParser extends UiKitParserModal {
return (
{
} as any)
).toThrow('viewId is required for view interactions');
});
+
+ it('maps a message box action button and keeps the composer text', () => {
+ const interaction = toUserInteraction({
+ type: ActionTypes.ACTION_BUTTON,
+ actionId: 'action-id',
+ appId: 'app-id',
+ rid: 'room-id',
+ tmid: 'thread-id',
+ payload: { context: 'messageBoxAction', message: 'draft' },
+ triggerId: 'trigger-id'
+ });
+
+ expect(interaction).toEqual({
+ type: 'actionButton',
+ actionId: 'action-id',
+ payload: { context: 'messageBoxAction', message: 'draft' },
+ mid: undefined,
+ tmid: 'thread-id',
+ rid: 'room-id',
+ triggerId: 'trigger-id'
+ });
+ });
+
+ it('maps a room action button', () => {
+ const interaction = toUserInteraction({
+ type: ActionTypes.ACTION_BUTTON,
+ actionId: 'action-id',
+ appId: 'app-id',
+ rid: 'room-id',
+ payload: { context: 'roomAction' },
+ triggerId: 'trigger-id'
+ });
+
+ expect(interaction).toEqual({
+ type: 'actionButton',
+ actionId: 'action-id',
+ payload: { context: 'roomAction' },
+ mid: undefined,
+ tmid: undefined,
+ rid: 'room-id',
+ triggerId: 'trigger-id'
+ });
+ });
+
+ it('throws when an action button has no context', () => {
+ expect(() =>
+ toUserInteraction({
+ type: ActionTypes.ACTION_BUTTON,
+ actionId: 'action-id',
+ rid: 'room-id',
+ triggerId: 'trigger-id'
+ })
+ ).toThrow('actionId and payload.context are required for actionButton interaction');
+ });
});
describe('toServerModalInteractionType', () => {
diff --git a/app/containers/UIKit/interactionAdapters.ts b/app/containers/UIKit/interactionAdapters.ts
index e19af85bdb2..c5c6b93cbd6 100644
--- a/app/containers/UIKit/interactionAdapters.ts
+++ b/app/containers/UIKit/interactionAdapters.ts
@@ -24,12 +24,29 @@ export const toUserInteraction = ({
container,
mid,
rid,
+ tmid,
triggerId,
viewId,
payload,
view,
isCleared
}: ITriggerAction & { triggerId: string }): UserInteraction => {
+ if (type === ActionTypes.ACTION_BUTTON) {
+ if (!actionId || !payload?.context) {
+ throw new Error('actionId and payload.context are required for actionButton interaction');
+ }
+
+ return {
+ type: 'actionButton',
+ actionId,
+ payload,
+ mid,
+ tmid,
+ rid,
+ triggerId
+ } as UserInteraction;
+ }
+
if (type === ActionTypes.ACTION) {
if (!actionId || !container) {
throw new Error('actionId and container are required for blockAction interaction');
diff --git a/app/containers/UIKit/interfaces.ts b/app/containers/UIKit/interfaces.ts
index 3d01fbd658e..8af4414bbde 100644
--- a/app/containers/UIKit/interfaces.ts
+++ b/app/containers/UIKit/interfaces.ts
@@ -18,7 +18,8 @@ import { type TSupportedThemes } from '~/theme';
export const ActionTypes = {
ACTION: 'blockAction',
SUBMIT: 'viewSubmit',
- CLOSED: 'viewClosed'
+ CLOSED: 'viewClosed',
+ ACTION_BUTTON: 'actionButton'
} as const;
export const ContainerTypes = {
@@ -31,7 +32,8 @@ export const ModalActions = {
OPEN: 'modal.open',
CLOSE: 'modal.close',
UPDATE: 'modal.update',
- ERRORS: 'errors'
+ ERRORS: 'errors',
+ UNSUPPORTED: 'unsupported'
} as const;
export type TActionType = (typeof ActionTypes)[keyof typeof ActionTypes];
@@ -112,6 +114,7 @@ export interface ITriggerAction {
blockId?: string;
rid?: string;
mid?: string;
+ tmid?: string;
viewId?: string;
payload?: any;
view?: IView;
diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json
index 05fff14d240..373bd6bc40f 100644
--- a/app/i18n/locales/ar.json
+++ b/app/i18n/locales/ar.json
@@ -52,7 +52,10 @@
"Animals_and_nature": "الحيوانات والطبيعة",
"Announcement": "إعلان",
"announcement": "إعلان",
+ "App_action_error": "تعذر تشغيل إجراء التطبيق هذا",
+ "App_action_unsupported": "إجراء التطبيق هذا غير مدعوم على الهاتف المحمول حتى الآن",
"Apply_Certificate": "تقديم الشهادة",
+ "Apps": "التطبيقات",
"ARCHIVE": "أرشفة",
"archive": "أرشفة",
"are_typing": "يكتب",
diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json
index e329789462c..e267c03ae03 100644
--- a/app/i18n/locales/bn-IN.json
+++ b/app/i18n/locales/bn-IN.json
@@ -70,7 +70,10 @@
"Animals_and_nature": "প্রাণী এবং প্রকৃতি",
"Announcement": "ঘোষণা",
"announcement": "ঘোষণা",
+ "App_action_error": "এই অ্যাপ অ্যাকশনটি চালানো যায়নি",
+ "App_action_unsupported": "এই অ্যাপ অ্যাকশনটি এখনও মোবাইলে সমর্থিত নয়",
"Apply_Certificate": "সার্টিফিকেট প্রয়োগ করুন",
+ "Apps": "অ্যাপ",
"ARCHIVE": "সংরক্ষণাগার",
"archive": "সংরক্ষণাগার",
"are_typing": "টাইপ করছে",
diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json
index baeb07eddcc..5e6807a3eef 100644
--- a/app/i18n/locales/cs.json
+++ b/app/i18n/locales/cs.json
@@ -72,8 +72,11 @@
"Animals_and_nature": "Zvířata a příroda",
"Announcement": "Oznámení",
"announcement": "oznámení",
+ "App_action_error": "Tuto akci aplikace se nepodařilo spustit",
+ "App_action_unsupported": "Tato akce aplikace zatím není na mobilu podporována",
"App_users_are_not_allowed_to_log_in_directly": "Uživatelé aplikace se nemohou přihlásit přímo.",
"Apply_Certificate": "Použít certifikát",
+ "Apps": "Aplikace",
"ARCHIVE": "Archiv",
"archive": "archiv",
"are_typing": "píšou",
diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json
index c7e401363fa..287eda63ac9 100644
--- a/app/i18n/locales/de.json
+++ b/app/i18n/locales/de.json
@@ -70,7 +70,10 @@
"Animals_and_nature": "Tiere und Natur",
"Announcement": "Ankündigung",
"announcement": "Ankündigung",
+ "App_action_error": "Diese App-Aktion konnte nicht ausgeführt werden",
+ "App_action_unsupported": "Diese App-Aktion wird auf Mobilgeräten noch nicht unterstützt",
"Apply_Certificate": "Zertifikat anwenden",
+ "Apps": "Anwendungen",
"ARCHIVE": "Archiv",
"archive": "Archiv",
"are_typing": "tippen",
diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json
index e5d6c388566..d058b15cfe9 100644
--- a/app/i18n/locales/en.json
+++ b/app/i18n/locales/en.json
@@ -73,8 +73,11 @@
"Animals_and_nature": "Animals and nature",
"Announcement": "Announcement",
"announcement": "announcement",
+ "App_action_error": "Couldn't run this app action",
+ "App_action_unsupported": "This app action isn't supported on mobile yet",
"App_users_are_not_allowed_to_log_in_directly": "App users are not allowed to log in directly.",
"Apply_Certificate": "Apply certificate",
+ "Apps": "Apps",
"ARCHIVE": "Archive",
"archive": "archive",
"are_typing": "are typing",
diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json
index 00642d9caa8..6dce305fce5 100644
--- a/app/i18n/locales/es.json
+++ b/app/i18n/locales/es.json
@@ -48,7 +48,10 @@
"Animals_and_nature": "Animales y naturaleza",
"Announcement": "Anuncio",
"announcement": "anuncio",
+ "App_action_error": "No se pudo ejecutar esta acción de la aplicación",
+ "App_action_unsupported": "Esta acción de la aplicación aún no es compatible con dispositivos móviles",
"Apply_Certificate": "Aplicar certificado",
+ "Apps": "Aplicaciones",
"ARCHIVE": "Fichero",
"archive": "fichero",
"are_typing": "están escribiendo",
diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json
index caac539ba74..f9756294459 100644
--- a/app/i18n/locales/fi.json
+++ b/app/i18n/locales/fi.json
@@ -63,7 +63,10 @@
"Animals_and_nature": "Eläimet ja luonto",
"Announcement": "Ilmoitus",
"announcement": "ilmoitus",
+ "App_action_error": "Tätä sovellustoimintoa ei voitu suorittaa",
+ "App_action_unsupported": "Tätä sovellustoimintoa ei vielä tueta mobiililaitteilla",
"Apply_Certificate": "Käytä sertifikaattia",
+ "Apps": "Sovellukset",
"ARCHIVE": "Arkisto",
"archive": "arkisto",
"are_typing": "kirjoittavat",
diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json
index 2d287afc8cb..f80c71f1aea 100644
--- a/app/i18n/locales/fr.json
+++ b/app/i18n/locales/fr.json
@@ -55,7 +55,10 @@
"Animals_and_nature": "Animaux et nature",
"Announcement": "Annonce",
"announcement": "annonce",
+ "App_action_error": "Impossible d’exécuter cette action de l’application",
+ "App_action_unsupported": "Cette action de l’application n’est pas encore prise en charge sur mobile",
"Apply_Certificate": "Appliquer le certificat",
+ "Apps": "Applications",
"ARCHIVE": "Archiver",
"archive": "archiver",
"are_typing": "sont en train d'écrire",
diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json
index 8f688f3f572..bd8a3af882a 100644
--- a/app/i18n/locales/hi-IN.json
+++ b/app/i18n/locales/hi-IN.json
@@ -70,7 +70,10 @@
"Animals_and_nature": "जानवर और प्रकृति",
"Announcement": "घोषणा",
"announcement": "घोषणा",
+ "App_action_error": "यह ऐप कार्रवाई चल नहीं सकी",
+ "App_action_unsupported": "यह ऐप कार्रवाई अभी मोबाइल पर समर्थित नहीं है",
"Apply_Certificate": "प्रमाणपत्र लागू करें",
+ "Apps": "ऐप्स",
"ARCHIVE": "संग्रह",
"archive": "संग्रह",
"are_typing": "टाइप कर रहे हैं",
diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json
index 3240e9dd1ec..bf05fbe4593 100644
--- a/app/i18n/locales/hu.json
+++ b/app/i18n/locales/hu.json
@@ -70,7 +70,10 @@
"Animals_and_nature": "Állatok és természet",
"Announcement": "Bejelentés",
"announcement": "bejelentés",
+ "App_action_error": "Nem sikerült futtatni ezt az alkalmazásműveletet",
+ "App_action_unsupported": "Ez az alkalmazásművelet még nem támogatott mobileszközön",
"Apply_Certificate": "Tanúsítvány alkalmazása",
+ "Apps": "Alkalmazások",
"ARCHIVE": "Archívum",
"archive": "archívum",
"are_typing": "éppen ír",
diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json
index cf7fd6b77f5..7f79d387cda 100644
--- a/app/i18n/locales/it.json
+++ b/app/i18n/locales/it.json
@@ -56,7 +56,10 @@
"Animals_and_nature": "Animali e natura",
"Announcement": "Annuncio",
"announcement": "annuncio",
+ "App_action_error": "Impossibile eseguire questa azione dell’app",
+ "App_action_unsupported": "Questa azione dell’app non è ancora supportata sui dispositivi mobili",
"Apply_Certificate": "Applica certificato",
+ "Apps": "App",
"ARCHIVE": "Archivio",
"archive": "archivio",
"are_typing": "stanno scrivendo",
diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json
index de7448f4440..977484ce25a 100644
--- a/app/i18n/locales/ja.json
+++ b/app/i18n/locales/ja.json
@@ -51,7 +51,10 @@
"Animals_and_nature": "動物と自然",
"Announcement": "アナウンス",
"announcement": "アナウンス",
+ "App_action_error": "このアプリ操作を実行できませんでした",
+ "App_action_unsupported": "このアプリ操作はまだモバイルではサポートされていません",
"Apply_Certificate": "証明書を適用",
+ "Apps": "アプリ",
"ARCHIVE": "アーカイブ",
"archive": "アーカイブ",
"are_typing": "が入力中",
diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json
index 63d1427e86c..eb797b769b5 100644
--- a/app/i18n/locales/nl.json
+++ b/app/i18n/locales/nl.json
@@ -55,7 +55,10 @@
"Animals_and_nature": "Dieren en natuur",
"Announcement": "Aankondiging",
"announcement": "aankondiging",
+ "App_action_error": "Deze app-actie kon niet worden uitgevoerd",
+ "App_action_unsupported": "Deze app-actie wordt nog niet ondersteund op mobiel",
"Apply_Certificate": "Certificaat toepassen",
+ "Apps": "Apps",
"ARCHIVE": "Archiveer",
"archive": "archiveer",
"are_typing": "zijn aan het typen",
diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json
index a12fa48bbbd..e3659ab77b0 100644
--- a/app/i18n/locales/nn.json
+++ b/app/i18n/locales/nn.json
@@ -38,7 +38,10 @@
"Alt_text_placeholder": "Legg til alternativ tekst...",
"and": "og",
"Announcement": "Kunngjøring",
+ "App_action_error": "Klarte ikkje å køyre denne apphandlinga",
+ "App_action_unsupported": "Denne apphandlinga er ikkje støtta på mobil enno",
"App_users_are_not_allowed_to_log_in_directly": "Appbrukere har ikke lov til å logge inn direkte.",
+ "Apps": "Appar",
"ARCHIVE": "Arkiv",
"are_typing": "skriver",
"Are_you_sure_question_mark": "Er du sikker?",
diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json
index 2ee5a378326..d4d844f1526 100644
--- a/app/i18n/locales/no.json
+++ b/app/i18n/locales/no.json
@@ -71,8 +71,11 @@
"and_N_more": "Og {{count}} mer",
"Announcement": "Kunngjøring",
"announcement": "kunngjøring",
+ "App_action_error": "Kunne ikke kjøre denne apphandlingen",
+ "App_action_unsupported": "Denne apphandlingen støttes ikke på mobil ennå",
"App_users_are_not_allowed_to_log_in_directly": "Appbrukere har ikke lov til å logge inn direkte.",
"Apply_Certificate": "Bruke sertifikat",
+ "Apps": "Apper",
"ARCHIVE": "Arkiv",
"archive": "arkiv",
"are_typing": "skriver",
diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json
index dae7aae0162..9be98c7684e 100644
--- a/app/i18n/locales/pt-BR.json
+++ b/app/i18n/locales/pt-BR.json
@@ -71,8 +71,11 @@
"Animals_and_nature": "Animais e natureza",
"Announcement": "Anúncio",
"announcement": "anúncio",
+ "App_action_error": "Não foi possível executar esta ação do aplicativo",
+ "App_action_unsupported": "Esta ação do aplicativo ainda não é compatível com dispositivos móveis",
"App_users_are_not_allowed_to_log_in_directly": "Usuários do aplicativo não estão autorizados a fazer login diretamente.",
"Apply_Certificate": "Aplicar certificado",
+ "Apps": "Aplicativos",
"ARCHIVE": "Arquivar",
"archive": "arquivar",
"are_typing": "estão digitando",
diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json
index 43f3895155b..2df93da75e4 100644
--- a/app/i18n/locales/pt-PT.json
+++ b/app/i18n/locales/pt-PT.json
@@ -50,7 +50,10 @@
"Animals_and_nature": "Animais e natureza",
"Announcement": "Anúncio",
"announcement": "anúncio",
+ "App_action_error": "Não foi possível executar esta ação da aplicação",
+ "App_action_unsupported": "Esta ação da aplicação ainda não é suportada em dispositivos móveis",
"Apply_Certificate": "Aplicar certificado",
+ "Apps": "Aplicações",
"ARCHIVE": "Arquivar",
"archive": "arquivar",
"are_typing": "estão a escrever",
diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json
index 4b8dd52b4a6..deafd3a8bc6 100644
--- a/app/i18n/locales/ru.json
+++ b/app/i18n/locales/ru.json
@@ -61,7 +61,10 @@
"Animals_and_nature": "Животные и природа",
"Announcement": "Объявление",
"announcement": "объявление",
+ "App_action_error": "Не удалось выполнить это действие приложения",
+ "App_action_unsupported": "Это действие приложения пока не поддерживается в мобильной версии",
"Apply_Certificate": "Применить сертификат",
+ "Apps": "Приложения",
"ARCHIVE": "Архив",
"archive": "архив",
"are_typing": "печатают",
diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json
index e080591f5bd..1fb13375660 100644
--- a/app/i18n/locales/sl-SI.json
+++ b/app/i18n/locales/sl-SI.json
@@ -59,7 +59,10 @@
"Animals_and_nature": "Živali in narava",
"Announcement": "Obvestilo",
"announcement": "obvestilo",
+ "App_action_error": "Tega dejanja aplikacije ni bilo mogoče izvesti",
+ "App_action_unsupported": "To dejanje aplikacije še ni podprto v mobilni različici",
"Apply_Certificate": "Uporabi certifikat",
+ "Apps": "Aplikacije",
"ARCHIVE": "Arhiv",
"archive": "arhiv",
"are_typing": "pišejo",
diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json
index 748f4d7306a..91ff3d5db94 100644
--- a/app/i18n/locales/sv.json
+++ b/app/i18n/locales/sv.json
@@ -63,7 +63,10 @@
"Animals_and_nature": "Djur och natur",
"Announcement": "Meddelande",
"announcement": "meddelande",
+ "App_action_error": "Det gick inte att köra den här appåtgärden",
+ "App_action_unsupported": "Den här appåtgärden stöds ännu inte på mobilen",
"Apply_Certificate": "Tillämpa certifikat",
+ "Apps": "Appar",
"ARCHIVE": "Arkiv",
"archive": "arkiv",
"are_typing": "skriver",
diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json
index cd198bdc36b..78f5a7700af 100644
--- a/app/i18n/locales/ta-IN.json
+++ b/app/i18n/locales/ta-IN.json
@@ -70,7 +70,10 @@
"Animals_and_nature": "விலங்குகள் மற்றும் இயற்கை",
"Announcement": "அறிவிப்பு",
"announcement": "அறிவிப்பு",
+ "App_action_error": "இந்தப் பயன்பாட்டு செயலை இயக்க முடியவில்லை",
+ "App_action_unsupported": "இந்தப் பயன்பாட்டு செயல் இன்னும் மொபைலில் ஆதரிக்கப்படவில்லை",
"Apply_Certificate": "சான்றிதழைப் பயன்படுத்துங்கள்",
+ "Apps": "பயன்பாடுகள்",
"ARCHIVE": "சேமி",
"archive": "சேமி",
"are_typing": "எழுதுதல் நடந்துள்ளது",
diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json
index fe28f80a8ae..b58a6e99ddc 100644
--- a/app/i18n/locales/te-IN.json
+++ b/app/i18n/locales/te-IN.json
@@ -70,7 +70,10 @@
"Animals_and_nature": "జంతువులు మరియు ప్రకృతి",
"Announcement": "ప్రకటన",
"announcement": "ప్రకటన",
+ "App_action_error": "ఈ యాప్ చర్యను అమలు చేయలేకపోయాము",
+ "App_action_unsupported": "ఈ యాప్ చర్యకు ఇంకా మొబైల్లో మద్దతు లేదు",
"Apply_Certificate": "సర్టిఫికేట్ని వర్తింపజేయండి",
+ "Apps": "యాప్లు",
"ARCHIVE": "ఆర్కైవ్",
"archive": "ఆర్కైవ్",
"are_typing": "రాయడం",
diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json
index 4463fbd3357..2b9cfe113f0 100644
--- a/app/i18n/locales/tr.json
+++ b/app/i18n/locales/tr.json
@@ -51,7 +51,10 @@
"Animals_and_nature": "Hayvanlar ve doğa",
"Announcement": "Duyuru",
"announcement": "duyuru",
+ "App_action_error": "Bu uygulama eylemi çalıştırılamadı",
+ "App_action_unsupported": "Bu uygulama eylemi henüz mobilde desteklenmiyor",
"Apply_Certificate": "Sertifika uygula",
+ "Apps": "Uygulamalar",
"ARCHIVE": "Arşi̇vle",
"archive": "arşivle",
"are_typing": "yazıyor",
diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json
index 3bbaf33c32b..abdd0302928 100644
--- a/app/i18n/locales/zh-CN.json
+++ b/app/i18n/locales/zh-CN.json
@@ -51,7 +51,10 @@
"Animals_and_nature": "动物与自然",
"Announcement": "公告",
"announcement": "公告",
+ "App_action_error": "无法运行此应用操作",
+ "App_action_unsupported": "移动端暂不支持此应用操作",
"Apply_Certificate": "应用证书",
+ "Apps": "应用",
"ARCHIVE": "封存",
"archive": "封存",
"are_typing": "正在输入",
diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json
index 3c55c5326e7..bd1d87993c8 100644
--- a/app/i18n/locales/zh-TW.json
+++ b/app/i18n/locales/zh-TW.json
@@ -51,7 +51,10 @@
"Animals_and_nature": "動物與自然",
"Announcement": "公告",
"announcement": "公告",
+ "App_action_error": "無法執行此應用程式操作",
+ "App_action_unsupported": "行動版尚不支援此應用程式操作",
"Apply_Certificate": "應用證書",
+ "Apps": "應用程式",
"ARCHIVE": "封存",
"archive": "封存",
"are_typing": "正在輸入",
diff --git a/app/lib/methods/actions.test.ts b/app/lib/apps/__tests__/actions.test.ts
similarity index 85%
rename from app/lib/methods/actions.test.ts
rename to app/lib/apps/__tests__/actions.test.ts
index 42e168438a1..94aaf04928d 100644
--- a/app/lib/methods/actions.test.ts
+++ b/app/lib/apps/__tests__/actions.test.ts
@@ -1,24 +1,24 @@
import { ActionTypes, ModalActions } from '~/containers/UIKit/interfaces';
-import { generateTriggerId, handlePayloadUserInteraction, triggerAction } from './actions';
-import EventEmitter from './helpers/events';
-import fetch from './helpers/fetch';
-import Navigation from '../navigation/appNavigation';
+import { generateTriggerId, handlePayloadUserInteraction, triggerAction } from '../actions';
+import EventEmitter from '~/lib/methods/helpers/events';
+import fetch from '~/lib/methods/helpers/fetch';
+import Navigation from '~/lib/navigation/appNavigation';
-jest.mock('./helpers', () => ({
+jest.mock('~/lib/methods/helpers', () => ({
random: jest.fn(() => 'trigger-fixed-id')
}));
-jest.mock('./helpers/fetch', () => jest.fn());
+jest.mock('~/lib/methods/helpers/fetch', () => jest.fn());
-jest.mock('./helpers/events', () => ({
+jest.mock('~/lib/methods/helpers/events', () => ({
emit: jest.fn()
}));
-jest.mock('../navigation/appNavigation', () => ({
+jest.mock('~/lib/navigation/appNavigation', () => ({
navigate: jest.fn()
}));
-jest.mock('../services/sdk', () => ({
+jest.mock('~/lib/services/sdk', () => ({
__esModule: true,
default: {
currentLogin: {
@@ -196,7 +196,7 @@ describe('actions', () => {
status: 500
} as Response);
- await expect(triggerAction(actionInput)).rejects.toThrow('Failed to trigger action: 500');
+ await expect(triggerAction(actionInput)).rejects.toThrow('Failed to POST /api/apps/ui.interaction/app-id/: 500');
});
it('throws when response body is malformed JSON', async () => {
@@ -208,19 +208,28 @@ describe('actions', () => {
await expect(triggerAction(actionInput)).rejects.toThrow('Invalid JSON response from server');
});
- it('throws when response has unknown modal interaction type', async () => {
+ it('reports an unsupported surface this client cannot render', async () => {
mockedFetch.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
- type: 'unknown.legacy',
+ type: 'contextual_bar.open',
triggerId: 'trigger-fixed-id'
})
)
} as Response);
- await expect(triggerAction(actionInput)).rejects.toThrow('Unknown modal interaction type: unknown.legacy');
+ await expect(triggerAction(actionInput)).resolves.toBe(ModalActions.UNSUPPORTED);
+ });
+
+ it('reports no action when an app only acknowledges the interaction', async () => {
+ mockedFetch.mockResolvedValueOnce({
+ ok: true,
+ text: () => Promise.resolve(JSON.stringify({ success: true }))
+ } as Response);
+
+ await expect(triggerAction(actionInput)).resolves.toBeUndefined();
});
it('invalidates trigger id after processing', async () => {
diff --git a/app/lib/apps/__tests__/appsStore.test.ts b/app/lib/apps/__tests__/appsStore.test.ts
new file mode 100644
index 00000000000..3eb2fa22c6f
--- /dev/null
+++ b/app/lib/apps/__tests__/appsStore.test.ts
@@ -0,0 +1,275 @@
+import { subscribeToApps, useAppsStore } from '../appsStore';
+import { UIActionButtonContext } from '../definitions';
+
+const mockGetAppActionButtons = jest.fn(() =>
+ Promise.resolve([
+ { appId: 'app-id', actionId: 'summarize', context: UIActionButtonContext.MESSAGE_BOX_ACTION, labelI18n: 'summarize' }
+ ])
+);
+const mockGetAppsLanguages = jest.fn(() =>
+ Promise.resolve({ apps: [{ id: 'app-id', languages: { en: { summarize: 'Summarize' } } }] })
+);
+jest.mock('~/lib/services/restApi', () => ({
+ getAppActionButtons: () => mockGetAppActionButtons(),
+ getAppsLanguages: () => mockGetAppsLanguages()
+}));
+
+jest.mock('~/lib/methods/helpers/log', () => ({
+ __esModule: true,
+ default: jest.fn()
+}));
+
+const mockStop = jest.fn();
+const mockUnsubscribe = jest.fn(() => Promise.resolve());
+const mockOnStreamData = jest.fn((_event: string, _callback: (message: unknown) => void) => Promise.resolve({ stop: mockStop }));
+const mockSubscribe = jest.fn(() => Promise.resolve({ unsubscribe: mockUnsubscribe }));
+jest.mock('~/lib/services/sdk', () => ({
+ __esModule: true,
+ default: {
+ onStreamData: (event: string, callback: (message: unknown) => void) => mockOnStreamData(event, callback),
+ subscribe: (...args: unknown[]) => mockSubscribe(...(args as []))
+ }
+}));
+
+const mockState = { login: { isAuthenticated: false }, meteor: { connected: false } };
+const mockListeners = new Set<() => void>();
+jest.mock('~/lib/store/auxStore', () => ({
+ store: {
+ getState: () => mockState,
+ subscribe: (listener: () => void) => {
+ mockListeners.add(listener);
+ return () => mockListeners.delete(listener);
+ }
+ }
+}));
+
+const notify = () => mockListeners.forEach(listener => listener());
+
+const setLoginReady = (ready: boolean) => {
+ mockState.login.isAuthenticated = ready;
+ mockState.meteor.connected = ready;
+ notify();
+};
+
+/** Keeps the session authenticated so only the transport flaps, as it does on a socket drop. */
+const setConnected = (connected: boolean) => {
+ mockState.meteor.connected = connected;
+ notify();
+};
+
+const streamCallback = () => mockOnStreamData.mock.calls[0][1] as (message: unknown) => void;
+
+const flush = () => new Promise(resolve => setImmediate(resolve));
+
+describe('subscribeToApps', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockState.login.isAuthenticated = false;
+ mockState.meteor.connected = false;
+ mockListeners.clear();
+ useAppsStore.getState().reset();
+ });
+
+ it('waits for login to be ready before fetching and subscribing', async () => {
+ const dispose = subscribeToApps();
+
+ expect(mockGetAppActionButtons).not.toHaveBeenCalled();
+ expect(mockSubscribe).not.toHaveBeenCalled();
+
+ setLoginReady(true);
+ await flush();
+
+ expect(mockGetAppActionButtons).toHaveBeenCalledTimes(1);
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+ expect(useAppsStore.getState().actionButtons).toHaveLength(1);
+
+ dispose();
+ });
+
+ it('subscribes immediately when login is already ready', async () => {
+ setLoginReady(true);
+
+ const dispose = subscribeToApps();
+ await flush();
+
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+
+ dispose();
+ });
+
+ it('resubscribes on reconnect while the consumer stays mounted', async () => {
+ setLoginReady(true);
+ const dispose = subscribeToApps();
+ await flush();
+
+ setLoginReady(false);
+ await flush();
+
+ expect(mockStop).toHaveBeenCalledTimes(1);
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
+
+ setLoginReady(true);
+ await flush();
+
+ expect(mockSubscribe).toHaveBeenCalledTimes(2);
+ expect(mockGetAppActionButtons).toHaveBeenCalledTimes(2);
+
+ dispose();
+ });
+
+ it('retries on the next reconnect after a failed subscribe', async () => {
+ mockOnStreamData.mockImplementationOnce(() => {
+ throw new Error('Sdk is not initialized');
+ });
+
+ setLoginReady(true);
+ const dispose = subscribeToApps();
+ await flush();
+
+ expect(mockSubscribe).not.toHaveBeenCalled();
+
+ setLoginReady(false);
+ setLoginReady(true);
+ await flush();
+
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+
+ dispose();
+ });
+
+ it('resubscribes when only the transport flaps, with the session still authenticated', async () => {
+ setLoginReady(true);
+ const dispose = subscribeToApps();
+ await flush();
+
+ setConnected(false);
+ await flush();
+
+ expect(mockState.login.isAuthenticated).toBe(true);
+ expect(mockStop).toHaveBeenCalledTimes(1);
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
+
+ setConnected(true);
+ await flush();
+
+ expect(mockSubscribe).toHaveBeenCalledTimes(2);
+
+ dispose();
+ });
+
+ it('refetches only the action buttons on actions/changed', async () => {
+ setLoginReady(true);
+ const dispose = subscribeToApps();
+ await flush();
+ mockGetAppActionButtons.mockClear();
+ mockGetAppsLanguages.mockClear();
+
+ streamCallback()({ fields: { args: [['actions/changed', []]] } });
+ await flush();
+
+ expect(mockGetAppActionButtons).toHaveBeenCalledTimes(1);
+ expect(mockGetAppsLanguages).not.toHaveBeenCalled();
+
+ dispose();
+ });
+
+ it('refetches only the translations on app/added', async () => {
+ setLoginReady(true);
+ const dispose = subscribeToApps();
+ await flush();
+ mockGetAppActionButtons.mockClear();
+ mockGetAppsLanguages.mockClear();
+
+ streamCallback()({ fields: { args: [['app/added', ['app-id']]] } });
+ await flush();
+
+ expect(mockGetAppsLanguages).toHaveBeenCalledTimes(1);
+ expect(mockGetAppActionButtons).not.toHaveBeenCalled();
+
+ dispose();
+ });
+
+ it('retries a failed subscribe while the connection stays up', async () => {
+ jest.useFakeTimers({ doNotFake: ['setImmediate'] });
+ try {
+ mockSubscribe.mockImplementationOnce(() => Promise.reject(new Error('nosub')));
+
+ setLoginReady(true);
+ const dispose = subscribeToApps();
+ await flush();
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+
+ jest.advanceTimersByTime(2000);
+ await flush();
+ expect(mockSubscribe).toHaveBeenCalledTimes(2);
+
+ dispose();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('refetches the translations when an app is updated', async () => {
+ setLoginReady(true);
+ const dispose = subscribeToApps();
+ await flush();
+ mockGetAppsLanguages.mockClear();
+
+ streamCallback()({ fields: { args: [['app/updated', ['app-id']]] } });
+ await flush();
+
+ expect(mockGetAppsLanguages).toHaveBeenCalledTimes(1);
+
+ dispose();
+ });
+
+ it('keeps a single subscription while more than one consumer is mounted', async () => {
+ setLoginReady(true);
+ const first = subscribeToApps();
+ const second = subscribeToApps();
+ await flush();
+
+ expect(mockSubscribe).toHaveBeenCalledTimes(1);
+
+ first();
+ await flush();
+ expect(mockUnsubscribe).not.toHaveBeenCalled();
+
+ second();
+ await flush();
+ expect(mockStop).toHaveBeenCalledTimes(1);
+ expect(mockUnsubscribe).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe('useAppsStore reset', () => {
+ it('drops responses that resolve after a reset', async () => {
+ let resolveButtons: (value: unknown) => void = () => {};
+ let resolveLanguages: (value: unknown) => void = () => {};
+ mockGetAppActionButtons.mockImplementationOnce(() => new Promise(resolve => (resolveButtons = resolve)) as never);
+ mockGetAppsLanguages.mockImplementationOnce(() => new Promise(resolve => (resolveLanguages = resolve)) as never);
+
+ const buttons = useAppsStore.getState().fetchActionButtons();
+ const translations = useAppsStore.getState().fetchTranslations();
+ useAppsStore.getState().reset();
+ resolveButtons([{ appId: 'stale', actionId: 'a' }]);
+ resolveLanguages({ apps: [{ id: 'stale', languages: { en: { k: 'v' } } }] });
+ await Promise.all([buttons, translations]);
+
+ expect(useAppsStore.getState().actionButtons).toEqual([]);
+ expect(useAppsStore.getState().translations).toEqual({});
+ });
+
+ it('keeps the loaded data when a refetch fails', async () => {
+ await useAppsStore.getState().fetchActionButtons();
+ await useAppsStore.getState().fetchTranslations();
+ mockGetAppActionButtons.mockImplementationOnce(() => Promise.reject(new Error('503')));
+ mockGetAppsLanguages.mockImplementationOnce(() => Promise.reject(new Error('503')));
+
+ await useAppsStore.getState().fetchActionButtons();
+ await useAppsStore.getState().fetchTranslations();
+
+ expect(useAppsStore.getState().actionButtons).toHaveLength(1);
+ expect(useAppsStore.getState().translations).toEqual({ 'app-id': { en: { summarize: 'Summarize' } } });
+ });
+});
diff --git a/app/lib/apps/__tests__/filters.test.ts b/app/lib/apps/__tests__/filters.test.ts
new file mode 100644
index 00000000000..16990428e77
--- /dev/null
+++ b/app/lib/apps/__tests__/filters.test.ts
@@ -0,0 +1,91 @@
+import { type IAppActionButton, RoomTypeFilter, UIActionButtonContext } from '../definitions';
+import { applyAuthFilter, applyCategoryFilter, applyRoomFilter, collectPermissions } from '../filters';
+
+const button = (overrides: Partial = {}): IAppActionButton => ({
+ appId: 'app-id',
+ actionId: 'action-id',
+ context: UIActionButtonContext.MESSAGE_BOX_ACTION,
+ labelI18n: 'label',
+ ...overrides
+});
+
+describe('applyRoomFilter', () => {
+ test('a button with no roomTypes shows everywhere', () => {
+ expect(applyRoomFilter(button(), { t: 'd' })).toBe(true);
+ });
+
+ test.each([
+ [RoomTypeFilter.PUBLIC_CHANNEL, { t: 'c' }],
+ [RoomTypeFilter.PRIVATE_CHANNEL, { t: 'p' }],
+ [RoomTypeFilter.PUBLIC_TEAM, { t: 'c', teamMain: true }],
+ [RoomTypeFilter.PRIVATE_TEAM, { t: 'p', teamMain: true }],
+ [RoomTypeFilter.PUBLIC_DISCUSSION, { t: 'c', prid: 'parent' }],
+ [RoomTypeFilter.PRIVATE_DISCUSSION, { t: 'p', prid: 'parent' }],
+ [RoomTypeFilter.DIRECT, { t: 'd' }],
+ [RoomTypeFilter.DIRECT_MULTIPLE, { t: 'd', uids: ['a', 'b', 'c'] }],
+ [RoomTypeFilter.LIVE_CHAT, { t: 'l' }]
+ ])('%s matches its room', (roomType, room) => {
+ expect(applyRoomFilter(button({ when: { roomTypes: [roomType] } }), room)).toBe(true);
+ });
+
+ test('a team filter does not match a plain channel of the same type', () => {
+ expect(applyRoomFilter(button({ when: { roomTypes: [RoomTypeFilter.PUBLIC_TEAM] } }), { t: 'c' })).toBe(false);
+ });
+
+ test('a 1-on-1 DM is not a multiple direct message', () => {
+ expect(applyRoomFilter(button({ when: { roomTypes: [RoomTypeFilter.DIRECT_MULTIPLE] } }), { t: 'd', uids: ['a', 'b'] })).toBe(
+ false
+ );
+ });
+});
+
+describe('applyCategoryFilter', () => {
+ test('an uncategorised button belongs to default', () => {
+ expect(applyCategoryFilter(button(), 'default')).toBe(true);
+ expect(applyCategoryFilter(button(), 'ai')).toBe(false);
+ });
+
+ test('an ai button is kept out of default', () => {
+ expect(applyCategoryFilter(button({ category: 'ai' }), 'ai')).toBe(true);
+ expect(applyCategoryFilter(button({ category: 'ai' }), 'default')).toBe(false);
+ });
+});
+
+describe('applyAuthFilter', () => {
+ const auth = { roles: ['user', 'owner'], permissions: { 'pin-message': ['owner'], 'delete-p': ['admin'] } };
+
+ test('a button with no conditions always passes', () => {
+ expect(applyAuthFilter(button(), auth)).toBe(true);
+ });
+
+ test('hasOnePermission passes when any listed permission is granted by a held role', () => {
+ expect(applyAuthFilter(button({ when: { hasOnePermission: ['delete-p', 'pin-message'] } }), auth)).toBe(true);
+ expect(applyAuthFilter(button({ when: { hasOnePermission: ['delete-p'] } }), auth)).toBe(false);
+ });
+
+ test('hasAllPermissions requires every listed permission', () => {
+ expect(applyAuthFilter(button({ when: { hasAllPermissions: ['pin-message'] } }), auth)).toBe(true);
+ expect(applyAuthFilter(button({ when: { hasAllPermissions: ['pin-message', 'delete-p'] } }), auth)).toBe(false);
+ });
+
+ test('an unknown permission is treated as not granted', () => {
+ expect(applyAuthFilter(button({ when: { hasOnePermission: ['never-synced'] } }), auth)).toBe(false);
+ });
+
+ test('role conditions read the merged workspace and subscription roles', () => {
+ expect(applyAuthFilter(button({ when: { hasOneRole: ['owner'] } }), auth)).toBe(true);
+ expect(applyAuthFilter(button({ when: { hasAllRoles: ['user', 'owner'] } }), auth)).toBe(true);
+ expect(applyAuthFilter(button({ when: { hasAllRoles: ['user', 'admin'] } }), auth)).toBe(false);
+ });
+});
+
+describe('collectPermissions', () => {
+ test('gathers every referenced permission once', () => {
+ const buttons = [
+ button({ when: { hasOnePermission: ['a', 'b'] } }),
+ button({ when: { hasAllPermissions: ['b', 'c'] } }),
+ button()
+ ];
+ expect(collectPermissions(buttons).sort()).toEqual(['a', 'b', 'c']);
+ });
+});
diff --git a/app/lib/apps/__tests__/translations.test.ts b/app/lib/apps/__tests__/translations.test.ts
new file mode 100644
index 00000000000..02457036c82
--- /dev/null
+++ b/app/lib/apps/__tests__/translations.test.ts
@@ -0,0 +1,33 @@
+import { type TAppTranslations } from '../appsStore';
+import { translateAppKey } from '../translations';
+
+const translations: TAppTranslations = {
+ 'app-id': {
+ en: { greeting: 'Summarize' },
+ 'pt-br': { greeting: 'Resumir' },
+ pt: { greeting: 'Resumo' }
+ }
+};
+
+describe('translateAppKey', () => {
+ test('uses the exact locale when the app ships it', () => {
+ expect(translateAppKey({ appId: 'app-id', key: 'greeting', translations, locale: 'pt-BR' })).toBe('Resumir');
+ });
+
+ test('matches a locale the app spells differently', () => {
+ expect(translateAppKey({ appId: 'app-id', key: 'greeting', translations, locale: 'pt_br' })).toBe('Resumir');
+ });
+
+ test('falls back to the base language', () => {
+ expect(translateAppKey({ appId: 'app-id', key: 'greeting', translations, locale: 'pt-PT' })).toBe('Resumo');
+ });
+
+ test('falls back to english for an unshipped language', () => {
+ expect(translateAppKey({ appId: 'app-id', key: 'greeting', translations, locale: 'ja' })).toBe('Summarize');
+ });
+
+ test('falls back to the raw key for an unknown app or key', () => {
+ expect(translateAppKey({ appId: 'other-app', key: 'greeting', translations, locale: 'en' })).toBe('greeting');
+ expect(translateAppKey({ appId: 'app-id', key: 'missing', translations, locale: 'en' })).toBe('missing');
+ });
+});
diff --git a/app/lib/methods/triggerActions.test.ts b/app/lib/apps/__tests__/triggerActions.test.ts
similarity index 71%
rename from app/lib/methods/triggerActions.test.ts
rename to app/lib/apps/__tests__/triggerActions.test.ts
index 8f868114421..ac1bb3c0001 100644
--- a/app/lib/methods/triggerActions.test.ts
+++ b/app/lib/apps/__tests__/triggerActions.test.ts
@@ -1,13 +1,18 @@
import { ActionTypes, ModalActions } from '~/containers/UIKit/interfaces';
-import Navigation from '../navigation/appNavigation';
-import { triggerAction } from './actions';
-import { triggerBlockAction, triggerCancel, triggerSubmitView } from './triggerActions';
+import Navigation from '~/lib/navigation/appNavigation';
+import { triggerAction } from '../actions';
+import { triggerBlockAction, triggerCancel, triggerSubmitView } from '../triggerActions';
-jest.mock('../navigation/appNavigation', () => ({
+jest.mock('~/lib/navigation/appNavigation', () => ({
back: jest.fn()
}));
-jest.mock('./actions', () => ({
+const mockShowToast = jest.fn();
+jest.mock('~/lib/methods/helpers/showToast', () => ({
+ showToast: (message: string) => mockShowToast(message)
+}));
+
+jest.mock('../actions', () => ({
triggerAction: jest.fn()
}));
@@ -66,6 +71,15 @@ describe('triggerActions wrappers', () => {
expect(mockedBack).not.toHaveBeenCalled();
});
+ it('keeps the modal open and shows a toast for an unsupported response', async () => {
+ mockedTriggerAction.mockResolvedValueOnce(ModalActions.UNSUPPORTED);
+
+ await triggerSubmitView(submitInput as any);
+
+ expect(mockedBack).not.toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ });
+
it('does not go back for modal.open', async () => {
mockedTriggerAction.mockResolvedValueOnce(ModalActions.OPEN);
@@ -109,4 +123,12 @@ describe('triggerActions wrappers', () => {
...input
});
});
+
+ it('shows a toast when a block action gets an unsupported response', async () => {
+ mockedTriggerAction.mockResolvedValueOnce(ModalActions.UNSUPPORTED);
+
+ await expect(triggerBlockAction({ actionId: 'a', appId: 'app-id' } as any)).resolves.toBe(ModalActions.UNSUPPORTED);
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/app/lib/apps/__tests__/useAppActionButtons.test.ts b/app/lib/apps/__tests__/useAppActionButtons.test.ts
new file mode 100644
index 00000000000..e0f0371ff44
--- /dev/null
+++ b/app/lib/apps/__tests__/useAppActionButtons.test.ts
@@ -0,0 +1,207 @@
+import { act, renderHook, waitFor } from '@testing-library/react-native';
+
+import { type IAppActionButton, UIActionButtonContext } from '../definitions';
+import { useAppActionButtons } from '../useAppActionButtons';
+
+let mockActionButtons: IAppActionButton[] = [];
+const mockTranslations = { 'app-id': { en: { summarize: 'Summarize thread' } } };
+const mockSubscribeToApps = jest.fn(() => jest.fn());
+jest.mock('../appsStore', () => ({
+ subscribeToApps: () => mockSubscribeToApps(),
+ useAppsStore: (selector: (state: unknown) => unknown) =>
+ selector({ actionButtons: mockActionButtons, translations: mockTranslations })
+}));
+
+const { BehaviorSubject, ReplaySubject } = jest.requireActual('rxjs');
+let mockSubscriptions = new BehaviorSubject([{ t: 'c', roles: ['owner'] }]);
+let mockPermissionRecords = new BehaviorSubject([] as { id: string; roles: string[] }[]);
+jest.mock('~/lib/database', () => ({
+ __esModule: true,
+ default: {
+ get active() {
+ return {
+ get: (table: string) => ({
+ query: () => ({
+ observeWithColumns: () => (table === 'subscriptions' ? mockSubscriptions : mockPermissionRecords)
+ })
+ })
+ };
+ }
+ }
+}));
+
+let mockUserRoles: string[] = ['user'];
+let mockLanguage = 'en';
+jest.mock('~/lib/hooks/useAppSelector', () => ({
+ useAppSelector: (selector: (state: unknown) => unknown) =>
+ selector({ login: { user: { roles: mockUserRoles, language: mockLanguage } } })
+}));
+
+const button = (overrides: Partial = {}): IAppActionButton => ({
+ appId: 'app-id',
+ actionId: 'summarize',
+ context: UIActionButtonContext.MESSAGE_BOX_ACTION,
+ labelI18n: 'summarize',
+ ...overrides
+});
+
+const useMessageBox = (rid?: string) =>
+ useAppActionButtons({ filters: [{ context: UIActionButtonContext.MESSAGE_BOX_ACTION }], rid })[0];
+
+describe('useAppActionButtons', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockActionButtons = [];
+ mockSubscriptions = new BehaviorSubject([{ t: 'c', roles: ['owner'] }]);
+ mockPermissionRecords = new BehaviorSubject([]);
+ mockUserRoles = ['user'];
+ mockLanguage = 'en';
+ });
+
+ it('subscribes to the apps stream while mounted', () => {
+ const unsubscribe = jest.fn();
+ mockSubscribeToApps.mockReturnValueOnce(unsubscribe);
+
+ const { unmount } = renderHook(() => useMessageBox('rid'));
+
+ expect(mockSubscribeToApps).toHaveBeenCalledTimes(1);
+ unmount();
+ expect(unsubscribe).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns nothing until the room is resolved', () => {
+ mockActionButtons = [button()];
+ mockSubscriptions = new ReplaySubject(1);
+
+ const { result } = renderHook(() => useMessageBox('rid'));
+
+ expect(result.current).toEqual([]);
+
+ act(() => mockSubscriptions.next([{ t: 'c', roles: [] }]));
+
+ expect(result.current).toHaveLength(1);
+ });
+
+ it('labels a button with the app translation for the active locale', async () => {
+ mockActionButtons = [button()];
+
+ const { result } = renderHook(() => useMessageBox('rid'));
+
+ await waitFor(() => expect(result.current).toHaveLength(1));
+ expect(result.current[0]).toMatchObject({ id: 'app-id/summarize', label: 'Summarize thread' });
+ });
+
+ it('drops the previous room buttons until the new room resolves', async () => {
+ mockActionButtons = [button()];
+
+ const { result, rerender } = renderHook(({ rid }: { rid: string }) => useMessageBox(rid), { initialProps: { rid: 'rid' } });
+
+ await waitFor(() => expect(result.current).toHaveLength(1));
+
+ mockSubscriptions = new ReplaySubject(1);
+ rerender({ rid: 'other-rid' });
+
+ expect(result.current).toEqual([]);
+
+ act(() => mockSubscriptions.next([{ t: 'c', roles: [] }]));
+
+ expect(result.current).toHaveLength(1);
+ });
+
+ it('keeps only the requested context', async () => {
+ mockActionButtons = [button(), button({ actionId: 'other', context: UIActionButtonContext.ROOM_ACTION })];
+
+ const { result } = renderHook(() => useMessageBox('rid'));
+
+ await waitFor(() => expect(result.current).toHaveLength(1));
+ expect(result.current[0].button.actionId).toBe('summarize');
+ });
+
+ it('separates the ai category from the default one', async () => {
+ mockActionButtons = [button(), button({ actionId: 'ai-one', category: 'ai' })];
+
+ const { result } = renderHook(
+ () =>
+ useAppActionButtons({ filters: [{ context: UIActionButtonContext.MESSAGE_BOX_ACTION, category: 'ai' }], rid: 'rid' })[0]
+ );
+
+ await waitFor(() => expect(result.current).toHaveLength(1));
+ expect(result.current[0].button.actionId).toBe('ai-one');
+ });
+
+ it('drops a button whose room type does not match', async () => {
+ mockActionButtons = [
+ button({ when: { roomTypes: ['direct'] } }),
+ button({ actionId: 'channel-only', when: { roomTypes: ['public_channel'] } })
+ ];
+
+ const { result } = renderHook(() => useMessageBox('rid'));
+
+ await waitFor(() => expect(result.current).toHaveLength(1));
+ expect(result.current[0].button.actionId).toBe('channel-only');
+ });
+
+ it('resolves a permission against the roles the subscription and the user hold', async () => {
+ mockActionButtons = [button({ when: { hasOnePermission: ['pin-message'] } })];
+ mockPermissionRecords.next([{ id: 'pin-message', roles: ['owner'] }]);
+
+ const { result } = renderHook(() => useMessageBox('rid'));
+
+ await waitFor(() => expect(result.current).toHaveLength(1));
+
+ act(() => mockPermissionRecords.next([{ id: 'pin-message', roles: ['admin'] }]));
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('hides a role-gated button when the subscription loses the role', async () => {
+ mockActionButtons = [button({ when: { hasOneRole: ['owner'] } })];
+
+ const { result } = renderHook(() => useMessageBox('rid'));
+
+ await waitFor(() => expect(result.current).toHaveLength(1));
+
+ act(() => mockSubscriptions.next([{ t: 'c', roles: [] }]));
+
+ expect(result.current).toEqual([]);
+ });
+
+ it('shows a room-type button once the subscription is created', async () => {
+ mockActionButtons = [button({ when: { roomTypes: ['direct'] } })];
+ mockSubscriptions.next([]);
+
+ const { result } = renderHook(() => useMessageBox('rid'));
+
+ expect(result.current).toEqual([]);
+
+ act(() => mockSubscriptions.next([{ t: 'd', roles: [] }]));
+
+ expect(result.current).toHaveLength(1);
+ });
+
+ it('keeps every category when the filter names none', async () => {
+ mockActionButtons = [button(), button({ actionId: 'ai-one', category: 'ai' })];
+
+ const { result } = renderHook(() => useMessageBox('rid'));
+
+ await waitFor(() => expect(result.current).toHaveLength(2));
+ });
+
+ it('returns one list per filter', async () => {
+ mockActionButtons = [button(), button({ actionId: 'ai-room', context: UIActionButtonContext.ROOM_ACTION, category: 'ai' })];
+
+ const { result } = renderHook(() =>
+ useAppActionButtons({
+ filters: [
+ { context: UIActionButtonContext.ROOM_ACTION, category: 'ai' },
+ { context: UIActionButtonContext.MESSAGE_BOX_ACTION }
+ ],
+ rid: 'rid'
+ })
+ );
+
+ await waitFor(() => expect(result.current[0]).toHaveLength(1));
+ expect(result.current[0][0].button.actionId).toBe('ai-room');
+ expect(result.current[1][0].button.actionId).toBe('summarize');
+ });
+});
diff --git a/app/lib/methods/actions.ts b/app/lib/apps/actions.ts
similarity index 78%
rename from app/lib/methods/actions.ts
rename to app/lib/apps/actions.ts
index 870e112b745..aa0a503e636 100644
--- a/app/lib/methods/actions.ts
+++ b/app/lib/apps/actions.ts
@@ -2,11 +2,10 @@ import { type ServerInteraction } from '@rocket.chat/ui-kit';
import { type ITriggerAction, ModalActions, type TModalAction } from '~/containers/UIKit/interfaces';
import { toServerModalInteractionType, toUserInteraction } from '~/containers/UIKit/interactionAdapters';
-import EventEmitter from './helpers/events';
-import fetch from './helpers/fetch';
-import { random } from './helpers';
-import Navigation from '../navigation/appNavigation';
-import sdk from '../services/sdk';
+import EventEmitter from '~/lib/methods/helpers/events';
+import { random } from '~/lib/methods/helpers';
+import Navigation from '~/lib/navigation/appNavigation';
+import { fetchAppsApi } from '~/lib/services/restApi';
const triggersId = new Map();
@@ -100,6 +99,7 @@ export async function triggerAction({
appId,
rid,
mid,
+ tmid,
viewId,
container,
...rest
@@ -108,17 +108,13 @@ export async function triggerAction({
const payload = rest.payload ?? rest.value;
try {
- const { host, currentLogin } = sdk;
- if (!host || !currentLogin) {
- throw new Error('triggerAction requires an initialized, authenticated session');
- }
- const { userId, authToken } = currentLogin;
const interaction = toUserInteraction({
type,
actionId,
appId,
rid,
mid,
+ tmid,
viewId,
container,
payload,
@@ -129,20 +125,7 @@ export async function triggerAction({
triggerId
});
- // we need to use fetch because this.sdk.post add /v1 to url
- const result = await fetch(`${host}/api/apps/ui.interaction/${appId}/`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-Auth-Token': authToken,
- 'X-User-Id': userId
- },
- body: JSON.stringify(interaction)
- });
-
- if (!result.ok) {
- throw new Error(`Failed to trigger action: ${result.status}`);
- }
+ const result = await fetchAppsApi(`ui.interaction/${appId}/`, { method: 'POST', body: JSON.stringify(interaction) });
const text = await result.text();
if (!text || text.trim() === '') {
@@ -160,7 +143,10 @@ export async function triggerAction({
const { type: interactionType, ...data } = parsed;
const modalType = toServerModalInteractionType(interactionType ?? '');
if (!modalType) {
- throw new Error(`Unknown modal interaction type: ${interactionType ?? 'undefined'}`);
+ if (interactionType) {
+ return ModalActions.UNSUPPORTED;
+ }
+ return;
}
if (modalType === ModalActions.CLOSE) {
return ModalActions.CLOSE;
diff --git a/app/lib/apps/appsStore.ts b/app/lib/apps/appsStore.ts
new file mode 100644
index 00000000000..673aced9291
--- /dev/null
+++ b/app/lib/apps/appsStore.ts
@@ -0,0 +1,203 @@
+import { create } from 'zustand';
+
+import { type IAppActionButton } from './definitions';
+import { normalizeLanguage } from './translations';
+import log from '~/lib/methods/helpers/log';
+import { getAppActionButtons, getAppsLanguages } from '~/lib/services/restApi';
+import sdk from '~/lib/services/sdk';
+import { isLoginReady } from '~/lib/services/waitForLoginReady';
+import { store } from '~/lib/store/auxStore';
+
+export type TAppTranslations = {
+ // appId -> language -> key -> translation
+ [appId: string]: { [language: string]: { [key: string]: string } };
+};
+
+type TAppsState = {
+ actionButtons: IAppActionButton[];
+ translations: TAppTranslations;
+};
+
+type TAppsActions = {
+ fetchActionButtons: () => Promise;
+ fetchTranslations: () => Promise;
+ reset: () => void;
+};
+
+const initialState: TAppsState = {
+ actionButtons: [],
+ translations: {}
+};
+
+let storeVersion = 0;
+
+export const useAppsStore = create(set => ({
+ ...initialState,
+
+ fetchActionButtons: async () => {
+ const version = storeVersion;
+ try {
+ const actionButtons = await getAppActionButtons();
+ if (version === storeVersion) {
+ set({ actionButtons });
+ }
+ } catch {
+ // Servers without the Apps framework reject this; keep whatever was already loaded.
+ }
+ },
+
+ fetchTranslations: async () => {
+ const version = storeVersion;
+ try {
+ const { apps } = await getAppsLanguages();
+ if (version !== storeVersion) {
+ return;
+ }
+ const translations = apps.reduce((acc, { id, languages }) => {
+ acc[id] = Object.entries(languages).reduce((byLanguage, [language, keys]) => {
+ byLanguage[normalizeLanguage(language)] = keys;
+ return byLanguage;
+ }, {});
+ return acc;
+ }, {});
+ set({ translations });
+ } catch {
+ // Servers without the Apps framework reject this; keep whatever was already loaded.
+ }
+ },
+
+ reset: () => {
+ storeVersion += 1;
+ set(initialState);
+ }
+}));
+
+const APPS_STREAM = 'stream-apps';
+const APPS_EVENT = 'apps';
+
+let consumers = 0;
+let generation = 0;
+let subscribed = false;
+let loginReady = false;
+let storeListener: (() => void) | null = null;
+let streamListener: Promise<{ stop: () => void }> | null = null;
+let streamSubscription: { unsubscribe: () => Promise } | null = null;
+let retryTimer: ReturnType | null = null;
+let retryAttempt = 0;
+
+const RETRY_BASE_MS = 2000;
+const RETRY_MAX_MS = 60000;
+
+const clearRetry = () => {
+ if (retryTimer) {
+ clearTimeout(retryTimer);
+ retryTimer = null;
+ }
+};
+
+const handleStreamData = (ddpMessage: { fields?: { args?: [[string, unknown[]]] } }) => {
+ const [event] = ddpMessage?.fields?.args?.[0] || [];
+ const { fetchActionButtons, fetchTranslations } = useAppsStore.getState();
+ if (event === 'actions/changed') {
+ fetchActionButtons().catch(log);
+ }
+ // Translations ship with an app, so any install, update or removal can change them.
+ if (typeof event === 'string' && event.startsWith('app/')) {
+ fetchTranslations().catch(log);
+ }
+};
+
+const subscribeToStream = () => {
+ if (subscribed || consumers === 0) {
+ return;
+ }
+ subscribed = true;
+ const current = (generation += 1);
+ const { fetchActionButtons, fetchTranslations } = useAppsStore.getState();
+ fetchActionButtons().catch(log);
+ fetchTranslations().catch(log);
+ let listener: typeof streamListener = null;
+ const fail = (e: unknown) => {
+ log(e);
+ listener?.then(l => l.stop()).catch(log);
+ if (current !== generation) {
+ return;
+ }
+ subscribed = false;
+ streamListener = null;
+ clearRetry();
+ const delay = Math.min(RETRY_BASE_MS * 2 ** retryAttempt, RETRY_MAX_MS);
+ retryAttempt += 1;
+ retryTimer = setTimeout(() => {
+ retryTimer = null;
+ if (current === generation && loginReady) {
+ subscribeToStream();
+ }
+ }, delay);
+ };
+ try {
+ listener = sdk.onStreamData(APPS_STREAM, handleStreamData);
+ streamListener = listener;
+ sdk
+ .subscribe(APPS_STREAM, APPS_EVENT)
+ .then(subscription => {
+ // The last consumer may have unmounted while this was in flight.
+ if (current !== generation || consumers === 0) {
+ subscription?.unsubscribe().catch(log);
+ return;
+ }
+ streamSubscription = subscription ?? null;
+ retryAttempt = 0;
+ })
+ .catch(fail);
+ } catch (e) {
+ fail(e);
+ }
+};
+
+const unsubscribeFromStream = () => {
+ generation += 1;
+ clearRetry();
+ retryAttempt = 0;
+ subscribed = false;
+ streamListener?.then(listener => listener.stop()).catch(log);
+ streamListener = null;
+ streamSubscription?.unsubscribe().catch(log);
+ streamSubscription = null;
+};
+
+// The composer can mount before the first connection, and `disconnect()` drops both the SDK and the
+// store, so setup follows the connection rather than the mount.
+const handleStoreChange = () => {
+ const ready = isLoginReady();
+ if (ready === loginReady) {
+ return;
+ }
+ loginReady = ready;
+ if (ready) {
+ subscribeToStream();
+ } else {
+ unsubscribeFromStream();
+ }
+};
+
+export const subscribeToApps = (): (() => void) => {
+ consumers += 1;
+ if (consumers === 1) {
+ loginReady = isLoginReady();
+ storeListener = store.subscribe(handleStoreChange);
+ if (loginReady) {
+ subscribeToStream();
+ }
+ }
+
+ return () => {
+ consumers = Math.max(consumers - 1, 0);
+ if (consumers > 0) {
+ return;
+ }
+ storeListener?.();
+ storeListener = null;
+ unsubscribeFromStream();
+ };
+};
diff --git a/app/lib/apps/definitions.ts b/app/lib/apps/definitions.ts
new file mode 100644
index 00000000000..657c253ec03
--- /dev/null
+++ b/app/lib/apps/definitions.ts
@@ -0,0 +1,65 @@
+/**
+ * Mirror of `@rocket.chat/apps-engine/definition/ui`, which isn't a dependency here.
+ * Keep in sync with the server: packages/apps-engine/src/definition/ui.
+ */
+
+export const UIActionButtonContext = {
+ MESSAGE_ACTION: 'messageAction',
+ ROOM_ACTION: 'roomAction',
+ MESSAGE_BOX_ACTION: 'messageBoxAction',
+ USER_DROPDOWN_ACTION: 'userDropdownAction',
+ ROOM_SIDEBAR_ACTION: 'roomSideBarAction'
+} as const;
+
+export type TUIActionButtonContext = (typeof UIActionButtonContext)[keyof typeof UIActionButtonContext];
+
+export const RoomTypeFilter = {
+ PUBLIC_CHANNEL: 'public_channel',
+ PRIVATE_CHANNEL: 'private_channel',
+ PUBLIC_TEAM: 'public_team',
+ PRIVATE_TEAM: 'private_team',
+ PUBLIC_DISCUSSION: 'public_discussion',
+ PRIVATE_DISCUSSION: 'private_discussion',
+ DIRECT: 'direct',
+ DIRECT_MULTIPLE: 'direct_multiple',
+ LIVE_CHAT: 'livechat'
+} as const;
+
+export type TRoomTypeFilter = (typeof RoomTypeFilter)[keyof typeof RoomTypeFilter];
+
+export type TAppActionButtonCategory = 'default' | 'ai';
+
+export interface IAppActionButtonWhen {
+ roomTypes?: TRoomTypeFilter[];
+ messageActionContext?: string[];
+ hasOnePermission?: string[];
+ hasAllPermissions?: string[];
+ hasOneRole?: string[];
+ hasAllRoles?: string[];
+}
+
+export interface IAppActionButton {
+ appId: string;
+ actionId: string;
+ context: TUIActionButtonContext;
+ labelI18n: string;
+ variant?: 'danger';
+ when?: IAppActionButtonWhen;
+ category?: TAppActionButtonCategory;
+}
+
+export interface IAppActionButtonRoom {
+ t?: string;
+ teamMain?: boolean;
+ prid?: string;
+ uids?: string[];
+}
+
+export interface IAppLanguages {
+ apps: {
+ id: string;
+ languages: { [language: string]: { [key: string]: string } };
+ }[];
+}
+
+export const getIdForActionButton = ({ appId, actionId }: IAppActionButton): string => `${appId}/${actionId}`;
diff --git a/app/lib/apps/filters.ts b/app/lib/apps/filters.ts
new file mode 100644
index 00000000000..5da425c86bd
--- /dev/null
+++ b/app/lib/apps/filters.ts
@@ -0,0 +1,60 @@
+import {
+ type IAppActionButton,
+ type IAppActionButtonRoom,
+ type TAppActionButtonCategory,
+ type TRoomTypeFilter,
+ RoomTypeFilter
+} from './definitions';
+
+const isTeamRoom = ({ teamMain }: IAppActionButtonRoom): boolean => !!teamMain;
+const isDiscussion = ({ prid }: IAppActionButtonRoom): boolean => !!prid;
+
+const roomTypeMatchers: { [K in TRoomTypeFilter]: (room: IAppActionButtonRoom) => boolean } = {
+ [RoomTypeFilter.PUBLIC_CHANNEL]: room => room.t === 'c',
+ [RoomTypeFilter.PRIVATE_CHANNEL]: room => room.t === 'p',
+ [RoomTypeFilter.PUBLIC_TEAM]: room => isTeamRoom(room) && room.t === 'c',
+ [RoomTypeFilter.PRIVATE_TEAM]: room => isTeamRoom(room) && room.t === 'p',
+ [RoomTypeFilter.PUBLIC_DISCUSSION]: room => isDiscussion(room) && room.t === 'c',
+ [RoomTypeFilter.PRIVATE_DISCUSSION]: room => isDiscussion(room) && room.t === 'p',
+ [RoomTypeFilter.DIRECT]: room => room.t === 'd',
+ [RoomTypeFilter.DIRECT_MULTIPLE]: room => room.t === 'd' && (room.uids?.length ?? 0) > 2,
+ [RoomTypeFilter.LIVE_CHAT]: room => room.t === 'l'
+};
+
+export const applyRoomFilter = (button: IAppActionButton, room: IAppActionButtonRoom): boolean => {
+ const { roomTypes } = button.when || {};
+ return !roomTypes || roomTypes.some(filter => roomTypeMatchers[filter]?.(room));
+};
+
+export const applyCategoryFilter = (button: IAppActionButton, category: TAppActionButtonCategory): boolean => {
+ const { category: buttonCategory } = button;
+
+ if (category === 'default') {
+ return !buttonCategory || buttonCategory === 'default';
+ }
+
+ return buttonCategory === category;
+};
+
+export interface IAppActionButtonAuth {
+ roles: string[];
+ permissions: { [permission: string]: string[] };
+}
+
+export const applyAuthFilter = (button: IAppActionButton, { roles, permissions }: IAppActionButtonAuth): boolean => {
+ const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {};
+
+ const grants = (permission: string): boolean => (permissions[permission] ?? []).some(role => roles.includes(role));
+ const holds = (role: string): boolean => roles.includes(role);
+
+ return (
+ (!hasAllPermissions || hasAllPermissions.every(grants)) &&
+ (!hasOnePermission || hasOnePermission.some(grants)) &&
+ (!hasAllRoles || hasAllRoles.every(holds)) &&
+ (!hasOneRole || hasOneRole.some(holds))
+ );
+};
+
+export const collectPermissions = (buttons: IAppActionButton[]): string[] => [
+ ...new Set(buttons.flatMap(({ when }) => [...(when?.hasAllPermissions ?? []), ...(when?.hasOnePermission ?? [])]))
+];
diff --git a/app/lib/apps/translations.ts b/app/lib/apps/translations.ts
new file mode 100644
index 00000000000..d4a122c1775
--- /dev/null
+++ b/app/lib/apps/translations.ts
@@ -0,0 +1,33 @@
+import { type TAppTranslations } from './appsStore';
+import i18n from '~/i18n';
+
+export const normalizeLanguage = (language: string) => language.toLowerCase().replace('_', '-');
+
+export const translateAppKey = ({
+ appId,
+ key,
+ translations,
+ locale = i18n.locale
+}: {
+ appId: string;
+ key: string;
+ translations: TAppTranslations;
+ locale?: string;
+}): string => {
+ const languages = translations[appId];
+ if (!languages) {
+ return key;
+ }
+
+ const normalized = normalizeLanguage(locale);
+ const candidates = [normalized, normalized.split('-')[0], 'en'];
+
+ for (const candidate of candidates) {
+ const translation = languages[candidate]?.[key];
+ if (translation) {
+ return translation;
+ }
+ }
+
+ return key;
+};
diff --git a/app/lib/apps/triggerActions.ts b/app/lib/apps/triggerActions.ts
new file mode 100644
index 00000000000..efb7158049a
--- /dev/null
+++ b/app/lib/apps/triggerActions.ts
@@ -0,0 +1,33 @@
+import {
+ ActionTypes,
+ type ITriggerBlockAction,
+ type ITriggerCancel,
+ type ITriggerSubmitView,
+ ModalActions
+} from '~/containers/UIKit/interfaces';
+import I18n from '~/i18n';
+import { showToast } from '~/lib/methods/helpers/showToast';
+import Navigation from '~/lib/navigation/appNavigation';
+import { triggerAction } from './actions';
+
+const notifyUnsupported = (result: T): T => {
+ if (result === ModalActions.UNSUPPORTED) {
+ showToast(I18n.t('App_action_unsupported'));
+ }
+ return result;
+};
+
+export async function triggerSubmitView({ viewId, ...options }: ITriggerSubmitView) {
+ const result = notifyUnsupported(await triggerAction({ type: ActionTypes.SUBMIT, viewId, ...options }));
+ if (!result || ModalActions.CLOSE === result) {
+ Navigation.back();
+ }
+}
+
+export function triggerCancel({ view, ...options }: ITriggerCancel) {
+ return triggerAction({ type: ActionTypes.CLOSED, view, ...options });
+}
+
+export async function triggerBlockAction(options: ITriggerBlockAction) {
+ return notifyUnsupported(await triggerAction({ type: ActionTypes.ACTION, ...options }));
+}
diff --git a/app/lib/apps/triggerAppActionButton.ts b/app/lib/apps/triggerAppActionButton.ts
new file mode 100644
index 00000000000..e86c15d4ea0
--- /dev/null
+++ b/app/lib/apps/triggerAppActionButton.ts
@@ -0,0 +1,35 @@
+import { type IAppActionButton } from './definitions';
+import { ActionTypes, ModalActions } from '~/containers/UIKit/interfaces';
+import I18n from '~/i18n';
+import { triggerAction } from './actions';
+import { showToast } from '~/lib/methods/helpers/showToast';
+import log from '~/lib/methods/helpers/log';
+
+interface ITriggerAppActionButton {
+ button: IAppActionButton;
+ rid?: string;
+ tmid?: string;
+ mid?: string;
+ message?: string;
+}
+
+export const triggerAppActionButton = async ({ button, rid, tmid, mid, message }: ITriggerAppActionButton): Promise => {
+ try {
+ const result = await triggerAction({
+ type: ActionTypes.ACTION_BUTTON,
+ actionId: button.actionId,
+ appId: button.appId,
+ rid,
+ tmid,
+ mid,
+ payload: { context: button.context, ...(message !== undefined ? { message } : {}) }
+ });
+
+ if (result === ModalActions.UNSUPPORTED) {
+ showToast(I18n.t('App_action_unsupported'));
+ }
+ } catch (e) {
+ log(e);
+ showToast(I18n.t('App_action_error'));
+ }
+};
diff --git a/app/lib/apps/useAppActionButtons.ts b/app/lib/apps/useAppActionButtons.ts
new file mode 100644
index 00000000000..76ca6021fb4
--- /dev/null
+++ b/app/lib/apps/useAppActionButtons.ts
@@ -0,0 +1,155 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Q } from '@nozbe/watermelondb';
+import { shallowEqual } from 'react-redux';
+import { useShallow } from 'zustand/react/shallow';
+
+import { subscribeToApps, useAppsStore } from './appsStore';
+import {
+ getIdForActionButton,
+ type IAppActionButton,
+ type IAppActionButtonRoom,
+ type TAppActionButtonCategory,
+ type TUIActionButtonContext
+} from './definitions';
+import { applyAuthFilter, applyCategoryFilter, applyRoomFilter, collectPermissions } from './filters';
+import { translateAppKey } from './translations';
+import database from '~/lib/database';
+import { useAppSelector } from '~/lib/hooks/useAppSelector';
+import log from '~/lib/methods/helpers/log';
+import { getUserSelector } from '~/selectors/login';
+import { type TPermissionModel, type TSubscriptionModel } from '~/definitions';
+import i18n from '~/i18n';
+
+export interface IAppActionButtonItem {
+ id: string;
+ label: string;
+ button: IAppActionButton;
+}
+
+export interface IAppActionButtonFilter {
+ context: TUIActionButtonContext;
+ /** Leave out to accept every category. */
+ category?: TAppActionButtonCategory;
+}
+
+interface IRoomContext {
+ /** The rid this was resolved for, so a room change can't be filtered against the previous one. */
+ rid: string;
+ room: IAppActionButtonRoom;
+ roles: string[];
+}
+
+const splitKey = (key: string): string[] => (key ? key.split(',') : []);
+
+const useRoomContext = (rid?: string, enabled = true): IRoomContext | null => {
+ const [roomContext, setRoomContext] = useState(null);
+
+ useEffect(() => {
+ if (!rid || !enabled) {
+ return;
+ }
+ // Observing the query rather than one record picks up a subscription created after mount.
+ const subscription = database.active
+ .get('subscriptions')
+ .query(Q.where('id', rid))
+ .observeWithColumns(['roles', 't', 'team_main', 'prid', 'uids'])
+ .subscribe({
+ next: records => {
+ const [sub] = records as TSubscriptionModel[];
+ setRoomContext({
+ rid,
+ room: { t: sub?.t, teamMain: sub?.teamMain, prid: sub?.prid, uids: sub?.uids },
+ roles: sub?.roles ?? []
+ });
+ },
+ error: log
+ });
+ return () => subscription.unsubscribe();
+ }, [rid, enabled]);
+
+ return rid && roomContext?.rid === rid ? roomContext : null;
+};
+
+const usePermissionRoles = (permissionsKey: string): { [permission: string]: string[] } | null => {
+ const [state, setState] = useState<{ key: string; roles: { [permission: string]: string[] } } | null>(null);
+
+ useEffect(() => {
+ const ids = splitKey(permissionsKey);
+ if (!ids.length) {
+ return;
+ }
+ const subscription = database.active
+ .get('permissions')
+ .query(Q.where('id', Q.oneOf(ids)))
+ .observeWithColumns(['roles'])
+ .subscribe({
+ next: records => {
+ const roles = (records as TPermissionModel[]).reduce<{ [permission: string]: string[] }>((acc, record) => {
+ acc[record.id] = record.roles ?? [];
+ return acc;
+ }, {});
+ setState({ key: permissionsKey, roles });
+ },
+ error: log
+ });
+ return () => subscription.unsubscribe();
+ }, [permissionsKey]);
+
+ if (!permissionsKey) {
+ return {};
+ }
+ return state?.key === permissionsKey ? state.roles : null;
+};
+
+/** Returns one list of buttons per filter, in the same order. */
+export const useAppActionButtons = ({
+ filters,
+ rid
+}: {
+ filters: IAppActionButtonFilter[];
+ rid?: string;
+}): IAppActionButtonItem[][] => {
+ const contextsKey = filters.map(({ context }) => context).join(',');
+ const buttons = useAppsStore(
+ useShallow(state => {
+ const contexts = splitKey(contextsKey);
+ return state.actionButtons.filter(button => contexts.includes(button.context));
+ })
+ );
+ const translations = useAppsStore(state => state.translations);
+ const userRoles = useAppSelector(state => getUserSelector(state).roles || [], shallowEqual);
+ // Re-renders on a language change, so `i18n.locale` below is read fresh.
+ useAppSelector(state => getUserSelector(state).language);
+ const { locale } = i18n;
+
+ useEffect(subscribeToApps, []);
+
+ const hasButtons = buttons.length > 0;
+ const permissionsKey = useMemo(() => collectPermissions(buttons).join(','), [buttons]);
+ const roomContext = useRoomContext(rid, hasButtons);
+ const permissions = usePermissionRoles(permissionsKey);
+ const filtersKey = filters.map(({ context, category }) => `${context}:${category ?? ''}`).join(',');
+
+ return useMemo(() => {
+ const parsedFilters = splitKey(filtersKey).map(entry => {
+ const [context, category] = entry.split(':');
+ return { context, category: (category || undefined) as TAppActionButtonCategory | undefined };
+ });
+ if (!hasButtons || !permissions || (rid && !roomContext)) {
+ return parsedFilters.map(() => []);
+ }
+ const roles = [...new Set([...(roomContext?.roles ?? []), ...userRoles])];
+ const room = roomContext?.room ?? {};
+ const visible = buttons.filter(button => applyRoomFilter(button, room) && applyAuthFilter(button, { roles, permissions }));
+
+ return parsedFilters.map(({ context, category }) =>
+ visible
+ .filter(button => button.context === context && (!category || applyCategoryFilter(button, category)))
+ .map(button => ({
+ id: getIdForActionButton(button),
+ label: translateAppKey({ appId: button.appId, key: button.labelI18n, translations, locale }),
+ button
+ }))
+ );
+ }, [buttons, filtersKey, hasButtons, locale, permissions, rid, roomContext, translations, userRoles]);
+};
diff --git a/app/lib/methods/helpers/log/events.ts b/app/lib/methods/helpers/log/events.ts
index 8952e03379c..5b0d2f7e1c7 100644
--- a/app/lib/methods/helpers/log/events.ts
+++ b/app/lib/methods/helpers/log/events.ts
@@ -40,6 +40,7 @@ export default {
SIDEBAR_GO_PROFILE: 'sidebar_go_profile',
SIDEBAR_GO_SETTINGS: 'sidebar_go_settings',
SIDEBAR_GO_ADMINPANEL: 'sidebar_go_admin_panel',
+ SIDEBAR_APP_ACTION: 'sidebar_app_action',
// STATUS VIEW
STATUS_DONE: 'status_done',
diff --git a/app/lib/methods/subscriptions/rooms.test.ts b/app/lib/methods/subscriptions/rooms.test.ts
index 1e9993153af..bc78bf11d21 100644
--- a/app/lib/methods/subscriptions/rooms.test.ts
+++ b/app/lib/methods/subscriptions/rooms.test.ts
@@ -59,7 +59,7 @@ jest.mock('../getRoom', () => ({
getRoom: jest.fn()
}));
-jest.mock('../actions', () => ({
+jest.mock('~/lib/apps/actions', () => ({
handlePayloadUserInteraction: jest.fn()
}));
diff --git a/app/lib/methods/subscriptions/rooms.ts b/app/lib/methods/subscriptions/rooms.ts
index f25bd22ba9e..71f39cd2bff 100644
--- a/app/lib/methods/subscriptions/rooms.ts
+++ b/app/lib/methods/subscriptions/rooms.ts
@@ -7,7 +7,7 @@ import database from '~/lib/database';
import protectedFunction from '../helpers/protectedFunction';
import log from '../helpers/log';
import { store } from '~/lib/store/auxStore';
-import { handlePayloadUserInteraction } from '../actions';
+import { handlePayloadUserInteraction } from '~/lib/apps/actions';
import buildMessage from '../helpers/buildMessage';
import EventEmitter from '../helpers/events';
import { removedRoom } from '~/actions/room';
diff --git a/app/lib/methods/triggerActions.ts b/app/lib/methods/triggerActions.ts
deleted file mode 100644
index ea800a7151e..00000000000
--- a/app/lib/methods/triggerActions.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import {
- ActionTypes,
- type ITriggerBlockAction,
- type ITriggerCancel,
- type ITriggerSubmitView,
- ModalActions
-} from '~/containers/UIKit/interfaces';
-import Navigation from '../navigation/appNavigation';
-import { triggerAction } from './actions';
-
-export async function triggerSubmitView({ viewId, ...options }: ITriggerSubmitView) {
- const result = await triggerAction({ type: ActionTypes.SUBMIT, viewId, ...options });
- if (!result || ModalActions.CLOSE === result) {
- Navigation.back();
- }
-}
-
-export function triggerCancel({ view, ...options }: ITriggerCancel) {
- return triggerAction({ type: ActionTypes.CLOSED, view, ...options });
-}
-
-export function triggerBlockAction(options: ITriggerBlockAction) {
- return triggerAction({ type: ActionTypes.ACTION, ...options });
-}
diff --git a/app/lib/services/connect.test.ts b/app/lib/services/connect.test.ts
index e95b1f95a1a..ee2bef21081 100644
--- a/app/lib/services/connect.test.ts
+++ b/app/lib/services/connect.test.ts
@@ -1,5 +1,6 @@
import { connect, determineAuthType, disconnect, login, loginTOTP } from './connect';
import { mediaSessionInstance } from './voip/MediaSessionInstance';
+import { useAppsStore } from '../apps/appsStore';
import { pendingHangups } from './voip/pendingHangups';
import { setUser } from '~/actions/login';
import database from '../database';
@@ -428,6 +429,20 @@ describe('VoIP media session lifecycle (disconnect)', () => {
});
});
+describe('apps store lifecycle (disconnect)', () => {
+ it('clears the apps store when disconnect runs', () => {
+ useAppsStore.setState({
+ actionButtons: [{ appId: 'app-id', actionId: 'action-id', labelI18n: 'label', context: 'messageBoxAction' }],
+ translations: { 'app-id': { en: { label: 'Label' } } }
+ });
+
+ disconnect();
+
+ expect(useAppsStore.getState().actionButtons).toEqual([]);
+ expect(useAppsStore.getState().translations).toEqual({});
+ });
+});
+
describe('connect — pendingHangups drain on reconnect', () => {
beforeEach(() => {
jest.clearAllMocks();
diff --git a/app/lib/services/connect.ts b/app/lib/services/connect.ts
index f10ca47b24b..3058f76076a 100644
--- a/app/lib/services/connect.ts
+++ b/app/lib/services/connect.ts
@@ -13,6 +13,7 @@ import { loginRequest, logout, setLoginServices, setUser } from '~/actions/login
import { waitForLoginReady } from './waitForLoginReady';
import sdk, { type IStreamDataListener } from './sdk';
import { mediaSessionInstance } from './voip/MediaSessionInstance';
+import { useAppsStore } from '../apps/appsStore';
import { pendingHangups } from './voip/pendingHangups';
import I18n from '~/i18n';
import {
@@ -421,6 +422,7 @@ function abort(): void {
function disconnect(): void {
sdk.disconnect();
mediaSessionInstance.reset();
+ useAppsStore.getState().reset();
}
async function getWebsocketInfo({
diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts
index ee642ca2dfb..7c20833dcf0 100644
--- a/app/lib/services/restApi.ts
+++ b/app/lib/services/restApi.ts
@@ -18,6 +18,7 @@ import {
import { type TParams } from '~/definitions/ILivechatEditView';
import { type ILivechatTag } from '~/definitions/ILivechatTag';
import { type ISpotlight } from '~/definitions/ISpotlight';
+import { type IAppActionButton, type IAppLanguages } from '../apps/definitions';
import { TEAM_TYPE } from '~/definitions/ITeam';
import { type OperationParams, type ResultFor } from '~/definitions/rest/helpers';
import { type SubscriptionsEndpoints } from '~/definitions/rest/v1/subscriptions';
@@ -1281,3 +1282,33 @@ export const mediaCallsStateSignals = async (contractId: string): Promise<{ sign
return { signals: [], success: false };
}
};
+
+// The Apps endpoints live outside /v1, which the SDK always prefixes.
+export const fetchAppsApi = async (path: string, init: { method: 'GET' | 'POST'; body?: string }): Promise => {
+ const { host, currentLogin } = sdk;
+ if (!host || !currentLogin) {
+ throw new Error('The Apps REST API requires an initialized, authenticated session');
+ }
+ const { userId, authToken } = currentLogin;
+
+ const response = await fetch(`${host}/api/apps/${path}`, {
+ ...init,
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Auth-Token': authToken,
+ 'X-User-Id': userId
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to ${init.method} /api/apps/${path}: ${response.status}`);
+ }
+
+ return response;
+};
+
+const appsApiGet = async (path: string): Promise => (await fetchAppsApi(path, { method: 'GET' })).json();
+
+export const getAppActionButtons = (): Promise => appsApiGet('actionButtons');
+
+export const getAppsLanguages = (): Promise => appsApiGet('languages');
diff --git a/app/views/ModalBlockView.tsx b/app/views/ModalBlockView.tsx
index 290977e8dff..222af5755a7 100644
--- a/app/views/ModalBlockView.tsx
+++ b/app/views/ModalBlockView.tsx
@@ -13,7 +13,7 @@ import { textParser } from '../containers/UIKit/utils';
import Navigation from '../lib/navigation/appNavigation';
import { type MasterDetailInsideStackParamList } from '../stacks/MasterDetailStack/types';
import { ContainerTypes, ModalActions, type TModalAction } from '../containers/UIKit/interfaces';
-import { triggerBlockAction, triggerCancel, triggerSubmitView } from '../lib/methods/triggerActions';
+import { triggerBlockAction, triggerCancel, triggerSubmitView } from '~/lib/apps/triggerActions';
import { type IApplicationState, type TAnyMessageModel } from '../definitions';
import KeyboardView from '../containers/KeyboardView';
import { MessageRoomProvider } from '../containers/message/stores/MessageRoomStore';
diff --git a/app/views/RoomView/services/blockAction.ts b/app/views/RoomView/services/blockAction.ts
index 19dc33c0e69..07b91911dbd 100644
--- a/app/views/RoomView/services/blockAction.ts
+++ b/app/views/RoomView/services/blockAction.ts
@@ -1,4 +1,4 @@
-import { triggerBlockAction } from '~/lib/methods/triggerActions';
+import { triggerBlockAction } from '~/lib/apps/triggerActions';
import { ContainerTypes, type ITriggerBlockAction } from '~/containers/UIKit/interfaces';
type TBlockActionParams = Omit & { mid: string };
diff --git a/app/views/SidebarView/components/Apps.test.tsx b/app/views/SidebarView/components/Apps.test.tsx
new file mode 100644
index 00000000000..0fe04d08248
--- /dev/null
+++ b/app/views/SidebarView/components/Apps.test.tsx
@@ -0,0 +1,47 @@
+import { fireEvent, render } from '@testing-library/react-native';
+
+import Apps from './Apps';
+import { triggerAppActionButton } from '~/lib/apps/triggerAppActionButton';
+import { useAppActionButtons } from '~/lib/apps/useAppActionButtons';
+
+jest.mock('~/lib/apps/useAppActionButtons', () => ({ useAppActionButtons: jest.fn() }));
+jest.mock('~/lib/apps/triggerAppActionButton', () => ({ triggerAppActionButton: jest.fn() }));
+jest.mock('~/lib/methods/helpers/log', () => ({ events: { SIDEBAR_APP_ACTION: 'sidebar_app_action' }, logEvent: jest.fn() }));
+
+const mockedHook = useAppActionButtons as jest.Mock;
+const navigation = { closeDrawer: jest.fn() } as any;
+
+const button = { appId: 'app-1', actionId: 'open-thing', context: 'userDropdownAction', labelI18n: 'open_thing' };
+const item = { id: 'app-1/open-thing', label: 'Open thing', button };
+
+describe('Sidebar Apps', () => {
+ beforeEach(() => jest.clearAllMocks());
+
+ it('asks for user dropdown buttons', () => {
+ mockedHook.mockReturnValue([[]]);
+ render();
+ expect(mockedHook).toHaveBeenCalledWith({ filters: [{ context: 'userDropdownAction' }] });
+ });
+
+ it('renders nothing without buttons', () => {
+ mockedHook.mockReturnValue([[]]);
+ const { toJSON } = render();
+ expect(toJSON()).toBeNull();
+ });
+
+ it('renders one item per button with its label', () => {
+ mockedHook.mockReturnValue([[item]]);
+ const { getByTestId, getByText } = render();
+ expect(getByTestId('sidebar-app-app-1/open-thing')).toBeTruthy();
+ expect(getByText('Open thing')).toBeTruthy();
+ });
+
+ it('closes the drawer and triggers the app action', () => {
+ mockedHook.mockReturnValue([[item]]);
+ const { getByTestId } = render();
+ fireEvent.press(getByTestId('sidebar-app-app-1/open-thing'));
+
+ expect(navigation.closeDrawer).toHaveBeenCalled();
+ expect(triggerAppActionButton).toHaveBeenCalledWith({ button });
+ });
+});
diff --git a/app/views/SidebarView/components/Apps.tsx b/app/views/SidebarView/components/Apps.tsx
new file mode 100644
index 00000000000..f4dfa158a65
--- /dev/null
+++ b/app/views/SidebarView/components/Apps.tsx
@@ -0,0 +1,44 @@
+import { memo } from 'react';
+import { type DrawerNavigationProp } from '@react-navigation/drawer';
+
+import * as List from '~/containers/List';
+import { UIActionButtonContext } from '~/lib/apps/definitions';
+import { triggerAppActionButton } from '~/lib/apps/triggerAppActionButton';
+import { type IAppActionButtonItem, useAppActionButtons } from '~/lib/apps/useAppActionButtons';
+import { events, logEvent } from '~/lib/methods/helpers/log';
+import { type DrawerParamList } from '~/stacks/types';
+
+const SIDEBAR_APP_FILTERS = [{ context: UIActionButtonContext.USER_DROPDOWN_ACTION }];
+
+const Apps = ({ navigation }: { navigation: DrawerNavigationProp }) => {
+ const [appActions] = useAppActionButtons({ filters: SIDEBAR_APP_FILTERS });
+
+ if (!appActions.length) {
+ return null;
+ }
+
+ const onPress = ({ button }: IAppActionButtonItem) => {
+ logEvent(events.SIDEBAR_APP_ACTION, { appId: button.appId, actionId: button.actionId });
+ navigation.closeDrawer();
+ triggerAppActionButton({ button });
+ };
+
+ return (
+ <>
+
+ {appActions.map(item => (
+ }
+ onPress={() => onPress(item)}
+ testID={`sidebar-app-${item.id}`}
+ />
+ ))}
+
+ >
+ );
+};
+
+export default memo(Apps);
diff --git a/app/views/SidebarView/index.tsx b/app/views/SidebarView/index.tsx
index c65c6ba18ef..4741c6ed593 100644
--- a/app/views/SidebarView/index.tsx
+++ b/app/views/SidebarView/index.tsx
@@ -10,6 +10,7 @@ import CustomStatus from './components/CustomStatus';
import Stacks from './components/Stacks';
import Admin from './components/Admin';
import Profile from './components/Profile';
+import Apps from './components/Apps';
const SidebarView = ({ navigation }: { navigation: DrawerNavigationProp }) => {
const [currentScreen, setCurrentScreen] = useState(null);
@@ -30,6 +31,7 @@ const SidebarView = ({ navigation }: { navigation: DrawerNavigationProp
+
);