UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

404 lines (342 loc) 12.6 kB
import { StateStore } from '../store'; import { debounce, type DebouncedFunc } from '../utils'; import type { QueryReturnValue, SearchSourceOptions, SearchSourceState, SearchSourceType, } from './types'; import type { APIError } from '../errors'; import { isAPIError, isErrorRetryable } from '../errors'; /** * Passed to a search source's `query()`. Carries a cancellation handle and makes no * claim that the query performs a request: a source may resolve from local data and * ignore the signal. Where a source does issue requests, forward this straight through * as the request options. */ export type SearchQueryOptions = { /** Aborted once a newer query supersedes this one. */ signal?: AbortSignal; }; export type DebounceOptions = { /** Applies to both short and long queries unless overridden by the options below. */ debounceMs?: number; shortQueryDebounceMs?: number; longQueryDebounceMs?: number; shortQueryMaxLength?: number; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any interface ISearchSource<T = any> { activate(): void; canExecuteQuery(newSearchString?: string): boolean; deactivate(): void; readonly hasNext: boolean; readonly hasResults: boolean; readonly initialState: SearchSourceState<T>; readonly isActive: boolean; readonly isLoading: boolean; readonly items: T[] | undefined; readonly lastQueryError: Error | undefined; readonly next: string | undefined | null; readonly offset: number | undefined; resetState(): void; readonly searchQuery: string; readonly state: StateStore<SearchSourceState<T>>; readonly type: SearchSourceType; } // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface SearchSource<T = any> extends ISearchSource<T> { cancelScheduledQuery(): void; setDebounceOptions(options: DebounceOptions): void; search(text?: string): Promise<void> | undefined; } // eslint-disable-next-line @typescript-eslint/no-explicit-any export interface SearchSourceSync<T = any> extends ISearchSource<T> { cancelScheduledQuery(): void; setDebounceOptions(options: DebounceOptions): void; search(text?: string): void; } // Debounce defaults are resolved by resolveDebounceOptions, not here. const DEFAULT_SEARCH_SOURCE_OPTIONS: Required< Omit<SearchSourceOptions, keyof DebounceOptions> > = { pageSize: 10, allowEmptySearchString: false, resetOnNewSearchQuery: true, } as const; /** * An explicit `debounceMs` applies to both buckets, so integrators who already tuned it * keep exactly their configured interval. Only when it is absent do the 500/300 defaults * kick in. */ const resolveDebounceOptions = ({ debounceMs, shortQueryDebounceMs, longQueryDebounceMs, shortQueryMaxLength, }: DebounceOptions) => ({ shortQueryDebounceMs: shortQueryDebounceMs ?? debounceMs ?? 500, longQueryDebounceMs: longQueryDebounceMs ?? debounceMs ?? 300, shortQueryMaxLength: shortQueryMaxLength ?? 2, }); abstract class BaseSearchSourceBase< T, R extends void | Promise<void>, > implements ISearchSource<T> { state: StateStore<SearchSourceState<T>>; pageSize: number; protected allowEmptySearchString: boolean; protected resetOnNewSearchQuery: boolean; // assigned by setDebounceOptions, which the constructor always calls protected shortQueryDebounceMs!: number; protected longQueryDebounceMs!: number; protected shortQueryMaxLength!: number; protected searchDebounced!: DebouncedFunc<(searchString?: string) => R>; abstract readonly type: SearchSourceType; protected constructor(options?: SearchSourceOptions) { const { pageSize, allowEmptySearchString, resetOnNewSearchQuery } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options, }; this.pageSize = pageSize; this.allowEmptySearchString = allowEmptySearchString; this.resetOnNewSearchQuery = resetOnNewSearchQuery; this.state = new StateStore<SearchSourceState<T>>(this.initialState); // Field initializers run before this body, so setDebounceOptions is already // assigned; it only captures getDebounceMs lazily. this.setDebounceOptions(options ?? {}); } abstract executeQuery(newSearchString?: string): R; setDebounceOptions = (options: DebounceOptions = {}) => { const resolved = resolveDebounceOptions(options); this.shortQueryDebounceMs = resolved.shortQueryDebounceMs; this.longQueryDebounceMs = resolved.longQueryDebounceMs; this.shortQueryMaxLength = resolved.shortQueryMaxLength; this.searchDebounced = debounce(this.executeQuery.bind(this), (searchString) => this.getDebounceMs(searchString), ); }; /** * Short queries match too much and are slow server-side, so they get a longer debounce. * Resolved per call, which is what lets the interval switch as the user keeps typing. */ protected getDebounceMs = (searchString?: string) => { const { length } = searchString ?? this.searchQuery; return length <= this.shortQueryMaxLength ? this.shortQueryDebounceMs : this.longQueryDebounceMs; }; /** * A new search query preempts an in-flight one; pagination waits for it to finish * and needs a next page to fetch. */ protected canDispatchQuery(hasNewSearchQuery: boolean) { return hasNewSearchQuery || (!this.isLoading && this.hasNext); } search = (searchQuery?: string) => this.searchDebounced(searchQuery); get lastQueryError() { return this.state.getLatestValue().lastQueryError; } get hasNext() { return this.state.getLatestValue().hasNext; } get hasResults() { return Array.isArray(this.state.getLatestValue().items); } get isActive() { return this.state.getLatestValue().isActive; } get isLoading() { return this.state.getLatestValue().isLoading; } get initialState(): SearchSourceState<T> { return { hasNext: true, isActive: false, isLoading: false, items: undefined, lastQueryError: undefined, next: undefined, offset: 0, searchQuery: '', }; } get items() { return this.state.getLatestValue().items; } get next() { return this.state.getLatestValue().next; } get offset() { return this.state.getLatestValue().offset; } get searchQuery() { return this.state.getLatestValue().searchQuery; } activate = () => { if (this.isActive) return; this.state.partialNext({ isActive: true }); }; deactivate = () => { if (!this.isActive) return; this.state.partialNext({ isActive: false }); }; canExecuteQuery = (newSearchString?: string) => { const hasNewSearchQuery = typeof newSearchString !== 'undefined'; const searchString = newSearchString ?? this.searchQuery; return !!( this.isActive && this.canDispatchQuery(hasNewSearchQuery) && (this.allowEmptySearchString || searchString) ); }; protected getStateBeforeFirstQuery(newSearchString: string): SearchSourceState<T> { const initialState = this.initialState; const oldItems = this.items; const items = this.resetOnNewSearchQuery ? initialState.items : oldItems; return { ...initialState, items, isActive: this.isActive, isLoading: this.resetOnNewSearchQuery ? true : !oldItems, searchQuery: newSearchString, }; } protected getStateAfterQuery( stateUpdate: Partial<SearchSourceState<T>>, isFirstPage: boolean, ): SearchSourceState<T> { const current = this.state.getLatestValue(); return { ...current, lastQueryError: undefined, // reset lastQueryError that can be overridden by the stateUpdate ...stateUpdate, isLoading: false, items: isFirstPage ? stateUpdate.items : [...(this.items ?? []), ...(stateUpdate.items || [])], }; } protected prepareStateForQuery(newSearchString?: string) { const hasNewSearchQuery = typeof newSearchString !== 'undefined'; const searchString = newSearchString ?? this.searchQuery; if (hasNewSearchQuery) { this.state.next(this.getStateBeforeFirstQuery(newSearchString ?? '')); } else { this.state.partialNext({ isLoading: true }); } return { searchString, hasNewSearchQuery }; } protected updatePaginationStateFromQuery(result: QueryReturnValue<T>) { const { items, next } = result; const stateUpdate: Partial<SearchSourceState<T>> = {}; if (Object.prototype.hasOwnProperty.call(result, 'next')) { stateUpdate.next = next; stateUpdate.hasNext = !!next; } else { stateUpdate.offset = (this.offset ?? 0) + items.length; stateUpdate.hasNext = items.length === this.pageSize; } return stateUpdate; } resetState() { this.state.next(this.initialState); } resetStateAndActivate() { this.resetState(); this.activate(); } } export abstract class BaseSearchSource<T> extends BaseSearchSourceBase<T, Promise<void>> implements SearchSource<T> { /** Aborts the in-flight request, if any, once a newer query is dispatched. */ protected queryAbortController: AbortController | null = null; protected abstract query( searchQuery: string, options?: SearchQueryOptions, ): Promise<QueryReturnValue<T>>; protected abstract filterQueryResults(items: T[]): T[] | Promise<T[]>; /** Aborts the in-flight request, if any. Returns true when one was aborted. */ protected abortInFlightQuery() { if (!this.queryAbortController) return false; this.queryAbortController.abort(); this.queryAbortController = null; return true; } resetState() { // otherwise an in-flight response would repopulate the state we just cleared this.abortInFlightQuery(); super.resetState(); } async executeQuery(newSearchString?: string) { // Checked before anything is dispatched, so that the abort below always has a // successor that will clear isLoading. if (!this.canExecuteQuery(newSearchString)) return; // cancel the previous request before dispatching the new one this.abortInFlightQuery(); const { signal } = (this.queryAbortController = new AbortController()); const { hasNewSearchQuery, searchString } = this.prepareStateForQuery(newSearchString); let stateUpdate: Partial<SearchSourceState<T>> = {}; try { const results = await this.query(searchString, { signal }); if (!results) return; const { items } = results; stateUpdate = this.updatePaginationStateFromQuery(results); stateUpdate.items = await this.filterQueryResults(items); } catch (e) { stateUpdate.lastQueryError = e as Error; if (isAPIError(e as Error) && !isErrorRetryable(e as APIError)) { stateUpdate.hasNext = false; } } finally { // A newer query owns the state now - publishing here would clobber it with a // stale result (and with the abort error). if (!signal.aborted) { this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery)); } } } cancelScheduledQuery() { this.searchDebounced.cancel(); // Nothing will dispatch a successor query, so release the loading state the // aborted query owned - its own finally block skips the state write. if (this.abortInFlightQuery() && this.isLoading) { this.state.partialNext({ isLoading: false }); } } } // Queries are resolved locally, so there is nothing to cancel here - only the // dynamic debounce applies. export abstract class BaseSearchSourceSync<T> extends BaseSearchSourceBase<T, void> implements SearchSourceSync<T> { protected abstract query(searchQuery: string): QueryReturnValue<T>; protected abstract filterQueryResults(items: T[]): T[]; executeQuery(newSearchString?: string) { if (!this.canExecuteQuery(newSearchString)) return; const { hasNewSearchQuery, searchString } = this.prepareStateForQuery(newSearchString); let stateUpdate: Partial<SearchSourceState<T>> = {}; try { const results = this.query(searchString); if (!results) return; const { items } = results; stateUpdate = this.updatePaginationStateFromQuery(results); stateUpdate.items = this.filterQueryResults(items); } catch (e) { stateUpdate.lastQueryError = e as Error; if (isAPIError(e as Error) && !isErrorRetryable(e as APIError)) { stateUpdate.hasNext = false; } } finally { this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery)); } } cancelScheduledQuery() { this.searchDebounced.cancel(); } }