diff --git a/packages/iios-kernel-client/src/types.ts b/packages/iios-kernel-client/src/types.ts index d1b74dd..e8f8a01 100644 --- a/packages/iios-kernel-client/src/types.ts +++ b/packages/iios-kernel-client/src/types.ts @@ -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'; } diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index c5a77d0..5ff7d63 100644 --- a/packages/iios-messaging-ui/package.json +++ b/packages/iios-messaging-ui/package.json @@ -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", diff --git a/packages/iios-messaging-ui/src/adapters/mock.ts b/packages/iios-messaging-ui/src/adapters/mock.ts index 5514657..b442234 100644 --- a/packages/iios-messaging-ui/src/adapters/mock.ts +++ b/packages/iios-messaging-ui/src/adapters/mock.ts @@ -270,8 +270,9 @@ export class MockAdapter implements MessagingAdapter { // No-op: nobody is typing back in a mock. } - async markRead(): Promise { - // 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 { + this.emit(threadId, { kind: 'receipt', messageId, actorId: ME }); } isConnected(): boolean { diff --git a/packages/iios-messaging-ui/src/hooks/use-messages.test.tsx b/packages/iios-messaging-ui/src/hooks/use-messages.test.tsx index f6d0771..30db811 100644 --- a/packages/iios-messaging-ui/src/hooks/use-messages.test.tsx +++ b/packages/iios-messaging-ui/src/hooks/use-messages.test.tsx @@ -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); + }); +}); diff --git a/packages/iios-messaging-ui/src/hooks/use-messages.ts b/packages/iios-messaging-ui/src/hooks/use-messages.ts index ccb49a6..cedb6ce 100644 --- a/packages/iios-messaging-ui/src/hooks/use-messages.ts +++ b/packages/iios-messaging-ui/src/hooks/use-messages.ts @@ -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. diff --git a/packages/iios-service/src/messaging/message.gateway.ts b/packages/iios-service/src/messaging/message.gateway.ts index 40bbe89..7637abd 100644 --- a/packages/iios-service/src/messaging/message.gateway.ts +++ b/packages/iios-service/src/messaging/message.gateway.ts @@ -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 }; }