mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
Merge pull request #1338 from HOLOEYE-JZ/feat/plugin-search-results
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { setActionDispatcher } from '@/lib/plugin-sandbox/actions';
|
||||
import type { ProvidedSearchResult } from '@/lib/plugin-search-results';
|
||||
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';
|
||||
|
||||
// The block above a search's message list with the entries plugins returned
|
||||
// from onProvideSearchResults; a click goes back to the plugin as onAction,
|
||||
// or opens the message an entry names.
|
||||
|
||||
const result = (pluginId: string, key: string, source?: string): ProvidedSearchResult =>
|
||||
({ pluginId, key, title: `Title ${key}`, snippet: `Snippet ${key}`, ...(source ? { source } : {}) });
|
||||
|
||||
const dispatch = vi.fn(async () => {});
|
||||
|
||||
/** A login whose primary account is `primary`, reading `shared` accounts too. */
|
||||
const client = (primary: string, shared: string[] = []) => ({
|
||||
getAccountId: () => primary,
|
||||
getSharedAccounts: () => [primary, ...shared].map((id) => ({ id, name: id, isPrimary: id === primary, capabilities: { mail: true } })),
|
||||
});
|
||||
|
||||
const withEmail = (key: string, email: { id: string; accountId?: string }): ProvidedSearchResult =>
|
||||
({ ...result('crm', key, 'CRM'), email });
|
||||
|
||||
beforeEach(() => {
|
||||
setActionDispatcher(dispatch);
|
||||
usePluginStore.setState({ plugins: [{ id: 'chat', name: 'Team chat' }] } as never);
|
||||
useEmailStore.setState({ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, selectedEmail: null });
|
||||
useAuthStore.setState({
|
||||
activeAccountId: 'login-1',
|
||||
getAllConnectedClients: () => new Map([['login-2', client('acc-2')], ['login-1', client('acc-1', ['group-1'])]]),
|
||||
} as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActionDispatcher(null);
|
||||
dispatch.mockClear();
|
||||
});
|
||||
|
||||
describe('PluginSearchResults', () => {
|
||||
it('shows nothing outside a search', () => {
|
||||
useEmailStore.setState({ searchQuery: '', externalSearchResults: [result('crm', 'a', 'CRM')] });
|
||||
const { container } = render(<PluginSearchResults surface="search-results" />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('shows the entries of a search by filters alone', () => {
|
||||
useEmailStore.setState({
|
||||
searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'bob@x.test' },
|
||||
externalSearchResults: [result('crm', 'a', 'CRM')],
|
||||
});
|
||||
render(<PluginSearchResults surface="search-results" />);
|
||||
expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title a');
|
||||
});
|
||||
|
||||
it('groups the entries by source, the plugin\'s name without one', () => {
|
||||
useEmailStore.setState({
|
||||
searchQuery: 'resolution',
|
||||
externalSearchResults: [result('crm', 'a', 'CRM'), result('chat', 'b'), result('crm', 'c', 'CRM')],
|
||||
});
|
||||
render(<PluginSearchResults surface="search-results" />);
|
||||
|
||||
expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title a');
|
||||
expect(screen.getByRole('region', { name: 'CRM' })).toHaveTextContent('Title c');
|
||||
expect(screen.getByRole('region', { name: 'Team chat' })).toHaveTextContent('Snippet b');
|
||||
});
|
||||
|
||||
it('sends a click to the plugin that gave the entry, with the query', () => {
|
||||
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [result('crm', 'mail-123', 'CRM')] });
|
||||
render(<PluginSearchResults surface="search-results" />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Title mail-123/ }));
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('crm', 'search-results', 'mail-123', { query: 'resolution' });
|
||||
});
|
||||
|
||||
it('opens the message an entry names in the reader, not the plugin', () => {
|
||||
const open = vi.fn();
|
||||
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [withEmail('mail-1', { id: 'M1' })] });
|
||||
render(<PluginSearchResults surface="search-results" onOpenEmail={open} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Title mail-1/ }));
|
||||
|
||||
expect(open).toHaveBeenCalledWith({ id: 'M1', sourceClientAccountId: 'login-1', sourceAccountId: 'acc-1' });
|
||||
expect(dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a message of a shared account through the login reaching it, the active one first', () => {
|
||||
const open = vi.fn();
|
||||
useEmailStore.setState({
|
||||
searchQuery: 'resolution',
|
||||
externalSearchResults: [withEmail('g', { id: 'M1', accountId: 'group-1' }), withEmail('o', { id: 'M2', accountId: 'acc-2' })],
|
||||
});
|
||||
render(<PluginSearchResults surface="search-results" onOpenEmail={open} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Title g/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /Title o/ }));
|
||||
|
||||
expect(open).toHaveBeenNthCalledWith(1, { id: 'M1', sourceClientAccountId: 'login-1', sourceAccountId: 'group-1' });
|
||||
expect(open).toHaveBeenNthCalledWith(2, { id: 'M2', sourceClientAccountId: 'login-2', sourceAccountId: 'acc-2' });
|
||||
});
|
||||
|
||||
it('sends the click to the plugin when no login reaches the message\'s account', () => {
|
||||
const open = vi.fn();
|
||||
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [withEmail('mail-1', { id: 'M1', accountId: 'elsewhere' })] });
|
||||
render(<PluginSearchResults surface="search-results" onOpenEmail={open} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Title mail-1/ }));
|
||||
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(dispatch).toHaveBeenCalledWith('crm', 'search-results', 'mail-1', { query: 'resolution' });
|
||||
});
|
||||
|
||||
it('marks the entry whose message is open, in the same account', () => {
|
||||
useEmailStore.setState({
|
||||
searchQuery: 'resolution',
|
||||
externalSearchResults: [withEmail('a', { id: 'M1', accountId: 'group-1' }), withEmail('b', { id: 'M1' })],
|
||||
selectedEmail: { id: 'M1', sourceAccountId: 'group-1' } as Email,
|
||||
});
|
||||
render(<PluginSearchResults surface="search-results" onOpenEmail={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Title a/ })).toHaveAttribute('aria-current', 'true');
|
||||
expect(screen.getByRole('button', { name: /Title b/ })).not.toHaveAttribute('aria-current');
|
||||
|
||||
act(() => useEmailStore.setState({ selectedEmail: { id: 'M1' } as Email }));
|
||||
expect(screen.getByRole('button', { name: /Title a/ })).not.toHaveAttribute('aria-current');
|
||||
expect(screen.getByRole('button', { name: /Title b/ })).toHaveAttribute('aria-current', 'true');
|
||||
});
|
||||
|
||||
it('folds a group', () => {
|
||||
useEmailStore.setState({ searchQuery: 'resolution', externalSearchResults: [result('crm', 'a', 'CRM')] });
|
||||
render(<PluginSearchResults surface="search-results" />);
|
||||
|
||||
const heading = screen.getByRole('button', { name: /^CRM/ });
|
||||
expect(heading).toHaveAttribute('aria-expanded', 'true');
|
||||
fireEvent.click(heading);
|
||||
|
||||
expect(screen.queryByText('Title a')).toBeNull();
|
||||
expect(heading).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
@@ -42,12 +42,15 @@ import { PluginActions } from "@/components/plugins/plugin-actions";
|
||||
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
|
||||
import { unreadSectionRowCount } from "@/lib/unread-first";
|
||||
import { MvListSectionHeader } from "./mv-list-section-header";
|
||||
import { type OpenableEmail, PluginSearchResults } from "./plugin-search-results";
|
||||
|
||||
interface EmailListProps {
|
||||
emails: Email[];
|
||||
/** emailKeyFor of the open message: ids repeat across accounts. */
|
||||
selectedEmailKey?: string;
|
||||
onEmailSelect?: (email: Email) => void;
|
||||
/** Opens a message a plugin's search result names (PluginSearchResults). */
|
||||
onOpenSearchResult?: (email: OpenableEmail) => void;
|
||||
/** A mouse was pressed on a row: the click that follows will open it. */
|
||||
onEmailPrefetch?: (email: Email) => void;
|
||||
onEmailDoubleClick?: (email: Email) => void;
|
||||
@@ -128,6 +131,7 @@ export function EmailList({
|
||||
emails,
|
||||
selectedEmailKey,
|
||||
onEmailSelect,
|
||||
onOpenSearchResult,
|
||||
onEmailPrefetch,
|
||||
onEmailDoubleClick,
|
||||
onFindFromSender,
|
||||
@@ -974,6 +978,8 @@ export function EmailList({
|
||||
|
||||
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
|
||||
|
||||
<PluginSearchResults surface="search-results" onOpenEmail={onOpenSearchResult} />
|
||||
|
||||
{listColumns && (
|
||||
<ListColumnsHeader density={density} selecting={hasSelection} showRecipient={showRecipientColumn} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "@/components/icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
import { runPluginAction } from "@/lib/plugin-sandbox/actions";
|
||||
import type { ProvidedSearchResult } from "@/lib/plugin-search-results";
|
||||
import type { SearchResultEmail } from "@/lib/plugin-types";
|
||||
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";
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
heading: string;
|
||||
results: ProvidedSearchResult[];
|
||||
}
|
||||
|
||||
/** One group per plugin and source, in the order the entries came. */
|
||||
function groupResults(results: ProvidedSearchResult[], pluginName: (id: string) => string): Group[] {
|
||||
const groups = new Map<string, Group>();
|
||||
for (const r of results) {
|
||||
const id = `${r.pluginId}\u0000${r.source ?? ""}`;
|
||||
let group = groups.get(id);
|
||||
if (!group) {
|
||||
group = { id, heading: r.source ?? pluginName(r.pluginId), results: [] };
|
||||
groups.set(id, group);
|
||||
}
|
||||
group.results.push(r);
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
/** A message as the reader opens one that is not a row of the list: the login reaching it and its account. */
|
||||
export type OpenableEmail = Required<Pick<Email, "id" | "sourceClientAccountId" | "sourceAccountId">>;
|
||||
|
||||
/**
|
||||
* The login through which the user reaches a message an entry names, the
|
||||
* active one first; null when none does (an account the user cannot read).
|
||||
* Without an account the message is the active login's own.
|
||||
*/
|
||||
export function reachableEmail(email: SearchResultEmail): OpenableEmail | null {
|
||||
const auth = useAuthStore.getState();
|
||||
const clients = [...auth.getAllConnectedClients()]
|
||||
.sort(([a], [b]) => Number(b === auth.activeAccountId) - Number(a === auth.activeAccountId));
|
||||
for (const [login, client] of clients) {
|
||||
if (!email.accountId) {
|
||||
return login === auth.activeAccountId
|
||||
? { id: email.id, sourceClientAccountId: login, sourceAccountId: client.getAccountId() }
|
||||
: null;
|
||||
}
|
||||
const reaches = client.getAccountId() === email.accountId
|
||||
|| client.getSharedAccounts().some((a) => a.id === email.accountId && a.capabilities.mail);
|
||||
if (reaches) return { id: email.id, sourceClientAccountId: login, sourceAccountId: email.accountId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The active login's own account. */
|
||||
function ownAccountId(): string | undefined {
|
||||
const auth = useAuthStore.getState();
|
||||
return auth.activeAccountId ? auth.getAllConnectedClients().get(auth.activeAccountId)?.getAccountId() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the message open in the reader is the one an entry names. JMAP ids
|
||||
* are unique per account only, so the accounts are compared too, a missing
|
||||
* one being the active login's own.
|
||||
*/
|
||||
function isOpen(email: SearchResultEmail | undefined, open: Email | null): boolean {
|
||||
if (!email || !open || open.id !== email.id) return false;
|
||||
const own = ownAccountId();
|
||||
return (open.sourceAccountId ?? own) === (email.accountId ?? own);
|
||||
}
|
||||
|
||||
interface PluginSearchResultsProps {
|
||||
/** Entries plugins return from onProvideSearchResults; a click is the plugin's onAction. */
|
||||
surface: "search-results";
|
||||
/** Opens a message in the reader, as a click on a list row does; without it every click is onAction. */
|
||||
onOpenEmail?: (email: OpenableEmail) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 plugins = usePluginStore((s) => s.plugins);
|
||||
const idPrefix = useId();
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupResults(results, (id) => plugins.find((p) => p.id === id)?.name ?? id),
|
||||
[results, plugins],
|
||||
);
|
||||
|
||||
if (!searching || groups.length === 0) return null;
|
||||
|
||||
const click = (r: ProvidedSearchResult) => {
|
||||
const reachable = r.email && onOpenEmail ? reachableEmail(r.email) : null;
|
||||
if (reachable) onOpenEmail?.(reachable);
|
||||
else void runPluginAction(r.pluginId, surface, r.key, { query });
|
||||
};
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div data-plugin-surface={surface} className="max-h-[40%] flex-shrink-0 overflow-y-auto border-b border-border bg-muted/30">
|
||||
{groups.map((group, index) => {
|
||||
const open = !collapsed.has(group.id);
|
||||
const listId = `${idPrefix}-${index}`;
|
||||
return (
|
||||
<section key={group.id} aria-label={group.heading}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(group.id)}
|
||||
aria-expanded={open}
|
||||
aria-controls={listId}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5 rtl:-scale-x-100" />}
|
||||
<span className="truncate">{group.heading}</span>
|
||||
<span className="font-normal">({group.results.length})</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul id={listId} className="pb-1">
|
||||
{group.results.map((r) => {
|
||||
const current = isOpen(r.email, openEmail);
|
||||
return (
|
||||
<li key={r.key}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => click(r)}
|
||||
aria-current={current ? "true" : undefined}
|
||||
className={cn(
|
||||
"flex w-full flex-col items-start gap-0.5 px-3 py-1.5 text-start",
|
||||
"hover:bg-muted focus-visible:bg-muted focus-visible:outline-none",
|
||||
current && "bg-accent",
|
||||
)}
|
||||
>
|
||||
<span className="w-full truncate text-sm font-medium text-foreground">{r.title}</span>
|
||||
{r.snippet && <span className="line-clamp-2 text-xs text-muted-foreground">{r.snippet}</span>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5215,6 +5215,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
}
|
||||
}}
|
||||
onEmailSelect={handleEmailSelect}
|
||||
onOpenSearchResult={handleEmailSelect}
|
||||
onEmailPrefetch={handleEmailPrefetch}
|
||||
onEmailDoubleClick={isEmbedded ? ((email) => {
|
||||
const store = useProTabStore.getState();
|
||||
|
||||
@@ -37,7 +37,7 @@ const sources = ['app', 'components', 'contexts', 'hooks', 'lib', 'stores'].flat
|
||||
|
||||
/** A call site that fires the hook: `xHooks.onFoo.emit(` and friends. */
|
||||
function isFired(hookName: string, groupName: string): boolean {
|
||||
const re = new RegExp(`\\b${groupName}\\.${hookName}\\s*\\.(emit|emitSync|emitTo|intercept|interceptOutcome|interceptWithValue|transform|takeOver)\\b`);
|
||||
const re = new RegExp(`\\b${groupName}\\.${hookName}\\s*\\.(emit|emitSync|emitTo|intercept|interceptOutcome|interceptWithValue|transform|takeOver|collect)\\b`);
|
||||
return re.test(sources);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { checkSearchResults, MAX_RESULTS_PER_PLUGIN, provideSearchResults } from '../plugin-search-results';
|
||||
import { clearAllHooks, emailHooks } from '../plugin-hooks';
|
||||
|
||||
afterEach(() => {
|
||||
clearAllHooks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('checkSearchResults', () => {
|
||||
it('keeps entries with a key and a title, as plain text, with the plugin', () => {
|
||||
expect(checkSearchResults('p', [
|
||||
{ key: 'a', title: ' Pixel grid\n too coarse ', snippet: 'For our AR prototype', source: 'CRM', onClick: 'x', extra: 1 },
|
||||
])).toEqual([{ pluginId: 'p', key: 'a', title: 'Pixel grid too coarse', snippet: 'For our AR prototype', source: 'CRM' }]);
|
||||
});
|
||||
|
||||
it('drops what is not an entry, and a key given twice', () => {
|
||||
expect(checkSearchResults('p', [
|
||||
null, 'text', { title: 'no key' }, { key: 'a' }, { key: 7, title: 'number key' },
|
||||
{ key: 'b', title: 'B' }, { key: 'b', title: 'B again' },
|
||||
]).map((r) => r.key)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('keeps the message an entry names, with its account', () => {
|
||||
expect(checkSearchResults('p', [
|
||||
{ key: 'a', title: 'A', email: { id: 'M1', accountId: 'group-1', extra: true } },
|
||||
{ key: 'b', title: 'B', email: { id: 'M2' } },
|
||||
]).map((r) => r.email)).toEqual([{ id: 'M1', accountId: 'group-1' }, { id: 'M2' }]);
|
||||
});
|
||||
|
||||
it('keeps an entry whose message is not one, without it', () => {
|
||||
const checked = checkSearchResults('p', [
|
||||
{ key: 'a', title: 'A', email: 'M1' },
|
||||
{ key: 'b', title: 'B', email: { id: '' } },
|
||||
{ key: 'c', title: 'C', email: { id: 'M3', accountId: 7 } },
|
||||
{ key: 'd', title: 'D', email: { id: 'x'.repeat(256) } },
|
||||
]);
|
||||
expect(checked.map((r) => r.key)).toEqual(['a', 'b', 'c', 'd']);
|
||||
expect(checked.every((r) => !('email' in r))).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores an answer that is not a list', () => {
|
||||
expect(checkSearchResults('p', { key: 'a', title: 'A' })).toEqual([]);
|
||||
expect(checkSearchResults('p', undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('cuts long texts and takes at most the limit per plugin', () => {
|
||||
const many = Array.from({ length: MAX_RESULTS_PER_PLUGIN + 5 }, (_, i) => ({ key: `k${i}`, title: 'x'.repeat(500), snippet: 'y'.repeat(900) }));
|
||||
const checked = checkSearchResults('p', many);
|
||||
expect(checked).toHaveLength(MAX_RESULTS_PER_PLUGIN);
|
||||
expect(checked[0].title.length).toBe(200);
|
||||
expect(checked[0].title.endsWith('…')).toBe(true);
|
||||
expect(checked[0].snippet.length).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provideSearchResults', () => {
|
||||
it('asks no one without handlers', async () => {
|
||||
expect(await provideSearchResults('q', {})).toEqual([]);
|
||||
});
|
||||
|
||||
it('asks the plugins at once, each with an empty list', async () => {
|
||||
const order: string[] = [];
|
||||
emailHooks.onProvideSearchResults.register('slow', async (initial: unknown[]) => {
|
||||
order.push(`slow ${initial.length}`);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
return [{ key: 's', title: 'S' }];
|
||||
});
|
||||
emailHooks.onProvideSearchResults.register('fast', (initial: unknown[]) => {
|
||||
order.push(`fast ${initial.length}`);
|
||||
return [{ key: 'f', title: 'F' }];
|
||||
});
|
||||
|
||||
const results = await provideSearchResults('q', {});
|
||||
|
||||
expect(order).toEqual(['slow 0', 'fast 0']);
|
||||
expect(results.map((r) => `${r.pluginId}:${r.key}`)).toEqual(['slow:s', 'fast:f']);
|
||||
});
|
||||
|
||||
it('leaves out 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']);
|
||||
});
|
||||
|
||||
it('leaves out a plugin that runs out of time', 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);
|
||||
|
||||
expect((await pending).map((r) => r.pluginId)).toEqual(['ok']);
|
||||
});
|
||||
});
|
||||
@@ -116,7 +116,7 @@ export const HOOK_CATALOG = {
|
||||
// ─── Mail: search ──────────────────────────────────────────
|
||||
onSearch: H('mail-search', 'transform', 'email:read', '{ query, filters: SearchFilters }', 'before a search runs; may change the query and filters'),
|
||||
onSearchResults: H('mail-search', 'transform', 'email:read', 'string[], { query, filters, emailIds }', 'after search results arrived; return ids of further messages to add to them (at most 100)'),
|
||||
onProvideSearchResults: H('mail-search', 'transform', 'email:read', 'ExternalSearchResult[], { query, filters }', 'to add results from elsewhere to a search'),
|
||||
onProvideSearchResults: H('mail-search', 'transform', 'email:read', 'ExternalSearchResult[], { query, filters }', 'to add results from elsewhere to a search; drawn above its message list; an entry naming a message (email) opens it on a click, any other click comes back as onAction with surface "search-results" and the entry\'s key'),
|
||||
|
||||
// ─── Mail: attachments ─────────────────────────────────────
|
||||
onBeforeAttachmentUpload: H('mail-attachments', 'intercept', 'email:read', 'AttachmentInfo', 'before a file is attached in the composer'),
|
||||
|
||||
+24
-3
@@ -303,6 +303,26 @@ export class HookBus<T extends (...args: any[]) => any> {
|
||||
return { allowed: true, value };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async collect<V>(initial: V, ...rest: unknown[]): Promise<{ pluginId: string; value: unknown }[]> {
|
||||
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 answers.filter((a): a is { pluginId: string; value: unknown } => a !== null);
|
||||
}
|
||||
|
||||
/** Fire handlers as transforms - each receives the output of the previous */
|
||||
async transform<V>(initial: V, ...rest: unknown[]): Promise<V> {
|
||||
let value = initial;
|
||||
@@ -464,9 +484,10 @@ export const emailHooks = {
|
||||
// renderer for an attachment. Initial value: AttachmentPreview, second
|
||||
// argument: AttachmentInfo.
|
||||
onAttachmentPreview: new HookBus(),
|
||||
// Transform hook - lets plugins contribute additional results to the global
|
||||
// search panel. Initial value: ExternalSearchResult[]. Second argument:
|
||||
// { query: string, filters: SearchFilters }.
|
||||
// Lets plugins contribute results from elsewhere to a search, drawn above
|
||||
// its message list (lib/plugin-search-results.ts). Each handler is asked
|
||||
// on its own with an empty ExternalSearchResult[] and { query, filters },
|
||||
// so a click can go back to the plugin that gave the entry.
|
||||
onProvideSearchResults: new HookBus(),
|
||||
// Observer fired (debounced) when the composer draft body, subject, or
|
||||
// recipients change. Handler receives a DraftView snapshot. Use for AI
|
||||
|
||||
@@ -196,6 +196,11 @@ export function setActionDispatcher(next: ActionDispatcher | null): void {
|
||||
}
|
||||
|
||||
export async function runAction(action: RegisteredAction, surface: string, context: Record<string, unknown>): Promise<void> {
|
||||
await runPluginAction(action.pluginId, surface, action.id, context);
|
||||
}
|
||||
|
||||
/** A click on something a plugin gave as data elsewhere (a search result's entry). */
|
||||
export async function runPluginAction(pluginId: string, surface: string, actionId: string, context: Record<string, unknown>): Promise<void> {
|
||||
if (!dispatcher) return;
|
||||
await dispatcher(action.pluginId, surface, action.id, context);
|
||||
await dispatcher(pluginId, surface, actionId, context);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Results plugins add to a search through onProvideSearchResults, drawn as a
|
||||
// block above the search's message list (components/email/plugin-search-results.tsx).
|
||||
// Each plugin is asked on its own, so every entry keeps the plugin it came
|
||||
// from: a click goes back to that plugin as onAction, unless the entry names a
|
||||
// message, which the click opens. What a plugin returns crosses from its
|
||||
// sandbox, so it is checked and cut here, never trusted.
|
||||
|
||||
import { emailHooks } from './plugin-hooks';
|
||||
import type { ExternalSearchResult, SearchResultEmail } from './plugin-types';
|
||||
|
||||
/** An entry as the host keeps it: checked, with the plugin that gave it. */
|
||||
export interface ProvidedSearchResult extends ExternalSearchResult {
|
||||
pluginId: string;
|
||||
}
|
||||
|
||||
/** Most entries one plugin may add to one search. */
|
||||
export const MAX_RESULTS_PER_PLUGIN = 20;
|
||||
const MAX_KEY = 200;
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_SNIPPET = 300;
|
||||
const MAX_SOURCE = 40;
|
||||
/** JMAP ids are at most 255 octets (RFC 8620 §1.2). */
|
||||
const MAX_ID = 255;
|
||||
|
||||
function text(value: unknown, max: number): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
const flat = value.replace(/\s+/g, ' ').trim();
|
||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
||||
}
|
||||
|
||||
function jmapId(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID ? value : null;
|
||||
}
|
||||
|
||||
/** The message an entry names, or null for anything that is not one. */
|
||||
function email(value: unknown): SearchResultEmail | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const raw = value as Record<string, unknown>;
|
||||
const id = jmapId(raw.id);
|
||||
if (!id) return null;
|
||||
if (raw.accountId === undefined) return { id };
|
||||
const accountId = jmapId(raw.accountId);
|
||||
return accountId ? { id, accountId } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One plugin's answer, checked: a list of entries with a key and a title,
|
||||
* plain text cut to its limits, keys unique, at most MAX_RESULTS_PER_PLUGIN.
|
||||
* Anything else is dropped without failing the rest.
|
||||
*/
|
||||
export function checkSearchResults(pluginId: string, value: unknown): ProvidedSearchResult[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: ProvidedSearchResult[] = [];
|
||||
for (const raw of value) {
|
||||
if (out.length >= MAX_RESULTS_PER_PLUGIN) break;
|
||||
if (!raw || typeof raw !== 'object') continue;
|
||||
const entry = raw as Record<string, unknown>;
|
||||
const key = typeof entry.key === 'string' ? entry.key : '';
|
||||
const title = text(entry.title, MAX_TITLE);
|
||||
if (!key || key.length > MAX_KEY || seen.has(key) || !title) continue;
|
||||
seen.add(key);
|
||||
const source = text(entry.source, MAX_SOURCE);
|
||||
const message = email(entry.email);
|
||||
out.push({
|
||||
pluginId, key, title, snippet: text(entry.snippet, MAX_SNIPPET),
|
||||
...(source ? { source } : {}),
|
||||
...(message ? { email: message } : {}),
|
||||
});
|
||||
}
|
||||
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[]> {
|
||||
if (emailHooks.onProvideSearchResults.size === 0) return [];
|
||||
const answers = await emailHooks.onProvideSearchResults.collect([] as ExternalSearchResult[], { query, filters });
|
||||
return answers.flatMap(({ pluginId, value }) => checkSearchResults(pluginId, value));
|
||||
}
|
||||
@@ -62,6 +62,7 @@ export const SLOT_CATALOG = {
|
||||
'message-bulk-actions': S('mail-list', 'action', ['ui:message-list'], { emailIds: 'email:read' }, 'buttons in the toolbar shown while messages are selected', true),
|
||||
'context-menu-email': S('mail-list', 'frame', ['ui:context-menu'], {}, 'the right-click menu of a message'),
|
||||
'toolbar-actions': S('mail-list', 'frame', ['ui:toolbar'], {}, 'the message toolbar', true),
|
||||
'search-results': S('mail-list', 'data', ['email:read'], { query: 'email:read' }, 'entries a plugin returns from onProvideSearchResults, in a block above a search\'s message list; a click opens the message an entry names, any other reaches the plugin as onAction with the entry\'s key as actionId'),
|
||||
|
||||
// ─── Reader ────────────────────────────────────────────────
|
||||
'reader-header': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'at the top of the open message, above the subject'),
|
||||
|
||||
+21
-7
@@ -1199,19 +1199,33 @@ export interface ConflictWarning {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returned by onProvideSearchResults transform handlers. Plugins extend the
|
||||
* initial array with their own results (CRM hits, Slack messages, etc.).
|
||||
* The host renders these in a grouped section below native email results.
|
||||
* Returned by onProvideSearchResults handlers: results from elsewhere (CRM
|
||||
* hits, chat messages, ...) for the search that is running. The host draws
|
||||
* them as a block above the search's message list, grouped by `source`.
|
||||
* A click on an entry that names a message the user can reach (`email`)
|
||||
* opens it in the reader, as a click on a row of the list does. Any other
|
||||
* click reaches the plugin that returned the entry as `onAction`, with
|
||||
* `surface: 'search-results'`, the entry's `key` as `actionId` and the
|
||||
* search's `query` in the context. Plain text only; see
|
||||
* lib/plugin-search-results.ts for the limits.
|
||||
*/
|
||||
export interface ExternalSearchResult {
|
||||
/** Stable unique key */
|
||||
/** Unique among the plugin's entries for this search; comes back as actionId. */
|
||||
key: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
/** Plugin-handled action when the result row is clicked */
|
||||
onClick: () => void;
|
||||
/** Optional source label, e.g. "Slack", "Notion" */
|
||||
/** Group heading, e.g. "CRM", "Slack"; the plugin's name when missing. */
|
||||
source?: string;
|
||||
/** The message the entry stands for, opened in the reader on a click. */
|
||||
email?: SearchResultEmail;
|
||||
}
|
||||
|
||||
/** A message named by a search result. */
|
||||
export interface SearchResultEmail {
|
||||
/** The message's JMAP id. */
|
||||
id: string;
|
||||
/** The JMAP account holding it (a shared or group account); the signed-in account's own when missing. */
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
|
||||
import { clearAllHooks, emailHooks } from '@/lib/plugin-hooks';
|
||||
import type { ExternalSearchResult } from '@/lib/plugin-types';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// onProvideSearchResults: plugins' results from elsewhere, asked beside the
|
||||
// search, never before its list is shown.
|
||||
|
||||
const hit = (id: string): Email => ({
|
||||
id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, subject: `Subject ${id}`,
|
||||
receivedAt: '2026-10-01T00:00:00Z', from: [], to: [], preview: '', size: 1, hasAttachment: false,
|
||||
} as Email);
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((r) => { resolve = r; });
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const entry = (key: string): ExternalSearchResult => ({ key, title: `Title ${key}`, snippet: 'text', source: 'CRM' });
|
||||
|
||||
let client: IJMAPClient;
|
||||
|
||||
beforeEach(() => {
|
||||
clearAllHooks();
|
||||
client = {
|
||||
getAccountId: () => 'me',
|
||||
searchEmails: vi.fn().mockResolvedValue({ emails: [hit('e1')], hasMore: false, total: 1 }),
|
||||
advancedSearchEmails: vi.fn().mockResolvedValue({ emails: [hit('e1')], hasMore: false, total: 1 }),
|
||||
getSomeEmails: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as IJMAPClient;
|
||||
useAuthStore.setState({ activeAccountId: 'acct', getClientForAccount: () => client as never } as never);
|
||||
useSettingsStore.setState({ emailsPerPage: 50 } as never);
|
||||
useEmailStore.setState({
|
||||
mailboxes: [{ id: 'inbox', name: 'Inbox', role: 'inbox', isShared: false } as Mailbox],
|
||||
accountMailboxes: {},
|
||||
viewingAccountId: null,
|
||||
selectedMailbox: 'inbox',
|
||||
searchMailboxId: 'inbox',
|
||||
selectedKeyword: null,
|
||||
isUnifiedView: false,
|
||||
unifiedRole: null,
|
||||
crossView: null,
|
||||
searchQuery: '',
|
||||
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
|
||||
searchAbortController: null,
|
||||
externalSearchResults: [],
|
||||
emails: [],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => clearAllHooks());
|
||||
|
||||
describe('onProvideSearchResults', () => {
|
||||
it('lists the messages before a slow plugin answers, then shows its entries with the plugin', async () => {
|
||||
const answer = deferred<ExternalSearchResult[]>();
|
||||
const handler = vi.fn(() => answer.promise);
|
||||
emailHooks.onProvideSearchResults.register('crm', handler);
|
||||
|
||||
await useEmailStore.getState().searchEmails(client, 'resolution');
|
||||
|
||||
expect(handler).toHaveBeenCalledWith([], { query: 'resolution', filters: DEFAULT_SEARCH_FILTERS });
|
||||
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1']);
|
||||
expect(useEmailStore.getState().externalSearchResults).toEqual([]);
|
||||
|
||||
answer.resolve([entry('m1')]);
|
||||
await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(1));
|
||||
expect(useEmailStore.getState().externalSearchResults[0]).toEqual({ ...entry('m1'), pluginId: 'crm' });
|
||||
});
|
||||
|
||||
it('asks every plugin on its own and keeps each entry with the plugin that gave it', async () => {
|
||||
emailHooks.onProvideSearchResults.register('a', (results: ExternalSearchResult[]) => [...results, entry('x')]);
|
||||
emailHooks.onProvideSearchResults.register('b', (results: ExternalSearchResult[]) => [...results, entry('x')]);
|
||||
|
||||
await useEmailStore.getState().searchEmails(client, 'resolution');
|
||||
|
||||
await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(2));
|
||||
expect(useEmailStore.getState().externalSearchResults.map((r) => r.pluginId)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('drops the answer to a search that was replaced', async () => {
|
||||
const first = deferred<ExternalSearchResult[]>();
|
||||
const second = deferred<ExternalSearchResult[]>();
|
||||
const answers = [first, second];
|
||||
emailHooks.onProvideSearchResults.register('crm', () => answers.shift()!.promise);
|
||||
|
||||
await useEmailStore.getState().searchEmails(client, 'one');
|
||||
await useEmailStore.getState().searchEmails(client, 'two');
|
||||
second.resolve([entry('two')]);
|
||||
await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(1));
|
||||
first.resolve([entry('one')]);
|
||||
await first.promise;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
expect(useEmailStore.getState().externalSearchResults.map((r) => r.key)).toEqual(['two']);
|
||||
});
|
||||
|
||||
it('clears the last search\'s entries when a new search starts', async () => {
|
||||
emailHooks.onProvideSearchResults.register('crm', (_results: ExternalSearchResult[], { query }: { query: string }) =>
|
||||
(query === 'one' ? [entry('one')] : new Promise(() => {})));
|
||||
|
||||
await useEmailStore.getState().searchEmails(client, 'one');
|
||||
await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(1));
|
||||
await useEmailStore.getState().searchEmails(client, 'two');
|
||||
|
||||
expect(useEmailStore.getState().externalSearchResults).toEqual([]);
|
||||
});
|
||||
|
||||
it('does the same on the advanced search', async () => {
|
||||
const answer = deferred<ExternalSearchResult[]>();
|
||||
emailHooks.onProvideSearchResults.register('crm', () => answer.promise);
|
||||
useEmailStore.setState({ searchQuery: 'resolution', searchFilters: { ...DEFAULT_SEARCH_FILTERS, from: 'bob@x.test' } });
|
||||
|
||||
await useEmailStore.getState().advancedSearch(client);
|
||||
|
||||
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['e1']);
|
||||
answer.resolve([entry('m1')]);
|
||||
await vi.waitFor(() => expect(useEmailStore.getState().externalSearchResults).toHaveLength(1));
|
||||
expect(useEmailStore.getState().externalSearchResults[0].pluginId).toBe('crm');
|
||||
});
|
||||
});
|
||||
+30
-19
@@ -19,7 +19,7 @@ import { allowEmailMove, createMailboxWithHooks, emitEmailMove, idsFromSelection
|
||||
import { emailSummary, emailToReadView } from "@/lib/plugin-projection";
|
||||
import { resolveThreadRoute } from "@/lib/thread-routing";
|
||||
import { emailKeyFor, threadKeyFor, threadIdFromKey, KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from "@/lib/thread-utils";
|
||||
import type { ExternalSearchResult } from "@/lib/plugin-types";
|
||||
import { provideSearchResults, type ProvidedSearchResult } from "@/lib/plugin-search-results";
|
||||
import { positionsByAccount, fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, fetchTagEmails, trashAndJunkIds, searchAcrossAccounts, advancedSearchAcrossAccounts, getCrossUnreadTotal, dedupeUnifiedAccounts, unifiedServerKey, type AcrossAccountsSearchOptions, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
|
||||
import { defaultSearchScopeFor, isAllFoldersSearchScope, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
@@ -204,8 +204,8 @@ interface EmailStore {
|
||||
searchScopeFollowsFolder: boolean;
|
||||
isAdvancedSearchOpen: boolean;
|
||||
searchAbortController: AbortController | null;
|
||||
/** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */
|
||||
externalSearchResults: ExternalSearchResult[];
|
||||
/** Plugins' results from elsewhere for the search running (onProvideSearchResults), drawn above its list. */
|
||||
externalSearchResults: ProvidedSearchResult[];
|
||||
|
||||
// Unified mailbox state
|
||||
// Bumped by any action that changes which mailbox/keyword/unified view is
|
||||
@@ -2836,6 +2836,31 @@ async function pluginSearchResultIds(query: string, filters: unknown, emails: Em
|
||||
.slice(0, MAX_PLUGIN_SEARCH_IDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
let providedSearchRun: object | null = null;
|
||||
|
||||
function startProvidedSearchResults(
|
||||
query: string,
|
||||
filters: unknown,
|
||||
store: { get: () => EmailStore; set: (partial: Partial<EmailStore>) => void },
|
||||
): void {
|
||||
const run = {};
|
||||
providedSearchRun = run;
|
||||
// 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 });
|
||||
};
|
||||
show([]);
|
||||
if (emailHooks.onProvideSearchResults.size === 0) return;
|
||||
void provideSearchResults(query, filters).then((results) => {
|
||||
if (providedSearchRun === run) show(results);
|
||||
});
|
||||
}
|
||||
|
||||
export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
emails: [],
|
||||
mailboxes: [],
|
||||
@@ -4726,6 +4751,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
const query = search.query;
|
||||
const { isUnifiedView, unifiedRole, crossView, searchMailboxId, searchFilters, selectedKeyword } = get();
|
||||
startProvidedSearchResults(query, searchFilters, { get, set });
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
let result;
|
||||
@@ -4788,18 +4814,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
result.total += newEmails.length;
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], {
|
||||
query,
|
||||
filters: searchFilters
|
||||
});
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
|
||||
set({
|
||||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||
externalSearchResults: externals,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
// Also here: a fast answer can land before the deferred loading write.
|
||||
@@ -4843,6 +4861,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const { query: searchQuery, filters: searchFilters } = transformed
|
||||
?? (emailHooks.onSearch.size > 0 ? await transformSearch(typedQuery, typedFilters) : { query: typedQuery, filters: typedFilters });
|
||||
if (controller.signal.aborted) return;
|
||||
startProvidedSearchResults(searchQuery, searchFilters, { get, set });
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
let result;
|
||||
let accountId;
|
||||
@@ -4909,18 +4928,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
result.total += newEmails.length;
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
|
||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], {
|
||||
query: searchQuery,
|
||||
filters: searchFilters
|
||||
});
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
result.emails = await emailHooks.onEmailsFetched.transform(result.emails);
|
||||
set({
|
||||
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
|
||||
externalSearchResults: externals,
|
||||
hasMoreEmails: result.hasMore,
|
||||
totalEmails: result.total,
|
||||
isLoading: false,
|
||||
|
||||
Reference in New Issue
Block a user