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/__tests__/live-stalwart-mailbox-share.test.ts b/src/api/__tests__/live-stalwart-mailbox-share.test.ts new file mode 100644 index 00000000..0d1c9c29 --- /dev/null +++ b/src/api/__tests__/live-stalwart-mailbox-share.test.ts @@ -0,0 +1,107 @@ +/** + * Live checks of folder sharing (the folder menu's "Share…") against a real + * Stalwart: the first login makes a folder, shares it with the second one, + * changes the role, and takes the access away again. + * + * 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-mailbox-share.test.ts + * + * The folder and its share are removed afterwards, also after a failed check. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +import { JMAPClient } from '../jmap-client'; +import { CAPABILITIES } from '../types'; +import { supportsMailboxSharing } from '../delegation'; +import { getMailboxShareWith, setMailboxShare } from '../mailbox-sharing'; +import { createMailbox, deleteMailbox } from '../email'; +import { listPrincipals } from '../files'; +import { directoryNotice } from '../../lib/principal-directory'; +import { detectPreset, MAILBOX_PRESETS } from '../../lib/delegation/share-rights'; + +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; + +let owner: JMAPClient; +let delegate: JMAPClient; +let folderId: string | null = null; + +async function useOwner(): Promise { + const mod = await import('../jmap-client'); + (mod.jmapClient as unknown as JMAPClient).restoreSnapshot(owner.snapshot()); +} + +/** The owner's folders the delegate can see. */ +async function delegateSees(): Promise { + const session = await delegate.refreshSession(); + if (!session.accounts?.[owner.accountId]) return []; + const res = await delegate.request( + [['Mailbox/get', { accountId: owner.accountId, ids: null, properties: ['id'] }, '0']], + [CAPABILITIES.CORE, CAPABILITIES.MAIL], + ); + const [method, body] = (res.methodResponses ?? [])[0] as [string, { list?: { id: string }[] }, string]; + return method === 'Mailbox/get' ? (body.list ?? []).map((m) => m.id) : []; +} + +live('live Stalwart: folder sharing', () => { + beforeAll(async () => { + owner = new JMAPClient(); + await owner.connect(SERVER!, USER, PASS); + delegate = new JMAPClient(); + await delegate.connect(SERVER!, USER2!, PASS2); + await useOwner(); + }, 60_000); + + afterAll(async () => { + if (!owner || !folderId) return; + await useOwner(); + await setMailboxShare(folderId, delegate.accountId, null).catch(() => undefined); + await deleteMailbox(folderId, undefined, { onDestroyRemoveEmails: true }).catch(() => undefined); + }, 60_000); + + it('shares a folder read-only, upgrades it, and takes it back', async () => { + expect(supportsMailboxSharing()).toBe(true); + folderId = await createMailbox({ name: `Bulwark share check ${Date.now()}` }); + const principalId = delegate.accountId; + + // Nobody yet: Stalwart answers {} (or null). + expect(Object.keys((await getMailboxShareWith(folderId)) ?? {})).toEqual([]); + + await setMailboxShare(folderId, principalId, MAILBOX_PRESETS.read); + const afterRead = await getMailboxShareWith(folderId); + expect(afterRead?.[principalId]).toBeTruthy(); + // 1.0 stores the keyword right with the seen right; still "Read only". + expect(detectPreset('mailbox', afterRead![principalId])).toBe('read'); + expect(await delegateSees()).toContain(folderId); + + await setMailboxShare(folderId, principalId, MAILBOX_PRESETS.readWrite); + const afterWrite = await getMailboxShareWith(folderId); + expect(detectPreset('mailbox', afterWrite![principalId])).toBe('readWrite'); + + await setMailboxShare(folderId, principalId, null); + const afterRemove = await getMailboxShareWith(folderId); + expect(afterRemove?.[principalId]).toBeFalsy(); + expect(await delegateSees()).not.toContain(folderId); + }, 60_000); + + // Stalwart 1.0 hides the directory unless the admin turns directory + // queries on: the sheet then says why the list is short and names a + // sharee it can't resolve "Unknown user". 0.16 lists everyone. + it('either lists the person shared with or explains why the list is short', async () => { + const listing = await listPrincipals(); + const listed = listing.principals.some((p) => p.id === delegate.accountId); + const notice = directoryNotice(listing); + expect(listed || notice !== null).toBe(true); + if (!listed) expect(notice).toBe('restricted'); + }, 60_000); +}); diff --git a/src/api/__tests__/mailbox-sharing.test.ts b/src/api/__tests__/mailbox-sharing.test.ts new file mode 100644 index 00000000..022b84a5 --- /dev/null +++ b/src/api/__tests__/mailbox-sharing.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../jmap-client', () => ({ + jmapClient: { + accountId: 'own', + request: vi.fn(), + }, +})); + +import { jmapClient } from '../jmap-client'; +import { getMailboxShareWith, setMailboxShare } from '../mailbox-sharing'; +import { MAILBOX_PRESETS } from '../../lib/delegation/share-rights'; +import { canShareMailbox, mailboxShareRef } from '../../lib/mailbox-share'; +import type { Mailbox, MailboxRights } from '../types'; + +const mockRequest = jmapClient.request as ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('getMailboxShareWith', () => { + it("asks for the folder's shareWith with the mail:share capability", async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Mailbox/get', { list: [{ id: 'mb1', shareWith: { p2: MAILBOX_PRESETS.read } }] }, '0']], + }); + const shareWith = await getMailboxShareWith('mb1'); + expect(shareWith).toEqual({ p2: MAILBOX_PRESETS.read }); + const [[call], using] = mockRequest.mock.calls[0]; + expect(call).toEqual(['Mailbox/get', { accountId: 'own', ids: ['mb1'], properties: ['id', 'shareWith'] }, '0']); + expect(using).toContain('urn:ietf:params:jmap:mail:share'); + }); + + it('reads a folder of a shared account under its owner', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/get', { list: [{ id: 'x', shareWith: null }] }, '0']] }); + expect(await getMailboxShareWith('x', 'team')).toBeNull(); + expect(mockRequest.mock.calls[0][0][0][1].accountId).toBe('team'); + }); + + it('fails when the folder is gone', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/get', { list: [], notFound: ['mb1'] }, '0']] }); + await expect(getMailboxShareWith('mb1')).rejects.toThrow('Folder not found'); + }); +}); + +describe('setMailboxShare', () => { + it('patches one principal into shareWith, and null takes the access away', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/set', { updated: { mb1: null } }, '0']] }); + await setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read); + await setMailboxShare('mb1', 'p2', null, 'team'); + expect(mockRequest.mock.calls[0][0][0][1]).toEqual({ accountId: 'own', update: { mb1: { 'shareWith/p2': MAILBOX_PRESETS.read } } }); + expect(mockRequest.mock.calls[1][0][0][1]).toEqual({ accountId: 'team', update: { mb1: { 'shareWith/p2': null } } }); + }); + + it("reports the server's refusal", async () => { + mockRequest.mockResolvedValue({ + methodResponses: [['Mailbox/set', { notUpdated: { mb1: { type: 'forbidden', description: 'Not allowed' } } }, '0']], + }); + await expect(setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read)).rejects.toThrow('Not allowed'); + }); + + it('fails when the server does not confirm the change', async () => { + mockRequest.mockResolvedValue({ methodResponses: [['Mailbox/set', { updated: {} }, '0']] }); + await expect(setMailboxShare('mb1', 'p2', MAILBOX_PRESETS.read)).rejects.toThrow('did not confirm'); + }); +}); + +describe('which folders offer Share', () => { + const rights = (extra: Record = {}) => ({ ...MAILBOX_PRESETS.read, ...extra }) as unknown as MailboxRights; + + it("offers it on the user's own folders where the server shares folders", () => { + expect(canShareMailbox({ isShared: false, myRights: rights() }, true)).toBe(true); + expect(canShareMailbox({ isShared: false, myRights: rights() }, false)).toBe(false); + }); + + it("offers it on someone else's folder only with their permission to pass it on", () => { + expect(canShareMailbox({ isShared: true, myRights: rights() }, true)).toBe(false); + expect(canShareMailbox({ isShared: true, myRights: rights({ mayShare: true }) }, true)).toBe(true); + }); + + it("shares a shared account's folder under its owner, with the owner's id", () => { + const shared = { id: 'team:5', originalId: '5', isShared: true, accountId: 'team' } as Mailbox; + expect(mailboxShareRef(shared)).toEqual({ mailboxId: '5', accountId: 'team' }); + expect(mailboxShareRef({ id: 'a', isShared: false, accountId: 'own' } as Mailbox)).toEqual({ mailboxId: 'a', accountId: undefined }); + }); +}); 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/api/mailbox-sharing.ts b/src/api/mailbox-sharing.ts new file mode 100644 index 00000000..f53ec327 --- /dev/null +++ b/src/api/mailbox-sharing.ts @@ -0,0 +1,62 @@ +// Sharing a mail folder with other principals +// (urn:ietf:params:jmap:mail:share), as the webmail's +// JMAPClient.getMailboxShareWith / setMailboxShare do (lib/jmap/client.ts). + +import { jmapClient } from './jmap-client'; +import { CAPABILITIES } from './types'; +import { requireMethodResult } from './jmap-result'; +import { MAIL_SHARE_CAPABILITY } from './delegation'; +import type { ShareMailboxRights } from '../lib/delegation/share-rights'; + +const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL, MAIL_SHARE_CAPABILITY]; + +/** + * The current `shareWith` of one folder. Read on demand (the share sheet): + * Stalwart leaves shareWith out of Mailbox/get unless it is asked for. + * `accountId` is the owner's account for a folder someone shared with the + * user, with the owner's mailbox id. + */ +export async function getMailboxShareWith( + mailboxId: string, + accountId?: string, +): Promise | null> { + const res = await jmapClient.request( + [['Mailbox/get', { accountId: accountId || jmapClient.accountId, ids: [mailboxId], properties: ['id', 'shareWith'] }, '0']], + USING, + ); + const result = requireMethodResult<{ list?: Array<{ id: string; shareWith?: Record | null }> }>( + res, '0', 'Mailbox/get', + ); + const mailbox = (result.list ?? []).find((mb) => mb.id === mailboxId); + if (!mailbox) throw new Error('Folder not found'); + return mailbox.shareWith ?? null; +} + +/** + * Grants (`rights`) or revokes (`null`) a principal's access to a folder with + * a `shareWith/{principalId}` patch, like the calendar and address book + * shares. + */ +export async function setMailboxShare( + mailboxId: string, + principalId: string, + rights: ShareMailboxRights | null, + accountId?: string, +): Promise { + const res = await jmapClient.request( + [['Mailbox/set', { + accountId: accountId || jmapClient.accountId, + update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } }, + }, '0']], + USING, + ); + const result = requireMethodResult<{ + updated?: Record | null; + notUpdated?: Record; + }>(res, '0', 'Mailbox/set'); + const err = result.notUpdated?.[mailboxId]; + if (err) throw new Error(err.description || err.type || 'Failed to update folder share'); + if (!result.updated || !(mailboxId in result.updated)) { + throw new Error('Server did not confirm the share update'); + } +} diff --git a/src/components/SidebarDrawer.tsx b/src/components/SidebarDrawer.tsx index 84db85dc..984092f6 100644 --- a/src/components/SidebarDrawer.tsx +++ b/src/components/SidebarDrawer.tsx @@ -8,7 +8,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Inbox, Send, File as FileIcon, Trash2, Ban, Archive, Star, Folder, FolderOpen, ChevronDown, ChevronRight, X, Settings, LogOut, Check, Plus, - Clock, Layers, Users, Tag, Mails, MailOpen, StickyNote, AlarmClock, Flag, + Clock, Layers, Users, Tag, Mails, MoreHorizontal, Share2, MailOpen, StickyNote, AlarmClock, Flag, CheckCheck, Eraser, FolderPlus, Pencil, AlertTriangle, UserMinus, Bell, BellOff, RotateCcw, type AppIcon, } from './icons'; @@ -34,6 +34,11 @@ import { markMailboxesAsRead, emptyMailbox, createMailbox, updateMailbox, deleteMailbox, } from '../api/email'; import { useTagCountsStore } from '../stores/tag-counts-store'; +import { sidebarTagRows } from '../lib/keyword-nesting'; +import { supportsMailboxSharing } from '../api/delegation'; +import { MailboxShareSheet } from './sharing/MailboxShareSheet'; +import { canShareMailbox } from '../lib/mailbox-share'; +import { tagIdFromKeyword } from '../lib/thread-utils'; import { useNavigation } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; import type { RootStackParamList } from '../navigation/types'; @@ -59,6 +64,7 @@ const STORAGE_KEYS = { expanded: 'sidebar:expandedMailboxes', foldersExpanded: 'sidebar:foldersExpanded', tagsExpanded: 'sidebar:tagsExpanded', + collapsedTags: 'sidebar:collapsedTags', unifiedExpanded: 'sidebar:unifiedExpanded', }; @@ -360,18 +366,26 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side const keywordDefs = useKeywordsStore((s) => s.keywords); const keywordsHydrated = useKeywordsStore((s) => s.hydrated); const hydrateKeywords = useKeywordsStore((s) => s.hydrate); + const nestedTags = useKeywordsStore((s) => s.nested); const navigation = useNavigation>(); const [foldersExpanded, setFoldersExpanded] = React.useState(true); const [tagsExpanded, setTagsExpanded] = React.useState(true); const [unifiedExpanded, setUnifiedExpanded] = React.useState(false); const [expandedFolders, setExpandedFolders] = React.useState>(() => new Set()); + // Parent tags the user folded; every other parent shows its children, as + // the webmail opens all of them until the user folds one. + const [collapsedTags, setCollapsedTags] = React.useState>(() => new Set()); + // "Show all (N)": the tags the visibility settings hide, for now. + const [showAllTags, setShowAllTags] = React.useState(false); const [accountMenuOpen, setAccountMenuOpen] = React.useState(false); const [sheet, setSheet] = React.useState<{ title: string; actions: SheetAction[] } | null>(null); const [prompt, setPrompt] = React.useState<{ title: string; message?: string; initial?: string; confirmLabel: string; onSubmit: (v: string) => void; } | null>(null); const [busy, setBusy] = React.useState(false); + // The folder whose "Share" sheet is open (webmail MailboxShareDialog). + const [sharing, setSharing] = React.useState<{ mailbox: Mailbox; name: string } | null>(null); const tagCounts = useTagCountsStore((s) => s.counts); const tagCountsGeneration = useTagCountsStore((s) => s.generation); const ensureTagCounts = useTagCountsStore((s) => s.ensure); @@ -381,12 +395,19 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side React.useEffect(() => { void (async () => { try { - const [rawExp, rawFld, rawTags, rawUnified] = await Promise.all([ + const [rawExp, rawFld, rawTags, rawUnified, rawCollapsedTags] = await Promise.all([ AsyncStorage.getItem(STORAGE_KEYS.expanded), AsyncStorage.getItem(STORAGE_KEYS.foldersExpanded), AsyncStorage.getItem(STORAGE_KEYS.tagsExpanded), AsyncStorage.getItem(STORAGE_KEYS.unifiedExpanded), + AsyncStorage.getItem(STORAGE_KEYS.collapsedTags), ]); + if (rawCollapsedTags) { + try { + const ids = JSON.parse(rawCollapsedTags) as string[]; + if (Array.isArray(ids)) setCollapsedTags(new Set(ids)); + } catch { /* ignore */ } + } if (rawExp) { try { const ids = JSON.parse(rawExp) as string[]; @@ -459,6 +480,15 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side onClose(); }, [selectMailbox, setFilters, onClose]); + const toggleTagExpand = React.useCallback((id: string) => { + setCollapsedTags((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); else next.add(id); + void AsyncStorage.setItem(STORAGE_KEYS.collapsedTags, JSON.stringify(Array.from(next))).catch(() => {}); + return next; + }); + }, []); + // Tag view (#175): every folder, messages carrying the tag. const selectTag = React.useCallback((id: string) => { setFilters({ keyword: keywordToken(id) }); @@ -479,6 +509,16 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side void ensureTagCounts(activeAccountId, keywordDefs.map((k) => k.id), accounts, excluded); }, [visible, tagsExpanded, keywordDefs, tagAccountsKey, mailboxes, activeAccountId, tagCountsGeneration, ensureTagCounts]); + // The tag rows: nested under their parents with "Nested tags" on, and + // without the tags whose visibility hides them (webmail sidebar). + const selectedTagId = filters.keyword ? tagIdFromKeyword(filters.keyword) : null; + const tagRows = React.useMemo( + () => sidebarTagRows(keywordDefs, nestedTags, tagCounts, { + showAll: showAllTags, selectedId: selectedTagId, collapsed: collapsedTags, + }), + [keywordDefs, nestedTags, tagCounts, showAllTags, selectedTagId, collapsedTags], + ); + // ── Folder actions (long-press) ─────────────────────────────────────── const refFor = (mb: Mailbox) => ({ id: mb.originalId ?? mb.id, @@ -629,6 +669,14 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side }), }); } + if (canShareMailbox(mb, supportsMailboxSharing())) { + actions.push({ + key: 'share', + label: t('mailbox_context_menu.share', 'Share…'), + icon: Share2, + onPress: () => setSharing({ mailbox: mb, name }), + }); + } if (!mb.role && mb.myRights?.mayRename !== false) { actions.push({ key: 'rename', @@ -1221,27 +1269,44 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side )} {t('sidebar.tags', 'Tags')} - {tagsExpanded && keywordDefs.map((kw) => { - const counts = tagCounts[kw.id]; - const isSelected = filters.keyword === keywordToken(kw.id); - const dot = c.tags[kw.color]?.dot ?? c.textMuted; + {tagsExpanded && tagRows.rows.map(({ node, label, hasChildren, expanded }) => { + const counts = tagCounts[node.id]; + const isSelected = filters.keyword === keywordToken(node.id); + const dot = c.tags[node.color]?.dot ?? c.textMuted; return ( } - label={kw.label} - depth={0} + label={label} + depth={node.depth} isSelected={isSelected} unread={counts?.unread ?? 0} total={counts?.total ?? 0} showTotal={showFolderTotalCount} - hasChildren={false} - isExpanded={false} - onPress={() => selectTag(kw.id)} - onToggleExpand={() => {}} + hasChildren={hasChildren} + isExpanded={expanded} + onPress={() => selectTag(node.id)} + onToggleExpand={() => toggleTagExpand(node.id)} /> ); })} + {tagsExpanded && (tagRows.hiddenCount > 0 || showAllTags) && ( + } + label={showAllTags + ? t('sidebar.show_fewer_tags', 'Show less') + : t('sidebar.show_all_tags', 'Show all ({count})', { count: tagRows.hiddenCount })} + depth={0} + isSelected={false} + unread={0} + total={0} + showTotal={false} + hasChildren={false} + isExpanded={false} + onPress={() => setShowAllTags((prev) => !prev)} + onToggleExpand={() => {}} + /> + )} )} @@ -1264,6 +1329,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side onClose={() => setPrompt(null)} /> )} + setSharing(null)} /> ); } @@ -1299,6 +1365,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side onClose={() => setPrompt(null)} /> )} + setSharing(null)} /> ); } diff --git a/src/components/TagSheet.tsx b/src/components/TagSheet.tsx index 8e6426f3..8afddb13 100644 --- a/src/components/TagSheet.tsx +++ b/src/components/TagSheet.tsx @@ -6,7 +6,8 @@ import { X, Check, Tag } from './icons'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { useSheetDrag } from '../lib/use-sheet-drag'; -import { keywordToken, type KeywordDef } from '../stores/keywords-store'; +import { keywordToken, useKeywordsStore, type KeywordDef } from '../stores/keywords-store'; +import { buildKeywordTree, flattenKeywordTree, treeRowLabel } from '../lib/keyword-nesting'; import { useLocaleStore } from '../stores/locale-store'; import { getEmailTagIds } from '../lib/thread-utils'; import type { Email } from '../api/types'; @@ -59,6 +60,14 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle [selectedEmails], ); + // With "Nested tags" on, a tree: children indented under their parent and + // named by their own label, as the webmail tag picker draws it. + const nested = useKeywordsStore((s) => s.nested); + const rows = React.useMemo( + () => flattenKeywordTree(buildKeywordTree(keywords, nested)), + [keywords, nested], + ); + // Tags set on the selection that no local definition explains (set by the // webmail or another device): listed so they can at least be removed. const unknownIds = React.useMemo(() => { @@ -103,7 +112,7 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle {keywords.length === 0 && unknownIds.length === 0 ? ( {t('email_list.no_tags_defined', 'No tags defined. Add tags in Settings.')} ) : ( - keywords.map((kw) => { + rows.map((kw) => { const token = keywordToken(kw.id); const applied = allHaveToken(token); const dot = c.tags[kw.color]?.dot ?? c.primary; @@ -111,10 +120,10 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle onToggle(token, !applied)} - style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} + style={({ pressed }) => [styles.row, tagRowIndent(kw.depth), pressed && styles.rowPressed]} > - {kw.label} + {treeRowLabel(kw, keywords, nested)} {applied && } ); @@ -143,6 +152,11 @@ export function TagSheet({ visible, onClose, keywords, selectedEmails, onToggle ); } +/** Left padding of a tag row: one step per nesting level. */ +export function tagRowIndent(depth: number): { paddingLeft: number } { + return { paddingLeft: spacing.lg + depth * spacing.lg }; +} + function makeStyles(c: ThemePalette) { return StyleSheet.create({ overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(0,0,0,0.5)' }, 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/__tests__/mailbox-share-wiring.test.ts b/src/components/__tests__/mailbox-share-wiring.test.ts new file mode 100644 index 00000000..805c533f --- /dev/null +++ b/src/components/__tests__/mailbox-share-wiring.test.ts @@ -0,0 +1,30 @@ +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('folder sharing in the sidebar', () => { + it('offers Share in the folder menu where the folder can be shared, and opens the share sheet', () => { + const source = read('components/SidebarDrawer.tsx'); + expect(source).toMatch(/canShareMailbox\(mb, supportsMailboxSharing\(\)\)/); + expect(source).toMatch(/key: 'share'/); + expect(source).toMatch(/onPress: \(\) => setSharing\(\{ mailbox: mb, name \}\)/); + // Both the docked sidebar and the drawer render the sheet. + expect(source.match(/ { + const source = read('components/sharing/MailboxShareSheet.tsx'); + expect(source).toMatch(/getMailboxShareWith\(mailboxId, accountId\)/); + expect(source).toMatch(/setMailboxShare\(mailboxId, principalId, rights, accountId\)/); + expect(source).toMatch(/detectPreset\('mailbox', rights\)/); + expect(source).not.toMatch(/freeBusy/); + }); + + it('lets the share sheet name a preset the server stored differently', () => { + const source = read('components/sharing/ShareCollectionSheet.tsx'); + expect(source).toMatch(/detectPreset \? detectPreset\(rights\) : detectSharePreset\(rights, presets\)/); + }); +}); diff --git a/src/components/__tests__/nested-tags-wiring.test.ts b/src/components/__tests__/nested-tags-wiring.test.ts new file mode 100644 index 00000000..02004be4 --- /dev/null +++ b/src/components/__tests__/nested-tags-wiring.test.ts @@ -0,0 +1,37 @@ +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. The rows +// themselves come from lib/keyword-nesting (keyword-nesting.test.ts); these +// check that the screens draw them. +const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', file), 'utf8'); + +describe('nested tags and tag visibility on screen', () => { + it('draws the sidebar tags as a tree with the visibility settings applied', () => { + const source = read('components/SidebarDrawer.tsx'); + expect(source).toMatch(/sidebarTagRows\(keywordDefs, nestedTags, tagCounts/); + expect(source).toMatch(/depth=\{node\.depth\}/); + expect(source).toMatch(/onToggleExpand=\{\(\) => toggleTagExpand\(node\.id\)\}/); + expect(source).toMatch(/sidebar\.show_all_tags/); + }); + + it('indents nested tags in the tag sheet', () => { + const source = read('components/TagSheet.tsx'); + expect(source).toMatch(/flattenKeywordTree\(buildKeywordTree\(keywords, nested\)\)/); + expect(source).toMatch(/tagRowIndent\(kw\.depth\)/); + }); + + it('offers the nesting switch, a parent and the sidebar visibility in Settings > Tags', () => { + const source = read('components/settings/KeywordSettings.tsx'); + expect(source).toMatch(/settings\.keywords\.nesting\.label/); + expect(source).toMatch(/onChange=\{setNested\}/); + expect(source).toMatch(/composeKeywordId\(parentId \|\| null, label\)/); + expect(source).toMatch(/updateKeyword\(kw\.id, \{ visibility: value as KeywordVisibility \}\)/); + }); + + it('names a nested tag by its path in the list and the reader', () => { + expect(read('screens/EmailListScreen.tsx')).toMatch(/formatKeyword\(id, keywordDefs, nestedTags\)/); + expect(read('components/email/MessageHeader.tsx')).toMatch(/formatKeyword\(kw\.id, keywordDefs, nestedTags\)/); + }); +}); diff --git a/src/components/calendar/CalendarSearchSheet.tsx b/src/components/calendar/CalendarSearchSheet.tsx index c2a7e085..e249218b 100644 --- a/src/components/calendar/CalendarSearchSheet.tsx +++ b/src/components/calendar/CalendarSearchSheet.tsx @@ -14,7 +14,7 @@ import { format } from 'date-fns'; import { ArrowLeft, MapPin, Repeat, Search, X } from '../icons'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { useCalendarStore, searchCalendarEvents } from '../../stores/calendar-store'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { formatCalendarDay } from '../../lib/calendar-system'; @@ -62,6 +62,7 @@ export function CalendarSearchSheet({ onClose, }: CalendarSearchSheetProps) { const c = useColors(); + const paint = useCvdColor(); const styles = React.useMemo(() => makeStyles(c), [c]); const { locale, t, system } = useCalendarLocale(); const loaded = useCalendarStore((s) => s.events); @@ -134,7 +135,7 @@ export function CalendarSearchSheet({ const start = getEventStartDate(event); const end = getEventEndDate(event); const calendar = calendars.find((cal) => cal.id === getPrimaryCalendarId(event)); - const color = getEventColor(event, calendars); + const color = paint(getEventColor(event, calendars)); const inactive = isInactiveEvent(event, currentUserEmails); const place = event.locations ? Object.values(event.locations)[0]?.name : undefined; const meta = [place, calendar?.name].filter(Boolean).join(' · '); diff --git a/src/components/calendar/CalendarSidebarDrawer.tsx b/src/components/calendar/CalendarSidebarDrawer.tsx index f725a8f6..5900e2f1 100644 --- a/src/components/calendar/CalendarSidebarDrawer.tsx +++ b/src/components/calendar/CalendarSidebarDrawer.tsx @@ -16,7 +16,7 @@ import { } from '../icons'; import type { Calendar } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { useAnimDuration } from '../../theme/dynamic'; import { CALENDAR_COLOR_PALETTE, calendarColorName, getCalendarColor } from '../../lib/calendar-utils'; import { BIRTHDAY_CALENDAR_ID } from '../../lib/birthday-calendar'; @@ -309,6 +309,7 @@ function Section({ isSubscriptionCalendar?: (calendarId: string) => boolean; }) { const c = useColors(); + const paint = useCvdColor(); const styles = React.useMemo(() => makeStyles(c), [c]); const t = useLocaleStore((s) => s.t); // Neighbours a calendar can move past: the server calendars of this section. @@ -352,8 +353,8 @@ function Section({ style={[ styles.swatch, { - backgroundColor: visible ? getCalendarColor(cal) : 'transparent', - borderColor: getCalendarColor(cal), + backgroundColor: visible ? paint(getCalendarColor(cal)) : 'transparent', + borderColor: paint(getCalendarColor(cal)), }, ]} > diff --git a/src/components/calendar/EventCard.tsx b/src/components/calendar/EventCard.tsx index fd6f9055..0ada84b6 100644 --- a/src/components/calendar/EventCard.tsx +++ b/src/components/calendar/EventCard.tsx @@ -4,7 +4,7 @@ import { Clock, MapPin, Users } from '../icons'; import { format } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { eventTimeRange, getEventColor, @@ -38,9 +38,10 @@ function participantCount(event: CalendarEvent): number { */ export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress, day }: EventCardProps) { const c = useColors(); + const paint = useCvdColor(); const styles = React.useMemo(() => makeStyles(c), [c]); const { locale, t } = useCalendarLocale(); - const color = getEventColor(event, calendars); + const color = paint(getEventColor(event, calendars)); const range = eventTimeRange(event); const { start, end } = range; const allDay = range.allDay || (!!day && isTimedEventFullDayOnDate(event, day)); diff --git a/src/components/calendar/EventDetailSheet.tsx b/src/components/calendar/EventDetailSheet.tsx index 3a52bcc1..975b5f48 100644 --- a/src/components/calendar/EventDetailSheet.tsx +++ b/src/components/calendar/EventDetailSheet.tsx @@ -39,7 +39,7 @@ import { import { format, type Locale } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { eventTimeRange, getEventColor, @@ -140,6 +140,7 @@ export function EventDetailSheet({ onSaveNote, }: EventDetailSheetProps) { const c = useColors(); + const paint = useCvdColor(); const { locale, t } = useCalendarLocale(); const [rsvpBusy, setRsvpBusy] = React.useState(false); const [noteOpen, setNoteOpen] = React.useState(false); @@ -200,7 +201,7 @@ export function EventDetailSheet({ if (!event) return null; - const color = getEventColor(event, calendars); + const color = paint(getEventColor(event, calendars)); const calendarId = getPrimaryCalendarId(event); const calendar = calendars.find((c) => c.id === calendarId); const range = formatRange(event, timeFormat, locale); diff --git a/src/components/calendar/EventModal.tsx b/src/components/calendar/EventModal.tsx index 7c45d440..9dd5175f 100644 --- a/src/components/calendar/EventModal.tsx +++ b/src/components/calendar/EventModal.tsx @@ -36,7 +36,7 @@ import type { VirtualLocation, } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { getCalendarColor, getPrimaryCalendarId, @@ -189,6 +189,7 @@ export function EventModal({ onClose, }: EventModalProps) { const c = useColors(); + const paint = useCvdColor(); const styles = React.useMemo(() => makeStyles(c), [c]); const { locale, t } = useCalendarLocale(); const timeFormat = useSettingsStore((s) => s.calendarTimeFormat); @@ -588,7 +589,7 @@ export function EventModal({ )} @@ -614,7 +615,7 @@ export function EventModal({ {c.name} diff --git a/src/components/calendar/ICalImportSheet.tsx b/src/components/calendar/ICalImportSheet.tsx index 9ae1b344..b112c323 100644 --- a/src/components/calendar/ICalImportSheet.tsx +++ b/src/components/calendar/ICalImportSheet.tsx @@ -10,7 +10,7 @@ import { uploadBlob } from '../../api/blob'; import { parseCalendarBlob } from '../../api/calendar'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { getCalendarColor } from '../../lib/calendar-utils'; import { isWritableCalendar } from '../../lib/calendar-editability'; import { ImportRefusedError, type ImportResult } from '../../stores/calendar-store'; @@ -58,6 +58,7 @@ function eventDateLabel(event: Partial, locale: Locale): string | export function ICalImportSheet({ visible, onClose, calendars, onImport, onImported }: Props) { const c = useColors(); + const paint = useCvdColor(); const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); const t = useLocaleStore((s) => s.t); @@ -308,7 +309,7 @@ export function ICalImportSheet({ visible, onClose, calendars, onImport, onImpor onPress={() => setCalendarId(cal.id)} style={[styles.calChip, cal.id === calendarId && styles.calChipActive]} > - + {cal.name} ))} diff --git a/src/components/calendar/ICalSubscriptionSheet.tsx b/src/components/calendar/ICalSubscriptionSheet.tsx index 4350f39f..f05f87f9 100644 --- a/src/components/calendar/ICalSubscriptionSheet.tsx +++ b/src/components/calendar/ICalSubscriptionSheet.tsx @@ -7,7 +7,7 @@ import { X, Plus, RefreshCw, Trash2, Rss, AlertTriangle } from '../icons'; import { formatDistanceToNow } from 'date-fns'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { CALENDAR_COLOR_PALETTE, calendarColorName } from '../../lib/calendar-utils'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { @@ -37,6 +37,7 @@ interface Props { export function ICalSubscriptionSheet({ visible, onClose }: Props) { const c = useColors(); + const paint = useCvdColor(); const styles = React.useMemo(() => makeStyles(c), [c]); const insets = useSafeAreaInsets(); const { locale, t } = useCalendarLocale(); @@ -257,7 +258,7 @@ export function ICalSubscriptionSheet({ visible, onClose }: Props) { const busy = !!syncing[sub.id]; return ( - + startEdit(sub)}> {sub.name} diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index 0d2868f1..f9bdda85 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -10,7 +10,7 @@ import { } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { componentSizes, spacing, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { useSettingsStore } from '../../stores/settings-store'; import { buildEventDayIndex, @@ -157,6 +157,7 @@ function MonthWeekRowInner({ const { width } = useWindowDimensions(); // "Light event colors" (webmail calendarTintedEvents). const tinted = useSettingsStore((s) => s.calendarTintedEvents); + const paint = useCvdColor(); const chipTime = monthChipShowsTime(true, width / 7); if (variant === 'full') { return ( @@ -241,7 +242,7 @@ function MonthWeekRowInner({ {visible.map((event, idx) => ( ))} {overflow > 0 && ( @@ -255,7 +256,7 @@ function MonthWeekRowInner({ // Solid bars in the calendar colour; declined and // cancelled events outlined (repos/branding/APP.md). const inactive = isInactiveEvent(event, currentUserEmails); - const colors = eventBlockColors(getEventColor(event, calendars), inactive, c, tinted); + const colors = eventBlockColors(paint(getEventColor(event, calendars)), inactive, c, tinted); return ( s.calendarTintedEvents); + const paint = useCvdColor(); const showTime = monthChipShowsTime(showTimeInMonthView, width / 7); return ( @@ -371,7 +373,7 @@ function FullWeekRow({ {dayEvents.slice(0, shown).map((event, idx) => { const inactive = isInactiveEvent(event, currentUserEmails); - const colors = eventBlockColors(getEventColor(event, calendars), inactive, c, tinted); + const colors = eventBlockColors(paint(getEventColor(event, calendars)), inactive, c, tinted); // No time on events that fill the whole day. const time = showTime && !event.showWithoutTime && !isTimedEventFullDayOnDate(event, d) ? `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ` diff --git a/src/components/calendar/TasksSheet.tsx b/src/components/calendar/TasksSheet.tsx index 046502ed..0b0b7be3 100644 --- a/src/components/calendar/TasksSheet.tsx +++ b/src/components/calendar/TasksSheet.tsx @@ -27,7 +27,7 @@ import { import { format, isBefore, startOfDay } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { getCalendarColor, getTaskDueDisplayDate, timePattern, type TimeFormat } from '../../lib/calendar-utils'; import { displayNow, isDisplayToday } from '../../lib/calendar-timezone'; import { @@ -109,6 +109,7 @@ export function TasksSheet({ onDelete, }: TasksSheetProps) { const c = useColors(); + const paint = useCvdColor(); const styles = React.useMemo(() => makeStyles(c), [c]); const { locale, t } = useCalendarLocale(); const slideY = React.useRef(new Animated.Value(Dimensions.get('window').height)).current; @@ -373,7 +374,7 @@ export function TasksSheet({ onPress={() => setEditor((e) => ({ ...e, calendarId: cal.id }))} style={[styles.calChip, cal.id === editor.calendarId && styles.calChipActive]} > - + {cal.name} ))} diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index a5c3b2e0..7c3f2a05 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -18,7 +18,7 @@ import { import { differenceInCalendarDays, format, type Locale } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { useSettingsStore } from '../../stores/settings-store'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { dayNumberLabel } from '../../lib/calendar-system'; @@ -125,6 +125,7 @@ function TimeGridScrollViewInner({ draftResetKey = 0, }: TimeGridScrollViewProps) { const c = useColors(); + const paint = useCvdColor(); // "Light event colors" (webmail calendarTintedEvents). const tinted = useSettingsStore((s) => s.calendarTintedEvents); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -455,7 +456,7 @@ function TimeGridScrollViewInner({ key={`${segment.event.id}:${segment.startIndex}:${segment.row}`} title={segment.event.title || t('calendar.events.no_title', '(No title)')} colors={eventBlockColors( - getEventColor(segment.event, calendars), + paint(getEventColor(segment.event, calendars)), isInactiveEvent(segment.event, currentUserEmails), c, tinted, @@ -620,6 +621,7 @@ const DayColumn = React.memo(function DayColumn({ const { locale, t } = useCalendarLocale(); // "Light event colors" (webmail calendarTintedEvents). const tinted = useSettingsStore((s) => s.calendarTintedEvents); + const paint = useCvdColor(); const handleLongPress = React.useCallback( (e: GestureResponderEvent) => { onLongPressAt?.(day, hourAtOffset(e.nativeEvent.locationY, HOUR_HEIGHT)); @@ -650,7 +652,7 @@ const DayColumn = React.memo(function DayColumn({ ? minutesToTimeLabel(startMinutes, timeFormat) : null} colors={eventBlockColors( - getEventColor(event, calendars), + paint(getEventColor(event, calendars)), isInactiveEvent(event, currentUserEmails), colors, tinted, diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index fb47be4b..2d47f51d 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -10,7 +10,7 @@ import { addDays, format, isSameDay, startOfWeek } from 'date-fns'; import { displayNow, displayNowMinutes, isDisplayToday } from '../../lib/calendar-timezone'; import type { Calendar, CalendarEvent } from '../../api/types'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; import { useSettingsStore } from '../../stores/settings-store'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { dayNumberLabel } from '../../lib/calendar-system'; @@ -68,6 +68,7 @@ function WeekViewInner({ currentUserEmails, }: WeekViewProps) { const c = useColors(); + const paint = useCvdColor(); // "Light event colors" (webmail calendarTintedEvents). const tinted = useSettingsStore((s) => s.calendarTintedEvents); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -213,7 +214,7 @@ function WeekViewInner({ key={`${segment.event.id}:${segment.startIndex}:${segment.row}`} title={segment.event.title || t('calendar.events.no_title', '(No title)')} colors={eventBlockColors( - getEventColor(segment.event, calendars), + paint(getEventColor(segment.event, calendars)), isInactiveEvent(segment.event, currentUserEmails), c, tinted, @@ -289,7 +290,7 @@ function WeekViewInner({ ? minutesToTimeLabel(startMinutes, timeFormat) : null} colors={eventBlockColors( - getEventColor(event, calendars), + paint(getEventColor(event, calendars)), isInactiveEvent(event, currentUserEmails), c, tinted, diff --git a/src/components/calendar/__tests__/calendar-cvd-colours.test.ts b/src/components/calendar/__tests__/calendar-cvd-colours.test.ts new file mode 100644 index 00000000..e3bf4a95 --- /dev/null +++ b/src/components/calendar/__tests__/calendar-cvd-colours.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { cvdColor } from '../../../lib/cvd-palette'; + +// Checked on the source: the suite has no React Native renderer. Every place +// that draws a calendar's colour runs it through useCvdColor() first, so the +// colour-blind-safe setting recolours calendars as its description promises +// (webmail cf4d02b49). The colour pickers keep the raw colours. +const src = path.resolve(__dirname, '../../..'); +const DRAWERS = [ + 'components/calendar/EventCard.tsx', + 'components/calendar/EventDetailSheet.tsx', + 'components/calendar/MonthView.tsx', + 'components/calendar/WeekView.tsx', + 'components/calendar/TimeGridScrollView.tsx', + 'components/calendar/CalendarSearchSheet.tsx', + 'components/calendar/CalendarSidebarDrawer.tsx', + 'components/calendar/EventModal.tsx', + 'components/calendar/TasksSheet.tsx', + 'components/calendar/ICalImportSheet.tsx', + 'components/contacts/ContactActivity.tsx', + 'components/email/CalendarInvitationBanner.tsx', + 'components/email/structured-mail/booking-calendar-actions.tsx', +]; + +function colourCalls(source: string): string[] { + return source + .split(/\r?\n/) + .filter((line) => !/^\s*import\b/.test(line) && /\bget(Event|Calendar)Color\(/.test(line)) + .map((line) => line.trim()); +} + +describe('calendar colours in the colour-blind-safe palette', () => { + it.each(DRAWERS)('%s paints every calendar colour it draws', (file) => { + const source = fs.readFileSync(path.join(src, file), 'utf8'); + expect(source).toMatch(/const paint = useCvdColor\(\);/); + const calls = colourCalls(source); + expect(calls.length).toBeGreaterThan(0); + for (const line of calls) { + expect(line).toMatch(/paint\(get(Event|Calendar)Color\(/); + } + }); + + it('keeps the raw colour in the calendar editor, which is a colour picker', () => { + const source = fs.readFileSync(path.join(src, 'components/calendar/CalendarEditSheet.tsx'), 'utf8'); + expect(source).not.toMatch(/useCvdColor/); + }); + + it('paints a subscription swatch through the hook', () => { + const source = fs.readFileSync(path.join(src, 'components/calendar/ICalSubscriptionSheet.tsx'), 'utf8'); + expect(source).toMatch(/paint\(sub\.color\)/); + }); + + it('maps a calendar green to the palette green only when the setting is on', () => { + expect(cvdColor('#22c55e', false)).toBe('#22c55e'); + expect(cvdColor('#22c55e', true).toLowerCase()).not.toBe('#22c55e'); + }); +}); diff --git a/src/components/contacts/ContactActivity.tsx b/src/components/contacts/ContactActivity.tsx index 803dfdf5..b76e8219 100644 --- a/src/components/contacts/ContactActivity.tsx +++ b/src/components/contacts/ContactActivity.tsx @@ -17,7 +17,7 @@ import { singleLine } from '../../lib/single-line'; import { prefetchMessage } from '../../lib/email-detail-cache'; import SenderAvatar from '../SenderAvatar'; import { radius, spacing, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; -import { useColors } from '../../theme/colors'; +import { useColors, useCvdColor } from '../../theme/colors'; const EMAIL_LIMIT = 5; const EVENT_LIMIT = 5; @@ -92,6 +92,7 @@ interface Props { export function ContactActivity({ contact }: Props) { const c = useColors(); + const paint = useCvdColor(); const styles = React.useMemo(() => makeStyles(c), [c]); const navigation = useNavigation