Files
webmail/lib/plugin-search-results.ts
T
Jacob Zeitz 03876158fe 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.
2026-10-29 10:25:59 +01:00

92 lines
3.6 KiB
TypeScript

// 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. 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 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);
}