diff --git a/src/experimental/Search/SearchBar/SearchBar.tsx b/src/experimental/Search/SearchBar/SearchBar.tsx index 53300b3a2..5b1708dec 100644 --- a/src/experimental/Search/SearchBar/SearchBar.tsx +++ b/src/experimental/Search/SearchBar/SearchBar.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import React, { useCallback, useEffect } from 'react'; import { useSearchContext } from '../SearchContext'; +import { useSearchQueriesInProgress } from '../hooks'; import { useTranslationContext } from '../../../context'; import { useStateStore } from '../../../store'; import type { SearchControllerState } from '../SearchController'; @@ -8,7 +9,6 @@ import type { SearchControllerState } from '../SearchController'; const searchControllerStateSelector = (nextValue: SearchControllerState) => ({ input: nextValue.input, isActive: nextValue.isActive, - queriesInProgress: nextValue.queriesInProgress, searchQuery: nextValue.searchQuery, }); @@ -21,8 +21,9 @@ export const SearchBar = () => { placeholder, searchController, } = useSearchContext(); + const queriesInProgress = useSearchQueriesInProgress(searchController); - const { input, isActive, queriesInProgress, searchQuery } = useStateStore( + const { input, isActive, searchQuery } = useStateStore( searchController.state, searchControllerStateSelector, ); diff --git a/src/experimental/Search/SearchController.ts b/src/experimental/Search/SearchController.ts index 89496d7b3..c464f4d14 100644 --- a/src/experimental/Search/SearchController.ts +++ b/src/experimental/Search/SearchController.ts @@ -1,4 +1,3 @@ -import debounce from 'lodash.debounce'; import { StateStore } from 'stream-chat'; import type { Channel, @@ -16,7 +15,82 @@ import type { UserSort, } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../types'; -import type { DebouncedFunc } from 'lodash'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface DebouncedFunc any> { + /** + * Call the original function, but applying the debounce rules. + * + * If the debounced function can be run immediately, this calls it and returns its return + * value. + * + * Otherwise, it returns the return value of the last invocation, or undefined if the debounced + * function was not invoked yet. + */ + (...args: Parameters): ReturnType | undefined; + + /** + * Throw away any pending invocation of the debounced function. + */ + cancel(): void; + + /** + * If there is a pending invocation of the debounced function, invoke it immediately and return + * its return value. + * + * Otherwise, return the value from the last invocation, or undefined if the debounced function + * was never invoked. + */ + flush(): ReturnType | undefined; +} + +// works exactly the same as lodash.debounce +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const debounce = any>( + fn: T, + timeout = 0, + { leading = false, trailing = true }: { leading?: boolean; trailing?: boolean } = {}, +): DebouncedFunc => { + let runningTimeout: null | NodeJS.Timeout = null; + let argsForTrailingExecution: Parameters | null = null; + let lastResult: ReturnType | undefined; + + const debouncedFn = (...args: Parameters) => { + if (runningTimeout) { + clearTimeout(runningTimeout); + } else if (leading) { + lastResult = fn(...args); + } + if (trailing) argsForTrailingExecution = args; + + const timeoutHandler = () => { + if (argsForTrailingExecution) { + lastResult = fn(...argsForTrailingExecution); + argsForTrailingExecution = null; + } + runningTimeout = null; + }; + + runningTimeout = setTimeout(timeoutHandler, timeout); + return lastResult; + }; + + debouncedFn.cancel = () => { + if (runningTimeout) clearTimeout(runningTimeout); + }; + + debouncedFn.flush = () => { + if (runningTimeout) { + clearTimeout(runningTimeout); + runningTimeout = null; + if (argsForTrailingExecution) { + lastResult = fn(...argsForTrailingExecution); + } + } + return lastResult; + }; + return debouncedFn; +}; // eslint-disable-next-line @typescript-eslint/ban-types export type SearchSourceType = 'channels' | 'users' | 'messages' | (string & {}); @@ -67,7 +141,7 @@ export type SearchSourceOptions = { }; const DEFAULT_SEARCH_SOURCE_OPTIONS: Required = { - debounceMs: 300, + debounceMs: 5000, isActive: false, pageSize: 10, } as const; @@ -151,6 +225,15 @@ export abstract class BaseSearchSource implements SearchSource { }; async executeQuery(searchQuery: string) { + const hasNewSearchQuery = typeof searchQuery !== 'undefined'; + if (!this.isActive || this.isLoading || !this.hasMore || !searchQuery) return; + + if (hasNewSearchQuery) { + this.resetState({ isActive: this.isActive, isLoading: true, searchQuery }); + } else { + this.state.partialNext({ isLoading: true }); + } + const stateUpdate: Partial> = {}; try { const results = await this.query(searchQuery); @@ -180,21 +263,6 @@ export abstract class BaseSearchSource implements SearchSource { } search = async (searchQuery?: string) => { - if (!this.isActive) return; - const hasNewSearchQuery = typeof searchQuery !== 'undefined'; - const preventLoadMore = - (!hasNewSearchQuery && !this.hasMore) || - this.isLoading || - (!hasNewSearchQuery && !this.searchQuery); - const preventSearchStart = hasNewSearchQuery && this.isLoading; - if (preventLoadMore || preventSearchStart) return; - - if (hasNewSearchQuery) { - this.resetState({ isActive: this.isActive, isLoading: true, searchQuery }); - } else { - this.state.partialNext({ isLoading: true }); - } - await new Promise((resolve) => { this.resolveDebouncedSearch = resolve; this.searchDebounced(searchQuery ?? this.searchQuery); @@ -416,7 +484,6 @@ export type SearchControllerState< Sources extends SearchSource[] = DefaultSearchSources > = { isActive: boolean; - queriesInProgress: Array; searchQuery: string; sources: Sources; // FIXME: focusedMessage should live in a MessageListController class that does not exist yet. @@ -448,7 +515,6 @@ export class SearchController< constructor({ config, sources }: SearchControllerOptions = {}) { this.state = new StateStore>({ isActive: false, - queriesInProgress: [], searchQuery: '', sources: sources ?? (([] as unknown) as Sources), }); @@ -470,10 +536,6 @@ export class SearchController< return this.state.getLatestValue().isActive; } - get queriesInProgress() { - return this.state.getLatestValue().queriesInProgress; - } - get searchQuery() { return this.state.getLatestValue().searchQuery; } @@ -482,14 +544,6 @@ export class SearchController< return this.sources.map((s) => s.type) as Sources[number]['type'][]; } - get isCleared() { - return this.activeSources.every((s) => !s.hasResults && !s.isLoading && !s.searchQuery); - } - - get isLoading() { - return this.state.getLatestValue().queriesInProgress.length > 0; - } - setInputElement = (input: HTMLInputElement) => { this.state.partialNext({ input }); }; @@ -558,13 +612,9 @@ export class SearchController< search = async (searchQuery?: string) => { const searchedSources = this.activeSources; this.state.partialNext({ - queriesInProgress: searchedSources.map((s) => s.type), searchQuery, }); await Promise.all(searchedSources.map((source) => source.search(searchQuery))); - this.state.partialNext({ - queriesInProgress: [], - }); }; cancelSearchQueries = () => { diff --git a/src/experimental/Search/hooks/index.ts b/src/experimental/Search/hooks/index.ts new file mode 100644 index 000000000..4cbd2305f --- /dev/null +++ b/src/experimental/Search/hooks/index.ts @@ -0,0 +1 @@ +export * from './useSearchQueriesInProgress'; diff --git a/src/experimental/Search/hooks/useSearchQueriesInProgress.ts b/src/experimental/Search/hooks/useSearchQueriesInProgress.ts new file mode 100644 index 000000000..1ce44b36a --- /dev/null +++ b/src/experimental/Search/hooks/useSearchQueriesInProgress.ts @@ -0,0 +1,54 @@ +import { + DefaultSearchSources, + SearchController, + SearchControllerState, + SearchSource, +} from '../SearchController'; +import { useEffect, useState } from 'react'; +import type { DefaultStreamChatGenerics } from '../../../types'; +import { useStateStore } from '../../../store'; + +const searchControllerStateSelector = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + Sources extends SearchSource[] = DefaultSearchSources +>( + value: SearchControllerState, +) => ({ + sources: value.sources, +}); + +export type UseSearchQueriesInProgressParams< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + Sources extends SearchSource[] = DefaultSearchSources +> = { + searchController: SearchController; +}; + +export const useSearchQueriesInProgress = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + Sources extends SearchSource[] = DefaultSearchSources +>( + searchController: SearchController, +) => { + const [queriesInProgress, setQueriesInProgress] = useState([]); + const { sources } = useStateStore(searchController.state, searchControllerStateSelector); + + useEffect(() => { + const subscriptions = sources.map((source) => + source.state.subscribeWithSelector( + (value) => ({ isLoading: value.isLoading }), + ({ isLoading }) => { + setQueriesInProgress((prev) => { + if (isLoading) return prev.concat(source.type); + return prev.filter((type) => type !== source.type); + }); + }, + ), + ); + + return () => { + subscriptions.forEach((unsubscribe) => unsubscribe()); + }; + }, [sources]); + return queriesInProgress; +};