From 7192054994127dddc5cfb2a0cc26a9bf9bb0d70f Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Wed, 15 May 2024 17:16:56 +0200 Subject: [PATCH 01/41] Initial commit --- src/channel.ts | 1 + src/channel_state.ts | 31 ++--- src/index.ts | 1 + src/store/SimpleStateStore.ts | 79 ++++++++++++ src/thread.ts | 219 ++++++++++++++++++++-------------- src/types.ts | 14 +-- src/utils.ts | 99 ++++++++------- tsconfig.json | 2 +- 8 files changed, 289 insertions(+), 157 deletions(-) create mode 100644 src/store/SimpleStateStore.ts diff --git a/src/channel.ts b/src/channel.ts index 75905bf08f..4ab8f2a5da 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -820,6 +820,7 @@ export class Channel = Record< string, @@ -61,6 +61,7 @@ export class ChannelState['formatMessage']>>; }[] = []; + constructor(channel: Channel) { this._channel = channel; this.watcher_count = 0; @@ -132,26 +133,13 @@ export class ChannelState} message a message object + * Takes the message object, parses the dates, sets `__html` + * and sets the status to `received` if missing; returns a new message object. * + * @param {MessageResponse} message `MessageResponse` object */ - 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', - }; - } + formatMessage = (message: MessageResponse): FormatMessageResponse => + formatMessage(message); /** * addMessagesSorted - Add the list of messages to state and resorts the messages @@ -295,15 +283,16 @@ export class ChannelState, message?: MessageResponse, - enforce_unique?: boolean, + enforceUnique?: boolean, ) { if (!message) return; const messageWithReaction = message; this._updateMessage(message, (msg) => { - messageWithReaction.own_reactions = this._addOwnReactionToMessage(msg.own_reactions, reaction, enforce_unique); + messageWithReaction.own_reactions = this._addOwnReactionToMessage(msg.own_reactions, reaction, enforceUnique); return this.formatMessage(messageWithReaction); }); return messageWithReaction; diff --git a/src/index.ts b/src/index.ts index 232af6ec0c..bc7b787b5c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,3 +15,4 @@ export * from './types'; export * from './segment'; export * from './campaign'; export { isOwnUser, chatCodes, logChatPromiseExecution, formatMessage } from './utils'; +export * from './store/SimpleStateStore'; diff --git a/src/store/SimpleStateStore.ts b/src/store/SimpleStateStore.ts new file mode 100644 index 0000000000..6bb6375639 --- /dev/null +++ b/src/store/SimpleStateStore.ts @@ -0,0 +1,79 @@ +type Patch = (value: T) => T; +type Handler = (nextValue: T) => any; +type Initiator = (get: SimpleStateStore['getLatestValue'], set: SimpleStateStore['next']) => T; + +function isPatch(value: T | Patch): value is Patch { + return typeof value === 'function'; +} +function isInitiator(value: T | Initiator): value is Initiator { + return typeof value === 'function'; +} + +export class SimpleStateStore< + T + // O extends { + // [K in keyof T]: T[K] extends Function ? K : never; + // }[keyof T] = never +> { + private value: T; + private handlerSet = new Set>(); + + constructor(initialValueOrInitiator: T | Initiator) { + this.value = isInitiator(initialValueOrInitiator) + ? initialValueOrInitiator(this.getLatestValue, this.next) + : initialValueOrInitiator; + } + + public next = (newValueOrPatch: T | Patch) => { + // newValue (or patch output) should never be mutated previous value + const newValue = isPatch(newValueOrPatch) ? newValueOrPatch(this.value) : newValueOrPatch; + + // do not notify subscribers if the value hasn't changed (or mutation has been returned) + if (newValue === this.value) return; + this.value = newValue; + + this.handlerSet.forEach((handler) => handler(this.value)); + }; + + public getLatestValue = () => this.value; + + // TODO: filter and return actions (functions) only in a type-safe manner (only allows state T to be a dict) + // public get actions(): { [K in O]: T[K] } { + // return {}; + // } + public get actions() { + return this.value; + } + + public subscribe = (handler: Handler) => { + handler(this.value); + this.handlerSet.add(handler); + return () => { + this.handlerSet.delete(handler); + }; + }; + + public subscribeWithSelector = (selector: (nextValue: T) => O, handler: Handler) => { + let selectedValues = selector(this.value); + + const wrappedHandler: Handler = (nextValue) => { + const newlySelectedValues = selector(nextValue); + + const hasUnequalMembers = selectedValues.some((value, index) => value !== newlySelectedValues[index]); + + if (hasUnequalMembers) { + selectedValues = newlySelectedValues; + handler(newlySelectedValues); + } + }; + + return this.subscribe(wrappedHandler); + }; +} + +// const a = new SimpleStateStore({ string: 'aaa', b: 123 }); + +// a.subscribeWithSelector( +// (nv) => [nv.b, nv.string] as const, +// ([a, b]) => console.log(), +// ); diff --git a/src/thread.ts b/src/thread.ts index 1cc402093d..bb3d7a31c5 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,142 +1,185 @@ import { StreamChat } from './client'; +import { Channel } from './channel'; import { DefaultGenerics, ExtendableGenerics, MessageResponse, ThreadResponse, - ChannelResponse, FormatMessageResponse, ReactionResponse, UserResponse, } from './types'; import { addToMessageList, formatMessage } from './utils'; +import { SimpleStateStore } from './store/SimpleStateStore'; type ThreadReadStatus = Record< string, { - last_read: Date; + last_read: string; last_read_message_id: string; + lastRead: Date; unread_messages: number; user: UserResponse; } >; +// const formatReadState = () => + export class Thread { - id: string; - latestReplies: FormatMessageResponse[] = []; - participants: ThreadResponse['thread_participants'] = []; - message: FormatMessageResponse; - channel: ChannelResponse; - _channel: ReturnType['channel']>; - replyCount = 0; - _client: StreamChat; - read: ThreadReadStatus = {}; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: Record = {}; - - constructor(client: StreamChat, t: ThreadResponse) { - const { - parent_message_id, - parent_message, - latest_replies, - thread_participants, - reply_count, - channel, + public readonly state: SimpleStateStore<{ + channel: Channel; + channelData: ThreadResponse['channel']; + createdAt: string; + deletedAt: string; + latestReplies: Array>; + parentMessage: FormatMessageResponse | undefined; + participants: ThreadResponse['thread_participants']; + read: ThreadReadStatus; + replyCount: number; + updatedAt: string; + }>; + public id: string; + private client: StreamChat; + + constructor(client: StreamChat, threadData: ThreadResponse) { + // TODO: move to function "formatReadStatus" + const { read: unformattedRead = [] } = threadData; + // TODO: check why this one is sometimes undefined (should return empty array instead) + const read = unformattedRead.reduce>((pv, cv) => { + pv[cv.user.id] ??= { + ...cv, + lastRead: new Date(cv.last_read), + }; + return pv; + }, {}); + + console.log({ parent: threadData.parent_message, id: threadData.parent_message_id }); + + this.state = new SimpleStateStore({ + channelData: threadData.channel, // not channel instance + channel: client.channel(threadData.channel.type, threadData.channel.id), + createdAt: threadData.created_at, + deletedAt: threadData.deleted_at, + latestReplies: threadData.latest_replies.map(formatMessage), + // TODO: check why this is sometimes undefined + parentMessage: threadData.parent_message && formatMessage(threadData.parent_message), + participants: threadData.thread_participants, read, - ...data - } = t; - - this.id = parent_message_id; - this.message = formatMessage(parent_message); - this.latestReplies = latest_replies.map(formatMessage); - this.participants = thread_participants; - this.replyCount = reply_count; - this.channel = channel; - this._channel = client.channel(t.channel.type, t.channel.id); - this._client = client; - if (read) { - for (const r of read) { - this.read[r.user.id] = { - ...r, - last_read: new Date(r.last_read), - }; - } - } - this.data = data; + replyCount: threadData.reply_count, + updatedAt: threadData.updated_at, + }); + + // parent_message_id is being re-used as thread.id + this.id = threadData.parent_message_id; + this.client = client; + + // TODO: register WS listeners (message.new / reply ) + // client.on() } - getClient(): StreamChat { - return this._client; + get channel() { + return this.state.getLatestValue().channel; } - /** - * addReply - Adds or updates a latestReplies to the thread - * - * @param {MessageResponse} message reply message to be added. - */ - addReply(message: MessageResponse) { - if (message.parent_id !== this.message.id) { + addReply = (message: MessageResponse) => { + if (message.parent_id !== this.id) { throw new Error('Message does not belong to this thread'); } - this.latestReplies = addToMessageList(this.latestReplies, formatMessage(message), true); - } + this.state.next((pv) => ({ + ...pv, + latestReplies: addToMessageList(pv.latestReplies, formatMessage(message), true), + })); + }; + + updateReply = (message: MessageResponse) => { + this.state.next((pv) => ({ + ...pv, + latestReplies: pv.latestReplies.map((m) => { + if (m.id === message.id) return formatMessage(message); + return m; + }), + })); + }; + + updateParentMessage = (message: MessageResponse) => { + if (message.id !== this.id) { + throw new Error('Message does not belong to this thread'); + } - updateReply(message: MessageResponse) { - this.latestReplies = this.latestReplies.map((m) => { - if (m.id === message.id) { - return formatMessage(message); + this.state.next((pv) => { + const newData = { ...pv, parentMessage: formatMessage(message) }; + // update channel if channelData change (unlikely but handled anyway) + if (message.channel) { + newData['channel'] = this.client.channel(message.channel.type, message.channel.id); } - return m; + return newData; }); - } + }; 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) { + if (message.parent_id === this.id) { this.updateReply(message); - return; } - if (!message.parent_id && message.id === this.message.id) { - this.message = formatMessage(message); + if (!message.parent_id && message.id === this.id) { + this.updateParentMessage(message); } } addReaction( reaction: ReactionResponse, message?: MessageResponse, - enforce_unique?: boolean, + enforceUnique?: 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; - }); + this.state.next((pv) => ({ + ...pv, + latestReplies: pv.latestReplies.map((reply) => { + if (reply.id !== message.id) return reply; + + // FIXME: this addReaction API weird (maybe clean it up later) + const updatedMessage = pv.channel.state.addReaction(reaction, message, enforceUnique); + if (updatedMessage) return formatMessage(updatedMessage); + + return reply; + }), + })); } 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; - }); + this.state.next((pv) => ({ + ...pv, + latestReplies: pv.latestReplies.map((reply) => { + if (reply.id !== message.id) return reply; + + // FIXME: this removeReaction API is weird (maybe clean it up later) + const updatedMessage = pv.channel.state.removeReaction(reaction, message); + if (updatedMessage) return formatMessage(updatedMessage); + + return reply; + }), + })); } + + loadNext = async ({ + options = { + id_gt: this.state.getLatestValue().latestReplies.at(-1)?.id, + }, + sort = [{ created_at: -1 }], + }: { + options: Parameters['getReplies']>['1']; + sort: Parameters['getReplies']>['2']; + }) => { + // todo: loading/error states + const vals = await this.channel.getReplies(this.id, options, sort); + }; + + // TODO: impl + loadPrevious = () => { + // ... + }; } diff --git a/src/types.ts b/src/types.ts index c96d8b31bf..82e985b434 100644 --- a/src/types.ts +++ b/src/types.ts @@ -504,14 +504,7 @@ 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; @@ -519,6 +512,13 @@ export type ThreadResponse; + read?: { + last_read: string; + last_read_message_id: string; + unread_messages: number; + user: UserResponse; + }[]; }; // TODO: Figure out a way to strongly type set and unset. diff --git a/src/utils.ts b/src/utils.ts index 3730df5825..9aa2926a90 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -277,11 +277,10 @@ export const axiosParamsSerializer: AxiosRequestConfig['paramsSerializer'] = (pa }; /** - * 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 + * Takes the message object, parses the dates, sets `__html` + * and sets the status to `received` if missing; returns a new message object. * + * @param {MessageResponse} message `MessageResponse` object */ export function formatMessage( message: MessageResponse, @@ -292,7 +291,7 @@ export function formatMessage( - messages: Array>, - message: FormatMessageResponse, +// TODO: does not respect message lists ordered [newest -> oldest] only [oldest -> newest] +export const findInsertionIndex = ({ + message, + messages, + sortBy = 'created_at', +}: { + message: T; + messages: Array; + sortBy?: 'pinned_at' | 'created_at'; +}) => { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const messageTime = message[sortBy]!.getTime(); + + let left = 0; + let middle = 0; + let right = messages.length - 1; + + while (left <= right) { + middle = Math.floor((right + left) / 2); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + if (messages[middle][sortBy]!.getTime() <= messageTime) left = middle + 1; + else right = middle - 1; + } + + return left; +}; + +export function addToMessageList( + messages: readonly T[], + newMessage: T, timestampChanged = false, sortBy: 'pinned_at' | 'created_at' = 'created_at', addIfDoesNotExist = true, ) { const addMessageToList = addIfDoesNotExist || timestampChanged; - let messageArr = messages; + let newMessages = [...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)); + newMessages = newMessages.filter((message) => !(message.id && newMessage.id === message.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]; + if (!newMessages.length) { + if (addMessageToList) return newMessages.concat(newMessage); + + return newMessages; } - const messageTime = (message[sortBy] as Date).getTime(); - const messageIsNewest = (messageArr[messageArrayLength - 1][sortBy] as Date).getTime() < messageTime; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const messageTime = newMessage[sortBy]!.getTime(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const messageIsNewest = newMessages.at(-1)![sortBy]!.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]; + if (messageIsNewest) { + if (addMessageToList) return newMessages.concat(newMessage); + + return newMessages; } // 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; - } + const insertionIndex = findInsertionIndex({ message: newMessage, messages: newMessages, sortBy }); // 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 (!timestampChanged && newMessage.id) { + if (newMessages[insertionIndex] && newMessage.id === newMessages[insertionIndex].id) { + newMessages[insertionIndex] = newMessage; + return newMessages; } - if (messageArr[left - 1] && message.id === messageArr[left - 1].id) { - messageArr[left - 1] = message; - return [...messageArr]; + if (newMessages[insertionIndex - 1] && newMessage.id === newMessages[insertionIndex - 1].id) { + newMessages[insertionIndex - 1] = newMessage; + return newMessages; } } // 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); + newMessages.splice(insertionIndex, 0, newMessage); } - return [...messageArr]; + + return newMessages; } function maybeGetReactionGroupsFallback( diff --git a/tsconfig.json b/tsconfig.json index 26dea59f5a..102ae5bfad 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "baseUrl": "./src", "esModuleInterop": true, "moduleResolution": "node", - "lib": ["DOM", "ES6"], + "lib": ["DOM", "ESNext", "ESNext.Array"], "noEmitOnError": false, "noImplicitAny": true, "preserveConstEnums": true, From 6c51d697b659c6d2d1e80d7ad5e36739b854d653 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Sat, 25 May 2024 00:03:11 +0200 Subject: [PATCH 02/41] First MVP --- src/client.ts | 13 +- src/store/SimpleStateStore.ts | 30 ++-- src/thread.ts | 292 +++++++++++++++++++++++++++------- src/utils.ts | 3 +- test/unit/thread.js | 2 +- 5 files changed, 261 insertions(+), 79 deletions(-) diff --git a/src/client.ts b/src/client.ts index 295af907d6..6560ed8c3e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -205,7 +205,7 @@ import { QueryMessageHistoryResponse, } from './types'; import { InsightMetrics, postInsights } from './insights'; -import { Thread } from './thread'; +import { Thread, ThreadManager } from './thread'; import { Moderation } from './moderation'; function isString(x: unknown): x is string { @@ -219,6 +219,7 @@ export class StreamChat; }; + threads: ThreadManager; anonymous: boolean; persistUserOnConnectionFailure?: boolean; axiosInstance: AxiosInstance; @@ -338,6 +339,8 @@ export class StreamChat>(this.baseURL + `/threads`, opts); return { - threads: res.threads.map((thread) => new Thread(this, thread)), + threads: res.threads.map((thread) => new Thread({ client: this, threadData: thread })), next: res.next, }; } @@ -2751,7 +2754,7 @@ export class StreamChat>(this.baseURL + `/threads/${messageId}`, opts); - return new Thread(this, res.thread); + return new Thread({ client: this, threadData: res.thread }); } /** diff --git a/src/store/SimpleStateStore.ts b/src/store/SimpleStateStore.ts index 6bb6375639..c81b3ee370 100644 --- a/src/store/SimpleStateStore.ts +++ b/src/store/SimpleStateStore.ts @@ -2,6 +2,12 @@ type Patch = (value: T) => T; type Handler = (nextValue: T) => any; type Initiator = (get: SimpleStateStore['getLatestValue'], set: SimpleStateStore['next']) => T; +export type InferStoreValueType = T extends SimpleStateStore + ? R + : T extends { state: SimpleStateStore } + ? L + : never; + function isPatch(value: T | Patch): value is Patch { return typeof value === 'function'; } @@ -53,27 +59,27 @@ export class SimpleStateStore< }; }; - public subscribeWithSelector = (selector: (nextValue: T) => O, handler: Handler) => { - let selectedValues = selector(this.value); + public subscribeWithSelector = ( + selector: (nextValue: T) => O, + handler: Handler, + emitOnSubscribe = false, + ) => { + // begin with undefined to reduce amount of selector calls + let selectedValues: O | undefined; const wrappedHandler: Handler = (nextValue) => { const newlySelectedValues = selector(nextValue); - const hasUnequalMembers = selectedValues.some((value, index) => value !== newlySelectedValues[index]); + const hasUnequalMembers = selectedValues?.some((value, index) => value !== newlySelectedValues[index]); - if (hasUnequalMembers) { + // initial subscription call begins with hasUnequalMembers as undefined (skip comparison), fallback to unset selectedValues + if (hasUnequalMembers || !selectedValues) { + // skip initial handler call unless explicitly asked for (emitOnSubscribe) + if (selectedValues || (!selectedValues && emitOnSubscribe)) handler(newlySelectedValues); selectedValues = newlySelectedValues; - handler(newlySelectedValues); } }; return this.subscribe(wrappedHandler); }; } - -// const a = new SimpleStateStore({ string: 'aaa', b: 123 }); - -// a.subscribeWithSelector( -// (nv) => [nv.b, nv.string] as const, -// ([a, b]) => console.log(), -// ); diff --git a/src/thread.ts b/src/thread.ts index bb3d7a31c5..9bd9af95db 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -8,9 +8,14 @@ import { FormatMessageResponse, ReactionResponse, UserResponse, + Event, + QueryThreadsOptions, + MessagePaginationOptions, + AscDesc, + GetRepliesAPIResponse, } from './types'; -import { addToMessageList, formatMessage } from './utils'; -import { SimpleStateStore } from './store/SimpleStateStore'; +import { addToMessageList, formatMessage, normalizeQuerySort } from './utils'; +import { InferStoreValueType, SimpleStateStore } from './store/SimpleStateStore'; type ThreadReadStatus = Record< string, @@ -25,25 +30,48 @@ type ThreadReadStatus +type QueryRepliesApiResponse = GetRepliesAPIResponse; + +type ThreadState = { + createdAt: string; + deletedAt: string; + latestReplies: Array>; + loadingNextPage: boolean; + loadingPreviousPage: boolean; + nextId: string | null; + parentMessage: FormatMessageResponse | undefined; + participants: ThreadResponse['thread_participants']; + previousId: string | null; + read: ThreadReadStatus; + replyCount: number; + updatedAt: string; + + channel?: Channel; + channelData?: ThreadResponse['channel']; +}; + export class Thread { - public readonly state: SimpleStateStore<{ - channel: Channel; - channelData: ThreadResponse['channel']; - createdAt: string; - deletedAt: string; - latestReplies: Array>; - parentMessage: FormatMessageResponse | undefined; - participants: ThreadResponse['thread_participants']; - read: ThreadReadStatus; - replyCount: number; - updatedAt: string; - }>; + public readonly state: SimpleStateStore>; public id: string; private client: StreamChat; + private unsubscribeFunctions: Set<() => void> = new Set(); - constructor(client: StreamChat, threadData: ThreadResponse) { + constructor({ + client, + threadData = {}, + registerEventHandlers = true, + }: { + client: StreamChat; + registerEventHandlers?: boolean; + threadData?: Partial>; + }) { // TODO: move to function "formatReadStatus" - const { read: unformattedRead = [] } = threadData; + const { + read: unformattedRead = [], + latest_replies: latestReplies = [], + thread_participants: threadParticipants = [], + reply_count: replyCount = 0, + } = threadData; // TODO: check why this one is sometimes undefined (should return empty array instead) const read = unformattedRead.reduce>((pv, cv) => { pv[cv.user.id] ??= { @@ -53,49 +81,91 @@ export class Thread>({ channelData: threadData.channel, // not channel instance - channel: client.channel(threadData.channel.type, threadData.channel.id), - createdAt: threadData.created_at, - deletedAt: threadData.deleted_at, - latestReplies: threadData.latest_replies.map(formatMessage), + channel: threadData.channel && client.channel(threadData.channel.type, threadData.channel.id), + createdAt: threadData.created_at ?? placeholderDate, + deletedAt: threadData.deleted_at ?? placeholderDate, + latestReplies: latestReplies.map(formatMessage), // TODO: check why this is sometimes undefined parentMessage: threadData.parent_message && formatMessage(threadData.parent_message), - participants: threadData.thread_participants, + participants: threadParticipants, read, - replyCount: threadData.reply_count, - updatedAt: threadData.updated_at, + replyCount, + updatedAt: threadData.updated_at ?? placeholderDate, + + nextId: latestReplies.at(-1)?.id ?? null, + previousId: latestReplies.at(0)?.id ?? null, + loadingNextPage: false, + loadingPreviousPage: false, }); // parent_message_id is being re-used as thread.id - this.id = threadData.parent_message_id; + this.id = threadData.parent_message_id ?? `thread-${placeholderDate}`; // FIXME: use instead nanoid this.client = client; - // TODO: register WS listeners (message.new / reply ) - // client.on() + if (registerEventHandlers) this.registerEventHandlers(); } get channel() { return this.state.getLatestValue().channel; } - addReply = (message: MessageResponse) => { + private registerEventHandlers = () => { + this.unsubscribeFunctions.add( + this.client.on('notification.thread_message_new', (event) => { + if (!event.message) return; + if (event.message.parent_id !== this.id) return; + + this.addReply({ message: event.message }); + }).unsubscribe, + ); + + const handleMessageUpdate = (event: Event) => { + if (!event.message) return; + if (event.message.parent_id !== this.id) return; + + this.updateParentMessageOrReply(event.message); + }; + this.unsubscribeFunctions.add(this.client.on('message.updated', handleMessageUpdate).unsubscribe); + this.unsubscribeFunctions.add(this.client.on('message.deleted', handleMessageUpdate).unsubscribe); + this.unsubscribeFunctions.add(this.client.on('reaction.new', handleMessageUpdate).unsubscribe); + this.unsubscribeFunctions.add(this.client.on('reaction.deleted', handleMessageUpdate).unsubscribe); + }; + + private updateLocalState = >(key: keyof T, newValue: T[typeof key]) => { + this.state.next((current) => ({ + ...current, + [key]: newValue, + })); + }; + + // TODO: rename to upsert? + // does also update through addToMessageList function + addReply = ({ message }: { message: MessageResponse }) => { if (message.parent_id !== this.id) { throw new Error('Message does not belong to this thread'); } - this.state.next((pv) => ({ - ...pv, - latestReplies: addToMessageList(pv.latestReplies, formatMessage(message), true), + this.state.next((current) => ({ + ...current, + latestReplies: addToMessageList( + current.latestReplies, + formatMessage(message), + message.user?.id === this.client.user?.id, // deal with timestampChanged only related to local user (optimistic updates) + ), })); }; + /** + * @deprecated not sure whether we need this + */ updateReply = (message: MessageResponse) => { - this.state.next((pv) => ({ - ...pv, - latestReplies: pv.latestReplies.map((m) => { + this.state.next((current) => ({ + ...current, + latestReplies: current.latestReplies.map((m) => { if (m.id === message.id) return formatMessage(message); return m; }), @@ -107,9 +177,9 @@ export class Thread { - const newData = { ...pv, parentMessage: formatMessage(message) }; - // update channel if channelData change (unlikely but handled anyway) + this.state.next((current) => { + const newData = { ...current, parentMessage: formatMessage(message) }; + // update channel on channelData change (unlikely but handled anyway) if (message.channel) { newData['channel'] = this.client.channel(message.channel.type, message.channel.id); } @@ -117,7 +187,7 @@ export class Thread) { + updateParentMessageOrReply(message: MessageResponse) { if (message.parent_id === this.id) { this.updateReply(message); } @@ -134,13 +204,13 @@ export class Thread ({ - ...pv, - latestReplies: pv.latestReplies.map((reply) => { + this.state.next((current) => ({ + ...current, + latestReplies: current.latestReplies.map((reply) => { if (reply.id !== message.id) return reply; // FIXME: this addReaction API weird (maybe clean it up later) - const updatedMessage = pv.channel.state.addReaction(reaction, message, enforceUnique); + const updatedMessage = current.channel?.state.addReaction(reaction, message, enforceUnique); if (updatedMessage) return formatMessage(updatedMessage); return reply; @@ -151,13 +221,13 @@ export class Thread, message?: MessageResponse) { if (!message) return; - this.state.next((pv) => ({ - ...pv, - latestReplies: pv.latestReplies.map((reply) => { + this.state.next((current) => ({ + ...current, + latestReplies: current.latestReplies.map((reply) => { if (reply.id !== message.id) return reply; // FIXME: this removeReaction API is weird (maybe clean it up later) - const updatedMessage = pv.channel.state.removeReaction(reaction, message); + const updatedMessage = current.channel?.state.removeReaction(reaction, message); if (updatedMessage) return formatMessage(updatedMessage); return reply; @@ -165,21 +235,125 @@ export class Thread['getReplies']>['1']; - sort: Parameters['getReplies']>['2']; - }) => { - // todo: loading/error states - const vals = await this.channel.getReplies(this.id, options, sort); + sort?: { created_at: AscDesc }[]; + } & MessagePaginationOptions & { user?: UserResponse; user_id?: string } = {}) => + this.client.get>(`${this.client.baseURL}/messages/${this.id}/replies`, { + sort: normalizeQuerySort(sort), + ...otherOptions, + }); + + loadNextPage = async (/* TODO: options? */) => { + this.updateLocalState('loadingNextPage', true); + + const { loadingNextPage, nextId } = this.state.getLatestValue(); + + if (loadingNextPage || nextId === null) return; + + try { + const data = await this.queryReplies({ + id_gt: nextId, + limit: 10, + }); + } catch (error) { + this.client.logger('error', (error as Error).message); + } finally { + this.updateLocalState('loadingNextPage', false); + } + }; + + loadPreviousPage = async (/* TODO: options? */) => { + const { loadingPreviousPage, previousId } = this.state.getLatestValue(); + if (loadingPreviousPage || previousId === null) return; + + this.updateLocalState('loadingPreviousPage', true); + + try { + const data = await this.queryReplies({ + id_lt: previousId, + limit: 10, + }); + + this.state.next((current) => ({ + ...current, + latestReplies: data.messages.map(formatMessage).concat(current.latestReplies), + // TODO: previousId: res.len < opts.limit ? null : res.at(0).id + })); + } catch (error) { + this.client.logger('error', (error as Error).message); + console.log(error); + } finally { + this.updateLocalState('loadingPreviousPage', false); + } + }; +} + +// TODO: +// class ThreadState +// class ThreadManagerState + +export class ThreadManager { + public readonly state: SimpleStateStore<{ + loadingNextPage: boolean; + loadingPreviousPage: boolean; + threads: Thread[]; + unreadCount: number; + nextId?: string | null; // null means no next page available + previousId?: string | null; + }>; + private client: StreamChat; + + constructor({ client }: { client: StreamChat }) { + this.client = client; + this.state = new SimpleStateStore({ + threads: [] as Thread[], + unreadCount: 0, + loadingNextPage: false as boolean, // WHAT IN THE FUCK? + loadingPreviousPage: false as boolean, + // nextId: undefined, + // previousId: undefined, + }); + } + + // TODO: maybe will use? + // private threadIndexMap = new Map(); + + // remove `next` from options as that is handled internally + public loadNextPage = async (options: Omit = {}) => { + const { nextId, loadingNextPage } = this.state.getLatestValue(); + + if (nextId === null || loadingNextPage) return; + + // FIXME: redo defaults + const optionsWithDefaults: QueryThreadsOptions = { + limit: 10, + participant_limit: 10, + reply_limit: 10, + watch: true, + next: nextId, + ...options, + }; + + this.state.next((current) => ({ ...current, loadingNextPage: true })); + + try { + const data = await this.client.queryThreads(optionsWithDefaults); + this.state.next((current) => ({ + ...current, + threads: current.threads.concat(data.threads), + nextId: data.next ?? null, + })); + } catch (error) { + this.client.logger('error', (error as Error).message); + } finally { + this.state.next((current) => ({ ...current, loadingNextPage: false })); + } }; - // TODO: impl - loadPrevious = () => { - // ... + public loadPreviousPage = () => { + // TODO: impl }; } diff --git a/src/utils.ts b/src/utils.ts index 9aa2926a90..128264c93b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -382,8 +382,7 @@ export function addToMessageList( } } - // Do not add updated or deleted messages to the list if they do not already exist - // or have a timestamp change. + // do not add updated or deleted messages to the list if they already exist or come with a timestamp change if (addMessageToList) { newMessages.splice(insertionIndex, 0, newMessage); } diff --git a/test/unit/thread.js b/test/unit/thread.js index 487f47274a..f06a751238 100644 --- a/test/unit/thread.js +++ b/test/unit/thread.js @@ -27,7 +27,7 @@ describe('Thread', () => { client.userID = 'observer'; channel = generateChannel().channel; parent = generateMsg(); - thread = new Thread(client, generateThread(channel, parent)); + thread = new Thread({ client, threadData: generateThread(channel, parent) }); }); it('should throw error if the message is not a reply to the parent', async () => { const reply = generateMsg({ From 23bf70aee076c9c31b13af08dbc60aaad0eaba3a Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Tue, 28 May 2024 18:14:50 +0200 Subject: [PATCH 03/41] Adjustments to state handlers --- src/thread.ts | 252 +++++++++++++++++++++++++++++--------------------- src/utils.ts | 14 +-- 2 files changed, 153 insertions(+), 113 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index 9bd9af95db..bcb624e273 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -6,7 +6,7 @@ import { MessageResponse, ThreadResponse, FormatMessageResponse, - ReactionResponse, + // ReactionResponse, UserResponse, Event, QueryThreadsOptions, @@ -28,28 +28,33 @@ type ThreadReadStatus; -// const formatReadState = () => - type QueryRepliesApiResponse = GetRepliesAPIResponse; -type ThreadState = { +type QueryRepliesOptions = { + sort?: { created_at: AscDesc }[]; +} & MessagePaginationOptions & { user?: UserResponse; user_id?: string }; + +type ThreadState = { createdAt: string; deletedAt: string; - latestReplies: Array>; + latestReplies: Array>; loadingNextPage: boolean; loadingPreviousPage: boolean; nextId: string | null; - parentMessage: FormatMessageResponse | undefined; - participants: ThreadResponse['thread_participants']; + parentMessage: FormatMessageResponse | undefined; + participants: ThreadResponse['thread_participants']; previousId: string | null; - read: ThreadReadStatus; + read: ThreadReadStatus; replyCount: number; updatedAt: string; - channel?: Channel; - channelData?: ThreadResponse['channel']; + channel?: Channel; + channelData?: ThreadResponse['channel']; }; +const DEFAULT_PAGE_LIMIT = 15; +const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; + export class Thread { public readonly state: SimpleStateStore>; public id: string; @@ -65,14 +70,15 @@ export class Thread>; }) { - // TODO: move to function "formatReadStatus" const { + // TODO: check why this one is sometimes undefined (should return empty array instead) read: unformattedRead = [], latest_replies: latestReplies = [], thread_participants: threadParticipants = [], reply_count: replyCount = 0, } = threadData; - // TODO: check why this one is sometimes undefined (should return empty array instead) + + // TODO: move to a function "formatReadStatus" and figure out whether this format is even useful const read = unformattedRead.reduce>((pv, cv) => { pv[cv.user.id] ??= { ...cv, @@ -103,9 +109,10 @@ export class Thread { + /** + * Makes Thread instance listen to events and adjust its state accordingly. + */ + public registerEventHandlers = () => { + // check whether this instance has subscriptions and is already listening for changes + if (this.unsubscribeFunctions.size) return; + this.unsubscribeFunctions.add( this.client.on('notification.thread_message_new', (event) => { if (!event.message) return; if (event.message.parent_id !== this.id) return; - this.addReply({ message: event.message }); + // deal with timestampChanged only related to local user (optimistic updates) + this.upsertReply({ message: event.message, timestampChanged: event.message.user?.id === this.client.user?.id }); }).unsubscribe, ); @@ -129,10 +143,15 @@ export class Thread { + this.unsubscribeFunctions.add(this.client.on(eventType, handleMessageUpdate).unsubscribe); + }); + }; + + public deregisterEventHandlers = () => { + this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); + // TODO: stop watching }; private updateLocalState = >(key: keyof T, newValue: T[typeof key]) => { @@ -142,33 +161,20 @@ export class Thread }) => { + upsertReply = ({ + message, + timestampChanged = false, + }: { + message: MessageResponse | FormatMessageResponse; + timestampChanged?: boolean; + }) => { if (message.parent_id !== this.id) { throw new Error('Message does not belong to this thread'); } this.state.next((current) => ({ ...current, - latestReplies: addToMessageList( - current.latestReplies, - formatMessage(message), - message.user?.id === this.client.user?.id, // deal with timestampChanged only related to local user (optimistic updates) - ), - })); - }; - - /** - * @deprecated not sure whether we need this - */ - updateReply = (message: MessageResponse) => { - this.state.next((current) => ({ - ...current, - latestReplies: current.latestReplies.map((m) => { - if (m.id === message.id) return formatMessage(message); - return m; - }), + latestReplies: addToMessageList(current.latestReplies, formatMessage(message), timestampChanged), })); }; @@ -179,74 +185,95 @@ export class Thread { const newData = { ...current, parentMessage: formatMessage(message) }; + // update channel on channelData change (unlikely but handled anyway) if (message.channel) { + newData['channelData'] = message.channel; newData['channel'] = this.client.channel(message.channel.type, message.channel.id); } + return newData; }); }; - updateParentMessageOrReply(message: MessageResponse) { + updateParentMessageOrReply = (message: MessageResponse) => { if (message.parent_id === this.id) { - this.updateReply(message); + this.upsertReply({ message }); } if (!message.parent_id && message.id === this.id) { this.updateParentMessage(message); } - } - - addReaction( - reaction: ReactionResponse, - message?: MessageResponse, - enforceUnique?: boolean, - ) { - if (!message) return; - - this.state.next((current) => ({ - ...current, - latestReplies: current.latestReplies.map((reply) => { - if (reply.id !== message.id) return reply; - - // FIXME: this addReaction API weird (maybe clean it up later) - const updatedMessage = current.channel?.state.addReaction(reaction, message, enforceUnique); - if (updatedMessage) return formatMessage(updatedMessage); - - return reply; - }), - })); - } - - removeReaction(reaction: ReactionResponse, message?: MessageResponse) { - if (!message) return; - - this.state.next((current) => ({ - ...current, - latestReplies: current.latestReplies.map((reply) => { - if (reply.id !== message.id) return reply; - - // FIXME: this removeReaction API is weird (maybe clean it up later) - const updatedMessage = current.channel?.state.removeReaction(reaction, message); - if (updatedMessage) return formatMessage(updatedMessage); + }; - return reply; - }), - })); - } + /* + TODO: merge and rename to toggleReaction instead (used for optimistic updates and WS only) + & move optimistic logic from stream-chat-react to here + */ + // addReaction = ({ + // reaction, + // message, + // enforceUnique, + // }: { + // reaction: ReactionResponse; + // enforceUnique?: boolean; + // message?: MessageResponse; + // }) => { + // if (!message) return; + + // this.state.next((current) => ({ + // ...current, + // latestReplies: current.latestReplies.map((reply) => { + // if (reply.id !== message.id) return reply; + + // // FIXME: this addReaction API weird (maybe clean it up later) + // const updatedMessage = current.channel?.state.addReaction(reaction, message, enforceUnique); + // if (updatedMessage) return formatMessage(updatedMessage); + + // return reply; + // }), + // })); + // }; + + // removeReaction = (reaction: ReactionResponse, message?: MessageResponse) => { + // if (!message) return; + + // this.state.next((current) => ({ + // ...current, + // latestReplies: current.latestReplies.map((reply) => { + // if (reply.id !== message.id) return reply; + + // // FIXME: this removeReaction API is weird (maybe clean it up later) + // const updatedMessage = current.channel?.state.removeReaction(reaction, message); + // if (updatedMessage) return formatMessage(updatedMessage); + + // return reply; + // }), + // })); + // }; + + public markAsRead = () => { + // TODO: impl + }; + // moved from channel to thread directly (skipped getClient thing as this call does not need active WS connection) public queryReplies = ({ - sort = [{ created_at: -1 }], + sort = DEFAULT_SORT, + limit = DEFAULT_PAGE_LIMIT, ...otherOptions - }: { - sort?: { created_at: AscDesc }[]; - } & MessagePaginationOptions & { user?: UserResponse; user_id?: string } = {}) => + }: QueryRepliesOptions = {}) => this.client.get>(`${this.client.baseURL}/messages/${this.id}/replies`, { sort: normalizeQuerySort(sort), + limit, ...otherOptions, }); - loadNextPage = async (/* TODO: options? */) => { + // loadNextPage and loadPreviousPage rely on pagination id's calculated from previous requests + // these functions exclude these options (id_lt, id_lte...) from their options to prevent unexpected pagination behavior + loadNextPage = async ({ + sort, + limit = DEFAULT_PAGE_LIMIT, + }: Pick, 'sort' | 'limit'> = {}) => { this.updateLocalState('loadingNextPage', true); const { loadingNextPage, nextId } = this.state.getLatestValue(); @@ -256,8 +283,17 @@ export class Thread ({ + ...current, + latestReplies: current.latestReplies.concat(data.messages.map(formatMessage)), + nextId: data.messages.length < limit || !lastMessageId ? null : lastMessageId, + })); } catch (error) { this.client.logger('error', (error as Error).message); } finally { @@ -265,8 +301,12 @@ export class Thread { + loadPreviousPage = async ({ + sort, + limit = DEFAULT_PAGE_LIMIT, + }: Pick, 'sort' | 'limit'> = {}) => { const { loadingPreviousPage, previousId } = this.state.getLatestValue(); + if (loadingPreviousPage || previousId === null) return; this.updateLocalState('loadingPreviousPage', true); @@ -274,13 +314,16 @@ export class Thread ({ ...current, latestReplies: data.messages.map(formatMessage).concat(current.latestReplies), - // TODO: previousId: res.len < opts.limit ? null : res.at(0).id + previousId: data.messages.length < limit || !firstMessageId ? null : firstMessageId, })); } catch (error) { this.client.logger('error', (error as Error).message); @@ -291,34 +334,35 @@ export class Thread = { + loadingNextPage: boolean; + loadingPreviousPage: boolean; + threads: Thread[]; + unreadCount: number; + nextId?: string | null; // null means no next page available + previousId?: string | null; +}; + export class ThreadManager { - public readonly state: SimpleStateStore<{ - loadingNextPage: boolean; - loadingPreviousPage: boolean; - threads: Thread[]; - unreadCount: number; - nextId?: string | null; // null means no next page available - previousId?: string | null; - }>; + public readonly state: SimpleStateStore>; private client: StreamChat; constructor({ client }: { client: StreamChat }) { this.client = client; - this.state = new SimpleStateStore({ + this.state = new SimpleStateStore>({ threads: [] as Thread[], unreadCount: 0, - loadingNextPage: false as boolean, // WHAT IN THE FUCK? - loadingPreviousPage: false as boolean, - // nextId: undefined, - // previousId: undefined, + loadingNextPage: false, + loadingPreviousPage: false, + nextId: undefined, + previousId: undefined, }); } - // TODO: maybe will use? // private threadIndexMap = new Map(); // remove `next` from options as that is handled internally diff --git a/src/utils.ts b/src/utils.ts index 128264c93b..e0ae91fbe2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -283,7 +283,7 @@ export const axiosParamsSerializer: AxiosRequestConfig['paramsSerializer'] = (pa * @param {MessageResponse} message `MessageResponse` object */ export function formatMessage( - message: MessageResponse, + message: MessageResponse | FormatMessageResponse, ): FormatMessageResponse { return { ...message, @@ -348,10 +348,8 @@ export function addToMessageList( } // for empty list just concat and return unless it's an update or deletion - if (!newMessages.length) { - if (addMessageToList) return newMessages.concat(newMessage); - - return newMessages; + if (!newMessages.length && addMessageToList) { + return newMessages.concat(newMessage); } // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -360,10 +358,8 @@ export function addToMessageList( const messageIsNewest = newMessages.at(-1)![sortBy]!.getTime() < messageTime; // if message is newer than last item in the list concat and return unless it's an update or deletion - if (messageIsNewest) { - if (addMessageToList) return newMessages.concat(newMessage); - - return newMessages; + if (messageIsNewest && addMessageToList) { + return newMessages.concat(newMessage); } // find the closest index to push the new message From c2350c24990db5356def06a347d5fa6e3a85a88e Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Mon, 10 Jun 2024 14:12:09 +0200 Subject: [PATCH 04/41] Add loadUnreadThreads and adjust logic --- src/client.ts | 6 +- src/store/SimpleStateStore.ts | 12 ++- src/thread.ts | 174 +++++++++++++++++++++++++++++----- 3 files changed, 164 insertions(+), 28 deletions(-) diff --git a/src/client.ts b/src/client.ts index 6560ed8c3e..fe68e83dec 100644 --- a/src/client.ts +++ b/src/client.ts @@ -339,8 +339,7 @@ export class StreamChat null; this.recoverStateOnReconnect = this.options.recoverStateOnReconnect; + + // reusing the same name the channel has (Channel.threads) + this.threads = new ThreadManager({ client: this }); } /** diff --git a/src/store/SimpleStateStore.ts b/src/store/SimpleStateStore.ts index c81b3ee370..7579f473c4 100644 --- a/src/store/SimpleStateStore.ts +++ b/src/store/SimpleStateStore.ts @@ -8,6 +8,10 @@ export type InferStoreValueType = T extends SimpleStateStore ? L : never; +export type StoreSelector = ( + nextValue: T extends SimpleStateStore ? S : never, +) => readonly typeof nextValue[keyof typeof nextValue][]; + function isPatch(value: T | Patch): value is Patch { return typeof value === 'function'; } @@ -72,11 +76,11 @@ export class SimpleStateStore< const hasUnequalMembers = selectedValues?.some((value, index) => value !== newlySelectedValues[index]); + selectedValues = newlySelectedValues; + // initial subscription call begins with hasUnequalMembers as undefined (skip comparison), fallback to unset selectedValues - if (hasUnequalMembers || !selectedValues) { - // skip initial handler call unless explicitly asked for (emitOnSubscribe) - if (selectedValues || (!selectedValues && emitOnSubscribe)) handler(newlySelectedValues); - selectedValues = newlySelectedValues; + if (hasUnequalMembers || (typeof hasUnequalMembers === 'undefined' && emitOnSubscribe)) { + handler(newlySelectedValues); } }; diff --git a/src/thread.ts b/src/thread.ts index bcb624e273..2c1e4abc60 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -55,6 +55,20 @@ type ThreadState = { const DEFAULT_PAGE_LIMIT = 15; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; +/** + * IDEA: request batching + * + * When the internet connection drops and during downtime threads receive messages, each thread instance should + * do a re-fetch with the latest known message in its list (loadNextPage) once connection restores. In case there are 20+ + * thread instances this would cause a creation of 20+requests, instead going through a "batching" mechanism this would be aggregated + * and requested only once. + * + * batched req: {[threadId]: { id_gt: "lastKnownMessageId" }, ...} + * batched res: {[threadId]: { messages: [...] }, ...} + * + * Obviously this requires BE support and a batching mechanism on the client-side. + */ + export class Thread { public readonly state: SimpleStateStore>; public id: string; @@ -64,10 +78,10 @@ export class Thread; - registerEventHandlers?: boolean; + registerSubscriptions?: boolean; threadData?: Partial>; }) { const { @@ -109,21 +123,28 @@ export class Thread>(key: keyof T, newValue: T[typeof key]) => { + this.state.next((current) => ({ + ...current, + [key]: newValue, + })); + }; + /** * Makes Thread instance listen to events and adjust its state accordingly. */ - public registerEventHandlers = () => { + public registerSubscriptions = () => { // check whether this instance has subscriptions and is already listening for changes if (this.unsubscribeFunctions.size) return; @@ -149,19 +170,12 @@ export class Thread { + public deregisterSubscriptions = () => { this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); // TODO: stop watching }; - private updateLocalState = >(key: keyof T, newValue: T[typeof key]) => { - this.state.next((current) => ({ - ...current, - [key]: newValue, - })); - }; - - upsertReply = ({ + public upsertReply = ({ message, timestampChanged = false, }: { @@ -178,7 +192,7 @@ export class Thread) => { + public updateParentMessage = (message: MessageResponse) => { if (message.id !== this.id) { throw new Error('Message does not belong to this thread'); } @@ -196,7 +210,7 @@ export class Thread) => { + public updateParentMessageOrReply = (message: MessageResponse) => { if (message.parent_id === this.id) { this.upsertReply({ message }); } @@ -270,7 +284,7 @@ export class Thread, 'sort' | 'limit'> = {}) => { @@ -301,7 +315,7 @@ export class Thread, 'sort' | 'limit'> = {}) => { @@ -341,8 +355,12 @@ export class Thread = { loadingNextPage: boolean; loadingPreviousPage: boolean; + threadIdIndexMap: { [key: string]: number }; // TODO: maybe does not need to live here threads: Thread[]; - unreadCount: number; + unreadThreads: { + existingReorderedIds: string[]; + newIds: string[]; + }; nextId?: string | null; // null means no next page available previousId?: string | null; }; @@ -350,20 +368,132 @@ type ThreadManagerState = { export class ThreadManager { public readonly state: SimpleStateStore>; private client: StreamChat; + private unsubscribeFunctions: Set<() => void> = new Set(); constructor({ client }: { client: StreamChat }) { this.client = client; this.state = new SimpleStateStore>({ - threads: [] as Thread[], - unreadCount: 0, + threads: [], + threadIdIndexMap: {}, + unreadThreads: { + // new threads or threads which have not been loaded and is not possible to paginate to anymore + // as these threads received new replies which moved them up in the list - used for the badge + newIds: [], + // threads already loaded within the local state but will change positin in `threads` array when + // `loadUnreadThreads` gets called - used to calculate proper query limit + existingReorderedIds: [], + }, loadingNextPage: false, loadingPreviousPage: false, nextId: undefined, previousId: undefined, }); + + this.registerSubscriptions(); } - // private threadIndexMap = new Map(); + public registerSubscriptions = () => { + this.unsubscribeFunctions.add( + // re-generate map each time the threads array changes + this.state.subscribeWithSelector( + (nextValue) => [nextValue.threads], + ([threads]) => { + const newThreadIdIndexMap = threads.reduce((map, thread, index) => { + map[thread.id] ??= index; + return map; + }, {}); + + this.state.next((current) => ({ ...current, threadIdIndexMap: newThreadIdIndexMap })); + }, + true, + ), + ); + + const handleNewReply = (event: Event) => { + if (!event.message || !event.message.parent_id) return; + const parentId = event.message.parent_id; + + const { + threadIdIndexMap, + unreadThreads: { newIds, existingReorderedIds }, + } = this.state.getLatestValue(); + + const existsLocally = typeof threadIdIndexMap[parentId] !== 'undefined'; + + if (existsLocally && !existingReorderedIds.includes(parentId)) { + return this.state.next((current) => ({ + ...current, + unreadThreads: { + ...current.unreadThreads, + existingReorderedIds: current.unreadThreads.existingReorderedIds.concat(parentId), + }, + })); + } + + if (!existsLocally && !newIds.includes(parentId)) { + return this.state.next((current) => ({ + ...current, + unreadThreads: { + ...current.unreadThreads, + newIds: current.unreadThreads.newIds.concat(parentId), + }, + })); + } + }; + + this.unsubscribeFunctions.add(this.client.on('notification.thread_message_new', handleNewReply).unsubscribe); + // TODO: not sure, if this needs to be here, wait for Vish to do the check that "notification.thread_message_new" + // comes in as expected + this.unsubscribeFunctions.add(this.client.on('message.new', handleNewReply).unsubscribe); + }; + + public deregisterSubscriptions = () => { + this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); + }; + + public loadUnreadThreads = async () => { + const { + unreadThreads: { newIds, existingReorderedIds }, + } = this.state.getLatestValue(); + + const combinedLimit = newIds.length + existingReorderedIds.length; + + if (!combinedLimit) return; + + try { + const data = await this.client.queryThreads({ limit: combinedLimit }); + + // TODO: test thoroughly + this.state.next((current) => { + // merge existing and new threads, filter out re-ordered + + const newThreads: Thread[] = []; + + for (const thread of data.threads) { + const existingThread: Thread | undefined = current.threads[current.threadIdIndexMap[thread.id]]; + + newThreads.push(existingThread ?? thread); + + // TODO: remove from here once registration is moved to ThreadManager.registerThreadSubscriptions() and to orchestrate it all + if (existingThread) thread.deregisterSubscriptions(); + } + + const existingFilteredThreads = current.threads.filter((t) => + current.unreadThreads.existingReorderedIds.includes(t.id), + ); + + return { + ...current, + unreadThreadIds: { newIds: [], existingReorderedIds: [] }, // reset + threads: newThreads.concat(existingFilteredThreads), + }; + }); + } catch (error) { + // TODO: loading states + } finally { + console.log('...'); + } + }; // remove `next` from options as that is handled internally public loadNextPage = async (options: Omit = {}) => { From 501b8c126c5e7d930ac26cae220c114250062d2e Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Sat, 15 Jun 2024 02:50:07 +0200 Subject: [PATCH 05/41] New handlers and extending storage options --- src/store/SimpleStateStore.ts | 11 ++- src/thread.ts | 158 ++++++++++++++++++++++++---------- 2 files changed, 119 insertions(+), 50 deletions(-) diff --git a/src/store/SimpleStateStore.ts b/src/store/SimpleStateStore.ts index 7579f473c4..7ac8c2e023 100644 --- a/src/store/SimpleStateStore.ts +++ b/src/store/SimpleStateStore.ts @@ -1,5 +1,5 @@ type Patch = (value: T) => T; -type Handler = (nextValue: T) => any; +type Handler = (nextValue: T, previousValue?: T) => any; type Initiator = (get: SimpleStateStore['getLatestValue'], set: SimpleStateStore['next']) => T; export type InferStoreValueType = T extends SimpleStateStore @@ -40,9 +40,11 @@ export class SimpleStateStore< // do not notify subscribers if the value hasn't changed (or mutation has been returned) if (newValue === this.value) return; + + const oldValue = this.value; this.value = newValue; - this.handlerSet.forEach((handler) => handler(this.value)); + this.handlerSet.forEach((handler) => handler(this.value, oldValue)); }; public getLatestValue = () => this.value; @@ -66,7 +68,7 @@ export class SimpleStateStore< public subscribeWithSelector = ( selector: (nextValue: T) => O, handler: Handler, - emitOnSubscribe = false, + emitOnSubscribe = true, ) => { // begin with undefined to reduce amount of selector calls let selectedValues: O | undefined; @@ -76,11 +78,12 @@ export class SimpleStateStore< const hasUnequalMembers = selectedValues?.some((value, index) => value !== newlySelectedValues[index]); + const oldSelectedValues = selectedValues; selectedValues = newlySelectedValues; // initial subscription call begins with hasUnequalMembers as undefined (skip comparison), fallback to unset selectedValues if (hasUnequalMembers || (typeof hasUnequalMembers === 'undefined' && emitOnSubscribe)) { - handler(newlySelectedValues); + handler(newlySelectedValues, oldSelectedValues); } }; diff --git a/src/thread.ts b/src/thread.ts index 2c1e4abc60..c7ed7b4066 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -6,13 +6,13 @@ import { MessageResponse, ThreadResponse, FormatMessageResponse, - // ReactionResponse, UserResponse, Event, QueryThreadsOptions, MessagePaginationOptions, AscDesc, GetRepliesAPIResponse, + EventAPIResponse, } from './types'; import { addToMessageList, formatMessage, normalizeQuerySort } from './utils'; import { InferStoreValueType, SimpleStateStore } from './store/SimpleStateStore'; @@ -22,7 +22,7 @@ type ThreadReadStatus; } @@ -35,8 +35,8 @@ type QueryRepliesOptions = { } & MessagePaginationOptions & { user?: UserResponse; user_id?: string }; type ThreadState = { - createdAt: string; - deletedAt: string; + createdAt: Date; + deletedAt: Date | null; latestReplies: Array>; loadingNextPage: boolean; loadingPreviousPage: boolean; @@ -46,7 +46,7 @@ type ThreadState = { previousId: string | null; read: ThreadReadStatus; replyCount: number; - updatedAt: string; + updatedAt: Date | null; channel?: Channel; channelData?: ThreadResponse['channel']; @@ -60,8 +60,8 @@ const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; * * When the internet connection drops and during downtime threads receive messages, each thread instance should * do a re-fetch with the latest known message in its list (loadNextPage) once connection restores. In case there are 20+ - * thread instances this would cause a creation of 20+requests, instead going through a "batching" mechanism this would be aggregated - * and requested only once. + * thread instances this would cause a creation of 20+requests. Going through a "batching" mechanism instead - these + * requests would get aggregated and sent only once. * * batched req: {[threadId]: { id_gt: "lastKnownMessageId" }, ...} * batched res: {[threadId]: { messages: [...] }, ...} @@ -78,10 +78,8 @@ export class Thread; - registerSubscriptions?: boolean; threadData?: Partial>; }) { const { @@ -96,25 +94,26 @@ export class Thread>((pv, cv) => { pv[cv.user.id] ??= { ...cv, - lastRead: new Date(cv.last_read), + lastReadAt: new Date(cv.last_read), }; return pv; }, {}); - const placeholderDate = new Date().toISOString(); + const placeholderDate = new Date(); this.state = new SimpleStateStore>({ channelData: threadData.channel, // not channel instance channel: threadData.channel && client.channel(threadData.channel.type, threadData.channel.id), - createdAt: threadData.created_at ?? placeholderDate, - deletedAt: threadData.deleted_at ?? placeholderDate, + createdAt: threadData.created_at ? new Date(threadData.created_at) : placeholderDate, + // FIXME: tell Vish to propagate deleted at from parent message upwards + deletedAt: threadData.parent_message?.deleted_at?.length ? new Date(threadData.parent_message.deleted_at) : null, latestReplies: latestReplies.map(formatMessage), // TODO: check why this is sometimes undefined parentMessage: threadData.parent_message && formatMessage(threadData.parent_message), participants: threadParticipants, read, replyCount, - updatedAt: threadData.updated_at ?? placeholderDate, + updatedAt: threadData.updated_at?.length ? new Date(threadData.updated_at) : null, nextId: latestReplies.at(-1)?.id ?? null, previousId: latestReplies.at(0)?.id ?? null, @@ -125,9 +124,6 @@ export class Thread { + this.client.on('message.new', (event) => { if (!event.message) return; if (event.message.parent_id !== this.id) return; @@ -158,9 +154,36 @@ export class Thread { + if (!event.user || !event.created_at || !event.thread || !this.client.user) return; + if (event.user.id !== this.client.user.id) return; + if (event.thread.parent_message_id !== this.id) return; + + const currentUserId = event.user.id; + const createdAt = event.created_at; + const user = event.user; + + // FIXME: not sure if this is correct at all + this.state.next((current) => ({ + ...current, + read: { + ...current.read, + [currentUserId]: { + last_read: createdAt, + lastReadAt: new Date(createdAt), + user, + unread_messages: 0, + // TODO: fix this (lastestReplies.at(-1) might include message that is still being sent, which is wrong) + last_read_message_id: 'unknown', + }, + }, + })); + }).unsubscribe, + ); + const handleMessageUpdate = (event: Event) => { if (!event.message) return; - if (event.message.parent_id !== this.id) return; this.updateParentMessageOrReply(event.message); }; @@ -193,12 +216,26 @@ export class Thread) => { + const currentUserId = this.client.user!.id; + if (message.id !== this.id) { throw new Error('Message does not belong to this thread'); } this.state.next((current) => { - const newData = { ...current, parentMessage: formatMessage(message) }; + const newData: typeof current = { + ...current, + parentMessage: formatMessage(message), + replyCount: message.reply_count ?? current.replyCount, + // TODO: do not do this to "active" (visibly selected) threads, clean this up + read: { + ...current.read, + [currentUserId]: { + ...current.read[currentUserId], + unread_messages: current.read[currentUserId].unread_messages + 1, + }, + }, + }; // update channel on channelData change (unlikely but handled anyway) if (message.channel) { @@ -266,8 +303,21 @@ export class Thread { - // TODO: impl + public markAsRead = async () => { + const { channelData } = this.state.getLatestValue(); + + try { + await this.client.post>( + `${this.client.baseURL}/channels/${channelData?.type}/${channelData?.id}/read`, + { + thread_id: this.id, + }, + ); + } catch { + // ... + } finally { + // ... + } }; // moved from channel to thread directly (skipped getClient thing as this call does not need active WS connection) @@ -348,11 +398,11 @@ export class Thread = { + // TODO: implement network status handler (network down, isStateStale: true, reset to false once state has been refreshed) + // review lazy-reload approach (You're viewing de-synchronized thread, click here to refresh) or refresh on notification.thread_message_new + // reset threads approach (the easiest approach but has to be done with ThreadManager - drops all the state and loads anew) + isStateStale: boolean; loadingNextPage: boolean; loadingPreviousPage: boolean; threadIdIndexMap: { [key: string]: number }; // TODO: maybe does not need to live here @@ -379,7 +429,7 @@ export class ThreadManager { // new threads or threads which have not been loaded and is not possible to paginate to anymore // as these threads received new replies which moved them up in the list - used for the badge newIds: [], - // threads already loaded within the local state but will change positin in `threads` array when + // threads already loaded within the local state but will change position in `threads` array when // `loadUnreadThreads` gets called - used to calculate proper query limit existingReorderedIds: [], }, @@ -387,28 +437,48 @@ export class ThreadManager { loadingPreviousPage: false, nextId: undefined, previousId: undefined, + isStateStale: false, }); + // TODO: temporary - do not register handlers here but rather make Chat component have control over this this.registerSubscriptions(); } public registerSubscriptions = () => { + const handleThreadsChange = ( + [newThreads]: readonly [Thread[]], + previouslySelectedValue?: readonly [Thread[]], + ) => { + // create new threadIdIndexMap + const newThreadIdIndexMap = newThreads.reduce((map, thread, index) => { + map[thread.id] ??= index; + return map; + }, {}); + + // handle individual thread subscriptions + if (previouslySelectedValue) { + const [previousThreads] = previouslySelectedValue; + previousThreads.forEach((t) => { + // thread with registered handlers has been removed, deregister and let gc do its thing + if (typeof newThreadIdIndexMap[t.id] !== 'undefined') return; + t.deregisterSubscriptions(); + }); + } + newThreads.forEach((t) => t.registerSubscriptions()); + + // publish new threadIdIndexMap + this.state.next((current) => ({ ...current, threadIdIndexMap: newThreadIdIndexMap })); + }; + this.unsubscribeFunctions.add( // re-generate map each time the threads array changes - this.state.subscribeWithSelector( - (nextValue) => [nextValue.threads], - ([threads]) => { - const newThreadIdIndexMap = threads.reduce((map, thread, index) => { - map[thread.id] ??= index; - return map; - }, {}); - - this.state.next((current) => ({ ...current, threadIdIndexMap: newThreadIdIndexMap })); - }, - true, - ), + this.state.subscribeWithSelector((nextValue) => [nextValue.threads] as const, handleThreadsChange), ); + // TODO?: handle parent message deleted (extend unreadThreads \w deletedIds?) + // delete locally (manually) and run rest of the query loadUnreadThreads + // requires BE support (filter deleted threads) + const handleNewReply = (event: Event) => { if (!event.message || !event.message.parent_id) return; const parentId = event.message.parent_id; @@ -463,7 +533,6 @@ export class ThreadManager { try { const data = await this.client.queryThreads({ limit: combinedLimit }); - // TODO: test thoroughly this.state.next((current) => { // merge existing and new threads, filter out re-ordered @@ -473,18 +542,15 @@ export class ThreadManager { const existingThread: Thread | undefined = current.threads[current.threadIdIndexMap[thread.id]]; newThreads.push(existingThread ?? thread); - - // TODO: remove from here once registration is moved to ThreadManager.registerThreadSubscriptions() and to orchestrate it all - if (existingThread) thread.deregisterSubscriptions(); } - const existingFilteredThreads = current.threads.filter((t) => - current.unreadThreads.existingReorderedIds.includes(t.id), + const existingFilteredThreads = current.threads.filter( + ({ id }) => !current.unreadThreads.existingReorderedIds.includes(id), ); return { ...current, - unreadThreadIds: { newIds: [], existingReorderedIds: [] }, // reset + unreadThreads: { newIds: [], existingReorderedIds: [] }, // reset threads: newThreads.concat(existingFilteredThreads), }; }); From f5b272f2ab6c8bcfd3626b476fe75f5cd6fd1478 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 21 Jun 2024 00:52:19 +0200 Subject: [PATCH 06/41] New handlers and fixes to existing ones --- src/store/SimpleStateStore.ts | 4 +- src/thread.ts | 197 ++++++++++++++++++++++++++-------- 2 files changed, 155 insertions(+), 46 deletions(-) diff --git a/src/store/SimpleStateStore.ts b/src/store/SimpleStateStore.ts index 7ac8c2e023..16a486be42 100644 --- a/src/store/SimpleStateStore.ts +++ b/src/store/SimpleStateStore.ts @@ -1,5 +1,5 @@ -type Patch = (value: T) => T; -type Handler = (nextValue: T, previousValue?: T) => any; +export type Patch = (value: T) => T; +export type Handler = (nextValue: T, previousValue?: T) => any; type Initiator = (get: SimpleStateStore['getLatestValue'], set: SimpleStateStore['next']) => T; export type InferStoreValueType = T extends SimpleStateStore diff --git a/src/thread.ts b/src/thread.ts index c7ed7b4066..7a8f601871 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,3 +1,5 @@ +import throttle from 'lodash.throttle'; + import { StreamChat } from './client'; import { Channel } from './channel'; import { @@ -15,18 +17,17 @@ import { EventAPIResponse, } from './types'; import { addToMessageList, formatMessage, normalizeQuerySort } from './utils'; -import { InferStoreValueType, SimpleStateStore } from './store/SimpleStateStore'; +import { Handler, InferStoreValueType, Patch, SimpleStateStore } from './store/SimpleStateStore'; -type ThreadReadStatus = Record< - string, - { +type ThreadReadStatus = { + [key: string]: { last_read: string; last_read_message_id: string; lastReadAt: Date; unread_messages: number; user: UserResponse; - } ->; + }; +}; type QueryRepliesApiResponse = GetRepliesAPIResponse; @@ -37,6 +38,7 @@ type QueryRepliesOptions = { type ThreadState = { createdAt: Date; deletedAt: Date | null; + isStateStale: boolean; latestReplies: Array>; loadingNextPage: boolean; loadingPreviousPage: boolean; @@ -53,10 +55,12 @@ type ThreadState = { }; const DEFAULT_PAGE_LIMIT = 15; +const DEFAULT_MARK_AS_READ_THROTTLE_DURATION = 1000; +const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; /** - * IDEA: request batching + * Request batching? * * When the internet connection drops and during downtime threads receive messages, each thread instance should * do a re-fetch with the latest known message in its list (loadNextPage) once connection restores. In case there are 20+ @@ -72,6 +76,10 @@ const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; export class Thread { public readonly state: SimpleStateStore>; public id: string; + // used as handler helper - actively mark read all of the incoming messages + // if the thread is active (visibly selected in the UI) + // TODO: figure out whether this API will work with the scrollable list (based on visibility maybe?) + public active = false; private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); @@ -119,6 +127,10 @@ export class Thread>(key: keyof T, newValue: T[typeof key]) => { this.state.next((current) => ({ ...current, @@ -137,6 +153,14 @@ export class Thread { + this.active = true; + }; + + public deactivate = () => { + this.active = false; + }; + /** * Makes Thread instance listen to events and adjust its state accordingly. */ @@ -144,23 +168,75 @@ export class Thread [nextValue.read[currentuserId]?.unread_messages], + ([unreadMessagesCount]) => { + if (!this.active || !unreadMessagesCount) return; + + throttledMarkAsRead(); + }, + ), + ); + + this.unsubscribeFunctions.add( + // TODO: figure out why we're not receiving this event + this.client.on('user.watching.stop', (event) => { + if (!event.channel_id) return; + + const { channelData } = this.state.getLatestValue(); + + if (!channelData || event.channel_id !== channelData.id) return; + + this.updateLocalState('isStateStale', true); + }).unsubscribe, + ); + this.unsubscribeFunctions.add( this.client.on('message.new', (event) => { - if (!event.message) return; + const currentUserId = this.client.user?.id; + if (!event.message || !currentUserId) return; if (event.message.parent_id !== this.id) return; - // deal with timestampChanged only related to local user (optimistic updates) - this.upsertReply({ message: event.message, timestampChanged: event.message.user?.id === this.client.user?.id }); + let updateUnreadMessagesCount: Patch> | undefined; + // TODO: permissions (read events) + // only define side effect function if the message does not belong to the current user + if (event.message.user?.id !== currentUserId) { + updateUnreadMessagesCount = (current) => ({ + ...current, + read: { + ...current.read, + [currentUserId]: { + ...current.read[currentUserId], + unread_messages: (current.read[currentUserId]?.unread_messages ?? 0) + 1, + }, + }, + }); + } + + this.upsertReply({ + message: event.message, + // deal with timestampChanged only related to local user (optimistic updates) + timestampChanged: event.message.user?.id === this.client.user?.id, + stateUpdateSideEffect: updateUnreadMessagesCount, + }); }).unsubscribe, ); this.unsubscribeFunctions.add( this.client.on('message.read', (event) => { - if (!event.user || !event.created_at || !event.thread || !this.client.user) return; - if (event.user.id !== this.client.user.id) return; + if (!event.user || !event.created_at || !event.thread) return; if (event.thread.parent_message_id !== this.id) return; - const currentUserId = event.user.id; + const userId = event.user.id; const createdAt = event.created_at; const user = event.user; @@ -169,10 +245,11 @@ export class Thread | FormatMessageResponse; + // FIXME: not sure if I like this - THE WHY: main function of this method should be to add/update the message that's coming in + // but if the message that's coming in is not ours we would also like to increase the unread count - one atomic update seems + // better than two separate ones + /** + * State-updating side effect + */ + stateUpdateSideEffect?: Patch>; timestampChanged?: boolean; }) => { if (message.parent_id !== this.id) { @@ -212,12 +297,11 @@ export class Thread ({ ...current, latestReplies: addToMessageList(current.latestReplies, formatMessage(message), timestampChanged), + ...stateUpdateSideEffect?.(current), })); }; public updateParentMessage = (message: MessageResponse) => { - const currentUserId = this.client.user!.id; - if (message.id !== this.id) { throw new Error('Message does not belong to this thread'); } @@ -227,14 +311,6 @@ export class Thread { - const { channelData } = this.state.getLatestValue(); + const { channelData, read } = this.state.getLatestValue(); + const currentUserId = this.client.user?.id; + + const { unread_messages: unreadMessagesCount } = (currentUserId && read[currentUserId]) || {}; + + if (!unreadMessagesCount) return; try { await this.client.post>( @@ -399,10 +480,6 @@ export class Thread = { - // TODO: implement network status handler (network down, isStateStale: true, reset to false once state has been refreshed) - // review lazy-reload approach (You're viewing de-synchronized thread, click here to refresh) or refresh on notification.thread_message_new - // reset threads approach (the easiest approach but has to be done with ThreadManager - drops all the state and loads anew) - isStateStale: boolean; loadingNextPage: boolean; loadingPreviousPage: boolean; threadIdIndexMap: { [key: string]: number }; // TODO: maybe does not need to live here @@ -437,7 +514,6 @@ export class ThreadManager { loadingPreviousPage: false, nextId: undefined, previousId: undefined, - isStateStale: false, }); // TODO: temporary - do not register handlers here but rather make Chat component have control over this @@ -445,10 +521,33 @@ export class ThreadManager { } public registerSubscriptions = () => { - const handleThreadsChange = ( - [newThreads]: readonly [Thread[]], - previouslySelectedValue?: readonly [Thread[]], - ) => { + if (this.unsubscribeFunctions.size) return; + + // TODO: maybe debounce instead? + const throttledHandleConnectionRecovery = throttle( + () => { + // TODO: cancel possible in-progress queries (loadNextPage...) + + this.state.next((current) => ({ + ...current, + threads: [], + unreadThreads: { newIds: [], existingReorderedIds: [] }, + nextId: undefined, + previousId: undefined, + isStateStale: false, + })); + + this.loadNextPage(); + }, + DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, + { leading: true, trailing: true }, + ); + + this.unsubscribeFunctions.add( + this.client.on('connection.recovered', throttledHandleConnectionRecovery).unsubscribe, + ); + + const handleThreadsChange: Handler[]]> = ([newThreads], previouslySelectedValue) => { // create new threadIdIndexMap const newThreadIdIndexMap = newThreads.reduce((map, thread, index) => { map[thread.id] ??= index; @@ -459,9 +558,11 @@ export class ThreadManager { if (previouslySelectedValue) { const [previousThreads] = previouslySelectedValue; previousThreads.forEach((t) => { - // thread with registered handlers has been removed, deregister and let gc do its thing - if (typeof newThreadIdIndexMap[t.id] !== 'undefined') return; - t.deregisterSubscriptions(); + // thread with registered handlers has been removed or its signature changed (new instance) + // deregister and let gc do its thing + if (typeof newThreadIdIndexMap[t.id] === 'undefined' || newThreads[newThreadIdIndexMap[t.id]] !== t) { + t.deregisterSubscriptions(); + } }); } newThreads.forEach((t) => t.registerSubscriptions()); @@ -485,9 +586,15 @@ export class ThreadManager { const { threadIdIndexMap, + nextId, + threads, unreadThreads: { newIds, existingReorderedIds }, } = this.state.getLatestValue(); + // prevents from handling replies until the threads have been loaded + // (does not fill information for "unread threads" banner to appear) + if (!threads.length && nextId !== null) return; + const existsLocally = typeof threadIdIndexMap[parentId] !== 'undefined'; if (existsLocally && !existingReorderedIds.includes(parentId)) { @@ -512,15 +619,13 @@ export class ThreadManager { }; this.unsubscribeFunctions.add(this.client.on('notification.thread_message_new', handleNewReply).unsubscribe); - // TODO: not sure, if this needs to be here, wait for Vish to do the check that "notification.thread_message_new" - // comes in as expected - this.unsubscribeFunctions.add(this.client.on('message.new', handleNewReply).unsubscribe); }; public deregisterSubscriptions = () => { this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); }; + // TODO: add activity status, trigger this method when this instance becomes active public loadUnreadThreads = async () => { const { unreadThreads: { newIds, existingReorderedIds }, @@ -537,16 +642,19 @@ export class ThreadManager { // merge existing and new threads, filter out re-ordered const newThreads: Thread[] = []; + const existingThreadIdsToFilterOut: string[] = []; for (const thread of data.threads) { const existingThread: Thread | undefined = current.threads[current.threadIdIndexMap[thread.id]]; - newThreads.push(existingThread ?? thread); + // ditch threads which report stale state and use new one + // *(state can be considered as stale when channel associated with the thread stops being watched) + newThreads.push(existingThread && existingThread.hasStaleState ? thread : existingThread); + + if (existingThread) existingThreadIdsToFilterOut.push(existingThread.id); } - const existingFilteredThreads = current.threads.filter( - ({ id }) => !current.unreadThreads.existingReorderedIds.includes(id), - ); + const existingFilteredThreads = current.threads.filter(({ id }) => !existingThreadIdsToFilterOut.includes(id)); return { ...current, @@ -556,6 +664,7 @@ export class ThreadManager { }); } catch (error) { // TODO: loading states + console.error(error); } finally { console.log('...'); } From 2c26384fcfd835555d4d14a91518169802b415e4 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 21 Jun 2024 00:59:10 +0200 Subject: [PATCH 07/41] Upgrade TS, adjust conf and code, add lodash/throttle --- package.json | 4 +++- src/client.ts | 2 +- src/connection.ts | 8 ++++---- src/connection_fallback.ts | 4 ++-- tsconfig.json | 3 ++- yarn.lock | 27 ++++++++++++++++++++++----- 6 files changed, 34 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index 93f59d7be9..4d2f911842 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "form-data": "^4.0.0", "isomorphic-ws": "^4.0.1", "jsonwebtoken": "~9.0.0", + "lodash.throttle": "^4.1.1", "ws": "^7.4.4" }, "devDependencies": { @@ -71,6 +72,7 @@ "@types/chai-as-promised": "^7.1.4", "@types/chai-like": "^1.1.1", "@types/eslint": "7.2.7", + "@types/lodash.throttle": "^4.1.9", "@types/mocha": "^9.0.0", "@types/node": "^16.11.11", "@types/prettier": "^2.2.2", @@ -103,7 +105,7 @@ "rollup-plugin-terser": "^7.0.2", "sinon": "^12.0.1", "standard-version": "^9.3.2", - "typescript": "^4.2.3", + "typescript": "^5.5.4", "uuid": "^8.3.2" }, "scripts": { diff --git a/src/client.ts b/src/client.ts index fe68e83dec..21b9526938 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1437,7 +1437,7 @@ export class StreamChat Date: Wed, 26 Jun 2024 01:51:44 +0200 Subject: [PATCH 08/41] New handlers and fixes --- src/thread.ts | 193 +++++++++++++++++++++++++++++++++++++------------- src/types.ts | 2 + 2 files changed, 147 insertions(+), 48 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index 7a8f601871..1013f2e5f7 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -17,7 +17,7 @@ import { EventAPIResponse, } from './types'; import { addToMessageList, formatMessage, normalizeQuerySort } from './utils'; -import { Handler, InferStoreValueType, Patch, SimpleStateStore } from './store/SimpleStateStore'; +import { Handler, InferStoreValueType, SimpleStateStore } from './store/SimpleStateStore'; type ThreadReadStatus = { [key: string]: { @@ -36,27 +36,32 @@ type QueryRepliesOptions = { } & MessagePaginationOptions & { user?: UserResponse; user_id?: string }; type ThreadState = { + active: boolean; + createdAt: Date; deletedAt: Date | null; isStateStale: boolean; latestReplies: Array>; loadingNextPage: boolean; loadingPreviousPage: boolean; - nextId: string | null; parentMessage: FormatMessageResponse | undefined; participants: ThreadResponse['thread_participants']; - previousId: string | null; read: ThreadReadStatus; replyCount: number; + staggeredRead: ThreadReadStatus; updatedAt: Date | null; channel?: Channel; channelData?: ThreadResponse['channel']; + + nextId?: string | null; + previousId?: string | null; }; -const DEFAULT_PAGE_LIMIT = 15; +const DEFAULT_PAGE_LIMIT = 50; const DEFAULT_MARK_AS_READ_THROTTLE_DURATION = 1000; const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000; +const MAX_QUERY_THREADS_LIMIT = 25; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; /** @@ -76,10 +81,7 @@ const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; export class Thread { public readonly state: SimpleStateStore>; public id: string; - // used as handler helper - actively mark read all of the incoming messages - // if the thread is active (visibly selected in the UI) - // TODO: figure out whether this API will work with the scrollable list (based on visibility maybe?) - public active = false; + private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); @@ -110,16 +112,22 @@ export class Thread>({ + // used as handler helper - actively mark read all of the incoming messages + // if the thread is active (visibly selected in the UI) + // TODO: figure out whether this API will work with the scrollable list (based on visibility maybe?) + active: false, channelData: threadData.channel, // not channel instance channel: threadData.channel && client.channel(threadData.channel.type, threadData.channel.id), createdAt: threadData.created_at ? new Date(threadData.created_at) : placeholderDate, - // FIXME: tell Vish to propagate deleted at from parent message upwards deletedAt: threadData.parent_message?.deleted_at?.length ? new Date(threadData.parent_message.deleted_at) : null, latestReplies: latestReplies.map(formatMessage), // TODO: check why this is sometimes undefined parentMessage: threadData.parent_message && formatMessage(threadData.parent_message), participants: threadParticipants, + // actual read state representing BE values read, + // + staggeredRead: read, replyCount, updatedAt: threadData.updated_at?.length ? new Date(threadData.updated_at) : null, @@ -154,11 +162,11 @@ export class Thread { - this.active = true; + this.updateLocalState('active', true); }; public deactivate = () => { - this.active = false; + this.updateLocalState('active', false); }; /** @@ -180,7 +188,9 @@ export class Thread [nextValue.read[currentuserId]?.unread_messages], ([unreadMessagesCount]) => { - if (!this.active || !unreadMessagesCount) return; + const { active } = this.state.getLatestValue(); + + if (!active || !unreadMessagesCount) return; throttledMarkAsRead(); }, @@ -188,9 +198,32 @@ export class Thread [nextValue.active, nextValue.isStateStale], + ([active, isStateStale]) => { + if (active && isStateStale) { + // reset state and re-load first page + + this.state.next((current) => ({ + ...current, + previousId: undefined, + nextId: undefined, + latestReplies: [], + isStateStale: false, + })); + + this.loadPreviousPage(); + } + }, + ), + ); + + this.unsubscribeFunctions.add( + // TODO: figure out why the current user is not receiving this event this.client.on('user.watching.stop', (event) => { - if (!event.channel_id) return; + const currentUserId = this.client.user?.id; + if (!event.channel_id || !event.user || !currentUserId || currentUserId !== event.user.id) return; const { channelData } = this.state.getLatestValue(); @@ -206,28 +239,13 @@ export class Thread> | undefined; - // TODO: permissions (read events) - // only define side effect function if the message does not belong to the current user - if (event.message.user?.id !== currentUserId) { - updateUnreadMessagesCount = (current) => ({ - ...current, - read: { - ...current.read, - [currentUserId]: { - ...current.read[currentUserId], - unread_messages: (current.read[currentUserId]?.unread_messages ?? 0) + 1, - }, - }, - }); - } - this.upsertReply({ message: event.message, // deal with timestampChanged only related to local user (optimistic updates) timestampChanged: event.message.user?.id === this.client.user?.id, - stateUpdateSideEffect: updateUnreadMessagesCount, }); + + if (event.user && event.user.id !== currentUserId) this.incrementOwnUnreadCount(); }).unsubscribe, ); @@ -275,19 +293,30 @@ export class Thread { + const currentUserId = this.client.user?.id; + if (!currentUserId) return; + // TODO: permissions (read events) - use channel._countMessageAsUnread + // only define side effect function if the message does not belong to the current user + this.state.next((current) => { + return { + ...current, + read: { + ...current.read, + [currentUserId]: { + ...current.read[currentUserId], + unread_messages: (current.read[currentUserId]?.unread_messages ?? 0) + 1, + }, + }, + }; + }); + }; + public upsertReply = ({ message, timestampChanged = false, - stateUpdateSideEffect, }: { message: MessageResponse | FormatMessageResponse; - // FIXME: not sure if I like this - THE WHY: main function of this method should be to add/update the message that's coming in - // but if the message that's coming in is not ours we would also like to increase the unread count - one atomic update seems - // better than two separate ones - /** - * State-updating side effect - */ - stateUpdateSideEffect?: Patch>; timestampChanged?: boolean; }) => { if (message.parent_id !== this.id) { @@ -297,7 +326,6 @@ export class Thread ({ ...current, latestReplies: addToMessageList(current.latestReplies, formatMessage(message), timestampChanged), - ...stateUpdateSideEffect?.(current), })); }; @@ -480,11 +508,13 @@ export class Thread = { + active: boolean; loadingNextPage: boolean; loadingPreviousPage: boolean; - threadIdIndexMap: { [key: string]: number }; // TODO: maybe does not need to live here + threadIdIndexMap: { [key: string]: number }; threads: Thread[]; unreadThreads: { + combinedCount: number; existingReorderedIds: string[]; newIds: string[]; }; @@ -500,8 +530,10 @@ export class ThreadManager { constructor({ client }: { client: StreamChat }) { this.client = client; this.state = new SimpleStateStore>({ + active: false, threads: [], threadIdIndexMap: {}, + // TODO: re-think the naming unreadThreads: { // new threads or threads which have not been loaded and is not possible to paginate to anymore // as these threads received new replies which moved them up in the list - used for the badge @@ -509,6 +541,7 @@ export class ThreadManager { // threads already loaded within the local state but will change position in `threads` array when // `loadUnreadThreads` gets called - used to calculate proper query limit existingReorderedIds: [], + combinedCount: 0, }, loadingNextPage: false, loadingPreviousPage: false, @@ -520,9 +553,53 @@ export class ThreadManager { this.registerSubscriptions(); } + // eslint-disable-next-line sonarjs/no-identical-functions + private updateLocalState = >(key: keyof T, newValue: T[typeof key]) => { + this.state.next((current) => ({ + ...current, + [key]: newValue, + })); + }; + + public activate = () => { + this.updateLocalState('active', true); + }; + + public deactivate = () => { + this.updateLocalState('active', false); + }; + public registerSubscriptions = () => { if (this.unsubscribeFunctions.size) return; + this.unsubscribeFunctions.add( + // TODO: find out if there's a better version of doing this (client.user is obviously not reactive and unitialized during construction) + this.client.on('health.check', (event) => { + if (!event.me) return; + + const { unread_threads: unreadThreadsCount } = event.me; + + // TODO: extract to a reusable function + this.state.next((current) => ({ + ...current, + unreadThreads: { ...current.unreadThreads, combinedCount: unreadThreadsCount }, + })); + }).unsubscribe, + ); + + this.unsubscribeFunctions.add( + this.client.on('notification.mark_read', (event) => { + if (typeof event.unread_threads === 'undefined') return; + + const { unread_threads: unreadThreadsCount } = event; + + this.state.next((current) => ({ + ...current, + unreadThreads: { ...current.unreadThreads, combinedCount: unreadThreadsCount }, + })); + }).unsubscribe, + ); + // TODO: maybe debounce instead? const throttledHandleConnectionRecovery = throttle( () => { @@ -531,7 +608,7 @@ export class ThreadManager { this.state.next((current) => ({ ...current, threads: [], - unreadThreads: { newIds: [], existingReorderedIds: [] }, + unreadThreads: { ...current.unreadThreads, newIds: [], existingReorderedIds: [] }, nextId: undefined, previousId: undefined, isStateStale: false, @@ -547,6 +624,18 @@ export class ThreadManager { this.client.on('connection.recovered', throttledHandleConnectionRecovery).unsubscribe, ); + this.unsubscribeFunctions.add( + this.state.subscribeWithSelector( + (nextValue) => [nextValue.active], + ([active]) => { + if (!active) return; + + // automatically clear all the changes that happened "behind the scenes" + this.loadUnreadThreads(); + }, + ), + ); + const handleThreadsChange: Handler[]]> = ([newThreads], previouslySelectedValue) => { // create new threadIdIndexMap const newThreadIdIndexMap = newThreads.reduce((map, thread, index) => { @@ -627,20 +716,28 @@ export class ThreadManager { // TODO: add activity status, trigger this method when this instance becomes active public loadUnreadThreads = async () => { + // TODO: redo this whole thing + // - do reload with limit which is currently loaded amount of threads but less than max (25) - not working well + // - ask BE to allow you to do {id: {$in: [...]}} (always push new to the top) - custom ordering might not work + // - re-load 25 at most, drop rest? - again, might not fit custom ordering - at which point the "in" option seems better const { + threads, unreadThreads: { newIds, existingReorderedIds }, } = this.state.getLatestValue(); - const combinedLimit = newIds.length + existingReorderedIds.length; + const triggerLimit = newIds.length + existingReorderedIds.length; - if (!combinedLimit) return; + if (!triggerLimit) return; + + const combinedLimit = threads.length + newIds.length; try { - const data = await this.client.queryThreads({ limit: combinedLimit }); + const data = await this.client.queryThreads({ + limit: combinedLimit <= MAX_QUERY_THREADS_LIMIT ? combinedLimit : MAX_QUERY_THREADS_LIMIT, + }); this.state.next((current) => { // merge existing and new threads, filter out re-ordered - const newThreads: Thread[] = []; const existingThreadIdsToFilterOut: string[] = []; @@ -649,7 +746,7 @@ export class ThreadManager { // ditch threads which report stale state and use new one // *(state can be considered as stale when channel associated with the thread stops being watched) - newThreads.push(existingThread && existingThread.hasStaleState ? thread : existingThread); + newThreads.push(existingThread && !existingThread.hasStaleState ? existingThread : thread); if (existingThread) existingThreadIdsToFilterOut.push(existingThread.id); } @@ -658,7 +755,7 @@ export class ThreadManager { return { ...current, - unreadThreads: { newIds: [], existingReorderedIds: [] }, // reset + unreadThreads: { ...current.unreadThreads, newIds: [], existingReorderedIds: [] }, // reset threads: newThreads.concat(existingFilteredThreads), }; }); diff --git a/src/types.ts b/src/types.ts index 82e985b434..143755e1ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1236,6 +1236,8 @@ export type Event; user_id?: string; watcher_count?: number; From ab16fda5a1c6f22a6afb14357712c26b6d3559dd Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Sat, 13 Jul 2024 00:30:36 +0200 Subject: [PATCH 09/41] New fixes, handlers and adjustments --- src/channel_state.ts | 2 +- src/thread.ts | 357 ++++++++++++++++++++++++++----------------- src/types.ts | 3 +- src/utils.ts | 1 + 4 files changed, 223 insertions(+), 140 deletions(-) diff --git a/src/channel_state.ts b/src/channel_state.ts index 12d629cffd..87158fa3f3 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -653,7 +653,7 @@ export class ChannelState.message.updated | .message.updated + */ + export class Thread { public readonly state: SimpleStateStore>; public id: string; private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); + private failedRepliesMap: Map> = new Map(); constructor({ client, @@ -119,19 +126,19 @@ export class Thread }) => { + if (thread === this) return; // skip if the instances are the same + if (thread.id !== this.id) return; // disallow merging of states of instances that do not match ids + + const { + read, + staggeredRead, + replyCount, + latestReplies, + parentMessage, + participants, + createdAt, + deletedAt, + updatedAt, + nextId, + previousId, + channelData, + } = thread.state.getLatestValue(); + + this.state.next((current) => { + const failedReplies = Array.from(this.failedRepliesMap.values()); + + return { + ...current, + read, + staggeredRead, + replyCount, + latestReplies: latestReplies.concat(failedReplies), + parentMessage, + participants, + createdAt, + deletedAt, + updatedAt, + nextId, + previousId, + channelData, + isStateStale: false, + }; + }); + }; + /** * Makes Thread instance listen to events and adjust its state accordingly. */ + // eslint-disable-next-line sonarjs/cognitive-complexity public registerSubscriptions = () => { // check whether this instance has subscriptions and is already listening for changes if (this.unsubscribeFunctions.size) return; @@ -197,37 +247,64 @@ export class Thread { + // this.updateLocalState('recovering', true); + + // TODO: add online status to prevent recovery attempts during the time the connection is down + try { + const thread = await this.client.getThread(this.id, { watch: true }); + + this.partiallyReplaceState({ thread }); + } catch (error) { + // TODO: handle recovery fail + console.warn(error); + } finally { + // this.updateLocalState('recovering', false); + } + }, + DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, + { + leading: true, + trailing: true, + }, + ); + + // when the thread becomes active or it becomes stale while active (channel stops being watched or connection drops) + // the recovery handler pulls its latest state to replace with the current one + // failed messages are preserved and appended to the newly recovered replies this.unsubscribeFunctions.add( - // TODO: re-visit this behavior, not sure whether I like the solution this.state.subscribeWithSelector( (nextValue) => [nextValue.active, nextValue.isStateStale], - ([active, isStateStale]) => { - if (active && isStateStale) { - // reset state and re-load first page - - this.state.next((current) => ({ - ...current, - previousId: undefined, - nextId: undefined, - latestReplies: [], - isStateStale: false, - })); - - this.loadPreviousPage(); - } + async ([active, isStateStale]) => { + // TODO: cancel in-progress recovery? + if (active && isStateStale) throttledHandleStateRecovery(); }, ), ); + // this.unsubscribeFunctions.add( + // // mark local state as stale when connection drops + // this.client.on('connection.changed', (event) => { + // if (typeof event.online === 'undefined') return; + + // // state is already stale or connection recovered + // if (this.state.getLatestValue().isStateStale || event.online) return; + + // this.updateLocalState('isStateStale', true); + // }).unsubscribe, + // ); + this.unsubscribeFunctions.add( // TODO: figure out why the current user is not receiving this event this.client.on('user.watching.stop', (event) => { const currentUserId = this.client.user?.id; - if (!event.channel_id || !event.user || !currentUserId || currentUserId !== event.user.id) return; + if (!event.channel || !event.user || !currentUserId || currentUserId !== event.user.id) return; const { channelData } = this.state.getLatestValue(); - if (!channelData || event.channel_id !== channelData.id) return; + if (!channelData || event.channel.cid !== channelData.cid) return; this.updateLocalState('isStateStale', true); }).unsubscribe, @@ -239,6 +316,10 @@ export class Thread { this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); - // TODO: stop watching }; public incrementOwnUnreadCount = () => { const currentUserId = this.client.user?.id; if (!currentUserId) return; // TODO: permissions (read events) - use channel._countMessageAsUnread - // only define side effect function if the message does not belong to the current user this.state.next((current) => { return { ...current, @@ -322,10 +401,16 @@ export class Thread ({ ...current, - latestReplies: addToMessageList(current.latestReplies, formatMessage(message), timestampChanged), + latestReplies: addToMessageList(current.latestReplies, formattedMessage, timestampChanged), })); }; @@ -335,10 +420,14 @@ export class Thread { + const formattedMessage = formatMessage(message); + const newData: typeof current = { ...current, - parentMessage: formatMessage(message), + parentMessage: formattedMessage, replyCount: message.reply_count ?? current.replyCount, + // TODO: probably should not have to do this + deletedAt: formattedMessage.deleted_at, }; // update channel on channelData change (unlikely but handled anyway) @@ -509,15 +598,14 @@ export class Thread = { active: boolean; + isOnline: boolean; + lastConnectionDownAt: Date | null; loadingNextPage: boolean; loadingPreviousPage: boolean; threadIdIndexMap: { [key: string]: number }; threads: Thread[]; - unreadThreads: { - combinedCount: number; - existingReorderedIds: string[]; - newIds: string[]; - }; + unreadThreadsCount: number; + unseenThreadIds: string[]; nextId?: string | null; // null means no next page available previousId?: string | null; }; @@ -533,16 +621,12 @@ export class ThreadManager { active: false, threads: [], threadIdIndexMap: {}, - // TODO: re-think the naming - unreadThreads: { - // new threads or threads which have not been loaded and is not possible to paginate to anymore - // as these threads received new replies which moved them up in the list - used for the badge - newIds: [], - // threads already loaded within the local state but will change position in `threads` array when - // `loadUnreadThreads` gets called - used to calculate proper query limit - existingReorderedIds: [], - combinedCount: 0, - }, + isOnline: false, + unreadThreadsCount: 0, + // new threads or threads which have not been loaded and is not possible to paginate to anymore + // as these threads received new replies which moved them up in the list - used for the badge + unseenThreadIds: [], + lastConnectionDownAt: null, loadingNextPage: false, loadingPreviousPage: false, nextId: undefined, @@ -569,61 +653,77 @@ export class ThreadManager { this.updateLocalState('active', false); }; + // eslint-disable-next-line sonarjs/cognitive-complexity public registerSubscriptions = () => { if (this.unsubscribeFunctions.size) return; - this.unsubscribeFunctions.add( - // TODO: find out if there's a better version of doing this (client.user is obviously not reactive and unitialized during construction) - this.client.on('health.check', (event) => { - if (!event.me) return; + const handleUnreadThreadsCountChange = (event: Event) => { + const { unread_threads: unreadThreadsCount } = event.me ?? event; - const { unread_threads: unreadThreadsCount } = event.me; + if (typeof unreadThreadsCount === 'undefined') return; - // TODO: extract to a reusable function - this.state.next((current) => ({ - ...current, - unreadThreads: { ...current.unreadThreads, combinedCount: unreadThreadsCount }, - })); - }).unsubscribe, - ); - - this.unsubscribeFunctions.add( - this.client.on('notification.mark_read', (event) => { - if (typeof event.unread_threads === 'undefined') return; - - const { unread_threads: unreadThreadsCount } = event; + this.state.next((current) => ({ + ...current, + unreadThreadsCount, + })); + }; - this.state.next((current) => ({ - ...current, - unreadThreads: { ...current.unreadThreads, combinedCount: unreadThreadsCount }, - })); - }).unsubscribe, + [ + 'health.check', + 'notification.mark_read', + 'notification.thread_message_new', + 'notification.channel_deleted', + ].forEach((eventType) => + this.unsubscribeFunctions.add(this.client.on(eventType, handleUnreadThreadsCountChange).unsubscribe), ); - // TODO: maybe debounce instead? + // TODO: return to previous recovery option as state merging is now in place const throttledHandleConnectionRecovery = throttle( - () => { - // TODO: cancel possible in-progress queries (loadNextPage...) + async () => { + const { lastConnectionDownAt, threads } = this.state.getLatestValue(); - this.state.next((current) => ({ - ...current, - threads: [], - unreadThreads: { ...current.unreadThreads, newIds: [], existingReorderedIds: [] }, - nextId: undefined, - previousId: undefined, - isStateStale: false, - })); + if (!lastConnectionDownAt) return; + + const channelCids = new Set(); + for (const thread of threads) { + if (!thread.channel) continue; - this.loadNextPage(); + channelCids.add(thread.channel.cid); + } + + try { + // FIXME: syncing does not work for me + await this.client.sync(Array.from(channelCids), lastConnectionDownAt.toISOString(), { watch: true }); + this.updateLocalState('lastConnectionDownAt', null); + } catch (error) { + console.warn(error); + } }, DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, - { leading: true, trailing: true }, + { + leading: true, + trailing: true, + }, ); this.unsubscribeFunctions.add( this.client.on('connection.recovered', throttledHandleConnectionRecovery).unsubscribe, ); + this.unsubscribeFunctions.add( + this.client.on('connection.changed', (event) => { + if (typeof event.online === 'undefined') return; + + const { lastConnectionDownAt } = this.state.getLatestValue(); + + if (!event.online && !lastConnectionDownAt) { + this.updateLocalState('lastConnectionDownAt', new Date()); + } + + this.updateLocalState('isOnline', event.online); + }).unsubscribe, + ); + this.unsubscribeFunctions.add( this.state.subscribeWithSelector( (nextValue) => [nextValue.active], @@ -631,7 +731,7 @@ export class ThreadManager { if (!active) return; // automatically clear all the changes that happened "behind the scenes" - this.loadUnreadThreads(); + this.reload(); }, ), ); @@ -665,20 +765,13 @@ export class ThreadManager { this.state.subscribeWithSelector((nextValue) => [nextValue.threads] as const, handleThreadsChange), ); - // TODO?: handle parent message deleted (extend unreadThreads \w deletedIds?) - // delete locally (manually) and run rest of the query loadUnreadThreads - // requires BE support (filter deleted threads) + // TODO: handle parent message hard-deleted (extend state with \w hardDeletedThreadIds?) const handleNewReply = (event: Event) => { if (!event.message || !event.message.parent_id) return; const parentId = event.message.parent_id; - const { - threadIdIndexMap, - nextId, - threads, - unreadThreads: { newIds, existingReorderedIds }, - } = this.state.getLatestValue(); + const { threadIdIndexMap, nextId, threads, unseenThreadIds } = this.state.getLatestValue(); // prevents from handling replies until the threads have been loaded // (does not fill information for "unread threads" banner to appear) @@ -686,79 +779,67 @@ export class ThreadManager { const existsLocally = typeof threadIdIndexMap[parentId] !== 'undefined'; - if (existsLocally && !existingReorderedIds.includes(parentId)) { - return this.state.next((current) => ({ - ...current, - unreadThreads: { - ...current.unreadThreads, - existingReorderedIds: current.unreadThreads.existingReorderedIds.concat(parentId), - }, - })); - } + if (existsLocally || unseenThreadIds.includes(parentId)) return; - if (!existsLocally && !newIds.includes(parentId)) { - return this.state.next((current) => ({ - ...current, - unreadThreads: { - ...current.unreadThreads, - newIds: current.unreadThreads.newIds.concat(parentId), - }, - })); - } + return this.state.next((current) => ({ + ...current, + unseenThreadIds: current.unseenThreadIds.concat(parentId), + })); }; this.unsubscribeFunctions.add(this.client.on('notification.thread_message_new', handleNewReply).unsubscribe); }; public deregisterSubscriptions = () => { + // TODO: think about state reset or at least invalidation this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); }; - // TODO: add activity status, trigger this method when this instance becomes active - public loadUnreadThreads = async () => { - // TODO: redo this whole thing - // - do reload with limit which is currently loaded amount of threads but less than max (25) - not working well - // - ask BE to allow you to do {id: {$in: [...]}} (always push new to the top) - custom ordering might not work - // - re-load 25 at most, drop rest? - again, might not fit custom ordering - at which point the "in" option seems better - const { - threads, - unreadThreads: { newIds, existingReorderedIds }, - } = this.state.getLatestValue(); - - const triggerLimit = newIds.length + existingReorderedIds.length; + public reload = async () => { + const { threads, unseenThreadIds } = this.state.getLatestValue(); - if (!triggerLimit) return; + if (!unseenThreadIds.length) return; - const combinedLimit = threads.length + newIds.length; + const combinedLimit = threads.length + unseenThreadIds.length; try { const data = await this.client.queryThreads({ limit: combinedLimit <= MAX_QUERY_THREADS_LIMIT ? combinedLimit : MAX_QUERY_THREADS_LIMIT, }); - this.state.next((current) => { - // merge existing and new threads, filter out re-ordered - const newThreads: Thread[] = []; - const existingThreadIdsToFilterOut: string[] = []; + const { threads, threadIdIndexMap } = this.state.getLatestValue(); - for (const thread of data.threads) { - const existingThread: Thread | undefined = current.threads[current.threadIdIndexMap[thread.id]]; + const newThreads: Thread[] = []; + // const existingThreadIdsToFilterOut: string[] = []; - // ditch threads which report stale state and use new one - // *(state can be considered as stale when channel associated with the thread stops being watched) - newThreads.push(existingThread && !existingThread.hasStaleState ? existingThread : thread); + for (const thread of data.threads) { + const existingThread: Thread | undefined = threads[threadIdIndexMap[thread.id]]; - if (existingThread) existingThreadIdsToFilterOut.push(existingThread.id); + newThreads.push(existingThread ?? thread); + + // replace state of threads which report stale state + // *(state can be considered as stale when channel associated with the thread stops being watched) + if (existingThread && existingThread.hasStaleState) { + existingThread.partiallyReplaceState({ thread }); } - const existingFilteredThreads = current.threads.filter(({ id }) => !existingThreadIdsToFilterOut.includes(id)); + // if (existingThread) existingThreadIdsToFilterOut.push(existingThread.id); + } - return { - ...current, - unreadThreads: { ...current.unreadThreads, newIds: [], existingReorderedIds: [] }, // reset - threads: newThreads.concat(existingFilteredThreads), - }; - }); + // TODO: use some form of a "cache" for unused threads + // to reach for upon next pagination or re-query + // keep them subscribed and "running" behind the scenes but + // not in the list for multitude of reasons (clean cache on last pagination which returns empty array - nothing to pair cached threads to) + // (this.loadedThreadIdMap) + // const existingFilteredThreads = threads.filter(({ id }) => !existingThreadIdsToFilterOut.includes(id)); + + this.state.next((current) => ({ + ...current, + unseenThreadIds: [], // reset + // TODO: extract merging logic and allow loadNextPage to merge as well (in combination with the cache thing) + threads: newThreads, //.concat(existingFilteredThreads), + nextId: data.next ?? null, // re-adjust next cursor + })); } catch (error) { // TODO: loading states console.error(error); @@ -789,7 +870,7 @@ export class ThreadManager { const data = await this.client.queryThreads(optionsWithDefaults); this.state.next((current) => ({ ...current, - threads: current.threads.concat(data.threads), + threads: data.threads.length ? current.threads.concat(data.threads) : current.threads, nextId: data.next ?? null, })); } catch (error) { @@ -799,7 +880,7 @@ export class ThreadManager { } }; - public loadPreviousPage = () => { - // TODO: impl + private loadPreviousPage = () => { + // TODO: impl? }; } diff --git a/src/types.ts b/src/types.ts index 143755e1ab..f58e968082 100644 --- a/src/types.ts +++ b/src/types.ts @@ -473,10 +473,11 @@ export type FormatMessageResponse, - 'created_at' | 'pinned_at' | 'updated_at' | 'status' + 'created_at' | 'pinned_at' | 'updated_at' | 'deleted_at' | 'status' > & StreamChatGenerics['messageType'] & { created_at: Date; + deleted_at: Date | null; pinned_at: Date | null; status: string; updated_at: Date; diff --git a/src/utils.ts b/src/utils.ts index e0ae91fbe2..92ef0e53af 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -295,6 +295,7 @@ export function formatMessage Date: Wed, 17 Jul 2024 00:47:06 +0200 Subject: [PATCH 10/41] New fixes, handlers and adjustments --- src/store/SimpleStateStore.ts | 5 +- src/thread.ts | 269 +++++++++++++--------------------- src/utils.ts | 9 ++ 3 files changed, 118 insertions(+), 165 deletions(-) diff --git a/src/store/SimpleStateStore.ts b/src/store/SimpleStateStore.ts index 16a486be42..a62b7c42cc 100644 --- a/src/store/SimpleStateStore.ts +++ b/src/store/SimpleStateStore.ts @@ -20,7 +20,7 @@ function isInitiator(value: T | Initiator): value is Initiator { } export class SimpleStateStore< - T + T // TODO: limit T to object only? // O extends { // [K in keyof T]: T[K] extends Function ? K : never; // }[keyof T] = never @@ -47,6 +47,9 @@ export class SimpleStateStore< this.handlerSet.forEach((handler) => handler(this.value, oldValue)); }; + public patchedNext = (key: keyof T, newValue: T[typeof key]) => + this.next((current) => ({ ...current, [key]: newValue })); + public getLatestValue = () => this.value; // TODO: filter and return actions (functions) only in a type-safe manner (only allows state T to be a dict) diff --git a/src/thread.ts b/src/thread.ts index 7476be1e44..a1f721db04 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -2,7 +2,7 @@ import throttle from 'lodash.throttle'; import { StreamChat } from './client'; import { Channel } from './channel'; -import { +import type { DefaultGenerics, ExtendableGenerics, MessageResponse, @@ -16,8 +16,14 @@ import { GetRepliesAPIResponse, EventAPIResponse, } from './types'; -import { addToMessageList, formatMessage, normalizeQuerySort } from './utils'; -import { Handler, InferStoreValueType, SimpleStateStore } from './store/SimpleStateStore'; +import { + addToMessageList, + findInsertionIndex, + formatMessage, + normalizeQuerySort, + transformReadArrayToDictionary, +} from './utils'; +import { Handler, SimpleStateStore } from './store/SimpleStateStore'; type ThreadReadStatus = { [key: string]: { @@ -84,44 +90,31 @@ const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; * .message.updated | .message.updated */ -export class Thread { - public readonly state: SimpleStateStore>; +// TODO: store users someplace and reference them from state as now replies might contain users with stale information + +export class Thread { + public readonly state: SimpleStateStore>; public id: string; - private client: StreamChat; + private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); - private failedRepliesMap: Map> = new Map(); + private failedRepliesMap: Map> = new Map(); - constructor({ - client, - threadData = {}, - }: { - client: StreamChat; - threadData?: Partial>; - }) { + constructor({ client, threadData = {} }: { client: StreamChat; threadData?: Partial> }) { const { - // TODO: check why this one is sometimes undefined (should return empty array instead) read: unformattedRead = [], latest_replies: latestReplies = [], thread_participants: threadParticipants = [], reply_count: replyCount = 0, } = threadData; - // TODO: move to a function "formatReadStatus" and figure out whether this format is even useful - const read = unformattedRead.reduce>((pv, cv) => { - pv[cv.user.id] ??= { - ...cv, - lastReadAt: new Date(cv.last_read), - }; - return pv; - }, {}); + const read = transformReadArrayToDictionary(unformattedRead); const placeholderDate = new Date(); - this.state = new SimpleStateStore>({ + this.state = new SimpleStateStore>({ // used as handler helper - actively mark read all of the incoming messages - // if the thread is active (visibly selected in the UI) - // TODO: figure out whether this API will work with the scrollable list (based on visibility maybe?) + // if the thread is active (visibly selected in the UI or main focal point in advanced list) active: false, channelData: threadData.channel, // not channel instance channel: threadData.channel && client.channel(threadData.channel.type, threadData.channel.id), @@ -133,12 +126,14 @@ export class Thread>(key: keyof T, newValue: T[typeof key]) => { - this.state.next((current) => ({ - ...current, - [key]: newValue, - })); - }; - public activate = () => { - this.updateLocalState('active', true); + this.state.patchedNext('active', true); }; public deactivate = () => { - this.updateLocalState('active', false); + this.state.patchedNext('active', false); }; // take state of one instance and merge it to the current instance - public partiallyReplaceState = ({ thread }: { thread: Thread }) => { + public partiallyReplaceState = ({ thread }: { thread: Thread }) => { if (thread === this) return; // skip if the instances are the same if (thread.id !== this.id) return; // disallow merging of states of instances that do not match ids @@ -236,12 +224,11 @@ export class Thread [nextValue.read[currentuserId]?.unread_messages], - ([unreadMessagesCount]) => { - const { active } = this.state.getLatestValue(); - + (nextValue) => [nextValue.active, nextValue.read[currentuserId]?.unread_messages], + ([active, unreadMessagesCount]) => { if (!active || !unreadMessagesCount) return; + // mark thread as read whenever thread becomes active or is already active and unread messages count increases throttledMarkAsRead(); }, ), @@ -250,8 +237,6 @@ export class Thread { - // this.updateLocalState('recovering', true); - // TODO: add online status to prevent recovery attempts during the time the connection is down try { const thread = await this.client.getThread(this.id, { watch: true }); @@ -306,7 +291,7 @@ export class Thread) => { + const handleMessageUpdate = (event: Event) => { if (!event.message) return; - this.updateParentMessageOrReply(event.message); + if (event.hard_delete && event.type === 'message.deleted' && event.message.parent_id === this.id) { + return this.deleteReplyLocally({ message: event.message }); + } + + this.updateParentMessageOrReplyLocally(event.message); }; ['message.updated', 'message.deleted', 'reaction.new', 'reaction.deleted'].forEach((eventType) => { @@ -391,11 +380,27 @@ export class Thread }) => { + const { latestReplies } = this.state.getLatestValue(); + + const index = findInsertionIndex({ + message: formatMessage(message), + messages: latestReplies, + }); + + const actualIndex = + latestReplies[index].id === message.id ? index : latestReplies[index - 1].id === message.id ? index - 1 : null; + + if (actualIndex === null) return; + + this.state.next((current) => ({ ...current, latestReplies: latestReplies.toSpliced(actualIndex, 1) })); + }; + + public upsertReplyLocally = ({ message, timestampChanged = false, }: { - message: MessageResponse | FormatMessageResponse; + message: MessageResponse | FormatMessageResponse; timestampChanged?: boolean; }) => { if (message.parent_id !== this.id) { @@ -403,7 +408,7 @@ export class Thread) => { + public updateParentMessageLocally = (message: MessageResponse) => { if (message.id !== this.id) { throw new Error('Message does not belong to this thread'); } @@ -440,63 +445,17 @@ export class Thread) => { + public updateParentMessageOrReplyLocally = (message: MessageResponse) => { if (message.parent_id === this.id) { - this.upsertReply({ message }); + this.upsertReplyLocally({ message }); } if (!message.parent_id && message.id === this.id) { - this.updateParentMessage(message); + this.updateParentMessageLocally(message); } }; - /* - TODO: merge and rename to toggleReaction instead (used for optimistic updates and WS only) - & move optimistic logic from stream-chat-react to here - */ - // addReaction = ({ - // reaction, - // message, - // enforceUnique, - // }: { - // reaction: ReactionResponse; - // enforceUnique?: boolean; - // message?: MessageResponse; - // }) => { - // if (!message) return; - - // this.state.next((current) => ({ - // ...current, - // latestReplies: current.latestReplies.map((reply) => { - // if (reply.id !== message.id) return reply; - - // // FIXME: this addReaction API weird (maybe clean it up later) - // const updatedMessage = current.channel?.state.addReaction(reaction, message, enforceUnique); - // if (updatedMessage) return formatMessage(updatedMessage); - - // return reply; - // }), - // })); - // }; - - // removeReaction = (reaction: ReactionResponse, message?: MessageResponse) => { - // if (!message) return; - - // this.state.next((current) => ({ - // ...current, - // latestReplies: current.latestReplies.map((reply) => { - // if (reply.id !== message.id) return reply; - - // // FIXME: this removeReaction API is weird (maybe clean it up later) - // const updatedMessage = current.channel?.state.removeReaction(reaction, message); - // if (updatedMessage) return formatMessage(updatedMessage); - - // return reply; - // }), - // })); - // }; - - public markAsRead = async () => { + public markAsRead = () => { const { channelData, read } = this.state.getLatestValue(); const currentUserId = this.client.user?.id; @@ -504,18 +463,12 @@ export class Thread>( - `${this.client.baseURL}/channels/${channelData?.type}/${channelData?.id}/read`, - { - thread_id: this.id, - }, - ); - } catch { - // ... - } finally { - // ... - } + return this.client.post>( + `${this.client.baseURL}/channels/${channelData?.type}/${channelData?.id}/read`, + { + thread_id: this.id, + }, + ); }; // moved from channel to thread directly (skipped getClient thing as this call does not need active WS connection) @@ -523,8 +476,8 @@ export class Thread = {}) => - this.client.get>(`${this.client.baseURL}/messages/${this.id}/replies`, { + }: QueryRepliesOptions = {}) => + this.client.get>(`${this.client.baseURL}/messages/${this.id}/replies`, { sort: normalizeQuerySort(sort), limit, ...otherOptions, @@ -535,8 +488,8 @@ export class Thread, 'sort' | 'limit'> = {}) => { - this.updateLocalState('loadingNextPage', true); + }: Pick, 'sort' | 'limit'> = {}) => { + this.state.patchedNext('loadingNextPage', true); const { loadingNextPage, nextId } = this.state.getLatestValue(); @@ -553,25 +506,28 @@ export class Thread ({ ...current, - latestReplies: current.latestReplies.concat(data.messages.map(formatMessage)), + // prevent re-creating array if there's nothing to add to the current one + latestReplies: data.messages.length + ? current.latestReplies.concat(data.messages.map(formatMessage)) + : current.latestReplies, nextId: data.messages.length < limit || !lastMessageId ? null : lastMessageId, })); } catch (error) { this.client.logger('error', (error as Error).message); } finally { - this.updateLocalState('loadingNextPage', false); + this.state.patchedNext('loadingNextPage', false); } }; public loadPreviousPage = async ({ sort, limit = DEFAULT_PAGE_LIMIT, - }: Pick, 'sort' | 'limit'> = {}) => { + }: Pick, 'sort' | 'limit'> = {}) => { const { loadingPreviousPage, previousId } = this.state.getLatestValue(); if (loadingPreviousPage || previousId === null) return; - this.updateLocalState('loadingPreviousPage', true); + this.state.patchedNext('loadingPreviousPage', true); try { const data = await this.queryReplies({ @@ -584,44 +540,44 @@ export class Thread ({ ...current, - latestReplies: data.messages.map(formatMessage).concat(current.latestReplies), + latestReplies: data.messages.length + ? data.messages.map(formatMessage).concat(current.latestReplies) + : current.latestReplies, previousId: data.messages.length < limit || !firstMessageId ? null : firstMessageId, })); } catch (error) { this.client.logger('error', (error as Error).message); - console.log(error); } finally { - this.updateLocalState('loadingPreviousPage', false); + this.state.patchedNext('loadingPreviousPage', false); } }; } -type ThreadManagerState = { +type ThreadManagerState = { active: boolean; - isOnline: boolean; lastConnectionDownAt: Date | null; loadingNextPage: boolean; loadingPreviousPage: boolean; threadIdIndexMap: { [key: string]: number }; - threads: Thread[]; + threads: Thread[]; unreadThreadsCount: number; unseenThreadIds: string[]; - nextId?: string | null; // null means no next page available - previousId?: string | null; + nextCursor?: string | null; // null means no next page available + // TODO?: implement once supported by BE + // previousCursor?: string | null; }; -export class ThreadManager { - public readonly state: SimpleStateStore>; - private client: StreamChat; +export class ThreadManager { + public readonly state: SimpleStateStore>; + private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); - constructor({ client }: { client: StreamChat }) { + constructor({ client }: { client: StreamChat }) { this.client = client; - this.state = new SimpleStateStore>({ + this.state = new SimpleStateStore>({ active: false, threads: [], threadIdIndexMap: {}, - isOnline: false, unreadThreadsCount: 0, // new threads or threads which have not been loaded and is not possible to paginate to anymore // as these threads received new replies which moved them up in the list - used for the badge @@ -629,28 +585,19 @@ export class ThreadManager { lastConnectionDownAt: null, loadingNextPage: false, loadingPreviousPage: false, - nextId: undefined, - previousId: undefined, + nextCursor: undefined, }); // TODO: temporary - do not register handlers here but rather make Chat component have control over this this.registerSubscriptions(); } - // eslint-disable-next-line sonarjs/no-identical-functions - private updateLocalState = >(key: keyof T, newValue: T[typeof key]) => { - this.state.next((current) => ({ - ...current, - [key]: newValue, - })); - }; - public activate = () => { - this.updateLocalState('active', true); + this.state.patchedNext('active', true); }; public deactivate = () => { - this.updateLocalState('active', false); + this.state.patchedNext('active', false); }; // eslint-disable-next-line sonarjs/cognitive-complexity @@ -694,7 +641,7 @@ export class ThreadManager { try { // FIXME: syncing does not work for me await this.client.sync(Array.from(channelCids), lastConnectionDownAt.toISOString(), { watch: true }); - this.updateLocalState('lastConnectionDownAt', null); + this.state.patchedNext('lastConnectionDownAt', null); } catch (error) { console.warn(error); } @@ -717,10 +664,8 @@ export class ThreadManager { const { lastConnectionDownAt } = this.state.getLatestValue(); if (!event.online && !lastConnectionDownAt) { - this.updateLocalState('lastConnectionDownAt', new Date()); + this.state.patchedNext('lastConnectionDownAt', new Date()); } - - this.updateLocalState('isOnline', event.online); }).unsubscribe, ); @@ -736,7 +681,7 @@ export class ThreadManager { ), ); - const handleThreadsChange: Handler[]]> = ([newThreads], previouslySelectedValue) => { + const handleThreadsChange: Handler[]]> = ([newThreads], previouslySelectedValue) => { // create new threadIdIndexMap const newThreadIdIndexMap = newThreads.reduce((map, thread, index) => { map[thread.id] ??= index; @@ -771,11 +716,11 @@ export class ThreadManager { if (!event.message || !event.message.parent_id) return; const parentId = event.message.parent_id; - const { threadIdIndexMap, nextId, threads, unseenThreadIds } = this.state.getLatestValue(); + const { threadIdIndexMap, nextCursor, threads, unseenThreadIds } = this.state.getLatestValue(); // prevents from handling replies until the threads have been loaded // (does not fill information for "unread threads" banner to appear) - if (!threads.length && nextId !== null) return; + if (!threads.length && nextCursor !== null) return; const existsLocally = typeof threadIdIndexMap[parentId] !== 'undefined'; @@ -809,11 +754,11 @@ export class ThreadManager { const { threads, threadIdIndexMap } = this.state.getLatestValue(); - const newThreads: Thread[] = []; + const newThreads: Thread[] = []; // const existingThreadIdsToFilterOut: string[] = []; for (const thread of data.threads) { - const existingThread: Thread | undefined = threads[threadIdIndexMap[thread.id]]; + const existingThread: Thread | undefined = threads[threadIdIndexMap[thread.id]]; newThreads.push(existingThread ?? thread); @@ -838,7 +783,7 @@ export class ThreadManager { unseenThreadIds: [], // reset // TODO: extract merging logic and allow loadNextPage to merge as well (in combination with the cache thing) threads: newThreads, //.concat(existingFilteredThreads), - nextId: data.next ?? null, // re-adjust next cursor + nextCursor: data.next ?? null, // re-adjust next cursor })); } catch (error) { // TODO: loading states @@ -850,9 +795,9 @@ export class ThreadManager { // remove `next` from options as that is handled internally public loadNextPage = async (options: Omit = {}) => { - const { nextId, loadingNextPage } = this.state.getLatestValue(); + const { nextCursor, loadingNextPage } = this.state.getLatestValue(); - if (nextId === null || loadingNextPage) return; + if (nextCursor === null || loadingNextPage) return; // FIXME: redo defaults const optionsWithDefaults: QueryThreadsOptions = { @@ -860,7 +805,7 @@ export class ThreadManager { participant_limit: 10, reply_limit: 10, watch: true, - next: nextId, + next: nextCursor, ...options, }; @@ -871,7 +816,7 @@ export class ThreadManager { this.state.next((current) => ({ ...current, threads: data.threads.length ? current.threads.concat(data.threads) : current.threads, - nextId: data.next ?? null, + nextCursor: data.next ?? null, })); } catch (error) { this.client.logger('error', (error as Error).message); @@ -879,8 +824,4 @@ export class ThreadManager { this.state.next((current) => ({ ...current, loadingNextPage: false })); } }; - - private loadPreviousPage = () => { - // TODO: impl? - }; } diff --git a/src/utils.ts b/src/utils.ts index 92ef0e53af..528ec346d7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -411,3 +411,12 @@ function maybeGetReactionGroupsFallback( return null; } + +export const transformReadArrayToDictionary = (readArray: T[]) => + readArray.reduce<{ [key: string]: T & { lastReadAt: Date } }>((accumulator, currentValue) => { + accumulator[currentValue.user.id as string] ??= { + ...currentValue, + lastReadAt: new Date(currentValue.last_read), + }; + return accumulator; + }, {}); From 462eca37cdd8b0396d4f55efba52a5e3178ca160 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Wed, 24 Jul 2024 11:26:24 +0200 Subject: [PATCH 11/41] Added some ThreadManager tests --- src/thread.ts | 41 +++--- test/unit/thread.js | 70 --------- test/unit/thread.test.ts | 304 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 95 deletions(-) delete mode 100644 test/unit/thread.js create mode 100644 test/unit/thread.test.ts diff --git a/src/thread.ts b/src/thread.ts index a1f721db04..0973836043 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -13,16 +13,8 @@ import type { QueryThreadsOptions, MessagePaginationOptions, AscDesc, - GetRepliesAPIResponse, - EventAPIResponse, } from './types'; -import { - addToMessageList, - findInsertionIndex, - formatMessage, - normalizeQuerySort, - transformReadArrayToDictionary, -} from './utils'; +import { addToMessageList, findInsertionIndex, formatMessage, transformReadArrayToDictionary } from './utils'; import { Handler, SimpleStateStore } from './store/SimpleStateStore'; type ThreadReadStatus = { @@ -35,8 +27,6 @@ type ThreadReadStatus = GetRepliesAPIResponse; - type QueryRepliesOptions = { sort?: { created_at: AscDesc }[]; } & MessagePaginationOptions & { user?: UserResponse; user_id?: string }; @@ -121,7 +111,7 @@ export class Thread { createdAt: threadData.created_at ? new Date(threadData.created_at) : placeholderDate, deletedAt: threadData.parent_message?.deleted_at ? new Date(threadData.parent_message.deleted_at) : null, latestReplies: latestReplies.map(formatMessage), - // TODO: check why this is sometimes undefined + // thread is "parentMessage" parentMessage: threadData.parent_message && formatMessage(threadData.parent_message), participants: threadParticipants, // actual read state in-sync with BE values @@ -456,19 +446,16 @@ export class Thread { }; public markAsRead = () => { - const { channelData, read } = this.state.getLatestValue(); + const { read } = this.state.getLatestValue(); const currentUserId = this.client.user?.id; const { unread_messages: unreadMessagesCount } = (currentUserId && read[currentUserId]) || {}; if (!unreadMessagesCount) return; - return this.client.post>( - `${this.client.baseURL}/channels/${channelData?.type}/${channelData?.id}/read`, - { - thread_id: this.id, - }, - ); + if (!this.channel) throw new Error('markAsRead: This Thread intance has no channel bound to it'); + + return this.channel.markRead({ thread_id: this.id }); }; // moved from channel to thread directly (skipped getClient thing as this call does not need active WS connection) @@ -476,12 +463,11 @@ export class Thread { sort = DEFAULT_SORT, limit = DEFAULT_PAGE_LIMIT, ...otherOptions - }: QueryRepliesOptions = {}) => - this.client.get>(`${this.client.baseURL}/messages/${this.id}/replies`, { - sort: normalizeQuerySort(sort), - limit, - ...otherOptions, - }); + }: QueryRepliesOptions = {}) => { + if (!this.channel) throw new Error('queryReplies: This Thread intance has no channel bound to it'); + + return this.channel.getReplies(this.id, { limit, ...otherOptions }, sort); + }; // loadNextPage and loadPreviousPage rely on pagination id's calculated from previous requests // these functions exclude these options (id_lt, id_lte...) from their options to prevent unexpected pagination behavior @@ -638,11 +624,16 @@ export class ThreadManager { channelCids.add(thread.channel.cid); } + if (!channelCids.size) return; + try { // FIXME: syncing does not work for me await this.client.sync(Array.from(channelCids), lastConnectionDownAt.toISOString(), { watch: true }); this.state.patchedNext('lastConnectionDownAt', null); } catch (error) { + // TODO: if error mentions that the amount of events is more than 2k + // do a reload-type recovery (re-query threads and merge states) + console.warn(error); } }, diff --git a/test/unit/thread.js b/test/unit/thread.js deleted file mode 100644 index f06a751238..0000000000 --- a/test/unit/thread.js +++ /dev/null @@ -1,70 +0,0 @@ -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('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, threadData: 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'); - }); - }); -}); diff --git a/test/unit/thread.test.ts b/test/unit/thread.test.ts new file mode 100644 index 0000000000..c96e45a520 --- /dev/null +++ b/test/unit/thread.test.ts @@ -0,0 +1,304 @@ +import { expect } 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 { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; +import { generateThread } from './test-utils/generateThread'; + +import { ChannelState, EventTypes, MessageResponse, StreamChat, Thread, ThreadManager } from '../../src'; +import sinon from 'sinon'; + +const TEST_USER_ID = 'observer'; + +describe('Thread', () => { + describe('addReply', async () => { + // TODO: test thread state merging but in Thread instead + + // 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, threadData: 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'); + // }); + }); +}); + +describe('ThreadManager', () => { + let client: StreamChat; + let channelResponse: ReturnType['channel']; + let parentMessageResponse: ReturnType; + let thread: Thread; + let threadManager: ThreadManager; + + beforeEach(() => { + client = new StreamChat('apiKey'); + client.userID = TEST_USER_ID; + client.user = { id: TEST_USER_ID }; + channelResponse = generateChannel().channel; + parentMessageResponse = generateMsg(); + thread = new Thread({ client, threadData: generateThread(channelResponse, parentMessageResponse) }); + threadManager = new ThreadManager({ client }); + }); + + // describe('Initial State', () => { + // // check initial state + // }); + + describe('Subscription Handlers', () => { + beforeEach(() => { + threadManager.registerSubscriptions(); + }); + + ([ + ['health.check', 2], + ['notification.mark_read', 1], + ['notification.thread_message_new', 8], + ['notification.channel_deleted', 11], + ] as const).forEach(([eventType, unreadCount]) => { + it(`unreadThreadsCount changes on ${eventType}`, () => { + client.dispatchEvent({ received_at: new Date().toISOString(), type: eventType, unread_threads: unreadCount }); + + const { unreadThreadsCount } = threadManager.state.getLatestValue(); + + expect(unreadThreadsCount).to.eq(unreadCount); + }); + }); + + describe('Event notification.thread_message_new', () => { + it('does not fill the unseenThreadIds array if threads have not been loaded yet', () => { + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(threadManager.state.getLatestValue().nextCursor).to.be.undefined; + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: uuidv4() }) as MessageResponse, + }); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + }); + + it('adds parentMessageId to the unseenThreadIds array on notification.thread_message_new', () => { + // artificial first page load + threadManager.state.patchedNext('nextCursor', null); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + + const parentMessageId = uuidv4(); + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: parentMessageId }) as MessageResponse, + }); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.have.lengthOf(1); + }); + + it('skips duplicate parentMessageIds in unseenThreadIds array', () => { + // artificial first page load + threadManager.state.patchedNext('nextCursor', null); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + + const parentMessageId = uuidv4(); + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: parentMessageId }) as MessageResponse, + }); + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: parentMessageId }) as MessageResponse, + }); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.have.lengthOf(1); + }); + + it('skips if thread (parentMessageId) is already loaded within threads array', () => { + // artificial first page load + threadManager.state.patchedNext('threads', [thread]); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: thread.id }) as MessageResponse, + }); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + }); + }); + + it('recovers from connection down', () => { + threadManager.state.patchedNext('threads', [thread]); + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'connection.changed', + online: false, + }); + + const { lastConnectionDownAt } = threadManager.state.getLatestValue(); + + expect(lastConnectionDownAt).to.be.a('date'); + + // mock client.sync + const stub = sinon.stub(client, 'sync').resolves(); + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'connection.recovered', + }); + + expect(stub.calledWith([thread.channel!.cid], lastConnectionDownAt?.toISOString())).to.be.true; + + // TODO: simulate .sync fail, check re-query called + }); + + it('always calls reload on ThreadManager activation', () => { + const stub = sinon.stub(threadManager, 'reload').resolves(); + + threadManager.activate(); + + expect(stub.called).to.be.true; + }); + + it('should generate a new threadIdIndexMap on threads array change', () => { + expect(threadManager.state.getLatestValue().threadIdIndexMap).to.deep.equal({}); + + threadManager.state.patchedNext('threads', [thread]); + + expect(threadManager.state.getLatestValue().threadIdIndexMap).to.deep.equal({ [thread.id]: 0 }); + }); + }); + + describe('ThreadManager.reload & ThreadManager.loadNextPage Method', () => { + let stubbedQueryThreads: sinon.SinonStub< + Parameters, + ReturnType + >; + + beforeEach(() => { + stubbedQueryThreads = sinon.stub(client, 'queryThreads').resolves({ + threads: [], + next: undefined, + }); + }); + + it('skips reload if unseenThreadIds array is empty', async () => { + await threadManager.reload(); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(stubbedQueryThreads.notCalled).to.be.true; + }); + + it('has been called with proper limits', async () => { + threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + + await threadManager.reload(); + + expect(stubbedQueryThreads.calledWith({ limit: 2 })).to.be.true; + }); + + it('adds new thread if it does not exist within the threads array', async () => { + threadManager.state.patchedNext('unseenThreadIds', ['t1']); + + stubbedQueryThreads.resolves({ + threads: [thread], + next: undefined, + }); + + await threadManager.reload(); + + const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + + expect(nextCursor).to.be.null; + expect(threads).to.contain(thread); + expect(unseenThreadIds).to.be.empty; + }); + + // TODO: test merge but instance is the same! + it('replaces state of the existing thread which reports stale state within the threads array', async () => { + // prepare + threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + thread.state.patchedNext('isStateStale', true); + + const newThread = new Thread({ + client, + threadData: generateThread(channelResponse, parentMessageResponse, { thread_participants: [{ id: 'u1' }] }), + }); + + expect(thread.state.getLatestValue().participants).to.have.lengthOf(0); + expect(newThread.id).to.equal(thread.id); + expect(newThread).to.not.equal(thread); + + stubbedQueryThreads.resolves({ + threads: [newThread], + next: undefined, + }); + + await threadManager.reload(); + + const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + + expect(nextCursor).to.be.null; + expect(threads).to.have.lengthOf(1); + expect(threads).to.contain(thread); + expect(unseenThreadIds).to.be.empty; + expect(thread.state.getLatestValue().participants).to.have.lengthOf(1); + }); + + // TODO: reload construct new threads with order returned from response + + // ThreadManager.loadNextPage + // TODO: check queryThreads not called if already loading or nothing more to load + // TODO: check nextCursor & threads are set properly + // TODO: check queryThreads is called with proper nextCursor + }); +}); From 7b205681f99fa38bb57dfef52537fb441338a2d9 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Wed, 24 Jul 2024 15:11:11 +0200 Subject: [PATCH 12/41] Finalize ThreadManager tests --- src/thread.ts | 7 +- test/unit/thread.test.ts | 205 +++++++++++++++++++++++++++++---------- 2 files changed, 159 insertions(+), 53 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index 0973836043..2656e8357f 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -31,7 +31,7 @@ type QueryRepliesOptions = { sort?: { created_at: AscDesc }[]; } & MessagePaginationOptions & { user?: UserResponse; user_id?: string }; -type ThreadState = { +export type ThreadState = { active: boolean; createdAt: Date; @@ -539,11 +539,10 @@ export class Thread { }; } -type ThreadManagerState = { +export type ThreadManagerState = { active: boolean; lastConnectionDownAt: Date | null; loadingNextPage: boolean; - loadingPreviousPage: boolean; threadIdIndexMap: { [key: string]: number }; threads: Thread[]; unreadThreadsCount: number; @@ -551,6 +550,7 @@ type ThreadManagerState = { nextCursor?: string | null; // null means no next page available // TODO?: implement once supported by BE // previousCursor?: string | null; + // loadingPreviousPage: boolean; }; export class ThreadManager { @@ -570,7 +570,6 @@ export class ThreadManager { unseenThreadIds: [], lastConnectionDownAt: null, loadingNextPage: false, - loadingPreviousPage: false, nextCursor: undefined, }); diff --git a/test/unit/thread.test.ts b/test/unit/thread.test.ts index c96e45a520..b7b1ce3ed0 100644 --- a/test/unit/thread.test.ts +++ b/test/unit/thread.test.ts @@ -18,7 +18,6 @@ const TEST_USER_ID = 'observer'; describe('Thread', () => { describe('addReply', async () => { // TODO: test thread state merging but in Thread instead - // let client; // let channel; // let parent; @@ -218,7 +217,7 @@ describe('ThreadManager', () => { }); }); - describe('ThreadManager.reload & ThreadManager.loadNextPage Method', () => { + describe('ThreadManager.reload & ThreadManager.loadNextPage Methods', () => { let stubbedQueryThreads: sinon.SinonStub< Parameters, ReturnType @@ -231,74 +230,182 @@ describe('ThreadManager', () => { }); }); - it('skips reload if unseenThreadIds array is empty', async () => { - await threadManager.reload(); + describe('ThreadManager.reload', () => { + it('skips reload if unseenThreadIds array is empty', async () => { + await threadManager.reload(); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - expect(stubbedQueryThreads.notCalled).to.be.true; - }); + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(stubbedQueryThreads.notCalled).to.be.true; + }); - it('has been called with proper limits', async () => { - threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + it('has been called with proper limits', async () => { + threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); - await threadManager.reload(); + await threadManager.reload(); - expect(stubbedQueryThreads.calledWith({ limit: 2 })).to.be.true; - }); + expect(stubbedQueryThreads.calledWith({ limit: 2 })).to.be.true; + }); - it('adds new thread if it does not exist within the threads array', async () => { - threadManager.state.patchedNext('unseenThreadIds', ['t1']); + it('adds new thread if it does not exist within the threads array', async () => { + threadManager.state.patchedNext('unseenThreadIds', ['t1']); - stubbedQueryThreads.resolves({ - threads: [thread], - next: undefined, + stubbedQueryThreads.resolves({ + threads: [thread], + next: undefined, + }); + + await threadManager.reload(); + + const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + + expect(nextCursor).to.be.null; + expect(threads).to.contain(thread); + expect(unseenThreadIds).to.be.empty; }); - await threadManager.reload(); + // TODO: test merge but instance is the same! + it('replaces state of the existing thread which reports stale state within the threads array', async () => { + // prepare + threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + thread.state.patchedNext('isStateStale', true); + + const newThread = new Thread({ + client, + threadData: generateThread(channelResponse, parentMessageResponse, { thread_participants: [{ id: 'u1' }] }), + }); - const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + expect(thread.state.getLatestValue().participants).to.have.lengthOf(0); + expect(newThread.id).to.equal(thread.id); + expect(newThread).to.not.equal(thread); - expect(nextCursor).to.be.null; - expect(threads).to.contain(thread); - expect(unseenThreadIds).to.be.empty; + stubbedQueryThreads.resolves({ + threads: [newThread], + next: undefined, + }); + + await threadManager.reload(); + + const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + + expect(nextCursor).to.be.null; + expect(threads).to.have.lengthOf(1); + expect(threads).to.contain(thread); + expect(unseenThreadIds).to.be.empty; + expect(thread.state.getLatestValue().participants).to.have.lengthOf(1); + }); + + it('new state reflects order of the threads coming from the response', async () => { + // prepare + threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + + const newThreads = [ + new Thread({ + client, + threadData: generateThread(channelResponse, generateMsg()), + }), + // same thread.id as prepared thread (just changed position in the response and different instance) + new Thread({ + client, + threadData: generateThread(channelResponse, parentMessageResponse, { thread_participants: [{ id: 'u1' }] }), + }), + new Thread({ + client, + threadData: generateThread(channelResponse, generateMsg()), + }), + ]; + + expect(newThreads[1].id).to.equal(thread.id); + expect(newThreads[1]).to.not.equal(thread); + + stubbedQueryThreads.resolves({ + threads: newThreads, + next: undefined, + }); + + await threadManager.reload(); + + const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + + expect(nextCursor).to.be.null; + expect(threads).to.have.lengthOf(3); + expect(threads[1]).to.equal(thread); + expect(unseenThreadIds).to.be.empty; + }); }); - // TODO: test merge but instance is the same! - it('replaces state of the existing thread which reports stale state within the threads array', async () => { - // prepare - threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); - thread.state.patchedNext('isStateStale', true); + describe('ThreadManager.loadNextPage', () => { + it("prevents loading next page if there's no next page to load", async () => { + expect(threadManager.state.getLatestValue().nextCursor).is.undefined; + + threadManager.state.patchedNext('nextCursor', null); + + await threadManager.loadNextPage(); - const newThread = new Thread({ - client, - threadData: generateThread(channelResponse, parentMessageResponse, { thread_participants: [{ id: 'u1' }] }), + expect(stubbedQueryThreads.called).to.be.false; }); - expect(thread.state.getLatestValue().participants).to.have.lengthOf(0); - expect(newThread.id).to.equal(thread.id); - expect(newThread).to.not.equal(thread); + it('prevents loading next page if already loading', async () => { + expect(threadManager.state.getLatestValue().loadingNextPage).is.false; - stubbedQueryThreads.resolves({ - threads: [newThread], - next: undefined, + threadManager.state.patchedNext('loadingNextPage', true); + + await threadManager.loadNextPage(); + + expect(stubbedQueryThreads.called).to.be.false; }); - await threadManager.reload(); + it('switches loading state properly', async () => { + const spy = sinon.spy(); - const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + threadManager.state.subscribeWithSelector((nextValue) => [nextValue.loadingNextPage], spy, false); - expect(nextCursor).to.be.null; - expect(threads).to.have.lengthOf(1); - expect(threads).to.contain(thread); - expect(unseenThreadIds).to.be.empty; - expect(thread.state.getLatestValue().participants).to.have.lengthOf(1); - }); + await threadManager.loadNextPage(); - // TODO: reload construct new threads with order returned from response + expect(spy.callCount).to.equal(2); + expect(spy.firstCall.calledWith([true])).to.be.true; + expect(spy.lastCall.calledWith([false])).to.be.true; + }); + + it('sets proper nextCursor and threads', async () => { + threadManager.state.patchedNext('threads', [thread]); + + const newThread = new Thread({ + client, + threadData: generateThread(channelResponse, generateMsg()), + }); + + stubbedQueryThreads.resolves({ + threads: [newThread], + next: undefined, + }); - // ThreadManager.loadNextPage - // TODO: check queryThreads not called if already loading or nothing more to load - // TODO: check nextCursor & threads are set properly - // TODO: check queryThreads is called with proper nextCursor + await threadManager.loadNextPage(); + + const { threads, nextCursor } = threadManager.state.getLatestValue(); + + expect(threads).to.have.lengthOf(2); + expect(threads[1]).to.equal(newThread); + expect(nextCursor).to.be.null; + }); + + it('is called with proper nextCursor and sets new nextCursor', async () => { + const cursor1 = uuidv4(); + const cursor2 = uuidv4(); + + threadManager.state.patchedNext('nextCursor', cursor1); + + stubbedQueryThreads.resolves({ + threads: [], + next: cursor2, + }); + + await threadManager.loadNextPage(); + + const { nextCursor } = threadManager.state.getLatestValue(); + + expect(stubbedQueryThreads.calledWithMatch({ next: cursor1 })).to.be.true; + expect(nextCursor).to.equal(cursor2); + }); + }); }); }); From f8a9ea335c40f14b83b02528f6f16db653dc5cd2 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 26 Jul 2024 01:37:11 +0200 Subject: [PATCH 13/41] Added new tests and fixes --- src/store/SimpleStateStore.ts | 13 +- src/thread.ts | 6 +- test/unit/test-utils/generateThread.js | 2 +- test/unit/thread.test.ts | 719 ++++++++++++++++--------- 4 files changed, 459 insertions(+), 281 deletions(-) diff --git a/src/store/SimpleStateStore.ts b/src/store/SimpleStateStore.ts index a62b7c42cc..fd9a3a88c1 100644 --- a/src/store/SimpleStateStore.ts +++ b/src/store/SimpleStateStore.ts @@ -21,9 +21,6 @@ function isInitiator(value: T | Initiator): value is Initiator { export class SimpleStateStore< T // TODO: limit T to object only? - // O extends { - // [K in keyof T]: T[K] extends Function ? K : never; - // }[keyof T] = never > { private value: T; private handlerSet = new Set>(); @@ -47,19 +44,11 @@ export class SimpleStateStore< this.handlerSet.forEach((handler) => handler(this.value, oldValue)); }; - public patchedNext = (key: keyof T, newValue: T[typeof key]) => + public patchedNext = (key: L, newValue: T[L]) => this.next((current) => ({ ...current, [key]: newValue })); public getLatestValue = () => this.value; - // TODO: filter and return actions (functions) only in a type-safe manner (only allows state T to be a dict) - // public get actions(): { [K in O]: T[K] } { - // return {}; - // } - public get actions() { - return this.value; - } - public subscribe = (handler: Handler) => { handler(this.value); this.handlerSet.add(handler); diff --git a/src/thread.ts b/src/thread.ts index 2656e8357f..5d3985a3ff 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -182,7 +182,7 @@ export class Thread { read, staggeredRead, replyCount, - latestReplies: latestReplies.concat(failedReplies), + latestReplies: latestReplies.length ? latestReplies.concat(failedReplies) : latestReplies, parentMessage, participants, createdAt, @@ -291,6 +291,10 @@ export class Thread { if (!event.message || !currentUserId) return; if (event.message.parent_id !== this.id) return; + const { isStateStale } = this.state.getLatestValue(); + + if (isStateStale) return; + if (this.failedRepliesMap.has(event.message.id)) { this.failedRepliesMap.delete(event.message.id); } diff --git a/test/unit/test-utils/generateThread.js b/test/unit/test-utils/generateThread.js index 93d322ec35..77c759aa4a 100644 --- a/test/unit/test-utils/generateThread.js +++ b/test/unit/test-utils/generateThread.js @@ -11,7 +11,7 @@ export const generateThread = (channel, parent, opts = {}) => { updated_at: new Date().toISOString(), channel_cid: channel.cid, last_message_at: new Date().toISOString(), - deleted_at: '', + deleted_at: undefined, read: [], reply_count: 0, latest_replies: [], diff --git a/test/unit/thread.test.ts b/test/unit/thread.test.ts index b7b1ce3ed0..72eeb94e43 100644 --- a/test/unit/thread.test.ts +++ b/test/unit/thread.test.ts @@ -10,401 +10,586 @@ import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; import { generateThread } from './test-utils/generateThread'; -import { ChannelState, EventTypes, MessageResponse, StreamChat, Thread, ThreadManager } from '../../src'; +import { Channel, MessageResponse, StreamChat, Thread, ThreadManager, formatMessage } from '../../src'; import sinon from 'sinon'; const TEST_USER_ID = 'observer'; -describe('Thread', () => { - describe('addReply', async () => { - // TODO: test thread state merging but in Thread instead - // 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, threadData: 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'); - // }); - }); -}); - -describe('ThreadManager', () => { +describe('Threads 2.0', () => { let client: StreamChat; let channelResponse: ReturnType['channel']; + let channel: Channel; let parentMessageResponse: ReturnType; let thread: Thread; let threadManager: ThreadManager; beforeEach(() => { client = new StreamChat('apiKey'); - client.userID = TEST_USER_ID; - client.user = { id: TEST_USER_ID }; + client._setUser({ id: TEST_USER_ID }); + channelResponse = generateChannel().channel; + channel = client.channel(channelResponse.type, channelResponse.id); parentMessageResponse = generateMsg(); thread = new Thread({ client, threadData: generateThread(channelResponse, parentMessageResponse) }); threadManager = new ThreadManager({ client }); }); - // describe('Initial State', () => { - // // check initial state - // }); - - describe('Subscription Handlers', () => { - beforeEach(() => { - threadManager.registerSubscriptions(); + describe('Thread', () => { + it('has constructed proper initial state', () => { + // TODO: id equal to parent message id + // TODO: read state as dictionary + // TODO: channel as instance + // TODO: latest replies formatted + // TODO: parent message formatted + // TODO: created_at formatted + // TODO: deleted_at formatted (or null if not applicable) + // }); - ([ - ['health.check', 2], - ['notification.mark_read', 1], - ['notification.thread_message_new', 8], - ['notification.channel_deleted', 11], - ] as const).forEach(([eventType, unreadCount]) => { - it(`unreadThreadsCount changes on ${eventType}`, () => { - client.dispatchEvent({ received_at: new Date().toISOString(), type: eventType, unread_threads: unreadCount }); + describe('Methods', () => { + describe('Thread.upsertReply', () => { + // does not test whether the message has been inserted at the correct position + // that should be unit-tested separately (addToMessageList utility function) - const { unreadThreadsCount } = threadManager.state.getLatestValue(); + it('prevents inserting a new message that does not belong to the associated thread', () => { + const newMessage = generateMsg(); - expect(unreadThreadsCount).to.eq(unreadCount); - }); - }); + const fn = () => { + thread.upsertReplyLocally({ message: newMessage as MessageResponse }); + }; - describe('Event notification.thread_message_new', () => { - it('does not fill the unseenThreadIds array if threads have not been loaded yet', () => { - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - expect(threadManager.state.getLatestValue().nextCursor).to.be.undefined; + expect(fn).to.throw(Error); + }); - client.dispatchEvent({ - received_at: new Date().toISOString(), - type: 'notification.thread_message_new', - message: generateMsg({ parent_id: uuidv4() }) as MessageResponse, + it('inserts a new message that belongs to the associated thread', () => { + const newMessage = generateMsg({ parent_id: thread.id }); + + const { latestReplies } = thread.state.getLatestValue(); + + expect(latestReplies).to.have.lengthOf(0); + + thread.upsertReplyLocally({ message: newMessage as MessageResponse }); + + expect(thread.state.getLatestValue().latestReplies).to.have.lengthOf(1); + expect(thread.state.getLatestValue().latestReplies[0].id).to.equal(newMessage.id); }); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - }); + it('updates existing message', () => { + const newMessage = formatMessage(generateMsg({ parent_id: thread.id, text: 'aaa' }) as MessageResponse); + const newMessageCopy = ({ ...newMessage, text: 'bbb' } as unknown) as MessageResponse; - it('adds parentMessageId to the unseenThreadIds array on notification.thread_message_new', () => { - // artificial first page load - threadManager.state.patchedNext('nextCursor', null); + thread.state.patchedNext('latestReplies', [newMessage]); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + const { latestReplies } = thread.state.getLatestValue(); - const parentMessageId = uuidv4(); + expect(latestReplies).to.have.lengthOf(1); + expect(latestReplies.at(0)!.id).to.equal(newMessageCopy.id); + expect(latestReplies.at(0)!.text).to.not.equal(newMessageCopy.text); - client.dispatchEvent({ - received_at: new Date().toISOString(), - type: 'notification.thread_message_new', - message: generateMsg({ parent_id: parentMessageId }) as MessageResponse, + thread.upsertReplyLocally({ message: newMessageCopy }); + + expect(thread.state.getLatestValue().latestReplies).to.have.lengthOf(1); + expect(thread.state.getLatestValue().latestReplies.at(0)!.text).to.equal(newMessageCopy.text); }); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.have.lengthOf(1); + // TODO: timestampChanged (check that duplicates get removed) }); - it('skips duplicate parentMessageIds in unseenThreadIds array', () => { - // artificial first page load - threadManager.state.patchedNext('nextCursor', null); + describe('Thread.partiallyReplaceState', () => { + it('prevents copying state of the instance with different id', () => { + const newThread = new Thread({ + client, + threadData: generateThread(generateChannel({ channel: { id: channelResponse.id } }).channel, generateMsg()), + }); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(thread.id).to.not.equal(newThread.id); - const parentMessageId = uuidv4(); + thread.partiallyReplaceState({ thread: newThread }); - client.dispatchEvent({ - received_at: new Date().toISOString(), - type: 'notification.thread_message_new', - message: generateMsg({ parent_id: parentMessageId }) as MessageResponse, + const { read, latestReplies, parentMessage, participants, channelData } = thread.state.getLatestValue(); + + // compare non-primitive values only + expect(read).to.not.equal(newThread.state.getLatestValue().read); + expect(latestReplies).to.not.equal(newThread.state.getLatestValue().latestReplies); + expect(parentMessage).to.not.equal(newThread.state.getLatestValue().parentMessage); + expect(participants).to.not.equal(newThread.state.getLatestValue().participants); + expect(channelData).to.not.equal(newThread.state.getLatestValue().channelData); }); - client.dispatchEvent({ - received_at: new Date().toISOString(), - type: 'notification.thread_message_new', - message: generateMsg({ parent_id: parentMessageId }) as MessageResponse, + it('copies state of the instance with the same id', () => { + const newThread = new Thread({ + client, + threadData: generateThread( + generateChannel({ channel: { id: channelResponse.id } }).channel, + generateMsg({ id: parentMessageResponse.id }), + ), + }); + + expect(thread.id).to.equal(newThread.id); + expect(thread).to.not.equal(newThread); + + thread.partiallyReplaceState({ thread: newThread }); + + const { read, latestReplies, parentMessage, participants, channelData } = thread.state.getLatestValue(); + + // compare non-primitive values only + expect(read).to.equal(newThread.state.getLatestValue().read); + expect(latestReplies).to.equal(newThread.state.getLatestValue().latestReplies); + expect(parentMessage).to.equal(newThread.state.getLatestValue().parentMessage); + expect(participants).to.equal(newThread.state.getLatestValue().participants); + expect(channelData).to.equal(newThread.state.getLatestValue().channelData); }); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.have.lengthOf(1); + it('appends own failed replies from failedRepliesMap during merging', () => { + const newThread = new Thread({ + client, + threadData: generateThread( + generateChannel({ channel: { id: channelResponse.id } }).channel, + generateMsg({ id: parentMessageResponse.id }), + { latest_replies: [generateMsg({ parent_id: parentMessageResponse.id })] }, + ), + }); + + const failedMessage = formatMessage( + generateMsg({ status: 'failed', parent_id: thread.id }) as MessageResponse, + ); + thread.upsertReplyLocally({ message: failedMessage }); + + expect(thread.id).to.equal(newThread.id); + expect(thread).to.not.equal(newThread); + + thread.partiallyReplaceState({ thread: newThread }); + + const { latestReplies } = thread.state.getLatestValue(); + + // compare non-primitive values only + expect(latestReplies).to.have.lengthOf(2); + expect(latestReplies.at(-1)!.id).to.equal(failedMessage.id); + expect(latestReplies).to.not.equal(newThread.state.getLatestValue().latestReplies); + }); }); - it('skips if thread (parentMessageId) is already loaded within threads array', () => { - // artificial first page load - threadManager.state.patchedNext('threads', [thread]); + describe('Thread.incrementOwnUnreadCount', () => { + it('increments own unread count even if read object is empty', () => { + const { read } = thread.state.getLatestValue(); + // TODO: write a helper for immediate own unread count + const ownUnreadCount = read[TEST_USER_ID]?.unread_messages ?? 0; - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(ownUnreadCount).to.equal(0); - client.dispatchEvent({ - received_at: new Date().toISOString(), - type: 'notification.thread_message_new', - message: generateMsg({ parent_id: thread.id }) as MessageResponse, + thread.incrementOwnUnreadCount(); + + expect(thread.state.getLatestValue().read[TEST_USER_ID]?.unread_messages).to.equal(1); }); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - }); - }); + it("increments own unread count if read object contains current user's record", () => { + // prepare + thread.state.patchedNext('read', { + [TEST_USER_ID]: { + lastReadAt: new Date(), + last_read: '', + last_read_message_id: '', + unread_messages: 2, + user: { id: TEST_USER_ID }, + }, + }); + + const { read } = thread.state.getLatestValue(); + const ownUnreadCount = read[TEST_USER_ID]?.unread_messages ?? 0; - it('recovers from connection down', () => { - threadManager.state.patchedNext('threads', [thread]); + expect(ownUnreadCount).to.equal(2); - client.dispatchEvent({ - received_at: new Date().toISOString(), - type: 'connection.changed', - online: false, + thread.incrementOwnUnreadCount(); + + expect(thread.state.getLatestValue().read[TEST_USER_ID]?.unread_messages).to.equal(3); + }); + }); + + describe('Thread.deleteReplyLocally', () => { + // it(''); }); - const { lastConnectionDownAt } = threadManager.state.getLatestValue(); + describe('Thread.markAsRead', () => { + let stubbedChannelMarkRead: sinon.SinonStub, ReturnType>; - expect(lastConnectionDownAt).to.be.a('date'); + beforeEach(() => { + stubbedChannelMarkRead = sinon.stub(channel, 'markRead').resolves(); + }); - // mock client.sync - const stub = sinon.stub(client, 'sync').resolves(); + it('prevents calling channel.markRead if the unread count of the current user is 0', async () => { + const { read } = thread.state.getLatestValue(); + const ownUnreadCount = read[TEST_USER_ID]?.unread_messages ?? 0; - client.dispatchEvent({ - received_at: new Date().toISOString(), - type: 'connection.recovered', - }); + expect(ownUnreadCount).to.equal(0); - expect(stub.calledWith([thread.channel!.cid], lastConnectionDownAt?.toISOString())).to.be.true; + await thread.markAsRead(); - // TODO: simulate .sync fail, check re-query called - }); + expect(stubbedChannelMarkRead.notCalled).to.be.true; + }); - it('always calls reload on ThreadManager activation', () => { - const stub = sinon.stub(threadManager, 'reload').resolves(); + it('calls channel.markRead if the unread count is greater than zero', async () => { + // prepare + thread.incrementOwnUnreadCount(); - threadManager.activate(); + const { read } = thread.state.getLatestValue(); - expect(stub.called).to.be.true; + const ownUnreadCount = read[TEST_USER_ID]?.unread_messages ?? 0; + + expect(ownUnreadCount).to.equal(1); + + await thread.markAsRead(); + + expect(stubbedChannelMarkRead.calledOnceWith({ thread_id: thread.id })).to.be.true; + }); + }); }); - it('should generate a new threadIdIndexMap on threads array change', () => { - expect(threadManager.state.getLatestValue().threadIdIndexMap).to.deep.equal({}); + describe('Subscription Handlers', () => { + beforeEach(() => { + thread.registerSubscriptions(); + }); + + it('calls markAsRead whenever it becomes active or own reply count increases', () => {}); + + it('it recovers from stale state whenever it becomes active (or is active and becomes stale)', () => {}); + + it('marks own state as stale whenever current user stops watching associated channel', () => {}); - threadManager.state.patchedNext('threads', [thread]); + it('properly handles new messages', () => {}); - expect(threadManager.state.getLatestValue().threadIdIndexMap).to.deep.equal({ [thread.id]: 0 }); + it('properly handles message updates (both reply and parent)', () => {}); }); }); - describe('ThreadManager.reload & ThreadManager.loadNextPage Methods', () => { - let stubbedQueryThreads: sinon.SinonStub< - Parameters, - ReturnType - >; + describe('ThreadManager', () => { + // describe('Initial State', () => { + // // check initial state + // }); - beforeEach(() => { - stubbedQueryThreads = sinon.stub(client, 'queryThreads').resolves({ - threads: [], - next: undefined, + describe('Subscription Handlers', () => { + beforeEach(() => { + threadManager.registerSubscriptions(); }); - }); - describe('ThreadManager.reload', () => { - it('skips reload if unseenThreadIds array is empty', async () => { - await threadManager.reload(); + ([ + ['health.check', 2], + ['notification.mark_read', 1], + ['notification.thread_message_new', 8], + ['notification.channel_deleted', 11], + ] as const).forEach(([eventType, unreadCount]) => { + it(`unreadThreadsCount changes on ${eventType}`, () => { + client.dispatchEvent({ received_at: new Date().toISOString(), type: eventType, unread_threads: unreadCount }); + + const { unreadThreadsCount } = threadManager.state.getLatestValue(); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - expect(stubbedQueryThreads.notCalled).to.be.true; + expect(unreadThreadsCount).to.eq(unreadCount); + }); }); - it('has been called with proper limits', async () => { - threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + describe('Event notification.thread_message_new', () => { + it('does not fill the unseenThreadIds array if threads have not been loaded yet', () => { + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(threadManager.state.getLatestValue().nextCursor).to.be.undefined; - await threadManager.reload(); + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: uuidv4() }) as MessageResponse, + }); - expect(stubbedQueryThreads.calledWith({ limit: 2 })).to.be.true; - }); + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + }); - it('adds new thread if it does not exist within the threads array', async () => { - threadManager.state.patchedNext('unseenThreadIds', ['t1']); + it('adds parentMessageId to the unseenThreadIds array on notification.thread_message_new', () => { + // artificial first page load + threadManager.state.patchedNext('nextCursor', null); - stubbedQueryThreads.resolves({ - threads: [thread], - next: undefined, + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + + const parentMessageId = uuidv4(); + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: parentMessageId }) as MessageResponse, + }); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.have.lengthOf(1); }); - await threadManager.reload(); + it('skips duplicate parentMessageIds in unseenThreadIds array', () => { + // artificial first page load + threadManager.state.patchedNext('nextCursor', null); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + + const parentMessageId = uuidv4(); + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: parentMessageId }) as MessageResponse, + }); + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: parentMessageId }) as MessageResponse, + }); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.have.lengthOf(1); + }); + + it('skips if thread (parentMessageId) is already loaded within threads array', () => { + // artificial first page load + threadManager.state.patchedNext('threads', [thread]); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: thread.id }) as MessageResponse, + }); - expect(nextCursor).to.be.null; - expect(threads).to.contain(thread); - expect(unseenThreadIds).to.be.empty; + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + }); }); - // TODO: test merge but instance is the same! - it('replaces state of the existing thread which reports stale state within the threads array', async () => { - // prepare - threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); - thread.state.patchedNext('isStateStale', true); + it('recovers from connection down', () => { + threadManager.state.patchedNext('threads', [thread]); - const newThread = new Thread({ - client, - threadData: generateThread(channelResponse, parentMessageResponse, { thread_participants: [{ id: 'u1' }] }), + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'connection.changed', + online: false, }); - expect(thread.state.getLatestValue().participants).to.have.lengthOf(0); - expect(newThread.id).to.equal(thread.id); - expect(newThread).to.not.equal(thread); + const { lastConnectionDownAt } = threadManager.state.getLatestValue(); - stubbedQueryThreads.resolves({ - threads: [newThread], - next: undefined, + expect(lastConnectionDownAt).to.be.a('date'); + + // mock client.sync + const stub = sinon.stub(client, 'sync').resolves(); + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'connection.recovered', }); - await threadManager.reload(); + expect(stub.calledWith([thread.channel!.cid], lastConnectionDownAt?.toISOString())).to.be.true; + + // TODO: simulate .sync fail, check re-query called + }); - const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + it('always calls reload on ThreadManager activation', () => { + const stub = sinon.stub(threadManager, 'reload').resolves(); - expect(nextCursor).to.be.null; - expect(threads).to.have.lengthOf(1); - expect(threads).to.contain(thread); - expect(unseenThreadIds).to.be.empty; - expect(thread.state.getLatestValue().participants).to.have.lengthOf(1); + threadManager.activate(); + + expect(stub.called).to.be.true; }); - it('new state reflects order of the threads coming from the response', async () => { - // prepare - threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + it('should generate a new threadIdIndexMap on threads array change', () => { + expect(threadManager.state.getLatestValue().threadIdIndexMap).to.deep.equal({}); - const newThreads = [ - new Thread({ - client, - threadData: generateThread(channelResponse, generateMsg()), - }), - // same thread.id as prepared thread (just changed position in the response and different instance) - new Thread({ - client, - threadData: generateThread(channelResponse, parentMessageResponse, { thread_participants: [{ id: 'u1' }] }), - }), - new Thread({ - client, - threadData: generateThread(channelResponse, generateMsg()), - }), - ]; + threadManager.state.patchedNext('threads', [thread]); + + expect(threadManager.state.getLatestValue().threadIdIndexMap).to.deep.equal({ [thread.id]: 0 }); + }); + }); - expect(newThreads[1].id).to.equal(thread.id); - expect(newThreads[1]).to.not.equal(thread); + describe('Methods', () => { + let stubbedQueryThreads: sinon.SinonStub< + Parameters, + ReturnType + >; - stubbedQueryThreads.resolves({ - threads: newThreads, + beforeEach(() => { + stubbedQueryThreads = sinon.stub(client, 'queryThreads').resolves({ + threads: [], next: undefined, }); + }); - await threadManager.reload(); + describe('ThreadManager.reload', () => { + it('skips reload if unseenThreadIds array is empty', async () => { + await threadManager.reload(); - const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(stubbedQueryThreads.notCalled).to.be.true; + }); - expect(nextCursor).to.be.null; - expect(threads).to.have.lengthOf(3); - expect(threads[1]).to.equal(thread); - expect(unseenThreadIds).to.be.empty; - }); - }); + it('has been called with proper limits', async () => { + threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); - describe('ThreadManager.loadNextPage', () => { - it("prevents loading next page if there's no next page to load", async () => { - expect(threadManager.state.getLatestValue().nextCursor).is.undefined; + await threadManager.reload(); - threadManager.state.patchedNext('nextCursor', null); + expect(stubbedQueryThreads.calledWith({ limit: 2 })).to.be.true; + }); - await threadManager.loadNextPage(); + it('adds new thread if it does not exist within the threads array', async () => { + threadManager.state.patchedNext('unseenThreadIds', ['t1']); - expect(stubbedQueryThreads.called).to.be.false; - }); + stubbedQueryThreads.resolves({ + threads: [thread], + next: undefined, + }); - it('prevents loading next page if already loading', async () => { - expect(threadManager.state.getLatestValue().loadingNextPage).is.false; + await threadManager.reload(); - threadManager.state.patchedNext('loadingNextPage', true); + const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); - await threadManager.loadNextPage(); + expect(nextCursor).to.be.null; + expect(threads).to.contain(thread); + expect(unseenThreadIds).to.be.empty; + }); - expect(stubbedQueryThreads.called).to.be.false; - }); + // TODO: test merge but instance is the same! + it('replaces state of the existing thread which reports stale state within the threads array', async () => { + // prepare + threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + thread.state.patchedNext('isStateStale', true); + + const newThread = new Thread({ + client, + threadData: generateThread(channelResponse, parentMessageResponse, { thread_participants: [{ id: 'u1' }] }), + }); - it('switches loading state properly', async () => { - const spy = sinon.spy(); + expect(thread.state.getLatestValue().participants).to.have.lengthOf(0); + expect(newThread.id).to.equal(thread.id); + expect(newThread).to.not.equal(thread); - threadManager.state.subscribeWithSelector((nextValue) => [nextValue.loadingNextPage], spy, false); + stubbedQueryThreads.resolves({ + threads: [newThread], + next: undefined, + }); - await threadManager.loadNextPage(); + await threadManager.reload(); - expect(spy.callCount).to.equal(2); - expect(spy.firstCall.calledWith([true])).to.be.true; - expect(spy.lastCall.calledWith([false])).to.be.true; + const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + + expect(nextCursor).to.be.null; + expect(threads).to.have.lengthOf(1); + expect(threads).to.contain(thread); + expect(unseenThreadIds).to.be.empty; + expect(thread.state.getLatestValue().participants).to.have.lengthOf(1); + }); + + it('new state reflects order of the threads coming from the response', async () => { + // prepare + threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + + const newThreads = [ + new Thread({ + client, + threadData: generateThread(channelResponse, generateMsg()), + }), + // same thread.id as prepared thread (just changed position in the response and different instance) + new Thread({ + client, + threadData: generateThread(channelResponse, parentMessageResponse, { + thread_participants: [{ id: 'u1' }], + }), + }), + new Thread({ + client, + threadData: generateThread(channelResponse, generateMsg()), + }), + ]; + + expect(newThreads[1].id).to.equal(thread.id); + expect(newThreads[1]).to.not.equal(thread); + + stubbedQueryThreads.resolves({ + threads: newThreads, + next: undefined, + }); + + await threadManager.reload(); + + const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + + expect(nextCursor).to.be.null; + expect(threads).to.have.lengthOf(3); + expect(threads[1]).to.equal(thread); + expect(unseenThreadIds).to.be.empty; + }); }); - it('sets proper nextCursor and threads', async () => { - threadManager.state.patchedNext('threads', [thread]); + describe('ThreadManager.loadNextPage', () => { + it("prevents loading next page if there's no next page to load", async () => { + expect(threadManager.state.getLatestValue().nextCursor).is.undefined; + + threadManager.state.patchedNext('nextCursor', null); - const newThread = new Thread({ - client, - threadData: generateThread(channelResponse, generateMsg()), + await threadManager.loadNextPage(); + + expect(stubbedQueryThreads.called).to.be.false; }); - stubbedQueryThreads.resolves({ - threads: [newThread], - next: undefined, + it('prevents loading next page if already loading', async () => { + expect(threadManager.state.getLatestValue().loadingNextPage).is.false; + + threadManager.state.patchedNext('loadingNextPage', true); + + await threadManager.loadNextPage(); + + expect(stubbedQueryThreads.called).to.be.false; }); - await threadManager.loadNextPage(); + it('switches loading state properly', async () => { + const spy = sinon.spy(); - const { threads, nextCursor } = threadManager.state.getLatestValue(); + threadManager.state.subscribeWithSelector((nextValue) => [nextValue.loadingNextPage], spy, false); - expect(threads).to.have.lengthOf(2); - expect(threads[1]).to.equal(newThread); - expect(nextCursor).to.be.null; - }); + await threadManager.loadNextPage(); - it('is called with proper nextCursor and sets new nextCursor', async () => { - const cursor1 = uuidv4(); - const cursor2 = uuidv4(); + expect(spy.callCount).to.equal(2); + expect(spy.firstCall.calledWith([true])).to.be.true; + expect(spy.lastCall.calledWith([false])).to.be.true; + }); - threadManager.state.patchedNext('nextCursor', cursor1); + it('sets proper nextCursor and threads', async () => { + threadManager.state.patchedNext('threads', [thread]); - stubbedQueryThreads.resolves({ - threads: [], - next: cursor2, + const newThread = new Thread({ + client, + threadData: generateThread(channelResponse, generateMsg()), + }); + + stubbedQueryThreads.resolves({ + threads: [newThread], + next: undefined, + }); + + await threadManager.loadNextPage(); + + const { threads, nextCursor } = threadManager.state.getLatestValue(); + + expect(threads).to.have.lengthOf(2); + expect(threads[1]).to.equal(newThread); + expect(nextCursor).to.be.null; }); - await threadManager.loadNextPage(); + it('is called with proper nextCursor and sets new nextCursor', async () => { + const cursor1 = uuidv4(); + const cursor2 = uuidv4(); - const { nextCursor } = threadManager.state.getLatestValue(); + threadManager.state.patchedNext('nextCursor', cursor1); - expect(stubbedQueryThreads.calledWithMatch({ next: cursor1 })).to.be.true; - expect(nextCursor).to.equal(cursor2); + stubbedQueryThreads.resolves({ + threads: [], + next: cursor2, + }); + + await threadManager.loadNextPage(); + + const { nextCursor } = threadManager.state.getLatestValue(); + + expect(stubbedQueryThreads.calledWithMatch({ next: cursor1 })).to.be.true; + expect(nextCursor).to.equal(cursor2); + }); }); }); }); From 69fdaef84ed4c68b9285f5ffff5d2cdd42b1443e Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 26 Jul 2024 20:50:22 +0200 Subject: [PATCH 14/41] Drop lodash.throttle, use own implementation --- package.json | 2 -- src/thread.ts | 4 +--- src/utils.ts | 33 +++++++++++++++++++++++++++++++++ yarn.lock | 17 ----------------- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 4d2f911842..1ca6eee803 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "form-data": "^4.0.0", "isomorphic-ws": "^4.0.1", "jsonwebtoken": "~9.0.0", - "lodash.throttle": "^4.1.1", "ws": "^7.4.4" }, "devDependencies": { @@ -72,7 +71,6 @@ "@types/chai-as-promised": "^7.1.4", "@types/chai-like": "^1.1.1", "@types/eslint": "7.2.7", - "@types/lodash.throttle": "^4.1.9", "@types/mocha": "^9.0.0", "@types/node": "^16.11.11", "@types/prettier": "^2.2.2", diff --git a/src/thread.ts b/src/thread.ts index 5d3985a3ff..827760bd2c 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,5 +1,3 @@ -import throttle from 'lodash.throttle'; - import { StreamChat } from './client'; import { Channel } from './channel'; import type { @@ -14,7 +12,7 @@ import type { MessagePaginationOptions, AscDesc, } from './types'; -import { addToMessageList, findInsertionIndex, formatMessage, transformReadArrayToDictionary } from './utils'; +import { addToMessageList, findInsertionIndex, formatMessage, transformReadArrayToDictionary, throttle } from './utils'; import { Handler, SimpleStateStore } from './store/SimpleStateStore'; type ThreadReadStatus = { diff --git a/src/utils.ts b/src/utils.ts index 528ec346d7..40065f4d30 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -420,3 +420,36 @@ export const transformReadArrayToDictionary = unknown>( + fn: T, + timeout = 200, + { leading = true, trailing = false }: { leading?: boolean; trailing?: boolean } = {}, +) => { + let runningTimeout: null | NodeJS.Timeout = null; + let storedArgs: Parameters | null = null; + + return (...args: Parameters) => { + if (runningTimeout) { + if (trailing) storedArgs = args; + return; + } + + if (leading) fn(...args); + + const timeoutHandler = () => { + if (storedArgs) { + fn(...storedArgs); + storedArgs = null; + runningTimeout = setTimeout(timeoutHandler, timeout); + + return; + } + + runningTimeout = null; + }; + + runningTimeout = setTimeout(timeoutHandler, timeout); + }; +}; diff --git a/yarn.lock b/yarn.lock index d337eea36d..479e974c97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1797,18 +1797,6 @@ dependencies: "@types/node" "*" -"@types/lodash.throttle@^4.1.9": - version "4.1.9" - resolved "https://registry.yarnpkg.com/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz#f17a6ae084f7c0117bd7df145b379537bc9615c5" - integrity sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g== - dependencies: - "@types/lodash" "*" - -"@types/lodash@*": - version "4.17.7" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.7.tgz#2f776bcb53adc9e13b2c0dfd493dfcbd7de43612" - integrity sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA== - "@types/minimist@^1.2.0": version "1.2.2" resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" @@ -4345,11 +4333,6 @@ lodash.ismatch@^4.4.0: resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" integrity sha1-dWy1FQyjum8RCFp4hJZF8Yj4Xzc= -lodash.throttle@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" - integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== - lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" From ff29795eb19d23b9f0ab1872c2608cc41746a22b Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 1 Aug 2024 00:48:33 +0200 Subject: [PATCH 15/41] Adjust tests and package.json --- package.json | 2 +- src/thread.ts | 15 +- test/unit/{thread.test.ts => threads.test.ts} | 336 +++++++++++++++++- 3 files changed, 337 insertions(+), 16 deletions(-) rename test/unit/{thread.test.ts => threads.test.ts} (61%) diff --git a/package.json b/package.json index 1ca6eee803..ef0bd62761 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,7 @@ "test-types": "node test/typescript/index.js && tsc --esModuleInterop true --noEmit true --strictNullChecks true --noImplicitAny true --strict true test/typescript/*.ts", "eslint": "eslint '**/*.{js,md,ts}' --max-warnings 0 --ignore-path ./.eslintignore", "eslint-fix": "npx eslint --fix '**/*.{js,md,ts}' --max-warnings 0 --ignore-path ./.eslintignore", - "test-unit": "NODE_ENV=test mocha --exit --bail --timeout 20000 --require ./babel-register test/unit/*.js", + "test-unit": "NODE_ENV=test mocha --exit --bail --timeout 20000 --require ./babel-register test/unit/*.{js,test.ts}", "test-coverage": "nyc yarn test-unit", "test": "yarn test-unit", "testwatch": "NODE_ENV=test nodemon ./node_modules/.bin/mocha --timeout 20000 --require test-entry.js test/test.js", diff --git a/src/thread.ts b/src/thread.ts index 827760bd2c..7da61dcdf4 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -29,7 +29,7 @@ type QueryRepliesOptions = { sort?: { created_at: AscDesc }[]; } & MessagePaginationOptions & { user?: UserResponse; user_id?: string }; -export type ThreadState = { +export type ThreadState = { active: boolean; createdAt: Date; @@ -53,10 +53,10 @@ export type ThreadState = { }; const DEFAULT_PAGE_LIMIT = 50; -const DEFAULT_MARK_AS_READ_THROTTLE_DURATION = 1000; const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000; const MAX_QUERY_THREADS_LIMIT = 25; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; +export const DEFAULT_MARK_AS_READ_THROTTLE_DURATION = 1000; /** * Request batching? @@ -202,7 +202,8 @@ export class Thread { // check whether this instance has subscriptions and is already listening for changes if (this.unsubscribeFunctions.size) return; - const throttledMarkAsRead = throttle(this.markAsRead, DEFAULT_MARK_AS_READ_THROTTLE_DURATION, { + // TODO: figure out why markAsRead needs to be wrapped like this (for tests to pass) + const throttledMarkAsRead = throttle(() => this.markAsRead(), DEFAULT_MARK_AS_READ_THROTTLE_DURATION, { leading: true, trailing: true, }); @@ -300,10 +301,12 @@ export class Thread { this.upsertReplyLocally({ message: event.message, // deal with timestampChanged only related to local user (optimistic updates) - timestampChanged: event.message.user?.id === this.client.user?.id, + timestampChanged: event.message.user?.id === currentUserId, }); - if (event.user && event.user.id !== currentUserId) this.incrementOwnUnreadCount(); + if (event.message.user?.id !== currentUserId) this.incrementOwnUnreadCount(); + // TODO: figure out if event.user is better when it comes to event messages? + // if (event.user && event.user.id !== currentUserId) this.incrementOwnUnreadCount(); }).unsubscribe, ); @@ -381,7 +384,7 @@ export class Thread { }); const actualIndex = - latestReplies[index].id === message.id ? index : latestReplies[index - 1].id === message.id ? index - 1 : null; + latestReplies[index]?.id === message.id ? index : latestReplies[index - 1]?.id === message.id ? index - 1 : null; if (actualIndex === null) return; diff --git a/test/unit/thread.test.ts b/test/unit/threads.test.ts similarity index 61% rename from test/unit/thread.test.ts rename to test/unit/threads.test.ts index 72eeb94e43..f03472bb00 100644 --- a/test/unit/thread.test.ts +++ b/test/unit/threads.test.ts @@ -10,7 +10,17 @@ import { getOrCreateChannelApi } from './test-utils/getOrCreateChannelApi'; import { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; import { generateThread } from './test-utils/generateThread'; -import { Channel, MessageResponse, StreamChat, Thread, ThreadManager, formatMessage } from '../../src'; +import { + Channel, + ChannelResponse, + DEFAULT_MARK_AS_READ_THROTTLE_DURATION, + MessageResponse, + StreamChat, + Thread, + ThreadManager, + ThreadResponse, + formatMessage, +} from '../../src'; import sinon from 'sinon'; const TEST_USER_ID = 'observer'; @@ -47,7 +57,7 @@ describe('Threads 2.0', () => { }); describe('Methods', () => { - describe('Thread.upsertReply', () => { + describe('Thread.upsertReplyLocally', () => { // does not test whether the message has been inserted at the correct position // that should be unit-tested separately (addToMessageList utility function) @@ -95,6 +105,76 @@ describe('Threads 2.0', () => { // TODO: timestampChanged (check that duplicates get removed) }); + describe('Thread.updateParentMessageLocally', () => { + it('prevents updating a parent message if the ids do not match', () => { + const newMessage = generateMsg(); + + const fn = () => { + thread.updateParentMessageLocally(newMessage as MessageResponse); + }; + + expect(fn).to.throw(Error); + }); + + it('updates parent message and related top-level properties (deletedAt & replyCount)', () => { + const newMessage = generateMsg({ + id: parentMessageResponse.id, + text: 'aaa', + reply_count: 10, + deleted_at: new Date().toISOString(), + }); + + const { deletedAt, replyCount, parentMessage } = thread.state.getLatestValue(); + + // baseline + expect(parentMessage!.id).to.equal(thread.id); + expect(deletedAt).to.be.null; + expect(replyCount).to.equal(0); + expect(parentMessage!.text).to.equal(parentMessageResponse.text); + + thread.updateParentMessageLocally(newMessage as MessageResponse); + + expect(thread.state.getLatestValue().deletedAt).to.be.a('date'); + expect(thread.state.getLatestValue().deletedAt!.toISOString()).to.equal( + (newMessage as MessageResponse).deleted_at, + ); + expect(thread.state.getLatestValue().replyCount).to.equal(newMessage.reply_count); + expect(thread.state.getLatestValue().parentMessage!.text).to.equal(newMessage.text); + }); + }); + + describe('Thread.updateParentMessageOrReplyLocally', () => { + it('calls upsertReplyLocally if the message has parent_id and it equals to the thread.id', () => { + const upsertReplyLocallyStub = sinon.stub(thread, 'upsertReplyLocally').returns(); + const updateParentMessageLocallyStub = sinon.stub(thread, 'updateParentMessageLocally').returns(); + + thread.updateParentMessageOrReplyLocally(generateMsg({ parent_id: thread.id }) as MessageResponse); + + expect(upsertReplyLocallyStub.called).to.be.true; + expect(updateParentMessageLocallyStub.called).to.be.false; + }); + + it('calls updateParentMessageLocally if message does not have parent_id and its id equals to the id of the thread', () => { + const upsertReplyLocallyStub = sinon.stub(thread, 'upsertReplyLocally').returns(); + const updateParentMessageLocallyStub = sinon.stub(thread, 'updateParentMessageLocally').returns(); + + thread.updateParentMessageOrReplyLocally(generateMsg({ id: thread.id }) as MessageResponse); + + expect(upsertReplyLocallyStub.called).to.be.false; + expect(updateParentMessageLocallyStub.called).to.be.true; + }); + + it('does not call either updateParentMessageLocally or upsertReplyLocally', () => { + const upsertReplyLocallyStub = sinon.stub(thread, 'upsertReplyLocally').returns(); + const updateParentMessageLocallyStub = sinon.stub(thread, 'updateParentMessageLocally').returns(); + + thread.updateParentMessageOrReplyLocally(generateMsg() as MessageResponse); + + expect(upsertReplyLocallyStub.called).to.be.false; + expect(updateParentMessageLocallyStub.called).to.be.false; + }); + }); + describe('Thread.partiallyReplaceState', () => { it('prevents copying state of the instance with different id', () => { const newThread = new Thread({ @@ -206,7 +286,37 @@ describe('Threads 2.0', () => { }); describe('Thread.deleteReplyLocally', () => { - // it(''); + it('deletes appropriate message from the latestReplies array', () => { + const TARGET_MESSAGE_INDEX = 2; + + const createdAt = new Date().getTime(); + // five messages "created" second apart + thread.state.patchedNext( + 'latestReplies', + Array.from({ length: 5 }, (_, i) => + formatMessage( + generateMsg({ created_at: new Date(createdAt + 1000 * i).toISOString() }) as MessageResponse, + ), + ), + ); + + const { latestReplies } = thread.state.getLatestValue(); + + expect(latestReplies).to.have.lengthOf(5); + + const messageToDelete = generateMsg({ + created_at: latestReplies[TARGET_MESSAGE_INDEX].created_at.toISOString(), + id: latestReplies[TARGET_MESSAGE_INDEX].id, + }); + + expect(latestReplies[TARGET_MESSAGE_INDEX].id).to.equal(messageToDelete.id); + expect(latestReplies[TARGET_MESSAGE_INDEX].created_at.toISOString()).to.equal(messageToDelete.created_at); + + thread.deleteReplyLocally({ message: messageToDelete as MessageResponse }); + + expect(thread.state.getLatestValue().latestReplies).to.have.lengthOf(4); + expect(thread.state.getLatestValue().latestReplies[TARGET_MESSAGE_INDEX].id).to.not.equal(messageToDelete.id); + }); }); describe('Thread.markAsRead', () => { @@ -245,19 +355,227 @@ describe('Threads 2.0', () => { }); describe('Subscription Handlers', () => { + // let timers: sinon.SinonFakeTimers; + beforeEach(() => { thread.registerSubscriptions(); }); - it('calls markAsRead whenever it becomes active or own reply count increases', () => {}); + it('calls markAsRead whenever thread becomes active or own reply count increases', () => { + const timers = sinon.useFakeTimers({ toFake: ['setTimeout'] }); + + const stubbedMarkAsRead = sinon.stub(thread, 'markAsRead').resolves(); + + thread.incrementOwnUnreadCount(); + + expect(thread.state.getLatestValue().active).to.be.false; + expect(thread.state.getLatestValue().read[TEST_USER_ID].unread_messages).to.equal(1); + expect(stubbedMarkAsRead.called).to.be.false; + + thread.activate(); + + expect(thread.state.getLatestValue().active).to.be.true; + expect(stubbedMarkAsRead.calledOnce, 'Called once').to.be.true; + + thread.incrementOwnUnreadCount(); + + timers.tick(DEFAULT_MARK_AS_READ_THROTTLE_DURATION + 1); + + expect(stubbedMarkAsRead.calledTwice, 'Called twice').to.be.true; + + timers.restore(); + }); + + it('recovers from stale state whenever the thread becomes active (or is active and its state becomes stale)', async () => { + // prepare + const newThread = new Thread({ + client, + threadData: generateThread(channelResponse, generateMsg({ id: parentMessageResponse.id })), + }); + const stubbedGetThread = sinon.stub(client, 'getThread').resolves(newThread); + const partiallyReplaceStateSpy = sinon.spy(thread, 'partiallyReplaceState'); + + thread.state.patchedNext('isStateStale', true); + + expect(thread.state.getLatestValue().isStateStale).to.be.true; + expect(stubbedGetThread.called).to.be.false; + expect(partiallyReplaceStateSpy.called).to.be.false; + + thread.activate(); + + expect(stubbedGetThread.calledOnce).to.be.true; + + await stubbedGetThread.firstCall.returnValue; + + expect(partiallyReplaceStateSpy.calledWith({ thread: newThread })).to.be.true; + }); + + describe('Event user.watching.stop', () => { + it('ignores incoming event if the data do not match (channel or user.id)', () => { + client.dispatchEvent({ + type: 'user.watching.stop', + channel: channelResponse as ChannelResponse, + user: { id: 'bob' }, + }); + + expect(thread.state.getLatestValue().isStateStale).to.be.false; + + client.dispatchEvent({ + type: 'user.watching.stop', + channel: generateChannel().channel as ChannelResponse, + user: { id: TEST_USER_ID }, + }); + + expect(thread.state.getLatestValue().isStateStale).to.be.false; + }); + + it('marks own state as stale whenever current user stops watching associated channel', () => { + client.dispatchEvent({ + type: 'user.watching.stop', + channel: channelResponse as ChannelResponse, + user: { id: TEST_USER_ID }, + }); + + expect(thread.state.getLatestValue().isStateStale).to.be.true; + }); + }); + + describe('Event message.read', () => { + it('prevents adjusting unread_messages & last_read if thread.id does not match', () => { + // prepare + thread.incrementOwnUnreadCount(); + expect(thread.state.getLatestValue().read[TEST_USER_ID].unread_messages).to.equal(1); - it('it recovers from stale state whenever it becomes active (or is active and becomes stale)', () => {}); + client.dispatchEvent({ + type: 'message.read', + user: { id: TEST_USER_ID }, + thread: (generateThread(channelResponse, generateMsg()) as unknown) as ThreadResponse, + }); - it('marks own state as stale whenever current user stops watching associated channel', () => {}); + expect(thread.state.getLatestValue().read[TEST_USER_ID].unread_messages).to.equal(1); + }); + + [TEST_USER_ID, 'bob'].forEach((userId) => { + it(`correctly sets read information for user with id: ${userId}`, () => { + // prepare + const lastReadAt = new Date(); + thread.state.patchedNext('read', { + [userId]: { + lastReadAt: lastReadAt, + last_read: lastReadAt.toISOString(), + last_read_message_id: '', + unread_messages: 1, + user: { id: userId }, + }, + }); + + expect(thread.state.getLatestValue().read[userId].unread_messages).to.equal(1); + + const createdAt = new Date().toISOString(); + + client.dispatchEvent({ + type: 'message.read', + user: { id: userId }, + thread: (generateThread( + channelResponse, + generateMsg({ id: parentMessageResponse.id }), + ) as unknown) as ThreadResponse, + created_at: createdAt, + }); + + expect(thread.state.getLatestValue().read[userId].unread_messages).to.equal(0); + expect(thread.state.getLatestValue().read[userId].last_read).to.equal(createdAt); + }); + }); + }); + + describe('Event message.new', () => { + it('prevents handling a reply if it does not belong to the associated thread', () => { + // prepare + const upsertReplyLocallySpy = sinon.spy(thread, 'upsertReplyLocally'); + + client.dispatchEvent({ + type: 'message.new', + message: generateMsg({ parent_id: uuidv4() }) as MessageResponse, + }); + + expect(upsertReplyLocallySpy.called).to.be.false; + }); + + it('prevents handling a reply if the state of the thread is stale', () => { + // prepare + const upsertReplyLocallySpy = sinon.spy(thread, 'upsertReplyLocally'); + + thread.state.patchedNext('isStateStale', true); - it('properly handles new messages', () => {}); + client.dispatchEvent({ type: 'message.new', message: generateMsg({ id: thread.id }) as MessageResponse }); - it('properly handles message updates (both reply and parent)', () => {}); + expect(upsertReplyLocallySpy.called).to.be.false; + }); + + it('calls upsertLocalReply with proper values and calls incrementOwnUnreadCount if the reply does not belong to current user', () => { + // prepare + const upsertReplyLocallySpy = sinon.spy(thread, 'upsertReplyLocally'); + const incrementOwnUnreadCountSpy = sinon.spy(thread, 'incrementOwnUnreadCount'); + + const newMessage = generateMsg({ parent_id: thread.id, user: { id: 'bob' } }) as MessageResponse; + + client.dispatchEvent({ + type: 'message.new', + message: newMessage, + }); + + expect(upsertReplyLocallySpy.calledWith({ message: newMessage, timestampChanged: false })).to.be.true; + expect(incrementOwnUnreadCountSpy.called).to.be.true; + }); + + it('calls upsertLocalReply with timestampChanged true if the reply belongs to the current user', () => { + // prepare + const upsertReplyLocallySpy = sinon.spy(thread, 'upsertReplyLocally'); + const incrementOwnUnreadCountSpy = sinon.spy(thread, 'incrementOwnUnreadCount'); + + const newMessage = generateMsg({ parent_id: thread.id, user: { id: TEST_USER_ID } }) as MessageResponse; + + client.dispatchEvent({ + type: 'message.new', + message: newMessage, + }); + + expect(upsertReplyLocallySpy.calledWith({ message: newMessage, timestampChanged: true })).to.be.true; + expect(incrementOwnUnreadCountSpy.called).to.be.false; + }); + + // TODO: cover failed replies at some point + }); + + describe('Events message.updated, message.deleted, reaction.new, reaction.deleted', () => { + it('calls deleteReplyLocally if the reply has been hard-deleted', () => { + const deleteReplyLocallySpy = sinon.spy(thread, 'deleteReplyLocally'); + const updateParentMessageOrReplyLocallySpy = sinon.spy(thread, 'updateParentMessageOrReplyLocally'); + + client.dispatchEvent({ + type: 'message.deleted', + hard_delete: true, + message: generateMsg({ parent_id: thread.id }) as MessageResponse, + }); + + expect(deleteReplyLocallySpy.calledOnce).to.be.true; + expect(updateParentMessageOrReplyLocallySpy.called).to.be.false; + }); + + (['message.updated', 'message.deleted', 'reaction.new', 'reaction.deleted'] as const).forEach((eventType) => { + it(`calls updateParentMessageOrReplyLocally on ${eventType}`, () => { + const updateParentMessageOrReplyLocallySpy = sinon.spy(thread, 'updateParentMessageOrReplyLocally'); + + client.dispatchEvent({ + type: eventType, + message: generateMsg({ parent_id: thread.id }) as MessageResponse, + }); + + expect(updateParentMessageOrReplyLocallySpy.calledOnce).to.be.true; + }); + }); + }); }); }); @@ -282,7 +600,7 @@ describe('Threads 2.0', () => { const { unreadThreadsCount } = threadManager.state.getLatestValue(); - expect(unreadThreadsCount).to.eq(unreadCount); + expect(unreadThreadsCount).to.equal(unreadCount); }); }); From fbc636151158261e89564511feb3a49a5f3a974c Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Sat, 3 Aug 2024 00:33:29 +0200 Subject: [PATCH 16/41] Rename next/previous parameters to "cursors", adjust tests --- src/thread.ts | 86 +++++++++++++++++++++++++++------------ test/unit/threads.test.ts | 43 +++++++++++++++++++- 2 files changed, 103 insertions(+), 26 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index 7da61dcdf4..ffb60a7589 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -15,6 +15,8 @@ import type { import { addToMessageList, findInsertionIndex, formatMessage, transformReadArrayToDictionary, throttle } from './utils'; import { Handler, SimpleStateStore } from './store/SimpleStateStore'; +type WithRequired = T & { [P in K]-?: T[P] }; + type ThreadReadStatus = { [key: string]: { last_read: string; @@ -48,8 +50,9 @@ export type ThreadState = { channel?: Channel; channelData?: ThreadResponse['channel']; - nextId?: string | null; - previousId?: string | null; + // messageId as cursor + nextCursor?: string | null; + previousCursor?: string | null; }; const DEFAULT_PAGE_LIMIT = 50; @@ -119,10 +122,10 @@ export class Thread { replyCount, updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null, - nextId: latestReplies.at(-1)?.id ?? null, + nextCursor: latestReplies.length === replyCount ? null : latestReplies.at(-1)?.id ?? null, // TODO: check whether the amount of replies is less than replies_limit (thread.queriedWithOptions = {...}) // otherwise we perform one extra query (not a big deal but preventable) - previousId: latestReplies.at(0)?.id ?? null, + previousCursor: latestReplies.length === replyCount ? null : latestReplies.at(0)?.id ?? null, loadingNextPage: false, loadingPreviousPage: false, // TODO: implement network status handler (network down, isStateStale: true, reset to false once state has been refreshed) @@ -167,8 +170,8 @@ export class Thread { createdAt, deletedAt, updatedAt, - nextId, - previousId, + nextCursor, + previousCursor, channelData, } = thread.state.getLatestValue(); @@ -186,8 +189,8 @@ export class Thread { createdAt, deletedAt, updatedAt, - nextId, - previousId, + nextCursor, + previousCursor, channelData, isStateStale: false, }; @@ -482,13 +485,13 @@ export class Thread { }: Pick, 'sort' | 'limit'> = {}) => { this.state.patchedNext('loadingNextPage', true); - const { loadingNextPage, nextId } = this.state.getLatestValue(); + const { loadingNextPage, nextCursor } = this.state.getLatestValue(); - if (loadingNextPage || nextId === null) return; + if (loadingNextPage || nextCursor === null) return; try { const data = await this.queryReplies({ - id_gt: nextId, + id_gt: nextCursor, limit, sort, }); @@ -501,7 +504,7 @@ export class Thread { latestReplies: data.messages.length ? current.latestReplies.concat(data.messages.map(formatMessage)) : current.latestReplies, - nextId: data.messages.length < limit || !lastMessageId ? null : lastMessageId, + nextCursor: data.messages.length < limit || !lastMessageId ? null : lastMessageId, })); } catch (error) { this.client.logger('error', (error as Error).message); @@ -514,15 +517,15 @@ export class Thread { sort, limit = DEFAULT_PAGE_LIMIT, }: Pick, 'sort' | 'limit'> = {}) => { - const { loadingPreviousPage, previousId } = this.state.getLatestValue(); + const { loadingPreviousPage, previousCursor } = this.state.getLatestValue(); - if (loadingPreviousPage || previousId === null) return; + if (loadingPreviousPage || previousCursor === null) return; this.state.patchedNext('loadingPreviousPage', true); try { const data = await this.queryReplies({ - id_lt: previousId, + id_lt: previousCursor, limit, sort, }); @@ -534,7 +537,7 @@ export class Thread { latestReplies: data.messages.length ? data.messages.map(formatMessage).concat(current.latestReplies) : current.latestReplies, - previousId: data.messages.length < limit || !firstMessageId ? null : firstMessageId, + previousCursor: data.messages.length < limit || !firstMessageId ? null : firstMessageId, })); } catch (error) { this.client.logger('error', (error as Error).message); @@ -743,7 +746,7 @@ export class ThreadManager { const combinedLimit = threads.length + unseenThreadIds.length; try { - const data = await this.client.queryThreads({ + const data = await this.queryThreads({ limit: combinedLimit <= MAX_QUERY_THREADS_LIMIT ? combinedLimit : MAX_QUERY_THREADS_LIMIT, }); @@ -788,26 +791,59 @@ export class ThreadManager { } }; + public queryThreads = async ({ + limit = 25, + participant_limit = 10, + reply_limit = 10, + watch = true, + ...restOfTheOptions + }: QueryThreadsOptions = {}) => { + const optionsWithDefaults: WithRequired< + QueryThreadsOptions, + 'reply_limit' | 'limit' | 'participant_limit' | 'watch' + > = { + limit, + participant_limit, + reply_limit, + watch, + ...restOfTheOptions, + }; + + const { threads, next } = await this.client.queryThreads(optionsWithDefaults); + + // FIXME: currently this is done within threads based on reply_count property + // but that does not take into consideration sorting (only oldest -> newest) + // re-enable functionality bellow, and take into consideration sorting + + // re-adjust next/previous cursors based on query options + // data.threads.forEach((thread) => { + // thread.state.next((current) => ({ + // ...current, + // nextCursor: current.latestReplies.length < optionsWithDefaults.reply_limit ? null : current.nextCursor, + // previousCursor: + // current.latestReplies.length < optionsWithDefaults.reply_limit ? null : current.previousCursor, + // })); + // }); + + return { threads, next }; + }; + // remove `next` from options as that is handled internally public loadNextPage = async (options: Omit = {}) => { const { nextCursor, loadingNextPage } = this.state.getLatestValue(); if (nextCursor === null || loadingNextPage) return; - // FIXME: redo defaults - const optionsWithDefaults: QueryThreadsOptions = { - limit: 10, - participant_limit: 10, - reply_limit: 10, - watch: true, - next: nextCursor, + const optionsWithNextCursor: QueryThreadsOptions = { ...options, + next: nextCursor, }; this.state.next((current) => ({ ...current, loadingNextPage: true })); try { - const data = await this.client.queryThreads(optionsWithDefaults); + const data = await this.queryThreads(optionsWithNextCursor); + this.state.next((current) => ({ ...current, threads: data.threads.length ? current.threads.concat(data.threads) : current.threads, diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index f03472bb00..f1572cd220 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -352,6 +352,10 @@ describe('Threads 2.0', () => { expect(stubbedChannelMarkRead.calledOnceWith({ thread_id: thread.id })).to.be.true; }); }); + + describe('Thread.loadNextPage', () => {}); + + describe('Thread.loadPreviousPage', () => {}); }); describe('Subscription Handlers', () => { @@ -743,7 +747,7 @@ describe('Threads 2.0', () => { await threadManager.reload(); - expect(stubbedQueryThreads.calledWith({ limit: 2 })).to.be.true; + expect(stubbedQueryThreads.calledWithMatch({ limit: 2 })).to.be.true; }); it('adds new thread if it does not exist within the threads array', async () => { @@ -856,6 +860,19 @@ describe('Threads 2.0', () => { expect(stubbedQueryThreads.called).to.be.false; }); + it('calls queryThreads with proper defaults', async () => { + stubbedQueryThreads.resolves({ + threads: [], + next: undefined, + }); + + await threadManager.loadNextPage(); + + expect( + stubbedQueryThreads.calledWithMatch({ limit: 25, participant_limit: 10, reply_limit: 10, watch: true }), + ).to.be.true; + }); + it('switches loading state properly', async () => { const spy = sinon.spy(); @@ -908,6 +925,30 @@ describe('Threads 2.0', () => { expect(stubbedQueryThreads.calledWithMatch({ next: cursor1 })).to.be.true; expect(nextCursor).to.equal(cursor2); }); + + // FIXME: skipped as it's not needed until queryThreads supports reply sorting (asc/desc) + it.skip('adjusts nextCursor & previousCusor properties of the queried threads according to query options', () => { + + const REPLY_COUNT = 3; + + const newThread = new Thread({ + client, + threadData: generateThread(channelResponse, generateMsg(), { + latest_replies: Array.from({ length: REPLY_COUNT }, () => generateMsg()), + reply_count: REPLY_COUNT, + }), + }); + + expect(newThread.state.getLatestValue().latestReplies).to.have.lengthOf(REPLY_COUNT); + expect(newThread.state.getLatestValue().replyCount).to.equal(REPLY_COUNT); + + stubbedQueryThreads.resolves({ + threads: [newThread], + next: undefined, + }); + + // ... + }); }); }); }); From d547870ed76063f6111fd9e46e6b2abf37d0a2ec Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Mon, 5 Aug 2024 09:33:42 +0200 Subject: [PATCH 17/41] Upgrade Mocha --- package.json | 2 +- yarn.lock | 297 +++++++++++++++++++++++++-------------------------- 2 files changed, 149 insertions(+), 150 deletions(-) diff --git a/package.json b/package.json index ef0bd62761..38d8b09549 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,7 @@ "eslint-plugin-typescript-sort-keys": "1.5.0", "husky": "^4.3.8", "lint-staged": "^15.2.2", - "mocha": "^9.1.3", + "mocha": "^10.7.0", "nyc": "^15.1.0", "prettier": "^2.2.1", "rollup": "^2.41.0", diff --git a/yarn.lock b/yarn.lock index 479e974c97..d77bfd8377 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1970,11 +1970,6 @@ "@typescript-eslint/types" "4.17.0" eslint-visitor-keys "^2.0.0" -"@ungap/promise-all-settled@1.1.2": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" - integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== - JSONStream@^1.0.4: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -2036,11 +2031,16 @@ ajv@^7.0.2: require-from-string "^2.0.2" uri-js "^4.2.2" -ansi-colors@4.1.1, ansi-colors@^4.1.1: +ansi-colors@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== +ansi-colors@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== + ansi-escapes@^6.2.0: version "6.2.0" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-6.2.0.tgz#8a13ce75286f417f1963487d86ba9f90dccf9947" @@ -2220,6 +2220,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + braces@^3.0.1, braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" @@ -2227,7 +2234,7 @@ braces@^3.0.1, braces@^3.0.2, braces@~3.0.2: dependencies: fill-range "^7.0.1" -browser-stdout@1.3.1: +browser-stdout@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== @@ -2397,21 +2404,6 @@ check-error@^1.0.2: resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.2.tgz#574d312edd88bb5dd8912e9286dd6c0aed4aac82" integrity sha1-V00xLt2Iu13YkS6Sht1sCu1KrII= -chokidar@3.5.2: - version "3.5.2" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75" - integrity sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - chokidar@^3.4.0: version "3.5.1" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a" @@ -2427,6 +2419,21 @@ chokidar@^3.4.0: optionalDependencies: fsevents "~2.3.1" +chokidar@^3.5.3: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + ci-info@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" @@ -2824,13 +2831,6 @@ dateformat@^3.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -debug@4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.2.tgz#f0a49c18ac8779e31d4a0c6029dfb76873c7428b" - integrity sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw== - dependencies: - ms "2.1.2" - debug@4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -2845,6 +2845,13 @@ debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: dependencies: ms "2.1.2" +debug@^4.3.5: + version "4.3.6" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.6.tgz#2ab2c38fbaffebf8aa95fdfe6d88438c7a13c52b" + integrity sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg== + dependencies: + ms "2.1.2" + decamelize-keys@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/decamelize-keys/-/decamelize-keys-1.1.0.tgz#d171a87933252807eb3cb61dc1c1445d078df2d9" @@ -2909,16 +2916,21 @@ detect-newline@^3.1.0: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== -diff@5.0.0, diff@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" - integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== - diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +diff@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + +diff@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" + integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -3035,16 +3047,16 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== -escape-string-regexp@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - escape-string-regexp@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= +escape-string-regexp@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + eslint-config-prettier@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.1.0.tgz#4ef1eaf97afe5176e6a75ddfb57c335121abc5a6" @@ -3335,14 +3347,6 @@ find-cache-dir@^3.2.0: make-dir "^3.0.2" pkg-dir "^4.1.0" -find-up@5.0.0, find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - find-up@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" @@ -3365,6 +3369,14 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" +find-up@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + find-versions@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/find-versions/-/find-versions-4.0.0.tgz#3c57e573bf97769b8cb8df16934b627915da4965" @@ -3553,18 +3565,6 @@ glob-parent@^5.0.0, glob-parent@^5.1.0, glob-parent@~5.1.0, glob-parent@~5.1.2: dependencies: is-glob "^4.0.1" -glob@7.1.7: - version "7.1.7" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.7.tgz#3b193e9233f01d42d0b3f78294bbeeb418f94a90" - integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - glob@^7.0.0, glob@^7.1.3, glob@^7.1.6: version "7.1.6" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" @@ -3589,6 +3589,17 @@ glob@^7.1.4: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" + integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^5.0.1" + once "^1.3.0" + global-dirs@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-0.1.1.tgz#b319c0dd4607f353f3be9cca4c72fc148c49f445" @@ -3630,11 +3641,6 @@ graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== -growl@1.10.5: - version "1.10.5" - resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" - integrity sha512-qBr4OuELkhPenW6goKVXiv47US3clb3/IbuWF9KNKEijAy9oeHxU9IgzjvJhHkUzhaj7rOUD7+YGWqUjLp5oSA== - handlebars@^4.7.7: version "4.7.7" resolved "https://registry.yarnpkg.com/handlebars/-/handlebars-4.7.7.tgz#9ce33416aad02dbd6c8fafa8240d5d98004945a1" @@ -3687,7 +3693,7 @@ hasha@^5.0.0: is-stream "^2.0.0" type-fest "^0.8.0" -he@1.2.0: +he@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== @@ -4104,13 +4110,6 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - js-yaml@^3.13.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -4119,6 +4118,13 @@ js-yaml@^3.13.1: argparse "^1.0.7" esprima "^4.0.0" +js-yaml@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -4338,7 +4344,7 @@ lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -log-symbols@4.1.0: +log-symbols@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== @@ -4476,13 +4482,20 @@ min-indent@^1.0.0: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== -minimatch@3.0.4, minimatch@^3.0.4: +minimatch@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== dependencies: brace-expansion "^1.1.7" +minimatch@^5.0.1, minimatch@^5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" + integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== + dependencies: + brace-expansion "^2.0.1" + minimist-options@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619" @@ -4497,35 +4510,31 @@ minimist@^1.2.5: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== -mocha@^9.1.3: - version "9.1.3" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-9.1.3.tgz#8a623be6b323810493d8c8f6f7667440fa469fdb" - integrity sha512-Xcpl9FqXOAYqI3j79pEtHBBnQgVXIhpULjGQa7DVb0Po+VzmSIK9kanAiWLHoRR/dbZ2qpdPshuXr8l1VaHCzw== - dependencies: - "@ungap/promise-all-settled" "1.1.2" - ansi-colors "4.1.1" - browser-stdout "1.3.1" - chokidar "3.5.2" - debug "4.3.2" - diff "5.0.0" - escape-string-regexp "4.0.0" - find-up "5.0.0" - glob "7.1.7" - growl "1.10.5" - he "1.2.0" - js-yaml "4.1.0" - log-symbols "4.1.0" - minimatch "3.0.4" - ms "2.1.3" - nanoid "3.1.25" - serialize-javascript "6.0.0" - strip-json-comments "3.1.1" - supports-color "8.1.1" - which "2.0.2" - workerpool "6.1.5" - yargs "16.2.0" - yargs-parser "20.2.4" - yargs-unparser "2.0.0" +mocha@^10.7.0: + version "10.7.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.7.0.tgz#9e5cbed8fa9b37537a25bd1f7fb4f6fc45458b9a" + integrity sha512-v8/rBWr2VO5YkspYINnvu81inSz2y3ODJrhO175/Exzor1RcEZZkizgE2A+w/CAXXoESS8Kys5E62dOHGHzULA== + dependencies: + ansi-colors "^4.1.3" + browser-stdout "^1.3.1" + chokidar "^3.5.3" + debug "^4.3.5" + diff "^5.2.0" + escape-string-regexp "^4.0.0" + find-up "^5.0.0" + glob "^8.1.0" + he "^1.2.0" + js-yaml "^4.1.0" + log-symbols "^4.1.0" + minimatch "^5.1.6" + ms "^2.1.3" + serialize-javascript "^6.0.2" + strip-json-comments "^3.1.1" + supports-color "^8.1.1" + workerpool "^6.5.1" + yargs "^16.2.0" + yargs-parser "^20.2.9" + yargs-unparser "^2.0.0" modify-values@^1.0.0: version "1.0.1" @@ -4537,16 +4546,11 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1: +ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nanoid@3.1.25: - version "3.1.25" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.25.tgz#09ca32747c0e543f0e1814b7d3793477f9c8e152" - integrity sha512-rdwtIXaXCLFAQbnfqDRnI6jaRHp9fTcYBjtFKE8eezcZ7LuLjhUaQGNeMXf1HmRoCH32CLz6XwX0TtxEOS/A3Q== - natural-compare-lite@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4" @@ -5393,13 +5397,6 @@ semver@^7.3.8: dependencies: lru-cache "^6.0.0" -serialize-javascript@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" - integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== - dependencies: - randombytes "^2.1.0" - serialize-javascript@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa" @@ -5407,6 +5404,13 @@ serialize-javascript@^4.0.0: dependencies: randombytes "^2.1.0" +serialize-javascript@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + set-blocking@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" @@ -5714,18 +5718,11 @@ strip-indent@^3.0.0: dependencies: min-indent "^1.0.0" -strip-json-comments@3.1.1, strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: +strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== -supports-color@8.1.1: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -5740,6 +5737,13 @@ supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: dependencies: has-flag "^4.0.0" +supports-color@^8.1.1: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + supports-preserve-symlinks-flag@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" @@ -6099,7 +6103,7 @@ which-pm-runs@^1.0.0: resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb" integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs= -which@2.0.2, which@^2.0.1: +which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== @@ -6116,10 +6120,10 @@ wordwrap@^1.0.0: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus= -workerpool@6.1.5: - version "6.1.5" - resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.1.5.tgz#0f7cf076b6215fd7e1da903ff6f22ddd1886b581" - integrity sha512-XdKkCK0Zqc6w3iTxLckiuJ81tiD/o5rBE/m+nXpRCB+/Sq4DqkfXZ/x0jW02DG1tGsfUGXbTJyZDP+eu67haSw== +workerpool@^6.5.1: + version "6.5.1" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" + integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== wrap-ansi@^6.2.0: version "6.2.0" @@ -6203,11 +6207,6 @@ yaml@^1.10.0: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.0.tgz#3b593add944876077d4d683fee01081bd9fff31e" integrity sha512-yr2icI4glYaNG+KWONODapy2/jDdMSDnrONSjblABjD9B4Z5LgiircSt8m8sRZFNi08kG9Sm0uSHtEmP3zaEGg== -yargs-parser@20.2.4: - version "20.2.4" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" - integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== - yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" @@ -6221,7 +6220,7 @@ yargs-parser@^20.2.2: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.6.tgz#69f920addf61aafc0b8b89002f5d66e28f2d8b20" integrity sha512-AP1+fQIWSM/sMiET8fyayjx/J+JmTPt2Mr0FkrgqB4todtfa53sOsrSAcIrJRD5XS20bKUwaDIuMkWKCEiQLKA== -yargs-parser@^20.2.3: +yargs-parser@^20.2.3, yargs-parser@^20.2.9: version "20.2.9" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== @@ -6231,7 +6230,7 @@ yargs-parser@^21.0.0: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.0.0.tgz#a485d3966be4317426dd56bdb6a30131b281dc55" integrity sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA== -yargs-unparser@2.0.0: +yargs-unparser@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== @@ -6241,19 +6240,6 @@ yargs-unparser@2.0.0: flat "^5.0.2" is-plain-obj "^2.1.0" -yargs@16.2.0, yargs@^16.0.0, yargs@^16.2.0: - version "16.2.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.0" - y18n "^5.0.5" - yargs-parser "^20.2.2" - yargs@^15.0.2: version "15.4.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" @@ -6271,6 +6257,19 @@ yargs@^15.0.2: y18n "^4.0.0" yargs-parser "^18.1.2" +yargs@^16.0.0, yargs@^16.2.0: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + yargs@^17.0.0: version "17.3.1" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.3.1.tgz#da56b28f32e2fd45aefb402ed9c26f42be4c07b9" From 7590addac8b68c1ce9256aa59c0d5f4471edd743 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Mon, 5 Aug 2024 10:34:20 +0200 Subject: [PATCH 18/41] Replace unsupported Array.toSpliced --- src/thread.ts | 11 ++++++++++- test/unit/threads.test.ts | 2 ++ tsconfig.json | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index ffb60a7589..9d8a239c30 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -391,7 +391,16 @@ export class Thread { if (actualIndex === null) return; - this.state.next((current) => ({ ...current, latestReplies: latestReplies.toSpliced(actualIndex, 1) })); + this.state.next((current) => { + // TODO: replace with "Array.toSpliced" when applicable + const latestRepliesCopy = [...latestReplies]; + latestRepliesCopy.splice(actualIndex, 1); + + return { + ...current, + latestReplies: latestRepliesCopy, + }; + }); }; public upsertReplyLocally = ({ diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index f1572cd220..ee75aadfe7 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -314,6 +314,8 @@ describe('Threads 2.0', () => { thread.deleteReplyLocally({ message: messageToDelete as MessageResponse }); + // check whether array signatures changed + expect(thread.state.getLatestValue().latestReplies).to.not.equal(latestReplies); expect(thread.state.getLatestValue().latestReplies).to.have.lengthOf(4); expect(thread.state.getLatestValue().latestReplies[TARGET_MESSAGE_INDEX].id).to.not.equal(messageToDelete.id); }); diff --git a/tsconfig.json b/tsconfig.json index 4edc52f531..09a18d765e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "baseUrl": "./src", "esModuleInterop": true, "moduleResolution": "node", - "lib": ["DOM", "ESNext", "ESNext.Array"], + "lib": ["DOM", "ESNext"], "noEmitOnError": false, "noImplicitAny": true, "preserveConstEnums": true, From 0e5d673b8bac9ed5695a6afd900227408df82226 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Tue, 6 Aug 2024 21:37:11 +0200 Subject: [PATCH 19/41] Adjust insertion index API --- src/client.ts | 2 +- src/thread.ts | 19 ++++++++++---- src/utils.ts | 71 +++++++++++++++++++++++++++++++++++++++------------ 3 files changed, 69 insertions(+), 23 deletions(-) diff --git a/src/client.ts b/src/client.ts index 21b9526938..69485026d8 100644 --- a/src/client.ts +++ b/src/client.ts @@ -609,7 +609,7 @@ export class StreamChat = T & { [P in K]-?: T[P] }; @@ -381,9 +387,12 @@ export class Thread { public deleteReplyLocally = ({ message }: { message: MessageResponse }) => { const { latestReplies } = this.state.getLatestValue(); - const index = findInsertionIndex({ - message: formatMessage(message), - messages: latestReplies, + const index = findIndexInSortedArray({ + needle: formatMessage(message), + sortedArray: latestReplies, + // TODO: make following two configurable (sortDirection and created_at) + sortDirection: 'ascending', + selectValueToCompare: (m) => m['created_at'].getTime(), }); const actualIndex = @@ -796,7 +805,7 @@ export class ThreadManager { // TODO: loading states console.error(error); } finally { - console.log('...'); + // ... } }; diff --git a/src/utils.ts b/src/utils.ts index 40065f4d30..49ef8ad162 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -305,28 +305,60 @@ export function formatMessage oldest] only [oldest -> newest] -export const findInsertionIndex = ({ - message, - messages, - sortBy = 'created_at', +export const findIndexInSortedArray = ({ + needle, + sortedArray, + selectValueToCompare = (e) => e, + sortDirection = 'ascending', }: { - message: T; - messages: Array; - sortBy?: 'pinned_at' | 'created_at'; + needle: T; + sortedArray: readonly T[]; + /** + * In array of objects (like messages), pick a specific + * property to compare needle value to. + * + * @example + * ```ts + * selectValueToCompare: (message) => message.created_at.getTime() + * ``` + */ + selectValueToCompare?: (arrayElement: T) => L | T; + /** + * @default ascending + * @description + * ```md + * ascending - [1,2,3,4,5...] + * descending - [...5,4,3,2,1] + * ``` + */ + sortDirection?: 'ascending' | 'descending'; }) => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const messageTime = message[sortBy]!.getTime(); + if (!sortedArray.length) return 0; let left = 0; + let right = sortedArray.length - 1; let middle = 0; - let right = messages.length - 1; + + const recalculateMiddle = () => { + middle = Math.round((left + right) / 2); + }; + + const actualNeedle = selectValueToCompare(needle); + recalculateMiddle(); while (left <= right) { - middle = Math.floor((right + left) / 2); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - if (messages[middle][sortBy]!.getTime() <= messageTime) left = middle + 1; - else right = middle - 1; + // if (actualNeedle === selectValueToCompare(sortedArray[middle])) return middle; + + if ( + (sortDirection === 'ascending' && actualNeedle < selectValueToCompare(sortedArray[middle])) || + (sortDirection === 'descending' && actualNeedle > selectValueToCompare(sortedArray[middle])) + ) { + right = middle - 1; + } else { + left = middle + 1; + } + + recalculateMiddle(); } return left; @@ -364,9 +396,14 @@ export function addToMessageList( } // find the closest index to push the new message - const insertionIndex = findInsertionIndex({ message: newMessage, messages: newMessages, sortBy }); + const insertionIndex = findIndexInSortedArray({ + needle: newMessage, + sortedArray: messages, + sortDirection: 'ascending', + selectValueToCompare: (m) => m[sortBy]!.getTime(), + }); - // message already exists and not filtered due to timestampChanged, update and return + // message already exists and not filtered with timestampChanged, update and return if (!timestampChanged && newMessage.id) { if (newMessages[insertionIndex] && newMessage.id === newMessages[insertionIndex].id) { newMessages[insertionIndex] = newMessage; From bfd5ed5e887421db9c01d6f808e841efb701f06f Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Tue, 13 Aug 2024 11:26:10 +0200 Subject: [PATCH 20/41] Remove subscription registration from constructor --- src/thread.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index 41e8a33281..976ef4225e 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -598,9 +598,6 @@ export class ThreadManager { loadingNextPage: false, nextCursor: undefined, }); - - // TODO: temporary - do not register handlers here but rather make Chat component have control over this - this.registerSubscriptions(); } public activate = () => { From 6c50a9ee4d920ad85f43a1ca9d71520d994ab9a0 Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Wed, 14 Aug 2024 15:28:50 +0200 Subject: [PATCH 21/41] chore: thread and thread manager into separate file (#1336) --- src/client.ts | 3 +- src/index.ts | 1 + src/thread.ts | 364 +++--------------------------------------- src/thread_manager.ts | 318 ++++++++++++++++++++++++++++++++++++ 4 files changed, 343 insertions(+), 343 deletions(-) create mode 100644 src/thread_manager.ts diff --git a/src/client.ts b/src/client.ts index 69485026d8..26aa2346ca 100644 --- a/src/client.ts +++ b/src/client.ts @@ -205,8 +205,9 @@ import { QueryMessageHistoryResponse, } from './types'; import { InsightMetrics, postInsights } from './insights'; -import { Thread, ThreadManager } from './thread'; +import { Thread } from './thread'; import { Moderation } from './moderation'; +import { ThreadManager } from './thread_manager'; function isString(x: unknown): x is string { return typeof x === 'string' || x instanceof String; diff --git a/src/index.ts b/src/index.ts index bc7b787b5c..1937eaec8c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export * from './client_state'; export * from './channel'; export * from './channel_state'; export * from './thread'; +export * from './thread_manager'; export * from './connection'; export * from './events'; export * from './moderation'; diff --git a/src/thread.ts b/src/thread.ts index 976ef4225e..ce3f2e1c01 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,27 +1,24 @@ -import { StreamChat } from './client'; -import { Channel } from './channel'; +import type { Channel } from './channel'; +import type { StreamChat } from './client'; +import { SimpleStateStore } from './store/SimpleStateStore'; import type { + AscDesc, DefaultGenerics, + Event, ExtendableGenerics, + FormatMessageResponse, + MessagePaginationOptions, MessageResponse, ThreadResponse, - FormatMessageResponse, UserResponse, - Event, - QueryThreadsOptions, - MessagePaginationOptions, - AscDesc, } from './types'; import { addToMessageList, findIndexInSortedArray, formatMessage, - transformReadArrayToDictionary, throttle, + transformReadArrayToDictionary, } from './utils'; -import { Handler, SimpleStateStore } from './store/SimpleStateStore'; - -type WithRequired = T & { [P in K]-?: T[P] }; type ThreadReadStatus = { [key: string]: { @@ -62,8 +59,6 @@ export type ThreadState = { }; const DEFAULT_PAGE_LIMIT = 50; -const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000; -const MAX_QUERY_THREADS_LIMIT = 25; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; export const DEFAULT_MARK_AS_READ_THROTTLE_DURATION = 1000; @@ -232,27 +227,19 @@ export class Thread { ), ); - // TODO: use debounce instead - const throttledHandleStateRecovery = throttle( - async () => { - // TODO: add online status to prevent recovery attempts during the time the connection is down - try { - const thread = await this.client.getThread(this.id, { watch: true }); - - this.partiallyReplaceState({ thread }); - } catch (error) { - // TODO: handle recovery fail - console.warn(error); - } finally { - // this.updateLocalState('recovering', false); - } - }, - DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, - { - leading: true, - trailing: true, - }, - ); + const handleStateRecovery = async () => { + // TODO: add online status to prevent recovery attempts during the time the connection is down + try { + const thread = await this.client.getThread(this.id, { watch: true }); + + this.partiallyReplaceState({ thread }); + } catch (error) { + // TODO: handle recovery fail + console.warn(error); + } finally { + // this.updateLocalState('recovering', false); + } + }; // when the thread becomes active or it becomes stale while active (channel stops being watched or connection drops) // the recovery handler pulls its latest state to replace with the current one @@ -262,7 +249,7 @@ export class Thread { (nextValue) => [nextValue.active, nextValue.isStateStale], async ([active, isStateStale]) => { // TODO: cancel in-progress recovery? - if (active && isStateStale) throttledHandleStateRecovery(); + if (active && isStateStale) handleStateRecovery(); }, ), ); @@ -564,310 +551,3 @@ export class Thread { } }; } - -export type ThreadManagerState = { - active: boolean; - lastConnectionDownAt: Date | null; - loadingNextPage: boolean; - threadIdIndexMap: { [key: string]: number }; - threads: Thread[]; - unreadThreadsCount: number; - unseenThreadIds: string[]; - nextCursor?: string | null; // null means no next page available - // TODO?: implement once supported by BE - // previousCursor?: string | null; - // loadingPreviousPage: boolean; -}; - -export class ThreadManager { - public readonly state: SimpleStateStore>; - private client: StreamChat; - private unsubscribeFunctions: Set<() => void> = new Set(); - - constructor({ client }: { client: StreamChat }) { - this.client = client; - this.state = new SimpleStateStore>({ - active: false, - threads: [], - threadIdIndexMap: {}, - unreadThreadsCount: 0, - // new threads or threads which have not been loaded and is not possible to paginate to anymore - // as these threads received new replies which moved them up in the list - used for the badge - unseenThreadIds: [], - lastConnectionDownAt: null, - loadingNextPage: false, - nextCursor: undefined, - }); - } - - public activate = () => { - this.state.patchedNext('active', true); - }; - - public deactivate = () => { - this.state.patchedNext('active', false); - }; - - // eslint-disable-next-line sonarjs/cognitive-complexity - public registerSubscriptions = () => { - if (this.unsubscribeFunctions.size) return; - - const handleUnreadThreadsCountChange = (event: Event) => { - const { unread_threads: unreadThreadsCount } = event.me ?? event; - - if (typeof unreadThreadsCount === 'undefined') return; - - this.state.next((current) => ({ - ...current, - unreadThreadsCount, - })); - }; - - [ - 'health.check', - 'notification.mark_read', - 'notification.thread_message_new', - 'notification.channel_deleted', - ].forEach((eventType) => - this.unsubscribeFunctions.add(this.client.on(eventType, handleUnreadThreadsCountChange).unsubscribe), - ); - - // TODO: return to previous recovery option as state merging is now in place - const throttledHandleConnectionRecovery = throttle( - async () => { - const { lastConnectionDownAt, threads } = this.state.getLatestValue(); - - if (!lastConnectionDownAt) return; - - const channelCids = new Set(); - for (const thread of threads) { - if (!thread.channel) continue; - - channelCids.add(thread.channel.cid); - } - - if (!channelCids.size) return; - - try { - // FIXME: syncing does not work for me - await this.client.sync(Array.from(channelCids), lastConnectionDownAt.toISOString(), { watch: true }); - this.state.patchedNext('lastConnectionDownAt', null); - } catch (error) { - // TODO: if error mentions that the amount of events is more than 2k - // do a reload-type recovery (re-query threads and merge states) - - console.warn(error); - } - }, - DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, - { - leading: true, - trailing: true, - }, - ); - - this.unsubscribeFunctions.add( - this.client.on('connection.recovered', throttledHandleConnectionRecovery).unsubscribe, - ); - - this.unsubscribeFunctions.add( - this.client.on('connection.changed', (event) => { - if (typeof event.online === 'undefined') return; - - const { lastConnectionDownAt } = this.state.getLatestValue(); - - if (!event.online && !lastConnectionDownAt) { - this.state.patchedNext('lastConnectionDownAt', new Date()); - } - }).unsubscribe, - ); - - this.unsubscribeFunctions.add( - this.state.subscribeWithSelector( - (nextValue) => [nextValue.active], - ([active]) => { - if (!active) return; - - // automatically clear all the changes that happened "behind the scenes" - this.reload(); - }, - ), - ); - - const handleThreadsChange: Handler[]]> = ([newThreads], previouslySelectedValue) => { - // create new threadIdIndexMap - const newThreadIdIndexMap = newThreads.reduce((map, thread, index) => { - map[thread.id] ??= index; - return map; - }, {}); - - // handle individual thread subscriptions - if (previouslySelectedValue) { - const [previousThreads] = previouslySelectedValue; - previousThreads.forEach((t) => { - // thread with registered handlers has been removed or its signature changed (new instance) - // deregister and let gc do its thing - if (typeof newThreadIdIndexMap[t.id] === 'undefined' || newThreads[newThreadIdIndexMap[t.id]] !== t) { - t.deregisterSubscriptions(); - } - }); - } - newThreads.forEach((t) => t.registerSubscriptions()); - - // publish new threadIdIndexMap - this.state.next((current) => ({ ...current, threadIdIndexMap: newThreadIdIndexMap })); - }; - - this.unsubscribeFunctions.add( - // re-generate map each time the threads array changes - this.state.subscribeWithSelector((nextValue) => [nextValue.threads] as const, handleThreadsChange), - ); - - // TODO: handle parent message hard-deleted (extend state with \w hardDeletedThreadIds?) - - const handleNewReply = (event: Event) => { - if (!event.message || !event.message.parent_id) return; - const parentId = event.message.parent_id; - - const { threadIdIndexMap, nextCursor, threads, unseenThreadIds } = this.state.getLatestValue(); - - // prevents from handling replies until the threads have been loaded - // (does not fill information for "unread threads" banner to appear) - if (!threads.length && nextCursor !== null) return; - - const existsLocally = typeof threadIdIndexMap[parentId] !== 'undefined'; - - if (existsLocally || unseenThreadIds.includes(parentId)) return; - - return this.state.next((current) => ({ - ...current, - unseenThreadIds: current.unseenThreadIds.concat(parentId), - })); - }; - - this.unsubscribeFunctions.add(this.client.on('notification.thread_message_new', handleNewReply).unsubscribe); - }; - - public deregisterSubscriptions = () => { - // TODO: think about state reset or at least invalidation - this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); - }; - - public reload = async () => { - const { threads, unseenThreadIds } = this.state.getLatestValue(); - - if (!unseenThreadIds.length) return; - - const combinedLimit = threads.length + unseenThreadIds.length; - - try { - const data = await this.queryThreads({ - limit: combinedLimit <= MAX_QUERY_THREADS_LIMIT ? combinedLimit : MAX_QUERY_THREADS_LIMIT, - }); - - const { threads, threadIdIndexMap } = this.state.getLatestValue(); - - const newThreads: Thread[] = []; - // const existingThreadIdsToFilterOut: string[] = []; - - for (const thread of data.threads) { - const existingThread: Thread | undefined = threads[threadIdIndexMap[thread.id]]; - - newThreads.push(existingThread ?? thread); - - // replace state of threads which report stale state - // *(state can be considered as stale when channel associated with the thread stops being watched) - if (existingThread && existingThread.hasStaleState) { - existingThread.partiallyReplaceState({ thread }); - } - - // if (existingThread) existingThreadIdsToFilterOut.push(existingThread.id); - } - - // TODO: use some form of a "cache" for unused threads - // to reach for upon next pagination or re-query - // keep them subscribed and "running" behind the scenes but - // not in the list for multitude of reasons (clean cache on last pagination which returns empty array - nothing to pair cached threads to) - // (this.loadedThreadIdMap) - // const existingFilteredThreads = threads.filter(({ id }) => !existingThreadIdsToFilterOut.includes(id)); - - this.state.next((current) => ({ - ...current, - unseenThreadIds: [], // reset - // TODO: extract merging logic and allow loadNextPage to merge as well (in combination with the cache thing) - threads: newThreads, //.concat(existingFilteredThreads), - nextCursor: data.next ?? null, // re-adjust next cursor - })); - } catch (error) { - // TODO: loading states - console.error(error); - } finally { - // ... - } - }; - - public queryThreads = async ({ - limit = 25, - participant_limit = 10, - reply_limit = 10, - watch = true, - ...restOfTheOptions - }: QueryThreadsOptions = {}) => { - const optionsWithDefaults: WithRequired< - QueryThreadsOptions, - 'reply_limit' | 'limit' | 'participant_limit' | 'watch' - > = { - limit, - participant_limit, - reply_limit, - watch, - ...restOfTheOptions, - }; - - const { threads, next } = await this.client.queryThreads(optionsWithDefaults); - - // FIXME: currently this is done within threads based on reply_count property - // but that does not take into consideration sorting (only oldest -> newest) - // re-enable functionality bellow, and take into consideration sorting - - // re-adjust next/previous cursors based on query options - // data.threads.forEach((thread) => { - // thread.state.next((current) => ({ - // ...current, - // nextCursor: current.latestReplies.length < optionsWithDefaults.reply_limit ? null : current.nextCursor, - // previousCursor: - // current.latestReplies.length < optionsWithDefaults.reply_limit ? null : current.previousCursor, - // })); - // }); - - return { threads, next }; - }; - - // remove `next` from options as that is handled internally - public loadNextPage = async (options: Omit = {}) => { - const { nextCursor, loadingNextPage } = this.state.getLatestValue(); - - if (nextCursor === null || loadingNextPage) return; - - const optionsWithNextCursor: QueryThreadsOptions = { - ...options, - next: nextCursor, - }; - - this.state.next((current) => ({ ...current, loadingNextPage: true })); - - try { - const data = await this.queryThreads(optionsWithNextCursor); - - this.state.next((current) => ({ - ...current, - threads: data.threads.length ? current.threads.concat(data.threads) : current.threads, - nextCursor: data.next ?? null, - })); - } catch (error) { - this.client.logger('error', (error as Error).message); - } finally { - this.state.next((current) => ({ ...current, loadingNextPage: false })); - } - }; -} diff --git a/src/thread_manager.ts b/src/thread_manager.ts new file mode 100644 index 0000000000..7d3136c25d --- /dev/null +++ b/src/thread_manager.ts @@ -0,0 +1,318 @@ +import type { StreamChat } from './client'; +import type { Handler } from './store/SimpleStateStore'; +import { SimpleStateStore } from './store/SimpleStateStore'; +import type { Thread } from './thread'; +import type { DefaultGenerics, Event, ExtendableGenerics, QueryThreadsOptions } from './types'; +import { throttle } from './utils'; + +const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000; +const MAX_QUERY_THREADS_LIMIT = 25; + +export type ThreadManagerState = { + active: boolean; + lastConnectionDownAt: Date | null; + loadingNextPage: boolean; + threadIdIndexMap: { [key: string]: number }; + threads: Thread[]; + unreadThreadsCount: number; + unseenThreadIds: string[]; + nextCursor?: string | null; // null means no next page available + // TODO?: implement once supported by BE + // previousCursor?: string | null; + // loadingPreviousPage: boolean; +}; + +type WithRequired = T & { [P in K]-?: T[P] }; + +export class ThreadManager { + public readonly state: SimpleStateStore>; + private client: StreamChat; + private unsubscribeFunctions: Set<() => void> = new Set(); + + constructor({ client }: { client: StreamChat }) { + this.client = client; + this.state = new SimpleStateStore>({ + active: false, + threads: [], + threadIdIndexMap: {}, + unreadThreadsCount: 0, + // new threads or threads which have not been loaded and is not possible to paginate to anymore + // as these threads received new replies which moved them up in the list - used for the badge + unseenThreadIds: [], + lastConnectionDownAt: null, + loadingNextPage: false, + nextCursor: undefined, + }); + } + + public activate = () => { + this.state.patchedNext('active', true); + }; + + public deactivate = () => { + this.state.patchedNext('active', false); + }; + + // eslint-disable-next-line sonarjs/cognitive-complexity + public registerSubscriptions = () => { + if (this.unsubscribeFunctions.size) return; + + const handleUnreadThreadsCountChange = (event: Event) => { + const { unread_threads: unreadThreadsCount } = event.me ?? event; + + if (typeof unreadThreadsCount === 'undefined') return; + + this.state.next((current) => ({ + ...current, + unreadThreadsCount, + })); + }; + + [ + 'health.check', + 'notification.mark_read', + 'notification.thread_message_new', + 'notification.channel_deleted', + ].forEach((eventType) => + this.unsubscribeFunctions.add(this.client.on(eventType, handleUnreadThreadsCountChange).unsubscribe), + ); + + // TODO: return to previous recovery option as state merging is now in place + const throttledHandleConnectionRecovery = throttle( + async () => { + const { lastConnectionDownAt, threads } = this.state.getLatestValue(); + + if (!lastConnectionDownAt) return; + + const channelCids = new Set(); + for (const thread of threads) { + if (!thread.channel) continue; + + channelCids.add(thread.channel.cid); + } + + if (!channelCids.size) return; + + try { + // FIXME: syncing does not work for me + await this.client.sync(Array.from(channelCids), lastConnectionDownAt.toISOString(), { watch: true }); + this.state.patchedNext('lastConnectionDownAt', null); + } catch (error) { + // TODO: if error mentions that the amount of events is more than 2k + // do a reload-type recovery (re-query threads and merge states) + + console.warn(error); + } + }, + DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, + { + leading: true, + trailing: true, + }, + ); + + this.unsubscribeFunctions.add( + this.client.on('connection.recovered', throttledHandleConnectionRecovery).unsubscribe, + ); + + this.unsubscribeFunctions.add( + this.client.on('connection.changed', (event) => { + if (typeof event.online === 'undefined') return; + + const { lastConnectionDownAt } = this.state.getLatestValue(); + + if (!event.online && !lastConnectionDownAt) { + this.state.patchedNext('lastConnectionDownAt', new Date()); + } + }).unsubscribe, + ); + + this.unsubscribeFunctions.add( + this.state.subscribeWithSelector( + (nextValue) => [nextValue.active], + ([active]) => { + if (!active) return; + + // automatically clear all the changes that happened "behind the scenes" + this.reload(); + }, + ), + ); + + const handleThreadsChange: Handler[]]> = ([newThreads], previouslySelectedValue) => { + // create new threadIdIndexMap + const newThreadIdIndexMap = newThreads.reduce((map, thread, index) => { + map[thread.id] ??= index; + return map; + }, {}); + + // handle individual thread subscriptions + if (previouslySelectedValue) { + const [previousThreads] = previouslySelectedValue; + previousThreads.forEach((t) => { + // thread with registered handlers has been removed or its signature changed (new instance) + // deregister and let gc do its thing + if (typeof newThreadIdIndexMap[t.id] === 'undefined' || newThreads[newThreadIdIndexMap[t.id]] !== t) { + t.deregisterSubscriptions(); + } + }); + } + newThreads.forEach((t) => t.registerSubscriptions()); + + // publish new threadIdIndexMap + this.state.next((current) => ({ ...current, threadIdIndexMap: newThreadIdIndexMap })); + }; + + this.unsubscribeFunctions.add( + // re-generate map each time the threads array changes + this.state.subscribeWithSelector((nextValue) => [nextValue.threads] as const, handleThreadsChange), + ); + + // TODO: handle parent message hard-deleted (extend state with \w hardDeletedThreadIds?) + + const handleNewReply = (event: Event) => { + if (!event.message || !event.message.parent_id) return; + const parentId = event.message.parent_id; + + const { threadIdIndexMap, nextCursor, threads, unseenThreadIds } = this.state.getLatestValue(); + + // prevents from handling replies until the threads have been loaded + // (does not fill information for "unread threads" banner to appear) + if (!threads.length && nextCursor !== null) return; + + const existsLocally = typeof threadIdIndexMap[parentId] !== 'undefined'; + + if (existsLocally || unseenThreadIds.includes(parentId)) return; + + return this.state.next((current) => ({ + ...current, + unseenThreadIds: current.unseenThreadIds.concat(parentId), + })); + }; + + this.unsubscribeFunctions.add(this.client.on('notification.thread_message_new', handleNewReply).unsubscribe); + }; + + public deregisterSubscriptions = () => { + // TODO: think about state reset or at least invalidation + this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); + }; + + public reload = async () => { + const { threads, unseenThreadIds } = this.state.getLatestValue(); + + if (!unseenThreadIds.length) return; + + const combinedLimit = threads.length + unseenThreadIds.length; + + try { + const data = await this.queryThreads({ + limit: combinedLimit <= MAX_QUERY_THREADS_LIMIT ? combinedLimit : MAX_QUERY_THREADS_LIMIT, + }); + + const { threads, threadIdIndexMap } = this.state.getLatestValue(); + + const newThreads: Thread[] = []; + // const existingThreadIdsToFilterOut: string[] = []; + + for (const thread of data.threads) { + const existingThread: Thread | undefined = threads[threadIdIndexMap[thread.id]]; + + newThreads.push(existingThread ?? thread); + + // replace state of threads which report stale state + // *(state can be considered as stale when channel associated with the thread stops being watched) + if (existingThread && existingThread.hasStaleState) { + existingThread.partiallyReplaceState({ thread }); + } + + // if (existingThread) existingThreadIdsToFilterOut.push(existingThread.id); + } + + // TODO: use some form of a "cache" for unused threads + // to reach for upon next pagination or re-query + // keep them subscribed and "running" behind the scenes but + // not in the list for multitude of reasons (clean cache on last pagination which returns empty array - nothing to pair cached threads to) + // (this.loadedThreadIdMap) + // const existingFilteredThreads = threads.filter(({ id }) => !existingThreadIdsToFilterOut.includes(id)); + + this.state.next((current) => ({ + ...current, + unseenThreadIds: [], // reset + // TODO: extract merging logic and allow loadNextPage to merge as well (in combination with the cache thing) + threads: newThreads, //.concat(existingFilteredThreads), + nextCursor: data.next ?? null, // re-adjust next cursor + })); + } catch (error) { + // TODO: loading states + console.error(error); + } finally { + // ... + } + }; + + public queryThreads = async ({ + limit = 25, + participant_limit = 10, + reply_limit = 10, + watch = true, + ...restOfTheOptions + }: QueryThreadsOptions = {}) => { + const optionsWithDefaults: WithRequired< + QueryThreadsOptions, + 'reply_limit' | 'limit' | 'participant_limit' | 'watch' + > = { + limit, + participant_limit, + reply_limit, + watch, + ...restOfTheOptions, + }; + + const { threads, next } = await this.client.queryThreads(optionsWithDefaults); + + // FIXME: currently this is done within threads based on reply_count property + // but that does not take into consideration sorting (only oldest -> newest) + // re-enable functionality bellow, and take into consideration sorting + + // re-adjust next/previous cursors based on query options + // data.threads.forEach((thread) => { + // thread.state.next((current) => ({ + // ...current, + // nextCursor: current.latestReplies.length < optionsWithDefaults.reply_limit ? null : current.nextCursor, + // previousCursor: + // current.latestReplies.length < optionsWithDefaults.reply_limit ? null : current.previousCursor, + // })); + // }); + + return { threads, next }; + }; + + // remove `next` from options as that is handled internally + public loadNextPage = async (options: Omit = {}) => { + const { nextCursor, loadingNextPage } = this.state.getLatestValue(); + + if (nextCursor === null || loadingNextPage) return; + + const optionsWithNextCursor: QueryThreadsOptions = { + ...options, + next: nextCursor, + }; + + this.state.next((current) => ({ ...current, loadingNextPage: true })); + + try { + const data = await this.queryThreads(optionsWithNextCursor); + + this.state.next((current) => ({ + ...current, + threads: data.threads.length ? current.threads.concat(data.threads) : current.threads, + nextCursor: data.next ?? null, + })); + } catch (error) { + this.client.logger('error', (error as Error).message); + } finally { + this.state.next((current) => ({ ...current, loadingNextPage: false })); + } + }; +} From a08ffb11a868653565383164043577b3e2553467 Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Wed, 14 Aug 2024 16:21:29 +0200 Subject: [PATCH 22/41] feat: do not store channelData in thread state (#1337) --- src/thread.ts | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index ce3f2e1c01..56a9152317 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -36,7 +36,7 @@ type QueryRepliesOptions = { export type ThreadState = { active: boolean; - + channel: Channel; createdAt: Date; deletedAt: Date | null; isStateStale: boolean; @@ -47,12 +47,8 @@ export type ThreadState = { participants: ThreadResponse['thread_participants']; read: ThreadReadStatus; replyCount: number; - staggeredRead: ThreadReadStatus; updatedAt: Date | null; - channel?: Channel; - channelData?: ThreadResponse['channel']; - // messageId as cursor nextCursor?: string | null; previousCursor?: string | null; @@ -92,12 +88,12 @@ export class Thread { private unsubscribeFunctions: Set<() => void> = new Set(); private failedRepliesMap: Map> = new Map(); - constructor({ client, threadData = {} }: { client: StreamChat; threadData?: Partial> }) { + constructor({ client, threadData }: { client: StreamChat; threadData: ThreadResponse }) { const { read: unformattedRead = [], - latest_replies: latestReplies = [], - thread_participants: threadParticipants = [], - reply_count: replyCount = 0, + latest_replies: latestReplies, + thread_participants: threadParticipants, + reply_count: replyCount, } = threadData; const read = transformReadArrayToDictionary(unformattedRead); @@ -108,8 +104,7 @@ export class Thread { // used as handler helper - actively mark read all of the incoming messages // if the thread is active (visibly selected in the UI or main focal point in advanced list) active: false, - channelData: threadData.channel, // not channel instance - channel: threadData.channel && client.channel(threadData.channel.type, threadData.channel.id), + channel: client.channel(threadData.channel.type, threadData.channel.id, threadData.channel), createdAt: threadData.created_at ? new Date(threadData.created_at) : placeholderDate, deletedAt: threadData.parent_message?.deleted_at ? new Date(threadData.parent_message.deleted_at) : null, latestReplies: latestReplies.map(formatMessage), @@ -118,8 +113,6 @@ export class Thread { participants: threadParticipants, // actual read state in-sync with BE values read, - // also read state but staggered - used for UI purposes (unread count banner) - staggeredRead: read, replyCount, updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null, @@ -163,7 +156,6 @@ export class Thread { const { read, - staggeredRead, replyCount, latestReplies, parentMessage, @@ -173,7 +165,6 @@ export class Thread { updatedAt, nextCursor, previousCursor, - channelData, } = thread.state.getLatestValue(); this.state.next((current) => { @@ -182,7 +173,6 @@ export class Thread { return { ...current, read, - staggeredRead, replyCount, latestReplies: latestReplies.length ? latestReplies.concat(failedReplies) : latestReplies, parentMessage, @@ -192,7 +182,6 @@ export class Thread { updatedAt, nextCursor, previousCursor, - channelData, isStateStale: false, }; }); @@ -272,9 +261,9 @@ export class Thread { const currentUserId = this.client.user?.id; if (!event.channel || !event.user || !currentUserId || currentUserId !== event.user.id) return; - const { channelData } = this.state.getLatestValue(); + const { channel } = this.state.getLatestValue(); - if (!channelData || event.channel.cid !== channelData.cid) return; + if (event.channel.cid !== channel.cid) return; this.state.patchedNext('isStateStale', true); }).unsubscribe, @@ -440,8 +429,7 @@ export class Thread { // update channel on channelData change (unlikely but handled anyway) if (message.channel) { - newData['channelData'] = message.channel; - newData['channel'] = this.client.channel(message.channel.type, message.channel.id); + newData['channel'] = this.client.channel(message.channel.type, message.channel.id, message.channel); } return newData; From 2916a2173ef50301593da7b16d5374982546183b Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Wed, 21 Aug 2024 11:56:30 +0200 Subject: [PATCH 23/41] fix: updates for state store --- src/index.ts | 2 +- src/store.ts | 57 ++++++++++++++++++++++++ src/store/SimpleStateStore.ts | 84 ----------------------------------- src/thread.ts | 20 ++++----- src/thread_manager.ts | 16 +++---- src/types.ts | 2 +- test/unit/threads.test.ts | 82 ++++++++++++++++------------------ 7 files changed, 116 insertions(+), 147 deletions(-) create mode 100644 src/store.ts delete mode 100644 src/store/SimpleStateStore.ts diff --git a/src/index.ts b/src/index.ts index 1937eaec8c..64c3c92311 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,4 +16,4 @@ export * from './types'; export * from './segment'; export * from './campaign'; export { isOwnUser, chatCodes, logChatPromiseExecution, formatMessage } from './utils'; -export * from './store/SimpleStateStore'; +export * from './store'; diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000000..c4069460aa --- /dev/null +++ b/src/store.ts @@ -0,0 +1,57 @@ +export type Patch = (value: T) => T; +export type Handler = (nextValue: T, previousValue: T | undefined) => void; +export type Unsubscibe = () => void; + +function isPatch(value: T | Patch): value is Patch { + return typeof value === 'function'; +} + +export class StateStore> { + private handlerSet = new Set>(); + + constructor(private value: T) {} + + public next = (newValueOrPatch: T | Patch): void => { + // newValue (or patch output) should never be mutated previous value + const newValue = isPatch(newValueOrPatch) ? newValueOrPatch(this.value) : newValueOrPatch; + + // do not notify subscribers if the value hasn't changed + if (newValue === this.value) return; + + const oldValue = this.value; + this.value = newValue; + + this.handlerSet.forEach((handler) => handler(this.value, oldValue)); + }; + + public partialNext = (partial: Partial): void => this.next((current) => ({ ...current, ...partial })); + + public getLatestValue = (): T => this.value; + + public subscribe = (handler: Handler): Unsubscibe => { + handler(this.value, undefined); + this.handlerSet.add(handler); + return () => { + this.handlerSet.delete(handler); + }; + }; + + public subscribeWithSelector = (selector: (nextValue: T) => O, handler: Handler) => { + // begin with undefined to reduce amount of selector calls + let selectedValues: O | undefined; + + const wrappedHandler: Handler = (nextValue) => { + const newlySelectedValues = selector(nextValue); + const hasUpdatedValues = selectedValues?.some((value, index) => value !== newlySelectedValues[index]) ?? true; + + if (!hasUpdatedValues) return; + + const oldSelectedValues = selectedValues; + selectedValues = newlySelectedValues; + + handler(newlySelectedValues, oldSelectedValues); + }; + + return this.subscribe(wrappedHandler); + }; +} diff --git a/src/store/SimpleStateStore.ts b/src/store/SimpleStateStore.ts deleted file mode 100644 index fd9a3a88c1..0000000000 --- a/src/store/SimpleStateStore.ts +++ /dev/null @@ -1,84 +0,0 @@ -export type Patch = (value: T) => T; -export type Handler = (nextValue: T, previousValue?: T) => any; -type Initiator = (get: SimpleStateStore['getLatestValue'], set: SimpleStateStore['next']) => T; - -export type InferStoreValueType = T extends SimpleStateStore - ? R - : T extends { state: SimpleStateStore } - ? L - : never; - -export type StoreSelector = ( - nextValue: T extends SimpleStateStore ? S : never, -) => readonly typeof nextValue[keyof typeof nextValue][]; - -function isPatch(value: T | Patch): value is Patch { - return typeof value === 'function'; -} -function isInitiator(value: T | Initiator): value is Initiator { - return typeof value === 'function'; -} - -export class SimpleStateStore< - T // TODO: limit T to object only? -> { - private value: T; - private handlerSet = new Set>(); - - constructor(initialValueOrInitiator: T | Initiator) { - this.value = isInitiator(initialValueOrInitiator) - ? initialValueOrInitiator(this.getLatestValue, this.next) - : initialValueOrInitiator; - } - - public next = (newValueOrPatch: T | Patch) => { - // newValue (or patch output) should never be mutated previous value - const newValue = isPatch(newValueOrPatch) ? newValueOrPatch(this.value) : newValueOrPatch; - - // do not notify subscribers if the value hasn't changed (or mutation has been returned) - if (newValue === this.value) return; - - const oldValue = this.value; - this.value = newValue; - - this.handlerSet.forEach((handler) => handler(this.value, oldValue)); - }; - - public patchedNext = (key: L, newValue: T[L]) => - this.next((current) => ({ ...current, [key]: newValue })); - - public getLatestValue = () => this.value; - - public subscribe = (handler: Handler) => { - handler(this.value); - this.handlerSet.add(handler); - return () => { - this.handlerSet.delete(handler); - }; - }; - - public subscribeWithSelector = ( - selector: (nextValue: T) => O, - handler: Handler, - emitOnSubscribe = true, - ) => { - // begin with undefined to reduce amount of selector calls - let selectedValues: O | undefined; - - const wrappedHandler: Handler = (nextValue) => { - const newlySelectedValues = selector(nextValue); - - const hasUnequalMembers = selectedValues?.some((value, index) => value !== newlySelectedValues[index]); - - const oldSelectedValues = selectedValues; - selectedValues = newlySelectedValues; - - // initial subscription call begins with hasUnequalMembers as undefined (skip comparison), fallback to unset selectedValues - if (hasUnequalMembers || (typeof hasUnequalMembers === 'undefined' && emitOnSubscribe)) { - handler(newlySelectedValues, oldSelectedValues); - } - }; - - return this.subscribe(wrappedHandler); - }; -} diff --git a/src/thread.ts b/src/thread.ts index 56a9152317..2cbd623b26 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,6 +1,6 @@ import type { Channel } from './channel'; import type { StreamChat } from './client'; -import { SimpleStateStore } from './store/SimpleStateStore'; +import { StateStore } from './store'; import type { AscDesc, DefaultGenerics, @@ -81,7 +81,7 @@ export const DEFAULT_MARK_AS_READ_THROTTLE_DURATION = 1000; // TODO: store users someplace and reference them from state as now replies might contain users with stale information export class Thread { - public readonly state: SimpleStateStore>; + public readonly state: StateStore>; public id: string; private client: StreamChat; @@ -100,7 +100,7 @@ export class Thread { const placeholderDate = new Date(); - this.state = new SimpleStateStore>({ + this.state = new StateStore>({ // used as handler helper - actively mark read all of the incoming messages // if the thread is active (visibly selected in the UI or main focal point in advanced list) active: false, @@ -142,11 +142,11 @@ export class Thread { } public activate = () => { - this.state.patchedNext('active', true); + this.state.partialNext({ active: true }); }; public deactivate = () => { - this.state.patchedNext('active', false); + this.state.partialNext({ active: false }); }; // take state of one instance and merge it to the current instance @@ -265,7 +265,7 @@ export class Thread { if (event.channel.cid !== channel.cid) return; - this.state.patchedNext('isStateStale', true); + this.state.partialNext({ isStateStale: true }); }).unsubscribe, ); @@ -476,7 +476,7 @@ export class Thread { sort, limit = DEFAULT_PAGE_LIMIT, }: Pick, 'sort' | 'limit'> = {}) => { - this.state.patchedNext('loadingNextPage', true); + this.state.partialNext({ loadingNextPage: true }); const { loadingNextPage, nextCursor } = this.state.getLatestValue(); @@ -502,7 +502,7 @@ export class Thread { } catch (error) { this.client.logger('error', (error as Error).message); } finally { - this.state.patchedNext('loadingNextPage', false); + this.state.partialNext({ loadingNextPage: false }); } }; @@ -514,7 +514,7 @@ export class Thread { if (loadingPreviousPage || previousCursor === null) return; - this.state.patchedNext('loadingPreviousPage', true); + this.state.partialNext({ loadingPreviousPage: true }); try { const data = await this.queryReplies({ @@ -535,7 +535,7 @@ export class Thread { } catch (error) { this.client.logger('error', (error as Error).message); } finally { - this.state.patchedNext('loadingPreviousPage', false); + this.state.partialNext({ loadingPreviousPage: false }); } }; } diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 7d3136c25d..7dcce0b278 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -1,6 +1,6 @@ import type { StreamChat } from './client'; -import type { Handler } from './store/SimpleStateStore'; -import { SimpleStateStore } from './store/SimpleStateStore'; +import type { Handler } from './store'; +import { StateStore } from './store'; import type { Thread } from './thread'; import type { DefaultGenerics, Event, ExtendableGenerics, QueryThreadsOptions } from './types'; import { throttle } from './utils'; @@ -25,13 +25,13 @@ export type ThreadManagerState type WithRequired = T & { [P in K]-?: T[P] }; export class ThreadManager { - public readonly state: SimpleStateStore>; + public readonly state: StateStore>; private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); constructor({ client }: { client: StreamChat }) { this.client = client; - this.state = new SimpleStateStore>({ + this.state = new StateStore>({ active: false, threads: [], threadIdIndexMap: {}, @@ -46,11 +46,11 @@ export class ThreadManager { } public activate = () => { - this.state.patchedNext('active', true); + this.state.partialNext({ active: true }); }; public deactivate = () => { - this.state.patchedNext('active', false); + this.state.partialNext({ active: false }); }; // eslint-disable-next-line sonarjs/cognitive-complexity @@ -96,7 +96,7 @@ export class ThreadManager { try { // FIXME: syncing does not work for me await this.client.sync(Array.from(channelCids), lastConnectionDownAt.toISOString(), { watch: true }); - this.state.patchedNext('lastConnectionDownAt', null); + this.state.partialNext({ lastConnectionDownAt: null }); } catch (error) { // TODO: if error mentions that the amount of events is more than 2k // do a reload-type recovery (re-query threads and merge states) @@ -122,7 +122,7 @@ export class ThreadManager { const { lastConnectionDownAt } = this.state.getLatestValue(); if (!event.online && !lastConnectionDownAt) { - this.state.patchedNext('lastConnectionDownAt', new Date()); + this.state.partialNext({ lastConnectionDownAt: new Date() }); } }).unsubscribe, ); diff --git a/src/types.ts b/src/types.ts index f58e968082..3d38d6ad58 100644 --- a/src/types.ts +++ b/src/types.ts @@ -503,7 +503,6 @@ export type ThreadResponse; channel_cid: string; created_at: string; - deleted_at: string; latest_replies: MessageResponse[]; parent_message_id: string; reply_count: number; @@ -513,6 +512,7 @@ export type ThreadResponse; read?: { last_read: string; diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index ee75aadfe7..0d5f38802d 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -2,12 +2,7 @@ import { expect } 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 { mockChannelQueryResponse } from './test-utils/mockChannelQueryResponse'; import { generateThread } from './test-utils/generateThread'; import { @@ -88,7 +83,7 @@ describe('Threads 2.0', () => { const newMessage = formatMessage(generateMsg({ parent_id: thread.id, text: 'aaa' }) as MessageResponse); const newMessageCopy = ({ ...newMessage, text: 'bbb' } as unknown) as MessageResponse; - thread.state.patchedNext('latestReplies', [newMessage]); + thread.state.partialNext({ latestReplies: [newMessage] }); const { latestReplies } = thread.state.getLatestValue(); @@ -186,14 +181,13 @@ describe('Threads 2.0', () => { thread.partiallyReplaceState({ thread: newThread }); - const { read, latestReplies, parentMessage, participants, channelData } = thread.state.getLatestValue(); + const { read, latestReplies, parentMessage, participants } = thread.state.getLatestValue(); // compare non-primitive values only expect(read).to.not.equal(newThread.state.getLatestValue().read); expect(latestReplies).to.not.equal(newThread.state.getLatestValue().latestReplies); expect(parentMessage).to.not.equal(newThread.state.getLatestValue().parentMessage); expect(participants).to.not.equal(newThread.state.getLatestValue().participants); - expect(channelData).to.not.equal(newThread.state.getLatestValue().channelData); }); it('copies state of the instance with the same id', () => { @@ -210,14 +204,13 @@ describe('Threads 2.0', () => { thread.partiallyReplaceState({ thread: newThread }); - const { read, latestReplies, parentMessage, participants, channelData } = thread.state.getLatestValue(); + const { read, latestReplies, parentMessage, participants } = thread.state.getLatestValue(); // compare non-primitive values only expect(read).to.equal(newThread.state.getLatestValue().read); expect(latestReplies).to.equal(newThread.state.getLatestValue().latestReplies); expect(parentMessage).to.equal(newThread.state.getLatestValue().parentMessage); expect(participants).to.equal(newThread.state.getLatestValue().participants); - expect(channelData).to.equal(newThread.state.getLatestValue().channelData); }); it('appends own failed replies from failedRepliesMap during merging', () => { @@ -264,13 +257,15 @@ describe('Threads 2.0', () => { it("increments own unread count if read object contains current user's record", () => { // prepare - thread.state.patchedNext('read', { - [TEST_USER_ID]: { - lastReadAt: new Date(), - last_read: '', - last_read_message_id: '', - unread_messages: 2, - user: { id: TEST_USER_ID }, + thread.state.partialNext({ + read: { + [TEST_USER_ID]: { + lastReadAt: new Date(), + last_read: '', + last_read_message_id: '', + unread_messages: 2, + user: { id: TEST_USER_ID }, + }, }, }); @@ -291,14 +286,13 @@ describe('Threads 2.0', () => { const createdAt = new Date().getTime(); // five messages "created" second apart - thread.state.patchedNext( - 'latestReplies', - Array.from({ length: 5 }, (_, i) => + thread.state.partialNext({ + latestReplies: Array.from({ length: 5 }, (_, i) => formatMessage( generateMsg({ created_at: new Date(createdAt + 1000 * i).toISOString() }) as MessageResponse, ), ), - ); + }); const { latestReplies } = thread.state.getLatestValue(); @@ -401,7 +395,7 @@ describe('Threads 2.0', () => { const stubbedGetThread = sinon.stub(client, 'getThread').resolves(newThread); const partiallyReplaceStateSpy = sinon.spy(thread, 'partiallyReplaceState'); - thread.state.patchedNext('isStateStale', true); + thread.state.partialNext({ isStateStale: true }); expect(thread.state.getLatestValue().isStateStale).to.be.true; expect(stubbedGetThread.called).to.be.false; @@ -465,13 +459,15 @@ describe('Threads 2.0', () => { it(`correctly sets read information for user with id: ${userId}`, () => { // prepare const lastReadAt = new Date(); - thread.state.patchedNext('read', { - [userId]: { - lastReadAt: lastReadAt, - last_read: lastReadAt.toISOString(), - last_read_message_id: '', - unread_messages: 1, - user: { id: userId }, + thread.state.partialNext({ + read: { + [userId]: { + lastReadAt: lastReadAt, + last_read: lastReadAt.toISOString(), + last_read_message_id: '', + unread_messages: 1, + user: { id: userId }, + }, }, }); @@ -512,7 +508,7 @@ describe('Threads 2.0', () => { // prepare const upsertReplyLocallySpy = sinon.spy(thread, 'upsertReplyLocally'); - thread.state.patchedNext('isStateStale', true); + thread.state.partialNext({ isStateStale: true }); client.dispatchEvent({ type: 'message.new', message: generateMsg({ id: thread.id }) as MessageResponse }); @@ -626,7 +622,7 @@ describe('Threads 2.0', () => { it('adds parentMessageId to the unseenThreadIds array on notification.thread_message_new', () => { // artificial first page load - threadManager.state.patchedNext('nextCursor', null); + threadManager.state.partialNext({ nextCursor: null }); expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; @@ -643,7 +639,7 @@ describe('Threads 2.0', () => { it('skips duplicate parentMessageIds in unseenThreadIds array', () => { // artificial first page load - threadManager.state.patchedNext('nextCursor', null); + threadManager.state.partialNext({ nextCursor: null }); expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; @@ -666,7 +662,7 @@ describe('Threads 2.0', () => { it('skips if thread (parentMessageId) is already loaded within threads array', () => { // artificial first page load - threadManager.state.patchedNext('threads', [thread]); + threadManager.state.partialNext({ threads: [thread] }); expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; @@ -681,7 +677,7 @@ describe('Threads 2.0', () => { }); it('recovers from connection down', () => { - threadManager.state.patchedNext('threads', [thread]); + threadManager.state.partialNext({ threads: [thread] }); client.dispatchEvent({ received_at: new Date().toISOString(), @@ -717,7 +713,7 @@ describe('Threads 2.0', () => { it('should generate a new threadIdIndexMap on threads array change', () => { expect(threadManager.state.getLatestValue().threadIdIndexMap).to.deep.equal({}); - threadManager.state.patchedNext('threads', [thread]); + threadManager.state.partialNext({ threads: [thread] }); expect(threadManager.state.getLatestValue().threadIdIndexMap).to.deep.equal({ [thread.id]: 0 }); }); @@ -753,7 +749,7 @@ describe('Threads 2.0', () => { }); it('adds new thread if it does not exist within the threads array', async () => { - threadManager.state.patchedNext('unseenThreadIds', ['t1']); + threadManager.state.partialNext({ unseenThreadIds: ['t1'] }); stubbedQueryThreads.resolves({ threads: [thread], @@ -773,7 +769,7 @@ describe('Threads 2.0', () => { it('replaces state of the existing thread which reports stale state within the threads array', async () => { // prepare threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); - thread.state.patchedNext('isStateStale', true); + thread.state.partialNext({ isStateStale: true }); const newThread = new Thread({ client, @@ -845,7 +841,7 @@ describe('Threads 2.0', () => { it("prevents loading next page if there's no next page to load", async () => { expect(threadManager.state.getLatestValue().nextCursor).is.undefined; - threadManager.state.patchedNext('nextCursor', null); + threadManager.state.partialNext({ nextCursor: null }); await threadManager.loadNextPage(); @@ -855,7 +851,7 @@ describe('Threads 2.0', () => { it('prevents loading next page if already loading', async () => { expect(threadManager.state.getLatestValue().loadingNextPage).is.false; - threadManager.state.patchedNext('loadingNextPage', true); + threadManager.state.partialNext({ loadingNextPage: true }); await threadManager.loadNextPage(); @@ -878,7 +874,8 @@ describe('Threads 2.0', () => { it('switches loading state properly', async () => { const spy = sinon.spy(); - threadManager.state.subscribeWithSelector((nextValue) => [nextValue.loadingNextPage], spy, false); + threadManager.state.subscribeWithSelector((nextValue) => [nextValue.loadingNextPage], spy); + spy.resetHistory(); await threadManager.loadNextPage(); @@ -888,7 +885,7 @@ describe('Threads 2.0', () => { }); it('sets proper nextCursor and threads', async () => { - threadManager.state.patchedNext('threads', [thread]); + threadManager.state.partialNext({ threads: [thread] }); const newThread = new Thread({ client, @@ -913,7 +910,7 @@ describe('Threads 2.0', () => { const cursor1 = uuidv4(); const cursor2 = uuidv4(); - threadManager.state.patchedNext('nextCursor', cursor1); + threadManager.state.partialNext({ nextCursor: cursor1 }); stubbedQueryThreads.resolves({ threads: [], @@ -930,7 +927,6 @@ describe('Threads 2.0', () => { // FIXME: skipped as it's not needed until queryThreads supports reply sorting (asc/desc) it.skip('adjusts nextCursor & previousCusor properties of the queried threads according to query options', () => { - const REPLY_COUNT = 3; const newThread = new Thread({ From 20974a88dd6a5d16166215cad8406b7dac4439f8 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Wed, 21 Aug 2024 13:37:24 +0200 Subject: [PATCH 24/41] Adjust ThreadResponse type, remove unused properties, rename generic --- src/thread.ts | 66 ++++++++++++++++++------------------------- src/thread_manager.ts | 24 ++++++++-------- src/types.ts | 38 ++++++++++++++----------- 3 files changed, 62 insertions(+), 66 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index 2cbd623b26..0f59477b86 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -9,6 +9,7 @@ import type { FormatMessageResponse, MessagePaginationOptions, MessageResponse, + ReadResponse, ThreadResponse, UserResponse, } from './types'; @@ -20,32 +21,23 @@ import { transformReadArrayToDictionary, } from './utils'; -type ThreadReadStatus = { - [key: string]: { - last_read: string; - last_read_message_id: string; - lastReadAt: Date; - unread_messages: number; - user: UserResponse; - }; -}; - -type QueryRepliesOptions = { +type QueryRepliesOptions = { sort?: { created_at: AscDesc }[]; -} & MessagePaginationOptions & { user?: UserResponse; user_id?: string }; +} & MessagePaginationOptions & { user?: UserResponse; user_id?: string }; -export type ThreadState = { +export type ThreadState = { active: boolean; - channel: Channel; + channel: Channel; createdAt: Date; deletedAt: Date | null; isStateStale: boolean; - latestReplies: Array>; + latestReplies: Array>; loadingNextPage: boolean; loadingPreviousPage: boolean; - parentMessage: FormatMessageResponse | undefined; - participants: ThreadResponse['thread_participants']; - read: ThreadReadStatus; + parentMessage: FormatMessageResponse | undefined; + participants: ThreadResponse['thread_participants']; + // FIXME: use ReturnType>> instead (once applicable) + read: { [key: string]: ReadResponse & { lastReadAt: Date } }; replyCount: number; updatedAt: Date | null; @@ -80,32 +72,30 @@ export const DEFAULT_MARK_AS_READ_THROTTLE_DURATION = 1000; // TODO: store users someplace and reference them from state as now replies might contain users with stale information -export class Thread { - public readonly state: StateStore>; +export class Thread { + public readonly state: StateStore>; public id: string; - private client: StreamChat; + private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); - private failedRepliesMap: Map> = new Map(); + private failedRepliesMap: Map> = new Map(); - constructor({ client, threadData }: { client: StreamChat; threadData: ThreadResponse }) { + constructor({ client, threadData }: { client: StreamChat; threadData: ThreadResponse }) { const { read: unformattedRead = [], latest_replies: latestReplies, thread_participants: threadParticipants, - reply_count: replyCount, + reply_count: replyCount = 0, } = threadData; const read = transformReadArrayToDictionary(unformattedRead); - const placeholderDate = new Date(); - - this.state = new StateStore>({ + this.state = new StateStore>({ // used as handler helper - actively mark read all of the incoming messages // if the thread is active (visibly selected in the UI or main focal point in advanced list) active: false, channel: client.channel(threadData.channel.type, threadData.channel.id, threadData.channel), - createdAt: threadData.created_at ? new Date(threadData.created_at) : placeholderDate, + createdAt: new Date(threadData.created_at), deletedAt: threadData.parent_message?.deleted_at ? new Date(threadData.parent_message.deleted_at) : null, latestReplies: latestReplies.map(formatMessage), // thread is "parentMessage" @@ -129,7 +119,7 @@ export class Thread { }); // parent_message_id is being re-used as thread.id - this.id = threadData.parent_message_id ?? `thread-no-id-${placeholderDate}`; // FIXME: use nanoid instead + this.id = threadData.parent_message_id; this.client = client; } @@ -150,7 +140,7 @@ export class Thread { }; // take state of one instance and merge it to the current instance - public partiallyReplaceState = ({ thread }: { thread: Thread }) => { + public partiallyReplaceState = ({ thread }: { thread: Thread }) => { if (thread === this) return; // skip if the instances are the same if (thread.id !== this.id) return; // disallow merging of states of instances that do not match ids @@ -323,7 +313,7 @@ export class Thread { }).unsubscribe, ); - const handleMessageUpdate = (event: Event) => { + const handleMessageUpdate = (event: Event) => { if (!event.message) return; if (event.hard_delete && event.type === 'message.deleted' && event.message.parent_id === this.id) { @@ -360,7 +350,7 @@ export class Thread { }); }; - public deleteReplyLocally = ({ message }: { message: MessageResponse }) => { + public deleteReplyLocally = ({ message }: { message: MessageResponse }) => { const { latestReplies } = this.state.getLatestValue(); const index = findIndexInSortedArray({ @@ -392,7 +382,7 @@ export class Thread { message, timestampChanged = false, }: { - message: MessageResponse | FormatMessageResponse; + message: MessageResponse | FormatMessageResponse; timestampChanged?: boolean; }) => { if (message.parent_id !== this.id) { @@ -411,7 +401,7 @@ export class Thread { })); }; - public updateParentMessageLocally = (message: MessageResponse) => { + public updateParentMessageLocally = (message: MessageResponse) => { if (message.id !== this.id) { throw new Error('Message does not belong to this thread'); } @@ -436,7 +426,7 @@ export class Thread { }); }; - public updateParentMessageOrReplyLocally = (message: MessageResponse) => { + public updateParentMessageOrReplyLocally = (message: MessageResponse) => { if (message.parent_id === this.id) { this.upsertReplyLocally({ message }); } @@ -464,7 +454,7 @@ export class Thread { sort = DEFAULT_SORT, limit = DEFAULT_PAGE_LIMIT, ...otherOptions - }: QueryRepliesOptions = {}) => { + }: QueryRepliesOptions = {}) => { if (!this.channel) throw new Error('queryReplies: This Thread intance has no channel bound to it'); return this.channel.getReplies(this.id, { limit, ...otherOptions }, sort); @@ -475,7 +465,7 @@ export class Thread { public loadNextPage = async ({ sort, limit = DEFAULT_PAGE_LIMIT, - }: Pick, 'sort' | 'limit'> = {}) => { + }: Pick, 'sort' | 'limit'> = {}) => { this.state.partialNext({ loadingNextPage: true }); const { loadingNextPage, nextCursor } = this.state.getLatestValue(); @@ -509,7 +499,7 @@ export class Thread { public loadPreviousPage = async ({ sort, limit = DEFAULT_PAGE_LIMIT, - }: Pick, 'sort' | 'limit'> = {}) => { + }: Pick, 'sort' | 'limit'> = {}) => { const { loadingPreviousPage, previousCursor } = this.state.getLatestValue(); if (loadingPreviousPage || previousCursor === null) return; diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 7dcce0b278..f2d07d1b9a 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -8,12 +8,12 @@ import { throttle } from './utils'; const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000; const MAX_QUERY_THREADS_LIMIT = 25; -export type ThreadManagerState = { +export type ThreadManagerState = { active: boolean; lastConnectionDownAt: Date | null; loadingNextPage: boolean; threadIdIndexMap: { [key: string]: number }; - threads: Thread[]; + threads: Thread[]; unreadThreadsCount: number; unseenThreadIds: string[]; nextCursor?: string | null; // null means no next page available @@ -24,14 +24,14 @@ export type ThreadManagerState type WithRequired = T & { [P in K]-?: T[P] }; -export class ThreadManager { - public readonly state: StateStore>; - private client: StreamChat; +export class ThreadManager { + public readonly state: StateStore>; + private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); - constructor({ client }: { client: StreamChat }) { + constructor({ client }: { client: StreamChat }) { this.client = client; - this.state = new StateStore>({ + this.state = new StateStore>({ active: false, threads: [], threadIdIndexMap: {}, @@ -57,7 +57,7 @@ export class ThreadManager { public registerSubscriptions = () => { if (this.unsubscribeFunctions.size) return; - const handleUnreadThreadsCountChange = (event: Event) => { + const handleUnreadThreadsCountChange = (event: Event) => { const { unread_threads: unreadThreadsCount } = event.me ?? event; if (typeof unreadThreadsCount === 'undefined') return; @@ -139,7 +139,7 @@ export class ThreadManager { ), ); - const handleThreadsChange: Handler[]]> = ([newThreads], previouslySelectedValue) => { + const handleThreadsChange: Handler[]]> = ([newThreads], previouslySelectedValue) => { // create new threadIdIndexMap const newThreadIdIndexMap = newThreads.reduce((map, thread, index) => { map[thread.id] ??= index; @@ -170,7 +170,7 @@ export class ThreadManager { // TODO: handle parent message hard-deleted (extend state with \w hardDeletedThreadIds?) - const handleNewReply = (event: Event) => { + const handleNewReply = (event: Event) => { if (!event.message || !event.message.parent_id) return; const parentId = event.message.parent_id; @@ -212,11 +212,11 @@ export class ThreadManager { const { threads, threadIdIndexMap } = this.state.getLatestValue(); - const newThreads: Thread[] = []; + const newThreads: Thread[] = []; // const existingThreadIdsToFilterOut: string[] = []; for (const thread of data.threads) { - const existingThread: Thread | undefined = threads[threadIdIndexMap[thread.id]]; + const existingThread: Thread | undefined = threads[threadIdIndexMap[thread.id]]; newThreads.push(existingThread ?? thread); diff --git a/src/types.ts b/src/types.ts index 3d38d6ad58..672e6eee8c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -499,28 +499,34 @@ export type GetMessageAPIResponse< StreamChatGenerics extends ExtendableGenerics = DefaultGenerics > = SendMessageAPIResponse; -export type ThreadResponse = { - channel: ChannelResponse; +export interface ThreadResponse { + // FIXME: according to OpenAPI, `channel` could be undefined but since cid is provided I'll asume that it's wrong + channel: ChannelResponse; channel_cid: string; created_at: string; - latest_replies: MessageResponse[]; + created_by_user_id: string; + latest_replies: Array>; parent_message_id: string; - reply_count: number; - thread_participants: { - created_at: string; - user: UserResponse; - }[]; title: string; updated_at: string; + created_by?: UserResponse; deleted_at?: string; - parent_message?: MessageResponse; - read?: { - last_read: string; - last_read_message_id: string; - unread_messages: number; - user: UserResponse; - }[]; -}; + last_message_at?: string; + parent_message?: MessageResponse; + participant_count?: number; + read?: Array>; + reply_count?: number; + thread_participants?: Array<{ + channel_cid: string; + created_at: string; + last_read_at: string; + last_thread_message_at?: string; + left_thread_at?: string; + thread_id?: string; + user?: UserResponse; + user_id?: string; + }>; +} // TODO: Figure out a way to strongly type set and unset. export type PartialThreadUpdate = { From 3d88ef72b9b94123eeb8a94e9a57d1469bf78a72 Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Wed, 21 Aug 2024 13:59:19 +0200 Subject: [PATCH 25/41] fix: typo --- src/store.ts | 4 ++-- src/thread_manager.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/store.ts b/src/store.ts index c4069460aa..f76c8bacb5 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,6 +1,6 @@ export type Patch = (value: T) => T; export type Handler = (nextValue: T, previousValue: T | undefined) => void; -export type Unsubscibe = () => void; +export type Unsubscribe = () => void; function isPatch(value: T | Patch): value is Patch { return typeof value === 'function'; @@ -28,7 +28,7 @@ export class StateStore> { public getLatestValue = (): T => this.value; - public subscribe = (handler: Handler): Unsubscibe => { + public subscribe = (handler: Handler): Unsubscribe => { handler(this.value, undefined); this.handlerSet.add(handler); return () => { diff --git a/src/thread_manager.ts b/src/thread_manager.ts index f2d07d1b9a..6a31501458 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -62,10 +62,7 @@ export class ThreadManager { if (typeof unreadThreadsCount === 'undefined') return; - this.state.next((current) => ({ - ...current, - unreadThreadsCount, - })); + this.state.partialNext({ unreadThreadsCount }); }; [ From 928e6a7458a4c7600a652e49a0727f8cfc5fe003 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Wed, 21 Aug 2024 15:41:28 +0200 Subject: [PATCH 26/41] One test down --- test/unit/test-utils/generateThread.js | 1 + test/unit/threads.test.ts | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/test-utils/generateThread.js b/test/unit/test-utils/generateThread.js index 77c759aa4a..cf67b44949 100644 --- a/test/unit/test-utils/generateThread.js +++ b/test/unit/test-utils/generateThread.js @@ -16,6 +16,7 @@ export const generateThread = (channel, parent, opts = {}) => { reply_count: 0, latest_replies: [], thread_participants: [], + created_by_user_id: '', ...opts, }; }; diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 0d5f38802d..dfdb9327a5 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -31,8 +31,7 @@ describe('Threads 2.0', () => { beforeEach(() => { client = new StreamChat('apiKey'); client._setUser({ id: TEST_USER_ID }); - - channelResponse = generateChannel().channel; + channelResponse = generateChannel({ channel: { id: uuidv4() } }).channel; channel = client.channel(channelResponse.type, channelResponse.id); parentMessageResponse = generateMsg(); thread = new Thread({ client, threadData: generateThread(channelResponse, parentMessageResponse) }); From e71b7bceb1a3694d94ae5b5b82b1ff4a5e728911 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Wed, 21 Aug 2024 15:54:35 +0200 Subject: [PATCH 27/41] Register TM subscriptions before running tests --- test/unit/threads.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index dfdb9327a5..b677d206a1 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -729,6 +729,8 @@ describe('Threads 2.0', () => { threads: [], next: undefined, }); + + threadManager.registerSubscriptions(); }); describe('ThreadManager.reload', () => { @@ -767,7 +769,7 @@ describe('Threads 2.0', () => { // TODO: test merge but instance is the same! it('replaces state of the existing thread which reports stale state within the threads array', async () => { // prepare - threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + threadManager.state.partialNext({ threads: [thread], unseenThreadIds: ['t1'] }); thread.state.partialNext({ isStateStale: true }); const newThread = new Thread({ @@ -943,8 +945,6 @@ describe('Threads 2.0', () => { threads: [newThread], next: undefined, }); - - // ... }); }); }); From 2ac8fea85974404307cf032571c9d467a92ddd58 Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Wed, 21 Aug 2024 19:48:19 +0200 Subject: [PATCH 28/41] WIP --- src/thread.ts | 264 +++++++++++++++++------------------------- src/thread_manager.ts | 2 +- src/types.ts | 2 +- src/utils.ts | 9 -- 4 files changed, 110 insertions(+), 167 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index 0f59477b86..ad3e5042d0 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -13,13 +13,7 @@ import type { ThreadResponse, UserResponse, } from './types'; -import { - addToMessageList, - findIndexInSortedArray, - formatMessage, - throttle, - transformReadArrayToDictionary, -} from './utils'; +import { addToMessageList, findIndexInSortedArray, formatMessage, throttle } from './utils'; type QueryRepliesOptions = { sort?: { created_at: AscDesc }[]; @@ -29,23 +23,35 @@ export type ThreadState = { active: boolean; channel: Channel; createdAt: Date; - deletedAt: Date | null; isStateStale: boolean; - latestReplies: Array>; - loadingNextPage: boolean; - loadingPreviousPage: boolean; + pagination: ThreadRepliesPagination; parentMessage: FormatMessageResponse | undefined; participants: ThreadResponse['thread_participants']; - // FIXME: use ReturnType>> instead (once applicable) - read: { [key: string]: ReadResponse & { lastReadAt: Date } }; + read: ThreadReadState; + replies: Array>; replyCount: number; updatedAt: Date | null; +}; + +export type ThreadRepliesPagination = { + isLoadingNext: boolean; + isLoadingPrev: boolean; + nextCursor: string | null; + prevCursor: string | null; +}; - // messageId as cursor - nextCursor?: string | null; - previousCursor?: string | null; +export type ThreadUserReadState = { + lastReadAt: Date; + user: UserResponse; + lastReadMessageId?: string; + unreadMessageCount?: number; }; +export type ThreadReadState = Record< + string, + ThreadUserReadState +>; + const DEFAULT_PAGE_LIMIT = 50; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; export const DEFAULT_MARK_AS_READ_THROTTLE_DURATION = 1000; @@ -78,40 +84,23 @@ export class Thread { private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); - private failedRepliesMap: Map> = new Map(); + private pendingRepliesMap: Map> = new Map(); constructor({ client, threadData }: { client: StreamChat; threadData: ThreadResponse }) { - const { - read: unformattedRead = [], - latest_replies: latestReplies, - thread_participants: threadParticipants, - reply_count: replyCount = 0, - } = threadData; - - const read = transformReadArrayToDictionary(unformattedRead); - this.state = new StateStore>({ // used as handler helper - actively mark read all of the incoming messages // if the thread is active (visibly selected in the UI or main focal point in advanced list) active: false, channel: client.channel(threadData.channel.type, threadData.channel.id, threadData.channel), createdAt: new Date(threadData.created_at), - deletedAt: threadData.parent_message?.deleted_at ? new Date(threadData.parent_message.deleted_at) : null, - latestReplies: latestReplies.map(formatMessage), + replies: threadData.latest_replies.map(formatMessage), // thread is "parentMessage" - parentMessage: threadData.parent_message && formatMessage(threadData.parent_message), - participants: threadParticipants, - // actual read state in-sync with BE values - read, - replyCount, + parentMessage: formatMessage(threadData.parent_message), + participants: threadData.thread_participants, + read: formatReadState(threadData.read ?? []), + replyCount: threadData.reply_count ?? 0, updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null, - - nextCursor: latestReplies.length === replyCount ? null : latestReplies.at(-1)?.id ?? null, - // TODO: check whether the amount of replies is less than replies_limit (thread.queriedWithOptions = {...}) - // otherwise we perform one extra query (not a big deal but preventable) - previousCursor: latestReplies.length === replyCount ? null : latestReplies.at(0)?.id ?? null, - loadingNextPage: false, - loadingPreviousPage: false, + pagination: repliesPaginationFromInitialThread(threadData), // TODO: implement network status handler (network down, isStateStale: true, reset to false once state has been refreshed) // review lazy-reload approach (You're viewing de-synchronized thread, click here to refresh) or refresh on notification.thread_message_new // reset threads approach (the easiest approach but has to be done with ThreadManager - drops all the state and loads anew) @@ -140,38 +129,32 @@ export class Thread { }; // take state of one instance and merge it to the current instance - public partiallyReplaceState = ({ thread }: { thread: Thread }) => { + public hydrateState = (thread: Thread) => { if (thread === this) return; // skip if the instances are the same if (thread.id !== this.id) return; // disallow merging of states of instances that do not match ids const { read, replyCount, - latestReplies, + replies, parentMessage, participants, createdAt, - deletedAt, updatedAt, - nextCursor, - previousCursor, } = thread.state.getLatestValue(); this.state.next((current) => { - const failedReplies = Array.from(this.failedRepliesMap.values()); + const pendingReplies = Array.from(this.pendingRepliesMap.values()); return { ...current, read, replyCount, - latestReplies: latestReplies.length ? latestReplies.concat(failedReplies) : latestReplies, + replies: replies.length ? replies.concat(pendingReplies) : replies, parentMessage, participants, createdAt, - deletedAt, updatedAt, - nextCursor, - previousCursor, isStateStale: false, }; }); @@ -191,27 +174,23 @@ export class Thread { trailing: true, }); - const currentuserId = this.client.user?.id; - - if (currentuserId) - this.unsubscribeFunctions.add( - this.state.subscribeWithSelector( - (nextValue) => [nextValue.active, nextValue.read[currentuserId]?.unread_messages], - ([active, unreadMessagesCount]) => { - if (!active || !unreadMessagesCount) return; + this.unsubscribeFunctions.add( + this.state.subscribeWithSelector( + (nextValue) => [nextValue.active, this.client.userID && nextValue.read[this.client.userID]?.unreadMessageCount], + ([active, unreadMessagesCount]) => { + if (!active || !unreadMessagesCount) return; - // mark thread as read whenever thread becomes active or is already active and unread messages count increases - throttledMarkAsRead(); - }, - ), - ); + // mark thread as read whenever thread becomes active or is already active and unread messages count increases + throttledMarkAsRead(); + }, + ), + ); const handleStateRecovery = async () => { // TODO: add online status to prevent recovery attempts during the time the connection is down try { const thread = await this.client.getThread(this.id, { watch: true }); - - this.partiallyReplaceState({ thread }); + this.hydrateState(thread); } catch (error) { // TODO: handle recovery fail console.warn(error); @@ -226,30 +205,16 @@ export class Thread { this.unsubscribeFunctions.add( this.state.subscribeWithSelector( (nextValue) => [nextValue.active, nextValue.isStateStale], - async ([active, isStateStale]) => { - // TODO: cancel in-progress recovery? + ([active, isStateStale]) => { if (active && isStateStale) handleStateRecovery(); }, ), ); - // this.unsubscribeFunctions.add( - // // mark local state as stale when connection drops - // this.client.on('connection.changed', (event) => { - // if (typeof event.online === 'undefined') return; - - // // state is already stale or connection recovered - // if (this.state.getLatestValue().isStateStale || event.online) return; - - // this.updateLocalState('isStateStale', true); - // }).unsubscribe, - // ); - this.unsubscribeFunctions.add( // TODO: figure out why the current user is not receiving this event this.client.on('user.watching.stop', (event) => { - const currentUserId = this.client.user?.id; - if (!event.channel || !event.user || !currentUserId || currentUserId !== event.user.id) return; + if (!event.channel || !event.user || !this.client.userID || this.client.userID !== event.user.id) return; const { channel } = this.state.getLatestValue(); @@ -261,25 +226,24 @@ export class Thread { this.unsubscribeFunctions.add( this.client.on('message.new', (event) => { - const currentUserId = this.client.user?.id; - if (!event.message || !currentUserId) return; + if (!event.message || !this.client.userID) return; if (event.message.parent_id !== this.id) return; const { isStateStale } = this.state.getLatestValue(); if (isStateStale) return; - if (this.failedRepliesMap.has(event.message.id)) { - this.failedRepliesMap.delete(event.message.id); + if (this.pendingRepliesMap.has(event.message.id)) { + this.pendingRepliesMap.delete(event.message.id); } this.upsertReplyLocally({ message: event.message, // deal with timestampChanged only related to local user (optimistic updates) - timestampChanged: event.message.user?.id === currentUserId, + timestampChanged: event.message.user?.id === this.client.userID, }); - if (event.message.user?.id !== currentUserId) this.incrementOwnUnreadCount(); + if (event.message.user?.id !== this.client.userID) this.incrementOwnUnreadCount(); // TODO: figure out if event.user is better when it comes to event messages? // if (event.user && event.user.id !== currentUserId) this.incrementOwnUnreadCount(); }).unsubscribe, @@ -333,9 +297,12 @@ export class Thread { }; public incrementOwnUnreadCount = () => { - const currentUserId = this.client.user?.id; + const currentUserId = this.client.userID; if (!currentUserId) return; - // TODO: permissions (read events) - use channel._countMessageAsUnread + + const channelOwnCapabilities = this.channel.data?.own_capabilities; + if (Array.isArray(channelOwnCapabilities) && !channelOwnCapabilities.includes('read-events')) return; + this.state.next((current) => { return { ...current, @@ -343,7 +310,7 @@ export class Thread { ...current.read, [currentUserId]: { ...current.read[currentUserId], - unread_messages: (current.read[currentUserId]?.unread_messages ?? 0) + 1, + unreadMessageCount: (current.read[currentUserId]?.unreadMessageCount ?? 0) + 1, }, }, }; @@ -351,7 +318,7 @@ export class Thread { }; public deleteReplyLocally = ({ message }: { message: MessageResponse }) => { - const { latestReplies } = this.state.getLatestValue(); + const { replies: latestReplies } = this.state.getLatestValue(); const index = findIndexInSortedArray({ needle: formatMessage(message), @@ -373,7 +340,7 @@ export class Thread { return { ...current, - latestReplies: latestRepliesCopy, + replies: latestRepliesCopy, }; }); }; @@ -390,14 +357,14 @@ export class Thread { } const formattedMessage = formatMessage(message); - // store failed message to reference later in state merging + // store pending message to reference later in state merging if (message.status === 'failed') { - this.failedRepliesMap.set(formattedMessage.id, formattedMessage); + this.pendingRepliesMap.set(formattedMessage.id, formattedMessage); } this.state.next((current) => ({ ...current, - latestReplies: addToMessageList(current.latestReplies, formattedMessage, timestampChanged), + replies: addToMessageList(current.replies, formattedMessage, timestampChanged), })); }; @@ -413,8 +380,6 @@ export class Thread { ...current, parentMessage: formattedMessage, replyCount: message.reply_count ?? current.replyCount, - // TODO: probably should not have to do this - deletedAt: formattedMessage.deleted_at, }; // update channel on channelData change (unlikely but handled anyway) @@ -439,13 +404,8 @@ export class Thread { public markAsRead = () => { const { read } = this.state.getLatestValue(); const currentUserId = this.client.user?.id; - - const { unread_messages: unreadMessagesCount } = (currentUserId && read[currentUserId]) || {}; - - if (!unreadMessagesCount) return; - - if (!this.channel) throw new Error('markAsRead: This Thread intance has no channel bound to it'); - + const { unreadMessageCount } = (currentUserId && read[currentUserId]) || {}; + if (!unreadMessageCount) return; return this.channel.markRead({ thread_id: this.id }); }; @@ -460,72 +420,64 @@ export class Thread { return this.channel.getReplies(this.id, { limit, ...otherOptions }, sort); }; - // loadNextPage and loadPreviousPage rely on pagination id's calculated from previous requests - // these functions exclude these options (id_lt, id_lte...) from their options to prevent unexpected pagination behavior - public loadNextPage = async ({ - sort, - limit = DEFAULT_PAGE_LIMIT, - }: Pick, 'sort' | 'limit'> = {}) => { - this.state.partialNext({ loadingNextPage: true }); + public loadPage = async (count: number) => { + const { pagination } = this.state.getLatestValue(); + const [loadingKey, cursorKey] = + count > 0 ? (['isLoadingNext', 'nextCursor'] as const) : (['isLoadingPrev', 'prevCursor'] as const); - const { loadingNextPage, nextCursor } = this.state.getLatestValue(); + if (pagination[loadingKey] || pagination[cursorKey] === null) return; - if (loadingNextPage || nextCursor === null) return; + const queryOptions = { [count > 0 ? 'id_gt' : 'id_lt']: Math.abs(count) }; + const limit = Math.abs(count); - try { - const data = await this.queryReplies({ - id_gt: nextCursor, - limit, - sort, - }); + this.state.partialNext({ pagination: { ...pagination, [loadingKey]: true } }); - const lastMessageId = data.messages.at(-1)?.id; + try { + const data = await this.queryReplies({ ...queryOptions, limit }); + const replies = data.messages.map(formatMessage); + const maybeNextCursor = replies.at(count > 0 ? -1 : 0)?.id ?? null; this.state.next((current) => ({ ...current, // prevent re-creating array if there's nothing to add to the current one - latestReplies: data.messages.length - ? current.latestReplies.concat(data.messages.map(formatMessage)) - : current.latestReplies, - nextCursor: data.messages.length < limit || !lastMessageId ? null : lastMessageId, + replies: replies.length ? current.replies.concat(replies) : current.replies, + pagination: { + ...current.pagination, + [cursorKey]: data.messages.length < limit ? null : maybeNextCursor, + [loadingKey]: false, + }, })); } catch (error) { this.client.logger('error', (error as Error).message); - } finally { - this.state.partialNext({ loadingNextPage: false }); - } - }; - - public loadPreviousPage = async ({ - sort, - limit = DEFAULT_PAGE_LIMIT, - }: Pick, 'sort' | 'limit'> = {}) => { - const { loadingPreviousPage, previousCursor } = this.state.getLatestValue(); - - if (loadingPreviousPage || previousCursor === null) return; - - this.state.partialNext({ loadingPreviousPage: true }); - - try { - const data = await this.queryReplies({ - id_lt: previousCursor, - limit, - sort, - }); - - const firstMessageId = data.messages.at(0)?.id; - this.state.next((current) => ({ ...current, - latestReplies: data.messages.length - ? data.messages.map(formatMessage).concat(current.latestReplies) - : current.latestReplies, - previousCursor: data.messages.length < limit || !firstMessageId ? null : firstMessageId, + pagination: { + ...current.pagination, + [loadingKey]: false, + }, })); - } catch (error) { - this.client.logger('error', (error as Error).message); - } finally { - this.state.partialNext({ loadingPreviousPage: false }); } }; } + +const formatReadState = (read: ReadResponse[]): ThreadReadState => + read.reduce((state, userRead) => { + state[userRead.user.id] = { + user: userRead.user, + lastReadMessageId: userRead.last_read_message_id, + unreadMessageCount: userRead.unread_messages, + lastReadAt: new Date(userRead.last_read), + }; + return state; + }, {}); + +const repliesPaginationFromInitialThread = (thread: ThreadResponse): ThreadRepliesPagination => { + const latestRepliesContainsAllReplies = thread.latest_replies.length === thread.reply_count; + + return { + nextCursor: latestRepliesContainsAllReplies ? null : thread.latest_replies.at(-1)?.id ?? null, + prevCursor: latestRepliesContainsAllReplies ? null : thread.latest_replies.at(0)?.id ?? null, + isLoadingNext: false, + isLoadingPrev: false, + }; +}; diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 6a31501458..0cc85d693b 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -220,7 +220,7 @@ export class ThreadManager { // replace state of threads which report stale state // *(state can be considered as stale when channel associated with the thread stops being watched) if (existingThread && existingThread.hasStaleState) { - existingThread.partiallyReplaceState({ thread }); + existingThread.hydrateState(thread); } // if (existingThread) existingThreadIdsToFilterOut.push(existingThread.id); diff --git a/src/types.ts b/src/types.ts index 672e6eee8c..e4d988b6a3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -506,13 +506,13 @@ export interface ThreadResponse>; + parent_message: MessageResponse; parent_message_id: string; title: string; updated_at: string; created_by?: UserResponse; deleted_at?: string; last_message_at?: string; - parent_message?: MessageResponse; participant_count?: number; read?: Array>; reply_count?: number; diff --git a/src/utils.ts b/src/utils.ts index 49ef8ad162..5029cc6bf2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -449,15 +449,6 @@ function maybeGetReactionGroupsFallback( return null; } -export const transformReadArrayToDictionary = (readArray: T[]) => - readArray.reduce<{ [key: string]: T & { lastReadAt: Date } }>((accumulator, currentValue) => { - accumulator[currentValue.user.id as string] ??= { - ...currentValue, - lastReadAt: new Date(currentValue.last_read), - }; - return accumulator; - }, {}); - // works exactly the same as lodash.throttle export const throttle = unknown>( fn: T, From 10daa99d4b24c075d882630e6af7d1558b2bcfd9 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 22 Aug 2024 12:41:53 +0200 Subject: [PATCH 29/41] Return existingReorderedThreadIds functionality --- src/thread_manager.ts | 35 +++++++++++++++++++------ test/unit/threads.test.ts | 55 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 0cc85d693b..6db6e0da73 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -10,6 +10,7 @@ const MAX_QUERY_THREADS_LIMIT = 25; export type ThreadManagerState = { active: boolean; + existingReorderedThreadIds: string[]; lastConnectionDownAt: Date | null; loadingNextPage: boolean; threadIdIndexMap: { [key: string]: number }; @@ -33,6 +34,8 @@ export class ThreadManager { this.client = client; this.state = new StateStore>({ active: false, + // existing threads which have gotten recent activity during the time the manager was inactive + existingReorderedThreadIds: [], threads: [], threadIdIndexMap: {}, unreadThreadsCount: 0, @@ -171,7 +174,14 @@ export class ThreadManager { if (!event.message || !event.message.parent_id) return; const parentId = event.message.parent_id; - const { threadIdIndexMap, nextCursor, threads, unseenThreadIds } = this.state.getLatestValue(); + const { + threadIdIndexMap, + nextCursor, + threads, + unseenThreadIds, + existingReorderedThreadIds, + active, + } = this.state.getLatestValue(); // prevents from handling replies until the threads have been loaded // (does not fill information for "unread threads" banner to appear) @@ -179,12 +189,20 @@ export class ThreadManager { const existsLocally = typeof threadIdIndexMap[parentId] !== 'undefined'; - if (existsLocally || unseenThreadIds.includes(parentId)) return; + // only register these changes during the time the thread manager is inactive + if (existsLocally && !existingReorderedThreadIds.includes(parentId) && !active) { + return this.state.next((current) => ({ + ...current, + existingReorderedThreadIds: current.existingReorderedThreadIds.concat(parentId), + })); + } - return this.state.next((current) => ({ - ...current, - unseenThreadIds: current.unseenThreadIds.concat(parentId), - })); + if (!existsLocally && !unseenThreadIds.includes(parentId)) { + return this.state.next((current) => ({ + ...current, + unseenThreadIds: current.unseenThreadIds.concat(parentId), + })); + } }; this.unsubscribeFunctions.add(this.client.on('notification.thread_message_new', handleNewReply).unsubscribe); @@ -196,9 +214,9 @@ export class ThreadManager { }; public reload = async () => { - const { threads, unseenThreadIds } = this.state.getLatestValue(); + const { threads, unseenThreadIds, existingReorderedThreadIds } = this.state.getLatestValue(); - if (!unseenThreadIds.length) return; + if (!unseenThreadIds.length && !existingReorderedThreadIds.length) return; const combinedLimit = threads.length + unseenThreadIds.length; @@ -236,6 +254,7 @@ export class ThreadManager { this.state.next((current) => ({ ...current, unseenThreadIds: [], // reset + existingReorderedThreadIds: [], // reset // TODO: extract merging logic and allow loadNextPage to merge as well (in combination with the cache thing) threads: newThreads, //.concat(existingFilteredThreads), nextCursor: data.next ?? null, // re-adjust next cursor diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index b677d206a1..cd9ab6fdac 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -619,7 +619,7 @@ describe('Threads 2.0', () => { expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; }); - it('adds parentMessageId to the unseenThreadIds array on notification.thread_message_new', () => { + it('adds parentMessageId to the unseenThreadIds array', () => { // artificial first page load threadManager.state.partialNext({ nextCursor: null }); @@ -659,11 +659,13 @@ describe('Threads 2.0', () => { expect(threadManager.state.getLatestValue().unseenThreadIds).to.have.lengthOf(1); }); - it('skips if thread (parentMessageId) is already loaded within threads array', () => { + it('adds parentMessageId to the existingReorderedThreadIds if such thread is already loaded within threads array', () => { // artificial first page load threadManager.state.partialNext({ threads: [thread] }); + expect(threadManager.state.getLatestValue().existingReorderedThreadIds).to.be.empty; expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(threadManager.state.getLatestValue().active).to.be.false; client.dispatchEvent({ received_at: new Date().toISOString(), @@ -672,6 +674,27 @@ describe('Threads 2.0', () => { }); expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(threadManager.state.getLatestValue().existingReorderedThreadIds).to.have.lengthOf(1); + expect(threadManager.state.getLatestValue().existingReorderedThreadIds[0]).to.equal(thread.id); + }); + + it('skips parentMessageId addition to the existingReorderedThreadIds if the ThreadManager is inactive', () => { + // artificial first page load + threadManager.state.partialNext({ threads: [thread] }); + threadManager.activate(); + + expect(threadManager.state.getLatestValue().existingReorderedThreadIds).to.be.empty; + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(threadManager.state.getLatestValue().active).to.be.true; + + client.dispatchEvent({ + received_at: new Date().toISOString(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: thread.id }) as MessageResponse, + }); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(threadManager.state.getLatestValue().existingReorderedThreadIds).to.be.empty; }); }); @@ -734,15 +757,39 @@ describe('Threads 2.0', () => { }); describe('ThreadManager.reload', () => { - it('skips reload if unseenThreadIds array is empty', async () => { + it('skips reload if both unseenThreadIds and existingReorderedThreadIds arrays are empty', async () => { + const { unseenThreadIds, existingReorderedThreadIds } = threadManager.state.getLatestValue(); + + expect(unseenThreadIds).to.be.empty; + expect(existingReorderedThreadIds).to.be.empty; + await threadManager.reload(); expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(threadManager.state.getLatestValue().existingReorderedThreadIds).to.be.empty; expect(stubbedQueryThreads.notCalled).to.be.true; }); + (['existingReorderedThreadIds', 'unseenThreadIds'] as const).forEach((bucketName) => { + it(`doesn't skip reload if ${bucketName} is not empty`, async () => { + threadManager.state.partialNext({ [bucketName]: ['t1'] }); + + expect(threadManager.state.getLatestValue()[bucketName]).to.have.lengthOf(1); + + await threadManager.reload(); + + expect(threadManager.state.getLatestValue()[bucketName]).to.be.empty; + expect(stubbedQueryThreads.calledOnce).to.be.true; + }); + }); + it('has been called with proper limits', async () => { - threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); + threadManager.state.next((current) => ({ + ...current, + threads: [thread], + unseenThreadIds: ['t1'], + existingReorderedThreadIds: ['t2'], + })); await threadManager.reload(); From 79942923086e21ee611ace8aa8d0e368e40cc9cb Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 23 Aug 2024 14:40:50 +0200 Subject: [PATCH 30/41] Remove unnecessary return type --- src/channel_state.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/channel_state.ts b/src/channel_state.ts index 87158fa3f3..97399c78c2 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -138,8 +138,7 @@ export class ChannelState} message `MessageResponse` object */ - formatMessage = (message: MessageResponse): FormatMessageResponse => - formatMessage(message); + formatMessage = (message: MessageResponse) => formatMessage(message); /** * addMessagesSorted - Add the list of messages to state and resorts the messages From 3358e12dbf7f0027525a3ea54a6283ef7fc6907c Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Wed, 28 Aug 2024 15:08:49 +0200 Subject: [PATCH 31/41] fix: fix most TODOs and tests for Threads class (#1347) --- package.json | 3 +- src/thread.ts | 470 ++++++++++---------- src/thread_manager.ts | 2 +- test/unit/threads.test.ts | 876 ++++++++++++++++++++++++-------------- 4 files changed, 804 insertions(+), 547 deletions(-) diff --git a/package.json b/package.json index 38d8b09549..c039b74d9a 100644 --- a/package.json +++ b/package.json @@ -132,5 +132,6 @@ }, "engines": { "node": ">=16" - } + }, + "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" } diff --git a/src/thread.ts b/src/thread.ts index ad3e5042d0..8b5ba2be2e 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -4,7 +4,6 @@ import { StateStore } from './store'; import type { AscDesc, DefaultGenerics, - Event, ExtendableGenerics, FormatMessageResponse, MessagePaginationOptions, @@ -20,12 +19,22 @@ type QueryRepliesOptions = { } & MessagePaginationOptions & { user?: UserResponse; user_id?: string }; export type ThreadState = { + /** + * Determines if the thread is currently opened and on-screen. When the thread is active, + * all new messages are immediately marked as read. + */ active: boolean; channel: Channel; createdAt: Date; + deletedAt: Date | null; + isLoading: boolean; isStateStale: boolean; pagination: ThreadRepliesPagination; - parentMessage: FormatMessageResponse | undefined; + /** + * Thread is identified by and has a one-to-one relation with its parent message. + * We use parent message id as a thread id. + */ + parentMessage: FormatMessageResponse; participants: ThreadResponse['thread_participants']; read: ThreadReadState; replies: Array>; @@ -42,72 +51,45 @@ export type ThreadRepliesPagination = { export type ThreadUserReadState = { lastReadAt: Date; + unreadMessageCount: number; user: UserResponse; lastReadMessageId?: string; - unreadMessageCount?: number; }; export type ThreadReadState = Record< string, - ThreadUserReadState + ThreadUserReadState | undefined >; const DEFAULT_PAGE_LIMIT = 50; const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; -export const DEFAULT_MARK_AS_READ_THROTTLE_DURATION = 1000; - -/** - * Request batching? - * - * When the internet connection drops and during downtime threads receive messages, each thread instance should - * do a re-fetch with the latest known message in its list (loadNextPage) once connection restores. In case there are 20+ - * thread instances this would cause a creation of 20+requests. Going through a "batching" mechanism instead - these - * requests would get aggregated and sent only once. - * - * batched req: {[threadId]: { id_gt: "lastKnownMessageId" }, ...} - * batched res: {[threadId]: { messages: [...] }, ...} - * - * Obviously this requires BE support and a batching mechanism on the client-side. - */ - -/** - * Targeted events? - * - * .message.updated | .message.updated - */ - -// TODO: store users someplace and reference them from state as now replies might contain users with stale information +const MARK_AS_READ_THROTTLE_TIMEOUT = 1000; export class Thread { public readonly state: StateStore>; - public id: string; + public readonly id: string; private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); - private pendingRepliesMap: Map> = new Map(); + private failedRepliesMap: Map> = new Map(); constructor({ client, threadData }: { client: StreamChat; threadData: ThreadResponse }) { this.state = new StateStore>({ - // used as handler helper - actively mark read all of the incoming messages - // if the thread is active (visibly selected in the UI or main focal point in advanced list) active: false, channel: client.channel(threadData.channel.type, threadData.channel.id, threadData.channel), createdAt: new Date(threadData.created_at), - replies: threadData.latest_replies.map(formatMessage), - // thread is "parentMessage" + deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null, + isLoading: false, + isStateStale: false, + pagination: repliesPaginationFromInitialThread(threadData), parentMessage: formatMessage(threadData.parent_message), participants: threadData.thread_participants, read: formatReadState(threadData.read ?? []), + replies: threadData.latest_replies.map(formatMessage), replyCount: threadData.reply_count ?? 0, updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null, - pagination: repliesPaginationFromInitialThread(threadData), - // TODO: implement network status handler (network down, isStateStale: true, reset to false once state has been refreshed) - // review lazy-reload approach (You're viewing de-synchronized thread, click here to refresh) or refresh on notification.thread_message_new - // reset threads approach (the easiest approach but has to be done with ThreadManager - drops all the state and loads anew) - isStateStale: false, }); - // parent_message_id is being re-used as thread.id this.id = threadData.parent_message_id; this.client = client; } @@ -120,6 +102,10 @@ export class Thread { return this.state.getLatestValue().isStateStale; } + get ownUnreadCount() { + return ownUnreadCountSelector(this.client.userID)(this.state.getLatestValue()); + } + public activate = () => { this.state.partialNext({ active: true }); }; @@ -128,10 +114,30 @@ export class Thread { this.state.partialNext({ active: false }); }; - // take state of one instance and merge it to the current instance + public loadState = async () => { + if (this.state.getLatestValue().isLoading) { + return; + } + + this.state.partialNext({ isLoading: true }); + + try { + const thread = await this.client.getThread(this.id, { watch: true }); + this.hydrateState(thread); + } finally { + this.state.partialNext({ isLoading: false }); + } + }; + public hydrateState = (thread: Thread) => { - if (thread === this) return; // skip if the instances are the same - if (thread.id !== this.id) return; // disallow merging of states of instances that do not match ids + if (thread === this) { + // skip if the instances are the same + return; + } + + if (thread.id !== this.id) { + throw new Error("Cannot hydrate thread state with using thread's state"); + } const { read, @@ -140,208 +146,194 @@ export class Thread { parentMessage, participants, createdAt, + deletedAt, updatedAt, } = thread.state.getLatestValue(); - this.state.next((current) => { - const pendingReplies = Array.from(this.pendingRepliesMap.values()); + // Preserve pending replies and append them to the updated list of replies + const pendingReplies = Array.from(this.failedRepliesMap.values()); - return { - ...current, - read, - replyCount, - replies: replies.length ? replies.concat(pendingReplies) : replies, - parentMessage, - participants, - createdAt, - updatedAt, - isStateStale: false, - }; + this.state.partialNext({ + read, + replyCount, + replies: pendingReplies.length ? replies.concat(pendingReplies) : replies, + parentMessage, + participants, + createdAt, + deletedAt, + updatedAt, + isStateStale: false, }); }; - /** - * Makes Thread instance listen to events and adjust its state accordingly. - */ - // eslint-disable-next-line sonarjs/cognitive-complexity public registerSubscriptions = () => { - // check whether this instance has subscriptions and is already listening for changes - if (this.unsubscribeFunctions.size) return; - - // TODO: figure out why markAsRead needs to be wrapped like this (for tests to pass) - const throttledMarkAsRead = throttle(() => this.markAsRead(), DEFAULT_MARK_AS_READ_THROTTLE_DURATION, { - leading: true, - trailing: true, - }); + if (this.unsubscribeFunctions.size) { + // Thread is already listening for events and changes + return; + } - this.unsubscribeFunctions.add( - this.state.subscribeWithSelector( - (nextValue) => [nextValue.active, this.client.userID && nextValue.read[this.client.userID]?.unreadMessageCount], - ([active, unreadMessagesCount]) => { - if (!active || !unreadMessagesCount) return; + this.unsubscribeFunctions.add(this.subscribeMarkActiveThreadRead()); + this.unsubscribeFunctions.add(this.subscribeReloadActiveStaleThread()); + this.unsubscribeFunctions.add(this.subscribeMarkThreadStale()); + this.unsubscribeFunctions.add(this.subscribeNewReplies()); + this.unsubscribeFunctions.add(this.subscribeRepliesRead()); + this.unsubscribeFunctions.add(this.subscribeReplyDeleted()); + this.unsubscribeFunctions.add(this.subscribeMessageUpdated()); + }; - // mark thread as read whenever thread becomes active or is already active and unread messages count increases - throttledMarkAsRead(); - }, - ), + private subscribeMarkActiveThreadRead = () => { + return this.state.subscribeWithSelector( + (nextValue) => [nextValue.active, ownUnreadCountSelector(this.client.userID)(nextValue)], + ([active, unreadMessageCount]) => { + if (!active || !unreadMessageCount) return; + this.throttledMarkAsRead(); + }, ); + }; - const handleStateRecovery = async () => { - // TODO: add online status to prevent recovery attempts during the time the connection is down - try { - const thread = await this.client.getThread(this.id, { watch: true }); - this.hydrateState(thread); - } catch (error) { - // TODO: handle recovery fail - console.warn(error); - } finally { - // this.updateLocalState('recovering', false); - } - }; - - // when the thread becomes active or it becomes stale while active (channel stops being watched or connection drops) - // the recovery handler pulls its latest state to replace with the current one - // failed messages are preserved and appended to the newly recovered replies - this.unsubscribeFunctions.add( - this.state.subscribeWithSelector( - (nextValue) => [nextValue.active, nextValue.isStateStale], - ([active, isStateStale]) => { - if (active && isStateStale) handleStateRecovery(); - }, - ), + private subscribeReloadActiveStaleThread = () => + this.state.subscribeWithSelector( + (nextValue) => [nextValue.active, nextValue.isStateStale], + ([active, isStateStale]) => { + if (active && isStateStale) { + this.loadState(); + } + }, ); - this.unsubscribeFunctions.add( - // TODO: figure out why the current user is not receiving this event - this.client.on('user.watching.stop', (event) => { - if (!event.channel || !event.user || !this.client.userID || this.client.userID !== event.user.id) return; + private subscribeMarkThreadStale = () => + this.client.on('user.watching.stop', (event) => { + const { channel } = this.state.getLatestValue(); - const { channel } = this.state.getLatestValue(); + if (!this.client.userID || this.client.userID !== event.user?.id || event.channel?.cid !== channel.cid) { + return; + } - if (event.channel.cid !== channel.cid) return; + this.state.partialNext({ isStateStale: true }); + }).unsubscribe; - this.state.partialNext({ isStateStale: true }); - }).unsubscribe, - ); + private subscribeNewReplies = () => + this.client.on('message.new', (event) => { + if (!this.client.userID || event.message?.parent_id !== this.id) { + return; + } - this.unsubscribeFunctions.add( - this.client.on('message.new', (event) => { - if (!event.message || !this.client.userID) return; - if (event.message.parent_id !== this.id) return; + const isOwnMessage = event.message.user?.id === this.client.userID; + const { active, read } = this.state.getLatestValue(); - const { isStateStale } = this.state.getLatestValue(); + this.upsertReplyLocally({ + message: event.message, + // Message from current user could have been added optimistically, + // so the actual timestamp might differ in the event + timestampChanged: isOwnMessage, + }); - if (isStateStale) return; + if (active) { + this.throttledMarkAsRead(); + } - if (this.pendingRepliesMap.has(event.message.id)) { - this.pendingRepliesMap.delete(event.message.id); + const nextRead: ThreadReadState = {}; + + for (const userId of Object.keys(read)) { + if (read[userId]) { + let nextUserRead: ThreadUserReadState = read[userId]; + + if (userId === event.user?.id) { + // The user who just sent a message to the thread has no unread messages + // in that thread + nextUserRead = { + ...nextUserRead, + lastReadAt: event.created_at ? new Date(event.created_at) : new Date(), + user: event.user, + unreadMessageCount: 0, + }; + } else if (active && userId === this.client.userID) { + // Do not increment unread count for the current user in an active thread + } else { + // Increment unread count for all users except the author of the new message + nextUserRead = { + ...nextUserRead, + unreadMessageCount: read[userId].unreadMessageCount + 1, + }; + } + + nextRead[userId] = nextUserRead; } + } - this.upsertReplyLocally({ - message: event.message, - // deal with timestampChanged only related to local user (optimistic updates) - timestampChanged: event.message.user?.id === this.client.userID, - }); - - if (event.message.user?.id !== this.client.userID) this.incrementOwnUnreadCount(); - // TODO: figure out if event.user is better when it comes to event messages? - // if (event.user && event.user.id !== currentUserId) this.incrementOwnUnreadCount(); - }).unsubscribe, - ); + this.state.partialNext({ read: nextRead }); + }).unsubscribe; - this.unsubscribeFunctions.add( - this.client.on('message.read', (event) => { - if (!event.user || !event.created_at || !event.thread) return; - if (event.thread.parent_message_id !== this.id) return; + private subscribeRepliesRead = () => + this.client.on('message.read', (event) => { + if (!event.user || !event.created_at || !event.thread) return; + if (event.thread.parent_message_id !== this.id) return; - const userId = event.user.id; - const createdAt = event.created_at; - const user = event.user; + const userId = event.user.id; + const createdAt = event.created_at; + const user = event.user; - // FIXME: not sure if this is correct at all - this.state.next((current) => ({ - ...current, - read: { - ...current.read, - [userId]: { - last_read: createdAt, - lastReadAt: new Date(createdAt), - user, - // TODO: rename all of these since it's formatted (find out where it's being used in the SDK) - unread_messages: 0, - // TODO: fix this (lastestReplies.at(-1) might include message that is still being sent, which is wrong) - last_read_message_id: 'unknown', - }, + this.state.next((current) => ({ + ...current, + read: { + ...current.read, + [userId]: { + lastReadAt: new Date(createdAt), + user, + lastReadMessageId: event.last_read_message_id, + unreadMessageCount: 0, }, - })); - }).unsubscribe, - ); - - const handleMessageUpdate = (event: Event) => { - if (!event.message) return; + }, + })); + }).unsubscribe; - if (event.hard_delete && event.type === 'message.deleted' && event.message.parent_id === this.id) { + private subscribeReplyDeleted = () => + this.client.on('message.deleted', (event) => { + if (event.message?.parent_id === this.id && event.hard_delete) { return this.deleteReplyLocally({ message: event.message }); } + }).unsubscribe; + + private subscribeMessageUpdated = () => { + const unsubscribeFunctions = ['message.updated', 'reaction.new', 'reaction.deleted'].map( + (eventType) => + this.client.on(eventType, (event) => { + if (event.message) { + this.updateParentMessageOrReplyLocally(event.message); + } + }).unsubscribe, + ); - this.updateParentMessageOrReplyLocally(event.message); - }; - - ['message.updated', 'message.deleted', 'reaction.new', 'reaction.deleted'].forEach((eventType) => { - this.unsubscribeFunctions.add(this.client.on(eventType, handleMessageUpdate).unsubscribe); - }); + return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); }; - public deregisterSubscriptions = () => { + public unregisterSubscriptions = () => { this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); - }; - - public incrementOwnUnreadCount = () => { - const currentUserId = this.client.userID; - if (!currentUserId) return; - - const channelOwnCapabilities = this.channel.data?.own_capabilities; - if (Array.isArray(channelOwnCapabilities) && !channelOwnCapabilities.includes('read-events')) return; - - this.state.next((current) => { - return { - ...current, - read: { - ...current.read, - [currentUserId]: { - ...current.read[currentUserId], - unreadMessageCount: (current.read[currentUserId]?.unreadMessageCount ?? 0) + 1, - }, - }, - }; - }); + this.unsubscribeFunctions.clear(); }; public deleteReplyLocally = ({ message }: { message: MessageResponse }) => { - const { replies: latestReplies } = this.state.getLatestValue(); + const { replies } = this.state.getLatestValue(); const index = findIndexInSortedArray({ needle: formatMessage(message), - sortedArray: latestReplies, - // TODO: make following two configurable (sortDirection and created_at) + sortedArray: replies, sortDirection: 'ascending', - selectValueToCompare: (m) => m['created_at'].getTime(), + selectValueToCompare: (reply) => reply.created_at.getTime(), }); const actualIndex = - latestReplies[index]?.id === message.id ? index : latestReplies[index - 1]?.id === message.id ? index - 1 : null; + replies[index]?.id === message.id ? index : replies[index - 1]?.id === message.id ? index - 1 : null; - if (actualIndex === null) return; + if (actualIndex === null) { + return; + } - this.state.next((current) => { - // TODO: replace with "Array.toSpliced" when applicable - const latestRepliesCopy = [...latestReplies]; - latestRepliesCopy.splice(actualIndex, 1); + const updatedReplies = [...replies]; + updatedReplies.splice(actualIndex, 1); - return { - ...current, - replies: latestRepliesCopy, - }; + this.state.partialNext({ + replies: updatedReplies, }); }; @@ -349,17 +341,20 @@ export class Thread { message, timestampChanged = false, }: { - message: MessageResponse | FormatMessageResponse; + message: MessageResponse; timestampChanged?: boolean; }) => { if (message.parent_id !== this.id) { - throw new Error('Message does not belong to this thread'); + throw new Error('Reply does not belong to this thread'); } + const formattedMessage = formatMessage(message); - // store pending message to reference later in state merging if (message.status === 'failed') { - this.pendingRepliesMap.set(formattedMessage.id, formattedMessage); + // store failed reply so that it's not lost when reloading or hydrating + this.failedRepliesMap.set(formattedMessage.id, formattedMessage); + } else if (this.failedRepliesMap.has(message.id)) { + this.failedRepliesMap.delete(message.id); } this.state.next((current) => ({ @@ -378,6 +373,7 @@ export class Thread { const newData: typeof current = { ...current, + deletedAt: formattedMessage.deleted_at, parentMessage: formattedMessage, replyCount: message.reply_count ?? current.replyCount, }; @@ -401,33 +397,42 @@ export class Thread { } }; - public markAsRead = () => { - const { read } = this.state.getLatestValue(); - const currentUserId = this.client.user?.id; - const { unreadMessageCount } = (currentUserId && read[currentUserId]) || {}; - if (!unreadMessageCount) return; - return this.channel.markRead({ thread_id: this.id }); + public markAsRead = async ({ force = false }: { force?: boolean } = {}) => { + if (this.ownUnreadCount === 0 && !force) { + return null; + } + + return await this.channel.markRead({ thread_id: this.id }); }; - // moved from channel to thread directly (skipped getClient thing as this call does not need active WS connection) + private throttledMarkAsRead = throttle(() => this.markAsRead(), MARK_AS_READ_THROTTLE_TIMEOUT, { trailing: true }); + public queryReplies = ({ - sort = DEFAULT_SORT, limit = DEFAULT_PAGE_LIMIT, + sort = DEFAULT_SORT, ...otherOptions }: QueryRepliesOptions = {}) => { - if (!this.channel) throw new Error('queryReplies: This Thread intance has no channel bound to it'); - return this.channel.getReplies(this.id, { limit, ...otherOptions }, sort); }; - public loadPage = async (count: number) => { + public loadNextPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) => { + return this.loadPage(limit); + }; + + public loadPrevPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) => { + return this.loadPage(-limit); + }; + + private loadPage = async (count: number) => { const { pagination } = this.state.getLatestValue(); - const [loadingKey, cursorKey] = - count > 0 ? (['isLoadingNext', 'nextCursor'] as const) : (['isLoadingPrev', 'prevCursor'] as const); + const [loadingKey, cursorKey, insertionMethodKey] = + count > 0 + ? (['isLoadingNext', 'nextCursor', 'push'] as const) + : (['isLoadingPrev', 'prevCursor', 'unshift'] as const); if (pagination[loadingKey] || pagination[cursorKey] === null) return; - const queryOptions = { [count > 0 ? 'id_gt' : 'id_lt']: Math.abs(count) }; + const queryOptions = { [count > 0 ? 'id_gt' : 'id_lt']: pagination[cursorKey] }; const limit = Math.abs(count); this.state.partialNext({ pagination: { ...pagination, [loadingKey]: true } }); @@ -437,16 +442,25 @@ export class Thread { const replies = data.messages.map(formatMessage); const maybeNextCursor = replies.at(count > 0 ? -1 : 0)?.id ?? null; - this.state.next((current) => ({ - ...current, + this.state.next((current) => { + let nextReplies = current.replies; + // prevent re-creating array if there's nothing to add to the current one - replies: replies.length ? current.replies.concat(replies) : current.replies, - pagination: { - ...current.pagination, - [cursorKey]: data.messages.length < limit ? null : maybeNextCursor, - [loadingKey]: false, - }, - })); + if (replies.length > 0) { + nextReplies = [...current.replies]; + nextReplies[insertionMethodKey](...replies); + } + + return { + ...current, + replies: nextReplies, + pagination: { + ...current.pagination, + [cursorKey]: data.messages.length < limit ? null : maybeNextCursor, + [loadingKey]: false, + }, + }; + }); } catch (error) { this.client.logger('error', (error as Error).message); this.state.next((current) => ({ @@ -465,7 +479,7 @@ const formatReadState = (read: ReadResponse[]): ThreadReadState => state[userRead.user.id] = { user: userRead.user, lastReadMessageId: userRead.last_read_message_id, - unreadMessageCount: userRead.unread_messages, + unreadMessageCount: userRead.unread_messages ?? 0, lastReadAt: new Date(userRead.last_read), }; return state; @@ -481,3 +495,9 @@ const repliesPaginationFromInitialThread = (thread: ThreadResponse): ThreadRepli isLoadingPrev: false, }; }; + +const ownUnreadCountSelector = (currentUserId: string | undefined) => < + SCG extends ExtendableGenerics = DefaultGenerics +>( + state: ThreadState, +) => (currentUserId && state.read[currentUserId]?.unreadMessageCount) || 0; diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 6db6e0da73..8ac6e0342d 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -153,7 +153,7 @@ export class ThreadManager { // thread with registered handlers has been removed or its signature changed (new instance) // deregister and let gc do its thing if (typeof newThreadIdIndexMap[t.id] === 'undefined' || newThreads[newThreadIdIndexMap[t.id]] !== t) { - t.deregisterSubscriptions(); + t.unregisterSubscriptions(); } }); } diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index cd9ab6fdac..3790ffa2d8 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -5,419 +5,527 @@ import { generateChannel } from './test-utils/generateChannel'; import { generateMsg } from './test-utils/generateMessage'; import { generateThread } from './test-utils/generateThread'; +import sinon from 'sinon'; import { Channel, ChannelResponse, - DEFAULT_MARK_AS_READ_THROTTLE_DURATION, MessageResponse, StreamChat, Thread, ThreadManager, ThreadResponse, - formatMessage, } from '../../src'; -import sinon from 'sinon'; const TEST_USER_ID = 'observer'; describe('Threads 2.0', () => { let client: StreamChat; - let channelResponse: ReturnType['channel']; + let channelResponse: ChannelResponse; let channel: Channel; - let parentMessageResponse: ReturnType; - let thread: Thread; + let parentMessageResponse: MessageResponse; let threadManager: ThreadManager; + function createTestThread({ + channelOverrides = {}, + parentMessageOverrides = {}, + ...overrides + }: Partial & { + channelOverrides?: Partial; + parentMessageOverrides?: Partial; + } = {}) { + return new Thread({ + client, + threadData: generateThread( + { ...channelResponse, ...channelOverrides }, + { ...parentMessageResponse, ...parentMessageOverrides }, + overrides, + ), + }); + } + beforeEach(() => { client = new StreamChat('apiKey'); client._setUser({ id: TEST_USER_ID }); - channelResponse = generateChannel({ channel: { id: uuidv4() } }).channel; + channelResponse = generateChannel({ channel: { id: uuidv4() } }).channel as ChannelResponse; channel = client.channel(channelResponse.type, channelResponse.id); - parentMessageResponse = generateMsg(); - thread = new Thread({ client, threadData: generateThread(channelResponse, parentMessageResponse) }); + parentMessageResponse = generateMsg() as MessageResponse; threadManager = new ThreadManager({ client }); }); - describe('Thread', () => { - it('has constructed proper initial state', () => { - // TODO: id equal to parent message id - // TODO: read state as dictionary - // TODO: channel as instance - // TODO: latest replies formatted - // TODO: parent message formatted - // TODO: created_at formatted - // TODO: deleted_at formatted (or null if not applicable) - // + describe.only('Thread', () => { + it('initializes properly', () => { + const thread = new Thread({ client, threadData: generateThread(channelResponse, parentMessageResponse) }); + expect(thread.id).to.equal(parentMessageResponse.id); }); describe('Methods', () => { - describe('Thread.upsertReplyLocally', () => { - // does not test whether the message has been inserted at the correct position - // that should be unit-tested separately (addToMessageList utility function) - + describe('upsertReplyLocally', () => { it('prevents inserting a new message that does not belong to the associated thread', () => { - const newMessage = generateMsg(); - - const fn = () => { - thread.upsertReplyLocally({ message: newMessage as MessageResponse }); - }; - - expect(fn).to.throw(Error); + const thread = createTestThread(); + const message = generateMsg() as MessageResponse; + expect(() => thread.upsertReplyLocally({ message })).to.throw(); }); it('inserts a new message that belongs to the associated thread', () => { - const newMessage = generateMsg({ parent_id: thread.id }); - - const { latestReplies } = thread.state.getLatestValue(); - - expect(latestReplies).to.have.lengthOf(0); + const thread = createTestThread(); + const message = generateMsg({ parent_id: thread.id }) as MessageResponse; + const stateBefore = thread.state.getLatestValue(); + expect(stateBefore.replies).to.have.lengthOf(0); - thread.upsertReplyLocally({ message: newMessage as MessageResponse }); + thread.upsertReplyLocally({ message }); - expect(thread.state.getLatestValue().latestReplies).to.have.lengthOf(1); - expect(thread.state.getLatestValue().latestReplies[0].id).to.equal(newMessage.id); + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.have.lengthOf(1); + expect(stateAfter.replies[0].id).to.equal(message.id); }); it('updates existing message', () => { - const newMessage = formatMessage(generateMsg({ parent_id: thread.id, text: 'aaa' }) as MessageResponse); - const newMessageCopy = ({ ...newMessage, text: 'bbb' } as unknown) as MessageResponse; + const message = generateMsg({ parent_id: parentMessageResponse.id, text: 'aaa' }) as MessageResponse; + const thread = createTestThread({ latest_replies: [message] }); + const udpatedMessage = { ...message, text: 'bbb' }; - thread.state.partialNext({ latestReplies: [newMessage] }); + const stateBefore = thread.state.getLatestValue(); + expect(stateBefore.replies).to.have.lengthOf(1); + expect(stateBefore.replies[0].id).to.equal(message.id); + expect(stateBefore.replies[0].text).to.not.equal(udpatedMessage.text); - const { latestReplies } = thread.state.getLatestValue(); + thread.upsertReplyLocally({ message: udpatedMessage }); - expect(latestReplies).to.have.lengthOf(1); - expect(latestReplies.at(0)!.id).to.equal(newMessageCopy.id); - expect(latestReplies.at(0)!.text).to.not.equal(newMessageCopy.text); - - thread.upsertReplyLocally({ message: newMessageCopy }); - - expect(thread.state.getLatestValue().latestReplies).to.have.lengthOf(1); - expect(thread.state.getLatestValue().latestReplies.at(0)!.text).to.equal(newMessageCopy.text); + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.have.lengthOf(1); + expect(stateAfter.replies[0].text).to.equal(udpatedMessage.text); }); - // TODO: timestampChanged (check that duplicates get removed) + it('updates optimistically added message', () => { + const optimisticMessage = generateMsg({ + parent_id: parentMessageResponse.id, + text: 'aaa', + date: '2020-01-01T00:00:00Z', + }) as MessageResponse; + + const message = generateMsg({ + parent_id: parentMessageResponse.id, + text: 'bbb', + date: '2020-01-01T00:00:10Z', + }) as MessageResponse; + + const thread = createTestThread({ latest_replies: [optimisticMessage, message] }); + const udpatedMessage = { ...optimisticMessage, text: 'ccc', date: '2020-01-01T00:00:20Z' }; + + const stateBefore = thread.state.getLatestValue(); + expect(stateBefore.replies).to.have.lengthOf(2); + expect(stateBefore.replies[0].id).to.equal(optimisticMessage.id); + expect(stateBefore.replies[0].text).to.equal('aaa'); + expect(stateBefore.replies[1].id).to.equal(message.id); + + thread.upsertReplyLocally({ message: udpatedMessage, timestampChanged: true }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.have.lengthOf(2); + expect(stateAfter.replies[0].id).to.equal(message.id); + expect(stateAfter.replies[1].id).to.equal(optimisticMessage.id); + expect(stateAfter.replies[1].text).to.equal('ccc'); + }); }); - describe('Thread.updateParentMessageLocally', () => { + describe('updateParentMessageLocally', () => { it('prevents updating a parent message if the ids do not match', () => { - const newMessage = generateMsg(); + const thread = createTestThread(); + const message = generateMsg() as MessageResponse; + expect(() => thread.updateParentMessageLocally(message)).to.throw(); + }); - const fn = () => { - thread.updateParentMessageLocally(newMessage as MessageResponse); - }; + it('updates parent message and related top-level properties', () => { + const thread = createTestThread(); - expect(fn).to.throw(Error); - }); + const stateBefore = thread.state.getLatestValue(); + expect(stateBefore.deletedAt).to.be.null; + expect(stateBefore.replyCount).to.equal(0); + expect(stateBefore.parentMessage.text).to.equal(parentMessageResponse.text); - it('updates parent message and related top-level properties (deletedAt & replyCount)', () => { - const newMessage = generateMsg({ + const updatedMessage = generateMsg({ id: parentMessageResponse.id, text: 'aaa', reply_count: 10, deleted_at: new Date().toISOString(), - }); - - const { deletedAt, replyCount, parentMessage } = thread.state.getLatestValue(); - - // baseline - expect(parentMessage!.id).to.equal(thread.id); - expect(deletedAt).to.be.null; - expect(replyCount).to.equal(0); - expect(parentMessage!.text).to.equal(parentMessageResponse.text); + }) as MessageResponse; - thread.updateParentMessageLocally(newMessage as MessageResponse); + thread.updateParentMessageLocally(updatedMessage); - expect(thread.state.getLatestValue().deletedAt).to.be.a('date'); - expect(thread.state.getLatestValue().deletedAt!.toISOString()).to.equal( - (newMessage as MessageResponse).deleted_at, - ); - expect(thread.state.getLatestValue().replyCount).to.equal(newMessage.reply_count); - expect(thread.state.getLatestValue().parentMessage!.text).to.equal(newMessage.text); + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.deletedAt).to.be.not.null; + expect(stateAfter.deletedAt!.toISOString()).to.equal(updatedMessage.deleted_at); + expect(stateAfter.replyCount).to.equal(updatedMessage.reply_count); + expect(stateAfter.parentMessage.text).to.equal(updatedMessage.text); }); }); - describe('Thread.updateParentMessageOrReplyLocally', () => { - it('calls upsertReplyLocally if the message has parent_id and it equals to the thread.id', () => { - const upsertReplyLocallyStub = sinon.stub(thread, 'upsertReplyLocally').returns(); - const updateParentMessageLocallyStub = sinon.stub(thread, 'updateParentMessageLocally').returns(); + describe('updateParentMessageOrReplyLocally', () => { + it('updates reply if the message has a matching parent id', () => { + const thread = createTestThread(); + const message = generateMsg({ parent_id: thread.id }) as MessageResponse; + const upsertReplyLocallyStub = sinon.stub(thread, 'upsertReplyLocally'); + const updateParentMessageLocallyStub = sinon.stub(thread, 'updateParentMessageLocally'); - thread.updateParentMessageOrReplyLocally(generateMsg({ parent_id: thread.id }) as MessageResponse); + thread.updateParentMessageOrReplyLocally(message); expect(upsertReplyLocallyStub.called).to.be.true; expect(updateParentMessageLocallyStub.called).to.be.false; }); - it('calls updateParentMessageLocally if message does not have parent_id and its id equals to the id of the thread', () => { - const upsertReplyLocallyStub = sinon.stub(thread, 'upsertReplyLocally').returns(); - const updateParentMessageLocallyStub = sinon.stub(thread, 'updateParentMessageLocally').returns(); + it('updates parent message if the message has a matching id and is not a reply', () => { + const thread = createTestThread(); + const message = generateMsg({ id: thread.id }) as MessageResponse; + const upsertReplyLocallyStub = sinon.stub(thread, 'upsertReplyLocally'); + const updateParentMessageLocallyStub = sinon.stub(thread, 'updateParentMessageLocally'); - thread.updateParentMessageOrReplyLocally(generateMsg({ id: thread.id }) as MessageResponse); + thread.updateParentMessageOrReplyLocally(message); expect(upsertReplyLocallyStub.called).to.be.false; expect(updateParentMessageLocallyStub.called).to.be.true; }); - it('does not call either updateParentMessageLocally or upsertReplyLocally', () => { - const upsertReplyLocallyStub = sinon.stub(thread, 'upsertReplyLocally').returns(); - const updateParentMessageLocallyStub = sinon.stub(thread, 'updateParentMessageLocally').returns(); + it('does nothing if the message is unrelated to the thread', () => { + const thread = createTestThread(); + const message = generateMsg() as MessageResponse; + const upsertReplyLocallyStub = sinon.stub(thread, 'upsertReplyLocally'); + const updateParentMessageLocallyStub = sinon.stub(thread, 'updateParentMessageLocally'); - thread.updateParentMessageOrReplyLocally(generateMsg() as MessageResponse); + thread.updateParentMessageOrReplyLocally(message); expect(upsertReplyLocallyStub.called).to.be.false; expect(updateParentMessageLocallyStub.called).to.be.false; }); }); - describe('Thread.partiallyReplaceState', () => { - it('prevents copying state of the instance with different id', () => { - const newThread = new Thread({ - client, - threadData: generateThread(generateChannel({ channel: { id: channelResponse.id } }).channel, generateMsg()), - }); + describe('hydrateState', () => { + it('prevents hydrating state from the instance with a different id', () => { + const thread = createTestThread(); + const otherThread = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); - expect(thread.id).to.not.equal(newThread.id); + expect(thread.id).to.not.equal(otherThread.id); + expect(() => thread.hydrateState(otherThread)).to.throw(); + }); - thread.partiallyReplaceState({ thread: newThread }); + it('copies state of the instance with the same id', () => { + const thread = createTestThread(); + const hydrationThread = createTestThread(); + thread.hydrateState(hydrationThread); - const { read, latestReplies, parentMessage, participants } = thread.state.getLatestValue(); + const stateAfter = thread.state.getLatestValue(); + const hydrationState = hydrationThread.state.getLatestValue(); // compare non-primitive values only - expect(read).to.not.equal(newThread.state.getLatestValue().read); - expect(latestReplies).to.not.equal(newThread.state.getLatestValue().latestReplies); - expect(parentMessage).to.not.equal(newThread.state.getLatestValue().parentMessage); - expect(participants).to.not.equal(newThread.state.getLatestValue().participants); + expect(stateAfter.read).to.equal(hydrationState.read); + expect(stateAfter.replies).to.equal(hydrationState.replies); + expect(stateAfter.parentMessage).to.equal(hydrationState.parentMessage); + expect(stateAfter.participants).to.equal(hydrationState.participants); }); - it('copies state of the instance with the same id', () => { - const newThread = new Thread({ - client, - threadData: generateThread( - generateChannel({ channel: { id: channelResponse.id } }).channel, - generateMsg({ id: parentMessageResponse.id }), - ), + it('retains failed replies after hydration', () => { + const thread = createTestThread(); + const hydrationThread = createTestThread({ + latest_replies: [generateMsg({ parent_id: parentMessageResponse.id }) as MessageResponse], }); - expect(thread.id).to.equal(newThread.id); - expect(thread).to.not.equal(newThread); - - thread.partiallyReplaceState({ thread: newThread }); + const failedMessage = generateMsg({ + status: 'failed', + parent_id: parentMessageResponse.id, + }) as MessageResponse; + thread.upsertReplyLocally({ message: failedMessage }); - const { read, latestReplies, parentMessage, participants } = thread.state.getLatestValue(); + thread.hydrateState(hydrationThread); - // compare non-primitive values only - expect(read).to.equal(newThread.state.getLatestValue().read); - expect(latestReplies).to.equal(newThread.state.getLatestValue().latestReplies); - expect(parentMessage).to.equal(newThread.state.getLatestValue().parentMessage); - expect(participants).to.equal(newThread.state.getLatestValue().participants); + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.have.lengthOf(2); + expect(stateAfter.replies[1].id).to.equal(failedMessage.id); }); + }); - it('appends own failed replies from failedRepliesMap during merging', () => { - const newThread = new Thread({ - client, - threadData: generateThread( - generateChannel({ channel: { id: channelResponse.id } }).channel, - generateMsg({ id: parentMessageResponse.id }), - { latest_replies: [generateMsg({ parent_id: parentMessageResponse.id })] }, - ), - }); - - const failedMessage = formatMessage( - generateMsg({ status: 'failed', parent_id: thread.id }) as MessageResponse, + describe('deleteReplyLocally', () => { + it('deletes appropriate message', () => { + const createdAt = new Date().getTime(); + // five messages "created" second apart + const messages = Array.from( + { length: 5 }, + (_, i) => generateMsg({ created_at: new Date(createdAt + 1000 * i).toISOString() }) as MessageResponse, ); - thread.upsertReplyLocally({ message: failedMessage }); + const thread = createTestThread({ latest_replies: messages }); - expect(thread.id).to.equal(newThread.id); - expect(thread).to.not.equal(newThread); + const stateBefore = thread.state.getLatestValue(); + expect(stateBefore.replies).to.have.lengthOf(5); - thread.partiallyReplaceState({ thread: newThread }); + const messageToDelete = generateMsg({ + created_at: messages[2].created_at, + id: messages[2].id, + }) as MessageResponse; - const { latestReplies } = thread.state.getLatestValue(); + thread.deleteReplyLocally({ message: messageToDelete }); - // compare non-primitive values only - expect(latestReplies).to.have.lengthOf(2); - expect(latestReplies.at(-1)!.id).to.equal(failedMessage.id); - expect(latestReplies).to.not.equal(newThread.state.getLatestValue().latestReplies); + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.not.equal(stateBefore.replies); + expect(stateAfter.replies).to.have.lengthOf(4); + expect(stateAfter.replies.find((reply) => reply.id === messageToDelete.id)).to.be.undefined; }); }); - describe('Thread.incrementOwnUnreadCount', () => { - it('increments own unread count even if read object is empty', () => { - const { read } = thread.state.getLatestValue(); - // TODO: write a helper for immediate own unread count - const ownUnreadCount = read[TEST_USER_ID]?.unread_messages ?? 0; + describe('markAsRead', () => { + let stubbedChannelMarkRead: sinon.SinonStub, ReturnType>; - expect(ownUnreadCount).to.equal(0); + beforeEach(() => { + stubbedChannelMarkRead = sinon.stub(channel, 'markRead').resolves(); + }); - thread.incrementOwnUnreadCount(); + it('does nothing if unread count of the current user is zero', async () => { + const thread = createTestThread(); + expect(thread.ownUnreadCount).to.equal(0); + + await thread.markAsRead(); - expect(thread.state.getLatestValue().read[TEST_USER_ID]?.unread_messages).to.equal(1); + expect(stubbedChannelMarkRead.notCalled).to.be.true; }); - it("increments own unread count if read object contains current user's record", () => { - // prepare - thread.state.partialNext({ - read: { - [TEST_USER_ID]: { - lastReadAt: new Date(), - last_read: '', - last_read_message_id: '', - unread_messages: 2, + it('calls channel.markRead if unread count of the current user is greater than zero', async () => { + const thread = createTestThread({ + read: [ + { + last_read: new Date().toISOString(), user: { id: TEST_USER_ID }, + unread_messages: 42, }, - }, + ], }); - const { read } = thread.state.getLatestValue(); - const ownUnreadCount = read[TEST_USER_ID]?.unread_messages ?? 0; - - expect(ownUnreadCount).to.equal(2); + expect(thread.ownUnreadCount).to.equal(42); - thread.incrementOwnUnreadCount(); + await thread.markAsRead(); - expect(thread.state.getLatestValue().read[TEST_USER_ID]?.unread_messages).to.equal(3); + expect(stubbedChannelMarkRead.calledOnceWith({ thread_id: thread.id })).to.be.true; }); }); - describe('Thread.deleteReplyLocally', () => { - it('deletes appropriate message from the latestReplies array', () => { - const TARGET_MESSAGE_INDEX = 2; + describe('loadPage', () => { + it('sets up pagination on initialization (all replies included in response)', () => { + const thread = createTestThread({ latest_replies: [generateMsg() as MessageResponse], reply_count: 1 }); + const state = thread.state.getLatestValue(); + expect(state.pagination.prevCursor).to.be.null; + expect(state.pagination.nextCursor).to.be.null; + }); - const createdAt = new Date().getTime(); - // five messages "created" second apart - thread.state.partialNext({ - latestReplies: Array.from({ length: 5 }, (_, i) => - formatMessage( - generateMsg({ created_at: new Date(createdAt + 1000 * i).toISOString() }) as MessageResponse, - ), - ), + it('sets up pagination on initialization (not all replies included in response)', () => { + const firstMessage = generateMsg() as MessageResponse; + const lastMessage = generateMsg() as MessageResponse; + const thread = createTestThread({ latest_replies: [firstMessage, lastMessage], reply_count: 3 }); + const state = thread.state.getLatestValue(); + expect(state.pagination.prevCursor).not.to.be.null; + expect(state.pagination.nextCursor).not.to.be.null; + }); + + it('updates pagination after loading next page (end reached)', async () => { + const thread = createTestThread({ + latest_replies: [generateMsg(), generateMsg()] as MessageResponse[], + reply_count: 3, + }); + sinon.stub(thread, 'queryReplies').resolves({ + messages: [generateMsg()] as MessageResponse[], + duration: '', }); - const { latestReplies } = thread.state.getLatestValue(); + await thread.loadNextPage({ limit: 2 }); - expect(latestReplies).to.have.lengthOf(5); + const state = thread.state.getLatestValue(); + expect(state.pagination.nextCursor).to.be.null; + }); - const messageToDelete = generateMsg({ - created_at: latestReplies[TARGET_MESSAGE_INDEX].created_at.toISOString(), - id: latestReplies[TARGET_MESSAGE_INDEX].id, + it('updates pagination after loading next page (end not reached)', async () => { + const thread = createTestThread({ + latest_replies: [generateMsg(), generateMsg()] as MessageResponse[], + reply_count: 4, + }); + const lastMessage = generateMsg() as MessageResponse; + sinon.stub(thread, 'queryReplies').resolves({ + messages: [generateMsg(), lastMessage] as MessageResponse[], + duration: '', }); - expect(latestReplies[TARGET_MESSAGE_INDEX].id).to.equal(messageToDelete.id); - expect(latestReplies[TARGET_MESSAGE_INDEX].created_at.toISOString()).to.equal(messageToDelete.created_at); - - thread.deleteReplyLocally({ message: messageToDelete as MessageResponse }); + await thread.loadNextPage({ limit: 2 }); - // check whether array signatures changed - expect(thread.state.getLatestValue().latestReplies).to.not.equal(latestReplies); - expect(thread.state.getLatestValue().latestReplies).to.have.lengthOf(4); - expect(thread.state.getLatestValue().latestReplies[TARGET_MESSAGE_INDEX].id).to.not.equal(messageToDelete.id); + const state = thread.state.getLatestValue(); + expect(state.pagination.nextCursor).to.equal(lastMessage.id); }); - }); - describe('Thread.markAsRead', () => { - let stubbedChannelMarkRead: sinon.SinonStub, ReturnType>; + it('forms correct request when loading next page', async () => { + const firstMessage = generateMsg() as MessageResponse; + const lastMessage = generateMsg() as MessageResponse; + const thread = createTestThread({ latest_replies: [firstMessage, lastMessage], reply_count: 3 }); + const queryRepliesStub = sinon.stub(thread, 'queryReplies').resolves({ messages: [], duration: '' }); - beforeEach(() => { - stubbedChannelMarkRead = sinon.stub(channel, 'markRead').resolves(); + await thread.loadNextPage({ limit: 42 }); + + expect( + queryRepliesStub.calledOnceWith({ + id_gt: lastMessage.id, + limit: 42, + }), + ).to.be.true; }); - it('prevents calling channel.markRead if the unread count of the current user is 0', async () => { - const { read } = thread.state.getLatestValue(); - const ownUnreadCount = read[TEST_USER_ID]?.unread_messages ?? 0; + it('updates pagination after loading previous page (end reached)', async () => { + const thread = createTestThread({ + latest_replies: [generateMsg(), generateMsg()] as MessageResponse[], + reply_count: 3, + }); + sinon.stub(thread, 'queryReplies').resolves({ + messages: [generateMsg()] as MessageResponse[], + duration: '', + }); - expect(ownUnreadCount).to.equal(0); + await thread.loadPrevPage({ limit: 2 }); - await thread.markAsRead(); + const state = thread.state.getLatestValue(); + expect(state.pagination.prevCursor).to.be.null; + }); - expect(stubbedChannelMarkRead.notCalled).to.be.true; + it('updates pagination after loading previous page (end not reached)', async () => { + const thread = createTestThread({ + latest_replies: [generateMsg(), generateMsg()] as MessageResponse[], + reply_count: 4, + }); + const firstMessage = generateMsg() as MessageResponse; + sinon.stub(thread, 'queryReplies').resolves({ + messages: [firstMessage, generateMsg()] as MessageResponse[], + duration: '', + }); + + await thread.loadPrevPage({ limit: 2 }); + + const state = thread.state.getLatestValue(); + expect(state.pagination.prevCursor).to.equal(firstMessage.id); }); - it('calls channel.markRead if the unread count is greater than zero', async () => { - // prepare - thread.incrementOwnUnreadCount(); + it('forms correct request when loading previous page', async () => { + const firstMessage = generateMsg() as MessageResponse; + const lastMessage = generateMsg() as MessageResponse; + const thread = createTestThread({ latest_replies: [firstMessage, lastMessage], reply_count: 3 }); + const queryRepliesStub = sinon.stub(thread, 'queryReplies').resolves({ messages: [], duration: '' }); - const { read } = thread.state.getLatestValue(); + await thread.loadPrevPage({ limit: 42 }); - const ownUnreadCount = read[TEST_USER_ID]?.unread_messages ?? 0; + expect( + queryRepliesStub.calledOnceWith({ + id_lt: firstMessage.id, + limit: 42, + }), + ).to.be.true; + }); - expect(ownUnreadCount).to.equal(1); + it('appends messages when loading next page', async () => { + const initialMessages = [generateMsg(), generateMsg()] as MessageResponse[]; + const nextMessages = [generateMsg(), generateMsg()] as MessageResponse[]; + const thread = createTestThread({ latest_replies: initialMessages, reply_count: 4 }); + sinon.stub(thread, 'queryReplies').resolves({ messages: nextMessages, duration: '' }); - await thread.markAsRead(); + await thread.loadNextPage({ limit: 2 }); - expect(stubbedChannelMarkRead.calledOnceWith({ thread_id: thread.id })).to.be.true; + const stateAfter = thread.state.getLatestValue(); + const expectedMessageOrder = [...initialMessages, ...nextMessages].map(({ id }) => id).join(', '); + const actualMessageOrder = stateAfter.replies.map(({ id }) => id).join(', '); + expect(actualMessageOrder).to.equal(expectedMessageOrder); }); - }); - describe('Thread.loadNextPage', () => {}); + it('prepends messages when loading previous page', async () => { + const initialMessages = [generateMsg(), generateMsg()] as MessageResponse[]; + const prevMessages = [generateMsg(), generateMsg()] as MessageResponse[]; + const thread = createTestThread({ latest_replies: initialMessages, reply_count: 4 }); + sinon.stub(thread, 'queryReplies').resolves({ messages: prevMessages, duration: '' }); + + await thread.loadPrevPage({ limit: 2 }); - describe('Thread.loadPreviousPage', () => {}); + const stateAfter = thread.state.getLatestValue(); + const expectedMessageOrder = [...prevMessages, ...initialMessages].map(({ id }) => id).join(', '); + const actualMessageOrder = stateAfter.replies.map(({ id }) => id).join(', '); + expect(actualMessageOrder).to.equal(expectedMessageOrder); + }); + }); }); - describe('Subscription Handlers', () => { - // let timers: sinon.SinonFakeTimers; + describe('Subscription and Event Handlers', () => { + it('marks active channel as read', () => { + const clock = sinon.useFakeTimers(); - beforeEach(() => { + const thread = createTestThread({ + read: [ + { + last_read: new Date().toISOString(), + user: { id: TEST_USER_ID }, + unread_messages: 42, + }, + ], + }); thread.registerSubscriptions(); - }); - - it('calls markAsRead whenever thread becomes active or own reply count increases', () => { - const timers = sinon.useFakeTimers({ toFake: ['setTimeout'] }); + const stateBefore = thread.state.getLatestValue(); const stubbedMarkAsRead = sinon.stub(thread, 'markAsRead').resolves(); - - thread.incrementOwnUnreadCount(); - - expect(thread.state.getLatestValue().active).to.be.false; - expect(thread.state.getLatestValue().read[TEST_USER_ID].unread_messages).to.equal(1); + expect(stateBefore.active).to.be.false; + expect(thread.ownUnreadCount).to.equal(42); expect(stubbedMarkAsRead.called).to.be.false; thread.activate(); + clock.runAll(); - expect(thread.state.getLatestValue().active).to.be.true; - expect(stubbedMarkAsRead.calledOnce, 'Called once').to.be.true; - - thread.incrementOwnUnreadCount(); + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.active).to.be.true; + expect(stubbedMarkAsRead.calledOnce).to.be.true; - timers.tick(DEFAULT_MARK_AS_READ_THROTTLE_DURATION + 1); + client.dispatchEvent({ + type: 'message.new', + message: generateMsg({ parent_id: thread.id, user: { id: 'bob' } }) as MessageResponse, + user: { id: 'bob' }, + }); + clock.runAll(); - expect(stubbedMarkAsRead.calledTwice, 'Called twice').to.be.true; + expect(stubbedMarkAsRead.calledTwice).to.be.true; - timers.restore(); + thread.unregisterSubscriptions(); + clock.restore(); }); - it('recovers from stale state whenever the thread becomes active (or is active and its state becomes stale)', async () => { - // prepare - const newThread = new Thread({ - client, - threadData: generateThread(channelResponse, generateMsg({ id: parentMessageResponse.id })), - }); - const stubbedGetThread = sinon.stub(client, 'getThread').resolves(newThread); - const partiallyReplaceStateSpy = sinon.spy(thread, 'partiallyReplaceState'); + it('reloads stale state when thread is active', async () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + + const stateBefore = thread.state.getLatestValue(); + const stubbedGetThread = sinon + .stub(client, 'getThread') + .resolves(createTestThread({ latest_replies: [generateMsg() as MessageResponse] })); thread.state.partialNext({ isStateStale: true }); - expect(thread.state.getLatestValue().isStateStale).to.be.true; + expect(thread.hasStaleState).to.be.true; expect(stubbedGetThread.called).to.be.false; - expect(partiallyReplaceStateSpy.called).to.be.false; thread.activate(); expect(stubbedGetThread.calledOnce).to.be.true; - await stubbedGetThread.firstCall.returnValue; + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).not.to.equal(stateBefore.replies); - expect(partiallyReplaceStateSpy.calledWith({ thread: newThread })).to.be.true; + thread.unregisterSubscriptions(); }); - describe('Event user.watching.stop', () => { + describe('Event: user.watching.stop', () => { it('ignores incoming event if the data do not match (channel or user.id)', () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + client.dispatchEvent({ type: 'user.watching.stop', - channel: channelResponse as ChannelResponse, + channel: channelResponse, user: { id: 'bob' }, }); - expect(thread.state.getLatestValue().isStateStale).to.be.false; + expect(thread.hasStaleState).to.be.false; client.dispatchEvent({ type: 'user.watching.stop', @@ -425,148 +533,274 @@ describe('Threads 2.0', () => { user: { id: TEST_USER_ID }, }); - expect(thread.state.getLatestValue().isStateStale).to.be.false; + expect(thread.hasStaleState).to.be.false; + + thread.unregisterSubscriptions(); }); it('marks own state as stale whenever current user stops watching associated channel', () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + client.dispatchEvent({ type: 'user.watching.stop', - channel: channelResponse as ChannelResponse, + channel: channelResponse, user: { id: TEST_USER_ID }, }); - expect(thread.state.getLatestValue().isStateStale).to.be.true; + expect(thread.hasStaleState).to.be.true; + + thread.unregisterSubscriptions(); }); }); - describe('Event message.read', () => { - it('prevents adjusting unread_messages & last_read if thread.id does not match', () => { - // prepare - thread.incrementOwnUnreadCount(); - expect(thread.state.getLatestValue().read[TEST_USER_ID].unread_messages).to.equal(1); + describe('Event: message.read', () => { + it('does not update read state with events from other threads', () => { + const thread = createTestThread({ + read: [ + { + last_read: new Date().toISOString(), + user: { id: 'bob' }, + unread_messages: 42, + }, + ], + }); + thread.registerSubscriptions(); + + const stateBefore = thread.state.getLatestValue(); + expect(stateBefore.read['bob']?.unreadMessageCount).to.equal(42); client.dispatchEvent({ type: 'message.read', - user: { id: TEST_USER_ID }, - thread: (generateThread(channelResponse, generateMsg()) as unknown) as ThreadResponse, + user: { id: 'bob' }, + thread: generateThread(channelResponse, generateMsg()) as ThreadResponse, }); - expect(thread.state.getLatestValue().read[TEST_USER_ID].unread_messages).to.equal(1); + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.read['bob']?.unreadMessageCount).to.equal(42); }); - [TEST_USER_ID, 'bob'].forEach((userId) => { - it(`correctly sets read information for user with id: ${userId}`, () => { - // prepare - const lastReadAt = new Date(); - thread.state.partialNext({ - read: { - [userId]: { - lastReadAt: lastReadAt, - last_read: lastReadAt.toISOString(), - last_read_message_id: '', - unread_messages: 1, - user: { id: userId }, - }, + it('correctly updates read information for user', () => { + const lastReadAt = new Date(); + const thread = createTestThread({ + read: [ + { + last_read: lastReadAt.toISOString(), + last_read_message_id: '', + unread_messages: 42, + user: { id: 'bob' }, }, - }); + ], + }); + thread.registerSubscriptions(); - expect(thread.state.getLatestValue().read[userId].unread_messages).to.equal(1); + const stateBefore = thread.state.getLatestValue(); + expect(stateBefore.read['bob']?.unreadMessageCount).to.equal(42); + const createdAt = new Date(); - const createdAt = new Date().toISOString(); + client.dispatchEvent({ + type: 'message.read', + user: { id: 'bob' }, + thread: generateThread(channelResponse, generateMsg({ id: parentMessageResponse.id })) as ThreadResponse, + created_at: createdAt.toISOString(), + }); - client.dispatchEvent({ - type: 'message.read', - user: { id: userId }, - thread: (generateThread( - channelResponse, - generateMsg({ id: parentMessageResponse.id }), - ) as unknown) as ThreadResponse, - created_at: createdAt, - }); + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.read['bob']?.unreadMessageCount).to.equal(0); + expect(stateAfter.read['bob']?.lastReadAt.toISOString()).to.equal(createdAt.toISOString()); - expect(thread.state.getLatestValue().read[userId].unread_messages).to.equal(0); - expect(thread.state.getLatestValue().read[userId].last_read).to.equal(createdAt); - }); + thread.unregisterSubscriptions(); }); }); - describe('Event message.new', () => { - it('prevents handling a reply if it does not belong to the associated thread', () => { - // prepare - const upsertReplyLocallySpy = sinon.spy(thread, 'upsertReplyLocally'); + describe('Event: message.new', () => { + it('ignores a reply if it does not belong to the associated thread', () => { + const thread = createTestThread(); + thread.registerSubscriptions(); + const stateBefore = thread.state.getLatestValue(); client.dispatchEvent({ type: 'message.new', message: generateMsg({ parent_id: uuidv4() }) as MessageResponse, + user: { id: TEST_USER_ID }, }); - expect(upsertReplyLocallySpy.called).to.be.false; + const stateAfter = thread.state.getLatestValue(); + expect(stateBefore).to.equal(stateAfter); + + thread.unregisterSubscriptions(); }); it('prevents handling a reply if the state of the thread is stale', () => { - // prepare - const upsertReplyLocallySpy = sinon.spy(thread, 'upsertReplyLocally'); - + const thread = createTestThread(); + thread.registerSubscriptions(); thread.state.partialNext({ isStateStale: true }); + const stateBefore = thread.state.getLatestValue(); + + client.dispatchEvent({ + type: 'message.new', + message: generateMsg({ parent_id: uuidv4() }) as MessageResponse, + user: { id: TEST_USER_ID }, + }); - client.dispatchEvent({ type: 'message.new', message: generateMsg({ id: thread.id }) as MessageResponse }); + const stateAfter = thread.state.getLatestValue(); + expect(stateBefore).to.equal(stateAfter); - expect(upsertReplyLocallySpy.called).to.be.false; + thread.unregisterSubscriptions(); }); - it('calls upsertLocalReply with proper values and calls incrementOwnUnreadCount if the reply does not belong to current user', () => { - // prepare - const upsertReplyLocallySpy = sinon.spy(thread, 'upsertReplyLocally'); - const incrementOwnUnreadCountSpy = sinon.spy(thread, 'incrementOwnUnreadCount'); + it('increments unread count if the reply does not belong to current user', () => { + const thread = createTestThread({ + read: [ + { + last_read: new Date().toISOString(), + user: { id: TEST_USER_ID }, + unread_messages: 0, + }, + ], + }); + thread.registerSubscriptions(); const newMessage = generateMsg({ parent_id: thread.id, user: { id: 'bob' } }) as MessageResponse; - client.dispatchEvent({ type: 'message.new', message: newMessage, + user: { id: 'bob' }, }); - expect(upsertReplyLocallySpy.calledWith({ message: newMessage, timestampChanged: false })).to.be.true; - expect(incrementOwnUnreadCountSpy.called).to.be.true; + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.have.length(1); + expect(stateAfter.replies.find((reply) => reply.id === newMessage.id)).not.to.be.undefined; + expect(thread.ownUnreadCount).to.equal(1); + + thread.unregisterSubscriptions(); }); - it('calls upsertLocalReply with timestampChanged true if the reply belongs to the current user', () => { - // prepare - const upsertReplyLocallySpy = sinon.spy(thread, 'upsertReplyLocally'); - const incrementOwnUnreadCountSpy = sinon.spy(thread, 'incrementOwnUnreadCount'); + it('handles receiving a reply that was previously optimistically added', () => { + const thread = createTestThread({ + latest_replies: [generateMsg() as MessageResponse], + read: [ + { + user: { id: TEST_USER_ID }, + last_read: new Date().toISOString(), + unread_messages: 0, + }, + ], + }); + const message = generateMsg({ + parent_id: thread.id, + user: { id: TEST_USER_ID }, + }) as MessageResponse; + thread.upsertReplyLocally({ message }); - const newMessage = generateMsg({ parent_id: thread.id, user: { id: TEST_USER_ID } }) as MessageResponse; + const stateBefore = thread.state.getLatestValue(); + expect(stateBefore.replies).to.have.length(2); + expect(thread.ownUnreadCount).to.equal(0); client.dispatchEvent({ type: 'message.new', - message: newMessage, + message, + user: { id: TEST_USER_ID }, }); - expect(upsertReplyLocallySpy.calledWith({ message: newMessage, timestampChanged: true })).to.be.true; - expect(incrementOwnUnreadCountSpy.called).to.be.false; + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.have.length(2); + expect(thread.ownUnreadCount).to.equal(0); }); + }); - // TODO: cover failed replies at some point + it('resets unread count when new message is by the current user', () => { + const thread = createTestThread({ + read: [ + { + last_read: new Date().toISOString(), + user: { id: TEST_USER_ID }, + unread_messages: 42, + }, + ], + }); + thread.registerSubscriptions(); + + expect(thread.ownUnreadCount).to.equal(42); + + client.dispatchEvent({ + type: 'message.new', + message: generateMsg({ + parent_id: thread.id, + user: { id: TEST_USER_ID }, + }) as MessageResponse, + user: { id: TEST_USER_ID }, + }); + + expect(thread.ownUnreadCount).to.equal(0); + + thread.unregisterSubscriptions(); + }); + + it('does not increment unread count in an active thread', () => { + const thread = createTestThread({ + read: [ + { + last_read: new Date().toISOString(), + user: { id: TEST_USER_ID }, + unread_messages: 0, + }, + ], + }); + thread.registerSubscriptions(); + thread.activate(); + + client.dispatchEvent({ + type: 'message.new', + message: generateMsg({ + parent_id: thread.id, + user: { id: 'bob' }, + }) as MessageResponse, + user: { id: 'bob' }, + }); + + expect(thread.ownUnreadCount).to.equal(0); + + thread.unregisterSubscriptions(); }); - describe('Events message.updated, message.deleted, reaction.new, reaction.deleted', () => { - it('calls deleteReplyLocally if the reply has been hard-deleted', () => { - const deleteReplyLocallySpy = sinon.spy(thread, 'deleteReplyLocally'); - const updateParentMessageOrReplyLocallySpy = sinon.spy(thread, 'updateParentMessageOrReplyLocally'); + describe('Event: message.deleted', () => { + it('deletes reply if the message was hard-deleted', () => { + const createdAt = new Date().getTime(); + // five messages "created" second apart + const messages = Array.from( + { length: 5 }, + (_, i) => + generateMsg({ + parent_id: parentMessageResponse.id, + created_at: new Date(createdAt + 1000 * i).toISOString(), + }) as MessageResponse, + ); + const thread = createTestThread({ latest_replies: messages }); + thread.registerSubscriptions(); + + const messageToDelete = messages[2]; client.dispatchEvent({ type: 'message.deleted', hard_delete: true, - message: generateMsg({ parent_id: thread.id }) as MessageResponse, + message: messageToDelete, }); - expect(deleteReplyLocallySpy.calledOnce).to.be.true; - expect(updateParentMessageOrReplyLocallySpy.called).to.be.false; + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.have.lengthOf(4); + expect(stateAfter.replies.find((reply) => reply.id === messageToDelete.id)).to.be.undefined; + + thread.unregisterSubscriptions(); }); + }); - (['message.updated', 'message.deleted', 'reaction.new', 'reaction.deleted'] as const).forEach((eventType) => { - it(`calls updateParentMessageOrReplyLocally on ${eventType}`, () => { + describe('Events: message.updated, reaction.new, reaction.deleted', () => { + (['message.updated', 'reaction.new', 'reaction.deleted'] as const).forEach((eventType) => { + it(`updates reply or parent message on "${eventType}"`, () => { + const thread = createTestThread(); const updateParentMessageOrReplyLocallySpy = sinon.spy(thread, 'updateParentMessageOrReplyLocally'); + thread.registerSubscriptions(); client.dispatchEvent({ type: eventType, @@ -574,6 +808,8 @@ describe('Threads 2.0', () => { }); expect(updateParentMessageOrReplyLocallySpy.calledOnce).to.be.true; + + thread.unregisterSubscriptions(); }); }); }); @@ -821,7 +1057,7 @@ describe('Threads 2.0', () => { const newThread = new Thread({ client, - threadData: generateThread(channelResponse, parentMessageResponse, { thread_participants: [{ id: 'u1' }] }), + threadData: generateThread(channelResponse, parentMessage, { thread_participants: [{ id: 'u1' }] }), }); expect(thread.state.getLatestValue().participants).to.have.lengthOf(0); @@ -856,7 +1092,7 @@ describe('Threads 2.0', () => { // same thread.id as prepared thread (just changed position in the response and different instance) new Thread({ client, - threadData: generateThread(channelResponse, parentMessageResponse, { + threadData: generateThread(channelResponse, parentMessage, { thread_participants: [{ id: 'u1' }], }), }), From ffbe784b313ae323b611723bfc44df2929b22b30 Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Thu, 29 Aug 2024 15:37:21 +0200 Subject: [PATCH 32/41] fix: fix most TODOs and tests for ThreadManager class (#1348) --- src/thread.ts | 4 +- src/thread_manager.ts | 421 +++++++++++++++++--------------------- test/unit/threads.test.ts | 418 ++++++++++++++++++------------------- 3 files changed, 386 insertions(+), 457 deletions(-) diff --git a/src/thread.ts b/src/thread.ts index 8b5ba2be2e..6c98e72a76 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -114,7 +114,7 @@ export class Thread { this.state.partialNext({ active: false }); }; - public loadState = async () => { + public reload = async () => { if (this.state.getLatestValue().isLoading) { return; } @@ -196,7 +196,7 @@ export class Thread { (nextValue) => [nextValue.active, nextValue.isStateStale], ([active, isStateStale]) => { if (active && isStateStale) { - this.loadState(); + this.reload(); } }, ); diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 8ac6e0342d..5eb9674ee2 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -1,5 +1,4 @@ import type { StreamChat } from './client'; -import type { Handler } from './store'; import { StateStore } from './store'; import type { Thread } from './thread'; import type { DefaultGenerics, Event, ExtendableGenerics, QueryThreadsOptions } from './types'; @@ -10,20 +9,25 @@ const MAX_QUERY_THREADS_LIMIT = 25; export type ThreadManagerState = { active: boolean; - existingReorderedThreadIds: string[]; - lastConnectionDownAt: Date | null; - loadingNextPage: boolean; - threadIdIndexMap: { [key: string]: number }; + isThreadOrderStale: boolean; + lastConnectionDropAt: Date | null; + pagination: ThreadManagerPagination; + ready: boolean; threads: Thread[]; + threadsById: Record | undefined>; unreadThreadsCount: number; + /** + * List of threads that haven't been loaded in the list, but have received new messages + * since the latest reload. Useful to display a banner prompting to reload the thread list. + */ unseenThreadIds: string[]; - nextCursor?: string | null; // null means no next page available - // TODO?: implement once supported by BE - // previousCursor?: string | null; - // loadingPreviousPage: boolean; }; -type WithRequired = T & { [P in K]-?: T[P] }; +export type ThreadManagerPagination = { + isLoading: boolean; + isLoadingNext: boolean; + nextCursor: string | null; +}; export class ThreadManager { public readonly state: StateStore>; @@ -34,17 +38,18 @@ export class ThreadManager { this.client = client; this.state = new StateStore>({ active: false, - // existing threads which have gotten recent activity during the time the manager was inactive - existingReorderedThreadIds: [], + isThreadOrderStale: false, threads: [], - threadIdIndexMap: {}, + threadsById: {}, unreadThreadsCount: 0, - // new threads or threads which have not been loaded and is not possible to paginate to anymore - // as these threads received new replies which moved them up in the list - used for the badge unseenThreadIds: [], - lastConnectionDownAt: null, - loadingNextPage: false, - nextCursor: undefined, + lastConnectionDropAt: null, + pagination: { + isLoading: false, + isLoadingNext: false, + nextCursor: null, + }, + ready: false, }); } @@ -56,279 +61,227 @@ export class ThreadManager { this.state.partialNext({ active: false }); }; - // eslint-disable-next-line sonarjs/cognitive-complexity public registerSubscriptions = () => { if (this.unsubscribeFunctions.size) return; - const handleUnreadThreadsCountChange = (event: Event) => { - const { unread_threads: unreadThreadsCount } = event.me ?? event; - - if (typeof unreadThreadsCount === 'undefined') return; - - this.state.partialNext({ unreadThreadsCount }); - }; + this.unsubscribeFunctions.add(this.subscribeUnreadThreadsCountChange()); + this.unsubscribeFunctions.add(this.subscribeManageThreadSubscriptions()); + this.unsubscribeFunctions.add(this.subscribeReloadOnActivation()); + this.unsubscribeFunctions.add(this.subscribeNewReplies()); + this.unsubscribeFunctions.add(this.subscribeRecoverAfterConnectionDrop()); + }; - [ + private subscribeUnreadThreadsCountChange = () => { + const unsubscribeFunctions = [ 'health.check', 'notification.mark_read', 'notification.thread_message_new', 'notification.channel_deleted', - ].forEach((eventType) => - this.unsubscribeFunctions.add(this.client.on(eventType, handleUnreadThreadsCountChange).unsubscribe), + ].map( + (eventType) => + this.client.on(eventType, (event) => { + const { unread_threads: unreadThreadsCount } = event.me ?? event; + if (typeof unreadThreadsCount === 'number') { + this.state.partialNext({ unreadThreadsCount }); + } + }).unsubscribe, ); - // TODO: return to previous recovery option as state merging is now in place - const throttledHandleConnectionRecovery = throttle( - async () => { - const { lastConnectionDownAt, threads } = this.state.getLatestValue(); - - if (!lastConnectionDownAt) return; - - const channelCids = new Set(); - for (const thread of threads) { - if (!thread.channel) continue; - - channelCids.add(thread.channel.cid); - } - - if (!channelCids.size) return; + return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); + }; - try { - // FIXME: syncing does not work for me - await this.client.sync(Array.from(channelCids), lastConnectionDownAt.toISOString(), { watch: true }); - this.state.partialNext({ lastConnectionDownAt: null }); - } catch (error) { - // TODO: if error mentions that the amount of events is more than 2k - // do a reload-type recovery (re-query threads and merge states) + private subscribeManageThreadSubscriptions = () => + this.state.subscribeWithSelector( + (nextValue) => [nextValue.threads], + ([nextThreads], prev = [[]]) => { + const [prevThreads] = prev; + const removedThreads = prevThreads.filter((thread) => !nextThreads.includes(thread)); - console.warn(error); - } + nextThreads.forEach((thread) => thread.registerSubscriptions()); + removedThreads.forEach((thread) => thread.unregisterSubscriptions()); }, - DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, - { - leading: true, - trailing: true, - }, - ); - - this.unsubscribeFunctions.add( - this.client.on('connection.recovered', throttledHandleConnectionRecovery).unsubscribe, ); - this.unsubscribeFunctions.add( - this.client.on('connection.changed', (event) => { - if (typeof event.online === 'undefined') return; - - const { lastConnectionDownAt } = this.state.getLatestValue(); - - if (!event.online && !lastConnectionDownAt) { - this.state.partialNext({ lastConnectionDownAt: new Date() }); - } - }).unsubscribe, + private subscribeReloadOnActivation = () => + this.state.subscribeWithSelector( + (nextValue) => [nextValue.active], + ([active]) => { + if (active) this.reload(); + }, ); - this.unsubscribeFunctions.add( - this.state.subscribeWithSelector( - (nextValue) => [nextValue.active], - ([active]) => { - if (!active) return; + private subscribeNewReplies = () => + this.client.on('notification.thread_message_new', (event: Event) => { + const parentId = event.message?.parent_id; + if (!parentId) return; - // automatically clear all the changes that happened "behind the scenes" - this.reload(); - }, - ), - ); + const { threadsById, unseenThreadIds, ready } = this.state.getLatestValue(); + if (!ready) return; - const handleThreadsChange: Handler[]]> = ([newThreads], previouslySelectedValue) => { - // create new threadIdIndexMap - const newThreadIdIndexMap = newThreads.reduce((map, thread, index) => { - map[thread.id] ??= index; - return map; - }, {}); - - // handle individual thread subscriptions - if (previouslySelectedValue) { - const [previousThreads] = previouslySelectedValue; - previousThreads.forEach((t) => { - // thread with registered handlers has been removed or its signature changed (new instance) - // deregister and let gc do its thing - if (typeof newThreadIdIndexMap[t.id] === 'undefined' || newThreads[newThreadIdIndexMap[t.id]] !== t) { - t.unregisterSubscriptions(); - } - }); + if (threadsById[parentId]) { + this.state.partialNext({ isThreadOrderStale: true }); + } else if (!unseenThreadIds.includes(parentId)) { + this.state.partialNext({ unseenThreadIds: unseenThreadIds.concat(parentId) }); } - newThreads.forEach((t) => t.registerSubscriptions()); - - // publish new threadIdIndexMap - this.state.next((current) => ({ ...current, threadIdIndexMap: newThreadIdIndexMap })); - }; + }).unsubscribe; + + private subscribeRecoverAfterConnectionDrop = () => { + const unsubscribeConnectionDropped = this.client.on('connection.changed', (event) => { + if (event.online === false) { + this.state.next((current) => + current.lastConnectionDropAt + ? current + : { + ...current, + lastConnectionDropAt: new Date(), + }, + ); + } + }).unsubscribe; - this.unsubscribeFunctions.add( - // re-generate map each time the threads array changes - this.state.subscribeWithSelector((nextValue) => [nextValue.threads] as const, handleThreadsChange), + const throttledHandleConnectionRecovered = throttle( + () => { + const { lastConnectionDropAt } = this.state.getLatestValue(); + if (!lastConnectionDropAt) return; + this.reload({ force: true }); + }, + DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, + { trailing: true }, ); - // TODO: handle parent message hard-deleted (extend state with \w hardDeletedThreadIds?) - - const handleNewReply = (event: Event) => { - if (!event.message || !event.message.parent_id) return; - const parentId = event.message.parent_id; - - const { - threadIdIndexMap, - nextCursor, - threads, - unseenThreadIds, - existingReorderedThreadIds, - active, - } = this.state.getLatestValue(); - - // prevents from handling replies until the threads have been loaded - // (does not fill information for "unread threads" banner to appear) - if (!threads.length && nextCursor !== null) return; - - const existsLocally = typeof threadIdIndexMap[parentId] !== 'undefined'; - - // only register these changes during the time the thread manager is inactive - if (existsLocally && !existingReorderedThreadIds.includes(parentId) && !active) { - return this.state.next((current) => ({ - ...current, - existingReorderedThreadIds: current.existingReorderedThreadIds.concat(parentId), - })); - } + const unsubscribeConnectionRecovered = this.client.on('connection.recovered', throttledHandleConnectionRecovered) + .unsubscribe; - if (!existsLocally && !unseenThreadIds.includes(parentId)) { - return this.state.next((current) => ({ - ...current, - unseenThreadIds: current.unseenThreadIds.concat(parentId), - })); - } + return () => { + unsubscribeConnectionDropped(); + unsubscribeConnectionRecovered(); }; - - this.unsubscribeFunctions.add(this.client.on('notification.thread_message_new', handleNewReply).unsubscribe); }; - public deregisterSubscriptions = () => { - // TODO: think about state reset or at least invalidation + public unregisterSubscriptions = () => { + this.state.getLatestValue().threads.forEach((thread) => thread.unregisterSubscriptions()); this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); + this.unsubscribeFunctions.clear(); }; - public reload = async () => { - const { threads, unseenThreadIds, existingReorderedThreadIds } = this.state.getLatestValue(); - - if (!unseenThreadIds.length && !existingReorderedThreadIds.length) return; - - const combinedLimit = threads.length + unseenThreadIds.length; + public reload = async ({ force = false } = {}) => { + const { threads, unseenThreadIds, isThreadOrderStale, pagination, ready } = this.state.getLatestValue(); + if (pagination.isLoading) return; + if (!force && ready && !unseenThreadIds.length && !isThreadOrderStale) return; + const limit = threads.length + unseenThreadIds.length; try { - const data = await this.queryThreads({ - limit: combinedLimit <= MAX_QUERY_THREADS_LIMIT ? combinedLimit : MAX_QUERY_THREADS_LIMIT, - }); - - const { threads, threadIdIndexMap } = this.state.getLatestValue(); - - const newThreads: Thread[] = []; - // const existingThreadIdsToFilterOut: string[] = []; + this.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoading: true, + }, + })); - for (const thread of data.threads) { - const existingThread: Thread | undefined = threads[threadIdIndexMap[thread.id]]; + const response = await this.queryThreads({ limit: Math.min(limit, MAX_QUERY_THREADS_LIMIT) }); + const { threadsById: currentThreads } = this.state.getLatestValue(); + const nextThreads: Thread[] = []; - newThreads.push(existingThread ?? thread); + for (const incomingThread of response.threads) { + const existingThread = currentThreads[incomingThread.id]; - // replace state of threads which report stale state - // *(state can be considered as stale when channel associated with the thread stops being watched) - if (existingThread && existingThread.hasStaleState) { - existingThread.hydrateState(thread); + if (existingThread) { + // Reuse thread instances if possible + nextThreads.push(existingThread); + if (existingThread.hasStaleState) { + existingThread.hydrateState(incomingThread); + } + } else { + nextThreads.push(incomingThread); } - - // if (existingThread) existingThreadIdsToFilterOut.push(existingThread.id); } - // TODO: use some form of a "cache" for unused threads - // to reach for upon next pagination or re-query - // keep them subscribed and "running" behind the scenes but - // not in the list for multitude of reasons (clean cache on last pagination which returns empty array - nothing to pair cached threads to) - // (this.loadedThreadIdMap) - // const existingFilteredThreads = threads.filter(({ id }) => !existingThreadIdsToFilterOut.includes(id)); - this.state.next((current) => ({ ...current, - unseenThreadIds: [], // reset - existingReorderedThreadIds: [], // reset - // TODO: extract merging logic and allow loadNextPage to merge as well (in combination with the cache thing) - threads: newThreads, //.concat(existingFilteredThreads), - nextCursor: data.next ?? null, // re-adjust next cursor + ...prepareThreadsUpdate(current, nextThreads), + unseenThreadIds: [], + isThreadOrderStale: false, + pagination: { + ...current.pagination, + isLoading: false, + nextCursor: response.next ?? null, + }, + ready: true, })); } catch (error) { - // TODO: loading states - console.error(error); - } finally { - // ... + this.client.logger('error', (error as Error).message); + this.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoading: false, + }, + })); } }; - public queryThreads = async ({ - limit = 25, - participant_limit = 10, - reply_limit = 10, - watch = true, - ...restOfTheOptions - }: QueryThreadsOptions = {}) => { - const optionsWithDefaults: WithRequired< - QueryThreadsOptions, - 'reply_limit' | 'limit' | 'participant_limit' | 'watch' - > = { - limit, - participant_limit, - reply_limit, - watch, - ...restOfTheOptions, - }; - - const { threads, next } = await this.client.queryThreads(optionsWithDefaults); - - // FIXME: currently this is done within threads based on reply_count property - // but that does not take into consideration sorting (only oldest -> newest) - // re-enable functionality bellow, and take into consideration sorting - - // re-adjust next/previous cursors based on query options - // data.threads.forEach((thread) => { - // thread.state.next((current) => ({ - // ...current, - // nextCursor: current.latestReplies.length < optionsWithDefaults.reply_limit ? null : current.nextCursor, - // previousCursor: - // current.latestReplies.length < optionsWithDefaults.reply_limit ? null : current.previousCursor, - // })); - // }); - - return { threads, next }; + public queryThreads = (options: QueryThreadsOptions = {}) => { + return this.client.queryThreads({ + limit: 25, + participant_limit: 10, + reply_limit: 10, + watch: true, + ...options, + }); }; - // remove `next` from options as that is handled internally public loadNextPage = async (options: Omit = {}) => { - const { nextCursor, loadingNextPage } = this.state.getLatestValue(); - - if (nextCursor === null || loadingNextPage) return; + const { pagination } = this.state.getLatestValue(); - const optionsWithNextCursor: QueryThreadsOptions = { - ...options, - next: nextCursor, - }; - - this.state.next((current) => ({ ...current, loadingNextPage: true })); + if (pagination.isLoadingNext || !pagination.nextCursor) return; try { - const data = await this.queryThreads(optionsWithNextCursor); + this.state.partialNext({ pagination: { ...pagination, isLoadingNext: true } }); + + const response = await this.queryThreads({ + ...options, + next: pagination.nextCursor, + }); this.state.next((current) => ({ ...current, - threads: data.threads.length ? current.threads.concat(data.threads) : current.threads, - nextCursor: data.next ?? null, + ...prepareThreadsUpdate( + current, + response.threads.length ? current.threads.concat(response.threads) : current.threads, + ), + pagination: { + ...current.pagination, + nextCursor: response.next ?? null, + isLoadingNext: false, + }, })); } catch (error) { this.client.logger('error', (error as Error).message); - } finally { - this.state.next((current) => ({ ...current, loadingNextPage: false })); + this.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoadingNext: false, + }, + })); } }; } + +function prepareThreadsUpdate( + state: ThreadManagerState, + threads: Thread[], +): Partial> { + if (threads === state.threads) { + return {}; + } + + return { + threads, + threadsById: threads.reduce>>((newThreadsById, thread) => { + newThreadsById[thread.id] = thread; + return newThreadsById; + }, {}), + }; +} diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 3790ffa2d8..a12b4fb33b 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -52,7 +52,7 @@ describe('Threads 2.0', () => { threadManager = new ThreadManager({ client }); }); - describe.only('Thread', () => { + describe('Thread', () => { it('initializes properly', () => { const thread = new Thread({ client, threadData: generateThread(channelResponse, parentMessageResponse) }); expect(thread.id).to.equal(parentMessageResponse.id); @@ -817,37 +817,44 @@ describe('Threads 2.0', () => { }); describe('ThreadManager', () => { - // describe('Initial State', () => { - // // check initial state - // }); + it('initializes properly', () => { + const state = threadManager.state.getLatestValue(); + expect(state.threads).to.be.empty; + expect(state.unseenThreadIds).to.be.empty; + expect(state.pagination.isLoading).to.be.false; + expect(state.pagination.nextCursor).to.be.null; + }); - describe('Subscription Handlers', () => { + describe('Subscription and Event Handlers', () => { beforeEach(() => { threadManager.registerSubscriptions(); }); + afterEach(() => { + threadManager.unregisterSubscriptions(); + sinon.restore(); + }); + ([ ['health.check', 2], ['notification.mark_read', 1], ['notification.thread_message_new', 8], ['notification.channel_deleted', 11], - ] as const).forEach(([eventType, unreadCount]) => { - it(`unreadThreadsCount changes on ${eventType}`, () => { - client.dispatchEvent({ received_at: new Date().toISOString(), type: eventType, unread_threads: unreadCount }); + ] as const).forEach(([eventType, expectedUnreadCount]) => { + it(`updates unread thread count on "${eventType}"`, () => { + client.dispatchEvent({ + type: eventType, + unread_threads: expectedUnreadCount, + }); const { unreadThreadsCount } = threadManager.state.getLatestValue(); - - expect(unreadThreadsCount).to.equal(unreadCount); + expect(unreadThreadsCount).to.equal(expectedUnreadCount); }); }); - describe('Event notification.thread_message_new', () => { - it('does not fill the unseenThreadIds array if threads have not been loaded yet', () => { - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - expect(threadManager.state.getLatestValue().nextCursor).to.be.undefined; - + describe('Event: notification.thread_message_new', () => { + it('ignores notification.thread_message_new before anything was loaded', () => { client.dispatchEvent({ - received_at: new Date().toISOString(), type: 'notification.thread_message_new', message: generateMsg({ parent_id: uuidv4() }) as MessageResponse, }); @@ -855,29 +862,19 @@ describe('Threads 2.0', () => { expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; }); - it('adds parentMessageId to the unseenThreadIds array', () => { - // artificial first page load - threadManager.state.partialNext({ nextCursor: null }); - - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - - const parentMessageId = uuidv4(); + it('tracks new unseen threads', () => { + threadManager.state.partialNext({ ready: true }); client.dispatchEvent({ - received_at: new Date().toISOString(), type: 'notification.thread_message_new', - message: generateMsg({ parent_id: parentMessageId }) as MessageResponse, + message: generateMsg({ parent_id: uuidv4() }) as MessageResponse, }); expect(threadManager.state.getLatestValue().unseenThreadIds).to.have.lengthOf(1); }); - it('skips duplicate parentMessageIds in unseenThreadIds array', () => { - // artificial first page load - threadManager.state.partialNext({ nextCursor: null }); - - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - + it('deduplicates unseen threads', () => { + threadManager.state.partialNext({ ready: true }); const parentMessageId = uuidv4(); client.dispatchEvent({ @@ -895,33 +892,17 @@ describe('Threads 2.0', () => { expect(threadManager.state.getLatestValue().unseenThreadIds).to.have.lengthOf(1); }); - it('adds parentMessageId to the existingReorderedThreadIds if such thread is already loaded within threads array', () => { - // artificial first page load - threadManager.state.partialNext({ threads: [thread] }); - - expect(threadManager.state.getLatestValue().existingReorderedThreadIds).to.be.empty; - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - expect(threadManager.state.getLatestValue().active).to.be.false; - - client.dispatchEvent({ - received_at: new Date().toISOString(), - type: 'notification.thread_message_new', - message: generateMsg({ parent_id: thread.id }) as MessageResponse, + it('tracks thread order becoming stale', () => { + const thread = createTestThread(); + threadManager.state.partialNext({ + threads: [thread], + threadsById: { [thread.id]: thread }, + ready: true, }); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - expect(threadManager.state.getLatestValue().existingReorderedThreadIds).to.have.lengthOf(1); - expect(threadManager.state.getLatestValue().existingReorderedThreadIds[0]).to.equal(thread.id); - }); - - it('skips parentMessageId addition to the existingReorderedThreadIds if the ThreadManager is inactive', () => { - // artificial first page load - threadManager.state.partialNext({ threads: [thread] }); - threadManager.activate(); - - expect(threadManager.state.getLatestValue().existingReorderedThreadIds).to.be.empty; - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - expect(threadManager.state.getLatestValue().active).to.be.true; + const stateBefore = threadManager.state.getLatestValue(); + expect(stateBefore.isThreadOrderStale).to.be.false; + expect(stateBefore.unseenThreadIds).to.be.empty; client.dispatchEvent({ received_at: new Date().toISOString(), @@ -929,51 +910,71 @@ describe('Threads 2.0', () => { message: generateMsg({ parent_id: thread.id }) as MessageResponse, }); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - expect(threadManager.state.getLatestValue().existingReorderedThreadIds).to.be.empty; + const stateAfter = threadManager.state.getLatestValue(); + expect(stateAfter.isThreadOrderStale).to.be.true; + expect(stateAfter.unseenThreadIds).to.be.empty; }); }); - it('recovers from connection down', () => { + it('reloads after connection drop', () => { + const thread = createTestThread(); threadManager.state.partialNext({ threads: [thread] }); + threadManager.registerSubscriptions(); + const stub = sinon.stub(client, 'queryThreads').resolves({ + threads: [], + next: undefined, + }); + const clock = sinon.useFakeTimers(); client.dispatchEvent({ - received_at: new Date().toISOString(), type: 'connection.changed', online: false, }); - const { lastConnectionDownAt } = threadManager.state.getLatestValue(); + const { lastConnectionDropAt } = threadManager.state.getLatestValue(); + expect(lastConnectionDropAt).to.be.a('date'); - expect(lastConnectionDownAt).to.be.a('date'); - - // mock client.sync - const stub = sinon.stub(client, 'sync').resolves(); - - client.dispatchEvent({ - received_at: new Date().toISOString(), - type: 'connection.recovered', - }); + client.dispatchEvent({ type: 'connection.recovered' }); + clock.runAll(); - expect(stub.calledWith([thread.channel!.cid], lastConnectionDownAt?.toISOString())).to.be.true; + expect(stub.calledOnce).to.be.true; - // TODO: simulate .sync fail, check re-query called + threadManager.unregisterSubscriptions(); + clock.restore(); }); - it('always calls reload on ThreadManager activation', () => { + it('reloads list on activation', () => { const stub = sinon.stub(threadManager, 'reload').resolves(); - threadManager.activate(); - expect(stub.called).to.be.true; }); - it('should generate a new threadIdIndexMap on threads array change', () => { - expect(threadManager.state.getLatestValue().threadIdIndexMap).to.deep.equal({}); + it('manages subscriptions when threads are added to and removed from the list', () => { + const createTestThreadAndSpySubscriptions = () => { + const thread = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + const registerSubscriptionsSpy = sinon.spy(thread, 'registerSubscriptions'); + const unregisterSubscriptionsSpy = sinon.spy(thread, 'unregisterSubscriptions'); + return [thread, registerSubscriptionsSpy, unregisterSubscriptionsSpy] as const; + }; + const [thread1, registerThread1, unregisterThread1] = createTestThreadAndSpySubscriptions(); + const [thread2, registerThread2, unregisterThread2] = createTestThreadAndSpySubscriptions(); + const [thread3, registerThread3, unregisterThread3] = createTestThreadAndSpySubscriptions(); - threadManager.state.partialNext({ threads: [thread] }); + threadManager.state.partialNext({ threads: [thread1, thread2] }); + + expect(registerThread1.calledOnce).to.be.true; + expect(registerThread2.calledOnce).to.be.true; + + threadManager.state.partialNext({ threads: [thread2, thread3] }); + + expect(unregisterThread1.calledOnce).to.be.true; + expect(registerThread3.calledOnce).to.be.true; + + threadManager.unregisterSubscriptions(); - expect(threadManager.state.getLatestValue().threadIdIndexMap).to.deep.equal({ [thread.id]: 0 }); + expect(unregisterThread1.calledOnce).to.be.true; + expect(unregisterThread2.calledOnce).to.be.true; + expect(unregisterThread3.calledOnce).to.be.true; }); }); @@ -988,53 +989,47 @@ describe('Threads 2.0', () => { threads: [], next: undefined, }); - - threadManager.registerSubscriptions(); }); - describe('ThreadManager.reload', () => { - it('skips reload if both unseenThreadIds and existingReorderedThreadIds arrays are empty', async () => { - const { unseenThreadIds, existingReorderedThreadIds } = threadManager.state.getLatestValue(); + describe('reload', () => { + it('skips reload if there were no updates since the latest reload', async () => { + threadManager.state.partialNext({ ready: true }); + await threadManager.reload(); + expect(stubbedQueryThreads.notCalled).to.be.true; + }); - expect(unseenThreadIds).to.be.empty; - expect(existingReorderedThreadIds).to.be.empty; + it('reloads if thread list order is stale', async () => { + threadManager.state.partialNext({ isThreadOrderStale: true }); await threadManager.reload(); - expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; - expect(threadManager.state.getLatestValue().existingReorderedThreadIds).to.be.empty; - expect(stubbedQueryThreads.notCalled).to.be.true; + expect(threadManager.state.getLatestValue().isThreadOrderStale).to.be.false; + expect(stubbedQueryThreads.calledOnce).to.be.true; }); - (['existingReorderedThreadIds', 'unseenThreadIds'] as const).forEach((bucketName) => { - it(`doesn't skip reload if ${bucketName} is not empty`, async () => { - threadManager.state.partialNext({ [bucketName]: ['t1'] }); - - expect(threadManager.state.getLatestValue()[bucketName]).to.have.lengthOf(1); + it('reloads if there are new unseen threads', async () => { + threadManager.state.partialNext({ unseenThreadIds: [uuidv4()] }); - await threadManager.reload(); + await threadManager.reload(); - expect(threadManager.state.getLatestValue()[bucketName]).to.be.empty; - expect(stubbedQueryThreads.calledOnce).to.be.true; - }); + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(stubbedQueryThreads.calledOnce).to.be.true; }); - it('has been called with proper limits', async () => { - threadManager.state.next((current) => ({ - ...current, - threads: [thread], - unseenThreadIds: ['t1'], - existingReorderedThreadIds: ['t2'], - })); + it('picks correct limit when reloading', async () => { + threadManager.state.partialNext({ + threads: [createTestThread()], + unseenThreadIds: [uuidv4()], + }); await threadManager.reload(); expect(stubbedQueryThreads.calledWithMatch({ limit: 2 })).to.be.true; }); - it('adds new thread if it does not exist within the threads array', async () => { - threadManager.state.partialNext({ unseenThreadIds: ['t1'] }); - + it('adds new thread instances to the list', async () => { + const thread = createTestThread(); + threadManager.state.partialNext({ unseenThreadIds: [thread.id] }); stubbedQueryThreads.resolves({ threads: [thread], next: undefined, @@ -1042,28 +1037,40 @@ describe('Threads 2.0', () => { await threadManager.reload(); - const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + const { threads, unseenThreadIds } = threadManager.state.getLatestValue(); - expect(nextCursor).to.be.null; expect(threads).to.contain(thread); expect(unseenThreadIds).to.be.empty; }); - // TODO: test merge but instance is the same! - it('replaces state of the existing thread which reports stale state within the threads array', async () => { - // prepare - threadManager.state.partialNext({ threads: [thread], unseenThreadIds: ['t1'] }); - thread.state.partialNext({ isStateStale: true }); - - const newThread = new Thread({ - client, - threadData: generateThread(channelResponse, parentMessage, { thread_participants: [{ id: 'u1' }] }), + it('reuses existing thread instances', async () => { + const existingThread = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + const newThread = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + threadManager.state.partialNext({ threads: [existingThread], unseenThreadIds: [newThread.id] }); + stubbedQueryThreads.resolves({ + threads: [newThread, existingThread], + next: undefined, }); - expect(thread.state.getLatestValue().participants).to.have.lengthOf(0); - expect(newThread.id).to.equal(thread.id); - expect(newThread).to.not.equal(thread); + await threadManager.reload(); + + const { threads } = threadManager.state.getLatestValue(); + + expect(threads[0]).to.equal(newThread); + expect(threads[1]).to.equal(existingThread); + }); + it('hydrates existing stale threads when reloading', async () => { + const existingThread = createTestThread(); + existingThread.state.partialNext({ isStateStale: true }); + const newThread = createTestThread({ + thread_participants: [{ user_id: 'u1' }] as ThreadResponse['thread_participants'], + }); + threadManager.state.partialNext({ + threads: [existingThread], + threadsById: { [existingThread.id]: existingThread }, + unseenThreadIds: [newThread.id], + }); stubbedQueryThreads.resolves({ threads: [newThread], next: undefined, @@ -1071,61 +1078,43 @@ describe('Threads 2.0', () => { await threadManager.reload(); - const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + const { threads } = threadManager.state.getLatestValue(); - expect(nextCursor).to.be.null; expect(threads).to.have.lengthOf(1); - expect(threads).to.contain(thread); - expect(unseenThreadIds).to.be.empty; - expect(thread.state.getLatestValue().participants).to.have.lengthOf(1); + expect(threads).to.contain(existingThread); + expect(existingThread.state.getLatestValue().participants).to.have.lengthOf(1); }); - it('new state reflects order of the threads coming from the response', async () => { - // prepare - threadManager.state.next((current) => ({ ...current, threads: [thread], unseenThreadIds: ['t1'] })); - - const newThreads = [ - new Thread({ - client, - threadData: generateThread(channelResponse, generateMsg()), - }), - // same thread.id as prepared thread (just changed position in the response and different instance) - new Thread({ - client, - threadData: generateThread(channelResponse, parentMessage, { - thread_participants: [{ id: 'u1' }], - }), - }), - new Thread({ - client, - threadData: generateThread(channelResponse, generateMsg()), - }), - ]; - - expect(newThreads[1].id).to.equal(thread.id); - expect(newThreads[1]).to.not.equal(thread); - + it('reorders threads according to the response order', async () => { + const existingThread = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + const newThread1 = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + const newThread2 = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + threadManager.state.partialNext({ + threads: [existingThread], + unseenThreadIds: [newThread1.id, newThread2.id], + }); stubbedQueryThreads.resolves({ - threads: newThreads, + threads: [newThread1, existingThread, newThread2], next: undefined, }); await threadManager.reload(); - const { threads, nextCursor, unseenThreadIds } = threadManager.state.getLatestValue(); + const { threads } = threadManager.state.getLatestValue(); - expect(nextCursor).to.be.null; - expect(threads).to.have.lengthOf(3); - expect(threads[1]).to.equal(thread); - expect(unseenThreadIds).to.be.empty; + expect(threads[1]).to.equal(existingThread); }); }); - describe('ThreadManager.loadNextPage', () => { - it("prevents loading next page if there's no next page to load", async () => { - expect(threadManager.state.getLatestValue().nextCursor).is.undefined; - - threadManager.state.partialNext({ nextCursor: null }); + describe('loadNextPage', () => { + it('does nothing if there is no next page to load', async () => { + threadManager.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: null, + }, + })); await threadManager.loadNextPage(); @@ -1133,16 +1122,28 @@ describe('Threads 2.0', () => { }); it('prevents loading next page if already loading', async () => { - expect(threadManager.state.getLatestValue().loadingNextPage).is.false; - - threadManager.state.partialNext({ loadingNextPage: true }); + threadManager.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoadingNext: true, + nextCursor: 'cursor', + }, + })); await threadManager.loadNextPage(); expect(stubbedQueryThreads.called).to.be.false; }); - it('calls queryThreads with proper defaults', async () => { + it('forms correct request when loading next page', async () => { + threadManager.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: 'cursor', + }, + })); stubbedQueryThreads.resolves({ threads: [], next: undefined, @@ -1151,14 +1152,26 @@ describe('Threads 2.0', () => { await threadManager.loadNextPage(); expect( - stubbedQueryThreads.calledWithMatch({ limit: 25, participant_limit: 10, reply_limit: 10, watch: true }), + stubbedQueryThreads.calledWithMatch({ + limit: 25, + participant_limit: 10, + reply_limit: 10, + next: 'cursor', + watch: true, + }), ).to.be.true; }); it('switches loading state properly', async () => { + threadManager.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: 'cursor', + }, + })); const spy = sinon.spy(); - - threadManager.state.subscribeWithSelector((nextValue) => [nextValue.loadingNextPage], spy); + threadManager.state.subscribeWithSelector((nextValue) => [nextValue.pagination.isLoadingNext], spy); spy.resetHistory(); await threadManager.loadNextPage(); @@ -1168,66 +1181,29 @@ describe('Threads 2.0', () => { expect(spy.lastCall.calledWith([false])).to.be.true; }); - it('sets proper nextCursor and threads', async () => { - threadManager.state.partialNext({ threads: [thread] }); - - const newThread = new Thread({ - client, - threadData: generateThread(channelResponse, generateMsg()), - }); - + it('updates thread list and pagination', async () => { + const existingThread = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + const newThread = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + threadManager.state.next((current) => ({ + ...current, + threads: [existingThread], + pagination: { + ...current.pagination, + nextCursor: 'cursor1', + }, + })); stubbedQueryThreads.resolves({ threads: [newThread], - next: undefined, + next: 'cursor2', }); await threadManager.loadNextPage(); - const { threads, nextCursor } = threadManager.state.getLatestValue(); + const { threads, pagination } = threadManager.state.getLatestValue(); expect(threads).to.have.lengthOf(2); expect(threads[1]).to.equal(newThread); - expect(nextCursor).to.be.null; - }); - - it('is called with proper nextCursor and sets new nextCursor', async () => { - const cursor1 = uuidv4(); - const cursor2 = uuidv4(); - - threadManager.state.partialNext({ nextCursor: cursor1 }); - - stubbedQueryThreads.resolves({ - threads: [], - next: cursor2, - }); - - await threadManager.loadNextPage(); - - const { nextCursor } = threadManager.state.getLatestValue(); - - expect(stubbedQueryThreads.calledWithMatch({ next: cursor1 })).to.be.true; - expect(nextCursor).to.equal(cursor2); - }); - - // FIXME: skipped as it's not needed until queryThreads supports reply sorting (asc/desc) - it.skip('adjusts nextCursor & previousCusor properties of the queried threads according to query options', () => { - const REPLY_COUNT = 3; - - const newThread = new Thread({ - client, - threadData: generateThread(channelResponse, generateMsg(), { - latest_replies: Array.from({ length: REPLY_COUNT }, () => generateMsg()), - reply_count: REPLY_COUNT, - }), - }); - - expect(newThread.state.getLatestValue().latestReplies).to.have.lengthOf(REPLY_COUNT); - expect(newThread.state.getLatestValue().replyCount).to.equal(REPLY_COUNT); - - stubbedQueryThreads.resolves({ - threads: [newThread], - next: undefined, - }); + expect(pagination.nextCursor).to.equal('cursor2'); }); }); }); From ac3730fbda04a1f3af90a24c25f568e56ca36ec2 Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Thu, 29 Aug 2024 16:12:47 +0200 Subject: [PATCH 33/41] fix: revert TS bump --- package.json | 2 +- src/client.ts | 2 +- src/connection.ts | 8 ++++---- src/connection_fallback.ts | 4 ++-- src/thread.ts | 8 +++++--- src/utils.ts | 1 + yarn.lock | 10 +++++----- 7 files changed, 19 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 27fe60ab1f..eae1c898f9 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "rollup-plugin-terser": "^7.0.2", "sinon": "^12.0.1", "standard-version": "^9.3.2", - "typescript": "^5.5.4", + "typescript": "4.2.3", "uuid": "^8.3.2" }, "scripts": { diff --git a/src/client.ts b/src/client.ts index 2caaad6656..5c611081db 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1440,7 +1440,7 @@ export class StreamChat { const nextRead: ThreadReadState = {}; for (const userId of Object.keys(read)) { - if (read[userId]) { - let nextUserRead: ThreadUserReadState = read[userId]; + const userRead = read[userId]; + + if (userRead) { + let nextUserRead: ThreadUserReadState = userRead; if (userId === event.user?.id) { // The user who just sent a message to the thread has no unread messages @@ -253,7 +255,7 @@ export class Thread { // Increment unread count for all users except the author of the new message nextUserRead = { ...nextUserRead, - unreadMessageCount: read[userId].unreadMessageCount + 1, + unreadMessageCount: userRead.unreadMessageCount + 1, }; } diff --git a/src/utils.ts b/src/utils.ts index 90e23b953d..c3bd41464e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -403,6 +403,7 @@ export function addToMessageList( needle: newMessage, sortedArray: messages, sortDirection: 'ascending', + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion selectValueToCompare: (m) => m[sortBy]!.getTime(), }); diff --git a/yarn.lock b/yarn.lock index d47db9665a..be99174413 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5914,16 +5914,16 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +typescript@4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.3.tgz#39062d8019912d43726298f09493d598048c1ce3" + integrity sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw== + typescript@^4.4.3: version "4.5.4" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.4.tgz#a17d3a0263bf5c8723b9c52f43c5084edf13c2e8" integrity sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg== -typescript@^5.5.4: - version "5.5.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" - integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== - uglify-js@^3.1.4: version "3.14.5" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.14.5.tgz#cdabb7d4954231d80cb4a927654c4655e51f4859" From ba25d982250cdc349b4ae4055e6854aa009de516 Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Thu, 29 Aug 2024 16:19:40 +0200 Subject: [PATCH 34/41] fix: revert some unrelated changes --- src/channel.ts | 5 +++-- src/channel_state.ts | 5 ++--- src/client.ts | 2 -- tsconfig.json | 5 ++--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/channel.ts b/src/channel.ts index b2ec05dada..90cba5ed0f 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -823,9 +823,10 @@ export class Channel; user_id?: string }} options Pagination params, ie {limit:10, id_lte: 10} diff --git a/src/channel_state.ts b/src/channel_state.ts index 7748f80a5a..a202d668ff 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -284,16 +284,15 @@ export class ChannelState, message?: MessageResponse, - enforceUnique?: boolean, + enforce_unique?: boolean, ) { if (!message) return; const messageWithReaction = message; this._updateMessage(message, (msg) => { - messageWithReaction.own_reactions = this._addOwnReactionToMessage(msg.own_reactions, reaction, enforceUnique); + messageWithReaction.own_reactions = this._addOwnReactionToMessage(msg.own_reactions, reaction, enforce_unique); return this.formatMessage(messageWithReaction); }); return messageWithReaction; diff --git a/src/client.ts b/src/client.ts index 5c611081db..1319ced23b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -409,8 +409,6 @@ export class StreamChat null; this.recoverStateOnReconnect = this.options.recoverStateOnReconnect; - - // reusing the same name the channel has (Channel.threads) this.threads = new ThreadManager({ client: this }); } diff --git a/tsconfig.json b/tsconfig.json index 09a18d765e..26dea59f5a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "baseUrl": "./src", "esModuleInterop": true, "moduleResolution": "node", - "lib": ["DOM", "ESNext"], + "lib": ["DOM", "ES6"], "noEmitOnError": false, "noImplicitAny": true, "preserveConstEnums": true, @@ -15,8 +15,7 @@ "declarationMap": true, "declarationDir": "./dist/types", "module": "commonjs", - "target": "ES5", - "skipLibCheck": true + "target": "ES5" }, "include": ["./src/**/*"] } From 656860af24a1e689fb0e1cff8405340f5da406cc Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Thu, 29 Aug 2024 16:53:24 +0200 Subject: [PATCH 35/41] fix: optimize subscribeManageThreadSubscriptions --- src/thread_manager.ts | 10 ++++++---- test/unit/threads.test.ts | 10 ++++++++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 5eb9674ee2..85aeff29d4 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -92,10 +92,12 @@ export class ThreadManager { private subscribeManageThreadSubscriptions = () => this.state.subscribeWithSelector( - (nextValue) => [nextValue.threads], - ([nextThreads], prev = [[]]) => { - const [prevThreads] = prev; - const removedThreads = prevThreads.filter((thread) => !nextThreads.includes(thread)); + (nextValue) => [nextValue.threads, nextValue.threadsById] as const, + ([nextThreads, nextThreadsById], prev) => { + const [prevThreads = []] = prev ?? []; + // Thread instance was removed if there's no thread with the given id at all, + // or it was replaced with a new instance + const removedThreads = prevThreads.filter((thread) => thread !== nextThreadsById[thread.id]); nextThreads.forEach((thread) => thread.registerSubscriptions()); removedThreads.forEach((thread) => thread.unregisterSubscriptions()); diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index a12b4fb33b..64fc483fae 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -960,12 +960,18 @@ describe('Threads 2.0', () => { const [thread2, registerThread2, unregisterThread2] = createTestThreadAndSpySubscriptions(); const [thread3, registerThread3, unregisterThread3] = createTestThreadAndSpySubscriptions(); - threadManager.state.partialNext({ threads: [thread1, thread2] }); + threadManager.state.partialNext({ + threads: [thread1, thread2], + threadsById: { [thread1.id]: thread1, [thread2.id]: thread2 }, + }); expect(registerThread1.calledOnce).to.be.true; expect(registerThread2.calledOnce).to.be.true; - threadManager.state.partialNext({ threads: [thread2, thread3] }); + threadManager.state.partialNext({ + threads: [thread2, thread3], + threadsById: { [thread2.id]: thread2, [thread3.id]: thread3 }, + }); expect(unregisterThread1.calledOnce).to.be.true; expect(registerThread3.calledOnce).to.be.true; From a6d69ef01d7a5a7503df5bdda2d7dcc18fbf4d68 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Thu, 29 Aug 2024 19:09:56 +0200 Subject: [PATCH 36/41] refactor: lazy lookup table --- src/thread_manager.ts | 63 ++++++++++++++++++++------------------- test/unit/threads.test.ts | 29 ++++++++++++++---- 2 files changed, 57 insertions(+), 35 deletions(-) diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 85aeff29d4..976d42c3ab 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -14,7 +14,6 @@ export type ThreadManagerState pagination: ThreadManagerPagination; ready: boolean; threads: Thread[]; - threadsById: Record | undefined>; unreadThreadsCount: number; /** * List of threads that haven't been loaded in the list, but have received new messages @@ -33,6 +32,10 @@ export class ThreadManager { public readonly state: StateStore>; private client: StreamChat; private unsubscribeFunctions: Set<() => void> = new Set(); + private threadsByIdGetterCache: { + threads: ThreadManagerState['threads']; + threadsById: Record | undefined>; + }; constructor({ client }: { client: StreamChat }) { this.client = client; @@ -40,7 +43,6 @@ export class ThreadManager { active: false, isThreadOrderStale: false, threads: [], - threadsById: {}, unreadThreadsCount: 0, unseenThreadIds: [], lastConnectionDropAt: null, @@ -51,6 +53,26 @@ export class ThreadManager { }, ready: false, }); + + this.threadsByIdGetterCache = { threads: [], threadsById: {} }; + } + + public get threadsById() { + const { threads } = this.state.getLatestValue(); + + if (threads === this.threadsByIdGetterCache.threads) { + return this.threadsByIdGetterCache.threadsById; + } + + const threadsById = threads.reduce>>((newThreadsById, thread) => { + newThreadsById[thread.id] = thread; + return newThreadsById; + }, {}); + + this.threadsByIdGetterCache.threads = threads; + this.threadsByIdGetterCache.threadsById = threadsById; + + return threadsById; } public activate = () => { @@ -92,12 +114,12 @@ export class ThreadManager { private subscribeManageThreadSubscriptions = () => this.state.subscribeWithSelector( - (nextValue) => [nextValue.threads, nextValue.threadsById] as const, - ([nextThreads, nextThreadsById], prev) => { + (nextValue) => [nextValue.threads] as const, + ([nextThreads], prev) => { const [prevThreads = []] = prev ?? []; // Thread instance was removed if there's no thread with the given id at all, // or it was replaced with a new instance - const removedThreads = prevThreads.filter((thread) => thread !== nextThreadsById[thread.id]); + const removedThreads = prevThreads.filter((thread) => thread !== this.threadsById[thread.id]); nextThreads.forEach((thread) => thread.registerSubscriptions()); removedThreads.forEach((thread) => thread.unregisterSubscriptions()); @@ -117,10 +139,10 @@ export class ThreadManager { const parentId = event.message?.parent_id; if (!parentId) return; - const { threadsById, unseenThreadIds, ready } = this.state.getLatestValue(); + const { unseenThreadIds, ready } = this.state.getLatestValue(); if (!ready) return; - if (threadsById[parentId]) { + if (this.threadsById[parentId]) { this.state.partialNext({ isThreadOrderStale: true }); } else if (!unseenThreadIds.includes(parentId)) { this.state.partialNext({ unseenThreadIds: unseenThreadIds.concat(parentId) }); @@ -182,7 +204,8 @@ export class ThreadManager { })); const response = await this.queryThreads({ limit: Math.min(limit, MAX_QUERY_THREADS_LIMIT) }); - const { threadsById: currentThreads } = this.state.getLatestValue(); + + const currentThreads = this.threadsById; const nextThreads: Thread[] = []; for (const incomingThread of response.threads) { @@ -201,7 +224,7 @@ export class ThreadManager { this.state.next((current) => ({ ...current, - ...prepareThreadsUpdate(current, nextThreads), + threads: nextThreads, unseenThreadIds: [], isThreadOrderStale: false, pagination: { @@ -248,10 +271,7 @@ export class ThreadManager { this.state.next((current) => ({ ...current, - ...prepareThreadsUpdate( - current, - response.threads.length ? current.threads.concat(response.threads) : current.threads, - ), + threads: response.threads.length ? current.threads.concat(response.threads) : current.threads, pagination: { ...current.pagination, nextCursor: response.next ?? null, @@ -270,20 +290,3 @@ export class ThreadManager { } }; } - -function prepareThreadsUpdate( - state: ThreadManagerState, - threads: Thread[], -): Partial> { - if (threads === state.threads) { - return {}; - } - - return { - threads, - threadsById: threads.reduce>>((newThreadsById, thread) => { - newThreadsById[thread.id] = thread; - return newThreadsById; - }, {}), - }; -} diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 64fc483fae..bc085d04b4 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -896,7 +896,6 @@ describe('Threads 2.0', () => { const thread = createTestThread(); threadManager.state.partialNext({ threads: [thread], - threadsById: { [thread.id]: thread }, ready: true, }); @@ -962,7 +961,6 @@ describe('Threads 2.0', () => { threadManager.state.partialNext({ threads: [thread1, thread2], - threadsById: { [thread1.id]: thread1, [thread2.id]: thread2 }, }); expect(registerThread1.calledOnce).to.be.true; @@ -970,7 +968,6 @@ describe('Threads 2.0', () => { threadManager.state.partialNext({ threads: [thread2, thread3], - threadsById: { [thread2.id]: thread2, [thread3.id]: thread3 }, }); expect(unregisterThread1.calledOnce).to.be.true; @@ -984,7 +981,7 @@ describe('Threads 2.0', () => { }); }); - describe('Methods', () => { + describe('Methods & Getters', () => { let stubbedQueryThreads: sinon.SinonStub< Parameters, ReturnType @@ -997,6 +994,29 @@ describe('Threads 2.0', () => { }); }); + describe('threadsById', () => { + it('lazily generates & re-generates a proper lookup table', () => { + const thread1 = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + const thread2 = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + const thread3 = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + + threadManager.state.partialNext({ threads: [thread1, thread2] }); + const state1 = threadManager.state.getLatestValue(); + + expect(state1.threads).to.have.lengthOf(2); + expect(Object.keys(threadManager.threadsById)).to.have.lengthOf(2); + expect(threadManager.threadsById).to.have.keys(thread1.id, thread2.id); + + threadManager.state.partialNext({ threads: [thread3] }); + const state2 = threadManager.state.getLatestValue(); + + expect(state2.threads).to.have.lengthOf(1); + expect(Object.keys(threadManager.threadsById)).to.have.lengthOf(1); + expect(threadManager.threadsById).to.have.keys(thread3.id); + expect(threadManager.threadsById[thread3.id]).to.equal(thread3); + }); + }); + describe('reload', () => { it('skips reload if there were no updates since the latest reload', async () => { threadManager.state.partialNext({ ready: true }); @@ -1074,7 +1094,6 @@ describe('Threads 2.0', () => { }); threadManager.state.partialNext({ threads: [existingThread], - threadsById: { [existingThread.id]: existingThread }, unseenThreadIds: [newThread.id], }); stubbedQueryThreads.resolves({ From 082769fbb1cee4cc8e29570e8a7c7ddaa9121fed Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 30 Aug 2024 12:55:49 +0200 Subject: [PATCH 37/41] Remove threadData from the Channel instantiation step --- src/thread.ts | 2 +- test/unit/threads.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/thread.ts b/src/thread.ts index fc2c6d85da..d25cfe48be 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -76,7 +76,7 @@ export class Thread { constructor({ client, threadData }: { client: StreamChat; threadData: ThreadResponse }) { this.state = new StateStore>({ active: false, - channel: client.channel(threadData.channel.type, threadData.channel.id, threadData.channel), + channel: client.channel(threadData.channel.type, threadData.channel.id), createdAt: new Date(threadData.created_at), deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null, isLoading: false, diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index bc085d04b4..bd7372ebfc 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -55,7 +55,9 @@ describe('Threads 2.0', () => { describe('Thread', () => { it('initializes properly', () => { const thread = new Thread({ client, threadData: generateThread(channelResponse, parentMessageResponse) }); + expect(thread.id).to.equal(parentMessageResponse.id); + expect(thread.channel.data).to.be.empty; }); describe('Methods', () => { @@ -1000,6 +1002,8 @@ describe('Threads 2.0', () => { const thread2 = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); const thread3 = createTestThread({ parentMessageOverrides: { id: uuidv4() } }); + expect(threadManager.threadsById).to.be.empty; + threadManager.state.partialNext({ threads: [thread1, thread2] }); const state1 = threadManager.state.getLatestValue(); From ea0230658d052188a41deaa9db602254b6761537 Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Fri, 30 Aug 2024 14:51:07 +0200 Subject: [PATCH 38/41] Fix missing initial unreadThreadCount --- src/thread_manager.ts | 21 +++++++++++++-------- test/unit/threads.test.ts | 4 ++-- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/thread_manager.ts b/src/thread_manager.ts index 976d42c3ab..fd37c5d6b1 100644 --- a/src/thread_manager.ts +++ b/src/thread_manager.ts @@ -1,9 +1,10 @@ -import type { StreamChat } from './client'; import { StateStore } from './store'; -import type { Thread } from './thread'; -import type { DefaultGenerics, Event, ExtendableGenerics, QueryThreadsOptions } from './types'; import { throttle } from './utils'; +import type { StreamChat } from './client'; +import type { Thread } from './thread'; +import type { DefaultGenerics, Event, ExtendableGenerics, OwnUserResponse, QueryThreadsOptions } from './types'; + const DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION = 1000; const MAX_QUERY_THREADS_LIMIT = 25; @@ -14,7 +15,7 @@ export type ThreadManagerState pagination: ThreadManagerPagination; ready: boolean; threads: Thread[]; - unreadThreadsCount: number; + unreadThreadCount: number; /** * List of threads that haven't been loaded in the list, but have received new messages * since the latest reload. Useful to display a banner prompting to reload the thread list. @@ -43,7 +44,7 @@ export class ThreadManager { active: false, isThreadOrderStale: false, threads: [], - unreadThreadsCount: 0, + unreadThreadCount: 0, unseenThreadIds: [], lastConnectionDropAt: null, pagination: { @@ -94,6 +95,10 @@ export class ThreadManager { }; private subscribeUnreadThreadsCountChange = () => { + // initiate + const { unread_threads: unreadThreadCount = 0 } = (this.client.user as OwnUserResponse) ?? {}; + this.state.partialNext({ unreadThreadCount }); + const unsubscribeFunctions = [ 'health.check', 'notification.mark_read', @@ -102,9 +107,9 @@ export class ThreadManager { ].map( (eventType) => this.client.on(eventType, (event) => { - const { unread_threads: unreadThreadsCount } = event.me ?? event; - if (typeof unreadThreadsCount === 'number') { - this.state.partialNext({ unreadThreadsCount }); + const { unread_threads: unreadThreadCount } = event.me ?? event; + if (typeof unreadThreadCount === 'number') { + this.state.partialNext({ unreadThreadCount }); } }).unsubscribe, ); diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index bd7372ebfc..3c0369d8d6 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -849,8 +849,8 @@ describe('Threads 2.0', () => { unread_threads: expectedUnreadCount, }); - const { unreadThreadsCount } = threadManager.state.getLatestValue(); - expect(unreadThreadsCount).to.equal(expectedUnreadCount); + const { unreadThreadCount } = threadManager.state.getLatestValue(); + expect(unreadThreadCount).to.equal(expectedUnreadCount); }); }); From 23b70aa6fbcc5802e65648b755680e10fdca03bc Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Mon, 2 Sep 2024 10:06:24 +0200 Subject: [PATCH 39/41] fix: latest replies are always the last page --- src/thread.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/thread.ts b/src/thread.ts index d25cfe48be..276cc7f1f3 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -491,7 +491,7 @@ const repliesPaginationFromInitialThread = (thread: ThreadResponse): ThreadRepli const latestRepliesContainsAllReplies = thread.latest_replies.length === thread.reply_count; return { - nextCursor: latestRepliesContainsAllReplies ? null : thread.latest_replies.at(-1)?.id ?? null, + nextCursor: null, prevCursor: latestRepliesContainsAllReplies ? null : thread.latest_replies.at(0)?.id ?? null, isLoadingNext: false, isLoadingPrev: false, From 5e0f99ae68f9519bbe1ba6beeb665e72e3f3a5dd Mon Sep 17 00:00:00 2001 From: Anton Arnautov Date: Mon, 2 Sep 2024 16:21:31 +0200 Subject: [PATCH 40/41] Fix tests, add missing TM test --- test/unit/threads.test.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 3c0369d8d6..98f993bb92 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -320,7 +320,7 @@ describe('Threads 2.0', () => { const thread = createTestThread({ latest_replies: [firstMessage, lastMessage], reply_count: 3 }); const state = thread.state.getLatestValue(); expect(state.pagination.prevCursor).not.to.be.null; - expect(state.pagination.nextCursor).not.to.be.null; + expect(state.pagination.nextCursor).to.be.null; }); it('updates pagination after loading next page (end reached)', async () => { @@ -1021,6 +1021,20 @@ describe('Threads 2.0', () => { }); }); + describe('registerSubscriptions', () => { + it('properly initiates unreadThreadCount on subscribeUnreadThreadsCountChange call', () => { + client._setUser({ id: TEST_USER_ID, unread_threads: 4 }); + + const stateBefore = threadManager.state.getLatestValue(); + expect(stateBefore.unreadThreadCount).to.equal(0); + + threadManager.registerSubscriptions(); + + const stateAfter = threadManager.state.getLatestValue(); + expect(stateAfter.unreadThreadCount).to.equal(4); + }); + }); + describe('reload', () => { it('skips reload if there were no updates since the latest reload', async () => { threadManager.state.partialNext({ ready: true }); From 226c03d43847a14085dd20399b4864099ad66f95 Mon Sep 17 00:00:00 2001 From: Matvei Andrienko Date: Mon, 2 Sep 2024 17:05:00 +0200 Subject: [PATCH 41/41] fix: pagination tests --- test/unit/threads.test.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/unit/threads.test.ts b/test/unit/threads.test.ts index 98f993bb92..a76080c419 100644 --- a/test/unit/threads.test.ts +++ b/test/unit/threads.test.ts @@ -328,6 +328,13 @@ describe('Threads 2.0', () => { latest_replies: [generateMsg(), generateMsg()] as MessageResponse[], reply_count: 3, }); + thread.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: 'cursor', + }, + })); sinon.stub(thread, 'queryReplies').resolves({ messages: [generateMsg()] as MessageResponse[], duration: '', @@ -344,6 +351,13 @@ describe('Threads 2.0', () => { latest_replies: [generateMsg(), generateMsg()] as MessageResponse[], reply_count: 4, }); + thread.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: 'cursor', + }, + })); const lastMessage = generateMsg() as MessageResponse; sinon.stub(thread, 'queryReplies').resolves({ messages: [generateMsg(), lastMessage] as MessageResponse[], @@ -360,6 +374,13 @@ describe('Threads 2.0', () => { const firstMessage = generateMsg() as MessageResponse; const lastMessage = generateMsg() as MessageResponse; const thread = createTestThread({ latest_replies: [firstMessage, lastMessage], reply_count: 3 }); + thread.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: lastMessage.id, + }, + })); const queryRepliesStub = sinon.stub(thread, 'queryReplies').resolves({ messages: [], duration: '' }); await thread.loadNextPage({ limit: 42 }); @@ -425,6 +446,13 @@ describe('Threads 2.0', () => { const initialMessages = [generateMsg(), generateMsg()] as MessageResponse[]; const nextMessages = [generateMsg(), generateMsg()] as MessageResponse[]; const thread = createTestThread({ latest_replies: initialMessages, reply_count: 4 }); + thread.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: initialMessages[1].id, + }, + })); sinon.stub(thread, 'queryReplies').resolves({ messages: nextMessages, duration: '' }); await thread.loadNextPage({ limit: 2 });