Merge branch 'cu-tagsx' into catch-up-2026-11

This commit is contained in:
Linus Rath
2026-10-31 15:51:50 +01:00
48 changed files with 3559 additions and 93 deletions
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { client } = vi.hoisted(() => ({
client: {
isConnected: true,
username: 'me@example.org',
serverUrl: 'https://mail.example.org',
accountId: 'own',
currentSession: null as unknown,
request: vi.fn(),
getStoredCredentials: vi.fn(),
},
}));
vi.mock('../jmap-client', () => ({
jmapClient: client,
rewriteSessionUrl: (url: string) => url,
extractOrigin: () => null,
}));
vi.mock('../../stores/auth-store', () => ({ useAuthStore: { getState: () => ({ activeAccountId: null }) } }));
vi.mock('../../stores/account-store', () => ({ useAccountStore: { getState: () => ({ accounts: [] }) } }));
vi.mock('../../stores/email-store', () => ({ useEmailStore: { getState: () => ({ emails: [], mailboxes: [] }) } }));
vi.mock('../../stores/contacts-store', () => ({ useContactsStore: { getState: () => ({ contacts: [], addressBooks: [] }) } }));
vi.mock('../../stores/calendar-store', () => ({ useCalendarStore: { getState: () => ({ events: [], calendars: [] }) } }));
import { accountsFor, mailProvider, contactsProvider, resetGlobalSearchCache } from '../global-search';
import { parseSearchQuery } from '../../lib/global-search/query-parser';
import { generateAccountId } from '../../lib/account-utils';
import type { JMAPSession } from '../types';
const MAIL = 'urn:ietf:params:jmap:mail';
const CONTACTS = 'urn:ietf:params:jmap:contacts';
const session = {
capabilities: { 'urn:ietf:params:jmap:core': {}, [MAIL]: {}, [CONTACTS]: {} },
primaryAccounts: { [MAIL]: 'own', [CONTACTS]: 'own' },
accounts: {
own: { name: 'me@example.org', isPersonal: true, accountCapabilities: { [MAIL]: {}, [CONTACTS]: {} } },
team: { name: 'Support', isPersonal: false, accountCapabilities: { [MAIL]: {} } },
},
} as unknown as JMAPSession;
const live = {
localAccountId: generateAccountId('me@example.org', 'https://mail.example.org'),
label: 'Me', email: 'me@example.org', active: true,
};
beforeEach(() => {
resetGlobalSearchCache();
client.request.mockReset();
client.currentSession = session;
});
describe('the JMAP accounts a login searches', () => {
it('lists the own account first, then the shared ones that hold the kind', () => {
expect(accountsFor(session, MAIL).map((a) => [a.id, a.primary])).toEqual([['own', true], ['team', false]]);
expect(accountsFor(session, CONTACTS).map((a) => a.id)).toEqual(['own']);
});
});
describe('mail search of the signed-in login', () => {
it('searches every folder but Trash and Junk of each account, with snippets, in one request per account', async () => {
client.request.mockImplementation(async (calls: unknown[][]) => {
const [method, args] = calls[0] as [string, { accountId: string }];
if (method === 'Mailbox/get') {
return { methodResponses: [['Mailbox/get', { list: [{ id: `${args.accountId}-in`, role: 'inbox', name: 'Inbox' }, { id: `${args.accountId}-tr`, role: 'trash', name: 'Trash' }] }, '0']] };
}
return {
methodResponses: [
['Email/query', { ids: [`${args.accountId}-m1`] }, 'q'],
['Email/get', { list: [{ id: `${args.accountId}-m1`, threadId: 't', subject: 'Invoice', mailboxIds: { [`${args.accountId}-in`]: true } }] }, 'g'],
['SearchSnippet/get', { list: [{ emailId: `${args.accountId}-m1`, subject: '<mark>Invoice</mark>', preview: null }] }, 's'],
],
};
});
const { hits } = await mailProvider.remote(parseSearchQuery('invoice'), live, { limit: 10, signal: new AbortController().signal });
const query = client.request.mock.calls.find(([calls]) => calls[0][0] === 'Email/query')![0];
expect(query[0][1].filter).toEqual({ operator: 'AND', conditions: [{ text: 'invoice' }, { inMailboxOtherThan: ['own-tr'] }] });
expect(query.map((c: unknown[]) => c[0])).toEqual(['Email/query', 'Email/get', 'SearchSnippet/get']);
expect(hits.map((h) => [h.id, h.jmapAccountId, h.shared, h.subtitle])).toEqual([
['own-m1', 'own', false, 'Inbox'],
['team-m1', 'team', true, 'Support · Inbox'],
]);
expect(hits[0].kind === 'mail' && hits[0].snippet?.subject).toBe('<mark>Invoice</mark>');
});
});
describe('contact search of the signed-in login', () => {
it('asks ContactCard/query with the text and names the card by the id the contacts list uses', async () => {
client.request.mockResolvedValue({
methodResponses: [
['ContactCard/query', { ids: ['c1'] }, 'q'],
['ContactCard/get', { list: [{ id: 'c1', addressBookIds: { b1: true }, name: { full: 'Anna Berg' }, emails: { e: { address: 'anna@x.example' } } }] }, 'g'],
['AddressBook/get', { list: [{ id: 'b1', name: 'Personal' }] }, 'b'],
],
});
const { hits } = await contactsProvider.remote(parseSearchQuery('anna'), live, { limit: 10, signal: new AbortController().signal });
expect(client.request.mock.calls[0][0][0]).toEqual(['ContactCard/query', { accountId: 'own', filter: { text: 'anna' }, limit: 11 }, 'q']);
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ kind: 'contacts', id: 'c1', storeId: 'c1', subtitle: 'Personal · anna@x.example' });
});
});
@@ -0,0 +1,172 @@
/**
* Live checks of "Search everything" against a real Stalwart: one word finds
* a message, a contact, an event and a folder of the signed-in login, and a
* contact of a second login that is searched "detached", with its stored
* credentials, while the first stays connected.
*
* Opt-in like live-stalwart.test.ts: skipped unless `BULWARK_LIVE_JMAP`,
* `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` are set (`BULWARK_LIVE_PASS2`
* defaults to the first password).
*
* BULWARK_LIVE_JMAP=http://10.10.10.62:8120 \
* BULWARK_LIVE_USER=e2e-perf@example.org BULWARK_LIVE_PASS=… \
* BULWARK_LIVE_USER2=e2e-tablet-land@example.org \
* npx vitest run src/api/__tests__/live-stalwart-global-search.test.ts
*
* Everything the run makes is removed again, also after a failed check.
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
const { secrets } = vi.hoisted(() => ({ secrets: new Map<string, string>() }));
vi.mock('expo-secure-store', () => ({
setItemAsync: vi.fn(async (key: string, value: string) => { secrets.set(key, value); }),
getItemAsync: vi.fn(async (key: string) => secrets.get(key) ?? null),
deleteItemAsync: vi.fn(async (key: string) => { secrets.delete(key); }),
}));
import { JMAPClient, credentialsKey } from '../jmap-client';
import { CAPABILITIES } from '../types';
import { GLOBAL_SEARCH_PROVIDERS, resetGlobalSearchCache } from '../global-search';
import { runGlobalSearch } from '../../lib/global-search/run';
import { parseSearchQuery } from '../../lib/global-search/query-parser';
import type { SearchAccount, SearchOutcome } from '../../lib/global-search/types';
import { generateAccountId } from '../../lib/account-utils';
import { createFolder, deleteFileNodes } from '../files';
const SERVER = process.env.BULWARK_LIVE_JMAP;
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
const USER2 = process.env.BULWARK_LIVE_USER2;
const PASS2 = process.env.BULWARK_LIVE_PASS2 ?? PASS;
const live = SERVER && PASS && USER2 ? describe : describe.skip;
// One word nobody else has, so the full-text index finds only this run.
const TOKEN = `qz${Date.now().toString(36)}`;
type Responses = Array<[string, Record<string, any>, string]>;
let owner: JMAPClient;
let second: JMAPClient;
const made: Array<{ client: () => JMAPClient; method: string; accountId: string; id: string; using: string[] }> = [];
async function call(client: JMAPClient, calls: unknown[], using: string[]): Promise<Responses> {
return (await client.request(calls as never, using)).methodResponses as Responses;
}
async function createOne(client: JMAPClient, method: string, accountId: string, object: Record<string, unknown>, using: string[]): Promise<string> {
const [[name, body]] = await call(client, [[method, { accountId, create: { x: object } }, '0']], using);
expect(name, JSON.stringify(body)).toBe(method);
const id = body.created?.x?.id as string | undefined;
expect(id, JSON.stringify(body.notCreated)).toBeTruthy();
made.push({ client: () => client, method, accountId, id: id!, using });
return id!;
}
async function seedContact(client: JMAPClient): Promise<string> {
const accountId = client.currentSession!.primaryAccounts[CAPABILITIES.CONTACTS];
const using = [CAPABILITIES.CORE, CAPABILITIES.CONTACTS];
const [[, books]] = await call(client, [['AddressBook/get', { accountId, properties: ['id', 'isDefault'] }, '0']], using);
const book = (books.list as { id: string; isDefault?: boolean }[]).find((b) => b.isDefault) ?? books.list[0];
return createOne(client, 'ContactCard/set', accountId, {
'@type': 'Card', version: '1.0', kind: 'individual', addressBookIds: { [book.id]: true },
uid: `urn:uuid:${TOKEN}-${client.username}`,
// 0.16 finds a card by the words of its name components, not of `full`.
name: { components: [{ kind: 'given', value: 'Findme' }, { kind: 'surname', value: TOKEN }], isOrdered: true, full: `Findme ${TOKEN}` },
}, using);
}
const accountFor = (user: string, active: boolean): SearchAccount => ({
localAccountId: generateAccountId(user, SERVER!), label: user, email: user, serverUrl: SERVER, active,
});
async function search(accounts: SearchAccount[]): Promise<SearchOutcome> {
return runGlobalSearch({
parsed: parseSearchQuery(TOKEN), accounts, providers: GLOBAL_SEARCH_PROVIDERS,
localLimit: 10, remoteLimit: 25, signal: new AbortController().signal, onUpdate: () => undefined, timeoutMs: 20_000,
});
}
live('live Stalwart: search everything', () => {
let ids: { mail: string; contact: string; event: string; folder: string | null; secondContact: string };
beforeAll(async () => {
const mod = await import('../jmap-client');
owner = new JMAPClient();
await owner.connect(SERVER!, USER, PASS);
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(owner.snapshot());
second = new JMAPClient();
await second.connect(SERVER!, USER2!, PASS2);
// The second login is searched with what the app keeps in secure storage.
secrets.set(credentialsKey(generateAccountId(USER2!, SERVER!)), JSON.stringify({ serverUrl: SERVER, username: USER2, password: PASS2 }));
resetGlobalSearchCache();
const mailAccount = owner.accountId;
const mailUsing = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
const [[, drafts]] = await call(owner, [['Mailbox/query', { accountId: mailAccount, filter: { role: 'drafts' } }, '0']], mailUsing);
const mail = await createOne(owner, 'Email/set', mailAccount, {
mailboxIds: { [drafts.ids[0]]: true }, keywords: { $draft: true, $seen: true },
from: [{ email: USER }], subject: `Findme ${TOKEN}`,
bodyValues: { b: { value: 'search check' } }, textBody: [{ partId: 'b', type: 'text/plain' }],
}, mailUsing);
const contact = await seedContact(owner);
const calAccount = owner.currentSession!.primaryAccounts[CAPABILITIES.CALENDARS];
const calUsing = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
const [[, calendars]] = await call(owner, [['Calendar/get', { accountId: calAccount, properties: ['id', 'isDefault'] }, '0']], calUsing);
const calendar = (calendars.list as { id: string; isDefault?: boolean }[]).find((c) => c.isDefault) ?? calendars.list[0];
const event = await createOne(owner, 'CalendarEvent/set', calAccount, {
'@type': 'Event', calendarIds: { [calendar.id]: true }, title: `Findme ${TOKEN}`,
start: '2027-06-01T10:00:00', duration: 'PT1H', timeZone: 'Europe/Berlin',
}, calUsing);
let folder: string | null = null;
if (owner.currentSession!.capabilities[CAPABILITIES.FILES]) {
// The app's own call: 1.0 wants the draft's @type, 0.16 refuses it.
folder = (await createFolder(`findme-${TOKEN}`, null)).id;
}
const secondContact = await seedContact(second);
ids = { mail, contact, event, folder, secondContact };
}, 90_000);
afterAll(async () => {
if (ids?.folder) await deleteFileNodes([ids.folder]).catch(() => undefined);
for (const item of made.reverse()) {
await call(item.client(), [[item.method, { accountId: item.accountId, destroy: [item.id] }, '0']], item.using).catch(() => undefined);
}
}, 60_000);
it('finds the message, contact, event and folder of the signed-in login and the contact of the other one', async () => {
const accounts = [accountFor(USER, true), accountFor(USER2!, false)];
// The full-text index lags a little behind a write.
let outcome = await search(accounts);
for (let i = 0; i < 15; i++) {
const found = outcome.hits.mail.some((h) => h.id === ids.mail)
&& outcome.hits.calendar.some((h) => h.id === ids.event)
&& outcome.hits.contacts.filter((h) => h.id === ids.contact || h.id === ids.secondContact).length === 2;
if (found) break;
await new Promise((r) => setTimeout(r, 2000));
outcome = await search(accounts);
}
for (const kind of ['mail', 'contacts', 'calendar', 'files'] as const) expect(outcome.status[kind].errors, kind).toEqual([]);
const mail = outcome.hits.mail.find((h) => h.id === ids.mail);
expect(mail?.localAccountId).toBe(accounts[0].localAccountId);
expect(outcome.hits.calendar.find((h) => h.id === ids.event)?.title).toBe(`Findme ${TOKEN}`);
const contacts = outcome.hits.contacts.filter((h) => h.id === ids.contact || h.id === ids.secondContact);
expect(contacts.map((h) => h.localAccountId).sort()).toEqual(accounts.map((a) => a.localAccountId).sort());
if (ids.folder) {
const folder = outcome.hits.files.find((h) => h.id === ids.folder);
expect(folder).toMatchObject({ isFolder: true, folderPath: '/' });
}
}, 120_000);
it('keeps to the account picked with account: and the kind picked with in:', async () => {
const accounts = [accountFor(USER, true), accountFor(USER2!, false)];
const outcome = await runGlobalSearch({
parsed: parseSearchQuery(`in:contacts account:${USER2} ${TOKEN}`), accounts, providers: GLOBAL_SEARCH_PROVIDERS,
localLimit: 10, remoteLimit: 25, signal: new AbortController().signal, onUpdate: () => undefined, timeoutMs: 20_000,
});
expect(outcome.hits.mail).toEqual([]);
expect(outcome.hits.contacts.map((h) => h.localAccountId)).toEqual([accounts[1].localAccountId]);
}, 60_000);
});
@@ -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<void> {
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<string[]> {
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);
});
+86
View File
@@ -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<typeof vi.fn>;
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<string, boolean> = {}) => ({ ...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 });
});
});
+516
View File
@@ -0,0 +1,516 @@
// "Search everything": the search providers (webmail
// lib/global-search/providers/*.ts and accounts.ts).
//
// The JMAP client is one live session, so the signed-in login goes through
// it, and every other login is asked "detached": with its stored
// credentials, posting to its own API URL (as the unified inbox does), so the
// live session is never disturbed. Shared and group accounts inside a login
// are searched too.
import { jmapClient, rewriteSessionUrl, extractOrigin, type StoredCredentials } from './jmap-client';
import { CAPABILITIES, type CalendarEvent, type ContactCard, type Email, type FileNode, type JMAPMethodCall, type JMAPSession } from './types';
import { fetchWithDeadline } from './unified-inbox';
import { getAllFileNodesAcrossAccounts, fileNodeKind } from './files';
import { namespaceId } from './contacts';
import { freshStoredCredentials } from '../lib/stored-token-refresh';
import { generateAccountId } from '../lib/account-utils';
import { accountSupports } from '../lib/account-capability';
import { contactFromWire } from '../lib/contact-wire';
import { getContactDisplayName, getContactPrimaryEmail } from '../lib/contact-utils';
import { textFilter } from '../lib/search-utils';
import { snippetFilterFor, snippetHighlight, type SearchSnippetResult } from '../lib/search-snippet';
import { matchesTerms, type ParsedQuery } from '../lib/global-search/query-parser';
import {
calendarFilterFor, contactFields, emailFields, emailMatchesFilters, eventFields, isRecurringEvent,
mailFilterFor, pathOfNode, type MailboxRef,
} from '../lib/global-search/filters';
import type {
CalendarHit, ContactHit, FileHit, GlobalSearchHit, MailHit, SearchAccount, SearchProvider,
} from '../lib/global-search/types';
import { useAccountStore } from '../stores/account-store';
import { useAuthStore } from '../stores/auth-store';
import { useEmailStore } from '../stores/email-store';
import { useContactsStore } from '../stores/contacts-store';
import { useCalendarStore } from '../stores/calendar-store';
type MethodResponses = Array<[string, Record<string, any>, string]>;
const SESSION_TTL_MS = 10 * 60_000;
const MAILBOX_TTL_MS = 60_000;
/** Files have no usable server search: the listing is filtered, cached a minute. */
export const FILE_LISTING_TTL_MS = 60_000;
const MAIL_PROPERTIES = [
'id', 'threadId', 'mailboxIds', 'keywords', 'receivedAt', 'from', 'to', 'cc',
'subject', 'preview', 'hasAttachment', 'size',
];
const EVENT_PROPERTIES = [
'id', 'uid', 'calendarIds', 'title', 'description', 'start', 'duration', 'timeZone',
'showWithoutTime', 'locations', 'participants', 'recurrenceRule', 'recurrenceId',
];
const DETACHED_FILE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified', 'nodeType'];
// ── Logins ───────────────────────────────────────────────────────────────
/** Every login the search fans out to (webmail getSearchAccounts). */
export function getSearchAccounts(): SearchAccount[] {
const { accounts } = useAccountStore.getState();
const activeId = useAuthStore.getState().activeAccountId;
return accounts
.filter((a) => !a.needsSignIn)
.map((a) => ({
localAccountId: a.id,
label: a.displayName || a.email || a.username,
email: a.email || a.username,
serverUrl: a.serverUrl,
active: a.id === activeId,
}));
}
interface LoginSession {
session: JMAPSession;
post: (calls: JMAPMethodCall[], using: string[]) => Promise<MethodResponses>;
live: boolean;
}
const sessions = new Map<string, { at: number; value: Promise<LoginSession> }>();
/** Forget the detached sessions (sign-out, tests). */
export function resetGlobalSearchCache(): void {
sessions.clear();
mailboxCache.clear();
fileListings.clear();
}
function isLiveLogin(localAccountId: string): boolean {
if (!jmapClient.isConnected || !jmapClient.currentSession) return false;
const { username, serverUrl } = jmapClient;
return !!username && !!serverUrl && generateAccountId(username, serverUrl) === localAccountId;
}
function authHeader(creds: StoredCredentials): string {
if (creds.accessToken) return `Bearer ${creds.accessToken}`;
return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
}
async function openDetached(localAccountId: string): Promise<LoginSession> {
let creds = await jmapClient.getStoredCredentials(localAccountId);
if (!creds) throw new Error('No stored credentials');
creds = await freshStoredCredentials(localAccountId, creds);
const base = creds.serverUrl.replace(/\/+$/, '');
const auth = authHeader(creds);
const sessionRes = await fetchWithDeadline(`${base}/.well-known/jmap`, {
headers: { Authorization: auth, Accept: 'application/json' },
});
const session = (await sessionRes.json()) as JMAPSession;
const apiUrl = rewriteSessionUrl(session.apiUrl, extractOrigin(base)) ?? session.apiUrl;
return {
session,
live: false,
post: async (calls, using) => {
try {
const res = await fetchWithDeadline(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: auth },
body: JSON.stringify({ using, methodCalls: calls }),
});
const body = await res.json();
return (body.methodResponses ?? []) as MethodResponses;
} catch (err) {
// A dead session must not be reused for the next search.
if (err instanceof Error && err.message === 'Session expired') sessions.delete(localAccountId);
throw err;
}
},
};
}
async function loginSession(account: SearchAccount): Promise<LoginSession> {
if (isLiveLogin(account.localAccountId)) {
return {
session: jmapClient.currentSession!,
live: true,
post: async (calls, using) => (await jmapClient.request(calls, using)).methodResponses as MethodResponses,
};
}
const cached = sessions.get(account.localAccountId);
if (cached && Date.now() - cached.at < SESSION_TTL_MS) return cached.value;
const value = openDetached(account.localAccountId);
sessions.set(account.localAccountId, { at: Date.now(), value });
value.catch(() => sessions.delete(account.localAccountId));
return value;
}
/**
* The JMAP accounts of a login that hold a kind: its primary one first, then
* the shared and group accounts the user may use it with.
*/
export function accountsFor(session: JMAPSession, capability: string): { id: string; name: string; primary: boolean }[] {
const primaryId = session.primaryAccounts?.[capability] ?? session.primaryAccounts?.[CAPABILITIES.MAIL];
const out: { id: string; name: string; primary: boolean }[] = [];
if (primaryId && session.accounts?.[primaryId]) out.push({ id: primaryId, name: session.accounts[primaryId].name || primaryId, primary: true });
for (const [id, info] of Object.entries(session.accounts ?? {})) {
if (id === primaryId) continue;
if (!accountSupports(info, session.capabilities, capability)) continue;
out.push({ id, name: info.name || id, primary: false });
}
return out;
}
function responseOf(responses: MethodResponses, callId: string, method: string): Record<string, any> {
const match = responses.find((r) => r[2] === callId);
if (!match) throw new Error(`Missing response to ${method}`);
if (match[0] === 'error') throw new Error((match[1] as { description?: string; type?: string }).description || (match[1] as { type?: string }).type || `${method} failed`);
return match[1];
}
function abortIfNeeded(signal: AbortSignal): void {
if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
}
// ── Mail ─────────────────────────────────────────────────────────────────
const mailboxCache = new Map<string, { at: number; list: MailboxRef[] }>();
async function mailboxesOf(login: LoginSession, localAccountId: string, accountId: string): Promise<MailboxRef[]> {
const key = `${localAccountId}|${accountId}`;
const cached = mailboxCache.get(key);
if (cached && Date.now() - cached.at < MAILBOX_TTL_MS) return cached.list;
const res = await login.post([['Mailbox/get', { accountId, properties: ['id', 'name', 'role'] }, '0']], [CAPABILITIES.CORE, CAPABILITIES.MAIL]);
const list = (responseOf(res, '0', 'Mailbox/get').list ?? []) as MailboxRef[];
mailboxCache.set(key, { at: Date.now(), list });
return list;
}
function folderName(email: Pick<Email, 'mailboxIds'>, mailboxes: MailboxRef[]): string {
for (const id of Object.keys(email.mailboxIds ?? {})) {
const mailbox = mailboxes.find((m) => m.id === id);
if (mailbox) return mailbox.name;
}
return '';
}
function mailHit(
email: Email, account: SearchAccount, jmapAccountId: string, shared: boolean, folder: string, source: 'local' | 'remote',
): MailHit {
return {
kind: 'mail', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
id: email.id, accountLabel: account.label, title: email.subject ?? '', subtitle: folder,
date: email.receivedAt ?? null, source, email, snippet: email.searchSnippet ?? null,
};
}
/**
* One account's mail search: Email/query with the parsed filter (all
* folders but Trash and Junk), its messages, and the search snippets in the
* same request; a server without snippets still lists the messages.
*/
async function searchMailAccount(
login: LoginSession, parsed: ParsedQuery, accountId: string, mailboxes: MailboxRef[], limit: number,
): Promise<{ emails: Email[]; hasMore: boolean }> {
const filter = mailFilterFor(parsed, mailboxes);
const calls: JMAPMethodCall[] = [
['Email/query', { accountId, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit: limit + 1, collapseThreads: false }, 'q'],
['Email/get', { accountId, '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, properties: MAIL_PROPERTIES }, 'g'],
['SearchSnippet/get', { accountId, filter: snippetFilterFor(filter), '#emailIds': { resultOf: 'q', name: 'Email/query', path: '/ids' } }, 's'],
];
const res = await login.post(calls, [CAPABILITIES.CORE, CAPABILITIES.MAIL]);
const ids = (responseOf(res, 'q', 'Email/query').ids ?? []) as string[];
const list = (responseOf(res, 'g', 'Email/get').list ?? []) as Email[];
let snippets: SearchSnippetResult[] = [];
try {
snippets = (responseOf(res, 's', 'SearchSnippet/get').list ?? []) as SearchSnippetResult[];
} catch {
// Snippets only decorate the rows.
}
const byId = new Map(list.map((e) => [e.id, e]));
const emails = ids.slice(0, limit).map((id) => byId.get(id)).filter((e): e is Email => !!e).map((email) => {
const snippet = snippets.find((s) => s.emailId === email.id);
const highlight = snippet ? snippetHighlight(snippet) : null;
return highlight ? { ...email, searchSnippet: highlight } : email;
});
return { emails, hasMore: ids.length > limit };
}
export const mailProvider: SearchProvider = {
kind: 'mail',
local: (parsed, accounts, limit) => {
const account = accounts.find((a) => a.active);
if (!account) return [];
const { emails, mailboxes } = useEmailStore.getState();
let primary = '';
try { primary = jmapClient.accountId; } catch { /* no session */ }
const hits: MailHit[] = [];
for (const email of emails) {
if (!matchesTerms(parsed.terms, emailFields(email))) continue;
if (!emailMatchesFilters(email, parsed)) continue;
const mailbox = mailboxes.find((mb) => email.mailboxIds?.[mb.originalId ?? mb.id] || email.mailboxIds?.[mb.id]);
const jmapAccountId = email.jmapAccountId ?? (mailbox?.isShared && mailbox.accountId ? mailbox.accountId : primary);
hits.push(mailHit(email, account, jmapAccountId, jmapAccountId !== primary, mailbox?.name ?? '', 'local'));
if (hits.length >= limit) break;
}
return hits;
},
remote: async (parsed, account, { limit, signal }) => {
const login = await loginSession(account);
const hits: MailHit[] = [];
let hasMore = false;
// One account after the other: the run already asks the logins in parallel.
for (const target of accountsFor(login.session, CAPABILITIES.MAIL)) {
abortIfNeeded(signal);
const mailboxes = await mailboxesOf(login, account.localAccountId, target.id);
const result = await searchMailAccount(login, parsed, target.id, mailboxes, limit);
if (result.hasMore) hasMore = true;
for (const email of result.emails) {
const label = target.primary ? folderName(email, mailboxes) : [target.name, folderName(email, mailboxes)].filter(Boolean).join(' · ');
hits.push(mailHit(email, account, target.id, !target.primary, label, 'remote'));
}
}
return { hits, hasMore };
},
};
// ── Contacts ─────────────────────────────────────────────────────────────
function contactHit(
contact: ContactCard, account: SearchAccount, jmapAccountId: string, shared: boolean, storeId: string,
bookName: string, source: 'local' | 'remote',
): ContactHit {
const name = getContactDisplayName(contact);
const email = getContactPrimaryEmail(contact);
const title = name || email || contact.originalId || contact.id;
return {
kind: 'contacts', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
id: contact.originalId ?? contact.id, accountLabel: account.label, title,
subtitle: [bookName, email && email !== title ? email : ''].filter(Boolean).join(' · '),
date: contact.updated ?? null, source, contact, storeId,
};
}
export const contactsProvider: SearchProvider = {
kind: 'contacts',
local: (parsed, accounts, limit) => {
const account = accounts.find((a) => a.active);
if (!account) return [];
const { contacts, addressBooks } = useContactsStore.getState();
const primary = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CONTACTS] ?? '';
const hits: ContactHit[] = [];
for (const contact of contacts) {
if (!matchesTerms(parsed.terms, contactFields(contact, getContactDisplayName(contact)))) continue;
const bookId = Object.keys(contact.addressBookIds ?? {})[0];
const book = addressBooks.find((b) => b.id === bookId || b.originalId === bookId);
const jmapAccountId = contact.accountId ?? primary;
hits.push(contactHit(contact, account, jmapAccountId, !!contact.isShared, contact.id, book?.name ?? '', 'local'));
if (hits.length >= limit) break;
}
return hits;
},
remote: async (parsed, account, { limit, signal }) => {
const login = await loginSession(account);
const hits: ContactHit[] = [];
let hasMore = false;
for (const target of accountsFor(login.session, CAPABILITIES.CONTACTS)) {
abortIfNeeded(signal);
if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.CONTACTS)) continue;
const res = await login.post([
['ContactCard/query', { accountId: target.id, filter: textFilter(parsed.text), limit: limit + 1 }, 'q'],
['ContactCard/get', { accountId: target.id, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'],
['AddressBook/get', { accountId: target.id, properties: ['id', 'name'] }, 'b'],
], [CAPABILITIES.CORE, CAPABILITIES.CONTACTS]);
const ids = (responseOf(res, 'q', 'ContactCard/query').ids ?? []) as string[];
const list = ((responseOf(res, 'g', 'ContactCard/get').list ?? []) as ContactCard[]).map((c) => contactFromWire(c));
let books: { id: string; name: string }[] = [];
try { books = responseOf(res, 'b', 'AddressBook/get').list ?? []; } catch { /* names only */ }
if (ids.length > limit) hasMore = true;
for (const contact of list.slice(0, limit)) {
const bookId = Object.keys(contact.addressBookIds ?? {})[0];
const bookName = books.find((b) => b.id === bookId)?.name ?? '';
const storeId = target.primary ? contact.id : namespaceId(target.id, contact.id);
const shown: ContactCard = target.primary ? contact : { ...contact, id: storeId, originalId: contact.id, accountId: target.id, isShared: true };
hits.push(contactHit(shown, account, target.id, !target.primary, storeId,
target.primary ? bookName : [target.name, bookName].filter(Boolean).join(' · '), 'remote'));
}
}
return { hits, hasMore };
},
};
// ── Calendar ─────────────────────────────────────────────────────────────
function eventHit(
event: CalendarEvent, account: SearchAccount, jmapAccountId: string, shared: boolean, calendarName: string, source: 'local' | 'remote',
): CalendarHit {
const location = Object.values(event.locations ?? {}).find((l) => l?.name)?.name ?? '';
return {
kind: 'calendar', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
id: event.originalId ?? event.id, accountLabel: account.label, title: event.title || '',
subtitle: [calendarName, location].filter(Boolean).join(' · '),
date: event.start ?? null, source, event, isRecurring: isRecurringEvent(event),
};
}
/** Stalwart names it recurrenceRule (singular); the app's type has the plural. */
function eventFromWire(raw: Record<string, any>): CalendarEvent {
const rules = raw.recurrenceRules ?? (raw.recurrenceRule ? [raw.recurrenceRule] : null);
return { ...raw, recurrenceRules: rules } as CalendarEvent;
}
export const calendarProvider: SearchProvider = {
kind: 'calendar',
local: (parsed, accounts, limit) => {
const account = accounts.find((a) => a.active);
if (!account) return [];
const { events, calendars } = useCalendarStore.getState();
const primary = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CALENDARS] ?? '';
const hits: CalendarHit[] = [];
// The store holds occurrences; a matching series shows once.
const seen = new Set<string>();
for (const event of events) {
if (!matchesTerms(parsed.terms, eventFields(event))) continue;
const day = event.start?.slice(0, 10);
if (parsed.after && day && day < parsed.after) continue;
if (parsed.before && day && day > parsed.before) continue;
if (event.uid) {
if (seen.has(event.uid)) continue;
seen.add(event.uid);
}
const calendarId = Object.keys(event.calendarIds ?? {})[0];
const calendar = calendars.find((c) => c.id === calendarId);
const jmapAccountId = event.accountId ?? primary;
hits.push(eventHit(event, account, jmapAccountId, !!event.isShared, calendar?.name ?? '', 'local'));
if (hits.length >= limit) break;
}
return hits;
},
remote: async (parsed, account, { limit, signal }) => {
const login = await loginSession(account);
const hits: CalendarHit[] = [];
let hasMore = false;
for (const target of accountsFor(login.session, CAPABILITIES.CALENDARS)) {
abortIfNeeded(signal);
if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.CALENDARS)) continue;
const res = await login.post([
['CalendarEvent/query', { accountId: target.id, filter: calendarFilterFor(parsed), sort: [{ property: 'start', isAscending: false }], limit }, 'q'],
['CalendarEvent/get', { accountId: target.id, '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, properties: EVENT_PROPERTIES }, 'g'],
['Calendar/get', { accountId: target.id, properties: ['id', 'name'] }, 'c'],
], [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]);
const ids = (responseOf(res, 'q', 'CalendarEvent/query').ids ?? []) as string[];
const list = ((responseOf(res, 'g', 'CalendarEvent/get').list ?? []) as Record<string, any>[]).map(eventFromWire);
let calendars: { id: string; name: string }[] = [];
try { calendars = responseOf(res, 'c', 'Calendar/get').list ?? []; } catch { /* names only */ }
if (ids.length >= limit) hasMore = true;
for (const event of list) {
const calendarId = Object.keys(event.calendarIds ?? {})[0];
const name = calendars.find((c) => c.id === calendarId)?.name ?? '';
hits.push(eventHit(event, account, target.id, !target.primary,
target.primary ? name : [target.name, name].filter(Boolean).join(' · '), 'remote'));
}
}
return { hits, hasMore };
},
};
// ── Files ────────────────────────────────────────────────────────────────
interface FileListing {
at: number;
nodes: FileNode[] | null;
promise: Promise<FileNode[]>;
}
const fileListings = new Map<string, FileListing>();
/** Raw server id of a node (shared ones are listed as `<owner>:<id>`). */
export function rawFileNodeId(node: FileNode): string {
if (node.isShared && node.accountId && node.id.startsWith(`${node.accountId}:`)) return node.id.slice(node.accountId.length + 1);
return node.id;
}
async function listDetachedFiles(login: LoginSession): Promise<FileNode[]> {
const all: FileNode[] = [];
for (const target of accountsFor(login.session, CAPABILITIES.FILES)) {
if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.FILES)) continue;
try {
const res = await login.post([['FileNode/get', { accountId: target.id, ids: null, properties: DETACHED_FILE_PROPERTIES }, '0']], [CAPABILITIES.CORE, CAPABILITIES.FILES]);
for (const node of (responseOf(res, '0', 'FileNode/get').list ?? []) as FileNode[]) {
all.push(target.primary ? node : {
...node,
id: `${target.id}:${node.id}`,
parentId: node.parentId == null ? null : `${target.id}:${node.parentId}`,
accountId: target.id, accountName: target.name, isShared: true,
});
}
} catch (err) {
if (target.primary) throw err;
}
}
return all;
}
function fileListing(account: SearchAccount, login: LoginSession): FileListing {
const cached = fileListings.get(account.localAccountId);
if (cached && Date.now() - cached.at < FILE_LISTING_TTL_MS) return cached;
const entry: FileListing = {
at: Date.now(),
nodes: null,
promise: (login.live ? getAllFileNodesAcrossAccounts() : listDetachedFiles(login)).then((nodes) => {
entry.nodes = nodes;
return nodes;
}, (err) => {
// A failed listing must not be served as "no files" for a minute.
if (fileListings.get(account.localAccountId) === entry) fileListings.delete(account.localAccountId);
throw err;
}),
};
fileListings.set(account.localAccountId, entry);
return entry;
}
function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAccount, limit: number, source: 'local' | 'remote', primary: string): FileHit[] {
const hits: FileHit[] = [];
for (const node of nodes) {
if (node.discoverableOnly) continue;
const kind = fileNodeKind(node);
if (kind === 'symlink') continue;
if (!matchesTerms(parsed.terms, [node.name, kind === 'directory' ? 'folder' : node.type])) continue;
const folderPath = pathOfNode(nodes, node);
hits.push({
kind: 'files', serverUrl: account.serverUrl, localAccountId: account.localAccountId,
jmapAccountId: node.accountId ?? primary, shared: !!node.isShared,
id: rawFileNodeId(node), accountLabel: account.label, title: node.name,
subtitle: [node.isShared && node.accountName ? node.accountName : '', folderPath].filter(Boolean).join(' · '),
date: node.modified || node.created || null, source, node, folderPath, isFolder: kind === 'directory',
});
if (hits.length >= limit) break;
}
return hits;
}
export const filesProvider: SearchProvider = {
kind: 'files',
local: (parsed, accounts, limit) => {
const hits: FileHit[] = [];
for (const account of accounts) {
const nodes = fileListings.get(account.localAccountId)?.nodes;
if (!nodes) continue;
hits.push(...filterNodes(parsed, nodes, account, limit - hits.length, 'local', ''));
if (hits.length >= limit) break;
}
return hits;
},
remote: async (parsed, account, { limit, signal }) => {
const login = await loginSession(account);
if (accountsFor(login.session, CAPABILITIES.FILES).length === 0 || !login.session.capabilities?.[CAPABILITIES.FILES]) {
return { hits: [], hasMore: false };
}
const nodes = await fileListing(account, login).promise;
abortIfNeeded(signal);
const primary = login.session.primaryAccounts?.[CAPABILITIES.FILES] ?? '';
const hits = filterNodes(parsed, nodes, account, limit + 1, 'remote', primary);
return { hits: hits.slice(0, limit), hasMore: hits.length > limit };
},
};
export const GLOBAL_SEARCH_PROVIDERS: readonly SearchProvider[] = [mailProvider, contactsProvider, calendarProvider, filesProvider];
export type { GlobalSearchHit };
+62
View File
@@ -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<Record<string, ShareMailboxRights> | 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<string, ShareMailboxRights> | 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<void> {
const res = await jmapClient.request(
[['Mailbox/set', {
accountId: accountId || jmapClient.accountId,
update: { [mailboxId]: { [`shareWith/${principalId}`]: rights } },
}, '0']],
USING,
);
const result = requireMethodResult<{
updated?: Record<string, unknown> | null;
notUpdated?: Record<string, { description?: string; type?: string }>;
}>(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');
}
}
+80 -13
View File
@@ -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<NativeStackNavigationProp<RootStackParamList>>();
const [foldersExpanded, setFoldersExpanded] = React.useState(true);
const [tagsExpanded, setTagsExpanded] = React.useState(true);
const [unifiedExpanded, setUnifiedExpanded] = React.useState(false);
const [expandedFolders, setExpandedFolders] = React.useState<Set<string>>(() => 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<Set<string>>(() => 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
)}
<Text style={styles.sectionHeaderText}>{t('sidebar.tags', 'Tags')}</Text>
</Pressable>
{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 (
<SidebarRow
key={kw.id}
key={node.id}
icon={<Tag size={16} color={dot} fill={dot} />}
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) && (
<SidebarRow
icon={<MoreHorizontal size={16} color={c.textMuted} />}
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={() => {}}
/>
)}
</>
)}
</ScrollView>
@@ -1264,6 +1329,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onClose={() => setPrompt(null)}
/>
)}
<MailboxShareSheet mailbox={sharing?.mailbox ?? null} name={sharing?.name} onClose={() => setSharing(null)} />
</View>
);
}
@@ -1299,6 +1365,7 @@ export default function SidebarDrawer({ visible, onClose, docked = false }: Side
onClose={() => setPrompt(null)}
/>
)}
<MailboxShareSheet mailbox={sharing?.mailbox ?? null} name={sharing?.name} onClose={() => setSharing(null)} />
</SafeAreaModal>
);
}
+18 -4
View File
@@ -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 ? (
<Text style={styles.empty}>{t('email_list.no_tags_defined', 'No tags defined. Add tags in Settings.')}</Text>
) : (
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
<Pressable
key={kw.id}
onPress={() => onToggle(token, !applied)}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
style={({ pressed }) => [styles.row, tagRowIndent(kw.depth), pressed && styles.rowPressed]}
>
<Tag size={16} color={dot} fill={dot} />
<Text style={styles.rowLabel} numberOfLines={1}>{kw.label}</Text>
<Text style={styles.rowLabel} numberOfLines={1}>{treeRowLabel(kw, keywords, nested)}</Text>
{applied && <Check size={16} color={c.primary} />}
</Pressable>
);
@@ -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)' },
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// Checked on the source: the suite has no React Native renderer.
const read = (file: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', file), 'utf8');
describe('Search everything', () => {
it('opens from the mail search field with what was typed', () => {
const source = read('screens/EmailListScreen.tsx');
expect(source).toMatch(/testID="search-everything"/);
expect(source).toMatch(/setGlobalSearchOpen\(true\)/);
expect(source).toMatch(/<GlobalSearchSheet\s+visible=\{globalSearchOpen\}\s+initialQuery=\{searchInput\}/);
});
it('searches every kind of every login and opens a hit in its account', () => {
const source = read('components/search/GlobalSearchSheet.tsx');
expect(source).toMatch(/providers: GLOBAL_SEARCH_PROVIDERS/);
expect(source).toMatch(/accounts: getSearchAccounts/);
expect(source).toMatch(/openHit\(hit, navigation, allHits\)/);
// Scope and account chips, as in the webmail palette.
expect(source).toMatch(/global_search\.scope_files/);
expect(source).toMatch(/global_search\.all_accounts/);
});
});
@@ -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(/<MailboxShareSheet /g)).toHaveLength(2);
});
it('reads and writes the share through the folder sharing calls, with the folder presets', () => {
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\)/);
});
});
@@ -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\)/);
});
});
@@ -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(' · ');
@@ -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)),
},
]}
>
+3 -2
View File
@@ -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));
+3 -2
View File
@@ -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);
+4 -3
View File
@@ -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({
<View
style={[
styles.calendarSwatch,
{ backgroundColor: getCalendarColor(selectedCalendar) },
{ backgroundColor: paint(getCalendarColor(selectedCalendar)) },
]}
/>
)}
@@ -614,7 +615,7 @@ export function EventModal({
<View
style={[
styles.calendarSwatch,
{ backgroundColor: getCalendarColor(c) },
{ backgroundColor: paint(getCalendarColor(c)) },
]}
/>
<Text style={styles.popoverRowText}>{c.name}</Text>
+3 -2
View File
@@ -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<CalendarEvent>, 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]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<View style={[styles.calSwatch, { backgroundColor: paint(getCalendarColor(cal)) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
@@ -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 (
<View key={sub.id} style={styles.subRow}>
<View style={[styles.subSwatch, { backgroundColor: sub.color || c.primary }]} />
<View style={[styles.subSwatch, { backgroundColor: sub.color ? paint(sub.color) : c.primary }]} />
<Pressable style={{ flex: 1, minWidth: 0 }} onPress={() => startEdit(sub)}>
<Text style={styles.subName} numberOfLines={1}>{sub.name}</Text>
<Text style={styles.subMeta} numberOfLines={1}>
+6 -4
View File
@@ -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) => (
<View
key={`${event.id}-${idx}`}
style={[styles.dot, { backgroundColor: getEventColor(event, calendars) }]}
style={[styles.dot, { backgroundColor: paint(getEventColor(event, calendars)) }]}
/>
))}
{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 (
<View
key={`${event.id}-${idx}`}
@@ -331,6 +332,7 @@ function FullWeekRow({
const { t, system } = useCalendarLocale();
// "Light event colors" (webmail calendarTintedEvents).
const tinted = useSettingsStore((s) => s.calendarTintedEvents);
const paint = useCvdColor();
const showTime = monthChipShowsTime(showTimeInMonthView, width / 7);
return (
<View style={[styles.fullWeekRow, { height }]}>
@@ -371,7 +373,7 @@ function FullWeekRow({
</View>
{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 })} `
+3 -2
View File
@@ -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]}
>
<View style={[styles.calSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<View style={[styles.calSwatch, { backgroundColor: paint(getCalendarColor(cal)) }]} />
<Text style={styles.calChipText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
@@ -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,
+4 -3
View File
@@ -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,
@@ -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');
});
});
+3 -2
View File
@@ -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<Nav>();
const t = useLocaleStore((s) => s.t);
@@ -254,7 +255,7 @@ export function ContactActivity({ contact }: Props) {
upcomingEvents.map((event) => (
<View key={event.id} style={styles.eventRow}>
<View
style={[styles.eventDot, { backgroundColor: getEventColor(event, calendars) }]}
style={[styles.eventDot, { backgroundColor: paint(getEventColor(event, calendars)) }]}
/>
<Text style={styles.eventTime}>{formatEventTime(event, t)}</Text>
<View style={styles.eventInfo}>
@@ -8,7 +8,7 @@ import {
import { format, isSameDay } from 'date-fns';
import type { Calendar, Email, CalendarEvent } from '../../api/types';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useColors, useCvdColor } from '../../theme/colors';
import { fetchCalendarBlobText, findEventsByUid, parseCalendarBlob } from '../../api/calendar';
import { useCalendarStore } from '../../stores/calendar-store';
import { useSettingsStore } from '../../stores/settings-store';
@@ -72,6 +72,7 @@ function trustReasonText(
export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
const c = useColors();
const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
@@ -390,7 +391,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
{state !== 'done' && !existing && targetCalendar && candidates.length > 1 && (
<View>
<Pressable style={styles.calendarPicker} onPress={() => setPickerOpen((v) => !v)}>
<View style={[styles.calendarSwatch, { backgroundColor: getCalendarColor(targetCalendar) }]} />
<View style={[styles.calendarSwatch, { backgroundColor: paint(getCalendarColor(targetCalendar)) }]} />
<Text style={styles.calendarPickerText} numberOfLines={1}>{targetCalendar.name}</Text>
<ChevronDown size={14} color={c.textMuted} />
</Pressable>
@@ -402,7 +403,7 @@ export function CalendarInvitationBanner({ email, jmapAccountId }: Props) {
style={[styles.calendarRow, cal.id === targetCalendar.id && styles.calendarRowActive]}
onPress={() => { setCalendarId(cal.id); setPickerOpen(false); }}
>
<View style={[styles.calendarSwatch, { backgroundColor: getCalendarColor(cal) }]} />
<View style={[styles.calendarSwatch, { backgroundColor: paint(getCalendarColor(cal)) }]} />
<Text style={styles.calendarPickerText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
+9 -2
View File
@@ -12,7 +12,8 @@ import { useColors } from '../../theme/colors';
import SenderAvatar from '../SenderAvatar';
import { useSettingsStore } from '../../stores/settings-store';
import { useLocaleStore } from '../../stores/locale-store';
import { useAccountKeywordDefs } from '../../stores/keywords-store';
import { useAccountKeywordDefs, useKeywordsStore } from '../../stores/keywords-store';
import { formatKeyword } from '../../lib/keyword-nesting';
import { emailDisplayDate, formatHeaderDate, formatHeaderTime, formatFullDateTime } from '../../lib/email-date';
import {
deriveHeaderInfo, deliveryDeltaMs, formatDelta, isAuthenticationSpoofed, findReceivingIdentity, viaIdentityNameShown,
@@ -154,7 +155,13 @@ export function MessageHeader({ email, identities, headerInfo, onToggleStar, onA
return null;
}, [spoofed, identities, from?.email, email]);
const tags = React.useMemo(() => resolveEmailTags(email.keywords, keywordDefs), [keywordDefs, email.keywords]);
// A nested tag is named by its path, "Work/Clients" (webmail tag badge).
const nestedTags = useKeywordsStore((s) => s.nested);
const tags = React.useMemo(
() => resolveEmailTags(email.keywords, keywordDefs)
.map((kw) => ({ ...kw, label: formatKeyword(kw.id, keywordDefs, nestedTags) })),
[keywordDefs, email.keywords, nestedTags],
);
const important = !!email.keywords?.$important;
const answered = !!email.keywords?.$answered;
const forwarded = !!email.keywords?.$forwarded;
@@ -11,7 +11,7 @@ import { getCalendarColor } from '../../../lib/calendar-utils';
import type { StructuredCard } from '../../../lib/structured-mail';
import type { RootStackParamList } from '../../../navigation/types';
import { useLocaleStore } from '../../../stores/locale-store';
import { useColors } from '../../../theme/colors';
import { useColors, useCvdColor } from '../../../theme/colors';
import { radius, spacing, typography, type ThemePalette } from '../../../theme/tokens';
import { ArrowRight, CalendarCheck, CalendarDays, CalendarPlus, CalendarX, ChevronDown } from '../../icons';
import { useBookingCalendar } from './use-booking-calendar';
@@ -27,6 +27,7 @@ type Nav = NativeStackNavigationProp<RootStackParamList>;
*/
export function BookingCalendarActions({ booking, email }: { booking: StructuredCard; email: Pick<Email, 'from' | 'subject'> }) {
const c = useColors();
const paint = useCvdColor();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const navigation = useNavigation<Nav>();
@@ -144,7 +145,7 @@ export function BookingCalendarActions({ booking, email }: { booking: Structured
accessibilityLabel={cal.name}
testID="booking-calendar-choice"
>
<View style={[styles.swatch, { backgroundColor: getCalendarColor(cal) }]} />
<View style={[styles.swatch, { backgroundColor: paint(getCalendarColor(cal)) }]} />
<Text style={styles.pickerText} numberOfLines={1}>{cal.name}</Text>
</Pressable>
))}
+340
View File
@@ -0,0 +1,340 @@
import React from 'react';
import { ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { AlertTriangle, ArrowLeft, CalendarDays, Clock, FileText, Folder, Repeat, Search, X } from '../icons';
import SenderAvatar from '../SenderAvatar';
import { SafeAreaModal } from '../SafeAreaModal';
import { SearchSnippetText } from '../email/SearchSnippetText';
import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useLocaleStore } from '../../stores/locale-store';
import { useAccountStore } from '../../stores/account-store';
import { useSearchHistoryStore } from '../../stores/search-history-store';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { GLOBAL_SEARCH_PROVIDERS, getSearchAccounts } from '../../api/global-search';
import { SEARCH_KINDS, type SearchKind, type SearchScope } from '../../lib/global-search/query-parser';
import { useGlobalSearch } from '../../lib/global-search/use-global-search';
import { openHit, type HitNavigation } from '../../lib/global-search/open-hit';
import type { GlobalSearchHit } from '../../lib/global-search/types';
import { getContactDisplayName, getContactPrimaryEmail } from '../../lib/contact-utils';
export const SHEET_LOCAL_LIMIT = 10;
export const SHEET_REMOTE_LIMIT = 25;
/** Rows per kind before "Show all results" (the webmail palette shows five). */
export const ROWS_PER_KIND = 5;
/** The rows a kind shows: the first five, or all once its "Show all" was tapped. */
export function visibleHits(hits: GlobalSearchHit[], expanded: boolean): GlobalSearchHit[] {
return expanded ? hits : hits.slice(0, ROWS_PER_KIND);
}
interface GlobalSearchSheetProps {
visible: boolean;
/** What the search starts with (the mail search field's text). */
initialQuery?: string;
onClose: () => void;
navigation: HitNavigation;
}
function formatHitDate(iso: string, locale: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const sameYear = date.getFullYear() === new Date().getFullYear();
try {
return new Intl.DateTimeFormat(locale, sameYear ? { month: 'short', day: 'numeric' } : { year: 'numeric', month: 'short', day: 'numeric' }).format(date);
} catch {
return date.toDateString();
}
}
/**
* "Search everything" (webmail components/global-search/global-search-palette.tsx):
* one query across mail, contacts, calendar events and files of every
* signed-in account. What is already loaded shows while typing; the
* servers' answers merge in per kind as each account answers. A tap opens
* the hit in its own account.
*/
export function GlobalSearchSheet({ visible, initialQuery = '', onClose, navigation }: GlobalSearchSheetProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const locale = useLocaleStore((s) => s.locale);
const accounts = useAccountStore((s) => s.accounts);
const recentSearches = useSearchHistoryStore((s) => s.recentSearches);
const addRecentSearch = useSearchHistoryStore((s) => s.addRecentSearch);
const [query, setQuery] = React.useState(initialQuery);
const [scope, setScope] = React.useState<SearchScope>('all');
const [accountId, setAccountId] = React.useState<string | null>(null);
const [expanded, setExpanded] = React.useState<Set<SearchKind>>(() => new Set());
const pending = React.useRef<GlobalSearchHit | null>(null);
React.useEffect(() => {
if (!visible) return;
setQuery(initialQuery);
setExpanded(new Set());
}, [visible, initialQuery]);
React.useEffect(() => { setExpanded(new Set()); }, [query, scope, accountId]);
const { parsed, outcome, isSearching } = useGlobalSearch({
query, scope, accountId,
localLimit: SHEET_LOCAL_LIMIT,
remoteLimit: SHEET_REMOTE_LIMIT,
enabled: visible,
providers: GLOBAL_SEARCH_PROVIDERS,
accounts: getSearchAccounts,
});
const trimmed = query.trim();
const kinds = SEARCH_KINDS.filter((kind) => parsed.scope === 'all' || parsed.scope === kind);
const hasAnyHit = kinds.some((kind) => outcome.hits[kind].length > 0);
const allHits = kinds.flatMap((kind) => outcome.hits[kind]);
const errors = trimmed ? kinds.flatMap((kind) => outcome.status[kind].errors) : [];
const showRecent = !trimmed && recentSearches.length > 0;
const open = (hit: GlobalSearchHit) => {
if (trimmed) addRecentSearch(trimmed);
// iOS presents the next screen only once this modal is gone.
if (Platform.OS === 'ios') {
pending.current = hit;
onClose();
return;
}
onClose();
void openHit(hit, navigation, allHits);
};
const scopes: { key: SearchScope; label: string }[] = [
{ key: 'all', label: t('global_search.scope_all', 'All') },
{ key: 'mail', label: t('global_search.scope_mail', 'Mail') },
{ key: 'contacts', label: t('global_search.scope_contacts', 'Contacts') },
{ key: 'calendar', label: t('global_search.scope_calendar', 'Calendar') },
{ key: 'files', label: t('global_search.scope_files', 'Files') },
];
const kindLabel = (kind: SearchKind) => scopes.find((s) => s.key === kind)?.label ?? kind;
const renderLeading = (hit: GlobalSearchHit) => {
if (hit.kind === 'mail') {
const from = hit.email.from?.[0];
return <SenderAvatar name={from?.name} email={from?.email} size={28} />;
}
if (hit.kind === 'contacts') {
return <SenderAvatar name={getContactDisplayName(hit.contact) || undefined} email={getContactPrimaryEmail(hit.contact) || undefined} size={28} />;
}
const Icon = hit.kind === 'calendar' ? CalendarDays : hit.isFolder ? Folder : FileText;
return (
<View style={styles.iconChip}>
<Icon size={14} color={c.textSecondary} />
</View>
);
};
const renderRow = (hit: GlobalSearchHit) => {
const title = hit.title || (hit.kind === 'mail' ? t('global_search.no_subject', '(no subject)') : hit.id);
const subtitle = [hit.accountLabel, hit.subtitle].filter(Boolean).join(' · ');
const snippet = hit.kind === 'mail' && hit.snippet?.preview ? hit.snippet.preview : null;
return (
<Pressable
key={`${hit.kind}:${hit.localAccountId}:${hit.jmapAccountId}:${hit.id}`}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => open(hit)}
accessibilityRole="button"
accessibilityLabel={[title, subtitle].filter(Boolean).join(', ')}
>
{renderLeading(hit)}
<View style={styles.rowText}>
<View style={styles.rowTitleLine}>
<Text style={styles.rowTitle} numberOfLines={1}>
{hit.kind === 'mail' && hit.snippet?.subject ? <SearchSnippetText snippet={hit.snippet.subject} /> : title}
</Text>
{hit.kind === 'calendar' && hit.isRecurring ? (
<View accessible accessibilityLabel={t('global_search.recurring', 'Recurring')}>
<Repeat size={12} color={c.textMuted} />
</View>
) : null}
{hit.date ? <Text style={styles.rowDate}>{formatHitDate(hit.date, locale)}</Text> : null}
</View>
{subtitle ? <Text style={styles.rowSub} numberOfLines={1}>{subtitle}</Text> : null}
{snippet ? <Text style={styles.rowSub} numberOfLines={1}><SearchSnippetText snippet={snippet} /></Text> : null}
</View>
</Pressable>
);
};
const renderGroup = (kind: SearchKind) => {
const hits = outcome.hits[kind];
const status = outcome.status[kind];
if (hits.length === 0 && status.status !== 'loading') return null;
const isExpanded = expanded.has(kind);
return (
<View key={kind} style={styles.group} accessibilityRole="list" accessibilityLabel={kindLabel(kind)}>
<View style={styles.groupHeader}>
<Text style={styles.groupTitle}>{kindLabel(kind)}</Text>
<View style={styles.countPill}><Text style={styles.countText}>{hits.length}</Text></View>
{status.status === 'loading' ? <ActivityIndicator size="small" color={c.textMuted} /> : null}
</View>
{visibleHits(hits, isExpanded).map(renderRow)}
{!isExpanded && hits.length > ROWS_PER_KIND ? (
<Pressable
style={({ pressed }) => [styles.showAll, pressed && styles.rowPressed]}
onPress={() => setExpanded((prev) => new Set(prev).add(kind))}
accessibilityRole="button"
>
<Text style={styles.showAllText}>{t('global_search.show_all', 'Show all results')}</Text>
</Pressable>
) : null}
</View>
);
};
const chip = (key: string, label: string, selected: boolean, onPress: () => void) => (
<Pressable
key={key}
onPress={onPress}
style={[styles.chip, selected && styles.chipActive]}
accessibilityRole="button"
accessibilityState={{ selected }}
>
<Text style={[styles.chipText, selected && styles.chipTextActive]}>{label}</Text>
</Pressable>
);
return (
<SafeAreaModal
supportedOrientations={MODAL_ORIENTATIONS}
visible={visible}
animationType="slide"
presentationStyle="fullScreen"
onRequestClose={onClose}
onDismiss={() => {
const hit = pending.current;
pending.current = null;
if (hit) void openHit(hit, navigation, allHits);
}}
>
<SafeAreaView style={styles.container} edges={['top', 'bottom', 'left', 'right']}>
<View style={styles.header}>
<Pressable onPress={onClose} hitSlop={8} style={styles.headerBtn} accessibilityRole="button" accessibilityLabel={t('common.close', 'Close')}>
<ArrowLeft size={20} color={c.text} />
</Pressable>
<View style={styles.field}>
<Search size={16} color={c.textMuted} />
<TextInput
value={query}
onChangeText={setQuery}
placeholder={t('global_search.placeholder', 'Search mail, contacts, calendar, files...')}
placeholderTextColor={c.mutedForeground}
style={styles.input}
autoFocus
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
onSubmitEditing={() => { if (trimmed) addRecentSearch(trimmed); }}
accessibilityLabel={t('global_search.title', 'Search everything')}
/>
{isSearching ? <ActivityIndicator size="small" color={c.textMuted} /> : null}
{query ? (
<Pressable onPress={() => setQuery('')} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('contacts.clear_search', 'Clear search')}>
<X size={16} color={c.textMuted} />
</Pressable>
) : null}
</View>
</View>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.chips} keyboardShouldPersistTaps="handled">
{scopes.map((s) => chip(s.key, s.label, scope === s.key, () => setScope(s.key)))}
{accounts.length > 1 ? (
<>
<View style={styles.chipDivider} />
{chip('all-accounts', t('global_search.all_accounts', 'All accounts'), accountId === null, () => setAccountId(null))}
{accounts.map((a) => chip(a.id, a.displayName || a.email || a.username, accountId === a.id, () => setAccountId(a.id)))}
</>
) : null}
</ScrollView>
<ScrollView style={styles.results} keyboardShouldPersistTaps="handled">
{showRecent ? (
<View style={styles.group}>
<View style={styles.groupHeader}>
<Text style={styles.groupTitle}>{t('global_search.recent_searches', 'Recent searches')}</Text>
</View>
{recentSearches.map((term) => (
<Pressable key={term} style={({ pressed }) => [styles.recentRow, pressed && styles.rowPressed]} onPress={() => setQuery(term)} accessibilityRole="button">
<Clock size={14} color={c.textMuted} />
<Text style={styles.recentText} numberOfLines={1}>{term}</Text>
</Pressable>
))}
</View>
) : null}
{trimmed ? kinds.map(renderGroup) : null}
{errors.map((error) => (
<View key={`${error.localAccountId}:${error.message}`} style={styles.errorRow}>
<AlertTriangle size={14} color={c.warning} />
<Text style={styles.rowSub}>{t('global_search.account_error', "Couldn't search {account}", { account: error.accountLabel })}</Text>
</View>
))}
{!trimmed && !showRecent ? (
<Text style={styles.hint}>
{t('global_search.empty_hint', 'Search mail, contacts, events and files across all accounts. Try from:, subject:, has:attachment, after:2026-01-01.')}
</Text>
) : null}
{trimmed && !hasAnyHit && !isSearching ? (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>{t('global_search.no_results', 'No results for “{query}”', { query: trimmed })}</Text>
<Text style={styles.hint}>{t('global_search.server_hint', 'Complete words search the server; partial words match already-loaded items')}</Text>
</View>
) : null}
</ScrollView>
</SafeAreaView>
</SafeAreaModal>
);
}
function makeStyles(c: ThemePalette) {
return StyleSheet.create({
container: { flex: 1, backgroundColor: c.background },
header: {
flexDirection: 'row', alignItems: 'center', gap: spacing.sm,
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
borderBottomWidth: 1, borderBottomColor: c.border,
},
headerBtn: { width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
field: {
flex: 1, flexDirection: 'row', alignItems: 'center', gap: spacing.sm,
paddingHorizontal: spacing.md, borderRadius: radius.md, backgroundColor: c.surface,
borderWidth: 1, borderColor: c.border,
},
input: { flex: 1, ...typography.body, color: c.text, paddingVertical: spacing.sm },
chips: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs, paddingHorizontal: spacing.md, paddingVertical: spacing.sm },
chip: {
paddingHorizontal: spacing.md, paddingVertical: 6, borderRadius: radius.full,
borderWidth: 1, borderColor: c.border, backgroundColor: c.surface,
},
chipActive: { backgroundColor: c.primary, borderColor: c.primary },
chipText: { ...typography.caption, color: c.text },
chipTextActive: { color: c.primaryForeground },
chipDivider: { width: 1, height: 20, backgroundColor: c.border, marginHorizontal: spacing.xs },
results: { flex: 1 },
group: { paddingVertical: spacing.xs },
groupHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.lg, paddingTop: spacing.sm, paddingBottom: spacing.xs },
groupTitle: { ...typography.captionMedium, color: c.textSecondary },
countPill: { minWidth: 18, height: 18, borderRadius: 9, paddingHorizontal: 6, alignItems: 'center', justifyContent: 'center', backgroundColor: c.muted },
countText: { fontSize: 10, fontWeight: '600', color: c.textSecondary },
row: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.md, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, minHeight: 48 },
rowPressed: { backgroundColor: c.surfaceHover },
rowText: { flex: 1, minWidth: 0 },
rowTitleLine: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
rowTitle: { ...typography.bodyMedium, color: c.text, flex: 1 },
rowDate: { ...typography.caption, color: c.textMuted },
rowSub: { ...typography.caption, color: c.textMuted, marginTop: 2 },
iconChip: { width: 28, height: 28, borderRadius: 14, alignItems: 'center', justifyContent: 'center', backgroundColor: c.muted },
showAll: { paddingHorizontal: spacing.lg, paddingVertical: spacing.sm },
showAllText: { ...typography.caption, color: c.primary },
recentRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.lg, paddingVertical: spacing.sm, minHeight: 44 },
recentText: { ...typography.body, color: c.text, flex: 1 },
errorRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingHorizontal: spacing.lg, paddingVertical: spacing.xs },
hint: { ...typography.caption, color: c.textMuted, textAlign: 'center', padding: spacing.lg },
empty: { paddingVertical: spacing.lg },
emptyTitle: { ...typography.body, color: c.text, textAlign: 'center' },
});
}
+111 -26
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, TextInput, Alert, ActivityIndicator } from 'react-native';
import { Plus, Pencil, Trash2, Check, X, RotateCcw, ScanSearch } from '../icons';
import { SettingsSection } from './settings-section';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens';
import { useColors } from '../../theme/colors';
import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../../stores/keywords-store';
import { useKeywordsStore, currentTagsLogin, type KeywordDef, type KeywordVisibility } from '../../stores/keywords-store';
import { useEmailStore } from '../../stores/email-store';
import { supportsBulwarkMetadata } from '../../api/bulwark-metadata';
import { DARK_COLORS } from '../../theme/tokens';
@@ -13,6 +13,10 @@ import { discoverKeywords } from '../../api/keyword-discovery';
import { findUnrecognizedKeywords, type UnrecognizedKeyword } from '../../lib/keyword-discovery';
import { jmapClient } from '../../api/jmap-client';
import { deleteKeywordWithUndo } from '../../lib/settings-undo';
import { composeKeywordId, MAX_KEYWORD_ID_LENGTH } from '../../lib/new-tag';
import {
formatKeyword, getKeywordVisibility, getParentKeywordId, hasChildKeywords, parentKeywordOptions,
} from '../../lib/keyword-nesting';
type Keyword = KeywordDef;
@@ -30,6 +34,8 @@ export function KeywordSettings() {
const resetDefaults = useKeywordsStore((s) => s.resetDefaults);
const hydrated = useKeywordsStore((s) => s.hydrated);
const hydrate = useKeywordsStore((s) => s.hydrate);
const nested = useKeywordsStore((s) => s.nested);
const setNested = useKeywordsStore((s) => s.setNested);
useEffect(() => { if (!hydrated) void hydrate(); }, [hydrated, hydrate]);
@@ -38,8 +44,9 @@ export function KeywordSettings() {
const saveKeyword = (kw: Keyword, editing: boolean) => {
if (editing && editingId) {
const { id, ...patch } = kw;
updateKeyword(editingId, patch);
// The id stays: it is on the messages, and this app does not move
// them to a new keyword (webmail migrateKeyword).
updateKeyword(editingId, { label: kw.label, color: kw.color });
setEditingId(null);
} else {
addKeyword(kw);
@@ -98,8 +105,25 @@ export function KeywordSettings() {
setScan((s) => ({ ...s, found: s.found.filter((f) => f.id !== kw.id) }));
};
// Sidebar visibility (webmail keyword-settings.tsx).
const visibilityOptions = [
{ value: 'show', label: t('settings.keywords.visibility.show', 'Show') },
{ value: 'unread', label: t('settings.keywords.visibility.unread', 'Show if unread') },
{ value: 'hide', label: t('settings.keywords.visibility.hide', 'Hide') },
];
return (
<SettingsSection title={t('settings.keywords.title', "Email Tags")} description={t('settings.keywords.description_mobile', "Colored tags to organize your mail.")}>
<SettingItem
label={t('settings.keywords.nesting.label', 'Nested tags')}
description={t('settings.keywords.nesting.description', 'Nest tags underneath other tags and show them as a tree in the sidebar.')}
>
<ToggleSwitch
checked={nested}
onChange={setNested}
accessibilityLabel={t('settings.keywords.nesting.label', 'Nested tags')}
/>
</SettingItem>
<View style={{ gap: spacing.sm }}>
{keywords.map((kw) => {
if (editingId === kw.id) {
@@ -108,24 +132,47 @@ export function KeywordSettings() {
key={kw.id}
initial={kw}
existingIds={keywords.filter((k) => k.id !== kw.id).map((k) => k.id)}
keywords={keywords}
nested={nested}
onSave={(k) => saveKeyword(k, true)}
onCancel={() => setEditingId(null)}
/>
);
}
const palette = (c.tagsAsPicked ?? c.tags)[kw.color];
// A tag with tags nested under it can't go first (webmail
// has_children_delete): its children would lose their parent.
const hasChildren = hasChildKeywords(kw.id, keywords);
return (
<View key={kw.id} style={styles.kwRow}>
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
<Text style={styles.kwLabel}>{kw.label}</Text>
<Text style={styles.kwId}>$label:{kw.id}</Text>
<View style={{ flexDirection: 'row', gap: 2 }}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })}>
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable style={styles.iconBtn} onPress={() => deleteKeyword(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })}>
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
<View key={kw.id} style={styles.kwCard}>
<View style={styles.kwCardTop}>
<View style={[styles.kwDot, { backgroundColor: palette.dot }]} />
<Text style={styles.kwLabel}>{formatKeyword(kw.id, keywords, nested)}</Text>
<View style={{ flexDirection: 'row', gap: 2 }}>
<Pressable style={styles.iconBtn} onPress={() => setEditingId(kw.id)} accessibilityRole="button" accessibilityLabel={t('settings.keywords.edit_tag', 'Edit {name}', { name: kw.label })}>
<Pencil size={14} color={c.mutedForeground} />
</Pressable>
<Pressable
style={[styles.iconBtn, hasChildren && { opacity: 0.4 }]}
onPress={() => deleteKeyword(kw.id)}
disabled={hasChildren}
accessibilityRole="button"
accessibilityState={{ disabled: hasChildren }}
accessibilityLabel={t('settings.keywords.delete_tag', 'Delete {name}', { name: kw.label })}
accessibilityHint={hasChildren ? t('settings.keywords.has_children_delete', 'Remove the tags nested under this one first') : undefined}
>
<Trash2 size={14} color={c.mutedForeground} />
</Pressable>
</View>
</View>
<View style={styles.kwCardBottom}>
<Text style={[styles.kwId, { flex: 1 }]} numberOfLines={1}>$label:{kw.id}</Text>
<Text style={styles.kwVisibilityLabel}>{t('settings.keywords.visibility_field', 'Sidebar visibility')}</Text>
<Select
value={getKeywordVisibility(kw)}
onChange={(value) => updateKeyword(kw.id, { visibility: value as KeywordVisibility })}
options={visibilityOptions}
/>
</View>
</View>
);
@@ -134,6 +181,8 @@ export function KeywordSettings() {
{isAdding && (
<KeywordForm
existingIds={keywords.map((k) => k.id)}
keywords={keywords}
nested={nested}
onSave={(k) => saveKeyword(k, false)}
onCancel={() => setIsAdding(false)}
/>
@@ -267,34 +316,53 @@ function SharedTagsSection() {
interface KeywordFormProps {
initial?: Keyword;
existingIds: string[];
/** Every tag, for the parent picker. */
keywords: Keyword[];
/** "Nested tags" is on: a new tag can go under another one. */
nested: boolean;
onSave: (kw: Keyword) => void;
onCancel: () => void;
}
function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProps) {
function KeywordForm({ initial, existingIds, keywords, nested, onSave, onCancel }: KeywordFormProps) {
const c = useColors();
const styles = React.useMemo(() => makeStyles(c), [c]);
const t = useLocaleStore((s) => s.t);
const [label, setLabel] = useState(initial?.label ?? '');
const [color, setColor] = useState<keyof typeof DARK_COLORS.tags>(initial?.color ?? 'blue');
const [parentId, setParentId] = useState('');
const normalizedId = label
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const isDuplicate = normalizedId.length > 0 && existingIds.includes(normalizedId);
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate;
// A new tag takes the id of its name under its parent (webmail
// KeywordEditForm). An edited tag keeps its id, which is on the messages.
const normalizedId = initial ? initial.id : composeKeywordId(parentId || null, label);
const isDuplicate = !initial && normalizedId.length > 0 && existingIds.includes(normalizedId);
const isTooLong = !initial && normalizedId.length > MAX_KEYWORD_ID_LENGTH;
const isValid = normalizedId.length > 0 && label.trim().length > 0 && !isDuplicate && !isTooLong;
const parentOptions = [
{ value: '', label: t('settings.keywords.no_parent', 'No parent') },
...parentKeywordOptions(keywords).map((o) => ({ value: o.id, label: o.label })),
];
const initialParent = initial ? getParentKeywordId(initial.id) : null;
const handleSave = () => {
if (!isValid) return;
onSave({ id: normalizedId, label: label.trim(), color });
onSave({ ...(initial ?? {}), id: normalizedId, label: label.trim(), color });
};
return (
<View style={styles.form}>
{nested && !initial && keywords.length > 0 && (
<View>
<Text style={styles.formLabel}>{t('settings.keywords.parent_field', 'Parent tag')}</Text>
<Select value={parentId} onChange={setParentId} options={parentOptions} />
</View>
)}
{nested && initial && initialParent && (
<View>
<Text style={styles.formLabel}>{t('settings.keywords.parent_field', 'Parent tag')}</Text>
<Text style={styles.kwLabel}>{formatKeyword(initialParent, keywords, true)}</Text>
</View>
)}
<View>
<Text style={styles.formLabel}>{t('settings.keywords.label_field', "Display Name")}</Text>
<TextInput accessibilityLabel={t('settings.keywords.label_field', "Display Name")}
@@ -306,7 +374,13 @@ function KeywordForm({ initial, existingIds, onSave, onCancel }: KeywordFormProp
maxLength={30}
autoFocus
/>
{nested && normalizedId.length > 0 && <Text style={[styles.kwId, { marginTop: 4 }]}>$label:{normalizedId}</Text>}
{isDuplicate && <Text style={styles.errorText}>{t('settings.keywords.id_exists', "This tag ID already exists")}</Text>}
{isTooLong && (
<Text style={styles.errorText}>
{t('settings.keywords.too_long', 'This tag path is too long (at most {max} characters)', { max: MAX_KEYWORD_ID_LENGTH })}
</Text>
)}
</View>
<View>
@@ -364,6 +438,17 @@ function makeStyles(c: ThemePalette) {
borderColor: c.border,
backgroundColor: c.background,
},
kwCard: {
gap: spacing.xs,
padding: spacing.md,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: c.border,
backgroundColor: c.background,
},
kwCardTop: { flexDirection: 'row', alignItems: 'center', gap: spacing.md },
kwCardBottom: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, flexWrap: 'wrap' },
kwVisibilityLabel: { ...typography.caption, color: c.mutedForeground },
kwDot: { width: 20, height: 20, borderRadius: 10 },
kwLabel: { ...typography.bodyMedium, color: c.text, flex: 1 },
kwId: { fontSize: 10, color: c.mutedForeground, fontFamily: 'monospace' },
@@ -0,0 +1,98 @@
import React from 'react';
import { ActivityIndicator, Alert, Modal, Pressable, StyleSheet, View } from 'react-native';
import type { Mailbox } from '../../api/types';
import { getMailboxShareWith, setMailboxShare } from '../../api/mailbox-sharing';
import { detectPreset, MAILBOX_PRESETS, type ShareMailboxRights } from '../../lib/delegation/share-rights';
import { useLocaleStore } from '../../stores/locale-store';
import { useColors } from '../../theme/colors';
import { MODAL_ORIENTATIONS } from '../../lib/modal-orientations';
import { ShareCollectionSheet, type ShareableCollection, type SharePreset } from './ShareCollectionSheet';
import { mailboxShareRef } from '../../lib/mailbox-share';
interface MailboxShareSheetProps {
/** Null hides the sheet. */
mailbox: Mailbox | null;
/** The folder's name as the sidebar shows it (Inbox, Sent… localised). */
name?: string;
onClose: () => void;
}
/**
* Share a mail folder with other people (webmail mailbox-share-dialog.tsx):
* the folder's shareWith is read when the sheet opens, since it is not part
* of the folder list, and then the same sheet as for calendars and address
* books picks people and roles.
*/
export function MailboxShareSheet({ mailbox, name, onClose }: MailboxShareSheetProps) {
const t = useLocaleStore((s) => s.t);
const c = useColors();
const [collection, setCollection] = React.useState<ShareableCollection<ShareMailboxRights> | null>(null);
React.useEffect(() => {
setCollection(null);
if (!mailbox) return undefined;
let cancelled = false;
const { mailboxId, accountId } = mailboxShareRef(mailbox);
getMailboxShareWith(mailboxId, accountId).then(
(shareWith) => {
if (!cancelled) setCollection({ id: mailboxId, name: name || mailbox.name, shareWith });
},
(err: unknown) => {
if (cancelled) return;
Alert.alert(
t('sharing.share_failed', 'Failed to update sharing'),
err instanceof Error ? err.message : String(err),
);
onClose();
},
);
return () => {
cancelled = true;
};
// `name` and `t` only label the sheet; a new folder starts a new read.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mailbox]);
// The webmail's folder presets: no free/busy for mail.
const presets = React.useMemo<SharePreset<ShareMailboxRights>[]>(() => [
{ key: 'read', label: t('sharing.preset.read', 'Read only'), rights: MAILBOX_PRESETS.read },
{ key: 'readWrite', label: t('sharing.preset.readWrite', 'Read & write'), rights: MAILBOX_PRESETS.readWrite },
{ key: 'manager', label: t('sharing.preset.manager', 'Manager'), rights: MAILBOX_PRESETS.manager },
], [t]);
if (!mailbox) return null;
if (!collection) {
return (
<Modal supportedOrientations={MODAL_ORIENTATIONS} visible transparent animationType="fade" onRequestClose={onClose}>
<Pressable
style={styles.backdrop}
onPress={onClose}
accessibilityLabel={t('sharing.loading_principals', 'Loading users…')}
>
<View style={[styles.spinner, { backgroundColor: c.background }]}>
<ActivityIndicator size="small" color={c.textMuted} />
</View>
</Pressable>
</Modal>
);
}
const { accountId } = mailboxShareRef(mailbox);
return (
<ShareCollectionSheet
collection={collection}
presets={presets}
addRights={MAILBOX_PRESETS.read}
// Stalwart 1.0 stores a read grant with the keyword right on.
detectPreset={(rights) => detectPreset('mailbox', rights)}
onShare={(mailboxId, principalId, rights) => setMailboxShare(mailboxId, principalId, rights, accountId)}
onClose={onClose}
/>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.5)', alignItems: 'center', justifyContent: 'center' },
spinner: { padding: 20, borderRadius: 12 },
});
@@ -55,13 +55,18 @@ interface ShareCollectionSheetProps<R extends object> {
addRights: R;
onShare: (collectionId: string, principalId: string, rights: R | null) => Promise<void>;
onClose: () => void;
/**
* Names the preset a stored grant amounts to, when the server stores
* something other than what was sent (a folder on Stalwart 1.0).
*/
detectPreset?: (rights: R) => string;
}
// JMAP sharing for an owned collection: pick a principal, choose a role
// (webmail ShareCollectionDialog). The principal list comes from the same
// Principal/query the Files share sheet uses.
export function ShareCollectionSheet<R extends object>({
collection, presets, addRights, onShare, onClose,
collection, presets, addRights, onShare, onClose, detectPreset,
}: ShareCollectionSheetProps<R>) {
// Centred on a wide window, clear of side insets in landscape.
const sheetInsets = useSheetInsets();
@@ -224,7 +229,7 @@ export function ShareCollectionSheet<R extends object>({
<View style={styles.section}>
<Text style={styles.sectionLabel}>{t('calendar.share.shared_with', 'Shared with')}</Text>
{sharedEntries.map(([principalId, rights]) => {
const preset = detectSharePreset(rights, presets);
const preset = detectPreset ? detectPreset(rights) : detectSharePreset(rights, presets);
const busy = savingId === principalId;
return (
<View key={principalId} style={styles.shareRow}>
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const { auth } = vi.hoisted(() => ({
auth: { activeAccountId: 'login-a' as string | null, switchAccount: vi.fn() },
}));
vi.mock('../../stores/auth-store', () => ({ useAuthStore: { getState: () => auth } }));
import { openHit, filesLinkFor } from '../global-search/open-hit';
import { usePendingCalendarOpen } from '../../navigation/pending-calendar-open';
import { usePendingFilesLink } from '../../navigation/pending-files-link';
import type { GlobalSearchHit } from '../global-search/types';
const base = { localAccountId: 'login-a', jmapAccountId: 'own', shared: false, accountLabel: 'Me', subtitle: '', date: null, source: 'remote' as const };
beforeEach(() => {
auth.activeAccountId = 'login-a';
auth.switchAccount.mockReset();
usePendingCalendarOpen.setState({ target: null });
usePendingFilesLink.setState({ link: null });
});
describe('opening a search hit', () => {
it("opens a group account's message under that account, paging over the same account's hits", async () => {
const navigate = vi.fn();
const mail = (id: string, jmapAccountId: string, shared: boolean): GlobalSearchHit => ({
...base, kind: 'mail', id, jmapAccountId, shared, title: id, snippet: null,
email: { id, threadId: `t-${id}`, subject: id } as never,
});
const target = mail('5', 'team', true);
await openHit(target, { navigate }, [mail('4', 'team', true), target, mail('5', 'own', false)]);
expect(navigate).toHaveBeenCalledWith('EmailThread', expect.objectContaining({
emailId: '5', threadId: 't-5', jmapAccountId: 'team', emailIds: ['4', '5'],
}));
});
it("switches to the hit's login first, and opens nothing when the switch fails", async () => {
const navigate = vi.fn();
const contact: GlobalSearchHit = {
...base, localAccountId: 'login-b', kind: 'contacts', id: 'c1', title: 'Anna', storeId: 'c1', contact: { id: 'c1' } as never,
};
auth.switchAccount.mockImplementation(async (id: string) => { auth.activeAccountId = id; });
await openHit(contact, { navigate });
expect(auth.switchAccount).toHaveBeenCalledWith('login-b');
expect(navigate).toHaveBeenCalledWith('ContactDetail', { contactId: 'c1' });
navigate.mockReset();
auth.activeAccountId = 'login-a';
auth.switchAccount.mockImplementation(async () => undefined);
expect(await openHit(contact, { navigate })).toBe(false);
expect(navigate).not.toHaveBeenCalled();
});
it('hands a shared calendar event to the Calendar tab by its server id and account', async () => {
const navigate = vi.fn();
await openHit({
...base, kind: 'calendar', id: 'ev', jmapAccountId: 'boss', shared: true, title: 'Sync', isRecurring: false,
event: { id: 'ev', start: '2026-03-02T10:00:00', calendarIds: {} } as never,
}, { navigate });
expect(usePendingCalendarOpen.getState().target).toMatchObject({ kind: 'event', eventId: 'boss:ev', serverId: 'ev', accountId: 'boss' });
expect(navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Calendar' });
});
it('opens a file in its folder with a preview, a folder by its path, and a shared node by its listed id', async () => {
const file = (over: Record<string, unknown>): Extract<GlobalSearchHit, { kind: 'files' }> => ({
...base, kind: 'files', id: 'n', title: 'x', folderPath: '/Docs', isFolder: false,
node: { id: 'n', name: 'report.pdf' } as never, ...over,
} as never);
expect(filesLinkFor(file({}))).toEqual({ path: '/Docs', preview: 'report.pdf' });
expect(filesLinkFor(file({ isFolder: true, folderPath: '/', node: { id: 'n', name: 'Tax' } }))).toEqual({ path: '/Tax' });
expect(filesLinkFor(file({ node: { id: 'boss:n', name: 'a', isShared: true } }))).toEqual({ path: '/', shared: 'boss:n' });
const navigate = vi.fn();
await openHit(file({}), { navigate });
expect(usePendingFilesLink.getState().link).toEqual({ path: '/Docs', preview: 'report.pdf' });
expect(navigate).toHaveBeenCalledWith('MainTabs', { screen: 'Files' });
});
});
+226
View File
@@ -0,0 +1,226 @@
import { describe, it, expect, vi } from 'vitest';
import { accountMatches, hasRemoteQuery, matchesTerms, parseSearchQuery } from '../global-search/query-parser';
import { mergeHits, rankHits } from '../global-search/rank';
import { localOutcome, runGlobalSearch } from '../global-search/run';
import { calendarFilterFor, mailFilterFor, pathOfNode } from '../global-search/filters';
import { scopedQuery } from '../global-search/use-global-search';
import type { GlobalSearchHit, SearchAccount, SearchOutcome, SearchProvider } from '../global-search/types';
import type { FileNode } from '../../api/types';
// Ported from the webmail's lib/global-search/__tests__ (query-parser,
// rank, run-global-search), on the app's types.
const ACCOUNTS: SearchAccount[] = [
{ localAccountId: 'a', label: 'Work', email: 'me@work.example', active: true },
{ localAccountId: 'b', label: 'Home', email: 'me@home.example', active: false },
];
function hit(partial: Partial<GlobalSearchHit> & { id: string }): GlobalSearchHit {
return {
kind: 'mail', localAccountId: 'a', jmapAccountId: 'j', shared: false, accountLabel: 'Work',
title: partial.id, subtitle: '', date: null, source: 'local',
email: { id: partial.id, threadId: 't', subject: partial.id } as never, snippet: null,
...partial,
} as GlobalSearchHit;
}
describe('parseSearchQuery', () => {
it('keeps words and quoted phrases as text, lower-cased terms for local matching', () => {
const parsed = parseSearchQuery('Annual "Q3 Report"');
expect(parsed.text).toBe('Annual "Q3 Report"');
expect(parsed.terms).toEqual(['annual', 'q3 report']);
expect(parsed.scope).toBe('all');
});
it('reads scope, account and the mail operators', () => {
const parsed = parseSearchQuery('in:events account:Work from:anna subject:plan has:attachment is:unread after:2026-01-01 before:2026-02-01 budget');
expect(parsed.scope).toBe('calendar');
expect(parsed.account).toBe('work');
expect(parsed.mail).toMatchObject({ from: 'anna', subject: 'plan', hasAttachment: true, isUnread: true, dateAfter: '2026-01-01', dateBefore: '2026-02-01' });
expect(parsed.after).toBe('2026-01-01');
expect(parsed.text).toBe('budget');
});
it('makes in:trash a mail search of that folder, and is:anything keeps Trash and Junk in', () => {
expect(parseSearchQuery('in:trash x')).toMatchObject({ scope: 'mail', mailboxRole: 'trash' });
expect(parseSearchQuery('is:anything x').includeTrashAndJunk).toBe(true);
});
it('keeps malformed dates and unknown operators as free text', () => {
const parsed = parseSearchQuery('after:2026-02-30 foo:bar ticket');
expect(parsed.after).toBeNull();
expect(parsed.text).toBe('after:2026-02-30 foo:bar ticket');
});
it('lets an explicit in: win over the scope chip', () => {
expect(scopedQuery('x', 'files').scope).toBe('files');
expect(scopedQuery('in:contacts x', 'files').scope).toBe('contacts');
});
it('asks the server for mail on operators alone, for the other kinds only with text', () => {
const parsed = parseSearchQuery('is:unread');
expect(hasRemoteQuery(parsed, 'mail')).toBe(true);
expect(hasRemoteQuery(parsed, 'contacts')).toBe(false);
});
});
describe('matching helpers', () => {
it('needs every term in some field', () => {
expect(matchesTerms(['ann', 'plan'], ['Anna Smith', 'Project plan'])).toBe(true);
expect(matchesTerms(['ann', 'zzz'], ['Anna Smith'])).toBe(false);
expect(matchesTerms([], ['x'])).toBe(false);
});
it('matches a login by label, address prefix or id', () => {
expect(accountMatches('wor', { ...ACCOUNTS[0] })).toBe(true);
expect(accountMatches('me@home', { ...ACCOUNTS[1] })).toBe(true);
expect(accountMatches('home', { ...ACCOUNTS[0] })).toBe(false);
});
});
describe('server filters', () => {
const mailboxes = [
{ id: 'in', role: 'inbox', name: 'Inbox' },
{ id: 'tr', role: 'trash', name: 'Trash' },
{ id: 'sp', role: 'junk', name: 'Junk' },
];
it('searches every folder but Trash and Junk by default', () => {
expect(mailFilterFor(parseSearchQuery('invoice'), mailboxes)).toEqual({
operator: 'AND', conditions: [{ text: 'invoice' }, { inMailboxOtherThan: ['tr', 'sp'] }],
});
});
it('searches Trash alone with in:trash, and everything with is:anything', () => {
expect(mailFilterFor(parseSearchQuery('in:trash invoice'), mailboxes)).toEqual({
operator: 'AND', conditions: [{ text: 'invoice' }, { inMailbox: 'tr' }],
});
expect(mailFilterFor(parseSearchQuery('is:anything invoice'), mailboxes)).toEqual({ text: 'invoice' });
});
it('turns the mail operators into conditions', () => {
const filter = mailFilterFor(parseSearchQuery('from:anna is:starred has:attachment'), []);
expect(filter).toEqual({
operator: 'AND',
conditions: [{ from: 'anna' }, { hasAttachment: true }, { hasKeyword: '$flagged' }],
});
});
it('bounds the calendar search by whole days, one AND group for several parts', () => {
expect(calendarFilterFor(parseSearchQuery('standup after:2026-03-01'))).toEqual({ text: 'standup', after: '2026-03-01T00:00:00Z' });
expect(calendarFilterFor(parseSearchQuery('"team sync" weekly before:2026-03-31'))).toEqual({
operator: 'AND',
conditions: [{ before: '2026-03-31T23:59:59Z' }, { text: '"team sync"' }, { text: 'weekly' }],
});
});
it('finds the folder path of a file', () => {
const nodes = [
{ id: '1', name: 'Docs', parentId: null },
{ id: '2', name: 'Tax', parentId: '1' },
{ id: '3', name: 'return.pdf', parentId: '2' },
] as FileNode[];
expect(pathOfNode(nodes, nodes[2])).toBe('/Docs/Tax');
expect(pathOfNode(nodes, nodes[0])).toBe('/');
});
});
describe('rank and merge', () => {
it('puts exact and prefix title matches first, then newer hits', () => {
const parsed = parseSearchQuery('plan');
const ranked = rankHits([
hit({ id: 'old', title: 'about the plan', date: '2026-01-01T00:00:00Z' }),
hit({ id: 'new', title: 'about the plan', date: '2026-02-01T00:00:00Z' }),
hit({ id: 'exact', title: 'Plan' }),
hit({ id: 'prefix', title: 'Planning day' }),
], parsed);
expect(ranked.map((h) => h.id)).toEqual(['exact', 'prefix', 'new', 'old']);
});
it('lets a server hit replace the loaded one, and keeps the same id of two accounts apart', () => {
const merged = mergeHits(
[hit({ id: '1', source: 'local' }), hit({ id: '1', jmapAccountId: 'other', source: 'local' })],
[hit({ id: '1', source: 'remote', title: 'from server' })],
);
expect(merged).toHaveLength(2);
expect(merged.find((h) => h.jmapAccountId === 'j')?.source).toBe('remote');
// A loaded hit never replaces a server one.
expect(mergeHits(merged, [hit({ id: '1', source: 'local' })]).find((h) => h.jmapAccountId === 'j')?.source).toBe('remote');
});
});
describe('runGlobalSearch', () => {
const provider = (over: Partial<SearchProvider>): SearchProvider => ({
kind: 'mail',
local: () => [],
remote: async () => ({ hits: [], hasMore: false }),
...over,
});
it('shows the loaded hits first, then merges each account answer, and reports a failed account', async () => {
const updates: SearchOutcome[] = [];
const mail = provider({
local: () => [hit({ id: 'cached', title: 'plan cached' })],
remote: async (_p, account) => {
if (account.localAccountId === 'b') throw new Error('boom');
return { hits: [hit({ id: 'server', title: 'plan server', source: 'remote' })], hasMore: true };
},
});
const result = await runGlobalSearch({
parsed: parseSearchQuery('plan'), accounts: ACCOUNTS, providers: [mail],
localLimit: 10, remoteLimit: 10, signal: new AbortController().signal,
onUpdate: (o) => updates.push(o),
});
expect(updates[0].hits.mail.map((h) => h.id)).toEqual(['cached']);
expect(updates[0].status.mail.status).toBe('loading');
expect(result.hits.mail.map((h) => h.id).sort()).toEqual(['cached', 'server']);
expect(result.status.mail).toMatchObject({ status: 'done', hasMore: true });
expect(result.status.mail.errors).toEqual([{ localAccountId: 'b', accountLabel: 'Home', message: 'boom' }]);
});
it('asks only the account named by account:, and only the kind of the scope', async () => {
const asked: string[] = [];
const mail = provider({ remote: async (_p, a) => { asked.push(`mail:${a.localAccountId}`); return { hits: [], hasMore: false }; } });
const files = provider({ kind: 'files', remote: async (_p, a) => { asked.push(`files:${a.localAccountId}`); return { hits: [], hasMore: false }; } });
await runGlobalSearch({
parsed: parseSearchQuery('in:mail account:home plan'), accounts: ACCOUNTS, providers: [mail, files],
localLimit: 10, remoteLimit: 10, signal: new AbortController().signal, onUpdate: () => undefined,
});
expect(asked).toEqual(['mail:b']);
});
it('gives up on a slow account after the time limit', async () => {
vi.useFakeTimers();
try {
const slow = provider({ remote: () => new Promise(() => undefined) });
const done = runGlobalSearch({
parsed: parseSearchQuery('plan'), accounts: [ACCOUNTS[0]], providers: [slow],
localLimit: 10, remoteLimit: 10, signal: new AbortController().signal, onUpdate: () => undefined, timeoutMs: 1000,
});
await vi.advanceTimersByTimeAsync(1001);
const result = await done;
expect(result.status.mail.errors[0].message).toBe('timeout');
} finally {
vi.useRealTimers();
}
});
it('publishes nothing after the search was replaced', async () => {
const controller = new AbortController();
const onUpdate = vi.fn();
const mail = provider({ remote: async () => { controller.abort(); return { hits: [hit({ id: 'late' })], hasMore: false }; } });
await runGlobalSearch({
parsed: parseSearchQuery('plan'), accounts: [ACCOUNTS[0]], providers: [mail],
localLimit: 10, remoteLimit: 10, signal: controller.signal, onUpdate,
});
const last = onUpdate.mock.calls.at(-1)?.[0] as SearchOutcome;
expect(last.hits.mail.map((h) => h.id)).not.toContain('late');
});
it('runs no local pass for a query of operators only', () => {
const local = vi.fn(() => [hit({ id: 'x' })]);
const outcome = localOutcome(parseSearchQuery('is:unread'), ACCOUNTS, [provider({ local })], 10);
expect(local).not.toHaveBeenCalled();
expect(outcome.status.mail.status).toBe('loading');
});
});
+146
View File
@@ -0,0 +1,146 @@
import { describe, it, expect } from 'vitest';
import type { KeywordDef } from '../../stores/keywords-store';
import {
buildKeywordTree,
filterKeywordTree,
flattenKeywordTree,
formatKeyword,
getParentKeywordId,
hasChildKeywords,
isTagVisibleInSidebar,
parentKeywordOptions,
sidebarTagRows,
treeRowLabel,
} from '../keyword-nesting';
// The webmail's DEV_KEYWORDS (stores/settings-store.ts).
const TAGS: KeywordDef[] = [
{ id: 'work', label: 'Work', color: 'blue' },
{ id: 'work/clients', label: 'Clients', color: 'teal' },
{ id: 'work/clients/acme', label: 'Acme', color: 'green' },
{ id: 'personal', label: 'Personal', color: 'purple' },
{ id: 'personal/finance', label: 'Finance', color: 'amber' },
{ id: 'receipts', label: 'Receipts', color: 'gray' },
];
describe('buildKeywordTree', () => {
it('nests tags under the tag their id names as parent, keeping the order', () => {
const tree = buildKeywordTree(TAGS);
expect(tree.map((n) => n.id)).toEqual(['work', 'personal', 'receipts']);
expect(tree[0].children.map((n) => n.id)).toEqual(['work/clients']);
expect(tree[0].children[0].children[0]).toMatchObject({ id: 'work/clients/acme', depth: 2 });
});
it('keeps a tag whose parent is not defined at the root', () => {
const tree = buildKeywordTree([{ id: 'team/urgent', label: 'Urgent', color: 'red' }]);
expect(tree).toHaveLength(1);
expect(tree[0].depth).toBe(0);
});
it('makes every tag a root with nesting off', () => {
const tree = buildKeywordTree(TAGS, false);
expect(tree).toHaveLength(TAGS.length);
expect(tree.every((n) => n.depth === 0 && n.children.length === 0)).toBe(true);
});
});
describe('names', () => {
it('spells a nested tag out level by level with nesting on, by its own label with it off', () => {
expect(formatKeyword('work/clients/acme', TAGS, true)).toBe('Work/Clients/Acme');
expect(formatKeyword('work/clients/acme', TAGS, false)).toBe('Acme');
});
it('falls back to the raw level where a level has no definition', () => {
expect(formatKeyword('work/other', TAGS, true)).toBe('Work/other');
expect(formatKeyword('team/urgent', TAGS, false)).toBe('team/urgent');
});
it('names a root by its path and a child by its own label in a tree', () => {
const all: KeywordDef[] = [...TAGS, { id: 'team/urgent', label: 'Urgent', color: 'red' }];
const rows = flattenKeywordTree(buildKeywordTree(all));
expect(treeRowLabel(rows.find((r) => r.id === 'work/clients')!, all, true)).toBe('Clients');
expect(treeRowLabel(rows.find((r) => r.id === 'team/urgent')!, all, true)).toBe('team/Urgent');
});
});
describe('hierarchy helpers', () => {
it('finds the parent and children of a tag', () => {
expect(getParentKeywordId('work/clients/acme')).toBe('work/clients');
expect(getParentKeywordId('work')).toBeNull();
expect(hasChildKeywords('work', TAGS)).toBe(true);
expect(hasChildKeywords('receipts', TAGS)).toBe(false);
// `work-old` is not under `work`.
expect(hasChildKeywords('work', [{ id: 'work-old' }])).toBe(false);
});
it('offers every tag as a parent by its full path, in tree order', () => {
expect(parentKeywordOptions(TAGS).map((o) => o.label)).toEqual([
'Work', 'Work/Clients', 'Work/Clients/Acme', 'Personal', 'Personal/Finance', 'Receipts',
]);
expect(parentKeywordOptions(TAGS, 'work').map((o) => o.id)).toEqual(['personal', 'personal/finance', 'receipts']);
});
it('keeps a hidden parent when a child below it stays', () => {
const kept = filterKeywordTree(buildKeywordTree(TAGS), (n) => n.id === 'work/clients/acme');
expect(kept.map((n) => n.id)).toEqual(['work']);
expect(flattenKeywordTree(kept).map((n) => n.id)).toEqual(['work', 'work/clients', 'work/clients/acme']);
});
});
describe('sidebar visibility', () => {
it('hides a tag set to Hide, and one set to Show if unread once it has no unread mail', () => {
const hide = { id: 'a', visibility: 'hide' as const };
const unread = { id: 'b', visibility: 'unread' as const };
expect(isTagVisibleInSidebar(hide, {})).toBe(false);
expect(isTagVisibleInSidebar(unread, { b: { unread: 0 } })).toBe(false);
expect(isTagVisibleInSidebar(unread, { b: { unread: 2 } })).toBe(true);
// No count yet: shown rather than blinking out and back in.
expect(isTagVisibleInSidebar(unread, {})).toBe(true);
expect(isTagVisibleInSidebar({ id: 'c' }, {})).toBe(true);
});
it('shows a hidden tag while its tag view is open or "Show all" is on', () => {
const hide = { id: 'a', visibility: 'hide' as const };
expect(isTagVisibleInSidebar(hide, {}, { selectedId: 'a' })).toBe(true);
expect(isTagVisibleInSidebar(hide, {}, { showAll: true })).toBe(true);
});
});
describe('sidebarTagRows', () => {
it('draws nested rows at their depth, and leaves out the children of a folded parent', () => {
const { rows } = sidebarTagRows(TAGS, true, {});
expect(rows.map((r) => [r.node.id, r.node.depth, r.label])).toEqual([
['work', 0, 'Work'],
['work/clients', 1, 'Clients'],
['work/clients/acme', 2, 'Acme'],
['personal', 0, 'Personal'],
['personal/finance', 1, 'Finance'],
['receipts', 0, 'Receipts'],
]);
expect(rows[0]).toMatchObject({ hasChildren: true, expanded: true });
const folded = sidebarTagRows(TAGS, true, {}, { collapsed: new Set(['work']) });
expect(folded.rows.map((r) => r.node.id)).toEqual(['work', 'personal', 'personal/finance', 'receipts']);
expect(folded.rows[0]).toMatchObject({ hasChildren: true, expanded: false });
});
it('draws a flat list named by label with nesting off', () => {
const { rows } = sidebarTagRows(TAGS, false, {});
expect(rows.every((r) => r.node.depth === 0 && !r.hasChildren)).toBe(true);
expect(rows.map((r) => r.label)).toContain('Acme');
});
it('counts the tags the visibility settings hide, for "Show all (N)"', () => {
const tags: KeywordDef[] = [
...TAGS.filter((t) => t.id !== 'receipts'),
{ id: 'receipts', label: 'Receipts', color: 'gray', visibility: 'hide' },
{ id: 'news', label: 'News', color: 'gray', visibility: 'unread' },
];
const { rows, hiddenCount } = sidebarTagRows(tags, true, { news: { unread: 0 } });
expect(rows.map((r) => r.node.id)).not.toContain('receipts');
expect(rows.map((r) => r.node.id)).not.toContain('news');
expect(hiddenCount).toBe(2);
const all = sidebarTagRows(tags, true, { news: { unread: 0 } }, { showAll: true });
expect(all.hiddenCount).toBe(0);
expect(all.rows).toHaveLength(tags.length);
});
});
+2 -2
View File
@@ -3,7 +3,7 @@ import { keywordColorFromPalette, mergeSharedTags, parseSharedTags } from '../sh
describe('parseSharedTags', () => {
// The map shape the webmail writes (lib/shared-tags.ts).
it('reads the map by tag id, sorted by name, keeping unknown fields out', () => {
it('reads the map by tag id, sorted by name, with the sidebar visibility and without unknown fields', () => {
expect(parseSharedTags({
tags: {
urgent: { label: 'Urgent', color: 'red-dark', visibility: 'show', by: 'x' },
@@ -14,7 +14,7 @@ describe('parseSharedTags', () => {
})).toEqual([
{ id: 'billing', label: 'Billing', color: 'teal' },
{ id: 'unnamed', label: 'unnamed', color: 'blue' },
{ id: 'urgent', label: 'Urgent', color: 'red' },
{ id: 'urgent', label: 'Urgent', color: 'red', visibility: 'show' },
]);
});
+181
View File
@@ -0,0 +1,181 @@
import type { CalendarEvent, ContactCard, Email, EmailAddress, FileNode } from '../../api/types';
import { textConditions } from '../search-utils';
import type { MailFilters, ParsedQuery } from './query-parser';
// What each kind of item is searched by, and the server filters a parsed
// query turns into (webmail lib/global-search/providers/*.ts and
// buildJMAPFilter in lib/jmap/search-utils.ts).
type JmapFilter = Record<string, unknown>;
/** AND of two filters, flattening an AND on the left. */
export function andFilters(base: JmapFilter, extra: JmapFilter | null): JmapFilter {
if (!extra || Object.keys(extra).length === 0) return base;
if (Object.keys(base).length === 0) return extra;
if (base.operator === 'AND' && Array.isArray(base.conditions)) {
return { operator: 'AND', conditions: [...(base.conditions as JmapFilter[]), extra] };
}
return { operator: 'AND', conditions: [base, extra] };
}
/** The Email/query filter for free text and the mail operators (webmail buildJMAPFilter). */
export function buildMailFilter(text: string, filters: MailFilters): JmapFilter {
const conditions: JmapFilter[] = [];
if (text.trim()) conditions.push(...textConditions(text));
if (filters.from) conditions.push(...textConditions(filters.from, 'from'));
if (filters.to) conditions.push(...textConditions(filters.to, 'to'));
if (filters.subject) conditions.push(...textConditions(filters.subject, 'subject'));
if (filters.body) conditions.push(...textConditions(filters.body, 'body'));
if (filters.hasAttachment !== null) conditions.push({ hasAttachment: filters.hasAttachment });
if (filters.dateAfter) {
const date = new Date(filters.dateAfter);
if (!Number.isNaN(date.getTime())) conditions.push({ after: date.toISOString() });
}
if (filters.dateBefore) {
const end = new Date(filters.dateBefore);
if (!Number.isNaN(end.getTime())) {
end.setHours(23, 59, 59, 999);
conditions.push({ before: end.toISOString() });
}
}
if (filters.isUnread === true) conditions.push({ notKeyword: '$seen' });
else if (filters.isUnread === false) conditions.push({ hasKeyword: '$seen' });
if (filters.isStarred === true) conditions.push({ hasKeyword: '$flagged' });
else if (filters.isStarred === false) conditions.push({ notKeyword: '$flagged' });
if (conditions.length === 0) return {};
if (conditions.length === 1) return conditions[0];
return { operator: 'AND', conditions };
}
/** The folders of one mail account the filter needs: id and role. */
export interface MailboxRef {
id: string;
role?: string | null;
name: string;
}
function findRole(mailboxes: MailboxRef[], role: 'trash' | 'junk'): MailboxRef | undefined {
return mailboxes.find((m) => m.role === role || (role === 'junk' && m.role === 'spam'));
}
/**
* The default scope is everything but Trash and Junk; `in:trash` and
* `in:junk` search that folder alone and `is:anything` lifts the exclusion
* (webmail mailFilterFor). Without those folders, everything.
*/
export function mailFilterFor(parsed: ParsedQuery, mailboxes: MailboxRef[]): JmapFilter {
const base = buildMailFilter(parsed.text, parsed.mail);
if (parsed.mailboxRole) {
const mailbox = findRole(mailboxes, parsed.mailboxRole);
return mailbox ? andFilters(base, { inMailbox: mailbox.id }) : base;
}
if (parsed.includeTrashAndJunk) return base;
const excluded = (['trash', 'junk'] as const).map((role) => findRole(mailboxes, role)?.id).filter((id): id is string => !!id);
return excluded.length > 0 ? andFilters(base, { inMailboxOtherThan: excluded }) : base;
}
/**
* `after:`/`before:` are calendar days, compared against instants. Several
* text parts go out as an AND of single-key conditions: 1.0 takes one AND
* group when it expands recurrences (webmail calendarFilterFor).
*/
export function calendarFilterFor(parsed: ParsedQuery): JmapFilter {
const after = parsed.after ? `${parsed.after}T00:00:00Z` : undefined;
const before = parsed.before ? `${parsed.before}T23:59:59Z` : undefined;
const texts = textConditions(parsed.text).map(({ text }): JmapFilter => ({ text }));
if (texts.length === 1) {
const filter = texts[0];
if (after) filter.after = after;
if (before) filter.before = before;
return filter;
}
const bounds: JmapFilter[] = [];
if (after) bounds.push({ after });
if (before) bounds.push({ before });
return { operator: 'AND', conditions: [...bounds, ...texts] };
}
function addressFields(addresses: EmailAddress[] | null | undefined): string[] {
const out: string[] = [];
for (const address of addresses ?? []) {
if (address.name) out.push(address.name);
if (address.email) out.push(address.email);
}
return out;
}
/** The text a loaded message is found by. */
export function emailFields(email: Email): string[] {
return [email.subject ?? '', email.preview ?? '', ...addressFields(email.from), ...addressFields(email.to)];
}
function includesCi(haystack: string[], needle: string): boolean {
const n = needle.toLowerCase();
return haystack.some((h) => h.toLowerCase().includes(n));
}
/** The mail operators, applied to a loaded message. */
export function emailMatchesFilters(email: Email, parsed: ParsedQuery): boolean {
const { mail } = parsed;
if (mail.from && !includesCi(addressFields(email.from), mail.from)) return false;
if (mail.to && !includesCi([...addressFields(email.to), ...addressFields(email.cc)], mail.to)) return false;
if (mail.subject && !includesCi([email.subject ?? ''], mail.subject)) return false;
if (mail.hasAttachment !== null && Boolean(email.hasAttachment) !== mail.hasAttachment) return false;
if (mail.isUnread !== null && Boolean(email.keywords?.$seen) === mail.isUnread) return false;
if (mail.isStarred !== null && Boolean(email.keywords?.$flagged) !== mail.isStarred) return false;
const day = email.receivedAt?.slice(0, 10) ?? '';
if (mail.dateAfter && day < mail.dateAfter) return false;
if (mail.dateBefore && day > mail.dateBefore) return false;
return true;
}
/** The text a contact is found by: name, addresses, organisations, phones, nicknames. */
export function contactFields(contact: ContactCard, displayName: string): string[] {
const fields: string[] = [displayName];
for (const email of Object.values(contact.emails ?? {})) if (email?.address) fields.push(email.address);
for (const org of Object.values(contact.organizations ?? {})) {
const name = (org as { name?: string }).name;
if (name) fields.push(name);
}
for (const phone of Object.values(contact.phones ?? {})) {
const number = (phone as { number?: string }).number;
if (number) fields.push(number);
}
for (const nick of Object.values(contact.nicknames ?? {})) {
const name = (nick as { name?: string }).name;
if (name) fields.push(name);
}
return fields;
}
/** The text an event is found by: title, description, places and participants. */
export function eventFields(event: CalendarEvent): string[] {
const fields: string[] = [event.title ?? '', event.description ?? ''];
for (const location of Object.values(event.locations ?? {})) if (location?.name) fields.push(location.name);
for (const participant of Object.values(event.participants ?? {})) {
const p = participant as { name?: string | null; email?: string | null };
if (p.name) fields.push(p.name);
if (p.email) fields.push(p.email);
}
return fields;
}
export function isRecurringEvent(event: Pick<CalendarEvent, 'recurrenceId' | 'recurrenceRules'>): boolean {
return Boolean(event.recurrenceId) || (Array.isArray(event.recurrenceRules) && event.recurrenceRules.length > 0);
}
/** The folder path a file node lives in (`/` for the root). */
export function pathOfNode(nodes: FileNode[], node: FileNode): string {
const byId = new Map(nodes.map((n) => [n.id, n]));
const segments: string[] = [];
const seen = new Set<string>();
let parentId = node.parentId;
while (parentId != null && !seen.has(parentId)) {
seen.add(parentId);
const parent = byId.get(parentId);
if (!parent) break;
segments.unshift(parent.name);
parentId = parent.parentId;
}
return `/${segments.join('/')}`;
}
+74
View File
@@ -0,0 +1,74 @@
import { useAuthStore } from '../../stores/auth-store';
import { setPendingCalendarOpen } from '../../navigation/pending-calendar-open';
import { setPendingFilesLink } from '../../navigation/pending-files-link';
import type { GlobalSearchHit } from './types';
// Opening a search hit where it lives, in its own account (webmail
// lib/global-search/open-hit.ts, the standard layout's path): switch to the
// hit's login when it isn't the active one, then go to its screen. Every hit
// names its login, owning account and raw id (#847).
/** The navigation calls opening a hit needs. */
export interface HitNavigation {
navigate: (...args: any[]) => void;
}
/** The Files link of a file hit: own files by path, a shared drive's by its listed id. */
export function filesLinkFor(hit: Extract<GlobalSearchHit, { kind: 'files' }>): { path: string; preview?: string; shared?: string } {
if (hit.node.isShared) return { path: '/', shared: hit.node.id };
if (hit.isFolder) {
const base = hit.folderPath === '/' ? '' : hit.folderPath;
return { path: `${base}/${hit.node.name}` };
}
return { path: hit.folderPath, preview: hit.node.name };
}
/**
* Opens a hit. `peers` are the other hits of the list, so the reader can
* page over the messages of the same account. Resolves false when the
* switch to the hit's login did not happen.
*/
export async function openHit(hit: GlobalSearchHit, navigation: HitNavigation, peers: GlobalSearchHit[] = []): Promise<boolean> {
const auth = useAuthStore.getState();
if (auth.activeAccountId !== hit.localAccountId) {
await auth.switchAccount(hit.localAccountId);
if (useAuthStore.getState().activeAccountId !== hit.localAccountId) return false;
}
switch (hit.kind) {
case 'mail': {
const pages = peers
.filter((p) => p.kind === 'mail' && p.localAccountId === hit.localAccountId && p.jmapAccountId === hit.jmapAccountId)
.map((p) => p.id);
navigation.navigate('EmailThread', {
emailId: hit.id,
threadId: hit.email.threadId,
subject: hit.email.subject,
// A group or shared account's message names its account: ids repeat.
jmapAccountId: hit.shared ? hit.jmapAccountId : undefined,
emailIds: pages.includes(hit.id) ? pages : [hit.id],
});
return true;
}
case 'contacts':
navigation.navigate('ContactDetail', { contactId: hit.storeId });
return true;
case 'calendar': {
// Like a tapped reminder: the Calendar tab finds the event by its
// server id when it isn't in the loaded window.
const startMs = hit.event.start ? Date.parse(hit.event.start) : NaN;
setPendingCalendarOpen({
kind: 'event',
eventId: hit.source === 'local' ? hit.event.id : hit.shared ? `${hit.jmapAccountId}:${hit.id}` : hit.id,
serverId: hit.id,
accountId: hit.shared ? hit.jmapAccountId : undefined,
...(Number.isNaN(startMs) ? {} : { startMs }),
});
navigation.navigate('MainTabs', { screen: 'Calendar' });
return true;
}
case 'files':
setPendingFilesLink(filesLinkFor(hit));
navigation.navigate('MainTabs', { screen: 'Files' });
return true;
}
}
+205
View File
@@ -0,0 +1,205 @@
// Query syntax of "Search everything" (webmail lib/global-search/query-parser.ts):
//
// in:mail|contacts|calendar|files scope (also in:trash / in:junk for mail)
// account:<label|email> restrict to one login
// from: to: subject: mail filters
// has:attachment mail
// is:unread|read|starred|anything mail (`anything` includes Trash/Junk)
// after:YYYY-MM-DD before:YYYY-MM-DD mail + calendar date bounds
// "quoted phrase" kept as a phrase for the server
//
// Everything else - unknown `key:value` pairs and malformed dates included -
// stays free text, so a colon in a normal query never drops words.
export type SearchKind = 'mail' | 'contacts' | 'calendar' | 'files';
export const SEARCH_KINDS: readonly SearchKind[] = ['mail', 'contacts', 'calendar', 'files'];
export type SearchScope = 'all' | SearchKind;
/** The mail filters an operator can set (webmail SearchFilters). */
export interface MailFilters {
from: string;
to: string;
subject: string;
body: string;
hasAttachment: boolean | null;
isUnread: boolean | null;
isStarred: boolean | null;
dateAfter: string;
dateBefore: string;
}
export const DEFAULT_MAIL_FILTERS: MailFilters = {
from: '', to: '', subject: '', body: '',
hasAttachment: null, isUnread: null, isStarred: null,
dateAfter: '', dateBefore: '',
};
export interface ParsedQuery {
raw: string;
/** Free text for the server: words and quoted phrases, operators removed. */
text: string;
/** Lower-cased words and phrases for local substring matching. */
terms: string[];
scope: SearchScope;
/** `account:` value, lower-cased; matched against the login's label and address. */
account: string | null;
mail: MailFilters;
/** `YYYY-MM-DD` bounds shared by mail and calendar. */
after: string | null;
before: string | null;
/** `in:trash` / `in:junk`: search that folder only. */
mailboxRole: 'trash' | 'junk' | null;
/** `is:anything`: Trash and Junk stay in the mail search. */
includeTrashAndJunk: boolean;
hasOperators: boolean;
}
const SCOPE_ALIASES: Record<string, SearchScope> = {
all: 'all', everything: 'all',
mail: 'mail', email: 'mail', emails: 'mail', messages: 'mail',
contacts: 'contacts', contact: 'contacts', people: 'contacts',
calendar: 'calendar', calendars: 'calendar', events: 'calendar', event: 'calendar',
files: 'files', file: 'files', drive: 'files',
};
const OPERATORS = new Set(['in', 'account', 'from', 'to', 'subject', 'has', 'is', 'after', 'before']);
/** `YYYY-MM-DD` that names a real calendar day. */
export function isValidQueryDate(value: string): boolean {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return false;
const [, y, m, d] = match;
const date = new Date(Number(y), Number(m) - 1, Number(d));
return date.getMonth() === Number(m) - 1 && date.getDate() === Number(d);
}
interface Token {
key: string | null;
value: string;
quoted: boolean;
original: string;
}
// `key:"quoted value"`, `key:value`, `"quoted phrase"`, or a bare word. A
// trailing unmatched quote is tolerated (the user is still typing).
const TOKEN_RE = /([A-Za-z]+):(?:"([^"]*)"?|(\S+))|"([^"]*)"?|(\S+)/g;
function tokenizeQuery(raw: string): Token[] {
const tokens: Token[] = [];
for (const match of raw.matchAll(TOKEN_RE)) {
const [original, key, quotedValue, bareValue, phrase, word] = match;
if (key !== undefined) {
tokens.push({ key: key.toLowerCase(), value: quotedValue ?? bareValue ?? '', quoted: quotedValue !== undefined, original });
} else if (phrase !== undefined) {
tokens.push({ key: null, value: phrase, quoted: true, original });
} else if (word !== undefined) {
tokens.push({ key: null, value: word, quoted: false, original });
}
}
return tokens;
}
export function parseSearchQuery(raw: string): ParsedQuery {
const textParts: string[] = [];
const terms: string[] = [];
const mail: MailFilters = { ...DEFAULT_MAIL_FILTERS };
let scope: SearchScope = 'all';
let account: string | null = null;
let after: string | null = null;
let before: string | null = null;
let mailboxRole: 'trash' | 'junk' | null = null;
let includeTrashAndJunk = false;
let hasOperators = false;
const pushText = (value: string, quoted: boolean) => {
const trimmed = value.trim();
if (!trimmed) return;
textParts.push(quoted ? `"${trimmed}"` : trimmed);
terms.push(trimmed.toLowerCase());
};
for (const token of tokenizeQuery(raw)) {
if (token.key === null || !OPERATORS.has(token.key)) {
if (token.key !== null) pushText(token.original, false);
else pushText(token.value, token.quoted);
continue;
}
const value = token.value.trim();
const lower = value.toLowerCase();
let consumed = true;
switch (token.key) {
case 'in': {
const alias = SCOPE_ALIASES[lower];
if (alias) scope = alias;
else if (lower === 'trash' || lower === 'bin') { scope = 'mail'; mailboxRole = 'trash'; }
else if (lower === 'junk' || lower === 'spam') { scope = 'mail'; mailboxRole = 'junk'; }
else consumed = false;
break;
}
case 'account':
if (lower) account = lower; else consumed = false;
break;
case 'from':
if (value) mail.from = value; else consumed = false;
break;
case 'to':
if (value) mail.to = value; else consumed = false;
break;
case 'subject':
if (value) mail.subject = value; else consumed = false;
break;
case 'has':
if (lower === 'attachment' || lower === 'attachments' || lower === 'file') mail.hasAttachment = true;
else consumed = false;
break;
case 'is':
if (lower === 'unread') mail.isUnread = true;
else if (lower === 'read') mail.isUnread = false;
else if (lower === 'starred' || lower === 'flagged') mail.isStarred = true;
else if (lower === 'unstarred') mail.isStarred = false;
else if (lower === 'anything' || lower === 'any') includeTrashAndJunk = true;
else consumed = false;
break;
case 'after':
if (isValidQueryDate(value)) { after = value; mail.dateAfter = value; } else consumed = false;
break;
case 'before':
if (isValidQueryDate(value)) { before = value; mail.dateBefore = value; } else consumed = false;
break;
}
if (consumed) hasOperators = true;
else pushText(token.original, false);
}
return {
raw, text: textParts.join(' '), terms, scope, account, mail, after, before,
mailboxRole, includeTrashAndJunk, hasOperators,
};
}
/** Does a login match the `account:` operator (label substring or address prefix)? */
export function accountMatches(pattern: string | null, account: { label: string; email: string; localAccountId: string }): boolean {
if (!pattern) return true;
const p = pattern.toLowerCase();
const email = account.email.toLowerCase();
const label = account.label.toLowerCase();
return email === p || email.startsWith(p) || label.includes(p) || account.localAccountId.toLowerCase() === p;
}
/** Local matching: every term is a substring of at least one of the fields. */
export function matchesTerms(terms: string[], fields: Array<string | null | undefined>): boolean {
if (terms.length === 0) return false;
const haystack = fields.filter((f): f is string => typeof f === 'string' && f.length > 0).map((f) => f.toLowerCase());
if (haystack.length === 0) return false;
return terms.every((term) => haystack.some((field) => field.includes(term)));
}
/** Whether the parsed query has anything the server can be asked for this kind. */
export function hasRemoteQuery(parsed: ParsedQuery, kind: SearchKind): boolean {
if (parsed.text.trim()) return true;
if (kind !== 'mail') return false;
const { mail } = parsed;
return Boolean(mail.from || mail.to || mail.subject || mail.body
|| mail.hasAttachment !== null || mail.isUnread !== null || mail.isStarred !== null
|| mail.dateAfter || mail.dateBefore || parsed.mailboxRole);
}
+77
View File
@@ -0,0 +1,77 @@
import type { ParsedQuery } from './query-parser';
import type { GlobalSearchHit } from './types';
// Merging and ordering of search hits (webmail lib/global-search/rank.ts).
/**
* Identity for de-duplication: the same item reached through two logins of
* one server is one hit; ids are scoped by server and owning account (#847).
* Calendar hits collapse per series (the loaded window holds occurrences,
* the server returns the series).
*/
function mergeKey(hit: GlobalSearchHit): string {
const scope = hit.serverUrl ? `s ${hit.serverUrl} ${hit.jmapAccountId}` : `l ${hit.localAccountId} ${hit.jmapAccountId}`;
if (hit.kind === 'calendar' && hit.event.uid) return `calendar ${scope} uid:${hit.event.uid}`;
return `${hit.kind} ${scope} ${hit.id}`;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function atWordBoundary(field: string, term: string): boolean {
return new RegExp(`(^|[^\\p{L}\\p{N}])${escapeRegExp(term)}`, 'u').test(field);
}
/**
* Relevance for the typed terms: exact > prefix > whole word > substring on
* the title, then a subtitle match, then hits the server matched on a field
* that isn't shown (the body). Recency breaks ties.
*/
export function scoreHit(hit: GlobalSearchHit, parsed: ParsedQuery): number {
const query = parsed.terms.join(' ');
if (!query) return 10;
const title = hit.title.toLowerCase();
if (title === query) return 100;
if (title.startsWith(query)) return 80;
if (parsed.terms.every((term) => atWordBoundary(title, term))) return 60;
if (parsed.terms.every((term) => title.includes(term))) return 50;
if (parsed.terms.some((term) => title.includes(term))) return 30;
const subtitle = hit.subtitle.toLowerCase();
if (parsed.terms.some((term) => subtitle.includes(term))) return 20;
return 10;
}
function dateValue(hit: GlobalSearchHit): number {
if (!hit.date) return Number.NEGATIVE_INFINITY;
const ms = new Date(hit.date).getTime();
return Number.isNaN(ms) ? Number.NEGATIVE_INFINITY : ms;
}
/** Sorted copy: score, then newest first, then title. */
export function rankHits(hits: GlobalSearchHit[], parsed: ParsedQuery): GlobalSearchHit[] {
return hits
.map((hit, index) => ({ hit, index, score: scoreHit(hit, parsed) }))
.sort((a, b) =>
b.score - a.score
|| dateValue(b.hit) - dateValue(a.hit)
|| a.hit.title.localeCompare(b.hit.title)
|| a.index - b.index)
.map((entry) => entry.hit);
}
/**
* De-duplicates by identity. A server hit replaces a local one for the same
* item (it carries the snippet); a local hit never replaces a server one.
*/
export function mergeHits(existing: GlobalSearchHit[], incoming: GlobalSearchHit[]): GlobalSearchHit[] {
const byKey = new Map<string, GlobalSearchHit>();
for (const hit of existing) byKey.set(mergeKey(hit), hit);
for (const hit of incoming) {
const key = mergeKey(hit);
const current = byKey.get(key);
if (current && current.source === 'remote' && hit.source === 'local') continue;
byKey.set(key, hit);
}
return [...byKey.values()];
}
+132
View File
@@ -0,0 +1,132 @@
import { accountMatches, hasRemoteQuery, type ParsedQuery, type SearchKind } from './query-parser';
import { mergeHits, rankHits } from './rank';
import { emptyOutcome, type SearchAccount, type SearchOutcome, type SearchProvider } from './types';
// The fan-out of one search (webmail lib/global-search/run-global-search.ts).
export interface RunGlobalSearchOptions {
parsed: ParsedQuery;
accounts: SearchAccount[];
providers: readonly SearchProvider[];
localLimit: number;
remoteLimit: number;
signal: AbortSignal;
/** A fresh snapshot each time hits or statuses change; never after `signal` aborts. */
onUpdate: (outcome: SearchOutcome) => void;
concurrency?: number;
timeoutMs?: number;
includeLocal?: boolean;
}
export const DEFAULT_SEARCH_CONCURRENCY = 4;
export const DEFAULT_SEARCH_TIMEOUT_MS = 8000;
function snapshot(outcome: SearchOutcome): SearchOutcome {
const status = {} as SearchOutcome['status'];
for (const kind of Object.keys(outcome.status) as SearchKind[]) {
status[kind] = { ...outcome.status[kind], errors: [...outcome.status[kind].errors] };
}
return { hits: { ...outcome.hits }, status };
}
/** The pass over what is in memory, before any request goes out. */
export function localOutcome(
parsed: ParsedQuery,
accounts: SearchAccount[],
providers: readonly SearchProvider[],
limit: number,
): SearchOutcome {
const outcome = emptyOutcome();
const matching = accounts.filter((a) => accountMatches(parsed.account, a));
for (const provider of providers) {
if (parsed.scope !== 'all' && provider.kind !== parsed.scope) continue;
if (parsed.terms.length > 0) {
try {
outcome.hits[provider.kind] = rankHits(mergeHits([], provider.local(parsed, matching, limit)), parsed);
} catch {
// A store in the middle of a change must not break typing.
}
}
const willAsk = hasRemoteQuery(parsed, provider.kind) && matching.length > 0;
outcome.status[provider.kind].status = willAsk ? 'loading' : 'done';
}
return outcome;
}
/**
* Fans a parsed query out to every provider and login, publishing partial
* results as they land: the in-memory hits first, then each server answer
* merged, de-duplicated and re-ranked. A login that fails or times out
* becomes an error row; the others still show.
*/
export async function runGlobalSearch(options: RunGlobalSearchOptions): Promise<SearchOutcome> {
const {
parsed, providers, signal, onUpdate, localLimit, remoteLimit,
concurrency = DEFAULT_SEARCH_CONCURRENCY,
timeoutMs = DEFAULT_SEARCH_TIMEOUT_MS,
includeLocal = true,
} = options;
const accounts = options.accounts.filter((account) => accountMatches(parsed.account, account));
const outcome = includeLocal ? localOutcome(parsed, accounts, providers, localLimit) : emptyOutcome();
const publish = () => {
if (!signal.aborted) onUpdate(snapshot(outcome));
};
const active = providers.filter((p) => parsed.scope === 'all' || p.kind === parsed.scope);
type Task = { provider: SearchProvider; account: SearchAccount };
const tasks: Task[] = [];
const pending = new Map<SearchKind, number>();
for (const provider of active) {
const targets = hasRemoteQuery(parsed, provider.kind) ? accounts : [];
pending.set(provider.kind, targets.length);
outcome.status[provider.kind].status = targets.length > 0 ? 'loading' : 'done';
for (const account of targets) tasks.push({ provider, account });
}
publish();
const runTask = async ({ provider, account }: Task) => {
const { kind } = provider;
const controller = new AbortController();
const onAbort = () => controller.abort();
signal.addEventListener('abort', onAbort);
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeoutMs);
// Not every request takes a signal: race it against the abort.
const aborted = new Promise<never>((_, reject) => {
controller.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'), { name: 'AbortError' })));
});
try {
const result = await Promise.race([
provider.remote(parsed, account, { limit: remoteLimit, signal: controller.signal }),
aborted,
]);
if (signal.aborted) return;
outcome.hits[kind] = rankHits(mergeHits(outcome.hits[kind], result.hits), parsed);
if (result.hasMore) outcome.status[kind].hasMore = true;
} catch (error) {
if (signal.aborted) return;
outcome.status[kind].errors.push({
localAccountId: account.localAccountId,
accountLabel: account.label,
message: timedOut ? 'timeout' : error instanceof Error ? error.message : String(error),
});
} finally {
clearTimeout(timer);
signal.removeEventListener('abort', onAbort);
const left = (pending.get(kind) ?? 1) - 1;
pending.set(kind, left);
if (left <= 0) outcome.status[kind].status = 'done';
publish();
}
};
let next = 0;
const workers = Array.from({ length: Math.max(1, Math.min(concurrency, tasks.length)) }, async () => {
while (next < tasks.length && !signal.aborted) {
const task = tasks[next++];
await runTask(task);
}
});
await Promise.all(workers);
return snapshot(outcome);
}
+113
View File
@@ -0,0 +1,113 @@
import type { CalendarEvent, ContactCard, Email, FileNode } from '../../api/types';
import type { ParsedQuery, SearchKind } from './query-parser';
// "Search everything" (webmail lib/global-search/types.ts, #641): one query
// across mail, contacts, calendar events and files of every signed-in
// account. Every hit carries the login it is reached through
// (`localAccountId`, an account-store id), the JMAP account that owns it and
// the raw server id: ids repeat across accounts (#847), so nothing finds a
// hit again by its id alone.
export type HitSource = 'local' | 'remote';
interface HitBase {
kind: SearchKind;
/** Login (account-store id) the item is reached through. */
localAccountId: string;
/** JMAP account that owns the item (the login's own, or a shared one). */
jmapAccountId: string;
/** Whether that is not the login's own account. */
shared: boolean;
/** Raw server id, never namespaced. */
id: string;
accountLabel: string;
title: string;
/** Folder, address book, calendar or path, shown after the account label. */
subtitle: string;
/** ISO time for ordering by recency. */
date: string | null;
source: HitSource;
/** Server the login talks to: the same item reached through two logins is one hit. */
serverUrl?: string;
}
export interface MailHit extends HitBase {
kind: 'mail';
email: Email;
snippet: { subject: string | null; preview: string | null } | null;
}
export interface ContactHit extends HitBase {
kind: 'contacts';
contact: ContactCard;
/** The id the contacts store lists the card under (namespaced when shared). */
storeId: string;
}
export interface CalendarHit extends HitBase {
kind: 'calendar';
event: CalendarEvent;
isRecurring: boolean;
}
export interface FileHit extends HitBase {
kind: 'files';
node: FileNode;
/** Folder path the node lives in (`/` for the root). */
folderPath: string;
isFolder: boolean;
}
export type GlobalSearchHit = MailHit | ContactHit | CalendarHit | FileHit;
/** One login the search fans out to. */
export interface SearchAccount {
localAccountId: string;
label: string;
email: string;
serverUrl?: string;
/** The login the app is connected to right now. */
active: boolean;
}
export interface RemoteSearchResult {
hits: GlobalSearchHit[];
hasMore: boolean;
}
export interface SearchProvider {
kind: SearchKind;
/** Hits from what is already in memory: substring match, so partial words work. */
local: (parsed: ParsedQuery, accounts: SearchAccount[], limit: number) => GlobalSearchHit[];
/** One login's server hits. Throws on failure; the run turns that into an error row. */
remote: (parsed: ParsedQuery, account: SearchAccount, options: { limit: number; signal: AbortSignal }) => Promise<RemoteSearchResult>;
}
export interface SearchAccountError {
localAccountId: string;
accountLabel: string;
message: string;
}
export interface KindStatus {
status: 'idle' | 'loading' | 'done';
errors: SearchAccountError[];
hasMore: boolean;
}
export interface SearchOutcome {
hits: Record<SearchKind, GlobalSearchHit[]>;
status: Record<SearchKind, KindStatus>;
}
export function emptyOutcome(): SearchOutcome {
return {
hits: { mail: [], contacts: [], calendar: [], files: [] },
status: {
mail: { status: 'idle', errors: [], hasMore: false },
contacts: { status: 'idle', errors: [], hasMore: false },
calendar: { status: 'idle', errors: [], hasMore: false },
files: { status: 'idle', errors: [], hasMore: false },
},
};
}
@@ -0,0 +1,64 @@
import React from 'react';
import { parseSearchQuery, hasRemoteQuery, SEARCH_KINDS, type ParsedQuery, type SearchScope } from './query-parser';
import { localOutcome, runGlobalSearch } from './run';
import { emptyOutcome, type SearchAccount, type SearchOutcome, type SearchProvider } from './types';
// Keeps a search's merged outcome current (webmail hooks/use-global-search.ts):
// in-memory hits on every keystroke, server hits after a pause, partial
// results as each login answers. A newer query drops older answers.
export interface UseGlobalSearchOptions {
query: string;
/** Scope chip; an `in:` in the query wins. */
scope: SearchScope;
/** Account chip (account-store id); null = every login. */
accountId: string | null;
localLimit: number;
remoteLimit: number;
debounceMs?: number;
enabled?: boolean;
providers: readonly SearchProvider[];
accounts: () => SearchAccount[];
}
/** The parsed query with the scope chip applied (an explicit `in:` wins). */
export function scopedQuery(query: string, scope: SearchScope): ParsedQuery {
const base = parseSearchQuery(query);
return base.scope === 'all' && scope !== 'all' ? { ...base, scope } : base;
}
export function useGlobalSearch(options: UseGlobalSearchOptions): { parsed: ParsedQuery; outcome: SearchOutcome; isSearching: boolean } {
const {
query, scope, accountId, localLimit, remoteLimit, debounceMs = 300, enabled = true, providers, accounts,
} = options;
const parsed = React.useMemo(() => scopedQuery(query, scope), [query, scope]);
const [outcome, setOutcome] = React.useState<SearchOutcome>(emptyOutcome);
const searchId = React.useRef(0);
const isEmpty = parsed.terms.length === 0 && !SEARCH_KINDS.some((kind) => hasRemoteQuery(parsed, kind));
React.useEffect(() => {
const id = ++searchId.current;
if (!enabled || isEmpty) {
setOutcome(emptyOutcome());
return undefined;
}
const controller = new AbortController();
const all = accounts();
const targets = accountId ? all.filter((a) => a.localAccountId === accountId) : all;
setOutcome(localOutcome(parsed, targets, providers, localLimit));
const timer = setTimeout(() => {
void runGlobalSearch({
parsed, accounts: targets, providers, localLimit, remoteLimit, signal: controller.signal,
onUpdate: (next) => { if (id === searchId.current) setOutcome(next); },
});
}, debounceMs);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [parsed, enabled, isEmpty, accountId, accounts, providers, localLimit, remoteLimit, debounceMs]);
const isSearching = SEARCH_KINDS.some((kind) => outcome.status[kind].status === 'loading');
return { parsed, outcome, isSearching };
}
+213
View File
@@ -0,0 +1,213 @@
import type { KeywordDef, KeywordVisibility } from '../stores/keywords-store';
// Tag nesting and sidebar visibility, ported from the webmail's
// lib/keyword-nesting.ts and lib/keyword-format.ts.
//
// A tag is the JMAP keyword `$label:<id>`. Nesting reuses that single id:
// levels are joined with a slash, so `$label:work/clients` is the child of
// `$label:work`. The hierarchy lives in the keyword itself, so the webmail
// and the app agree on it without sharing their tag lists. Ids are made by
// composeKeywordId (lib/new-tag.ts).
/** Separates parent from child inside a tag id. */
export const KEYWORD_SEPARATOR = '/';
/** A tag definition placed in the hierarchy its id describes. */
export interface KeywordNode extends KeywordDef {
children: KeywordNode[];
depth: number;
}
/** Splits `work/clients/acme` into `["work", "clients", "acme"]`. */
export function keywordLevels(id: string): string[] {
return id.split(KEYWORD_SEPARATOR).filter(Boolean);
}
/** The id of the tag one level up, or null for a top-level tag. */
export function getParentKeywordId(id: string): string | null {
const index = id.lastIndexOf(KEYWORD_SEPARATOR);
return index === -1 ? null : id.slice(0, index);
}
/** True when `candidateId` sits anywhere below `ancestorId`. */
export function isKeywordDescendant(candidateId: string, ancestorId: string): boolean {
return candidateId.startsWith(`${ancestorId}${KEYWORD_SEPARATOR}`);
}
/** True when any defined tag sits below `id`. */
export function hasChildKeywords(id: string, keywords: readonly Pick<KeywordDef, 'id'>[]): boolean {
return keywords.some((keyword) => isKeywordDescendant(keyword.id, id));
}
/**
* The tag definitions as the tree their ids describe, keeping the user's
* order within each level. A tag whose direct parent is not defined stays at
* the root (callers name such a root in full). With nesting off every tag is
* its own root, so one code path draws both.
*/
export function buildKeywordTree(keywords: readonly KeywordDef[], nested = true): KeywordNode[] {
if (!nested) return keywords.map((kw) => ({ ...kw, children: [], depth: 0 }));
const nodes = new Map<string, KeywordNode>();
for (const keyword of keywords) nodes.set(keyword.id, { ...keyword, children: [], depth: 0 });
const roots: KeywordNode[] = [];
for (const keyword of keywords) {
const node = nodes.get(keyword.id)!;
const parentId = getParentKeywordId(keyword.id);
const parent = parentId ? nodes.get(parentId) : undefined;
if (parent && parent !== node) parent.children.push(node);
else roots.push(node);
}
const setDepth = (node: KeywordNode, depth: number) => {
node.depth = depth;
node.children.forEach((child) => setDepth(child, depth + 1));
};
roots.forEach((root) => setDepth(root, 0));
return roots;
}
/**
* Prunes a tag tree to the nodes worth showing. A node stays when the
* predicate accepts it or any of its descendants stays, so hiding a parent
* never strands the children below it. Depths stay as they were.
*/
export function filterKeywordTree(
nodes: readonly KeywordNode[],
isVisible: (node: KeywordNode) => boolean,
): KeywordNode[] {
const kept: KeywordNode[] = [];
for (const node of nodes) {
const children = filterKeywordTree(node.children, isVisible);
if (children.length > 0 || isVisible(node)) kept.push({ ...node, children });
}
return kept;
}
/** Total number of nodes in a tag tree, at every level. */
export function countKeywordNodes(nodes: readonly KeywordNode[]): number {
return nodes.reduce((total, node) => total + 1 + countKeywordNodes(node.children), 0);
}
/**
* The rows of a tag tree in drawing order, depth first. Children of a node
* whose id is not in `expanded` are left out; pass null to show every level.
*/
export function flattenKeywordTree(
nodes: readonly KeywordNode[],
expanded: ReadonlySet<string> | null = null,
): KeywordNode[] {
const out: KeywordNode[] = [];
const walk = (list: readonly KeywordNode[]) => {
for (const node of list) {
out.push(node);
if (node.children.length > 0 && (expanded === null || expanded.has(node.id))) walk(node.children);
}
};
walk(nodes);
return out;
}
/**
* A tag's display name, one entry per level. With nesting off a slash means
* nothing and the tag is named by its own label. With nesting on each level
* takes that tag's label, or the raw level when it has no definition.
*/
export function formatKeywordLabels(id: string, keywords: readonly KeywordDef[], nested: boolean): string[] {
const label = (levelId: string) => keywords.find((keyword) => keyword.id === levelId)?.label;
if (!nested) return [label(id) ?? id];
const levels = keywordLevels(id);
if (levels.length === 0) return [label(id) ?? id];
return levels.map((level, index) => label(levels.slice(0, index + 1).join(KEYWORD_SEPARATOR)) ?? level);
}
/** `Work/Clients/Acme` for a nested tag, its own name otherwise. */
export function formatKeyword(id: string, keywords: readonly KeywordDef[], nested: boolean): string {
return formatKeywordLabels(id, keywords, nested).join(KEYWORD_SEPARATOR);
}
/**
* The label of a row in a tree: a nested row is placed by its indentation and
* shows its own name; a root spells out its path, which matters when a level
* above it is not defined here.
*/
export function treeRowLabel(node: KeywordNode, keywords: readonly KeywordDef[], nested: boolean): string {
return node.depth === 0 ? formatKeyword(node.id, keywords, nested) : node.label;
}
/** Sidebar visibility of a tag, always shown when it was never set. */
export function getKeywordVisibility(keyword: Pick<KeywordDef, 'visibility'>): KeywordVisibility {
return keyword.visibility ?? 'show';
}
/**
* Whether a tag row shows in the sidebar (webmail sidebar isTagVisible).
* "Show if unread" keeps a tag whose count has not arrived yet, rather than
* blanking it and filling it back in; a count of zero unread hides it. The
* open tag view and "Show all" always show the tag.
*/
export function isTagVisibleInSidebar(
keyword: Pick<KeywordDef, 'id' | 'visibility'>,
counts: Readonly<Record<string, { unread: number } | undefined>>,
options: { showAll?: boolean; selectedId?: string | null } = {},
): boolean {
if (options.showAll || keyword.id === options.selectedId) return true;
const visibility = getKeywordVisibility(keyword);
if (visibility === 'hide') return false;
if (visibility === 'unread') {
const count = counts[keyword.id];
return !count || count.unread > 0;
}
return true;
}
/**
* The parents a tag being added can go under: every tag, in tree order,
* named by its full path (webmail KeywordEditForm parentOptions). An edited
* tag and the tags below it are left out.
*/
export function parentKeywordOptions(
keywords: readonly KeywordDef[],
excludeId?: string,
): { id: string; label: string; depth: number }[] {
return flattenKeywordTree(buildKeywordTree(keywords))
.filter((node) => !excludeId || (node.id !== excludeId && !isKeywordDescendant(node.id, excludeId)))
.map((node) => ({ id: node.id, label: formatKeyword(node.id, keywords, true), depth: node.depth }));
}
/** One tag row of the sidebar. */
export interface SidebarTagRow {
node: KeywordNode;
label: string;
hasChildren: boolean;
expanded: boolean;
}
/**
* The sidebar's tag rows and how many tags the visibility settings keep out
* of sight (the "Show all (N)" row), as the webmail sidebar builds them:
* the tree (or a flat list with nesting off), pruned by visibility, with the
* children of collapsed parents left out.
*/
export function sidebarTagRows(
keywords: readonly KeywordDef[],
nested: boolean,
counts: Readonly<Record<string, { unread: number } | undefined>>,
options: { showAll?: boolean; selectedId?: string | null; collapsed?: ReadonlySet<string> } = {},
): { rows: SidebarTagRow[]; hiddenCount: number } {
const tree = buildKeywordTree(keywords, nested);
const visible = filterKeywordTree(tree, (node) => isTagVisibleInSidebar(node, counts, options));
const hiddenCount = keywords.length - countKeywordNodes(visible);
const collapsed = options.collapsed ?? new Set<string>();
const rows: SidebarTagRow[] = [];
const walk = (nodes: readonly KeywordNode[]) => {
for (const node of nodes) {
const hasChildren = node.children.length > 0;
const expanded = hasChildren && !collapsed.has(node.id);
rows.push({ node, label: treeRowLabel(node, keywords, nested), hasChildren, expanded });
if (expanded) walk(node.children);
}
};
walk(visible);
return { rows, hiddenCount };
}
+31
View File
@@ -0,0 +1,31 @@
import type { Mailbox } from '../api/types';
// Folder sharing (webmail mailbox-share-dialog.tsx and the Share entry of
// mailbox-context-menu.tsx): which folders offer it, and where it is kept.
/**
* Where a folder's sharing is read and written: a folder of a shared or
* group account lives under its owner's account with the owner's id.
*/
export function mailboxShareRef(mailbox: Pick<Mailbox, 'id' | 'originalId' | 'isShared' | 'accountId'>): {
mailboxId: string;
accountId: string | undefined;
} {
return {
mailboxId: mailbox.originalId || mailbox.id,
accountId: mailbox.isShared ? mailbox.accountId : undefined,
};
}
/**
* Whether the folder's menu offers "Share": the server takes shareWith on
* folders (mail:share), and a folder someone shared with the user may be
* passed on only with their permission (webmail mailbox-context-menu.tsx).
*/
export function canShareMailbox(
mailbox: Pick<Mailbox, 'isShared' | 'myRights'>,
serverSupportsSharing: boolean,
): boolean {
if (!serverSupportsSharing) return false;
return !mailbox.isShared || (mailbox.myRights as { mayShare?: boolean } | undefined)?.mayShare === true;
}
+6 -1
View File
@@ -80,7 +80,12 @@ export function parseSharedTags(value: unknown): KeywordDef[] {
return Object.entries(entriesOf(value))
.map(([id, entry]) => {
const label = typeof entry.label === 'string' ? entry.label.trim().slice(0, MAX_SHARED_TAG_LABEL) : '';
return { id, label: label || id, color: keywordColorFromPalette(entry.color) };
const def: KeywordDef = { id, label: label || id, color: keywordColorFromPalette(entry.color) };
// The sidebar visibility a member set for the group (webmail readSharedTags).
if (entry.visibility === 'show' || entry.visibility === 'hide' || entry.visibility === 'unread') {
def.visibility = entry.visibility;
}
return def;
})
.sort((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id));
}
+31 -5
View File
@@ -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';
@@ -41,6 +42,7 @@ import {
} from '../stores/email-store';
import { useSettingsStore, type SwipeAction, type SwipeMode } from '../stores/settings-store';
import { useKeywordsStore, currentTagsLogin, type KeywordDef } from '../stores/keywords-store';
import { formatKeyword } from '../lib/keyword-nesting';
import { mergeSharedTags } from '../lib/shared-tags';
import { useBackWhileFocused } from '../lib/use-back-while-focused';
import { listBackStep } from '../lib/list-back';
@@ -196,17 +198,19 @@ const EmailRow = React.memo(function EmailRow({
const unread = isUnread(item);
const starred = isStarred(item);
const pinned = isPinned(item);
// A nested tag is named by its path, "Work/Clients" (webmail tag badge).
const nestedTags = useKeywordsStore((s) => s.nested);
const tags = React.useMemo(() => {
if (!tagIds) return [];
return tagIds.split(',').map((id) => {
const def = keywordDefs.find((k) => k.id === id);
return def
? { id, label: def.label, dot: c.tags[def.color]?.dot ?? c.textMuted, text: c.tags[def.color]?.text ?? c.textSecondary, bg: c.tags[def.color]?.bg ?? c.muted }
? { id, label: formatKeyword(id, keywordDefs, nestedTags), dot: c.tags[def.color]?.dot ?? c.textMuted, text: c.tags[def.color]?.text ?? c.textSecondary, bg: c.tags[def.color]?.bg ?? c.muted }
// A tag no local definition explains (set by another client): grey,
// with the raw id so it is at least visible and removable.
: { id, label: id, dot: c.tags.gray.dot, text: c.tags.gray.text, bg: c.tags.gray.bg };
: { id, label: formatKeyword(id, keywordDefs, nestedTags), dot: c.tags.gray.dot, text: c.tags.gray.text, bg: c.tags.gray.bg };
});
}, [tagIds, keywordDefs, c]);
}, [tagIds, keywordDefs, nestedTags, c]);
// The row's key (`rowKeyOf`): ids repeat across the accounts of a list
// spanning accounts (#1082).
@@ -1060,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).
@@ -1552,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 && (
<View style={styles.recentSearches}>
<Pressable
style={[styles.recentSearchRow, styles.recentSearchMain]}
onPress={() => {
setSearchFocused(false);
searchInputRef.current?.blur();
setGlobalSearchOpen(true);
}}
accessibilityRole="button"
testID="search-everything"
>
<Globe size={12} color={c.textMuted} />
<Text style={styles.recentSearchText} numberOfLines={1}>{t('global_search.title', 'Search everything')}</Text>
</Pressable>
{!searchInput.trim() && recentSearches.length > 0 && (
<>
<Text style={styles.recentSearchesTitle}>{t('advanced_search.suggestions_recent', 'Recent searches')}</Text>
@@ -1858,6 +1878,12 @@ export default function EmailListScreen({
</Pressable>
{!sidebarDocked && <SidebarDrawer visible={drawerOpen} onClose={() => setDrawerOpen(false)} />}
<GlobalSearchSheet
visible={globalSearchOpen}
initialQuery={searchInput}
onClose={() => setGlobalSearchOpen(false)}
navigation={navigation}
/>
<ListAttachmentOpener ref={attachmentOpenerRef} />
<Modal supportedOrientations={MODAL_ORIENTATIONS}
@@ -0,0 +1,36 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import AsyncStorage from '@react-native-async-storage/async-storage';
vi.mock('../../api/jmap-client', () => ({ jmapClient: { username: null, serverUrl: null, connectedAccountId: null } }));
vi.mock('../../api/bulwark-metadata', () => ({ getBulwarkMetadata: vi.fn() }));
import { useKeywordsStore, DEFAULT_KEYWORDS } from '../keywords-store';
beforeEach(async () => {
await AsyncStorage.clear();
useKeywordsStore.setState({ keywords: DEFAULT_KEYWORDS, nested: false, hydrated: false });
});
describe('nested tags and visibility in the tag store', () => {
it('keeps "Nested tags" across a restart', async () => {
useKeywordsStore.getState().setNested(true);
expect(useKeywordsStore.getState().nested).toBe(true);
await Promise.resolve();
useKeywordsStore.setState({ nested: false, hydrated: false });
await useKeywordsStore.getState().hydrate();
expect(useKeywordsStore.getState().nested).toBe(true);
});
it('starts with nesting off, as the webmail does', async () => {
await useKeywordsStore.getState().hydrate();
expect(useKeywordsStore.getState().nested).toBe(false);
});
it("stores a tag's sidebar visibility with the tag, in the webmail's shape", async () => {
useKeywordsStore.getState().add({ id: 'work/clients', label: 'Clients', color: 'teal' });
useKeywordsStore.getState().update('work/clients', { visibility: 'unread' });
await Promise.resolve();
const stored = JSON.parse((await AsyncStorage.getItem('webmail:keywords:v1')) ?? '[]');
expect(stored).toContainEqual({ id: 'work/clients', label: 'Clients', color: 'teal', visibility: 'unread' });
});
});
+34 -3
View File
@@ -10,12 +10,24 @@ const STORAGE_KEY = 'webmail:keywords:v1';
export type KeywordColor = keyof typeof colors.tags;
/**
* Whether a tag shows in the sidebar always, only while it has unread mail,
* or never (webmail KeywordVisibility).
*/
export type KeywordVisibility = 'show' | 'hide' | 'unread';
export interface KeywordDef {
/** JMAP keyword suffix, `$label:<id>`; `parent/child` when nested. */
id: string;
label: string;
color: KeywordColor;
/** Absent on tags stored before this was configurable: shown. */
visibility?: KeywordVisibility;
}
/** "Nested tags" (webmail nestedTags): a slash in a tag id is a level. */
const NESTED_STORAGE_KEY = 'webmail:keywords:nested:v1';
// Same ids/labels/colours as the webmail's DEFAULT_KEYWORDS
// (stores/settings-store.ts): the default tags are named after their colour,
// so a message tagged `$label:blue` on one client is the same tag on the
@@ -55,6 +67,12 @@ export const SHARED_TAGS_STALE_MS = 2 * 60_000;
interface KeywordsState {
keywords: KeywordDef[];
hydrated: boolean;
/**
* Nested tags on: `work/clients` is drawn under `work` in the sidebar,
* the tag sheet and Settings. Off by default, as in the webmail.
*/
nested: boolean;
setNested: (nested: boolean) => void;
/** Shared tag definitions of the group accounts, by JMAP account id. */
sharedTags: Record<string, SharedTagsEntry>;
/**
@@ -86,7 +104,7 @@ const sharedReadAt = new Map<string, number>();
function sameTags(a: KeywordDef[], b: KeywordDef[]): boolean {
return a.length === b.length
&& a.every((t, i) => t.id === b[i].id && t.label === b[i].label && t.color === b[i].color);
&& a.every((t, i) => t.id === b[i].id && t.label === b[i].label && t.color === b[i].color && t.visibility === b[i].visibility);
}
function persist(keywords: KeywordDef[]): void {
@@ -98,8 +116,16 @@ function persist(keywords: KeywordDef[]): void {
export const useKeywordsStore = create<KeywordsState>((set, get) => ({
keywords: DEFAULT_KEYWORDS,
hydrated: false,
nested: false,
sharedTags: {},
setNested: (nested) => {
set({ nested });
void AsyncStorage.setItem(NESTED_STORAGE_KEY, nested ? 'true' : 'false').catch((err) => {
console.warn('[keywords-store] persist nested failed', err);
});
},
loadSharedTags: (accountId, options) => {
const login = currentTagsLogin();
if (!login || !accountId || accountId === jmapClient.connectedAccountId) return Promise.resolve();
@@ -137,14 +163,19 @@ export const useKeywordsStore = create<KeywordsState>((set, get) => ({
hydrate: async () => {
if (get().hydrated) return;
try {
const raw = await AsyncStorage.getItem(STORAGE_KEY);
const [raw, rawNested] = await Promise.all([
AsyncStorage.getItem(STORAGE_KEY),
AsyncStorage.getItem(NESTED_STORAGE_KEY),
]);
const nested = rawNested === 'true';
if (raw) {
const parsed = JSON.parse(raw) as KeywordDef[];
if (Array.isArray(parsed)) {
set({ keywords: parsed, hydrated: true });
set({ keywords: parsed, nested, hydrated: true });
return;
}
}
set({ nested });
} catch (err) {
console.warn('[keywords-store] hydrate failed', err);
}