From f15b7bb3cf7367a27cfe6fa3124c6ca4d8549641 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 15:50:02 +0100 Subject: [PATCH] feat: search mail, contacts, events and files of every account at once --- .../__tests__/global-search-providers.test.ts | 103 ++++ .../live-stalwart-global-search.test.ts | 172 ++++++ src/api/global-search.ts | 516 ++++++++++++++++++ .../__tests__/global-search-wiring.test.ts | 25 + src/components/search/GlobalSearchSheet.tsx | 340 ++++++++++++ .../__tests__/global-search-open-hit.test.ts | 77 +++ src/lib/__tests__/global-search.test.ts | 226 ++++++++ src/lib/global-search/filters.ts | 181 ++++++ src/lib/global-search/open-hit.ts | 74 +++ src/lib/global-search/query-parser.ts | 205 +++++++ src/lib/global-search/rank.ts | 77 +++ src/lib/global-search/run.ts | 132 +++++ src/lib/global-search/types.ts | 113 ++++ src/lib/global-search/use-global-search.ts | 64 +++ src/screens/EmailListScreen.tsx | 27 +- 15 files changed, 2330 insertions(+), 2 deletions(-) create mode 100644 src/api/__tests__/global-search-providers.test.ts create mode 100644 src/api/__tests__/live-stalwart-global-search.test.ts create mode 100644 src/api/global-search.ts create mode 100644 src/components/__tests__/global-search-wiring.test.ts create mode 100644 src/components/search/GlobalSearchSheet.tsx create mode 100644 src/lib/__tests__/global-search-open-hit.test.ts create mode 100644 src/lib/__tests__/global-search.test.ts create mode 100644 src/lib/global-search/filters.ts create mode 100644 src/lib/global-search/open-hit.ts create mode 100644 src/lib/global-search/query-parser.ts create mode 100644 src/lib/global-search/rank.ts create mode 100644 src/lib/global-search/run.ts create mode 100644 src/lib/global-search/types.ts create mode 100644 src/lib/global-search/use-global-search.ts diff --git a/src/api/__tests__/global-search-providers.test.ts b/src/api/__tests__/global-search-providers.test.ts new file mode 100644 index 00000000..113916f2 --- /dev/null +++ b/src/api/__tests__/global-search-providers.test.ts @@ -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: 'Invoice', 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('Invoice'); + }); +}); + +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' }); + }); +}); diff --git a/src/api/__tests__/live-stalwart-global-search.test.ts b/src/api/__tests__/live-stalwart-global-search.test.ts new file mode 100644 index 00000000..9fe1698f --- /dev/null +++ b/src/api/__tests__/live-stalwart-global-search.test.ts @@ -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() })); +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]>; + +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 { + return (await client.request(calls as never, using)).methodResponses as Responses; +} + +async function createOne(client: JMAPClient, method: string, accountId: string, object: Record, using: string[]): Promise { + 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 { + 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 { + 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); +}); diff --git a/src/api/global-search.ts b/src/api/global-search.ts new file mode 100644 index 00000000..4141df50 --- /dev/null +++ b/src/api/global-search.ts @@ -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]>; + +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; + live: boolean; +} + +const sessions = new Map }>(); + +/** 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 { + 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 { + 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 { + 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(); + +async function mailboxesOf(login: LoginSession, localAccountId: string, accountId: string): Promise { + 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, 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): 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(); + 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[]).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; +} + +const fileListings = new Map(); + +/** Raw server id of a node (shared ones are listed as `:`). */ +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 { + 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 }; diff --git a/src/components/__tests__/global-search-wiring.test.ts b/src/components/__tests__/global-search-wiring.test.ts new file mode 100644 index 00000000..769de184 --- /dev/null +++ b/src/components/__tests__/global-search-wiring.test.ts @@ -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(/ { + 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/); + }); +}); diff --git a/src/components/search/GlobalSearchSheet.tsx b/src/components/search/GlobalSearchSheet.tsx new file mode 100644 index 00000000..9dee0145 --- /dev/null +++ b/src/components/search/GlobalSearchSheet.tsx @@ -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('all'); + const [accountId, setAccountId] = React.useState(null); + const [expanded, setExpanded] = React.useState>(() => new Set()); + const pending = React.useRef(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 ; + } + if (hit.kind === 'contacts') { + return ; + } + const Icon = hit.kind === 'calendar' ? CalendarDays : hit.isFolder ? Folder : FileText; + return ( + + + + ); + }; + + 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 ( + [styles.row, pressed && styles.rowPressed]} + onPress={() => open(hit)} + accessibilityRole="button" + accessibilityLabel={[title, subtitle].filter(Boolean).join(', ')} + > + {renderLeading(hit)} + + + + {hit.kind === 'mail' && hit.snippet?.subject ? : title} + + {hit.kind === 'calendar' && hit.isRecurring ? ( + + + + ) : null} + {hit.date ? {formatHitDate(hit.date, locale)} : null} + + {subtitle ? {subtitle} : null} + {snippet ? : null} + + + ); + }; + + 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 ( + + + {kindLabel(kind)} + {hits.length} + {status.status === 'loading' ? : null} + + {visibleHits(hits, isExpanded).map(renderRow)} + {!isExpanded && hits.length > ROWS_PER_KIND ? ( + [styles.showAll, pressed && styles.rowPressed]} + onPress={() => setExpanded((prev) => new Set(prev).add(kind))} + accessibilityRole="button" + > + {t('global_search.show_all', 'Show all results')} + + ) : null} + + ); + }; + + const chip = (key: string, label: string, selected: boolean, onPress: () => void) => ( + + {label} + + ); + + return ( + { + const hit = pending.current; + pending.current = null; + if (hit) void openHit(hit, navigation, allHits); + }} + > + + + + + + + + { if (trimmed) addRecentSearch(trimmed); }} + accessibilityLabel={t('global_search.title', 'Search everything')} + /> + {isSearching ? : null} + {query ? ( + setQuery('')} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('contacts.clear_search', 'Clear search')}> + + + ) : null} + + + + + {scopes.map((s) => chip(s.key, s.label, scope === s.key, () => setScope(s.key)))} + {accounts.length > 1 ? ( + <> + + {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} + + + + {showRecent ? ( + + + {t('global_search.recent_searches', 'Recent searches')} + + {recentSearches.map((term) => ( + [styles.recentRow, pressed && styles.rowPressed]} onPress={() => setQuery(term)} accessibilityRole="button"> + + {term} + + ))} + + ) : null} + {trimmed ? kinds.map(renderGroup) : null} + {errors.map((error) => ( + + + {t('global_search.account_error', "Couldn't search {account}", { account: error.accountLabel })} + + ))} + {!trimmed && !showRecent ? ( + + {t('global_search.empty_hint', 'Search mail, contacts, events and files across all accounts. Try from:, subject:, has:attachment, after:2026-01-01.')} + + ) : null} + {trimmed && !hasAnyHit && !isSearching ? ( + + {t('global_search.no_results', 'No results for “{query}”', { query: trimmed })} + {t('global_search.server_hint', 'Complete words search the server; partial words match already-loaded items')} + + ) : null} + + + + ); +} + +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' }, + }); +} diff --git a/src/lib/__tests__/global-search-open-hit.test.ts b/src/lib/__tests__/global-search-open-hit.test.ts new file mode 100644 index 00000000..39388bf2 --- /dev/null +++ b/src/lib/__tests__/global-search-open-hit.test.ts @@ -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): Extract => ({ + ...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' }); + }); +}); diff --git a/src/lib/__tests__/global-search.test.ts b/src/lib/__tests__/global-search.test.ts new file mode 100644 index 00000000..af15b991 --- /dev/null +++ b/src/lib/__tests__/global-search.test.ts @@ -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 & { 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 => ({ + 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'); + }); +}); diff --git a/src/lib/global-search/filters.ts b/src/lib/global-search/filters.ts new file mode 100644 index 00000000..d12e399c --- /dev/null +++ b/src/lib/global-search/filters.ts @@ -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; + +/** 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): 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(); + 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('/')}`; +} diff --git a/src/lib/global-search/open-hit.ts b/src/lib/global-search/open-hit.ts new file mode 100644 index 00000000..e226867f --- /dev/null +++ b/src/lib/global-search/open-hit.ts @@ -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): { 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 { + 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; + } +} diff --git a/src/lib/global-search/query-parser.ts b/src/lib/global-search/query-parser.ts new file mode 100644 index 00000000..f2c49e4a --- /dev/null +++ b/src/lib/global-search/query-parser.ts @@ -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: 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 = { + 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): 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); +} diff --git a/src/lib/global-search/rank.ts b/src/lib/global-search/rank.ts new file mode 100644 index 00000000..e9efa21a --- /dev/null +++ b/src/lib/global-search/rank.ts @@ -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(); + 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()]; +} diff --git a/src/lib/global-search/run.ts b/src/lib/global-search/run.ts new file mode 100644 index 00000000..6cebf628 --- /dev/null +++ b/src/lib/global-search/run.ts @@ -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 { + 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(); + 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((_, 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); +} diff --git a/src/lib/global-search/types.ts b/src/lib/global-search/types.ts new file mode 100644 index 00000000..a7bcc1f4 --- /dev/null +++ b/src/lib/global-search/types.ts @@ -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; +} + +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; + status: Record; +} + +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 }, + }, + }; +} diff --git a/src/lib/global-search/use-global-search.ts b/src/lib/global-search/use-global-search.ts new file mode 100644 index 00000000..7dbe5259 --- /dev/null +++ b/src/lib/global-search/use-global-search.ts @@ -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(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 }; +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 35b61b4d..c430bea3 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -7,8 +7,9 @@ import { Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X, Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays, 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'; +import { GlobalSearchSheet } from '../components/search/GlobalSearchSheet'; import { ActionSheet } from '../components/email/ActionSheet'; import { SORT_CHOICES, sortChoiceChanges, sortChoiceFor, type SortChoiceKey } from '../lib/sort-choices'; import { orderForMailbox, sanitizeSortLevels } from '../lib/message-list-order'; @@ -1063,6 +1064,9 @@ export default function EmailListScreen({ // Stalwart 1.0). const [searchInput, setSearchInput] = React.useState(storeSearchQuery); 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(() => { // Keep local input in sync when the store is cleared externally // (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) */} {!shortHeight && renderSearchBar(false)} - {searchFocused && ((!searchInput.trim() && recentSearches.length > 0) || contactSuggestions.length > 0) && ( + {searchFocused && ( + { + setSearchFocused(false); + searchInputRef.current?.blur(); + setGlobalSearchOpen(true); + }} + accessibilityRole="button" + testID="search-everything" + > + + {t('global_search.title', 'Search everything')} + {!searchInput.trim() && recentSearches.length > 0 && ( <> {t('advanced_search.suggestions_recent', 'Recent searches')} @@ -1861,6 +1878,12 @@ export default function EmailListScreen({ {!sidebarDocked && setDrawerOpen(false)} />} + setGlobalSearchOpen(false)} + navigation={navigation} + />