feat(plugins): hold the search results block's place while a plugin still searches

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.
This commit is contained in:
Jacob Zeitz
2026-10-29 10:25:59 +01:00
parent 99b6857d90
commit 03876158fe
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();
});
@@ -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(<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 { 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<Set<string>>(() => 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 (
<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}`;
@@ -163,6 +195,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
@@ -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: [] }]);
});
});
+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
@@ -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<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,