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,