From 59890bd68a4b56ad17e3825bb57809ceba17243c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 13:45:12 -0300 Subject: [PATCH 01/61] test(RoomView): replace LoadMore snapshot with state assertions --- app/views/RoomView/LoadMore/LoadMore.test.tsx | 51 +- .../__snapshots__/LoadMore.test.tsx.snap | 8038 ----------------- 2 files changed, 48 insertions(+), 8041 deletions(-) delete mode 100644 app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap diff --git a/app/views/RoomView/LoadMore/LoadMore.test.tsx b/app/views/RoomView/LoadMore/LoadMore.test.tsx index 76534bdfe85..8b873fc724c 100644 --- a/app/views/RoomView/LoadMore/LoadMore.test.tsx +++ b/app/views/RoomView/LoadMore/LoadMore.test.tsx @@ -1,6 +1,51 @@ -import { generateSnapshots } from '../../../../.rnstorybook/generateSnapshots'; -import * as stories from './LoadMore.stories'; +import { ActivityIndicator } from 'react-native'; +import { Provider } from 'react-redux'; +import { render, screen, userEvent } from '@testing-library/react-native'; + +import { createMockedStore } from '../../../reducers/mockedStore'; +import { MessageTypeLoad } from '../../../lib/constants/messageTypeLoad'; +import { type MessageType } from '../../../definitions'; +import LoadMore from '.'; jest.unmock('../../../lib/hooks/useResponsiveLayout/useResponsiveLayout'); -generateSnapshots(stories); +const renderLoadMore = ({ + type = MessageTypeLoad.MORE, + runOnRender = false +}: { type?: MessageType; runOnRender?: boolean } = {}) => { + const store = createMockedStore(); + render( + + + + ); + return store; +}; + +describe('LoadMore', () => { + test.each([ + [MessageTypeLoad.MORE, 'Load more'], + [MessageTypeLoad.PREVIOUS_CHUNK, 'Load older'], + [MessageTypeLoad.NEXT_CHUNK, 'Load newer'] + ])('renders the %s label', (type, label) => { + renderLoadMore({ type }); + + expect(screen.getByText(label)).toBeOnTheScreen(); + }); + + test('shows the loading indicator instead of the label after being pressed', async () => { + renderLoadMore(); + + await userEvent.press(screen.getByText('Load more')); + + expect(screen.queryByText('Load more')).not.toBeOnTheScreen(); + expect(screen.UNSAFE_queryByType(ActivityIndicator)).not.toBeNull(); + }); + + test('requests history on mount when runOnRender is set', () => { + const store = renderLoadMore({ runOnRender: true }); + + expect(store.getState().room.historyLoaders).toEqual(['loaderId']); + expect(screen.UNSAFE_queryByType(ActivityIndicator)).not.toBeNull(); + }); +}); diff --git a/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap b/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap deleted file mode 100644 index 6ce1e1c0e98..00000000000 --- a/app/views/RoomView/LoadMore/__snapshots__/LoadMore.test.tsx.snap +++ /dev/null @@ -1,8038 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Story Snapshots: Basic should match snapshot 1`] = ` -[ - - - - - - Load more - - - - , - - - - - - - - , - - - - - - Load older - - - - , - - - - - - Load newer - - - - , -] -`; - -exports[`Story Snapshots: BlackTheme should match snapshot 1`] = ` - - - - - - - - Load older - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Hey! - - - - - - - - - - - - - - - - - - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Older message - - - - - - - - - - - - - - - - - - - - Load newer - - - - - - - - - - Load more - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - - - - - - - - - - - - - - - - - - - - - - - - This is the third message - - - - - - - - - - - - - - - - - - - - - - - - - - - - - This is the second message - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - This is the first message - - - - - - - - - - - - - - - -`; - -exports[`Story Snapshots: DarkTheme should match snapshot 1`] = ` - - - - - - - - Load older - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Hey! - - - - - - - - - - - - - - - - - - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Older message - - - - - - - - - - - - - - - - - - - - Load newer - - - - - - - - - - Load more - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - - - - - - - - - - - - - - - - - - - - - - - - This is the third message - - - - - - - - - - - - - - - - - - - - - - - - - - - - - This is the second message - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - This is the first message - - - - - - - - - - - - - - - -`; - -exports[`Story Snapshots: LightTheme should match snapshot 1`] = ` - - - - - - - - Load older - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Hey! - - - - - - - - - - - - - - - - - - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - - - - - - - - - - - - - - - - - - - - - - - - - Older message - - - - - - - - - - - - - - - - - - - - Load newer - - - - - - - - - - Load more - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries - - - - - - - - - - - - - - - - - - - - - - - - - - - - This is the third message - - - - - - - - - - - - - - - - - - - - - - - - - - - - - This is the second message - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diego.mello - - - 10:00 AM - - - - - - - - - - - This is the first message - - - - - - - - - - - - - - - -`; From c6ccb388f8247adb05a651eb8eaf24b4189de95e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 13:51:48 -0300 Subject: [PATCH 02/61] fix(RoomView): cancel useThreadFollowing subscription on fast unmount useThreadFollowing subscribed inside a promise callback, so a room opened and closed before getMessageById resolved left the observer running: the cleanup ran while unsubscribe was still undefined. A cancelled flag set in cleanup and checked before subscribing closes that window, and also covers a tmid/userId change, where cleanup runs before the pending resolve. Dropped the paired suggestion to swap observe() for observeWithColumns(['replies']): observeWithColumns is a Query method, not a Model one, so it is not available on the record getMessageById returns. The derived value is a boolean, so setState already bails out when it does not change. --- .../__tests__/useThreadFollowing.test.ts | 24 ++++++++++++------- .../RoomView/hooks/useThreadFollowing.ts | 8 +++++-- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/app/views/RoomView/hooks/__tests__/useThreadFollowing.test.ts b/app/views/RoomView/hooks/__tests__/useThreadFollowing.test.ts index 41431a6d692..ac07784542e 100644 --- a/app/views/RoomView/hooks/__tests__/useThreadFollowing.test.ts +++ b/app/views/RoomView/hooks/__tests__/useThreadFollowing.test.ts @@ -15,17 +15,15 @@ type Emit = (value: T) => void; const setupObservable = () => { let emit: Emit | undefined; const unsubscribe = jest.fn(); - const threadRecord = { - observe: () => ({ - subscribe: (cb: Emit) => { - emit = cb; - return { unsubscribe }; - } - }) - }; + const subscribe = jest.fn((cb: Emit) => { + emit = cb; + return { unsubscribe }; + }); + const threadRecord = { observe: () => ({ subscribe }) }; mockGet.mockImplementation(() => ({ find: jest.fn(() => Promise.resolve(threadRecord)) })); return { unsubscribe, + subscribe, emitThread: (thread: any) => act(() => emit?.(thread)) }; }; @@ -55,6 +53,16 @@ describe('useThreadFollowing', () => { expect(mockGet).not.toHaveBeenCalled(); }); + it('does not subscribe when unmounted before the thread record resolves', async () => { + const observable = setupObservable(); + const { unmount } = renderHook(() => useThreadFollowing('tmid-1', 'user-1')); + + unmount(); + await flush(); + + expect(observable.subscribe).not.toHaveBeenCalled(); + }); + it('unsubscribes on unmount', async () => { const observable = setupObservable(); const { unmount } = renderHook(() => useThreadFollowing('tmid-1', 'user-1')); diff --git a/app/views/RoomView/hooks/useThreadFollowing.ts b/app/views/RoomView/hooks/useThreadFollowing.ts index 8f9b51c39dd..60a9a9a4831 100644 --- a/app/views/RoomView/hooks/useThreadFollowing.ts +++ b/app/views/RoomView/hooks/useThreadFollowing.ts @@ -9,9 +9,10 @@ export function useThreadFollowing(tmid?: string, userId?: string): boolean { if (!tmid) { return; } + let cancelled = false; let unsubscribe: (() => void) | undefined; getMessageById(tmid).then(threadRecord => { - if (!threadRecord) { + if (cancelled || !threadRecord) { return; } const subscription = threadRecord.observe().subscribe(thread => { @@ -20,7 +21,10 @@ export function useThreadFollowing(tmid?: string, userId?: string): boolean { unsubscribe = () => subscription.unsubscribe(); }); - return () => unsubscribe?.(); + return () => { + cancelled = true; + unsubscribe?.(); + }; }, [tmid, userId]); return isFollowingThread; From 730f2cd4198554609a395094ca711273632ed205 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 13:57:11 -0300 Subject: [PATCH 03/61] perf(RoomView): stop observing last_message on the room record The room observer woke on every incoming message, rebuilding roomUpdate and re-rendering the root, providers, list, footer and composer. Livechat is the only consumer of last_message: it now gets its own observer, created only for t === 'l', publishing lastMessageFromAgent into the room store. --- .../components/RoomFooter/RoomFooter.test.tsx | 1 + app/views/RoomView/constants.ts | 2 - app/views/RoomView/definitions.ts | 2 +- .../hooks/__tests__/useHeader.test.tsx | 13 +---- .../useOmnichannelPermissions.test.tsx | 20 ++------ .../hooks/__tests__/useRoomInit.test.ts | 1 + .../__tests__/useRoomMessageHandlers.test.tsx | 1 + app/views/RoomView/hooks/useHeader.tsx | 12 ++--- .../hooks/useOmnichannelPermissions.ts | 24 +++------ app/views/RoomView/index.tsx | 1 - app/views/RoomView/stores/RoomStore.test.ts | 49 +++++++++++++++++++ app/views/RoomView/stores/RoomStore.ts | 21 +++++++- 12 files changed, 88 insertions(+), 59 deletions(-) diff --git a/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx b/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx index 6ce3c3e1ead..6a9aa59b430 100644 --- a/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx +++ b/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx @@ -65,6 +65,7 @@ const makeRoomStore = (overrides: Partial = {}): RoomStore => canReturnQueue: false, canViewCannedResponse: false, canPlaceLivechatOnHold: false, + lastMessageFromAgent: false, init: jest.fn(() => Promise.resolve({ status: 'loaded', lastSeen: null })), join: jest.fn(), joinRoom: jest.fn(() => Promise.resolve()), diff --git a/app/views/RoomView/constants.ts b/app/views/RoomView/constants.ts index c772e92dbe4..7136a340bfd 100644 --- a/app/views/RoomView/constants.ts +++ b/app/views/RoomView/constants.ts @@ -24,7 +24,6 @@ export const roomAttrsUpdate = [ 'teamMain', 'teamId', 'status', - 'lastMessage', 'onHold', 't', 'autoTranslate', @@ -59,7 +58,6 @@ export const roomAttrsUpdateColumns: Record<(typeof roomAttrsUpdate)[number], st teamMain: 'team_main', teamId: 'team_id', status: 'status', - lastMessage: 'last_message', onHold: 'on_hold', t: 't', autoTranslate: 'auto_translate', diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts index 4c5a3fd0189..f04584b26b9 100644 --- a/app/views/RoomView/definitions.ts +++ b/app/views/RoomView/definitions.ts @@ -171,6 +171,7 @@ export interface RoomState { canReturnQueue: boolean; canViewCannedResponse: boolean; canPlaceLivechatOnHold: boolean; + lastMessageFromAgent: boolean; // Resolves with the run's outcome; only `loaded` carries the screen's unread divider anchor. init: (params?: IRoomStoreInitParams) => Promise; join: () => void; @@ -316,7 +317,6 @@ export interface IUseRoomNavigationResult { export interface IUseOmnichannelPermissionsParams { rid?: string; t?: string; - room: IRoomViewState['room']; roomUpdate: IRoomViewState['roomUpdate']; joined: boolean; livechatAllowManualOnHold?: boolean; diff --git a/app/views/RoomView/hooks/__tests__/useHeader.test.tsx b/app/views/RoomView/hooks/__tests__/useHeader.test.tsx index f9e89fb2254..94d045cbc46 100644 --- a/app/views/RoomView/hooks/__tests__/useHeader.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useHeader.test.tsx @@ -42,6 +42,7 @@ const makeRoomStore = (overrides: Partial = {}): RoomStore => canReturnQueue: false, canViewCannedResponse: false, canPlaceLivechatOnHold: false, + lastMessageFromAgent: false, init: jest.fn(), join: jest.fn(), joinRoom: jest.fn(() => Promise.resolve()), @@ -78,18 +79,6 @@ describe('useHeader', () => { expect(mockSetOptions.mock.calls[2][0]).toHaveProperty('headerTitle'); }); - it('does not re-fire the title effect when only lastMessage changes', () => { - mockTestStore = makeRoomStore({ roomUpdate: { topic: 'same', lastMessage: { msg: 'old' } } }); - - renderHook(() => useHeader({ rid: 'rid-1', tmid: undefined, name: 'general' })); - expect(mockSetOptions).toHaveBeenCalledTimes(2); - - act(() => { - mockTestStore.setState({ roomUpdate: { topic: 'same', lastMessage: { msg: 'new' } } }); - }); - expect(mockSetOptions).toHaveBeenCalledTimes(2); - }); - it('keeps the thread title from the passed name when the observed room name changes', () => { renderHook(() => useHeader({ rid: 'rid-1', tmid: 'tmid-1', name: 'Thread name' })); diff --git a/app/views/RoomView/hooks/__tests__/useOmnichannelPermissions.test.tsx b/app/views/RoomView/hooks/__tests__/useOmnichannelPermissions.test.tsx index 81a71db46c3..3ba1e3bbec0 100644 --- a/app/views/RoomView/hooks/__tests__/useOmnichannelPermissions.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useOmnichannelPermissions.test.tsx @@ -16,7 +16,7 @@ jest.mock('../../../../lib/hooks/usePermissions', () => ({ const mockGetRoutingConfig = getRoutingConfig as jest.Mock; const mockUsePermissions = usePermissions as jest.Mock; -const makeRoomStore = (): RoomStore => +const makeRoomStore = (lastMessageFromAgent = false): RoomStore => createStore(() => ({ room: { rid: 'rid-1', t: 'l' }, roomUpdate: {}, @@ -29,6 +29,7 @@ const makeRoomStore = (): RoomStore => canReturnQueue: false, canViewCannedResponse: false, canPlaceLivechatOnHold: false, + lastMessageFromAgent, init: jest.fn(), join: jest.fn(), joinRoom: jest.fn(() => Promise.resolve()), @@ -40,7 +41,6 @@ const renderOmnichannelPermissions = (overrides: Partial { mockUsePermissions.mockReturnValue([true, true]); mockGetRoutingConfig.mockResolvedValue({ returnQueue: true }); - const { roomStore } = renderOmnichannelPermissions({ - t: 'l', - room: { rid: 'rid-1', t: 'l', lastMessage: { token: undefined, u: { _id: 'u1' } }, onHold: false } as any - }); + const { roomStore } = renderOmnichannelPermissions({ t: 'l', roomUpdate: { onHold: false } }, makeRoomStore(true)); expect(roomStore.getState().canForwardGuest).toBe(true); expect(roomStore.getState().canViewCannedResponse).toBe(true); @@ -79,7 +76,7 @@ describe('useOmnichannelPermissions', () => { it('does not touch the flags for a non-livechat room', async () => { mockUsePermissions.mockReturnValue([true, true]); - const { roomStore } = renderOmnichannelPermissions({ t: 'c', room: { rid: 'rid-1', t: 'c' } as any }); + const { roomStore } = renderOmnichannelPermissions({ t: 'c' }); await act(async () => {}); @@ -106,7 +103,6 @@ describe('useOmnichannelPermissions', () => { initialProps: { rid: 'rid-1', t: 'l', - room: { rid: 'rid-1', t: 'l' } as any, roomUpdate: {}, joined: false, livechatAllowManualOnHold: true, @@ -129,7 +125,6 @@ describe('useOmnichannelPermissions', () => { const baseProps: IUseOmnichannelPermissionsParams = { rid: 'rid-1', t: 'l', - room: { rid: 'rid-1', t: 'l' } as any, roomUpdate: {}, joined: false, livechatAllowManualOnHold: true, @@ -151,17 +146,13 @@ describe('useOmnichannelPermissions', () => { }); it('recomputes canPlaceLivechatOnHold on a pure on-hold flip of the same room instance', async () => { - const roomStore = makeRoomStore(); + const roomStore = makeRoomStore(true); mockUsePermissions.mockReturnValue([true, true]); mockGetRoutingConfig.mockResolvedValue({ returnQueue: true }); - // WatermelonDB re-emits the same mutated model instance; only roomUpdate is a fresh snapshot. - const room = { rid: 'rid-1', t: 'l', lastMessage: { token: undefined, u: { _id: 'u1' } }, onHold: false } as any; - const baseProps: IUseOmnichannelPermissionsParams = { rid: 'rid-1', t: 'l', - room, roomUpdate: { onHold: false }, joined: true, livechatAllowManualOnHold: true, @@ -176,7 +167,6 @@ describe('useOmnichannelPermissions', () => { expect(roomStore.getState().canPlaceLivechatOnHold).toBe(true); }); - room.onHold = true; rerender({ ...baseProps, roomUpdate: { onHold: true } }); await waitFor(() => { diff --git a/app/views/RoomView/hooks/__tests__/useRoomInit.test.ts b/app/views/RoomView/hooks/__tests__/useRoomInit.test.ts index e2938cbe209..c75d7008301 100644 --- a/app/views/RoomView/hooks/__tests__/useRoomInit.test.ts +++ b/app/views/RoomView/hooks/__tests__/useRoomInit.test.ts @@ -32,6 +32,7 @@ const makeRoomStore = (): RoomStore => canReturnQueue: false, canViewCannedResponse: false, canPlaceLivechatOnHold: false, + lastMessageFromAgent: false, init: jest.fn(() => Promise.resolve({ status: 'loaded', lastSeen: null })), join: jest.fn(), joinRoom: jest.fn(() => Promise.resolve()), diff --git a/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx b/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx index 0e1ad5bcee0..e1c537078a2 100644 --- a/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx @@ -79,6 +79,7 @@ const makeRoomStore = (overrides: Partial = {}): RoomStore => canReturnQueue: false, canViewCannedResponse: false, canPlaceLivechatOnHold: false, + lastMessageFromAgent: false, init: jest.fn(), join: jest.fn(), joinRoom: jest.fn(() => Promise.resolve()), diff --git a/app/views/RoomView/hooks/useHeader.tsx b/app/views/RoomView/hooks/useHeader.tsx index 3d8e7ca5b50..ec2b5acf516 100644 --- a/app/views/RoomView/hooks/useHeader.tsx +++ b/app/views/RoomView/hooks/useHeader.tsx @@ -76,15 +76,9 @@ export const useHeader = ({ rid, tmid, name: roomName }: IUseHeaderParams): void const navigation = useNavigation(); const room = useRoomStoreByRid(rid, s => s.room); - // last_message re-emits on every incoming message; exclude it so the title effect only re-fires - // on fields the header actually renders. - const titleUpdate = useRoomStoreByRid( + const roomUpdate = useRoomStoreByRid( rid, - useShallow(s => { - const rest = { ...s.roomUpdate }; - delete rest.lastMessage; - return rest; - }) + useShallow(s => s.roomUpdate) ); const roomUserId = useRoomStoreByRid(rid, s => s.roomUserId); const goRoomActionsView = useGoRoomActionsView(rid); @@ -108,5 +102,5 @@ export const useHeader = ({ rid, tmid, name: roomName }: IUseHeaderParams): void const headerProps = getRoomHeaderProps({ room, tmid, roomName, roomUserId, onPress: goRoomActionsView }); navigation.setOptions({ headerTitle: () => }); - }, [rid, tmid, roomName, room, titleUpdate, roomUserId, navigation, goRoomActionsView]); + }, [rid, tmid, roomName, room, roomUpdate, roomUserId, navigation, goRoomActionsView]); }; diff --git a/app/views/RoomView/hooks/useOmnichannelPermissions.ts b/app/views/RoomView/hooks/useOmnichannelPermissions.ts index 437b47a6b0a..9729eb60590 100644 --- a/app/views/RoomView/hooks/useOmnichannelPermissions.ts +++ b/app/views/RoomView/hooks/useOmnichannelPermissions.ts @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; +import { useStore } from 'zustand'; import { getRoutingConfig } from '../../../lib/services/restApi'; import { usePermissions } from '../../../lib/hooks/usePermissions'; -import { type IRoomViewState, type IUseOmnichannelPermissionsParams } from '../definitions'; +import { type IUseOmnichannelPermissionsParams } from '../definitions'; const getCanReturnQueue = async () => { try { @@ -13,13 +14,9 @@ const getCanReturnQueue = async () => { } }; -const getCanPlaceLivechatOnHold = (livechatAllowManualOnHold: boolean | undefined, room: IRoomViewState['room']) => - !!(livechatAllowManualOnHold && !room?.lastMessage?.token && room?.lastMessage?.u && !room.onHold); - export function useOmnichannelPermissions({ rid, t, - room, roomUpdate, joined, livechatAllowManualOnHold, @@ -57,24 +54,15 @@ export function useOmnichannelPermissions({ }; }, [t]); + const lastMessageFromAgent = useStore(roomStore, s => s.lastMessageFromAgent); + useEffect(() => { if (t !== 'l') { return; } roomStore.setState({ canReturnQueue, - canPlaceLivechatOnHold: getCanPlaceLivechatOnHold(livechatAllowManualOnHold, room) + canPlaceLivechatOnHold: !!(livechatAllowManualOnHold && lastMessageFromAgent && !roomUpdate.onHold) }); - }, [ - t, - room, - roomStore, - canReturnQueue, - livechatAllowManualOnHold, - roomUpdate.lastMessage?.token, - roomUpdate.visitor, - roomUpdate.status, - roomUpdate.onHold, - joined - ]); + }, [t, roomStore, canReturnQueue, livechatAllowManualOnHold, lastMessageFromAgent, roomUpdate.onHold, joined]); } diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index 164b817f037..70e54b8a933 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -206,7 +206,6 @@ const RoomView = (props: IRoomViewProps) => { useOmnichannelPermissions({ rid, t, - room, roomUpdate, joined, livechatAllowManualOnHold, diff --git a/app/views/RoomView/stores/RoomStore.test.ts b/app/views/RoomView/stores/RoomStore.test.ts index 342bdc953c4..fcc2032e9e9 100644 --- a/app/views/RoomView/stores/RoomStore.test.ts +++ b/app/views/RoomView/stores/RoomStore.test.ts @@ -168,6 +168,55 @@ describe('RoomStore', () => { expect(observeWithColumns).toHaveBeenCalledWith(Object.values(roomAttrsUpdateColumns)); }); + it('does not observe last_message for a non-livechat room', () => { + const { observeWithColumns } = setupObserve(); + peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: stubRoom }); + + expect(observeWithColumns).toHaveBeenCalledTimes(1); + expect(Object.values(roomAttrsUpdateColumns)).not.toContain('last_message'); + }); + + describe('livechat last_message observer', () => { + const setupLivechatObserve = () => { + const emitters = new Map void>(); + const unsubscribe = jest.fn(); + const observeWithColumns = jest.fn((columns: string[]) => ({ + subscribe: (cb: (rows: any[]) => void) => { + emitters.set(columns.join(','), cb); + return { unsubscribe }; + } + })); + mockGet.mockReturnValue({ query: jest.fn(() => ({ observeWithColumns })) }); + return { + observeWithColumns, + unsubscribe, + emitLastMessage: (rows: any[]) => emitters.get('last_message')?.(rows) + }; + }; + + it('flags the last message as agent-authored when it carries no visitor token', () => { + const { observeWithColumns, emitLastMessage } = setupLivechatObserve(); + const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'l', initialRoom: stubRoom }); + + expect(observeWithColumns).toHaveBeenCalledWith(['last_message']); + + emitLastMessage([{ lastMessage: { u: { _id: 'agent-1' } } }]); + expect(store.getState().lastMessageFromAgent).toBe(true); + + emitLastMessage([{ lastMessage: { token: 'visitor-token', u: { _id: 'visitor-1' } } }]); + expect(store.getState().lastMessageFromAgent).toBe(false); + }); + + it('tears the last_message observer down with the room observer', () => { + const { unsubscribe } = setupLivechatObserve(); + acquireRoomStore({ rid: 'rid-1' }, peekOrCreateRoomStore({ rid: 'rid-1', t: 'l', initialRoom: stubRoom })); + + releaseRoomStore('rid-1'); + + expect(unsubscribe).toHaveBeenCalledTimes(2); + }); + }); + it('runs the main init path: fetches messages and sets member and canAutoTranslate', async () => { setupObserve(); const store = peekOrCreateRoomStore({ rid: 'rid-1', t: 'c', initialRoom: subRoom }); diff --git a/app/views/RoomView/stores/RoomStore.ts b/app/views/RoomView/stores/RoomStore.ts index 1c2d9d94af9..056bda8e351 100644 --- a/app/views/RoomView/stores/RoomStore.ts +++ b/app/views/RoomView/stores/RoomStore.ts @@ -138,6 +138,7 @@ const createRoomState = canReturnQueue: false, canViewCannedResponse: false, canPlaceLivechatOnHold: false, + lastMessageFromAgent: false, // A transient failure retries a couple of times instead of leaving the screen empty until the // user navigates away and back. The loop lives here so the caller's single `loading` window @@ -223,6 +224,18 @@ const observeRoom = (rid: string | undefined, t: string | undefined, store: Room return () => subscription.unsubscribe(); }; +// last_message is excluded from the room observer so an incoming message does not re-render the +// whole screen. Livechat rooms still need it: on-hold is only offered while the last message came +// from the agent rather than the visitor. +const observeLivechatLastMessage = (rid: string, store: RoomStore): (() => void) => { + const observable = database.active.get('subscriptions').query(Q.where('rid', rid)).observeWithColumns(['last_message']); + const subscription = observable.subscribe((rows: IRoomViewState['room'][]) => { + const lastMessage = rows[0]?.lastMessage; + store.setState({ lastMessageFromAgent: !!(lastMessage && !lastMessage.token && lastMessage.u) }); + }); + return () => subscription.unsubscribe(); +}; + interface IRoomStoreRegistryEntry { store: RoomStore; unsubscribe: () => void; @@ -255,7 +268,13 @@ const scheduleGraceSweep = (rid: string): void => { }; const register = (rid: string, t: string | undefined, store: RoomStore, refCount: number): void => { - registry.set(rid, { store, unsubscribe: observeRoom(rid, t, store), refCount, pendingSweep: false }); + const unsubscribeRoom = observeRoom(rid, t, store); + const unsubscribeLivechat = t === 'l' ? observeLivechatLastMessage(rid, store) : undefined; + const unsubscribe = () => { + unsubscribeRoom(); + unsubscribeLivechat?.(); + }; + registry.set(rid, { store, unsubscribe, refCount, pendingSweep: false }); }; // Render-safe: returns the rid-keyed store, creating it (observer + grace sweep) on first sight From dcb549890bf92198f6910223e10a2bf94c1c5c9b Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:00:07 -0300 Subject: [PATCH 04/61] refactor(RoomView): drop redundant hideSystemMessages re-filter --- .../RoomView/List/hooks/useMessages.test.tsx | 9 ++--- app/views/RoomView/List/hooks/useMessages.ts | 34 +++++++------------ 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/app/views/RoomView/List/hooks/useMessages.test.tsx b/app/views/RoomView/List/hooks/useMessages.test.tsx index badc1457d12..0ac73686cd3 100644 --- a/app/views/RoomView/List/hooks/useMessages.test.tsx +++ b/app/views/RoomView/List/hooks/useMessages.test.tsx @@ -12,6 +12,7 @@ import { MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad'; import { readThreads } from '../../../../lib/services/restApi'; import { mockedStore } from '../../../../reducers/mockedStore'; import { MAX_AUTO_LOADS, QUERY_SIZE } from '../constants'; +import { buildVisibleSystemTypesClause } from './buildVisibleSystemTypesClause'; import { useMessages } from './useMessages'; jest.mock('../../../../lib/database', () => ({ @@ -194,12 +195,12 @@ describe('useMessages', () => { expect(mockDbGet).not.toHaveBeenCalled(); }); - it('filters out system message types listed in hideSystemMessages', async () => { - emittedRows = [msg({ id: '1', t: 'uj' }), msg({ id: '2', t: undefined }), msg({ id: '3', t: 'room_changed_topic' })]; - const { result } = renderUseMessages({ hideSystemMessages: ['uj'] }); + it('asks the database to exclude system message types listed in hideSystemMessages', async () => { + renderUseMessages({ hideSystemMessages: ['uj'] }); await waitFor(() => { - expect(result.current[0].map(m => m.id)).toEqual(['2', '3']); + expect(queryCalls.length).toBeGreaterThan(0); }); + expect(queryCalls[0]).toContainEqual(buildVisibleSystemTypesClause(['uj'])); }); it('returns visibleMessagesIds aligned with visible messages', async () => { diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts index ab866ef256b..1f50b5e8a2e 100644 --- a/app/views/RoomView/List/hooks/useMessages.ts +++ b/app/views/RoomView/List/hooks/useMessages.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Q } from '@nozbe/watermelondb'; import { type Subscription } from 'rxjs'; import { useDispatch, useStore } from 'react-redux'; @@ -35,7 +35,7 @@ export const useMessages = ({ hideSystemMessages: string[]; t: RoomType; }) => { - const [rawMessages, setRawMessages] = useState([]); + const [messages, setMessages] = useState([]); // Optional UPPER ts bound for the Message Window. null => Live Window (newest-first, follows the // Live Tail). A finite number (ms since epoch) => Anchored Window pinned below the Live Tail. const [highTs, setHighTsState] = useState(null); @@ -202,7 +202,7 @@ export const useMessages = ({ } readThread(); - setRawMessages(newMessages); + setMessages(newMessages); }); // eslint-disable-next-line react-hooks/exhaustive-deps -- readThread is omitted intentionally: useDebouncedCallback stores func in a ref so changes propagate without recreating fetchMessages; hideSystemMessages must stay so the DB re-queries for proper pagination }, [rid, tmid, showMessageInMainThread, hideSystemMessages, highTs, unsubscribe, raiseOrReleaseAnchor]); @@ -224,30 +224,22 @@ export const useMessages = ({ return unsubscribe; }, [fetchMessages, unsubscribe]); - const visibleMessages = useMemo( - () => - !hideSystemMessages || hideSystemMessages.length === 0 - ? rawMessages - : rawMessages.filter(m => !m.t || !hideSystemMessages.includes(m.t)), - [rawMessages, hideSystemMessages] - ); - - // Sync the IDs ref after render, outside the memo, to satisfy the react-hooks/refs rule - // while still keeping the ref up to date before any paint (useLayoutEffect timing). + // Sync the IDs ref after render so the react-hooks/refs rule holds while the ref stays up to + // date before any paint (useLayoutEffect timing). useLayoutEffect(() => { - messagesIds.current = visibleMessages.map(m => m.id); - }, [visibleMessages]); + messagesIds.current = messages.map(m => m.id); + }, [messages]); useEffect( () => { // Snapshot the currently-visible loader into lastDispatchedLoaderId so the // auto-dispatch effect treats it as already-seen when it re-fires after the rid - // change — rawMessages may still reflect the previous room until the new + // change — messages may still reflect the previous room until the new // subscription emits, and we must not dispatch with a stale loader. - lastDispatchedLoaderId.current = findFirstLoaderId(visibleMessages); + lastDispatchedLoaderId.current = findFirstLoaderId(messages); autoLoadCount.current = 0; }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- visibleMessages intentionally omitted: stale read at rid-change is the desired behaviour + // eslint-disable-next-line react-hooks/exhaustive-deps -- messages intentionally omitted: stale read at rid-change is the desired behaviour [rid] ); @@ -269,7 +261,7 @@ export const useMessages = ({ return; } - const loaderId = findFirstLoaderId(visibleMessages); + const loaderId = findFirstLoaderId(messages); if (loaderId && loaderId !== lastDispatchedLoaderId.current) { // Skip if a fetch for this loader is already in flight (push happens before the DB subscription emits) @@ -280,7 +272,7 @@ export const useMessages = ({ autoLoadCount.current += 1; dispatch(roomHistoryRequest({ rid, t, loaderId })); } - }, [highTs, serverVersion, rid, t, hideSystemMessages, visibleMessages, dispatch, store]); + }, [highTs, serverVersion, rid, t, hideSystemMessages, messages, dispatch, store]); - return [visibleMessages, messagesIds, fetchMessages, { highTs, setHighTs }] as const; + return [messages, messagesIds, fetchMessages, { highTs, setHighTs }] as const; }; From df647d1780a4495ac143d8eec83ea60d1b2609c3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:00:53 -0300 Subject: [PATCH 05/61] refactor(RoomView): clone the observed messages only when appending the thread record --- app/views/RoomView/List/hooks/useMessages.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts index 1f50b5e8a2e..b5cc4af30f6 100644 --- a/app/views/RoomView/List/hooks/useMessages.ts +++ b/app/views/RoomView/List/hooks/useMessages.ts @@ -190,11 +190,7 @@ export const useMessages = ({ } subscription.current = observable.subscribe(result => { - const newMessages: TAnyMessageModel[] = [...result]; - - if (tmid && thread.current) { - newMessages.push(thread.current); - } + const newMessages: TAnyMessageModel[] = tmid && thread.current ? [...result, thread.current] : result; // Thread / local windows are never anchored, so rejoin only applies to the bounded main room. if (!tmid && highTs != null) { From 734a30433e54ffa0cd21b06fe3095455e1d25d1a Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:01:34 -0300 Subject: [PATCH 06/61] refactor(RoomView): build the message query clauses once and drop Q.skip(0) --- app/views/RoomView/List/hooks/useMessages.ts | 45 +++++++------------- 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts index b5cc4af30f6..3a5578b7d7b 100644 --- a/app/views/RoomView/List/hooks/useMessages.ts +++ b/app/views/RoomView/List/hooks/useMessages.ts @@ -149,7 +149,6 @@ export const useMessages = ({ // hideSystemMessages applied here so Q.take() counts only visible rows const visibleSystemClause = buildVisibleSystemTypesClause(hideSystemMessages); - let observable; if (tmid) { // Prefer threads table; fall back to messages while thread record isn't available yet if (!thread.current || thread.current.collection.table !== 'threads') { @@ -158,37 +157,23 @@ export const useMessages = ({ thread.current = await getMessageById(tmid); } } - observable = db - .get('thread_messages') - .query( - Q.where('rid', tmid), - ...(visibleSystemClause ? [visibleSystemClause] : []), - // Anchored Window upper bound (ts-only ordering: equal-ts rows can straddle the bound). - ...(highTs != null ? [Q.where('ts', Q.lte(highTs))] : []), - Q.sortBy('ts', Q.desc), - Q.skip(0), - Q.take(count.current) - ) - .observe(); - } else { - const whereClause: Q.Clause[] = [ - Q.where('rid', rid), - ...(visibleSystemClause ? [visibleSystemClause] : []), - // Anchored Window upper bound (ts-only ordering: equal-ts rows can straddle the bound). - ...(highTs != null ? [Q.where('ts', Q.lte(highTs))] : []), - Q.sortBy('ts', Q.desc), - Q.skip(0), - Q.take(count.current) - ]; - if (!showMessageInMainThread) { - whereClause.push(Q.or(Q.where('tmid', null), Q.where('tshow', Q.eq(true)))); - } - observable = db - .get('messages') - .query(...whereClause) - .observe(); } + const clauses: Q.Clause[] = [ + Q.where('rid', tmid ?? rid), + ...(visibleSystemClause ? [visibleSystemClause] : []), + // Anchored Window upper bound (ts-only ordering: equal-ts rows can straddle the bound). + ...(highTs != null ? [Q.where('ts', Q.lte(highTs))] : []), + ...(!tmid && !showMessageInMainThread ? [Q.or(Q.where('tmid', null), Q.where('tshow', Q.eq(true)))] : []), + Q.sortBy('ts', Q.desc), + Q.take(count.current) + ]; + + const observable = db + .get(tmid ? 'thread_messages' : 'messages') + .query(...clauses) + .observe(); + subscription.current = observable.subscribe(result => { const newMessages: TAnyMessageModel[] = tmid && thread.current ? [...result, thread.current] : result; From 28e0fa2703096951912f7947298244e8f8aee632 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:02:00 -0300 Subject: [PATCH 07/61] refactor(RoomView): skip the readThread debounce timer outside threads --- app/views/RoomView/List/hooks/useMessages.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts index 3a5578b7d7b..6362b3179f4 100644 --- a/app/views/RoomView/List/hooks/useMessages.ts +++ b/app/views/RoomView/List/hooks/useMessages.ts @@ -182,7 +182,9 @@ export const useMessages = ({ raiseOrReleaseAnchor(result as TAnyMessageModel[], highTs).catch(() => {}); } - readThread(); + if (tmid) { + readThread(); + } setMessages(newMessages); }); // eslint-disable-next-line react-hooks/exhaustive-deps -- readThread is omitted intentionally: useDebouncedCallback stores func in a ref so changes propagate without recreating fetchMessages; hideSystemMessages must stay so the DB re-queries for proper pagination From 5cc51219daa1e0ecca99f4c416bfa8b21424455d Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:03:50 -0300 Subject: [PATCH 08/61] refactor(RoomView): share the newer-loader lookup between the jump anchor and the rejoin --- .../RoomView/List/hooks/useMessages.test.tsx | 14 ++++----- app/views/RoomView/List/hooks/useMessages.ts | 18 ++++-------- app/views/RoomView/services/getLocalAnchor.ts | 29 ++++++++++++++----- 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/app/views/RoomView/List/hooks/useMessages.test.tsx b/app/views/RoomView/List/hooks/useMessages.test.tsx index 0ac73686cd3..dda15e27085 100644 --- a/app/views/RoomView/List/hooks/useMessages.test.tsx +++ b/app/views/RoomView/List/hooks/useMessages.test.tsx @@ -657,8 +657,8 @@ describe('useMessages', () => { const takeBeforeRaise = findTakeClause(queryCalls[queryCalls.length - 1])?.count; expect(takeBeforeRaise).toBe(QUERY_SIZE); - // The targeted read above the bound reveals the next batch plus a NEW Newer Loader at ts 1900. - fetchRows = [msg({ id: 'm2', ts: at(1700) }), newerLoaderAt('loader-H2', 1900)]; + // The targeted read above the bound reveals a NEW Newer Loader at ts 1900. + fetchRows = [newerLoaderAt('loader-H2', 1900)]; // loadNextMessages REMOVED the boundary loader: re-emit WITHOUT it (still under the old bound). emitRows([msg({ id: 'm1', ts: at(1000) })]); @@ -683,8 +683,8 @@ describe('useMessages', () => { expect(findBoundClause(queryCalls[queryCalls.length - 1])?.comparison.right.value).toBe(1500); }); - // The targeted read reveals the next batch but NO new Newer Loader: the Gap to the Live Tail closed. - fetchRows = [msg({ id: 'm2', ts: at(1700) }), msg({ id: 'm3', ts: at(1800) })]; + // The targeted read finds NO Newer Loader above the bound: the Gap to the Live Tail closed. + fetchRows = []; // loadNextMessages consumed the boundary loader: re-emit WITHOUT it. emitRows([msg({ id: 'm1', ts: at(1000) })]); @@ -712,7 +712,7 @@ describe('useMessages', () => { const anchoredTake = findTakeClause(queryCalls[queryCalls.length - 1])?.count ?? 0; // Gap closed (no Newer Loader above the bound), but 120 messages sit above it (the cached island). - fetchRows = [msg({ id: 'm2', ts: at(1700) }), msg({ id: 'm3', ts: at(1800) })]; + fetchRows = []; fetchCountValue = 120; // loadNextMessages consumed the boundary loader: re-emit without it. @@ -739,8 +739,8 @@ describe('useMessages', () => { expect(findBoundClause(queryCalls[queryCalls.length - 1])?.comparison.right.value).toBe(1500); }); - // The targeted read still shows a Newer Loader above the bound — the Gap is NOT closed. - fetchRows = [msg({ id: 'm2', ts: at(1700) }), newerLoaderAt('loader-H2', 1900)]; + // The targeted read still shows a Newer Loader above the bound: the Gap is NOT closed. + fetchRows = [newerLoaderAt('loader-H2', 1900)]; // Consume the boundary loader. emitRows([msg({ id: 'm1', ts: at(1000) })]); diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts index 6362b3179f4..a17568c0969 100644 --- a/app/views/RoomView/List/hooks/useMessages.ts +++ b/app/views/RoomView/List/hooks/useMessages.ts @@ -10,11 +10,12 @@ import { getThreadById } from '../../../../lib/database/services/Thread'; import { tsToMs } from '../../../../lib/dayjs'; import { compareServerVersion, useDebounce } from '../../../../lib/methods/helpers'; import { readThreads } from '../../../../lib/services/restApi'; -import { MESSAGE_TYPE_ANY_LOAD, MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad'; +import { MESSAGE_TYPE_ANY_LOAD, type MessageTypeLoad } from '../../../../lib/constants/messageTypeLoad'; import { MAX_AUTO_LOADS, QUERY_SIZE } from '../constants'; import { buildVisibleSystemTypesClause } from './buildVisibleSystemTypesClause'; import { roomHistoryRequest } from '../../../../actions/room'; import { isNewerLoader, raiseOrRelease } from '../../services/anchorResolver'; +import { findNewerLoaderAbove } from '../../services/getLocalAnchor'; import { type AnchorMessage } from '../../definitions'; const findFirstLoaderId = (messages: TAnyMessageModel[]): string | null => @@ -88,23 +89,14 @@ export const useMessages = ({ // invocation is stale and must not mutate count / highTs for the new window. const sub = subscription.current; - // Read the Newer Loader closest to the Live Tail directly (highest ts above the bound) so non-loader rows can't crowd the boundary loader out of the fetched set. - const rows = (await database.active - .get('messages') - .query( - Q.where('rid', rid), - Q.where('t', MessageTypeLoad.NEXT_CHUNK), - Q.where('ts', Q.gt(currentHighTs)), - Q.sortBy('ts', Q.desc), - Q.take(1) - ) - .fetch()) as TAnyMessageModel[]; + // Read the Newer Loader closest to the Live Tail directly so non-loader rows can't crowd the boundary loader out of the fetched set. + const loader = await findNewerLoaderAbove(rid, currentHighTs, 'closestToLiveTail'); if (subscription.current !== sub) { return; } - const next = raiseOrRelease(rows as unknown as AnchorMessage[], currentHighTs); + const next = raiseOrRelease(loader ? [loader as unknown as AnchorMessage] : [], currentHighTs); if (next === null) { // Gap closed → release to a Live Window. First grow count by the number of messages now diff --git a/app/views/RoomView/services/getLocalAnchor.ts b/app/views/RoomView/services/getLocalAnchor.ts index 06d8bd806ca..a6604049d99 100644 --- a/app/views/RoomView/services/getLocalAnchor.ts +++ b/app/views/RoomView/services/getLocalAnchor.ts @@ -6,23 +6,36 @@ import { tsToMs } from '../../../lib/dayjs'; import { type TAnyMessageModel } from '../../../definitions'; /** - * ts of the nearest Newer Loader above the target = the upper bracket of its Chunk; null when the - * cached region runs contiguous to the Live Tail (caller falls back to the target's own ts). + * The single Newer Loader above `aboveTs`: `nearest` = lowest ts (the upper bracket of the target's + * Chunk), `closestToLiveTail` = highest ts (the boundary loader the rejoin climbs toward). */ -const getLocalAnchorTs = async (rid: string, targetTs: Date | number | string): Promise => { - const targetMs = tsToMs(targetTs); - const loaders = (await database.active +export const findNewerLoaderAbove = async ( + rid: string, + aboveTs: Date | number | string, + direction: 'nearest' | 'closestToLiveTail' +): Promise => { + const rows = (await database.active .get('messages') .query( Q.where('rid', rid), Q.where('t', MessageTypeLoad.NEXT_CHUNK), - Q.where('ts', Q.gt(targetMs)), - Q.sortBy('ts', Q.asc), + Q.where('ts', Q.gt(tsToMs(aboveTs))), + Q.sortBy('ts', direction === 'nearest' ? Q.asc : Q.desc), Q.take(1) ) .fetch()) as TAnyMessageModel[]; - return loaders.length ? tsToMs(loaders[0].ts) : null; + return rows[0] ?? null; +}; + +/** + * ts of the nearest Newer Loader above the target = the upper bracket of its Chunk; null when the + * cached region runs contiguous to the Live Tail (caller falls back to the target's own ts). + */ +const getLocalAnchorTs = async (rid: string, targetTs: Date | number | string): Promise => { + const loader = await findNewerLoaderAbove(rid, targetTs, 'nearest'); + + return loader ? tsToMs(loader.ts) : null; }; export default getLocalAnchorTs; From 433d7817e82070bedc51452cc84bd0c14698008a Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:04:55 -0300 Subject: [PATCH 09/61] refactor(RoomView): drop the double casts to AnchorMessage --- app/views/RoomView/List/hooks/useMessages.ts | 5 ++--- app/views/RoomView/definitions.ts | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/views/RoomView/List/hooks/useMessages.ts b/app/views/RoomView/List/hooks/useMessages.ts index a17568c0969..509dad6ed3b 100644 --- a/app/views/RoomView/List/hooks/useMessages.ts +++ b/app/views/RoomView/List/hooks/useMessages.ts @@ -16,7 +16,6 @@ import { buildVisibleSystemTypesClause } from './buildVisibleSystemTypesClause'; import { roomHistoryRequest } from '../../../../actions/room'; import { isNewerLoader, raiseOrRelease } from '../../services/anchorResolver'; import { findNewerLoaderAbove } from '../../services/getLocalAnchor'; -import { type AnchorMessage } from '../../definitions'; const findFirstLoaderId = (messages: TAnyMessageModel[]): string | null => messages.find(m => m.t && MESSAGE_TYPE_ANY_LOAD.includes(m.t as MessageTypeLoad))?.id ?? null; @@ -75,7 +74,7 @@ export const useMessages = ({ const raiseOrReleaseAnchor = useCallback( async (observed: TAnyMessageModel[], currentHighTs: number) => { const wasPresent = boundaryLoaderPresent.current; - const isPresent = (observed as unknown as AnchorMessage[]).some(m => isNewerLoader(m) && tsToMs(m.ts) === currentHighTs); + const isPresent = observed.some(m => isNewerLoader(m) && tsToMs(m.ts) === currentHighTs); boundaryLoaderPresent.current = isPresent; // Only a present → absent transition is a consume. Anything else (still present, or never @@ -96,7 +95,7 @@ export const useMessages = ({ return; } - const next = raiseOrRelease(loader ? [loader as unknown as AnchorMessage] : [], currentHighTs); + const next = raiseOrRelease(loader ? [loader] : [], currentHighTs); if (next === null) { // Gap closed → release to a Live Window. First grow count by the number of messages now diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts index f04584b26b9..1eedb6df625 100644 --- a/app/views/RoomView/definitions.ts +++ b/app/views/RoomView/definitions.ts @@ -209,7 +209,7 @@ export type TGetMessageInfoResult = { export interface AnchorMessage { id: string; t?: string | null; - ts: Date | number; + ts: Date | number | string; } export interface IJumpTarget { From 2ac4ea8e356659d57e05550a4a9ffbe8f2556955 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:05:23 -0300 Subject: [PATCH 10/61] perf(RoomView): update the scroll FAB state only on a threshold crossing --- app/views/RoomView/List/components/List.tsx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index f5c6a52c632..845794649f5 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { StyleSheet, View } from 'react-native'; -import Animated, { useAnimatedScrollHandler } from 'react-native-reanimated'; +import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; import { useIsScreenReaderEnabled } from '../../../../lib/hooks/useIsScreenReaderEnabled'; @@ -26,12 +26,13 @@ const styles = StyleSheet.create({ const List = ({ flatListRef, jumpToBottom, isAnchored, ...props }: IListProps) => { const [scrolledPastLimit, setScrolledPastLimit] = useState(false); const isAutocompleteVisible = useIsAutocompleteVisible(); + const wasScrolledPastLimit = useSharedValue(false); const scrollHandler = useAnimatedScrollHandler({ onScroll: event => { - if (event.contentOffset.y > SCROLL_LIMIT) { - scheduleOnRN(setScrolledPastLimit, true); - } else { - scheduleOnRN(setScrolledPastLimit, false); + const isPastLimit = event.contentOffset.y > SCROLL_LIMIT; + if (isPastLimit !== wasScrolledPastLimit.value) { + wasScrolledPastLimit.value = isPastLimit; + scheduleOnRN(setScrolledPastLimit, isPastLimit); } } }); From 98cea96ce53f5148a7a26572893cdd940183339e Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:05:54 -0300 Subject: [PATCH 11/61] refactor(RoomView): type the animated message list instead of suppressing it --- app/views/RoomView/List/components/List.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/views/RoomView/List/components/List.tsx b/app/views/RoomView/List/components/List.tsx index 845794649f5..f376d738a3f 100644 --- a/app/views/RoomView/List/components/List.tsx +++ b/app/views/RoomView/List/components/List.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { StyleSheet, View } from 'react-native'; +import { FlatList, StyleSheet, View } from 'react-native'; import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; @@ -10,10 +10,13 @@ import { isExternalKeyboardConnected } from '../../../../lib/methods/helpers/ext import { MESSAGE_COMPOSER_EXIT_FOCUS_NATIVE_ID } from '../../../../lib/constants/accessibility'; import InvertedScrollView from './InvertedScrollView'; import NavBottomFAB from './NavBottomFAB'; +import { type TAnyMessageModel } from '../../../../definitions'; import { type IListProps } from '../../definitions'; import { SCROLL_LIMIT } from '../constants'; import { useIsAutocompleteVisible } from '../../stores/ComposerStore'; +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); + const styles = StyleSheet.create({ list: { flex: 1 @@ -45,8 +48,7 @@ const List = ({ flatListRef, jumpToBottom, isAnchored, ...props }: IListProps) = const renderScrollComponent = !isIOS && (isScreenReaderEnabled || isExternalKeyboardConnected()); return ( - {/* @ts-ignore */} - Date: Wed, 2 Sep 2026 14:06:58 -0300 Subject: [PATCH 12/61] refactor(message): shrink the inert message-action store to the action it serves --- app/containers/message/docs/ARCHITECTURE.md | 2 +- .../message/stores/MessageActionStore.tsx | 25 ++++++------------- .../__tests__/MessageActionStore.test.tsx | 6 +---- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/app/containers/message/docs/ARCHITECTURE.md b/app/containers/message/docs/ARCHITECTURE.md index d888b33ac6b..21d0341dfa8 100644 --- a/app/containers/message/docs/ARCHITECTURE.md +++ b/app/containers/message/docs/ARCHITECTURE.md @@ -51,7 +51,7 @@ Two dev-only guards protect this pattern from silent regressions (both no-op in Rows can render outside a full provider stack (a **Message Preview** has no `MessageActionProvider`; some tests mount only `MessageStore`/`MessageRoomStore`). The three stores disagree deliberately on how to fail: - **MessageStore** and **MessageRoomStore** hooks throw (`'Message hooks must be used within a MessageProvider'` / `'...MessageRoomProvider'`) if no provider is present — every row and every Room-scoped view is expected to always supply both. -- **MessageActionStore** distinguishes by hook: `useMessageAction()` throws without a provider, but `useIsBeingEdited(messageId)` falls back to a shared `inertStore` (`action: null`, and every action method throws `NO_OP_ACTIONS`) — a Message Preview rendered without a `MessageActionProvider` can ask "am I being edited?" and safely get `false` instead of crashing, since it can never actually enter an edit session. +- **MessageActionStore** distinguishes by hook: `useMessageAction()` throws without a provider, but `useIsBeingEdited(messageId)` falls back to a shared `inertStore` holding only `action: null` — a Message Preview rendered without a `MessageActionProvider` can ask "am I being edited?" and safely get `false` instead of crashing, since it can never actually enter an edit session. --- diff --git a/app/containers/message/stores/MessageActionStore.tsx b/app/containers/message/stores/MessageActionStore.tsx index c363bc4ef2c..0dfad3581d5 100644 --- a/app/containers/message/stores/MessageActionStore.tsx +++ b/app/containers/message/stores/MessageActionStore.tsx @@ -53,23 +53,9 @@ export type TMessageActionStore = ReturnType; export const MessageActionStoreContext = createContext(null); -const noOpAction = (): never => { - throw new Error('MessageActionStore: no provider — actions unavailable'); -}; - -const NO_OP_ACTIONS: TMessageActionActions = { - startEditing: noOpAction, - startQuote: noOpAction, - addQuote: noOpAction, - removeQuote: noOpAction, - startReacting: noOpAction, - setQuoteMessageIds: noOpAction, - clear: noOpAction -}; - -// Rows rendered outside a RoomView (search, pinned) can never be in edit mode. -// `action` is fixed at null (no `set` param) and its actions always throw — a safe, inert fallback. -export const inertStore = createStore()(() => ({ action: null, actions: NO_OP_ACTIONS })); +// Rows rendered outside a RoomView (search, pinned) can never be in edit mode, and the only hook +// that falls back here reads `action`. Fixed at null (no `set` param). +const inertStore = createStore>()(() => ({ action: null })); const useMessageActionStore = (selector: (state: MessageActionState) => T): T => { const store = useContext(MessageActionStoreContext); @@ -91,7 +77,10 @@ export const useMessageActionKind = (): NonNullable['kind'] */ export const useIsBeingEdited = (messageId: string): boolean => { const store = useContext(MessageActionStoreContext) ?? inertStore; - return useStore(store, s => s.action?.kind === 'edit' && s.action.messageId === messageId); + return useStore( + store, + (s: Pick) => s.action?.kind === 'edit' && s.action.messageId === messageId + ); }; // Stable ref so the selector doesn't emit a fresh array when not quoting. diff --git a/app/containers/message/stores/__tests__/MessageActionStore.test.tsx b/app/containers/message/stores/__tests__/MessageActionStore.test.tsx index 0450dfea9e4..7746921db11 100644 --- a/app/containers/message/stores/__tests__/MessageActionStore.test.tsx +++ b/app/containers/message/stores/__tests__/MessageActionStore.test.tsx @@ -2,7 +2,6 @@ import { render } from '@testing-library/react-native'; import { createMessageActionStore, - inertStore, MessageActionProvider, useEditingMessageId, useIsBeingEdited, @@ -23,7 +22,7 @@ describe('MessageActionStore', () => { expect(spy).toHaveBeenLastCalledWith(false); }); - it('does not share mutable state across separate no-provider renders, and its actions throw', () => { + it('does not share mutable state across separate no-provider renders', () => { const spyA = jest.fn(); const spyB = jest.fn(); const ProbeA = () => { @@ -40,9 +39,6 @@ describe('MessageActionStore', () => { expect(spyA).toHaveBeenLastCalledWith(false); expect(spyB).toHaveBeenLastCalledWith(false); - expect(() => inertStore.getState().actions.startEditing('x')).toThrow( - 'MessageActionStore: no provider — actions unavailable' - ); }); it('returns true only for the message being edited under a provider', () => { From 2a73c3ba95615cedaccac7422fa902464a710035 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:07:31 -0300 Subject: [PATCH 13/61] refactor(RoomView): flatten the jumpToMessage target checks --- app/views/RoomView/services/jumpToMessage.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/app/views/RoomView/services/jumpToMessage.ts b/app/views/RoomView/services/jumpToMessage.ts index 3ee1d7aec13..efabb7c1680 100644 --- a/app/views/RoomView/services/jumpToMessage.ts +++ b/app/views/RoomView/services/jumpToMessage.ts @@ -6,7 +6,7 @@ import { loadSurroundingMessages } from '../../../lib/methods/loadSurroundingMes import { resolveJumpAnchor } from './resolveJumpAnchor'; import getMessageInfo from './getMessageInfo'; import getLocalAnchorTs from './getLocalAnchor'; -import { type IJumpToMessageArgs, type TGetMessageInfoResult } from '../definitions'; +import { type IJumpToMessageArgs } from '../definitions'; export const jumpToMessage = async ({ messageId, @@ -19,16 +19,6 @@ export const jumpToMessage = async ({ navToThread, cancel }: IJumpToMessageArgs): Promise => { - const isTargetOutsideCurrentView = (message: TGetMessageInfoResult) => { - if (message.tmid && message.tmid === tmid) { - return false; - } - if (!message.tmid && message.rid === rid) { - return false; - } - return true; - }; - try { sendLoadingEvent({ visible: true, onCancel: cancel }); const message = await getMessageInfo(messageId); @@ -38,13 +28,16 @@ export const jumpToMessage = async ({ return; } - if (isTargetOutsideCurrentView(message)) { + const inThisThread = !!message.tmid && message.tmid === tmid; + const inThisRoom = !message.tmid && message.rid === rid; + + if (!inThisThread && !inThisRoom) { if (message.rid !== rid) { navToRoom(message); } else { navToThread(message); } - } else if (!message.tmid && message.rid === rid && t === 'thread' && !message.replies) { + } else if (inThisRoom && t === 'thread' && !message.replies) { /** * if the user is within a thread and the message that he is trying to jump to, is a message in the main room */ From d7104ad56177023fe849fe587acedd5bd5466eb8 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:08:09 -0300 Subject: [PATCH 14/61] refactor(RoomView): share one footer action button between TakeOrJoin and OnHold --- .../components/RoomFooter/FooterAction.tsx | 36 +++++++++++++++++++ .../RoomView/components/RoomFooter/OnHold.tsx | 26 +++++--------- .../components/RoomFooter/TakeOrJoin.tsx | 26 +++++--------- 3 files changed, 52 insertions(+), 36 deletions(-) create mode 100644 app/views/RoomView/components/RoomFooter/FooterAction.tsx diff --git a/app/views/RoomView/components/RoomFooter/FooterAction.tsx b/app/views/RoomView/components/RoomFooter/FooterAction.tsx new file mode 100644 index 00000000000..1dfc3c89ffe --- /dev/null +++ b/app/views/RoomView/components/RoomFooter/FooterAction.tsx @@ -0,0 +1,36 @@ +import { Text, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import Touch from '../../../../containers/Touch'; +import { useTheme } from '../../../../theme'; +import { useRoomScreen } from '../../stores/RoomScreenContext'; +import styles from './styles'; + +export const FooterAction = ({ + testID, + title, + buttonTestID, + buttonLabel, + onPress +}: { + testID: string; + title: string; + buttonTestID: string; + buttonLabel: string; + onPress: () => void; +}) => { + const { colors } = useTheme(); + const { bottom } = useSafeAreaInsets(); + const { loading } = useRoomScreen(); + + return ( + + {title} + + + {buttonLabel} + + + + ); +}; diff --git a/app/views/RoomView/components/RoomFooter/OnHold.tsx b/app/views/RoomView/components/RoomFooter/OnHold.tsx index fb21ed6afaf..f18fb5b193c 100644 --- a/app/views/RoomView/components/RoomFooter/OnHold.tsx +++ b/app/views/RoomView/components/RoomFooter/OnHold.tsx @@ -1,27 +1,17 @@ -import { Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import Touch from '../../../../containers/Touch'; import I18n from '../../../../i18n'; -import { useTheme } from '../../../../theme'; -import { useRoomScreen } from '../../stores/RoomScreenContext'; import { useRoomStore } from '../../stores/RoomStoreContext'; -import styles from './styles'; +import { FooterAction } from './FooterAction'; export const OnHold = () => { - const { colors } = useTheme(); - const { bottom } = useSafeAreaInsets(); - const { loading } = useRoomScreen(); const resumeRoom = useRoomStore(s => s.resumeRoom); return ( - - {I18n.t('Chat_is_on_hold')} - - - {I18n.t('Resume')} - - - + ); }; diff --git a/app/views/RoomView/components/RoomFooter/TakeOrJoin.tsx b/app/views/RoomView/components/RoomFooter/TakeOrJoin.tsx index 920e55b01dd..11350c4c9b3 100644 --- a/app/views/RoomView/components/RoomFooter/TakeOrJoin.tsx +++ b/app/views/RoomView/components/RoomFooter/TakeOrJoin.tsx @@ -1,32 +1,22 @@ -import { Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import Touch from '../../../../containers/Touch'; import I18n from '../../../../i18n'; -import { useTheme } from '../../../../theme'; import { type ITakeOrJoinProps } from '../../definitions'; -import { useRoomScreen } from '../../stores/RoomScreenContext'; import { useRoomStore, useRoomWithUpdate } from '../../stores/RoomStoreContext'; -import styles from './styles'; +import { FooterAction } from './FooterAction'; export const TakeOrJoin = ({ joinCodeRef }: ITakeOrJoinProps) => { - const { colors } = useTheme(); - const { bottom } = useSafeAreaInsets(); const room = useRoomWithUpdate(); - const { loading } = useRoomScreen(); const joinRoom = useRoomStore(s => s.joinRoom); // The join-code modal lives on this screen, so the trigger is handed to joinRoom per call. const onPressJoin = () => joinRoom(() => joinCodeRef.current?.show()); return ( - - {I18n.t('You_are_in_preview_mode')} - - - {I18n.t(room.t === 'l' ? 'Take_it' : 'Join')} - - - + ); }; From 4877ce8d5643712089775ad5bd645c917eb6e964 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:09:08 -0300 Subject: [PATCH 15/61] refactor(RoomView): type roomUpdate from the subscription model instead of any --- app/views/RoomView/definitions.ts | 4 +--- app/views/RoomView/stores/RoomStore.ts | 7 +++---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts index 1eedb6df625..93b8f86f908 100644 --- a/app/views/RoomView/definitions.ts +++ b/app/views/RoomView/definitions.ts @@ -57,9 +57,7 @@ export interface IRoomViewState { sysMes?: boolean; onHold?: boolean; }; - roomUpdate: { - [K in TRoomUpdate]?: any; - }; + roomUpdate: Partial>; member: any; lastSeen: Date | null; } diff --git a/app/views/RoomView/stores/RoomStore.ts b/app/views/RoomView/stores/RoomStore.ts index 056bda8e351..ed6632d924b 100644 --- a/app/views/RoomView/stores/RoomStore.ts +++ b/app/views/RoomView/stores/RoomStore.ts @@ -210,10 +210,9 @@ const observeRoom = (rid: string | undefined, t: string | undefined, store: Room room: next, // observeWithColumns re-emits the same cached model instance mutated in place, so a fresh // snapshot object is what re-renders consumers on a tracked-column change. - roomUpdate: roomAttrsUpdate.reduce((ret: IRoomViewState['roomUpdate'], attr) => { - ret[attr] = (next as TSubscriptionModel)[attr]; - return ret; - }, {}), + roomUpdate: Object.fromEntries( + roomAttrsUpdate.map(attr => [attr, (next as TSubscriptionModel)[attr]]) + ) as IRoomViewState['roomUpdate'], subscribed: true, joined: true }); From ae1d1441b9ce7aad4304f1cd4fe7b43b04ba8e3c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:09:28 -0300 Subject: [PATCH 16/61] test(RoomView): drop the tautological QUERY_SIZE assertion --- app/views/RoomView/List/hooks/useMessages.test.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/views/RoomView/List/hooks/useMessages.test.tsx b/app/views/RoomView/List/hooks/useMessages.test.tsx index dda15e27085..1e4a413a420 100644 --- a/app/views/RoomView/List/hooks/useMessages.test.tsx +++ b/app/views/RoomView/List/hooks/useMessages.test.tsx @@ -517,8 +517,6 @@ describe('useMessages', () => { const firstTake = JSON.stringify(queryCalls[0].at(-1)); const secondTake = JSON.stringify(queryCalls[1].at(-1)); expect(firstTake).not.toEqual(secondTake); - // Smoke check: the constant exists and is the expected step. - expect(QUERY_SIZE).toBe(50); }); it('calls readThreads when tmid is set', async () => { From 9f5f697c2f4ac7a0e4295d6efb3aca6b41d9f0a3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:10:05 -0300 Subject: [PATCH 17/61] refactor(RoomView): drop the blank-label sentinel from LeftButtons --- app/views/RoomView/components/LeftButtons.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/views/RoomView/components/LeftButtons.tsx b/app/views/RoomView/components/LeftButtons.tsx index 7ed820851d3..4ab339726ce 100644 --- a/app/views/RoomView/components/LeftButtons.tsx +++ b/app/views/RoomView/components/LeftButtons.tsx @@ -37,13 +37,12 @@ const LeftButtons = ({ rid, tmid }: ILeftButtonsProps): ReactElement | null => { const unreadsCount = useUnreadsCount(rid); if (!isMasterDetail || tmid) { - let label = ' '; - let labelLength = 1; + let label: string | undefined; let marginLeft = 0; let fontSize = 0; if (unreadsCount) { - label = unreadsCount > 99 ? '+99' : unreadsCount.toString() || ' '; - labelLength = label.length ? label.length : 1; + label = unreadsCount > 99 ? '+99' : unreadsCount.toString(); + const labelLength = label.length; marginLeft = -4 * labelLength; fontSize = labelLength > 1 ? 14 : 17; } From 0bd024ff286727004480f313f5bcef7309a5dc59 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:15:37 -0300 Subject: [PATCH 18/61] test(RoomView): cover the visible system types clause --- .../buildVisibleSystemTypesClause.test.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 app/views/RoomView/List/hooks/buildVisibleSystemTypesClause.test.ts diff --git a/app/views/RoomView/List/hooks/buildVisibleSystemTypesClause.test.ts b/app/views/RoomView/List/hooks/buildVisibleSystemTypesClause.test.ts new file mode 100644 index 00000000000..e024bc9cb56 --- /dev/null +++ b/app/views/RoomView/List/hooks/buildVisibleSystemTypesClause.test.ts @@ -0,0 +1,27 @@ +import { buildVisibleSystemTypesClause } from './buildVisibleSystemTypesClause'; +import { MESSAGE_TYPE_ANY_LOAD } from '../../../../lib/constants/messageTypeLoad'; + +describe('buildVisibleSystemTypesClause', () => { + it('returns null when nothing is hidden', () => { + expect(buildVisibleSystemTypesClause([])).toBeNull(); + }); + + it('keeps rows without a type, load rows, and rows of a type that is not hidden', () => { + const clause = buildVisibleSystemTypesClause(['uj', 'ul']); + + expect(JSON.parse(JSON.stringify(clause))).toEqual({ + type: 'or', + conditions: [ + { type: 'where', left: 't', comparison: { operator: 'eq', right: { value: null } } }, + { type: 'where', left: 't', comparison: { operator: 'oneOf', right: { values: [...MESSAGE_TYPE_ANY_LOAD] } } }, + { + type: 'and', + conditions: [ + { type: 'where', left: 't', comparison: { operator: 'notEq', right: { value: 'uj' } } }, + { type: 'where', left: 't', comparison: { operator: 'notEq', right: { value: 'ul' } } } + ] + } + ] + }); + }); +}); From 279d150aac2ce30f1a902a7fcf51406aeb243ec6 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:18:05 -0300 Subject: [PATCH 19/61] perf(MessageComposer): read send-time values from the store instead of subscribing --- .../MessageComposer/MessageComposer.tsx | 20 +++++++++---------- .../message/stores/MessageActionStore.tsx | 7 +++++-- app/views/RoomView/stores/ComposerStore.tsx | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/app/containers/MessageComposer/MessageComposer.tsx b/app/containers/MessageComposer/MessageComposer.tsx index 1a9fa2f9b6b..6805a659507 100644 --- a/app/containers/MessageComposer/MessageComposer.tsx +++ b/app/containers/MessageComposer/MessageComposer.tsx @@ -7,12 +7,10 @@ import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanima import { useComposerRid, useComposerSharing, - useComposerTmid, - useEditRequest, - useOnSendMessage, - useSetQuotesAndText + useComposerStoreApi, + useComposerTmid } from '../../views/RoomView/stores/ComposerStore'; -import { useEditingMessageId, useMessageActionKind, useQuotedMessageIds } from '../message/stores/MessageActionStore'; +import { useMessageActionKind, useMessageActionStoreApi } from '../message/stores/MessageActionStore'; import { Autocomplete } from './components'; import { MIN_HEIGHT } from './constants'; import { @@ -63,12 +61,9 @@ export const MessageComposer = ({ const rid = useComposerRid(); const tmid = useComposerTmid(); const sharing = useComposerSharing(); - const editRequest = useEditRequest(); - const onSendMessage = useOnSendMessage(); - const setQuotesAndText = useSetQuotesAndText(); + const composerStore = useComposerStoreApi(); + const messageActionStore = useMessageActionStoreApi(); const actionKind = useMessageActionKind(); - const editingMessageId = useEditingMessageId(); - const quotedMessageIds = useQuotedMessageIds(); const alsoSendThreadToChannel = useAlsoSendThreadToChannel(); const { showEmojiKeyboard, showEmojiSearchbar, openEmojiSearchbar, resetKeyboard, keyboardHeight } = useEmojiKeyboard(); const { setAlsoSendThreadToChannel, setAutocompleteParams, clearAttachments } = useMessageComposerApi(); @@ -108,6 +103,11 @@ export const MessageComposer = ({ const handleSendMessage = async () => { if (!rid) return; + const { editRequest, onSendMessage, setQuotesAndText } = composerStore.getState(); + const { action } = messageActionStore.getState(); + const editingMessageId = action?.kind === 'edit' ? action.messageId : undefined; + const quotedMessageIds = action?.kind === 'quote' ? action.messageIds : []; + if (alsoSendThreadToChannel) { setAlsoSendThreadToChannel(false); } diff --git a/app/containers/message/stores/MessageActionStore.tsx b/app/containers/message/stores/MessageActionStore.tsx index 0dfad3581d5..5cf0b28c7ff 100644 --- a/app/containers/message/stores/MessageActionStore.tsx +++ b/app/containers/message/stores/MessageActionStore.tsx @@ -57,14 +57,17 @@ export const MessageActionStoreContext = createContext>()(() => ({ action: null })); -const useMessageActionStore = (selector: (state: MessageActionState) => T): T => { +export const useMessageActionStoreApi = (): TMessageActionStore => { const store = useContext(MessageActionStoreContext); if (!store) { throw new Error('Message action hooks must be used within a MessageActionProvider'); } - return useStore(store, selector); + return store; }; +const useMessageActionStore = (selector: (state: MessageActionState) => T): T => + useStore(useMessageActionStoreApi(), selector); + // `action` is a single ref replaced wholesale on every `set` — no useShallow needed. export const useMessageAction = (): TMessageActionState => useMessageActionStore(s => s.action); diff --git a/app/views/RoomView/stores/ComposerStore.tsx b/app/views/RoomView/stores/ComposerStore.tsx index 034b72a2075..a2213744f3b 100644 --- a/app/views/RoomView/stores/ComposerStore.tsx +++ b/app/views/RoomView/stores/ComposerStore.tsx @@ -17,7 +17,7 @@ export const createComposerStore = (initial: TComposerExternalState) => export const ComposerStoreContext = createContext(null); -const useComposerStoreApi = (): ComposerStore => { +export const useComposerStoreApi = (): ComposerStore => { const store = useContext(ComposerStoreContext); if (!store) { throw new Error('Composer store hooks must be used within a ComposerProvider'); From de186c306907457d68fce25c0f085dbb2084c7fa Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:21:05 -0300 Subject: [PATCH 20/61] perf(RoomView): seed serverVersion into the room store at creation joinRoom no longer reads the redux singleton at call time; the screen passes the version it already selects. --- app/views/RoomView/definitions.ts | 1 + app/views/RoomView/index.tsx | 2 +- app/views/RoomView/stores/RoomStore.ts | 18 ++++++++++++------ app/views/RoomView/stores/joinRoom.test.ts | 3 +-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts index 93b8f86f908..bc21d808b83 100644 --- a/app/views/RoomView/definitions.ts +++ b/app/views/RoomView/definitions.ts @@ -190,6 +190,7 @@ export interface IGetOrCreateRoomStoreParams { t?: string; initialRoom: IRoomViewState['room']; roomUserId?: string | null; + serverVersion?: string | null; } export type ComposerStore = StoreApi; diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index 70e54b8a933..345d53898bd 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -122,7 +122,7 @@ const RoomView = (props: IRoomViewProps) => { const userRef = useLiveRef(user); - const roomStore = useRoomStoreForScreen({ rid, t, initialRoom, roomUserId: initialRoomUserId }); + const roomStore = useRoomStoreForScreen({ rid, t, initialRoom, roomUserId: initialRoomUserId, serverVersion }); const room = useStore(roomStore, s => s.room); const roomUpdate = useStore(roomStore, s => s.roomUpdate); diff --git a/app/views/RoomView/stores/RoomStore.ts b/app/views/RoomView/stores/RoomStore.ts index ed6632d924b..d56cdeaebca 100644 --- a/app/views/RoomView/stores/RoomStore.ts +++ b/app/views/RoomView/stores/RoomStore.ts @@ -21,7 +21,6 @@ import { import { roomAttrsUpdate, roomAttrsUpdateColumns } from '../constants'; import getMessages from '../services/getMessages'; import { joinRoomImpl, resumeRoomImpl } from '../services/joinRoom'; -import { store as reduxStore } from '../../../lib/store/auxStore'; const OBSERVED_COLUMNS = Object.values(roomAttrsUpdateColumns); @@ -124,7 +123,8 @@ const createRoomState = ( rid: string | undefined, initialRoom: IRoomViewState['room'] = EMPTY_ROOM, - roomUserId: string | null | undefined = null + roomUserId: string | null | undefined = null, + serverVersion: string | null | undefined = null ): StateCreator => (set, get) => ({ room: initialRoom, @@ -191,7 +191,7 @@ const createRoomState = // store, so the caller passes its own trigger instead of registering one here. joinRoom: requestJoinCode => joinRoomImpl(get().room, { - serverVersion: reduxStore.getState().server.version, + serverVersion, requestJoinCode, onJoin: get().join }), @@ -279,15 +279,21 @@ const register = (rid: string, t: string | undefined, store: RoomStore, refCount // Render-safe: returns the rid-keyed store, creating it (observer + grace sweep) on first sight // without touching refCount. Safe to call from a useState initializer, which may run twice under // StrictMode/concurrent render. Acquire/release own the lifetime. -export const peekOrCreateRoomStore = ({ rid, t, initialRoom, roomUserId }: IGetOrCreateRoomStoreParams): RoomStore => { +export const peekOrCreateRoomStore = ({ + rid, + t, + initialRoom, + roomUserId, + serverVersion +}: IGetOrCreateRoomStoreParams): RoomStore => { if (!rid) { - return createStore(createRoomState(rid, initialRoom, roomUserId)); + return createStore(createRoomState(rid, initialRoom, roomUserId, serverVersion)); } const existing = registry.get(rid); if (existing) { return existing.store; } - const store = createStore(createRoomState(rid, initialRoom, roomUserId)); + const store = createStore(createRoomState(rid, initialRoom, roomUserId, serverVersion)); register(rid, t, store, 0); scheduleGraceSweep(rid); return store; diff --git a/app/views/RoomView/stores/joinRoom.test.ts b/app/views/RoomView/stores/joinRoom.test.ts index 5a699dc7cb2..45fa07e9590 100644 --- a/app/views/RoomView/stores/joinRoom.test.ts +++ b/app/views/RoomView/stores/joinRoom.test.ts @@ -27,7 +27,6 @@ jest.mock('../../../ee/omnichannel/lib', () => ({ takeInquiry: jest.fn(() => Promise.resolve()), takeResume: jest.fn(() => Promise.resolve()) })); -jest.mock('../../../lib/store/auxStore', () => ({ store: { getState: () => ({ server: { version: '6.1.0' } }) } })); const mockJoinRoomService = joinRoomService as jest.Mock; const mockTakeInquiry = takeInquiry as jest.Mock; @@ -35,7 +34,7 @@ const mockTakeResume = takeResume as jest.Mock; // rid-less stores bypass the registry, so each case gets an isolated store with the full creator actions. const makeStore = (room: IRoomViewState['room']) => { - const store = peekOrCreateRoomStore({ initialRoom: room }); + const store = peekOrCreateRoomStore({ initialRoom: room, serverVersion: '6.1.0' }); store.setState({ join: jest.fn() }); return store; }; From 90f3569b0b55ee4d4844a86b712951d494dd87c3 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:21:33 -0300 Subject: [PATCH 21/61] perf(RoomView): skip the extra render when the acquired room store is unchanged --- app/views/RoomView/stores/RoomStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/RoomView/stores/RoomStore.ts b/app/views/RoomView/stores/RoomStore.ts index d56cdeaebca..700deae4f9b 100644 --- a/app/views/RoomView/stores/RoomStore.ts +++ b/app/views/RoomView/stores/RoomStore.ts @@ -335,11 +335,11 @@ export const useRoomStoreForScreen = (params: IGetOrCreateRoomStoreParams): Room const { rid } = screenParams; useEffect(() => { - setStore(acquireRoomStore(screenParams, store)); + setStore(current => acquireRoomStore(screenParams, current)); return () => { InteractionManager.runAfterInteractions(() => releaseRoomStore(rid)); }; - }, [rid, screenParams, store]); + }, [rid, screenParams]); return store; }; From 02bf2e62fb081c2c927f0e9d7e6c0772cf218f06 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:24:50 -0300 Subject: [PATCH 22/61] refactor(RoomView): resolve thread names through fetchThreadName pushThreadRoom carried its own copy of the removed-thread branch and had drifted to `Thread`; the shared helper's `Message_removed` now wins for both call paths. --- .../RoomView/services/fetchThreadName.ts | 10 +++++++++- app/views/RoomView/services/pushThreadRoom.ts | 19 ++++++------------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/app/views/RoomView/services/fetchThreadName.ts b/app/views/RoomView/services/fetchThreadName.ts index 8635198a889..523be247119 100644 --- a/app/views/RoomView/services/fetchThreadName.ts +++ b/app/views/RoomView/services/fetchThreadName.ts @@ -2,10 +2,18 @@ import I18n from '../../../i18n'; import { getThreadById } from '../../../lib/database/services/Thread'; import getThreadName from '../../../lib/methods/getThreadName'; -export const fetchThreadName = async (rid: string, threadId: string, messageId: string): Promise => { +export const fetchThreadName = async ( + rid: string, + threadId: string, + messageId: string, + knownName?: string +): Promise => { const threadRecord = await getThreadById(threadId); if (threadRecord?.t === 'rm') { return I18n.t('Message_removed'); } + if (knownName) { + return knownName; + } return getThreadName(rid, threadId, messageId); }; diff --git a/app/views/RoomView/services/pushThreadRoom.ts b/app/views/RoomView/services/pushThreadRoom.ts index 5b11e99b068..38d323d8b76 100644 --- a/app/views/RoomView/services/pushThreadRoom.ts +++ b/app/views/RoomView/services/pushThreadRoom.ts @@ -1,8 +1,7 @@ import I18n from '../../../i18n'; -import { getThreadById } from '../../../lib/database/services/Thread'; -import getThreadName from '../../../lib/methods/getThreadName'; import { makeThreadName } from '../../../lib/methods/helpers/room'; import { sendLoadingEvent } from '../../../containers/Loading'; +import { fetchThreadName } from './fetchThreadName'; import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../../lib/constants/keys'; import { SubscriptionType, type TAnyMessageModel } from '../../../definitions'; import { type IRoomViewProps, type TGetMessageInfoResult } from '../definitions'; @@ -34,18 +33,12 @@ export const pushThreadRoom = async ({ jumpToMessageId = item.id; } sendLoadingEvent({ visible: true, onCancel }); - const threadRecord = await getThreadById(item.tmid); - if (threadRecord?.t === 'rm') { - name = I18n.t('Thread'); - } - if (!name) { - const threadName = await getThreadName(rid, item.tmid, jumpToMessageId); - if (!threadName) { - sendLoadingEvent({ visible: false }); - return; - } - name = threadName; + const threadName = await fetchThreadName(rid, item.tmid, jumpToMessageId, name); + if (!threadName) { + sendLoadingEvent({ visible: false }); + return; } + name = threadName; if ('id' in item && 't' in item && item.t === E2E_MESSAGE_TYPE && 'e2e' in item && item.e2e !== E2E_STATUS.DONE) { name = I18n.t('Encrypted_message'); } From 40ba0a24ec406c04dfd67f1b376b9d056696b990 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:27:08 -0300 Subject: [PATCH 23/61] refactor(RoomView): one thread-press wiring for the message tree The handlers hook wired pushThreadRoom a second time without onCancel, so the loading overlay opened there had no cancel button. It now takes the screen's onThreadPress, and onReplyInit delegates to it instead of repeating the push. --- .../components/RoomMessageProvider.tsx | 7 ++++--- app/views/RoomView/definitions.ts | 5 ++++- .../__tests__/useMessageActions.test.tsx | 15 ++++--------- .../__tests__/useRoomMessageHandlers.test.tsx | 5 +++-- .../RoomView/hooks/useMessageActions.tsx | 21 +++---------------- .../RoomView/hooks/useRoomMessageHandlers.tsx | 9 ++------ app/views/RoomView/index.tsx | 2 +- 7 files changed, 21 insertions(+), 43 deletions(-) diff --git a/app/views/RoomView/components/RoomMessageProvider.tsx b/app/views/RoomView/components/RoomMessageProvider.tsx index 84fd30c9ba8..9c0f5c23937 100644 --- a/app/views/RoomView/components/RoomMessageProvider.tsx +++ b/app/views/RoomView/components/RoomMessageProvider.tsx @@ -1,12 +1,13 @@ import { type ReactElement, type ReactNode } from 'react'; import { MessageRoomProvider, type MessageRoomState } from '../../../containers/message/stores/MessageRoomStore'; +import { type IRoomActions } from '../definitions'; import { useRoomMessageHandlers } from '../hooks/useRoomMessageHandlers'; -type IRoomMessageProviderProps = Omit & { children: ReactNode }; +type IRoomMessageProviderProps = Omit & { children: ReactNode; roomActions: IRoomActions }; -export const RoomMessageProvider = ({ children, ...state }: IRoomMessageProviderProps): ReactElement => { - const handlers = useRoomMessageHandlers(state.tmid); +export const RoomMessageProvider = ({ children, roomActions, ...state }: IRoomMessageProviderProps): ReactElement => { + const handlers = useRoomMessageHandlers({ tmid: state.tmid, ...roomActions }); return ( diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts index bc21d808b83..87f17530496 100644 --- a/app/views/RoomView/definitions.ts +++ b/app/views/RoomView/definitions.ts @@ -133,6 +133,10 @@ export interface IListContainerProps { serverVersion: string | null; } +export interface IRoomActions { + onThreadPress: (item: TAnyMessageModel) => void; +} + // The screen's own state, carried by RoomScreenContext — see that module for why it is per-screen. export interface IRoomScreenContextValue { loading: boolean; @@ -247,7 +251,6 @@ export interface IUseMessageActionsParams { hideActionSheet: () => void; rid?: string; tmid?: string; - roomUserId?: string | null; onThreadPress: (item: TAnyMessageModel) => void; messageComposerRef: RefObject; messageActionsRef: RefObject; diff --git a/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx b/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx index 8378bd66db6..074ffb0adb0 100644 --- a/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx @@ -5,7 +5,6 @@ import { setReaction } from '../../../../lib/services/restApi'; import log from '../../../../lib/methods/helpers/log'; import { Review } from '../../../../lib/methods/helpers/review'; import { getMessageById } from '../../../../lib/database/services/Message'; -import { SubscriptionType } from '../../../../definitions'; import { createMessageActionStore } from '../../../../containers/message/stores/MessageActionStore'; import ReactionPicker from '../../components/ReactionPicker'; import { useMessageActions } from '../useMessageActions'; @@ -65,7 +64,6 @@ const renderMessageActions = (overrides: Partial = {}, hideActionSheet, rid: RID, tmid: undefined, - roomUserId: 'room-user-1', onThreadPress, messageComposerRef: refs.messageComposerRef as any, messageActionsRef: refs.messageActionsRef as any, @@ -334,22 +332,17 @@ describe('useMessageActions', () => { expect(navigation.push).not.toHaveBeenCalled(); }); - it('onReplyInit pushes a new thread RoomView when none exists yet', async () => { + it('onReplyInit delegates to the thread press when no thread exists yet', async () => { const message = { id: 'msg-1', msg: 'hello' }; mockGetMessageById.mockResolvedValue(message); - const { result, navigation } = renderMessageActions(); + const { result, onThreadPress, navigation } = renderMessageActions(); await act(async () => { await result.current.onReplyInit('msg-1'); }); - expect(navigation.push).toHaveBeenCalledWith('RoomView', { - rid: RID, - tmid: 'msg-1', - name: 'hello', - t: SubscriptionType.THREAD, - roomUserId: 'room-user-1' - }); + expect(onThreadPress).toHaveBeenCalledWith(message); + expect(navigation.push).not.toHaveBeenCalled(); }); it('onReplyInit no-ops when the message cannot be found', async () => { diff --git a/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx b/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx index e1c537078a2..3fc30b309e2 100644 --- a/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx @@ -56,6 +56,7 @@ jest.mock('../../../../lib/methods/getThreadName', () => ({ const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; const mockDispatch = jest.fn(); const mockShowActionSheet = jest.fn(); +const mockOnThreadPress = jest.fn(); const mockHideActionSheet = jest.fn(); const mockToggleFollowMessage = toggleFollowMessage as jest.Mock; @@ -92,7 +93,7 @@ const renderRoomMessageHandlers = (roomStoreOverrides: Partial = {}, const messageActionStore = createMessageActionStore(); const clearLastSeen = jest.fn(); - const { result } = renderHook(() => useRoomMessageHandlers(tmid), { + const { result } = renderHook(() => useRoomMessageHandlers({ tmid, onThreadPress: mockOnThreadPress }), { wrapper: ({ children }) => ( @@ -105,7 +106,7 @@ const renderRoomMessageHandlers = (roomStoreOverrides: Partial = {}, return { result, roomStore, messageActionStore, clearLastSeen }; }; -const renderWithoutStores = () => renderHook(() => useRoomMessageHandlers()); +const renderWithoutStores = () => renderHook(() => useRoomMessageHandlers({ onThreadPress: mockOnThreadPress })); describe('useRoomMessageHandlers', () => { beforeEach(() => { diff --git a/app/views/RoomView/hooks/useMessageActions.tsx b/app/views/RoomView/hooks/useMessageActions.tsx index ad8f6c1134b..64a13149239 100644 --- a/app/views/RoomView/hooks/useMessageActions.tsx +++ b/app/views/RoomView/hooks/useMessageActions.tsx @@ -1,11 +1,8 @@ -import { useNavigation } from '@react-navigation/native'; - import { editMessage } from '../../../lib/methods/editMessage'; import log from '../../../lib/methods/helpers/log'; -import { makeThreadName } from '../../../lib/methods/helpers/room'; import { getMessageById } from '../../../lib/database/services/Message'; -import { type IMessage, type IMessageEditAttachment, SubscriptionType, type TAnyMessageModel } from '../../../definitions'; -import { type IRoomViewProps, type IUseMessageActionsParams, type IUseMessageActionsResult } from '../definitions'; +import { type IMessage, type IMessageEditAttachment, type TAnyMessageModel } from '../../../definitions'; +import { type IUseMessageActionsParams, type IUseMessageActionsResult } from '../definitions'; import ReactionPicker from '../components/ReactionPicker'; import { useReactionActions } from './useReactionActions'; @@ -15,14 +12,11 @@ export function useMessageActions({ hideActionSheet, rid, tmid, - roomUserId, onThreadPress, messageComposerRef, messageActionsRef, messageErrorActionsRef }: IUseMessageActionsParams): IUseMessageActionsResult { - const navigation = useNavigation(); - const { resetAction, onReactionClose, onReactionPress } = useReactionActions({ messageActionStore, hideActionSheet }); const handleCloseEmoji = (action?: (params?: unknown) => void, params?: unknown) => { @@ -119,16 +113,7 @@ export function useMessageActions({ if (!message || !rid) { return; } - if (message.tlm) { - return onThreadPress(message); - } - navigation.push('RoomView', { - rid, - tmid: messageId, - name: makeThreadName(message), - t: SubscriptionType.THREAD, - roomUserId - }); + onThreadPress(message); }; const setQuotesAndText = (text: string, quotes: string[]) => { diff --git a/app/views/RoomView/hooks/useRoomMessageHandlers.tsx b/app/views/RoomView/hooks/useRoomMessageHandlers.tsx index 0a347027d2b..100d30b703a 100644 --- a/app/views/RoomView/hooks/useRoomMessageHandlers.tsx +++ b/app/views/RoomView/hooks/useRoomMessageHandlers.tsx @@ -23,17 +23,16 @@ import { import { useActionSheet } from '../../../containers/ActionSheet'; import ReactionsList from '../../../containers/ReactionsList'; import { MessageActionStoreContext } from '../../../containers/message/stores/MessageActionStore'; -import { type IRoomViewProps } from '../definitions'; +import { type IRoomActions, type IRoomViewProps } from '../definitions'; import { useRoomStore } from '../stores/RoomStoreContext'; import { useRoomScreen } from '../stores/RoomScreenContext'; import { blockAction as blockActionService } from '../services/blockAction'; import { fetchThreadName as fetchThreadNameService } from '../services/fetchThreadName'; import { toggleFollowThread as toggleFollowThreadService } from '../../../lib/methods/toggleFollowThread'; -import { pushThreadRoom } from '../services/pushThreadRoom'; import { sendRoomMessage } from '../services/sendRoomMessage'; import { useReactionActions } from './useReactionActions'; -export function useRoomMessageHandlers(tmid?: string): IUseRoomMessageHandlersResult { +export function useRoomMessageHandlers({ tmid, onThreadPress }: IRoomActions & { tmid?: string }): IUseRoomMessageHandlersResult { const navigation = useNavigation(); const dispatch = useDispatch(); const isMasterDetail = useMasterDetail(); @@ -44,7 +43,6 @@ export function useRoomMessageHandlers(tmid?: string): IUseRoomMessageHandlersRe const { clearLastSeen } = useRoomScreen(); const rid = useRoomStore(s => s.room.rid); const room = useRoomStore(s => s.room); - const roomUserId = useRoomStore(s => s.roomUserId); if (!messageActionStore) { throw new Error('useRoomMessageHandlers must be used within a MessageActionProvider'); @@ -63,9 +61,6 @@ export function useRoomMessageHandlers(tmid?: string): IUseRoomMessageHandlersRe } }; - // No orchestrator-owned cancelJumpToMessageRef to self-source here, so the loading overlay renders without a cancel button. - const onThreadPress = (item: TAnyMessageModel) => pushThreadRoom({ rid, item, roomUserId, navigation }); - const navToRoomInfo = (navParam: IRoomInfoParam) => { logEvent(events[`ROOM_GO_${navParam.t === SubscriptionType.DIRECT ? 'USER' : 'ROOM'}_INFO`]); const params = { ...navParam, fromRid: rid }; diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index 345d53898bd..39215eca3c1 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -164,7 +164,6 @@ const RoomView = (props: IRoomViewProps) => { hideActionSheet, rid, tmid, - roomUserId, onThreadPress, messageComposerRef, messageActionsRef, @@ -266,6 +265,7 @@ const RoomView = (props: IRoomViewProps) => { ) : null} Date: Wed, 2 Sep 2026 14:28:12 -0300 Subject: [PATCH 24/61] refactor(RoomView): call useReactionActions once per room screen The handlers hook built a second set of reaction actions over the same message action store; it now takes the ones the screen already created. --- app/views/RoomView/definitions.ts | 2 ++ .../__tests__/useRoomMessageHandlers.test.tsx | 36 ++++++++++++------- .../RoomView/hooks/useRoomMessageHandlers.tsx | 19 ++++------ app/views/RoomView/index.tsx | 2 +- 4 files changed, 34 insertions(+), 25 deletions(-) diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts index 87f17530496..ba2d4629b56 100644 --- a/app/views/RoomView/definitions.ts +++ b/app/views/RoomView/definitions.ts @@ -135,6 +135,8 @@ export interface IListContainerProps { export interface IRoomActions { onThreadPress: (item: TAnyMessageModel) => void; + onReactionPress: (emoji: IEmoji, messageId: string) => Promise; + resetAction: () => void; } // The screen's own state, carried by RoomScreenContext — see that module for why it is per-screen. diff --git a/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx b/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx index 3fc30b309e2..11dc48b360a 100644 --- a/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx @@ -9,7 +9,7 @@ import { getUserSelector } from '../../../../selectors/login'; import { type RoomState, type RoomStore } from '../../definitions'; import { RoomStoreContext } from '../../stores/RoomStoreContext'; import { RoomScreenContext } from '../../stores/RoomScreenContext'; -import { createMessageActionStore, MessageActionStoreContext } from '../../../../containers/message/stores/MessageActionStore'; +import { createMessageActionStore } from '../../../../containers/message/stores/MessageActionStore'; import { useRoomMessageHandlers } from '../useRoomMessageHandlers'; jest.mock('@react-navigation/native', () => ({ @@ -57,6 +57,7 @@ const mockNavigation = { navigate: jest.fn(), push: jest.fn() }; const mockDispatch = jest.fn(); const mockShowActionSheet = jest.fn(); const mockOnThreadPress = jest.fn(); +const mockOnReactionPress = jest.fn(); const mockHideActionSheet = jest.fn(); const mockToggleFollowMessage = toggleFollowMessage as jest.Mock; @@ -92,21 +93,32 @@ const renderRoomMessageHandlers = (roomStoreOverrides: Partial = {}, const roomStore = makeRoomStore(roomStoreOverrides); const messageActionStore = createMessageActionStore(); const clearLastSeen = jest.fn(); - - const { result } = renderHook(() => useRoomMessageHandlers({ tmid, onThreadPress: mockOnThreadPress }), { - wrapper: ({ children }) => ( - - - {children} - - - ) - }); + const resetAction = () => messageActionStore.getState().actions.clear(); + + const { result } = renderHook( + () => useRoomMessageHandlers({ tmid, onThreadPress: mockOnThreadPress, onReactionPress: mockOnReactionPress, resetAction }), + { + wrapper: ({ children }) => ( + + + {children} + + + ) + } + ); return { result, roomStore, messageActionStore, clearLastSeen }; }; -const renderWithoutStores = () => renderHook(() => useRoomMessageHandlers({ onThreadPress: mockOnThreadPress })); +const renderWithoutStores = () => + renderHook(() => + useRoomMessageHandlers({ + onThreadPress: mockOnThreadPress, + onReactionPress: mockOnReactionPress, + resetAction: jest.fn() + }) + ); describe('useRoomMessageHandlers', () => { beforeEach(() => { diff --git a/app/views/RoomView/hooks/useRoomMessageHandlers.tsx b/app/views/RoomView/hooks/useRoomMessageHandlers.tsx index 100d30b703a..c66918e244a 100644 --- a/app/views/RoomView/hooks/useRoomMessageHandlers.tsx +++ b/app/views/RoomView/hooks/useRoomMessageHandlers.tsx @@ -1,4 +1,3 @@ -import { useContext } from 'react'; import { useDispatch } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; @@ -22,7 +21,6 @@ import { } from '../../../definitions'; import { useActionSheet } from '../../../containers/ActionSheet'; import ReactionsList from '../../../containers/ReactionsList'; -import { MessageActionStoreContext } from '../../../containers/message/stores/MessageActionStore'; import { type IRoomActions, type IRoomViewProps } from '../definitions'; import { useRoomStore } from '../stores/RoomStoreContext'; import { useRoomScreen } from '../stores/RoomScreenContext'; @@ -30,26 +28,23 @@ import { blockAction as blockActionService } from '../services/blockAction'; import { fetchThreadName as fetchThreadNameService } from '../services/fetchThreadName'; import { toggleFollowThread as toggleFollowThreadService } from '../../../lib/methods/toggleFollowThread'; import { sendRoomMessage } from '../services/sendRoomMessage'; -import { useReactionActions } from './useReactionActions'; -export function useRoomMessageHandlers({ tmid, onThreadPress }: IRoomActions & { tmid?: string }): IUseRoomMessageHandlersResult { +export function useRoomMessageHandlers({ + tmid, + onThreadPress, + onReactionPress, + resetAction +}: IRoomActions & { tmid?: string }): IUseRoomMessageHandlersResult { const navigation = useNavigation(); const dispatch = useDispatch(); const isMasterDetail = useMasterDetail(); const user = useAppSelector(getUserSelector); - const { showActionSheet, hideActionSheet } = useActionSheet(); + const { showActionSheet } = useActionSheet(); - const messageActionStore = useContext(MessageActionStoreContext); const { clearLastSeen } = useRoomScreen(); const rid = useRoomStore(s => s.room.rid); const room = useRoomStore(s => s.room); - if (!messageActionStore) { - throw new Error('useRoomMessageHandlers must be used within a MessageActionProvider'); - } - - const { resetAction, onReactionPress } = useReactionActions({ messageActionStore, hideActionSheet }); - const onDiscussionPress = async (drid: TAnyMessageModel['drid']) => { if (!drid) return; const discussion = await getRoomInfo(drid); diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index 39215eca3c1..e78b0294deb 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -265,7 +265,7 @@ const RoomView = (props: IRoomViewProps) => { ) : null} Date: Wed, 2 Sep 2026 14:29:59 -0300 Subject: [PATCH 25/61] refactor(RoomView): one sendRoomMessage wiring per room screen The answer-button handler duplicated the screen's send wiring; it now reuses it, and the send behaviour is covered on the service itself. --- app/views/RoomView/definitions.ts | 2 +- .../__tests__/useRoomMessageHandlers.test.tsx | 59 ++++--------------- .../RoomView/hooks/useRoomMessageHandlers.tsx | 13 +--- app/views/RoomView/index.tsx | 2 +- .../RoomView/services/sendRoomMessage.test.ts | 50 ++++++++++++++++ 5 files changed, 67 insertions(+), 59 deletions(-) create mode 100644 app/views/RoomView/services/sendRoomMessage.test.ts diff --git a/app/views/RoomView/definitions.ts b/app/views/RoomView/definitions.ts index ba2d4629b56..2b2fdc66a90 100644 --- a/app/views/RoomView/definitions.ts +++ b/app/views/RoomView/definitions.ts @@ -136,7 +136,7 @@ export interface IListContainerProps { export interface IRoomActions { onThreadPress: (item: TAnyMessageModel) => void; onReactionPress: (emoji: IEmoji, messageId: string) => Promise; - resetAction: () => void; + sendMessage: (message?: string, tshow?: boolean) => void; } // The screen's own state, carried by RoomScreenContext — see that module for why it is per-screen. diff --git a/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx b/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx index 11dc48b360a..e6fdc076f2d 100644 --- a/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useRoomMessageHandlers.test.tsx @@ -3,13 +3,9 @@ import { createStore } from 'zustand'; import { toggleFollowMessage } from '../../../../lib/services/restApi'; import { replyBroadcast as replyBroadcastAction } from '../../../../actions/messages'; -import { Review } from '../../../../lib/methods/helpers/review'; -import { sendMessage } from '../../../../lib/methods/sendMessage'; import { getUserSelector } from '../../../../selectors/login'; import { type RoomState, type RoomStore } from '../../definitions'; import { RoomStoreContext } from '../../stores/RoomStoreContext'; -import { RoomScreenContext } from '../../stores/RoomScreenContext'; -import { createMessageActionStore } from '../../../../containers/message/stores/MessageActionStore'; import { useRoomMessageHandlers } from '../useRoomMessageHandlers'; jest.mock('@react-navigation/native', () => ({ @@ -58,11 +54,11 @@ const mockDispatch = jest.fn(); const mockShowActionSheet = jest.fn(); const mockOnThreadPress = jest.fn(); const mockOnReactionPress = jest.fn(); +const mockSendRoomMessage = jest.fn(); const mockHideActionSheet = jest.fn(); const mockToggleFollowMessage = toggleFollowMessage as jest.Mock; const mockReplyBroadcastAction = replyBroadcastAction as jest.Mock; -const mockSendMessage = sendMessage as jest.Mock; const mockUser = { id: 'u1', username: 'user', token: 'tok', showMessageInMainThread: false }; @@ -91,24 +87,21 @@ const makeRoomStore = (overrides: Partial = {}): RoomStore => const renderRoomMessageHandlers = (roomStoreOverrides: Partial = {}, tmid?: string) => { const roomStore = makeRoomStore(roomStoreOverrides); - const messageActionStore = createMessageActionStore(); - const clearLastSeen = jest.fn(); - const resetAction = () => messageActionStore.getState().actions.clear(); const { result } = renderHook( - () => useRoomMessageHandlers({ tmid, onThreadPress: mockOnThreadPress, onReactionPress: mockOnReactionPress, resetAction }), + () => + useRoomMessageHandlers({ + tmid, + onThreadPress: mockOnThreadPress, + onReactionPress: mockOnReactionPress, + sendMessage: mockSendRoomMessage + }), { - wrapper: ({ children }) => ( - - - {children} - - - ) + wrapper: ({ children }) => {children} } ); - return { result, roomStore, messageActionStore, clearLastSeen }; + return { result, roomStore }; }; const renderWithoutStores = () => @@ -116,7 +109,7 @@ const renderWithoutStores = () => useRoomMessageHandlers({ onThreadPress: mockOnThreadPress, onReactionPress: mockOnReactionPress, - resetAction: jest.fn() + sendMessage: jest.fn() }) ); @@ -172,32 +165,6 @@ describe('useRoomMessageHandlers', () => { }); }); - describe('onAnswerButtonPress', () => { - it('sends the message, clears the unread divider and reports a positive review event', async () => { - mockSendMessage.mockResolvedValue(undefined); - const { result, clearLastSeen, messageActionStore } = renderRoomMessageHandlers({}, 'thread-1'); - messageActionStore.getState().actions.startEditing('msg-1'); - - await act(async () => { - result.current.onAnswerButtonPress('hello', true); - await Promise.resolve(); - }); - - expect(mockSendMessage).toHaveBeenCalledWith('rid-1', 'hello', 'thread-1', mockUser, true); - expect(clearLastSeen).toHaveBeenCalledTimes(1); - expect(Review.pushPositiveEvent).toHaveBeenCalledTimes(1); - expect(messageActionStore.getState().action).toBeNull(); - }); - - it('no-ops when the message is undefined', () => { - const { result } = renderRoomMessageHandlers(); - - act(() => result.current.onAnswerButtonPress(undefined)); - - expect(mockSendMessage).not.toHaveBeenCalled(); - }); - }); - describe('store contexts absent', () => { let consoleErrorSpy: jest.SpyInstance; @@ -209,8 +176,8 @@ describe('useRoomMessageHandlers', () => { consoleErrorSpy.mockRestore(); }); - it('throws when the screen and room contexts are absent', () => { - expect(() => renderWithoutStores()).toThrow(/must be used within a Room(Screen|Store)Context\.Provider/); + it('throws when the room store context is absent', () => { + expect(() => renderWithoutStores()).toThrow(/must be used within a RoomStoreContext\.Provider/); }); }); }); diff --git a/app/views/RoomView/hooks/useRoomMessageHandlers.tsx b/app/views/RoomView/hooks/useRoomMessageHandlers.tsx index c66918e244a..7050001d6c0 100644 --- a/app/views/RoomView/hooks/useRoomMessageHandlers.tsx +++ b/app/views/RoomView/hooks/useRoomMessageHandlers.tsx @@ -9,9 +9,7 @@ import { goRoom, type TGoRoomItem } from '../../../lib/methods/helpers/goRoom'; import { showErrorAlert } from '../../../lib/methods/helpers/info'; import { events, logEvent } from '../../../lib/methods/helpers/log'; import { isInActiveVoipCall } from '../../../lib/services/voip/isInActiveVoipCall'; -import { useAppSelector } from '../../../lib/hooks/useAppSelector'; import { useMasterDetail } from '../../../lib/hooks/useMasterDetail'; -import { getUserSelector } from '../../../selectors/login'; import { type IMessage, type IRoomInfoParam, @@ -23,25 +21,21 @@ import { useActionSheet } from '../../../containers/ActionSheet'; import ReactionsList from '../../../containers/ReactionsList'; import { type IRoomActions, type IRoomViewProps } from '../definitions'; import { useRoomStore } from '../stores/RoomStoreContext'; -import { useRoomScreen } from '../stores/RoomScreenContext'; import { blockAction as blockActionService } from '../services/blockAction'; import { fetchThreadName as fetchThreadNameService } from '../services/fetchThreadName'; import { toggleFollowThread as toggleFollowThreadService } from '../../../lib/methods/toggleFollowThread'; -import { sendRoomMessage } from '../services/sendRoomMessage'; export function useRoomMessageHandlers({ tmid, onThreadPress, onReactionPress, - resetAction + sendMessage }: IRoomActions & { tmid?: string }): IUseRoomMessageHandlersResult { const navigation = useNavigation(); const dispatch = useDispatch(); const isMasterDetail = useMasterDetail(); - const user = useAppSelector(getUserSelector); const { showActionSheet } = useActionSheet(); - const { clearLastSeen } = useRoomScreen(); const rid = useRoomStore(s => s.room.rid); const room = useRoomStore(s => s.room); @@ -115,9 +109,6 @@ export function useRoomMessageHandlers({ return toggleFollowThreadService(threadMessageId, isFollowingThread); }; - const onAnswerButtonPress = (message?: string, tshow?: boolean) => - sendRoomMessage({ rid, message, tmid, user, tshow, onMessageSent: clearLastSeen, resetAction }); - return { blockAction: blockActionService, navToRoomInfo, @@ -131,6 +122,6 @@ export function useRoomMessageHandlers({ replyBroadcast, fetchThreadName, toggleFollowThread, - onAnswerButtonPress + onAnswerButtonPress: sendMessage }; } diff --git a/app/views/RoomView/index.tsx b/app/views/RoomView/index.tsx index e78b0294deb..b5c882cfe41 100644 --- a/app/views/RoomView/index.tsx +++ b/app/views/RoomView/index.tsx @@ -265,7 +265,7 @@ const RoomView = (props: IRoomViewProps) => { ) : null} ({ + sendMessage: jest.fn(() => Promise.resolve()) +})); +jest.mock('../../../lib/methods/helpers/review', () => ({ + Review: { pushPositiveEvent: jest.fn() } +})); +jest.mock('../../../lib/methods/helpers/log', () => ({ + __esModule: true, + default: jest.fn(), + logEvent: jest.fn(), + events: { ROOM_SEND_MESSAGE: 'ROOM_SEND_MESSAGE' } +})); + +const mockSendMessage = sendMessage as jest.Mock; + +const user = { id: 'u1', username: 'user', token: 'tok' }; + +const send = (message?: string) => { + const onMessageSent = jest.fn(); + const resetAction = jest.fn(); + sendRoomMessage({ rid: 'rid-1', message, tmid: 'thread-1', user, tshow: true, onMessageSent, resetAction }); + return { onMessageSent, resetAction }; +}; + +describe('sendRoomMessage', () => { + beforeEach(() => jest.clearAllMocks()); + + it('sends the message, clears the unread divider and reports a positive review event', async () => { + const { onMessageSent, resetAction } = send('hello'); + + await Promise.resolve(); + + expect(mockSendMessage).toHaveBeenCalledWith('rid-1', 'hello', 'thread-1', user, true); + expect(onMessageSent).toHaveBeenCalledTimes(1); + expect(Review.pushPositiveEvent).toHaveBeenCalledTimes(1); + expect(resetAction).toHaveBeenCalledTimes(1); + }); + + it('no-ops when the message is undefined', () => { + const { onMessageSent, resetAction } = send(undefined); + + expect(mockSendMessage).not.toHaveBeenCalled(); + expect(onMessageSent).not.toHaveBeenCalled(); + expect(resetAction).not.toHaveBeenCalled(); + }); +}); From 0fdc273650465f676a5ae1af164b08a9fb1f8130 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:30:40 -0300 Subject: [PATCH 26/61] refactor(message): one MessageRoomProvider The provider forked into two components only to default timeFormat; the setting is now read unconditionally and used when the caller passes none. --- .../message/stores/MessageRoomStore.tsx | 28 ++++--------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/app/containers/message/stores/MessageRoomStore.tsx b/app/containers/message/stores/MessageRoomStore.tsx index 4d69bd17845..e02239e181b 100644 --- a/app/containers/message/stores/MessageRoomStore.tsx +++ b/app/containers/message/stores/MessageRoomStore.tsx @@ -91,8 +91,10 @@ const useFrozenHandlersGuard: (state: MessageRoomState) => void = __DEV__ ? useFrozenHandlersGuardDev : useFrozenHandlersGuardProd; -const MessageRoomStoreProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => { - const [store] = useState(() => createMessageRoomStore(state)); +export const MessageRoomProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => { + const timeFormatSetting = useSetting('Message_TimeFormat') as string; + const timeFormat = state.timeFormat ?? timeFormatSetting; + const [store] = useState(() => createMessageRoomStore({ ...state, timeFormat })); useFrozenHandlersGuard(state); // These fields can change mid-session (e.g. an open room gets archived), unlike the @@ -100,7 +102,7 @@ const MessageRoomStoreProvider = ({ children, ...state }: { children: ReactNode useEffect(() => { const reactiveState: ReactiveSnapshot = { handlers: state.handlers, - timeFormat: state.timeFormat, + timeFormat, autoTranslateRoom: state.autoTranslateRoom, autoTranslateLanguage: state.autoTranslateLanguage, archived: state.archived, @@ -111,7 +113,7 @@ const MessageRoomStoreProvider = ({ children, ...state }: { children: ReactNode store.setState(reactiveState); }, [ state.handlers, - state.timeFormat, + timeFormat, state.autoTranslateRoom, state.autoTranslateLanguage, state.archived, @@ -124,24 +126,6 @@ const MessageRoomStoreProvider = ({ children, ...state }: { children: ReactNode return {children}; }; -const MessageRoomProviderWithSetting = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => { - const Message_TimeFormat = useSetting('Message_TimeFormat') as string; - - return ( - - {children} - - ); -}; - -export const MessageRoomProvider = ({ children, ...state }: { children: ReactNode } & MessageRoomState): ReactElement => { - return state.timeFormat != null ? ( - {children} - ) : ( - {children} - ); -}; - export const useJumpToMessage = (): ((link: string) => void) | undefined => useMessageRoomStore(s => s.jumpToMessage); export const useCloseEmojiAndAction = (): MessageRoomState['closeEmojiAndAction'] => useMessageRoomStore(s => s.closeEmojiAndAction); From 108ddbde709475390664462561f612cb97d944db Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:36:10 -0300 Subject: [PATCH 27/61] test(RoomView): cover pushThreadRoom and fetchThreadName pushThreadRoom is now the single owner of thread-name resolution and of both reply paths, and neither it nor the helper had a test. --- .../__tests__/useMessageActions.test.tsx | 15 +--- .../RoomView/services/fetchThreadName.test.ts | 38 ++++++++++ .../RoomView/services/pushThreadRoom.test.ts | 75 +++++++++++++++++++ 3 files changed, 114 insertions(+), 14 deletions(-) create mode 100644 app/views/RoomView/services/fetchThreadName.test.ts create mode 100644 app/views/RoomView/services/pushThreadRoom.test.ts diff --git a/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx b/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx index 074ffb0adb0..f312e7ed5aa 100644 --- a/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx +++ b/app/views/RoomView/hooks/__tests__/useMessageActions.test.tsx @@ -319,20 +319,7 @@ describe('useMessageActions', () => { }); describe('reply', () => { - it('onReplyInit navigates to the thread when one already exists', async () => { - const message = { id: 'msg-1', tlm: '2024-01-01' }; - mockGetMessageById.mockResolvedValue(message); - const { result, onThreadPress, navigation } = renderMessageActions(); - - await act(async () => { - await result.current.onReplyInit('msg-1'); - }); - - expect(onThreadPress).toHaveBeenCalledWith(message); - expect(navigation.push).not.toHaveBeenCalled(); - }); - - it('onReplyInit delegates to the thread press when no thread exists yet', async () => { + it('onReplyInit delegates the message to the thread press', async () => { const message = { id: 'msg-1', msg: 'hello' }; mockGetMessageById.mockResolvedValue(message); const { result, onThreadPress, navigation } = renderMessageActions(); diff --git a/app/views/RoomView/services/fetchThreadName.test.ts b/app/views/RoomView/services/fetchThreadName.test.ts new file mode 100644 index 00000000000..4a5b7164c98 --- /dev/null +++ b/app/views/RoomView/services/fetchThreadName.test.ts @@ -0,0 +1,38 @@ +import { getThreadById } from '../../../lib/database/services/Thread'; +import getThreadName from '../../../lib/methods/getThreadName'; +import { fetchThreadName } from './fetchThreadName'; + +jest.mock('../../../lib/database/services/Thread', () => ({ + getThreadById: jest.fn() +})); +jest.mock('../../../lib/methods/getThreadName', () => ({ + __esModule: true, + default: jest.fn(() => Promise.resolve('Thread title')) +})); + +const mockGetThreadById = getThreadById as jest.Mock; +const mockGetThreadName = getThreadName as jest.Mock; + +describe('fetchThreadName', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetThreadById.mockResolvedValue(null); + }); + + it('reports a removed thread even when the caller already has a name', async () => { + mockGetThreadById.mockResolvedValue({ t: 'rm' }); + + await expect(fetchThreadName('rid-1', 'tmid-1', 'msg-1', 'known name')).resolves.toBe('message removed'); + expect(mockGetThreadName).not.toHaveBeenCalled(); + }); + + it('keeps the name the caller already has', async () => { + await expect(fetchThreadName('rid-1', 'tmid-1', 'msg-1', 'known name')).resolves.toBe('known name'); + expect(mockGetThreadName).not.toHaveBeenCalled(); + }); + + it('resolves the thread name when the caller has none', async () => { + await expect(fetchThreadName('rid-1', 'tmid-1', 'msg-1')).resolves.toBe('Thread title'); + expect(mockGetThreadName).toHaveBeenCalledWith('rid-1', 'tmid-1', 'msg-1'); + }); +}); diff --git a/app/views/RoomView/services/pushThreadRoom.test.ts b/app/views/RoomView/services/pushThreadRoom.test.ts new file mode 100644 index 00000000000..84f379e9747 --- /dev/null +++ b/app/views/RoomView/services/pushThreadRoom.test.ts @@ -0,0 +1,75 @@ +import { sendLoadingEvent } from '../../../containers/Loading'; +import { E2E_MESSAGE_TYPE } from '../../../lib/constants/keys'; +import { SubscriptionType } from '../../../definitions'; +import { fetchThreadName } from './fetchThreadName'; +import { pushThreadRoom } from './pushThreadRoom'; + +jest.mock('../../../containers/Loading', () => ({ + sendLoadingEvent: jest.fn() +})); +jest.mock('./fetchThreadName', () => ({ + fetchThreadName: jest.fn(() => Promise.resolve('Thread title')) +})); + +const mockFetchThreadName = fetchThreadName as jest.Mock; +const mockSendLoadingEvent = sendLoadingEvent as jest.Mock; + +const navigation = { push: jest.fn() } as any; +const onCancel = jest.fn(); + +const push = (item: any) => pushThreadRoom({ rid: 'rid-1', item, roomUserId: 'user-1', navigation, onCancel }); + +describe('pushThreadRoom', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockFetchThreadName.mockResolvedValue('Thread title'); + }); + + it('opens the parent thread of a reply, resolving its name', async () => { + await push({ id: 'msg-1', tmid: 'tmid-1', tmsg: 'known name' }); + + expect(mockFetchThreadName).toHaveBeenCalledWith('rid-1', 'tmid-1', 'msg-1', 'known name'); + expect(mockSendLoadingEvent).toHaveBeenCalledWith({ visible: true, onCancel }); + expect(navigation.push).toHaveBeenCalledWith('RoomView', { + rid: 'rid-1', + tmid: 'tmid-1', + name: 'Thread title', + t: SubscriptionType.THREAD, + roomUserId: 'user-1', + jumpToMessageId: 'msg-1' + }); + }); + + it('stops and hides the overlay when the thread name cannot be resolved', async () => { + mockFetchThreadName.mockResolvedValue(undefined); + + await push({ id: 'msg-1', tmid: 'tmid-1' }); + + expect(mockSendLoadingEvent).toHaveBeenLastCalledWith({ visible: false }); + expect(navigation.push).not.toHaveBeenCalled(); + }); + + it('titles an undecrypted thread as an encrypted message', async () => { + await push({ id: 'msg-1', tmid: 'tmid-1', t: E2E_MESSAGE_TYPE, e2e: 'pending' }); + + expect(navigation.push).toHaveBeenCalledWith('RoomView', expect.objectContaining({ name: 'Encrypted message' })); + }); + + it('opens a thread rooted at the message itself when it has no parent', async () => { + await push({ id: 'msg-1', msg: 'hello', tlm: undefined }); + + expect(navigation.push).toHaveBeenCalledWith('RoomView', { + rid: 'rid-1', + tmid: 'msg-1', + name: 'hello', + t: SubscriptionType.THREAD, + roomUserId: 'user-1' + }); + }); + + it('does nothing without a rid', async () => { + await pushThreadRoom({ rid: undefined, item: { tmid: 'tmid-1' }, roomUserId: null, navigation }); + + expect(navigation.push).not.toHaveBeenCalled(); + }); +}); From 8e4cc95df5d9c89b1eea0c56bab671a161f5ddb0 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 2 Sep 2026 14:39:59 -0300 Subject: [PATCH 28/61] feat(RoomView): render a retryable screen when room init fails useRoomInit exposes failed and retry so a room whose init exhausted its attempts no longer renders as an empty room. --- app/i18n/locales/en.json | 1 + .../RoomView/components/MessageRow.test.tsx | 3 +- .../components/RoomFooter/RoomFooter.test.tsx | 3 +- .../RoomView/components/RoomLoadFailed.tsx | 71 +++++++++++++++++++ app/views/RoomView/definitions.ts | 2 + .../hooks/__tests__/useRoomInit.test.ts | 37 ++++++++++ app/views/RoomView/hooks/useRoomInit.ts | 27 +++++-- app/views/RoomView/index.tsx | 5 ++ 8 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 app/views/RoomView/components/RoomLoadFailed.tsx diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 8f9606fe55d..7099e12f1fb 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -784,6 +784,7 @@ "room_changed_topic_to": "changed room topic to: {{topic}}", "room_changed_type": "changed room to {{type}}", "room_disallowed_reactions": "disallowed reactions", + "Room_failed_to_load": "This room could not be loaded. Check your connection and try again.", "Room_Info": "Room info", "Room_Info_Edit": "Room info edit", "Room_name_changed_to": "changed room name to: {{name}}", diff --git a/app/views/RoomView/components/MessageRow.test.tsx b/app/views/RoomView/components/MessageRow.test.tsx index ee19af235a6..f4e5f6b254d 100644 --- a/app/views/RoomView/components/MessageRow.test.tsx +++ b/app/views/RoomView/components/MessageRow.test.tsx @@ -81,7 +81,8 @@ describe('MessageRow', () => { render( - + diff --git a/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx b/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx index 6a9aa59b430..4e157569c58 100644 --- a/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx +++ b/app/views/RoomView/components/RoomFooter/RoomFooter.test.tsx @@ -77,7 +77,8 @@ const renderFooter = (roomStore: RoomStore, reduxStore = makeReduxStore(), loadi render( - + diff --git a/app/views/RoomView/components/RoomLoadFailed.tsx b/app/views/RoomView/components/RoomLoadFailed.tsx new file mode 100644 index 00000000000..3b9c9a1c73f --- /dev/null +++ b/app/views/RoomView/components/RoomLoadFailed.tsx @@ -0,0 +1,71 @@ +import { type ReactElement } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import Button from '../../../containers/Button'; +import { CustomIcon } from '../../../containers/CustomIcon'; +import I18n from '../../../i18n'; +import { useTheme } from '../../../theme'; +import sharedStyles from '../../Styles'; + +const GAP = 32; + +export const RoomLoadFailed = ({ onRetry }: { onRetry: () => void }): ReactElement => { + const { colors } = useTheme(); + const styles = useStyle(); + return ( + + + + + + + {I18n.t('Oops')} + {I18n.t('Room_failed_to_load')} + +