Skip to content
94 changes: 5 additions & 89 deletions app/lib/methods/helpers/goRoom.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import { InteractionManager } from 'react-native';

import { goRoom } from './goRoom';
import { peekOrCreateRoomStore, acquireRoomStore, releaseRoomStore } from '../../../views/RoomView/stores/RoomStore';

jest.mock('../../navigation/appNavigation', () => ({
__esModule: true,
Expand All @@ -12,93 +9,12 @@ jest.mock('../../navigation/appNavigation', () => ({
dispatch: jest.fn()
}
}));
jest.mock('../../database/services/Subscription', () => ({ getSubscriptionByRoomId: jest.fn(() => Promise.resolve(null)) }));
jest.mock('./helpers', () => ({ getRoomTitle: jest.fn(() => 'Room'), getUidDirectMessage: jest.fn() }));

jest.mock('../../database/services/Subscription', () => ({
getSubscriptionByRoomId: jest.fn(() => Promise.resolve(null))
}));

// RoomStore transitive imports pull native/ESM modules (mobile-crypto via readMessages);
// stub them so importing goRoom -> RoomStore stays runnable under jest.
jest.mock('../readMessages', () => ({ readMessages: jest.fn(() => Promise.resolve()) }));
jest.mock('../loadThreadMessages', () => ({ loadThreadMessages: jest.fn(() => Promise.resolve()) }));
jest.mock('../../services/restApi', () => ({ getUserInfo: jest.fn() }));
jest.mock('../isInviteSubscription', () => ({ isInviteSubscription: jest.fn(() => false) }));
jest.mock('../../../views/RoomView/services/getMessages', () => ({
__esModule: true,
default: jest.fn(() => Promise.resolve())
}));
jest.mock('../../store/auxStore', () => ({
store: {
getState: () => ({
settings: {},
login: { user: { id: 'u1', username: 'user' } }
})
}
}));
jest.mock('../../database', () => ({
__esModule: true,
default: {
active: {
get: () => ({
query: () => ({ observeWithColumns: () => ({ subscribe: () => ({ unsubscribe: jest.fn() }) }) })
})
}
}
}));

let graceCb: (() => void) | undefined;

describe('goRoom RoomStore warm-up', () => {
beforeEach(() => {
graceCb = undefined;
jest.spyOn(InteractionManager, 'runAfterInteractions').mockImplementation(((cb: () => void) => {
graceCb = cb;
return { then: () => {} };
}) as unknown as typeof InteractionManager.runAfterInteractions);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('tears down the warmed store via its grace sweep when navigation is effectively cancelled (no mount)', async () => {
describe('goRoom navigation', () => {
it('navigates without creating a RoomStore during the transition', async () => {
await goRoom({ item: { rid: 'r1', t: 'c' as any }, isMasterDetail: false } as any);
// warm-up left the entry at refCount 0 and scheduled its own grace sweep

expect(graceCb).toBeDefined();

// Peek the warmed entry to capture its instance (peek does not touch refCount).
const warmed = peekOrCreateRoomStore({ rid: 'r1', initialRoom: {} as any });

graceCb!(); // grace sweep: refCount 0 -> entry torn down

const fresh = peekOrCreateRoomStore({ rid: 'r1', initialRoom: {} as any }); // brand-new instance
expect(fresh).not.toBe(warmed);

releaseRoomStore('r1');
});

it('keeps the store alive when RoomView mounts before the grace sweep fires', async () => {
await goRoom({ item: { rid: 'r1', t: 'c' as any }, isMasterDetail: false } as any);

expect(graceCb).toBeDefined();

// Simulate RoomView mounting against the warmed entry and acquiring it: refCount 0 -> 1.
const mounted = peekOrCreateRoomStore({ rid: 'r1', initialRoom: {} as any });
acquireRoomStore({ rid: 'r1' }, mounted);

// Grace sweep fires after the transition: refCount is 1, so the entry stays alive.
graceCb!();

const stillAlive = peekOrCreateRoomStore({ rid: 'r1', initialRoom: {} as any });
expect(stillAlive).toBe(mounted);

releaseRoomStore('r1');
});

it('does not warm up the store when there is no rid', async () => {
await goRoom({ item: { t: 'c' as any }, isMasterDetail: false } as any);

expect(graceCb).toBeUndefined();
expect(true).toBe(true);
});
});
26 changes: 0 additions & 26 deletions app/lib/methods/helpers/goRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
import { getRoomTitle, getUidDirectMessage } from './helpers';
import { createDirectMessage } from '../createDirectMessage';
import { emitErrorCreateDirectMessage } from './emitErrorCreateDirectMessage';
import type { peekOrCreateRoomStore as TPeekOrCreateRoomStore } from '../../../views/RoomView/stores/RoomStore';

interface IGoRoomItem {
search?: boolean; // comes from spotlight
Expand Down Expand Up @@ -55,31 +54,6 @@ const navigate = ({ item, isMasterDetail, ...props }: { item: TGoRoomItem; isMas
return;
}

// Warm the RoomStore at press time so its DB observer runs during the nav transition and
// RoomView mounts against a hydrated store. peekOrCreate leaves the entry at refCount 0 and
// schedules its own grace sweep: if RoomView acquires it, it stays alive; otherwise (cancelled/
// failed navigation) the sweep reclaims it after the transition. No explicit release needed.
if (routeParams.rid) {
// Lazy require: goRoom is a low-level helper imported across the app, RoomStore lives in the
// view layer and pulls the encryption/native graph. Loading it only when a warm-up actually
// runs keeps that graph out of every goRoom importer.
const { peekOrCreateRoomStore } = require('../../../views/RoomView/stores/RoomStore') as {
peekOrCreateRoomStore: typeof TPeekOrCreateRoomStore;
};
peekOrCreateRoomStore({
rid: routeParams.rid,
initialRoom: {
rid: routeParams.rid,
t: routeParams.t as string,
name: routeParams.name,
prid: routeParams.prid,
visitor: routeParams.visitor,
joinCodeRequired: routeParams.joinCodeRequired
},
roomUserId: routeParams.roomUserId
});
}

Navigation.popTo('DrawerNavigator');
if (isMasterDetail) {
return Navigation.dispatch((state: any) => {
Expand Down
5 changes: 3 additions & 2 deletions app/views/RoomView/RoomScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { useRoomRemoved } from './hooks/useRoomRemoved';
import { useOmnichannelPermissions } from './hooks/useOmnichannelPermissions';
import { useInAppFeedback } from './hooks/useInAppFeedback';

const RoomScreen = ({ route, rid, t, tmid, roomStore }: IRoomScreenProps) => {
const RoomScreen = ({ route, rid, t, tmid, roomStore, ready }: IRoomScreenProps) => {
const { colors } = useTheme();
const isMasterDetail = useMasterDetail();

Expand Down Expand Up @@ -59,12 +59,13 @@ const RoomScreen = ({ route, rid, t, tmid, roomStore }: IRoomScreenProps) => {
t,
tmid,
roomStore,
ready,
roomUserId,
quoteMessageId: route.params?.messageId
});
useRoomSubscription(rid, tmid);
useRoomAudioLifecycle(rid, tmid);
useRoomRemoved(rid, isMasterDetail);
useRoomRemoved(rid, isMasterDetail, roomStore);
useInAppFeedback();
useOmnichannelPermissions({ rid, t, roomStore });

Expand Down
6 changes: 5 additions & 1 deletion app/views/RoomView/__tests__/RoomGate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,13 @@ jest.mock('../stores/RoomStore', () => {
const { createStore } = require('zustand');
const store = createStore(() => ({ room: {}, roomUpdate: {} }));
return {
useRoomStoreForScreen: () => {
createRoomStore: () => {
store.setState({ room: room.current, roomUpdate: {} }, true);
return store;
},
observeRoom: (_rid: string, _initialRoom: unknown, _store: unknown, onReady: () => void) => {
onReady();
return jest.fn();
}
};
});
Expand Down
10 changes: 6 additions & 4 deletions app/views/RoomView/components/LeftButtons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import { getUserSelector } from '../../../selectors/login';
import { HeaderBackButton } from '../../../containers/Header/components/HeaderBackButton';
import { useUnreadsCount } from '../hooks/useUnreadsCount';
import { useGoRoomActionsView } from '../hooks/useGoRoomActionsView';
import { useRoomStoreByRid } from '../stores/RoomStore';
import { useStore } from 'zustand';
import { type RoomStore } from '../definitions';

const styles = StyleSheet.create({
avatar: {
Expand All @@ -20,15 +21,16 @@ const styles = StyleSheet.create({
interface ILeftButtonsProps {
rid?: string;
tmid?: string;
roomStore: RoomStore;
}

const LeftButtons = ({ rid, tmid }: ILeftButtonsProps): ReactElement | null => {
const LeftButtons = ({ rid, tmid, roomStore }: ILeftButtonsProps): ReactElement | null => {
const { goBack } = useAppNavigation();
const goRoomActionsView = useGoRoomActionsView(rid);
const goRoomActionsView = useGoRoomActionsView(roomStore);
const isMasterDetail = useMasterDetail();
const baseUrl = useAppSelector(state => state.server.server);
const { id: userId, token } = useAppSelector(getUserSelector);
const room = useRoomStoreByRid(rid, s => s.room);
const room = useStore(roomStore, s => s.room);
const { t } = room;
const title = 'id' in room ? room.name : undefined;

Expand Down
9 changes: 4 additions & 5 deletions app/views/RoomView/components/MessageRow.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { act, render } from '@testing-library/react-native';

import database from '../../../lib/database';
import { peekOrCreateRoomStore, releaseRoomStore } from '../stores/RoomStore';
import { createRoomStore, observeRoom } from '../stores/RoomStore';
import { RoomScreenContext } from '../stores/RoomScreenContext';
import { RoomStoreContext } from '../stores/RoomStoreContext';
import { MessageRow } from './MessageRow';
Expand Down Expand Up @@ -69,15 +69,14 @@ describe('MessageRow', () => {
});

// Release the 'rid-1' store the case acquires, isolating via the public API (no test-only reset).
afterEach(() => {
releaseRoomStore('rid-1');
});
afterEach(() => {});

it('re-renders with fresh isIgnored when the same room instance re-emits a mutated ignored list', () => {
const { emit } = setupObserve();
const sub: any = { id: 'sub-1', rid: 'rid-1', t: 'c', ignored: [] };
const item: any = { id: 'msg-1', ts: new Date('2024-01-01T10:00:00Z'), u: { _id: 'author-1' } };
const store = peekOrCreateRoomStore({ rid: 'rid-1', initialRoom: sub });
const store = createRoomStore({ rid: 'rid-1', initialRoom: sub });
observeRoom('rid-1', sub, store);

render(
<RoomStoreContext.Provider value={store}>
Expand Down
17 changes: 9 additions & 8 deletions app/views/RoomView/components/RightButtons.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,11 @@ let mockRoomState = {
room: { rid: 'rid-1', t: 'c', name: 'general' },
canForwardGuest: false
};
jest.mock('zustand', () => ({
useStore: (_store: unknown, selector: (state: typeof mockRoomState) => unknown) => selector(mockRoomState)
}));
jest.mock('../../../ee/omnichannel/hooks/useCanReturnQueue', () => ({ useCanReturnQueue: () => false }));
jest.mock('../hooks/useCanPlaceLivechatOnHold', () => ({ useCanPlaceLivechatOnHold: () => false }));
jest.mock('../stores/RoomStore', () => ({
useRoomStoreByRid: (_rid: string | undefined, selector: (state: typeof mockRoomState) => unknown) => selector(mockRoomState)
}));

let mockE2EEStatus = { showMissingE2EEKey: false, showE2EEDisabledRoom: false };
jest.mock('../hooks/useE2EEStatus', () => ({ useE2EEStatus: () => mockE2EEStatus }));
Expand Down Expand Up @@ -75,6 +75,7 @@ jest.mock('../../../containers/Header/components/HeaderButton', () => {
jest.mock('./HeaderCallButton', () => ({ HeaderCallButton: () => null }));

describe('RightButtons', () => {
const roomStore = {} as any;
beforeEach(() => {
jest.clearAllMocks();
mockRoomState = {
Expand All @@ -94,31 +95,31 @@ describe('RightButtons', () => {
});

it('renders nothing without a rid', () => {
const { toJSON } = render(<RightButtons />);
const { toJSON } = render(<RightButtons roomStore={roomStore} />);
expect(toJSON()).toBeNull();
});

it('renders search and threads buttons for a regular channel', () => {
const { queryByTestId } = render(<RightButtons rid='rid-1' />);
const { queryByTestId } = render(<RightButtons rid='rid-1' roomStore={roomStore} />);
expect(queryByTestId('room-view-search')).toBeTruthy();
expect(queryByTestId('room-view-header-threads')).toBeTruthy();
});

it('renders the omnichannel kebab for a non-preview livechat room', () => {
mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'l', name: 'chat' } as any };
const { queryByTestId } = render(<RightButtons rid='rid-1' />);
const { queryByTestId } = render(<RightButtons rid='rid-1' roomStore={roomStore} />);
expect(queryByTestId('room-view-header-omnichannel-kebab')).toBeTruthy();
});

it('renders the follow toggle when a tmid is present', () => {
const { queryByTestId } = render(<RightButtons rid='rid-1' tmid='tmid-1' />);
const { queryByTestId } = render(<RightButtons rid='rid-1' tmid='tmid-1' roomStore={roomStore} />);
expect(queryByTestId('room-view-header-follow')).toBeTruthy();
});

it('renders the encryption toggle when there is an E2EE warning', () => {
mockRoomState = { ...mockRoomState, room: { rid: 'rid-1', t: 'c', encrypted: true } as any };
mockE2EEStatus = { showMissingE2EEKey: true, showE2EEDisabledRoom: false };
const { queryByTestId } = render(<RightButtons rid='rid-1' />);
const { queryByTestId } = render(<RightButtons rid='rid-1' roomStore={roomStore} />);
expect(queryByTestId('room-view-header-encryption')).toBeTruthy();
});
});
16 changes: 9 additions & 7 deletions app/views/RoomView/components/RightButtons.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ReactElement } from 'react';
import { useStore } from 'zustand';
import { useNavigation } from '@react-navigation/native';
import { type NativeStackNavigationProp } from '@react-navigation/native-stack';

Expand Down Expand Up @@ -28,11 +29,12 @@ import { useE2EEStatus } from '../hooks/useE2EEStatus';
import { useSubscriptionUnreads } from '../hooks/useSubscriptionUnreads';
import { useThreadFollowing } from '../hooks/useThreadFollowing';
import { toggleFollowThread } from '../../../lib/methods/toggleFollowThread';
import { useRoomStoreByRid } from '../stores/RoomStore';
import { type RoomStore } from '../definitions';

interface IRightButtonsProps {
rid?: string;
tmid?: string;
roomStore: RoomStore;
}

type RightButtonsNavigation = NativeStackNavigationProp<ChatsStackParamList & TNavigation, 'RoomView'>;
Expand Down Expand Up @@ -122,7 +124,7 @@ const closeLivechat = async ({
}
};

const RightButtons = ({ rid, tmid }: IRightButtonsProps): ReactElement | null => {
const RightButtons = ({ rid, tmid, roomStore }: IRightButtonsProps): ReactElement | null => {
const navigation = useNavigation<NativeStackNavigationProp<ChatsStackParamList & TNavigation, 'RoomView'>>();
const isMasterDetail = useMasterDetail();
const { colors } = useTheme();
Expand All @@ -133,16 +135,16 @@ const RightButtons = ({ rid, tmid }: IRightButtonsProps): ReactElement | null =>
const livechatRequestComment = useSetting('Livechat_request_comment_when_closing_conversation') as boolean;
const issuesWithNotifications = useAppSelector(state => state.troubleshootingNotification.issuesWithNotifications);

const room = useRoomStoreByRid(rid, s => s.room);
const canForwardGuest = useRoomStoreByRid(rid, s => s.canForwardGuest);
const room = useStore(roomStore, s => s.room);
const canForwardGuest = useStore(roomStore, s => s.canForwardGuest);
const canReturnQueue = useCanReturnQueue(room.t === 'l');
const canPlaceLivechatOnHold = useCanPlaceLivechatOnHold(rid);
const canPlaceLivechatOnHold = useCanPlaceLivechatOnHold(roomStore);

const { showMissingE2EEKey, showE2EEDisabledRoom } = useE2EEStatus(rid);
const { showMissingE2EEKey, showE2EEDisabledRoom } = useE2EEStatus(roomStore);
const hasE2EEWarning = !!('encrypted' in room && (showMissingE2EEKey || showE2EEDisabledRoom));

const isFollowingThread = useThreadFollowing(tmid, userId);
const { tunread, tunreadUser, tunreadGroup, isSelfDm, subscription } = useSubscriptionUnreads(rid, userId);
const { tunread, tunreadUser, tunreadGroup, isSelfDm, subscription } = useSubscriptionUnreads(roomStore, userId);
const [canToggleEncryption] = usePermissions(['toggle-room-e2e-encryption'], rid);

const t = room.t as SubscriptionType;
Expand Down
2 changes: 2 additions & 0 deletions app/views/RoomView/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export type TRoomRouteParse = { status: 'valid'; input: IRoomScreenInput } | { s

export interface IRoomScreenProps extends Pick<IRoomViewProps, 'route'>, Pick<IRoomScreenInput, 'rid' | 't' | 'tmid'> {
roomStore: RoomStore;
ready: boolean;
}

export interface IRoomFooterProps {
Expand Down Expand Up @@ -300,6 +301,7 @@ export interface IUseRoomMessagingParams {
rid?: string;
t?: string;
tmid?: string;
ready: boolean;
roomStore: RoomStore;
roomUserId?: string | null;
quoteMessageId?: string;
Expand Down
Loading
Loading