From 78600ed6e62fb61a58650adb4911b8f331a545a6 Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Tue, 19 Dec 2023 14:17:25 +0100 Subject: [PATCH 1/9] feat: threads 2.0 (DO NOT MERGE) --- src/client.ts | 5 +++++ src/types.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/src/client.ts b/src/client.ts index 5b464b984..f75c31da2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -95,6 +95,7 @@ import { GetImportResponse, GetMessageAPIResponse, GetRateLimitsResponse, + GetThreadsAPIResponse, GetUnreadCountAPIResponse, ListChannelResponse, ListCommandsResponse, @@ -2578,6 +2579,10 @@ export class StreamChat(this.baseURL + `/threads`); + } + getUserAgent() { return ( this.userAgent || `stream-chat-javascript-client-${this.node ? 'node' : 'browser'}-${process.env.PKG_VERSION}` diff --git a/src/types.ts b/src/types.ts index 9aefd6235..caa65defc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -473,6 +473,8 @@ export type GetMessageAPIResponse< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics > = SendMessageAPIResponse; +export type GetThreadsAPIResponse = APIResponse; + export type GetMultipleMessagesAPIResponse< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics > = APIResponse & { From d0a6309841b4dba9252233830007ec33f1228007 Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Sun, 21 Jan 2024 00:03:07 +0100 Subject: [PATCH 2/9] feat: thread list feature --- src/channel_state.ts | 60 +------------------------ src/client.ts | 102 +++++++++++++++++++++++++++++++++++++++++-- src/events.ts | 1 + src/index.ts | 3 +- src/thread.ts | 65 +++++++++++++++++++++++++++ src/types.ts | 32 +++++++++++++- src/utils.ts | 102 ++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 301 insertions(+), 64 deletions(-) create mode 100644 src/thread.ts diff --git a/src/channel_state.ts b/src/channel_state.ts index 66b93fbac..e7574979d 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -12,6 +12,7 @@ import { UserResponse, PendingMessageResponse, } from './types'; +import { addToMessageList } from './utils'; type ChannelReadStatus = Record< string, @@ -441,64 +442,7 @@ export class ChannelState !(msg.id && message.id === msg.id)); - } - - // Get array length after filtering - const messageArrayLength = messageArr.length; - - // for empty list just concat and return unless it's an update or deletion - if (messageArrayLength === 0 && addMessageToList) { - return messageArr.concat(message); - } else if (messageArrayLength === 0) { - return [...messageArr]; - } - - const messageTime = (message[sortBy] as Date).getTime(); - const messageIsNewest = (messageArr[messageArrayLength - 1][sortBy] as Date).getTime() < messageTime; - - // if message is newer than last item in the list concat and return unless it's an update or deletion - if (messageIsNewest && addMessageToList) { - return messageArr.concat(message); - } else if (messageIsNewest) { - return [...messageArr]; - } - - // find the closest index to push the new message - let left = 0; - let middle = 0; - let right = messageArrayLength - 1; - while (left <= right) { - middle = Math.floor((right + left) / 2); - if ((messageArr[middle][sortBy] as Date).getTime() <= messageTime) left = middle + 1; - else right = middle - 1; - } - - // message already exists and not filtered due to timestampChanged, update and return - if (!timestampChanged && message.id) { - if (messageArr[left] && message.id === messageArr[left].id) { - messageArr[left] = message; - return [...messageArr]; - } - - if (messageArr[left - 1] && message.id === messageArr[left - 1].id) { - messageArr[left - 1] = message; - return [...messageArr]; - } - } - - // Do not add updated or deleted messages to the list if they do not already exist - // or have a timestamp change. - if (addMessageToList) { - messageArr.splice(left, 0, message); - } - return [...messageArr]; + return addToMessageList(messages, message, timestampChanged, sortBy, addIfDoesNotExist); } /** diff --git a/src/client.ts b/src/client.ts index f75c31da2..0efe698e9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -95,7 +95,7 @@ import { GetImportResponse, GetMessageAPIResponse, GetRateLimitsResponse, - GetThreadsAPIResponse, + QueryThreadsAPIResponse, GetUnreadCountAPIResponse, ListChannelResponse, ListCommandsResponse, @@ -165,8 +165,11 @@ import { UserOptions, UserResponse, UserSort, + GetThreadAPIResponse, + PartialThreadUpdate, } from './types'; import { InsightMetrics, postInsights } from './insights'; +import { Thread } from './thread'; function isString(x: unknown): x is string { return typeof x === 'string' || x instanceof String; @@ -2579,8 +2582,101 @@ export class StreamChat(this.baseURL + `/threads`); + async queryThreads(options?: { + limit?: number; + next?: string; + participant_limit?: number; + prev?: string; + reply_limit?: number; + watch?: boolean; + }) { + const opts = { + limit: 2, + participant_limit: 100, + reply_limit: 3, + watch: true, + ...(options || {}), + }; + + const res = await this.post>(this.baseURL + `/threads`, opts); + const threads: Thread[] = []; + + for (const t of res.threads) { + const thread = new Thread(this, t); + threads.push(thread); + } + + // TODO: Currently we are handling watch on client level. We should move this to server side. + const cids = threads.map((thread) => thread.channel.cid); + if (options?.watch && cids.length > 0) { + await this.queryChannels( + { + cid: { $in: cids }, + } as ChannelFilters, + {}, + { + limit: 30, + message_limit: 0, + watch: true, + }, + ); + } + + return { + threads, + next: res.next, + }; + } + + async getThread( + messageId: string, + options: { participant_limit?: number; reply_limit?: number; watch?: boolean } = {}, + ) { + const opts = { + participant_limit: 100, + reply_limit: 3, + ...options, + }; + + const res = await this.get>(this.baseURL + `/threads/${messageId}`, opts); + + if (options?.watch) { + const channel = this.channel(res.thread.channel.type, res.thread.channel.id); + await channel.watch(); + } + + return new Thread(this, res.thread); + } + async partialUpdateThread(messageId: string, partialThreadObject: PartialThreadUpdate) { + if (!messageId) { + throw Error('Please specify the message id when calling updateThread'); + } + + // check for reserved fields from ThreadResponse type within partialThreadObject's set and unset. + // Throw error if any of the reserved field is found. + const reservedThreadFields = [ + 'created_at', + 'id', + 'last_message_at', + 'type', + 'updated_at', + 'user', + 'reply_count', + 'participants', + 'channel', + ]; + + for (const key in { ...partialThreadObject.set, ...partialThreadObject.unset }) { + if (reservedThreadFields.includes(key)) { + throw Error( + `You cannot set ${key} field. ${key} is reserved for server-side use. Please omit ${key} from your set object.`, + ); + } + } + + return await this.patch>(this.baseURL + `/threads/${messageId}`, { + ...partialThreadObject, + }); } getUserAgent() { diff --git a/src/events.ts b/src/events.ts index 25f378b2c..a895adb63 100644 --- a/src/events.ts +++ b/src/events.ts @@ -28,6 +28,7 @@ export const EVENT_MAP = { 'notification.message_new': true, 'notification.mutes_updated': true, 'notification.removed_from_channel': true, + 'notification.thread_message_new': true, 'reaction.deleted': true, 'reaction.new': true, 'reaction.updated': true, diff --git a/src/index.ts b/src/index.ts index 964b55725..a4e097d96 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export * from './client'; export * from './client_state'; export * from './channel'; export * from './channel_state'; +export * from './thread'; export * from './connection'; export * from './events'; export * from './permissions'; @@ -10,4 +11,4 @@ export * from './signing'; export * from './token_manager'; export * from './insights'; export * from './types'; -export { isOwnUser, chatCodes, logChatPromiseExecution } from './utils'; +export { isOwnUser, chatCodes, logChatPromiseExecution, formatMessage } from './utils'; diff --git a/src/thread.ts b/src/thread.ts new file mode 100644 index 000000000..e7562f604 --- /dev/null +++ b/src/thread.ts @@ -0,0 +1,65 @@ +import { StreamChat } from './client'; +import { + DefaultGenerics, + ExtendableGenerics, + MessageResponse, + ThreadResponse, + ChannelResponse, + FormatMessageResponse, +} from './types'; +import { addToMessageList, formatMessage } from './utils'; + +export class Thread { + id: string; + latestReplies: FormatMessageResponse[] = []; + participants: ThreadResponse['thread_participants'] = []; + message: FormatMessageResponse; + channel: ChannelResponse; + replyCount = 0; + _client: StreamChat; + + constructor(client: StreamChat, t: ThreadResponse) { + this.id = t.parent_message.id; + this.message = formatMessage(t.parent_message); + this.latestReplies = t.latest_replies.map(formatMessage); + this.participants = t.thread_participants; + this.replyCount = t.reply_count; + this.channel = t.channel; + this._client = client; + } + + getClient(): StreamChat { + return this._client; + } + + addReply(message: MessageResponse) { + this.latestReplies = addToMessageList(this.latestReplies, formatMessage(message)); + } + + updateReply(message: MessageResponse) { + this.latestReplies = this.latestReplies.map((m) => { + if (m.id === message.id) { + return formatMessage(message); + } + return m; + }); + } + + updateMessageOrReplyIfExists(message: MessageResponse) { + if (!message.parent_id && message.id !== this.message.id) { + return; + } + + if (message.parent_id && message.parent_id !== this.message.id) { + return; + } + + if (message.parent_id && message.parent_id === this.message.id) { + this.updateReply(message); + } + + if (!message.parent_id && message.id === this.message.id) { + this.message = formatMessage(message); + } + } +} diff --git a/src/types.ts b/src/types.ts index caa65defc..5f814eb6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -473,7 +473,36 @@ export type GetMessageAPIResponse< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics > = SendMessageAPIResponse; -export type GetThreadsAPIResponse = APIResponse; +export type ThreadResponse = { + channel: ChannelResponse; + channel_cid: string; + created_at: string; + deleted_at: string; + latest_replies: MessageResponse[]; + parent_message: MessageResponse; + parent_message_id: string; + reply_count: number; + thread_participants: { + created_at: string; + user: UserResponse; + }[]; + title: string; + updated_at: string; +}; + +// TODO: Figure out a way to strongly type set and unset. +export type PartialThreadUpdate = { + set?: Partial>; + unset?: Partial>; +}; + +export type QueryThreadsAPIResponse = APIResponse & { + threads: ThreadResponse[]; + next?: string; +}; +export type GetThreadAPIResponse = APIResponse & { + thread: ThreadResponse; +}; export type GetMultipleMessagesAPIResponse< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics @@ -1079,6 +1108,7 @@ export type Event; received_at?: string | Date; team?: string; + thread?: ThreadResponse; // @deprecated number of all unread messages across all current user's unread channels, equals unread_count total_unread_count?: number; // number of all current user's channels with at least one unread message including the channel in this event diff --git a/src/utils.ts b/src/utils.ts index 4f53ed762..b3e9f8f26 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,14 @@ import FormData from 'form-data'; -import { AscDesc, ExtendableGenerics, DefaultGenerics, OwnUserBase, OwnUserResponse, UserResponse } from './types'; +import { + AscDesc, + ExtendableGenerics, + DefaultGenerics, + OwnUserBase, + OwnUserResponse, + UserResponse, + MessageResponse, + FormatMessageResponse, +} from './types'; import { AxiosRequestConfig } from 'axios'; /** @@ -263,3 +272,94 @@ export const axiosParamsSerializer: AxiosRequestConfig['paramsSerializer'] = (pa return newParams.join('&'); }; + +/** + * formatMessage - Takes the message object. Parses the dates, sets __html + * and sets the status to received if missing. Returns a message object + * + * @param {MessageResponse} message a message object + * + */ +export function formatMessage( + message: MessageResponse, +): FormatMessageResponse { + return { + ...message, + /** + * @deprecated please use `html` + */ + __html: message.html, + // parse the date.. + pinned_at: message.pinned_at ? new Date(message.pinned_at) : null, + created_at: message.created_at ? new Date(message.created_at) : new Date(), + updated_at: message.updated_at ? new Date(message.updated_at) : new Date(), + status: message.status || 'received', + }; +} + +export function addToMessageList( + messages: Array>, + message: FormatMessageResponse, + timestampChanged = false, + sortBy: 'pinned_at' | 'created_at' = 'created_at', + addIfDoesNotExist = true, +) { + const addMessageToList = addIfDoesNotExist || timestampChanged; + let messageArr = messages; + + // if created_at has changed, message should be filtered and re-inserted in correct order + // slow op but usually this only happens for a message inserted to state before actual response with correct timestamp + if (timestampChanged) { + messageArr = messageArr.filter((msg) => !(msg.id && message.id === msg.id)); + } + + // Get array length after filtering + const messageArrayLength = messageArr.length; + + // for empty list just concat and return unless it's an update or deletion + if (messageArrayLength === 0 && addMessageToList) { + return messageArr.concat(message); + } else if (messageArrayLength === 0) { + return [...messageArr]; + } + + const messageTime = (message[sortBy] as Date).getTime(); + const messageIsNewest = (messageArr[messageArrayLength - 1][sortBy] as Date).getTime() < messageTime; + + // if message is newer than last item in the list concat and return unless it's an update or deletion + if (messageIsNewest && addMessageToList) { + return messageArr.concat(message); + } else if (messageIsNewest) { + return [...messageArr]; + } + + // find the closest index to push the new message + let left = 0; + let middle = 0; + let right = messageArrayLength - 1; + while (left <= right) { + middle = Math.floor((right + left) / 2); + if ((messageArr[middle][sortBy] as Date).getTime() <= messageTime) left = middle + 1; + else right = middle - 1; + } + + // message already exists and not filtered due to timestampChanged, update and return + if (!timestampChanged && message.id) { + if (messageArr[left] && message.id === messageArr[left].id) { + messageArr[left] = message; + return [...messageArr]; + } + + if (messageArr[left - 1] && message.id === messageArr[left - 1].id) { + messageArr[left - 1] = message; + return [...messageArr]; + } + } + + // Do not add updated or deleted messages to the list if they do not already exist + // or have a timestamp change. + if (addMessageToList) { + messageArr.splice(left, 0, message); + } + return [...messageArr]; +} From c5aa5783c62f45932e710c2868c9db8158831a13 Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Mon, 22 Jan 2024 18:13:43 +0100 Subject: [PATCH 3/9] feat: added reaction endpoint to thread --- src/client.ts | 4 ++-- src/thread.ts | 36 +++++++++++++++++++++++++++++++++++- src/types.ts | 14 ++++++++++++++ 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/src/client.ts b/src/client.ts index 0efe698e9..7bebc5302 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2591,8 +2591,8 @@ export class StreamChat; channel: ChannelResponse; + _channel: ReturnType['channel']>; replyCount = 0; _client: StreamChat; - + read: ThreadResponse['read'] = []; constructor(client: StreamChat, t: ThreadResponse) { this.id = t.parent_message.id; this.message = formatMessage(t.parent_message); @@ -25,7 +27,9 @@ export class Thread { @@ -62,4 +66,34 @@ export class Thread, + message?: MessageResponse, + enforce_unique?: boolean, + ) { + if (!message) return; + + this.latestReplies = this.latestReplies.map((m) => { + if (m.id === message.id) { + return formatMessage( + this._channel.state.addReaction(reaction, message, enforce_unique) as MessageResponse, + ); + } + return m; + }); + } + + removeReaction(reaction: ReactionResponse, message?: MessageResponse) { + if (!message) return; + + this.latestReplies = this.latestReplies.map((m) => { + if (m.id === message.id) { + return formatMessage( + this._channel.state.removeReaction(reaction, message) as MessageResponse, + ); + } + return m; + }); + } } diff --git a/src/types.ts b/src/types.ts index 5f814eb6c..ee0c47e79 100644 --- a/src/types.ts +++ b/src/types.ts @@ -481,6 +481,12 @@ export type ThreadResponse[]; parent_message: MessageResponse; parent_message_id: string; + read: { + last_read: string; + last_read_message_id: string; + unread_messages: number; + user: UserResponse; + }[]; reply_count: number; thread_participants: { created_at: string; @@ -536,7 +542,14 @@ export type GetUnreadCountAPIResponse = APIResponse & { last_read: string; unread_count: number; }[]; + threads: { + last_read: string; + last_read_message_id: string; + parent_message_id: string; + unread_count: number; + }[]; total_unread_count: number; + total_unread_threads_count: number; }; export type ListChannelResponse = APIResponse & { @@ -902,6 +915,7 @@ export type MarkChannelsReadOptions = { client_id?: string; connection_id?: string; + thread_id?: string; user?: UserResponse; user_id?: string; }; From 92f24ebcb921561c726a996afed6f8a64024955c Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Thu, 25 Jan 2024 15:23:19 +0100 Subject: [PATCH 4/9] fix: date constructor for last_read_at date stamp --- src/thread.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index 9aa94ca48..478e53359 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -7,9 +7,19 @@ import { ChannelResponse, FormatMessageResponse, ReactionResponse, + UserResponse, } from './types'; import { addToMessageList, formatMessage } from './utils'; +type ThreadReadStatus = Record< + string, + { + last_read: Date; + last_read_message_id: string; + unread_messages: number; + user: UserResponse; + } +>; export class Thread { id: string; latestReplies: FormatMessageResponse[] = []; @@ -19,7 +29,8 @@ export class Thread['channel']>; replyCount = 0; _client: StreamChat; - read: ThreadResponse['read'] = []; + read: ThreadReadStatus = {}; + constructor(client: StreamChat, t: ThreadResponse) { this.id = t.parent_message.id; this.message = formatMessage(t.parent_message); @@ -29,7 +40,12 @@ export class Thread { From 62753be7b75e163fdadc132d9813138af4a2fcb3 Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Fri, 26 Jan 2024 13:09:36 +0100 Subject: [PATCH 5/9] fix: watch handling --- src/client.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index 7bebc5302..bdc01404d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2590,12 +2590,13 @@ export class StreamChat>(this.baseURL + `/threads`, opts); From e584fe8c9a7fbd222567ca227bdaf9113a1774fe Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Sun, 28 Jan 2024 23:58:52 +0100 Subject: [PATCH 6/9] refactor: moved watch to backend --- src/client.ts | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/src/client.ts b/src/client.ts index bdc01404d..fe3c48e83 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2591,12 +2591,12 @@ export class StreamChat>(this.baseURL + `/threads`, opts); @@ -2607,22 +2607,6 @@ export class StreamChat thread.channel.cid); - if (options?.watch && cids.length > 0) { - await this.queryChannels( - { - cid: { $in: cids }, - } as ChannelFilters, - {}, - { - limit: 30, - message_limit: 0, - watch: true, - }, - ); - } - return { threads, next: res.next, @@ -2636,18 +2620,15 @@ export class StreamChat>(this.baseURL + `/threads/${messageId}`, opts); - if (options?.watch) { - const channel = this.channel(res.thread.channel.type, res.thread.channel.id); - await channel.watch(); - } - return new Thread(this, res.thread); } + async partialUpdateThread(messageId: string, partialThreadObject: PartialThreadUpdate) { if (!messageId) { throw Error('Please specify the message id when calling updateThread'); From d92c8592b09fd046f428f132606d39d19ffda76e Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Fri, 2 Feb 2024 14:02:35 +0100 Subject: [PATCH 7/9] refactor: code review changes --- src/client.ts | 70 +++++++++++++++++++++++++++++++++------------------ src/thread.ts | 1 + src/types.ts | 15 +++++++++++ 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/src/client.ts b/src/client.ts index fe3c48e83..9b6bcfa87 100644 --- a/src/client.ts +++ b/src/client.ts @@ -167,6 +167,8 @@ import { UserSort, GetThreadAPIResponse, PartialThreadUpdate, + QueryThreadsOptions, + GetThreadOptions, } from './types'; import { InsightMetrics, postInsights } from './insights'; import { Thread } from './thread'; @@ -2582,15 +2584,18 @@ export class StreamChat[], next: string }} Returns the list of threads and the next cursor. + */ + async queryThreads(options?: QueryThreadsOptions) { const opts = { limit: 10, participant_limit: 10, @@ -2600,23 +2605,29 @@ export class StreamChat>(this.baseURL + `/threads`, opts); - const threads: Thread[] = []; - - for (const t of res.threads) { - const thread = new Thread(this, t); - threads.push(thread); - } return { - threads, + threads: res.threads.map((thread) => new Thread(this, thread)), next: res.next, }; } - async getThread( - messageId: string, - options: { participant_limit?: number; reply_limit?: number; watch?: boolean } = {}, - ) { + /** + * getThread - returns the thread of a message by its id. + * + * @param {string} messageId The message id + * @param {GetThreadOptions} options Options object for pagination and limiting the participants and replies. + * @param {boolean} options.watch Subscribes the user to the channel of the thread. + * @param {number} options.participant_limit Limits the number of participants returned per threads. + * @param {number} options.reply_limit Limits the number of replies returned per threads. + * + * @returns {Thread} Returns the thread. + */ + async getThread(messageId: string, options: GetThreadOptions = {}) { + if (!messageId) { + throw Error('Please specify the message id when calling partialUpdateThread'); + } + const opts = { participant_limit: 100, reply_limit: 3, @@ -2629,9 +2640,17 @@ export class StreamChat(this, res.thread); } + /** + * partialUpdateThread - updates the given thread + * + * @param {string} messageId The id of the thread message which needs to be updated. + * @param {PartialThreadUpdate} partialThreadObject should contain "set" or "unset" params for any of the thread's non-reserved fields. + * + * @returns {GetThreadAPIResponse} Returns the updated thread. + */ async partialUpdateThread(messageId: string, partialThreadObject: PartialThreadUpdate) { if (!messageId) { - throw Error('Please specify the message id when calling updateThread'); + throw Error('Please specify the message id when calling partialUpdateThread'); } // check for reserved fields from ThreadResponse type within partialThreadObject's set and unset. @@ -2651,14 +2670,15 @@ export class StreamChat>(this.baseURL + `/threads/${messageId}`, { - ...partialThreadObject, - }); + return await this.patch>( + this.baseURL + `/threads/${messageId}`, + partialThreadObject, + ); } getUserAgent() { diff --git a/src/thread.ts b/src/thread.ts index 478e53359..2278f43a1 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -20,6 +20,7 @@ type ThreadReadStatus; } >; + export class Thread { id: string; latestReplies: FormatMessageResponse[] = []; diff --git a/src/types.ts b/src/types.ts index e46eecef6..23deac25e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -507,10 +507,25 @@ export type PartialThreadUpdate = { unset?: Partial>; }; +export type QueryThreadsOptions = { + limit?: number; + next?: string; + participant_limit?: number; + reply_limit?: number; + watch?: boolean; +}; + export type QueryThreadsAPIResponse = APIResponse & { threads: ThreadResponse[]; next?: string; }; + +export type GetThreadOptions = { + participant_limit?: number; + reply_limit?: number; + watch?: boolean; +}; + export type GetThreadAPIResponse = APIResponse & { thread: ThreadResponse; }; From 34c8a893cb83dfe4a43f3b1649e33754895fc7fc Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Fri, 2 Feb 2024 14:19:26 +0100 Subject: [PATCH 8/9] fix: ts test --- test/typescript/response-generators/client.js | 14 +++++++++----- test/typescript/response-generators/moderation.js | 9 +++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/test/typescript/response-generators/client.js b/test/typescript/response-generators/client.js index 99dd0b2d1..d6f3d9984 100644 --- a/test/typescript/response-generators/client.js +++ b/test/typescript/response-generators/client.js @@ -89,11 +89,15 @@ async function listCommands() { } catch { // No command to delete } - await authClient.createCommand({ - description: 'testListCommand', - name: 'testListCommand', - set: 'testListCommand_set', - }); + try { + await authClient.createCommand({ + description: 'testListCommand', + name: 'testListCommand', + set: 'testListCommand_set', + }); + } catch { + // Command exists + } const result = await authClient.listCommands(); await authClient.deleteCommand('testListCommand'); return result; diff --git a/test/typescript/response-generators/moderation.js b/test/typescript/response-generators/moderation.js index 2138e940e..85f7e003b 100644 --- a/test/typescript/response-generators/moderation.js +++ b/test/typescript/response-generators/moderation.js @@ -73,8 +73,13 @@ async function deleteBlockList() { await cleanupBlockList(client, name); await cleanupBlockList(client, name2); - await client.createBlockList({ name, words: ['F*!k'] }); - await client.createBlockList({ name: name2, words: ['S!*t'] }); + try { + await client.createBlockList({ name, words: ['F*!k'] }); + await client.createBlockList({ name: name2, words: ['S!*t'] }); + } catch (err) { + // in case the blocklist already exists + // do nothing + } const returnValue = await client.deleteBlockList(name); await client.deleteBlockList(name2); From 84f4055b6d08929b49f329d8134f5140b5b4d535 Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Mon, 5 Feb 2024 15:03:23 +0100 Subject: [PATCH 9/9] fix: threads methods improvements --- src/thread.ts | 24 ++++++--- src/types.ts | 1 + src/utils.ts | 1 + test/unit/test-utils/generateMessage.js | 2 +- test/unit/test-utils/generateThread.js | 21 ++++++++ test/unit/thread.js | 70 +++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 test/unit/test-utils/generateThread.js create mode 100644 test/unit/thread.js diff --git a/src/thread.ts b/src/thread.ts index 2278f43a1..788651357 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -41,11 +41,13 @@ export class Thread} message reply message to be added. + */ addReply(message: MessageResponse) { - this.latestReplies = addToMessageList(this.latestReplies, formatMessage(message)); + if (message.parent_id !== this.message.id) { + throw new Error('Message does not belong to this thread'); + } + + this.latestReplies = addToMessageList(this.latestReplies, formatMessage(message), true); } updateReply(message: MessageResponse) { @@ -77,6 +88,7 @@ export class Thread { const date = msg.date || new Date().toISOString(); return { id: uuidv4(), - text: 'x', + text: uuidv4(), html: '

x

\n', type: 'regular', user: { id: 'id' }, diff --git a/test/unit/test-utils/generateThread.js b/test/unit/test-utils/generateThread.js new file mode 100644 index 000000000..93d322ec3 --- /dev/null +++ b/test/unit/test-utils/generateThread.js @@ -0,0 +1,21 @@ +import { v4 as uuidv4 } from 'uuid'; +import { generateUser } from './generateUser'; + +export const generateThread = (channel, parent, opts = {}) => { + return { + parent_message_id: parent.id, + parent_message: parent, + channel, + title: 'title', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + channel_cid: channel.cid, + last_message_at: new Date().toISOString(), + deleted_at: '', + read: [], + reply_count: 0, + latest_replies: [], + thread_participants: [], + ...opts, + }; +}; diff --git a/test/unit/thread.js b/test/unit/thread.js new file mode 100644 index 000000000..d0501c421 --- /dev/null +++ b/test/unit/thread.js @@ -0,0 +1,70 @@ +import chai from 'chai'; +import { v4 as uuidv4 } from 'uuid'; + +import { generateChannel } from './test-utils/generateChannel'; +import { generateMember } from './test-utils/generateMember'; +import { generateMsg } from './test-utils/generateMessage'; +import { generateUser } from './test-utils/generateUser'; +import { getClientWithUser } from './test-utils/getClient'; +import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; +import sinon from 'sinon'; +import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; + +import { ChannelState, StreamChat, Thread } from '../../src'; +import { generateThread } from './test-utils/generateThread'; + +const expect = chai.expect; + +describe.only('Thread', () => { + describe('addReply', async () => { + let client; + let channel; + let parent; + let thread; + + beforeEach(() => { + client = new StreamChat('apiKey'); + client.userID = 'observer'; + channel = generateChannel().channel; + parent = generateMsg(); + thread = new Thread(client, generateThread(channel, parent)); + }); + it('should throw error if the message is not a reply to the parent', async () => { + const reply = generateMsg({ + status: 'pending', + parent_id: 'some_other_id', + }); + expect(() => thread.addReply(reply)).to.throw('Message does not belong to this thread'); + }); + + it('should add reply to the thread', async () => { + const reply1 = generateMsg({ + status: 'pending', + parent_id: parent.id, + }); + + thread.addReply(reply1); + expect(thread.latestReplies).to.have.length(1); + expect(thread.latestReplies[0].status).to.equal('pending'); + + reply1.status = 'received'; + thread.addReply(reply1); + expect(thread.latestReplies).to.have.length(1); + expect(thread.latestReplies[0].status).to.equal('received'); + + const reply2 = generateMsg({ + status: 'pending', + parent_id: parent.id, + }); + + thread.addReply(reply2); + expect(thread.latestReplies).to.have.length(2); + expect(thread.latestReplies[1].status).to.equal('pending'); + + reply2.status = 'received'; + thread.addReply(reply2); + expect(thread.latestReplies).to.have.length(2); + expect(thread.latestReplies[1].status).to.equal('received'); + }); + }); +});