UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

315 lines (268 loc) 10.1 kB
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 const THREAD_MANAGER_INITIAL_STATE = { active: false, isThreadOrderStale: false, threads: [], unreadThreadCount: 0, unseenThreadIds: [], lastConnectionDropAt: null, pagination: { isLoading: false, isLoadingNext: false, nextCursor: null, }, ready: false, }; export type ThreadManagerState<SCG extends ExtendableGenerics = DefaultGenerics> = { active: boolean; isThreadOrderStale: boolean; lastConnectionDropAt: Date | null; pagination: ThreadManagerPagination; ready: boolean; threads: Thread<SCG>[]; 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<SCG extends ExtendableGenerics = DefaultGenerics> { public readonly state: StateStore<ThreadManagerState<SCG>>; private client: StreamChat<SCG>; private unsubscribeFunctions: Set<() => void> = new Set(); private threadsByIdGetterCache: { threads: ThreadManagerState<SCG>['threads']; threadsById: Record<string, Thread<SCG> | undefined>; }; constructor({ client }: { client: StreamChat<SCG> }) { this.client = client; this.state = new StateStore<ThreadManagerState<SCG>>(THREAD_MANAGER_INITIAL_STATE); 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<Record<string, Thread<SCG>>>((newThreadsById, thread) => { newThreadsById[thread.id] = thread; return newThreadsById; }, {}); this.threadsByIdGetterCache.threads = threads; this.threadsByIdGetterCache.threadsById = threadsById; return threadsById; } public resetState = () => { this.state.next(THREAD_MANAGER_INITIAL_STATE); }; 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()); this.unsubscribeFunctions.add(this.subscribeChannelDeleted()); }; private subscribeUnreadThreadsCountChange = () => { // initiate const { unread_threads: unreadThreadCount = 0 } = (this.client.user as OwnUserResponse<SCG>) ?? {}; 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 subscribeChannelDeleted = () => this.client.on('notification.channel_deleted', (event) => { const { cid } = event; const { threads } = this.state.getLatestValue(); const newThreads = threads.filter((thread) => thread.channel.cid !== cid); this.state.partialNext({ threads: newThreads }); }).unsubscribe; private subscribeManageThreadSubscriptions = () => this.state.subscribeWithSelector( (nextValue) => ({ threads: nextValue.threads }), ({ threads: nextThreads }, prev) => { const { threads: 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) => ({ active: nextValue.active }), ({ active }) => { if (active) this.reload(); }, ); private subscribeNewReplies = () => this.client.on('notification.thread_message_new', (event: Event<SCG>) => { 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) || MAX_QUERY_THREADS_LIMIT, }); const currentThreads = this.threadsById; const nextThreads: Thread<SCG>[] = []; 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<QueryThreadsOptions, 'next'> = {}) => { 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, }, })); } }; }