Merge pull request #1419 from HOLOEYE-JZ/feat/plugin-search-results-pending

# Conflicts:
#	components/email/plugin-search-results.tsx
This commit is contained in:
Linus Rath
2026-10-29 14:11:34 +01:00
36 changed files with 272 additions and 41 deletions
@@ -8,7 +8,11 @@ import type { Email } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePluginStore } from '@/stores/plugin-store';
import { PluginSearchResults } from '../plugin-search-results';
import { PENDING_GRACE_MS, PluginSearchResults } from '../plugin-search-results';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => (key === 'more_results_searching' ? 'Searching for more results…' : key),
}));
// The block above a search's message list with the entries plugins returned
// from onProvideSearchResults; a click goes back to the plugin as onAction,
@@ -31,7 +35,7 @@ const withEmail = (key: string, email: { id: string; accountId?: string }): Prov
beforeEach(() => {
setActionDispatcher(dispatch);
usePluginStore.setState({ plugins: [{ id: 'chat', name: 'Team chat' }] } as never);
useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, selectedEmail: null });
useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, selectedEmail: null, externalSearchPending: [] });
useAuthStore.setState({
activeAccountId: 'login-1',
getAllConnectedClients: () => new Map([['login-2', client('acc-2')], ['login-1', client('acc-1', ['group-1'])]]),
@@ -39,6 +43,7 @@ beforeEach(() => {
});
afterEach(() => {
vi.useRealTimers();
setActionDispatcher(null);
dispatch.mockClear();
});
@@ -158,4 +163,63 @@ describe('PluginSearchResults', () => {
expect(screen.queryByText('Title a')).toBeNull();
expect(heading).toHaveAttribute('aria-expanded', 'false');
});
it('says nothing about a plugin that answers within the grace', () => {
vi.useFakeTimers();
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [], externalSearchPending: ['crm'] });
const { container } = render(<PluginSearchResults surface="search-results" />);
act(() => vi.advanceTimersByTime(PENDING_GRACE_MS - 1));
act(() => useEmailStore.setState({ externalSearchPending: [] }));
act(() => vi.advanceTimersByTime(PENDING_GRACE_MS));
expect(container).toBeEmptyDOMElement();
});
it('holds the block\'s place with placeholder rows while a plugin takes longer, then fills it', () => {
vi.useFakeTimers();
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [], externalSearchPending: ['crm'] });
const { container } = render(<PluginSearchResults surface="search-results" />);
expect(container).toBeEmptyDOMElement();
act(() => vi.advanceTimersByTime(PENDING_GRACE_MS));
const waiting = screen.getByRole('region', { name: 'Searching for more results…' });
expect(waiting).toHaveAttribute('aria-busy', 'true');
const block = container.querySelector('[data-plugin-surface]');
expect(block).toHaveClass('h-48');
act(() => useEmailStore.setState({ externalSearchResults: [result('crm', 'a', 'CRM')], externalSearchPending: [] }));
expect(screen.queryByRole('region', { name: 'Searching for more results…' })).toBeNull();
expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title a');
// The same height as while it waited: the list below does not move again.
expect(container.querySelector('[data-plugin-surface]')).toHaveClass('h-48');
});
it('shows the entries of a plugin that answered beside the one still searching', () => {
vi.useFakeTimers();
useEmailStore.setState({
searchQuery: 'resolution',
externalSearchResults: [result('chat', 'b')],
externalSearchPending: ['crm'],
});
render(<PluginSearchResults surface="search-results" />);
act(() => vi.advanceTimersByTime(PENDING_GRACE_MS));
expect(screen.getByRole('region', { name: 'Team chat' })).toHaveTextContent('Title b');
expect(screen.getByRole('region', { name: 'Searching for more results…' })).toBeInTheDocument();
});
it('takes the place again at the next search', () => {
vi.useFakeTimers();
useEmailStore.setState({ searchQuery: 'one', externalSearchResults: [], externalSearchPending: ['crm'] });
const { container } = render(<PluginSearchResults surface="search-results" />);
act(() => vi.advanceTimersByTime(PENDING_GRACE_MS));
act(() => useEmailStore.setState({ externalSearchResults: [result('crm', 'a', 'CRM')], externalSearchPending: [] }));
// A search the plugin answers at once: the block as without waiting.
act(() => useEmailStore.setState({ searchQuery: 'two', externalSearchResults: [], externalSearchPending: ['crm'] }));
act(() => useEmailStore.setState({ externalSearchResults: [result('crm', 'c', 'CRM')], externalSearchPending: [] }));
expect(container.querySelector('[data-plugin-surface]')).not.toHaveClass('h-48');
});
});
+52 -6
View File
@@ -1,7 +1,8 @@
"use client";
import { useId, useMemo, useState } from "react";
import { ChevronDown, ChevronRight } from "@/components/icons";
import { useEffect, useId, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { ChevronDown, ChevronRight, Loader2 } from "@/components/icons";
import { SearchSnippetText } from "@/components/email/search-snippet-text";
import { cn } from "@/lib/utils";
import { isFilterEmpty } from "@/lib/jmap/search-utils";
@@ -76,6 +77,27 @@ function isOpen(email: SearchResultEmail | undefined, open: Email | null): boole
return (open.sourceAccountId ?? own) === (email.accountId ?? own);
}
/** ms a plugin may take before the block holds its place for the entries to come. */
export const PENDING_GRACE_MS = 300;
/**
* Whether the block holds its place for this search: a plugin asked had not
* answered after PENDING_GRACE_MS. Kept once the answers came, until the next
* search asks again, so the block keeps its height and the list below does
* not move a second time. A plugin that answers at once (one that does not
* search for this query) shows nothing.
*/
function useHeldPlace(pending: boolean): boolean {
const [held, setHeld] = useState(false);
useEffect(() => {
if (!pending) return;
setHeld(false);
const timer = setTimeout(() => setHeld(true), PENDING_GRACE_MS);
return () => clearTimeout(timer);
}, [pending]);
return held;
}
interface PluginSearchResultsProps {
/** Entries plugins return from onProvideSearchResults; a click is the plugin's onAction. */
surface: "search-results";
@@ -86,15 +108,21 @@ interface PluginSearchResultsProps {
/**
* Results from elsewhere for the search that is running (CRM hits, chat
* messages, ...), above its message list. Renders nothing outside a search or
* without entries. A click on an entry naming a message the user reaches opens
* it; any other goes back to the plugin that gave the entry.
* without entries. While a plugin takes longer than PENDING_GRACE_MS the block
* shows at a fixed height with placeholder rows, which its entries then fill,
* so the list moves once, with its own results, not when a slow plugin
* answers. A click on an entry naming a message the user reaches opens it; any
* other goes back to the plugin that gave the entry.
*/
export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResultsProps) {
const results = useEmailStore((s) => s.externalSearchResults);
const openEmail = useEmailStore((s) => s.selectedEmail);
const query = useEmailStore((s) => s.searchQuery);
const searching = useEmailStore((s) => !!s.searchQuery.trim() || !isFilterEmpty(s.searchFilters));
const pending = useEmailStore((s) => s.externalSearchPending.length > 0);
const plugins = usePluginStore((s) => s.plugins);
const held = useHeldPlace(pending);
const t = useTranslations('email_list');
const idPrefix = useId();
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
@@ -103,7 +131,8 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult
[results, plugins],
);
if (!searching || groups.length === 0) return null;
const waiting = pending && held;
if (!searching || (groups.length === 0 && !waiting)) return null;
const click = (r: ProvidedSearchResult) => {
const reachable = r.email && onOpenEmail ? reachableEmail(r.email) : null;
@@ -120,7 +149,10 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult
});
return (
<div data-plugin-surface={surface} className="max-h-[40%] flex-shrink-0 overflow-y-auto border-b border-border bg-muted/30">
<div
data-plugin-surface={surface}
className={cn("max-h-[40%] flex-shrink-0 overflow-y-auto border-b border-border bg-muted/30", held && "h-48")}
>
{groups.map((group, index) => {
const open = !collapsed.has(group.id);
const listId = `${idPrefix}-${index}`;
@@ -168,6 +200,20 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult
</section>
);
})}
{waiting && (
<section aria-label={t('more_results_searching')} aria-busy="true">
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span className="truncate">{t('more_results_searching')}</span>
</div>
{[0, 1, 2].map((row) => (
<div key={row} className="flex flex-col gap-1.5 px-3 py-2" aria-hidden="true">
<div className="h-3 w-3/5 animate-pulse rounded bg-muted" />
<div className="h-2.5 w-4/5 animate-pulse rounded bg-muted" />
</div>
))}
</section>
)}
</div>
);
}
+36 -11
View File
@@ -73,12 +73,21 @@ describe('checkSearchResults', () => {
});
});
/** Asks the plugins, collecting each answer in the order it came. */
function ask(query = 'q') {
const answers: { pluginId: string; keys: string[] }[] = [];
const asked = provideSearchResults(query, {}, (pluginId, results) => {
answers.push({ pluginId, keys: results.map((r) => r.key) });
});
return { asked, answers };
}
describe('provideSearchResults', () => {
it('asks no one without handlers', async () => {
expect(await provideSearchResults('q', {})).toEqual([]);
it('asks no one without handlers', () => {
expect(ask().asked).toEqual([]);
});
it('asks the plugins at once, each with an empty list', async () => {
it('asks the plugins at once, each with an empty list, and hands over each answer as it comes', async () => {
const order: string[] = [];
emailHooks.onProvideSearchResults.register('slow', async (initial: unknown[]) => {
order.push(`slow ${initial.length}`);
@@ -90,27 +99,43 @@ describe('provideSearchResults', () => {
return [{ key: 'f', title: 'F' }];
});
const results = await provideSearchResults('q', {});
const { asked, answers } = ask();
expect(asked).toEqual(['slow', 'fast']);
expect(order).toEqual(['slow 0', 'fast 0']);
expect(results.map((r) => `${r.pluginId}:${r.key}`)).toEqual(['slow:s', 'fast:f']);
await vi.waitFor(() => expect(answers).toHaveLength(2));
expect(answers).toEqual([{ pluginId: 'fast', keys: ['f'] }, { pluginId: 'slow', keys: ['s'] }]);
});
it('leaves out a plugin that throws', async () => {
it('checks each answer', async () => {
emailHooks.onProvideSearchResults.register('p', () => [{ key: 'a', title: 'A' }, { key: 'a', title: 'twice' }, 'text']);
const { answers } = ask();
await vi.waitFor(() => expect(answers).toEqual([{ pluginId: 'p', keys: ['a'] }]));
});
it('answers nothing for a plugin that throws', async () => {
emailHooks.onProvideSearchResults.register('broken', () => { throw new Error('down'); });
emailHooks.onProvideSearchResults.register('ok', () => [{ key: 'a', title: 'A' }]);
expect((await provideSearchResults('q', {})).map((r) => r.pluginId)).toEqual(['ok']);
const { answers } = ask();
await vi.waitFor(() => expect(answers).toHaveLength(2));
expect(answers).toContainEqual({ pluginId: 'broken', keys: [] });
expect(answers).toContainEqual({ pluginId: 'ok', keys: ['a'] });
});
it('leaves out a plugin that runs out of time', async () => {
it('answers nothing for a plugin that runs out of time, the others long before', async () => {
vi.useFakeTimers();
emailHooks.onProvideSearchResults.register('hung', () => new Promise(() => {}));
emailHooks.onProvideSearchResults.register('ok', () => [{ key: 'a', title: 'A' }]);
const pending = provideSearchResults('q', {});
await vi.advanceTimersByTimeAsync(10_000);
const { answers } = ask();
await vi.advanceTimersByTimeAsync(0);
expect(answers).toEqual([{ pluginId: 'ok', keys: ['a'] }]);
expect((await pending).map((r) => r.pluginId)).toEqual(['ok']);
await vi.advanceTimersByTimeAsync(10_000);
expect(answers).toEqual([{ pluginId: 'ok', keys: ['a'] }, { pluginId: 'hung', keys: [] }]);
});
});
+16 -12
View File
@@ -306,21 +306,25 @@ export class HookBus<T extends (...args: any[]) => any> {
/**
* Ask every handler at once, each with the same initial value, and keep
* each answer with the plugin that gave it, for hooks whose results the
* host shows per plugin (onProvideSearchResults). A handler that throws or
* runs out of time gives nothing.
* host shows per plugin (onProvideSearchResults): the plugins asked, in
* their order, each with its answer to come, so the host can show one as
* soon as it comes and knows who it still waits for. A handler that throws
* or runs out of time answers null.
*/
async collect<V>(initial: V, ...rest: unknown[]): Promise<{ pluginId: string; value: unknown }[]> {
collect<V>(initial: V, ...rest: unknown[]): { pluginId: string; answer: Promise<{ value: unknown } | null> }[] {
const asked = this.handlers.filter(({ pluginId }) => !pluginErrorTracker.isDisabled(pluginId));
const answers = await Promise.all(asked.map(async ({ pluginId, handler }) => {
try {
const value: unknown = await withTimeout(pluginId, handler(initial, ...rest), this.timeoutMs);
return { pluginId, value };
} catch (err) {
pluginErrorTracker.record(pluginId, err);
return null;
}
return asked.map(({ pluginId, handler }) => ({
pluginId,
answer: (async () => {
try {
const value: unknown = await withTimeout(pluginId, handler(initial, ...rest), this.timeoutMs);
return { value };
} catch (err) {
pluginErrorTracker.record(pluginId, err);
return null;
}
})(),
}));
return answers.filter((a): a is { pluginId: string; value: unknown } => a !== null);
}
/** Fire handlers as transforms - each receives the output of the previous */
+16 -4
View File
@@ -102,9 +102,21 @@ export function checkSearchResults(pluginId: string, value: unknown): ProvidedSe
return out;
}
/** Ask every plugin for results to a search; each answer checked, in the plugins' order. */
export async function provideSearchResults(query: string, filters: unknown): Promise<ProvidedSearchResult[]> {
/**
* Ask every plugin for results to a search. Returns the plugins asked, in
* their order; each answer goes to `onAnswer` as it comes, checked (empty for
* a plugin that throws or runs out of time), so a slow plugin keeps no other
* waiting.
*/
export function provideSearchResults(
query: string,
filters: unknown,
onAnswer: (pluginId: string, results: ProvidedSearchResult[]) => void,
): string[] {
if (emailHooks.onProvideSearchResults.size === 0) return [];
const answers = await emailHooks.onProvideSearchResults.collect([] as ExternalSearchResult[], { query, filters });
return answers.flatMap(({ pluginId, value }) => checkSearchResults(pluginId, value));
const asked = emailHooks.onProvideSearchResults.collect([] as ExternalSearchResult[], { query, filters });
for (const { pluginId, answer } of asked) {
void answer.then((given) => onAnswer(pluginId, given ? checkSearchResults(pluginId, given.value) : []));
}
return asked.map(({ pluginId }) => pluginId);
}
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "صندوق البريد هذا فارغ",
"no_search_results": "لم يتم العثور على نتائج",
"no_search_results_description": "حاول تعديل البحث أو عوامل التصفية",
"more_results_searching": "جارٍ البحث عن نتائج أخرى…",
"loading": "جارٍ تحميل الرسائل...",
"unread": "غير مقروءة",
"to_me": "إليّ",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Aquesta bústia és buida",
"no_search_results": "No s'ha trobat cap resultat",
"no_search_results_description": "Proveu d'ajustar la cerca o els filtres",
"more_results_searching": "S'estan cercant més resultats…",
"loading": "Carregant els correus...",
"unread": "no llegit",
"to_me": "Per a mi",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Tato schránka je prázdná",
"no_search_results": "Nenalezeny žádné výsledky",
"no_search_results_description": "Zkuste změnit hledání nebo filtry",
"more_results_searching": "Hledají se další výsledky…",
"loading": "Načítání zpráv...",
"unread": "nepřečtené",
"to_me": "Pro mě",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Denne postkasse er tom",
"no_search_results": "Ingen resultater fundet",
"no_search_results_description": "Prøv at justere din søgning eller filtre",
"more_results_searching": "Søger efter flere resultater…",
"loading": "Indlæser e-mails...",
"unread": "ulæst",
"to_me": "Til mig",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Dieses Postfach ist leer",
"no_search_results": "Keine Ergebnisse gefunden",
"no_search_results_description": "Passen Sie Ihre Suche oder Filter an",
"more_results_searching": "Weitere Treffer werden gesucht…",
"loading": "E-Mails werden geladen...",
"unread": "ungelesen",
"to_me": "An mich",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "This mailbox is empty",
"no_search_results": "No results found",
"no_search_results_description": "Try adjusting your search or filters",
"more_results_searching": "Searching for more results…",
"loading": "Loading emails...",
"unread": "unread",
"to_me": "To me",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Este buzón está vacío",
"no_search_results": "No se encontraron resultados",
"no_search_results_description": "Intente ajustar su búsqueda o filtros",
"more_results_searching": "Buscando más resultados…",
"loading": "Cargando correos...",
"unread": "no leído",
"to_me": "Para mí",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "See postkast on tühi",
"no_search_results": "Tulemusi ei leitud",
"no_search_results_description": "Proovi otsingut või filtreid muuta",
"more_results_searching": "Otsitakse veel tulemusi…",
"loading": "Kirjade laadimine...",
"unread": "lugemata",
"to_me": "Minule",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "این صندوق پستی خالی است",
"no_search_results": "نتیجه‌ای یافت نشد",
"no_search_results_description": "جستجو یا فیلترهای خود را تنظیم کنید",
"more_results_searching": "در حال جستجوی نتایج بیشتر…",
"loading": "در حال بارگذاری ایمیل‌ها...",
"unread": "خوانده نشده",
"to_me": "به من",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Cette boîte est vide",
"no_search_results": "Aucun résultat trouvé",
"no_search_results_description": "Essayez de modifier votre recherche ou vos filtres",
"more_results_searching": "Recherche d'autres résultats…",
"loading": "Chargement des emails...",
"unread": "non lu",
"to_me": "À moi",
+1
View File
@@ -261,6 +261,7 @@
"no_emails_description": "תיבת הדואר הזו ריקה",
"no_search_results": "לא נמצאו תוצאות",
"no_search_results_description": "נסה להתאים את החיפוש או המסננים שלך",
"more_results_searching": "מחפש תוצאות נוספות…",
"loading": "טוען אימיילים...",
"unread": "לא נקרא",
"to_me": "לִי",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Ez a postafiók üres",
"no_search_results": "Nincs találat",
"no_search_results_description": "Próbáld módosítani a keresést vagy a szűrőket",
"more_results_searching": "További találatok keresése…",
"loading": "E-mailek betöltése...",
"unread": "olvasatlan",
"to_me": "Nekem",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Questa casella è vuota",
"no_search_results": "Nessun risultato trovato",
"no_search_results_description": "Prova a modificare la ricerca o i filtri",
"more_results_searching": "Ricerca di altri risultati…",
"loading": "Caricamento messaggi...",
"unread": "non letto",
"to_me": "A me",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "このメールボックスは空です",
"no_search_results": "結果が見つかりません",
"no_search_results_description": "検索やフィルターを調整してください",
"more_results_searching": "ほかの結果を検索しています…",
"loading": "メールを読み込み中...",
"unread": "未読",
"to_me": "宛先: 自分",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "편지함이 비어있어요",
"no_search_results": "검색 결과가 없어요",
"no_search_results_description": "검색어나 필터를 변경해 보세요",
"more_results_searching": "추가 결과를 검색하는 중…",
"loading": "메일을 불러오는 중...",
"unread": "읽지 않음",
"to_me": "나에게",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Mape ir tukša",
"no_search_results": "Nekas netika atrasts",
"no_search_results_description": "Mēģiniet mainīt meklēšanas vaicājumu vai filtrus",
"more_results_searching": "Meklē vēl rezultātus…",
"loading": "Ielādē vēstules...",
"unread": "nelasītas",
"to_me": "Man",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Энэ шуудангийн хайрцаг хоосон",
"no_search_results": "Илэрц олдсонгүй",
"no_search_results_description": "Хайлт эсвэл шүүлтүүрээ тохируулаад үзээрэй",
"more_results_searching": "Нэмэлт үр дүн хайж байна…",
"loading": "Имэйл ачаалж байна...",
"unread": "уншаагүй",
"to_me": "Надад",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Denne postkassen er tom",
"no_search_results": "Ingen resultater funnet",
"no_search_results_description": "Prøv å justere søket eller filtrene",
"more_results_searching": "Søker etter flere resultater…",
"loading": "Laster inn e-poster...",
"unread": "ulest",
"to_me": "Til meg",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Deze mailbox is leeg",
"no_search_results": "Geen resultaten gevonden",
"no_search_results_description": "Pas je zoekopdracht of filters aan",
"more_results_searching": "Meer resultaten zoeken…",
"loading": "E-mails laden...",
"unread": "ongelezen",
"to_me": "Aan mij",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Ta skrzynka jest pusta",
"no_search_results": "Nie znaleziono wyników",
"no_search_results_description": "Spróbuj zmienić wyszukiwanie lub filtry",
"more_results_searching": "Wyszukiwanie kolejnych wyników…",
"loading": "Ładowanie wiadomości...",
"unread": "nieprzeczytane",
"to_me": "Do mnie",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Esta caixa de correio está vazia",
"no_search_results": "Nenhum resultado encontrado",
"no_search_results_description": "Tente ajustar sua pesquisa ou filtros",
"more_results_searching": "A procurar mais resultados…",
"loading": "Carregando e-mails...",
"unread": "não lido",
"to_me": "Para mim",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Această căsuță poștală este goală",
"no_search_results": "Nu s-au găsit rezultate",
"no_search_results_description": "Încercați să modificați criteriile de căutare sau filtrele",
"more_results_searching": "Se caută mai multe rezultate…",
"loading": "Se încarcă e-mailurile...",
"unread": "necitit",
"to_me": "Pentru mine",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Папка пуста",
"no_search_results": "Результатов не найдено",
"no_search_results_description": "Попробуйте изменить поисковый запрос или фильтры",
"more_results_searching": "Ищем другие результаты…",
"loading": "Загрузка писем...",
"unread": "непрочитанные",
"to_me": "Мне",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Táto schránka je prázdna",
"no_search_results": "Neboli nájdené žiadne výsledky",
"no_search_results_description": "Skúste zmeniť hľadanie alebo filtre",
"more_results_searching": "Hľadajú sa ďalšie výsledky…",
"loading": "Načítavanie e-mailov...",
"unread": "neprečítané",
"to_me": "Pre mňa",
+1
View File
@@ -534,6 +534,7 @@
"no_emails_description": "กล่องจดหมายนี้ว่างเปล่า",
"no_search_results": "ไม่พบผลการค้นหา",
"no_search_results_description": "ลองปรับเปลี่ยนคำค้นหาหรือตัวกรองของคุณ",
"more_results_searching": "กำลังค้นหาผลลัพธ์เพิ่มเติม…",
"loading": "กำลังโหลดอีเมล...",
"unread": "ยังไม่อ่าน",
"to_me": "ถึงฉัน",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Bu posta kutusu boş",
"no_search_results": "Sonuç bulunamadı",
"no_search_results_description": "Arama teriminizi veya filtrelerinizi değiştirmeyi deneyin",
"more_results_searching": "Başka sonuçlar aranıyor…",
"loading": "E-postalar yükleniyor...",
"unread": "okunmamış",
"to_me": "Bana",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "Ця поштова скринька порожня",
"no_search_results": "Результатів не знайдено",
"no_search_results_description": "Спробуйте налаштувати пошук або фільтри",
"more_results_searching": "Шукаємо інші результати…",
"loading": "Завантаження електронних листів...",
"unread": "непрочитаний",
"to_me": "мені",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "此信件匣中沒有郵件",
"no_search_results": "找不到結果",
"no_search_results_description": "請調整搜尋條件或篩選器",
"more_results_searching": "正在搜尋更多結果…",
"loading": "正在載入郵件…",
"unread": "未讀",
"to_me": "寄給我",
+1
View File
@@ -296,6 +296,7 @@
"no_emails_description": "此邮箱为空",
"no_search_results": "未找到结果",
"no_search_results_description": "请调整搜索条件或筛选器",
"more_results_searching": "正在搜索更多结果…",
"loading": "正在加载邮件...",
"unread": "未读",
"to_me": "发给我",
@@ -50,6 +50,7 @@ beforeEach(() => {
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
searchAbortController: null,
externalSearchResults: [],
externalSearchPending: [],
emails: [],
});
});
@@ -123,4 +124,40 @@ describe('onProvideSearchResults', () => {
await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(1));
expect(useEmailStore.getState().externalSearchResults[0].pluginId).toBe('crm');
});
it('keeps the plugins it still waits for as pending, and shows each one\'s entries as soon as it answers', async () => {
const slow = deferred<ExternalSearchResult[]>();
const fast = deferred<ExternalSearchResult[]>();
emailHooks.onProvideSearchResults.register('slow', () => slow.promise);
emailHooks.onProvideSearchResults.register('fast', () => fast.promise);
await useEmailStore.getState().searchEmails(client, 'resolution');
expect(useEmailStore.getState().externalSearchPending).toEqual(['slow', 'fast']);
fast.resolve([entry('f')]);
await vi.waitFor(() => expect(useEmailStore.getState().externalSearchPending).toEqual(['slow']));
expect(useEmailStore.getState().externalSearchResults.map((r) => r.key)).toEqual(['f']);
slow.resolve([entry('s')]);
await vi.waitFor(() => expect(useEmailStore.getState().externalSearchPending).toEqual([]));
// In the plugins' order, not the answers'.
expect(useEmailStore.getState().externalSearchResults.map((r) => r.key)).toEqual(['s', 'f']);
});
it('waits for no one without plugins', async () => {
await useEmailStore.getState().searchEmails(client, 'resolution');
expect(useEmailStore.getState().externalSearchPending).toEqual([]);
});
it('waits no more for the plugins of a search that was replaced', async () => {
emailHooks.onProvideSearchResults.register('crm', (_results: ExternalSearchResult[], { query }: { query: string }) =>
(query === 'one' ? new Promise(() => {}) : [entry('two')]));
await useEmailStore.getState().searchEmails(client, 'one');
await useEmailStore.getState().searchEmails(client, 'two');
await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(1));
expect(useEmailStore.getState().externalSearchPending).toEqual([]);
});
});
+20 -6
View File
@@ -302,6 +302,8 @@ interface EmailStore {
searchAbortController: AbortController | null;
/** Plugins' results from elsewhere for the search running (onProvideSearchResults), drawn above its list. */
externalSearchResults: ProvidedSearchResult[];
/** The plugins asked for the search running that have not answered yet, so the block can say more is coming. */
externalSearchPending: string[];
// Unified mailbox state
// Bumped by any action that changes which mailbox/keyword/unified view is
@@ -3230,6 +3232,8 @@ async function pluginSearchResultIds(query: string, filters: unknown, emails: Em
* Plugins' results for the search that is running (onProvideSearchResults).
* Asked beside the search, not before its list is shown: a slow plugin never
* delays the messages, and the answer to a search that was replaced is dropped.
* Each plugin's entries show as soon as it answers, in the plugins' order; the
* ones still asked are kept as pending.
*/
let providedSearchRun: object | null = null;
@@ -3240,15 +3244,24 @@ function startProvidedSearchResults(
): void {
const run = {};
providedSearchRun = run;
const answers = new Map<string, ProvidedSearchResult[]>();
let asked: string[] = [];
// Written only when it changes: a search without plugin results renders as before.
const show = (results: ProvidedSearchResult[]) => {
if (results.length > 0 || store.get().externalSearchResults.length > 0) store.set({ externalSearchResults: results });
const show = () => {
const results = asked.flatMap((id) => answers.get(id) ?? []);
const pending = asked.filter((id) => !answers.has(id));
const shown = store.get();
if (results.length > 0 || pending.length > 0 || shown.externalSearchResults.length > 0 || shown.externalSearchPending.length > 0) {
store.set({ externalSearchResults: results, externalSearchPending: pending });
}
};
show([]);
if (emailHooks.onProvideSearchResults.size === 0) return;
void provideSearchResults(query, filters).then((results) => {
if (providedSearchRun === run) show(results);
show();
asked = provideSearchResults(query, filters, (pluginId, results) => {
if (providedSearchRun !== run) return;
answers.set(pluginId, results);
show();
});
show();
}
export const useEmailStore = create<EmailStore>((set, get) => ({
@@ -3300,6 +3313,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
isAdvancedSearchOpen: false,
searchAbortController: null,
externalSearchResults: [],
externalSearchPending: [],
// Unified mailbox state
viewToken: 0,