fix(messaging): a sent message no longer shows "Seen" instantly

Two independent defects; either alone caused it, in DMs, groups and channels.

1. useMessages reported a read of the newest NON-PENDING message regardless of
   author. Sending therefore made the client immediately mark its own message
   read, the server echoed a receipt for it, and the sender's bubble showed
   "Seen" before anyone had opened the thread. You do not read your own message:
   lastReadableId now skips your own.

2. The guard meant to catch exactly this — `e.actorId !== currentActorId` — could
   never fire: the receipt carries the reader's IIOS actor UUID while clients hold
   a userId, so the comparison was always true and every receipt, including your
   own, counted as the other side. The gateway now also emits userId on READ and
   DELIVERED receipts (matching what `annotation` already did), and ReceiptEvent
   carries it as optional so older servers still typecheck.

MockAdapter.markRead was a no-op, so it could not exercise any of this; it now
echoes a receipt like a real server. Regression test verified to fail without
the fix. SDK 0.1.14; suite 17 files / 101 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 17:46:42 +05:30
parent eb0ace8ad7
commit 272c6acd31
6 changed files with 59 additions and 8 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@insignia/iios-messaging-ui",
"version": "0.1.13",
"version": "0.1.14",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
@@ -270,8 +270,9 @@ export class MockAdapter implements MessagingAdapter {
// No-op: nobody is typing back in a mock.
}
async markRead(): Promise<void> {
// No-op: the mock has no second party to report a read.
/** Echo a receipt the way a real server does, so the "ignore my own read" path is exercised. */
async markRead(threadId: string, messageId: string): Promise<void> {
this.emit(threadId, { kind: 'receipt', messageId, actorId: ME });
}
isConnected(): boolean {
@@ -158,3 +158,39 @@ describe('useMessages', () => {
}
});
});
describe('read receipts', () => {
// REGRESSION: the hook reported a read of the newest message regardless of author, so sending a
// message made the server echo a receipt for it and the sender's own bubble showed "Seen"
// immediately — in DMs, groups and channels alike, before anyone had opened it.
it('never reports a read of my own message', async () => {
const adapter = new MockAdapter();
const markRead = vi.spyOn(adapter, 'markRead');
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
markRead.mockClear();
await act(async () => {
await result.current.send('a message from me');
});
// The newest message is now mine — reading it would be reading myself.
const mine = result.current.messages.filter((m) => m.mine).map((m) => m.id);
for (const call of markRead.mock.calls) expect(mine).not.toContain(call[1]);
});
it('does not mark my message seen just because the receipt came back', async () => {
const adapter = new MockAdapter();
const { result } = renderHook(() => useMessages('th_mock_1'), { wrapper: wrap(adapter) });
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.send('hello there');
});
const mine = result.current.messages.find((m) => m.mine && m.text === 'hello there');
expect(mine).toBeDefined();
expect(result.current.seenIds.has(mine!.id)).toBe(false);
});
});
@@ -160,13 +160,19 @@ export function useMessages(threadId: string | null): MessagesState {
if (threadId) adapter.sendTyping(threadId);
}, [adapter, threadId]);
// The newest acknowledged (non-pending) message id — what we report as read.
// The newest acknowledged (non-pending) message id from SOMEONE ELSE — what we report as read.
//
// Skipping my own messages is load-bearing, not tidiness: reporting a read of the message I just
// sent makes the server broadcast a receipt for it, and the sender's own client then paints it
// "Seen" the instant it is delivered, before anyone has looked at it.
const lastReadableId = useMemo(() => {
for (let i = raw.length - 1; i >= 0; i--) {
if (!raw[i]!.pending) return raw[i]!.id;
const m = raw[i]!;
if (m.pending || isOwnMessage(m, currentActorId)) continue;
return m.id;
}
return null;
}, [raw]);
}, [raw, currentActorId]);
// Report my read of the newest message (drives the other side's "seen" tick).
// Keyed on the id, not the whole array, so reaction/optimistic churn doesn't re-fire it.