Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3c9a701
feat: apps engine action buttons in the composer action sheet
OtavioStasiak Sep 16, 2026
c765159
feat: apps engine action button store, filters and translations
OtavioStasiak Sep 16, 2026
6edb71e
chore: format code and fix lint issues
OtavioStasiak Sep 16, 2026
76b7475
feat: trigger apps engine action buttons
OtavioStasiak Sep 16, 2026
65d3600
chore: new i18n labels
OtavioStasiak Sep 16, 2026
b241f43
chore: unit tests
OtavioStasiak Sep 16, 2026
5eb3910
feat: useAppActionButtons
OtavioStasiak Sep 16, 2026
57cec21
action: organized translations
OtavioStasiak Sep 16, 2026
0d1d666
fix: remove unused imports
OtavioStasiak Sep 16, 2026
955b77f
fix: translation
OtavioStasiak Sep 16, 2026
79e12d6
Merge branch 'develop' into feat.apps-engine-ai
OtavioStasiak Sep 16, 2026
b1a070a
chore: rollback unused changes
OtavioStasiak Sep 17, 2026
b98ee80
code improvements
OtavioStasiak Sep 17, 2026
4b7932a
chore: move UIKit action handlers to app/lib/apps
OtavioStasiak Sep 17, 2026
577d2d4
e2e tests
OtavioStasiak Sep 17, 2026
50df26e
Merge branch 'develop' into feat.apps-engine-ai
OtavioStasiak Sep 17, 2026
3aefacb
fix: e2e tests
OtavioStasiak Sep 17, 2026
457f50c
fix: reset apps store on disconnect
OtavioStasiak Sep 23, 2026
cc93147
fix: resubscribe apps stream on reconnect
OtavioStasiak Sep 23, 2026
fcd0e09
chore: drop apps barrel, emit toasts via typed emitter
OtavioStasiak Sep 23, 2026
d708959
fix: send tmid with composer ai actions
OtavioStasiak Sep 23, 2026
700f791
fix: scope apps stream refetches and room button filtering
OtavioStasiak Sep 23, 2026
a22983f
Merge branch 'develop' into feat.apps-engine-ai
OtavioStasiak Sep 23, 2026
392a1fe
feat: apps engine action buttons in the sidebar
OtavioStasiak Sep 25, 2026
1f2748d
translation
OtavioStasiak Sep 25, 2026
d96aeb8
tests
OtavioStasiak Sep 25, 2026
a96d931
Merge branch 'develop' into feat.app-on-profile
OtavioStasiak Sep 25, 2026
fb73770
action: organized translations
OtavioStasiak Sep 25, 2026
92d37ae
fix: translation
OtavioStasiak Sep 29, 2026
e40ec28
Merge branch 'develop' into feat.app-on-profile
OtavioStasiak Sep 29, 2026
6b386d6
fix(apps): ignore stale fetch responses after store reset
OtavioStasiak Sep 30, 2026
d45c8d9
fix(apps): stop stream listener when subscribe fails
OtavioStasiak Sep 30, 2026
28208f8
refactor(apps): reuse showToast helper instead of a second toast emitter
OtavioStasiak Sep 30, 2026
0b6d07f
test(apps): cover live filtering, retry and unsupported toasts
OtavioStasiak Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .maestro/scripts/data-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -294,6 +331,8 @@ output.utils = {
login,
getDeepLink,
createDM,
findAppActionButton,
getAppTranslation,
sleep,
groupMessageCount
};
117 changes: 117 additions & 0 deletions .maestro/tests/uikit/poll.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
appId: ${APP_ID}
name: Apps Engine Poll
onFlowStart:
- runFlow: '../../helpers/setup.yaml'
onFlowComplete:
- evalScript: ${output.utils.deleteCreatedUsers()}
tags:
- test-9

---
- evalScript: ${output.user = output.utils.createUser()}
- evalScript: ${output.room = output.utils.createRandomRoom(output.user.username, output.user.password)}
- evalScript: ${output.pollButton = output.utils.findAppActionButton(output.user.username, output.user.password, 'messageBoxAction', 'message-box-create-poll')}
- evalScript: ${output.pollLabel = output.utils.getAppTranslation(output.user.username, output.user.password, output.pollButton.appId, 'create_poll_button')}
- evalScript: ${output.pollAction = 'message-composer-app-action-' + output.pollButton.appId + '/' + output.pollButton.actionId}
- evalScript: ${output.question = 'Poll ' + output.random()}

- runFlow:
file: '../../helpers/login-with-deeplink.yaml'
env:
USERNAME: ${output.user.username}
PASSWORD: ${output.user.password}

- runFlow:
file: '../../helpers/navigate-to-room.yaml'
env:
ROOM: ${output.room.name}

# should list the Poll app messageBoxAction in the composer actions, labeled with the app's own i18n
- extendedWaitUntil:
visible:
id: 'message-composer-actions'
timeout: 60000
- tapOn:
id: 'message-composer-actions'
- extendedWaitUntil:
visible:
id: 'action-sheet'
timeout: 60000
- extendedWaitUntil:
visible:
id: '${output.pollAction}'
timeout: 60000
- assertVisible:
text: '${output.pollLabel}'

# should open the app's modal and dismiss it without creating a poll
- tapOn:
id: '${output.pollAction}'
- extendedWaitUntil:
visible:
id: 'uikit-input-question'
timeout: 60000
- assertVisible:
id: 'submit-modal-uikit'
- tapOn:
id: 'close-modal-uikit'
- extendedWaitUntil:
visible:
id: 'room-view-title-${output.room.name}'
timeout: 60000

# should create a poll from the modal
- tapOn:
id: 'message-composer-actions'
- extendedWaitUntil:
visible:
id: 'action-sheet'
timeout: 60000
- tapOn:
id: '${output.pollAction}'
- extendedWaitUntil:
visible:
id: 'uikit-input-question'
timeout: 60000
- tapOn:
id: 'uikit-input-question'
- inputText: ${output.question}
- tapOn:
id: 'uikit-input-option-0'
- inputText: 'Option A'
- tapOn:
id: 'uikit-input-option-1'
- inputText: 'Option B'
- tapOn:
id: 'submit-modal-uikit'

# should render the poll blocks the app posted back
- extendedWaitUntil:
visible:
text: '${output.question}'
timeout: 60000
- assertVisible:
text: 'Option A'
- assertVisible:
text: 'Option B'
- assertVisible:
text: '.*0.00% \(0\).*'

# should vote and see the results
- tapOn:
text: '(poll_vote|Vote)'
index: 0
- tapOn:
id: 'header-back'
- extendedWaitUntil:
visible:
id: 'rooms-list-view'
timeout: 60000
- runFlow:
file: '../../helpers/navigate-to-room.yaml'
env:
ROOM: ${output.room.name}
- extendedWaitUntil:
visible:
text: '.*100.00% \(1\).*'
timeout: 60000
11 changes: 10 additions & 1 deletion .sniffler/test-map.json
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,15 @@
},
{
"test": ".maestro/tests/uikit/button-kit.yaml",
"dependsOn": ["app/containers/UIKit/**"]
"dependsOn": ["app/containers/UIKit/**", "app/lib/apps/**"]
},
{
"test": ".maestro/tests/uikit/poll.yaml",
"dependsOn": [
"app/containers/UIKit/**",
"app/lib/apps/**",
"app/containers/MessageComposer/**",
"app/views/ModalBlockView.tsx"
]
}
]
2 changes: 1 addition & 1 deletion app/containers/MessageComposer/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ import { useMasterDetail } from '~/lib/hooks/useMasterDetail';
import { usePermissions } from '~/lib/hooks/usePermissions';
import { useCanUploadFile, useChooseMedia } from '~/containers/MessageComposer/hooks';
import { useComposerRid, useComposerTmid, useComposerType } from '~/containers/MessageComposer/ComposerStore';
import { UIActionButtonContext } from '~/lib/apps/definitions';
import { useAppActionButtons } from '~/lib/apps/useAppActionButtons';
import { triggerAppActionButton } from '~/lib/apps/triggerAppActionButton';

const COMPOSER_APP_FILTERS = [
{ context: UIActionButtonContext.ROOM_ACTION, category: 'ai' as const },
{ context: UIActionButtonContext.MESSAGE_BOX_ACTION }
];

export const ActionsButton = () => {
const rid = useComposerRid();
const tmid = useComposerTmid();
const t = useComposerType();
const { closeEmojiKeyboardAndAction } = useContext(MessageInnerContext);
const { closeEmojiKeyboardAndAction, getText } = useContext(MessageInnerContext);
const permissionToUpload = useCanUploadFile(rid);
const [permissionToViewCannedResponses] = usePermissions(['view-canned-responses'], rid);
const { takePhoto, takeVideo, chooseFromLibrary, chooseFile } = useChooseMedia({
Expand All @@ -25,6 +33,7 @@ export const ActionsButton = () => {
});
const { showActionSheet, hideActionSheet } = useActionSheet();
const isMasterDetail = useMasterDetail();
const [aiActions, appActions] = useAppActionButtons({ filters: COMPOSER_APP_FILTERS, rid });

const createDiscussion = async () => {
if (!rid) return;
Expand Down Expand Up @@ -95,6 +104,30 @@ export const ActionsButton = () => {
onPress: () => createDiscussion()
});

aiActions.forEach(({ id, label, button }) => {
options.push({
title: label,
icon: 'stars',
danger: button.variant === 'danger',
testID: `message-composer-ai-action-${id}`,
onPress: () => {
triggerAppActionButton({ button, rid, tmid });
}
});
});

appActions.forEach(({ id, label, button }) => {
options.push({
title: label,
icon: 'apps',
danger: button.variant === 'danger',
testID: `message-composer-app-action-${id}`,
onPress: () => {
triggerAppActionButton({ button, rid, tmid, message: getText() ?? '' });
}
});
});

closeEmojiKeyboardAndAction(showActionSheet, { options });
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { render, screen } from '@testing-library/react-native';

import { type IAppActionButtonItem } from '~/lib/apps/useAppActionButtons';
import { ActionsButton } from '../ActionsButton';

const mockUseAppActionButtons = jest.fn((_params: unknown): IAppActionButtonItem[] => []);
jest.mock('~/lib/apps/useAppActionButtons', () => ({
useAppActionButtons: ({ filters, rid }: { filters: { context: string; category?: string }[]; rid?: string }) =>
filters.map(filter => mockUseAppActionButtons({ ...filter, rid }))
}));

const mockTriggerAppActionButton = jest.fn();
jest.mock('~/lib/apps/triggerAppActionButton', () => ({
triggerAppActionButton: (params: unknown) => mockTriggerAppActionButton(params)
}));

const mockShowActionSheet = jest.fn();
jest.mock('~/containers/ActionSheet', () => ({
useActionSheet: () => ({ showActionSheet: mockShowActionSheet, hideActionSheet: jest.fn() })
}));

jest.mock('~/containers/MessageComposer/ComposerStore', () => ({
useComposerRid: () => 'rid-1',
useComposerTmid: () => 'tmid-1',
useComposerType: () => 'c'
}));

jest.mock('~/containers/MessageComposer/context', () => {
const ReactActual = jest.requireActual('react');
return {
MessageInnerContext: ReactActual.createContext({
closeEmojiKeyboardAndAction: (action: Function, params: unknown) => action(params),
getText: () => 'draft text'
})
};
});

jest.mock('~/containers/MessageComposer/hooks', () => ({
useCanUploadFile: () => false,
useChooseMedia: () => ({ takePhoto: jest.fn(), takeVideo: jest.fn(), chooseFromLibrary: jest.fn(), chooseFile: jest.fn() })
}));
jest.mock('~/lib/hooks/usePermissions', () => ({ usePermissions: () => [false] }));
jest.mock('~/lib/hooks/useMasterDetail', () => ({ useMasterDetail: () => false }));
jest.mock('~/lib/database/services/Subscription', () => ({ getSubscriptionByRoomId: jest.fn() }));

jest.mock('../BaseButton', () => {
const ReactActual = jest.requireActual('react');
return {
BaseButton: ({ onPress, testID }: { onPress: () => void; testID: string }) =>
ReactActual.createElement('BaseButton', { onPress, testID })
};
});

const item = (id: string, overrides = {}): IAppActionButtonItem => ({
id,
label: `Label ${id}`,
button: { appId: 'app-id', actionId: id, context: 'messageBoxAction', labelI18n: 'label', ...overrides }
});

const openSheet = () => {
render(<ActionsButton />);
screen.getByTestId('message-composer-actions').props.onPress();
return mockShowActionSheet.mock.calls[0][0].options as { title: string; testID?: string; onPress: () => void }[];
};

describe('ActionsButton', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseAppActionButtons.mockImplementation(() => []);
});

it('shows only the built-in actions when no app registered any', () => {
const options = openSheet();

expect(options.map(option => option.title)).toEqual(['Create discussion']);
});

it('lists ai room actions then message box actions after the built-in ones', () => {
mockUseAppActionButtons.mockImplementation(params =>
(params as { context: string }).context === 'messageBoxAction'
? [item('translate')]
: [item('summarize', { context: 'roomAction', category: 'ai' })]
);

const options = openSheet();

expect(options.map(option => option.title)).toEqual(['Create discussion', 'Label summarize', 'Label translate']);
expect(options[1].testID).toBe('message-composer-ai-action-summarize');
expect(options[2].testID).toBe('message-composer-app-action-translate');
});

it('asks for the ai category of the room action context', () => {
openSheet();

expect(mockUseAppActionButtons).toHaveBeenCalledWith({ context: 'messageBoxAction', rid: 'rid-1' });
expect(mockUseAppActionButtons).toHaveBeenCalledWith({ context: 'roomAction', category: 'ai', rid: 'rid-1' });
});

it('sends the thread and the composer draft with a message box action', () => {
const messageBoxItem = item('translate');
mockUseAppActionButtons.mockImplementation(params =>
(params as { context: string }).context === 'messageBoxAction' ? [messageBoxItem] : []
);

openSheet()[1].onPress();

expect(mockTriggerAppActionButton).toHaveBeenCalledWith({
button: messageBoxItem.button,
rid: 'rid-1',
tmid: 'tmid-1',
message: 'draft text'
});
});

it('sends the thread but not the composer draft with an ai action', () => {
const aiItem = item('summarize', { context: 'roomAction', category: 'ai' });
mockUseAppActionButtons.mockImplementation(params =>
(params as { context: string }).context === 'roomAction' ? [aiItem] : []
);

openSheet()[1].onPress();

expect(mockTriggerAppActionButton).toHaveBeenCalledWith({ button: aiItem.button, rid: 'rid-1', tmid: 'tmid-1' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading