Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
86 commits
Select commit Hold shift + click to select a range
0163897
Revert "fix: re-subscribe room streams after re-authentication on rec…
diegolmello Jul 26, 2026
6622eed
Revert "fix: re-subscribe room streams on DDP reconnect (#7426)"
diegolmello Jul 26, 2026
38446b4
Revert "fix: re-subscribe rooms stream after forced socket reopen (#7…
diegolmello Jul 26, 2026
d848d35
Revert "fix: keep live socket on deeplink login (prevent orphan WebSo…
diegolmello Jul 26, 2026
87437c9
Revert "fix(voip,connect): probe Meteor Connect on foreground and loc…
diegolmello Jul 26, 2026
5b24172
fix: rewire pendingHangups drain without awaitDdpLoggedIn
diegolmello Jul 26, 2026
c840710
fix: remove 7380 orphan-socket gate tests from deepLinking
diegolmello Jul 26, 2026
8db6b9a
test: cover handleClickCallPush new-server path and pendingHangups lo…
diegolmello Jul 26, 2026
94dceb6
refactor: extract isLoginReady helper in connect.ts
diegolmello Jul 26, 2026
ceacfd7
fix(sync): derive the message sync cursor from the server clock
diegolmello Jul 27, 2026
6cceff4
fix(state): re-sync the open room on foreground over REST
diegolmello Jul 27, 2026
8ed70c9
fix(sync): self-heal a sync cursor that sits ahead of the server
diegolmello Jul 27, 2026
89392d7
fix(state): request the rooms delta on foreground
diegolmello Jul 27, 2026
000128d
fix(RoomView): re-run init when the subscription row arrives and boun…
diegolmello Jul 27, 2026
ba000ed
test(RoomView): share one awaiting act flush helper
diegolmello Jul 27, 2026
6524e83
fix(RoomView): lazy state reads in init to heal notification-tap race
diegolmello Jul 27, 2026
9ec8dcf
refactor(sync): align cursor terminology and drop dead code
diegolmello Jul 27, 2026
f1e310a
fix: restore orphan-socket guard in SDK patch
diegolmello Jul 27, 2026
2281397
test: restore updateMessage concurrency and subscribe coverage
diegolmello Jul 27, 2026
dd66175
fix(sync): derive the cursor from every fetched batch
diegolmello Jul 27, 2026
b84d6e0
refactor: address review nits in connect and MediaSessionInstance
diegolmello Jul 27, 2026
4032848
fix(RoomView): dispatch init load on cursor presence, not subscriptio…
diegolmello Jul 27, 2026
59b0d8b
fix(sync): throttle self-heal tail load per room
diegolmello Jul 27, 2026
edd1cd1
fix(sync): throttle foreground rooms-delta request
diegolmello Jul 27, 2026
2a5e376
Merge remote-tracking branch 'origin/purring-seahorse' into missing-m…
diegolmello Jul 27, 2026
ed3375f
test(sagas): pin Date.now past the rooms-delta throttle window
diegolmello Jul 27, 2026
9a638ea
feat(sdk): add reopenNow, liveness probe, and disconnected emit to DD…
diegolmello Jul 28, 2026
383c47a
fix(voip): gate native call accept on socket readiness
diegolmello Jul 28, 2026
bb8220f
fix(sdk): serialize forced reopen against concurrent open, harden probe
diegolmello Jul 28, 2026
0282782
refactor(voip): share socket health classification
diegolmello Jul 28, 2026
efda410
fix(connect): reconnect immediately when foregrounding a stale socket
diegolmello Jul 28, 2026
83c9af6
fix(voip): stop aborted accept gates from terminating the call
diegolmello Jul 28, 2026
79d151d
test(voip): align call-lifecycle integration tests with gated accept
diegolmello Jul 28, 2026
2402af6
fix(sync): drop the lying-cursor heal
diegolmello Jul 28, 2026
44d451b
fix(voip): await processSignal before native-accept replay
diegolmello Jul 28, 2026
281731c
merge branch 'purring-seahorse' into dry-badger
diegolmello Jul 28, 2026
aa485ee
revert(sync): drop the foreground re-sync chain
diegolmello Jul 28, 2026
d048d85
revert(RoomView): drop the init control-flow chain
diegolmello Jul 28, 2026
689ed20
fix(sync): snapshot the cursor from the first batch only
diegolmello Jul 28, 2026
70b5e7c
docs: describe only the cursor mechanisms that still exist
diegolmello Jul 28, 2026
409c796
test(voip): tidy timer cleanup and duplicate mock in accept tests
diegolmello Jul 28, 2026
4ba1445
fix(voip): terminate native call when readiness sequence throws
diegolmello Jul 28, 2026
f1e7fe1
fix(sdk): require both media subs and reuse sub id when resubscribing
diegolmello Jul 28, 2026
b7556ec
fix(read): let the server own the ls timestamp
diegolmello Jul 28, 2026
480e381
fix(sync): drop loadMissedMessages' cursor guards
diegolmello Jul 28, 2026
4c08b65
fix(sync): take the Last Open from every batch and page
diegolmello Jul 28, 2026
2ee0ef4
test(voip): integration coverage for accept gate ladder and socket probe
diegolmello Jul 28, 2026
00d163e
fix(sync): return early from loadMissedMessages without a subscription
diegolmello Jul 28, 2026
18d3a87
refactor(sync): name the Server Timestamp carrier and its snapshot
diegolmello Jul 28, 2026
9537a04
chore(sync): drop review-flagged comments
diegolmello Jul 28, 2026
935a286
fix(sync): handle CodeRabbit round-3 findings
diegolmello Jul 28, 2026
24fc7e1
chore: format code and fix lint issues
diegolmello Jul 28, 2026
10849b1
chore(voip): temporary reconnect-latency trace instrumentation, dev-only
diegolmello Jul 29, 2026
def7a43
fix(voip): classify closed socket as reopen regardless of ping age
diegolmello Jul 29, 2026
0879401
fix(sdk): lower hardcoded ddp ping interval to 10s
diegolmello Jul 29, 2026
3adab1d
fix(sdk): apply sdk patch via pnpm only
diegolmello Jul 29, 2026
0a7d944
fix(sdk): revert to patch-package, keep 10s ping interval
diegolmello Jul 29, 2026
0571fe4
fix(voip): guard AppState access in reconnect trace
diegolmello Jul 29, 2026
fde4947
test(voip): match integration ping interval to 10s sdk patch
diegolmello Jul 29, 2026
111d20b
fix(voip): always confirm socket health with a round trip
diegolmello Jul 30, 2026
0c8157c
remove reconnectMark
diegolmello Jul 30, 2026
6ce7bd9
refactor(connection): extract socket health module with unit suite
diegolmello Jul 30, 2026
352ccf0
test(connection): add socket health integration suite
diegolmello Jul 30, 2026
5a58112
refactor(connection): move foreground socket recovery onto socket hea…
diegolmello Jul 30, 2026
841614f
refactor(voip): gate call accept on socket health module
diegolmello Jul 30, 2026
e1d91ab
test(connection): type the socket health integration scaffolding
diegolmello Jul 30, 2026
7f40909
refactor(connection): consolidate onAbort into shared helper
diegolmello Jul 30, 2026
0260f74
refactor(connection): fold executeRecovery into shareRecovery
diegolmello Jul 30, 2026
f07798a
Revert "fix: re-subscribe room streams after re-authentication on rec…
diegolmello Jul 26, 2026
5efd229
Revert "fix: re-subscribe room streams on DDP reconnect (#7426)"
diegolmello Jul 26, 2026
fa3278a
Revert "fix: re-subscribe rooms stream after forced socket reopen (#7…
diegolmello Jul 26, 2026
635b153
Revert "fix: keep live socket on deeplink login (prevent orphan WebSo…
diegolmello Jul 26, 2026
187acf2
Revert "fix(voip,connect): probe Meteor Connect on foreground and loc…
diegolmello Jul 26, 2026
095b455
fix: rewire pendingHangups drain without awaitDdpLoggedIn
diegolmello Jul 26, 2026
b5d6064
fix: remove 7380 orphan-socket gate tests from deepLinking
diegolmello Jul 26, 2026
1b4c174
test: cover handleClickCallPush new-server path and pendingHangups lo…
diegolmello Jul 26, 2026
8b45556
refactor: extract isLoginReady helper in connect.ts
diegolmello Jul 26, 2026
83e878f
fix: restore orphan-socket guard in SDK patch
diegolmello Jul 27, 2026
13e1afa
test: restore updateMessage concurrency and subscribe coverage
diegolmello Jul 27, 2026
36fb8aa
refactor: address review nits in connect and MediaSessionInstance
diegolmello Jul 27, 2026
8af1086
fix(voip): await processSignal before native-accept replay
diegolmello Jul 28, 2026
5918b64
merge branch 'purring-seahorse' into dry-badger
diegolmello Jul 31, 2026
582126e
merge branch 'dry-badger' into missing-messages-notification
diegolmello Jul 31, 2026
a0af7a8
merge branch 'develop' into dry-badger
diegolmello Jul 31, 2026
c36d51e
merge branch 'dry-badger' into missing-messages-notification
diegolmello Jul 31, 2026
3317201
merge branch 'develop' into missing-messages-notification
diegolmello Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,23 @@ Independent boolean markers on a Message, orthogonal to its Status — a Message
| **Room History** | Older Messages of a Room fetched on demand from the server (distinct from **Server History**) | Message history |
| **Jump to Message** | Re-position the Room view onto a target Message that may be far from the Live Tail or not yet synced — fetches a surrounding Chunk | Scroll to message |

## Timestamp Trust Boundary

Not every `_updatedAt` is worth the same. A Message's `_updatedAt` read out of a **server response** is server truth, and is the only legitimate source for the sync cursor. The same field read off of a **WatermelonDB row** is device-tainted: offline sends, Temp and Error sends, push-inserted rows, and `normalizeMessage`'s `_updatedAt || new Date()` fallback all stamp the device clock.

Therefore the **Last Open** must be taken from the raw payload _before_ `normalizeMessage` / `buildMessage` runs — never from a database row, and never from `Date.now()`.

| Term | Definition | Aliases to avoid |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
| **Last Open** | The fetch cursor for a Subscription (`lastOpen` column): the newest server `_updatedAt` actually received for that Room | last open, last update |
| **Last Seen** | The Subscription's read receipt (`ls`): the newest Message the user has read, which anchors the Unread Separator | last read |
| **Server Timestamp** | An `_updatedAt` taken from a server response — the only value the server can meaningfully compare a cursor against | Timestamp (ambiguous) |
| **Device Timestamp** | An `_updatedAt` present on a local row but written by the device clock; unusable as a cursor because the server never saw it | Timestamp (ambiguous) |

A **Last Open** below a change's **Server Timestamp** only costs a re-fetch; one above it makes the server stay silent, and the change is never delivered. When in doubt, the lower cursor is the safe one.

A **Last Open** and a **Last Seen** are not interchangeable — conflating them (one column serving as both fetch cursor and unread anchor) is what produced permanently invisible Messages.

## Message Action & Position State

Two distinct kinds of transient per-Room state drive how the Room view renders Messages. Keep them apart.
Expand Down Expand Up @@ -243,6 +260,7 @@ A **Message Action** is the active mode on a Message in the Room view. The three
- **"Forward"** in omnichannel context means **Transfer** (reassigning a room to another agent/department). The codebase uses both `forwardRoom` and "transfer" — prefer **Transfer** as the domain term.
- **"History"** is overloaded: **Server History** is the recent-Servers reconnection list; **Room History** is older Messages fetched on demand. The action `roomHistoryRequest` and saga `ROOM.HISTORY_REQUEST` refer to **Room History**.
- **"Window"** is used metaphorically in the Subscriptions dialogue ("a Subscription is the user's window into it"); a **Message Window** is the concrete observed Message range in the Room view. Disambiguate when both could be meant.
- **"`lastOpen`"** names a database column, not a concept: it stores the **Last Open**, a server-clock fetch cursor. It has never meant "when the user last opened the room". The Unread Separator anchor is **Last Seen** (`ls`). Do not read `lastOpen` as a read receipt or write a device clock into it.
- **"Load more"** is directional: older Messages are an **Older Loader** (`MORE`/`PREVIOUS_CHUNK`), newer Messages are a **Newer Loader** (`NEXT_CHUNK`). Avoid bare "load more".
- **"System message" vs "Info message"** — **System Message** is the umbrella (any `t`-bearing server Message); **Info Message** is the narrower set of room-event System Messages. The typed events `e2e`, `discussion-created`, `jitsi_call_started`, and `videoconf` are System Messages but NOT Info Messages — each gets its own rendering branch.
- **"Thread reply"** is overloaded. The glossary's **Thread Message** is the data concept (any Message with `tmid`); the code's `isThreadReply` is a _rendering position_ — the first Thread Message in a run shown in the parent Room, which gets the "in reply to" header. Do not use "thread reply" for the data concept.
Expand Down
2 changes: 1 addition & 1 deletion app/containers/MessageActions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@

await db.write(async () => {
try {
await subRecord.update(sub => (sub.lastOpen = ts as Date)); // TODO: reevaluate IMessage
await subRecord.update(sub => (sub.ls = ts as Date));
} catch {
// do nothing
}
Expand Down Expand Up @@ -400,7 +400,7 @@
});
};

const getOptions = (message: TAnyMessageModel) => {

Check warning on line 403 in app/containers/MessageActions/index.tsx

View workflow job for this annotation

GitHub Actions / format

Arrow function has a complexity of 38. Maximum allowed is 31

Check warning on line 403 in app/containers/MessageActions/index.tsx

View workflow job for this annotation

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

Arrow function has a complexity of 38. Maximum allowed is 31
const options: TActionSheetOptionsItem[] = [];
const videoConfBlock = message.t === 'videoconf';

Expand Down
77 changes: 77 additions & 0 deletions app/lib/methods/loadMessagesForRoom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getMessageById } from '../database/services/Message';
import { getSubscriptionByRoomId } from '../database/services/Subscription';
import updateMessages from './updateMessages';
import { store } from '../store/auxStore';
import { updateLastOpen } from './updateLastOpen';

jest.mock('../services/sdk', () => ({
__esModule: true,
Expand All @@ -31,6 +32,10 @@ jest.mock('../store/auxStore', () => ({
}));

jest.mock('./updateMessages', () => jest.fn());
jest.mock('./updateLastOpen', () => ({
...jest.requireActual('./updateLastOpen'),
updateLastOpen: jest.fn()
}));

const mockedSdkGet = sdk.get as jest.MockedFunction<typeof sdk.get>;
const mockedGetMessageById = getMessageById as jest.MockedFunction<typeof getMessageById>;
Expand Down Expand Up @@ -232,4 +237,76 @@ describe('loadMessagesForRoom', () => {
expect(mockedDispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: ROOM.HISTORY_UI_LOADER_PUSH }));
expect(mockedDispatch).not.toHaveBeenCalledWith(expect.objectContaining({ type: ROOM.HISTORY_UI_LOADER_POP }));
});

describe('last open', () => {
const mockedUpdateLastOpen = updateLastOpen as jest.MockedFunction<typeof updateLastOpen>;

const buildStampedBatch = (prefix: string, hour: number, length: number) =>
Array.from(
{ length },
(_, index) =>
({
_id: `${prefix}-${index + 1}`,
rid: 'ROOM_ID',
ts: new Date(Date.UTC(2024, 0, 1, hour, 0, length - index)).toISOString(),
_updatedAt: new Date(Date.UTC(2024, 0, 1, hour, 0, length - index)).toISOString(),
t: 'uj'
} as any)
);

it('writes the Last Open from every fetched batch on the initial tail load', async () => {
const firstBatch = buildStampedBatch('first', 11, 50);
const secondBatch = buildStampedBatch('second', 10, 50);

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

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

expect(mockedUpdateLastOpen).toHaveBeenCalledTimes(1);
// Every batch's server stamps contribute to the cursor, not just the newest page.
expect(mockedUpdateLastOpen).toHaveBeenCalledWith(
'ROOM_ID',
expect.arrayContaining([
...firstBatch.map(message => ({ _updatedAt: message._updatedAt })),
...secondBatch.map(message => ({ _updatedAt: message._updatedAt }))
])
);
});

it('uses the highest _updatedAt even when it arrives in an older batch', async () => {
const firstBatch = buildStampedBatch('first', 11, 50);
const secondBatch = buildStampedBatch('second', 10, 50).map(message => ({
...message,
_updatedAt: new Date(Date.UTC(2024, 0, 1, 12, 0, 0)).toISOString()
}));

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

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

const received = mockedUpdateLastOpen.mock.calls[0][1];
const timestamps = received.map(m => new Date(m._updatedAt as string | Date).getTime()).filter(t => !Number.isNaN(t));
expect(new Date(Math.max(...timestamps))).toEqual(new Date(Date.UTC(2024, 0, 1, 12, 0, 0)));
});

it('does not write when loading an older page (latest)', async () => {
mockedSdkGet.mockResolvedValueOnce({ success: true, messages: buildStampedBatch('older', 9, 10) } as any);

await loadMessagesForRoom({ rid: 'ROOM_ID', t: 'c', latest: new Date(Date.UTC(2024, 0, 1, 10, 0, 0)) });

expect(mockedUpdateLastOpen).not.toHaveBeenCalled();
});

it('does not write when filling a gap (loaderItem)', async () => {
mockedSdkGet.mockResolvedValueOnce({ success: true, messages: buildStampedBatch('gap', 9, 10) } as any);

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

expect(mockedUpdateLastOpen).not.toHaveBeenCalled();
});
});
});
15 changes: 11 additions & 4 deletions app/lib/methods/loadMessagesForRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import sdk from '../services/sdk';
import { store } from '../store/auxStore';
import updateMessages from './updateMessages';
import { generateLoadMoreId } from './helpers/generateLoadMoreId';
import { snapshotServerTimestamps, type TServerTimestamps, updateLastOpen } from './updateLastOpen';

const COUNT = 50;
const MAX_BATCHES = 10;
Expand All @@ -32,15 +33,16 @@ async function load(args: {
t: RoomTypes;
loaderItem?: TMessageModel;
onUiLoaderPushed?: (loaderId: string) => void;
}): Promise<{ messages: IMessage[]; lastBatchWasFull: boolean }> {
}): Promise<{ messages: IMessage[]; serverTimestamps: TServerTimestamps; lastBatchWasFull: boolean }> {
const roomId = args.rid;
const hideSystemMessages = await resolveHideSystemMessages(roomId);
const apiType = roomTypeToApiType(args.t);
if (!apiType) {
return { messages: [], lastBatchWasFull: false };
return { messages: [], serverTimestamps: [], lastBatchWasFull: false };
}

const allMessages: IMessage[] = [];
const serverTimestamps: TServerTimestamps = [];
let visibleMainMessagesCount = 0;
let batchesFetched = 0;
let lastBatchWasFull = false;
Expand Down Expand Up @@ -73,6 +75,7 @@ async function load(args: {
}

const batch = data.messages as IMessage[];
serverTimestamps.push(...snapshotServerTimestamps(batch));
allMessages.push(...batch);
lastBatchWasFull = batch.length === COUNT;

Expand Down Expand Up @@ -108,7 +111,7 @@ async function load(args: {

const startTimestamp = args.latest ? new Date(args.latest).toISOString() : undefined;
await fetchBatch(startTimestamp);
return { messages: allMessages, lastBatchWasFull };
return { messages: allMessages, serverTimestamps, lastBatchWasFull };
}

export async function loadMessagesForRoom(args: {
Expand All @@ -119,7 +122,7 @@ export async function loadMessagesForRoom(args: {
}): Promise<void> {
let uiLoaderId: string | null = null;
try {
const { messages, lastBatchWasFull } = await load({
const { messages, serverTimestamps, lastBatchWasFull } = await load({
...args,
onUiLoaderPushed: id => {
uiLoaderId = id;
Expand All @@ -139,6 +142,10 @@ export async function loadMessagesForRoom(args: {
}
await updateMessages({ rid: args.rid, update: messages, loaderItem: args.loaderItem });
}

if (!args.latest && !args.loaderItem) {
await updateLastOpen(args.rid, serverTimestamps);
}
} catch (e) {
log(e);
throw e;
Expand Down
188 changes: 188 additions & 0 deletions app/lib/methods/loadMissedMessages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import { loadMissedMessages } from './loadMissedMessages';
import sdk from '../services/sdk';
import updateMessages from './updateMessages';
import { getSubscriptionByRoomId } from '../database/services/Subscription';
import { updateLastOpen } from './updateLastOpen';
import { store } from '../store/auxStore';

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

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

jest.mock('../store/auxStore', () => ({
store: {
getState: jest.fn(() => ({ server: { version: '7.4.0' } })),
dispatch: jest.fn()
}
}));

jest.mock('./updateMessages', () => jest.fn());
jest.mock('./updateLastOpen', () => ({
...jest.requireActual('./updateLastOpen'),
updateLastOpen: jest.fn()
}));
jest.mock('./helpers/log', () => ({ __esModule: true, default: jest.fn() }));

const mockedSdkGet = sdk.get as jest.MockedFunction<typeof sdk.get>;
const mockedUpdateMessages = updateMessages as jest.MockedFunction<typeof updateMessages>;
const mockedGetSubscriptionByRoomId = getSubscriptionByRoomId as jest.MockedFunction<typeof getSubscriptionByRoomId>;
const mockedUpdateLastOpen = updateLastOpen as jest.MockedFunction<typeof updateLastOpen>;

const RID = 'ROOM_ID';

describe('loadMissedMessages', () => {
beforeEach(() => {
jest.clearAllMocks();
mockedUpdateMessages.mockResolvedValue(0);
mockedGetSubscriptionByRoomId.mockResolvedValue(null as never);
(store.getState as jest.Mock).mockReturnValue({ server: { version: '7.4.0' } });
});

it('routes a deleted-only recursion payload to remove, not update', async () => {
mockedGetSubscriptionByRoomId.mockResolvedValue({ lastOpen: null, t: 'p' } as never);
const deletedMessage = { _id: 'deleted-1', rid: RID, _updatedAt: new Date(Date.UTC(2024, 0, 1, 12, 0, 0)) };
mockedSdkGet.mockResolvedValue({
result: { updated: [], deleted: [deletedMessage], cursor: { next: null } }
} as never);

await loadMissedMessages({ rid: RID, deletedNext: 1704110400000 });

expect(mockedSdkGet).toHaveBeenCalledTimes(1);
expect(mockedSdkGet).toHaveBeenCalledWith('chat.syncMessages', expect.objectContaining({ roomId: RID, type: 'DELETED' }));
expect(mockedUpdateMessages).toHaveBeenCalledWith(
expect.objectContaining({
rid: RID,
update: [],
remove: [deletedMessage]
})
);
});

it('fetches nothing when the subscription has no cursor', async () => {
mockedGetSubscriptionByRoomId.mockResolvedValue({ lastOpen: null, t: 'p' } as never);

await loadMissedMessages({ rid: RID });

expect(mockedSdkGet).not.toHaveBeenCalled();
});

describe('last open', () => {
const CURSOR = new Date(Date.UTC(2024, 0, 1, 11, 0, 0));
const flush = () => new Promise(resolve => setImmediate(resolve));

const message = (id: string, updatedAt: string) => ({ _id: id, rid: RID, _updatedAt: updatedAt });

beforeEach(() => {
mockedGetSubscriptionByRoomId.mockResolvedValue({ lastOpen: CURSOR, t: 'c' } as never);
});

it('writes the Last Open from the updated payload once the cursor has drained', async () => {
mockedSdkGet.mockResolvedValue({
result: { updated: [message('a', '2024-01-01T11:30:00.000Z')], deleted: [], cursor: { next: null } }
} as never);

await loadMissedMessages({ rid: RID });

expect(mockedUpdateLastOpen).toHaveBeenCalledTimes(1);
expect(mockedUpdateLastOpen).toHaveBeenCalledWith(RID, [{ _updatedAt: '2024-01-01T11:30:00.000Z' }]);
});

it('does not write mid-pagination, only after the final page of a paginated run', async () => {
const PAGE_2 = Date.UTC(2024, 0, 1, 11, 30, 0);
mockedSdkGet.mockImplementation(((_endpoint: string, params: { type?: string; next?: number }) => {
if (params.type === 'DELETED') {
return Promise.resolve({ result: { deleted: [], cursor: { next: null } } });
}
if (params.next === PAGE_2) {
return Promise.resolve({
result: { updated: [message('b', '2024-01-01T11:45:00.000Z')], deleted: [], cursor: { next: null } }
});
}
return Promise.resolve({
result: { updated: [message('a', '2024-01-01T11:30:00.000Z')], deleted: [], cursor: { next: PAGE_2 } }
});
}) as never);

await loadMissedMessages({ rid: RID });

// First page still has a next cursor, so nothing may be persisted yet.
expect(mockedUpdateLastOpen).not.toHaveBeenCalled();

await flush();

expect(mockedUpdateLastOpen).toHaveBeenCalledTimes(1);
// Every page walked contributes its stamps, not only the last one.
expect(mockedUpdateLastOpen).toHaveBeenCalledWith(RID, [
{ _updatedAt: '2024-01-01T11:30:00.000Z' },
{ _updatedAt: '2024-01-01T11:45:00.000Z' }
]);
});

it('keeps the highest _updatedAt when it arrives on an earlier page', async () => {
const PAGE_2 = Date.UTC(2024, 0, 1, 11, 30, 0);
mockedSdkGet.mockImplementation(((_endpoint: string, params: { type?: string; next?: number }) => {
if (params.type === 'DELETED') {
return Promise.resolve({ result: { deleted: [], cursor: { next: null } } });
}
if (params.next === PAGE_2) {
return Promise.resolve({
result: { updated: [message('b', '2024-01-01T11:10:00.000Z')], deleted: [], cursor: { next: null } }
});
}
return Promise.resolve({
result: { updated: [message('a', '2024-01-01T11:59:00.000Z')], deleted: [], cursor: { next: PAGE_2 } }
});
}) as never);

await loadMissedMessages({ rid: RID });
await flush();

const received = mockedUpdateLastOpen.mock.calls[0][1];
const timestamps = received.map(m => new Date(m._updatedAt as string | Date).getTime()).filter(t => !Number.isNaN(t));
expect(new Date(Math.max(...timestamps))).toEqual(new Date('2024-01-01T11:59:00.000Z'));
});

it('does not write again on a deleted-only continuation page', async () => {
mockedSdkGet.mockResolvedValue({
result: { updated: [], deleted: [message('gone', '2024-01-01T11:30:00.000Z')], cursor: { next: null } }
} as never);

await loadMissedMessages({ rid: RID, deletedNext: Date.UTC(2024, 0, 1, 11, 30, 0) });

expect(mockedUpdateLastOpen).not.toHaveBeenCalled();
});

it('writes nothing derived from deleted rows when the payload is deleted-only', async () => {
mockedSdkGet.mockResolvedValue({
result: { updated: [], deleted: [message('gone', '2024-01-01T11:30:00.000Z')], cursor: { next: null } }
} as never);

await loadMissedMessages({ rid: RID });

expect(mockedUpdateLastOpen).toHaveBeenCalledWith(RID, []);
});

it('writes once on the legacy unpaginated server branch', async () => {
(store.getState as jest.Mock).mockReturnValue({ server: { version: '7.0.0' } });
mockedSdkGet.mockResolvedValue({
result: { updated: [message('a', '2024-01-01T11:30:00.000Z')], deleted: [] }
} as never);

await loadMissedMessages({ rid: RID });

expect(mockedSdkGet).toHaveBeenCalledWith(
'chat.syncMessages',
expect.objectContaining({ lastUpdate: CURSOR.toISOString() })
);
expect(mockedUpdateLastOpen).toHaveBeenCalledTimes(1);
expect(mockedUpdateLastOpen).toHaveBeenCalledWith(RID, [{ _updatedAt: '2024-01-01T11:30:00.000Z' }]);
});
});
});
Loading
Loading