mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
174 lines
6.4 KiB
TypeScript
174 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { getSearchAccounts } from '@/lib/global-search/accounts';
|
|
import { GLOBAL_SEARCH_PROVIDERS } from '@/lib/global-search/providers';
|
|
import { parseSearchQuery, type ParsedQuery, type SearchScope } from '@/lib/global-search/query-parser';
|
|
import { mergeHits, rankHits } from '@/lib/global-search/rank';
|
|
import { hasRemoteQuery, runGlobalSearch } from '@/lib/global-search/run-global-search';
|
|
import {
|
|
emptyOutcome,
|
|
type SearchAccount,
|
|
type SearchKind,
|
|
type SearchOutcome,
|
|
type SearchProvider,
|
|
} from '@/lib/global-search/types';
|
|
|
|
export interface UseGlobalSearchOptions {
|
|
query: string;
|
|
/** Scope chip; an explicit `in:` in the query wins over it. */
|
|
scope: SearchScope;
|
|
/** Account chip (`AccountEntry.id`); null = every login. */
|
|
accountId: string | null;
|
|
/** Hits per kind from the local caches. */
|
|
localLimit: number;
|
|
/** Hits per kind per login from the server. */
|
|
remoteLimit: number;
|
|
/** Delay before the server pass; the local pass runs immediately. */
|
|
debounceMs?: number;
|
|
enabled?: boolean;
|
|
/** Injection point for tests. */
|
|
providers?: readonly SearchProvider[];
|
|
accounts?: () => SearchAccount[];
|
|
}
|
|
|
|
export interface UseGlobalSearchResult {
|
|
parsed: ParsedQuery;
|
|
outcome: SearchOutcome;
|
|
/** Any kind still waiting on a server response. */
|
|
isSearching: boolean;
|
|
/** True when the query has nothing to search for (empty, or operators only with no server-side meaning). */
|
|
isEmpty: boolean;
|
|
/** Re-run the server pass now (Enter in the palette, refresh in the tab). */
|
|
rerun: () => void;
|
|
/** Mail pagination for the search tab: fetches the next page and appends. */
|
|
loadMoreMail: () => void;
|
|
mailLimit: number;
|
|
}
|
|
|
|
/**
|
|
* Runs a global search for a query and keeps the merged outcome up to date:
|
|
* local cache hits synchronously on every keystroke, server hits after the
|
|
* debounce, partial results published as each login answers. Stale responses
|
|
* are dropped by search id, so a fast typist never sees results for an
|
|
* earlier query flash in.
|
|
*/
|
|
export function useGlobalSearch(options: UseGlobalSearchOptions): UseGlobalSearchResult {
|
|
const {
|
|
query, scope, accountId, localLimit, remoteLimit,
|
|
debounceMs = 300, enabled = true,
|
|
providers = GLOBAL_SEARCH_PROVIDERS,
|
|
accounts: accountsSource = getSearchAccounts,
|
|
} = options;
|
|
|
|
const parsed = useMemo(() => {
|
|
const base = parseSearchQuery(query);
|
|
return base.scope === 'all' && scope !== 'all' ? { ...base, scope } : base;
|
|
}, [query, scope]);
|
|
|
|
const [outcome, setOutcome] = useState<SearchOutcome>(emptyOutcome);
|
|
const [mailLimit, setMailLimit] = useState(remoteLimit);
|
|
const [runToken, setRunToken] = useState(0);
|
|
const searchIdRef = useRef(0);
|
|
const controllerRef = useRef<AbortController | null>(null);
|
|
const outcomeRef = useRef(outcome);
|
|
outcomeRef.current = outcome;
|
|
|
|
const resolveAccounts = useCallback(() => {
|
|
const all = accountsSource();
|
|
return accountId ? all.filter((a) => a.localAccountId === accountId) : all;
|
|
}, [accountsSource, accountId]);
|
|
|
|
const hasAnything = parsed.terms.length > 0
|
|
|| (['mail', 'contacts', 'calendar', 'files'] as SearchKind[]).some((kind) => hasRemoteQuery(parsed, kind));
|
|
const isEmpty = !hasAnything;
|
|
|
|
// Reset pagination whenever the query itself changes.
|
|
useEffect(() => {
|
|
setMailLimit(remoteLimit);
|
|
}, [query, scope, accountId, remoteLimit]);
|
|
|
|
useEffect(() => {
|
|
controllerRef.current?.abort();
|
|
const searchId = ++searchIdRef.current;
|
|
if (!enabled || isEmpty) {
|
|
setOutcome(emptyOutcome());
|
|
return;
|
|
}
|
|
const controller = new AbortController();
|
|
controllerRef.current = controller;
|
|
const accounts = resolveAccounts();
|
|
|
|
// Instant pass: whatever the caches already hold, no round-trip.
|
|
const local = emptyOutcome();
|
|
for (const provider of providers) {
|
|
if (parsed.scope !== 'all' && provider.kind !== parsed.scope) continue;
|
|
if (parsed.terms.length > 0) {
|
|
try {
|
|
local.hits[provider.kind] = rankHits(mergeHits([], provider.local(parsed, accounts, localLimit)), parsed);
|
|
} catch {
|
|
// A cache mid-mutation must not break typing; the server pass follows.
|
|
}
|
|
}
|
|
const willAsk = hasRemoteQuery(parsed, provider.kind) && accounts.some((a) => provider.supports(a));
|
|
local.status[provider.kind].status = willAsk ? 'loading' : 'done';
|
|
}
|
|
setOutcome(local);
|
|
|
|
const timer = setTimeout(() => {
|
|
if (controller.signal.aborted) return;
|
|
void runGlobalSearch({
|
|
parsed,
|
|
accounts,
|
|
providers: [...providers],
|
|
localLimit,
|
|
remoteLimit: mailLimit > remoteLimit ? mailLimit : remoteLimit,
|
|
signal: controller.signal,
|
|
onUpdate: (next) => {
|
|
if (searchId !== searchIdRef.current) return;
|
|
setOutcome(next);
|
|
},
|
|
});
|
|
}, debounceMs);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
// `mailLimit` intentionally drives re-runs through loadMoreMail (rerun via runToken).
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [parsed, enabled, isEmpty, resolveAccounts, providers, localLimit, remoteLimit, debounceMs, runToken]);
|
|
|
|
const rerun = useCallback(() => setRunToken((n) => n + 1), []);
|
|
|
|
const loadMoreMail = useCallback(() => {
|
|
const nextLimit = mailLimit + remoteLimit;
|
|
setMailLimit(nextLimit);
|
|
const controller = new AbortController();
|
|
const searchId = searchIdRef.current;
|
|
const accounts = resolveAccounts();
|
|
const mailProviders = providers.filter((p) => p.kind === 'mail');
|
|
void runGlobalSearch({
|
|
parsed,
|
|
accounts,
|
|
providers: [...mailProviders],
|
|
localLimit,
|
|
remoteLimit: nextLimit,
|
|
includeLocal: false,
|
|
signal: controller.signal,
|
|
onUpdate: (next) => {
|
|
if (searchId !== searchIdRef.current) return;
|
|
const current = outcomeRef.current;
|
|
setOutcome({
|
|
hits: { ...current.hits, mail: rankHits(mergeHits(current.hits.mail, next.hits.mail), parsed) },
|
|
status: { ...current.status, mail: next.status.mail },
|
|
});
|
|
},
|
|
});
|
|
}, [mailLimit, remoteLimit, resolveAccounts, providers, parsed, localLimit]);
|
|
|
|
const isSearching = Object.values(outcome.status).some((s) => s.status === 'loading');
|
|
|
|
return { parsed, outcome, isSearching, isEmpty, rerun, loadMoreMail, mailLimit };
|
|
}
|