diff --git a/package.json b/package.json index f51e216f43..eae1c898f9 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", @@ -103,7 +103,7 @@ "rollup-plugin-terser": "^7.0.2", "sinon": "^12.0.1", "standard-version": "^9.3.2", - "typescript": "^4.2.3", + "typescript": "4.2.3", "uuid": "^8.3.2" }, "scripts": { @@ -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", @@ -132,5 +132,6 @@ }, "engines": { "node": ">=16" - } + }, + "packageManager": "yarn@1.22.21+sha1.1959a18351b811cdeedbd484a8f86c3cc3bbaf72" } diff --git a/src/channel.ts b/src/channel.ts index c7edbd4ed0..90cba5ed0f 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -824,7 +824,9 @@ 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 61b991e005..a202d668ff 100644 --- a/src/channel_state.ts +++ b/src/channel_state.ts @@ -14,7 +14,7 @@ import { ReactionResponse, UserResponse, } from './types'; -import { addToMessageList } from './utils'; +import { addToMessageList, formatMessage } from './utils'; import { DEFAULT_MESSAGE_SET_PAGINATION } from './constants'; type ChannelReadStatus = Record< @@ -59,6 +59,7 @@ export class ChannelState) { this._channel = channel; this.watcher_count = 0; @@ -134,26 +135,12 @@ 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) => formatMessage(message); /** * addMessagesSorted - Add the list of messages to state and resorts the messages @@ -666,7 +653,7 @@ export class ChannelState; }; + threads: ThreadManager; anonymous: boolean; persistUserOnConnectionFailure?: boolean; axiosInstance: AxiosInstance; @@ -340,6 +342,7 @@ export class StreamChat null; this.recoverStateOnReconnect = this.options.recoverStateOnReconnect; + this.threads = new ThreadManager({ client: this }); } /** @@ -606,7 +610,7 @@ 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, }; } @@ -2787,7 +2791,7 @@ export class StreamChat(this, res.thread); + return new Thread({ client: this, threadData: res.thread }); } /** diff --git a/src/index.ts b/src/index.ts index 232af6ec0c..64c3c92311 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'; @@ -15,3 +16,4 @@ export * from './types'; export * from './segment'; export * from './campaign'; export { isOwnUser, chatCodes, logChatPromiseExecution, formatMessage } from './utils'; +export * from './store'; diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 0000000000..f76c8bacb5 --- /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 Unsubscribe = () => 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): Unsubscribe => { + 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/thread.ts b/src/thread.ts index 1cc402093d..276cc7f1f3 100644 --- a/src/thread.ts +++ b/src/thread.ts @@ -1,142 +1,505 @@ -import { StreamChat } from './client'; -import { +import type { Channel } from './channel'; +import type { StreamChat } from './client'; +import { StateStore } from './store'; +import type { + AscDesc, DefaultGenerics, ExtendableGenerics, + FormatMessageResponse, + MessagePaginationOptions, MessageResponse, + ReadResponse, ThreadResponse, - ChannelResponse, - FormatMessageResponse, - ReactionResponse, UserResponse, } from './types'; -import { addToMessageList, formatMessage } from './utils'; +import { addToMessageList, findIndexInSortedArray, formatMessage, throttle } from './utils'; + +type QueryRepliesOptions = { + sort?: { created_at: AscDesc }[]; +} & 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; + /** + * 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>; + replyCount: number; + updatedAt: Date | null; +}; -type ThreadReadStatus = Record< +export type ThreadRepliesPagination = { + isLoadingNext: boolean; + isLoadingPrev: boolean; + nextCursor: string | null; + prevCursor: string | null; +}; + +export type ThreadUserReadState = { + lastReadAt: Date; + unreadMessageCount: number; + user: UserResponse; + lastReadMessageId?: string; +}; + +export type ThreadReadState = Record< string, - { - last_read: Date; - last_read_message_id: string; - unread_messages: number; - user: UserResponse; - } + ThreadUserReadState | undefined >; -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, - 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; - } +const DEFAULT_PAGE_LIMIT = 50; +const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }]; +const MARK_AS_READ_THROTTLE_TIMEOUT = 1000; + +export class Thread { + public readonly state: StateStore>; + public readonly id: string; + + private client: StreamChat; + private unsubscribeFunctions: Set<() => void> = new Set(); + private failedRepliesMap: Map> = new Map(); - getClient(): StreamChat { - return this._client; + constructor({ client, threadData }: { client: StreamChat; threadData: ThreadResponse }) { + this.state = new StateStore>({ + active: false, + 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, + 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, + }); + + this.id = threadData.parent_message_id; + this.client = client; } - /** - * 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) { - throw new Error('Message does not belong to this thread'); - } + get channel() { + return this.state.getLatestValue().channel; + } - this.latestReplies = addToMessageList(this.latestReplies, formatMessage(message), true); + get hasStaleState() { + return this.state.getLatestValue().isStateStale; } - updateReply(message: MessageResponse) { - this.latestReplies = this.latestReplies.map((m) => { - if (m.id === message.id) { - return formatMessage(message); - } - return m; - }); + get ownUnreadCount() { + return ownUnreadCountSelector(this.client.userID)(this.state.getLatestValue()); } - updateMessageOrReplyIfExists(message: MessageResponse) { - if (!message.parent_id && message.id !== this.message.id) { + public activate = () => { + this.state.partialNext({ active: true }); + }; + + public deactivate = () => { + this.state.partialNext({ active: false }); + }; + + public reload = async () => { + if (this.state.getLatestValue().isLoading) { return; } - if (message.parent_id && message.parent_id !== this.message.id) { - 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 }); } + }; - if (message.parent_id && message.parent_id === this.message.id) { - this.updateReply(message); + public hydrateState = (thread: Thread) => { + if (thread === this) { + // skip if the instances are the same return; } - if (!message.parent_id && message.id === this.message.id) { - this.message = formatMessage(message); + if (thread.id !== this.id) { + throw new Error("Cannot hydrate thread state with using thread's state"); } - } - addReaction( - reaction: ReactionResponse, - message?: MessageResponse, - enforce_unique?: boolean, - ) { - if (!message) return; - - this.latestReplies = this.latestReplies.map((m) => { - if (m.id === message.id) { - return formatMessage( - this._channel.state.addReaction(reaction, message, enforce_unique) as MessageResponse, - ); + const { + read, + replyCount, + replies, + parentMessage, + participants, + createdAt, + deletedAt, + updatedAt, + } = thread.state.getLatestValue(); + + // Preserve pending replies and append them to the updated list of replies + const pendingReplies = Array.from(this.failedRepliesMap.values()); + + this.state.partialNext({ + read, + replyCount, + replies: pendingReplies.length ? replies.concat(pendingReplies) : replies, + parentMessage, + participants, + createdAt, + deletedAt, + updatedAt, + isStateStale: false, + }); + }; + + public registerSubscriptions = () => { + if (this.unsubscribeFunctions.size) { + // Thread is already listening for events and changes + 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()); + }; + + private subscribeMarkActiveThreadRead = () => { + return this.state.subscribeWithSelector( + (nextValue) => [nextValue.active, ownUnreadCountSelector(this.client.userID)(nextValue)], + ([active, unreadMessageCount]) => { + if (!active || !unreadMessageCount) return; + this.throttledMarkAsRead(); + }, + ); + }; + + private subscribeReloadActiveStaleThread = () => + this.state.subscribeWithSelector( + (nextValue) => [nextValue.active, nextValue.isStateStale], + ([active, isStateStale]) => { + if (active && isStateStale) { + this.reload(); + } + }, + ); + + private subscribeMarkThreadStale = () => + this.client.on('user.watching.stop', (event) => { + const { channel } = this.state.getLatestValue(); + + if (!this.client.userID || this.client.userID !== event.user?.id || event.channel?.cid !== channel.cid) { + return; + } + + 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; + } + + const isOwnMessage = event.message.user?.id === this.client.userID; + const { active, read } = 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 (active) { + this.throttledMarkAsRead(); + } + + const nextRead: ThreadReadState = {}; + + for (const userId of Object.keys(read)) { + 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 + // 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: userRead.unreadMessageCount + 1, + }; + } + + nextRead[userId] = nextUserRead; + } + } + + this.state.partialNext({ read: nextRead }); + }).unsubscribe; + + 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; + + this.state.next((current) => ({ + ...current, + read: { + ...current.read, + [userId]: { + lastReadAt: new Date(createdAt), + user, + lastReadMessageId: event.last_read_message_id, + unreadMessageCount: 0, + }, + }, + })); + }).unsubscribe; + + private subscribeReplyDeleted = () => + this.client.on('message.deleted', (event) => { + if (event.message?.parent_id === this.id && event.hard_delete) { + return this.deleteReplyLocally({ message: event.message }); } - return m; + }).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, + ); + + return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); + }; + + public unregisterSubscriptions = () => { + this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); + this.unsubscribeFunctions.clear(); + }; + + public deleteReplyLocally = ({ message }: { message: MessageResponse }) => { + const { replies } = this.state.getLatestValue(); + + const index = findIndexInSortedArray({ + needle: formatMessage(message), + sortedArray: replies, + sortDirection: 'ascending', + selectValueToCompare: (reply) => reply.created_at.getTime(), }); - } - removeReaction(reaction: ReactionResponse, message?: MessageResponse) { - if (!message) return; + const actualIndex = + replies[index]?.id === message.id ? index : replies[index - 1]?.id === message.id ? index - 1 : null; + + if (actualIndex === null) { + return; + } + + const updatedReplies = [...replies]; + updatedReplies.splice(actualIndex, 1); + + this.state.partialNext({ + replies: updatedReplies, + }); + }; + + public upsertReplyLocally = ({ + message, + timestampChanged = false, + }: { + message: MessageResponse; + timestampChanged?: boolean; + }) => { + if (message.parent_id !== this.id) { + throw new Error('Reply does not belong to this thread'); + } + + const formattedMessage = formatMessage(message); + + if (message.status === 'failed') { + // 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) => ({ + ...current, + replies: addToMessageList(current.replies, formattedMessage, timestampChanged), + })); + }; + + public updateParentMessageLocally = (message: MessageResponse) => { + if (message.id !== this.id) { + throw new Error('Message does not belong to this thread'); + } + + this.state.next((current) => { + const formattedMessage = formatMessage(message); + + const newData: typeof current = { + ...current, + deletedAt: formattedMessage.deleted_at, + parentMessage: formattedMessage, + replyCount: message.reply_count ?? current.replyCount, + }; - this.latestReplies = this.latestReplies.map((m) => { - if (m.id === message.id) { - return formatMessage( - this._channel.state.removeReaction(reaction, message) as MessageResponse, - ); + // update channel on channelData change (unlikely but handled anyway) + if (message.channel) { + newData['channel'] = this.client.channel(message.channel.type, message.channel.id, message.channel); } - return m; + + return newData; }); - } + }; + + public updateParentMessageOrReplyLocally = (message: MessageResponse) => { + if (message.parent_id === this.id) { + this.upsertReplyLocally({ message }); + } + + if (!message.parent_id && message.id === this.id) { + this.updateParentMessageLocally(message); + } + }; + + public markAsRead = async ({ force = false }: { force?: boolean } = {}) => { + if (this.ownUnreadCount === 0 && !force) { + return null; + } + + return await this.channel.markRead({ thread_id: this.id }); + }; + + private throttledMarkAsRead = throttle(() => this.markAsRead(), MARK_AS_READ_THROTTLE_TIMEOUT, { trailing: true }); + + public queryReplies = ({ + limit = DEFAULT_PAGE_LIMIT, + sort = DEFAULT_SORT, + ...otherOptions + }: QueryRepliesOptions = {}) => { + return this.channel.getReplies(this.id, { limit, ...otherOptions }, sort); + }; + + 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, 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']: pagination[cursorKey] }; + const limit = Math.abs(count); + + this.state.partialNext({ pagination: { ...pagination, [loadingKey]: true } }); + + 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) => { + let nextReplies = current.replies; + + // prevent re-creating array if there's nothing to add to the current one + 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) => ({ + ...current, + pagination: { + ...current.pagination, + [loadingKey]: 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 ?? 0, + lastReadAt: new Date(userRead.last_read), + }; + return state; + }, {}); + +const repliesPaginationFromInitialThread = (thread: ThreadResponse): ThreadRepliesPagination => { + const latestRepliesContainsAllReplies = thread.latest_replies.length === thread.reply_count; + + return { + nextCursor: null, + prevCursor: latestRepliesContainsAllReplies ? null : thread.latest_replies.at(0)?.id ?? null, + isLoadingNext: false, + 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 new file mode 100644 index 0000000000..fd37c5d6b1 --- /dev/null +++ b/src/thread_manager.ts @@ -0,0 +1,297 @@ +import { StateStore } from './store'; +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; + +export type ThreadManagerState = { + active: boolean; + isThreadOrderStale: boolean; + lastConnectionDropAt: Date | null; + pagination: ThreadManagerPagination; + ready: boolean; + threads: Thread[]; + 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. + */ + unseenThreadIds: string[]; +}; + +export type ThreadManagerPagination = { + isLoading: boolean; + isLoadingNext: boolean; + nextCursor: string | null; +}; + +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; + this.state = new StateStore>({ + active: false, + isThreadOrderStale: false, + threads: [], + unreadThreadCount: 0, + unseenThreadIds: [], + lastConnectionDropAt: null, + pagination: { + isLoading: false, + isLoadingNext: false, + nextCursor: null, + }, + 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 = () => { + this.state.partialNext({ active: true }); + }; + + public deactivate = () => { + this.state.partialNext({ active: false }); + }; + + public registerSubscriptions = () => { + if (this.unsubscribeFunctions.size) return; + + 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 = () => { + // initiate + const { unread_threads: unreadThreadCount = 0 } = (this.client.user as OwnUserResponse) ?? {}; + this.state.partialNext({ unreadThreadCount }); + + const unsubscribeFunctions = [ + 'health.check', + 'notification.mark_read', + 'notification.thread_message_new', + 'notification.channel_deleted', + ].map( + (eventType) => + this.client.on(eventType, (event) => { + const { unread_threads: unreadThreadCount } = event.me ?? event; + if (typeof unreadThreadCount === 'number') { + this.state.partialNext({ unreadThreadCount }); + } + }).unsubscribe, + ); + + return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe()); + }; + + private subscribeManageThreadSubscriptions = () => + this.state.subscribeWithSelector( + (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 !== this.threadsById[thread.id]); + + nextThreads.forEach((thread) => thread.registerSubscriptions()); + removedThreads.forEach((thread) => thread.unregisterSubscriptions()); + }, + ); + + private subscribeReloadOnActivation = () => + this.state.subscribeWithSelector( + (nextValue) => [nextValue.active], + ([active]) => { + if (active) this.reload(); + }, + ); + + private subscribeNewReplies = () => + this.client.on('notification.thread_message_new', (event: Event) => { + const parentId = event.message?.parent_id; + if (!parentId) return; + + const { unseenThreadIds, ready } = this.state.getLatestValue(); + if (!ready) return; + + if (this.threadsById[parentId]) { + this.state.partialNext({ isThreadOrderStale: true }); + } else if (!unseenThreadIds.includes(parentId)) { + this.state.partialNext({ unseenThreadIds: unseenThreadIds.concat(parentId) }); + } + }).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; + + const throttledHandleConnectionRecovered = throttle( + () => { + const { lastConnectionDropAt } = this.state.getLatestValue(); + if (!lastConnectionDropAt) return; + this.reload({ force: true }); + }, + DEFAULT_CONNECTION_RECOVERY_THROTTLE_DURATION, + { trailing: true }, + ); + + const unsubscribeConnectionRecovered = this.client.on('connection.recovered', throttledHandleConnectionRecovered) + .unsubscribe; + + return () => { + unsubscribeConnectionDropped(); + unsubscribeConnectionRecovered(); + }; + }; + + public unregisterSubscriptions = () => { + this.state.getLatestValue().threads.forEach((thread) => thread.unregisterSubscriptions()); + this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction()); + this.unsubscribeFunctions.clear(); + }; + + 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 { + this.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoading: true, + }, + })); + + const response = await this.queryThreads({ limit: Math.min(limit, MAX_QUERY_THREADS_LIMIT) }); + + const currentThreads = this.threadsById; + const nextThreads: Thread[] = []; + + for (const incomingThread of response.threads) { + const existingThread = currentThreads[incomingThread.id]; + + if (existingThread) { + // Reuse thread instances if possible + nextThreads.push(existingThread); + if (existingThread.hasStaleState) { + existingThread.hydrateState(incomingThread); + } + } else { + nextThreads.push(incomingThread); + } + } + + this.state.next((current) => ({ + ...current, + threads: nextThreads, + unseenThreadIds: [], + isThreadOrderStale: false, + pagination: { + ...current.pagination, + isLoading: false, + nextCursor: response.next ?? null, + }, + ready: true, + })); + } catch (error) { + this.client.logger('error', (error as Error).message); + this.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoading: false, + }, + })); + } + }; + + public queryThreads = (options: QueryThreadsOptions = {}) => { + return this.client.queryThreads({ + limit: 25, + participant_limit: 10, + reply_limit: 10, + watch: true, + ...options, + }); + }; + + public loadNextPage = async (options: Omit = {}) => { + const { pagination } = this.state.getLatestValue(); + + if (pagination.isLoadingNext || !pagination.nextCursor) return; + + try { + this.state.partialNext({ pagination: { ...pagination, isLoadingNext: true } }); + + const response = await this.queryThreads({ + ...options, + next: pagination.nextCursor, + }); + + this.state.next((current) => ({ + ...current, + threads: 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); + this.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoadingNext: false, + }, + })); + } + }; +} diff --git a/src/types.ts b/src/types.ts index 556624f41c..0bc702ac07 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; @@ -498,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; - deleted_at: string; - latest_replies: MessageResponse[]; - parent_message: MessageResponse; + created_by_user_id: string; + latest_replies: Array>; + 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; - user: UserResponse; - }[]; title: string; updated_at: string; -}; + created_by?: UserResponse; + deleted_at?: string; + last_message_at?: string; + 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 = { @@ -1236,6 +1243,8 @@ export type Event; user_id?: string; watcher_count?: number; diff --git a/src/utils.ts b/src/utils.ts index f086a0f49d..c3bd41464e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -280,14 +280,13 @@ 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, + message: MessageResponse | FormatMessageResponse, ): FormatMessageResponse { return { ...message, @@ -295,10 +294,11 @@ export function formatMessage( - messages: Array>, - message: FormatMessageResponse, +export const findIndexInSortedArray = ({ + needle, + sortedArray, + selectValueToCompare = (e) => e, + sortDirection = 'ascending', +}: { + 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'; +}) => { + if (!sortedArray.length) return 0; + + let left = 0; + let right = sortedArray.length - 1; + let middle = 0; + + const recalculateMiddle = () => { + middle = Math.round((left + right) / 2); + }; + + const actualNeedle = selectValueToCompare(needle); + recalculateMiddle(); + + while (left <= right) { + // 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; +}; + +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 && addMessageToList) { + return newMessages.concat(newMessage); } - 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]; + return newMessages.concat(newMessage); } // 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 = findIndexInSortedArray({ + needle: newMessage, + sortedArray: messages, + sortDirection: 'ascending', + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + selectValueToCompare: (m) => m[sortBy]!.getTime(), + }); - // 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]; + // 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; + 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. + // do not add updated or deleted messages to the list if they already exist or come with a timestamp change if (addMessageToList) { - messageArr.splice(left, 0, message); + newMessages.splice(insertionIndex, 0, newMessage); } - return [...messageArr]; + + return newMessages; } function maybeGetReactionGroupsFallback( @@ -400,6 +453,39 @@ function maybeGetReactionGroupsFallback( return null; } +// works exactly the same as lodash.throttle +export const throttle = 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); + }; +}; + type MessagePaginationUpdatedParams = { parentSet: MessageSet; requestedPageSize: number; diff --git a/test/unit/test-utils/generateThread.js b/test/unit/test-utils/generateThread.js index 93d322ec35..cf67b44949 100644 --- a/test/unit/test-utils/generateThread.js +++ b/test/unit/test-utils/generateThread.js @@ -11,11 +11,12 @@ 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: [], thread_participants: [], + created_by_user_id: '', ...opts, }; }; diff --git a/test/unit/thread.js b/test/unit/thread.js deleted file mode 100644 index 487f47274a..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, 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/threads.test.ts b/test/unit/threads.test.ts new file mode 100644 index 0000000000..a76080c419 --- /dev/null +++ b/test/unit/threads.test.ts @@ -0,0 +1,1282 @@ +import { expect } from 'chai'; +import { v4 as uuidv4 } from 'uuid'; + +import { generateChannel } from './test-utils/generateChannel'; +import { generateMsg } from './test-utils/generateMessage'; +import { generateThread } from './test-utils/generateThread'; + +import sinon from 'sinon'; +import { + Channel, + ChannelResponse, + MessageResponse, + StreamChat, + Thread, + ThreadManager, + ThreadResponse, +} from '../../src'; + +const TEST_USER_ID = 'observer'; + +describe('Threads 2.0', () => { + let client: StreamChat; + let channelResponse: ChannelResponse; + let channel: Channel; + 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 as ChannelResponse; + channel = client.channel(channelResponse.type, channelResponse.id); + parentMessageResponse = generateMsg() as MessageResponse; + threadManager = new ThreadManager({ client }); + }); + + 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', () => { + describe('upsertReplyLocally', () => { + it('prevents inserting a new message that does not belong to the associated thread', () => { + 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 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 }); + + 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 message = generateMsg({ parent_id: parentMessageResponse.id, text: 'aaa' }) as MessageResponse; + const thread = createTestThread({ latest_replies: [message] }); + const udpatedMessage = { ...message, text: 'bbb' }; + + 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); + + thread.upsertReplyLocally({ message: udpatedMessage }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.have.lengthOf(1); + expect(stateAfter.replies[0].text).to.equal(udpatedMessage.text); + }); + + 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('updateParentMessageLocally', () => { + it('prevents updating a parent message if the ids do not match', () => { + const thread = createTestThread(); + const message = generateMsg() as MessageResponse; + expect(() => thread.updateParentMessageLocally(message)).to.throw(); + }); + + it('updates parent message and related top-level properties', () => { + const thread = createTestThread(); + + 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); + + const updatedMessage = generateMsg({ + id: parentMessageResponse.id, + text: 'aaa', + reply_count: 10, + deleted_at: new Date().toISOString(), + }) as MessageResponse; + + thread.updateParentMessageLocally(updatedMessage); + + 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('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(message); + + expect(upsertReplyLocallyStub.called).to.be.true; + expect(updateParentMessageLocallyStub.called).to.be.false; + }); + + 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(message); + + expect(upsertReplyLocallyStub.called).to.be.false; + expect(updateParentMessageLocallyStub.called).to.be.true; + }); + + 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(message); + + expect(upsertReplyLocallyStub.called).to.be.false; + expect(updateParentMessageLocallyStub.called).to.be.false; + }); + }); + + 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(otherThread.id); + expect(() => thread.hydrateState(otherThread)).to.throw(); + }); + + it('copies state of the instance with the same id', () => { + const thread = createTestThread(); + const hydrationThread = createTestThread(); + thread.hydrateState(hydrationThread); + + const stateAfter = thread.state.getLatestValue(); + const hydrationState = hydrationThread.state.getLatestValue(); + + // compare non-primitive values only + 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('retains failed replies after hydration', () => { + const thread = createTestThread(); + const hydrationThread = createTestThread({ + latest_replies: [generateMsg({ parent_id: parentMessageResponse.id }) as MessageResponse], + }); + + const failedMessage = generateMsg({ + status: 'failed', + parent_id: parentMessageResponse.id, + }) as MessageResponse; + thread.upsertReplyLocally({ message: failedMessage }); + + thread.hydrateState(hydrationThread); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.have.lengthOf(2); + expect(stateAfter.replies[1].id).to.equal(failedMessage.id); + }); + }); + + 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, + ); + const thread = createTestThread({ latest_replies: messages }); + + const stateBefore = thread.state.getLatestValue(); + expect(stateBefore.replies).to.have.lengthOf(5); + + const messageToDelete = generateMsg({ + created_at: messages[2].created_at, + id: messages[2].id, + }) as MessageResponse; + + thread.deleteReplyLocally({ message: messageToDelete }); + + 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('markAsRead', () => { + let stubbedChannelMarkRead: sinon.SinonStub, ReturnType>; + + beforeEach(() => { + stubbedChannelMarkRead = sinon.stub(channel, 'markRead').resolves(); + }); + + 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(stubbedChannelMarkRead.notCalled).to.be.true; + }); + + 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, + }, + ], + }); + + expect(thread.ownUnreadCount).to.equal(42); + + await thread.markAsRead(); + + expect(stubbedChannelMarkRead.calledOnceWith({ thread_id: thread.id })).to.be.true; + }); + }); + + 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; + }); + + 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).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, + }); + thread.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: 'cursor', + }, + })); + sinon.stub(thread, 'queryReplies').resolves({ + messages: [generateMsg()] as MessageResponse[], + duration: '', + }); + + await thread.loadNextPage({ limit: 2 }); + + const state = thread.state.getLatestValue(); + expect(state.pagination.nextCursor).to.be.null; + }); + + it('updates pagination after loading next page (end not reached)', async () => { + const thread = createTestThread({ + 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[], + duration: '', + }); + + await thread.loadNextPage({ limit: 2 }); + + const state = thread.state.getLatestValue(); + expect(state.pagination.nextCursor).to.equal(lastMessage.id); + }); + + 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 }); + 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 }); + + expect( + queryRepliesStub.calledOnceWith({ + id_gt: lastMessage.id, + limit: 42, + }), + ).to.be.true; + }); + + 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: '', + }); + + await thread.loadPrevPage({ limit: 2 }); + + const state = thread.state.getLatestValue(); + expect(state.pagination.prevCursor).to.be.null; + }); + + 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('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: '' }); + + await thread.loadPrevPage({ limit: 42 }); + + expect( + queryRepliesStub.calledOnceWith({ + id_lt: firstMessage.id, + limit: 42, + }), + ).to.be.true; + }); + + 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 }); + 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 }); + + 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); + }); + + 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 }); + + 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 and Event Handlers', () => { + it('marks active channel as read', () => { + const clock = sinon.useFakeTimers(); + + const thread = createTestThread({ + read: [ + { + last_read: new Date().toISOString(), + user: { id: TEST_USER_ID }, + unread_messages: 42, + }, + ], + }); + thread.registerSubscriptions(); + + const stateBefore = thread.state.getLatestValue(); + const stubbedMarkAsRead = sinon.stub(thread, 'markAsRead').resolves(); + expect(stateBefore.active).to.be.false; + expect(thread.ownUnreadCount).to.equal(42); + expect(stubbedMarkAsRead.called).to.be.false; + + thread.activate(); + clock.runAll(); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.active).to.be.true; + expect(stubbedMarkAsRead.calledOnce).to.be.true; + + client.dispatchEvent({ + type: 'message.new', + message: generateMsg({ parent_id: thread.id, user: { id: 'bob' } }) as MessageResponse, + user: { id: 'bob' }, + }); + clock.runAll(); + + expect(stubbedMarkAsRead.calledTwice).to.be.true; + + thread.unregisterSubscriptions(); + clock.restore(); + }); + + 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.hasStaleState).to.be.true; + expect(stubbedGetThread.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); + + thread.unregisterSubscriptions(); + }); + + 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, + user: { id: 'bob' }, + }); + + expect(thread.hasStaleState).to.be.false; + + client.dispatchEvent({ + type: 'user.watching.stop', + channel: generateChannel().channel as ChannelResponse, + user: { id: TEST_USER_ID }, + }); + + 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, + user: { id: TEST_USER_ID }, + }); + + expect(thread.hasStaleState).to.be.true; + + thread.unregisterSubscriptions(); + }); + }); + + 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: 'bob' }, + thread: generateThread(channelResponse, generateMsg()) as ThreadResponse, + }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.read['bob']?.unreadMessageCount).to.equal(42); + }); + + 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(); + + const stateBefore = thread.state.getLatestValue(); + expect(stateBefore.read['bob']?.unreadMessageCount).to.equal(42); + const createdAt = new Date(); + + client.dispatchEvent({ + type: 'message.read', + user: { id: 'bob' }, + thread: generateThread(channelResponse, generateMsg({ id: parentMessageResponse.id })) as ThreadResponse, + created_at: createdAt.toISOString(), + }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.read['bob']?.unreadMessageCount).to.equal(0); + expect(stateAfter.read['bob']?.lastReadAt.toISOString()).to.equal(createdAt.toISOString()); + + thread.unregisterSubscriptions(); + }); + }); + + 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 }, + }); + + 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', () => { + 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 }, + }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateBefore).to.equal(stateAfter); + + thread.unregisterSubscriptions(); + }); + + 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' }, + }); + + 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('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 stateBefore = thread.state.getLatestValue(); + expect(stateBefore.replies).to.have.length(2); + expect(thread.ownUnreadCount).to.equal(0); + + client.dispatchEvent({ + type: 'message.new', + message, + user: { id: TEST_USER_ID }, + }); + + const stateAfter = thread.state.getLatestValue(); + expect(stateAfter.replies).to.have.length(2); + expect(thread.ownUnreadCount).to.equal(0); + }); + }); + + 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('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: messageToDelete, + }); + + 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(); + }); + }); + + 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, + message: generateMsg({ parent_id: thread.id }) as MessageResponse, + }); + + expect(updateParentMessageOrReplyLocallySpy.calledOnce).to.be.true; + + thread.unregisterSubscriptions(); + }); + }); + }); + }); + }); + + describe('ThreadManager', () => { + 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 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, expectedUnreadCount]) => { + it(`updates unread thread count on "${eventType}"`, () => { + client.dispatchEvent({ + type: eventType, + unread_threads: expectedUnreadCount, + }); + + const { unreadThreadCount } = threadManager.state.getLatestValue(); + expect(unreadThreadCount).to.equal(expectedUnreadCount); + }); + }); + + describe('Event: notification.thread_message_new', () => { + it('ignores notification.thread_message_new before anything was loaded', () => { + client.dispatchEvent({ + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: uuidv4() }) as MessageResponse, + }); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + }); + + it('tracks new unseen threads', () => { + threadManager.state.partialNext({ ready: true }); + + client.dispatchEvent({ + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: uuidv4() }) as MessageResponse, + }); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.have.lengthOf(1); + }); + + it('deduplicates unseen threads', () => { + threadManager.state.partialNext({ ready: true }); + 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('tracks thread order becoming stale', () => { + const thread = createTestThread(); + threadManager.state.partialNext({ + threads: [thread], + ready: 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(), + type: 'notification.thread_message_new', + message: generateMsg({ parent_id: thread.id }) as MessageResponse, + }); + + const stateAfter = threadManager.state.getLatestValue(); + expect(stateAfter.isThreadOrderStale).to.be.true; + expect(stateAfter.unseenThreadIds).to.be.empty; + }); + }); + + 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({ + type: 'connection.changed', + online: false, + }); + + const { lastConnectionDropAt } = threadManager.state.getLatestValue(); + expect(lastConnectionDropAt).to.be.a('date'); + + client.dispatchEvent({ type: 'connection.recovered' }); + clock.runAll(); + + expect(stub.calledOnce).to.be.true; + + threadManager.unregisterSubscriptions(); + clock.restore(); + }); + + it('reloads list on activation', () => { + const stub = sinon.stub(threadManager, 'reload').resolves(); + threadManager.activate(); + expect(stub.called).to.be.true; + }); + + 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: [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(unregisterThread1.calledOnce).to.be.true; + expect(unregisterThread2.calledOnce).to.be.true; + expect(unregisterThread3.calledOnce).to.be.true; + }); + }); + + describe('Methods & Getters', () => { + let stubbedQueryThreads: sinon.SinonStub< + Parameters, + ReturnType + >; + + beforeEach(() => { + stubbedQueryThreads = sinon.stub(client, 'queryThreads').resolves({ + threads: [], + next: undefined, + }); + }); + + 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() } }); + + expect(threadManager.threadsById).to.be.empty; + + 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('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 }); + await threadManager.reload(); + expect(stubbedQueryThreads.notCalled).to.be.true; + }); + + it('reloads if thread list order is stale', async () => { + threadManager.state.partialNext({ isThreadOrderStale: true }); + + await threadManager.reload(); + + expect(threadManager.state.getLatestValue().isThreadOrderStale).to.be.false; + expect(stubbedQueryThreads.calledOnce).to.be.true; + }); + + it('reloads if there are new unseen threads', async () => { + threadManager.state.partialNext({ unseenThreadIds: [uuidv4()] }); + + await threadManager.reload(); + + expect(threadManager.state.getLatestValue().unseenThreadIds).to.be.empty; + expect(stubbedQueryThreads.calledOnce).to.be.true; + }); + + 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 instances to the list', async () => { + const thread = createTestThread(); + threadManager.state.partialNext({ unseenThreadIds: [thread.id] }); + stubbedQueryThreads.resolves({ + threads: [thread], + next: undefined, + }); + + await threadManager.reload(); + + const { threads, unseenThreadIds } = threadManager.state.getLatestValue(); + + expect(threads).to.contain(thread); + expect(unseenThreadIds).to.be.empty; + }); + + 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, + }); + + 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], + unseenThreadIds: [newThread.id], + }); + stubbedQueryThreads.resolves({ + threads: [newThread], + next: undefined, + }); + + await threadManager.reload(); + + const { threads } = threadManager.state.getLatestValue(); + + expect(threads).to.have.lengthOf(1); + expect(threads).to.contain(existingThread); + expect(existingThread.state.getLatestValue().participants).to.have.lengthOf(1); + }); + + 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: [newThread1, existingThread, newThread2], + next: undefined, + }); + + await threadManager.reload(); + + const { threads } = threadManager.state.getLatestValue(); + + expect(threads[1]).to.equal(existingThread); + }); + }); + + 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(); + + expect(stubbedQueryThreads.called).to.be.false; + }); + + it('prevents loading next page if already loading', async () => { + threadManager.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + isLoadingNext: true, + nextCursor: 'cursor', + }, + })); + + await threadManager.loadNextPage(); + + expect(stubbedQueryThreads.called).to.be.false; + }); + + it('forms correct request when loading next page', async () => { + threadManager.state.next((current) => ({ + ...current, + pagination: { + ...current.pagination, + nextCursor: 'cursor', + }, + })); + stubbedQueryThreads.resolves({ + threads: [], + next: undefined, + }); + + await threadManager.loadNextPage(); + + expect( + 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.pagination.isLoadingNext], spy); + spy.resetHistory(); + + await threadManager.loadNextPage(); + + expect(spy.callCount).to.equal(2); + expect(spy.firstCall.calledWith([true])).to.be.true; + expect(spy.lastCall.calledWith([false])).to.be.true; + }); + + 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: 'cursor2', + }); + + await threadManager.loadNextPage(); + + const { threads, pagination } = threadManager.state.getLatestValue(); + + expect(threads).to.have.lengthOf(2); + expect(threads[1]).to.equal(newThread); + expect(pagination.nextCursor).to.equal('cursor2'); + }); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 48fefc7413..be99174413 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" @@ -5910,7 +5914,7 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@^4.2.3: +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== @@ -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"