Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(Thread): parentMessage delete & initial read object #1366

Merged
merged 1 commit into from
Sep 17, 2024
Merged
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
52 changes: 32 additions & 20 deletions src/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { StateStore } from './store';
import type {
AscDesc,
DefaultGenerics,
EventTypes,
ExtendableGenerics,
FormatMessageResponse,
MessagePaginationOptions,
Expand Down Expand Up @@ -79,6 +80,12 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
});
channel._hydrateMembers(threadData.channel.members ?? []);

// For when read object is undefined and due to that unreadMessageCount for
// the current user isn't being incremented on message.new
const placeholderReadResponse: ReadResponse[] = client.userID
? [{ user: { id: client.userID }, unread_messages: 0, last_read: new Date().toISOString() }]
: [];

this.state = new StateStore<ThreadState<SCG>>({
active: false,
channel,
Expand All @@ -89,7 +96,9 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
pagination: repliesPaginationFromInitialThread(threadData),
parentMessage: formatMessage(threadData.parent_message),
participants: threadData.thread_participants,
read: formatReadState(threadData.read ?? []),
read: formatReadState(
!threadData.read || threadData.read.length === 0 ? placeholderReadResponse : threadData.read,
),
replies: threadData.latest_replies.map(formatMessage),
replyCount: threadData.reply_count ?? 0,
updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null,
Expand Down Expand Up @@ -182,7 +191,7 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
this.unsubscribeFunctions.add(this.subscribeMarkThreadStale());
this.unsubscribeFunctions.add(this.subscribeNewReplies());
this.unsubscribeFunctions.add(this.subscribeRepliesRead());
this.unsubscribeFunctions.add(this.subscribeReplyDeleted());
this.unsubscribeFunctions.add(this.subscribeMessageDeleted());
this.unsubscribeFunctions.add(this.subscribeMessageUpdated());
};

Expand Down Expand Up @@ -294,20 +303,30 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
}));
}).unsubscribe;

private subscribeReplyDeleted = () =>
private subscribeMessageDeleted = () =>
this.client.on('message.deleted', (event) => {
if (event.message?.parent_id !== this.id) return;
if (!event.message) return;

// Deleted message is a reply of this thread
if (event.message.parent_id === this.id) {
if (event.hard_delete) {
this.deleteReplyLocally({ message: event.message });
} else {
// Handle soft delete (updates deleted_at timestamp)
this.upsertReplyLocally({ message: event.message });
}
}

if (event.hard_delete) {
this.deleteReplyLocally({ message: event.message });
} else {
// Handle soft delete (updates deleted_at timestamp)
this.upsertReplyLocally({ message: event.message });
// Deleted message is parent message of this thread
if (event.message.id === this.id) {
this.updateParentMessageLocally({ message: event.message });
}
}).unsubscribe;

private subscribeMessageUpdated = () => {
const unsubscribeFunctions = ['message.updated', 'reaction.new', 'reaction.deleted'].map(
const eventTypes: EventTypes[] = ['message.updated', 'reaction.new', 'reaction.deleted', 'reaction.updated'];

const unsubscribeFunctions = eventTypes.map(
(eventType) =>
this.client.on(eventType, (event) => {
if (event.message) {
Expand Down Expand Up @@ -375,27 +394,20 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
}));
};

public updateParentMessageLocally = (message: MessageResponse<SCG>) => {
public updateParentMessageLocally = ({ message }: { message: MessageResponse<SCG> }) => {
if (message.id !== this.id) {
throw new Error('Message does not belong to this thread');
}

this.state.next((current) => {
const formattedMessage = formatMessage(message);

const newData: typeof current = {
return {
...current,
deletedAt: formattedMessage.deleted_at,
parentMessage: formattedMessage,
replyCount: message.reply_count ?? current.replyCount,
};

// update channel on channelData change (unlikely but handled anyway)
if (message.channel) {
newData['channel'] = this.client.channel(message.channel.type, message.channel.id, message.channel);
}

return newData;
});
};

Expand All @@ -405,7 +417,7 @@ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
}

if (!message.parent_id && message.id === this.id) {
this.updateParentMessageLocally(message);
this.updateParentMessageLocally({ message });
}
};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { v4 as uuidv4 } from 'uuid';
import { generateUser } from './generateUser';

export const generateThread = (channel, parent, opts = {}) => {
export const generateThreadResponse = (channel, parent, opts = {}) => {
return {
parent_message_id: parent.id,
parent_message: parent,
Expand Down
49 changes: 41 additions & 8 deletions test/unit/threads.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { v4 as uuidv4 } from 'uuid';

import { generateChannel } from './test-utils/generateChannel';
import { generateMsg } from './test-utils/generateMessage';
import { generateThread } from './test-utils/generateThread';
import { generateThreadResponse } from './test-utils/generateThreadResponse';

import sinon from 'sinon';
import {
Expand Down Expand Up @@ -35,7 +35,7 @@ describe('Threads 2.0', () => {
} = {}) {
return new Thread({
client,
threadData: generateThread(
threadData: generateThreadResponse(
{ ...channelResponse, ...channelOverrides },
{ ...parentMessageResponse, ...parentMessageOverrides },
overrides,
Expand All @@ -54,7 +54,12 @@ describe('Threads 2.0', () => {

describe('Thread', () => {
it('initializes properly', () => {
const thread = new Thread({ client, threadData: generateThread(channelResponse, parentMessageResponse) });
const threadResponse = generateThreadResponse(channelResponse, parentMessageResponse);
const thread = new Thread({ client, threadData: threadResponse });
const state = thread.state.getLatestValue();

expect(threadResponse.read).to.have.lengthOf(0);
expect(state.read).to.have.keys([TEST_USER_ID]);

expect(thread.id).to.equal(parentMessageResponse.id);
expect(thread.channel.data?.name).to.equal(channelResponse.name);
Expand Down Expand Up @@ -134,7 +139,7 @@ describe('Threads 2.0', () => {
it('prevents updating a parent message if the ids do not match', () => {
const thread = createTestThread();
const message = generateMsg() as MessageResponse;
expect(() => thread.updateParentMessageLocally(message)).to.throw();
expect(() => thread.updateParentMessageLocally({ message })).to.throw();
});

it('updates parent message and related top-level properties', () => {
Expand All @@ -152,7 +157,7 @@ describe('Threads 2.0', () => {
deleted_at: new Date().toISOString(),
}) as MessageResponse;

thread.updateParentMessageLocally(updatedMessage);
thread.updateParentMessageLocally({ message: updatedMessage });

const stateAfter = thread.state.getLatestValue();
expect(stateAfter.deletedAt).to.be.not.null;
Expand Down Expand Up @@ -603,7 +608,7 @@ describe('Threads 2.0', () => {
client.dispatchEvent({
type: 'message.read',
user: { id: 'bob' },
thread: generateThread(channelResponse, generateMsg()) as ThreadResponse,
thread: generateThreadResponse(channelResponse, generateMsg()) as ThreadResponse,
});

const stateAfter = thread.state.getLatestValue();
Expand Down Expand Up @@ -631,7 +636,10 @@ describe('Threads 2.0', () => {
client.dispatchEvent({
type: 'message.read',
user: { id: 'bob' },
thread: generateThread(channelResponse, generateMsg({ id: parentMessageResponse.id })) as ThreadResponse,
thread: generateThreadResponse(
channelResponse,
generateMsg({ id: parentMessageResponse.id }),
) as ThreadResponse,
created_at: createdAt.toISOString(),
});

Expand Down Expand Up @@ -858,10 +866,35 @@ describe('Threads 2.0', () => {

thread.unregisterSubscriptions();
});

it('handles deletion of the thread (updates deleted_at and parentMessage properties)', () => {
const thread = createTestThread();
thread.registerSubscriptions();

const stateBefore = thread.state.getLatestValue();

const parentMessage = generateMsg({
id: thread.id,
deleted_at: new Date().toISOString(),
type: 'deleted',
}) as MessageResponse;

expect(thread.id).to.equal(parentMessage.id);
expect(stateBefore.deletedAt).to.be.null;

client.dispatchEvent({ type: 'message.deleted', message: parentMessage });

const stateAfter = thread.state.getLatestValue();

expect(stateAfter.deletedAt).to.be.a('date');
expect(stateAfter.deletedAt!.toISOString()).to.equal(parentMessage.deleted_at);
expect(stateAfter.parentMessage.deleted_at).to.be.a('date');
expect(stateAfter.parentMessage.deleted_at!.toISOString()).to.equal(parentMessage.deleted_at);
});
});

describe('Events: message.updated, reaction.new, reaction.deleted', () => {
(['message.updated', 'reaction.new', 'reaction.deleted'] as const).forEach((eventType) => {
(['message.updated', 'reaction.new', 'reaction.deleted', 'reaction.updated'] as const).forEach((eventType) => {
it(`updates reply or parent message on "${eventType}"`, () => {
const thread = createTestThread();
const updateParentMessageOrReplyLocallySpy = sinon.spy(thread, 'updateParentMessageOrReplyLocally');
Expand Down
Loading