Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/actions/actionsTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ export const ROOM = createRequestTypes('ROOM', [
'FORWARD',
'USER_TYPING',
'HISTORY_REQUEST',
'HISTORY_FINISHED'
'HISTORY_FINISHED',
'HISTORY_UI_LOADER_PUSH',
'HISTORY_UI_LOADER_POP'
]);
export const INQUIRY = createRequestTypes('INQUIRY', [
...defaultTypes,
Expand Down
26 changes: 25 additions & 1 deletion app/actions/room.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,14 +59,24 @@ export interface IRoomHistoryFinished extends Action {
loaderId: string;
}

export interface IRoomHistoryUiLoaderPush extends Action {
loaderId: string;
}

export interface IRoomHistoryUiLoaderPop extends Action {
loaderId: string;
}

export type TActionsRoom = TSubscribeRoom &
TUnsubscribeRoom &
ILeaveRoom &
IDeleteRoom &
IForwardRoom &
IUserTyping &
IRoomHistoryRequest &
IRoomHistoryFinished;
IRoomHistoryFinished &
IRoomHistoryUiLoaderPush &
IRoomHistoryUiLoaderPop;

export function subscribeRoom(rid: string): TSubscribeRoom {
return {
Expand Down Expand Up @@ -138,3 +148,17 @@ export function roomHistoryFinished({ loaderId }: { loaderId: string }): IRoomHi
loaderId
};
}

export function roomHistoryUiLoaderPush({ loaderId }: { loaderId: string }): IRoomHistoryUiLoaderPush {
return {
type: ROOM.HISTORY_UI_LOADER_PUSH,
loaderId
};
}

export function roomHistoryUiLoaderPop({ loaderId }: { loaderId: string }): IRoomHistoryUiLoaderPop {
return {
type: ROOM.HISTORY_UI_LOADER_POP,
loaderId
};
}
240 changes: 240 additions & 0 deletions app/lib/methods/loadMessagesForRoom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
import { loadMessagesForRoom } from './loadMessagesForRoom';
import sdk from '../services/sdk';
import { ROOM } from '../../actions/actionsTypes';
import { getMessageById } from '../database/services/Message';
import { getSubscriptionByRoomId } from '../database/services/Subscription';
import updateMessages from './updateMessages';
import { store } from '../store/auxStore';

jest.mock('../services/sdk', () => ({
__esModule: true,
default: {
get: jest.fn()
}
}));

jest.mock('../database/services/Message', () => ({
getMessageById: jest.fn()
}));

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

jest.mock('../store/auxStore', () => ({
store: {
getState: jest.fn(() => ({
settings: { Hide_System_Messages: ['uj'] }
})),
dispatch: jest.fn()
}
}));

jest.mock('./updateMessages', () => jest.fn());

const mockedSdkGet = sdk.get as jest.MockedFunction<typeof sdk.get>;
const mockedGetMessageById = getMessageById as jest.MockedFunction<typeof getMessageById>;
const mockedUpdateMessages = updateMessages as jest.MockedFunction<typeof updateMessages>;
const mockedDispatch = store.dispatch as jest.MockedFunction<typeof store.dispatch>;
const mockedGetSubscriptionByRoomId = getSubscriptionByRoomId as jest.MockedFunction<typeof getSubscriptionByRoomId>;

const buildMessage = ({ id, ts, t }: { id: string; ts: string; t?: string }) =>
({
_id: id,
rid: 'ROOM_ID',
ts,
...(t ? { t } : {})
} as any);

describe('loadMessagesForRoom', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedGetMessageById.mockResolvedValue(null);
mockedUpdateMessages.mockResolvedValue(0);
mockedGetSubscriptionByRoomId.mockResolvedValue(null as any);
});

const buildHiddenBatch = (prefix: string, baseSeconds: number) =>
Array.from({ length: 50 }, (_, index) =>
buildMessage({
id: `${prefix}-${index + 1}`,
ts: new Date(Date.UTC(2024, 0, 1, 0, 0, baseSeconds - index)).toISOString(),
t: 'uj'
})
);

it('fetches additional history batches until it fills the visible page when hidden system messages consume the first batch', async () => {
const firstBatch = Array.from({ length: 50 }, (_, index) =>
buildMessage({
id: `first-${index + 1}`,
ts: new Date(Date.UTC(2024, 0, 1, 0, 0, 50 - index)).toISOString(),
t: index < 49 ? 'uj' : undefined
})
);
const secondBatch = Array.from({ length: 50 }, (_, index) =>
buildMessage({
id: `second-${index + 1}`,
ts: new Date(Date.UTC(2023, 11, 31, 23, 59, 50 - index)).toISOString(),
t: index === 49 ? 'uj' : undefined
})
);

mockedSdkGet
.mockResolvedValueOnce({ success: true, messages: firstBatch } as any)
.mockResolvedValueOnce({ success: true, messages: secondBatch } as any);

await loadMessagesForRoom({
rid: 'ROOM_ID',
t: 'c'
});

expect(mockedSdkGet).toHaveBeenCalledTimes(2);
expect(mockedSdkGet).toHaveBeenNthCalledWith(
2,
'channels.history',
expect.objectContaining({
roomId: 'ROOM_ID',
latest: firstBatch[firstBatch.length - 1].ts
})
);

expect(mockedUpdateMessages).toHaveBeenCalledTimes(2);
expect(mockedUpdateMessages).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
rid: 'ROOM_ID',
update: expect.arrayContaining([
expect.objectContaining({ _id: 'first-50' }),
expect.objectContaining({ _id: 'load-more-first-50', t: 'load_more' })
])
})
);
expect(mockedUpdateMessages).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
rid: 'ROOM_ID',
update: expect.arrayContaining([
expect.objectContaining({ _id: 'first-50' }),
expect.objectContaining({ _id: 'second-49' }),
expect.objectContaining({ _id: 'load-more-second-50', t: 'load_more' })
])
})
);

expect(mockedDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: ROOM.HISTORY_UI_LOADER_PUSH,
loaderId: 'load-more-first-50'
})
);
expect(mockedDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: ROOM.HISTORY_UI_LOADER_POP,
loaderId: 'load-more-first-50'
})
);
});
Comment thread
OtavioStasiak marked this conversation as resolved.

it('stops fetching after MAX_BATCHES even when the visible page is still unfilled', async () => {
// Every batch is fully hidden, so visibleMainMessagesCount never reaches COUNT
mockedSdkGet.mockImplementation((_endpoint, params) => {
const batchIndex = (params as { latest?: string }).latest

Check failure on line 141 in app/lib/methods/loadMessagesForRoom.test.ts

View workflow job for this annotation

GitHub Actions / ESLint and Test / run-eslint-and-test

Conversion of type 'void | undefined' to type '{ latest?: string | undefined; }' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
? Number((params as { latest?: string }).latest!.slice(-3, -1))

Check failure on line 142 in app/lib/methods/loadMessagesForRoom.test.ts

View workflow job for this annotation

GitHub Actions / ESLint and Test / run-eslint-and-test

Conversion of type 'void | undefined' to type '{ latest?: string | undefined; }' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
: 0;
return Promise.resolve({ success: true, messages: buildHiddenBatch(`batch-${batchIndex}`, 50) } as any);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
});

await loadMessagesForRoom({ rid: 'ROOM_ID', t: 'c' });

// MAX_BATCHES = 10
expect(mockedSdkGet).toHaveBeenCalledTimes(10);
});

it('does not append a trailing load-more when the last batch was not full', async () => {
const partialBatch = Array.from({ length: 30 }, (_, index) =>
buildMessage({
id: `partial-${index + 1}`,
ts: new Date(Date.UTC(2024, 0, 1, 0, 0, 30 - index)).toISOString()
})
);

mockedSdkGet.mockResolvedValueOnce({ success: true, messages: partialBatch } as any);

await loadMessagesForRoom({ rid: 'ROOM_ID', t: 'c' });

expect(mockedSdkGet).toHaveBeenCalledTimes(1);
expect(mockedUpdateMessages).toHaveBeenCalledTimes(1);
const finalUpdate = mockedUpdateMessages.mock.calls[0][0] as { update: { _id: string; t?: string }[] };
expect(finalUpdate.update).toHaveLength(30);
expect(finalUpdate.update.find(m => m.t === 'load_more')).toBeUndefined();
expect(mockedDispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: ROOM.HISTORY_UI_LOADER_PUSH }));
});

it('pops the ui loader when a recursive batch fetch fails after the loader was pushed', async () => {
const firstBatch = buildHiddenBatch('first', 50);
const networkError = new Error('boom');

mockedSdkGet.mockResolvedValueOnce({ success: true, messages: firstBatch } as any).mockRejectedValueOnce(networkError);

await expect(loadMessagesForRoom({ rid: 'ROOM_ID', t: 'c' })).rejects.toBe(networkError);

expect(mockedDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: ROOM.HISTORY_UI_LOADER_PUSH,
loaderId: 'load-more-first-50'
})
);
expect(mockedDispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: ROOM.HISTORY_UI_LOADER_POP,
loaderId: 'load-more-first-50'
})
);
});

it('falls back to settings.Hide_System_Messages when sub.sysMes is a boolean (not an array)', async () => {
mockedGetSubscriptionByRoomId.mockResolvedValue({ sysMes: true } as any);

const firstBatch = buildHiddenBatch('first', 50);
const secondBatch = Array.from({ length: 50 }, (_, index) =>
buildMessage({
id: `second-${index + 1}`,
ts: new Date(Date.UTC(2023, 11, 31, 23, 59, 50 - index)).toISOString()
})
);

mockedSdkGet
.mockResolvedValueOnce({ success: true, messages: firstBatch } as any)
.mockResolvedValueOnce({ success: true, messages: secondBatch } as any);

await loadMessagesForRoom({ rid: 'ROOM_ID', t: 'c' });

// settings.Hide_System_Messages = ['uj'] (from top-level mock) → first batch hidden → must recurse
expect(mockedSdkGet).toHaveBeenCalledTimes(2);
});

it('does not insert an intermediate load-more when a loaderItem is provided', async () => {
const firstBatch = buildHiddenBatch('first', 50);
const secondBatch = Array.from({ length: 50 }, (_, index) =>
buildMessage({
id: `second-${index + 1}`,
ts: new Date(Date.UTC(2023, 11, 31, 23, 59, 50 - index)).toISOString()
})
);

mockedSdkGet
.mockResolvedValueOnce({ success: true, messages: firstBatch } as any)
.mockResolvedValueOnce({ success: true, messages: secondBatch } as any);

await loadMessagesForRoom({
rid: 'ROOM_ID',
t: 'c',
loaderItem: { id: 'tapped-load-more' } as any
});

// Only the outer write — the intermediate batch-1 loader insert is skipped
expect(mockedUpdateMessages).toHaveBeenCalledTimes(1);
expect(mockedDispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: ROOM.HISTORY_UI_LOADER_PUSH }));
expect(mockedDispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: ROOM.HISTORY_UI_LOADER_POP }));
});
});
Loading
Loading