From 03876158fe8922b52ce6de4058634b743213effd Mon Sep 17 00:00:00 2001 From: Jacob Zeitz Date: Thu, 29 Oct 2026 07:23:29 +0100 Subject: [PATCH] feat(plugins): hold the search results block's place while a plugin still searches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin's results to a search came all at once after its handler answered, with nothing before: the block then appeared above the message list and pushed it down, under a pointer about to click a row. onProvideSearchResults handlers that search elsewhere (a CRM's search by meaning) take seconds. Each plugin's answer now shows as it comes, and the store keeps the plugins still asked. One that has not answered after 300 ms gets the block at a fixed height with placeholder rows under "Searching for more results…"; its entries then fill that height, so the list moves once, with its own results. A plugin that answers at once (one not searching for this query) shows nothing. --- .../__tests__/plugin-search-results.test.tsx | 68 ++++++++++++++++++- components/email/plugin-search-results.tsx | 58 ++++++++++++++-- lib/__tests__/plugin-search-results.test.ts | 47 ++++++++++--- lib/plugin-hooks.ts | 28 ++++---- lib/plugin-search-results.ts | 20 ++++-- locales/ar/common.json | 1 + locales/ca/common.json | 1 + locales/cs/common.json | 1 + locales/da/common.json | 1 + locales/de/common.json | 1 + locales/en/common.json | 1 + locales/es/common.json | 1 + locales/et/common.json | 1 + locales/fa/common.json | 1 + locales/fr/common.json | 1 + locales/he/common.json | 1 + locales/hu/common.json | 1 + locales/it/common.json | 1 + locales/ja/common.json | 1 + locales/ko/common.json | 1 + locales/lv/common.json | 1 + locales/mn/common.json | 1 + locales/nb/common.json | 1 + locales/nl/common.json | 1 + locales/pl/common.json | 1 + locales/pt/common.json | 1 + locales/ro/common.json | 1 + locales/ru/common.json | 1 + locales/sk/common.json | 1 + locales/th/common.json | 1 + locales/tr/common.json | 1 + locales/uk/common.json | 1 + locales/zh-TW/common.json | 1 + locales/zh/common.json | 1 + .../email-store-plugin-search-results.test.ts | 37 ++++++++++ stores/email-store.ts | 26 +++++-- 36 files changed, 272 insertions(+), 41 deletions(-) diff --git a/components/email/__tests__/plugin-search-results.test.tsx b/components/email/__tests__/plugin-search-results.test.tsx index b7f80893d..6876ab19a 100644 --- a/components/email/__tests__/plugin-search-results.test.tsx +++ b/components/email/__tests__/plugin-search-results.test.tsx @@ -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(); }); @@ -145,4 +150,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(); + + 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(); + 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(); + 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(); + 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'); + }); }); diff --git a/components/email/plugin-search-results.tsx b/components/email/plugin-search-results.tsx index 9f71b1505..a8d9d6414 100644 --- a/components/email/plugin-search-results.tsx +++ b/components/email/plugin-search-results.tsx @@ -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 { cn } from "@/lib/utils"; import { isFilterEmpty } from "@/lib/jmap/search-utils"; import { runPluginAction } from "@/lib/plugin-sandbox/actions"; @@ -75,6 +76,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"; @@ -85,15 +107,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>(() => new Set()); @@ -102,7 +130,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; @@ -119,7 +148,10 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult }); return ( -
+
{groups.map((group, index) => { const open = !collapsed.has(group.id); const listId = `${idPrefix}-${index}`; @@ -163,6 +195,20 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult ); })} + {waiting && ( +
+
+ + {t('more_results_searching')} +
+ {[0, 1, 2].map((row) => ( +
+ )}
); } diff --git a/lib/__tests__/plugin-search-results.test.ts b/lib/__tests__/plugin-search-results.test.ts index 1b92e9c62..1fa5bda1e 100644 --- a/lib/__tests__/plugin-search-results.test.ts +++ b/lib/__tests__/plugin-search-results.test.ts @@ -55,12 +55,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}`); @@ -72,27 +81,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: [] }]); }); }); diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 5d16dbf46..6b83e5fa5 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -306,21 +306,25 @@ export class HookBus 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(initial: V, ...rest: unknown[]): Promise<{ pluginId: string; value: unknown }[]> { + collect(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 */ diff --git a/lib/plugin-search-results.ts b/lib/plugin-search-results.ts index 91b5f2120..01d15ff36 100644 --- a/lib/plugin-search-results.ts +++ b/lib/plugin-search-results.ts @@ -71,9 +71,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 { +/** + * 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); } diff --git a/locales/ar/common.json b/locales/ar/common.json index 53d1387de..cc7693fbe 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -296,6 +296,7 @@ "no_emails_description": "صندوق البريد هذا فارغ", "no_search_results": "لم يتم العثور على نتائج", "no_search_results_description": "حاول تعديل البحث أو عوامل التصفية", + "more_results_searching": "جارٍ البحث عن نتائج أخرى…", "loading": "جارٍ تحميل الرسائل...", "unread": "غير مقروءة", "to_me": "إليّ", diff --git a/locales/ca/common.json b/locales/ca/common.json index f6518c120..cf555e325 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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", diff --git a/locales/cs/common.json b/locales/cs/common.json index a52d94e20..f06d96efb 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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ě", diff --git a/locales/da/common.json b/locales/da/common.json index 3e04e5ef2..80ac5bfee 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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", diff --git a/locales/de/common.json b/locales/de/common.json index 87f90a0e2..cc0b61079 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index e370559a0..108046bde 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index 45d4255d7..96f9382dc 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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í", diff --git a/locales/et/common.json b/locales/et/common.json index 409dc7914..cd2f35480 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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", diff --git a/locales/fa/common.json b/locales/fa/common.json index c67b0be54..412bdb3a9 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -296,6 +296,7 @@ "no_emails_description": "این صندوق پستی خالی است", "no_search_results": "نتیجه‌ای یافت نشد", "no_search_results_description": "جستجو یا فیلترهای خود را تنظیم کنید", + "more_results_searching": "در حال جستجوی نتایج بیشتر…", "loading": "در حال بارگذاری ایمیل‌ها...", "unread": "خوانده نشده", "to_me": "به من", diff --git a/locales/fr/common.json b/locales/fr/common.json index 05d4a448c..a46fa3309 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/he/common.json b/locales/he/common.json index 639fb6c7a..814966400 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -261,6 +261,7 @@ "no_emails_description": "תיבת הדואר הזו ריקה", "no_search_results": "לא נמצאו תוצאות", "no_search_results_description": "נסה להתאים את החיפוש או המסננים שלך", + "more_results_searching": "מחפש תוצאות נוספות…", "loading": "טוען אימיילים...", "unread": "לא נקרא", "to_me": "לִי", diff --git a/locales/hu/common.json b/locales/hu/common.json index d73d1024c..4d4dd1505 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index d233c37f2..3e8481c22 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index 14443e59d..0b0772285 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -296,6 +296,7 @@ "no_emails_description": "このメールボックスは空です", "no_search_results": "結果が見つかりません", "no_search_results_description": "検索やフィルターを調整してください", + "more_results_searching": "ほかの結果を検索しています…", "loading": "メールを読み込み中...", "unread": "未読", "to_me": "宛先: 自分", diff --git a/locales/ko/common.json b/locales/ko/common.json index 96e51cd9f..d785a92d2 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -296,6 +296,7 @@ "no_emails_description": "편지함이 비어있어요", "no_search_results": "검색 결과가 없어요", "no_search_results_description": "검색어나 필터를 변경해 보세요", + "more_results_searching": "추가 결과를 검색하는 중…", "loading": "메일을 불러오는 중...", "unread": "읽지 않음", "to_me": "나에게", diff --git a/locales/lv/common.json b/locales/lv/common.json index ea3047e14..eae3fe3e0 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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", diff --git a/locales/mn/common.json b/locales/mn/common.json index e6ea18a15..9873f45b9 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -296,6 +296,7 @@ "no_emails_description": "Энэ шуудангийн хайрцаг хоосон", "no_search_results": "Илэрц олдсонгүй", "no_search_results_description": "Хайлт эсвэл шүүлтүүрээ тохируулаад үзээрэй", + "more_results_searching": "Нэмэлт үр дүн хайж байна…", "loading": "Имэйл ачаалж байна...", "unread": "уншаагүй", "to_me": "Надад", diff --git a/locales/nb/common.json b/locales/nb/common.json index 6794e637c..02a358448 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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", diff --git a/locales/nl/common.json b/locales/nl/common.json index 21f2232f5..e9e39b86a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pl/common.json b/locales/pl/common.json index b0aae170a..bd20acb0b 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index 3603dd30d..6b8b3bf46 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/locales/ro/common.json b/locales/ro/common.json index 6f8502c27..1948972bf 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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", diff --git a/locales/ru/common.json b/locales/ru/common.json index b88e0e8f0..8e95280a5 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -296,6 +296,7 @@ "no_emails_description": "Папка пуста", "no_search_results": "Результатов не найдено", "no_search_results_description": "Попробуйте изменить поисковый запрос или фильтры", + "more_results_searching": "Ищем другие результаты…", "loading": "Загрузка писем...", "unread": "непрочитанные", "to_me": "Мне", diff --git a/locales/sk/common.json b/locales/sk/common.json index 7c2579d36..ddcb7969b 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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", diff --git a/locales/th/common.json b/locales/th/common.json index e8ce9fe3b..543139f70 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -534,6 +534,7 @@ "no_emails_description": "กล่องจดหมายนี้ว่างเปล่า", "no_search_results": "ไม่พบผลการค้นหา", "no_search_results_description": "ลองปรับเปลี่ยนคำค้นหาหรือตัวกรองของคุณ", + "more_results_searching": "กำลังค้นหาผลลัพธ์เพิ่มเติม…", "loading": "กำลังโหลดอีเมล...", "unread": "ยังไม่อ่าน", "to_me": "ถึงฉัน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 848d215f2..7c774ad6a 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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", diff --git a/locales/uk/common.json b/locales/uk/common.json index f6acc947b..356060a52 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -296,6 +296,7 @@ "no_emails_description": "Ця поштова скринька порожня", "no_search_results": "Результатів не знайдено", "no_search_results_description": "Спробуйте налаштувати пошук або фільтри", + "more_results_searching": "Шукаємо інші результати…", "loading": "Завантаження електронних листів...", "unread": "непрочитаний", "to_me": "мені", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index f3aa1b8b8..6737b2737 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -296,6 +296,7 @@ "no_emails_description": "此信件匣中沒有郵件", "no_search_results": "找不到結果", "no_search_results_description": "請調整搜尋條件或篩選器", + "more_results_searching": "正在搜尋更多結果…", "loading": "正在載入郵件…", "unread": "未讀", "to_me": "寄給我", diff --git a/locales/zh/common.json b/locales/zh/common.json index 3e0ae974b..417cae6b4 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -296,6 +296,7 @@ "no_emails_description": "此邮箱为空", "no_search_results": "未找到结果", "no_search_results_description": "请调整搜索条件或筛选器", + "more_results_searching": "正在搜索更多结果…", "loading": "正在加载邮件...", "unread": "未读", "to_me": "发给我", diff --git a/stores/__tests__/email-store-plugin-search-results.test.ts b/stores/__tests__/email-store-plugin-search-results.test.ts index 1ad4ab937..c914d1109 100644 --- a/stores/__tests__/email-store-plugin-search-results.test.ts +++ b/stores/__tests__/email-store-plugin-search-results.test.ts @@ -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(); + const fast = deferred(); + 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([]); + }); }); diff --git a/stores/email-store.ts b/stores/email-store.ts index 384df23f6..014731285 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -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(); + 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((set, get) => ({ @@ -3300,6 +3313,7 @@ export const useEmailStore = create((set, get) => ({ isAdvancedSearchOpen: false, searchAbortController: null, externalSearchResults: [], + externalSearchPending: [], // Unified mailbox state viewToken: 0,