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:
@@ -37,7 +37,11 @@ export interface OpenThreadResult {
|
||||
|
||||
export interface ReceiptEvent {
|
||||
interactionId: string;
|
||||
/** The reader's IIOS actor UUID — an internal id, NOT comparable to a caller's userId. */
|
||||
actorId: string;
|
||||
/** The reader's userId — the same id space clients hold, so they can ignore their own receipt.
|
||||
* Optional: absent from servers older than the change that added it. */
|
||||
userId?: string;
|
||||
kind: 'READ' | 'DELIVERED';
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -159,7 +159,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
||||
async read(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
||||
const { principal } = client.data as SocketState;
|
||||
const r = await this.messages.markRead(body.threadId, principal, body.interactionId);
|
||||
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, kind: 'READ' });
|
||||
// userId as well as actorId: actorId is an IIOS actor UUID, but clients hold the caller's
|
||||
// USERID, so without this they cannot tell their own receipt from someone else's.
|
||||
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, userId: principal.userId, kind: 'READ' });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -167,7 +169,9 @@ export class MessageGateway implements OnGatewayInit, OnGatewayConnection, OnGat
|
||||
async delivered(@ConnectedSocket() client: Socket, @MessageBody() body: { threadId: string; interactionId: string }) {
|
||||
const { principal } = client.data as SocketState;
|
||||
const r = await this.messages.markDelivered(body.threadId, principal, body.interactionId);
|
||||
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, kind: 'DELIVERED' });
|
||||
// userId as well as actorId: actorId is an IIOS actor UUID, but clients hold the caller's
|
||||
// USERID, so without this they cannot tell their own receipt from someone else's.
|
||||
this.server.to(body.threadId).emit('receipt', { interactionId: r.interactionId, actorId: r.actorId, userId: principal.userId, kind: 'DELIVERED' });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user