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 + );