From d7fe75b9833aaa552c584bad72b9204478675935 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 25 Jun 2026 16:10:52 +0200 Subject: [PATCH] Initial commit Co-authored-by: TabishRiazBajwa --- src/thread.ts | 29 ++++++++++++ src/thread_manager.ts | 1 + src/types.ts | 1 + test/unit/threads.test.ts | 92 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+) diff --git a/src/thread.ts b/src/thread.ts index bf6f778121..cbfc4947a7 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -63,6 +63,7 @@ export type ThreadUserReadState = { unreadMessageCount: number; user: UserResponse; lastReadMessageId?: string; + firstUnreadMessageId?: string; }; export type ThreadReadState = Record; @@ -272,6 +273,7 @@ export class Thread extends WithSubscriptions { this.addUnsubscribeFunction(this.subscribeMarkThreadStale()); this.addUnsubscribeFunction(this.subscribeNewReplies()); this.addUnsubscribeFunction(this.subscribeRepliesRead()); + this.addUnsubscribeFunction(this.subscribeRepliesUnread()); this.addUnsubscribeFunction(this.subscribeMessageDeleted()); this.addUnsubscribeFunction(this.subscribeMessageUpdated()); }; @@ -330,6 +332,33 @@ export class Thread extends WithSubscriptions { this.state.partialNext({ isStateStale: true }); }).unsubscribe; + private subscribeRepliesUnread = () => + this.client.on('notification.mark_unread', (event) => { + if (!event.user || !event.created_at || !event.thread_id) return; + if (event.thread_id !== this.id) return; + + const userId = event.user.id; + const createdAt = event.created_at; + const user = event.user; + + this.state.next((current) => ({ + ...current, + read: { + ...current.read, + [userId]: { + ...current.read[userId], + lastReadAt: + typeof event.last_read_at !== 'undefined' + ? new Date(event.last_read_at) + : new Date(createdAt), + user, + firstUnreadMessageId: event.first_unread_message_id, + unreadMessageCount: event.unread_messages ?? 0, + }, + }, + })); + }).unsubscribe; + private subscribeNewReplies = () => this.client.on('message.new', (event) => { if (!this.client.userID || event.message?.parent_id !== this.id) { diff --git a/src/thread_manager.ts b/src/thread_manager.ts index ed7b28dd19..702d6720d5 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -117,6 +117,7 @@ export class ThreadManager extends WithSubscriptions { const unsubscribeFunctions = [ 'health.check', 'notification.mark_read', + 'notification.mark_unread', 'notification.thread_message_new', 'notification.channel_deleted', ].map( diff --git a/src/types.ts b/src/types.ts index fa4af64054..ed306ddc14 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1695,6 +1695,7 @@ export type Event = CustomEventData & { watcher_count?: number; channel_last_message_at?: string; app?: Record; // TODO: further specify type + thread_id?: string; }; export type UserCustomEvent = CustomEventData & { diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 121cfc40f5..c38558f3c4 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -814,6 +814,97 @@ describe('Threads 2.0', () => { }); }); + describe('Event: notification.mark_unread', () => { + it('ignores event from a different thread', () => { + const thread = createTestThread({ + read: [ + { + last_read: new Date().toISOString(), + user: { id: TEST_USER_ID }, + unread_messages: 0, + }, + ], + }); + thread.registerSubscriptions(); + const stateBefore = thread.state.getLatestValue(); + + client.dispatchEvent({ + type: 'notification.mark_unread', + user: { id: TEST_USER_ID }, + created_at: new Date().toISOString(), + thread_id: uuidv4(), + unread_messages: 7, + }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter).to.equal(stateBefore); + }); + + it('updates read state for the user when marked unread', () => { + const lastReadMessageId = uuidv4(); + const thread = createTestThread({ + read: [ + { + last_read: new Date().toISOString(), + user: { id: TEST_USER_ID }, + unread_messages: 0, + last_read_message_id: lastReadMessageId, + }, + ], + }); + thread.registerSubscriptions(); + + const lastReadAt = new Date(); + const createdAt = new Date(Date.now() - 5000); + const firstUnreadMessageId = uuidv4(); + + client.dispatchEvent({ + type: 'notification.mark_unread', + user: { id: TEST_USER_ID }, + created_at: createdAt.toISOString(), + last_read_at: lastReadAt.toISOString(), + thread_id: thread.id, + first_unread_message_id: firstUnreadMessageId, + unread_messages: 3, + }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.read[TEST_USER_ID]?.unreadMessageCount).to.equal(3); + expect(stateAfter.read[TEST_USER_ID]?.firstUnreadMessageId).to.equal( + firstUnreadMessageId, + ); + expect(stateAfter.read[TEST_USER_ID]?.lastReadAt.toISOString()).to.equal( + lastReadAt.toISOString(), + ); + expect(stateAfter.read[TEST_USER_ID]?.lastReadMessageId).to.equal( + lastReadMessageId, + ); + }); + + it('creates a read entry for a user that did not have one previously', () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + + const otherUserId = 'bob'; + const createdAt = new Date(); + + client.dispatchEvent({ + type: 'notification.mark_unread', + user: { id: otherUserId }, + created_at: createdAt.toISOString(), + thread_id: thread.id, + unread_messages: 4, + }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.read[otherUserId]?.unreadMessageCount).to.equal(4); + expect(stateAfter.read[otherUserId]?.user.id).to.equal(otherUserId); + expect(stateAfter.read[otherUserId]?.lastReadAt.toISOString()).to.equal( + createdAt.toISOString(), + ); + }); + }); + describe('Event: message.new', () => { it('ignores a reply if it does not belong to the associated thread', () => { const thread = createTestThread(); @@ -1158,6 +1249,7 @@ describe('Threads 2.0', () => { [ ['health.check', 2], ['notification.mark_read', 1], + ['notification.mark_unread', 5], ['notification.thread_message_new', 8], ['notification.channel_deleted', 11], ] as const