feat: search mail, contacts, events and files of every account at once

This commit is contained in:
Linus Rath
2026-10-31 15:50:02 +01:00
parent 417f1843a2
commit f15b7bb3cf
15 changed files with 2330 additions and 2 deletions
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { client } = vi.hoisted(() => ({
client: {
isConnected: true,
username: 'me@example.org',
serverUrl: 'https://mail.example.org',
accountId: 'own',
currentSession: null as unknown,
request: vi.fn(),
getStoredCredentials: vi.fn(),
},
}));
vi.mock('../jmap-client', () => ({
jmapClient: client,
rewriteSessionUrl: (url: string) => url,
extractOrigin: () => null,
}));
vi.mock('../../stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId: null }) } }));
vi.mock('../../stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts: [] }) } }));
vi.mock('../../stores/email-store', () => ({ useEmailStore: { getState: () => ({ emails: [], mailboxes: [] }) } }));
vi.mock('../../stores/contacts-store', () => ({ useContactsStore: { getState: () => ({ contacts: [], addressBooks: [] }) } }));
vi.mock('../../stores/calendar-store', () => ({ useCalendarStore: { getState: () => ({ events: [], calendars: [] }) } }));
import { accountsFor, mailProvider, contactsProvider, resetGlobalSearchCache } from '../global-search';
import { parseSearchQuery } from '../../lib/global-search/query-parser';
import { generateAccountId } from '../../lib/account-utils';
import type { JMAPSession } from '../types';
const MAIL = 'urn:ietf:params:jmap:mail';
const CONTACTS = 'urn:ietf:params:jmap:contacts';
const session = {
capabilities: { 'urn:ietf:params:jmap:core': {}, [MAIL]: {}, [CONTACTS]: {} },
primaryAccounts: { [MAIL]: 'own', [CONTACTS]: 'own' },
accounts: {
own: { name: 'me@example.org', isPersonal: true, accountCapabilities: { [MAIL]: {}, [CONTACTS]: {} } },
team: { name: 'Support', isPersonal: false, accountCapabilities: { [MAIL]: {} } },
},
} as unknown as JMAPSession;
const live = {
localAccountId: generateAccountId('me@example.org', 'https://mail.example.org'),
label: 'Me', email: 'me@example.org', active: true,
};
beforeEach(() => {
resetGlobalSearchCache();
client.request.mockReset();
client.currentSession = session;
});
describe('the JMAP accounts a login searches', () => {
it('lists the own account first, then the shared ones that hold the kind', () => {
expect(accountsFor(session, MAIL).map((a) => [a.id, a.primary])).toEqual([['own', true], ['team', false]]);
expect(accountsFor(session, CONTACTS).map((a) => a.id)).toEqual(['own']);
});
});
describe('mail search of the signed-in login', () => {
it('searches every folder but Trash and Junk of each account, with snippets, in one request per account', async () => {
client.request.mockImplementation(async (calls: unknown[][]) => {
const [method, args] = calls[0] as [string, { accountId: string }];
if (method === 'Mailbox/get') {
return { methodResponses: [['Mailbox/get', { list: [{ id: `${args.accountId}-in`, role: 'inbox', name: 'Inbox' }, { id: `${args.accountId}-tr`, role: 'trash', name: 'Trash' }] }, '0']] };
}
return {
methodResponses: [
['Email/query', { ids: [`${args.accountId}-m1`] }, 'q'],
['Email/get', { list: [{ id: `${args.accountId}-m1`, threadId: 't', subject: 'Invoice', mailboxIds: { [`${args.accountId}-in`]: true } }] }, 'g'],
['SearchSnippet/get', { list: [{ emailId: `${args.accountId}-m1`, subject: '<mark>Invoice</mark>', preview: null }] }, 's'],
],
};
});
const { hits } = await mailProvider.remote(parseSearchQuery('invoice'), live, { limit: 10, signal: new AbortController().signal });
const query = client.request.mock.calls.find(([calls]) => calls[0][0] === 'Email/query')![0];
expect(query[0][1].filter).toEqual({ operator: 'AND', conditions: [{ text: 'invoice' }, { inMailboxOtherThan: ['own-tr'] }] });
expect(query.map((c: unknown[]) => c[0])).toEqual(['Email/query', 'Email/get', 'SearchSnippet/get']);
expect(hits.map((h) => [h.id, h.jmapAccountId, h.shared, h.subtitle])).toEqual([
['own-m1', 'own', false, 'Inbox'],
['team-m1', 'team', true, 'Support · Inbox'],
]);
expect(hits[0].kind === 'mail' && hits[0].snippet?.subject).toBe('<mark>Invoice</mark>');
});
});
describe('contact search of the signed-in login', () => {
it('asks ContactCard/query with the text and names the card by the id the contacts list uses', async () => {
client.request.mockResolvedValue({
methodResponses: [
['ContactCard/query', { ids: ['c1'] }, 'q'],
['ContactCard/get', { list: [{ id: 'c1', addressBookIds: { b1: true }, name: { full: 'Anna Berg' }, emails: { e: { address: 'anna@x.example' } } }] }, 'g'],
['AddressBook/get', { list: [{ id: 'b1', name: 'Personal' }] }, 'b'],
],
});
const { hits } = await contactsProvider.remote(parseSearchQuery('anna'), live, { limit: 10, signal: new AbortController().signal });
expect(client.request.mock.calls[0][0][0]).toEqual(['ContactCard/query', { accountId: 'own', filter: { text: 'anna' }, limit: 11 }, 'q']);
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ kind: 'contacts', id: 'c1', storeId: 'c1', subtitle: 'Personal · anna@x.example' });
});
});
@@ -0,0 +1,172 @@
/**
* Live checks of "Search everything" against a real Stalwart: one word finds
* a message, a contact, an event and a folder of the signed-in login, and a
* contact of a second login that is searched "detached", with its stored
* credentials, while the first stays connected.
*
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP`,
* `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` are set (`BULWARK_LIVE_PASS2`
* defaults to the first password).
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
* BULWARK_LIVE_USER2=e2e-tablet-land@example.org \
* npx vitest run src/api/__tests__/live-stalwart-global-search.test.ts
*
* Everything the run makes is removed again, also after a failed check.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
const { secrets } = vi.hoisted(() => ({ secrets: new Map<string, string>() }));
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(async (key: string, value: string) => { secrets.set(key, value); }),
getItemAsync: vi.fn(async (key: string) => secrets.get(key) ?? null),
deleteItemAsync: vi.fn(async (key: string) => { secrets.delete(key); }),
}));
import { JMAPClient, credentialsKey } from '../jmap-client';
import { CAPABILITIES } from '../types';
import { GLOBAL_SEARCH_PROVIDERS, resetGlobalSearchCache } from '../global-search';
import { runGlobalSearch } from '../../lib/global-search/run';
import { parseSearchQuery } from '../../lib/global-search/query-parser';
import type { SearchAccount, SearchOutcome } from '../../lib/global-search/types';
import { generateAccountId } from '../../lib/account-utils';
import { createFolder, deleteFileNodes } from '../files';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const USER2 = process.env.BULWARK_LIVE_USER2;
const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS;
const live = SERVER && PASS && USER2 ? describe : describe.skip;
// One word nobody else has, so the full-text index finds only this run.
const TOKEN = `qz${Date.now().toString(36)}`;
type Responses = Array<[string, Record<string, any>, string]>;
let owner: JMAPClient;
let second: JMAPClient;
const made: Array<{ client: () => JMAPClient; method: string; accountId: string; id: string; using: string[] }> = [];
async function call(client: JMAPClient, calls: unknown[], using: string[]): Promise<Responses> {
return (await client.request(calls as never, using)).methodResponses as Responses;
}
async function createOne(client: JMAPClient, method: string, accountId: string, object: Record<string, unknown>, using: string[]): Promise<string> {
const [[name, body]] = await call(client, [[method, { accountId, create: { x: object } }, '0']], using);
expect(name, JSON.stringify(body)).toBe(method);
const id = body.created?.x?.id as string | undefined;
expect(id, JSON.stringify(body.notCreated)).toBeTruthy();
made.push({ client: () => client, method, accountId, id: id!, using });
return id!;
}
async function seedContact(client: JMAPClient): Promise<string> {
const accountId = client.currentSession!.primaryAccounts[CAPABILITIES.CONTACTS];
const using = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
const [[, books]] = await call(client, [['AddressBook/get', { accountId, properties: ['id', 'isDefault'] }, '0']], using);
const book = (books.list as { id: string; isDefault?: boolean }[]).find((b) => b.isDefault) ?? books.list[0];
return createOne(client, 'ContactCard/set', accountId, {
'@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book.id]: true },
uid: `urn:uuid:${TOKEN}-${client.username}`,
// 0.16 finds a card by the words of its name components, not of `full`.
name: { components: [{ kind: 'given', value: 'Findme' }, { kind: 'surname', value: TOKEN }], isOrdered: true, full: `Findme ${TOKEN}` },
}, using);
}
const accountFor = (user: string, active: boolean): SearchAccount => ({
localAccountId: generateAccountId(user, SERVER!), label: user, email: user, serverUrl: SERVER, active,
});
async function search(accounts: SearchAccount[]): Promise<SearchOutcome> {
return runGlobalSearch({
parsed: parseSearchQuery(TOKEN), accounts, providers: GLOBAL_SEARCH_PROVIDERS,
localLimit: 10, remoteLimit: 25, signal: new AbortController().signal, onUpdate: () => undefined, timeoutMs: 20_000,
});
}
live('live Stalwart: search everything', () => {
let ids: { mail: string; contact: string; event: string; folder: string | null; secondContact: string };
beforeAll(async () => {
const mod = await import('../jmap-client');
owner = new JMAPClient();
await owner.connect(SERVER!, USER, PASS);
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(owner.snapshot());
second = new JMAPClient();
await second.connect(SERVER!, USER2!, PASS2);
// The second login is searched with what the app keeps in secure storage.
secrets.set(credentialsKey(generateAccountId(USER2!, SERVER!)), JSON.stringify({ serverUrl: SERVER, username: USER2, password: PASS2 }));
resetGlobalSearchCache();
const mailAccount = owner.accountId;
const mailUsing = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
const [[, drafts]] = await call(owner, [['Mailbox/query', { accountId: mailAccount, filter: { role: 'drafts' } }, '0']], mailUsing);
const mail = await createOne(owner, 'Email/set', mailAccount, {
mailboxIds: { [drafts.ids[0]]: true }, keywords: { $draft: true, $seen: true },
from: [{ email: USER }], subject: `Findme ${TOKEN}`,
bodyValues: { b: { value: 'search check' } }, textBody: [{ partId: 'b', type: 'text/plain' }],
}, mailUsing);
const contact = await seedContact(owner);
const calAccount = owner.currentSession!.primaryAccounts[CAPABILITIES.CALENDARS];
const calUsing = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
const [[, calendars]] = await call(owner, [['Calendar/get', { accountId: calAccount, properties: ['id', 'isDefault'] }, '0']], calUsing);
const calendar = (calendars.list as { id: string; isDefault?: boolean }[]).find((c) => c.isDefault) ?? calendars.list[0];
const event = await createOne(owner, 'CalendarEvent/set', calAccount, {
'@type': 'Event', calendarIds: { [calendar.id]: true }, title: `Findme ${TOKEN}`,
start: '2027-06-01T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
}, calUsing);
let folder: string | null = null;
if (owner.currentSession!.capabilities[CAPABILITIES.FILES]) {
// The app's own call: 1.0 wants the draft's @type, 0.16 refuses it.
folder = (await createFolder(`findme-${TOKEN}`, null)).id;
}
const secondContact = await seedContact(second);
ids = { mail, contact, event, folder, secondContact };
}, 90_000);
afterAll(async () => {
if (ids?.folder) await deleteFileNodes([ids.folder]).catch(() => undefined);
for (const item of made.reverse()) {
await call(item.client(), [[item.method, { accountId: item.accountId, destroy: [item.id] }, '0']], item.using).catch(() => undefined);
}
}, 60_000);
it('finds the message, contact, event and folder of the signed-in login and the contact of the other one', async () => {
const accounts = [accountFor(USER, true), accountFor(USER2!, false)];
// The full-text index lags a little behind a write.
let outcome = await search(accounts);
for (let i = 0; i < 15; i++) {
const found = outcome.hits.mail.some((h) => h.id === ids.mail)
&& outcome.hits.calendar.some((h) => h.id === ids.event)
&& outcome.hits.contacts.filter((h) => h.id === ids.contact || h.id === ids.secondContact).length === 2;
if (found) break;
await new Promise((r) => setTimeout(r, 2000));
outcome = await search(accounts);
}
for (const kind of ['mail', 'contacts', 'calendar', 'files'] as const) expect(outcome.status[kind].errors, kind).toEqual([]);
const mail = outcome.hits.mail.find((h) => h.id === ids.mail);
expect(mail?.localAccountId).toBe(accounts[0].localAccountId);
expect(outcome.hits.calendar.find((h) => h.id === ids.event)?.title).toBe(`Findme ${TOKEN}`);
const contacts = outcome.hits.contacts.filter((h) => h.id === ids.contact || h.id === ids.secondContact);
expect(contacts.map((h) => h.localAccountId).sort()).toEqual(accounts.map((a) => a.localAccountId).sort());
if (ids.folder) {
const folder = outcome.hits.files.find((h) => h.id === ids.folder);
expect(folder).toMatchObject({ isFolder: true, folderPath: '/' });
}
}, 120_000);
it('keeps to the account picked with account: and the kind picked with in:', async () => {
const accounts = [accountFor(USER, true), accountFor(USER2!, false)];
const outcome = await runGlobalSearch({
parsed: parseSearchQuery(`in:contacts account:${USER2} ${TOKEN}`), accounts, providers: GLOBAL_SEARCH_PROVIDERS,
localLimit: 10, remoteLimit: 25, signal: new AbortController().signal, onUpdate: () => undefined, timeoutMs: 20_000,
});
expect(outcome.hits.mail).toEqual([]);
expect(outcome.hits.contacts.map((h) => h.localAccountId)).toEqual([accounts[1].localAccountId]);
}, 60_000);
});
+516
View File
@@ -0,0 +1,516 @@
// "Search everything": the search providers (webmail
// lib/global-search/providers/*.ts and accounts.ts).
//
// The JMAP client is one live session, so the signed-in login goes through
// it, and every other login is asked "detached": with its stored
// credentials, posting to its own API URL (as the unified inbox does), so the
// live session is never disturbed. Shared and group accounts inside a login
// are searched too.
import { jmapClient, rewriteSessionUrl, extractOrigin, type StoredCredentials } from './jmap-client';
import { CAPABILITIES, type CalendarEvent, type ContactCard, type Email, type FileNode, type JMAPMethodCall, type JMAPSession } from './types';
import { fetchWithDeadline } from './unified-inbox';
import { getAllFileNodesAcrossAccounts, fileNodeKind } from './files';
import { namespaceId } from './contacts';
import { freshStoredCredentials } from '../lib/stored-token-refresh';
import { generateAccountId } from '../lib/account-utils';
import { accountSupports } from '../lib/account-capability';
import { contactFromWire } from '../lib/contact-wire';
import { getContactDisplayName, getContactPrimaryEmail } from '../lib/contact-utils';
import { textFilter } from '../lib/search-utils';
import { snippetFilterFor, snippetHighlight, type SearchSnippetResult } from '../lib/search-snippet';
import { matchesTerms, type ParsedQuery } from '../lib/global-search/query-parser';
import {
calendarFilterFor, contactFields, emailFields, emailMatchesFilters, eventFields, isRecurringEvent,
mailFilterFor, pathOfNode, type MailboxRef,
} from '../lib/global-search/filters';
import type {
CalendarHit, ContactHit, FileHit, GlobalSearchHit, MailHit, SearchAccount, SearchProvider,
} from '../lib/global-search/types';
import { useAccountStore } from '../stores/account-store';
import { useAuthStore } from '../stores/auth-store';
import { useEmailStore } from '../stores/email-store';
import { useContactsStore } from '../stores/contacts-store';
import { useCalendarStore } from '../stores/calendar-store';
type MethodResponses = Array<[string, Record<string, any>, string]>;
const SESSION_TTL_MS = 10 * 60_000;
const MAILBOX_TTL_MS = 60_000;
/** Files have no usable server search: the listing is filtered, cached a minute. */
export const FILE_LISTING_TTL_MS = 60_000;
const MAIL_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'receivedAt', 'from', 'to', 'cc',
'subject', 'preview', 'hasAttachment', 'size',
];
const EVENT_PROPERTIES = [
'id', 'uid', 'calendarIds', 'title', 'description', 'start', 'duration', 'timeZone',
'showWithoutTime', 'locations', 'participants', 'recurrenceRule', 'recurrenceId',
];
const DETACHED_FILE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified', 'nodeType'];
// ── Logins ───────────────────────────────────────────────────────────────
/** Every login the search fans out to (webmail getSearchAccounts). */
export function getSearchAccounts(): SearchAccount[] {
const { accounts } = useAccountStore.getState();
const activeId = useAuthStore.getState().activeAccountId;
return accounts
.filter((a) => !a.needsSignIn)
.map((a) => ({
localAccountId: a.id,
label: a.displayName || a.email || a.username,
email: a.email || a.username,
serverUrl: a.serverUrl,
active: a.id === activeId,
}));
}
interface LoginSession {
session: JMAPSession;
post: (calls: JMAPMethodCall[], using: string[]) => Promise<MethodResponses>;
live: boolean;
}
const sessions = new Map<string, { at: number; value: Promise<LoginSession> }>();
/** Forget the detached sessions (sign-out, tests). */
export function resetGlobalSearchCache(): void {
sessions.clear();
mailboxCache.clear();
fileListings.clear();
}
function isLiveLogin(localAccountId: string): boolean {
if (!jmapClient.isConnected || !jmapClient.currentSession) return false;
const { username, serverUrl } = jmapClient;
return !!username && !!serverUrl && generateAccountId(username, serverUrl) === localAccountId;
}
function authHeader(creds: StoredCredentials): string {
if (creds.accessToken) return `Bearer ${creds.accessToken}`;
return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
}
async function openDetached(localAccountId: string): Promise<LoginSession> {
let creds = await jmapClient.getStoredCredentials(localAccountId);
if (!creds) throw new Error('No stored credentials');
creds = await freshStoredCredentials(localAccountId, creds);
const base = creds.serverUrl.replace(/\/+$/, '');
const auth = authHeader(creds);
const sessionRes = await fetchWithDeadline(`${base}/.well-known/jmap`, {
headers: { Authorization: auth, Accept: 'application/json' },
});
const session = (await sessionRes.json()) as JMAPSession;
const apiUrl = rewriteSessionUrl(session.apiUrl, extractOrigin(base)) ?? session.apiUrl;
return {
session,
live: false,
post: async (calls, using) => {
try {
const res = await fetchWithDeadline(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: auth },
body: JSON.stringify({ using, methodCalls: calls }),
});
const body = await res.json();
return (body.methodResponses ?? []) as MethodResponses;
} catch (err) {
// A dead session must not be reused for the next search.
if (err instanceof Error && err.message === 'Session expired') sessions.delete(localAccountId);
throw err;
}
},
};
}
async function loginSession(account: SearchAccount): Promise<LoginSession> {
if (isLiveLogin(account.localAccountId)) {
return {
session: jmapClient.currentSession!,
live: true,
post: async (calls, using) => (await jmapClient.request(calls, using)).methodResponses as MethodResponses,
};
}
const cached = sessions.get(account.localAccountId);
if (cached && Date.now() - cached.at < SESSION_TTL_MS) return cached.value;
const value = openDetached(account.localAccountId);
sessions.set(account.localAccountId, { at: Date.now(), value });
value.catch(() => sessions.delete(account.localAccountId));
return value;
}
/**
* The JMAP accounts of a login that hold a kind: its primary one first, then
* the shared and group accounts the user may use it with.
*/
export function accountsFor(session: JMAPSession, capability: string): { id: string; name: string; primary: boolean }[] {
const primaryId = session.primaryAccounts?.[capability] ?? session.primaryAccounts?.[CAPABILITIES.MAIL];
const out: { id: string; name: string; primary: boolean }[] = [];
if (primaryId && session.accounts?.[primaryId]) out.push({ id: primaryId, name: session.accounts[primaryId].name || primaryId, primary: true });
for (const [id, info] of Object.entries(session.accounts ?? {})) {
if (id === primaryId) continue;
if (!accountSupports(info, session.capabilities, capability)) continue;
out.push({ id, name: info.name || id, primary: false });
}
return out;
}
function responseOf(responses: MethodResponses, callId: string, method: string): Record<string, any> {
const match = responses.find((r) => r[2] === callId);
if (!match) throw new Error(`Missing response to ${method}`);
if (match[0] === 'error') throw new Error((match[1] as { description?: string; type?: string }).description || (match[1] as { type?: string }).type || `${method} failed`);
return match[1];
}
function abortIfNeeded(signal: AbortSignal): void {
if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
}
// ── Mail ─────────────────────────────────────────────────────────────────
const mailboxCache = new Map<string, { at: number; list: MailboxRef[] }>();
async function mailboxesOf(login: LoginSession, localAccountId: string, accountId: string): Promise<MailboxRef[]> {
const key = `${localAccountId}|${accountId}`;
const cached = mailboxCache.get(key);
if (cached && Date.now() - cached.at < MAILBOX_TTL_MS) return cached.list;
const res = await login.post([['Mailbox/get', { accountId, properties: ['id', 'name', 'role'] }, '0']], [CAPABILITIES.CORE, CAPABILITIES.MAIL]);
const list = (responseOf(res, '0', 'Mailbox/get').list ?? []) as MailboxRef[];
mailboxCache.set(key, { at: Date.now(), list });
return list;
}
function folderName(email: Pick<Email, 'mailboxIds'>, mailboxes: MailboxRef[]): string {
for (const id of Object.keys(email.mailboxIds ?? {})) {
const mailbox = mailboxes.find((m) => m.id === id);
if (mailbox) return mailbox.name;
}
return '';
}
function mailHit(
email: Email, account: SearchAccount, jmapAccountId: string, shared: boolean, folder: string, source: 'local' | 'remote',
): MailHit {
return {
kind: 'mail', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
id: email.id, accountLabel: account.label, title: email.subject ?? '', subtitle: folder,
date: email.receivedAt ?? null, source, email, snippet: email.searchSnippet ?? null,
};
}
/**
* One account's mail search: Email/query with the parsed filter (all
* folders but Trash and Junk), its messages, and the search snippets in the
* same request; a server without snippets still lists the messages.
*/
async function searchMailAccount(
login: LoginSession, parsed: ParsedQuery, accountId: string, mailboxes: MailboxRef[], limit: number,
): Promise<{ emails: Email[]; hasMore: boolean }> {
const filter = mailFilterFor(parsed, mailboxes);
const calls: JMAPMethodCall[] = [
['Email/query', { accountId, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit: limit + 1, collapseThreads: false }, 'q'],
['Email/get', { accountId, '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, properties: MAIL_PROPERTIES }, 'g'],
['SearchSnippet/get', { accountId, filter: snippetFilterFor(filter), '#emailIds': { resultOf: 'q', name: 'Email/query', path: '/ids' } }, 's'],
];
const res = await login.post(calls, [CAPABILITIES.CORE, CAPABILITIES.MAIL]);
const ids = (responseOf(res, 'q', 'Email/query').ids ?? []) as string[];
const list = (responseOf(res, 'g', 'Email/get').list ?? []) as Email[];
let snippets: SearchSnippetResult[] = [];
try {
snippets = (responseOf(res, 's', 'SearchSnippet/get').list ?? []) as SearchSnippetResult[];
} catch {
// Snippets only decorate the rows.
}
const byId = new Map(list.map((e) => [e.id, e]));
const emails = ids.slice(0, limit).map((id) => byId.get(id)).filter((e): e is Email => !!e).map((email) => {
const snippet = snippets.find((s) => s.emailId === email.id);
const highlight = snippet ? snippetHighlight(snippet) : null;
return highlight ? { ...email, searchSnippet: highlight } : email;
});
return { emails, hasMore: ids.length > limit };
}
export const mailProvider: SearchProvider = {
kind: 'mail',
local: (parsed, accounts, limit) => {
const account = accounts.find((a) => a.active);
if (!account) return [];
const { emails, mailboxes } = useEmailStore.getState();
let primary = '';
try { primary = jmapClient.accountId; } catch { /* no session */ }
const hits: MailHit[] = [];
for (const email of emails) {
if (!matchesTerms(parsed.terms, emailFields(email))) continue;
if (!emailMatchesFilters(email, parsed)) continue;
const mailbox = mailboxes.find((mb) => email.mailboxIds?.[mb.originalId ?? mb.id] || email.mailboxIds?.[mb.id]);
const jmapAccountId = email.jmapAccountId ?? (mailbox?.isShared && mailbox.accountId ? mailbox.accountId : primary);
hits.push(mailHit(email, account, jmapAccountId, jmapAccountId !== primary, mailbox?.name ?? '', 'local'));
if (hits.length >= limit) break;
}
return hits;
},
remote: async (parsed, account, { limit, signal }) => {
const login = await loginSession(account);
const hits: MailHit[] = [];
let hasMore = false;
// One account after the other: the run already asks the logins in parallel.
for (const target of accountsFor(login.session, CAPABILITIES.MAIL)) {
abortIfNeeded(signal);
const mailboxes = await mailboxesOf(login, account.localAccountId, target.id);
const result = await searchMailAccount(login, parsed, target.id, mailboxes, limit);
if (result.hasMore) hasMore = true;
for (const email of result.emails) {
const label = target.primary ? folderName(email, mailboxes) : [target.name, folderName(email, mailboxes)].filter(Boolean).join(' · ');
hits.push(mailHit(email, account, target.id, !target.primary, label, 'remote'));
}
}
return { hits, hasMore };
},
};
// ── Contacts ─────────────────────────────────────────────────────────────
function contactHit(
contact: ContactCard, account: SearchAccount, jmapAccountId: string, shared: boolean, storeId: string,
bookName: string, source: 'local' | 'remote',
): ContactHit {
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const title = name || email || contact.originalId || contact.id;
return {
kind: 'contacts', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
id: contact.originalId ?? contact.id, accountLabel: account.label, title,
subtitle: [bookName, email && email !== title ? email : ''].filter(Boolean).join(' · '),
date: contact.updated ?? null, source, contact, storeId,
};
}
export const contactsProvider: SearchProvider = {
kind: 'contacts',
local: (parsed, accounts, limit) => {
const account = accounts.find((a) => a.active);
if (!account) return [];
const { contacts, addressBooks } = useContactsStore.getState();
const primary = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CONTACTS] ?? '';
const hits: ContactHit[] = [];
for (const contact of contacts) {
if (!matchesTerms(parsed.terms, contactFields(contact, getContactDisplayName(contact)))) continue;
const bookId = Object.keys(contact.addressBookIds ?? {})[0];
const book = addressBooks.find((b) => b.id === bookId || b.originalId === bookId);
const jmapAccountId = contact.accountId ?? primary;
hits.push(contactHit(contact, account, jmapAccountId, !!contact.isShared, contact.id, book?.name ?? '', 'local'));
if (hits.length >= limit) break;
}
return hits;
},
remote: async (parsed, account, { limit, signal }) => {
const login = await loginSession(account);
const hits: ContactHit[] = [];
let hasMore = false;
for (const target of accountsFor(login.session, CAPABILITIES.CONTACTS)) {
abortIfNeeded(signal);
if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.CONTACTS)) continue;
const res = await login.post([
['ContactCard/query', { accountId: target.id, filter: textFilter(parsed.text), limit: limit + 1 }, 'q'],
['ContactCard/get', { accountId: target.id, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'],
['AddressBook/get', { accountId: target.id, properties: ['id', 'name'] }, 'b'],
], [CAPABILITIES.CORE, CAPABILITIES.CONTACTS]);
const ids = (responseOf(res, 'q', 'ContactCard/query').ids ?? []) as string[];
const list = ((responseOf(res, 'g', 'ContactCard/get').list ?? []) as ContactCard[]).map((c) => contactFromWire(c));
let books: { id: string; name: string }[] = [];
try { books = responseOf(res, 'b', 'AddressBook/get').list ?? []; } catch { /* names only */ }
if (ids.length > limit) hasMore = true;
for (const contact of list.slice(0, limit)) {
const bookId = Object.keys(contact.addressBookIds ?? {})[0];
const bookName = books.find((b) => b.id === bookId)?.name ?? '';
const storeId = target.primary ? contact.id : namespaceId(target.id, contact.id);
const shown: ContactCard = target.primary ? contact : { ...contact, id: storeId, originalId: contact.id, accountId: target.id, isShared: true };
hits.push(contactHit(shown, account, target.id, !target.primary, storeId,
target.primary ? bookName : [target.name, bookName].filter(Boolean).join(' · '), 'remote'));
}
}
return { hits, hasMore };
},
};
// ── Calendar ─────────────────────────────────────────────────────────────
function eventHit(
event: CalendarEvent, account: SearchAccount, jmapAccountId: string, shared: boolean, calendarName: string, source: 'local' | 'remote',
): CalendarHit {
const location = Object.values(event.locations ?? {}).find((l) => l?.name)?.name ?? '';
return {
kind: 'calendar', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
id: event.originalId ?? event.id, accountLabel: account.label, title: event.title || '',
subtitle: [calendarName, location].filter(Boolean).join(' · '),
date: event.start ?? null, source, event, isRecurring: isRecurringEvent(event),
};
}
/** Stalwart names it recurrenceRule (singular); the app's type has the plural. */
function eventFromWire(raw: Record<string, any>): CalendarEvent {
const rules = raw.recurrenceRules ?? (raw.recurrenceRule ? [raw.recurrenceRule] : null);
return { ...raw, recurrenceRules: rules } as CalendarEvent;
}
export const calendarProvider: SearchProvider = {
kind: 'calendar',
local: (parsed, accounts, limit) => {
const account = accounts.find((a) => a.active);
if (!account) return [];
const { events, calendars } = useCalendarStore.getState();
const primary = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CALENDARS] ?? '';
const hits: CalendarHit[] = [];
// The store holds occurrences; a matching series shows once.
const seen = new Set<string>();
for (const event of events) {
if (!matchesTerms(parsed.terms, eventFields(event))) continue;
const day = event.start?.slice(0, 10);
if (parsed.after && day && day < parsed.after) continue;
if (parsed.before && day && day > parsed.before) continue;
if (event.uid) {
if (seen.has(event.uid)) continue;
seen.add(event.uid);
}
const calendarId = Object.keys(event.calendarIds ?? {})[0];
const calendar = calendars.find((c) => c.id === calendarId);
const jmapAccountId = event.accountId ?? primary;
hits.push(eventHit(event, account, jmapAccountId, !!event.isShared, calendar?.name ?? '', 'local'));
if (hits.length >= limit) break;
}
return hits;
},
remote: async (parsed, account, { limit, signal }) => {
const login = await loginSession(account);
const hits: CalendarHit[] = [];
let hasMore = false;
for (const target of accountsFor(login.session, CAPABILITIES.CALENDARS)) {
abortIfNeeded(signal);
if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.CALENDARS)) continue;
const res = await login.post([
['CalendarEvent/query', { accountId: target.id, filter: calendarFilterFor(parsed), sort: [{ property: 'start', isAscending: false }], limit }, 'q'],
['CalendarEvent/get', { accountId: target.id, '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, properties: EVENT_PROPERTIES }, 'g'],
['Calendar/get', { accountId: target.id, properties: ['id', 'name'] }, 'c'],
], [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]);
const ids = (responseOf(res, 'q', 'CalendarEvent/query').ids ?? []) as string[];
const list = ((responseOf(res, 'g', 'CalendarEvent/get').list ?? []) as Record<string, any>[]).map(eventFromWire);
let calendars: { id: string; name: string }[] = [];
try { calendars = responseOf(res, 'c', 'Calendar/get').list ?? []; } catch { /* names only */ }
if (ids.length >= limit) hasMore = true;
for (const event of list) {
const calendarId = Object.keys(event.calendarIds ?? {})[0];
const name = calendars.find((c) => c.id === calendarId)?.name ?? '';
hits.push(eventHit(event, account, target.id, !target.primary,
target.primary ? name : [target.name, name].filter(Boolean).join(' · '), 'remote'));
}
}
return { hits, hasMore };
},
};
// ── Files ────────────────────────────────────────────────────────────────
interface FileListing {
at: number;
nodes: FileNode[] | null;
promise: Promise<FileNode[]>;
}
const fileListings = new Map<string, FileListing>();
/** Raw server id of a node (shared ones are listed as `<owner>:<id>`). */
export function rawFileNodeId(node: FileNode): string {
if (node.isShared && node.accountId && node.id.startsWith(`${node.accountId}:`)) return node.id.slice(node.accountId.length + 1);
return node.id;
}
async function listDetachedFiles(login: LoginSession): Promise<FileNode[]> {
const all: FileNode[] = [];
for (const target of accountsFor(login.session, CAPABILITIES.FILES)) {
if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.FILES)) continue;
try {
const res = await login.post([['FileNode/get', { accountId: target.id, ids: null, properties: DETACHED_FILE_PROPERTIES }, '0']], [CAPABILITIES.CORE, CAPABILITIES.FILES]);
for (const node of (responseOf(res, '0', 'FileNode/get').list ?? []) as FileNode[]) {
all.push(target.primary ? node : {
...node,
id: `${target.id}:${node.id}`,
parentId: node.parentId == null ? null : `${target.id}:${node.parentId}`,
accountId: target.id, accountName: target.name, isShared: true,
});
}
} catch (err) {
if (target.primary) throw err;
}
}
return all;
}
function fileListing(account: SearchAccount, login: LoginSession): FileListing {
const cached = fileListings.get(account.localAccountId);
if (cached && Date.now() - cached.at < FILE_LISTING_TTL_MS) return cached;
const entry: FileListing = {
at: Date.now(),
nodes: null,
promise: (login.live ? getAllFileNodesAcrossAccounts() : listDetachedFiles(login)).then((nodes) => {
entry.nodes = nodes;
return nodes;
}, (err) => {
// A failed listing must not be served as "no files" for a minute.
if (fileListings.get(account.localAccountId) === entry) fileListings.delete(account.localAccountId);
throw err;
}),
};
fileListings.set(account.localAccountId, entry);
return entry;
}
function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAccount, limit: number, source: 'local' | 'remote', primary: string): FileHit[] {
const hits: FileHit[] = [];
for (const node of nodes) {
if (node.discoverableOnly) continue;
const kind = fileNodeKind(node);
if (kind === 'symlink') continue;
if (!matchesTerms(parsed.terms, [node.name, kind === 'directory' ? 'folder' : node.type])) continue;
const folderPath = pathOfNode(nodes, node);
hits.push({
kind: 'files', serverUrl: account.serverUrl, localAccountId: account.localAccountId,
jmapAccountId: node.accountId ?? primary, shared: !!node.isShared,
id: rawFileNodeId(node), accountLabel: account.label, title: node.name,
subtitle: [node.isShared && node.accountName ? node.accountName : '', folderPath].filter(Boolean).join(' · '),
date: node.modified || node.created || null, source, node, folderPath, isFolder: kind === 'directory',
});
if (hits.length >= limit) break;
}
return hits;
}
export const filesProvider: SearchProvider = {
kind: 'files',
local: (parsed, accounts, limit) => {
const hits: FileHit[] = [];
for (const account of accounts) {
const nodes = fileListings.get(account.localAccountId)?.nodes;
if (!nodes) continue;
hits.push(...filterNodes(parsed, nodes, account, limit - hits.length, 'local', ''));
if (hits.length >= limit) break;
}
return hits;
},
remote: async (parsed, account, { limit, signal }) => {
const login = await loginSession(account);
if (accountsFor(login.session, CAPABILITIES.FILES).length === 0 || !login.session.capabilities?.[CAPABILITIES.FILES]) {
return { hits: [], hasMore: false };
}
const nodes = await fileListing(account, login).promise;
abortIfNeeded(signal);
const primary = login.session.primaryAccounts?.[CAPABILITIES.FILES] ?? '';
const hits = filterNodes(parsed, nodes, account, limit + 1, 'remote', primary);
return { hits: hits.slice(0, limit), hasMore: hits.length > limit };
},
};
export const GLOBAL_SEARCH_PROVIDERS: readonly SearchProvider[] = [mailProvider, contactsProvider, calendarProvider, filesProvider];
export type { GlobalSearchHit };
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Checked on the source: the suite has no React Native renderer.
const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', file), 'utf8');
describe('Search everything', () => {
it('opens from the mail search field with what was typed', () => {
const source = read('screens/EmailListScreen.tsx');
expect(source).toMatch(/testID="search-everything"/);
expect(source).toMatch(/setGlobalSearchOpen\(true\)/);
expect(source).toMatch(/<GlobalSearchSheet\s+visible=\{globalSearchOpen\}\s+initialQuery=\{searchInput\}/);
});
it('searches every kind of every login and opens a hit in its account', () => {
const source = read('components/search/GlobalSearchSheet.tsx');
expect(source).toMatch(/providers: GLOBAL_SEARCH_PROVIDERS/);
expect(source).toMatch(/accounts: getSearchAccounts/);
expect(source).toMatch(/openHit\(hit, navigation, allHits\)/);
// Scope and account chips, as in the webmail palette.
expect(source).toMatch(/global_search\.scope_files/);
expect(source).toMatch(/global_search\.all_accounts/);
});
});
+340
View File
@@ -0,0 +1,340 @@
import React from 'react';
import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { AlertTriangle, ArrowLeft, CalendarDays, Clock, FileText, Folder, Repeat, Search, X } from '../icons';
import SenderAvatar from '../SenderAvatar';
import { SafeAreaModal } from '../SafeAreaModal';
import { SearchSnippetText } from '../email/SearchSnippetText';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useAccountStore } from '../../stores/account-store';
import { useSearchHistoryStore } from '../../stores/search-history-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { GLOBAL_SEARCH_PROVIDERS, getSearchAccounts } from '../../api/global-search';
import { SEARCH_KINDS, type SearchKind, type SearchScope } from '../../lib/global-search/query-parser';
import { useGlobalSearch } from '../../lib/global-search/use-global-search';
import { openHit, type HitNavigation } from '../../lib/global-search/open-hit';
import type { GlobalSearchHit } from '../../lib/global-search/types';
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
export const SHEET_LOCAL_LIMIT = 10;
export const SHEET_REMOTE_LIMIT = 25;
/** Rows per kind before "Show all results" (the webmail palette shows five). */
export const ROWS_PER_KIND = 5;
/** The rows a kind shows: the first five, or all once its "Show all" was tapped. */
export function visibleHits(hits: GlobalSearchHit[], expanded: boolean): GlobalSearchHit[] {
return expanded ? hits : hits.slice(0, ROWS_PER_KIND);
}
interface GlobalSearchSheetProps {
visible: boolean;
/** What the search starts with (the mail search field's text). */
initialQuery?: string;
onClose: () => void;
navigation: HitNavigation;
}
function formatHitDate(iso: string, locale: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const sameYear = date.getFullYear() === new Date().getFullYear();
try {
return new Intl.DateTimeFormat(locale, sameYear ? { month: 'short', day: 'numeric' } : { year: 'numeric', month: 'short', day: 'numeric' }).format(date);
} catch {
return date.toDateString();
}
}
/**
* "Search everything" (webmail components/global-search/global-search-palette.tsx):
* one query across mail, contacts, calendar events and files of every
* signed-in account. What is already loaded shows while typing; the
* servers' answers merge in per kind as each account answers. A tap opens
* the hit in its own account.
*/
export function GlobalSearchSheet({ visible, initialQuery = '', onClose, navigation }: GlobalSearchSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const accounts = useAccountStore((s) => s.accounts);
const recentSearches = useSearchHistoryStore((s) => s.recentSearches);
const addRecentSearch = useSearchHistoryStore((s) => s.addRecentSearch);
const [query, setQuery] = React.useState(initialQuery);
const [scope, setScope] = React.useState<SearchScope>('all');
const [accountId, setAccountId] = React.useState<string | null>(null);
const [expanded, setExpanded] = React.useState<Set<SearchKind>>(() => new Set());
const pending = React.useRef<GlobalSearchHit | null>(null);
React.useEffect(() => {
if (!visible) return;
setQuery(initialQuery);
setExpanded(new Set());
}, [visible, initialQuery]);
React.useEffect(() => { setExpanded(new Set()); }, [query, scope, accountId]);
const { parsed, outcome, isSearching } = useGlobalSearch({
query, scope, accountId,
localLimit: SHEET_LOCAL_LIMIT,
remoteLimit: SHEET_REMOTE_LIMIT,
enabled: visible,
providers: GLOBAL_SEARCH_PROVIDERS,
accounts: getSearchAccounts,
});
const trimmed = query.trim();
const kinds = SEARCH_KINDS.filter((kind) => parsed.scope === 'all' || parsed.scope === kind);
const hasAnyHit = kinds.some((kind) => outcome.hits[kind].length > 0);
const allHits = kinds.flatMap((kind) => outcome.hits[kind]);
const errors = trimmed ? kinds.flatMap((kind) => outcome.status[kind].errors) : [];
const showRecent = !trimmed && recentSearches.length > 0;
const open = (hit: GlobalSearchHit) => {
if (trimmed) addRecentSearch(trimmed);
// iOS presents the next screen only once this modal is gone.
if (Platform.OS === 'ios') {
pending.current = hit;
onClose();
return;
}
onClose();
void openHit(hit, navigation, allHits);
};
const scopes: { key: SearchScope; label: string }[] = [
{ key: 'all', label: t('global_search.scope_all', 'All') },
{ key: 'mail', label: t('global_search.scope_mail', 'Mail') },
{ key: 'contacts', label: t('global_search.scope_contacts', 'Contacts') },
{ key: 'calendar', label: t('global_search.scope_calendar', 'Calendar') },
{ key: 'files', label: t('global_search.scope_files', 'Files') },
];
const kindLabel = (kind: SearchKind) => scopes.find((s) => s.key === kind)?.label ?? kind;
const renderLeading = (hit: GlobalSearchHit) => {
if (hit.kind === 'mail') {
const from = hit.email.from?.[0];
return <SenderAvatar name={from?.name} email={from?.email} size={28} />;
}
if (hit.kind === 'contacts') {
return <SenderAvatar name={getContactDisplayName(hit.contact) || undefined} email={getContactPrimaryEmail(hit.contact) || undefined} size={28} />;
}
const Icon = hit.kind === 'calendar' ? CalendarDays : hit.isFolder ? Folder : FileText;
return (
<View style={styles.iconChip}>
<Icon size={14} color={c.textSecondary} />
</View>
);
};
const renderRow = (hit: GlobalSearchHit) => {
const title = hit.title || (hit.kind === 'mail' ? t('global_search.no_subject', '(no subject)') : hit.id);
const subtitle = [hit.accountLabel, hit.subtitle].filter(Boolean).join(' · ');
const snippet = hit.kind === 'mail' && hit.snippet?.preview ? hit.snippet.preview : null;
return (
<Pressable
key={`${hit.kind}:${hit.localAccountId}:${hit.jmapAccountId}:${hit.id}`}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => open(hit)}
accessibilityRole="button"
accessibilityLabel={[title, subtitle].filter(Boolean).join(', ')}
>
{renderLeading(hit)}
<View style={styles.rowText}>
<View style={styles.rowTitleLine}>
<Text style={styles.rowTitle} numberOfLines={1}>
{hit.kind === 'mail' && hit.snippet?.subject ? <SearchSnippetText snippet={hit.snippet.subject} /> : title}
</Text>
{hit.kind === 'calendar' && hit.isRecurring ? (
<View accessible accessibilityLabel={t('global_search.recurring', 'Recurring')}>
<Repeat size={12} color={c.textMuted} />
</View>
) : null}
{hit.date ? <Text style={styles.rowDate}>{formatHitDate(hit.date, locale)}</Text> : null}
</View>
{subtitle ? <Text style={styles.rowSub} numberOfLines={1}>{subtitle}</Text> : null}
{snippet ? <Text style={styles.rowSub} numberOfLines={1}><SearchSnippetText snippet={snippet} /></Text> : null}
</View>
</Pressable>
);
};
const renderGroup = (kind: SearchKind) => {
const hits = outcome.hits[kind];
const status = outcome.status[kind];
if (hits.length === 0 && status.status !== 'loading') return null;
const isExpanded = expanded.has(kind);
return (
<View key={kind} style={styles.group} accessibilityRole="list" accessibilityLabel={kindLabel(kind)}>
<View style={styles.groupHeader}>
<Text style={styles.groupTitle}>{kindLabel(kind)}</Text>
<View style={styles.countPill}><Text style={styles.countText}>{hits.length}</Text></View>
{status.status === 'loading' ? <ActivityIndicator size="small" color={c.textMuted} /> : null}
</View>
{visibleHits(hits, isExpanded).map(renderRow)}
{!isExpanded && hits.length > ROWS_PER_KIND ? (
<Pressable
style={({ pressed }) => [styles.showAll, pressed && styles.rowPressed]}
onPress={() => setExpanded((prev) => new Set(prev).add(kind))}
accessibilityRole="button"
>
<Text style={styles.showAllText}>{t('global_search.show_all', 'Show all results')}</Text>
</Pressable>
) : null}
</View>
);
};
const chip = (key: string, label: string, selected: boolean, onPress: () => void) => (
<Pressable
key={key}
onPress={onPress}
style={[styles.chip, selected && styles.chipActive]}
accessibilityRole="button"
accessibilityState={{ selected }}
>
<Text style={[styles.chipText, selected && styles.chipTextActive]}>{label}</Text>
</Pressable>
);
return (
<SafeAreaModal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
onRequestClose={onClose}
onDismiss={() => {
const hit = pending.current;
pending.current = null;
if (hit) void openHit(hit, navigation, allHits);
}}
>
<SafeAreaView style={styles.container} edges={['top', 'bottom', 'left', 'right']}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn} accessibilityRole="button" accessibilityLabel={t('common.close', 'Close')}>
<ArrowLeft size={20} color={c.text} />
</Pressable>
<View style={styles.field}>
<Search size={16} color={c.textMuted} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t('global_search.placeholder', 'Search mail, contacts, calendar, files...')}
placeholderTextColor={c.mutedForeground}
style={styles.input}
autoFocus
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
onSubmitEditing={() => { if (trimmed) addRecentSearch(trimmed); }}
accessibilityLabel={t('global_search.title', 'Search everything')}
/>
{isSearching ? <ActivityIndicator size="small" color={c.textMuted} /> : null}
{query ? (
<Pressable onPress={() => setQuery('')} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('contacts.clear_search', 'Clear search')}>
<X size={16} color={c.textMuted} />
</Pressable>
) : null}
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips} keyboardShouldPersistTaps="handled">
{scopes.map((s) => chip(s.key, s.label, scope === s.key, () => setScope(s.key)))}
{accounts.length > 1 ? (
<>
<View style={styles.chipDivider} />
{chip('all-accounts', t('global_search.all_accounts', 'All accounts'), accountId === null, () => setAccountId(null))}
{accounts.map((a) => chip(a.id, a.displayName || a.email || a.username, accountId === a.id, () => setAccountId(a.id)))}
</>
) : null}
</ScrollView>
<ScrollView style={styles.results} keyboardShouldPersistTaps="handled">
{showRecent ? (
<View style={styles.group}>
<View style={styles.groupHeader}>
<Text style={styles.groupTitle}>{t('global_search.recent_searches', 'Recent searches')}</Text>
</View>
{recentSearches.map((term) => (
<Pressable key={term} style={({ pressed }) => [styles.recentRow, pressed && styles.rowPressed]} onPress={() => setQuery(term)} accessibilityRole="button">
<Clock size={14} color={c.textMuted} />
<Text style={styles.recentText} numberOfLines={1}>{term}</Text>
</Pressable>
))}
</View>
) : null}
{trimmed ? kinds.map(renderGroup) : null}
{errors.map((error) => (
<View key={`${error.localAccountId}:${error.message}`} style={styles.errorRow}>
<AlertTriangle size={14} color={c.warning} />
<Text style={styles.rowSub}>{t('global_search.account_error', "Couldn't search {account}", { account: error.accountLabel })}</Text>
</View>
))}
{!trimmed && !showRecent ? (
<Text style={styles.hint}>
{t('global_search.empty_hint', 'Search mail, contacts, events and files across all accounts. Try from:, subject:, has:attachment, after:2026-01-01.')}
</Text>
) : null}
{trimmed && !hasAnyHit && !isSearching ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>{t('global_search.no_results', 'No results for “{query}”', { query: trimmed })}</Text>
<Text style={styles.hint}>{t('global_search.server_hint', 'Complete words search the server; partial words match already-loaded items')}</Text>
</View>
) : null}
</ScrollView>
</SafeAreaView>
</SafeAreaModal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row', alignItems: 'center', gap: spacing.sm,
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
borderBottomWidth: 1, borderBottomColor: c.border,
},
headerBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
field: {
flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm,
paddingHorizontal: spacing.md, borderRadius: radius.md, backgroundColor: c.surface,
borderWidth: 1, borderColor: c.border,
},
input: { flex: 1, ...typography.body, color: c.text, paddingVertical: spacing.sm },
chips: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.md, paddingVertical: spacing.sm },
chip: {
paddingHorizontal: spacing.md, paddingVertical: 6, borderRadius: radius.full,
borderWidth: 1, borderColor: c.border, backgroundColor: c.surface,
},
chipActive: { backgroundColor: c.primary, borderColor: c.primary },
chipText: { ...typography.caption, color: c.text },
chipTextActive: { color: c.primaryForeground },
chipDivider: { width: 1, height: 20, backgroundColor: c.border, marginHorizontal: spacing.xs },
results: { flex: 1 },
group: { paddingVertical: spacing.xs },
groupHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.lg, paddingTop: spacing.sm, paddingBottom: spacing.xs },
groupTitle: { ...typography.captionMedium, color: c.textSecondary },
countPill: { minWidth: 18, height: 18, borderRadius: 9, paddingHorizontal: 6, alignItems: 'center', justifyContent: 'center', backgroundColor: c.muted },
countText: { fontSize: 10, fontWeight: '600', color: c.textSecondary },
row: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.md, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, minHeight: 48 },
rowPressed: { backgroundColor: c.surfaceHover },
rowText: { flex: 1, minWidth: 0 },
rowTitleLine: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
rowTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
rowDate: { ...typography.caption, color: c.textMuted },
rowSub: { ...typography.caption, color: c.textMuted, marginTop: 2 },
iconChip: { width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: c.muted },
showAll: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
showAllText: { ...typography.caption, color: c.primary },
recentRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, minHeight: 44 },
recentText: { ...typography.body, color: c.text, flex: 1 },
errorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.lg, paddingVertical: spacing.xs },
hint: { ...typography.caption, color: c.textMuted, textAlign: 'center', padding: spacing.lg },
empty: { paddingVertical: spacing.lg },
emptyTitle: { ...typography.body, color: c.text, textAlign: 'center' },
});
}
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { auth } = vi.hoisted(() => ({
auth: { activeAccountId: 'login-a' as string | null, switchAccount: vi.fn() },
}));
vi.mock('../../stores/auth-store', () => ({ useAuthStore: { getState: () => auth } }));
import { openHit, filesLinkFor } from '../global-search/open-hit';
import { usePendingCalendarOpen } from '../../navigation/pending-calendar-open';
import { usePendingFilesLink } from '../../navigation/pending-files-link';
import type { GlobalSearchHit } from '../global-search/types';
const base = { localAccountId: 'login-a', jmapAccountId: 'own', shared: false, accountLabel: 'Me', subtitle: '', date: null, source: 'remote' as const };
beforeEach(() => {
auth.activeAccountId = 'login-a';
auth.switchAccount.mockReset();
usePendingCalendarOpen.setState({ target: null });
usePendingFilesLink.setState({ link: null });
});
describe('opening a search hit', () => {
it("opens a group account's message under that account, paging over the same account's hits", async () => {
const navigate = vi.fn();
const mail = (id: string, jmapAccountId: string, shared: boolean): GlobalSearchHit => ({
...base, kind: 'mail', id, jmapAccountId, shared, title: id, snippet: null,
email: { id, threadId: `t-${id}`, subject: id } as never,
});
const target = mail('5', 'team', true);
await openHit(target, { navigate }, [mail('4', 'team', true), target, mail('5', 'own', false)]);
expect(navigate).toHaveBeenCalledWith('EmailThread', expect.objectContaining({
emailId: '5', threadId: 't-5', jmapAccountId: 'team', emailIds: ['4', '5'],
}));
});
it("switches to the hit's login first, and opens nothing when the switch fails", async () => {
const navigate = vi.fn();
const contact: GlobalSearchHit = {
...base, localAccountId: 'login-b', kind: 'contacts', id: 'c1', title: 'Anna', storeId: 'c1', contact: { id: 'c1' } as never,
};
auth.switchAccount.mockImplementation(async (id: string) => { auth.activeAccountId = id; });
await openHit(contact, { navigate });
expect(auth.switchAccount).toHaveBeenCalledWith('login-b');
expect(navigate).toHaveBeenCalledWith('ContactDetail', { contactId: 'c1' });
navigate.mockReset();
auth.activeAccountId = 'login-a';
auth.switchAccount.mockImplementation(async () => undefined);
expect(await openHit(contact, { navigate })).toBe(false);
expect(navigate).not.toHaveBeenCalled();
});
it('hands a shared calendar event to the Calendar tab by its server id and account', async () => {
const navigate = vi.fn();
await openHit({
...base, kind: 'calendar', id: 'ev', jmapAccountId: 'boss', shared: true, title: 'Sync', isRecurring: false,
event: { id: 'ev', start: '2026-03-02T10:00:00', calendarIds: {} } as never,
}, { navigate });
expect(usePendingCalendarOpen.getState().target).toMatchObject({ kind: 'event', eventId: 'boss:ev', serverId: 'ev', accountId: 'boss' });
expect(navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' });
});
it('opens a file in its folder with a preview, a folder by its path, and a shared node by its listed id', async () => {
const file = (over: Record<string, unknown>): Extract<GlobalSearchHit, { kind: 'files' }> => ({
...base, kind: 'files', id: 'n', title: 'x', folderPath: '/Docs', isFolder: false,
node: { id: 'n', name: 'report.pdf' } as never, ...over,
} as never);
expect(filesLinkFor(file({}))).toEqual({ path: '/Docs', preview: 'report.pdf' });
expect(filesLinkFor(file({ isFolder: true, folderPath: '/', node: { id: 'n', name: 'Tax' } }))).toEqual({ path: '/Tax' });
expect(filesLinkFor(file({ node: { id: 'boss:n', name: 'a', isShared: true } }))).toEqual({ path: '/', shared: 'boss:n' });
const navigate = vi.fn();
await openHit(file({}), { navigate });
expect(usePendingFilesLink.getState().link).toEqual({ path: '/Docs', preview: 'report.pdf' });
expect(navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Files' });
});
});
+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect, vi } from 'vitest';
import { accountMatches, hasRemoteQuery, matchesTerms, parseSearchQuery } from '../global-search/query-parser';
import { mergeHits, rankHits } from '../global-search/rank';
import { localOutcome, runGlobalSearch } from '../global-search/run';
import { calendarFilterFor, mailFilterFor, pathOfNode } from '../global-search/filters';
import { scopedQuery } from '../global-search/use-global-search';
import type { GlobalSearchHit, SearchAccount, SearchOutcome, SearchProvider } from '../global-search/types';
import type { FileNode } from '../../api/types';
// Ported from the webmail's lib/global-search/__tests__ (query-parser,
// rank, run-global-search), on the app's types.
const ACCOUNTS: SearchAccount[] = [
{ localAccountId: 'a', label: 'Work', email: 'me@work.example', active: true },
{ localAccountId: 'b', label: 'Home', email: 'me@home.example', active: false },
];
function hit(partial: Partial<GlobalSearchHit> & { id: string }): GlobalSearchHit {
return {
kind: 'mail', localAccountId: 'a', jmapAccountId: 'j', shared: false, accountLabel: 'Work',
title: partial.id, subtitle: '', date: null, source: 'local',
email: { id: partial.id, threadId: 't', subject: partial.id } as never, snippet: null,
...partial,
} as GlobalSearchHit;
}
describe('parseSearchQuery', () => {
it('keeps words and quoted phrases as text, lower-cased terms for local matching', () => {
const parsed = parseSearchQuery('Annual "Q3 Report"');
expect(parsed.text).toBe('Annual "Q3 Report"');
expect(parsed.terms).toEqual(['annual', 'q3 report']);
expect(parsed.scope).toBe('all');
});
it('reads scope, account and the mail operators', () => {
const parsed = parseSearchQuery('in:events account:Work from:anna subject:plan has:attachment is:unread after:2026-01-01 before:2026-02-01 budget');
expect(parsed.scope).toBe('calendar');
expect(parsed.account).toBe('work');
expect(parsed.mail).toMatchObject({ from: 'anna', subject: 'plan', hasAttachment: true, isUnread: true, dateAfter: '2026-01-01', dateBefore: '2026-02-01' });
expect(parsed.after).toBe('2026-01-01');
expect(parsed.text).toBe('budget');
});
it('makes in:trash a mail search of that folder, and is:anything keeps Trash and Junk in', () => {
expect(parseSearchQuery('in:trash x')).toMatchObject({ scope: 'mail', mailboxRole: 'trash' });
expect(parseSearchQuery('is:anything x').includeTrashAndJunk).toBe(true);
});
it('keeps malformed dates and unknown operators as free text', () => {
const parsed = parseSearchQuery('after:2026-02-30 foo:bar ticket');
expect(parsed.after).toBeNull();
expect(parsed.text).toBe('after:2026-02-30 foo:bar ticket');
});
it('lets an explicit in: win over the scope chip', () => {
expect(scopedQuery('x', 'files').scope).toBe('files');
expect(scopedQuery('in:contacts x', 'files').scope).toBe('contacts');
});
it('asks the server for mail on operators alone, for the other kinds only with text', () => {
const parsed = parseSearchQuery('is:unread');
expect(hasRemoteQuery(parsed, 'mail')).toBe(true);
expect(hasRemoteQuery(parsed, 'contacts')).toBe(false);
});
});
describe('matching helpers', () => {
it('needs every term in some field', () => {
expect(matchesTerms(['ann', 'plan'], ['Anna Smith', 'Project plan'])).toBe(true);
expect(matchesTerms(['ann', 'zzz'], ['Anna Smith'])).toBe(false);
expect(matchesTerms([], ['x'])).toBe(false);
});
it('matches a login by label, address prefix or id', () => {
expect(accountMatches('wor', { ...ACCOUNTS[0] })).toBe(true);
expect(accountMatches('me@home', { ...ACCOUNTS[1] })).toBe(true);
expect(accountMatches('home', { ...ACCOUNTS[0] })).toBe(false);
});
});
describe('server filters', () => {
const mailboxes = [
{ id: 'in', role: 'inbox', name: 'Inbox' },
{ id: 'tr', role: 'trash', name: 'Trash' },
{ id: 'sp', role: 'junk', name: 'Junk' },
];
it('searches every folder but Trash and Junk by default', () => {
expect(mailFilterFor(parseSearchQuery('invoice'), mailboxes)).toEqual({
operator: 'AND', conditions: [{ text: 'invoice' }, { inMailboxOtherThan: ['tr', 'sp'] }],
});
});
it('searches Trash alone with in:trash, and everything with is:anything', () => {
expect(mailFilterFor(parseSearchQuery('in:trash invoice'), mailboxes)).toEqual({
operator: 'AND', conditions: [{ text: 'invoice' }, { inMailbox: 'tr' }],
});
expect(mailFilterFor(parseSearchQuery('is:anything invoice'), mailboxes)).toEqual({ text: 'invoice' });
});
it('turns the mail operators into conditions', () => {
const filter = mailFilterFor(parseSearchQuery('from:anna is:starred has:attachment'), []);
expect(filter).toEqual({
operator: 'AND',
conditions: [{ from: 'anna' }, { hasAttachment: true }, { hasKeyword: '$flagged' }],
});
});
it('bounds the calendar search by whole days, one AND group for several parts', () => {
expect(calendarFilterFor(parseSearchQuery('standup after:2026-03-01'))).toEqual({ text: 'standup', after: '2026-03-01T00:00:00Z' });
expect(calendarFilterFor(parseSearchQuery('"team sync" weekly before:2026-03-31'))).toEqual({
operator: 'AND',
conditions: [{ before: '2026-03-31T23:59:59Z' }, { text: '"team sync"' }, { text: 'weekly' }],
});
});
it('finds the folder path of a file', () => {
const nodes = [
{ id: '1', name: 'Docs', parentId: null },
{ id: '2', name: 'Tax', parentId: '1' },
{ id: '3', name: 'return.pdf', parentId: '2' },
] as FileNode[];
expect(pathOfNode(nodes, nodes[2])).toBe('/Docs/Tax');
expect(pathOfNode(nodes, nodes[0])).toBe('/');
});
});
describe('rank and merge', () => {
it('puts exact and prefix title matches first, then newer hits', () => {
const parsed = parseSearchQuery('plan');
const ranked = rankHits([
hit({ id: 'old', title: 'about the plan', date: '2026-01-01T00:00:00Z' }),
hit({ id: 'new', title: 'about the plan', date: '2026-02-01T00:00:00Z' }),
hit({ id: 'exact', title: 'Plan' }),
hit({ id: 'prefix', title: 'Planning day' }),
], parsed);
expect(ranked.map((h) => h.id)).toEqual(['exact', 'prefix', 'new', 'old']);
});
it('lets a server hit replace the loaded one, and keeps the same id of two accounts apart', () => {
const merged = mergeHits(
[hit({ id: '1', source: 'local' }), hit({ id: '1', jmapAccountId: 'other', source: 'local' })],
[hit({ id: '1', source: 'remote', title: 'from server' })],
);
expect(merged).toHaveLength(2);
expect(merged.find((h) => h.jmapAccountId === 'j')?.source).toBe('remote');
// A loaded hit never replaces a server one.
expect(mergeHits(merged, [hit({ id: '1', source: 'local' })]).find((h) => h.jmapAccountId === 'j')?.source).toBe('remote');
});
});
describe('runGlobalSearch', () => {
const provider = (over: Partial<SearchProvider>): SearchProvider => ({
kind: 'mail',
local: () => [],
remote: async () => ({ hits: [], hasMore: false }),
...over,
});
it('shows the loaded hits first, then merges each account answer, and reports a failed account', async () => {
const updates: SearchOutcome[] = [];
const mail = provider({
local: () => [hit({ id: 'cached', title: 'plan cached' })],
remote: async (_p, account) => {
if (account.localAccountId === 'b') throw new Error('boom');
return { hits: [hit({ id: 'server', title: 'plan server', source: 'remote' })], hasMore: true };
},
});
const result = await runGlobalSearch({
parsed: parseSearchQuery('plan'), accounts: ACCOUNTS, providers: [mail],
localLimit: 10, remoteLimit: 10, signal: new AbortController().signal,
onUpdate: (o) => updates.push(o),
});
expect(updates[0].hits.mail.map((h) => h.id)).toEqual(['cached']);
expect(updates[0].status.mail.status).toBe('loading');
expect(result.hits.mail.map((h) => h.id).sort()).toEqual(['cached', 'server']);
expect(result.status.mail).toMatchObject({ status: 'done', hasMore: true });
expect(result.status.mail.errors).toEqual([{ localAccountId: 'b', accountLabel: 'Home', message: 'boom' }]);
});
it('asks only the account named by account:, and only the kind of the scope', async () => {
const asked: string[] = [];
const mail = provider({ remote: async (_p, a) => { asked.push(`mail:${a.localAccountId}`); return { hits: [], hasMore: false }; } });
const files = provider({ kind: 'files', remote: async (_p, a) => { asked.push(`files:${a.localAccountId}`); return { hits: [], hasMore: false }; } });
await runGlobalSearch({
parsed: parseSearchQuery('in:mail account:home plan'), accounts: ACCOUNTS, providers: [mail, files],
localLimit: 10, remoteLimit: 10, signal: new AbortController().signal, onUpdate: () => undefined,
});
expect(asked).toEqual(['mail:b']);
});
it('gives up on a slow account after the time limit', async () => {
vi.useFakeTimers();
try {
const slow = provider({ remote: () => new Promise(() => undefined) });
const done = runGlobalSearch({
parsed: parseSearchQuery('plan'), accounts: [ACCOUNTS[0]], providers: [slow],
localLimit: 10, remoteLimit: 10, signal: new AbortController().signal, onUpdate: () => undefined, timeoutMs: 1000,
});
await vi.advanceTimersByTimeAsync(1001);
const result = await done;
expect(result.status.mail.errors[0].message).toBe('timeout');
} finally {
vi.useRealTimers();
}
});
it('publishes nothing after the search was replaced', async () => {
const controller = new AbortController();
const onUpdate = vi.fn();
const mail = provider({ remote: async () => { controller.abort(); return { hits: [hit({ id: 'late' })], hasMore: false }; } });
await runGlobalSearch({
parsed: parseSearchQuery('plan'), accounts: [ACCOUNTS[0]], providers: [mail],
localLimit: 10, remoteLimit: 10, signal: controller.signal, onUpdate,
});
const last = onUpdate.mock.calls.at(-1)?.[0] as SearchOutcome;
expect(last.hits.mail.map((h) => h.id)).not.toContain('late');
});
it('runs no local pass for a query of operators only', () => {
const local = vi.fn(() => [hit({ id: 'x' })]);
const outcome = localOutcome(parseSearchQuery('is:unread'), ACCOUNTS, [provider({ local })], 10);
expect(local).not.toHaveBeenCalled();
expect(outcome.status.mail.status).toBe('loading');
});
});
+181
View File
@@ -0,0 +1,181 @@
import type { CalendarEvent, ContactCard, Email, EmailAddress, FileNode } from '../../api/types';
import { textConditions } from '../search-utils';
import type { MailFilters, ParsedQuery } from './query-parser';
// What each kind of item is searched by, and the server filters a parsed
// query turns into (webmail lib/global-search/providers/*.ts and
// buildJMAPFilter in lib/jmap/search-utils.ts).
type JmapFilter = Record<string, unknown>;
/** AND of two filters, flattening an AND on the left. */
export function andFilters(base: JmapFilter, extra: JmapFilter | null): JmapFilter {
if (!extra || Object.keys(extra).length === 0) return base;
if (Object.keys(base).length === 0) return extra;
if (base.operator === 'AND' && Array.isArray(base.conditions)) {
return { operator: 'AND', conditions: [...(base.conditions as JmapFilter[]), extra] };
}
return { operator: 'AND', conditions: [base, extra] };
}
/** The Email/query filter for free text and the mail operators (webmail buildJMAPFilter). */
export function buildMailFilter(text: string, filters: MailFilters): JmapFilter {
const conditions: JmapFilter[] = [];
if (text.trim()) conditions.push(...textConditions(text));
if (filters.from) conditions.push(...textConditions(filters.from, 'from'));
if (filters.to) conditions.push(...textConditions(filters.to, 'to'));
if (filters.subject) conditions.push(...textConditions(filters.subject, 'subject'));
if (filters.body) conditions.push(...textConditions(filters.body, 'body'));
if (filters.hasAttachment !== null) conditions.push({ hasAttachment: filters.hasAttachment });
if (filters.dateAfter) {
const date = new Date(filters.dateAfter);
if (!Number.isNaN(date.getTime())) conditions.push({ after: date.toISOString() });
}
if (filters.dateBefore) {
const end = new Date(filters.dateBefore);
if (!Number.isNaN(end.getTime())) {
end.setHours(23, 59, 59, 999);
conditions.push({ before: end.toISOString() });
}
}
if (filters.isUnread === true) conditions.push({ notKeyword: '$seen' });
else if (filters.isUnread === false) conditions.push({ hasKeyword: '$seen' });
if (filters.isStarred === true) conditions.push({ hasKeyword: '$flagged' });
else if (filters.isStarred === false) conditions.push({ notKeyword: '$flagged' });
if (conditions.length === 0) return {};
if (conditions.length === 1) return conditions[0];
return { operator: 'AND', conditions };
}
/** The folders of one mail account the filter needs: id and role. */
export interface MailboxRef {
id: string;
role?: string | null;
name: string;
}
function findRole(mailboxes: MailboxRef[], role: 'trash' | 'junk'): MailboxRef | undefined {
return mailboxes.find((m) => m.role === role || (role === 'junk' && m.role === 'spam'));
}
/**
* The default scope is everything but Trash and Junk; `in:trash` and
* `in:junk` search that folder alone and `is:anything` lifts the exclusion
* (webmail mailFilterFor). Without those folders, everything.
*/
export function mailFilterFor(parsed: ParsedQuery, mailboxes: MailboxRef[]): JmapFilter {
const base = buildMailFilter(parsed.text, parsed.mail);
if (parsed.mailboxRole) {
const mailbox = findRole(mailboxes, parsed.mailboxRole);
return mailbox ? andFilters(base, { inMailbox: mailbox.id }) : base;
}
if (parsed.includeTrashAndJunk) return base;
const excluded = (['trash', 'junk'] as const).map((role) => findRole(mailboxes, role)?.id).filter((id): id is string => !!id);
return excluded.length > 0 ? andFilters(base, { inMailboxOtherThan: excluded }) : base;
}
/**
* `after:`/`before:` are calendar days, compared against instants. Several
* text parts go out as an AND of single-key conditions: 1.0 takes one AND
* group when it expands recurrences (webmail calendarFilterFor).
*/
export function calendarFilterFor(parsed: ParsedQuery): JmapFilter {
const after = parsed.after ? `${parsed.after}T00:00:00Z` : undefined;
const before = parsed.before ? `${parsed.before}T23:59:59Z` : undefined;
const texts = textConditions(parsed.text).map(({ text }): JmapFilter => ({ text }));
if (texts.length === 1) {
const filter = texts[0];
if (after) filter.after = after;
if (before) filter.before = before;
return filter;
}
const bounds: JmapFilter[] = [];
if (after) bounds.push({ after });
if (before) bounds.push({ before });
return { operator: 'AND', conditions: [...bounds, ...texts] };
}
function addressFields(addresses: EmailAddress[] | null | undefined): string[] {
const out: string[] = [];
for (const address of addresses ?? []) {
if (address.name) out.push(address.name);
if (address.email) out.push(address.email);
}
return out;
}
/** The text a loaded message is found by. */
export function emailFields(email: Email): string[] {
return [email.subject ?? '', email.preview ?? '', ...addressFields(email.from), ...addressFields(email.to)];
}
function includesCi(haystack: string[], needle: string): boolean {
const n = needle.toLowerCase();
return haystack.some((h) => h.toLowerCase().includes(n));
}
/** The mail operators, applied to a loaded message. */
export function emailMatchesFilters(email: Email, parsed: ParsedQuery): boolean {
const { mail } = parsed;
if (mail.from && !includesCi(addressFields(email.from), mail.from)) return false;
if (mail.to && !includesCi([...addressFields(email.to), ...addressFields(email.cc)], mail.to)) return false;
if (mail.subject && !includesCi([email.subject ?? ''], mail.subject)) return false;
if (mail.hasAttachment !== null && Boolean(email.hasAttachment) !== mail.hasAttachment) return false;
if (mail.isUnread !== null && Boolean(email.keywords?.$seen) === mail.isUnread) return false;
if (mail.isStarred !== null && Boolean(email.keywords?.$flagged) !== mail.isStarred) return false;
const day = email.receivedAt?.slice(0, 10) ?? '';
if (mail.dateAfter && day < mail.dateAfter) return false;
if (mail.dateBefore && day > mail.dateBefore) return false;
return true;
}
/** The text a contact is found by: name, addresses, organisations, phones, nicknames. */
export function contactFields(contact: ContactCard, displayName: string): string[] {
const fields: string[] = [displayName];
for (const email of Object.values(contact.emails ?? {})) if (email?.address) fields.push(email.address);
for (const org of Object.values(contact.organizations ?? {})) {
const name = (org as { name?: string }).name;
if (name) fields.push(name);
}
for (const phone of Object.values(contact.phones ?? {})) {
const number = (phone as { number?: string }).number;
if (number) fields.push(number);
}
for (const nick of Object.values(contact.nicknames ?? {})) {
const name = (nick as { name?: string }).name;
if (name) fields.push(name);
}
return fields;
}
/** The text an event is found by: title, description, places and participants. */
export function eventFields(event: CalendarEvent): string[] {
const fields: string[] = [event.title ?? '', event.description ?? ''];
for (const location of Object.values(event.locations ?? {})) if (location?.name) fields.push(location.name);
for (const participant of Object.values(event.participants ?? {})) {
const p = participant as { name?: string | null; email?: string | null };
if (p.name) fields.push(p.name);
if (p.email) fields.push(p.email);
}
return fields;
}
export function isRecurringEvent(event: Pick<CalendarEvent, 'recurrenceId' | 'recurrenceRules'>): boolean {
return Boolean(event.recurrenceId) || (Array.isArray(event.recurrenceRules) && event.recurrenceRules.length > 0);
}
/** The folder path a file node lives in (`/` for the root). */
export function pathOfNode(nodes: FileNode[], node: FileNode): string {
const byId = new Map(nodes.map((n) => [n.id, n]));
const segments: string[] = [];
const seen = new Set<string>();
let parentId = node.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (!parent) break;
segments.unshift(parent.name);
parentId = parent.parentId;
}
return `/${segments.join('/')}`;
}
+74
View File
@@ -0,0 +1,74 @@
import { useAuthStore } from '../../stores/auth-store';
import { setPendingCalendarOpen } from '../../navigation/pending-calendar-open';
import { setPendingFilesLink } from '../../navigation/pending-files-link';
import type { GlobalSearchHit } from './types';
// Opening a search hit where it lives, in its own account (webmail
// lib/global-search/open-hit.ts, the standard layout's path): switch to the
// hit's login when it isn't the active one, then go to its screen. Every hit
// names its login, owning account and raw id (#847).
/** The navigation calls opening a hit needs. */
export interface HitNavigation {
navigate: (...args: any[]) => void;
}
/** The Files link of a file hit: own files by path, a shared drive's by its listed id. */
export function filesLinkFor(hit: Extract<GlobalSearchHit, { kind: 'files' }>): { path: string; preview?: string; shared?: string } {
if (hit.node.isShared) return { path: '/', shared: hit.node.id };
if (hit.isFolder) {
const base = hit.folderPath === '/' ? '' : hit.folderPath;
return { path: `${base}/${hit.node.name}` };
}
return { path: hit.folderPath, preview: hit.node.name };
}
/**
* Opens a hit. `peers` are the other hits of the list, so the reader can
* page over the messages of the same account. Resolves false when the
* switch to the hit's login did not happen.
*/
export async function openHit(hit: GlobalSearchHit, navigation: HitNavigation, peers: GlobalSearchHit[] = []): Promise<boolean> {
const auth = useAuthStore.getState();
if (auth.activeAccountId !== hit.localAccountId) {
await auth.switchAccount(hit.localAccountId);
if (useAuthStore.getState().activeAccountId !== hit.localAccountId) return false;
}
switch (hit.kind) {
case 'mail': {
const pages = peers
.filter((p) => p.kind === 'mail' && p.localAccountId === hit.localAccountId && p.jmapAccountId === hit.jmapAccountId)
.map((p) => p.id);
navigation.navigate('EmailThread', {
emailId: hit.id,
threadId: hit.email.threadId,
subject: hit.email.subject,
// A group or shared account's message names its account: ids repeat.
jmapAccountId: hit.shared ? hit.jmapAccountId : undefined,
emailIds: pages.includes(hit.id) ? pages : [hit.id],
});
return true;
}
case 'contacts':
navigation.navigate('ContactDetail', { contactId: hit.storeId });
return true;
case 'calendar': {
// Like a tapped reminder: the Calendar tab finds the event by its
// server id when it isn't in the loaded window.
const startMs = hit.event.start ? Date.parse(hit.event.start) : NaN;
setPendingCalendarOpen({
kind: 'event',
eventId: hit.source === 'local' ? hit.event.id : hit.shared ? `${hit.jmapAccountId}:${hit.id}` : hit.id,
serverId: hit.id,
accountId: hit.shared ? hit.jmapAccountId : undefined,
...(Number.isNaN(startMs) ? {} : { startMs }),
});
navigation.navigate('MainTabs', { screen: 'Calendar' });
return true;
}
case 'files':
setPendingFilesLink(filesLinkFor(hit));
navigation.navigate('MainTabs', { screen: 'Files' });
return true;
}
}
+205
View File
@@ -0,0 +1,205 @@
// Query syntax of "Search everything" (webmail lib/global-search/query-parser.ts):
//
// in:mail|contacts|calendar|files scope (also in:trash / in:junk for mail)
// account:<label|email> restrict to one login
// from: to: subject: mail filters
// has:attachment mail
// is:unread|read|starred|anything mail (`anything` includes Trash/Junk)
// after:YYYY-MM-DD before:YYYY-MM-DD mail + calendar date bounds
// "quoted phrase" kept as a phrase for the server
//
// Everything else - unknown `key:value` pairs and malformed dates included -
// stays free text, so a colon in a normal query never drops words.
export type SearchKind = 'mail' | 'contacts' | 'calendar' | 'files';
export const SEARCH_KINDS: readonly SearchKind[] = ['mail', 'contacts', 'calendar', 'files'];
export type SearchScope = 'all' | SearchKind;
/** The mail filters an operator can set (webmail SearchFilters). */
export interface MailFilters {
from: string;
to: string;
subject: string;
body: string;
hasAttachment: boolean | null;
isUnread: boolean | null;
isStarred: boolean | null;
dateAfter: string;
dateBefore: string;
}
export const DEFAULT_MAIL_FILTERS: MailFilters = {
from: '', to: '', subject: '', body: '',
hasAttachment: null, isUnread: null, isStarred: null,
dateAfter: '', dateBefore: '',
};
export interface ParsedQuery {
raw: string;
/** Free text for the server: words and quoted phrases, operators removed. */
text: string;
/** Lower-cased words and phrases for local substring matching. */
terms: string[];
scope: SearchScope;
/** `account:` value, lower-cased; matched against the login's label and address. */
account: string | null;
mail: MailFilters;
/** `YYYY-MM-DD` bounds shared by mail and calendar. */
after: string | null;
before: string | null;
/** `in:trash` / `in:junk`: search that folder only. */
mailboxRole: 'trash' | 'junk' | null;
/** `is:anything`: Trash and Junk stay in the mail search. */
includeTrashAndJunk: boolean;
hasOperators: boolean;
}
const SCOPE_ALIASES: Record<string, SearchScope> = {
all: 'all', everything: 'all',
mail: 'mail', email: 'mail', emails: 'mail', messages: 'mail',
contacts: 'contacts', contact: 'contacts', people: 'contacts',
calendar: 'calendar', calendars: 'calendar', events: 'calendar', event: 'calendar',
files: 'files', file: 'files', drive: 'files',
};
const OPERATORS = new Set(['in', 'account', 'from', 'to', 'subject', 'has', 'is', 'after', 'before']);
/** `YYYY-MM-DD` that names a real calendar day. */
export function isValidQueryDate(value: string): boolean {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return false;
const [, y, m, d] = match;
const date = new Date(Number(y), Number(m) - 1, Number(d));
return date.getMonth() === Number(m) - 1 && date.getDate() === Number(d);
}
interface Token {
key: string | null;
value: string;
quoted: boolean;
original: string;
}
// `key:"quoted value"`, `key:value`, `"quoted phrase"`, or a bare word. A
// trailing unmatched quote is tolerated (the user is still typing).
const TOKEN_RE = /([A-Za-z]+):(?:"([^"]*)"?|(\S+))|"([^"]*)"?|(\S+)/g;
function tokenizeQuery(raw: string): Token[] {
const tokens: Token[] = [];
for (const match of raw.matchAll(TOKEN_RE)) {
const [original, key, quotedValue, bareValue, phrase, word] = match;
if (key !== undefined) {
tokens.push({ key: key.toLowerCase(), value: quotedValue ?? bareValue ?? '', quoted: quotedValue !== undefined, original });
} else if (phrase !== undefined) {
tokens.push({ key: null, value: phrase, quoted: true, original });
} else if (word !== undefined) {
tokens.push({ key: null, value: word, quoted: false, original });
}
}
return tokens;
}
export function parseSearchQuery(raw: string): ParsedQuery {
const textParts: string[] = [];
const terms: string[] = [];
const mail: MailFilters = { ...DEFAULT_MAIL_FILTERS };
let scope: SearchScope = 'all';
let account: string | null = null;
let after: string | null = null;
let before: string | null = null;
let mailboxRole: 'trash' | 'junk' | null = null;
let includeTrashAndJunk = false;
let hasOperators = false;
const pushText = (value: string, quoted: boolean) => {
const trimmed = value.trim();
if (!trimmed) return;
textParts.push(quoted ? `"${trimmed}"` : trimmed);
terms.push(trimmed.toLowerCase());
};
for (const token of tokenizeQuery(raw)) {
if (token.key === null || !OPERATORS.has(token.key)) {
if (token.key !== null) pushText(token.original, false);
else pushText(token.value, token.quoted);
continue;
}
const value = token.value.trim();
const lower = value.toLowerCase();
let consumed = true;
switch (token.key) {
case 'in': {
const alias = SCOPE_ALIASES[lower];
if (alias) scope = alias;
else if (lower === 'trash' || lower === 'bin') { scope = 'mail'; mailboxRole = 'trash'; }
else if (lower === 'junk' || lower === 'spam') { scope = 'mail'; mailboxRole = 'junk'; }
else consumed = false;
break;
}
case 'account':
if (lower) account = lower; else consumed = false;
break;
case 'from':
if (value) mail.from = value; else consumed = false;
break;
case 'to':
if (value) mail.to = value; else consumed = false;
break;
case 'subject':
if (value) mail.subject = value; else consumed = false;
break;
case 'has':
if (lower === 'attachment' || lower === 'attachments' || lower === 'file') mail.hasAttachment = true;
else consumed = false;
break;
case 'is':
if (lower === 'unread') mail.isUnread = true;
else if (lower === 'read') mail.isUnread = false;
else if (lower === 'starred' || lower === 'flagged') mail.isStarred = true;
else if (lower === 'unstarred') mail.isStarred = false;
else if (lower === 'anything' || lower === 'any') includeTrashAndJunk = true;
else consumed = false;
break;
case 'after':
if (isValidQueryDate(value)) { after = value; mail.dateAfter = value; } else consumed = false;
break;
case 'before':
if (isValidQueryDate(value)) { before = value; mail.dateBefore = value; } else consumed = false;
break;
}
if (consumed) hasOperators = true;
else pushText(token.original, false);
}
return {
raw, text: textParts.join(' '), terms, scope, account, mail, after, before,
mailboxRole, includeTrashAndJunk, hasOperators,
};
}
/** Does a login match the `account:` operator (label substring or address prefix)? */
export function accountMatches(pattern: string | null, account: { label: string; email: string; localAccountId: string }): boolean {
if (!pattern) return true;
const p = pattern.toLowerCase();
const email = account.email.toLowerCase();
const label = account.label.toLowerCase();
return email === p || email.startsWith(p) || label.includes(p) || account.localAccountId.toLowerCase() === p;
}
/** Local matching: every term is a substring of at least one of the fields. */
export function matchesTerms(terms: string[], fields: Array<string | null | undefined>): boolean {
if (terms.length === 0) return false;
const haystack = fields.filter((f): f is string => typeof f === 'string' && f.length > 0).map((f) => f.toLowerCase());
if (haystack.length === 0) return false;
return terms.every((term) => haystack.some((field) => field.includes(term)));
}
/** Whether the parsed query has anything the server can be asked for this kind. */
export function hasRemoteQuery(parsed: ParsedQuery, kind: SearchKind): boolean {
if (parsed.text.trim()) return true;
if (kind !== 'mail') return false;
const { mail } = parsed;
return Boolean(mail.from || mail.to || mail.subject || mail.body
|| mail.hasAttachment !== null || mail.isUnread !== null || mail.isStarred !== null
|| mail.dateAfter || mail.dateBefore || parsed.mailboxRole);
}
+77
View File
@@ -0,0 +1,77 @@
import type { ParsedQuery } from './query-parser';
import type { GlobalSearchHit } from './types';
// Merging and ordering of search hits (webmail lib/global-search/rank.ts).
/**
* Identity for de-duplication: the same item reached through two logins of
* one server is one hit; ids are scoped by server and owning account (#847).
* Calendar hits collapse per series (the loaded window holds occurrences,
* the server returns the series).
*/
function mergeKey(hit: GlobalSearchHit): string {
const scope = hit.serverUrl ? `s ${hit.serverUrl} ${hit.jmapAccountId}` : `l ${hit.localAccountId} ${hit.jmapAccountId}`;
if (hit.kind === 'calendar' && hit.event.uid) return `calendar ${scope} uid:${hit.event.uid}`;
return `${hit.kind} ${scope} ${hit.id}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function atWordBoundary(field: string, term: string): boolean {
return new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(term)}`, 'u').test(field);
}
/**
* Relevance for the typed terms: exact > prefix > whole word > substring on
* the title, then a subtitle match, then hits the server matched on a field
* that isn't shown (the body). Recency breaks ties.
*/
export function scoreHit(hit: GlobalSearchHit, parsed: ParsedQuery): number {
const query = parsed.terms.join(' ');
if (!query) return 10;
const title = hit.title.toLowerCase();
if (title === query) return 100;
if (title.startsWith(query)) return 80;
if (parsed.terms.every((term) => atWordBoundary(title, term))) return 60;
if (parsed.terms.every((term) => title.includes(term))) return 50;
if (parsed.terms.some((term) => title.includes(term))) return 30;
const subtitle = hit.subtitle.toLowerCase();
if (parsed.terms.some((term) => subtitle.includes(term))) return 20;
return 10;
}
function dateValue(hit: GlobalSearchHit): number {
if (!hit.date) return Number.NEGATIVE_INFINITY;
const ms = new Date(hit.date).getTime();
return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
}
/** Sorted copy: score, then newest first, then title. */
export function rankHits(hits: GlobalSearchHit[], parsed: ParsedQuery): GlobalSearchHit[] {
return hits
.map((hit, index) => ({ hit, index, score: scoreHit(hit, parsed) }))
.sort((a, b) =>
b.score - a.score
|| dateValue(b.hit) - dateValue(a.hit)
|| a.hit.title.localeCompare(b.hit.title)
|| a.index - b.index)
.map((entry) => entry.hit);
}
/**
* De-duplicates by identity. A server hit replaces a local one for the same
* item (it carries the snippet); a local hit never replaces a server one.
*/
export function mergeHits(existing: GlobalSearchHit[], incoming: GlobalSearchHit[]): GlobalSearchHit[] {
const byKey = new Map<string, GlobalSearchHit>();
for (const hit of existing) byKey.set(mergeKey(hit), hit);
for (const hit of incoming) {
const key = mergeKey(hit);
const current = byKey.get(key);
if (current && current.source === 'remote' && hit.source === 'local') continue;
byKey.set(key, hit);
}
return [...byKey.values()];
}
+132
View File
@@ -0,0 +1,132 @@
import { accountMatches, hasRemoteQuery, type ParsedQuery, type SearchKind } from './query-parser';
import { mergeHits, rankHits } from './rank';
import { emptyOutcome, type SearchAccount, type SearchOutcome, type SearchProvider } from './types';
// The fan-out of one search (webmail lib/global-search/run-global-search.ts).
export interface RunGlobalSearchOptions {
parsed: ParsedQuery;
accounts: SearchAccount[];
providers: readonly SearchProvider[];
localLimit: number;
remoteLimit: number;
signal: AbortSignal;
/** A fresh snapshot each time hits or statuses change; never after `signal` aborts. */
onUpdate: (outcome: SearchOutcome) => void;
concurrency?: number;
timeoutMs?: number;
includeLocal?: boolean;
}
export const DEFAULT_SEARCH_CONCURRENCY = 4;
export const DEFAULT_SEARCH_TIMEOUT_MS = 8000;
function snapshot(outcome: SearchOutcome): SearchOutcome {
const status = {} as SearchOutcome['status'];
for (const kind of Object.keys(outcome.status) as SearchKind[]) {
status[kind] = { ...outcome.status[kind], errors: [...outcome.status[kind].errors] };
}
return { hits: { ...outcome.hits }, status };
}
/** The pass over what is in memory, before any request goes out. */
export function localOutcome(
parsed: ParsedQuery,
accounts: SearchAccount[],
providers: readonly SearchProvider[],
limit: number,
): SearchOutcome {
const outcome = emptyOutcome();
const matching = accounts.filter((a) => accountMatches(parsed.account, a));
for (const provider of providers) {
if (parsed.scope !== 'all' && provider.kind !== parsed.scope) continue;
if (parsed.terms.length > 0) {
try {
outcome.hits[provider.kind] = rankHits(mergeHits([], provider.local(parsed, matching, limit)), parsed);
} catch {
// A store in the middle of a change must not break typing.
}
}
const willAsk = hasRemoteQuery(parsed, provider.kind) && matching.length > 0;
outcome.status[provider.kind].status = willAsk ? 'loading' : 'done';
}
return outcome;
}
/**
* Fans a parsed query out to every provider and login, publishing partial
* results as they land: the in-memory hits first, then each server answer
* merged, de-duplicated and re-ranked. A login that fails or times out
* becomes an error row; the others still show.
*/
export async function runGlobalSearch(options: RunGlobalSearchOptions): Promise<SearchOutcome> {
const {
parsed, providers, signal, onUpdate, localLimit, remoteLimit,
concurrency = DEFAULT_SEARCH_CONCURRENCY,
timeoutMs = DEFAULT_SEARCH_TIMEOUT_MS,
includeLocal = true,
} = options;
const accounts = options.accounts.filter((account) => accountMatches(parsed.account, account));
const outcome = includeLocal ? localOutcome(parsed, accounts, providers, localLimit) : emptyOutcome();
const publish = () => {
if (!signal.aborted) onUpdate(snapshot(outcome));
};
const active = providers.filter((p) => parsed.scope === 'all' || p.kind === parsed.scope);
type Task = { provider: SearchProvider; account: SearchAccount };
const tasks: Task[] = [];
const pending = new Map<SearchKind, number>();
for (const provider of active) {
const targets = hasRemoteQuery(parsed, provider.kind) ? accounts : [];
pending.set(provider.kind, targets.length);
outcome.status[provider.kind].status = targets.length > 0 ? 'loading' : 'done';
for (const account of targets) tasks.push({ provider, account });
}
publish();
const runTask = async ({ provider, account }: Task) => {
const { kind } = provider;
const controller = new AbortController();
const onAbort = () => controller.abort();
signal.addEventListener('abort', onAbort);
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
// Not every request takes a signal: race it against the abort.
const aborted = new Promise<never>((_, reject) => {
controller.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })));
});
try {
const result = await Promise.race([
provider.remote(parsed, account, { limit: remoteLimit, signal: controller.signal }),
aborted,
]);
if (signal.aborted) return;
outcome.hits[kind] = rankHits(mergeHits(outcome.hits[kind], result.hits), parsed);
if (result.hasMore) outcome.status[kind].hasMore = true;
} catch (error) {
if (signal.aborted) return;
outcome.status[kind].errors.push({
localAccountId: account.localAccountId,
accountLabel: account.label,
message: timedOut ? 'timeout' : error instanceof Error ? error.message : String(error),
});
} finally {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
const left = (pending.get(kind) ?? 1) - 1;
pending.set(kind, left);
if (left <= 0) outcome.status[kind].status = 'done';
publish();
}
};
let next = 0;
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, tasks.length)) }, async () => {
while (next < tasks.length && !signal.aborted) {
const task = tasks[next++];
await runTask(task);
}
});
await Promise.all(workers);
return snapshot(outcome);
}
+113
View File
@@ -0,0 +1,113 @@
import type { CalendarEvent, ContactCard, Email, FileNode } from '../../api/types';
import type { ParsedQuery, SearchKind } from './query-parser';
// "Search everything" (webmail lib/global-search/types.ts, #641): one query
// across mail, contacts, calendar events and files of every signed-in
// account. Every hit carries the login it is reached through
// (`localAccountId`, an account-store id), the JMAP account that owns it and
// the raw server id: ids repeat across accounts (#847), so nothing finds a
// hit again by its id alone.
export type HitSource = 'local' | 'remote';
interface HitBase {
kind: SearchKind;
/** Login (account-store id) the item is reached through. */
localAccountId: string;
/** JMAP account that owns the item (the login's own, or a shared one). */
jmapAccountId: string;
/** Whether that is not the login's own account. */
shared: boolean;
/** Raw server id, never namespaced. */
id: string;
accountLabel: string;
title: string;
/** Folder, address book, calendar or path, shown after the account label. */
subtitle: string;
/** ISO time for ordering by recency. */
date: string | null;
source: HitSource;
/** Server the login talks to: the same item reached through two logins is one hit. */
serverUrl?: string;
}
export interface MailHit extends HitBase {
kind: 'mail';
email: Email;
snippet: { subject: string | null; preview: string | null } | null;
}
export interface ContactHit extends HitBase {
kind: 'contacts';
contact: ContactCard;
/** The id the contacts store lists the card under (namespaced when shared). */
storeId: string;
}
export interface CalendarHit extends HitBase {
kind: 'calendar';
event: CalendarEvent;
isRecurring: boolean;
}
export interface FileHit extends HitBase {
kind: 'files';
node: FileNode;
/** Folder path the node lives in (`/` for the root). */
folderPath: string;
isFolder: boolean;
}
export type GlobalSearchHit = MailHit | ContactHit | CalendarHit | FileHit;
/** One login the search fans out to. */
export interface SearchAccount {
localAccountId: string;
label: string;
email: string;
serverUrl?: string;
/** The login the app is connected to right now. */
active: boolean;
}
export interface RemoteSearchResult {
hits: GlobalSearchHit[];
hasMore: boolean;
}
export interface SearchProvider {
kind: SearchKind;
/** Hits from what is already in memory: substring match, so partial words work. */
local: (parsed: ParsedQuery, accounts: SearchAccount[], limit: number) => GlobalSearchHit[];
/** One login's server hits. Throws on failure; the run turns that into an error row. */
remote: (parsed: ParsedQuery, account: SearchAccount, options: { limit: number; signal: AbortSignal }) => Promise<RemoteSearchResult>;
}
export interface SearchAccountError {
localAccountId: string;
accountLabel: string;
message: string;
}
export interface KindStatus {
status: 'idle' | 'loading' | 'done';
errors: SearchAccountError[];
hasMore: boolean;
}
export interface SearchOutcome {
hits: Record<SearchKind, GlobalSearchHit[]>;
status: Record<SearchKind, KindStatus>;
}
export function emptyOutcome(): SearchOutcome {
return {
hits: { mail: [], contacts: [], calendar: [], files: [] },
status: {
mail: { status: 'idle', errors: [], hasMore: false },
contacts: { status: 'idle', errors: [], hasMore: false },
calendar: { status: 'idle', errors: [], hasMore: false },
files: { status: 'idle', errors: [], hasMore: false },
},
};
}
@@ -0,0 +1,64 @@
import React from 'react';
import { parseSearchQuery, hasRemoteQuery, SEARCH_KINDS, type ParsedQuery, type SearchScope } from './query-parser';
import { localOutcome, runGlobalSearch } from './run';
import { emptyOutcome, type SearchAccount, type SearchOutcome, type SearchProvider } from './types';
// Keeps a search's merged outcome current (webmail hooks/use-global-search.ts):
// in-memory hits on every keystroke, server hits after a pause, partial
// results as each login answers. A newer query drops older answers.
export interface UseGlobalSearchOptions {
query: string;
/** Scope chip; an `in:` in the query wins. */
scope: SearchScope;
/** Account chip (account-store id); null = every login. */
accountId: string | null;
localLimit: number;
remoteLimit: number;
debounceMs?: number;
enabled?: boolean;
providers: readonly SearchProvider[];
accounts: () => SearchAccount[];
}
/** The parsed query with the scope chip applied (an explicit `in:` wins). */
export function scopedQuery(query: string, scope: SearchScope): ParsedQuery {
const base = parseSearchQuery(query);
return base.scope === 'all' && scope !== 'all' ? { ...base, scope } : base;
}
export function useGlobalSearch(options: UseGlobalSearchOptions): { parsed: ParsedQuery; outcome: SearchOutcome; isSearching: boolean } {
const {
query, scope, accountId, localLimit, remoteLimit, debounceMs = 300, enabled = true, providers, accounts,
} = options;
const parsed = React.useMemo(() => scopedQuery(query, scope), [query, scope]);
const [outcome, setOutcome] = React.useState<SearchOutcome>(emptyOutcome);
const searchId = React.useRef(0);
const isEmpty = parsed.terms.length === 0 && !SEARCH_KINDS.some((kind) => hasRemoteQuery(parsed, kind));
React.useEffect(() => {
const id = ++searchId.current;
if (!enabled || isEmpty) {
setOutcome(emptyOutcome());
return undefined;
}
const controller = new AbortController();
const all = accounts();
const targets = accountId ? all.filter((a) => a.localAccountId === accountId) : all;
setOutcome(localOutcome(parsed, targets, providers, localLimit));
const timer = setTimeout(() => {
void runGlobalSearch({
parsed, accounts: targets, providers, localLimit, remoteLimit, signal: controller.signal,
onUpdate: (next) => { if (id === searchId.current) setOutcome(next); },
});
}, debounceMs);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [parsed, enabled, isEmpty, accountId, accounts, providers, localLimit, remoteLimit, debounceMs]);
const isSearching = SEARCH_KINDS.some((kind) => outcome.status[kind].status === 'loading');
return { parsed, outcome, isSearching };
}
+25 -2
View File
@@ -7,8 +7,9 @@ import {
Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X, Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X,
Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays, Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays,
Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide, Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide,
Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check, Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check, Globe,
} from '../components/icons'; } from '../components/icons';
import { GlobalSearchSheet } from '../components/search/GlobalSearchSheet';
import { ActionSheet } from '../components/email/ActionSheet'; import { ActionSheet } from '../components/email/ActionSheet';
import { SORT_CHOICES, sortChoiceChanges, sortChoiceFor, type SortChoiceKey } from '../lib/sort-choices'; import { SORT_CHOICES, sortChoiceChanges, sortChoiceFor, type SortChoiceKey } from '../lib/sort-choices';
import { orderForMailbox, sanitizeSortLevels } from '../lib/message-list-order'; import { orderForMailbox, sanitizeSortLevels } from '../lib/message-list-order';
@@ -1063,6 +1064,9 @@ export default function EmailListScreen({
// Stalwart 1.0). // Stalwart 1.0).
const [searchInput, setSearchInput] = React.useState(storeSearchQuery); const [searchInput, setSearchInput] = React.useState(storeSearchQuery);
const [searchFocused, setSearchFocused] = React.useState(false); const [searchFocused, setSearchFocused] = React.useState(false);
// "Search everything": mail, contacts, events and files of every account
// (webmail global search palette).
const [globalSearchOpen, setGlobalSearchOpen] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
// Keep local input in sync when the store is cleared externally // Keep local input in sync when the store is cleared externally
// (e.g. clearing the search from the chips row). // (e.g. clearing the search from the chips row).
@@ -1555,8 +1559,21 @@ export default function EmailListScreen({
{/* Search bar (always visible; inside the header in a short window) */} {/* Search bar (always visible; inside the header in a short window) */}
{!shortHeight && renderSearchBar(false)} {!shortHeight && renderSearchBar(false)}
{searchFocused && ((!searchInput.trim() && recentSearches.length > 0) || contactSuggestions.length > 0) && ( {searchFocused && (
<View style={styles.recentSearches}> <View style={styles.recentSearches}>
<Pressable
style={[styles.recentSearchRow, styles.recentSearchMain]}
onPress={() => {
setSearchFocused(false);
searchInputRef.current?.blur();
setGlobalSearchOpen(true);
}}
accessibilityRole="button"
testID="search-everything"
>
<Globe size={12} color={c.textMuted} />
<Text style={styles.recentSearchText} numberOfLines={1}>{t('global_search.title', 'Search everything')}</Text>
</Pressable>
{!searchInput.trim() && recentSearches.length > 0 && ( {!searchInput.trim() && recentSearches.length > 0 && (
<> <>
<Text style={styles.recentSearchesTitle}>{t('advanced_search.suggestions_recent', 'Recent searches')}</Text> <Text style={styles.recentSearchesTitle}>{t('advanced_search.suggestions_recent', 'Recent searches')}</Text>
@@ -1861,6 +1878,12 @@ export default function EmailListScreen({
</Pressable> </Pressable>
{!sidebarDocked && <SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />} {!sidebarDocked && <SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />}
<GlobalSearchSheet
visible={globalSearchOpen}
initialQuery={searchInput}
onClose={() => setGlobalSearchOpen(false)}
navigation={navigation}
/>
<ListAttachmentOpener ref={attachmentOpenerRef} /> <ListAttachmentOpener ref={attachmentOpenerRef} />
<Modal supportedOrientations={MODAL_ORIENTATIONS} <Modal supportedOrientations={MODAL_ORIENTATIONS}