Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,9 @@ describe('MessageRoomStore', () => {
});

describe('live callbacks', () => {
const wrap = (config: Partial<MessageRoomState>) => (
const wrap = (reactionInit?: MessageRoomState['reactionInit']) => (
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Provider store={mockedStore}>
<MessageRoomProvider timeFormat='fixed-format' {...config}>
<MessageRoomProvider timeFormat='fixed-format' reactionInit={reactionInit}>
<ReactionInitConsumer />
</MessageRoomProvider>
</Provider>
Expand All @@ -254,8 +254,8 @@ describe('MessageRoomStore', () => {
const first = jest.fn();
const second = jest.fn();

const { rerender } = render(wrap({ reactionInit: first }));
act(() => rerender(wrap({ reactionInit: second })));
const { rerender } = render(wrap(first));
act(() => rerender(wrap(second)));

fireEvent.press(screen.getByText('reaction'));

Expand All @@ -264,26 +264,26 @@ describe('MessageRoomStore', () => {
});

it('keeps the callback identity stable across rerenders', () => {
const { rerender } = render(wrap({ reactionInit: jest.fn() }));
const { rerender } = render(wrap(jest.fn()));
const renderCallsBefore = renderSpy.mock.calls.length;

act(() => rerender(wrap({ reactionInit: jest.fn() })));
act(() => rerender(wrap(jest.fn())));

expect(renderSpy.mock.calls.length).toBe(renderCallsBefore);
});

it('stays undefined when the provider does not supply the callback', () => {
render(wrap({}));
render(wrap());

expect(renderSpy).toHaveBeenLastCalledWith(undefined);
});

it('becomes defined when the provider starts supplying the callback', () => {
const reactionInit = jest.fn();
const { rerender } = render(wrap({}));
const { rerender } = render(wrap());
expect(renderSpy).toHaveBeenLastCalledWith(undefined);

act(() => rerender(wrap({ reactionInit })));
act(() => rerender(wrap(reactionInit)));
fireEvent.press(screen.getByText('reaction'));

expect(reactionInit).toHaveBeenCalledWith('message-id');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { renderHook, waitFor } from '@testing-library/react-native';
import { Provider } from 'react-redux';

jest.mock('../../../lib/services/restApi', () => ({ getRoutingConfig: jest.fn() }));
jest.mock('../../../../lib/services/restApi', () => ({ getRoutingConfig: jest.fn() }));

import { getRoutingConfig } from '../../../lib/services/restApi';
import routingConfigSaga from '../sagas/routingConfig';
import { createRecordingStore, cancelSagaTasks } from '../../../lib/testUtils/sagaStore';
import { useCanReturnQueue } from './useCanReturnQueue';
import { getRoutingConfig } from '../../../../lib/services/restApi';
import routingConfigSaga from '../../sagas/routingConfig';
import { createRecordingStore, cancelSagaTasks } from '../../../../lib/testUtils/sagaStore';
import { useCanReturnQueue } from '../useCanReturnQueue';
import type { ReactNode } from 'react';

const mockGetRoutingConfig = getRoutingConfig as jest.MockedFunction<typeof getRoutingConfig>;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { inquiryReset } from '../actions/inquiry';
import { routingConfigSuccess } from '../actions/routingConfig';
import { selectServerRequest } from '../../../actions/server';
import routingConfig, { initialState } from './routingConfig';
import { inquiryReset } from '../../actions/inquiry';
import { routingConfigSuccess } from '../../actions/routingConfig';
import { selectServerRequest } from '../../../../actions/server';
import routingConfig, { initialState } from '../routingConfig';

describe('routingConfig reducer', () => {
it('stores the server value', () => {
Expand Down
4 changes: 2 additions & 2 deletions app/ee/omnichannel/reducers/routingConfig.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type AnyAction } from 'redux';
import { ROUTING_CONFIG } from '../../../actions/actionsTypes';
import { SERVER } from '../../../actions/actionsTypes';

import { ROUTING_CONFIG, SERVER } from '../../../actions/actionsTypes';

export interface IRoutingConfig {
returnQueue: boolean | null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
jest.mock('../../../lib/services/restApi', () => ({ getRoutingConfig: jest.fn() }));
jest.mock('../../../../lib/services/restApi', () => ({ getRoutingConfig: jest.fn() }));

import { getRoutingConfig } from '../../../lib/services/restApi';
import { routingConfigRequest } from '../actions/routingConfig';
import routingConfig from './routingConfig';
import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../../lib/testUtils/sagaStore';
import { getRoutingConfig } from '../../../../lib/services/restApi';
import { routingConfigRequest } from '../../actions/routingConfig';
import routingConfig from '../routingConfig';
import { cancelSagaTasks, createRecordingStore, flushSagaMicrotasks } from '../../../../lib/testUtils/sagaStore';

const mockGetRoutingConfig = getRoutingConfig as jest.MockedFunction<typeof getRoutingConfig>;

Expand Down
1 change: 0 additions & 1 deletion app/lib/hooks/useShortnameToUnicode/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ const useShortnameToUnicode = (isEmojiPicker?: boolean) => {
const replaceShortnameWithUnicode = (shortname: string) => {
const name = shortname.replace(/:/g, '');

// a custom emoji sharing a built-in shortcode/alias must win
if (customEmojis(name)) {
return shortname;
}
Expand Down
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
6 changes: 3 additions & 3 deletions app/views/RoomView/List/hooks/useScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ export const useScroll = ({
}, SCROLL_TO_INDEX_RETRY_DELAY);
};

const jumpToMessage: IListContainerRef['jumpToMessage'] = (messageId, highTs) =>
const jumpToMessage: IListContainerRef['jumpToMessage'] = (messageId, highTsMs) =>
new Promise<void>(resolve => {
// Cancel any previous in-flight jump before starting a new one.
if (pendingJump.current) {
Expand All @@ -272,7 +272,7 @@ export const useScroll = ({
lastJumpTargetId.current = messageId;
scrollFailRetries.current = 0;
jumpGrowthRetries.current = 0;
const anchored = typeof highTs === 'number' && Number.isFinite(highTs);
const anchored = typeof highTsMs === 'number' && Number.isFinite(highTsMs);
const jump: IPendingJump = {
messageId,
anchored,
Expand All @@ -289,7 +289,7 @@ export const useScroll = ({
// Non-contiguous target → set the Anchored Window (re-seeds a QUERY_SIZE window onto the target's Chunk).
// Contiguous / thread / local targets keep their current window.
if (anchored) {
setHighTs(highTs as number);
setHighTs(highTsMs as number);
}

// Target may already be present (contiguous / local): resolve synchronously, still one scroll.
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
14 changes: 8 additions & 6 deletions app/views/RoomView/__tests__/RoomGate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,25 @@ 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, _store: unknown, onReady: () => void) => {
onReady();
return jest.fn();
}
};
});

const renderGate = (params: Record<string, unknown> | null = { rid: 'rid-1', t: 'c' }) => {
const reduxStore = createReduxStore(() => ({ server: { version: '6.1.0' } }));
const props = {
route: { params: params ?? undefined },
navigation: { setOptions: jest.fn() }
} as unknown as IRoomViewProps;
const route = { params: params ?? undefined } as unknown as IRoomViewProps['route'];
const navigation = { setOptions: jest.fn() } as unknown as IRoomViewProps['navigation'];
return render(
<Provider store={reduxStore}>
<View>
<RoomGate {...props} />
<RoomGate route={route} navigation={navigation} />
</View>
</Provider>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import RoomView from '../index';
import { type IRoomViewProps } from '../definitions';
import { loadThreadMessages } from '../../../lib/methods/loadThreadMessages';

// A thread mounts a second RoomView on the parent's rid, so both screens share one rid-keyed store.
// This harness mounts that pair and is reusable by any test about per-screen vs per-room state.

jest.mock('../../../i18n', () => ({
__esModule: true,
default: { t: (key: string) => key }
Expand Down Expand Up @@ -182,7 +179,7 @@ const renderRoomAndThread = ({
};
};

describe('RoomView screens sharing one rid-keyed store', () => {
describe('RoomView room and thread screens on the same rid', () => {
beforeEach(() => {
jest.clearAllMocks();
mockSubscriptionRows.current = [];
Expand Down Expand Up @@ -231,7 +228,7 @@ describe('RoomView screens sharing one rid-keyed store', () => {
expect(screen.getByTestId('last-seen-room').props.accessibilityLabel).toBe(String(ls));
});

it('shares the Livechat agent-authored flag when the thread screen mounts first', async () => {
it('reflects the Livechat agent-authored flag on both screens when the thread screen mounts first', async () => {
const rid = 'rid-livechat-shared';
mockSubscriptionRows.current = [{ id: 'sub-1', rid, t: 'l', lastMessage: { u: { _id: 'agent-1' } } }];
renderRoomAndThread({ rid, type: 'l', startWithThread: true });
Expand Down
Loading
Loading