From 800f07714000c20cc6f6b42c405c5e2f75b85694 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:03:37 +0100 Subject: [PATCH 01/35] feat: add the files sync contract and policies for files on this computer --- desktop-policies/Bulwark.admx | 19 ++ desktop-policies/de-DE/Bulwark.adml | 7 + desktop-policies/en-US/Bulwark.adml | 7 + .../org.bulwarkmail.desktop.mobileconfig | 6 + desktop-policies/policies.json | 3 + lib/desktop/__tests__/policy-schema.test.ts | 24 +- lib/desktop/policy-schema.ts | 63 ++++- lib/desktop/types.ts | 219 ++++++++++++++++++ lib/files-sync/types.ts | 131 +++++++++++ stores/files-sync-store.ts | 35 +++ 10 files changed, 512 insertions(+), 2 deletions(-) create mode 100644 lib/files-sync/types.ts create mode 100644 stores/files-sync-store.ts diff --git a/desktop-policies/Bulwark.admx b/desktop-policies/Bulwark.admx index 1b60d0497..d944d2db0 100644 --- a/desktop-policies/Bulwark.admx +++ b/desktop-policies/Bulwark.admx @@ -224,5 +224,24 @@ + + + + + + + + + + + + + + + + + + + diff --git a/desktop-policies/de-DE/Bulwark.adml b/desktop-policies/de-DE/Bulwark.adml index 2975e98f9..e9aa9a943 100644 --- a/desktop-policies/de-DE/Bulwark.adml +++ b/desktop-policies/de-DE/Bulwark.adml @@ -67,6 +67,12 @@ Wenn aktiviert oder nicht konfiguriert, trägt sich die App unter Windows in die Liste „Öffnen mit“ für gängige Dokumente und Bilder und in das Menü „Senden an“ ein, damit Benutzer eine Datei aus dem Dateimanager an eine neue Nachricht anhängen können. Sie wird nie zum Standardprogramm für diese Dateien. Wenn deaktiviert, entfernt die App diese Einträge für den Benutzer. Mail-Links und .eml-, .ics- und .vcf-Dateien sind nicht betroffen. Entwicklertools Dieselben Werte wie die gleichnamige Richtlinie von Chrome. 2: Die Entwicklertools sind aus und die Einstellung ist gesperrt. 0 oder 1 oder nicht konfiguriert: Benutzer können sie nach einer Warnung in den Einstellungen einschalten. Mit den Entwicklertools kann jeder an der Tastatur alles lesen, was die App enthält, auch E-Mails und Passwörter im Speicher. + Dateien als Laufwerk zulassen + Wenn aktiviert oder nicht konfiguriert, können Benutzer ihre Dateien im Dateimanager als Laufwerk anzeigen. Dateien werden beim Öffnen heruntergeladen. Vorerst nur unter Windows. Wenn deaktiviert, wird das Laufwerk nicht angeboten, und ein vorhandenes Laufwerk wird mit den Kopien der Dateien darin entfernt. Die Dateien auf dem Server sind davon nicht betroffen. + Synchronisieren von Ordnern mit Dateien zulassen + Wenn aktiviert oder nicht konfiguriert, können Benutzer einen Ordner auf dem Computer und einen Ordner in Dateien in beide Richtungen gleich halten. Wenn deaktiviert, wird dies nicht angeboten, und Ordner, die gerade synchronisiert werden, hören damit auf. Die Dateien in diesen Ordnern bleiben, wo sie sind. + Laufwerksbuchstabe für Dateien + Nur unter Windows. Ein Buchstabe von D bis Z gibt dem Laufwerk Dateien für jeden Benutzer diesen Buchstaben, und Benutzer können ihn nicht ändern. Geben Sie none ein, damit es nie einen Buchstaben bekommt. Wenn nicht konfiguriert, wählen Benutzer in den Einstellungen, und bis dahin gibt es keinen Buchstaben. Ein Wert, den die App nicht lesen kann, gilt als none. @@ -86,6 +92,7 @@ Adressen Wert (0, 1 oder 2) + diff --git a/desktop-policies/en-US/Bulwark.adml b/desktop-policies/en-US/Bulwark.adml index 9ae91340a..ade37900b 100644 --- a/desktop-policies/en-US/Bulwark.adml +++ b/desktop-policies/en-US/Bulwark.adml @@ -67,6 +67,12 @@ When enabled or not configured, the app adds itself to the "Open with" list for common documents and pictures and to the "Send to" menu on Windows, so people can attach a file to a new message from the file manager. It never becomes the default program for those files. When disabled, the app removes these entries for the user. Mail links and .eml, .ics and .vcf files are not affected. Developer tools The same values as Chrome's policy of this name. 2: developer tools are off and the setting is locked. 0 or 1, or not configured: people can turn them on under Settings, after a warning. Developer tools let anyone at the keyboard read everything the app holds, mail and passwords in memory included. + Allow Files as a drive + When enabled or not configured, people can show their Files in the file manager as a drive, with files downloaded when opened. Windows only for now. When disabled, the drive is not offered, and a drive that is already there is removed with the copies of files in it. Files on the server are not affected. + Allow syncing folders with Files + When enabled or not configured, people can keep a folder on the computer and a folder in Files the same, in both directions. When disabled, this is not offered and folders that sync now stop. The files in those folders stay where they are. + Drive letter for Files + Windows only. A letter from D to Z gives the Files drive that letter for every user, and they can't change it. Enter none to never give it a letter. When not configured, people choose under Settings, and there is no letter until they do. A value the app can't read counts as none. @@ -86,6 +92,7 @@ Addresses Value (0, 1 or 2) + diff --git a/desktop-policies/org.bulwarkmail.desktop.mobileconfig b/desktop-policies/org.bulwarkmail.desktop.mobileconfig index 23ddb522c..9658e41cc 100644 --- a/desktop-policies/org.bulwarkmail.desktop.mobileconfig +++ b/desktop-policies/org.bulwarkmail.desktop.mobileconfig @@ -69,6 +69,12 @@ DeveloperToolsAvailability 2 + FilesDriveEnabled + + FilesFolderSyncEnabled + + FilesDriveLetter + none Recommended UpdateChannel diff --git a/desktop-policies/policies.json b/desktop-policies/policies.json index 89dd1e510..40d390fc3 100644 --- a/desktop-policies/policies.json +++ b/desktop-policies/policies.json @@ -29,6 +29,9 @@ "BrowserSignIn": true, "OpenWithEnabled": true, "DeveloperToolsAvailability": 2, + "FilesDriveEnabled": true, + "FilesFolderSyncEnabled": true, + "FilesDriveLetter": "none", "Recommended": { "UpdateChannel": "stable" } diff --git a/lib/desktop/__tests__/policy-schema.test.ts b/lib/desktop/__tests__/policy-schema.test.ts index 07b11863c..1a73d1a85 100644 --- a/lib/desktop/__tests__/policy-schema.test.ts +++ b/lib/desktop/__tests__/policy-schema.test.ts @@ -32,7 +32,7 @@ describe('the schema', () => { 'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed', 'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist', 'AutoSelectCertificateForUrls', 'KeystoreEnabled', 'KeystoreAllowedOrigins', 'BrowserSignIn', 'WipeOfflineData', - 'OpenWithEnabled', 'DeveloperToolsAvailability', + 'OpenWithEnabled', 'DeveloperToolsAvailability', 'FilesDriveEnabled', 'FilesFolderSyncEnabled', 'FilesDriveLetter', ]); for (const key of POLICY_KEYS) { const { title, description } = POLICY_SCHEMA[key]; @@ -58,6 +58,28 @@ describe('developer tools and Open with', () => { }); }); +describe('Files on this computer', () => { + it('turn the drive and folder sync off only by force, and a value nobody can read leaves them off', () => { + for (const key of ['FilesDriveEnabled', 'FilesFolderSyncEnabled'] as const) { + expect(resolvePolicies(layers({}))[key]).toMatchObject({ value: true, locked: false }); + expect(resolvePolicies(layers({ recommendedUser: { [key]: 0 } }))[key]).toMatchObject({ value: true, locked: false }); + expect(resolvePolicies(layers({ forcedUser: { [key]: 0 } }))[key]).toMatchObject({ value: false, locked: true }); + expect(resolvePolicies(layers({ forcedMachine: { [key]: 'perhaps' } }))[key]).toMatchObject({ value: false, locked: true }); + } + }); + + it('takes a drive letter from D to Z or none, forced only, and a value nobody can read means no letter', () => { + expect(parsePolicyValue('FilesDriveLetter', 'x')).toEqual({ ok: true, value: 'X' }); + expect(parsePolicyValue('FilesDriveLetter', ' Q: ')).toEqual({ ok: true, value: 'Q' }); + expect(parsePolicyValue('FilesDriveLetter', 'NONE')).toEqual({ ok: true, value: 'none' }); + for (const bad of ['C', 'A:', 'B', 'QQ', 'Q:\\', '', 4, null]) expect(parsePolicyValue('FilesDriveLetter', bad).ok, String(bad)).toBe(false); + expect(resolvePolicies(layers({})).FilesDriveLetter).toMatchObject({ value: null, locked: false }); + expect(resolvePolicies(layers({ recommendedMachine: { FilesDriveLetter: 'R' } })).FilesDriveLetter).toMatchObject({ value: null, locked: false }); + expect(resolvePolicies(layers({ forcedMachine: { FilesDriveLetter: 'r' } })).FilesDriveLetter).toMatchObject({ value: 'R', locked: true }); + expect(resolvePolicies(layers({ forcedUser: { FilesDriveLetter: 'C' } })).FilesDriveLetter).toMatchObject({ value: 'none', locked: true }); + }); +}); + describe('precedence', () => { it('forced machine > forced user > recommended machine > recommended user > default', () => { const all = { diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index e3229f2dd..22535bbea 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -82,6 +82,9 @@ export interface PolicyValues { WipeOfflineData: string | null; OpenWithEnabled: boolean; DeveloperToolsAvailability: number | null; + FilesDriveEnabled: boolean; + FilesFolderSyncEnabled: boolean; + FilesDriveLetter: string | null; } export type PolicyKey = keyof PolicyValues; @@ -128,7 +131,7 @@ export type PolicyDefinition = PolicyBase & } | { type: 'string'; - format: 'instanceUrl' | 'shortcut' | 'hostList' | 'nonce'; + format: 'instanceUrl' | 'shortcut' | 'hostList' | 'nonce' | 'driveLetter'; default: null; maxLength: number; label: PolicyText; @@ -466,6 +469,47 @@ export const POLICY_SCHEMA: Record = { de: 'Dieselben Werte wie die gleichnamige Richtlinie von Chrome. 2: Die Entwicklertools sind aus und die Einstellung ist gesperrt. 0 oder 1 oder nicht konfiguriert: Benutzer können sie nach einer Warnung in den Einstellungen einschalten. Mit den Entwicklertools kann jeder an der Tastatur alles lesen, was die App enthält, auch E-Mails und Passwörter im Speicher.', }, }, + FilesDriveEnabled: { + category: 'system', + type: 'boolean', + default: true, + onInvalid: false, + forcedOnly: true, + sample: true, + title: { en: 'Allow Files as a drive', de: 'Dateien als Laufwerk zulassen' }, + description: { + en: 'When enabled or not configured, people can show their Files in the file manager as a drive, with files downloaded when opened. Windows only for now. When disabled, the drive is not offered, and a drive that is already there is removed with the copies of files in it. Files on the server are not affected.', + de: 'Wenn aktiviert oder nicht konfiguriert, können Benutzer ihre Dateien im Dateimanager als Laufwerk anzeigen. Dateien werden beim Öffnen heruntergeladen. Vorerst nur unter Windows. Wenn deaktiviert, wird das Laufwerk nicht angeboten, und ein vorhandenes Laufwerk wird mit den Kopien der Dateien darin entfernt. Die Dateien auf dem Server sind davon nicht betroffen.', + }, + }, + FilesFolderSyncEnabled: { + category: 'system', + type: 'boolean', + default: true, + onInvalid: false, + forcedOnly: true, + sample: true, + title: { en: 'Allow syncing folders with Files', de: 'Synchronisieren von Ordnern mit Dateien zulassen' }, + description: { + en: 'When enabled or not configured, people can keep a folder on the computer and a folder in Files the same, in both directions. When disabled, this is not offered and folders that sync now stop. The files in those folders stay where they are.', + de: 'Wenn aktiviert oder nicht konfiguriert, können Benutzer einen Ordner auf dem Computer und einen Ordner in Dateien in beide Richtungen gleich halten. Wenn deaktiviert, wird dies nicht angeboten, und Ordner, die gerade synchronisiert werden, hören damit auf. Die Dateien in diesen Ordnern bleiben, wo sie sind.', + }, + }, + FilesDriveLetter: { + category: 'system', + forcedOnly: true, + type: 'string', + format: 'driveLetter', + default: null, + maxLength: 8, + sample: 'none', + label: { en: 'Drive letter', de: 'Laufwerksbuchstabe' }, + title: { en: 'Drive letter for Files', de: 'Laufwerksbuchstabe für Dateien' }, + description: { + en: 'Windows only. A letter from D to Z gives the Files drive that letter for every user, and they can\'t change it. Enter none to never give it a letter. When not configured, people choose under Settings, and there is no letter until they do. A value the app can\'t read counts as none.', + de: 'Nur unter Windows. Ein Buchstabe von D bis Z gibt dem Laufwerk Dateien für jeden Benutzer diesen Buchstaben, und Benutzer können ihn nicht ändern. Geben Sie none ein, damit es nie einen Buchstaben bekommt. Wenn nicht konfiguriert, wählen Benutzer in den Einstellungen, und bis dahin gibt es keinen Buchstaben. Ein Wert, den die App nicht lesen kann, gilt als none.', + }, + }, }; export const POLICY_KEYS = Object.keys(POLICY_SCHEMA) as PolicyKey[]; @@ -617,6 +661,16 @@ export function pluginAllowedByPolicy(mode: PluginsMode, allowlist: readonly str const SHORTCUT = /^(?:(?:Command|Cmd|Control|Ctrl|CommandOrControl|CmdOrCtrl|Alt|Option|AltGr|Shift|Super|Meta)\+){1,4}(?:[A-Z0-9]|F[1-9]|F1[0-9]|F2[0-4]|Space|Tab|Enter|Backspace|Delete|Insert|Home|End|PageUp|PageDown|Up|Down|Left|Right|Plus|[-=,./;'`[\]\\])$/i; +/** A drive letter from D to Z, upper case, or `none`. */ +function parseDriveLetter(raw: unknown): Parsed { + if (typeof raw !== 'string') return fail('not_text'); + const text = raw.trim(); + if (text.toLowerCase() === 'none') return { ok: true, value: 'none' }; + const match = /^([D-Za-z]):?$/.exec(text); + if (!match || !/^[D-Z]$/i.test(match[1])) return fail('invalid_drive_letter'); + return { ok: true, value: match[1].toUpperCase() }; +} + function parseShortcut(raw: unknown): Parsed { if (typeof raw !== 'string') return fail('not_text'); const text = raw.trim(); @@ -796,6 +850,8 @@ export function parsePolicyValue(key: PolicyKey, raw: unknown, allowInsecure = f return parseHostList(raw); case 'nonce': return parseNonce(raw); + case 'driveLetter': + return parseDriveLetter(raw); } break; case 'list': @@ -821,6 +877,8 @@ function failClosedValue(key: PolicyKey): PolicyValues[PolicyKey] { if (definition.type === 'list') return [] as PolicyValues[PolicyKey]; // A shortcut nobody can read doesn't get the built-in one instead. if (definition.type === 'string' && definition.format === 'shortcut') return 'off' as PolicyValues[PolicyKey]; + // A drive letter nobody can read means no letter, not the user's own. + if (definition.type === 'string' && definition.format === 'driveLetter') return 'none' as PolicyValues[PolicyKey]; // A limit nobody can read is the tightest one, not the user's own. if (definition.type === 'integer') return (definition.onInvalid !== undefined ? definition.onInvalid : definition.min) as PolicyValues[PolicyKey]; return definition.default as PolicyValues[PolicyKey]; @@ -965,6 +1023,9 @@ export const POLICY_CONSUMERS: Record = { BrowserSignIn: { repo: 'shell', file: 'src/main/features/browser-sign-in.ts' }, OpenWithEnabled: { repo: 'shell', file: 'src/main/features/os-default-app.ts' }, DeveloperToolsAvailability: { repo: 'shell', file: 'src/main/features/dev-tools.ts' }, + FilesDriveEnabled: { repo: 'shell', file: 'src/main/features/files-drive.ts' }, + FilesFolderSyncEnabled: { repo: 'shell', file: 'src/main/features/files-folders.ts' }, + FilesDriveLetter: { repo: 'shell', file: 'src/main/features/files-drive.ts' }, }; /** The part the embedded server enforces. Sent to it by message. */ diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 467f918d1..68891ca12 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -845,6 +845,223 @@ export interface DesktopEditBridge { onChange: (listener: (sessions: DesktopEditSession[]) => void) => () => void; } +/* + * Files on this computer: folders kept the same as a Files folder, and on Windows the + * Files drive (Cloud Files API placeholders). The sync engine is the page's + * (lib/files-sync in the webmail): it holds the JMAP session and decides. Main only does + * what the page can't: the disk, the folder picker, the OS's sync root, and the + * callbacks Windows makes when someone opens a file that isn't on the disk yet. + * + * Paths here are always relative to a root, with `/` between parts, never `..`, never + * absolute. Main resolves them inside the root and refuses anything that leaves it, + * through a link or otherwise. + */ + +/** A place on this computer kept the same as Files: a folder the user picked, or the drive. */ +export interface FilesRoot { + /** Main's id for it. Random, never a path. */ + id: string; + kind: 'folder' | 'drive'; + /** The page's local account id it belongs to. */ + account: string; + /** Where it is on disk, to show and nothing else. */ + path: string; + /** A folder: the Files folder it pairs with (null: the top of Files). The drive: null. */ + remoteId: string | null; + /** What the page called the Files side when it was added, to show. */ + remoteLabel: string; + paused: boolean; + createdAt: number; +} + +/** One file or folder under a root, as it is on disk now. */ +export interface FilesLocalEntry { + /** Relative to the root, `/` between parts. */ + path: string; + kind: 'file' | 'directory'; + size: number; + mtimeMs: number; + /** The same across a rename on the same volume (file id, inode), to tell a move from a delete and a create. */ + fileId: string; + /** The drive only: what the placeholder says. */ + placeholder?: FilesPlaceholderState; +} + +export interface FilesPlaceholderState { + /** The FileNode id written into it. Null for something made in Explorer that isn't a placeholder yet. */ + identity: string | null; + /** Main or the page marked it in sync and nobody changed it since. */ + inSync: boolean; + /** The content is on this disk. */ + hydrated: boolean; + pin: 'pinned' | 'unpinned' | 'unspecified'; +} + +/** What the page expects a path to be, so main refuses to act on something that changed meanwhile. */ +export interface FilesExpect { + fileId: string; + size: number; + mtimeMs: number; +} + +export type FilesError = + /** Not there. */ + | 'missing' + /** Something is already at the target. */ + | 'exists' + /** It changed since the page looked (`expect` didn't match). */ + | 'changed' + /** Another program has it open. */ + | 'busy' + | 'denied' + /** The path leaves the root, or goes through a link. */ + | 'outside' + /** A name this file system can't hold. */ + | 'name' + /** The disk is full. */ + | 'full' + /** The organisation's policy says no. */ + | 'policy' + /** No real click came with a call that needs one. */ + | 'gesture' + /** The user closed the picker. */ + | 'cancelled' + /** The folder is inside another root, holds one, or is a system folder. */ + | 'overlap' + /** The root is paused, gone, or the app is locked. */ + | 'inactive' + | 'unsupported' + | 'failed'; + +export type FilesResult = { ok: true; value: T } | { ok: false; error: FilesError }; + +/** Something on disk under a root changed. `paths: null`: too much at once, scan it all. */ +export interface FilesChangeEvent { + rootId: string; + paths: string[] | null; +} + +/** Windows wants bytes of a file that isn't on the disk yet: someone opened it. */ +export interface FilesFetchRequest { + fetchId: string; + rootId: string; + /** The FileNode id in the placeholder. */ + identity: string; + path: string; + /** What Windows asked for. Supplying from 0 to the end is always fine. */ + offset: number; + length: number; + /** The size in the placeholder. */ + total: number; +} + +/** A remote file or folder the drive should show, before any content is fetched. */ +export interface FilesPlaceholder { + path: string; + kind: 'file' | 'directory'; + size: number; + mtimeMs: number; + identity: string; +} + +export interface FilesDriveLetterState { + /** In use now. */ + letter: string | null; + /** The user's own choice, kept even while a policy overrides it. */ + chosen: string | null; + /** `FilesDriveLetter` forces it (a letter, or `none`). */ + locked: boolean; + /** Letters free now, D to Z. */ + available: string[]; +} + +export interface FilesDriveState { + /** Windows 10 1809 or later with the helper loaded. */ + supported: boolean; + /** `FilesDriveEnabled` is off. */ + forcedOff: boolean; + /** Accounts the user turned the drive on for, whether or not it's registered right now. */ + wanted: string[]; + letter: FilesDriveLetterState; +} + +/** + * What the main page takes once, in its first render (`files.claim()`), like `auth.claim`: + * a same-origin frame reaches the bridge through `parent`, and main can't tell its calls + * apart. Main also takes these calls only from the main window. + */ +export interface FilesControls { + roots: () => Promise; + /** Shows the OS folder picker. Needs a real click. */ + addFolder: (request: { account: string; remoteId: string | null; remoteLabel: string }) => Promise>; + /** A folder: forgotten, its files stay. The drive: as `drive.disable(account, 'user')`. */ + removeRoot: (rootId: string) => Promise; + setPaused: (rootId: string, paused: boolean) => Promise; + /** Everything under `under` (default: the whole root). Skips links and main's own temporary files. */ + scan: (rootId: string, under?: string) => Promise>; + stat: (rootId: string, path: string) => Promise>; + /** A URL the page fetches once for the file's bytes (Range works). Refused for a drive file that isn't on the disk. */ + openRead: (rootId: string, path: string) => Promise>; + /** + * Writing a file: chunks go to a temporary file next to it, `commitWrite` puts it in place + * if what's there is still `expect` (null: nothing may be there), and sets `mtimeMs`. + */ + beginWrite: (rootId: string, path: string, options: { expect: FilesExpect | null; mtimeMs: number; size: number }) => Promise>; + writeChunk: (writeId: string, data: Uint8Array) => Promise>; + commitWrite: (writeId: string) => Promise>; + abortWrite: (writeId: string) => Promise; + mkdir: (rootId: string, path: string) => Promise>; + /** Never overwrites. A case-only rename works on a case-insensitive disk. */ + move: (rootId: string, from: string, to: string, expect: FilesExpect | null) => Promise>; + /** A folder: to the OS's trash where it has one. Refused when it changed since `expect`. */ + remove: (rootId: string, path: string, expect: FilesExpect | null) => Promise>; + onChange: (listener: (event: FilesChangeEvent) => void) => () => void; + /** The engine's own record of a root (what both sides looked like at the last sync). */ + loadState: (rootId: string) => Promise; + saveState: (rootId: string, state: string) => Promise; + /** Opens Explorer, Finder or the file manager on it. */ + showInFolder: (rootId: string, path: string) => Promise; + /** Windows only. Absent elsewhere. */ + drive?: FilesDriveControls; +} + +export interface FilesDriveControls { + state: () => Promise; + onStateChange: (listener: (state: FilesDriveState) => void) => () => void; + /** + * Registers and connects the account's sync root. Needs a real click unless the user + * turned it on before (a sign-in brings it back). `label` is the address, shown when + * there are several accounts. + */ + enable: (account: string, label: string) => Promise>; + /** + * `user`: turned off. `signed-out`: the account signed out, and comes back at the next + * sign-in. `wipe` and `refused`: a wipe or a refused login. Each unregisters the sync root + * and deletes it with everything in it; only `signed-out` keeps the wish. + */ + disable: (account: string, reason: 'user' | 'signed-out' | 'wipe' | 'refused') => Promise; + /** New placeholders, parents first. One answer per entry. */ + place: (rootId: string, entries: FilesPlaceholder[]) => Promise>; + /** + * New metadata from the server for a placeholder that is in sync. `dehydrate`: the + * content changed, drop the copy on disk (a pinned one is fetched again). Refused + * with `changed` when someone edited it here meanwhile. + */ + update: (rootId: string, path: string, meta: { size: number; mtimeMs: number; identity: string; dehydrate: boolean }, expect: FilesExpect | null) => Promise>; + /** After an upload: makes it a placeholder with this identity if it isn't one, and marks it in sync. */ + markInSync: (rootId: string, path: string, identity: string, expect: FilesExpect) => Promise>; + onFetch: (listener: (request: FilesFetchRequest) => void) => () => void; + onFetchCancel: (listener: (fetchId: string) => void) => () => void; + /** Bytes for a fetch, in order from `offset`. False when Windows no longer wants them. */ + supply: (fetchId: string, offset: number, data: Uint8Array) => Promise; + fail: (fetchId: string, error: 'offline' | 'missing' | 'failed') => void; + setLetter: (letter: string | null) => Promise>; +} + +export interface FilesBridge { + claim: () => FilesControls | null; +} + export interface BulwarkDesktopBridge { version: number; platform: 'win32' | 'darwin' | 'linux'; @@ -874,6 +1091,8 @@ export interface BulwarkDesktopBridge { edit?: DesktopEditBridge; /** Absent on older shells. */ devTools?: DesktopDevToolsBridge; + /** Files on this computer. Absent on older shells. */ + files?: FilesBridge; } declare global { diff --git a/lib/files-sync/types.ts b/lib/files-sync/types.ts new file mode 100644 index 000000000..5e7cdb744 --- /dev/null +++ b/lib/files-sync/types.ts @@ -0,0 +1,131 @@ +/** + * Files on this computer, the page's side: what the sync engine reports and what the + * settings page and the Files screen can ask of it. The engine (lib/files-sync) keeps + * folders on disk and the Windows drive the same as Files; the desktop shell does the + * disk work (FilesControls in lib/desktop/types.ts). + * + * The store (stores/files-sync-store.ts) holds this state for the UI. The engine sets + * it and registers a controller there; the UI never talks to the bridge itself. + */ + +import type { FilesDriveLetterState, FilesDriveState, FilesResult, FilesRoot } from '@/lib/desktop/types'; + +export type FilesSyncRootState = + /** Reading both sides for the first time since the app started. */ + | 'starting' + | 'syncing' + /** Both sides are the same. */ + | 'idle' + | 'paused' + /** The server can't be reached; changes wait. */ + | 'offline' + | 'error' + /** A mass delete stopped it: the user has to say yes or no (`massDelete`). */ + | 'confirm-delete' + /** The organisation's policy stopped it. */ + | 'blocked'; + +export interface FilesSyncRootStatus { + rootId: string; + state: FilesSyncRootState; + /** Changes waiting to go either way. */ + pending: number; + /** Done in the current run. */ + done: number; + lastSyncedAt: number | null; + /** For `error`: a key in the `files_sync` namespace saying what went wrong. */ + errorKey?: string; + /** For `confirm-delete`: how many items would go, and where. */ + massDelete?: { count: number; side: 'local' | 'remote' }; +} + +export type FilesSyncActivityKind = + | 'uploaded' + | 'downloaded' + | 'folder-created' + | 'renamed' + | 'moved' + /** Deleted for good: the server has no trash, or it was deleted on this computer by the other side. */ + | 'deleted' + /** Into Files' trash, or the computer's. */ + | 'trashed' + /** Both sides changed. Neither is lost: `detail` is the name of the copy kept next to it. */ + | 'conflict' + /** A name this computer can't hold, shown under another name (`detail`). */ + | 'renamed-for-disk' + /** Bigger than the server takes. Stays on this computer only. `detail`: the limit in bytes. */ + | 'too-large' + /** Waiting for the server. */ + | 'waiting' + /** Stopped before deleting many items, see FilesSyncRootStatus.massDelete. */ + | 'mass-delete' + | 'error'; + +export interface FilesSyncActivity { + id: string; + at: number; + rootId: string; + kind: FilesSyncActivityKind; + /** `up`: to Files. `down`: to this computer. */ + direction: 'up' | 'down' | null; + /** Relative to the root, `/` between parts. */ + path: string; + detail?: string; + /** For `error` and `waiting`: a key in the `files_sync` namespace. */ + messageKey?: string; +} + +/** Where a FileNode is on this computer. */ +export interface FilesSyncCoverage { + rootId: string; + kind: 'folder' | 'drive'; + /** Relative to the root. */ + path: string; +} + +export type FilesSyncItemStatus = + /** The same on both sides. */ + | 'synced' + /** Going one way right now. */ + | 'syncing' + /** Waiting to go. */ + | 'pending' + | 'error' + /** The drive: only in Files, fetched when opened. */ + | 'online-only'; + +/** What the UI can ask of the engine. Registered by the engine in the store. */ +export interface FilesSyncController { + /** Shows the folder picker; needs the click it came from. */ + addFolder: (remoteId: string | null, remoteLabel: string) => Promise>; + /** A folder stops syncing; its files stay. The drive: turned off. */ + removeRoot: (rootId: string) => Promise; + setPaused: (rootId: string, paused: boolean) => Promise; + /** Answer to `confirm-delete`. No: the deleted items come back from the other side. */ + confirmMassDelete: (rootId: string, allow: boolean) => Promise; + /** The current account's drive. */ + setDriveEnabled: (enabled: boolean) => Promise>; + setDriveLetter: (letter: string | null) => Promise>; + /** Where a FileNode of the current account is on this computer, if a root covers it. */ + coverage: (nodeId: string) => FilesSyncCoverage | null; + /** Explorer, Finder or the file manager, on that file or folder. */ + showInFolder: (nodeId: string) => Promise; + clearActivity: () => void; +} + +export interface FilesSyncSnapshot { + /** In the desktop app, with a shell that has the files bridge, in the main window. */ + available: boolean; + /** The local account the engine runs for, or null when nobody is signed in. */ + account: string | null; + roots: FilesRoot[]; + status: Record; + /** Newest first, capped. */ + activity: FilesSyncActivity[]; + /** Null off Windows or on an older shell. */ + drive: FilesDriveState | null; + /** `FilesFolderSyncEnabled` is off. */ + folderSyncForcedOff: boolean; + /** Per FileNode id of the current account, for rows in the Files screen. */ + items: Record; +} diff --git a/stores/files-sync-store.ts b/stores/files-sync-store.ts new file mode 100644 index 000000000..a6f1af6af --- /dev/null +++ b/stores/files-sync-store.ts @@ -0,0 +1,35 @@ +import { create } from 'zustand'; +import type { FilesSyncController, FilesSyncSnapshot } from '@/lib/files-sync/types'; + +/** + * Files on this computer, for the UI: the engine (lib/files-sync) writes the snapshot + * and registers its controller; the settings page and the Files screen read it and + * call the controller. Nothing here touches the bridge. + */ +interface FilesSyncStore extends FilesSyncSnapshot { + controller: FilesSyncController | null; + /** The engine's: replaces what it says changed. */ + publish: (patch: Partial) => void; + setController: (controller: FilesSyncController | null) => void; + /** Back to nothing: signed out, or the engine stopped. */ + reset: () => void; +} + +const EMPTY: FilesSyncSnapshot = { + available: false, + account: null, + roots: [], + status: {}, + activity: [], + drive: null, + folderSyncForcedOff: false, + items: {}, +}; + +export const useFilesSyncStore = create((set) => ({ + ...EMPTY, + controller: null, + publish: (patch) => set(patch), + setController: (controller) => set({ controller }), + reset: () => set({ ...EMPTY, controller: null }), +})); From 10157d477cf5fb04e86b84389336eb2ca24b81d7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:57:31 +0100 Subject: [PATCH 02/35] fix: show the same menus with icons and the right key names in the desktop app The desktop app read every HTML menu back into a native Electron menu. Native menus have no icons, and Chromium writes their keys in its own words, which on a German Windows is "Umschalttaste" where Windows itself says "Umschalt". So the same menu looked different in the browser and in the app. The desktop app now shows the HTML menus the browser shows. Key hints in menus and tooltips go through the app's key names (Umschalt, Entf, Maj). Tests: the native-menu reader and its tests are gone with it (`native-menu-keys`, `move-menu-native`, the native block of `native-ui` and of `context-menu-placement`). The attachment menu tests now read the HTML menu instead of the native popup's items; what they check is the same. --- .../__tests__/edit-attachment-gates.test.tsx | 11 +- .../email/__tests__/move-menu-native.test.tsx | 88 ----- .../__tests__/reader-attachments.test.tsx | 39 ++- components/email/composer-toolbar-menu.tsx | 3 +- components/email/message-action-shortcuts.ts | 11 +- components/email/move-target-list.tsx | 7 +- components/email/reader-attachments.tsx | 4 +- components/plugins/app-side-panel.tsx | 3 +- .../__tests__/context-menu-placement.test.tsx | 31 +- components/ui/__tests__/menu-keys.test.tsx | 89 +++++ components/ui/context-menu.tsx | 83 +---- hooks/use-context-menu.ts | 5 - .../__tests__/native-menu-keys.test.tsx | 98 ------ lib/desktop/__tests__/native-ui.test.tsx | 171 ++------- lib/desktop/native-menu.ts | 330 ------------------ lib/desktop/types.ts | 5 +- 16 files changed, 173 insertions(+), 805 deletions(-) delete mode 100644 components/email/__tests__/move-menu-native.test.tsx create mode 100644 components/ui/__tests__/menu-keys.test.tsx delete mode 100644 lib/desktop/__tests__/native-menu-keys.test.tsx delete mode 100644 lib/desktop/native-menu.ts diff --git a/components/email/__tests__/edit-attachment-gates.test.tsx b/components/email/__tests__/edit-attachment-gates.test.tsx index de7c3b84d..f66e39add 100644 --- a/components/email/__tests__/edit-attachment-gates.test.tsx +++ b/components/email/__tests__/edit-attachment-gates.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Email, Mailbox } from '@/lib/jmap/types'; import { PHISHING_KEYWORD } from '@/lib/phishing-report'; @@ -50,14 +50,11 @@ function makeEmail(attachment: Record, keywords: Record (name === 'then' ? undefined : () => undefined) }); -let popup: ReturnType; - beforeEach(() => { - popup = vi.fn(async () => null); window.bulwarkDesktop = { version: 1, platform: 'win32', - menu: { setState: () => {}, popup }, + menu: { setState: () => {} }, // The shell's list, for the ones used here. edit: { canEdit: (name: string) => /\.(docx|txt)$/.test(name), list: async () => [], onChange: () => () => {} }, } as never; @@ -74,8 +71,8 @@ async function viewerMenu(email: Email): Promise<(string | undefined)[]> { render(); const tile = await screen.findByTestId('attachment'); fireEvent.contextMenu(tile.parentElement ?? tile, { clientX: 5, clientY: 5 }); - await waitFor(() => expect(popup).toHaveBeenCalled()); - return (popup.mock.calls[0][0] as { label?: string }[]).map((item) => item.label); + const menu = await screen.findByRole('menu'); + return within(menu).getAllByRole('menuitem').map((item) => item.querySelector('[data-menu-label]')?.textContent ?? undefined); } describe('the reader', () => { diff --git a/components/email/__tests__/move-menu-native.test.tsx b/components/email/__tests__/move-menu-native.test.tsx deleted file mode 100644 index 145de43d0..000000000 --- a/components/email/__tests__/move-menu-native.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { render, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { ContextMenu, ContextMenuSubMenu } from '@/components/ui/context-menu'; -import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '@/lib/desktop/types'; -import type { Mailbox } from '@/lib/jmap/types'; -import { buildMoveMenu } from '@/lib/move-menu'; -import { MoveFolderList } from '../move-target-list'; - -// The desktop app shows the Move menu natively, read back from the HTML one. -// A native menu can't indent and the account headers used to be plain divs, -// so it showed every account's Inbox, Sent, Spam and Trash in one flat list -// with nothing to tell them apart. - -const writable = { mayAddItems: true } as Mailbox['myRights']; -const box = (id: string, name: string, extra: Partial = {}) => - ({ - id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, - myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra, - }) as Mailbox; -const shared = (id: string, name: string, extra: Partial = {}) => - box(`S:${id}`, name, { accountId: 'S', accountName: 'team@example.org', isShared: true, ...extra }); - -const mailboxes = [ - box('inbox', 'Inbox', { role: 'inbox' }), - box('trash', 'Trash', { role: 'trash' }), - box('work', 'Work'), - box('clients', 'Clients', { parentId: 'work' }), - box('acme', 'Acme', { parentId: 'clients' }), - box('rd', 'R&D'), - shared('inbox', 'Inbox', { role: 'inbox' }), - shared('trash', 'Trash', { role: 'trash' }), -]; - -let popup: ReturnType; - -beforeEach(() => { - popup = vi.fn(() => new Promise(() => {})); - window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } } as unknown as BulwarkDesktopBridge; -}); - -afterEach(() => { - window.bulwarkDesktop = undefined; -}); - -async function nativeMoveMenu(): Promise { - const model = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', recentIds: () => ['acme'] }); - render( - {}}> - - {}} /> - - , - ); - await waitFor(() => expect(popup).toHaveBeenCalled()); - const items = popup.mock.calls[0][0] as DesktopNativeMenuItem[]; - return items[0].submenu!; -} - -const labels = (items: DesktopNativeMenuItem[] | undefined) => (items ?? []).map((item) => item.label ?? item.type); - -describe('the Move menu shown natively', () => { - it('has the recent folders under a header, then the account\'s own folders', async () => { - const move = await nativeMoveMenu(); - expect(labels(move)).toEqual([ - 'recent', 'Work / Clients / Acme', 'separator', - 'mailboxes.inbox', 'mailboxes.trash', 'R&D', 'Work', - 'separator', 'team@example.org', - ]); - expect(move[0].enabled).toBe(false); - // The open folder is there, greyed. - expect(move[3].enabled).toBe(false); - }); - - it('nests subfolders as submenus, the folder itself first', async () => { - const move = await nativeMoveMenu(); - const work = move.find((item) => item.label === 'Work')!; - expect(labels(work.submenu)).toEqual(['Work', 'separator', 'Clients']); - expect(work.submenu![0].id).toBeTruthy(); - const clients = work.submenu![2]; - expect(labels(clients.submenu)).toEqual(['Clients', 'separator', 'Acme']); - }); - - it('puts another account\'s folders in a submenu under its name', async () => { - const move = await nativeMoveMenu(); - const team = move.find((item) => item.label === 'team@example.org')!; - expect(labels(team.submenu)).toEqual(['mailboxes.inbox', 'mailboxes.trash']); - }); -}); diff --git a/components/email/__tests__/reader-attachments.test.tsx b/components/email/__tests__/reader-attachments.test.tsx index 9bb922889..b13c02adb 100644 --- a/components/email/__tests__/reader-attachments.test.tsx +++ b/components/email/__tests__/reader-attachments.test.tsx @@ -30,47 +30,50 @@ describe('the right-click menu in the desktop app', () => { cleanup(); }); - const openMenu = async (popupAnswer: (items: { id?: string; label?: string }[]) => string | null, overrides: Partial[0]> = {}) => { - const popup = vi.fn(async (items: { id?: string; label?: string }[]) => popupAnswer(items)); - window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as never; + // The same HTML menu as everywhere else in the app (it was a native menu). + const openMenu = async (overrides: Partial[0]> = {}) => { + window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {} } } as never; const props = renderBar([file('1', 'report.pdf')], { onCopyFile: vi.fn(), ...overrides }); fireEvent.contextMenu(await screen.findByTestId('attachment'), { clientX: 5, clientY: 5 }); - await waitFor(() => expect(popup).toHaveBeenCalled()); - return { props, popup }; + const menu = await screen.findByRole('menu'); + const labels = within(menu).getAllByRole('menuitem').map((item) => item.querySelector('[data-menu-label]')?.textContent ?? ''); + const pick = (label: string) => fireEvent.click(within(menu).getAllByRole('menuitem').find((item) => item.querySelector('[data-menu-label]')?.textContent === label)!); + return { props, labels, pick }; }; - it('has Open, Save as and Copy, natively', async () => { - const { popup } = await openMenu(() => null); - expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open', 'save_as', 'copy']); + it('has Open, Save as and Copy', async () => { + const { labels } = await openMenu(); + expect(labels).toEqual(['open', 'save_as', 'copy']); }); it.each([['open', 'onOpen'], ['save_as', 'onDownload'], ['copy', 'onCopyFile']] as const)('%s runs the action', async (label, handler) => { - const { props } = await openMenu((items) => items.find((item) => item.label === label)!.id!); + const { props, pick } = await openMenu(); + pick(label); await waitFor(() => expect((props as unknown as Record>)[handler]).toHaveBeenCalledTimes(1)); expect((props as unknown as Record>)[handler].mock.calls[0][0]).toMatchObject({ id: '1' }); }); - it('with downloads off there\'s nothing to save or copy', async () => { - const { popup } = await openMenu(() => null, { downloadsOff: true, opensPreview: () => true }); - expect(popup.mock.calls[0][0].map((item) => item.label)).toEqual(['open']); + it('with downloads off there is nothing to save or copy', async () => { + const { labels } = await openMenu({ downloadsOff: true, opensPreview: () => true }); + expect(labels).toEqual(['open']); }); it('Edit shows when the file can be edited and runs the action', async () => { const onEdit = vi.fn(); - const { props, popup } = await openMenu((items) => items.find((i) => i.label === 'edit')?.id ?? null, { onEdit, canEdit: () => true }); - const labels = popup.mock.calls[0][0].flatMap((i: { label?: string }) => (i.label ? [i.label] : [])); + const { props, labels, pick } = await openMenu({ onEdit, canEdit: () => true }); expect(labels).toEqual(['open', 'save_as', 'edit', 'copy']); + pick('edit'); await waitFor(() => expect(onEdit).toHaveBeenCalledWith(props.attachments[0])); }); it('Edit stays out for a file it cannot take', async () => { - const { popup } = await openMenu(() => null, { onEdit: vi.fn(), canEdit: () => false }); - expect(popup.mock.calls[0][0].map((i: { label?: string }) => i.label)).not.toContain('edit'); + const { labels } = await openMenu({ onEdit: vi.fn(), canEdit: () => false }); + expect(labels).not.toContain('edit'); }); it('Edit stays out with downloads off, whatever else says yes', async () => { - const { popup } = await openMenu(() => null, { onEdit: vi.fn(), canEdit: () => true, downloadsOff: true }); - expect(popup.mock.calls[0][0].map((i: { label?: string }) => i.label)).not.toContain('edit'); + const { labels } = await openMenu({ onEdit: vi.fn(), canEdit: () => true, downloadsOff: true }); + expect(labels).not.toContain('edit'); }); it('a browser keeps its own menu', async () => { diff --git a/components/email/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx index 07abf0e97..f844a48ff 100644 --- a/components/email/composer-toolbar-menu.tsx +++ b/components/email/composer-toolbar-menu.tsx @@ -1,5 +1,6 @@ "use client"; +import { formatShortcutCombo } from "@/lib/shortcut-keys"; import { Button } from "@/components/ui/button"; import { ContextMenu, CONTEXT_MENU_ITEM_CLASS } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; @@ -192,7 +193,7 @@ export function ComposerPluginToolbarButtons({ surface, context, alwaysVisible = <> {actions.map((action) => { const Icon = iconForName(action.icon) ?? Puzzle; - const tip = action.shortcut ? `${action.label} (${action.shortcut})` : action.label; + const tip = action.shortcut ? `${action.label} (${formatShortcutCombo(action.shortcut)})` : action.label; return ( , - ); - expect(items[0].label).toBe('Day'); - expect(items[0].accelerator).toBe('D'); - }); - - it('a menu item\'s shortcut too', () => { - const items = menuOf( {}} />); - expect(items[0]).toMatchObject({ label: 'Cut', accelerator: 'Ctrl+X' }); - }); - - it('a count is no key: it goes after the label', () => { - const items = menuOf( {}} />); - expect(items[0].label).toBe('Notifications (3)'); - expect(items[0].accelerator).toBeUndefined(); - }); - - it('words in separate elements stay separate words', () => { - const items = menuOf( - , - ); - expect(items[0].label).toBe('Alice Example alice@example.net'); - }); -}); - -describe('toAccelerator', () => { - it.each([ - ['D', 'D'], - ['r', 'R'], - ['Shift + I', 'Shift+I'], - ['Ctrl+X', 'Ctrl+X'], - ['⌘X', 'Cmd+X'], - ['⌘⇧K', 'Cmd+Shift+K'], - ['Mod + Enter', 'CmdOrCtrl+Enter'], - ['Del', 'Delete'], - ['F2', 'F2'], - ['?', '?'], - ['#', '#'], - ['Ctrl++', 'Ctrl+Plus'], - ])('%s is %s', (hint, accelerator) => { - expect(toAccelerator(hint)).toBe(accelerator); - }); - - it.each(['j / ↓', '', 'Hyper+Q', 'twelve'])('%s is no single key', (hint) => { - expect(toAccelerator(hint)).toBeUndefined(); - }); -}); - -// Menu rows show their keys through MenuShortcut (or ContextMenuItem's -// shortcut), which the native menu knows. A raw in a file with menu -// rows is how "DayD" happened. -describe('menu rows show keys through MenuShortcut', () => { - const root = path.resolve(__dirname, '../../..'); - it('no raw next to role="menuitem…" rows', () => { - const files: string[] = []; - const walk = (dir: string) => { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name.startsWith('.')) continue; - const full = path.join(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.tsx')) files.push(full); - } - }; - walk(path.join(root, 'components')); - walk(path.join(root, 'app')); - const offenders = files.filter((file) => { - if (file.endsWith(path.join('ui', 'context-menu.tsx'))) return false; - const source = readFileSync(file, 'utf8'); - return /role="menuitem/.test(source) && /]/.test(source); - }); - expect(offenders.map((file) => path.relative(root, file))).toEqual([]); - }, 60_000); -}); diff --git a/lib/desktop/__tests__/native-ui.test.tsx b/lib/desktop/__tests__/native-ui.test.tsx index 58df7bba2..9e1a37ff5 100644 --- a/lib/desktop/__tests__/native-ui.test.tsx +++ b/lib/desktop/__tests__/native-ui.test.tsx @@ -1,14 +1,12 @@ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ContextMenu, ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from '@/components/ui/context-menu'; -import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '../types'; +import type { BulwarkDesktopBridge } from '../types'; import { accentColours, applySystemAccent } from '../system-accent'; import { mergedDesktopState, registerDesktopSurface, resetDesktopCommands, runDesktopCommand, setDesktopBaseState } from '../commands'; -import { readMenu } from '../native-menu'; import { noteOpenDraft, resetOpenDrafts, restoreUrlFor } from '../window-restore'; import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '../preferences'; import { closePopoutWindow, isPopoutPath } from '@/lib/popout/window-role'; -import { toast } from '@/stores/toast-store'; function setBridge(bridge: Partial | undefined) { window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined; @@ -42,149 +40,6 @@ function testMenu(extra?: React.ReactNode, handlers: { reply?: () => void; inbox ); } -describe('an HTML menu shown natively', () => { - let popup: ReturnType; - let answer: (id: string | null) => void; - - beforeEach(() => { - popup = vi.fn(() => new Promise((resolve) => { answer = resolve; })); - setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } }); - }); - - async function openMenu(children: React.ReactNode, onClose = vi.fn()) { - render({children}); - await waitFor(() => expect(popup).toHaveBeenCalled()); - return { items: popup.mock.calls[0][0] as DesktopNativeMenuItem[], at: popup.mock.calls[0][1], onClose }; - } - - it('reads every kind of row, in order, with submenus', async () => { - const { items } = await openMenu(testMenu()); - expect(items.map((item) => item.label ?? item.type)).toEqual(['Three messages', 'Reply', 'Forward', 'separator', 'Move to', 'Rules']); - expect(items[0].enabled).toBe(false); - expect(items[2].enabled).toBe(false); - expect(items[4].submenu?.map((item) => item.label)).toEqual(['Inbox', 'R&D']); - // A disabled submenu shows, and has nothing in it. - expect(items[5].enabled).toBe(false); - expect(items[5].submenu).toBeUndefined(); - }); - - it('takes the label without the shortcut or the hint', async () => { - const { items } = await openMenu(testMenu()); - expect(items[1].label).toBe('Reply'); - }); - - it('at the pointer from a right-click, under the button from a button', async () => { - const { at } = await openMenu(testMenu()); - expect(at).toBeUndefined(); - popup.mockClear(); - render( - {}}> - {}} /> - , - ); - await waitFor(() => expect(popup).toHaveBeenCalled()); - expect(popup.mock.calls[0][1]).toEqual({ x: 40, y: 74 }); - }); - - it('a pick runs the HTML row, the one a browser would run, and closes the menu', async () => { - const reply = vi.fn(); - const inbox = vi.fn(); - const { items, onClose } = await openMenu(testMenu(undefined, { reply, inbox })); - await act(async () => { - answer(items[4].submenu![0].id!); - }); - await waitFor(() => expect(onClose).toHaveBeenCalled()); - expect(inbox).toHaveBeenCalledTimes(1); - expect(reply).not.toHaveBeenCalled(); - }); - - it('dismissing it runs nothing and closes the menu', async () => { - const reply = vi.fn(); - const { onClose } = await openMenu(testMenu(undefined, { reply })); - await act(async () => { - answer(null); - }); - await waitFor(() => expect(onClose).toHaveBeenCalled()); - expect(reply).not.toHaveBeenCalled(); - }); - - it('the hidden copy is never visible', async () => { - await openMenu(testMenu()); - const menu = document.body.querySelector('[role="menu"]'); - expect(menu?.style.visibility).toBe('hidden'); - }); - - it('stays HTML when something in it can\'t be shown natively', async () => { - render( - {}}> - {testMenu()} - , - ); - await waitFor(() => expect(document.body.querySelector('[role="menu"]')?.style.visibility).toBe('visible')); - expect(popup).not.toHaveBeenCalled(); - expect(screen.getByRole('menuitem', { name: /Reply/ })).toBeTruthy(); - }); - - it('a plugin\'s frame keeps the rest native, with a line to the HTML menu', async () => { - const frame = ( -
'; - expect(readMenu(root).unsupported).toBe(true); - }); - - it('a row that went while the menu was up says so, instead of doing nothing', async () => { - const warning = vi.spyOn(toast, 'warning').mockImplementation(() => '' as never); - const reply = vi.fn(); - const { items, onClose } = await openMenu(testMenu(undefined, { reply })); - const row = screen.getAllByRole('menuitem', { hidden: true }).find((el) => el.textContent?.includes('Reply'))!; - row.parentElement!.remove(); - await act(async () => { - answer(items[1].id!); - }); - await waitFor(() => expect(onClose).toHaveBeenCalled()); - expect(reply).not.toHaveBeenCalled(); - expect(warning).toHaveBeenCalledWith('pick_gone'); - warning.mockRestore(); - }); - - it('a row with no words stays HTML too: a colour swatch says nothing in a native menu', () => { - const root = document.createElement('div'); - root.innerHTML = ''; - expect(readMenu(root).unsupported).toBe(true); - }); - - it('a search field over a list is left out, and what it searches stays', () => { - const root = document.createElement('div'); - root.innerHTML = ''; - const read = readMenu(root); - expect(read.unsupported).toBe(false); - expect(read.items.map((item) => item.label)).toEqual(['Inbox']); - }); - - it('reads check and radio rows', () => { - const root = document.createElement('div'); - root.innerHTML = ''; - expect(readMenu(root).items).toMatchObject([ - { type: 'checkbox', checked: true, label: 'Show' }, - { type: 'radio', checked: false, label: 'Other' }, - ]); - }); -}); - describe('the same menu in a browser', () => { it('is the HTML menu, on screen, and no bridge is asked', async () => { render( {}}>{testMenu()}); @@ -194,6 +49,28 @@ describe('the same menu in a browser', () => { }); }); +describe('the same menu in the desktop app', () => { + it('is the HTML menu too, with its icons and keys, and no native menu is asked for', async () => { + const popup = vi.fn(async () => null); + setBridge({ version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } }); + document.documentElement.lang = 'de'; + const Icon = () => ; + render( + {}}> + {}} shortcut="Shift + R" /> + , + ); + await waitFor(() => expect(document.body.querySelector('[role="menu"]')?.style.visibility).toBe('visible')); + const row = screen.getByRole('menuitem', { name: /Allen antworten/ }); + expect(row.querySelector('[data-testid="row-icon"]')).not.toBeNull(); + // Windows' own words for the keys, not Chromium's "Umschalttaste". + expect(row.querySelector('[data-menu-shortcut]')?.textContent).toBe('Umschalt + R'); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(popup).not.toHaveBeenCalled(); + document.documentElement.lang = ''; + }); +}); + describe('menu commands', () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/lib/desktop/native-menu.ts b/lib/desktop/native-menu.ts deleted file mode 100644 index c920c2fe6..000000000 --- a/lib/desktop/native-menu.ts +++ /dev/null @@ -1,330 +0,0 @@ -/** - * Turns an HTML menu into a native one. The ContextMenu component renders as it does - * in a browser, hidden, with its submenus open. This reads the result and shows that - * natively. A pick clicks the row the HTML menu has for it, so a menu has one list of - * entries with one set of handlers and the two can't drift. - * - * A menu with something a native one can't show (a colour picker, a row with no text) - * stays HTML. So does anything marked data-native-menu="html". A plugin's frame in the - * menu doesn't: the rest shows natively, with one line that opens the HTML menu. - */ - -import { getDesktopBridge } from './bridge'; -import type { DesktopNativeMenuItem } from './types'; - -export function nativeMenusAvailable(): boolean { - return !!getDesktopBridge()?.menu?.popup; -} - -export interface ReadMenu { - items: DesktopNativeMenuItem[]; - /** The HTML row behind each native item's id. */ - elements: Map; - /** There's something here a native menu can't show. */ - unsupported: boolean; - /** A plugin drew something of its own in the menu. */ - hasFrames: boolean; -} - -const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]'; - -/** The native line that opens the HTML menu instead. */ -export const HTML_MENU_ITEM = "html-menu"; - -function isDisabled(el: HTMLElement): boolean { - return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true'; -} - -/** What a row shows apart from its label: the key, a count. Never part of the label. */ -const ASIDE = 'kbd, [data-menu-shortcut], [data-menu-badge]'; - -/** - * The words of an element, each element's apart: "Day" and "D" make "Day D", not - * "DayD", and a name over an address doesn't run into it. Keys and counts are left out. - */ -function wordsOf(node: Node): string { - if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? ''; - if (!(node instanceof Element) || node.matches(ASIDE)) return ''; - return Array.from(node.childNodes).map(wordsOf).join(' '); -} - -function labelOf(el: HTMLElement): string { - const named = el.getAttribute('aria-label') ?? el.querySelector('[data-menu-label]')?.textContent ?? wordsOf(el); - const badge = el.querySelector('[data-menu-badge]')?.textContent?.trim(); - const label = named.replace(/\s+/g, ' ').trim(); - return badge && label ? `${label} (${badge})` : label; -} - -const KEY_NAMES: Record = { - del: 'Delete', delete: 'Delete', esc: 'Escape', escape: 'Escape', enter: 'Enter', return: 'Enter', - space: 'Space', tab: 'Tab', backspace: 'Backspace', insert: 'Insert', home: 'Home', end: 'End', - pageup: 'PageUp', pagedown: 'PageDown', up: 'Up', down: 'Down', left: 'Left', right: 'Right', - '↑': 'Up', '↓': 'Down', '←': 'Left', '→': 'Right', '+': 'Plus', '⌫': 'Backspace', '⏎': 'Enter', '↵': 'Enter', -}; -const MODIFIERS: Record = { - ctrl: 'Ctrl', control: 'Ctrl', mod: 'CmdOrCtrl', cmd: 'Cmd', command: 'Cmd', '⌘': 'Cmd', alt: 'Alt', option: 'Alt', - '⌥': 'Alt', shift: 'Shift', '⇧': 'Shift', '⌃': 'Ctrl', meta: 'Super', win: 'Super', -}; -const KEY = /^([A-Z0-9]|F([1-9]|1[0-9]|2[0-4])|[)!@#$%^&*(:;=<,_\-.>?/~`{\][|\\}"']|Plus|Space|Tab|Backspace|Delete|Insert|Enter|Up|Down|Left|Right|Home|End|PageUp|PageDown|Escape)$/; - -/** - * A key hint as the app shows it ("Shift + I", "⌘X", "Del") in Electron's accelerator - * syntax, or undefined when it isn't one key (two alternatives, a word). - */ -export function toAccelerator(hint: string): string | undefined { - let text = hint.trim(); - if (!text || text.includes(' / ')) return undefined; - const parts: string[] = []; - // macOS writes its modifiers as symbols with nothing between: ⌘⇧K. - while (text.length > 1 && /^[⌘⌥⇧⌃]/.test(text)) { - parts.push(MODIFIERS[text[0]]); - text = text.slice(1).replace(/^\s*\+?\s*/, ''); - } - const pieces = text === '+' ? ['+'] : text.split(/\s*\+\s*/).filter(Boolean); - if (text.endsWith('++')) pieces.push('+'); - for (let i = 0; i < pieces.length - 1; i++) { - const modifier = MODIFIERS[pieces[i].toLowerCase()]; - if (!modifier) return undefined; - parts.push(modifier); - } - const last = pieces[pieces.length - 1]; - if (!last) return undefined; - const key = KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last); - if (!KEY.test(key)) return undefined; - return [...new Set(parts), key].join('+'); -} - -function acceleratorOf(el: HTMLElement): string | undefined { - const hint = el.querySelector('[data-menu-shortcut], kbd')?.textContent; - return hint ? toAccelerator(hint) : undefined; -} - -function isHidden(el: Element): boolean { - return typeof window.getComputedStyle === 'function' && window.getComputedStyle(el).display === 'none'; -} - -export function readMenu(root: HTMLElement): ReadMenu { - const elements = new Map(); - let unsupported = root.querySelector('[data-native-menu="html"], select, textarea, input[type="color"]') !== null; - let hasFrames = false; - let next = 1; - - const walk = (container: Element, into: DesktopNativeMenuItem[]): void => { - for (const child of Array.from(container.children)) { - if (!(child instanceof HTMLElement) || isHidden(child)) continue; - if (child.getAttribute('role') === 'separator') { - into.push({ type: 'separator' }); - continue; - } - if (child.tagName === 'IFRAME') { - // Only a plugin's own slot has this coming. Anything else stays HTML whole. - if (child.closest('[data-plugin-slot]')) hasFrames = true; - else unsupported = true; - continue; - } - if (child.dataset.submenuPanel !== undefined) continue; - if (child.dataset.nativeSubmenu !== undefined) { - // A folder with subfolders (a Move list): a native menu can't indent, - // so it's a submenu with the folder itself first, as Thunderbird has it. - const group: DesktopNativeMenuItem[] = []; - walk(child, group); - const [head, ...rest] = group; - if (head && rest.length && head.type !== 'separator' && head.label) { - into.push({ label: head.label, submenu: [head, { type: 'separator' }, ...rest] }); - } else { - into.push(...group); - } - continue; - } - if (child.dataset.contextMenuHeader !== undefined) { - const label = labelOf(child); - if (label) into.push({ id: `h${next++}`, label, enabled: false }); - continue; - } - if (child.dataset.submenuTrigger !== undefined) { - const label = labelOf(child); - if (isDisabled(child)) { - into.push({ id: `s${next++}`, label, enabled: false }); - continue; - } - const panel = child.parentElement?.querySelector(':scope > [data-submenu-panel]'); - if (!panel) { - unsupported = true; - continue; - } - const submenu: DesktopNativeMenuItem[] = []; - walk(panel, submenu); - into.push({ label, submenu }); - continue; - } - if (child.matches(ROW)) { - // A row that opens its own popup: there's nothing to click through to. - if (child.hasAttribute('aria-haspopup')) { - unsupported = true; - continue; - } - const label = labelOf(child); - if (!label) { - // A swatch or an icon says nothing in a native menu. - unsupported = true; - continue; - } - const id = `i${next++}`; - elements.set(id, child); - const role = child.getAttribute('role'); - const accelerator = acceleratorOf(child); - into.push({ - id, - label, - enabled: !isDisabled(child), - ...(accelerator ? { accelerator } : {}), - ...(role === 'menuitemcheckbox' || role === 'menuitemradio' - ? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' } - : {}), - }); - continue; - } - // A search field over a list is no use where you can't type. - if (child instanceof HTMLInputElement) continue; - walk(child, into); - } - }; - - const items: DesktopNativeMenuItem[] = []; - walk(root, items); - return { items, elements, unsupported, hasFrames }; -} - -const SETTLE_MS = 30; -const SETTLE_LIMIT_MS = 400; - -/** - * The menu once it stopped changing. Entries can render a moment after the menu - * (the mail menu waits for its data), and a native menu can't grow once it's up. - */ -export async function readSettledMenu(root: HTMLElement, cancelled: () => boolean): Promise { - const started = Date.now(); - let previous = readMenu(root); - while (!cancelled() && Date.now() - started < SETTLE_LIMIT_MS) { - await new Promise((resolve) => setTimeout(resolve, SETTLE_MS)); - const current = readMenu(root); - if (current.items.length > 0 && JSON.stringify(current.items) === JSON.stringify(previous.items)) return current; - previous = current; - } - return previous; -} - -function labelOfId(items: DesktopNativeMenuItem[], id: string): string | undefined { - for (const item of items) { - if (item.id === id) return item.label; - const below = item.submenu && labelOfId(item.submenu, id); - if (below) return below; - } - return undefined; -} - -export type NativeMenuOutcome = 'done' | 'html' | 'gone'; - -/** - * Shows it and clicks the row picked. Says when the pick found nothing: the page can - * re-render under a menu that stays up (the list refreshing), and a row that's gone - * would otherwise do nothing with no word about it. If the same line is back at the - * same place, that's the one. - */ -export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x: number; y: number }, fromPlugins?: string): Promise { - const popup = getDesktopBridge()?.menu?.popup; - if (!popup) return 'done'; - const items = read.hasFrames && fromPlugins - ? [...read.items, { type: 'separator' as const }, { id: HTML_MENU_ITEM, label: fromPlugins }] - : read.items; - document.documentElement.dataset.nativeMenuOpen = ''; - let picked: string | null; - try { - picked = await popup(items, at); - } finally { - delete document.documentElement.dataset.nativeMenuOpen; - } - if (!picked) return 'done'; - if (picked === HTML_MENU_ITEM) return 'html'; - let row = read.elements.get(picked); - if (!row?.isConnected) { - const again = root.isConnected ? readMenu(root) : null; - row = again && labelOfId(again.items, picked) === labelOfId(read.items, picked) ? again.elements.get(picked) : undefined; - } - if (!row?.isConnected) return 'gone'; - row.click(); - return 'done'; -} - -/** - * The system's menu font and the room a native menu adds around its words, in screen - * pixels: Chromium's menus on Windows and Linux, AppKit's on macOS. Measured off the - * real menus at 100 %; an estimate within a few pixels is all a placement needs. - */ -const NATIVE_METRICS = { - win32: { font: '12px "Segoe UI", system-ui, sans-serif', padding: 74, keyGap: 28, charWidth: 6.2 }, - darwin: { font: '14px -apple-system, system-ui, sans-serif', padding: 48, keyGap: 24, charWidth: 7.2 }, - linux: { font: '13px system-ui, sans-serif', padding: 70, keyGap: 28, charWidth: 6.8 }, -} as const; - -let measureContext: CanvasRenderingContext2D | null | undefined; - -function textWidth(text: string, font: string, charWidth: number): number { - if (measureContext === undefined) { - try { - // jsdom has no canvas, and says so on the console. - measureContext = typeof document === 'undefined' || /jsdom/i.test(navigator.userAgent) - ? null - : document.createElement('canvas').getContext('2d'); - } catch { - measureContext = null; - } - } - if (measureContext) { - measureContext.font = font; - const width = measureContext.measureText(text).width; - if (Number.isFinite(width) && width > 0) return width; - } - return text.length * charWidth; -} - -/** - * How wide the native menu for these items will be, in the page's CSS pixels. A native - * menu is drawn at the screen's scale, not the page's zoom: at 150 % zoom it takes up - * less of the page. outerWidth is in screen pixels and innerWidth in CSS pixels, which - * gives the zoom in a window with no frame. - */ -export function estimateNativeMenuWidth(items: DesktopNativeMenuItem[], platform = getDesktopBridge()?.platform ?? 'win32'): number { - const metrics = NATIVE_METRICS[platform] ?? NATIVE_METRICS.win32; - let widest = 0; - for (const item of items) { - if (item.type === 'separator' || !item.label) continue; - const key = item.accelerator ? metrics.keyGap + textWidth(item.accelerator, metrics.font, metrics.charWidth) : 0; - widest = Math.max(widest, textWidth(item.label, metrics.font, metrics.charWidth) + key); - } - const screenWidth = widest + metrics.padding; - const zoom = typeof window !== 'undefined' && window.innerWidth > 0 && window.outerWidth > 0 - ? Math.min(5, Math.max(0.25, window.outerWidth / window.innerWidth)) - : 1; - return Math.round(screenWidth / zoom); -} - -/** - * Where a native menu from a button opens, in page coordinates: its top left corner, - * under the button and lined up with the edge the HTML menu would use. Only that: the - * OS keeps a native menu on the screen itself, and it may reach past the window. - */ -export function nativeMenuPoint( - anchor: { left: number; right: number; bottom: number; align?: 'start' | 'end' }, - menuWidth: number, - rtl: boolean, - gap: number, -): { x: number; y: number } { - const alignEnd = (anchor.align === 'end') !== rtl; - return { x: Math.max(0, Math.round(alignEnd ? anchor.right - menuWidth : anchor.left)), y: Math.round(anchor.bottom + gap) }; -} - -/** A native menu is up: the window's blur or a stray event says nothing about it. */ -export function nativeMenuIsOpen(): boolean { - return typeof document !== 'undefined' && document.documentElement.dataset.nativeMenuOpen !== undefined; -} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 467f918d1..150c7ef22 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -80,7 +80,10 @@ export interface DesktopSystemTheme { export type DesktopMenuState = Record; -/** A native menu built from an HTML one (lib/desktop/native-menu.ts). */ +/** + * A native menu the shell can show. The page shows its own HTML menus in the desktop app + * too, so they have their icons and the app's key names; this stays for older pages. + */ export interface DesktopNativeMenuItem { /** Handed back when this item is chosen. Left out of separators and submenus. */ id?: string; From d56708bad73a022b6899a2bd8fdb717668102c46 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:57:39 +0100 Subject: [PATCH 03/35] feat: star or unstar a whole selection at once --- lib/email-action-toast.ts | 17 ++++++ locales/ar/common.json | 4 ++ locales/ca/common.json | 4 ++ locales/cs/common.json | 4 ++ locales/da/common.json | 4 ++ locales/de/common.json | 4 ++ locales/en/common.json | 4 ++ locales/es/common.json | 4 ++ locales/et/common.json | 4 ++ locales/fa/common.json | 4 ++ locales/fr/common.json | 4 ++ locales/he/common.json | 4 ++ locales/hu/common.json | 4 ++ locales/it/common.json | 4 ++ locales/ja/common.json | 4 ++ locales/ko/common.json | 4 ++ locales/lv/common.json | 4 ++ locales/mn/common.json | 4 ++ locales/nb/common.json | 4 ++ locales/nl/common.json | 4 ++ locales/pl/common.json | 4 ++ locales/pt/common.json | 4 ++ locales/ro/common.json | 4 ++ locales/ru/common.json | 4 ++ locales/sk/common.json | 4 ++ locales/th/common.json | 4 ++ locales/tr/common.json | 4 ++ locales/uk/common.json | 4 ++ locales/vi/common.json | 4 ++ locales/zh-TW/common.json | 4 ++ locales/zh/common.json | 4 ++ .../email-store-plugin-organize-hooks.test.ts | 31 ++++++++++ stores/email-store.ts | 60 +++++++++++++++++++ 33 files changed, 228 insertions(+) diff --git a/lib/email-action-toast.ts b/lib/email-action-toast.ts index 02d987771..a841fb5e4 100644 --- a/lib/email-action-toast.ts +++ b/lib/email-action-toast.ts @@ -159,6 +159,23 @@ export async function setSelectionTagWithUndo( return done; } +/** + * Stars the selection or takes the star off (batchSetStarred; rejects when a write + * fails), with a toast that offers Undo. The messages already as asked are not put back. + */ +export async function setSelectionStarredWithUndo( + client: IJMAPClient, + starred: boolean, + words: { success: string; error: string; labels: UndoLabels }, +): Promise { + const points = keywordRestorePoints(resolveSelectedEmails(useEmailStore.getState()), ["$flagged"], { $flagged: starred }); + await runBatchEmailAction(() => useEmailStore.getState().batchSetStarred(client, starred), { + success: words.success, + error: words.error, + undo: undoKeywordAction(client, points, words.labels), + }); +} + /** * The toast's Undo for a reversible move (delete to Trash, archive, move, * report spam, remove from folder): puts the messages back where `batches` diff --git a/locales/ar/common.json b/locales/ar/common.json index 98c4a9e3d..f684a2b05 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "لترى البريد الجديد أثناء عملك في تطبيق آخر.", "tip_compose_keys": "اضغط {keys} لكتابة رسالة جديدة", "tip_palette_keys": "اضغط {keys} للعثور على أي أمر" + }, + "selectionView": { + "starred": "تمت إضافة النجمة", + "unstarred": "تمت إزالة النجمة" } } diff --git a/locales/ca/common.json b/locales/ca/common.json index c918f5894..f621915de 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Per veure el correu nou mentre treballeu en una altra aplicació.", "tip_compose_keys": "Premeu {keys} per escriure un missatge nou", "tip_palette_keys": "Premeu {keys} per trobar qualsevol ordre" + }, + "selectionView": { + "starred": "Marcat amb estrella", + "unstarred": "Estrella eliminada" } } diff --git a/locales/cs/common.json b/locales/cs/common.json index e26528ae2..dad6e2e5d 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Abyste viděli novou poštu, i když pracujete v jiné aplikaci.", "tip_compose_keys": "Stiskněte {keys} a napište novou zprávu", "tip_palette_keys": "Stiskněte {keys} a najděte jakýkoli příkaz" + }, + "selectionView": { + "starred": "Označeno hvězdičkou", + "unstarred": "Hvězdička odebrána" } } diff --git a/locales/da/common.json b/locales/da/common.json index 84eaf2b52..34548b07f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Så du ser ny mail, mens du arbejder i en anden app.", "tip_compose_keys": "Tryk på {keys} for at skrive en ny besked", "tip_palette_keys": "Tryk på {keys} for at finde en kommando" + }, + "selectionView": { + "starred": "Stjernemarkeret", + "unstarred": "Stjerne fjernet" } } diff --git a/locales/de/common.json b/locales/de/common.json index 09fcd154c..74f6bccce 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "So sehen Sie neue Mail, während Sie in einer anderen App arbeiten.", "tip_compose_keys": "Drücken Sie {keys}, um eine neue Nachricht zu schreiben", "tip_palette_keys": "Drücken Sie {keys}, um jeden Befehl zu finden" + }, + "selectionView": { + "starred": "Mit Stern markiert", + "unstarred": "Stern entfernt" } } diff --git a/locales/en/common.json b/locales/en/common.json index 71288373c..540b7e614 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "So you see new mail while you work in another app.", "tip_compose_keys": "Press {keys} to write a new message", "tip_palette_keys": "Press {keys} to find any command" + }, + "selectionView": { + "starred": "Starred", + "unstarred": "Star removed" } } diff --git a/locales/es/common.json b/locales/es/common.json index 9a6f3a2e4..de5a9ee0b 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Para ver el correo nuevo mientras trabaja en otra aplicación.", "tip_compose_keys": "Pulse {keys} para escribir un mensaje nuevo", "tip_palette_keys": "Pulse {keys} para buscar cualquier comando" + }, + "selectionView": { + "starred": "Destacado", + "unstarred": "Estrella quitada" } } diff --git a/locales/et/common.json b/locales/et/common.json index 5271d9544..efc788bc8 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Nii näete uut kirja ka siis, kui töötate teises rakenduses.", "tip_compose_keys": "Uue kirja kirjutamiseks vajutage {keys}", "tip_palette_keys": "Mis tahes käsu leidmiseks vajutage {keys}" + }, + "selectionView": { + "starred": "Tärniga märgitud", + "unstarred": "Tärn eemaldatud" } } diff --git a/locales/fa/common.json b/locales/fa/common.json index b3a8c96e8..c5ac332d9 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "تا ایمیل جدید را هنگام کار در برنامه‌ای دیگر ببینید.", "tip_compose_keys": "برای نوشتن پیام جدید {keys} را فشار دهید", "tip_palette_keys": "برای یافتن هر فرمانی {keys} را فشار دهید" + }, + "selectionView": { + "starred": "ستاره‌دار شد", + "unstarred": "ستاره برداشته شد" } } diff --git a/locales/fr/common.json b/locales/fr/common.json index 434b9f2fb..56e71087f 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Pour voir les nouveaux messages pendant que vous travaillez dans une autre application.", "tip_compose_keys": "Appuyez sur {keys} pour écrire un nouveau message", "tip_palette_keys": "Appuyez sur {keys} pour trouver n'importe quelle commande" + }, + "selectionView": { + "starred": "Suivi", + "unstarred": "Suivi retiré" } } diff --git a/locales/he/common.json b/locales/he/common.json index d85a4ac21..68fd24edb 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7718,5 +7718,9 @@ "unread_badge_description": "כדי לראות דואר חדש בזמן שאתם עובדים באפליקציה אחרת.", "tip_compose_keys": "הקישו {keys} כדי לכתוב הודעה חדשה", "tip_palette_keys": "הקישו {keys} כדי למצוא כל פקודה" + }, + "selectionView": { + "starred": "סומן בכוכב", + "unstarred": "הכוכב הוסר" } } diff --git a/locales/hu/common.json b/locales/hu/common.json index de0e1f9b0..846c70328 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Így akkor is látja az új leveleket, ha egy másik alkalmazásban dolgozik.", "tip_compose_keys": "Új üzenet írásához nyomja meg: {keys}", "tip_palette_keys": "Bármely parancs kereséséhez nyomja meg: {keys}" + }, + "selectionView": { + "starred": "Csillagozva", + "unstarred": "Csillag eltávolítva" } } diff --git a/locales/it/common.json b/locales/it/common.json index 2fc49f88c..6b3b7c9a8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Per vedere la nuova posta mentre lavori in un'altra app.", "tip_compose_keys": "Premi {keys} per scrivere un nuovo messaggio", "tip_palette_keys": "Premi {keys} per trovare qualsiasi comando" + }, + "selectionView": { + "starred": "Aggiunto a Speciali", + "unstarred": "Stella rimossa" } } diff --git a/locales/ja/common.json b/locales/ja/common.json index 752c7ea71..ec61f0038 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "別のアプリで作業中でも新着メールがわかります。", "tip_compose_keys": "{keys} を押して新しいメッセージを作成", "tip_palette_keys": "{keys} を押してコマンドを検索" + }, + "selectionView": { + "starred": "スターを付けました", + "unstarred": "スターを外しました" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index ff63df15a..5c01988d5 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "다른 앱에서 작업하는 동안에도 새 메일을 확인할 수 있습니다.", "tip_compose_keys": "{keys}를 눌러 새 메시지 쓰기", "tip_palette_keys": "{keys}를 눌러 명령 찾기" + }, + "selectionView": { + "starred": "별표를 추가했습니다", + "unstarred": "별표를 삭제했습니다" } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 5d91f4168..f809db7af 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Lai redzētu jaunu pastu, kamēr strādājat citā lietotnē.", "tip_compose_keys": "Nospiediet {keys}, lai rakstītu jaunu ziņojumu", "tip_palette_keys": "Nospiediet {keys}, lai atrastu jebkuru komandu" + }, + "selectionView": { + "starred": "Atzīmēts ar zvaigznīti", + "unstarred": "Zvaigznīte noņemta" } } diff --git a/locales/mn/common.json b/locales/mn/common.json index d718bfdbe..e4edf30da 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Өөр апп дээр ажиллаж байхдаа шинэ шуудангаа харахын тулд.", "tip_compose_keys": "Шинэ зурвас бичихийн тулд {keys} дарна уу", "tip_palette_keys": "Ямар ч тушаал олохын тулд {keys} дарна уу" + }, + "selectionView": { + "starred": "Одоор тэмдэглэсэн", + "unstarred": "Од хассан" } } diff --git a/locales/nb/common.json b/locales/nb/common.json index a59ad88bd..3043c7f51 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Slik ser du ny e-post mens du jobber i en annen app.", "tip_compose_keys": "Trykk på {keys} for å skrive en ny melding", "tip_palette_keys": "Trykk på {keys} for å finne en kommando" + }, + "selectionView": { + "starred": "Stjernemerket", + "unstarred": "Stjerne fjernet" } } diff --git a/locales/nl/common.json b/locales/nl/common.json index fead92f93..502489672 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Zo ziet u nieuwe mail terwijl u in een andere app werkt.", "tip_compose_keys": "Druk op {keys} om een nieuw bericht te schrijven", "tip_palette_keys": "Druk op {keys} om elke opdracht te vinden" + }, + "selectionView": { + "starred": "Met ster gemarkeerd", + "unstarred": "Ster verwijderd" } } diff --git a/locales/pl/common.json b/locales/pl/common.json index a87c1fe06..f87e7d2ab 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Aby widzieć nową pocztę, gdy pracujesz w innej aplikacji.", "tip_compose_keys": "Naciśnij {keys}, aby napisać nową wiadomość", "tip_palette_keys": "Naciśnij {keys}, aby znaleźć dowolne polecenie" + }, + "selectionView": { + "starred": "Oznaczono gwiazdką", + "unstarred": "Usunięto gwiazdkę" } } diff --git a/locales/pt/common.json b/locales/pt/common.json index ae6a59cfa..3ce4ae4f2 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Para ver novos e-mails enquanto trabalha noutra aplicação.", "tip_compose_keys": "Prima {keys} para escrever uma nova mensagem", "tip_palette_keys": "Prima {keys} para encontrar qualquer comando" + }, + "selectionView": { + "starred": "Com estrela", + "unstarred": "Estrela removida" } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 94cf7e1a7..975c26476 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Ca să vedeți e-mailurile noi în timp ce lucrați în altă aplicație.", "tip_compose_keys": "Apăsați {keys} pentru a scrie un mesaj nou", "tip_palette_keys": "Apăsați {keys} pentru a găsi orice comandă" + }, + "selectionView": { + "starred": "Marcat cu stea", + "unstarred": "Stea eliminată" } } diff --git a/locales/ru/common.json b/locales/ru/common.json index 3b9a670d4..904b65b7b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Чтобы видеть новую почту, пока вы работаете в другом приложении.", "tip_compose_keys": "Нажмите {keys}, чтобы написать новое письмо", "tip_palette_keys": "Нажмите {keys}, чтобы найти любую команду" + }, + "selectionView": { + "starred": "Помечено звёздочкой", + "unstarred": "Звёздочка снята" } } diff --git a/locales/sk/common.json b/locales/sk/common.json index 236f2c908..f05b64324 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Aby ste videli novú poštu, aj keď pracujete v inej aplikácii.", "tip_compose_keys": "Stlačte {keys} a napíšte novú správu", "tip_palette_keys": "Stlačte {keys} a nájdite akýkoľvek príkaz" + }, + "selectionView": { + "starred": "Označené hviezdičkou", + "unstarred": "Hviezdička odstránená" } } diff --git a/locales/th/common.json b/locales/th/common.json index d7c53dc4b..06bcf518d 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7725,5 +7725,9 @@ "unread_badge_description": "เพื่อให้เห็นอีเมลใหม่ขณะทำงานในแอปอื่น", "tip_compose_keys": "กด {keys} เพื่อเขียนข้อความใหม่", "tip_palette_keys": "กด {keys} เพื่อค้นหาคำสั่งใดก็ได้" + }, + "selectionView": { + "starred": "ติดดาวแล้ว", + "unstarred": "นำดาวออกแล้ว" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index e8de99bd2..48cb35f59 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Başka bir uygulamada çalışırken yeni postayı görmeniz için.", "tip_compose_keys": "Yeni bir ileti yazmak için {keys} tuşlarına basın", "tip_palette_keys": "Herhangi bir komutu bulmak için {keys} tuşlarına basın" + }, + "selectionView": { + "starred": "Yıldız eklendi", + "unstarred": "Yıldız kaldırıldı" } } diff --git a/locales/uk/common.json b/locales/uk/common.json index c5046b3ec..6679f2b30 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "Щоб бачити нову пошту, поки ви працюєте в іншому застосунку.", "tip_compose_keys": "Натисніть {keys}, щоб написати новий лист", "tip_palette_keys": "Натисніть {keys}, щоб знайти будь-яку команду" + }, + "selectionView": { + "starred": "Позначено зірочкою", + "unstarred": "Зірочку знято" } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 3ef82764b..b0e32e256 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -7729,5 +7729,9 @@ "unread_badge_description": "Để bạn thấy thư mới khi đang làm việc trong ứng dụng khác.", "tip_compose_keys": "Nhấn {keys} để viết thư mới", "tip_palette_keys": "Nhấn {keys} để tìm bất kỳ lệnh nào" + }, + "selectionView": { + "starred": "Đã gắn dấu sao", + "unstarred": "Đã bỏ dấu sao" } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 0561fd43f..2569dafa7 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "這樣您在其他應用程式中工作時也能看到新郵件。", "tip_compose_keys": "按 {keys} 撰寫新郵件", "tip_palette_keys": "按 {keys} 尋找任何指令" + }, + "selectionView": { + "starred": "已加星號", + "unstarred": "已移除星號" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index bc3109800..f5f4c719d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7717,5 +7717,9 @@ "unread_badge_description": "这样您在其他应用中工作时也能看到新邮件。", "tip_compose_keys": "按 {keys} 写新邮件", "tip_palette_keys": "按 {keys} 查找任何命令" + }, + "selectionView": { + "starred": "已加星标", + "unstarred": "已取消星标" } } diff --git a/stores/__tests__/email-store-plugin-organize-hooks.test.ts b/stores/__tests__/email-store-plugin-organize-hooks.test.ts index 2cdf1fdb4..5b8176328 100644 --- a/stores/__tests__/email-store-plugin-organize-hooks.test.ts +++ b/stores/__tests__/email-store-plugin-organize-hooks.test.ts @@ -373,6 +373,37 @@ describe('read, star and keyword hooks', () => { expect(after).toHaveBeenCalledWith({ emailIds: ['e1'], starred: true }); }); + it('stars a selection in one write, asks once and reports once', async () => { + openFolder('inbox', [makeEmail('a'), makeEmail('b', { keywords: { $seen: true, $flagged: true } }), makeEmail('c')]); + useEmailStore.setState({ selectedEmailKeys: new Set(['a', 'b', 'c']) }); + const before = vi.fn(() => true); + const after = vi.fn(); + emailHooks.onBeforeEmailStarToggle.register('t', before); + emailHooks.onEmailStarToggle.register('t', after); + + expect(await useEmailStore.getState().batchSetStarred(client, true)).toBe(true); + + // b has its star already: left alone. + expect(client.batchUpdateKeywords).toHaveBeenCalledTimes(1); + expect(client.batchUpdateKeywords).toHaveBeenCalledWith(['a', 'c'], { 'keywords/$flagged': true }, undefined); + expect(before).toHaveBeenCalledWith({ emailIds: ['a', 'c'], starred: true }); + expect(after).toHaveBeenCalledWith({ emailIds: ['a', 'c'], starred: true }); + expect(useEmailStore.getState().emails.every((e) => e.keywords.$flagged)).toBe(true); + + await useEmailStore.getState().batchSetStarred(client, false); + expect(client.batchUpdateKeywords).toHaveBeenLastCalledWith(['a', 'b', 'c'], { 'keywords/$flagged': null }, undefined); + expect(useEmailStore.getState().emails.some((e) => e.keywords.$flagged)).toBe(false); + }); + + it('writes nothing for a selection a plugin stops', async () => { + openFolder('inbox', [makeEmail('a'), makeEmail('b')]); + useEmailStore.setState({ selectedEmailKeys: new Set(['a', 'b']) }); + emailHooks.onBeforeEmailStarToggle.register('t', () => false); + await useEmailStore.getState().batchSetStarred(client, true); + expect(client.batchUpdateKeywords).not.toHaveBeenCalled(); + expect(useEmailStore.getState().emails.some((e) => e.keywords.$flagged)).toBe(false); + }); + it('names a group mailbox\'s account on a star toggle', async () => { const groupInbox = makeMailbox({ id: 'vertrieb:inbox', originalId: 'inbox', role: 'inbox', isShared: true, accountId: 'vertrieb' }); openFolder('vertrieb:inbox', [makeEmail('e1', { mailboxIds: { 'vertrieb:inbox': true } })]); diff --git a/stores/email-store.ts b/stores/email-store.ts index 429d1dcdc..ca41850de 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -597,6 +597,12 @@ interface EmailStore { */ /** `target`: these messages (target.emails) instead of the selection. */ batchSetTag: (client: IJMAPClient, tagId: string, add: boolean, target?: BatchActionTarget) => Promise; + /** + * Star every selected message, or take the star off every one. Written per account, + * as toggleStar writes one; the star hooks hear each account's messages. Rejects when + * a write fails. + */ + batchSetStarred: (client: IJMAPClient, starred: boolean) => Promise; batchDelete:(client: IJMAPClient, permanent?: boolean) => Promise; /** * Acts on the selection, or on `target`'s messages when given; with a @@ -6036,6 +6042,60 @@ export const useEmailStore = create((set, get) => ({ return true; }, + batchSetStarred: async (client, starred): Promise => { + if (get().selectedEmailKeys.size === 0) return true; + // By the account each message lives in, as toggleStar routes one. + const groups = new Map>(); + for (const email of resolveSelectedEmails(get())) { + if (!!email.keywords?.$flagged === starred) continue; + const { client: actionClient, accountId } = resolveEmailActionContext(email, client); + const byAccount = groups.get(actionClient) ?? new Map(); + groups.set(actionClient, byAccount); + byAccount.set(accountId, [...(byAccount.get(accountId) ?? []), email]); + } + if (groups.size === 0) return true; + + const writes = [...groups].flatMap(([actionClient, byAccount]) => + [...byAccount].map(async ([accountId, group]) => { + const hookAccountId = accountId && accountId !== actionClient.getAccountId?.() ? accountId : undefined; + const payload = { emailIds: group.map(e => e.id), starred, ...(hookAccountId ? { accountId: hookAccountId } : {}) }; + if (emailHooks.onBeforeEmailStarToggle.size > 0 && !(await emailHooks.onBeforeEmailStarToggle.intercept(payload))) return []; + await actionClient.batchUpdateKeywords(payload.emailIds, { [keywordPointer('$flagged')]: starred ? true : null }, accountId); + void emailHooks.onEmailStarToggle.emit(payload); + return group; + }), + ); + const settled = await Promise.allSettled(writes); + const written = new Set(settled.flatMap(result => result.status === 'fulfilled' ? result.value.map(emailKeyFor) : [])); + const restar = (keywords: Record | undefined) => { + const next = { ...keywords }; + if (starred) next.$flagged = true; + else delete next.$flagged; + return next; + }; + set((state) => { + let retained = state.retainedInViewIds; + // In the starred cross view an un-starred row stays until the view is opened again. + if (retainingCrossView(state) === 'starred') { + for (const e of state.emails) { + if (written.has(emailKeyFor(e))) retained = updateRetainedForView(retained, starred ? 'remove' : 'add', e.id); + } + } + return { + emails: state.emails.map(e => written.has(emailKeyFor(e)) ? { ...e, keywords: restar(e.keywords) } : e), + selectedEmail: state.selectedEmail && written.has(emailKeyFor(state.selectedEmail)) + ? { ...state.selectedEmail, keywords: restar(state.selectedEmail.keywords) } + : state.selectedEmail, + retainedInViewIds: retained, + }; + }); + if (written.size > 0) refillAfterKeywordChange(get, client, ['$flagged']); + + const failed = settled.find((result): result is PromiseRejectedResult => result.status === 'rejected'); + if (failed) throw failed.reason; + return true; + }, + batchDelete: async (client, permanent = false): Promise => { const { selectedEmailKeys, emails, selectedMailbox } = get(); const mailboxes = resolveActionMailboxes(); From a3d2f677be78a4510fc6e5f1f4cc27c150b4f0e4 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:57:51 +0100 Subject: [PATCH 04/35] fix: keep the message menus short, with the rest under More actions A row's right-click menu keeps Reply, Reply all, Forward, Archive, Delete, Move to, Mark as read, Star, Tag, Report spam and Open in new window at the top. Forward as attachment, the two links, Copy to account, Rules, Pin, Export, Print, View source and plugins' entries go under More actions, in that order. A selection gets the same structure without what needs one message, and the selection view in the reading pane shows the same actions as buttons in even lines, with a labelled More actions button, a count of unread and Clear selection with its Esc key. Tests: the batch menu tests open More actions first, since Forward as attachments, Remove from this folder and Report phishing moved there. The selection pane test expects Mark as unread for an all-read selection, as the menu offers, and checks the buttons have words rather than matching an aria-label the new buttons don't need. --- ...l-context-menu-remove-from-folder.test.tsx | 2 + .../__tests__/email-context-menu.test.tsx | 4 + .../__tests__/email-list-batch-bar.test.tsx | 68 +++- .../mail-list-plugin-surfaces.test.tsx | 10 + .../__tests__/phishing-report-menus.test.tsx | 1 + .../__tests__/row-menu-everywhere.test.tsx | 138 +++++++ .../email/__tests__/selection-view.test.ts | 27 ++ components/email/email-context-menu.tsx | 374 +++++------------- components/email/email-list.tsx | 133 ++++--- components/email/message-actions.ts | 154 ++++++++ components/email/selection-view.tsx | 219 ++++++++++ 11 files changed, 800 insertions(+), 330 deletions(-) create mode 100644 components/email/__tests__/row-menu-everywhere.test.tsx create mode 100644 components/email/__tests__/selection-view.test.ts create mode 100644 components/email/selection-view.tsx diff --git a/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx b/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx index 1b84b48c9..18ca47bf9 100644 --- a/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx +++ b/components/email/__tests__/email-context-menu-remove-from-folder.test.tsx @@ -106,11 +106,13 @@ describe('Remove from this folder in the message menu', () => { const onBatchRemoveFromFolder = vi.fn(); const batch = { isMultiSelect: true, selectedCount: 2, onBatchRemoveFromFolder }; const { unmount } = renderMenu({ ...batch, batchEmails: [inBoth, inboxOnly] }); + fireEvent.click(screen.getByTestId('ctx-more')); expect(screen.queryByTestId('ctx-remove-from-folder')).toBeNull(); unmount(); const both = { ...inBoth, id: 'e3' } as Email; renderMenu({ ...batch, batchEmails: [inBoth, both] }); + fireEvent.click(screen.getByTestId('ctx-more')); fireEvent.click(screen.getByTestId('ctx-remove-from-folder')); expect(onBatchRemoveFromFolder).toHaveBeenCalledTimes(1); }); diff --git a/components/email/__tests__/email-context-menu.test.tsx b/components/email/__tests__/email-context-menu.test.tsx index 44ec665d2..6af616533 100644 --- a/components/email/__tests__/email-context-menu.test.tsx +++ b/components/email/__tests__/email-context-menu.test.tsx @@ -53,6 +53,7 @@ function renderMenu(props: Partial = {}) { describe('EmailContextMenu batch forward as attachment', () => { it('shows the batch header and batch forward item when the right-clicked email is in a multi-selection', () => { renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() }); + fireEvent.click(screen.getByTestId('ctx-more')); expect(screen.getByTestId('ctx-batch-forward-as-attachment')).toBeInTheDocument(); expect(screen.getByText('items_selected')).toBeInTheDocument(); }); @@ -60,6 +61,7 @@ describe('EmailContextMenu batch forward as attachment', () => { it('clicking the batch item invokes onBatchForwardAsAttachment and closes the menu', () => { const onBatchForwardAsAttachment = vi.fn(); const { onClose } = renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment }); + fireEvent.click(screen.getByTestId('ctx-more')); fireEvent.click(screen.getByTestId('ctx-batch-forward-as-attachment')); expect(onBatchForwardAsAttachment).toHaveBeenCalledTimes(1); expect(onClose).toHaveBeenCalledTimes(1); @@ -67,6 +69,7 @@ describe('EmailContextMenu batch forward as attachment', () => { it('hides the single-message forward-as-attachment item in batch mode', () => { renderMenu({ isMultiSelect: true, selectedCount: 3, onBatchForwardAsAttachment: vi.fn() }); + fireEvent.click(screen.getByTestId('ctx-more')); // vitest's next-intl mock renders labels as raw keys; the singular key // must be absent while the plural batch key is present. expect(screen.queryByText('forward_as_attachment')).toBeNull(); @@ -96,6 +99,7 @@ describe('EmailContextMenu batch forward as attachment', () => { it('disables the batch item when no handler is passed', () => { renderMenu({ isMultiSelect: true, selectedCount: 3 }); + fireEvent.click(screen.getByTestId('ctx-more')); expect((screen.getByTestId('ctx-batch-forward-as-attachment') as HTMLButtonElement).disabled).toBe(true); }); }); diff --git a/components/email/__tests__/email-list-batch-bar.test.tsx b/components/email/__tests__/email-list-batch-bar.test.tsx index 67bddb70b..0183674a2 100644 --- a/components/email/__tests__/email-list-batch-bar.test.tsx +++ b/components/email/__tests__/email-list-batch-bar.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { useEmailStore } from '@/stores/email-store'; import { useAuthStore } from '@/stores/auth-store'; import { useSettingsStore } from '@/stores/settings-store'; @@ -61,6 +61,7 @@ describe('selection bar', () => { fireEvent.click(screen.getByTestId('batch-more')); expect(screen.getByText('items_selected')).toBeTruthy(); expect(screen.getByTestId('ctx-move-to')).toBeTruthy(); + fireEvent.click(screen.getByTestId('ctx-more')); expect(screen.getByTestId('ctx-batch-forward-as-attachment')).toBeTruthy(); }); }); @@ -99,7 +100,8 @@ describe('selection bar: Move, read state and the reading pane', () => { const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement; expect(pane).not.toBeNull(); expect(pane.textContent).toContain('selected_conversations'); - for (const id of ['archive', 'delete', 'move', 'mark-read']) { + // All read: the pane offers Mark as unread, as the selection's menu does. + for (const id of ['archive', 'delete', 'move', 'mark-unread', 'star', 'tag', 'spam', 'more']) { expect(pane.querySelector(`[data-testid="pane-batch-${id}"]`)).not.toBeNull(); } fireEvent.click(pane.querySelector('[data-testid="pane-batch-archive"]')!); @@ -136,15 +138,71 @@ describe('the reading pane for a selection (mail-p2-15)', () => { document.body.appendChild(host); render(); const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement; - for (const id of ['archive', 'delete', 'move', 'mark-read', 'tag']) { - const button = pane.querySelector(`[data-testid="pane-batch-${id}"]`) as HTMLElement; - expect(button.textContent?.trim()).toBe(button.getAttribute('aria-label')); + for (const button of Array.from(pane.querySelectorAll('button'))) { + expect(button.textContent?.trim()).not.toBe(''); } expect(screen.getByTestId('batch-archive').textContent).toBe(''); host.remove(); }); }); +// The selection view shows the selection menu's top level as buttons, in its order, +// with More actions as a labelled button, not a bare icon. +describe('the selection view', () => { + const paneLabels = (pane: HTMLElement) => + Array.from(pane.querySelectorAll('[data-testid="selection-actions"] button')) + .map((button) => button.querySelector('[data-selection-label]')?.textContent ?? ''); + + it('has the same buttons as the selection menu has entries, in the same order', () => { + const emails = useEmailStore.getState().emails.map((email, i) => (i === 0 ? { ...email, keywords: {} } : email)); + useEmailStore.setState({ emails }); + const host = document.createElement('div'); + document.body.appendChild(host); + render(); + const pane = host.querySelector('[data-testid="selection-pane"]') as HTMLElement; + fireEvent.click(screen.getByTestId('batch-more')); + const menu = screen.getAllByRole('menu').find((m) => m.textContent?.includes('items_selected'))!; + const menuTop = Array.from(menu.querySelectorAll('[role="menuitem"]')) + .filter((row) => row.parentElement?.closest('[role="menu"]') === menu) + .map((row) => row.querySelector('[data-menu-label]')?.textContent ?? ''); + expect(paneLabels(pane)).toEqual(menuTop); + expect(paneLabels(pane)).toEqual(['archive', 'delete', 'move_to', 'mark_read', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'more_actions']); + host.remove(); + }); + + it('opens what the menu has under More actions from its More actions button', () => { + const host = document.createElement('div'); + document.body.appendChild(host); + render(); + fireEvent.click(host.querySelector('[data-testid="pane-batch-more"]')!); + const menu = screen.getByRole('menu', { name: 'more_actions' }); + expect(within(menu).getByTestId('ctx-batch-forward-as-attachment')).toBeTruthy(); + expect(within(menu).queryByTestId('ctx-archive')).toBeNull(); + host.remove(); + }); + + const lineLengths = (width: number) => { + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(width); + const emails = useEmailStore.getState().emails.map((email, i) => (i === 0 ? { ...email, keywords: {} } : email)); + useEmailStore.setState({ emails }); + const host = document.createElement('div'); + document.body.appendChild(host); + const { unmount } = render(); + const lengths = Array.from(host.querySelectorAll('[data-selection-line]')).map((line) => line.querySelectorAll('button').length); + unmount(); + host.remove(); + vi.restoreAllMocks(); + return lengths; + }; + + it('leaves no button alone on a line in a narrow pane', () => { + // Nine buttons, room for three a line: three even lines, not 3 + 3 + 2 + 1. + expect(lineLengths(560)).toEqual([3, 3, 3]); + // Room for two: a column, rather than one left over. + expect(lineLengths(360).every((n) => n === 1)).toBe(true); + }); +}); + describe('selection bar and rows from the keyboard', () => { it('keeps the collapsed bar out of the Tab order', () => { useEmailStore.setState({ selectedEmailKeys: new Set() }); diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx index 043f7dbb9..98c067e31 100644 --- a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx +++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx @@ -277,6 +277,16 @@ describe('message-row-actions', () => { expect(onClose).toHaveBeenCalled(); }); + it('puts them under More actions in the right-click menu of a row, in the browser and the desktop app alike', async () => { + act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later' }])); + menu({ rowMenu: true }); + expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull(); + fireEvent.click(screen.getByTestId('ctx-more')); + const more = screen.getByRole('menu', { name: 'more_actions' }); + await act(async () => { fireEvent.click(within(more).getByRole('menuitem', { name: 'Later' })); }); + expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' }); + }); + it('keeps them out of the menu for a multi-selection, and out of an empty menu', () => { const { unmount } = menu(); expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull(); diff --git a/components/email/__tests__/phishing-report-menus.test.tsx b/components/email/__tests__/phishing-report-menus.test.tsx index 9a25f5627..7d9644e1d 100644 --- a/components/email/__tests__/phishing-report-menus.test.tsx +++ b/components/email/__tests__/phishing-report-menus.test.tsx @@ -167,6 +167,7 @@ describe('the row menu', () => { it('reports a whole selection, one message each', () => { setAddress('soc@example.org'); rowMenu('inbox', { isMultiSelect: true, selectedCount: 2, batchEmails: [email, other], onBatchMarkAsSpam: vi.fn() }); + fireEvent.click(screen.getByTestId('ctx-more')); fireEvent.click(screen.getByTestId('ctx-batch-report-phishing')); expect(usePhishingReportStore.getState().pending?.emails.map((e) => e.id)).toEqual(['e1', 'e2']); }); diff --git a/components/email/__tests__/row-menu-everywhere.test.tsx b/components/email/__tests__/row-menu-everywhere.test.tsx new file mode 100644 index 000000000..b99df236d --- /dev/null +++ b/components/email/__tests__/row-menu-everywhere.test.tsx @@ -0,0 +1,138 @@ +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() })); +// Rules needs a Sieve account; its entry stands in here. +vi.mock('../rules-menu', () => ({ + RulesContextSubMenu: () =>
rules.title
, +})); + +import { EmailContextMenu } from '../email-context-menu'; + +// The message's right-click menu is short everywhere: the browser and the desktop app +// show the same HTML menu (the desktop app had a native copy, without icons and with +// Chromium's key names), and the rarely used entries are under More actions. A +// selection gets the same structure without what needs one message. + +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', + hasAttachment: false, size: 100, +} as unknown as Email; +const other = { ...email, id: 'e2', keywords: {} } as unknown as Email; + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, + { id: 'work', name: 'Work', role: null, parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +type MenuProps = React.ComponentProps; + +function renderMenu(props: Partial = {}) { + return render( + , + ); +} + +/** The labels of a menu's own rows, not those of its submenus. */ +function labels(menu: HTMLElement): string[] { + return Array.from(menu.querySelectorAll('[role="menuitem"]')) + .filter((row) => row.parentElement?.closest('[role="menu"]') === menu) + .map((row) => row.querySelector('[data-menu-label]')?.textContent ?? ''); +} + +const ROW_TOP = ['reply', 'reply_all', 'forward', 'archive', 'delete', 'move_to', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'open_in_window', 'more_actions']; + +afterEach(() => { + window.bulwarkDesktop = undefined; + localStorage.clear(); +}); + +describe("a message row's right-click menu", () => { + it('has the short list at the top in the browser', () => { + renderMenu(); + expect(labels(screen.getAllByRole('menu')[0])).toEqual(ROW_TOP); + }); + + it('is the same HTML menu in the desktop app, on screen, with no native copy', async () => { + const popup = vi.fn(async () => null); + window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as never; + renderMenu(); + const menu = screen.getAllByRole('menu')[0]; + await waitFor(() => expect(menu.style.visibility).toBe('visible')); + expect(labels(menu)).toEqual(ROW_TOP); + // Icons on the rows, as in the browser. + expect(within(menu).getByTestId('ctx-delete').querySelector('svg')).not.toBeNull(); + expect(popup).not.toHaveBeenCalled(); + }); + + it('keeps the rest under More actions, in the order asked for', () => { + renderMenu(); + fireEvent.click(screen.getByTestId('ctx-more')); + expect(labels(screen.getByRole('menu', { name: 'more_actions' }))).toEqual([ + 'forward_as_attachment', 'copy_message', 'copy_conversation', 'rules.title', 'pin', 'export_email', 'print', 'view_source', + ]); + }); +}); + +describe("a selection's right-click menu", () => { + const selection = { isMultiSelect: true, selectedCount: 2, batchEmails: [email, other] }; + + it('has the same structure, without what needs one message', () => { + renderMenu(selection); + const top = labels(screen.getAllByRole('menu')[0]); + expect(top).toEqual(['archive', 'delete', 'move_to', 'mark_read', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'more_actions']); + for (const single of ['reply', 'reply_all', 'forward', 'open_in_window']) expect(top).not.toContain(single); + fireEvent.click(screen.getByTestId('ctx-more')); + expect(labels(screen.getByRole('menu', { name: 'more_actions' }))).toEqual(['forward_as_attachments', 'rules.title']); + }); + + it('stars the whole selection', () => { + const onBatchSetStarred = vi.fn(); + renderMenu({ ...selection, onBatchSetStarred }); + fireEvent.click(screen.getByTestId('ctx-star')); + expect(onBatchSetStarred).toHaveBeenCalledWith(true); + }); + + it('only More actions, for the selection view\'s button', () => { + renderMenu({ ...selection, section: 'more' }); + expect(labels(screen.getAllByRole('menu')[0])).toEqual(['forward_as_attachments', 'rules.title']); + }); +}); diff --git a/components/email/__tests__/selection-view.test.ts b/components/email/__tests__/selection-view.test.ts new file mode 100644 index 000000000..272093940 --- /dev/null +++ b/components/email/__tests__/selection-view.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { selectionRows } from '../selection-view'; + +// The selection view's buttons go in even lines: none left alone on the last one. + +describe('selectionRows', () => { + it('puts everything on one line when it fits', () => { + expect(selectionRows(4, 4)).toEqual([4]); + }); + + it('evens out the lines instead of leaving one button alone', () => { + expect(selectionRows(7, 3)).toEqual([3, 2, 2]); + expect(selectionRows(9, 4)).toEqual([3, 3, 3]); + expect(selectionRows(5, 4)).toEqual([3, 2]); + }); + + it('never has a line of one next to longer lines, for any count and width', () => { + for (let count = 2; count <= 12; count++) { + for (let perLine = 1; perLine <= 6; perLine++) { + const lines = selectionRows(count, perLine); + expect(lines.reduce((a, b) => a + b, 0)).toBe(count); + expect(Math.max(...lines)).toBeLessThanOrEqual(Math.max(1, perLine)); + if (lines.some((n) => n > 1)) expect(Math.min(...lines)).toBeGreaterThan(1); + } + } + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index 0d0d2eb0b..112d41c47 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -2,9 +2,7 @@ import { useMemo } from "react"; import { useLocale, useTranslations } from "next-intl"; -import { spamApplicableIn } from "@/lib/spam-applicable"; -import { useSettingsStore } from "@/stores/settings-store"; -import { useEmailStore, removalFolder, canRemoveFromFolder, bulkDeleteIsPermanent } from "@/stores/email-store"; +import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags"; import { Email, Mailbox } from "@/lib/jmap/types"; import { @@ -17,22 +15,9 @@ import { import { PluginSlot } from "@/components/plugins/plugin-slot"; import { PluginActions, useMessageActionTarget, usePluginActions } from "@/components/plugins/plugin-actions"; import { - Mail, - MailOpen, - Trash2, - Archive, - FolderInput, - FolderX, - Tag, - ShieldAlert, - ShieldCheck, - Paperclip, - Copy, - Fish, MoreHorizontal, - Undo, } from "@/components/icons"; -import { tidySeparators, useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; +import { rowMenuSections, useMessageActionList, useSelectionActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; import { restoreTarget, restoreTargetForAll } from "@/lib/trash-origin"; import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu"; @@ -43,8 +28,6 @@ import { useConnectionState } from "@/hooks/use-connection-state"; import { getEmailTagIds } from "@/lib/thread-utils"; import { TagPicker } from "./tag-picker"; import { RulesContextSubMenu } from "./rules-menu"; -import { useArchiveActionEnabled } from "@/lib/archive-gate"; -import { usePhishingReport } from "@/hooks/use-phishing-report"; interface Position { x: number; @@ -104,6 +87,8 @@ interface EmailContextMenuProps { onRescheduleScheduled?: () => void; // Batch actions onBatchMarkAsRead?: (read: boolean) => void; + /** Stars the whole selection, or takes the star off every message. */ + onBatchSetStarred?: (starred: boolean) => void; /** Tags every selected message carries, and those only some of them do. */ batchTagIds?: string[]; batchPartialTagIds?: string[]; @@ -152,21 +137,13 @@ interface EmailContextMenuProps { * "More actions". The reader's ⋮ is that "more" already, so it shows them all. */ rowMenu?: boolean; + /** + * "more": only what a selection's menu has under "More actions", for the + * selection view's More actions button. + */ + section?: "all" | "more"; } -/** - * What a row's menu shows at its top level, as Outlook and Gmail have it. The rest - * goes under "More actions". - */ -export const ROW_MENU_KEYS = new Set([ - "reschedule", "cancel_and_edit", "cancel_send", "edit_draft", - "reply", "reply_all", "forward", - "restore", "archive", "delete", "move", - "read", "star", "tag", - "spam", - "open_window", -]); - /** Entries that change the message on the server, or queue the change offline. */ const CHANGES_MAIL = new Set([ "archive", "delete", "read", "remove_from_folder", "star", "pin", "spam", @@ -201,6 +178,7 @@ export function EmailContextMenu({ onMarkAsSpam, onUndoSpam, onBatchMarkAsRead, + onBatchSetStarred, batchTagIds, batchPartialTagIds, batchTagScope = null, @@ -230,33 +208,22 @@ export function EmailContextMenu({ extraEntries, label, rowMenu = false, + section = "all", }: EmailContextMenuProps) { const t = useTranslations("context_menu"); const tViewer = useTranslations("email_viewer"); - const tRecover = useTranslations("recover_deleted"); const shortcutOf = useMessageActionShortcut(); // Offline, a change to a message waits in the outbox only where the account keeps // its mail on this device; otherwise it would fail, so it's greyed out. Rules and // copies to another account always need the server. const { offline, canChangeMail } = useConnectionState(); const tMenu = useTranslations("menuAudit"); - const archiveActionEnabled = useArchiveActionEnabled(); - const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); const isUnread = !email.keywords?.$seen; const currentTagIds = getEmailTagIds(email.keywords); // The row's account names its tags; a selection uses batchTagScope. const tagScope = useEmailTagScope(email); const showBatchActions = isMultiSelect && selectedCount > 1; - const isInJunkFolder = currentMailboxRole === 'junk'; - // Marking your own outgoing mail as spam makes no sense - hide the action - // in Sent, Drafts and Scheduled, and in Trash, where it is already thrown out. - const spamApplicable = spamApplicableIn(currentMailboxRole); - const deleteForever = bulkDeleteIsPermanent({ role: currentMailboxRole }); - const tActions = useTranslations("reader_actions"); const isScheduled = email.isScheduled === true; - // One report per selected message, behind one confirm (enterprise pack). - const batchPhishing = usePhishingReport(showBatchActions ? (batchEmails ?? []) : [], currentMailboxRole); - const tPhishing = useTranslations("phishing_report"); // "Remove from this folder" needs one open folder and messages that are // also in another one; for a selection, every selected message must be. const folderToLeave = useEmailStore(removalFolder); @@ -383,91 +350,7 @@ export function EmailContextMenu({ ) : null; - const archiveItem = archiveActionEnabled && ( - <> - {/* Archive */} - handleAction(onBatchArchive!)} - disabled={!onBatchArchive || !canChangeMail} - /> - - ); - const deleteItem = ( - <> - {/* Delete */} - handleAction(onBatchDelete!)} - disabled={!onBatchDelete || !canChangeMail} - destructive={!mountainView} - /> - - ); - // A selection of read and unread mail gets both; otherwise the one that changes something. - const selection = batchEmails?.length ? batchEmails : [email]; - const someUnread = selection.some((e) => !e.keywords?.$seen); - const someRead = selection.some((e) => !!e.keywords?.$seen); - const readItem = ( - <> - {someUnread && ( - handleAction(() => onBatchMarkAsRead?.(true))} - /> - )} - {someRead && ( - handleAction(() => onBatchMarkAsRead?.(false))} - /> - )} - - ); - const batchRestoreItem = batchRestoreTo ? ( - <> - handleAction(() => onBatchMoveToMailbox?.(batchRestoreTo))} - /> - - - ) : null; - const moveItem = ( - <> - {/* Move to submenu */} - {canMoveAnywhere && ( - - {renderMoveList(onBatchMoveToMailbox)} - - )} - {canRemove && ( - handleAction(onBatchRemoveFromFolder!)} - /> - )} - - ); + // Copy to another connected account, keeping the originals. const copyTargetsContent = onCopyToAccount ? copyTrees.map((target) => (
@@ -476,73 +359,33 @@ export function EmailContextMenu({
)) : null; - const copyItem = ( - <> - {/* Copy to another connected account, keeping the originals */} - {onCopyToAccount && copyTrees.length > 0 && ( - - {copyTargetsContent} - - )} - - ); - const rulesItem = ( - <> - {/* Rules: filter rules made from the sender(s), saved as Sieve */} - - - ); - const tagItem = ( - <> - {/* Set tag submenu - the whole selection's tags (#1077) */} - {onBatchToggleTag && ( - -
- onBatchToggleTag?.(tagId)} - scope={batchTagScope} - /> -
-
- )} - - ); - const spamBlock = ( - <> - {/* Spam - contextual based on folder; pointless on own outgoing mail */} - {spamApplicable && ( - <> - - handleAction(isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)} - disabled={(isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) || !canChangeMail} - destructive={!isInJunkFolder && !mountainView} - /> - {batchPhishing.available && ( - handleAction(batchPhishing.report)} - /> - )} - - )} - - ); - const renderSubmenu = (entry: Extract) => { + // A selection: the same structure as a row's menu, without what needs one + // message (useSelectionActionList), shared with the reading pane's selection view. + const selectionLists = useSelectionActionList({ + emails: showBatchActions ? (batchEmails?.length ? batchEmails : [email]) : [], + currentMailboxRole, + canMove: canMoveAnywhere, + canRemoveFromFolder: canRemove, + canCopyToAccount: !!onCopyToAccount && copyTrees.length > 0, + canTag: !!onBatchToggleTag, + onRestore: batchRestoreTo ? () => onBatchMoveToMailbox?.(batchRestoreTo) : undefined, + onArchive: onBatchArchive, + onDelete: onBatchDelete, + onMarkAsRead: onBatchMarkAsRead, + onSetStarred: onBatchSetStarred, + onMarkAsSpam: onBatchMarkAsSpam, + onUndoSpam: onBatchUndoSpam, + onForwardAsAttachment: onBatchForwardAsAttachment, + onRemoveFromFolder: onBatchRemoveFromFolder, + }); + + const renderSubmenu = (entry: Extract, batch: boolean) => { switch (entry.key) { case "move": return ( - {renderMoveList(onMoveToMailbox)} + {renderMoveList(batch ? onBatchMoveToMailbox : onMoveToMailbox)} ); case "copy_to_account": @@ -555,7 +398,16 @@ export function EmailContextMenu({ return (
- onSetTag?.(tagId)} scope={tagScope} /> + {batch ? ( + onBatchToggleTag?.(tagId)} + scope={batchTagScope} + /> + ) : ( + onSetTag?.(tagId)} scope={tagScope} /> + )}
); @@ -564,43 +416,78 @@ export function EmailContextMenu({ } }; - const renderEntries = (entries: MessageActionEntry[], keyPrefix = "") => entries.map((entry, index) => { + const renderEntries = (entries: MessageActionEntry[], keyPrefix = "", batch = false) => entries.map((entry, index) => { if (entry.kind === "separator") return ; - if (entry.kind === "submenu") return renderSubmenu(entry); + if (entry.kind === "submenu") return renderSubmenu(entry, batch); + // Mark as read shows Shift+I, Mark as unread U. + const unread = entry.key === "read" ? entry.testId === "ctx-mark-read" : isUnread; return ( handleAction(entry.run)} /> ); }); - // A row's menu: the usual few, the rest one level down. Not Spam in Spam - // and Restore in Trash stay at the top. - const rowTop = rowMenu - ? tidySeparators(singleEntries.filter((entry) => entry.kind === "separator" || ROW_MENU_KEYS.has(entry.key))) - : singleEntries; - const rowMore = rowMenu - ? tidySeparators(singleEntries.filter((entry) => entry.kind === "separator" || !ROW_MENU_KEYS.has(entry.key))) - : []; - const singleMenu = ( + + // Plugins' entries: under "More actions" in a row's or a selection's menu, + // at the end of the reader's ⋮ (which is that "more" already). + const pluginEntries = ( <> - {renderEntries(rowTop)} - {rowMore.length > 0 && ( - <> - - - {renderEntries(rowMore, "more-")} - - - )} + {!showBatchActions && rowPlugins && rowPluginBlock} + ); + const moreMenu = (entries: MessageActionEntry[], batch: boolean) => ( + <> + + + {renderEntries(entries, "more-", batch)} + {pluginEntries} + + + ); + + let body: React.ReactNode; + if (showBatchActions) { + body = isScheduled ? null : section === "more" ? ( + <> + {renderEntries(selectionLists.more, "more-", true)} + {pluginEntries} + + ) : ( + <> + {t("items_selected", { count: selectedCount })} + {renderEntries(selectionLists.top, "", true)} + {moreMenu(selectionLists.more, true)} + + ); + } else if (rowMenu) { + // A row's menu: the usual few, the rest one level down. + const { top, more } = rowMenuSections(singleEntries); + body = ( + <> + {renderEntries(top)} + {moreMenu(more, false)} + {extraEntries} + + ); + } else { + body = ( + <> + {renderEntries(singleEntries)} + {rowPlugins && rowPluginBlock} + {extraEntries} + + + ); + } return ( {offline && {tMenu("offline_header")}} - {showBatchActions ? ( - <> - - {t("items_selected", { count: selectedCount })} - - - {!isScheduled && (<> - {batchRestoreItem} - {/* Compose-style actions first, above the archive/delete group, - mirroring the single menu's ordering. */} - handleAction(onBatchForwardAsAttachment!)} - disabled={!onBatchForwardAsAttachment} - /> - - - {mountainView ? ( - <> - {archiveItem} - {deleteItem} - {readItem} - - {moveItem} - {tagItem} - {rulesItem} - {copyItem} - {spamBlock} - - ) : ( - <> - {archiveItem} - {deleteItem} - - {moveItem} - {copyItem} - {rulesItem} - {tagItem} - {spamBlock} - - {readItem} - - )} - )} - - ) : ( - <> - {singleMenu} - {rowPlugins && rowPluginBlock} - {extraEntries} - - )} - - + {body} ); } diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index ec02b40c5..b06421934 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -11,11 +11,15 @@ import type { LoadListAttachments } from "@/lib/list-attachments"; import { listRowShowsChips } from "./attachment-chips"; import { listVerificationCode } from "@/lib/verification-code"; import { EmailContextMenu } from "./email-context-menu"; +import { SelectionView } from "./selection-view"; +import { restoreTargetForAll } from "@/lib/trash-origin"; +import { useSelectionActionList } from "./message-actions"; +import { useMessageActionShortcut } from "./message-action-shortcuts"; import { ViewSourceDialog } from "./view-source-dialog"; import { useCopyToAccount, useEmailExport, requestPrint } from "@/hooks/use-message-file-actions"; import { BatchTagButton } from "./batch-tag-button"; import { cn, cleanPreview } from "@/lib/utils"; -import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, ShieldAlert, Archive, MoreVertical, X, FolderX, Fish, FolderInput, CheckSquare } from "@/components/icons"; +import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, ShieldAlert, Archive, MoreVertical, X, FolderX, Fish, FolderInput } from "@/components/icons"; import { createPortal } from "react-dom"; import { ContextMenu } from "@/components/ui/context-menu"; import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; @@ -51,7 +55,7 @@ import { useTranslations } from "next-intl"; import { localizeMailboxName } from "@/lib/mailbox-label"; import { toast } from "@/stores/toast-store"; import { confirmMailRemoval } from "@/lib/mail-removal-confirm"; -import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; +import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionStarredWithUndo, setSelectionTagWithUndo, toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; import { useVirtualizer } from "@tanstack/react-virtual"; import { measureVisibleRow } from "@/lib/virtual-row-measure"; import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display"; @@ -208,6 +212,7 @@ export function EmailList({ const openInWindow = useOpenEmailInWindow(); const tSpam = useTranslations('email_viewer.spam'); const tNotifications = useTranslations('notifications'); + const tSelection = useTranslations('selectionView'); const tViewer = useTranslations('email_viewer'); const tSidebar = useTranslations('sidebar'); const tListExtra = useTranslations('mail_list_extra'); @@ -406,6 +411,11 @@ export function EmailList({ const [sourceEmail, setSourceEmail] = useState(null); const [isProcessing, setIsProcessing] = useState(false); + // The selection view's More actions opens only that part of the selection's menu. + const [menuSection, setMenuSection] = useState<'all' | 'more'>('all'); + useEffect(() => { + if (!contextMenu.isOpen) setMenuSection('all'); + }, [contextMenu.isOpen]); const later = useTimeoutWhileMounted(); const parentRef = useRef(null); const batchToolbarRef = useRef(null); @@ -657,6 +667,20 @@ export function EmailList({ } }; + const handleBatchSetStarred = async (starred: boolean) => { + if (!client || isProcessing) return; + setIsProcessing(true); + try { + await setSelectionStarredWithUndo(client, starred, { + success: tSelection(starred ? 'starred' : 'unstarred'), + error: tNotifications('error_updating'), + labels: undo, + }); + } finally { + later(() => setIsProcessing(false), 500); + } + }; + const handleBatchUndoSpam = async () => { if (!client || isProcessing) return; setIsProcessing(true); @@ -780,33 +804,45 @@ export function EmailList({ // One read/unread button: "Mark as read" while any selected message is unread. const selectionHasUnread = selectedEmailsForRemoval.some((email) => !email.keywords?.$seen); - // Archive, Delete, Move, Mark read/unread and Tag, on the bar and in the - // reading pane. The pane's copies get their own test ids. - const selectionButtons = (where: 'bar' | 'pane') => { - const testId = (id: string) => (where === 'bar' ? id : `pane-${id}`); - // The pane has room: its buttons say what they do (mail-p2-15). The - // bar beside the list stays icons with tooltips. - const labelled = where === 'pane'; - const shape = labelled ? { size: 'sm' as const, title: '', className: 'gap-1.5' } : {}; - const name = (label: string) => (labelled ? {label} : null); - return ( + // The selection view's buttons: the selection menu's top level (useSelectionActionList). + const shortcutOf = useMessageActionShortcut(); + const selectionUnread = selectedEmailsForRemoval.filter((email) => !email.keywords?.$seen).length; + const selectionRestoreTo = effectiveMailboxRole === 'trash' && hasSelection + ? restoreTargetForAll(selectedEmailsForRemoval, mailboxes, selectedMailbox) + : undefined; + const selectionActions = useSelectionActionList({ + emails: selectedEmailsForRemoval, + currentMailboxRole: effectiveMailboxRole, + canMove: !moveMenuIsEmpty(selectionMoveModel), + canRemoveFromFolder: selectionCanLeaveFolder, + canTag: !isScheduledView, + onRestore: selectionRestoreTo ? () => void handleBatchMoveToMailbox(selectionRestoreTo) : undefined, + onArchive: () => void handleBatchArchive(), + onDelete: () => void handleBatchDelete(), + onMarkAsRead: (read) => void handleBatchMarkAsRead(read), + onSetStarred: (starred) => void handleBatchSetStarred(starred), + onMarkAsSpam: () => void handleBatchMarkAsSpam(), + onUndoSpam: () => void handleBatchUndoSpam(), + onRemoveFromFolder: () => void handleBatchRemoveFromFolder(), + }); + + // Archive, Delete, Move, Mark read/unread and Tag, as icons on the bar beside the + // list. The reading pane has the selection view. + const selectionButtons = () => ( <> {archiveActionEnabled && effectiveMailboxRole !== 'archive' && ( - void handleBatchArchive()} disabled={isProcessing} data-testid={testId('batch-archive')} {...shape}> + void handleBatchArchive()} disabled={isProcessing} data-testid="batch-archive"> - {name(tContextMenu('archive'))} )} - {name(t('batch_actions.delete'))} {!moveMenuIsEmpty(selectionMoveModel) && ( moveMenu.openContextMenuFrom(e.currentTarget, null, 'end')} disabled={isProcessing} - data-testid={testId('batch-move')} - {...shape} + data-testid="batch-move" > - {name(tContextMenu('move_to'))} )} handleBatchMarkAsRead(selectionHasUnread)} disabled={isProcessing} - data-testid={testId('batch-mark-read')} - {...shape} + data-testid="batch-mark-read" > {selectionHasUnread ? : } - {name(selectionHasUnread ? t('batch_actions.mark_read') : t('batch_actions.mark_unread'))} - ); - }; + ); + // What the bar and the pane call the selection: conversations, as the list // shows them, or emails when conversations are off. const selectionTitle = disableThreading @@ -1197,7 +1228,7 @@ export function EmailList({ {/* Archive, Delete, Move, Mark read/unread, Tag, then what fits a wide bar. The rest is in its ⋮, the selection's context menu (R6). */} - {selectionButtons('bar')} + {selectionButtons()} {selectionCanLeaveFolder && !batchNarrow && ( )} - {/* The reading pane for a selection: its count and the bar's buttons (mail-10). */} + {/* The reading pane for a selection: the selection menu's actions as buttons. */} {selectionPaneHost && hasSelection && !isScheduledView && createPortal( -
-
- -
-

{selectionTitle}

-
- {selectionButtons('pane')} - { - const anchor = selectedEmailsForRemoval[0]; - if (anchor) openContextMenuFrom(e.currentTarget, anchor, 'end'); - }} - > - - -
- -
, + (entry.key === 'star' ? undefined : shortcutOf(entry.key, entry.testId === 'ctx-mark-read'))} + onMove={(button) => moveMenu.openContextMenuFrom(button, null, 'start')} + onMore={(button) => { + const anchor = selectedEmailsForRemoval[0]; + if (!anchor) return; + setMenuSection('more'); + openContextMenuFrom(button, anchor, 'start'); + }} + onClear={clearSelection} + tags={{ selectedIds: selectionTags.all, partialIds: selectionTags.some, scope: selectionTags.scope, onToggle: handleBatchToggleTag }} + disabled={isProcessing || selectedEmailsForRemoval.length === 0} + />, selectionPaneHost, )} @@ -1602,6 +1625,8 @@ export function EmailList({ } : undefined} onViewSource={contextMenuEmail && !canExport(contextMenuEmail) ? undefined : () => setSourceEmail(contextMenuEmail)} onBatchMarkAsRead={(read) => markSelectionRead(read)} + onBatchSetStarred={(starred) => void handleBatchSetStarred(starred)} + section={menuSection} batchTagIds={selectionTags.all} batchPartialTagIds={selectionTags.some} batchTagScope={selectionTags.scope} diff --git a/components/email/message-actions.ts b/components/email/message-actions.ts index 8e707087e..0c8cc23ed 100644 --- a/components/email/message-actions.ts +++ b/components/email/message-actions.ts @@ -17,6 +17,7 @@ import { Mail, MailOpen, Star, + StarOff, Pin, PinOff, Trash2, @@ -320,3 +321,156 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr if (o.extras && o.extras.length > 0) list = [...list, SEPARATOR, ...o.extras]; return tidySeparators(list); } + +/** + * What a message's right-click and long-press menu shows at its top level, in the + * browser and in the desktop app alike: answer, file away, mark, spam, open. The rest + * goes under "More actions" (rowMenuSections). Restore in Trash, Not spam in Spam and a + * draft's or a scheduled message's own entries stay at the top. + */ +export const ROW_MENU_KEYS: ReadonlySet = new Set([ + "reschedule", "cancel_and_edit", "cancel_send", "edit_draft", + "reply", "reply_all", "forward", + "restore", "archive", "delete", "move", + "read", "star", "tag", + "spam", + "open_window", +]); + +/** + * "More actions", in groups with a line between: forwarding, links, copying and rules, + * marks, then save, print and source. Keys not named here (a host's own entries) go + * before the last group. + */ +const MORE_GROUPS: MessageActionKey[][] = [ + ["forward_as_attachment"], + ["copy_message_link", "copy_conversation_link"], + ["copy_to_account", "rules", "remove_from_folder"], + ["pin", "report_phishing", "find_from_sender"], + ["export", "print", "view_source"], +]; + +function moreGroupOf(key: MessageActionKey): number { + const at = MORE_GROUPS.findIndex((group) => group.includes(key)); + return at >= 0 ? at : MORE_GROUPS.length - 1.5; +} + +/** Splits one message's actions into the row menu's top level and its "More actions". */ +export function rowMenuSections(list: MessageActionEntry[]): { top: MessageActionEntry[]; more: MessageActionEntry[] } { + const top = tidySeparators(list.filter((entry) => entry.kind === "separator" || ROW_MENU_KEYS.has(entry.key))); + const rest = list.filter( + (entry): entry is Exclude => entry.kind !== "separator" && !ROW_MENU_KEYS.has(entry.key), + ); + // Stable: within a group the list's own order stays. + const sorted = rest + .map((entry, index) => ({ entry, index, group: moreGroupOf(entry.key) })) + .sort((a, b) => a.group - b.group || a.index - b.index); + const more: MessageActionEntry[] = []; + sorted.forEach(({ entry, group }, i) => { + if (i > 0 && sorted[i - 1].group !== group) more.push(SEPARATOR); + more.push(entry); + }); + return { top, more }; +} + +export interface SelectionActionOptions { + /** The selected messages. */ + emails: Email[]; + currentMailboxRole?: string; + canMove?: boolean; + canRemoveFromFolder?: boolean; + canCopyToAccount?: boolean; + /** Offer the Tag submenu. */ + canTag?: boolean; + /** In Trash: puts every selected message back. */ + onRestore?: () => void; + onArchive?: () => void; + onDelete?: () => void; + onMarkAsRead?: (read: boolean) => void; + onSetStarred?: (starred: boolean) => void; + onMarkAsSpam?: () => void; + onUndoSpam?: () => void; + onForwardAsAttachment?: () => void; + onRemoveFromFolder?: () => void; +} + +/** + * The actions for a selection of several messages. The selection's right-click menu + * and the reading pane's selection view both show these, in this order: the same + * structure as one message's row menu, without what needs a single message (answering, + * its own window, links, saving, printing, its source). + */ +export function useSelectionActionList(o: SelectionActionOptions): { top: MessageActionEntry[]; more: MessageActionEntry[] } { + const t = useTranslations("context_menu"); + const tPhishing = useTranslations("phishing_report"); + const tRecover = useTranslations("recover_deleted"); + const tActions = useTranslations("reader_actions"); + const archiveEnabled = useArchiveActionEnabled(); + const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); + const phishing = usePhishingReport(o.emails, o.currentMailboxRole); + const role = o.currentMailboxRole; + const inJunk = role === "junk"; + const deleteForever = bulkDeleteIsPermanent({ role }); + const someUnread = o.emails.some((e) => !e.keywords?.$seen); + const someRead = o.emails.some((e) => !!e.keywords?.$seen); + const allStarred = o.emails.length > 0 && o.emails.every((e) => !!e.keywords?.$flagged); + + const item = ( + key: MessageActionKey, + icon: Icon, + label: string, + run: (() => void) | undefined, + extra: { destructive?: boolean; testId?: string } = {}, + ): MessageActionEntry => ({ kind: "item", key, icon, label, run: () => run?.(), disabled: !run, ...extra }); + + const restore = role === "trash" && o.onRestore + ? [item("restore", Undo, tRecover("restore"), o.onRestore, { testId: "ctx-restore" }), SEPARATOR] + : []; + // In Archive there's nowhere further to archive it to. + const archive = archiveEnabled && role !== "archive" ? [item("archive", Archive, t("archive"), o.onArchive, { testId: "ctx-archive" })] : []; + const del = item("delete", Trash2, deleteForever ? tActions("delete_forever") : t("delete"), o.onDelete, { + destructive: !mountainView, + testId: "ctx-delete", + }); + const move: MessageActionEntry[] = o.canMove ? [{ kind: "submenu", key: "move", icon: FolderInput, label: t("move_to"), testId: "ctx-move-to" }] : []; + // A selection of read and unread mail gets both. + const read = [ + ...(someUnread ? [item("read", MailOpen, t("mark_read"), () => o.onMarkAsRead?.(true), { testId: "ctx-mark-read" })] : []), + ...(someRead ? [item("read", Mail, t("mark_unread"), () => o.onMarkAsRead?.(false), { testId: "ctx-mark-unread" })] : []), + ]; + // Takes the star off when every one has it, else stars them all. + const star = o.onSetStarred + ? [item("star", allStarred ? StarOff : Star, allStarred ? t("unstar") : t("star"), () => o.onSetStarred?.(!allStarred), { + testId: allStarred ? "ctx-unstar" : "ctx-star", + })] + : []; + const tag: MessageActionEntry[] = o.canTag ? [{ kind: "submenu", key: "tag", icon: Tag, label: t("tag"), testId: "ctx-tag" }] : []; + const spam = spamApplicableIn(role) + ? [item("spam", inJunk ? ShieldCheck : ShieldAlert, inJunk ? t("not_spam") : t("mark_as_spam"), inJunk ? o.onUndoSpam : o.onMarkAsSpam, { + destructive: !inJunk && !mountainView, + testId: inJunk ? "ctx-not-spam" : "ctx-spam", + })] + : []; + + // The row menu's order in each layout (useMessageActionList). + const top = tidySeparators(mountainView + ? [...restore, ...archive, del, ...read, SEPARATOR, ...move, ...tag, SEPARATOR, ...star, ...spam] + : [...restore, ...archive, del, SEPARATOR, ...move, SEPARATOR, ...read, ...star, ...tag, SEPARATOR, ...spam]); + + const copy: MessageActionEntry[] = o.canCopyToAccount + ? [{ kind: "submenu", key: "copy_to_account", icon: Copy, label: t("copy_to_account"), testId: "ctx-copy-to" }] + : []; + const more = tidySeparators([ + item("forward_as_attachment", Paperclip, t("forward_as_attachments"), o.onForwardAsAttachment, { testId: "ctx-batch-forward-as-attachment" }), + SEPARATOR, + ...copy, + { kind: "submenu", key: "rules", icon: Filter, label: t("rules.title"), testId: "ctx-rules" }, + ...(o.canRemoveFromFolder && o.onRemoveFromFolder + ? [item("remove_from_folder", FolderX, t("remove_from_folder"), o.onRemoveFromFolder, { testId: "ctx-remove-from-folder" })] + : []), + ...(phishing.available + ? [SEPARATOR, item("report_phishing", Fish, tPhishing("action"), phishing.report, { testId: "ctx-batch-report-phishing" })] + : []), + ]); + return { top, more }; +} diff --git a/components/email/selection-view.tsx b/components/email/selection-view.tsx new file mode 100644 index 000000000..fe0f2400a --- /dev/null +++ b/components/email/selection-view.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { ChevronDown, MoreHorizontal, CheckSquare } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { ContextMenu, MenuShortcut } from "@/components/ui/context-menu"; +import { useContextMenu } from "@/hooks/use-context-menu"; +import { cn } from "@/lib/utils"; +import { TagPicker } from "./tag-picker"; +import type { MessageActionEntry, MessageActionKey } from "./message-actions"; +import type { TagScope } from "@/hooks/use-account-tags"; + +/** The narrowest a button gets before the row takes one fewer (CSS px). */ +const MIN_BUTTON_WIDTH = 168; +const GAP = 8; + +/** + * How many buttons go on each line: as few lines as the width allows, and the lines as + * even as they can be, so no button is left alone on the last one. With room for one + * button a line, or no way to avoid a single one, it's one column. + */ +export function selectionRows(count: number, perLine: number): number[] { + if (count <= 0) return []; + const most = Math.max(1, Math.min(perLine, count)); + const lines = Math.ceil(count / most); + const base = Math.floor(count / lines); + if (lines > 1 && base < 2) return Array.from({ length: count }, () => 1); + const longer = count % lines; + return Array.from({ length: lines }, (_, i) => base + (i < longer ? 1 : 0)); +} + +/** "pane-batch-archive": the ids the selection bar's buttons had, for the pane's copies. */ +function paneTestId(entry: { key: string; testId?: string }): string { + const id = (entry.testId ?? entry.key).replace(/^ctx-/, "").replace(/^move-to$/, "move"); + return `pane-batch-${id}`; +} + +export interface SelectionViewProps { + /** "4 conversations selected". */ + title: string; + /** How many of them are unread; a line under the title when there are some. */ + unread: number; + /** The selection menu's top level (useSelectionActionList), in its order. */ + entries: MessageActionEntry[]; + /** The key hint for an entry, as the menu shows it. */ + shortcutOf: (entry: Extract) => string | undefined; + /** Opens the Move list under the button. */ + onMove: (button: HTMLElement) => void; + /** Opens what the selection menu has under "More actions", under the button. */ + onMore: (button: HTMLElement) => void; + onClear: () => void; + /** The tag picker for the whole selection. */ + tags: { selectedIds: string[]; partialIds: string[]; scope: TagScope | null; onToggle: (tagId: string) => void }; + disabled?: boolean; + /** Entries the device can't run now (offline). */ + isDisabled?: (key: MessageActionKey) => boolean; +} + +/** + * The reading pane while several messages are selected, as Outlook lays it out: what's + * selected, the selection menu's actions as buttons in even lines, More actions for the + * rest, and a way out. The buttons and their order come from the same list as the + * selection's right-click menu. + */ +export function SelectionView({ title, unread, entries, shortcutOf, onMove, onMore, onClear, tags, disabled, isDisabled }: SelectionViewProps) { + const t = useTranslations("email_list"); + const tViewer = useTranslations("email_viewer"); + const tCount = useTranslations("notification_center"); + const tagMenu = useContextMenu(); + const gridRef = useRef(null); + const [perLine, setPerLine] = useState(4); + + // As many buttons a line as fit the pane (a container's width, not the window's). + useLayoutEffect(() => { + const grid = gridRef.current; + if (!grid) return; + const measure = () => { + const width = grid.clientWidth; + if (width > 0) setPerLine(Math.max(1, Math.floor((width + GAP) / (MIN_BUTTON_WIDTH + GAP)))); + }; + measure(); + if (typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver(measure); + observer.observe(grid); + return () => observer.disconnect(); + }, []); + + // After an action the button that had the focus can go (Mark as read once all are + // read): the focus stays in the view, on the button now in its place. + const focused = useRef<{ el: HTMLElement; index: number } | null>(null); + useEffect(() => { + const grid = gridRef.current; + const last = focused.current; + if (!grid || !last || last.el.isConnected) return; + const active = document.activeElement; + if (active && active !== document.body) return; + const buttons = Array.from(grid.querySelectorAll("button:not(:disabled)")); + buttons[Math.min(last.index, buttons.length - 1)]?.focus(); + }); + + const items = entries.filter((entry) => entry.kind !== "separator"); + // More actions is the last button of the grid. + const lines = selectionRows(items.length + 1, perLine); + const widest = Math.max(1, ...lines); + const cell = `calc((100% - ${(widest - 1) * GAP}px) / ${widest})`; + + const button = (entry: Exclude, index: number) => { + const Icon = entry.icon; + const common = { + variant: "outline" as const, + size: "sm" as const, + style: { width: cell }, + className: cn( + "gap-2 min-w-0 justify-center", + entry.kind === "item" && entry.destructive && "hover:bg-destructive/10 hover:text-destructive", + ), + onFocus: (e: React.FocusEvent) => { focused.current = { el: e.currentTarget, index }; }, + }; + const label = {entry.label}; + if (entry.kind === "submenu") { + const open = (el: HTMLElement) => (entry.key === "tag" ? tagMenu.openContextMenuFrom(el, null, "start") : onMove(el)); + return ( + + ); + } + const key = shortcutOf(entry); + return ( + + ); + }; + + const all = [ + ...items.map((entry, index) => button(entry, index)), + , + ]; + let at = 0; + const rows = lines.map((length) => { + const row = all.slice(at, at + length); + at += length; + return row; + }); + + return ( +
+
+ ); +} From 6b11e850150d8865307a8bdd3a773532aad36682 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:06:30 +0100 Subject: [PATCH 05/35] test: open More actions before Report phishing in a row's menu on the server --- integration/tests/22-ent-security.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/integration/tests/22-ent-security.spec.ts b/integration/tests/22-ent-security.spec.ts index 051556ec5..441ace783 100644 --- a/integration/tests/22-ent-security.spec.ts +++ b/integration/tests/22-ent-security.spec.ts @@ -392,6 +392,8 @@ test('item 2: the ARF format arrives as a feedback report', async ({ browser }) await deliverAndWait({ from: ENT.bob, to: [ENT.alice], subject, text: 'Click here' }); const alice = await userPage(browser, ENT.alice); await alice.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`).first().click({ button: 'right', timeout: 60_000 }); + // A row's menu has it under More actions. + await alice.getByTestId('ctx-more').click(); await alice.getByTestId('ctx-report-phishing').click(); await alice.getByTestId('phishing-report-confirm').click(); await expect(alice.getByText(`Reported to ${ENT.sec} and moved to Junk.`).first()).toBeVisible({ timeout: 30_000 }); From 286bfc43e9fdf7ebe0a58be90ce811462f5fd4da Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:13:06 +0100 Subject: [PATCH 06/35] fix: give the selection view the reading pane's width so its buttons sit side by side --- components/mail/mail-app.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 7833a66e2..80d5c04cf 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -6637,7 +6637,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { void handleCompose(); }} emptyPaneContent={emptyPaneBesideList && selectedEmailKeys.size > 0 && !isScheduledView && !mvShell - ?
+ ?
: openFailure && !isLoadingEmail ? void handleEmailSelect(openFailure.target)} /> : undefined} From a21ef2aa353bf19f995319cba3be7ab3252d7387 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:13:06 +0100 Subject: [PATCH 07/35] fix: group More actions in fewer parts --- components/email/message-actions.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/components/email/message-actions.ts b/components/email/message-actions.ts index 0c8cc23ed..07976af39 100644 --- a/components/email/message-actions.ts +++ b/components/email/message-actions.ts @@ -338,15 +338,14 @@ export const ROW_MENU_KEYS: ReadonlySet = new Set Date: Wed, 4 Nov 2026 16:22:56 +0100 Subject: [PATCH 08/35] fix: lay out the selection view's buttons by the width their words need --- components/email/selection-view.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/components/email/selection-view.tsx b/components/email/selection-view.tsx index fe0f2400a..ebaec071e 100644 --- a/components/email/selection-view.tsx +++ b/components/email/selection-view.tsx @@ -11,8 +11,8 @@ import { TagPicker } from "./tag-picker"; import type { MessageActionEntry, MessageActionKey } from "./message-actions"; import type { TagScope } from "@/hooks/use-account-tags"; -/** The narrowest a button gets before the row takes one fewer (CSS px). */ -const MIN_BUTTON_WIDTH = 168; +/** The narrowest a button gets, whatever its words (CSS px). */ +const MIN_BUTTON_WIDTH = 120; const GAP = 8; /** @@ -71,20 +71,30 @@ export function SelectionView({ title, unread, entries, shortcutOf, onMove, onMo const gridRef = useRef(null); const [perLine, setPerLine] = useState(4); - // As many buttons a line as fit the pane (a container's width, not the window's). + // As many buttons a line as fit the pane (a container's width, not the window's), each + // as wide as the longest words of any of them, so none has to cut its label. + const labelsKey = entries.map((entry) => (entry.kind === "separator" ? "" : entry.label)).join("|"); useLayoutEffect(() => { const grid = gridRef.current; if (!grid) return; const measure = () => { const width = grid.clientWidth; - if (width > 0) setPerLine(Math.max(1, Math.floor((width + GAP) / (MIN_BUTTON_WIDTH + GAP)))); + if (width <= 0) return; + let need = MIN_BUTTON_WIDTH; + for (const button of Array.from(grid.querySelectorAll("button"))) { + const label = button.querySelector("[data-selection-label]"); + if (!label) continue; + // The label's whole text, plus what the button has around it (padding, icons). + need = Math.max(need, label.scrollWidth + button.clientWidth - label.clientWidth + 2); + } + setPerLine(Math.max(1, Math.floor((width + GAP) / (need + GAP)))); }; measure(); if (typeof ResizeObserver === "undefined") return; const observer = new ResizeObserver(measure); observer.observe(grid); return () => observer.disconnect(); - }, []); + }, [labelsKey]); // After an action the button that had the focus can go (Mark as read once all are // read): the focus stays in the view, on the button now in its place. From 7c96deebbeda5e2c414399e43afd6f639007d414 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:34:49 +0100 Subject: [PATCH 09/35] fix: measure what a selection view button needs apart from the width it has --- components/email/selection-view.tsx | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/components/email/selection-view.tsx b/components/email/selection-view.tsx index ebaec071e..d2cdbcd17 100644 --- a/components/email/selection-view.tsx +++ b/components/email/selection-view.tsx @@ -82,10 +82,14 @@ export function SelectionView({ title, unread, entries, shortcutOf, onMove, onMo if (width <= 0) return; let need = MIN_BUTTON_WIDTH; for (const button of Array.from(grid.querySelectorAll("button"))) { - const label = button.querySelector("[data-selection-label]"); - if (!label) continue; - // The label's whole text, plus what the button has around it (padding, icons). - need = Math.max(need, label.scrollWidth + button.clientWidth - label.clientWidth + 2); + // Its icons, the label's whole text, the gaps between them, padding and border: + // what it needs whatever width it has now. + const style = getComputedStyle(button); + const parts = Array.from(button.children) as HTMLElement[]; + const content = parts.reduce((sum, part) => sum + (part.hasAttribute("data-selection-label") ? part.scrollWidth : part.getBoundingClientRect().width), 0); + const gaps = (parseFloat(style.columnGap) || 0) * Math.max(0, parts.length - 1); + const frame = (parseFloat(style.paddingLeft) || 0) + (parseFloat(style.paddingRight) || 0) + (parseFloat(style.borderLeftWidth) || 0) + (parseFloat(style.borderRightWidth) || 0); + need = Math.max(need, Math.ceil(content + gaps + frame) + 2); } setPerLine(Math.max(1, Math.floor((width + GAP) / (need + GAP)))); }; From 62ab5cbb7632d8bd62d662fb14ff3edeffc4940a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:40:03 +0100 Subject: [PATCH 10/35] feat: add a Files on this computer settings page to the desktop app The settings nav test drew every page but recovery; the new page waits for the desktop app's sync engine like recovery waits for its probe, so the test leaves it out too and a new test checks it shows while the engine runs. --- app/boot-skeleton.css | 2 + .../boot-skeleton/settings-skeleton.tsx | 2 + components/files-sync/files-sync-prompts.tsx | 156 ++++++++ .../files-sync/files-sync-status-line.tsx | 60 +++ components/files-sync/files-sync-text.ts | 46 +++ .../__tests__/desktop-files-settings.test.tsx | 314 ++++++++++++++++ .../__tests__/settings-boot-skeleton.test.tsx | 19 +- .../settings-search-anchors.test.tsx | 17 +- .../settings/desktop-files-settings.tsx | 346 ++++++++++++++++++ components/settings/settings-app.tsx | 6 + components/settings/settings-tab-icons.ts | 4 +- lib/settings-search.ts | 11 + lib/settings-tabs.ts | 2 +- locales/ar/common.json | 106 ++++++ locales/ca/common.json | 106 ++++++ locales/cs/common.json | 106 ++++++ locales/da/common.json | 106 ++++++ locales/de/common.json | 106 ++++++ locales/en/common.json | 106 ++++++ locales/es/common.json | 106 ++++++ locales/et/common.json | 106 ++++++ locales/fa/common.json | 106 ++++++ locales/fr/common.json | 106 ++++++ locales/he/common.json | 106 ++++++ locales/hu/common.json | 106 ++++++ locales/it/common.json | 106 ++++++ locales/ja/common.json | 106 ++++++ locales/ko/common.json | 106 ++++++ locales/lv/common.json | 106 ++++++ locales/mn/common.json | 106 ++++++ locales/nb/common.json | 106 ++++++ locales/nl/common.json | 106 ++++++ locales/pl/common.json | 106 ++++++ locales/pt/common.json | 106 ++++++ locales/ro/common.json | 106 ++++++ locales/ru/common.json | 106 ++++++ locales/sk/common.json | 106 ++++++ locales/th/common.json | 106 ++++++ locales/tr/common.json | 106 ++++++ locales/uk/common.json | 106 ++++++ locales/vi/common.json | 106 ++++++ locales/zh-TW/common.json | 106 ++++++ locales/zh/common.json | 106 ++++++ 43 files changed, 4158 insertions(+), 7 deletions(-) create mode 100644 components/files-sync/files-sync-prompts.tsx create mode 100644 components/files-sync/files-sync-status-line.tsx create mode 100644 components/files-sync/files-sync-text.ts create mode 100644 components/settings/__tests__/desktop-files-settings.test.tsx create mode 100644 components/settings/desktop-files-settings.tsx diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index dbf7543cf..0592e4bfd 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -220,6 +220,7 @@ [data-boot-settings-tab="calendar"] [data-sk-settings-tab="calendar"], [data-boot-settings-tab="contacts"] [data-sk-settings-tab="contacts"], [data-boot-settings-tab="files"] [data-sk-settings-tab="files"], + [data-boot-settings-tab="files_sync"] [data-sk-settings-tab="files_sync"], [data-boot-settings-tab="addons"] [data-sk-settings-tab="addons"], [data-boot-settings-tab="offline"] [data-sk-settings-tab="offline"], [data-boot-settings-tab="privacy"] [data-sk-settings-tab="privacy"], @@ -239,6 +240,7 @@ @custom-variant boot-no-settings-addons (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="addons"]) *)); @custom-variant boot-no-settings-offline (&:where([data-boot-settings-tabs]:not([data-boot-settings-tabs~="offline"]) *)); @custom-variant boot-settings-recovery (&:where([data-boot-settings-tabs~="recovery"] *)); +@custom-variant boot-settings-files-sync (&:where([data-boot-settings-tabs~="files_sync"] *)); /* Settings pages with one section have no section jump row (SectionNav). */ @custom-variant boot-settings-one-section (&:where([data-boot-settings-tab="security"] *, [data-boot-settings-tab="filters"] *, [data-boot-settings-tab="vacation"] *, [data-boot-settings-tab="recovery"] *, [data-boot-settings-tab="addons"] *)); diff --git a/components/boot-skeleton/settings-skeleton.tsx b/components/boot-skeleton/settings-skeleton.tsx index c49515063..82477c6bb 100644 --- a/components/boot-skeleton/settings-skeleton.tsx +++ b/components/boot-skeleton/settings-skeleton.tsx @@ -27,6 +27,8 @@ const OPTIONAL: Partial> = { calendar: "boot-no-calendar:hidden boot-no-settings-calendar:hidden", contacts: "boot-no-settings-contacts:hidden", files: "boot-no-files:hidden boot-no-settings-files:hidden", + // Desktop app only, while the files sync runs. + files_sync: "hidden boot-settings-files-sync:flex", addons: "boot-no-settings-addons:hidden", // Not in the static Lite build. offline: "boot-no-settings-offline:hidden", diff --git a/components/files-sync/files-sync-prompts.tsx b/components/files-sync/files-sync-prompts.tsx new file mode 100644 index 000000000..fdf938506 --- /dev/null +++ b/components/files-sync/files-sync-prompts.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { useEffect, useState, useSyncExternalStore } from "react"; +import { useTranslations } from "next-intl"; +import { AlertTriangle } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { Dialog } from "@/components/ui/dialog"; +import { useFilesSyncStore } from "@/stores/files-sync-store"; +import type { FilesRoot } from "@/lib/desktop/types"; +import type { FilesSyncRootStatus } from "@/lib/files-sync/types"; +import { rootName } from "./files-sync-text"; + +/* + * The mass-delete question (FILES-SYNC-BRIEF: more than 50 items or more than half a + * pair at once pauses the pair and asks). The settings page asks it in the folder's + * row; everywhere else FilesSyncPrompts asks it in a dialog, so the user sees it + * wherever they are. + */ + +let inlineHosts = 0; +const inlineListeners = new Set<() => void>(); +const subscribeInline = (listener: () => void) => { + inlineListeners.add(listener); + return () => { inlineListeners.delete(listener); }; +}; +const inlineShown = () => inlineHosts > 0; + +/** The settings page asks in its rows while it's open: the dialog stays away meanwhile. */ +export function useInlineMassDeleteQuestions(): void { + useEffect(() => { + inlineHosts += 1; + inlineListeners.forEach((listener) => listener()); + return () => { + inlineHosts -= 1; + inlineListeners.forEach((listener) => listener()); + }; + }, []); +} + +/** The question's text, for a root in `confirm-delete`. */ +export function useMassDeleteText(root: FilesRoot, massDelete: NonNullable): string { + const t = useTranslations("files_sync"); + // The side the items went from: this computer's folder, or the Files folder. + return massDelete.side === "local" + ? t("mass_delete.local", { count: massDelete.count, folder: rootName(root, t("drive.root_name")) }) + : t("mass_delete.remote", { count: massDelete.count, folder: root.remoteLabel || t("drive.root_name") }); +} + +interface AnswerButtonsProps { + rootId: string; + count: number; + /** After an answer went out. */ + onAnswered?: () => void; +} + +/** "Delete them" and "Bring them back", straight to the engine. */ +export function MassDeleteAnswers({ rootId, count, onAnswered }: AnswerButtonsProps) { + const t = useTranslations("files_sync"); + const controller = useFilesSyncStore((s) => s.controller); + const [busy, setBusy] = useState(false); + const answer = (allow: boolean) => { + if (!controller) return; + setBusy(true); + void controller.confirmMassDelete(rootId, allow).catch(() => {}).finally(() => { + setBusy(false); + onAnswered?.(); + }); + }; + return ( + <> + + + + ); +} + +/** The question inside a folder's row on the settings page. */ +export function MassDeleteQuestion({ root, status }: { root: FilesRoot; status: FilesSyncRootStatus }) { + const massDelete = status.massDelete ?? { count: 0, side: "local" as const }; + const text = useMassDeleteText(root, massDelete); + return ( +
+

+

+
+ +
+
+ ); +} + +function PromptDialog({ root, status, onLater }: { root: FilesRoot; status: FilesSyncRootStatus; onLater: () => void }) { + const t = useTranslations("files_sync"); + const massDelete = status.massDelete!; + const text = useMassDeleteText(root, massDelete); + return ( + +
+ )} + footer={( + <> + + + + )} + /> + ); +} + +/** + * App-wide: asks about a mass delete wherever the user is, one root at a time. + * "Decide later" hides it for this question; the folder's row on the settings page + * still asks, and the pair stays paused until then. + */ +export function FilesSyncPrompts() { + const available = useFilesSyncStore((s) => s.available); + const roots = useFilesSyncStore((s) => s.roots); + const status = useFilesSyncStore((s) => s.status); + const controller = useFilesSyncStore((s) => s.controller); + const pageAsks = useSyncExternalStore(subscribeInline, inlineShown, () => false); + const [later, setLater] = useState>(() => new Set()); + + if (!available || !controller || pageAsks) return null; + const keyOf = (s: FilesSyncRootStatus) => `${s.rootId}:${s.massDelete?.side}:${s.massDelete?.count}`; + const root = roots.find((r) => { + const s = status[r.id]; + return s?.state === "confirm-delete" && !!s.massDelete && !later.has(keyOf(s)); + }); + if (!root) return null; + const rootStatus = status[root.id]; + return ( + setLater((prev) => new Set(prev).add(keyOf(rootStatus)))} + /> + ); +} diff --git a/components/files-sync/files-sync-status-line.tsx b/components/files-sync/files-sync-status-line.tsx new file mode 100644 index 000000000..a94d09139 --- /dev/null +++ b/components/files-sync/files-sync-status-line.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useFormatter, useTranslations } from "next-intl"; +import { AlertCircle, AlertTriangle, CheckCircle2, CloudOff, Loader2, Lock, Pause, RefreshCw, type AppIcon } from "@/components/icons"; +import { cn } from "@/lib/utils"; +import type { FilesSyncRootState, FilesSyncRootStatus } from "@/lib/files-sync/types"; +import { filesSyncErrorKey, syncTimeOptions } from "./files-sync-text"; + +const ICONS: Record = { + starting: { icon: Loader2, className: "text-muted-foreground animate-spin motion-reduce:animate-none" }, + syncing: { icon: RefreshCw, className: "text-primary animate-spin motion-reduce:animate-none" }, + idle: { icon: CheckCircle2, className: "text-green-600 dark:text-green-400" }, + paused: { icon: Pause, className: "text-muted-foreground" }, + offline: { icon: CloudOff, className: "text-muted-foreground" }, + error: { icon: AlertCircle, className: "text-destructive" }, + "confirm-delete": { icon: AlertTriangle, className: "text-amber-600 dark:text-amber-400" }, + blocked: { icon: Lock, className: "text-muted-foreground" }, +}; + +/** One line saying how a root is doing: up to date, syncing, paused, offline, stopped. */ +export function FilesSyncStatusLine({ status, paused, className }: { + status: FilesSyncRootStatus | undefined; + /** The root says it's paused; the engine may not have said so yet. */ + paused?: boolean; + className?: string; +}) { + const t = useTranslations("files_sync"); + const format = useFormatter(); + const state: FilesSyncRootState = status?.state ?? (paused ? "paused" : "starting"); + + let text: string; + switch (state) { + case "idle": + text = status?.lastSyncedAt + ? t("status.idle", { time: format.dateTime(new Date(status.lastSyncedAt), syncTimeOptions(status.lastSyncedAt)) }) + : t("status.idle_never"); + break; + case "syncing": + text = t("status.syncing", { done: status?.done ?? 0, pending: status?.pending ?? 0 }); + break; + case "offline": + text = t("status.offline", { count: status?.pending ?? 0 }); + break; + case "error": + text = t("status.error", { reason: t(filesSyncErrorKey(status?.errorKey)) }); + break; + case "confirm-delete": + text = t("status.confirm_delete"); + break; + default: + text = t(`status.${state}`); + } + const { icon: Icon, className: iconClass } = ICONS[state]; + return ( +

+

+ ); +} diff --git a/components/files-sync/files-sync-text.ts b/components/files-sync/files-sync-text.ts new file mode 100644 index 000000000..50c395ba0 --- /dev/null +++ b/components/files-sync/files-sync-text.ts @@ -0,0 +1,46 @@ +import type { FilesRoot } from "@/lib/desktop/types"; +import type { FilesSyncActivityKind, FilesSyncItemStatus } from "@/lib/files-sync/types"; + +/** The engine's message keys, under `files_sync.errors.`. */ +export const FILES_SYNC_ERROR_KEYS = [ + "offline", "server_refused", "too_large", "name_invalid", "disk_full", "denied", "busy", "quota", "not_found", "policy", "failed", +] as const; + +/** + * The key inside the `files_sync` namespace for an engine message key. The engine may + * write `offline`, `errors.offline` or `files_sync.errors.offline`; anything unknown + * reads as `failed`, so a new key never shows raw. + */ +export function filesSyncErrorKey(key: string | undefined | null): string { + const last = (key ?? "").split(".").pop() ?? ""; + return `errors.${(FILES_SYNC_ERROR_KEYS as readonly string[]).includes(last) ? last : "failed"}`; +} + +export const FILES_SYNC_ACTIVITY_KINDS: readonly FilesSyncActivityKind[] = [ + "uploaded", "downloaded", "folder-created", "renamed", "moved", "deleted", "trashed", + "conflict", "renamed-for-disk", "too-large", "waiting", "mass-delete", "error", +]; + +export const FILES_SYNC_ITEM_STATES: readonly FilesSyncItemStatus[] = ["synced", "syncing", "pending", "error", "online-only"]; + +/** "Show in Explorer", "Show in Finder", "Show in file manager". */ +export function showInKey(platform: string | undefined | null): string { + if (platform === "win32") return "show_in.explorer"; + if (platform === "darwin") return "show_in.finder"; + return "show_in.file_manager"; +} + +/** The name of a root to show: the drive's, or the last part of the folder's path. */ +export function rootName(root: FilesRoot, driveName: string): string { + if (root.kind === "drive") return driveName; + const parts = root.path.split(/[\\/]+/).filter(Boolean); + return parts[parts.length - 1] ?? root.path; +} + +/** A time as "14:05" today and "3 Nov, 14:05" before. */ +export function syncTimeOptions(at: number, now = Date.now()): { hour: "numeric"; minute: "2-digit"; day?: "numeric"; month?: "short" } { + const sameDay = new Date(at).toDateString() === new Date(now).toDateString(); + return sameDay + ? { hour: "numeric", minute: "2-digit" } + : { day: "numeric", month: "short", hour: "numeric", minute: "2-digit" }; +} diff --git a/components/settings/__tests__/desktop-files-settings.test.tsx b/components/settings/__tests__/desktop-files-settings.test.tsx new file mode 100644 index 000000000..4ef21b3e3 --- /dev/null +++ b/components/settings/__tests__/desktop-files-settings.test.tsx @@ -0,0 +1,314 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextIntlClientProvider } from 'next-intl'; +import en from '@/locales/en/common.json'; +import type { FilesDriveState, FilesRoot } from '@/lib/desktop/types'; +import type { FilesSyncActivity, FilesSyncController, FilesSyncRootStatus } from '@/lib/files-sync/types'; + +// Files on this computer, the settings page: the drive, synced folders, the +// activity list and the mass-delete question, against the store and a fake +// controller (the engine is built elsewhere). + +vi.unmock('next-intl'); +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); + +const { useFilesSyncStore } = await import('@/stores/files-sync-store'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { DesktopFilesSettings } = await import('../desktop-files-settings'); + +type Fake = { [K in keyof FilesSyncController]: ReturnType }; + +function fakeController(): Fake { + return { + addFolder: vi.fn(async () => ({ ok: true as const, value: folderRoot() })), + removeRoot: vi.fn(async () => {}), + setPaused: vi.fn(async () => {}), + confirmMassDelete: vi.fn(async () => {}), + setDriveEnabled: vi.fn(async () => ({ ok: true as const, value: null })), + setDriveLetter: vi.fn(async () => ({ ok: true as const, value: { letter: 'Z', chosen: 'Z', locked: false, available: [] } })), + coverage: vi.fn(() => null), + showInFolder: vi.fn(async () => true), + clearActivity: vi.fn(), + } as never; +} + +function folderRoot(patch: Partial = {}): FilesRoot { + return { + id: 'r1', kind: 'folder', account: 'acc', path: 'C:\\Users\\ann\\Documents\\Projects', remoteId: 'n-proj', + remoteLabel: 'Projects', paused: false, createdAt: 1, ...patch, + }; +} + +function drive(patch: Partial = {}): FilesDriveState { + return { + supported: true, forcedOff: false, wanted: [], + letter: { letter: null, chosen: null, locked: false, available: ['X', 'Z'] }, + ...patch, + }; +} + +let controller: ReturnType; + +function setup(patch: Record = {}) { + controller = fakeController(); + useFilesSyncStore.setState({ available: true, account: 'acc', controller: controller as unknown as FilesSyncController, ...patch }); +} + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + useFilesSyncStore.getState().reset(); +}); +afterEach(() => { + useFilesSyncStore.getState().reset(); +}); + +describe('Files on this computer settings page', () => { + it('draws nothing outside the desktop app or on an older shell', () => { + useFilesSyncStore.setState({ available: false, roots: [folderRoot()], drive: drive() }); + const { container } = renderPage(); + expect(container).toBeEmptyDOMElement(); + }); + + it('has no drive section off Windows', () => { + setup({ drive: null }); + renderPage(); + expect(screen.queryByRole('switch', { name: 'Show Files as a drive' })).toBeNull(); + expect(screen.getByRole('heading', { name: 'Synced folders' })).toBeInTheDocument(); + }); + + it('turns the drive on for the account from the click itself', () => { + setup({ drive: drive() }); + renderPage(); + const toggle = screen.getByRole('switch', { name: 'Show Files as a drive' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + // No drive, no letter. + expect(screen.queryByText('Drive letter')).toBeNull(); + fireEvent.click(toggle); + // Synchronously: the shell needs the click it came from. + expect(controller.setDriveEnabled).toHaveBeenCalledWith(true); + }); + + it('shows the drive on, and turns it off', () => { + setup({ drive: drive({ wanted: ['acc'] }) }); + renderPage(); + const toggle = screen.getByRole('switch', { name: 'Show Files as a drive' }); + expect(toggle).toHaveAttribute('aria-checked', 'true'); + fireEvent.click(toggle); + expect(controller.setDriveEnabled).toHaveBeenCalledWith(false); + }); + + it('says the organisation turned the drive off and keeps the switch off', () => { + setup({ drive: drive({ forcedOff: true, wanted: ['acc'] }) }); + renderPage(); + const toggle = screen.getByRole('switch', { name: 'Show Files as a drive' }); + expect(toggle).toHaveAttribute('aria-checked', 'false'); + expect(toggle).toBeDisabled(); + expect(screen.getByText(en.settings.org_locked)).toBeInTheDocument(); + }); + + it('offers no letter by default and the free letters, and sets the chosen one', () => { + setup({ drive: drive({ wanted: ['acc'] }) }); + renderPage(); + const select = screen.getByRole('combobox', { name: 'Drive letter' }) as HTMLSelectElement; + expect(select.value).toBe('none'); + expect([...select.options].map((o) => o.textContent)).toEqual(['No letter', 'X:', 'Z:']); + fireEvent.change(select, { target: { value: 'Z' } }); + expect(controller.setDriveLetter).toHaveBeenCalledWith('Z'); + fireEvent.change(select, { target: { value: 'none' } }); + expect(controller.setDriveLetter).toHaveBeenLastCalledWith(null); + }); + + it('shows a letter forced by policy, locked', () => { + setup({ drive: drive({ wanted: ['acc'], letter: { letter: 'Q', chosen: 'X', locked: true, available: ['X'] } }) }); + renderPage(); + const select = screen.getByRole('combobox', { name: 'Drive letter' }) as HTMLSelectElement; + expect(select.value).toBe('Q'); + expect(select).toBeDisabled(); + const row = select.closest('[data-search-label]') as HTMLElement; + expect(within(row).getByText(en.settings.org_locked)).toBeInTheDocument(); + }); + + it('lists a synced folder with its Files folder and pauses and resumes it', () => { + setup({ roots: [folderRoot()], status: { r1: { rootId: 'r1', state: 'idle', pending: 0, done: 0, lastSyncedAt: null } } }); + const { rerender } = renderPage(); + expect(screen.getByText('C:\\Users\\ann\\Documents\\Projects')).toBeInTheDocument(); + expect(screen.getByText('With Projects in Files')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Pause' })); + expect(controller.setPaused).toHaveBeenCalledWith('r1', true); + + act(() => useFilesSyncStore.setState({ roots: [folderRoot({ paused: true })], status: { r1: { rootId: 'r1', state: 'paused', pending: 0, done: 0, lastSyncedAt: null } } })); + rerender( + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Resume' })); + expect(controller.setPaused).toHaveBeenLastCalledWith('r1', false); + }); + + it('asks before removing a folder, saying its files stay', () => { + setup({ roots: [folderRoot()] }); + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Remove…' })); + const dialog = screen.getByRole('alertdialog'); + expect(dialog).toHaveTextContent('The files on this computer stay where they are'); + expect(controller.removeRoot).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Stop syncing' })); + expect(controller.removeRoot).toHaveBeenCalledWith('r1'); + }); + + it('says how each folder is doing', () => { + const at = Date.UTC(2026, 10, 4, 14, 5); + vi.useFakeTimers({ now: at + 60_000, toFake: ['Date'] }); + const states: [string, Partial][] = [ + ['idle', { lastSyncedAt: at }], + ['syncing', { done: 3, pending: 7 }], + ['paused', {}], + ['offline', { pending: 2 }], + ['error', { errorKey: 'files_sync.errors.quota' }], + ['blocked', {}], + ['starting', {}], + ]; + const roots = states.map(([state]) => folderRoot({ id: state, path: `/home/ann/${state}` })); + const status = Object.fromEntries(states.map(([state, extra]) => [state, { rootId: state, state, pending: 0, done: 0, lastSyncedAt: null, ...extra }])); + setup({ roots, status }); + renderPage(); + const line = (id: string) => document.querySelector(`[data-files-sync-root="${id}"] [data-files-sync-state]`)!.textContent; + expect(line('idle')).toMatch(/^Up to date\. Last synced .*2:05/); + expect(line('syncing')).toBe('Syncing: 3 done, 7 to go'); + expect(line('paused')).toBe('Paused'); + expect(line('offline')).toBe('Offline. 2 changes wait for the server'); + expect(line('error')).toBe('Stopped: Your storage in Files is full'); + expect(line('blocked')).toBe('Turned off by your organisation'); + expect(line('starting')).toBe('Starting…'); + // A blocked folder can't be resumed, only removed. + expect(within(document.querySelector('[data-files-sync-root="blocked"]') as HTMLElement).queryByRole('button', { name: 'Resume' })).toBeNull(); + vi.useRealTimers(); + }); + + it('shows an unknown error key as a plain failure, never the key', () => { + setup({ roots: [folderRoot()], status: { r1: { rootId: 'r1', state: 'error', pending: 0, done: 0, lastSyncedAt: null, errorKey: 'something_new' } } }); + renderPage(); + expect(screen.getByText('Stopped: Something went wrong')).toBeInTheDocument(); + }); + + it('adds a folder: picks the Files folder, then calls the controller from that click', async () => { + const listAllFileNodes = vi.fn(async () => [ + { id: 'n-proj', name: 'Projects', parentId: null, blobId: null }, + { id: 'n-2026', name: '2026', parentId: 'n-proj', blobId: null }, + { id: 'n-file', name: 'a.txt', parentId: 'n-proj', blobId: 'b1' }, + ]); + useAuthStore.setState({ client: { listAllFileNodes } as never }); + setup({ roots: [] }); + renderPage(); + expect(screen.getByText('No folders are synced yet.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Add folder…' })); + const dialog = await screen.findByRole('dialog', { name: 'Which folder in Files?' }); + // Only folders are offered. + expect(within(dialog).queryByText('a.txt')).toBeNull(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Expand Projects' })); + await waitFor(() => expect(within(dialog).getByText('2026')).toBeInTheDocument()); + fireEvent.click(within(dialog).getByText('2026')); + expect(controller.addFolder).not.toHaveBeenCalled(); + fireEvent.click(within(dialog).getByRole('button', { name: 'Choose folder on this computer…' })); + // In the same task as the click: nothing slow is awaited first. + expect(controller.addFolder).toHaveBeenCalledWith('n-2026', 'Projects / 2026'); + useAuthStore.setState({ client: null }); + }); + + it('says why a folder could not be added, and nothing when the picker was closed', async () => { + useAuthStore.setState({ client: { listAllFileNodes: vi.fn(async () => [{ id: 'n-proj', name: 'Projects', parentId: null, blobId: null }]) } as never }); + setup({ roots: [] }); + controller.addFolder.mockResolvedValueOnce({ ok: false, error: 'overlap' }); + renderPage(); + fireEvent.click(screen.getByRole('button', { name: 'Add folder…' })); + let dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByText('Projects')); + fireEvent.click(within(dialog).getByRole('button', { name: 'Choose folder on this computer…' })); + expect(await screen.findByRole('alert')).toHaveTextContent(en.files_sync.folders.add_failed.overlap); + + controller.addFolder.mockResolvedValueOnce({ ok: false, error: 'cancelled' }); + fireEvent.click(screen.getByRole('button', { name: 'Add folder…' })); + dialog = await screen.findByRole('dialog'); + fireEvent.click(within(dialog).getByText('Projects')); + fireEvent.click(within(dialog).getByRole('button', { name: 'Choose folder on this computer…' })); + await waitFor(() => expect(controller.addFolder).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + useAuthStore.setState({ client: null }); + }); + + it('hides adding folders when the organisation turned folder sync off', () => { + setup({ folderSyncForcedOff: true, roots: [folderRoot()], status: { r1: { rootId: 'r1', state: 'blocked', pending: 0, done: 0, lastSyncedAt: null } } }); + renderPage(); + expect(screen.queryByRole('button', { name: 'Add folder…' })).toBeNull(); + expect(screen.getByTestId('files-sync-folders-locked')).toHaveTextContent(en.settings.org_locked); + // The folder can still be removed; its files stay. + expect(screen.getByRole('button', { name: 'Remove…' })).toBeInTheDocument(); + }); + + it('lists recent activity newest first, marks conflicts and errors, and clears it', () => { + const activity: FilesSyncActivity[] = [ + { id: 'a1', at: 1000, rootId: 'r1', kind: 'uploaded', direction: 'up', path: 'notes.txt' }, + { id: 'a3', at: 3000, rootId: 'r1', kind: 'error', direction: 'up', path: 'big.iso', messageKey: 'files_sync.errors.quota' }, + { id: 'a2', at: 2000, rootId: 'r1', kind: 'conflict', direction: null, path: 'plan.docx', detail: 'plan (conflict, PC, 2026-11-04).docx' }, + { id: 'a4', at: 500, rootId: 'r1', kind: 'too-large', direction: null, path: 'video.mp4', detail: '1048576' }, + ]; + setup({ roots: [folderRoot()], activity }); + renderPage(); + const rows = within(screen.getByTestId('files-sync-activity')).getAllByRole('listitem'); + expect(rows.map((r) => r.getAttribute('data-activity-kind'))).toEqual(['error', 'conflict', 'uploaded', 'too-large']); + expect(rows[0]).toHaveTextContent('big.iso couldn\'t be synced: Your storage in Files is full'); + expect(within(rows[0]).getByRole('img', { name: 'Error' })).toBeInTheDocument(); + expect(rows[1]).toHaveTextContent('plan.docx was changed in both places. The other version is kept as plan (conflict, PC, 2026-11-04).docx'); + expect(within(rows[1]).getByRole('img', { name: 'Conflict' })).toBeInTheDocument(); + expect(rows[2]).toHaveTextContent('Projects'); + expect(rows[2]).toHaveTextContent('Uploaded notes.txt'); + expect(within(rows[2]).getByRole('img', { name: 'To Files' })).toBeInTheDocument(); + expect(rows[3]).toHaveTextContent('video.mp4 is bigger than Files allows (1 MB)'); + expect(within(rows[3]).getByRole('img', { name: 'Error' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Clear' })); + expect(controller.clearActivity).toHaveBeenCalled(); + }); + + it('has a line for every kind of activity', () => { + const kinds = ['uploaded', 'downloaded', 'folder-created', 'renamed', 'moved', 'deleted', 'trashed', 'conflict', 'renamed-for-disk', 'too-large', 'waiting', 'mass-delete', 'error'] as const; + setup({ roots: [folderRoot()], activity: kinds.map((kind, i) => ({ id: kind, at: 10_000 - i, rootId: 'r1', kind, direction: null, path: 'x.txt', detail: 'y.txt' })) }); + renderPage(); + const rows = within(screen.getByTestId('files-sync-activity')).getAllByRole('listitem'); + expect(rows).toHaveLength(kinds.length); + for (const row of rows) expect(row.textContent).not.toMatch(/files_sync\.|\{/); + }); + + it('says when nothing happened yet', () => { + setup({ roots: [folderRoot()] }); + renderPage(); + expect(screen.getByText('Nothing has happened yet.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Clear' })).toBeDisabled(); + }); + + it('asks about a mass delete in the folder row, with both answers', () => { + setup({ + roots: [folderRoot(), folderRoot({ id: 'r2', path: '/home/ann/Photos', remoteLabel: 'Photos' })], + status: { + r1: { rootId: 'r1', state: 'confirm-delete', pending: 0, done: 0, lastSyncedAt: null, massDelete: { count: 120, side: 'local' } }, + r2: { rootId: 'r2', state: 'confirm-delete', pending: 0, done: 0, lastSyncedAt: null, massDelete: { count: 64, side: 'remote' } }, + }, + }); + renderPage(); + const first = document.querySelector('[data-files-sync-root="r1"]') as HTMLElement; + expect(first).toHaveTextContent('120 items were deleted from Projects on this computer. Delete them in Files too?'); + const second = document.querySelector('[data-files-sync-root="r2"]') as HTMLElement; + expect(second).toHaveTextContent('64 items were deleted from Photos in Files. Delete them on this computer too?'); + fireEvent.click(within(first).getByRole('button', { name: 'Delete them' })); + expect(controller.confirmMassDelete).toHaveBeenCalledWith('r1', true); + fireEvent.click(within(second).getByRole('button', { name: 'Bring them back' })); + expect(controller.confirmMassDelete).toHaveBeenLastCalledWith('r2', false); + }); +}); diff --git a/components/settings/__tests__/settings-boot-skeleton.test.tsx b/components/settings/__tests__/settings-boot-skeleton.test.tsx index 5417f9871..d974d4cd0 100644 --- a/components/settings/__tests__/settings-boot-skeleton.test.tsx +++ b/components/settings/__tests__/settings-boot-skeleton.test.tsx @@ -35,6 +35,7 @@ const { useSettingsStore } = await import('@/stores/settings-store'); const { SETTINGS_NAV_GROUPS } = await import('../settings-tab-icons'); const { BOOT_HINTS_KEY } = await import('@/lib/boot-skeleton/keys'); const { resetRailHintsMemo } = await import('@/lib/boot-skeleton/hints'); +const { useFilesSyncStore } = await import('@/stores/files-sync-store'); /** A client that supports everything it is asked about. */ const fullClient = new Proxy({}, { @@ -91,11 +92,25 @@ describe('Settings while the session restores', () => { expect(skeleton()).toBeNull(); const shown = [...document.querySelectorAll('[data-testid^="settings-tab-"]')] .map((el) => el.getAttribute('data-testid')!.slice('settings-tab-'.length)); - // Recovery waits for a server probe this test doesn't answer. - const drawn = SETTINGS_NAV_GROUPS.flatMap((g) => g.tabs).filter((tab) => tab !== 'recovery'); + // Recovery waits for a server probe this test doesn't answer; Files on + // this computer for the desktop app's sync engine. + const drawn = SETTINGS_NAV_GROUPS.flatMap((g) => g.tabs).filter((tab) => tab !== 'recovery' && tab !== 'files_sync'); expect(shown).toEqual(drawn); }); + it('lists Files on this computer after Files only while the desktop sync engine runs', () => { + useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: fullClient as never }); + useFilesSyncStore.setState({ available: true }); + try { + render(); + const shown = [...document.querySelectorAll('[data-testid^="settings-tab-"]')] + .map((el) => el.getAttribute('data-testid')!.slice('settings-tab-'.length)); + expect(shown.slice(shown.indexOf('files'), shown.indexOf('files') + 2)).toEqual(['files', 'files_sync']); + } finally { + useFilesSyncStore.getState().reset(); + } + }); + it('leaves the next load the pages this account has', () => { useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: bareClient as never }); render(); diff --git a/components/settings/__tests__/settings-search-anchors.test.tsx b/components/settings/__tests__/settings-search-anchors.test.tsx index 14bb7ff1c..585aef541 100644 --- a/components/settings/__tests__/settings-search-anchors.test.tsx +++ b/components/settings/__tests__/settings-search-anchors.test.tsx @@ -36,6 +36,7 @@ const { useCalendarStore } = await import('@/stores/calendar-store'); const { useContactStore } = await import('@/stores/contact-store'); const { useEmailStore } = await import('@/stores/email-store'); const { useAccountStore } = await import('@/stores/account-store'); +const { useFilesSyncStore } = await import('@/stores/files-sync-store'); const { TourProvider } = await import('@/components/tour/tour-provider'); const { AdvancedSettingsProvider } = await import('../settings-section'); const { tabSearchEntries, getByPath, withAppName, settingsTabLabelKey } = await import('@/lib/settings-search'); @@ -60,6 +61,7 @@ const pages = { contacts: (await import('../contacts-settings')).ContactsSettings, contacts2: (await import('../address-book-management-settings')).AddressBookManagementSettings, files: (await import('../files-settings')).FilesSettingsComponent, + files_sync: (await import('../desktop-files-settings')).DesktopFilesSettings, addons: (await import('../addons-settings')).AddonsSettings, backup: (await import('../about-data-settings')).AboutDataSettings, offline: (await import('@/components/offline/offline-settings')).OfflineSettings, @@ -76,7 +78,7 @@ const OVERRIDES: Record unknown> = { getPrincipals: async () => [], listArchivedEmails: async () => ({ items: [], nextPosition: null }), }; /** Pages with rows of the desktop app's own, or browser rows it hides. */ -const DESKTOP_PAGES = new Set(['general', 'notifications', 'privacy', 'backup']); +const DESKTOP_PAGES = new Set(['general', 'notifications', 'privacy', 'backup', 'files_sync']); /** The desktop app's bridge, answering as Windows does, with every switch off. */ const DESKTOP_BRIDGE = { @@ -121,7 +123,7 @@ const TAB_PAGES: Record = { accessibility: ['accessibility'], message_list: ['message_list'], reading: ['reading'], composing: ['composing'], vacation: ['vacation'], filters: ['filters'], organize: ['organize'], security: ['security'], sharing: ['sharing'], recovery: ['recovery'], calendar: ['calendar', 'calendar2'], - contacts: ['contacts', 'contacts2'], files: ['files'], addons: ['addons'], backup: ['backup'], + contacts: ['contacts', 'contacts2'], files: ['files'], files_sync: ['files_sync'], addons: ['addons'], backup: ['backup'], offline: ['offline'], privacy: ['privacy'], about: ['about'], }; @@ -141,7 +143,15 @@ describe('settings search index', () => { const realMatchMedia = window.matchMedia; const variants: Record void> = { base: () => {}, - desktop: () => { window.bulwarkDesktop = DESKTOP_BRIDGE as never; }, + desktop: () => { + window.bulwarkDesktop = DESKTOP_BRIDGE as never; + // Files on this computer: the engine runs, on Windows, with the drive on offer. + useFilesSyncStore.setState({ + available: true, + account: 'acc', + drive: { supported: true, forcedOff: false, wanted: [], letter: { letter: null, chosen: null, locked: false, available: ['Z'] } }, + }); + }, touch: () => { window.matchMedia = ((q: string) => ({ ...realMatchMedia(q), matches: q.includes('coarse') })) as typeof window.matchMedia; }, @@ -160,6 +170,7 @@ describe('settings search index', () => { useAccountStore.setState(accountsInitial, true); window.matchMedia = realMatchMedia; window.bulwarkDesktop = undefined; + useFilesSyncStore.getState().reset(); apply(); out[variant] = {}; for (const [tab, Page] of Object.entries(pages)) { diff --git a/components/settings/desktop-files-settings.tsx b/components/settings/desktop-files-settings.tsx new file mode 100644 index 000000000..fbeb9357a --- /dev/null +++ b/components/settings/desktop-files-settings.tsx @@ -0,0 +1,346 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useFormatter, useTranslations } from "next-intl"; +import { + AlertCircle, AlertTriangle, ArrowDown, ArrowUp, FolderSync, HardDrive, HelpCircle, Loader2, Trash2, type AppIcon, +} from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { FolderPickerDialog, type PickerFolder } from "@/components/files/folder-picker-dialog"; +import { FilesSyncStatusLine } from "@/components/files-sync/files-sync-status-line"; +import { MassDeleteQuestion, useInlineMassDeleteQuestions } from "@/components/files-sync/files-sync-prompts"; +import { filesSyncErrorKey, rootName, syncTimeOptions } from "@/components/files-sync/files-sync-text"; +import { fileNodeKind } from "@/lib/jmap/filenode-kind"; +import { findTrashFolder, trashSubtreeIds } from "@/lib/files-trash"; +import { cn, formatFileSize } from "@/lib/utils"; +import type { FilesDriveState, FilesError, FilesRoot } from "@/lib/desktop/types"; +import type { FilesSyncActivity, FilesSyncActivityKind } from "@/lib/files-sync/types"; +import { useAuthStore } from "@/stores/auth-store"; +import { useFilesSyncStore } from "@/stores/files-sync-store"; +import { OrgLockedNote } from "./org-locked-note"; +import { SettingItem, SettingsSection, Select, ToggleSwitch } from "./settings-section"; + +/** + * Settings > Files on this computer, in the desktop app only: the Files drive + * (Windows), folders kept the same as a Files folder, and what happened lately. + * Everything comes from stores/files-sync-store.ts and goes to the engine's + * controller; the page never talks to the bridge. + */ +export function DesktopFilesSettings() { + const available = useFilesSyncStore((s) => s.available); + const drive = useFilesSyncStore((s) => s.drive); + // The page asks about a mass delete in the folder's row; the app-wide dialog waits. + useInlineMassDeleteQuestions(); + if (!available) return null; + return ( +
+ {drive && } + + +
+ ); +} + +/** What went wrong turning something on, in words. */ +function addErrorKey(error: FilesError): string | null { + if (error === "cancelled") return null; + if (error === "overlap" || error === "denied" || error === "policy") return `folders.add_failed.${error}`; + return "folders.add_failed.failed"; +} + +function DriveSection({ drive }: { drive: FilesDriveState }) { + const t = useTranslations("files_sync"); + const account = useFilesSyncStore((s) => s.account); + const roots = useFilesSyncStore((s) => s.roots); + const status = useFilesSyncStore((s) => s.status); + const controller = useFilesSyncStore((s) => s.controller); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const on = !drive.forcedOff && !!account && drive.wanted.includes(account); + const driveRoot = roots.find((r) => r.kind === "drive" && r.account === account); + const { letter } = drive; + const letterValue = (letter.locked ? letter.letter : letter.chosen) ?? "none"; + const letters = [...new Set([...letter.available, letter.chosen, letter.letter].filter((l): l is string => !!l))].sort(); + + const toggle = (next: boolean) => { + if (!controller) return; + setError(null); + setBusy(true); + // Straight from the click: turning it on needs the click it came from. + void controller.setDriveEnabled(next).then( + (result) => { if (!result.ok) setError(addErrorKey(result.error)); }, + () => setError("folders.add_failed.failed"), + ).finally(() => setBusy(false)); + }; + const chooseLetter = (value: string) => { + if (!controller) return; + setError(null); + void controller.setDriveLetter(value === "none" ? null : value).then( + (result) => { if (!result.ok) setError(addErrorKey(result.error)); }, + () => setError("folders.add_failed.failed"), + ); + }; + + return ( + + + + + {on && driveRoot && } + {on && driveRoot && status[driveRoot.id]?.state === "confirm-delete" && status[driveRoot.id]?.massDelete && ( + + )} + {(on || letter.locked) && drive.supported && ( + +