From 751694d33ea54820f580563be836baba5d5604bd Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Tue, 19 Dec 2023 14:17:25 +0100 Subject: [PATCH 1/2] 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 974d11cb51..06c3ee21b7 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 12b8554282..6187d32cc9 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 eb1e9b3270c9df666a2d9e2d02085d46c5e5baff Mon Sep 17 00:00:00 2001 From: Vishal Narkhede Date: Sun, 21 Jan 2024 00:03:07 +0100 Subject: [PATCH 2/2] 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 | 33 +++++++++++++- src/utils.ts | 102 ++++++++++++++++++++++++++++++++++++++++++- 7 files changed, 301 insertions(+), 65 deletions(-) create mode 100644 src/thread.ts diff --git a/src/channel_state.ts b/src/channel_state.ts index 2cfb1a95fc..622d63b743 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, @@ -435,64 +436,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 06c3ee21b7..20c0ce9d04 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 74e9842105..448d7df1c7 100644 --- a/src/events.ts +++ b/src/events.ts @@ -27,6 +27,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 964b557255..a4e097d961 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 0000000000..e7562f6049 --- /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 6187d32cc9..771008626d 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 @@ -1074,7 +1103,7 @@ export type Event; received_at?: string | Date; team?: string; - total_unread_count?: number; + thread?: ThreadResponse; unread_channels?: number; unread_count?: number; user?: UserResponse; diff --git a/src/utils.ts b/src/utils.ts index 4f53ed7623..b3e9f8f26e 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]; +}