From 3c9a70191682a8d25040fd3789ea02b58d6229bd Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 16 Sep 2026 17:34:20 -0300 Subject: [PATCH 01/29] feat: apps engine action buttons in the composer action sheet --- .../components/Buttons/ActionsButton.tsx | 30 ++++- .../Buttons/__tests__/ActionsButton.test.tsx | 125 ++++++++++++++++++ .../UIKit/interactionAdapters.test.ts | 54 ++++++++ app/containers/UIKit/interactionAdapters.ts | 17 +++ app/containers/UIKit/interfaces.ts | 7 +- app/lib/methods/actions.test.ts | 15 ++- app/lib/methods/triggerActions.ts | 2 +- 7 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx diff --git a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx index 5647826eab0..33d9066b8e5 100644 --- a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx +++ b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx @@ -10,12 +10,14 @@ 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, useAppActionButtons } from '~/lib/apps'; +import { triggerAppActionButton } from '~/lib/apps/triggerAppActionButton'; 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 +27,8 @@ export const ActionsButton = () => { }); const { showActionSheet, hideActionSheet } = useActionSheet(); const isMasterDetail = useMasterDetail(); + const appActions = useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid }); + const aiActions = useAppActionButtons({ context: UIActionButtonContext.ROOM_ACTION, category: 'ai', rid }); const createDiscussion = async () => { if (!rid) return; @@ -95,6 +99,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 }); + } + }); + }); + + 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..24d419f99fc --- /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', () => ({ + UIActionButtonContext: { MESSAGE_BOX_ACTION: 'messageBoxAction', ROOM_ACTION: 'roomAction' }, + useAppActionButtons: (params: unknown) => mockUseAppActionButtons(params) +})); + +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 only the room 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' }); + }); +}); diff --git a/app/containers/UIKit/interactionAdapters.test.ts b/app/containers/UIKit/interactionAdapters.test.ts index 18785a97c02..9986b2f35ec 100644 --- a/app/containers/UIKit/interactionAdapters.test.ts +++ b/app/containers/UIKit/interactionAdapters.test.ts @@ -76,6 +76,60 @@ describe('interactionAdapters', () => { } 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/lib/methods/actions.test.ts b/app/lib/methods/actions.test.ts index 42e168438a1..2a8be4dd503 100644 --- a/app/lib/methods/actions.test.ts +++ b/app/lib/methods/actions.test.ts @@ -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/methods/triggerActions.ts b/app/lib/methods/triggerActions.ts index ea800a7151e..40b1ac4cf2a 100644 --- a/app/lib/methods/triggerActions.ts +++ b/app/lib/methods/triggerActions.ts @@ -10,7 +10,7 @@ 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) { + if (!result || ModalActions.CLOSE === result || ModalActions.UNSUPPORTED === result) { Navigation.back(); } } From c7651591f9d4a6f6d885acce1063febc722e3fd0 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 16 Sep 2026 17:39:50 -0300 Subject: [PATCH 02/29] feat: apps engine action button store, filters and translations --- app/lib/apps/appsStore.ts | 116 +++++++++++++++++++++++++++++++++++ app/lib/apps/definitions.ts | 65 ++++++++++++++++++++ app/lib/apps/filters.ts | 60 ++++++++++++++++++ app/lib/apps/index.ts | 4 ++ app/lib/apps/translations.ts | 42 +++++++++++++ 5 files changed, 287 insertions(+) create mode 100644 app/lib/apps/appsStore.ts create mode 100644 app/lib/apps/definitions.ts create mode 100644 app/lib/apps/filters.ts create mode 100644 app/lib/apps/index.ts create mode 100644 app/lib/apps/translations.ts diff --git a/app/lib/apps/appsStore.ts b/app/lib/apps/appsStore.ts new file mode 100644 index 00000000000..fc10325cb14 --- /dev/null +++ b/app/lib/apps/appsStore.ts @@ -0,0 +1,116 @@ +import { create } from 'zustand'; + +import { type IAppActionButton } from './definitions'; +import { getAppActionButtons, getAppsLanguages } from './restApi'; +import log from '~/lib/methods/helpers/log'; +import sdk from '~/lib/services/sdk'; + +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: {} +}; + +export const useAppsStore = create(set => ({ + ...initialState, + + fetchActionButtons: async () => { + try { + set({ actionButtons: await getAppActionButtons() }); + } catch (e) { + set({ actionButtons: [] }); + log(e); + } + }, + + fetchTranslations: async () => { + try { + const { apps } = await getAppsLanguages(); + const translations = apps.reduce((acc, { id, languages }) => { + acc[id] = languages; + return acc; + }, {}); + set({ translations }); + } catch (e) { + set({ translations: {} }); + log(e); + } + }, + + reset: () => set(initialState) +})); + +const APPS_STREAM = 'stream-apps'; +const APPS_EVENT = 'apps'; + +let consumers = 0; +let generation = 0; +let streamListener: Promise<{ stop: () => void }> | null = null; +let streamSubscription: { unsubscribe: () => Promise } | null = 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); + } + if (event === 'app/added' || event === 'app/removed' || event === 'app/updated') { + fetchActionButtons().catch(log); + fetchTranslations().catch(log); + } +}; + + +export const subscribeToApps = (): (() => void) => { + consumers += 1; + if (consumers === 1) { + const current = (generation += 1); + const { fetchActionButtons, fetchTranslations } = useAppsStore.getState(); + fetchActionButtons().catch(log); + fetchTranslations().catch(log); + try { + // Both throw synchronously while no server is selected, the composer can mount first. + streamListener = sdk.onStreamData(APPS_STREAM, handleStreamData); + 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; + }) + .catch(log); + } catch (e) { + log(e); + } + } + + return () => { + consumers = Math.max(consumers - 1, 0); + if (consumers > 0) { + return; + } + generation += 1; + streamListener?.then(listener => listener.stop()).catch(log); + streamListener = null; + streamSubscription?.unsubscribe().catch(log); + streamSubscription = null; + }; +}; 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..2c6d4309052 --- /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) => !!teamMain; +const isDiscussion = ({ prid }: IAppActionButtonRoom) => !!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) => (permissions[permission] ?? []).some(role => roles.includes(role)); + const holds = (role: string) => 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/index.ts b/app/lib/apps/index.ts new file mode 100644 index 00000000000..2341bf59b03 --- /dev/null +++ b/app/lib/apps/index.ts @@ -0,0 +1,4 @@ +export * from './definitions'; +export * from './appsStore'; +export * from './triggerAppActionButton'; +export * from './useAppActionButtons'; diff --git a/app/lib/apps/translations.ts b/app/lib/apps/translations.ts new file mode 100644 index 00000000000..660117e9457 --- /dev/null +++ b/app/lib/apps/translations.ts @@ -0,0 +1,42 @@ +import { type TAppTranslations } from './appsStore'; +import i18n from '~/i18n'; + +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 byLanguage = Object.entries(languages).reduce<{ [language: string]: { [key: string]: string } }>( + (acc, [language, keys]) => { + acc[normalizeLanguage(language)] = keys; + return acc; + }, + {} + ); + + const normalized = normalizeLanguage(locale); + const candidates = [normalized, normalized.split('-')[0], 'en']; + + for (const candidate of candidates) { + const translation = byLanguage[candidate]?.[key]; + if (translation) { + return translation; + } + } + + return key; +}; From 6edb71e047853bd277d76644973832aed2f11f94 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 16 Sep 2026 20:41:16 +0000 Subject: [PATCH 03/29] chore: format code and fix lint issues --- app/lib/apps/appsStore.ts | 3 +-- app/lib/apps/translations.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/lib/apps/appsStore.ts b/app/lib/apps/appsStore.ts index fc10325cb14..efea3530ff8 100644 --- a/app/lib/apps/appsStore.ts +++ b/app/lib/apps/appsStore.ts @@ -6,7 +6,7 @@ import log from '~/lib/methods/helpers/log'; import sdk from '~/lib/services/sdk'; export type TAppTranslations = { - // appId -> language -> key -> translation + // appId -> language -> key -> translation [appId: string]: { [language: string]: { [key: string]: string } }; }; @@ -75,7 +75,6 @@ const handleStreamData = (ddpMessage: { fields?: { args?: [[string, unknown[]]] } }; - export const subscribeToApps = (): (() => void) => { consumers += 1; if (consumers === 1) { diff --git a/app/lib/apps/translations.ts b/app/lib/apps/translations.ts index 660117e9457..1aff9ad457a 100644 --- a/app/lib/apps/translations.ts +++ b/app/lib/apps/translations.ts @@ -3,7 +3,6 @@ import i18n from '~/i18n'; const normalizeLanguage = (language: string) => language.toLowerCase().replace('_', '-'); - export const translateAppKey = ({ appId, key, From 76b7475492e823815051f619a53831cd2bbc5c76 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 16 Sep 2026 17:44:43 -0300 Subject: [PATCH 04/29] feat: trigger apps engine action buttons --- app/lib/apps/appsStore.ts | 2 +- app/lib/apps/triggerAppActionButton.ts | 36 ++++++++++++++++++++++++++ app/lib/methods/actions.ts | 8 +++++- app/lib/services/restApi.ts | 29 +++++++++++++++++++++ 4 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 app/lib/apps/triggerAppActionButton.ts diff --git a/app/lib/apps/appsStore.ts b/app/lib/apps/appsStore.ts index efea3530ff8..d7fea4f57d7 100644 --- a/app/lib/apps/appsStore.ts +++ b/app/lib/apps/appsStore.ts @@ -1,8 +1,8 @@ import { create } from 'zustand'; import { type IAppActionButton } from './definitions'; -import { getAppActionButtons, getAppsLanguages } from './restApi'; import log from '~/lib/methods/helpers/log'; +import { getAppActionButtons, getAppsLanguages } from '~/lib/services/restApi'; import sdk from '~/lib/services/sdk'; export type TAppTranslations = { diff --git a/app/lib/apps/triggerAppActionButton.ts b/app/lib/apps/triggerAppActionButton.ts new file mode 100644 index 00000000000..e33843eb215 --- /dev/null +++ b/app/lib/apps/triggerAppActionButton.ts @@ -0,0 +1,36 @@ +import { type IAppActionButton } from './definitions'; +import { ActionTypes, ModalActions } from '~/containers/UIKit/interfaces'; +import { LISTENER } from '~/containers/Toast'; +import I18n from '~/i18n'; +import { triggerAction } from '~/lib/methods/actions'; +import EventEmitter from '~/lib/methods/helpers/events'; +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) { + EventEmitter.emit(LISTENER, { message: I18n.t('App_action_unsupported') }); + } + } catch (e) { + log(e); + EventEmitter.emit(LISTENER, { message: I18n.t('App_action_error') }); + } +}; diff --git a/app/lib/methods/actions.ts b/app/lib/methods/actions.ts index 870e112b745..7ac7c9b9739 100644 --- a/app/lib/methods/actions.ts +++ b/app/lib/methods/actions.ts @@ -5,6 +5,7 @@ import { toServerModalInteractionType, toUserInteraction } from '~/containers/UI import EventEmitter from './helpers/events'; import fetch from './helpers/fetch'; import { random } from './helpers'; +import log from './helpers/log'; import Navigation from '../navigation/appNavigation'; import sdk from '../services/sdk'; @@ -100,6 +101,7 @@ export async function triggerAction({ appId, rid, mid, + tmid, viewId, container, ...rest @@ -119,6 +121,7 @@ export async function triggerAction({ appId, rid, mid, + tmid, viewId, container, payload, @@ -160,7 +163,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/services/restApi.ts b/app/lib/services/restApi.ts index ee642ca2dfb..07b2453eb53 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,31 @@ export const mediaCallsStateSignals = async (contractId: string): Promise<{ sign return { signals: [], success: false }; } }; + +const appsApiGet = async (path: 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}`, { + method: 'GET', + headers: { + ...RocketChatSettings.customHeaders, + 'Content-Type': 'application/json', + 'X-Auth-Token': authToken, + 'X-User-Id': userId + } + }); + + if (!response.ok) { + throw new Error(`Failed to GET /api/apps/${path}: ${response.status}`); + } + + return response.json(); +}; + +export const getAppActionButtons = (): Promise => appsApiGet('actionButtons'); + +export const getAppsLanguages = (): Promise => appsApiGet('languages'); From 65d360091305b1edbff4cadb3629dcc3b345cf8f Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 16 Sep 2026 17:49:15 -0300 Subject: [PATCH 05/29] chore: new i18n labels --- app/i18n/locales/ar.json | 4 +++- app/i18n/locales/bn-IN.json | 4 +++- app/i18n/locales/cs.json | 4 +++- app/i18n/locales/de.json | 4 +++- app/i18n/locales/en.json | 2 ++ app/i18n/locales/es.json | 4 +++- app/i18n/locales/fi.json | 4 +++- app/i18n/locales/fr.json | 4 +++- app/i18n/locales/hi-IN.json | 4 +++- app/i18n/locales/hu.json | 4 +++- app/i18n/locales/it.json | 4 +++- app/i18n/locales/ja.json | 4 +++- app/i18n/locales/nl.json | 4 +++- app/i18n/locales/nn.json | 4 +++- app/i18n/locales/no.json | 4 +++- app/i18n/locales/pt-BR.json | 4 +++- app/i18n/locales/pt-PT.json | 4 +++- app/i18n/locales/ru.json | 4 +++- app/i18n/locales/sl-SI.json | 4 +++- app/i18n/locales/sv.json | 4 +++- app/i18n/locales/ta-IN.json | 4 +++- app/i18n/locales/te-IN.json | 4 +++- app/i18n/locales/tr.json | 4 +++- app/i18n/locales/zh-CN.json | 4 +++- app/i18n/locales/zh-TW.json | 4 +++- ios/Podfile.lock | 4 ++-- 26 files changed, 76 insertions(+), 26 deletions(-) diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index 9439f105e59..7475f69edef 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -53,6 +53,8 @@ "Announcement": "إعلان", "announcement": "إعلان", "Apply_Certificate": "تقديم الشهادة", + "App_action_error": "تعذر تشغيل إجراء التطبيق هذا", + "App_action_unsupported": "إجراء التطبيق هذا غير مدعوم على الهاتف المحمول حتى الآن", "ARCHIVE": "أرشفة", "archive": "أرشفة", "are_typing": "يكتب", @@ -723,4 +725,4 @@ "Your_password_is": "كلمة المرور الخاصة بك هي", "Your_Password_Must_Have": "يجب أن تحتوي كلمة المرور الخاصة بك على:", "Your_workspace": "مساحة عملك" -} \ No newline at end of file +} diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index 7402a97dc4d..c3edd1ecb90 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -71,6 +71,8 @@ "Announcement": "ঘোষণা", "announcement": "ঘোষণা", "Apply_Certificate": "সার্টিফিকেট প্রয়োগ করুন", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "সংরক্ষণাগার", "archive": "সংরক্ষণাগার", "are_typing": "টাইপ করছে", @@ -984,4 +986,4 @@ "Your_password_is": "আপনার পাসওয়ার্ড হল", "Your_Password_Must_Have": "আপনার পাসওয়ার্ড থাকতে হবে:", "Your_workspace": "আপনার ওয়ার্কস্পেস" -} \ No newline at end of file +} diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 199c40e0381..f634032a962 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -72,6 +72,8 @@ "Animals_and_nature": "Zvířata a příroda", "Announcement": "Oznámení", "announcement": "oznámení", + "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": "Uživatelé aplikace se nemohou přihlásit přímo.", "Apply_Certificate": "Použít certifikát", "ARCHIVE": "Archiv", @@ -1058,4 +1060,4 @@ "Your_Password_Must_Have": "Vaše heslo musí mít:", "Your_push_was_sent_to_s_devices": "Vaše push byla odeslána do {{s}} zařízení", "Your_workspace": "Váš pracovní prostor" -} \ No newline at end of file +} diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index 9e4e5a5a4d8..4199e5fa3ea 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -71,6 +71,8 @@ "Announcement": "Ankündigung", "announcement": "Ankündigung", "Apply_Certificate": "Zertifikat anwenden", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Archiv", "archive": "Archiv", "are_typing": "tippen", @@ -974,4 +976,4 @@ "Your_password_is": "Ihr Passwort lautet", "Your_Password_Must_Have": "Ihr Passwort muss:", "Your_workspace": "Ihr Arbeitsbereich" -} \ No newline at end of file +} diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 9fd5f9b0a34..6d812f3e160 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -73,6 +73,8 @@ "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", "ARCHIVE": "Archive", diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index da62e02988b..54a024e5ca1 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -49,6 +49,8 @@ "Announcement": "Anuncio", "announcement": "anuncio", "Apply_Certificate": "Aplicar certificado", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Fichero", "archive": "fichero", "are_typing": "están escribiendo", @@ -544,4 +546,4 @@ "You_will_not_be_able_to_recover_this_message": "¡No podrás recuperar este mensaje!", "Your_certificate": "Tu certificado", "Your_Password_Must_Have": "Su contraseña debe tener:" -} \ No newline at end of file +} diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 8fa95b736e7..878c0f41844 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -64,6 +64,8 @@ "Announcement": "Ilmoitus", "announcement": "ilmoitus", "Apply_Certificate": "Käytä sertifikaattia", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Arkisto", "archive": "arkisto", "are_typing": "kirjoittavat", @@ -946,4 +948,4 @@ "Your_password_is": "Salasanasi on", "Your_Password_Must_Have": "Salasanasi on oltava:", "Your_workspace": "Työtilasi" -} \ No newline at end of file +} diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index 91b03429ccf..f8428a73996 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -56,6 +56,8 @@ "Announcement": "Annonce", "announcement": "annonce", "Apply_Certificate": "Appliquer le certificat", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Archiver", "archive": "archiver", "are_typing": "sont en train d'écrire", @@ -868,4 +870,4 @@ "Your_password_is": "Votre mot de passe est", "Your_Password_Must_Have": "Votre mot de passe doit avoir:", "Your_workspace": "Votre espace de travail" -} \ No newline at end of file +} diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index de52a5998f1..8fd226d7972 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -71,6 +71,8 @@ "Announcement": "घोषणा", "announcement": "घोषणा", "Apply_Certificate": "प्रमाणपत्र लागू करें", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "संग्रह", "archive": "संग्रह", "are_typing": "टाइप कर रहे हैं", @@ -984,4 +986,4 @@ "Your_password_is": "आपका पासवर्ड है", "Your_Password_Must_Have": "आपका पासवर्ड होना चाहिए:", "Your_workspace": "आपका कार्यस्थान" -} \ No newline at end of file +} diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 52e10e5ded8..e06e158cee8 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -71,6 +71,8 @@ "Announcement": "Bejelentés", "announcement": "bejelentés", "Apply_Certificate": "Tanúsítvány alkalmazása", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Archívum", "archive": "archívum", "are_typing": "éppen ír", @@ -987,4 +989,4 @@ "Your_password_is": "A jelszava a következő", "Your_Password_Must_Have": "A jelszavának rendelkeznie kell:", "Your_workspace": "Az Ön munkaterülete" -} \ No newline at end of file +} diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index e57a0a868c1..79bb3245d03 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -57,6 +57,8 @@ "Announcement": "Annuncio", "announcement": "annuncio", "Apply_Certificate": "Applica certificato", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Archivio", "archive": "archivio", "are_typing": "stanno scrivendo", @@ -771,4 +773,4 @@ "Your_password_is": "La tua password è", "Your_Password_Must_Have": "La tua password deve avere:", "Your_workspace": "Il tuo workspace" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 292135e6973..387aefd4d5a 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -52,6 +52,8 @@ "Announcement": "アナウンス", "announcement": "アナウンス", "Apply_Certificate": "証明書を適用", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "アーカイブ", "archive": "アーカイブ", "are_typing": "が入力中", @@ -642,4 +644,4 @@ "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "招待リンクは{{date}}までか、あと{{usesLeft}}回で使用できなくなります。", "Your_invite_link_will_never_expire": "招待リンクはずっと有効です。", "Your_Password_Must_Have": "パスワードには次のことが必要です。" -} \ No newline at end of file +} diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 154f45b8274..298cba33b2e 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -56,6 +56,8 @@ "Announcement": "Aankondiging", "announcement": "aankondiging", "Apply_Certificate": "Certificaat toepassen", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Archiveer", "archive": "archiveer", "are_typing": "zijn aan het typen", @@ -868,4 +870,4 @@ "Your_password_is": "Jouw wachtwoord is", "Your_Password_Must_Have": "Uw wachtwoord moet hebben:", "Your_workspace": "Jouw werkruimte" -} \ No newline at end of file +} diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index 1903be41b3c..b61406d0714 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -38,6 +38,8 @@ "Alt_text_placeholder": "Legg til alternativ tekst...", "and": "og", "Announcement": "Kunngjøring", + "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": "Appbrukere har ikke lov til å logge inn direkte.", "ARCHIVE": "Arkiv", "are_typing": "skriver", @@ -491,4 +493,4 @@ "Your_invite_link_will_expire_after__usesLeft__uses": "Invitasjonslenken din utløper etter {{usesLeft}} anvendelser.", "Your_invite_link_will_expire_on__date__": "Invitasjonslenken din utløper {{date}}.", "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "Invitasjonskoblingen din utløper {{date}} eller etter {{usesLeft}} anvendelser." -} \ No newline at end of file +} diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 975694a1714..d4a69281fe6 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -71,6 +71,8 @@ "and_N_more": "Og {{count}} mer", "Announcement": "Kunngjøring", "announcement": "kunngjøring", + "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": "Appbrukere har ikke lov til å logge inn direkte.", "Apply_Certificate": "Bruke sertifikat", "ARCHIVE": "Arkiv", @@ -1033,4 +1035,4 @@ "Your_Password_Must_Have": "Passordet ditt må inneholde:", "Your_push_was_sent_to_s_devices": "Din push ble sendt til {{s}} enheter", "Your_workspace": "Arbeidsområdet ditt" -} \ No newline at end of file +} diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 8207718be7e..8e6ee84af1c 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -71,6 +71,8 @@ "Animals_and_nature": "Animais e natureza", "Announcement": "Anúncio", "announcement": "anúncio", + "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": "Usuários do aplicativo não estão autorizados a fazer login diretamente.", "Apply_Certificate": "Aplicar certificado", "ARCHIVE": "Arquivar", @@ -1070,4 +1072,4 @@ "Your_Password_Must_Have": "Sua senha deve conter:", "Your_push_was_sent_to_s_devices": "A sua notificação foi enviada para {{s}} dispositivos", "Your_workspace": "Sua workspace" -} \ No newline at end of file +} diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index 7af37603c07..eb70b7d2923 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -51,6 +51,8 @@ "Announcement": "Anúncio", "announcement": "anúncio", "Apply_Certificate": "Aplicar certificado", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Arquivar", "archive": "arquivar", "are_typing": "estão a escrever", @@ -603,4 +605,4 @@ "you_were_mentioned": "você foi mencionado", "You_will_not_be_able_to_recover_this_message": "Você será incapaz de recuperar esta mensagem!", "Your_Password_Must_Have": "Sua palavra-passe deve ter:" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index c4760411621..8c618f20363 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -62,6 +62,8 @@ "Announcement": "Объявление", "announcement": "объявление", "Apply_Certificate": "Применить сертификат", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Архив", "archive": "архив", "are_typing": "печатают", @@ -914,4 +916,4 @@ "Your_password_is": "Ваш пароль", "Your_Password_Must_Have": "Ваш пароль должен иметь:", "Your_workspace": "Ваш сервер" -} \ No newline at end of file +} diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 6ef4e13ab44..8430af3868e 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -60,6 +60,8 @@ "Announcement": "Obvestilo", "announcement": "obvestilo", "Apply_Certificate": "Uporabi certifikat", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Arhiv", "archive": "arhiv", "are_typing": "pišejo", @@ -883,4 +885,4 @@ "Your_password_is": "Vaše geslo je", "Your_Password_Must_Have": "Vaše geslo mora imeti:", "Your_workspace": "Vaš delovni prostor" -} \ No newline at end of file +} diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 6133d924135..800ab30dd3b 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -64,6 +64,8 @@ "Announcement": "Meddelande", "announcement": "meddelande", "Apply_Certificate": "Tillämpa certifikat", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Arkiv", "archive": "arkiv", "are_typing": "skriver", @@ -944,4 +946,4 @@ "Your_password_is": "Ditt lösenord är", "Your_Password_Must_Have": "Ditt lösenord måste ha:", "Your_workspace": "Din arbetsyta" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index fd2374b09c0..dc7f3609432 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -71,6 +71,8 @@ "Announcement": "அறிவிப்பு", "announcement": "அறிவிப்பு", "Apply_Certificate": "சான்றிதழைப் பயன்படுத்துங்கள்", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "சேமி", "archive": "சேமி", "are_typing": "எழுதுதல் நடந்துள்ளது", @@ -984,4 +986,4 @@ "Your_password_is": "உங்கள் கடவுச்சொல் உள்ளது", "Your_Password_Must_Have": "உங்கள் கடவுச்சொல் இருக்க வேண்டும்:", "Your_workspace": "உங்கள் பணிகள்" -} \ No newline at end of file +} diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index 4bee0d051b9..5a50c5716c6 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -71,6 +71,8 @@ "Announcement": "ప్రకటన", "announcement": "ప్రకటన", "Apply_Certificate": "సర్టిఫికేట్‌ని వర్తింపజేయండి", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "ఆర్కైవ్", "archive": "ఆర్కైవ్", "are_typing": "రాయడం", @@ -983,4 +985,4 @@ "Your_password_is": "మీ సంకేతపదం", "Your_Password_Must_Have": "మీ పాస్‌వర్డ్ తప్పనిసరిగా ఉండాలి:", "Your_workspace": "మీ వర్క్‌స్పేస్" -} \ No newline at end of file +} diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index f07d6b01fd7..b70b80f495d 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -52,6 +52,8 @@ "Announcement": "Duyuru", "announcement": "duyuru", "Apply_Certificate": "Sertifika uygula", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "Arşi̇vle", "archive": "arşivle", "are_typing": "yazıyor", @@ -754,4 +756,4 @@ "Your_password_is": "Şifreniz", "Your_Password_Must_Have": "Şifreniz:", "Your_workspace": "Çalışma alanınız" -} \ No newline at end of file +} diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 75a662179e0..1b3d7e6a4f6 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -52,6 +52,8 @@ "Announcement": "公告", "announcement": "公告", "Apply_Certificate": "应用证书", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "封存", "archive": "封存", "are_typing": "正在输入", @@ -714,4 +716,4 @@ "Your_password_is": "您的密码", "Your_Password_Must_Have": "您的密码必须具有:", "Your_workspace": "您的工作区" -} \ No newline at end of file +} diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index 78700ad3df7..9e011a5f9e6 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -52,6 +52,8 @@ "Announcement": "公告", "announcement": "公告", "Apply_Certificate": "應用證書", + "App_action_error": "Couldn't run this app action", + "App_action_unsupported": "This app action isn't supported on mobile yet", "ARCHIVE": "封存", "archive": "封存", "are_typing": "正在輸入", @@ -743,4 +745,4 @@ "Your_password_is": "您的密碼", "Your_Password_Must_Have": "您的密碼必須具有:", "Your_workspace": "您的工作區" -} \ No newline at end of file +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 4b134051245..cf427d200ce 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4153,7 +4153,7 @@ SPEC CHECKSUMS: React-timing: c39eeb992274aeaeb9f4666dc97a36a31d33fe94 React-utils: 2f9ba0088251788ad66aa1855ff99ed2424024d2 ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79 - ReactCodegen: 7fadc30654a54b3a0d62df08f55e2211e25486ee + ReactCodegen: e0a2012cda55dfd0d2e626ba5d11929c9ff5986c ReactCommon: 6d0fa86a4510730da7c72560e0ced14258292ab9 ReactNativeIncallManager: dccd3e7499caa3bb73d3acfedf4fb0360f1a87d5 RNBootSplash: 7fcc9a58ae343aeb1a1dd49f9030832fe432c544 @@ -4168,7 +4168,7 @@ SPEC CHECKSUMS: RNFBApp: a448037d2df74af9d374a0b765be12ff1e844dc0 RNFBCrashlytics: c3bb5533f9957eddc88f3ea383583309a4ce9f89 RNGestureHandler: b8d2e75c2e88fc2a1f6be3b3beeeed80b88fa37d - RNImageCropPicker: 0a63af4b79e514c1edd6c3152f19300c5ed85312 + RNImageCropPicker: 5432d75a3bc7209dc8b6ab189fcfe19bd6339fb9 RNKeychain: 76d042fc1dfba47f3945920bc82e0bce9ad1ff55 RNLocalize: c46dd4008dc8990a098fcafd6ee051122a1ccfda RNReanimated: e1690cdd7f215cfb96a3b7986b81889867dfdb4f From b241f43cf841ce7120b15837f1ad83758a4d1b29 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 16 Sep 2026 17:49:27 -0300 Subject: [PATCH 06/29] chore: unit tests --- app/lib/apps/__tests__/filters.test.ts | 91 +++++++++++++++++++++ app/lib/apps/__tests__/translations.test.ts | 33 ++++++++ 2 files changed, 124 insertions(+) create mode 100644 app/lib/apps/__tests__/filters.test.ts create mode 100644 app/lib/apps/__tests__/translations.test.ts 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..91de05f21ba --- /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'); + }); +}); From 5eb3910cfcc18cb72d200a930399daa2d9cde243 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 16 Sep 2026 17:50:00 -0300 Subject: [PATCH 07/29] feat: useAppActionButtons --- .../__tests__/useAppActionButtons.test.ts | 129 ++++++++++++++++++ app/lib/apps/useAppActionButtons.ts | 128 +++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 app/lib/apps/__tests__/useAppActionButtons.test.ts create mode 100644 app/lib/apps/useAppActionButtons.ts diff --git a/app/lib/apps/__tests__/useAppActionButtons.test.ts b/app/lib/apps/__tests__/useAppActionButtons.test.ts new file mode 100644 index 00000000000..713711e23a8 --- /dev/null +++ b/app/lib/apps/__tests__/useAppActionButtons.test.ts @@ -0,0 +1,129 @@ +import { 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 }) +})); + +let mockSubscription: Record | null = { t: 'c', roles: ['owner'] }; +jest.mock('~/lib/database/services/Subscription', () => ({ + getSubscriptionByRoomId: () => Promise.resolve(mockSubscription) +})); + +let mockPermissionRecords: { id: string; roles: string[] }[] = []; +jest.mock('~/lib/database', () => ({ + __esModule: true, + default: { + get active() { + return { + get: () => ({ + query: () => ({ fetch: () => Promise.resolve(mockPermissionRecords) }) + }) + }; + } + } +})); + +let mockUserRoles: string[] = ['user']; +jest.mock('~/lib/hooks/useAppSelector', () => ({ + useAppSelector: () => mockUserRoles +})); + +const button = (overrides: Partial = {}): IAppActionButton => ({ + appId: 'app-id', + actionId: 'summarize', + context: UIActionButtonContext.MESSAGE_BOX_ACTION, + labelI18n: 'summarize', + ...overrides +}); + +describe('useAppActionButtons', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockActionButtons = []; + mockSubscription = { t: 'c', roles: ['owner'] }; + mockPermissionRecords = []; + mockUserRoles = ['user']; + }); + + it('subscribes to the apps stream while mounted', () => { + const unsubscribe = jest.fn(); + mockSubscribeToApps.mockReturnValueOnce(unsubscribe); + + const { unmount } = renderHook(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid' })); + + expect(mockSubscribeToApps).toHaveBeenCalledTimes(1); + unmount(); + expect(unsubscribe).toHaveBeenCalledTimes(1); + }); + + it('returns nothing until the room is resolved', () => { + mockActionButtons = [button()]; + + const { result } = renderHook(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid' })); + + expect(result.current).toEqual([]); + }); + + it('labels a button with the app translation for the active locale', async () => { + mockActionButtons = [button()]; + + const { result } = renderHook(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid' })); + + await waitFor(() => expect(result.current).toHaveLength(1)); + expect(result.current[0]).toMatchObject({ id: 'app-id/summarize', label: 'Summarize thread' }); + }); + + it('keeps only the requested context', async () => { + mockActionButtons = [button(), button({ actionId: 'other', context: UIActionButtonContext.ROOM_ACTION })]; + + const { result } = renderHook(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: '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({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, category: 'ai', rid: 'rid' }) + ); + + 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(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: '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 = [{ id: 'pin-message', roles: ['owner'] }]; + + const { result } = renderHook(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid' })); + + await waitFor(() => expect(result.current).toHaveLength(1)); + + mockPermissionRecords = [{ id: 'pin-message', roles: ['admin'] }]; + const second = renderHook(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid-2' })); + + await waitFor(() => expect(second.result.current).toEqual([])); + }); +}); diff --git a/app/lib/apps/useAppActionButtons.ts b/app/lib/apps/useAppActionButtons.ts new file mode 100644 index 00000000000..8f1cd77172e --- /dev/null +++ b/app/lib/apps/useAppActionButtons.ts @@ -0,0 +1,128 @@ +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 { getSubscriptionByRoomId } from '~/lib/database/services/Subscription'; +import { useAppSelector } from '~/lib/hooks/useAppSelector'; +import log from '~/lib/methods/helpers/log'; +import { getUserSelector } from '~/selectors/login'; +import { type TPermissionModel } from '~/definitions'; + +const getPermissionRoles = async (ids: string[]): Promise<{ [permission: string]: string[] }> => { + if (!ids.length) { + return {}; + } + try { + const records = (await database.active + .get('permissions') + .query(Q.where('id', Q.oneOf(ids))) + .fetch()) as TPermissionModel[]; + return records.reduce<{ [permission: string]: string[] }>((acc, record) => { + acc[record.id] = record.roles ?? []; + return acc; + }, {}); + } catch (e) { + log(e); + return {}; + } +}; + +const splitKey = (key: string): string[] => (key ? key.split(',') : []); + +interface IAppActionButtonContext { + room: IAppActionButtonRoom; + roles: string[]; + permissions: { [permission: string]: string[] }; +} + +export interface IAppActionButtonItem { + id: string; + label: string; + button: IAppActionButton; +} + +export const useAppActionButtons = ({ + context, + category = 'default', + rid +}: { + context: TUIActionButtonContext; + category?: TAppActionButtonCategory; + rid?: string; +}): IAppActionButtonItem[] => { + const buttons = useAppsStore(useShallow(state => state.actionButtons.filter(button => button.context === context))); + const translations = useAppsStore(state => state.translations); + const userRoles = useAppSelector(state => getUserSelector(state).roles || [], shallowEqual); + + const [filterContext, setContext] = useState(null); + + useEffect(subscribeToApps, []); + + const permissionIds = useMemo(() => collectPermissions(buttons), [buttons]); + const permissionsKey = permissionIds.join(','); + const userRolesKey = userRoles.join(','); + const hasButtons = buttons.length > 0; + + useEffect(() => { + if (!hasButtons) { + return; + } + + let cancelled = false; + + const resolve = async () => { + const subscription = rid ? await getSubscriptionByRoomId(rid) : null; + const permissionRoles = await getPermissionRoles(splitKey(permissionsKey)); + if (cancelled) { + return; + } + setContext({ + room: { + t: subscription?.t, + teamMain: subscription?.teamMain, + prid: subscription?.prid, + uids: subscription?.uids + }, + roles: [...new Set([...(subscription?.roles ?? []), ...splitKey(userRolesKey)])], + permissions: permissionRoles + }); + }; + + resolve().catch(log); + + return () => { + cancelled = true; + }; + }, [hasButtons, rid, permissionsKey, userRolesKey]); + + return useMemo(() => { + if (!filterContext) { + return []; + } + const { room, roles, permissions } = filterContext; + return buttons + .filter( + button => + applyCategoryFilter(button, category) && + applyRoomFilter(button, room) && + applyAuthFilter(button, { roles, permissions }) + ) + .map(button => ({ + id: getIdForActionButton(button), + label: translateAppKey({ appId: button.appId, key: button.labelI18n, translations }), + button + })); + }, [buttons, category, filterContext, translations]); +}; From 57cec2184ed9fea0b90b6ddc349ac6285a467614 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 16 Sep 2026 20:51:08 +0000 Subject: [PATCH 08/29] action: organized translations --- app/i18n/locales/ar.json | 4 ++-- app/i18n/locales/bn-IN.json | 4 ++-- app/i18n/locales/cs.json | 2 +- app/i18n/locales/de.json | 4 ++-- app/i18n/locales/es.json | 4 ++-- app/i18n/locales/fi.json | 4 ++-- app/i18n/locales/fr.json | 4 ++-- app/i18n/locales/hi-IN.json | 4 ++-- app/i18n/locales/hu.json | 4 ++-- app/i18n/locales/it.json | 4 ++-- app/i18n/locales/ja.json | 4 ++-- app/i18n/locales/nl.json | 4 ++-- app/i18n/locales/nn.json | 2 +- app/i18n/locales/no.json | 2 +- app/i18n/locales/pt-BR.json | 2 +- app/i18n/locales/pt-PT.json | 4 ++-- app/i18n/locales/ru.json | 4 ++-- app/i18n/locales/sl-SI.json | 4 ++-- app/i18n/locales/sv.json | 4 ++-- app/i18n/locales/ta-IN.json | 4 ++-- app/i18n/locales/te-IN.json | 4 ++-- app/i18n/locales/tr.json | 4 ++-- app/i18n/locales/zh-CN.json | 4 ++-- app/i18n/locales/zh-TW.json | 4 ++-- 24 files changed, 44 insertions(+), 44 deletions(-) diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index 7475f69edef..06572a01d20 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -52,9 +52,9 @@ "Animals_and_nature": "الحيوانات والطبيعة", "Announcement": "إعلان", "announcement": "إعلان", - "Apply_Certificate": "تقديم الشهادة", "App_action_error": "تعذر تشغيل إجراء التطبيق هذا", "App_action_unsupported": "إجراء التطبيق هذا غير مدعوم على الهاتف المحمول حتى الآن", + "Apply_Certificate": "تقديم الشهادة", "ARCHIVE": "أرشفة", "archive": "أرشفة", "are_typing": "يكتب", @@ -725,4 +725,4 @@ "Your_password_is": "كلمة المرور الخاصة بك هي", "Your_Password_Must_Have": "يجب أن تحتوي كلمة المرور الخاصة بك على:", "Your_workspace": "مساحة عملك" -} +} \ No newline at end of file diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index c3edd1ecb90..c993c8477c3 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -70,9 +70,9 @@ "Animals_and_nature": "প্রাণী এবং প্রকৃতি", "Announcement": "ঘোষণা", "announcement": "ঘোষণা", - "Apply_Certificate": "সার্টিফিকেট প্রয়োগ করুন", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "সার্টিফিকেট প্রয়োগ করুন", "ARCHIVE": "সংরক্ষণাগার", "archive": "সংরক্ষণাগার", "are_typing": "টাইপ করছে", @@ -986,4 +986,4 @@ "Your_password_is": "আপনার পাসওয়ার্ড হল", "Your_Password_Must_Have": "আপনার পাসওয়ার্ড থাকতে হবে:", "Your_workspace": "আপনার ওয়ার্কস্পেস" -} +} \ No newline at end of file diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index f634032a962..7b32c2611ab 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -1060,4 +1060,4 @@ "Your_Password_Must_Have": "Vaše heslo musí mít:", "Your_push_was_sent_to_s_devices": "Vaše push byla odeslána do {{s}} zařízení", "Your_workspace": "Váš pracovní prostor" -} +} \ No newline at end of file diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index 4199e5fa3ea..0e30672a94c 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -70,9 +70,9 @@ "Animals_and_nature": "Tiere und Natur", "Announcement": "Ankündigung", "announcement": "Ankündigung", - "Apply_Certificate": "Zertifikat anwenden", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Zertifikat anwenden", "ARCHIVE": "Archiv", "archive": "Archiv", "are_typing": "tippen", @@ -976,4 +976,4 @@ "Your_password_is": "Ihr Passwort lautet", "Your_Password_Must_Have": "Ihr Passwort muss:", "Your_workspace": "Ihr Arbeitsbereich" -} +} \ No newline at end of file diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index 54a024e5ca1..5c9d5b5676b 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -48,9 +48,9 @@ "Animals_and_nature": "Animales y naturaleza", "Announcement": "Anuncio", "announcement": "anuncio", - "Apply_Certificate": "Aplicar certificado", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Aplicar certificado", "ARCHIVE": "Fichero", "archive": "fichero", "are_typing": "están escribiendo", @@ -546,4 +546,4 @@ "You_will_not_be_able_to_recover_this_message": "¡No podrás recuperar este mensaje!", "Your_certificate": "Tu certificado", "Your_Password_Must_Have": "Su contraseña debe tener:" -} +} \ No newline at end of file diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 878c0f41844..5d2fb40c495 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -63,9 +63,9 @@ "Animals_and_nature": "Eläimet ja luonto", "Announcement": "Ilmoitus", "announcement": "ilmoitus", - "Apply_Certificate": "Käytä sertifikaattia", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Käytä sertifikaattia", "ARCHIVE": "Arkisto", "archive": "arkisto", "are_typing": "kirjoittavat", @@ -948,4 +948,4 @@ "Your_password_is": "Salasanasi on", "Your_Password_Must_Have": "Salasanasi on oltava:", "Your_workspace": "Työtilasi" -} +} \ No newline at end of file diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index f8428a73996..0216e62bff0 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -55,9 +55,9 @@ "Animals_and_nature": "Animaux et nature", "Announcement": "Annonce", "announcement": "annonce", - "Apply_Certificate": "Appliquer le certificat", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Appliquer le certificat", "ARCHIVE": "Archiver", "archive": "archiver", "are_typing": "sont en train d'écrire", @@ -870,4 +870,4 @@ "Your_password_is": "Votre mot de passe est", "Your_Password_Must_Have": "Votre mot de passe doit avoir:", "Your_workspace": "Votre espace de travail" -} +} \ No newline at end of file diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 8fd226d7972..4b322e1c67e 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -70,9 +70,9 @@ "Animals_and_nature": "जानवर और प्रकृति", "Announcement": "घोषणा", "announcement": "घोषणा", - "Apply_Certificate": "प्रमाणपत्र लागू करें", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "प्रमाणपत्र लागू करें", "ARCHIVE": "संग्रह", "archive": "संग्रह", "are_typing": "टाइप कर रहे हैं", @@ -986,4 +986,4 @@ "Your_password_is": "आपका पासवर्ड है", "Your_Password_Must_Have": "आपका पासवर्ड होना चाहिए:", "Your_workspace": "आपका कार्यस्थान" -} +} \ No newline at end of file diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index e06e158cee8..fd7c4932e4e 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -70,9 +70,9 @@ "Animals_and_nature": "Állatok és természet", "Announcement": "Bejelentés", "announcement": "bejelentés", - "Apply_Certificate": "Tanúsítvány alkalmazása", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Tanúsítvány alkalmazása", "ARCHIVE": "Archívum", "archive": "archívum", "are_typing": "éppen ír", @@ -989,4 +989,4 @@ "Your_password_is": "A jelszava a következő", "Your_Password_Must_Have": "A jelszavának rendelkeznie kell:", "Your_workspace": "Az Ön munkaterülete" -} +} \ No newline at end of file diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index 79bb3245d03..c1704269769 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -56,9 +56,9 @@ "Animals_and_nature": "Animali e natura", "Announcement": "Annuncio", "announcement": "annuncio", - "Apply_Certificate": "Applica certificato", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Applica certificato", "ARCHIVE": "Archivio", "archive": "archivio", "are_typing": "stanno scrivendo", @@ -773,4 +773,4 @@ "Your_password_is": "La tua password è", "Your_Password_Must_Have": "La tua password deve avere:", "Your_workspace": "Il tuo workspace" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 387aefd4d5a..e364385ad11 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -51,9 +51,9 @@ "Animals_and_nature": "動物と自然", "Announcement": "アナウンス", "announcement": "アナウンス", - "Apply_Certificate": "証明書を適用", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "証明書を適用", "ARCHIVE": "アーカイブ", "archive": "アーカイブ", "are_typing": "が入力中", @@ -644,4 +644,4 @@ "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "招待リンクは{{date}}までか、あと{{usesLeft}}回で使用できなくなります。", "Your_invite_link_will_never_expire": "招待リンクはずっと有効です。", "Your_Password_Must_Have": "パスワードには次のことが必要です。" -} +} \ No newline at end of file diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 298cba33b2e..ed4d9eaa3e5 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -55,9 +55,9 @@ "Animals_and_nature": "Dieren en natuur", "Announcement": "Aankondiging", "announcement": "aankondiging", - "Apply_Certificate": "Certificaat toepassen", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Certificaat toepassen", "ARCHIVE": "Archiveer", "archive": "archiveer", "are_typing": "zijn aan het typen", @@ -870,4 +870,4 @@ "Your_password_is": "Jouw wachtwoord is", "Your_Password_Must_Have": "Uw wachtwoord moet hebben:", "Your_workspace": "Jouw werkruimte" -} +} \ No newline at end of file diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index b61406d0714..89fde0c3df1 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -493,4 +493,4 @@ "Your_invite_link_will_expire_after__usesLeft__uses": "Invitasjonslenken din utløper etter {{usesLeft}} anvendelser.", "Your_invite_link_will_expire_on__date__": "Invitasjonslenken din utløper {{date}}.", "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "Invitasjonskoblingen din utløper {{date}} eller etter {{usesLeft}} anvendelser." -} +} \ No newline at end of file diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index d4a69281fe6..6d5f4906b0b 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -1035,4 +1035,4 @@ "Your_Password_Must_Have": "Passordet ditt må inneholde:", "Your_push_was_sent_to_s_devices": "Din push ble sendt til {{s}} enheter", "Your_workspace": "Arbeidsområdet ditt" -} +} \ No newline at end of file diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 8e6ee84af1c..1e92b5351ad 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -1072,4 +1072,4 @@ "Your_Password_Must_Have": "Sua senha deve conter:", "Your_push_was_sent_to_s_devices": "A sua notificação foi enviada para {{s}} dispositivos", "Your_workspace": "Sua workspace" -} +} \ No newline at end of file diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index eb70b7d2923..5193401d9ac 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -50,9 +50,9 @@ "Animals_and_nature": "Animais e natureza", "Announcement": "Anúncio", "announcement": "anúncio", - "Apply_Certificate": "Aplicar certificado", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Aplicar certificado", "ARCHIVE": "Arquivar", "archive": "arquivar", "are_typing": "estão a escrever", @@ -605,4 +605,4 @@ "you_were_mentioned": "você foi mencionado", "You_will_not_be_able_to_recover_this_message": "Você será incapaz de recuperar esta mensagem!", "Your_Password_Must_Have": "Sua palavra-passe deve ter:" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 8c618f20363..330f69ed33c 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -61,9 +61,9 @@ "Animals_and_nature": "Животные и природа", "Announcement": "Объявление", "announcement": "объявление", - "Apply_Certificate": "Применить сертификат", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Применить сертификат", "ARCHIVE": "Архив", "archive": "архив", "are_typing": "печатают", @@ -916,4 +916,4 @@ "Your_password_is": "Ваш пароль", "Your_Password_Must_Have": "Ваш пароль должен иметь:", "Your_workspace": "Ваш сервер" -} +} \ No newline at end of file diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 8430af3868e..14c2f9b82ff 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -59,9 +59,9 @@ "Animals_and_nature": "Živali in narava", "Announcement": "Obvestilo", "announcement": "obvestilo", - "Apply_Certificate": "Uporabi certifikat", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Uporabi certifikat", "ARCHIVE": "Arhiv", "archive": "arhiv", "are_typing": "pišejo", @@ -885,4 +885,4 @@ "Your_password_is": "Vaše geslo je", "Your_Password_Must_Have": "Vaše geslo mora imeti:", "Your_workspace": "Vaš delovni prostor" -} +} \ No newline at end of file diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 800ab30dd3b..28faceb559d 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -63,9 +63,9 @@ "Animals_and_nature": "Djur och natur", "Announcement": "Meddelande", "announcement": "meddelande", - "Apply_Certificate": "Tillämpa certifikat", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Tillämpa certifikat", "ARCHIVE": "Arkiv", "archive": "arkiv", "are_typing": "skriver", @@ -946,4 +946,4 @@ "Your_password_is": "Ditt lösenord är", "Your_Password_Must_Have": "Ditt lösenord måste ha:", "Your_workspace": "Din arbetsyta" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index dc7f3609432..2603912a5a0 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -70,9 +70,9 @@ "Animals_and_nature": "விலங்குகள் மற்றும் இயற்கை", "Announcement": "அறிவிப்பு", "announcement": "அறிவிப்பு", - "Apply_Certificate": "சான்றிதழைப் பயன்படுத்துங்கள்", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "சான்றிதழைப் பயன்படுத்துங்கள்", "ARCHIVE": "சேமி", "archive": "சேமி", "are_typing": "எழுதுதல் நடந்துள்ளது", @@ -986,4 +986,4 @@ "Your_password_is": "உங்கள் கடவுச்சொல் உள்ளது", "Your_Password_Must_Have": "உங்கள் கடவுச்சொல் இருக்க வேண்டும்:", "Your_workspace": "உங்கள் பணிகள்" -} +} \ No newline at end of file diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index 5a50c5716c6..d454c9512c7 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -70,9 +70,9 @@ "Animals_and_nature": "జంతువులు మరియు ప్రకృతి", "Announcement": "ప్రకటన", "announcement": "ప్రకటన", - "Apply_Certificate": "సర్టిఫికేట్‌ని వర్తింపజేయండి", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "సర్టిఫికేట్‌ని వర్తింపజేయండి", "ARCHIVE": "ఆర్కైవ్", "archive": "ఆర్కైవ్", "are_typing": "రాయడం", @@ -985,4 +985,4 @@ "Your_password_is": "మీ సంకేతపదం", "Your_Password_Must_Have": "మీ పాస్‌వర్డ్ తప్పనిసరిగా ఉండాలి:", "Your_workspace": "మీ వర్క్‌స్పేస్" -} +} \ No newline at end of file diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index b70b80f495d..47d6085de03 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -51,9 +51,9 @@ "Animals_and_nature": "Hayvanlar ve doğa", "Announcement": "Duyuru", "announcement": "duyuru", - "Apply_Certificate": "Sertifika uygula", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "Sertifika uygula", "ARCHIVE": "Arşi̇vle", "archive": "arşivle", "are_typing": "yazıyor", @@ -756,4 +756,4 @@ "Your_password_is": "Şifreniz", "Your_Password_Must_Have": "Şifreniz:", "Your_workspace": "Çalışma alanınız" -} +} \ No newline at end of file diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 1b3d7e6a4f6..52dbbc84160 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -51,9 +51,9 @@ "Animals_and_nature": "动物与自然", "Announcement": "公告", "announcement": "公告", - "Apply_Certificate": "应用证书", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "应用证书", "ARCHIVE": "封存", "archive": "封存", "are_typing": "正在输入", @@ -716,4 +716,4 @@ "Your_password_is": "您的密码", "Your_Password_Must_Have": "您的密码必须具有:", "Your_workspace": "您的工作区" -} +} \ No newline at end of file diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index 9e011a5f9e6..7c960aacfff 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -51,9 +51,9 @@ "Animals_and_nature": "動物與自然", "Announcement": "公告", "announcement": "公告", - "Apply_Certificate": "應用證書", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apply_Certificate": "應用證書", "ARCHIVE": "封存", "archive": "封存", "are_typing": "正在輸入", @@ -745,4 +745,4 @@ "Your_password_is": "您的密碼", "Your_Password_Must_Have": "您的密碼必須具有:", "Your_workspace": "您的工作區" -} +} \ No newline at end of file From 0d1d666de403c36674e2305c88382761d7868b46 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 16 Sep 2026 17:57:08 -0300 Subject: [PATCH 09/29] fix: remove unused imports --- app/lib/methods/actions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/lib/methods/actions.ts b/app/lib/methods/actions.ts index 7ac7c9b9739..eb294e54bf5 100644 --- a/app/lib/methods/actions.ts +++ b/app/lib/methods/actions.ts @@ -5,7 +5,6 @@ import { toServerModalInteractionType, toUserInteraction } from '~/containers/UI import EventEmitter from './helpers/events'; import fetch from './helpers/fetch'; import { random } from './helpers'; -import log from './helpers/log'; import Navigation from '../navigation/appNavigation'; import sdk from '../services/sdk'; From 955b77fd9881d9326f57a88c26ee15e460c39925 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 16 Sep 2026 18:00:39 -0300 Subject: [PATCH 10/29] fix: translation --- app/i18n/locales/bn-IN.json | 6 +++--- app/i18n/locales/cs.json | 6 +++--- app/i18n/locales/de.json | 6 +++--- app/i18n/locales/es.json | 6 +++--- app/i18n/locales/fi.json | 6 +++--- app/i18n/locales/fr.json | 6 +++--- app/i18n/locales/hi-IN.json | 6 +++--- app/i18n/locales/hu.json | 6 +++--- app/i18n/locales/it.json | 6 +++--- app/i18n/locales/ja.json | 6 +++--- app/i18n/locales/nl.json | 6 +++--- app/i18n/locales/nn.json | 6 +++--- app/i18n/locales/no.json | 6 +++--- app/i18n/locales/pt-BR.json | 6 +++--- app/i18n/locales/pt-PT.json | 6 +++--- app/i18n/locales/ru.json | 6 +++--- app/i18n/locales/sl-SI.json | 6 +++--- app/i18n/locales/sv.json | 6 +++--- app/i18n/locales/ta-IN.json | 6 +++--- app/i18n/locales/te-IN.json | 6 +++--- app/i18n/locales/tr.json | 6 +++--- app/i18n/locales/zh-CN.json | 6 +++--- app/i18n/locales/zh-TW.json | 6 +++--- 23 files changed, 69 insertions(+), 69 deletions(-) diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index c993c8477c3..9ab9ed81a25 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -70,8 +70,8 @@ "Animals_and_nature": "প্রাণী এবং প্রকৃতি", "Announcement": "ঘোষণা", "announcement": "ঘোষণা", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "App_action_error": "এই অ্যাপ অ্যাকশনটি চালানো যায়নি", + "App_action_unsupported": "এই অ্যাপ অ্যাকশনটি এখনও মোবাইলে সমর্থিত নয়", "Apply_Certificate": "সার্টিফিকেট প্রয়োগ করুন", "ARCHIVE": "সংরক্ষণাগার", "archive": "সংরক্ষণাগার", @@ -986,4 +986,4 @@ "Your_password_is": "আপনার পাসওয়ার্ড হল", "Your_Password_Must_Have": "আপনার পাসওয়ার্ড থাকতে হবে:", "Your_workspace": "আপনার ওয়ার্কস্পেস" -} \ No newline at end of file +} diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 7b32c2611ab..71fce8ceb26 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -72,8 +72,8 @@ "Animals_and_nature": "Zvířata a příroda", "Announcement": "Oznámení", "announcement": "oznámení", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Archiv", @@ -1060,4 +1060,4 @@ "Your_Password_Must_Have": "Vaše heslo musí mít:", "Your_push_was_sent_to_s_devices": "Vaše push byla odeslána do {{s}} zařízení", "Your_workspace": "Váš pracovní prostor" -} \ No newline at end of file +} diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index 0e30672a94c..143b4daa279 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -70,8 +70,8 @@ "Animals_and_nature": "Tiere und Natur", "Announcement": "Ankündigung", "announcement": "Ankündigung", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Archiv", "archive": "Archiv", @@ -976,4 +976,4 @@ "Your_password_is": "Ihr Passwort lautet", "Your_Password_Must_Have": "Ihr Passwort muss:", "Your_workspace": "Ihr Arbeitsbereich" -} \ No newline at end of file +} diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index 5c9d5b5676b..8e90f829e60 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -48,8 +48,8 @@ "Animals_and_nature": "Animales y naturaleza", "Announcement": "Anuncio", "announcement": "anuncio", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Fichero", "archive": "fichero", @@ -546,4 +546,4 @@ "You_will_not_be_able_to_recover_this_message": "¡No podrás recuperar este mensaje!", "Your_certificate": "Tu certificado", "Your_Password_Must_Have": "Su contraseña debe tener:" -} \ No newline at end of file +} diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 5d2fb40c495..3bc27535d91 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -63,8 +63,8 @@ "Animals_and_nature": "Eläimet ja luonto", "Announcement": "Ilmoitus", "announcement": "ilmoitus", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "App_action_error": "Tätä sovellustoimintoa ei voitu suorittaa", + "App_action_unsupported": "Tätä sovellustoimintoa ei vielä tueta mobiililaitteilla", "Apply_Certificate": "Käytä sertifikaattia", "ARCHIVE": "Arkisto", "archive": "arkisto", @@ -948,4 +948,4 @@ "Your_password_is": "Salasanasi on", "Your_Password_Must_Have": "Salasanasi on oltava:", "Your_workspace": "Työtilasi" -} \ No newline at end of file +} diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index 0216e62bff0..a8dca245579 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -55,8 +55,8 @@ "Animals_and_nature": "Animaux et nature", "Announcement": "Annonce", "announcement": "annonce", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Archiver", "archive": "archiver", @@ -870,4 +870,4 @@ "Your_password_is": "Votre mot de passe est", "Your_Password_Must_Have": "Votre mot de passe doit avoir:", "Your_workspace": "Votre espace de travail" -} \ No newline at end of file +} diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 4b322e1c67e..9d3e12b6dfc 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -70,8 +70,8 @@ "Animals_and_nature": "जानवर और प्रकृति", "Announcement": "घोषणा", "announcement": "घोषणा", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "App_action_error": "यह ऐप कार्रवाई चल नहीं सकी", + "App_action_unsupported": "यह ऐप कार्रवाई अभी मोबाइल पर समर्थित नहीं है", "Apply_Certificate": "प्रमाणपत्र लागू करें", "ARCHIVE": "संग्रह", "archive": "संग्रह", @@ -986,4 +986,4 @@ "Your_password_is": "आपका पासवर्ड है", "Your_Password_Must_Have": "आपका पासवर्ड होना चाहिए:", "Your_workspace": "आपका कार्यस्थान" -} \ No newline at end of file +} diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index fd7c4932e4e..29d4c1dee46 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -70,8 +70,8 @@ "Animals_and_nature": "Állatok és természet", "Announcement": "Bejelentés", "announcement": "bejelentés", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Archívum", "archive": "archívum", @@ -989,4 +989,4 @@ "Your_password_is": "A jelszava a következő", "Your_Password_Must_Have": "A jelszavának rendelkeznie kell:", "Your_workspace": "Az Ön munkaterülete" -} \ No newline at end of file +} diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index c1704269769..8dcd88cc8e1 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -56,8 +56,8 @@ "Animals_and_nature": "Animali e natura", "Announcement": "Annuncio", "announcement": "annuncio", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Archivio", "archive": "archivio", @@ -773,4 +773,4 @@ "Your_password_is": "La tua password è", "Your_Password_Must_Have": "La tua password deve avere:", "Your_workspace": "Il tuo workspace" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index e364385ad11..ed6c515f51f 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -51,8 +51,8 @@ "Animals_and_nature": "動物と自然", "Announcement": "アナウンス", "announcement": "アナウンス", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "App_action_error": "このアプリ操作を実行できませんでした", + "App_action_unsupported": "このアプリ操作はまだモバイルではサポートされていません", "Apply_Certificate": "証明書を適用", "ARCHIVE": "アーカイブ", "archive": "アーカイブ", @@ -644,4 +644,4 @@ "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "招待リンクは{{date}}までか、あと{{usesLeft}}回で使用できなくなります。", "Your_invite_link_will_never_expire": "招待リンクはずっと有効です。", "Your_Password_Must_Have": "パスワードには次のことが必要です。" -} \ No newline at end of file +} diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index ed4d9eaa3e5..6f9d83ad1a3 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -55,8 +55,8 @@ "Animals_and_nature": "Dieren en natuur", "Announcement": "Aankondiging", "announcement": "aankondiging", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Archiveer", "archive": "archiveer", @@ -870,4 +870,4 @@ "Your_password_is": "Jouw wachtwoord is", "Your_Password_Must_Have": "Uw wachtwoord moet hebben:", "Your_workspace": "Jouw werkruimte" -} \ No newline at end of file +} diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index 89fde0c3df1..b1c5488a3ef 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -38,8 +38,8 @@ "Alt_text_placeholder": "Legg til alternativ tekst...", "and": "og", "Announcement": "Kunngjøring", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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.", "ARCHIVE": "Arkiv", "are_typing": "skriver", @@ -493,4 +493,4 @@ "Your_invite_link_will_expire_after__usesLeft__uses": "Invitasjonslenken din utløper etter {{usesLeft}} anvendelser.", "Your_invite_link_will_expire_on__date__": "Invitasjonslenken din utløper {{date}}.", "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "Invitasjonskoblingen din utløper {{date}} eller etter {{usesLeft}} anvendelser." -} \ No newline at end of file +} diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 6d5f4906b0b..5aa46e9346c 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -71,8 +71,8 @@ "and_N_more": "Og {{count}} mer", "Announcement": "Kunngjøring", "announcement": "kunngjøring", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Arkiv", @@ -1035,4 +1035,4 @@ "Your_Password_Must_Have": "Passordet ditt må inneholde:", "Your_push_was_sent_to_s_devices": "Din push ble sendt til {{s}} enheter", "Your_workspace": "Arbeidsområdet ditt" -} \ No newline at end of file +} diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 1e92b5351ad..8cbb778d061 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -71,8 +71,8 @@ "Animals_and_nature": "Animais e natureza", "Announcement": "Anúncio", "announcement": "anúncio", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Arquivar", @@ -1072,4 +1072,4 @@ "Your_Password_Must_Have": "Sua senha deve conter:", "Your_push_was_sent_to_s_devices": "A sua notificação foi enviada para {{s}} dispositivos", "Your_workspace": "Sua workspace" -} \ No newline at end of file +} diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index 5193401d9ac..9a8b87d89b8 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -50,8 +50,8 @@ "Animals_and_nature": "Animais e natureza", "Announcement": "Anúncio", "announcement": "anúncio", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Arquivar", "archive": "arquivar", @@ -605,4 +605,4 @@ "you_were_mentioned": "você foi mencionado", "You_will_not_be_able_to_recover_this_message": "Você será incapaz de recuperar esta mensagem!", "Your_Password_Must_Have": "Sua palavra-passe deve ter:" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 330f69ed33c..ff26d57a56f 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -61,8 +61,8 @@ "Animals_and_nature": "Животные и природа", "Announcement": "Объявление", "announcement": "объявление", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "App_action_error": "Не удалось выполнить это действие приложения", + "App_action_unsupported": "Это действие приложения пока не поддерживается в мобильной версии", "Apply_Certificate": "Применить сертификат", "ARCHIVE": "Архив", "archive": "архив", @@ -916,4 +916,4 @@ "Your_password_is": "Ваш пароль", "Your_Password_Must_Have": "Ваш пароль должен иметь:", "Your_workspace": "Ваш сервер" -} \ No newline at end of file +} diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 14c2f9b82ff..13e23aac27f 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -59,8 +59,8 @@ "Animals_and_nature": "Živali in narava", "Announcement": "Obvestilo", "announcement": "obvestilo", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Arhiv", "archive": "arhiv", @@ -885,4 +885,4 @@ "Your_password_is": "Vaše geslo je", "Your_Password_Must_Have": "Vaše geslo mora imeti:", "Your_workspace": "Vaš delovni prostor" -} \ No newline at end of file +} diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 28faceb559d..0141a54037c 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -63,8 +63,8 @@ "Animals_and_nature": "Djur och natur", "Announcement": "Meddelande", "announcement": "meddelande", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "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", "ARCHIVE": "Arkiv", "archive": "arkiv", @@ -946,4 +946,4 @@ "Your_password_is": "Ditt lösenord är", "Your_Password_Must_Have": "Ditt lösenord måste ha:", "Your_workspace": "Din arbetsyta" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index 2603912a5a0..ccbb2bebb26 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -70,8 +70,8 @@ "Animals_and_nature": "விலங்குகள் மற்றும் இயற்கை", "Announcement": "அறிவிப்பு", "announcement": "அறிவிப்பு", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "App_action_error": "இந்தப் பயன்பாட்டு செயலை இயக்க முடியவில்லை", + "App_action_unsupported": "இந்தப் பயன்பாட்டு செயல் இன்னும் மொபைலில் ஆதரிக்கப்படவில்லை", "Apply_Certificate": "சான்றிதழைப் பயன்படுத்துங்கள்", "ARCHIVE": "சேமி", "archive": "சேமி", @@ -986,4 +986,4 @@ "Your_password_is": "உங்கள் கடவுச்சொல் உள்ளது", "Your_Password_Must_Have": "உங்கள் கடவுச்சொல் இருக்க வேண்டும்:", "Your_workspace": "உங்கள் பணிகள்" -} \ No newline at end of file +} diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index d454c9512c7..f85640a6609 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -70,8 +70,8 @@ "Animals_and_nature": "జంతువులు మరియు ప్రకృతి", "Announcement": "ప్రకటన", "announcement": "ప్రకటన", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "App_action_error": "ఈ యాప్ చర్యను అమలు చేయలేకపోయాము", + "App_action_unsupported": "ఈ యాప్ చర్యకు ఇంకా మొబైల్‌లో మద్దতు లేదు", "Apply_Certificate": "సర్టిఫికేట్‌ని వర్తింపజేయండి", "ARCHIVE": "ఆర్కైవ్", "archive": "ఆర్కైవ్", @@ -985,4 +985,4 @@ "Your_password_is": "మీ సంకేతపదం", "Your_Password_Must_Have": "మీ పాస్‌వర్డ్ తప్పనిసరిగా ఉండాలి:", "Your_workspace": "మీ వర్క్‌స్పేస్" -} \ No newline at end of file +} diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index 47d6085de03..ed310ee31ed 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -51,8 +51,8 @@ "Animals_and_nature": "Hayvanlar ve doğa", "Announcement": "Duyuru", "announcement": "duyuru", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "App_action_error": "Bu uygulama eylemi çalıştırılamadı", + "App_action_unsupported": "Bu uygulama eylemi henüz mobilde desteklenmiyor", "Apply_Certificate": "Sertifika uygula", "ARCHIVE": "Arşi̇vle", "archive": "arşivle", @@ -756,4 +756,4 @@ "Your_password_is": "Şifreniz", "Your_Password_Must_Have": "Şifreniz:", "Your_workspace": "Çalışma alanınız" -} \ No newline at end of file +} diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 52dbbc84160..abfda029193 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -51,8 +51,8 @@ "Animals_and_nature": "动物与自然", "Announcement": "公告", "announcement": "公告", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "App_action_error": "无法运行此应用操作", + "App_action_unsupported": "移动端暂不支持此应用操作", "Apply_Certificate": "应用证书", "ARCHIVE": "封存", "archive": "封存", @@ -716,4 +716,4 @@ "Your_password_is": "您的密码", "Your_Password_Must_Have": "您的密码必须具有:", "Your_workspace": "您的工作区" -} \ No newline at end of file +} diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index 7c960aacfff..c1b5c10e8b8 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -51,8 +51,8 @@ "Animals_and_nature": "動物與自然", "Announcement": "公告", "announcement": "公告", - "App_action_error": "Couldn't run this app action", - "App_action_unsupported": "This app action isn't supported on mobile yet", + "App_action_error": "無法執行此應用程式操作", + "App_action_unsupported": "行動版尚不支援此應用程式操作", "Apply_Certificate": "應用證書", "ARCHIVE": "封存", "archive": "封存", @@ -745,4 +745,4 @@ "Your_password_is": "您的密碼", "Your_Password_Must_Have": "您的密碼必須具有:", "Your_workspace": "您的工作區" -} \ No newline at end of file +} From b1a070aa70d030d9dc62152970ac94cefa87e4ac Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 17 Sep 2026 12:22:41 -0300 Subject: [PATCH 11/29] chore: rollback unused changes --- app/i18n/locales/bn-IN.json | 2 +- app/i18n/locales/cs.json | 2 +- app/i18n/locales/de.json | 2 +- app/i18n/locales/es.json | 2 +- app/i18n/locales/fi.json | 2 +- app/i18n/locales/fr.json | 2 +- app/i18n/locales/hi-IN.json | 2 +- app/i18n/locales/hu.json | 2 +- app/i18n/locales/it.json | 2 +- app/i18n/locales/ja.json | 2 +- app/i18n/locales/nl.json | 2 +- app/i18n/locales/nn.json | 2 +- app/i18n/locales/no.json | 2 +- app/i18n/locales/pt-BR.json | 2 +- app/i18n/locales/pt-PT.json | 2 +- app/i18n/locales/ru.json | 2 +- app/i18n/locales/sl-SI.json | 2 +- app/i18n/locales/sv.json | 2 +- app/i18n/locales/ta-IN.json | 2 +- app/i18n/locales/te-IN.json | 2 +- app/i18n/locales/tr.json | 2 +- app/i18n/locales/zh-CN.json | 2 +- app/i18n/locales/zh-TW.json | 2 +- ios/Podfile.lock | 4 ++-- 24 files changed, 25 insertions(+), 25 deletions(-) diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index 9ab9ed81a25..fa25a53abc3 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -986,4 +986,4 @@ "Your_password_is": "আপনার পাসওয়ার্ড হল", "Your_Password_Must_Have": "আপনার পাসওয়ার্ড থাকতে হবে:", "Your_workspace": "আপনার ওয়ার্কস্পেস" -} +} \ No newline at end of file diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 71fce8ceb26..04fdbba7be6 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -1060,4 +1060,4 @@ "Your_Password_Must_Have": "Vaše heslo musí mít:", "Your_push_was_sent_to_s_devices": "Vaše push byla odeslána do {{s}} zařízení", "Your_workspace": "Váš pracovní prostor" -} +} \ No newline at end of file diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index 143b4daa279..21c0a75ed53 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -976,4 +976,4 @@ "Your_password_is": "Ihr Passwort lautet", "Your_Password_Must_Have": "Ihr Passwort muss:", "Your_workspace": "Ihr Arbeitsbereich" -} +} \ No newline at end of file diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index 8e90f829e60..b2bae2c6967 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -546,4 +546,4 @@ "You_will_not_be_able_to_recover_this_message": "¡No podrás recuperar este mensaje!", "Your_certificate": "Tu certificado", "Your_Password_Must_Have": "Su contraseña debe tener:" -} +} \ No newline at end of file diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 3bc27535d91..d39002479a8 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -948,4 +948,4 @@ "Your_password_is": "Salasanasi on", "Your_Password_Must_Have": "Salasanasi on oltava:", "Your_workspace": "Työtilasi" -} +} \ No newline at end of file diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index a8dca245579..a5de6046b1e 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -870,4 +870,4 @@ "Your_password_is": "Votre mot de passe est", "Your_Password_Must_Have": "Votre mot de passe doit avoir:", "Your_workspace": "Votre espace de travail" -} +} \ No newline at end of file diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 9d3e12b6dfc..9356be73d41 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -986,4 +986,4 @@ "Your_password_is": "आपका पासवर्ड है", "Your_Password_Must_Have": "आपका पासवर्ड होना चाहिए:", "Your_workspace": "आपका कार्यस्थान" -} +} \ No newline at end of file diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 29d4c1dee46..33bada1a175 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -989,4 +989,4 @@ "Your_password_is": "A jelszava a következő", "Your_Password_Must_Have": "A jelszavának rendelkeznie kell:", "Your_workspace": "Az Ön munkaterülete" -} +} \ No newline at end of file diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index 8dcd88cc8e1..5c4d571fa56 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -773,4 +773,4 @@ "Your_password_is": "La tua password è", "Your_Password_Must_Have": "La tua password deve avere:", "Your_workspace": "Il tuo workspace" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index ed6c515f51f..82d2ceaa07a 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -644,4 +644,4 @@ "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "招待リンクは{{date}}までか、あと{{usesLeft}}回で使用できなくなります。", "Your_invite_link_will_never_expire": "招待リンクはずっと有効です。", "Your_Password_Must_Have": "パスワードには次のことが必要です。" -} +} \ No newline at end of file diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 6f9d83ad1a3..2fbf87960d1 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -870,4 +870,4 @@ "Your_password_is": "Jouw wachtwoord is", "Your_Password_Must_Have": "Uw wachtwoord moet hebben:", "Your_workspace": "Jouw werkruimte" -} +} \ No newline at end of file diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index b1c5488a3ef..29d0e7530b3 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -493,4 +493,4 @@ "Your_invite_link_will_expire_after__usesLeft__uses": "Invitasjonslenken din utløper etter {{usesLeft}} anvendelser.", "Your_invite_link_will_expire_on__date__": "Invitasjonslenken din utløper {{date}}.", "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "Invitasjonskoblingen din utløper {{date}} eller etter {{usesLeft}} anvendelser." -} +} \ No newline at end of file diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 5aa46e9346c..9dfbacc6376 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -1035,4 +1035,4 @@ "Your_Password_Must_Have": "Passordet ditt må inneholde:", "Your_push_was_sent_to_s_devices": "Din push ble sendt til {{s}} enheter", "Your_workspace": "Arbeidsområdet ditt" -} +} \ No newline at end of file diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 8cbb778d061..a2c64b49804 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -1072,4 +1072,4 @@ "Your_Password_Must_Have": "Sua senha deve conter:", "Your_push_was_sent_to_s_devices": "A sua notificação foi enviada para {{s}} dispositivos", "Your_workspace": "Sua workspace" -} +} \ No newline at end of file diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index 9a8b87d89b8..b213bb9911b 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -605,4 +605,4 @@ "you_were_mentioned": "você foi mencionado", "You_will_not_be_able_to_recover_this_message": "Você será incapaz de recuperar esta mensagem!", "Your_Password_Must_Have": "Sua palavra-passe deve ter:" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index ff26d57a56f..8f4e67e26c5 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -916,4 +916,4 @@ "Your_password_is": "Ваш пароль", "Your_Password_Must_Have": "Ваш пароль должен иметь:", "Your_workspace": "Ваш сервер" -} +} \ No newline at end of file diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 13e23aac27f..0bd65842b00 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -885,4 +885,4 @@ "Your_password_is": "Vaše geslo je", "Your_Password_Must_Have": "Vaše geslo mora imeti:", "Your_workspace": "Vaš delovni prostor" -} +} \ No newline at end of file diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 0141a54037c..f03c41f168e 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -946,4 +946,4 @@ "Your_password_is": "Ditt lösenord är", "Your_Password_Must_Have": "Ditt lösenord måste ha:", "Your_workspace": "Din arbetsyta" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index ccbb2bebb26..1df96922806 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -986,4 +986,4 @@ "Your_password_is": "உங்கள் கடவுச்சொல் உள்ளது", "Your_Password_Must_Have": "உங்கள் கடவுச்சொல் இருக்க வேண்டும்:", "Your_workspace": "உங்கள் பணிகள்" -} +} \ No newline at end of file diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index f85640a6609..fb26259033c 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -985,4 +985,4 @@ "Your_password_is": "మీ సంకేతపదం", "Your_Password_Must_Have": "మీ పాస్‌వర్డ్ తప్పనిసరిగా ఉండాలి:", "Your_workspace": "మీ వర్క్‌స్పేస్" -} +} \ No newline at end of file diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index ed310ee31ed..064f16ded2f 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -756,4 +756,4 @@ "Your_password_is": "Şifreniz", "Your_Password_Must_Have": "Şifreniz:", "Your_workspace": "Çalışma alanınız" -} +} \ No newline at end of file diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index abfda029193..6bb95d8eef7 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -716,4 +716,4 @@ "Your_password_is": "您的密码", "Your_Password_Must_Have": "您的密码必须具有:", "Your_workspace": "您的工作区" -} +} \ No newline at end of file diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index c1b5c10e8b8..66303dfb0e5 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -745,4 +745,4 @@ "Your_password_is": "您的密碼", "Your_Password_Must_Have": "您的密碼必須具有:", "Your_workspace": "您的工作區" -} +} \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cf427d200ce..4b134051245 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4153,7 +4153,7 @@ SPEC CHECKSUMS: React-timing: c39eeb992274aeaeb9f4666dc97a36a31d33fe94 React-utils: 2f9ba0088251788ad66aa1855ff99ed2424024d2 ReactAppDependencyProvider: 1bcd3527ac0390a1c898c114f81ff954be35ed79 - ReactCodegen: e0a2012cda55dfd0d2e626ba5d11929c9ff5986c + ReactCodegen: 7fadc30654a54b3a0d62df08f55e2211e25486ee ReactCommon: 6d0fa86a4510730da7c72560e0ced14258292ab9 ReactNativeIncallManager: dccd3e7499caa3bb73d3acfedf4fb0360f1a87d5 RNBootSplash: 7fcc9a58ae343aeb1a1dd49f9030832fe432c544 @@ -4168,7 +4168,7 @@ SPEC CHECKSUMS: RNFBApp: a448037d2df74af9d374a0b765be12ff1e844dc0 RNFBCrashlytics: c3bb5533f9957eddc88f3ea383583309a4ce9f89 RNGestureHandler: b8d2e75c2e88fc2a1f6be3b3beeeed80b88fa37d - RNImageCropPicker: 5432d75a3bc7209dc8b6ab189fcfe19bd6339fb9 + RNImageCropPicker: 0a63af4b79e514c1edd6c3152f19300c5ed85312 RNKeychain: 76d042fc1dfba47f3945920bc82e0bce9ad1ff55 RNLocalize: c46dd4008dc8990a098fcafd6ee051122a1ccfda RNReanimated: e1690cdd7f215cfb96a3b7986b81889867dfdb4f From b98ee807e7399819e808bca3f2e4ebeef114668d Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 17 Sep 2026 13:03:15 -0300 Subject: [PATCH 12/29] code improvements --- app/lib/apps/filters.ts | 8 ++++---- app/lib/apps/useAppActionButtons.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/lib/apps/filters.ts b/app/lib/apps/filters.ts index 2c6d4309052..5da425c86bd 100644 --- a/app/lib/apps/filters.ts +++ b/app/lib/apps/filters.ts @@ -6,8 +6,8 @@ import { RoomTypeFilter } from './definitions'; -const isTeamRoom = ({ teamMain }: IAppActionButtonRoom) => !!teamMain; -const isDiscussion = ({ prid }: IAppActionButtonRoom) => !!prid; +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', @@ -44,8 +44,8 @@ export interface IAppActionButtonAuth { export const applyAuthFilter = (button: IAppActionButton, { roles, permissions }: IAppActionButtonAuth): boolean => { const { hasAllPermissions, hasOnePermission, hasAllRoles, hasOneRole } = button.when || {}; - const grants = (permission: string) => (permissions[permission] ?? []).some(role => roles.includes(role)); - const holds = (role: string) => roles.includes(role); + 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)) && diff --git a/app/lib/apps/useAppActionButtons.ts b/app/lib/apps/useAppActionButtons.ts index 8f1cd77172e..78aae67e973 100644 --- a/app/lib/apps/useAppActionButtons.ts +++ b/app/lib/apps/useAppActionButtons.ts @@ -82,7 +82,7 @@ export const useAppActionButtons = ({ let cancelled = false; - const resolve = async () => { + const resolve = async (): Promise => { const subscription = rid ? await getSubscriptionByRoomId(rid) : null; const permissionRoles = await getPermissionRoles(splitKey(permissionsKey)); if (cancelled) { From 4b7932ac05aa78a637f528528d3ec7be9a08edbb Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 17 Sep 2026 17:34:31 -0300 Subject: [PATCH 13/29] chore: move UIKit action handlers to app/lib/apps --- .../MessageComposer/MessageComposer.tsx | 2 +- .../components/ComposerInput.tsx | 2 +- .../__tests__}/actions.test.ts | 18 +++++++++--------- .../__tests__}/triggerActions.test.ts | 10 +++++----- app/lib/{methods => apps}/actions.ts | 10 +++++----- app/lib/apps/index.ts | 2 ++ app/lib/{methods => apps}/triggerActions.ts | 2 +- app/lib/apps/triggerAppActionButton.ts | 2 +- app/lib/methods/subscriptions/rooms.test.ts | 2 +- app/lib/methods/subscriptions/rooms.ts | 2 +- app/views/ModalBlockView.tsx | 2 +- app/views/RoomView/services/blockAction.ts | 2 +- 12 files changed, 29 insertions(+), 27 deletions(-) rename app/lib/{methods => apps/__tests__}/actions.test.ts (93%) rename app/lib/{methods => apps/__tests__}/triggerActions.test.ts (92%) rename app/lib/{methods => apps}/actions.ts (94%) rename app/lib/{methods => apps}/triggerActions.ts (93%) 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/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/lib/methods/actions.test.ts b/app/lib/apps/__tests__/actions.test.ts similarity index 93% rename from app/lib/methods/actions.test.ts rename to app/lib/apps/__tests__/actions.test.ts index 2a8be4dd503..a5b8a239b2f 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: { diff --git a/app/lib/methods/triggerActions.test.ts b/app/lib/apps/__tests__/triggerActions.test.ts similarity index 92% rename from app/lib/methods/triggerActions.test.ts rename to app/lib/apps/__tests__/triggerActions.test.ts index 8f868114421..1a154a51922 100644 --- a/app/lib/methods/triggerActions.test.ts +++ b/app/lib/apps/__tests__/triggerActions.test.ts @@ -1,13 +1,13 @@ 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', () => ({ +jest.mock('../actions', () => ({ triggerAction: jest.fn() })); diff --git a/app/lib/methods/actions.ts b/app/lib/apps/actions.ts similarity index 94% rename from app/lib/methods/actions.ts rename to app/lib/apps/actions.ts index eb294e54bf5..54a10a907d2 100644 --- a/app/lib/methods/actions.ts +++ b/app/lib/apps/actions.ts @@ -2,11 +2,11 @@ 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 fetch from '~/lib/methods/helpers/fetch'; +import { random } from '~/lib/methods/helpers'; +import Navigation from '~/lib/navigation/appNavigation'; +import sdk from '~/lib/services/sdk'; const triggersId = new Map(); diff --git a/app/lib/apps/index.ts b/app/lib/apps/index.ts index 2341bf59b03..5bcde763857 100644 --- a/app/lib/apps/index.ts +++ b/app/lib/apps/index.ts @@ -1,4 +1,6 @@ +export * from './actions'; export * from './definitions'; export * from './appsStore'; +export * from './triggerActions'; export * from './triggerAppActionButton'; export * from './useAppActionButtons'; diff --git a/app/lib/methods/triggerActions.ts b/app/lib/apps/triggerActions.ts similarity index 93% rename from app/lib/methods/triggerActions.ts rename to app/lib/apps/triggerActions.ts index 40b1ac4cf2a..58684759efa 100644 --- a/app/lib/methods/triggerActions.ts +++ b/app/lib/apps/triggerActions.ts @@ -5,7 +5,7 @@ import { type ITriggerSubmitView, ModalActions } from '~/containers/UIKit/interfaces'; -import Navigation from '../navigation/appNavigation'; +import Navigation from '~/lib/navigation/appNavigation'; import { triggerAction } from './actions'; export async function triggerSubmitView({ viewId, ...options }: ITriggerSubmitView) { diff --git a/app/lib/apps/triggerAppActionButton.ts b/app/lib/apps/triggerAppActionButton.ts index e33843eb215..4466a976d42 100644 --- a/app/lib/apps/triggerAppActionButton.ts +++ b/app/lib/apps/triggerAppActionButton.ts @@ -2,7 +2,7 @@ import { type IAppActionButton } from './definitions'; import { ActionTypes, ModalActions } from '~/containers/UIKit/interfaces'; import { LISTENER } from '~/containers/Toast'; import I18n from '~/i18n'; -import { triggerAction } from '~/lib/methods/actions'; +import { triggerAction } from './actions'; import EventEmitter from '~/lib/methods/helpers/events'; import log from '~/lib/methods/helpers/log'; 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 b382100b5a8..bfdb2db10a7 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/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 }; From 577d2d4509a52999053ada595c65f88227e4e0ba Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 17 Sep 2026 17:54:15 -0300 Subject: [PATCH 14/29] e2e tests --- .maestro/scripts/data-setup.js | 39 +++++++ .maestro/tests/uikit/poll.yaml | 109 ++++++++++++++++++ .sniffler/test-map.json | 11 +- .../__snapshots__/UiKitModal.test.tsx.snap | 4 + app/containers/UIKit/index.tsx | 1 + 5 files changed, 163 insertions(+), 1 deletion(-) create mode 100644 .maestro/tests/uikit/poll.yaml diff --git a/.maestro/scripts/data-setup.js b/.maestro/scripts/data-setup.js index 3f42e48f9db..b56365b1432 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); @@ -280,5 +317,7 @@ output.utils = { login, getDeepLink, createDM, + findAppActionButton, + getAppTranslation, sleep }; \ 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..9970411c4da --- /dev/null +++ b/.maestro/tests/uikit/poll.yaml @@ -0,0 +1,109 @@ +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: + id: 'message-content-${output.question}' + timeout: 60000 +- assertVisible: + text: 'Option A' +- assertVisible: + text: 'Option B' +- assertVisible: + text: '.*0.00% \(0\).*' + +# should vote and see the results update +# The vote buttons carry the app's raw i18n key: mobile only translates app keys on action +# button labels, not inside blocks. +- tapOn: + text: '(poll_vote|Vote)' + index: 0 +- extendedWaitUntil: + visible: + text: '.*100.00% \(1\).*' + timeout: 60000 diff --git a/.sniffler/test-map.json b/.sniffler/test-map.json index 7c9e3c8bd7c..52913e234ff 100644 --- a/.sniffler/test-map.json +++ b/.sniffler/test-map.json @@ -422,6 +422,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/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 ( Date: Thu, 17 Sep 2026 18:51:01 -0300 Subject: [PATCH 15/29] fix: e2e tests --- .maestro/tests/uikit/poll.yaml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.maestro/tests/uikit/poll.yaml b/.maestro/tests/uikit/poll.yaml index 9970411c4da..0cde535afe4 100644 --- a/.maestro/tests/uikit/poll.yaml +++ b/.maestro/tests/uikit/poll.yaml @@ -88,7 +88,7 @@ tags: # should render the poll blocks the app posted back - extendedWaitUntil: visible: - id: 'message-content-${output.question}' + text: '${output.question}' timeout: 60000 - assertVisible: text: 'Option A' @@ -97,12 +97,20 @@ tags: - assertVisible: text: '.*0.00% \(0\).*' -# should vote and see the results update -# The vote buttons carry the app's raw i18n key: mobile only translates app keys on action -# button labels, not inside blocks. +# 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\).*' From 457f50c5577f1b55a0cd4f01d73d860afcd867b4 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 23 Sep 2026 15:06:37 -0300 Subject: [PATCH 16/29] fix: reset apps store on disconnect --- app/lib/services/connect.test.ts | 15 +++++++++++++++ app/lib/services/connect.ts | 2 ++ 2 files changed, 17 insertions(+) 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 b4dc43e72a7..43deeb1a799 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 { @@ -420,6 +421,7 @@ function abort(): void { function disconnect(): void { sdk.disconnect(); mediaSessionInstance.reset(); + useAppsStore.getState().reset(); } async function getWebsocketInfo({ From cc93147b76eb0e6b74df561d14944aa9bac97c39 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 23 Sep 2026 15:11:23 -0300 Subject: [PATCH 17/29] fix: resubscribe apps stream on reconnect --- app/lib/apps/__tests__/appsStore.test.ts | 147 +++++++++++++++++++++++ app/lib/apps/appsStore.ts | 93 ++++++++++---- 2 files changed, 215 insertions(+), 25 deletions(-) create mode 100644 app/lib/apps/__tests__/appsStore.test.ts diff --git a/app/lib/apps/__tests__/appsStore.test.ts b/app/lib/apps/__tests__/appsStore.test.ts new file mode 100644 index 00000000000..e1ba3774045 --- /dev/null +++ b/app/lib/apps/__tests__/appsStore.test.ts @@ -0,0 +1,147 @@ +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(() => Promise.resolve({ stop: mockStop })); +const mockSubscribe = jest.fn(() => Promise.resolve({ unsubscribe: mockUnsubscribe })); +jest.mock('~/lib/services/sdk', () => ({ + __esModule: true, + default: { + onStreamData: (...args: unknown[]) => mockOnStreamData(...(args as [])), + 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 setLoginReady = (ready: boolean) => { + mockState.login.isAuthenticated = ready; + mockState.meteor.connected = ready; + mockListeners.forEach(listener => listener()); +}; + +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('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); + }); +}); diff --git a/app/lib/apps/appsStore.ts b/app/lib/apps/appsStore.ts index d7fea4f57d7..e1914f8d1a2 100644 --- a/app/lib/apps/appsStore.ts +++ b/app/lib/apps/appsStore.ts @@ -4,6 +4,8 @@ import { type IAppActionButton } from './definitions'; 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 @@ -60,6 +62,9 @@ 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; @@ -75,29 +80,69 @@ const handleStreamData = (ddpMessage: { fields?: { args?: [[string, unknown[]]] } }; +const subscribeToStream = () => { + if (subscribed || consumers === 0) { + return; + } + subscribed = true; + const current = (generation += 1); + const { fetchActionButtons, fetchTranslations } = useAppsStore.getState(); + fetchActionButtons().catch(log); + fetchTranslations().catch(log); + const fail = (e: unknown) => { + // Leaves the next `isLoginReady()` edge free to try again. + subscribed = false; + log(e); + }; + try { + streamListener = sdk.onStreamData(APPS_STREAM, handleStreamData); + 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; + }) + .catch(fail); + } catch (e) { + fail(e); + } +}; + +const unsubscribeFromStream = () => { + generation += 1; + 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) { - const current = (generation += 1); - const { fetchActionButtons, fetchTranslations } = useAppsStore.getState(); - fetchActionButtons().catch(log); - fetchTranslations().catch(log); - try { - // Both throw synchronously while no server is selected, the composer can mount first. - streamListener = sdk.onStreamData(APPS_STREAM, handleStreamData); - 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; - }) - .catch(log); - } catch (e) { - log(e); + loginReady = isLoginReady(); + storeListener = store.subscribe(handleStoreChange); + if (loginReady) { + subscribeToStream(); } } @@ -106,10 +151,8 @@ export const subscribeToApps = (): (() => void) => { if (consumers > 0) { return; } - generation += 1; - streamListener?.then(listener => listener.stop()).catch(log); - streamListener = null; - streamSubscription?.unsubscribe().catch(log); - streamSubscription = null; + storeListener?.(); + storeListener = null; + unsubscribeFromStream(); }; }; From fcd0e09f67d1e06515e54090bfc1bb4664877a6d Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 23 Sep 2026 18:21:26 -0300 Subject: [PATCH 18/29] chore: drop apps barrel, emit toasts via typed emitter --- .../MessageComposer/components/Buttons/ActionsButton.tsx | 3 ++- .../components/Buttons/__tests__/ActionsButton.test.tsx | 3 +-- app/containers/Toast.tsx | 3 +++ app/lib/apps/index.ts | 6 ------ app/lib/apps/triggerAppActionButton.ts | 7 +++---- app/lib/methods/helpers/emitter.ts | 1 + app/lib/services/restApi.ts | 1 - 7 files changed, 10 insertions(+), 14 deletions(-) delete mode 100644 app/lib/apps/index.ts diff --git a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx index 33d9066b8e5..bb2197bb4fd 100644 --- a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx +++ b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx @@ -10,7 +10,8 @@ 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, useAppActionButtons } from '~/lib/apps'; +import { UIActionButtonContext } from '~/lib/apps/definitions'; +import { useAppActionButtons } from '~/lib/apps/useAppActionButtons'; import { triggerAppActionButton } from '~/lib/apps/triggerAppActionButton'; export const ActionsButton = () => { diff --git a/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx b/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx index 24d419f99fc..271d6330146 100644 --- a/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx +++ b/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx @@ -4,8 +4,7 @@ import { type IAppActionButtonItem } from '~/lib/apps/useAppActionButtons'; import { ActionsButton } from '../ActionsButton'; const mockUseAppActionButtons = jest.fn((_params: unknown): IAppActionButtonItem[] => []); -jest.mock('~/lib/apps', () => ({ - UIActionButtonContext: { MESSAGE_BOX_ACTION: 'messageBoxAction', ROOM_ACTION: 'roomAction' }, +jest.mock('~/lib/apps/useAppActionButtons', () => ({ useAppActionButtons: (params: unknown) => mockUseAppActionButtons(params) })); diff --git a/app/containers/Toast.tsx b/app/containers/Toast.tsx index 8bb86755444..dea1661568c 100644 --- a/app/containers/Toast.tsx +++ b/app/containers/Toast.tsx @@ -5,6 +5,7 @@ import EasyToast from 'react-native-easy-toast'; import { useUserPreferences } from '../lib/methods/userPreferences'; import { type TAlertDisplayType } from '../views/AccessibilityAndAppearanceView'; import EventEmitter from '../lib/methods/helpers/events'; +import { emitter } from '../lib/methods/helpers/emitter'; import { useTheme } from '../theme'; import sharedStyles from '../views/Styles'; import { ALERT_DISPLAY_TYPE_PREFERENCES_KEY } from '../lib/constants/keys'; @@ -32,8 +33,10 @@ const Toast = (): ReactElement => { useEffect(() => { listener = EventEmitter.addEventListener(LISTENER, showToast); + emitter.on('showToast', showToast); return () => { EventEmitter.removeListener(LISTENER, listener); + emitter.off('showToast', showToast); }; }, [alertDisplayType]); diff --git a/app/lib/apps/index.ts b/app/lib/apps/index.ts deleted file mode 100644 index 5bcde763857..00000000000 --- a/app/lib/apps/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './actions'; -export * from './definitions'; -export * from './appsStore'; -export * from './triggerActions'; -export * from './triggerAppActionButton'; -export * from './useAppActionButtons'; diff --git a/app/lib/apps/triggerAppActionButton.ts b/app/lib/apps/triggerAppActionButton.ts index 4466a976d42..75e15db59f1 100644 --- a/app/lib/apps/triggerAppActionButton.ts +++ b/app/lib/apps/triggerAppActionButton.ts @@ -1,9 +1,8 @@ import { type IAppActionButton } from './definitions'; import { ActionTypes, ModalActions } from '~/containers/UIKit/interfaces'; -import { LISTENER } from '~/containers/Toast'; import I18n from '~/i18n'; import { triggerAction } from './actions'; -import EventEmitter from '~/lib/methods/helpers/events'; +import { emitter } from '~/lib/methods/helpers/emitter'; import log from '~/lib/methods/helpers/log'; interface ITriggerAppActionButton { @@ -27,10 +26,10 @@ export const triggerAppActionButton = async ({ button, rid, tmid, mid, message } }); if (result === ModalActions.UNSUPPORTED) { - EventEmitter.emit(LISTENER, { message: I18n.t('App_action_unsupported') }); + emitter.emit('showToast', { message: I18n.t('App_action_unsupported') }); } } catch (e) { log(e); - EventEmitter.emit(LISTENER, { message: I18n.t('App_action_error') }); + emitter.emit('showToast', { message: I18n.t('App_action_error') }); } }; diff --git a/app/lib/methods/helpers/emitter.ts b/app/lib/methods/helpers/emitter.ts index bd355b71833..e36693d56e6 100644 --- a/app/lib/methods/helpers/emitter.ts +++ b/app/lib/methods/helpers/emitter.ts @@ -17,6 +17,7 @@ export type TEmitterEvents = TDynamicMediaDownloadEvents & { setComposerHeightThread: number; audioFocused: string; navigationReady: undefined; + showToast: { message: string }; }; export type TKeyEmitterEvent = keyof TEmitterEvents; diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index 07b2453eb53..9b42df70505 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1293,7 +1293,6 @@ const appsApiGet = async (path: string): Promise => { const response = await fetch(`${host}/api/apps/${path}`, { method: 'GET', headers: { - ...RocketChatSettings.customHeaders, 'Content-Type': 'application/json', 'X-Auth-Token': authToken, 'X-User-Id': userId From d708959345ab4a293b0c5d06e0268cf1402655d0 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 23 Sep 2026 18:25:16 -0300 Subject: [PATCH 19/29] fix: send tmid with composer ai actions --- .../MessageComposer/components/Buttons/ActionsButton.tsx | 2 +- .../components/Buttons/__tests__/ActionsButton.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx index bb2197bb4fd..63469382bd7 100644 --- a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx +++ b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx @@ -107,7 +107,7 @@ export const ActionsButton = () => { danger: button.variant === 'danger', testID: `message-composer-ai-action-${id}`, onPress: () => { - triggerAppActionButton({ button, rid }); + triggerAppActionButton({ button, rid, tmid }); } }); }); diff --git a/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx b/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx index 271d6330146..986d1b41aff 100644 --- a/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx +++ b/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx @@ -111,7 +111,7 @@ describe('ActionsButton', () => { }); }); - it('sends only the room with an ai action', () => { + 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] : [] @@ -119,6 +119,6 @@ describe('ActionsButton', () => { openSheet()[1].onPress(); - expect(mockTriggerAppActionButton).toHaveBeenCalledWith({ button: aiItem.button, rid: 'rid-1' }); + expect(mockTriggerAppActionButton).toHaveBeenCalledWith({ button: aiItem.button, rid: 'rid-1', tmid: 'tmid-1' }); }); }); From 700f7916824179513300e61e39119a658444366f Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 23 Sep 2026 18:35:15 -0300 Subject: [PATCH 20/29] fix: scope apps stream refetches and room button filtering --- app/lib/apps/__tests__/appsStore.test.ts | 68 ++++++++++++++++++- .../__tests__/useAppActionButtons.test.ts | 16 +++++ app/lib/apps/appsStore.ts | 5 +- app/lib/apps/useAppActionButtons.ts | 16 +++-- 4 files changed, 95 insertions(+), 10 deletions(-) diff --git a/app/lib/apps/__tests__/appsStore.test.ts b/app/lib/apps/__tests__/appsStore.test.ts index e1ba3774045..2e00fa35efa 100644 --- a/app/lib/apps/__tests__/appsStore.test.ts +++ b/app/lib/apps/__tests__/appsStore.test.ts @@ -21,12 +21,12 @@ jest.mock('~/lib/methods/helpers/log', () => ({ const mockStop = jest.fn(); const mockUnsubscribe = jest.fn(() => Promise.resolve()); -const mockOnStreamData = jest.fn(() => Promise.resolve({ stop: mockStop })); +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: (...args: unknown[]) => mockOnStreamData(...(args as [])), + onStreamData: (event: string, callback: (message: unknown) => void) => mockOnStreamData(event, callback), subscribe: (...args: unknown[]) => mockSubscribe(...(args as [])) } })); @@ -43,12 +43,22 @@ jest.mock('~/lib/store/auxStore', () => ({ } })); +const notify = () => mockListeners.forEach(listener => listener()); + const setLoginReady = (ready: boolean) => { mockState.login.isAuthenticated = ready; mockState.meteor.connected = ready; - mockListeners.forEach(listener => listener()); + 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', () => { @@ -127,6 +137,58 @@ describe('subscribeToApps', () => { 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('keeps a single subscription while more than one consumer is mounted', async () => { setLoginReady(true); const first = subscribeToApps(); diff --git a/app/lib/apps/__tests__/useAppActionButtons.test.ts b/app/lib/apps/__tests__/useAppActionButtons.test.ts index 713711e23a8..859ada3cc4f 100644 --- a/app/lib/apps/__tests__/useAppActionButtons.test.ts +++ b/app/lib/apps/__tests__/useAppActionButtons.test.ts @@ -81,6 +81,22 @@ describe('useAppActionButtons', () => { 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 }) => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid }), + { initialProps: { rid: 'rid' } } + ); + + await waitFor(() => expect(result.current).toHaveLength(1)); + + rerender({ rid: 'other-rid' }); + + expect(result.current).toEqual([]); + await waitFor(() => expect(result.current).toHaveLength(1)); + }); + it('keeps only the requested context', async () => { mockActionButtons = [button(), button({ actionId: 'other', context: UIActionButtonContext.ROOM_ACTION })]; diff --git a/app/lib/apps/appsStore.ts b/app/lib/apps/appsStore.ts index e1914f8d1a2..b9f426074bd 100644 --- a/app/lib/apps/appsStore.ts +++ b/app/lib/apps/appsStore.ts @@ -71,11 +71,12 @@ let streamSubscription: { unsubscribe: () => Promise } | null = null; const handleStreamData = (ddpMessage: { fields?: { args?: [[string, unknown[]]] } }) => { const [event] = ddpMessage?.fields?.args?.[0] || []; const { fetchActionButtons, fetchTranslations } = useAppsStore.getState(); + // The engine fires `actions/changed` on both register and clear, so it covers the whole button + // lifecycle on its own; translations ship with an app and only arrive when one is added. if (event === 'actions/changed') { fetchActionButtons().catch(log); } - if (event === 'app/added' || event === 'app/removed' || event === 'app/updated') { - fetchActionButtons().catch(log); + if (event === 'app/added') { fetchTranslations().catch(log); } }; diff --git a/app/lib/apps/useAppActionButtons.ts b/app/lib/apps/useAppActionButtons.ts index 78aae67e973..17c524175b3 100644 --- a/app/lib/apps/useAppActionButtons.ts +++ b/app/lib/apps/useAppActionButtons.ts @@ -42,6 +42,8 @@ const getPermissionRoles = async (ids: string[]): Promise<{ [permission: string] const splitKey = (key: string): string[] => (key ? key.split(',') : []); interface IAppActionButtonContext { + /** Inputs this context was resolved for, so a room change can't be filtered against the previous one. */ + key: string; room: IAppActionButtonRoom; roles: string[]; permissions: { [permission: string]: string[] }; @@ -73,6 +75,7 @@ export const useAppActionButtons = ({ const permissionIds = useMemo(() => collectPermissions(buttons), [buttons]); const permissionsKey = permissionIds.join(','); const userRolesKey = userRoles.join(','); + const contextKey = `${rid ?? ''}|${permissionsKey}|${userRolesKey}`; const hasButtons = buttons.length > 0; useEffect(() => { @@ -82,13 +85,14 @@ export const useAppActionButtons = ({ let cancelled = false; - const resolve = async (): Promise => { + const resolveFilterContext = async (): Promise => { const subscription = rid ? await getSubscriptionByRoomId(rid) : null; const permissionRoles = await getPermissionRoles(splitKey(permissionsKey)); if (cancelled) { return; } setContext({ + key: contextKey, room: { t: subscription?.t, teamMain: subscription?.teamMain, @@ -100,15 +104,17 @@ export const useAppActionButtons = ({ }); }; - resolve().catch(log); + resolveFilterContext().catch(log); return () => { cancelled = true; }; - }, [hasButtons, rid, permissionsKey, userRolesKey]); + }, [hasButtons, rid, permissionsKey, userRolesKey, contextKey]); return useMemo(() => { - if (!filterContext) { + // A resolve for the previous room may still be the latest state; filtering against it would + // list buttons this room excludes. + if (!filterContext || filterContext.key !== contextKey) { return []; } const { room, roles, permissions } = filterContext; @@ -124,5 +130,5 @@ export const useAppActionButtons = ({ label: translateAppKey({ appId: button.appId, key: button.labelI18n, translations }), button })); - }, [buttons, category, filterContext, translations]); + }, [buttons, category, contextKey, filterContext, translations]); }; From 392a1fee2726d019caab2b5de231b125c07d19bf Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 24 Sep 2026 21:58:33 -0300 Subject: [PATCH 21/29] feat: apps engine action buttons in the sidebar --- app/lib/methods/helpers/log/events.ts | 1 + app/views/SidebarView/components/Apps.tsx | 42 +++++++++++++++++++++++ app/views/SidebarView/index.tsx | 2 ++ 3 files changed, 45 insertions(+) create mode 100644 app/views/SidebarView/components/Apps.tsx 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/views/SidebarView/components/Apps.tsx b/app/views/SidebarView/components/Apps.tsx new file mode 100644 index 00000000000..f93147a8977 --- /dev/null +++ b/app/views/SidebarView/components/Apps.tsx @@ -0,0 +1,42 @@ +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 Apps = ({ navigation }: { navigation: DrawerNavigationProp }) => { + const appActions = useAppActionButtons({ context: UIActionButtonContext.USER_DROPDOWN_ACTION }); + + 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 + ); From 1f2748df6aa5a2e688a2052ba4c5aaa9133047ff Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 24 Sep 2026 22:03:38 -0300 Subject: [PATCH 22/29] translation --- app/i18n/locales/ar.json | 3 ++- app/i18n/locales/bn-IN.json | 3 ++- app/i18n/locales/cs.json | 3 ++- app/i18n/locales/de.json | 3 ++- app/i18n/locales/en.json | 1 + app/i18n/locales/es.json | 3 ++- app/i18n/locales/fi.json | 3 ++- app/i18n/locales/fr.json | 3 ++- app/i18n/locales/hi-IN.json | 3 ++- app/i18n/locales/hu.json | 3 ++- app/i18n/locales/it.json | 3 ++- app/i18n/locales/ja.json | 3 ++- app/i18n/locales/nl.json | 3 ++- app/i18n/locales/nn.json | 3 ++- app/i18n/locales/no.json | 3 ++- app/i18n/locales/pt-BR.json | 1 + app/i18n/locales/pt-PT.json | 3 ++- app/i18n/locales/ru.json | 3 ++- app/i18n/locales/sl-SI.json | 3 ++- app/i18n/locales/sv.json | 3 ++- app/i18n/locales/ta-IN.json | 3 ++- app/i18n/locales/te-IN.json | 3 ++- app/i18n/locales/tr.json | 3 ++- app/i18n/locales/zh-CN.json | 3 ++- app/i18n/locales/zh-TW.json | 3 ++- 25 files changed, 48 insertions(+), 23 deletions(-) diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index 06572a01d20..395f167df33 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -54,6 +54,7 @@ "announcement": "إعلان", "App_action_error": "تعذر تشغيل إجراء التطبيق هذا", "App_action_unsupported": "إجراء التطبيق هذا غير مدعوم على الهاتف المحمول حتى الآن", + "Apps": "التطبيقات", "Apply_Certificate": "تقديم الشهادة", "ARCHIVE": "أرشفة", "archive": "أرشفة", @@ -725,4 +726,4 @@ "Your_password_is": "كلمة المرور الخاصة بك هي", "Your_Password_Must_Have": "يجب أن تحتوي كلمة المرور الخاصة بك على:", "Your_workspace": "مساحة عملك" -} \ No newline at end of file +} diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index fa25a53abc3..e36bd7591f7 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -72,6 +72,7 @@ "announcement": "ঘোষণা", "App_action_error": "এই অ্যাপ অ্যাকশনটি চালানো যায়নি", "App_action_unsupported": "এই অ্যাপ অ্যাকশনটি এখনও মোবাইলে সমর্থিত নয়", + "Apps": "অ্যাপ", "Apply_Certificate": "সার্টিফিকেট প্রয়োগ করুন", "ARCHIVE": "সংরক্ষণাগার", "archive": "সংরক্ষণাগার", @@ -986,4 +987,4 @@ "Your_password_is": "আপনার পাসওয়ার্ড হল", "Your_Password_Must_Have": "আপনার পাসওয়ার্ড থাকতে হবে:", "Your_workspace": "আপনার ওয়ার্কস্পেস" -} \ No newline at end of file +} diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 04fdbba7be6..ac4aa77c538 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -74,6 +74,7 @@ "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", + "Apps": "Aplikace", "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", "ARCHIVE": "Archiv", @@ -1060,4 +1061,4 @@ "Your_Password_Must_Have": "Vaše heslo musí mít:", "Your_push_was_sent_to_s_devices": "Vaše push byla odeslána do {{s}} zařízení", "Your_workspace": "Váš pracovní prostor" -} \ No newline at end of file +} diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index 21c0a75ed53..d9ab06ef3b4 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -72,6 +72,7 @@ "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", + "Apps": "Anwendungen", "Apply_Certificate": "Zertifikat anwenden", "ARCHIVE": "Archiv", "archive": "Archiv", @@ -976,4 +977,4 @@ "Your_password_is": "Ihr Passwort lautet", "Your_Password_Must_Have": "Ihr Passwort muss:", "Your_workspace": "Ihr Arbeitsbereich" -} \ No newline at end of file +} diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 6d812f3e160..edec17f89a0 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -75,6 +75,7 @@ "announcement": "announcement", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", + "Apps": "Apps", "App_users_are_not_allowed_to_log_in_directly": "App users are not allowed to log in directly.", "Apply_Certificate": "Apply certificate", "ARCHIVE": "Archive", diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index b2bae2c6967..90b4670f4ef 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -50,6 +50,7 @@ "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", + "Apps": "Aplicaciones", "Apply_Certificate": "Aplicar certificado", "ARCHIVE": "Fichero", "archive": "fichero", @@ -546,4 +547,4 @@ "You_will_not_be_able_to_recover_this_message": "¡No podrás recuperar este mensaje!", "Your_certificate": "Tu certificado", "Your_Password_Must_Have": "Su contraseña debe tener:" -} \ No newline at end of file +} diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index d39002479a8..eded197a805 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -65,6 +65,7 @@ "announcement": "ilmoitus", "App_action_error": "Tätä sovellustoimintoa ei voitu suorittaa", "App_action_unsupported": "Tätä sovellustoimintoa ei vielä tueta mobiililaitteilla", + "Apps": "Sovellukset", "Apply_Certificate": "Käytä sertifikaattia", "ARCHIVE": "Arkisto", "archive": "arkisto", @@ -948,4 +949,4 @@ "Your_password_is": "Salasanasi on", "Your_Password_Must_Have": "Salasanasi on oltava:", "Your_workspace": "Työtilasi" -} \ No newline at end of file +} diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index a5de6046b1e..ddce16faeb4 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -57,6 +57,7 @@ "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", + "Apps": "Applications", "Apply_Certificate": "Appliquer le certificat", "ARCHIVE": "Archiver", "archive": "archiver", @@ -870,4 +871,4 @@ "Your_password_is": "Votre mot de passe est", "Your_Password_Must_Have": "Votre mot de passe doit avoir:", "Your_workspace": "Votre espace de travail" -} \ No newline at end of file +} diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index 9356be73d41..4f61ff36032 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -72,6 +72,7 @@ "announcement": "घोषणा", "App_action_error": "यह ऐप कार्रवाई चल नहीं सकी", "App_action_unsupported": "यह ऐप कार्रवाई अभी मोबाइल पर समर्थित नहीं है", + "Apps": "ऐप्स", "Apply_Certificate": "प्रमाणपत्र लागू करें", "ARCHIVE": "संग्रह", "archive": "संग्रह", @@ -986,4 +987,4 @@ "Your_password_is": "आपका पासवर्ड है", "Your_Password_Must_Have": "आपका पासवर्ड होना चाहिए:", "Your_workspace": "आपका कार्यस्थान" -} \ No newline at end of file +} diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 33bada1a175..f607508fbbd 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -72,6 +72,7 @@ "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", + "Apps": "Alkalmazások", "Apply_Certificate": "Tanúsítvány alkalmazása", "ARCHIVE": "Archívum", "archive": "archívum", @@ -989,4 +990,4 @@ "Your_password_is": "A jelszava a következő", "Your_Password_Must_Have": "A jelszavának rendelkeznie kell:", "Your_workspace": "Az Ön munkaterülete" -} \ No newline at end of file +} diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index 5c4d571fa56..7a6e94fb046 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -58,6 +58,7 @@ "announcement": "annuncio", "App_action_error": "Impossibile eseguire questa azione dell’app", "App_action_unsupported": "Questa azione dell’app non è ancora supportata sui dispositivi mobili", + "Apps": "App", "Apply_Certificate": "Applica certificato", "ARCHIVE": "Archivio", "archive": "archivio", @@ -773,4 +774,4 @@ "Your_password_is": "La tua password è", "Your_Password_Must_Have": "La tua password deve avere:", "Your_workspace": "Il tuo workspace" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 82d2ceaa07a..973dec69522 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -53,6 +53,7 @@ "announcement": "アナウンス", "App_action_error": "このアプリ操作を実行できませんでした", "App_action_unsupported": "このアプリ操作はまだモバイルではサポートされていません", + "Apps": "アプリ", "Apply_Certificate": "証明書を適用", "ARCHIVE": "アーカイブ", "archive": "アーカイブ", @@ -644,4 +645,4 @@ "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "招待リンクは{{date}}までか、あと{{usesLeft}}回で使用できなくなります。", "Your_invite_link_will_never_expire": "招待リンクはずっと有効です。", "Your_Password_Must_Have": "パスワードには次のことが必要です。" -} \ No newline at end of file +} diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 2fbf87960d1..e3f2df72bf8 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -57,6 +57,7 @@ "announcement": "aankondiging", "App_action_error": "Deze app-actie kon niet worden uitgevoerd", "App_action_unsupported": "Deze app-actie wordt nog niet ondersteund op mobiel", + "Apps": "Apps", "Apply_Certificate": "Certificaat toepassen", "ARCHIVE": "Archiveer", "archive": "archiveer", @@ -870,4 +871,4 @@ "Your_password_is": "Jouw wachtwoord is", "Your_Password_Must_Have": "Uw wachtwoord moet hebben:", "Your_workspace": "Jouw werkruimte" -} \ No newline at end of file +} diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index 29d0e7530b3..5d2608889e2 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -40,6 +40,7 @@ "Announcement": "Kunngjøring", "App_action_error": "Klarte ikkje å køyre denne apphandlinga", "App_action_unsupported": "Denne apphandlinga er ikkje støtta på mobil enno", + "Apps": "Appar", "App_users_are_not_allowed_to_log_in_directly": "Appbrukere har ikke lov til å logge inn direkte.", "ARCHIVE": "Arkiv", "are_typing": "skriver", @@ -493,4 +494,4 @@ "Your_invite_link_will_expire_after__usesLeft__uses": "Invitasjonslenken din utløper etter {{usesLeft}} anvendelser.", "Your_invite_link_will_expire_on__date__": "Invitasjonslenken din utløper {{date}}.", "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "Invitasjonskoblingen din utløper {{date}} eller etter {{usesLeft}} anvendelser." -} \ No newline at end of file +} diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 9dfbacc6376..95870615fc0 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -73,6 +73,7 @@ "announcement": "kunngjøring", "App_action_error": "Kunne ikke kjøre denne apphandlingen", "App_action_unsupported": "Denne apphandlingen støttes ikke på mobil ennå", + "Apps": "Apper", "App_users_are_not_allowed_to_log_in_directly": "Appbrukere har ikke lov til å logge inn direkte.", "Apply_Certificate": "Bruke sertifikat", "ARCHIVE": "Arkiv", @@ -1035,4 +1036,4 @@ "Your_Password_Must_Have": "Passordet ditt må inneholde:", "Your_push_was_sent_to_s_devices": "Din push ble sendt til {{s}} enheter", "Your_workspace": "Arbeidsområdet ditt" -} \ No newline at end of file +} diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index a2c64b49804..bf99b798437 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -73,6 +73,7 @@ "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", + "Apps": "Aplicativos", "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", "ARCHIVE": "Arquivar", diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index b213bb9911b..159de12626e 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -52,6 +52,7 @@ "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", + "Apps": "Aplicações", "Apply_Certificate": "Aplicar certificado", "ARCHIVE": "Arquivar", "archive": "arquivar", @@ -605,4 +606,4 @@ "you_were_mentioned": "você foi mencionado", "You_will_not_be_able_to_recover_this_message": "Você será incapaz de recuperar esta mensagem!", "Your_Password_Must_Have": "Sua palavra-passe deve ter:" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 8f4e67e26c5..8705943a0b8 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -63,6 +63,7 @@ "announcement": "объявление", "App_action_error": "Не удалось выполнить это действие приложения", "App_action_unsupported": "Это действие приложения пока не поддерживается в мобильной версии", + "Apps": "Приложения", "Apply_Certificate": "Применить сертификат", "ARCHIVE": "Архив", "archive": "архив", @@ -916,4 +917,4 @@ "Your_password_is": "Ваш пароль", "Your_Password_Must_Have": "Ваш пароль должен иметь:", "Your_workspace": "Ваш сервер" -} \ No newline at end of file +} diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 0bd65842b00..baa6b4895aa 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -61,6 +61,7 @@ "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", + "Apps": "Aplikacije", "Apply_Certificate": "Uporabi certifikat", "ARCHIVE": "Arhiv", "archive": "arhiv", @@ -885,4 +886,4 @@ "Your_password_is": "Vaše geslo je", "Your_Password_Must_Have": "Vaše geslo mora imeti:", "Your_workspace": "Vaš delovni prostor" -} \ No newline at end of file +} diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index f03c41f168e..5e90dd5c8ae 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -65,6 +65,7 @@ "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", + "Apps": "Appar", "Apply_Certificate": "Tillämpa certifikat", "ARCHIVE": "Arkiv", "archive": "arkiv", @@ -946,4 +947,4 @@ "Your_password_is": "Ditt lösenord är", "Your_Password_Must_Have": "Ditt lösenord måste ha:", "Your_workspace": "Din arbetsyta" -} \ No newline at end of file +} diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index 1df96922806..f45c2c23fa2 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -72,6 +72,7 @@ "announcement": "அறிவிப்பு", "App_action_error": "இந்தப் பயன்பாட்டு செயலை இயக்க முடியவில்லை", "App_action_unsupported": "இந்தப் பயன்பாட்டு செயல் இன்னும் மொபைலில் ஆதரிக்கப்படவில்லை", + "Apps": "பயன்பாடுகள்", "Apply_Certificate": "சான்றிதழைப் பயன்படுத்துங்கள்", "ARCHIVE": "சேமி", "archive": "சேமி", @@ -986,4 +987,4 @@ "Your_password_is": "உங்கள் கடவுச்சொல் உள்ளது", "Your_Password_Must_Have": "உங்கள் கடவுச்சொல் இருக்க வேண்டும்:", "Your_workspace": "உங்கள் பணிகள்" -} \ No newline at end of file +} diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index fb26259033c..39ec1a02ee5 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -72,6 +72,7 @@ "announcement": "ప్రకటన", "App_action_error": "ఈ యాప్ చర్యను అమలు చేయలేకపోయాము", "App_action_unsupported": "ఈ యాప్ చర్యకు ఇంకా మొబైల్‌లో మద్దতు లేదు", + "Apps": "యాప్‌లు", "Apply_Certificate": "సర్టిఫికేట్‌ని వర్తింపజేయండి", "ARCHIVE": "ఆర్కైవ్", "archive": "ఆర్కైవ్", @@ -985,4 +986,4 @@ "Your_password_is": "మీ సంకేతపదం", "Your_Password_Must_Have": "మీ పాస్‌వర్డ్ తప్పనిసరిగా ఉండాలి:", "Your_workspace": "మీ వర్క్‌స్పేస్" -} \ No newline at end of file +} diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index 064f16ded2f..b0f00fdab9b 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -53,6 +53,7 @@ "announcement": "duyuru", "App_action_error": "Bu uygulama eylemi çalıştırılamadı", "App_action_unsupported": "Bu uygulama eylemi henüz mobilde desteklenmiyor", + "Apps": "Uygulamalar", "Apply_Certificate": "Sertifika uygula", "ARCHIVE": "Arşi̇vle", "archive": "arşivle", @@ -756,4 +757,4 @@ "Your_password_is": "Şifreniz", "Your_Password_Must_Have": "Şifreniz:", "Your_workspace": "Çalışma alanınız" -} \ No newline at end of file +} diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 6bb95d8eef7..86e64489f58 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -53,6 +53,7 @@ "announcement": "公告", "App_action_error": "无法运行此应用操作", "App_action_unsupported": "移动端暂不支持此应用操作", + "Apps": "应用", "Apply_Certificate": "应用证书", "ARCHIVE": "封存", "archive": "封存", @@ -716,4 +717,4 @@ "Your_password_is": "您的密码", "Your_Password_Must_Have": "您的密码必须具有:", "Your_workspace": "您的工作区" -} \ No newline at end of file +} diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index 66303dfb0e5..f3b73243861 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -53,6 +53,7 @@ "announcement": "公告", "App_action_error": "無法執行此應用程式操作", "App_action_unsupported": "行動版尚不支援此應用程式操作", + "Apps": "應用程式", "Apply_Certificate": "應用證書", "ARCHIVE": "封存", "archive": "封存", @@ -745,4 +746,4 @@ "Your_password_is": "您的密碼", "Your_Password_Must_Have": "您的密碼必須具有:", "Your_workspace": "您的工作區" -} \ No newline at end of file +} From d96aeb88c42a84dc3b3afff00c06baa12093a3c1 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Thu, 24 Sep 2026 22:03:46 -0300 Subject: [PATCH 23/29] tests --- .../SidebarView/components/Apps.test.tsx | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 app/views/SidebarView/components/Apps.test.tsx diff --git a/app/views/SidebarView/components/Apps.test.tsx b/app/views/SidebarView/components/Apps.test.tsx new file mode 100644 index 00000000000..a23b4b42779 --- /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({ 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 }); + }); +}); From fb7377087c9cd08d9db580a5e542c1943a74beda Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Fri, 25 Sep 2026 01:05:46 +0000 Subject: [PATCH 24/29] action: organized translations --- app/i18n/locales/ar.json | 4 ++-- app/i18n/locales/bn-IN.json | 4 ++-- app/i18n/locales/cs.json | 4 ++-- app/i18n/locales/de.json | 4 ++-- app/i18n/locales/en.json | 2 +- app/i18n/locales/es.json | 4 ++-- app/i18n/locales/fi.json | 4 ++-- app/i18n/locales/fr.json | 4 ++-- app/i18n/locales/hi-IN.json | 4 ++-- app/i18n/locales/hu.json | 4 ++-- app/i18n/locales/it.json | 4 ++-- app/i18n/locales/ja.json | 4 ++-- app/i18n/locales/nl.json | 4 ++-- app/i18n/locales/nn.json | 4 ++-- app/i18n/locales/no.json | 4 ++-- app/i18n/locales/pt-BR.json | 2 +- app/i18n/locales/pt-PT.json | 4 ++-- app/i18n/locales/ru.json | 4 ++-- app/i18n/locales/sl-SI.json | 4 ++-- app/i18n/locales/sv.json | 4 ++-- app/i18n/locales/ta-IN.json | 4 ++-- app/i18n/locales/te-IN.json | 4 ++-- app/i18n/locales/tr.json | 4 ++-- app/i18n/locales/zh-CN.json | 4 ++-- app/i18n/locales/zh-TW.json | 4 ++-- 25 files changed, 48 insertions(+), 48 deletions(-) diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index fbc078dd1cf..1ac51d51353 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -54,8 +54,8 @@ "announcement": "إعلان", "App_action_error": "تعذر تشغيل إجراء التطبيق هذا", "App_action_unsupported": "إجراء التطبيق هذا غير مدعوم على الهاتف المحمول حتى الآن", - "Apps": "التطبيقات", "Apply_Certificate": "تقديم الشهادة", + "Apps": "التطبيقات", "ARCHIVE": "أرشفة", "archive": "أرشفة", "are_typing": "يكتب", @@ -727,4 +727,4 @@ "Your_password_is": "كلمة المرور الخاصة بك هي", "Your_Password_Must_Have": "يجب أن تحتوي كلمة المرور الخاصة بك على:", "Your_workspace": "مساحة عملك" -} +} \ No newline at end of file diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index ca724235e4a..fabe798fbab 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -72,8 +72,8 @@ "announcement": "ঘোষণা", "App_action_error": "এই অ্যাপ অ্যাকশনটি চালানো যায়নি", "App_action_unsupported": "এই অ্যাপ অ্যাকশনটি এখনও মোবাইলে সমর্থিত নয়", - "Apps": "অ্যাপ", "Apply_Certificate": "সার্টিফিকেট প্রয়োগ করুন", + "Apps": "অ্যাপ", "ARCHIVE": "সংরক্ষণাগার", "archive": "সংরক্ষণাগার", "are_typing": "টাইপ করছে", @@ -988,4 +988,4 @@ "Your_password_is": "আপনার পাসওয়ার্ড হল", "Your_Password_Must_Have": "আপনার পাসওয়ার্ড থাকতে হবে:", "Your_workspace": "আপনার ওয়ার্কস্পেস" -} +} \ No newline at end of file diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 0b4a7a8ab60..42e0ad87c70 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -74,9 +74,9 @@ "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", - "Apps": "Aplikace", "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", @@ -1062,4 +1062,4 @@ "Your_Password_Must_Have": "Vaše heslo musí mít:", "Your_push_was_sent_to_s_devices": "Vaše push byla odeslána do {{s}} zařízení", "Your_workspace": "Váš pracovní prostor" -} +} \ No newline at end of file diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index b0d68f9de51..c4e83a1d020 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -72,8 +72,8 @@ "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", - "Apps": "Anwendungen", "Apply_Certificate": "Zertifikat anwenden", + "Apps": "Anwendungen", "ARCHIVE": "Archiv", "archive": "Archiv", "are_typing": "tippen", @@ -978,4 +978,4 @@ "Your_password_is": "Ihr Passwort lautet", "Your_Password_Must_Have": "Ihr Passwort muss:", "Your_workspace": "Ihr Arbeitsbereich" -} +} \ No newline at end of file diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 761d1748290..367dba828f5 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -75,9 +75,9 @@ "announcement": "announcement", "App_action_error": "Couldn't run this app action", "App_action_unsupported": "This app action isn't supported on mobile yet", - "Apps": "Apps", "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 ba2257cd6e5..8288adce7b1 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -50,8 +50,8 @@ "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", - "Apps": "Aplicaciones", "Apply_Certificate": "Aplicar certificado", + "Apps": "Aplicaciones", "ARCHIVE": "Fichero", "archive": "fichero", "are_typing": "están escribiendo", @@ -548,4 +548,4 @@ "You_will_not_be_able_to_recover_this_message": "¡No podrás recuperar este mensaje!", "Your_certificate": "Tu certificado", "Your_Password_Must_Have": "Su contraseña debe tener:" -} +} \ No newline at end of file diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 718585efbc7..5a97e51ec08 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -65,8 +65,8 @@ "announcement": "ilmoitus", "App_action_error": "Tätä sovellustoimintoa ei voitu suorittaa", "App_action_unsupported": "Tätä sovellustoimintoa ei vielä tueta mobiililaitteilla", - "Apps": "Sovellukset", "Apply_Certificate": "Käytä sertifikaattia", + "Apps": "Sovellukset", "ARCHIVE": "Arkisto", "archive": "arkisto", "are_typing": "kirjoittavat", @@ -950,4 +950,4 @@ "Your_password_is": "Salasanasi on", "Your_Password_Must_Have": "Salasanasi on oltava:", "Your_workspace": "Työtilasi" -} +} \ No newline at end of file diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index 1d98e10ad89..09b3ba91cb1 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -57,8 +57,8 @@ "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", - "Apps": "Applications", "Apply_Certificate": "Appliquer le certificat", + "Apps": "Applications", "ARCHIVE": "Archiver", "archive": "archiver", "are_typing": "sont en train d'écrire", @@ -872,4 +872,4 @@ "Your_password_is": "Votre mot de passe est", "Your_Password_Must_Have": "Votre mot de passe doit avoir:", "Your_workspace": "Votre espace de travail" -} +} \ No newline at end of file diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index ae1ad46371b..4a2bc1e4708 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -72,8 +72,8 @@ "announcement": "घोषणा", "App_action_error": "यह ऐप कार्रवाई चल नहीं सकी", "App_action_unsupported": "यह ऐप कार्रवाई अभी मोबाइल पर समर्थित नहीं है", - "Apps": "ऐप्स", "Apply_Certificate": "प्रमाणपत्र लागू करें", + "Apps": "ऐप्स", "ARCHIVE": "संग्रह", "archive": "संग्रह", "are_typing": "टाइप कर रहे हैं", @@ -988,4 +988,4 @@ "Your_password_is": "आपका पासवर्ड है", "Your_Password_Must_Have": "आपका पासवर्ड होना चाहिए:", "Your_workspace": "आपका कार्यस्थान" -} +} \ No newline at end of file diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 171e9a76e08..47c8fe25cab 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -72,8 +72,8 @@ "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", - "Apps": "Alkalmazások", "Apply_Certificate": "Tanúsítvány alkalmazása", + "Apps": "Alkalmazások", "ARCHIVE": "Archívum", "archive": "archívum", "are_typing": "éppen ír", @@ -991,4 +991,4 @@ "Your_password_is": "A jelszava a következő", "Your_Password_Must_Have": "A jelszavának rendelkeznie kell:", "Your_workspace": "Az Ön munkaterülete" -} +} \ No newline at end of file diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index d779b511200..a170cc8eb8e 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -58,8 +58,8 @@ "announcement": "annuncio", "App_action_error": "Impossibile eseguire questa azione dell’app", "App_action_unsupported": "Questa azione dell’app non è ancora supportata sui dispositivi mobili", - "Apps": "App", "Apply_Certificate": "Applica certificato", + "Apps": "App", "ARCHIVE": "Archivio", "archive": "archivio", "are_typing": "stanno scrivendo", @@ -775,4 +775,4 @@ "Your_password_is": "La tua password è", "Your_Password_Must_Have": "La tua password deve avere:", "Your_workspace": "Il tuo workspace" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index cbb36597ce4..715fd313df8 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -53,8 +53,8 @@ "announcement": "アナウンス", "App_action_error": "このアプリ操作を実行できませんでした", "App_action_unsupported": "このアプリ操作はまだモバイルではサポートされていません", - "Apps": "アプリ", "Apply_Certificate": "証明書を適用", + "Apps": "アプリ", "ARCHIVE": "アーカイブ", "archive": "アーカイブ", "are_typing": "が入力中", @@ -646,4 +646,4 @@ "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "招待リンクは{{date}}までか、あと{{usesLeft}}回で使用できなくなります。", "Your_invite_link_will_never_expire": "招待リンクはずっと有効です。", "Your_Password_Must_Have": "パスワードには次のことが必要です。" -} +} \ No newline at end of file diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index 9efafc0de82..c773386fe1a 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -57,8 +57,8 @@ "announcement": "aankondiging", "App_action_error": "Deze app-actie kon niet worden uitgevoerd", "App_action_unsupported": "Deze app-actie wordt nog niet ondersteund op mobiel", - "Apps": "Apps", "Apply_Certificate": "Certificaat toepassen", + "Apps": "Apps", "ARCHIVE": "Archiveer", "archive": "archiveer", "are_typing": "zijn aan het typen", @@ -872,4 +872,4 @@ "Your_password_is": "Jouw wachtwoord is", "Your_Password_Must_Have": "Uw wachtwoord moet hebben:", "Your_workspace": "Jouw werkruimte" -} +} \ No newline at end of file diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index aa9790131d1..441f06ed459 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -40,8 +40,8 @@ "Announcement": "Kunngjøring", "App_action_error": "Klarte ikkje å køyre denne apphandlinga", "App_action_unsupported": "Denne apphandlinga er ikkje støtta på mobil enno", - "Apps": "Appar", "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?", @@ -495,4 +495,4 @@ "Your_invite_link_will_expire_after__usesLeft__uses": "Invitasjonslenken din utløper etter {{usesLeft}} anvendelser.", "Your_invite_link_will_expire_on__date__": "Invitasjonslenken din utløper {{date}}.", "Your_invite_link_will_expire_on__date__or_after__usesLeft__uses": "Invitasjonskoblingen din utløper {{date}} eller etter {{usesLeft}} anvendelser." -} +} \ No newline at end of file diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 4324a6a14c6..0593f71d09b 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -73,9 +73,9 @@ "announcement": "kunngjøring", "App_action_error": "Kunne ikke kjøre denne apphandlingen", "App_action_unsupported": "Denne apphandlingen støttes ikke på mobil ennå", - "Apps": "Apper", "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", @@ -1037,4 +1037,4 @@ "Your_Password_Must_Have": "Passordet ditt må inneholde:", "Your_push_was_sent_to_s_devices": "Din push ble sendt til {{s}} enheter", "Your_workspace": "Arbeidsområdet ditt" -} +} \ No newline at end of file diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index d1db46acdf4..4010dad858f 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -73,9 +73,9 @@ "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", - "Apps": "Aplicativos", "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 2f182dd7a91..33afd28929c 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -52,8 +52,8 @@ "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", - "Apps": "Aplicações", "Apply_Certificate": "Aplicar certificado", + "Apps": "Aplicações", "ARCHIVE": "Arquivar", "archive": "arquivar", "are_typing": "estão a escrever", @@ -607,4 +607,4 @@ "you_were_mentioned": "você foi mencionado", "You_will_not_be_able_to_recover_this_message": "Você será incapaz de recuperar esta mensagem!", "Your_Password_Must_Have": "Sua palavra-passe deve ter:" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 215364f06a9..8ced77fcb15 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -63,8 +63,8 @@ "announcement": "объявление", "App_action_error": "Не удалось выполнить это действие приложения", "App_action_unsupported": "Это действие приложения пока не поддерживается в мобильной версии", - "Apps": "Приложения", "Apply_Certificate": "Применить сертификат", + "Apps": "Приложения", "ARCHIVE": "Архив", "archive": "архив", "are_typing": "печатают", @@ -918,4 +918,4 @@ "Your_password_is": "Ваш пароль", "Your_Password_Must_Have": "Ваш пароль должен иметь:", "Your_workspace": "Ваш сервер" -} +} \ No newline at end of file diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 8a4b07bddd5..9a230f21391 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -61,8 +61,8 @@ "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", - "Apps": "Aplikacije", "Apply_Certificate": "Uporabi certifikat", + "Apps": "Aplikacije", "ARCHIVE": "Arhiv", "archive": "arhiv", "are_typing": "pišejo", @@ -887,4 +887,4 @@ "Your_password_is": "Vaše geslo je", "Your_Password_Must_Have": "Vaše geslo mora imeti:", "Your_workspace": "Vaš delovni prostor" -} +} \ No newline at end of file diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index a8a546c487f..6cbe7bfae9e 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -65,8 +65,8 @@ "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", - "Apps": "Appar", "Apply_Certificate": "Tillämpa certifikat", + "Apps": "Appar", "ARCHIVE": "Arkiv", "archive": "arkiv", "are_typing": "skriver", @@ -948,4 +948,4 @@ "Your_password_is": "Ditt lösenord är", "Your_Password_Must_Have": "Ditt lösenord måste ha:", "Your_workspace": "Din arbetsyta" -} +} \ No newline at end of file diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index 6b121ed1f6d..05d8dbfc7f1 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -72,8 +72,8 @@ "announcement": "அறிவிப்பு", "App_action_error": "இந்தப் பயன்பாட்டு செயலை இயக்க முடியவில்லை", "App_action_unsupported": "இந்தப் பயன்பாட்டு செயல் இன்னும் மொபைலில் ஆதரிக்கப்படவில்லை", - "Apps": "பயன்பாடுகள்", "Apply_Certificate": "சான்றிதழைப் பயன்படுத்துங்கள்", + "Apps": "பயன்பாடுகள்", "ARCHIVE": "சேமி", "archive": "சேமி", "are_typing": "எழுதுதல் நடந்துள்ளது", @@ -988,4 +988,4 @@ "Your_password_is": "உங்கள் கடவுச்சொல் உள்ளது", "Your_Password_Must_Have": "உங்கள் கடவுச்சொல் இருக்க வேண்டும்:", "Your_workspace": "உங்கள் பணிகள்" -} +} \ No newline at end of file diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index a288e3da772..57cac526d85 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -72,8 +72,8 @@ "announcement": "ప్రకటన", "App_action_error": "ఈ యాప్ చర్యను అమలు చేయలేకపోయాము", "App_action_unsupported": "ఈ యాప్ చర్యకు ఇంకా మొబైల్‌లో మద్దতు లేదు", - "Apps": "యాప్‌లు", "Apply_Certificate": "సర్టిఫికేట్‌ని వర్తింపజేయండి", + "Apps": "యాప్‌లు", "ARCHIVE": "ఆర్కైవ్", "archive": "ఆర్కైవ్", "are_typing": "రాయడం", @@ -987,4 +987,4 @@ "Your_password_is": "మీ సంకేతపదం", "Your_Password_Must_Have": "మీ పాస్‌వర్డ్ తప్పనిసరిగా ఉండాలి:", "Your_workspace": "మీ వర్క్‌స్పేస్" -} +} \ No newline at end of file diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index 5e799cec316..141e0cd6b92 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -53,8 +53,8 @@ "announcement": "duyuru", "App_action_error": "Bu uygulama eylemi çalıştırılamadı", "App_action_unsupported": "Bu uygulama eylemi henüz mobilde desteklenmiyor", - "Apps": "Uygulamalar", "Apply_Certificate": "Sertifika uygula", + "Apps": "Uygulamalar", "ARCHIVE": "Arşi̇vle", "archive": "arşivle", "are_typing": "yazıyor", @@ -758,4 +758,4 @@ "Your_password_is": "Şifreniz", "Your_Password_Must_Have": "Şifreniz:", "Your_workspace": "Çalışma alanınız" -} +} \ No newline at end of file diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 98595db149f..048ee776961 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -53,8 +53,8 @@ "announcement": "公告", "App_action_error": "无法运行此应用操作", "App_action_unsupported": "移动端暂不支持此应用操作", - "Apps": "应用", "Apply_Certificate": "应用证书", + "Apps": "应用", "ARCHIVE": "封存", "archive": "封存", "are_typing": "正在输入", @@ -718,4 +718,4 @@ "Your_password_is": "您的密码", "Your_Password_Must_Have": "您的密码必须具有:", "Your_workspace": "您的工作区" -} +} \ No newline at end of file diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index cf663655ef6..eba78dc9d28 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -53,8 +53,8 @@ "announcement": "公告", "App_action_error": "無法執行此應用程式操作", "App_action_unsupported": "行動版尚不支援此應用程式操作", - "Apps": "應用程式", "Apply_Certificate": "應用證書", + "Apps": "應用程式", "ARCHIVE": "封存", "archive": "封存", "are_typing": "正在輸入", @@ -747,4 +747,4 @@ "Your_password_is": "您的密碼", "Your_Password_Must_Have": "您的密碼必須具有:", "Your_workspace": "您的工作區" -} +} \ No newline at end of file From 92d37ae8f8b7175ba914f37d26000c32a0c1bcc5 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Tue, 29 Sep 2026 16:03:13 -0300 Subject: [PATCH 25/29] fix: translation --- app/i18n/locales/te-IN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index 57cac526d85..f3ce5e46a2b 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -71,7 +71,7 @@ "Announcement": "ప్రకటన", "announcement": "ప్రకటన", "App_action_error": "ఈ యాప్ చర్యను అమలు చేయలేకపోయాము", - "App_action_unsupported": "ఈ యాప్ చర్యకు ఇంకా మొబైల్‌లో మద్దতు లేదు", + "App_action_unsupported": "ఈ యాప్ చర్యకు ఇంకా మొబైల్‌లో మద్దతు లేదు", "Apply_Certificate": "సర్టిఫికేట్‌ని వర్తింపజేయండి", "Apps": "యాప్‌లు", "ARCHIVE": "ఆర్కైవ్", From 6b386d6a083829d350c39418507d0efb518d5119 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 30 Sep 2026 17:03:07 -0300 Subject: [PATCH 26/29] fix(apps): ignore stale fetch responses after store reset --- app/lib/apps/__tests__/appsStore.test.ts | 19 ++++++++++++++++++ app/lib/apps/appsStore.ts | 25 ++++++++++++++++++++---- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/app/lib/apps/__tests__/appsStore.test.ts b/app/lib/apps/__tests__/appsStore.test.ts index 2e00fa35efa..0866b5ab3fc 100644 --- a/app/lib/apps/__tests__/appsStore.test.ts +++ b/app/lib/apps/__tests__/appsStore.test.ts @@ -207,3 +207,22 @@ describe('subscribeToApps', () => { 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({}); + }); +}); diff --git a/app/lib/apps/appsStore.ts b/app/lib/apps/appsStore.ts index b9f426074bd..77f3de79f35 100644 --- a/app/lib/apps/appsStore.ts +++ b/app/lib/apps/appsStore.ts @@ -28,33 +28,50 @@ const initialState: TAppsState = { translations: {} }; +let storeVersion = 0; + export const useAppsStore = create(set => ({ ...initialState, fetchActionButtons: async () => { + const version = storeVersion; try { - set({ actionButtons: await getAppActionButtons() }); + const actionButtons = await getAppActionButtons(); + if (version === storeVersion) { + set({ actionButtons }); + } } catch (e) { - set({ actionButtons: [] }); + if (version === storeVersion) { + set({ actionButtons: [] }); + } log(e); } }, fetchTranslations: async () => { + const version = storeVersion; try { const { apps } = await getAppsLanguages(); + if (version !== storeVersion) { + return; + } const translations = apps.reduce((acc, { id, languages }) => { acc[id] = languages; return acc; }, {}); set({ translations }); } catch (e) { - set({ translations: {} }); + if (version === storeVersion) { + set({ translations: {} }); + } log(e); } }, - reset: () => set(initialState) + reset: () => { + storeVersion += 1; + set(initialState); + } })); const APPS_STREAM = 'stream-apps'; From d45c8d9b5fb57a05211276262794e0bb7d9d76a0 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 30 Sep 2026 17:05:30 -0300 Subject: [PATCH 27/29] fix(apps): stop stream listener when subscribe fails --- app/lib/apps/appsStore.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/lib/apps/appsStore.ts b/app/lib/apps/appsStore.ts index 77f3de79f35..5f688ba858f 100644 --- a/app/lib/apps/appsStore.ts +++ b/app/lib/apps/appsStore.ts @@ -107,13 +107,20 @@ const subscribeToStream = () => { 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; + } // Leaves the next `isLoginReady()` edge free to try again. subscribed = false; - log(e); + streamListener = null; }; try { - streamListener = sdk.onStreamData(APPS_STREAM, handleStreamData); + listener = sdk.onStreamData(APPS_STREAM, handleStreamData); + streamListener = listener; sdk .subscribe(APPS_STREAM, APPS_EVENT) .then(subscription => { From 28208f84f8e8343ba8c78c1c72abd94c1da661bc Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 30 Sep 2026 17:19:09 -0300 Subject: [PATCH 28/29] refactor(apps): reuse showToast helper instead of a second toast emitter --- .../components/Buttons/ActionsButton.tsx | 8 +- .../Buttons/__tests__/ActionsButton.test.tsx | 3 +- app/containers/Toast.tsx | 3 - app/lib/apps/actions.ts | 23 +- app/lib/apps/appsStore.ts | 50 +++-- app/lib/apps/translations.ts | 12 +- app/lib/apps/triggerActions.ts | 17 +- app/lib/apps/triggerAppActionButton.ts | 6 +- app/lib/apps/useAppActionButtons.ts | 201 ++++++++++-------- app/lib/methods/helpers/emitter.ts | 1 - 10 files changed, 174 insertions(+), 150 deletions(-) diff --git a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx index 63469382bd7..3880f3527be 100644 --- a/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx +++ b/app/containers/MessageComposer/components/Buttons/ActionsButton.tsx @@ -14,6 +14,11 @@ 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(); @@ -28,8 +33,7 @@ export const ActionsButton = () => { }); const { showActionSheet, hideActionSheet } = useActionSheet(); const isMasterDetail = useMasterDetail(); - const appActions = useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid }); - const aiActions = useAppActionButtons({ context: UIActionButtonContext.ROOM_ACTION, category: 'ai', rid }); + const [aiActions, appActions] = useAppActionButtons({ filters: COMPOSER_APP_FILTERS, rid }); const createDiscussion = async () => { if (!rid) return; diff --git a/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx b/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx index 986d1b41aff..b22518cc3ab 100644 --- a/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx +++ b/app/containers/MessageComposer/components/Buttons/__tests__/ActionsButton.test.tsx @@ -5,7 +5,8 @@ import { ActionsButton } from '../ActionsButton'; const mockUseAppActionButtons = jest.fn((_params: unknown): IAppActionButtonItem[] => []); jest.mock('~/lib/apps/useAppActionButtons', () => ({ - useAppActionButtons: (params: unknown) => mockUseAppActionButtons(params) + useAppActionButtons: ({ filters, rid }: { filters: { context: string; category?: string }[]; rid?: string }) => + filters.map(filter => mockUseAppActionButtons({ ...filter, rid })) })); const mockTriggerAppActionButton = jest.fn(); diff --git a/app/containers/Toast.tsx b/app/containers/Toast.tsx index dea1661568c..8bb86755444 100644 --- a/app/containers/Toast.tsx +++ b/app/containers/Toast.tsx @@ -5,7 +5,6 @@ import EasyToast from 'react-native-easy-toast'; import { useUserPreferences } from '../lib/methods/userPreferences'; import { type TAlertDisplayType } from '../views/AccessibilityAndAppearanceView'; import EventEmitter from '../lib/methods/helpers/events'; -import { emitter } from '../lib/methods/helpers/emitter'; import { useTheme } from '../theme'; import sharedStyles from '../views/Styles'; import { ALERT_DISPLAY_TYPE_PREFERENCES_KEY } from '../lib/constants/keys'; @@ -33,10 +32,8 @@ const Toast = (): ReactElement => { useEffect(() => { listener = EventEmitter.addEventListener(LISTENER, showToast); - emitter.on('showToast', showToast); return () => { EventEmitter.removeListener(LISTENER, listener); - emitter.off('showToast', showToast); }; }, [alertDisplayType]); diff --git a/app/lib/apps/actions.ts b/app/lib/apps/actions.ts index 54a10a907d2..aa0a503e636 100644 --- a/app/lib/apps/actions.ts +++ b/app/lib/apps/actions.ts @@ -3,10 +3,9 @@ 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 '~/lib/methods/helpers/events'; -import fetch from '~/lib/methods/helpers/fetch'; import { random } from '~/lib/methods/helpers'; import Navigation from '~/lib/navigation/appNavigation'; -import sdk from '~/lib/services/sdk'; +import { fetchAppsApi } from '~/lib/services/restApi'; const triggersId = new Map(); @@ -109,11 +108,6 @@ 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, @@ -131,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() === '') { diff --git a/app/lib/apps/appsStore.ts b/app/lib/apps/appsStore.ts index 5f688ba858f..673aced9291 100644 --- a/app/lib/apps/appsStore.ts +++ b/app/lib/apps/appsStore.ts @@ -1,6 +1,7 @@ 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'; @@ -40,11 +41,8 @@ export const useAppsStore = create(set => ({ if (version === storeVersion) { set({ actionButtons }); } - } catch (e) { - if (version === storeVersion) { - set({ actionButtons: [] }); - } - log(e); + } catch { + // Servers without the Apps framework reject this; keep whatever was already loaded. } }, @@ -56,15 +54,15 @@ export const useAppsStore = create(set => ({ return; } const translations = apps.reduce((acc, { id, languages }) => { - acc[id] = languages; + acc[id] = Object.entries(languages).reduce((byLanguage, [language, keys]) => { + byLanguage[normalizeLanguage(language)] = keys; + return byLanguage; + }, {}); return acc; }, {}); set({ translations }); - } catch (e) { - if (version === storeVersion) { - set({ translations: {} }); - } - log(e); + } catch { + // Servers without the Apps framework reject this; keep whatever was already loaded. } }, @@ -84,16 +82,27 @@ 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(); - // The engine fires `actions/changed` on both register and clear, so it covers the whole button - // lifecycle on its own; translations ship with an app and only arrive when one is added. if (event === 'actions/changed') { fetchActionButtons().catch(log); } - if (event === 'app/added') { + // Translations ship with an app, so any install, update or removal can change them. + if (typeof event === 'string' && event.startsWith('app/')) { fetchTranslations().catch(log); } }; @@ -114,9 +123,17 @@ const subscribeToStream = () => { if (current !== generation) { return; } - // Leaves the next `isLoginReady()` edge free to try again. 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); @@ -130,6 +147,7 @@ const subscribeToStream = () => { return; } streamSubscription = subscription ?? null; + retryAttempt = 0; }) .catch(fail); } catch (e) { @@ -139,6 +157,8 @@ const subscribeToStream = () => { const unsubscribeFromStream = () => { generation += 1; + clearRetry(); + retryAttempt = 0; subscribed = false; streamListener?.then(listener => listener.stop()).catch(log); streamListener = null; diff --git a/app/lib/apps/translations.ts b/app/lib/apps/translations.ts index 1aff9ad457a..d4a122c1775 100644 --- a/app/lib/apps/translations.ts +++ b/app/lib/apps/translations.ts @@ -1,7 +1,7 @@ import { type TAppTranslations } from './appsStore'; import i18n from '~/i18n'; -const normalizeLanguage = (language: string) => language.toLowerCase().replace('_', '-'); +export const normalizeLanguage = (language: string) => language.toLowerCase().replace('_', '-'); export const translateAppKey = ({ appId, @@ -19,19 +19,11 @@ export const translateAppKey = ({ return key; } - const byLanguage = Object.entries(languages).reduce<{ [language: string]: { [key: string]: string } }>( - (acc, [language, keys]) => { - acc[normalizeLanguage(language)] = keys; - return acc; - }, - {} - ); - const normalized = normalizeLanguage(locale); const candidates = [normalized, normalized.split('-')[0], 'en']; for (const candidate of candidates) { - const translation = byLanguage[candidate]?.[key]; + const translation = languages[candidate]?.[key]; if (translation) { return translation; } diff --git a/app/lib/apps/triggerActions.ts b/app/lib/apps/triggerActions.ts index 58684759efa..efb7158049a 100644 --- a/app/lib/apps/triggerActions.ts +++ b/app/lib/apps/triggerActions.ts @@ -5,12 +5,21 @@ import { 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 = await triggerAction({ type: ActionTypes.SUBMIT, viewId, ...options }); - if (!result || ModalActions.CLOSE === result || ModalActions.UNSUPPORTED === result) { + const result = notifyUnsupported(await triggerAction({ type: ActionTypes.SUBMIT, viewId, ...options })); + if (!result || ModalActions.CLOSE === result) { Navigation.back(); } } @@ -19,6 +28,6 @@ export function triggerCancel({ view, ...options }: ITriggerCancel) { return triggerAction({ type: ActionTypes.CLOSED, view, ...options }); } -export function triggerBlockAction(options: ITriggerBlockAction) { - return triggerAction({ type: ActionTypes.ACTION, ...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 index 75e15db59f1..e86c15d4ea0 100644 --- a/app/lib/apps/triggerAppActionButton.ts +++ b/app/lib/apps/triggerAppActionButton.ts @@ -2,7 +2,7 @@ import { type IAppActionButton } from './definitions'; import { ActionTypes, ModalActions } from '~/containers/UIKit/interfaces'; import I18n from '~/i18n'; import { triggerAction } from './actions'; -import { emitter } from '~/lib/methods/helpers/emitter'; +import { showToast } from '~/lib/methods/helpers/showToast'; import log from '~/lib/methods/helpers/log'; interface ITriggerAppActionButton { @@ -26,10 +26,10 @@ export const triggerAppActionButton = async ({ button, rid, tmid, mid, message } }); if (result === ModalActions.UNSUPPORTED) { - emitter.emit('showToast', { message: I18n.t('App_action_unsupported') }); + showToast(I18n.t('App_action_unsupported')); } } catch (e) { log(e); - emitter.emit('showToast', { message: I18n.t('App_action_error') }); + showToast(I18n.t('App_action_error')); } }; diff --git a/app/lib/apps/useAppActionButtons.ts b/app/lib/apps/useAppActionButtons.ts index 17c524175b3..76ca6021fb4 100644 --- a/app/lib/apps/useAppActionButtons.ts +++ b/app/lib/apps/useAppActionButtons.ts @@ -14,40 +14,11 @@ import { import { applyAuthFilter, applyCategoryFilter, applyRoomFilter, collectPermissions } from './filters'; import { translateAppKey } from './translations'; import database from '~/lib/database'; -import { getSubscriptionByRoomId } from '~/lib/database/services/Subscription'; import { useAppSelector } from '~/lib/hooks/useAppSelector'; import log from '~/lib/methods/helpers/log'; import { getUserSelector } from '~/selectors/login'; -import { type TPermissionModel } from '~/definitions'; - -const getPermissionRoles = async (ids: string[]): Promise<{ [permission: string]: string[] }> => { - if (!ids.length) { - return {}; - } - try { - const records = (await database.active - .get('permissions') - .query(Q.where('id', Q.oneOf(ids))) - .fetch()) as TPermissionModel[]; - return records.reduce<{ [permission: string]: string[] }>((acc, record) => { - acc[record.id] = record.roles ?? []; - return acc; - }, {}); - } catch (e) { - log(e); - return {}; - } -}; - -const splitKey = (key: string): string[] => (key ? key.split(',') : []); - -interface IAppActionButtonContext { - /** Inputs this context was resolved for, so a room change can't be filtered against the previous one. */ - key: string; - room: IAppActionButtonRoom; - roles: string[]; - permissions: { [permission: string]: string[] }; -} +import { type TPermissionModel, type TSubscriptionModel } from '~/definitions'; +import i18n from '~/i18n'; export interface IAppActionButtonItem { id: string; @@ -55,80 +26,130 @@ export interface IAppActionButtonItem { button: IAppActionButton; } -export const useAppActionButtons = ({ - context, - category = 'default', - rid -}: { +export interface IAppActionButtonFilter { context: TUIActionButtonContext; + /** Leave out to accept every category. */ category?: TAppActionButtonCategory; - rid?: string; -}): IAppActionButtonItem[] => { - const buttons = useAppsStore(useShallow(state => state.actionButtons.filter(button => button.context === context))); - const translations = useAppsStore(state => state.translations); - const userRoles = useAppSelector(state => getUserSelector(state).roles || [], shallowEqual); +} - const [filterContext, setContext] = useState(null); +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[]; +} - useEffect(subscribeToApps, []); +const splitKey = (key: string): string[] => (key ? key.split(',') : []); - const permissionIds = useMemo(() => collectPermissions(buttons), [buttons]); - const permissionsKey = permissionIds.join(','); - const userRolesKey = userRoles.join(','); - const contextKey = `${rid ?? ''}|${permissionsKey}|${userRolesKey}`; - const hasButtons = buttons.length > 0; +const useRoomContext = (rid?: string, enabled = true): IRoomContext | null => { + const [roomContext, setRoomContext] = useState(null); useEffect(() => { - if (!hasButtons) { + 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]); - let cancelled = false; - - const resolveFilterContext = async (): Promise => { - const subscription = rid ? await getSubscriptionByRoomId(rid) : null; - const permissionRoles = await getPermissionRoles(splitKey(permissionsKey)); - if (cancelled) { - return; - } - setContext({ - key: contextKey, - room: { - t: subscription?.t, - teamMain: subscription?.teamMain, - prid: subscription?.prid, - uids: subscription?.uids + 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 }); }, - roles: [...new Set([...(subscription?.roles ?? []), ...splitKey(userRolesKey)])], - permissions: permissionRoles + error: log }); - }; + return () => subscription.unsubscribe(); + }, [permissionsKey]); - resolveFilterContext().catch(log); + 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, []); - return () => { - cancelled = true; - }; - }, [hasButtons, rid, permissionsKey, userRolesKey, contextKey]); + 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(() => { - // A resolve for the previous room may still be the latest state; filtering against it would - // list buttons this room excludes. - if (!filterContext || filterContext.key !== contextKey) { - return []; + 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 { room, roles, permissions } = filterContext; - return buttons - .filter( - button => - applyCategoryFilter(button, category) && - applyRoomFilter(button, room) && - applyAuthFilter(button, { roles, permissions }) - ) - .map(button => ({ - id: getIdForActionButton(button), - label: translateAppKey({ appId: button.appId, key: button.labelI18n, translations }), - button - })); - }, [buttons, category, contextKey, filterContext, translations]); + 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/emitter.ts b/app/lib/methods/helpers/emitter.ts index e36693d56e6..bd355b71833 100644 --- a/app/lib/methods/helpers/emitter.ts +++ b/app/lib/methods/helpers/emitter.ts @@ -17,7 +17,6 @@ export type TEmitterEvents = TDynamicMediaDownloadEvents & { setComposerHeightThread: number; audioFocused: string; navigationReady: undefined; - showToast: { message: string }; }; export type TKeyEmitterEvent = keyof TEmitterEvents; From 0b6d07f88d454f4c89c7ab9c3d4b12103a3a0be3 Mon Sep 17 00:00:00 2001 From: OtavioStasiak Date: Wed, 30 Sep 2026 17:19:35 -0300 Subject: [PATCH 29/29] test(apps): cover live filtering, retry and unsupported toasts --- app/lib/apps/__tests__/actions.test.ts | 2 +- app/lib/apps/__tests__/appsStore.test.ts | 47 +++++++ app/lib/apps/__tests__/translations.test.ts | 2 +- app/lib/apps/__tests__/triggerActions.test.ts | 22 ++++ .../__tests__/useAppActionButtons.test.ts | 120 +++++++++++++----- app/lib/services/restApi.ts | 11 +- .../SidebarView/components/Apps.test.tsx | 10 +- app/views/SidebarView/components/Apps.tsx | 4 +- 8 files changed, 177 insertions(+), 41 deletions(-) diff --git a/app/lib/apps/__tests__/actions.test.ts b/app/lib/apps/__tests__/actions.test.ts index a5b8a239b2f..94aaf04928d 100644 --- a/app/lib/apps/__tests__/actions.test.ts +++ b/app/lib/apps/__tests__/actions.test.ts @@ -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 () => { diff --git a/app/lib/apps/__tests__/appsStore.test.ts b/app/lib/apps/__tests__/appsStore.test.ts index 0866b5ab3fc..3eb2fa22c6f 100644 --- a/app/lib/apps/__tests__/appsStore.test.ts +++ b/app/lib/apps/__tests__/appsStore.test.ts @@ -189,6 +189,40 @@ describe('subscribeToApps', () => { 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(); @@ -225,4 +259,17 @@ describe('useAppsStore reset', () => { 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__/translations.test.ts b/app/lib/apps/__tests__/translations.test.ts index 91de05f21ba..02457036c82 100644 --- a/app/lib/apps/__tests__/translations.test.ts +++ b/app/lib/apps/__tests__/translations.test.ts @@ -4,7 +4,7 @@ import { translateAppKey } from '../translations'; const translations: TAppTranslations = { 'app-id': { en: { greeting: 'Summarize' }, - 'pt-BR': { greeting: 'Resumir' }, + 'pt-br': { greeting: 'Resumir' }, pt: { greeting: 'Resumo' } } }; diff --git a/app/lib/apps/__tests__/triggerActions.test.ts b/app/lib/apps/__tests__/triggerActions.test.ts index 1a154a51922..ac1bb3c0001 100644 --- a/app/lib/apps/__tests__/triggerActions.test.ts +++ b/app/lib/apps/__tests__/triggerActions.test.ts @@ -7,6 +7,11 @@ jest.mock('~/lib/navigation/appNavigation', () => ({ back: jest.fn() })); +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 index 859ada3cc4f..e0f0371ff44 100644 --- a/app/lib/apps/__tests__/useAppActionButtons.test.ts +++ b/app/lib/apps/__tests__/useAppActionButtons.test.ts @@ -1,4 +1,4 @@ -import { renderHook, waitFor } from '@testing-library/react-native'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; import { type IAppActionButton, UIActionButtonContext } from '../definitions'; import { useAppActionButtons } from '../useAppActionButtons'; @@ -12,19 +12,18 @@ jest.mock('../appsStore', () => ({ selector({ actionButtons: mockActionButtons, translations: mockTranslations }) })); -let mockSubscription: Record | null = { t: 'c', roles: ['owner'] }; -jest.mock('~/lib/database/services/Subscription', () => ({ - getSubscriptionByRoomId: () => Promise.resolve(mockSubscription) -})); - -let mockPermissionRecords: { id: string; roles: string[] }[] = []; +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: () => ({ - query: () => ({ fetch: () => Promise.resolve(mockPermissionRecords) }) + get: (table: string) => ({ + query: () => ({ + observeWithColumns: () => (table === 'subscriptions' ? mockSubscriptions : mockPermissionRecords) + }) }) }; } @@ -32,8 +31,10 @@ jest.mock('~/lib/database', () => ({ })); let mockUserRoles: string[] = ['user']; +let mockLanguage = 'en'; jest.mock('~/lib/hooks/useAppSelector', () => ({ - useAppSelector: () => mockUserRoles + useAppSelector: (selector: (state: unknown) => unknown) => + selector({ login: { user: { roles: mockUserRoles, language: mockLanguage } } }) })); const button = (overrides: Partial = {}): IAppActionButton => ({ @@ -44,20 +45,24 @@ const button = (overrides: Partial = {}): IAppActionButton => ...overrides }); +const useMessageBox = (rid?: string) => + useAppActionButtons({ filters: [{ context: UIActionButtonContext.MESSAGE_BOX_ACTION }], rid })[0]; + describe('useAppActionButtons', () => { beforeEach(() => { jest.clearAllMocks(); mockActionButtons = []; - mockSubscription = { t: 'c', roles: ['owner'] }; - mockPermissionRecords = []; + 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(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid' })); + const { unmount } = renderHook(() => useMessageBox('rid')); expect(mockSubscribeToApps).toHaveBeenCalledTimes(1); unmount(); @@ -66,16 +71,21 @@ describe('useAppActionButtons', () => { it('returns nothing until the room is resolved', () => { mockActionButtons = [button()]; + mockSubscriptions = new ReplaySubject(1); - const { result } = renderHook(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid' })); + 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(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid' })); + const { result } = renderHook(() => useMessageBox('rid')); await waitFor(() => expect(result.current).toHaveLength(1)); expect(result.current[0]).toMatchObject({ id: 'app-id/summarize', label: 'Summarize thread' }); @@ -84,23 +94,24 @@ describe('useAppActionButtons', () => { it('drops the previous room buttons until the new room resolves', async () => { mockActionButtons = [button()]; - const { result, rerender } = renderHook( - ({ rid }: { rid: string }) => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid }), - { initialProps: { rid: 'rid' } } - ); + 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([]); - await waitFor(() => expect(result.current).toHaveLength(1)); + + 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(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid' })); + const { result } = renderHook(() => useMessageBox('rid')); await waitFor(() => expect(result.current).toHaveLength(1)); expect(result.current[0].button.actionId).toBe('summarize'); @@ -109,8 +120,9 @@ describe('useAppActionButtons', () => { it('separates the ai category from the default one', async () => { mockActionButtons = [button(), button({ actionId: 'ai-one', category: 'ai' })]; - const { result } = renderHook(() => - useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, category: 'ai', rid: 'rid' }) + const { result } = renderHook( + () => + useAppActionButtons({ filters: [{ context: UIActionButtonContext.MESSAGE_BOX_ACTION, category: 'ai' }], rid: 'rid' })[0] ); await waitFor(() => expect(result.current).toHaveLength(1)); @@ -123,7 +135,7 @@ describe('useAppActionButtons', () => { button({ actionId: 'channel-only', when: { roomTypes: ['public_channel'] } }) ]; - const { result } = renderHook(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid' })); + const { result } = renderHook(() => useMessageBox('rid')); await waitFor(() => expect(result.current).toHaveLength(1)); expect(result.current[0].button.actionId).toBe('channel-only'); @@ -131,15 +143,65 @@ describe('useAppActionButtons', () => { it('resolves a permission against the roles the subscription and the user hold', async () => { mockActionButtons = [button({ when: { hasOnePermission: ['pin-message'] } })]; - mockPermissionRecords = [{ id: 'pin-message', roles: ['owner'] }]; + mockPermissionRecords.next([{ id: 'pin-message', roles: ['owner'] }]); - const { result } = renderHook(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid' })); + const { result } = renderHook(() => useMessageBox('rid')); await waitFor(() => expect(result.current).toHaveLength(1)); - mockPermissionRecords = [{ id: 'pin-message', roles: ['admin'] }]; - const second = renderHook(() => useAppActionButtons({ context: UIActionButtonContext.MESSAGE_BOX_ACTION, rid: 'rid-2' })); + 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(second.result.current).toEqual([])); + 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/services/restApi.ts b/app/lib/services/restApi.ts index 9b42df70505..7c20833dcf0 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -1283,7 +1283,8 @@ export const mediaCallsStateSignals = async (contractId: string): Promise<{ sign } }; -const appsApiGet = async (path: string): Promise => { +// 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'); @@ -1291,7 +1292,7 @@ const appsApiGet = async (path: string): Promise => { const { userId, authToken } = currentLogin; const response = await fetch(`${host}/api/apps/${path}`, { - method: 'GET', + ...init, headers: { 'Content-Type': 'application/json', 'X-Auth-Token': authToken, @@ -1300,12 +1301,14 @@ const appsApiGet = async (path: string): Promise => { }); if (!response.ok) { - throw new Error(`Failed to GET /api/apps/${path}: ${response.status}`); + throw new Error(`Failed to ${init.method} /api/apps/${path}: ${response.status}`); } - return response.json(); + 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/SidebarView/components/Apps.test.tsx b/app/views/SidebarView/components/Apps.test.tsx index a23b4b42779..0fe04d08248 100644 --- a/app/views/SidebarView/components/Apps.test.tsx +++ b/app/views/SidebarView/components/Apps.test.tsx @@ -18,26 +18,26 @@ describe('Sidebar Apps', () => { beforeEach(() => jest.clearAllMocks()); it('asks for user dropdown buttons', () => { - mockedHook.mockReturnValue([]); + mockedHook.mockReturnValue([[]]); render(); - expect(mockedHook).toHaveBeenCalledWith({ context: 'userDropdownAction' }); + expect(mockedHook).toHaveBeenCalledWith({ filters: [{ context: 'userDropdownAction' }] }); }); it('renders nothing without buttons', () => { - mockedHook.mockReturnValue([]); + mockedHook.mockReturnValue([[]]); const { toJSON } = render(); expect(toJSON()).toBeNull(); }); it('renders one item per button with its label', () => { - mockedHook.mockReturnValue([item]); + 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]); + mockedHook.mockReturnValue([[item]]); const { getByTestId } = render(); fireEvent.press(getByTestId('sidebar-app-app-1/open-thing')); diff --git a/app/views/SidebarView/components/Apps.tsx b/app/views/SidebarView/components/Apps.tsx index f93147a8977..f4dfa158a65 100644 --- a/app/views/SidebarView/components/Apps.tsx +++ b/app/views/SidebarView/components/Apps.tsx @@ -8,8 +8,10 @@ import { type IAppActionButtonItem, useAppActionButtons } from '~/lib/apps/useAp 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({ context: UIActionButtonContext.USER_DROPDOWN_ACTION }); + const [appActions] = useAppActionButtons({ filters: SIDEBAR_APP_FILTERS }); if (!appActions.length) { return null;