Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export class Thread extends WithSubscriptions {
this.addUnsubscribeFunction(this.subscribeMarkThreadStale());
this.addUnsubscribeFunction(this.subscribeNewReplies());
this.addUnsubscribeFunction(this.subscribeRepliesRead());
this.addUnsubscribeFunction(this.subscribeRepliesMarkUnread());
this.addUnsubscribeFunction(this.subscribeMessageDeleted());
this.addUnsubscribeFunction(this.subscribeMessageUpdated());
};
Expand Down Expand Up @@ -407,6 +408,35 @@ export class Thread extends WithSubscriptions {
}));
}).unsubscribe;

private subscribeRepliesMarkUnread = () =>
this.client.on('notification.mark_unread', (event) => {
// Filter: must have required fields and match this thread
if (!event.user || !event.last_read_at || !event.thread) return;
if (event.thread.parent_message_id !== this.id) return;

// Filter: only process own user's events (can't mark unread for others)
const ownMessage = event.user.id === this.client.user?.id;
if (!ownMessage) return;

const userId = event.user.id;
const user = event.user;
const lastReadAt = event.last_read_at;
const unreadCount = event.unread_messages ?? 0;

this.state.next((current) => ({
...current,
read: {
...current.read,
[userId]: {
lastReadAt: new Date(lastReadAt),
user,
lastReadMessageId: event.last_read_message_id,
unreadMessageCount: unreadCount,
},
},
}));
}).unsubscribe;

private subscribeMessageDeleted = () =>
this.client.on('message.deleted', (event) => {
if (!event.message) return;
Expand Down
112 changes: 112 additions & 0 deletions test/unit/threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,118 @@ describe('Threads 2.0', () => {
});
});

describe('Event: notification.mark_unread', () => {
it('does not update read state with events from other threads', () => {
const thread = createTestThread({
read: [
{
last_read: new Date().toISOString(),
user: { id: TEST_USER_ID },
unread_messages: 0,
},
],
});
thread.registerSubscriptions();

const stateBefore = thread.state.getLatestValue();
expect(stateBefore.read[TEST_USER_ID]?.unreadMessageCount).to.equal(0);

client.dispatchEvent({
type: 'notification.mark_unread',
user: { id: TEST_USER_ID },
unread_messages: 5,
last_read_at: new Date().toISOString(),
thread: generateThreadResponse(
channelResponse,
generateMsg(), // Different parent message ID
) as ThreadResponse,
});

const stateAfter = thread.state.getLatestValue();
expect(stateAfter.read[TEST_USER_ID]?.unreadMessageCount).to.equal(0);

thread.unregisterSubscriptions();
});

it('does not update read state when event is for a different user', () => {
const thread = createTestThread({
read: [
{
last_read: new Date().toISOString(),
user: { id: TEST_USER_ID },
unread_messages: 0,
last_read_message_id: 'msg-1',
},
],
});
thread.registerSubscriptions();

const stateBefore = thread.state.getLatestValue();
expect(stateBefore.read[TEST_USER_ID]?.unreadMessageCount).to.equal(0);

// Dispatch event for a DIFFERENT user (bob), not the current user
client.dispatchEvent({
type: 'notification.mark_unread',
user: { id: 'bob' }, // bob is NOT the current user (TEST_USER_ID)
unread_messages: 5,
last_read_at: new Date().toISOString(),
last_read_message_id: 'msg-4',
thread: generateThreadResponse(
channelResponse,
generateMsg({ id: parentMessageResponse.id }),
) as ThreadResponse,
});

const stateAfter = thread.state.getLatestValue();
// TEST_USER_ID's state should remain unchanged because event was for 'bob'
expect(stateAfter.read[TEST_USER_ID]?.unreadMessageCount).to.equal(0);
expect(stateAfter.read[TEST_USER_ID]?.lastReadMessageId).to.equal('msg-1');

thread.unregisterSubscriptions();
});

it('correctly updates unread count for current user', () => {
const lastReadAt = new Date();
const thread = createTestThread({
read: [
{
last_read: lastReadAt.toISOString(),
last_read_message_id: 'msg-1',
unread_messages: 0,
user: { id: TEST_USER_ID },
},
],
});
thread.registerSubscriptions();

const stateBefore = thread.state.getLatestValue();
expect(stateBefore.read[TEST_USER_ID]?.unreadMessageCount).to.equal(0);

const newLastReadAt = new Date(lastReadAt.getTime() - 60000); // 1 minute earlier

client.dispatchEvent({
type: 'notification.mark_unread',
user: { id: TEST_USER_ID },
unread_messages: 5,
last_read_at: newLastReadAt.toISOString(),
last_read_message_id: 'msg-4',
thread: generateThreadResponse(
channelResponse,
generateMsg({ id: parentMessageResponse.id }),
) as ThreadResponse,
});

const stateAfter = thread.state.getLatestValue();
expect(stateAfter.read[TEST_USER_ID]?.unreadMessageCount).to.equal(5);
expect(stateAfter.read[TEST_USER_ID]?.lastReadAt.toISOString()).to.equal(
newLastReadAt.toISOString(),
);
expect(stateAfter.read[TEST_USER_ID]?.lastReadMessageId).to.equal('msg-4');

thread.unregisterSubscriptions();
});
});

describe('Event: message.new', () => {
it('ignores a reply if it does not belong to the associated thread', () => {
const thread = createTestThread();
Expand Down