Skip to content
Open
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
29 changes: 29 additions & 0 deletions src/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export type ThreadUserReadState = {
unreadMessageCount: number;
user: UserResponse;
lastReadMessageId?: string;
firstUnreadMessageId?: string;
};

export type ThreadReadState = Record<string, ThreadUserReadState | undefined>;
Expand Down Expand Up @@ -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());
};
Expand Down Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/thread_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1695,6 +1695,7 @@ export type Event = CustomEventData & {
watcher_count?: number;
channel_last_message_at?: string;
app?: Record<string, unknown>; // TODO: further specify type
thread_id?: string;
};

export type UserCustomEvent = CustomEventData & {
Expand Down
92 changes: 92 additions & 0 deletions test/unit/threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down