mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge pull request #1419 from HOLOEYE-JZ/feat/plugin-search-results-pending
# Conflicts: # components/email/plugin-search-results.tsx
This commit is contained in:
@@ -8,7 +8,11 @@ import type { Email } from '@/lib/jmap/types';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { usePluginStore } from '@/stores/plugin-store';
|
||||
import { PluginSearchResults } from '../plugin-search-results';
|
||||
import { PENDING_GRACE_MS, PluginSearchResults } from '../plugin-search-results';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => (key === 'more_results_searching' ? 'Searching for more results…' : key),
|
||||
}));
|
||||
|
||||
// The block above a search's message list with the entries plugins returned
|
||||
// from onProvideSearchResults; a click goes back to the plugin as onAction,
|
||||
@@ -31,7 +35,7 @@ const withEmail = (key: string, email: { id: string; accountId?: string }): Prov
|
||||
beforeEach(() => {
|
||||
setActionDispatcher(dispatch);
|
||||
usePluginStore.setState({ plugins: [{ id: 'chat', name: 'Team chat' }] } as never);
|
||||
useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, selectedEmail: null });
|
||||
useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, selectedEmail: null, externalSearchPending: [] });
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'login-1',
|
||||
getAllConnectedClients: () => new Map([['login-2', client('acc-2')], ['login-1', client('acc-1', ['group-1'])]]),
|
||||
@@ -39,6 +43,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
setActionDispatcher(null);
|
||||
dispatch.mockClear();
|
||||
});
|
||||
@@ -158,4 +163,63 @@ describe('PluginSearchResults', () => {
|
||||
expect(screen.queryByText('Title a')).toBeNull();
|
||||
expect(heading).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('says nothing about a plugin that answers within the grace', () => {
|
||||
vi.useFakeTimers();
|
||||
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [], externalSearchPending: ['crm'] });
|
||||
const { container } = render(<PluginSearchResults surface="search-results" />);
|
||||
|
||||
act(() => vi.advanceTimersByTime(PENDING_GRACE_MS - 1));
|
||||
act(() => useEmailStore.setState({ externalSearchPending: [] }));
|
||||
act(() => vi.advanceTimersByTime(PENDING_GRACE_MS));
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('holds the block\'s place with placeholder rows while a plugin takes longer, then fills it', () => {
|
||||
vi.useFakeTimers();
|
||||
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [], externalSearchPending: ['crm'] });
|
||||
const { container } = render(<PluginSearchResults surface="search-results" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
|
||||
act(() => vi.advanceTimersByTime(PENDING_GRACE_MS));
|
||||
const waiting = screen.getByRole('region', { name: 'Searching for more results…' });
|
||||
expect(waiting).toHaveAttribute('aria-busy', 'true');
|
||||
const block = container.querySelector('[data-plugin-surface]');
|
||||
expect(block).toHaveClass('h-48');
|
||||
|
||||
act(() => useEmailStore.setState({ externalSearchResults: [result('crm', 'a', 'CRM')], externalSearchPending: [] }));
|
||||
expect(screen.queryByRole('region', { name: 'Searching for more results…' })).toBeNull();
|
||||
expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title a');
|
||||
// The same height as while it waited: the list below does not move again.
|
||||
expect(container.querySelector('[data-plugin-surface]')).toHaveClass('h-48');
|
||||
});
|
||||
|
||||
it('shows the entries of a plugin that answered beside the one still searching', () => {
|
||||
vi.useFakeTimers();
|
||||
useEmailStore.setState({
|
||||
searchQuery: 'resolution',
|
||||
externalSearchResults: [result('chat', 'b')],
|
||||
externalSearchPending: ['crm'],
|
||||
});
|
||||
render(<PluginSearchResults surface="search-results" />);
|
||||
act(() => vi.advanceTimersByTime(PENDING_GRACE_MS));
|
||||
|
||||
expect(screen.getByRole('region', { name: 'Team chat' })).toHaveTextContent('Title b');
|
||||
expect(screen.getByRole('region', { name: 'Searching for more results…' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('takes the place again at the next search', () => {
|
||||
vi.useFakeTimers();
|
||||
useEmailStore.setState({ searchQuery: 'one', externalSearchResults: [], externalSearchPending: ['crm'] });
|
||||
const { container } = render(<PluginSearchResults surface="search-results" />);
|
||||
act(() => vi.advanceTimersByTime(PENDING_GRACE_MS));
|
||||
act(() => useEmailStore.setState({ externalSearchResults: [result('crm', 'a', 'CRM')], externalSearchPending: [] }));
|
||||
|
||||
// A search the plugin answers at once: the block as without waiting.
|
||||
act(() => useEmailStore.setState({ searchQuery: 'two', externalSearchResults: [], externalSearchPending: ['crm'] }));
|
||||
act(() => useEmailStore.setState({ externalSearchResults: [result('crm', 'c', 'CRM')], externalSearchPending: [] }));
|
||||
|
||||
expect(container.querySelector('[data-plugin-surface]')).not.toHaveClass('h-48');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "@/components/icons";
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ChevronDown, ChevronRight, Loader2 } from "@/components/icons";
|
||||
import { SearchSnippetText } from "@/components/email/search-snippet-text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
@@ -76,6 +77,27 @@ function isOpen(email: SearchResultEmail | undefined, open: Email | null): boole
|
||||
return (open.sourceAccountId ?? own) === (email.accountId ?? own);
|
||||
}
|
||||
|
||||
/** ms a plugin may take before the block holds its place for the entries to come. */
|
||||
export const PENDING_GRACE_MS = 300;
|
||||
|
||||
/**
|
||||
* Whether the block holds its place for this search: a plugin asked had not
|
||||
* answered after PENDING_GRACE_MS. Kept once the answers came, until the next
|
||||
* search asks again, so the block keeps its height and the list below does
|
||||
* not move a second time. A plugin that answers at once (one that does not
|
||||
* search for this query) shows nothing.
|
||||
*/
|
||||
function useHeldPlace(pending: boolean): boolean {
|
||||
const [held, setHeld] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!pending) return;
|
||||
setHeld(false);
|
||||
const timer = setTimeout(() => setHeld(true), PENDING_GRACE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [pending]);
|
||||
return held;
|
||||
}
|
||||
|
||||
interface PluginSearchResultsProps {
|
||||
/** Entries plugins return from onProvideSearchResults; a click is the plugin's onAction. */
|
||||
surface: "search-results";
|
||||
@@ -86,15 +108,21 @@ interface PluginSearchResultsProps {
|
||||
/**
|
||||
* Results from elsewhere for the search that is running (CRM hits, chat
|
||||
* messages, ...), above its message list. Renders nothing outside a search or
|
||||
* without entries. A click on an entry naming a message the user reaches opens
|
||||
* it; any other goes back to the plugin that gave the entry.
|
||||
* without entries. While a plugin takes longer than PENDING_GRACE_MS the block
|
||||
* shows at a fixed height with placeholder rows, which its entries then fill,
|
||||
* so the list moves once, with its own results, not when a slow plugin
|
||||
* answers. A click on an entry naming a message the user reaches opens it; any
|
||||
* other goes back to the plugin that gave the entry.
|
||||
*/
|
||||
export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResultsProps) {
|
||||
const results = useEmailStore((s) => s.externalSearchResults);
|
||||
const openEmail = useEmailStore((s) => s.selectedEmail);
|
||||
const query = useEmailStore((s) => s.searchQuery);
|
||||
const searching = useEmailStore((s) => !!s.searchQuery.trim() || !isFilterEmpty(s.searchFilters));
|
||||
const pending = useEmailStore((s) => s.externalSearchPending.length > 0);
|
||||
const plugins = usePluginStore((s) => s.plugins);
|
||||
const held = useHeldPlace(pending);
|
||||
const t = useTranslations('email_list');
|
||||
const idPrefix = useId();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
|
||||
@@ -103,7 +131,8 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult
|
||||
[results, plugins],
|
||||
);
|
||||
|
||||
if (!searching || groups.length === 0) return null;
|
||||
const waiting = pending && held;
|
||||
if (!searching || (groups.length === 0 && !waiting)) return null;
|
||||
|
||||
const click = (r: ProvidedSearchResult) => {
|
||||
const reachable = r.email && onOpenEmail ? reachableEmail(r.email) : null;
|
||||
@@ -120,7 +149,10 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-plugin-surface={surface} className="max-h-[40%] flex-shrink-0 overflow-y-auto border-b border-border bg-muted/30">
|
||||
<div
|
||||
data-plugin-surface={surface}
|
||||
className={cn("max-h-[40%] flex-shrink-0 overflow-y-auto border-b border-border bg-muted/30", held && "h-48")}
|
||||
>
|
||||
{groups.map((group, index) => {
|
||||
const open = !collapsed.has(group.id);
|
||||
const listId = `${idPrefix}-${index}`;
|
||||
@@ -168,6 +200,20 @@ export function PluginSearchResults({ surface, onOpenEmail }: PluginSearchResult
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{waiting && (
|
||||
<section aria-label={t('more_results_searching')} aria-busy="true">
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
<span className="truncate">{t('more_results_searching')}</span>
|
||||
</div>
|
||||
{[0, 1, 2].map((row) => (
|
||||
<div key={row} className="flex flex-col gap-1.5 px-3 py-2" aria-hidden="true">
|
||||
<div className="h-3 w-3/5 animate-pulse rounded bg-muted" />
|
||||
<div className="h-2.5 w-4/5 animate-pulse rounded bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,12 +73,21 @@ describe('checkSearchResults', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** Asks the plugins, collecting each answer in the order it came. */
|
||||
function ask(query = 'q') {
|
||||
const answers: { pluginId: string; keys: string[] }[] = [];
|
||||
const asked = provideSearchResults(query, {}, (pluginId, results) => {
|
||||
answers.push({ pluginId, keys: results.map((r) => r.key) });
|
||||
});
|
||||
return { asked, answers };
|
||||
}
|
||||
|
||||
describe('provideSearchResults', () => {
|
||||
it('asks no one without handlers', async () => {
|
||||
expect(await provideSearchResults('q', {})).toEqual([]);
|
||||
it('asks no one without handlers', () => {
|
||||
expect(ask().asked).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks the plugins at once, each with an empty list', async () => {
|
||||
it('asks the plugins at once, each with an empty list, and hands over each answer as it comes', async () => {
|
||||
const order: string[] = [];
|
||||
emailHooks.onProvideSearchResults.register('slow', async (initial: unknown[]) => {
|
||||
order.push(`slow ${initial.length}`);
|
||||
@@ -90,27 +99,43 @@ describe('provideSearchResults', () => {
|
||||
return [{ key: 'f', title: 'F' }];
|
||||
});
|
||||
|
||||
const results = await provideSearchResults('q', {});
|
||||
const { asked, answers } = ask();
|
||||
|
||||
expect(asked).toEqual(['slow', 'fast']);
|
||||
expect(order).toEqual(['slow 0', 'fast 0']);
|
||||
expect(results.map((r) => `${r.pluginId}:${r.key}`)).toEqual(['slow:s', 'fast:f']);
|
||||
await vi.waitFor(() => expect(answers).toHaveLength(2));
|
||||
expect(answers).toEqual([{ pluginId: 'fast', keys: ['f'] }, { pluginId: 'slow', keys: ['s'] }]);
|
||||
});
|
||||
|
||||
it('leaves out a plugin that throws', async () => {
|
||||
it('checks each answer', async () => {
|
||||
emailHooks.onProvideSearchResults.register('p', () => [{ key: 'a', title: 'A' }, { key: 'a', title: 'twice' }, 'text']);
|
||||
|
||||
const { answers } = ask();
|
||||
|
||||
await vi.waitFor(() => expect(answers).toEqual([{ pluginId: 'p', keys: ['a'] }]));
|
||||
});
|
||||
|
||||
it('answers nothing for a plugin that throws', async () => {
|
||||
emailHooks.onProvideSearchResults.register('broken', () => { throw new Error('down'); });
|
||||
emailHooks.onProvideSearchResults.register('ok', () => [{ key: 'a', title: 'A' }]);
|
||||
|
||||
expect((await provideSearchResults('q', {})).map((r) => r.pluginId)).toEqual(['ok']);
|
||||
const { answers } = ask();
|
||||
|
||||
await vi.waitFor(() => expect(answers).toHaveLength(2));
|
||||
expect(answers).toContainEqual({ pluginId: 'broken', keys: [] });
|
||||
expect(answers).toContainEqual({ pluginId: 'ok', keys: ['a'] });
|
||||
});
|
||||
|
||||
it('leaves out a plugin that runs out of time', async () => {
|
||||
it('answers nothing for a plugin that runs out of time, the others long before', async () => {
|
||||
vi.useFakeTimers();
|
||||
emailHooks.onProvideSearchResults.register('hung', () => new Promise(() => {}));
|
||||
emailHooks.onProvideSearchResults.register('ok', () => [{ key: 'a', title: 'A' }]);
|
||||
|
||||
const pending = provideSearchResults('q', {});
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
const { answers } = ask();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(answers).toEqual([{ pluginId: 'ok', keys: ['a'] }]);
|
||||
|
||||
expect((await pending).map((r) => r.pluginId)).toEqual(['ok']);
|
||||
await vi.advanceTimersByTimeAsync(10_000);
|
||||
expect(answers).toEqual([{ pluginId: 'ok', keys: ['a'] }, { pluginId: 'hung', keys: [] }]);
|
||||
});
|
||||
});
|
||||
|
||||
+16
-12
@@ -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 */
|
||||
|
||||
@@ -102,9 +102,21 @@ export function checkSearchResults(pluginId: string, value: unknown): ProvidedSe
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Ask every plugin for results to a search; each answer checked, in the plugins' order. */
|
||||
export async function provideSearchResults(query: string, filters: unknown): Promise<ProvidedSearchResult[]> {
|
||||
/**
|
||||
* Ask every plugin for results to a search. Returns the plugins asked, in
|
||||
* their order; each answer goes to `onAnswer` as it comes, checked (empty for
|
||||
* a plugin that throws or runs out of time), so a slow plugin keeps no other
|
||||
* waiting.
|
||||
*/
|
||||
export function provideSearchResults(
|
||||
query: string,
|
||||
filters: unknown,
|
||||
onAnswer: (pluginId: string, results: ProvidedSearchResult[]) => void,
|
||||
): string[] {
|
||||
if (emailHooks.onProvideSearchResults.size === 0) return [];
|
||||
const answers = await emailHooks.onProvideSearchResults.collect([] as ExternalSearchResult[], { query, filters });
|
||||
return answers.flatMap(({ pluginId, value }) => checkSearchResults(pluginId, value));
|
||||
const asked = emailHooks.onProvideSearchResults.collect([] as ExternalSearchResult[], { query, filters });
|
||||
for (const { pluginId, answer } of asked) {
|
||||
void answer.then((given) => onAnswer(pluginId, given ? checkSearchResults(pluginId, given.value) : []));
|
||||
}
|
||||
return asked.map(({ pluginId }) => pluginId);
|
||||
}
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
"no_emails_description": "صندوق البريد هذا فارغ",
|
||||
"no_search_results": "لم يتم العثور على نتائج",
|
||||
"no_search_results_description": "حاول تعديل البحث أو عوامل التصفية",
|
||||
"more_results_searching": "جارٍ البحث عن نتائج أخرى…",
|
||||
"loading": "جارٍ تحميل الرسائل...",
|
||||
"unread": "غير مقروءة",
|
||||
"to_me": "إليّ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ě",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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í",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
"no_emails_description": "این صندوق پستی خالی است",
|
||||
"no_search_results": "نتیجهای یافت نشد",
|
||||
"no_search_results_description": "جستجو یا فیلترهای خود را تنظیم کنید",
|
||||
"more_results_searching": "در حال جستجوی نتایج بیشتر…",
|
||||
"loading": "در حال بارگذاری ایمیلها...",
|
||||
"unread": "خوانده نشده",
|
||||
"to_me": "به من",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -261,6 +261,7 @@
|
||||
"no_emails_description": "תיבת הדואר הזו ריקה",
|
||||
"no_search_results": "לא נמצאו תוצאות",
|
||||
"no_search_results_description": "נסה להתאים את החיפוש או המסננים שלך",
|
||||
"more_results_searching": "מחפש תוצאות נוספות…",
|
||||
"loading": "טוען אימיילים...",
|
||||
"unread": "לא נקרא",
|
||||
"to_me": "לִי",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
"no_emails_description": "このメールボックスは空です",
|
||||
"no_search_results": "結果が見つかりません",
|
||||
"no_search_results_description": "検索やフィルターを調整してください",
|
||||
"more_results_searching": "ほかの結果を検索しています…",
|
||||
"loading": "メールを読み込み中...",
|
||||
"unread": "未読",
|
||||
"to_me": "宛先: 自分",
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
"no_emails_description": "편지함이 비어있어요",
|
||||
"no_search_results": "검색 결과가 없어요",
|
||||
"no_search_results_description": "검색어나 필터를 변경해 보세요",
|
||||
"more_results_searching": "추가 결과를 검색하는 중…",
|
||||
"loading": "메일을 불러오는 중...",
|
||||
"unread": "읽지 않음",
|
||||
"to_me": "나에게",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
"no_emails_description": "Энэ шуудангийн хайрцаг хоосон",
|
||||
"no_search_results": "Илэрц олдсонгүй",
|
||||
"no_search_results_description": "Хайлт эсвэл шүүлтүүрээ тохируулаад үзээрэй",
|
||||
"more_results_searching": "Нэмэлт үр дүн хайж байна…",
|
||||
"loading": "Имэйл ачаалж байна...",
|
||||
"unread": "уншаагүй",
|
||||
"to_me": "Надад",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
"no_emails_description": "Папка пуста",
|
||||
"no_search_results": "Результатов не найдено",
|
||||
"no_search_results_description": "Попробуйте изменить поисковый запрос или фильтры",
|
||||
"more_results_searching": "Ищем другие результаты…",
|
||||
"loading": "Загрузка писем...",
|
||||
"unread": "непрочитанные",
|
||||
"to_me": "Мне",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -534,6 +534,7 @@
|
||||
"no_emails_description": "กล่องจดหมายนี้ว่างเปล่า",
|
||||
"no_search_results": "ไม่พบผลการค้นหา",
|
||||
"no_search_results_description": "ลองปรับเปลี่ยนคำค้นหาหรือตัวกรองของคุณ",
|
||||
"more_results_searching": "กำลังค้นหาผลลัพธ์เพิ่มเติม…",
|
||||
"loading": "กำลังโหลดอีเมล...",
|
||||
"unread": "ยังไม่อ่าน",
|
||||
"to_me": "ถึงฉัน",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
"no_emails_description": "Ця поштова скринька порожня",
|
||||
"no_search_results": "Результатів не знайдено",
|
||||
"no_search_results_description": "Спробуйте налаштувати пошук або фільтри",
|
||||
"more_results_searching": "Шукаємо інші результати…",
|
||||
"loading": "Завантаження електронних листів...",
|
||||
"unread": "непрочитаний",
|
||||
"to_me": "мені",
|
||||
|
||||
@@ -296,6 +296,7 @@
|
||||
"no_emails_description": "此信件匣中沒有郵件",
|
||||
"no_search_results": "找不到結果",
|
||||
"no_search_results_description": "請調整搜尋條件或篩選器",
|
||||
"more_results_searching": "正在搜尋更多結果…",
|
||||
"loading": "正在載入郵件…",
|
||||
"unread": "未讀",
|
||||
"to_me": "寄給我",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user