Feat/messaging ui foundation #13

Merged
maaz519 merged 6 commits from feat/messaging-ui-foundation into dev 2026-07-25 12:28:38 +00:00
6 changed files with 59 additions and 8 deletions
Showing only changes of commit 272c6acd31 - Show all commits
+4
View File
@@ -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 -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.
@@ -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 };
}