mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Merge branch 'cu-tagsx' into catch-up-2026-11
This commit is contained in:
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,516 @@
|
||||
// "Search everything": the search providers (webmail
|
||||
// lib/global-search/providers/*.ts and accounts.ts).
|
||||
//
|
||||
// The JMAP client is one live session, so the signed-in login goes through
|
||||
// it, and every other login is asked "detached": with its stored
|
||||
// credentials, posting to its own API URL (as the unified inbox does), so the
|
||||
// live session is never disturbed. Shared and group accounts inside a login
|
||||
// are searched too.
|
||||
|
||||
import { jmapClient, rewriteSessionUrl, extractOrigin, type StoredCredentials } from './jmap-client';
|
||||
import { CAPABILITIES, type CalendarEvent, type ContactCard, type Email, type FileNode, type JMAPMethodCall, type JMAPSession } from './types';
|
||||
import { fetchWithDeadline } from './unified-inbox';
|
||||
import { getAllFileNodesAcrossAccounts, fileNodeKind } from './files';
|
||||
import { namespaceId } from './contacts';
|
||||
import { freshStoredCredentials } from '../lib/stored-token-refresh';
|
||||
import { generateAccountId } from '../lib/account-utils';
|
||||
import { accountSupports } from '../lib/account-capability';
|
||||
import { contactFromWire } from '../lib/contact-wire';
|
||||
import { getContactDisplayName, getContactPrimaryEmail } from '../lib/contact-utils';
|
||||
import { textFilter } from '../lib/search-utils';
|
||||
import { snippetFilterFor, snippetHighlight, type SearchSnippetResult } from '../lib/search-snippet';
|
||||
import { matchesTerms, type ParsedQuery } from '../lib/global-search/query-parser';
|
||||
import {
|
||||
calendarFilterFor, contactFields, emailFields, emailMatchesFilters, eventFields, isRecurringEvent,
|
||||
mailFilterFor, pathOfNode, type MailboxRef,
|
||||
} from '../lib/global-search/filters';
|
||||
import type {
|
||||
CalendarHit, ContactHit, FileHit, GlobalSearchHit, MailHit, SearchAccount, SearchProvider,
|
||||
} from '../lib/global-search/types';
|
||||
import { useAccountStore } from '../stores/account-store';
|
||||
import { useAuthStore } from '../stores/auth-store';
|
||||
import { useEmailStore } from '../stores/email-store';
|
||||
import { useContactsStore } from '../stores/contacts-store';
|
||||
import { useCalendarStore } from '../stores/calendar-store';
|
||||
|
||||
type MethodResponses = Array<[string, Record<string, any>, string]>;
|
||||
|
||||
const SESSION_TTL_MS = 10 * 60_000;
|
||||
const MAILBOX_TTL_MS = 60_000;
|
||||
/** Files have no usable server search: the listing is filtered, cached a minute. */
|
||||
export const FILE_LISTING_TTL_MS = 60_000;
|
||||
|
||||
const MAIL_PROPERTIES = [
|
||||
'id', 'threadId', 'mailboxIds', 'keywords', 'receivedAt', 'from', 'to', 'cc',
|
||||
'subject', 'preview', 'hasAttachment', 'size',
|
||||
];
|
||||
const EVENT_PROPERTIES = [
|
||||
'id', 'uid', 'calendarIds', 'title', 'description', 'start', 'duration', 'timeZone',
|
||||
'showWithoutTime', 'locations', 'participants', 'recurrenceRule', 'recurrenceId',
|
||||
];
|
||||
const DETACHED_FILE_PROPERTIES = ['id', 'parentId', 'name', 'type', 'blobId', 'size', 'created', 'modified', 'nodeType'];
|
||||
|
||||
// ── Logins ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Every login the search fans out to (webmail getSearchAccounts). */
|
||||
export function getSearchAccounts(): SearchAccount[] {
|
||||
const { accounts } = useAccountStore.getState();
|
||||
const activeId = useAuthStore.getState().activeAccountId;
|
||||
return accounts
|
||||
.filter((a) => !a.needsSignIn)
|
||||
.map((a) => ({
|
||||
localAccountId: a.id,
|
||||
label: a.displayName || a.email || a.username,
|
||||
email: a.email || a.username,
|
||||
serverUrl: a.serverUrl,
|
||||
active: a.id === activeId,
|
||||
}));
|
||||
}
|
||||
|
||||
interface LoginSession {
|
||||
session: JMAPSession;
|
||||
post: (calls: JMAPMethodCall[], using: string[]) => Promise<MethodResponses>;
|
||||
live: boolean;
|
||||
}
|
||||
|
||||
const sessions = new Map<string, { at: number; value: Promise<LoginSession> }>();
|
||||
|
||||
/** Forget the detached sessions (sign-out, tests). */
|
||||
export function resetGlobalSearchCache(): void {
|
||||
sessions.clear();
|
||||
mailboxCache.clear();
|
||||
fileListings.clear();
|
||||
}
|
||||
|
||||
function isLiveLogin(localAccountId: string): boolean {
|
||||
if (!jmapClient.isConnected || !jmapClient.currentSession) return false;
|
||||
const { username, serverUrl } = jmapClient;
|
||||
return !!username && !!serverUrl && generateAccountId(username, serverUrl) === localAccountId;
|
||||
}
|
||||
|
||||
function authHeader(creds: StoredCredentials): string {
|
||||
if (creds.accessToken) return `Bearer ${creds.accessToken}`;
|
||||
return `Basic ${btoa(`${creds.username}:${creds.password}`)}`;
|
||||
}
|
||||
|
||||
async function openDetached(localAccountId: string): Promise<LoginSession> {
|
||||
let creds = await jmapClient.getStoredCredentials(localAccountId);
|
||||
if (!creds) throw new Error('No stored credentials');
|
||||
creds = await freshStoredCredentials(localAccountId, creds);
|
||||
const base = creds.serverUrl.replace(/\/+$/, '');
|
||||
const auth = authHeader(creds);
|
||||
const sessionRes = await fetchWithDeadline(`${base}/.well-known/jmap`, {
|
||||
headers: { Authorization: auth, Accept: 'application/json' },
|
||||
});
|
||||
const session = (await sessionRes.json()) as JMAPSession;
|
||||
const apiUrl = rewriteSessionUrl(session.apiUrl, extractOrigin(base)) ?? session.apiUrl;
|
||||
return {
|
||||
session,
|
||||
live: false,
|
||||
post: async (calls, using) => {
|
||||
try {
|
||||
const res = await fetchWithDeadline(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: auth },
|
||||
body: JSON.stringify({ using, methodCalls: calls }),
|
||||
});
|
||||
const body = await res.json();
|
||||
return (body.methodResponses ?? []) as MethodResponses;
|
||||
} catch (err) {
|
||||
// A dead session must not be reused for the next search.
|
||||
if (err instanceof Error && err.message === 'Session expired') sessions.delete(localAccountId);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function loginSession(account: SearchAccount): Promise<LoginSession> {
|
||||
if (isLiveLogin(account.localAccountId)) {
|
||||
return {
|
||||
session: jmapClient.currentSession!,
|
||||
live: true,
|
||||
post: async (calls, using) => (await jmapClient.request(calls, using)).methodResponses as MethodResponses,
|
||||
};
|
||||
}
|
||||
const cached = sessions.get(account.localAccountId);
|
||||
if (cached && Date.now() - cached.at < SESSION_TTL_MS) return cached.value;
|
||||
const value = openDetached(account.localAccountId);
|
||||
sessions.set(account.localAccountId, { at: Date.now(), value });
|
||||
value.catch(() => sessions.delete(account.localAccountId));
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* The JMAP accounts of a login that hold a kind: its primary one first, then
|
||||
* the shared and group accounts the user may use it with.
|
||||
*/
|
||||
export function accountsFor(session: JMAPSession, capability: string): { id: string; name: string; primary: boolean }[] {
|
||||
const primaryId = session.primaryAccounts?.[capability] ?? session.primaryAccounts?.[CAPABILITIES.MAIL];
|
||||
const out: { id: string; name: string; primary: boolean }[] = [];
|
||||
if (primaryId && session.accounts?.[primaryId]) out.push({ id: primaryId, name: session.accounts[primaryId].name || primaryId, primary: true });
|
||||
for (const [id, info] of Object.entries(session.accounts ?? {})) {
|
||||
if (id === primaryId) continue;
|
||||
if (!accountSupports(info, session.capabilities, capability)) continue;
|
||||
out.push({ id, name: info.name || id, primary: false });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function responseOf(responses: MethodResponses, callId: string, method: string): Record<string, any> {
|
||||
const match = responses.find((r) => r[2] === callId);
|
||||
if (!match) throw new Error(`Missing response to ${method}`);
|
||||
if (match[0] === 'error') throw new Error((match[1] as { description?: string; type?: string }).description || (match[1] as { type?: string }).type || `${method} failed`);
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function abortIfNeeded(signal: AbortSignal): void {
|
||||
if (signal.aborted) throw Object.assign(new Error('aborted'), { name: 'AbortError' });
|
||||
}
|
||||
|
||||
// ── Mail ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const mailboxCache = new Map<string, { at: number; list: MailboxRef[] }>();
|
||||
|
||||
async function mailboxesOf(login: LoginSession, localAccountId: string, accountId: string): Promise<MailboxRef[]> {
|
||||
const key = `${localAccountId}|${accountId}`;
|
||||
const cached = mailboxCache.get(key);
|
||||
if (cached && Date.now() - cached.at < MAILBOX_TTL_MS) return cached.list;
|
||||
const res = await login.post([['Mailbox/get', { accountId, properties: ['id', 'name', 'role'] }, '0']], [CAPABILITIES.CORE, CAPABILITIES.MAIL]);
|
||||
const list = (responseOf(res, '0', 'Mailbox/get').list ?? []) as MailboxRef[];
|
||||
mailboxCache.set(key, { at: Date.now(), list });
|
||||
return list;
|
||||
}
|
||||
|
||||
function folderName(email: Pick<Email, 'mailboxIds'>, mailboxes: MailboxRef[]): string {
|
||||
for (const id of Object.keys(email.mailboxIds ?? {})) {
|
||||
const mailbox = mailboxes.find((m) => m.id === id);
|
||||
if (mailbox) return mailbox.name;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function mailHit(
|
||||
email: Email, account: SearchAccount, jmapAccountId: string, shared: boolean, folder: string, source: 'local' | 'remote',
|
||||
): MailHit {
|
||||
return {
|
||||
kind: 'mail', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
|
||||
id: email.id, accountLabel: account.label, title: email.subject ?? '', subtitle: folder,
|
||||
date: email.receivedAt ?? null, source, email, snippet: email.searchSnippet ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One account's mail search: Email/query with the parsed filter (all
|
||||
* folders but Trash and Junk), its messages, and the search snippets in the
|
||||
* same request; a server without snippets still lists the messages.
|
||||
*/
|
||||
async function searchMailAccount(
|
||||
login: LoginSession, parsed: ParsedQuery, accountId: string, mailboxes: MailboxRef[], limit: number,
|
||||
): Promise<{ emails: Email[]; hasMore: boolean }> {
|
||||
const filter = mailFilterFor(parsed, mailboxes);
|
||||
const calls: JMAPMethodCall[] = [
|
||||
['Email/query', { accountId, filter, sort: [{ property: 'receivedAt', isAscending: false }], limit: limit + 1, collapseThreads: false }, 'q'],
|
||||
['Email/get', { accountId, '#ids': { resultOf: 'q', name: 'Email/query', path: '/ids' }, properties: MAIL_PROPERTIES }, 'g'],
|
||||
['SearchSnippet/get', { accountId, filter: snippetFilterFor(filter), '#emailIds': { resultOf: 'q', name: 'Email/query', path: '/ids' } }, 's'],
|
||||
];
|
||||
const res = await login.post(calls, [CAPABILITIES.CORE, CAPABILITIES.MAIL]);
|
||||
const ids = (responseOf(res, 'q', 'Email/query').ids ?? []) as string[];
|
||||
const list = (responseOf(res, 'g', 'Email/get').list ?? []) as Email[];
|
||||
let snippets: SearchSnippetResult[] = [];
|
||||
try {
|
||||
snippets = (responseOf(res, 's', 'SearchSnippet/get').list ?? []) as SearchSnippetResult[];
|
||||
} catch {
|
||||
// Snippets only decorate the rows.
|
||||
}
|
||||
const byId = new Map(list.map((e) => [e.id, e]));
|
||||
const emails = ids.slice(0, limit).map((id) => byId.get(id)).filter((e): e is Email => !!e).map((email) => {
|
||||
const snippet = snippets.find((s) => s.emailId === email.id);
|
||||
const highlight = snippet ? snippetHighlight(snippet) : null;
|
||||
return highlight ? { ...email, searchSnippet: highlight } : email;
|
||||
});
|
||||
return { emails, hasMore: ids.length > limit };
|
||||
}
|
||||
|
||||
export const mailProvider: SearchProvider = {
|
||||
kind: 'mail',
|
||||
local: (parsed, accounts, limit) => {
|
||||
const account = accounts.find((a) => a.active);
|
||||
if (!account) return [];
|
||||
const { emails, mailboxes } = useEmailStore.getState();
|
||||
let primary = '';
|
||||
try { primary = jmapClient.accountId; } catch { /* no session */ }
|
||||
const hits: MailHit[] = [];
|
||||
for (const email of emails) {
|
||||
if (!matchesTerms(parsed.terms, emailFields(email))) continue;
|
||||
if (!emailMatchesFilters(email, parsed)) continue;
|
||||
const mailbox = mailboxes.find((mb) => email.mailboxIds?.[mb.originalId ?? mb.id] || email.mailboxIds?.[mb.id]);
|
||||
const jmapAccountId = email.jmapAccountId ?? (mailbox?.isShared && mailbox.accountId ? mailbox.accountId : primary);
|
||||
hits.push(mailHit(email, account, jmapAccountId, jmapAccountId !== primary, mailbox?.name ?? '', 'local'));
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
},
|
||||
remote: async (parsed, account, { limit, signal }) => {
|
||||
const login = await loginSession(account);
|
||||
const hits: MailHit[] = [];
|
||||
let hasMore = false;
|
||||
// One account after the other: the run already asks the logins in parallel.
|
||||
for (const target of accountsFor(login.session, CAPABILITIES.MAIL)) {
|
||||
abortIfNeeded(signal);
|
||||
const mailboxes = await mailboxesOf(login, account.localAccountId, target.id);
|
||||
const result = await searchMailAccount(login, parsed, target.id, mailboxes, limit);
|
||||
if (result.hasMore) hasMore = true;
|
||||
for (const email of result.emails) {
|
||||
const label = target.primary ? folderName(email, mailboxes) : [target.name, folderName(email, mailboxes)].filter(Boolean).join(' · ');
|
||||
hits.push(mailHit(email, account, target.id, !target.primary, label, 'remote'));
|
||||
}
|
||||
}
|
||||
return { hits, hasMore };
|
||||
},
|
||||
};
|
||||
|
||||
// ── Contacts ─────────────────────────────────────────────────────────────
|
||||
|
||||
function contactHit(
|
||||
contact: ContactCard, account: SearchAccount, jmapAccountId: string, shared: boolean, storeId: string,
|
||||
bookName: string, source: 'local' | 'remote',
|
||||
): ContactHit {
|
||||
const name = getContactDisplayName(contact);
|
||||
const email = getContactPrimaryEmail(contact);
|
||||
const title = name || email || contact.originalId || contact.id;
|
||||
return {
|
||||
kind: 'contacts', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
|
||||
id: contact.originalId ?? contact.id, accountLabel: account.label, title,
|
||||
subtitle: [bookName, email && email !== title ? email : ''].filter(Boolean).join(' · '),
|
||||
date: contact.updated ?? null, source, contact, storeId,
|
||||
};
|
||||
}
|
||||
|
||||
export const contactsProvider: SearchProvider = {
|
||||
kind: 'contacts',
|
||||
local: (parsed, accounts, limit) => {
|
||||
const account = accounts.find((a) => a.active);
|
||||
if (!account) return [];
|
||||
const { contacts, addressBooks } = useContactsStore.getState();
|
||||
const primary = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CONTACTS] ?? '';
|
||||
const hits: ContactHit[] = [];
|
||||
for (const contact of contacts) {
|
||||
if (!matchesTerms(parsed.terms, contactFields(contact, getContactDisplayName(contact)))) continue;
|
||||
const bookId = Object.keys(contact.addressBookIds ?? {})[0];
|
||||
const book = addressBooks.find((b) => b.id === bookId || b.originalId === bookId);
|
||||
const jmapAccountId = contact.accountId ?? primary;
|
||||
hits.push(contactHit(contact, account, jmapAccountId, !!contact.isShared, contact.id, book?.name ?? '', 'local'));
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
},
|
||||
remote: async (parsed, account, { limit, signal }) => {
|
||||
const login = await loginSession(account);
|
||||
const hits: ContactHit[] = [];
|
||||
let hasMore = false;
|
||||
for (const target of accountsFor(login.session, CAPABILITIES.CONTACTS)) {
|
||||
abortIfNeeded(signal);
|
||||
if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.CONTACTS)) continue;
|
||||
const res = await login.post([
|
||||
['ContactCard/query', { accountId: target.id, filter: textFilter(parsed.text), limit: limit + 1 }, 'q'],
|
||||
['ContactCard/get', { accountId: target.id, '#ids': { resultOf: 'q', name: 'ContactCard/query', path: '/ids' } }, 'g'],
|
||||
['AddressBook/get', { accountId: target.id, properties: ['id', 'name'] }, 'b'],
|
||||
], [CAPABILITIES.CORE, CAPABILITIES.CONTACTS]);
|
||||
const ids = (responseOf(res, 'q', 'ContactCard/query').ids ?? []) as string[];
|
||||
const list = ((responseOf(res, 'g', 'ContactCard/get').list ?? []) as ContactCard[]).map((c) => contactFromWire(c));
|
||||
let books: { id: string; name: string }[] = [];
|
||||
try { books = responseOf(res, 'b', 'AddressBook/get').list ?? []; } catch { /* names only */ }
|
||||
if (ids.length > limit) hasMore = true;
|
||||
for (const contact of list.slice(0, limit)) {
|
||||
const bookId = Object.keys(contact.addressBookIds ?? {})[0];
|
||||
const bookName = books.find((b) => b.id === bookId)?.name ?? '';
|
||||
const storeId = target.primary ? contact.id : namespaceId(target.id, contact.id);
|
||||
const shown: ContactCard = target.primary ? contact : { ...contact, id: storeId, originalId: contact.id, accountId: target.id, isShared: true };
|
||||
hits.push(contactHit(shown, account, target.id, !target.primary, storeId,
|
||||
target.primary ? bookName : [target.name, bookName].filter(Boolean).join(' · '), 'remote'));
|
||||
}
|
||||
}
|
||||
return { hits, hasMore };
|
||||
},
|
||||
};
|
||||
|
||||
// ── Calendar ─────────────────────────────────────────────────────────────
|
||||
|
||||
function eventHit(
|
||||
event: CalendarEvent, account: SearchAccount, jmapAccountId: string, shared: boolean, calendarName: string, source: 'local' | 'remote',
|
||||
): CalendarHit {
|
||||
const location = Object.values(event.locations ?? {}).find((l) => l?.name)?.name ?? '';
|
||||
return {
|
||||
kind: 'calendar', serverUrl: account.serverUrl, localAccountId: account.localAccountId, jmapAccountId, shared,
|
||||
id: event.originalId ?? event.id, accountLabel: account.label, title: event.title || '',
|
||||
subtitle: [calendarName, location].filter(Boolean).join(' · '),
|
||||
date: event.start ?? null, source, event, isRecurring: isRecurringEvent(event),
|
||||
};
|
||||
}
|
||||
|
||||
/** Stalwart names it recurrenceRule (singular); the app's type has the plural. */
|
||||
function eventFromWire(raw: Record<string, any>): CalendarEvent {
|
||||
const rules = raw.recurrenceRules ?? (raw.recurrenceRule ? [raw.recurrenceRule] : null);
|
||||
return { ...raw, recurrenceRules: rules } as CalendarEvent;
|
||||
}
|
||||
|
||||
export const calendarProvider: SearchProvider = {
|
||||
kind: 'calendar',
|
||||
local: (parsed, accounts, limit) => {
|
||||
const account = accounts.find((a) => a.active);
|
||||
if (!account) return [];
|
||||
const { events, calendars } = useCalendarStore.getState();
|
||||
const primary = jmapClient.currentSession?.primaryAccounts?.[CAPABILITIES.CALENDARS] ?? '';
|
||||
const hits: CalendarHit[] = [];
|
||||
// The store holds occurrences; a matching series shows once.
|
||||
const seen = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (!matchesTerms(parsed.terms, eventFields(event))) continue;
|
||||
const day = event.start?.slice(0, 10);
|
||||
if (parsed.after && day && day < parsed.after) continue;
|
||||
if (parsed.before && day && day > parsed.before) continue;
|
||||
if (event.uid) {
|
||||
if (seen.has(event.uid)) continue;
|
||||
seen.add(event.uid);
|
||||
}
|
||||
const calendarId = Object.keys(event.calendarIds ?? {})[0];
|
||||
const calendar = calendars.find((c) => c.id === calendarId);
|
||||
const jmapAccountId = event.accountId ?? primary;
|
||||
hits.push(eventHit(event, account, jmapAccountId, !!event.isShared, calendar?.name ?? '', 'local'));
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
},
|
||||
remote: async (parsed, account, { limit, signal }) => {
|
||||
const login = await loginSession(account);
|
||||
const hits: CalendarHit[] = [];
|
||||
let hasMore = false;
|
||||
for (const target of accountsFor(login.session, CAPABILITIES.CALENDARS)) {
|
||||
abortIfNeeded(signal);
|
||||
if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.CALENDARS)) continue;
|
||||
const res = await login.post([
|
||||
['CalendarEvent/query', { accountId: target.id, filter: calendarFilterFor(parsed), sort: [{ property: 'start', isAscending: false }], limit }, 'q'],
|
||||
['CalendarEvent/get', { accountId: target.id, '#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' }, properties: EVENT_PROPERTIES }, 'g'],
|
||||
['Calendar/get', { accountId: target.id, properties: ['id', 'name'] }, 'c'],
|
||||
], [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]);
|
||||
const ids = (responseOf(res, 'q', 'CalendarEvent/query').ids ?? []) as string[];
|
||||
const list = ((responseOf(res, 'g', 'CalendarEvent/get').list ?? []) as Record<string, any>[]).map(eventFromWire);
|
||||
let calendars: { id: string; name: string }[] = [];
|
||||
try { calendars = responseOf(res, 'c', 'Calendar/get').list ?? []; } catch { /* names only */ }
|
||||
if (ids.length >= limit) hasMore = true;
|
||||
for (const event of list) {
|
||||
const calendarId = Object.keys(event.calendarIds ?? {})[0];
|
||||
const name = calendars.find((c) => c.id === calendarId)?.name ?? '';
|
||||
hits.push(eventHit(event, account, target.id, !target.primary,
|
||||
target.primary ? name : [target.name, name].filter(Boolean).join(' · '), 'remote'));
|
||||
}
|
||||
}
|
||||
return { hits, hasMore };
|
||||
},
|
||||
};
|
||||
|
||||
// ── Files ────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FileListing {
|
||||
at: number;
|
||||
nodes: FileNode[] | null;
|
||||
promise: Promise<FileNode[]>;
|
||||
}
|
||||
|
||||
const fileListings = new Map<string, FileListing>();
|
||||
|
||||
/** Raw server id of a node (shared ones are listed as `<owner>:<id>`). */
|
||||
export function rawFileNodeId(node: FileNode): string {
|
||||
if (node.isShared && node.accountId && node.id.startsWith(`${node.accountId}:`)) return node.id.slice(node.accountId.length + 1);
|
||||
return node.id;
|
||||
}
|
||||
|
||||
async function listDetachedFiles(login: LoginSession): Promise<FileNode[]> {
|
||||
const all: FileNode[] = [];
|
||||
for (const target of accountsFor(login.session, CAPABILITIES.FILES)) {
|
||||
if (!accountSupports(login.session.accounts?.[target.id], login.session.capabilities, CAPABILITIES.FILES)) continue;
|
||||
try {
|
||||
const res = await login.post([['FileNode/get', { accountId: target.id, ids: null, properties: DETACHED_FILE_PROPERTIES }, '0']], [CAPABILITIES.CORE, CAPABILITIES.FILES]);
|
||||
for (const node of (responseOf(res, '0', 'FileNode/get').list ?? []) as FileNode[]) {
|
||||
all.push(target.primary ? node : {
|
||||
...node,
|
||||
id: `${target.id}:${node.id}`,
|
||||
parentId: node.parentId == null ? null : `${target.id}:${node.parentId}`,
|
||||
accountId: target.id, accountName: target.name, isShared: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (target.primary) throw err;
|
||||
}
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
function fileListing(account: SearchAccount, login: LoginSession): FileListing {
|
||||
const cached = fileListings.get(account.localAccountId);
|
||||
if (cached && Date.now() - cached.at < FILE_LISTING_TTL_MS) return cached;
|
||||
const entry: FileListing = {
|
||||
at: Date.now(),
|
||||
nodes: null,
|
||||
promise: (login.live ? getAllFileNodesAcrossAccounts() : listDetachedFiles(login)).then((nodes) => {
|
||||
entry.nodes = nodes;
|
||||
return nodes;
|
||||
}, (err) => {
|
||||
// A failed listing must not be served as "no files" for a minute.
|
||||
if (fileListings.get(account.localAccountId) === entry) fileListings.delete(account.localAccountId);
|
||||
throw err;
|
||||
}),
|
||||
};
|
||||
fileListings.set(account.localAccountId, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAccount, limit: number, source: 'local' | 'remote', primary: string): FileHit[] {
|
||||
const hits: FileHit[] = [];
|
||||
for (const node of nodes) {
|
||||
if (node.discoverableOnly) continue;
|
||||
const kind = fileNodeKind(node);
|
||||
if (kind === 'symlink') continue;
|
||||
if (!matchesTerms(parsed.terms, [node.name, kind === 'directory' ? 'folder' : node.type])) continue;
|
||||
const folderPath = pathOfNode(nodes, node);
|
||||
hits.push({
|
||||
kind: 'files', serverUrl: account.serverUrl, localAccountId: account.localAccountId,
|
||||
jmapAccountId: node.accountId ?? primary, shared: !!node.isShared,
|
||||
id: rawFileNodeId(node), accountLabel: account.label, title: node.name,
|
||||
subtitle: [node.isShared && node.accountName ? node.accountName : '', folderPath].filter(Boolean).join(' · '),
|
||||
date: node.modified || node.created || null, source, node, folderPath, isFolder: kind === 'directory',
|
||||
});
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
|
||||
export const filesProvider: SearchProvider = {
|
||||
kind: 'files',
|
||||
local: (parsed, accounts, limit) => {
|
||||
const hits: FileHit[] = [];
|
||||
for (const account of accounts) {
|
||||
const nodes = fileListings.get(account.localAccountId)?.nodes;
|
||||
if (!nodes) continue;
|
||||
hits.push(...filterNodes(parsed, nodes, account, limit - hits.length, 'local', ''));
|
||||
if (hits.length >= limit) break;
|
||||
}
|
||||
return hits;
|
||||
},
|
||||
remote: async (parsed, account, { limit, signal }) => {
|
||||
const login = await loginSession(account);
|
||||
if (accountsFor(login.session, CAPABILITIES.FILES).length === 0 || !login.session.capabilities?.[CAPABILITIES.FILES]) {
|
||||
return { hits: [], hasMore: false };
|
||||
}
|
||||
const nodes = await fileListing(account, login).promise;
|
||||
abortIfNeeded(signal);
|
||||
const primary = login.session.primaryAccounts?.[CAPABILITIES.FILES] ?? '';
|
||||
const hits = filterNodes(parsed, nodes, account, limit + 1, 'remote', primary);
|
||||
return { hits: hits.slice(0, limit), hasMore: hits.length > limit };
|
||||
},
|
||||
};
|
||||
|
||||
export const GLOBAL_SEARCH_PROVIDERS: readonly SearchProvider[] = [mailProvider, contactsProvider, calendarProvider, filesProvider];
|
||||
|
||||
export type { GlobalSearchHit };
|
||||
@@ -0,0 +1,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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
]}
|
||||
>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 })} `
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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('/')}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user