From e64c6c7bc30f2d37ab5a8a1f7fdf8c5724301629 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 03:08:30 +0100 Subject: [PATCH 01/46] fix: keep data on a mistyped offline policy and use the shortest lock time for an unreadable one Forced OfflineEnabled values that make no sense read as forced off, which wipes kept mail. They now stop new mail being kept and delete nothing. An unreadable AppLockTimeoutMinutes now means the shortest time, so the existing out-of-range test changes from no value to 1: no value let the user's own choice win. - recommended AutoUpdate, AppLockRequired, TelemetryAllowed and CrashReportsAllowed are gone: nothing read them, or one acted as forced - auth lists and certificate rules can't name a whole top-level domain - ALL_WIRED is gone, as every key has a consumer --- lib/app-lock/__tests__/controller.test.ts | 3 + lib/app-lock/policy.ts | 8 +- lib/desktop/__tests__/policy-client.test.tsx | 7 ++ lib/desktop/__tests__/policy-schema.test.ts | 29 +++++-- lib/desktop/policy-schema.ts | 87 +++++++++++++++++-- lib/desktop/policy.ts | 5 +- lib/device-policy.ts | 6 +- .../__tests__/effective-scope-wiring.test.ts | 31 +++++++ lib/offline/offline-mode.ts | 3 +- 9 files changed, 161 insertions(+), 18 deletions(-) diff --git a/lib/app-lock/__tests__/controller.test.ts b/lib/app-lock/__tests__/controller.test.ts index ad3bd3121..1c2d41e91 100644 --- a/lib/app-lock/__tests__/controller.test.ts +++ b/lib/app-lock/__tests__/controller.test.ts @@ -770,6 +770,9 @@ describe('policy', () => { it('a forced timeout wins over the user\'s choice, a recommended one only over no choice', () => { withPolicy({ value: false, locked: false }, { value: 3, locked: true }); expect(effectiveTimeout(60)).toEqual({ minutes: 3, locked: true }); + // A value nobody could read: the user's choice was never what the admin meant. + (window.bulwarkDesktop as unknown as { policy: { get(): unknown } }).policy.get = () => ({ AppLockTimeoutMinutes: { value: null, locked: true, source: 'forced-machine', invalid: 'out_of_range' } }); + expect(effectiveTimeout(60)).toEqual({ minutes: 1, locked: true }); withPolicy({ value: false, locked: false }, { value: 3, locked: false }); expect(effectiveTimeout(60)).toEqual({ minutes: 60, locked: false }); expect(effectiveTimeout(undefined)).toEqual({ minutes: 3, locked: false }); diff --git a/lib/app-lock/policy.ts b/lib/app-lock/policy.ts index 718e2b27a..a12207dfa 100644 --- a/lib/app-lock/policy.ts +++ b/lib/app-lock/policy.ts @@ -5,8 +5,12 @@ */ import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { POLICY_SCHEMA } from '@/lib/desktop/policy-schema'; import { DEFAULT_TIMEOUT_MINUTES } from './state'; +const timeoutDefinition = POLICY_SCHEMA.AppLockTimeoutMinutes; +const SHORTEST_MINUTES = timeoutDefinition.type === 'integer' ? timeoutDefinition.min : 1; + interface PolicyEntry { value: T; locked: boolean; @@ -51,7 +55,9 @@ export interface EffectiveTimeout { export function effectiveTimeout(choice: number | undefined): EffectiveTimeout { const entry = read().AppLockTimeoutMinutes; const value = typeof entry?.value === 'number' && entry.value > 0 ? entry.value : null; - if (entry?.locked && value !== null) return { minutes: value, locked: true }; + // Forced to something nobody can read, by a shell that hands that on as nothing: the + // tightest there is, since the user's choice was never meant to apply. + if (entry?.locked) return { minutes: value ?? SHORTEST_MINUTES, locked: true }; return { minutes: choice ?? value ?? DEFAULT_TIMEOUT_MINUTES, locked: false }; } diff --git a/lib/desktop/__tests__/policy-client.test.tsx b/lib/desktop/__tests__/policy-client.test.tsx index 37670f0ac..dcc1ce17d 100644 --- a/lib/desktop/__tests__/policy-client.test.tsx +++ b/lib/desktop/__tests__/policy-client.test.tsx @@ -116,6 +116,13 @@ describe('in the app', () => { expect(devicesForAccount('some-account').noOffline).toBe(false); }, 60_000); + it('a forced value that makes no sense keeps offline off without forbidding it, which would wipe', async () => { + setBridgePolicy(snapshotOf({ OfflineEnabled: 'yes' })); + expect(desktopOfflinePolicy()).toEqual({ forced: false, fallback: null, invalid: true }); + const { devicesForAccount } = await import('@/lib/device-policy'); + expect(devicesForAccount('some-account').noOffline).toBe(false); + }, 60_000); + it('the hook settles on a bridge that copies what it returns, as contextBridge does', async () => { setBridgePolicy(snapshotOf({ PluginsMode: 'off' })); window.bulwarkDesktop!.policy!.get = () => JSON.parse(JSON.stringify(current)); diff --git a/lib/desktop/__tests__/policy-schema.test.ts b/lib/desktop/__tests__/policy-schema.test.ts index 3411ad9ea..3ad319aed 100644 --- a/lib/desktop/__tests__/policy-schema.test.ts +++ b/lib/desktop/__tests__/policy-schema.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_POLICY_SNAPSHOT, EMPTY_POLICY_LAYERS, POLICY_KEYS, + policyProblems, POLICY_CONSUMERS, POLICY_MAX_LIST_ITEMS, POLICY_SCHEMA, @@ -133,9 +134,9 @@ describe('hostile values fail closed', () => { expect(entry.raw!.length).toBeLessThanOrEqual(201); }); - it('an out of range timeout falls to no value, not to a huge one', () => { + it('an out of range timeout falls to the shortest, not to a huge one or the user\'s own', () => { const entry = resolvePolicies(layers({ forcedMachine: { AppLockTimeoutMinutes: 999999 } })).AppLockTimeoutMinutes; - expect(entry).toMatchObject({ value: null, invalid: 'out_of_range' }); + expect(entry).toMatchObject({ value: 1, invalid: 'out_of_range' }); }); }); @@ -322,6 +323,26 @@ describe('KeystoreAllowedOrigins', () => { }); }); +describe('what a recommendation can do', () => { + it.each(['AppLockRequired', 'TelemetryAllowed', 'CrashReportsAllowed', 'AutoUpdate'] as const)('%s: nothing, and the templates do not offer one', (key) => { + expect(POLICY_SCHEMA[key].forcedOnly).toBe(true); + expect(resolvePolicies({ ...EMPTY_POLICY_LAYERS, recommendedMachine: { [key]: 0 } })[key]).toMatchObject({ source: 'default', locked: false }); + expect(resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { [key]: 0 } })[key]).toMatchObject({ value: false, locked: true }); + }); +}); + +describe('values that fail open', () => { + it('a lock timeout nobody can read is the shortest there is', () => { + expect(resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { AppLockTimeoutMinutes: '99999' } }).AppLockTimeoutMinutes).toMatchObject({ value: 1, locked: true, invalid: 'out_of_range' }); + }); + + it('a wipe code too long to use is named as invalid', () => { + const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { WipeOfflineData: 'w'.repeat(200) } }); + expect(snapshot.WipeOfflineData).toMatchObject({ value: null, invalid: 'too_long' }); + expect(policyProblems(snapshot).map((problem) => problem.key)).toEqual(['WipeOfflineData']); + }); +}); + describe('every key has someone acting on it', () => { it('names a consumer or who owes one', () => { for (const key of POLICY_KEYS) { @@ -338,9 +359,7 @@ describe('every key has someone acting on it', () => { } }); - // The coordinator flips this when step 7 is done: then no key may be waiting. - const ALL_WIRED = false; - it.runIf(ALL_WIRED)('nobody is waiting any more', () => { + it('nobody is waiting any more', () => { expect(POLICY_KEYS.filter((key) => 'pendingConsumer' in POLICY_CONSUMERS[key])).toEqual([]); }); }); diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index 749a8361d..781726d39 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -86,9 +86,10 @@ interface PolicyBase { title: PolicyText; description: PolicyText; /** - * Only a forced value counts. Anything that decides what's allowed: it has no user - * choice to sit under, so a "recommended" one would act as forced without being - * shown as such, and only a forced value may loosen a forced one. + * Only a forced value counts. Anything that decides what's allowed, or a yes or no with + * nothing for a recommendation to set: it has no user choice to sit under, so a + * "recommended" one would act as forced without being shown as such, or do nothing, and + * only a forced value may loosen a forced one. */ forcedOnly?: boolean; } @@ -208,6 +209,7 @@ export const POLICY_SCHEMA: Record = { }, AutoUpdate: { category: 'updates', + forcedOnly: true, type: 'boolean', default: true, onInvalid: false, @@ -264,6 +266,7 @@ export const POLICY_SCHEMA: Record = { }, AppLockRequired: { category: 'security', + forcedOnly: true, type: 'boolean', default: false, onInvalid: true, @@ -290,6 +293,7 @@ export const POLICY_SCHEMA: Record = { }, TelemetryAllowed: { category: 'privacy', + forcedOnly: true, type: 'boolean', default: true, onInvalid: false, @@ -302,6 +306,7 @@ export const POLICY_SCHEMA: Record = { }, CrashReportsAllowed: { category: 'privacy', + forcedOnly: true, type: 'boolean', default: true, onInvalid: false, @@ -395,8 +400,8 @@ export const POLICY_SCHEMA: Record = { label: { en: 'Wipe code', de: 'Löschcode' }, title: { en: 'Delete mail kept on the device', de: 'Auf dem Gerät behaltene E-Mails löschen' }, description: { - en: 'Set this to a new value, such as today\'s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.', - de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.', + en: 'Set this to a new value, such as today\'s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. That memory is in the user\'s own profile, so someone who edits it can skip a wipe they know is coming: this cleans up a device, it doesn\'t take data from the person using it.', + de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. Diese Liste liegt im Profil des Benutzers. Wer sie bearbeitet, kann also eine Löschung umgehen, von der er weiß: Dies räumt ein Gerät auf, es nimmt der Person am Gerät keine Daten weg.', }, }, }; @@ -560,6 +565,19 @@ function parseShortcut(raw: unknown): Parsed { const HOST_LIST = /^[A-Za-z0-9*.,:_[\]-]+$/; +const COMMON_TOP_LEVEL = /^(?:[a-z]{2}|com|net|org|edu|gov|mil|int|info|biz|name|pro|mobi|app|dev|cloud|online|site|xyz|top|shop|store|tech|club|live|blog|page|arpa)$/; + +/** + * Chromium reads a plain name in these lists as "ends with", so `com` means every .com + * address, and `.com` is `*.com`. `intranet` or `sso` stay fine: that's how a server with + * no domain is named. + */ +function coversTopLevelDomain(host: string): boolean { + const name = host.replace(/:\d+$/, '').replace(/\.+$/, '').toLowerCase(); + if (name.startsWith('.')) return !/^\.+[^.]+\.[^.]/.test(name); + return COMMON_TOP_LEVEL.test(name); +} + /** What Chromium takes for its auth lists: names and wildcards, commas between them. */ function parseHostList(raw: unknown): Parsed { if (typeof raw !== 'string') return fail('not_text'); @@ -572,6 +590,7 @@ function parseHostList(raw: unknown): Parsed { // A wildcard must sit in front of a real domain of two labels or more: `*` or `*.com` // would hand Windows credentials to any site that asks. if (hosts.some((host) => host.includes('*') && !/^\*\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+(?::\d+)?$/.test(host))) return fail('invalid_host_list'); + if (hosts.some(coversTopLevelDomain)) return fail('invalid_host_list'); return { ok: true, value: hosts.join(',') }; } @@ -616,12 +635,15 @@ export function parseCertificateRule(raw: unknown): CertificateRule | null { const match = /^(?:https:\/\/)?(\[\*\.\]|\*\.)?([^/*[\]]+(?::\d+)?)\/?$/i.exec(pattern); if (!match) return null; let host: string; + let hostname: string; try { - host = new URL(`https://${match[2]}`).host; + ({ host, hostname } = new URL(`https://${match[2]}`)); } catch { return null; } if (!host) return null; + // A wildcard over a bare name is the whole of a top-level domain. + if (match[1] && !hostname.includes('.')) return null; if (filter !== undefined && (typeof filter !== 'object' || filter === null || Array.isArray(filter))) return null; const { ISSUER, SUBJECT } = (filter ?? {}) as { ISSUER?: unknown; SUBJECT?: unknown }; const issuer = certificateName(ISSUER); @@ -737,6 +759,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 limit nobody can read is the tightest one, not the user's own. + if (definition.type === 'integer') return definition.min as PolicyValues[PolicyKey]; return definition.default as PolicyValues[PolicyKey]; } @@ -802,6 +826,53 @@ export function effectivePolicyValue(entry: { value: T; locked: boolean }, ch export const DEFAULT_POLICY_SNAPSHOT: PolicySnapshot = resolvePolicies(EMPTY_POLICY_LAYERS); +export interface PolicyProblem { + key: PolicyKey; + reason: string; + raw?: string; +} + +/** Forced values the app can't use, and lists with entries it had to leave out. */ +export function policyProblems(snapshot: PolicySnapshot): PolicyProblem[] { + const problems: PolicyProblem[] = []; + for (const key of POLICY_KEYS) { + const { invalid, dropped, raw } = snapshot[key]; + if (invalid) problems.push({ key, reason: invalid, ...(raw ? { raw } : {}) }); + else if (dropped) problems.push({ key, reason: 'entries_left_out' }); + } + return problems; +} + +/** What the webmail reads, in `lib/` and `components/`. A page gets nothing else. */ +const PAGE_POLICY_KEYS: readonly PolicyKey[] = [ + 'AllowStandalone', + 'AllowedJmapServers', + 'InstanceUrl', + 'PluginsMode', + 'PluginAllowlist', + 'OfflineEnabled', + 'AppLockRequired', + 'AppLockTimeoutMinutes', + 'CrashReportsAllowed', +]; + +/** + * The policies as a page gets them. A page can be any server someone chose, and the auth + * lists and certificate rules name an organisation's internal hosts, so those stay at their + * defaults. Nor does it get a bad value's text, or the address of a server: only whether + * one is forced. + */ +export function pagePolicySnapshot(snapshot: PolicySnapshot): PolicySnapshot { + const page = { ...DEFAULT_POLICY_SNAPSHOT } as Record; + for (const key of PAGE_POLICY_KEYS) { + const entry: PolicyEntry = { ...snapshot[key] }; + delete entry.raw; + if (key === 'InstanceUrl') (entry as { value: unknown }).value = null; + page[key] = entry; + } + return page as PolicySnapshot; +} + /** * Who acts on each key, so none can sit idle: the desktop repo's test and the webmail's * read the named file for the key. `pendingConsumer` names who still has to wire one. @@ -819,8 +890,8 @@ export const POLICY_CONSUMERS: Record = { WipeOfflineData: { repo: 'shell', file: 'src/main/features/policies.ts' }, AuthServerAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' }, AuthNegotiateDelegateAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' }, - AutoUpdate: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' }, - UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' }, + AutoUpdate: { repo: 'shell', file: 'src/main/update-rules.ts' }, + UpdateChannel: { repo: 'shell', file: 'src/main/update-rules.ts' }, AppLockRequired: { repo: 'webmail', file: 'lib/app-lock/policy.ts' }, AppLockTimeoutMinutes: { repo: 'webmail', file: 'lib/app-lock/policy.ts' }, TelemetryAllowed: { repo: 'shell', file: 'src/main/features/privacy.ts' }, diff --git a/lib/desktop/policy.ts b/lib/desktop/policy.ts index 5e9ec2f2f..d541d2229 100644 --- a/lib/desktop/policy.ts +++ b/lib/desktop/policy.ts @@ -102,9 +102,12 @@ export function customServerAllowedByDesktopPolicy(snapshot: PolicySnapshot = ge * Whether accounts may keep mail on this device: `forced` when the policy says so * either way, `fallback` for the default where the user hasn't chosen. Forced off * feeds `noOffline` (lib/device-policy.ts), which also clears what's there already. + * `invalid`: forced to something that makes no sense. Nothing new is kept, and a typo + * deletes nothing. */ -export function desktopOfflinePolicy(snapshot: PolicySnapshot = getDesktopPolicy()): { forced: boolean | null; fallback: boolean | null } { +export function desktopOfflinePolicy(snapshot: PolicySnapshot = getDesktopPolicy()): { forced: boolean | null; fallback: boolean | null; invalid?: true } { const { OfflineEnabled } = snapshot; + if (OfflineEnabled.locked && OfflineEnabled.invalid) return { forced: false, fallback: null, invalid: true }; if (OfflineEnabled.locked) return { forced: OfflineEnabled.value, fallback: null }; return { forced: null, fallback: OfflineEnabled.source === 'default' ? null : OfflineEnabled.value }; } diff --git a/lib/device-policy.ts b/lib/device-policy.ts index f5cf9dc3e..93486080a 100644 --- a/lib/device-policy.ts +++ b/lib/device-policy.ts @@ -83,8 +83,10 @@ export function devicesForAccount(subject: DeviceSubject): DevicePolicy { const accountSubject = subjectForAccount(accountId); const policy = accountSubject === store.subject ? store.policy : store.resolveFor(accountSubject); const devices = devicesOf(policy); - // The desktop app's OS policy says the same thing as the organisation's switch. - return desktopOfflinePolicy().forced === false ? { ...devices, noOffline: true } : devices; + // The desktop app's OS policy says the same thing as the organisation's switch. Not a + // value that makes no sense: noOffline wipes, and offlineAllowed asks about that one itself. + const offline = desktopOfflinePolicy(); + return offline.forced === false && !offline.invalid ? { ...devices, noOffline: true } : devices; } /** What applies to anyone, with no account's groups: for data of an account nobody here is signed in to. */ diff --git a/lib/offline/__tests__/effective-scope-wiring.test.ts b/lib/offline/__tests__/effective-scope-wiring.test.ts index 4bf221c12..d9590bc76 100644 --- a/lib/offline/__tests__/effective-scope-wiring.test.ts +++ b/lib/offline/__tests__/effective-scope-wiring.test.ts @@ -1,6 +1,7 @@ import 'fake-indexeddb/auto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { EMPTY_POLICY_LAYERS, resolvePolicies } from '@/lib/desktop/policy-schema'; import { getOfflineHooks, setOfflineHooks } from '@/lib/jmap/offline-hooks'; import { setStoreBackend } from '@/lib/store/client/api'; import { FakeJmap } from '@/lib/store/__tests__/helpers/fake-jmap'; @@ -14,6 +15,8 @@ import { disableOffline, enableOffline, enforcePolicy, + offlineActive, + offlineAllowed, refreshQueueState, refreshUsage, registerOfflineHooks, @@ -62,6 +65,34 @@ afterEach(async () => { setStoreBackend(null); }); +describe('OfflineEnabled from the desktop app\'s OS policy', () => { + function forced(value: unknown): void { + const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { OfflineEnabled: value } }); + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { version: 1, platform: 'win32', mode: 'standalone', policy: { get: () => snapshot, onChange: () => () => {} } }; + } + + it('a value that makes no sense stops keeping mail and deletes nothing', async () => { + await enableOffline('acc'); + expect(useOfflineStore.getState().enabled).toEqual(['acc']); + + forced('yes'); + expect(offlineAllowed('acc')).toBe(false); + enforcePolicy(); + await vi.waitFor(() => expect(offlineActive('acc')).toBe(false)); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(useOfflineStore.getState().enabled).toEqual(['acc']); + expect(notices.map((notice) => notice.kind)).not.toContain('forbidden'); + }); + + it('forced off does delete', async () => { + await enableOffline('acc'); + forced(0); + enforcePolicy(); + await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([])); + expect(notices.map((notice) => notice.kind)).toContain('forbidden'); + }); +}); + describe('at page load, before the subject is known', () => { const forbiddenExceptGroup = { ...usePolicyStore.getState().rawPolicy, diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 2de80e0f6..0fc20a875 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -86,6 +86,7 @@ import { type OutboxRow, type UserChoice, } from './state'; +import { desktopOfflinePolicy } from '@/lib/desktop/policy'; import { effectiveScope, offlineDefaultOn, olderThanCap, platformDefault, userChoiceFor, type EffectiveScope } from './scope'; import { isStandaloneDisplayMode } from '@/lib/standalone-display'; import { readPersistence, requestPersistence, safariMayDelete } from './storage-health'; @@ -163,7 +164,7 @@ export function offlineSupported(): boolean { /** The organisation allows this account to keep mail on the device. */ export function offlineAllowed(account: string | null | undefined): boolean { - return offlineSupported() && !devicesForAccount(account ?? null).noOffline; + return offlineSupported() && !devicesForAccount(account ?? null).noOffline && !desktopOfflinePolicy().invalid; } /** A dropped account's data isn't judged by the groups of whoever is signed in now. */ From 3fa82849a8aecd7606c49edb6408799853ba6af8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 04:59:52 +0100 Subject: [PATCH 02/46] fix: open menus next to what opened them and show their keys apart from the label --- components/calendar/calendar-toolbar.tsx | 6 +- components/layout/phone-header-actions.tsx | 2 +- components/plugins/plugin-actions.tsx | 2 +- .../__tests__/context-menu-placement.test.tsx | 162 ++++++++++++++++++ components/ui/context-menu.tsx | 83 +++++---- hooks/use-context-menu.ts | 34 +--- .../__tests__/native-menu-keys.test.tsx | 98 +++++++++++ lib/desktop/native-menu.ts | 133 +++++++++++++- lib/desktop/types.ts | 5 + lib/menu-placement.ts | 83 +++++++++ 10 files changed, 539 insertions(+), 69 deletions(-) create mode 100644 components/ui/__tests__/context-menu-placement.test.tsx create mode 100644 lib/desktop/__tests__/native-menu-keys.test.tsx create mode 100644 lib/menu-placement.ts diff --git a/components/calendar/calendar-toolbar.tsx b/components/calendar/calendar-toolbar.tsx index d1957b882..fe6471667 100644 --- a/components/calendar/calendar-toolbar.tsx +++ b/components/calendar/calendar-toolbar.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { IconButton } from "@/components/ui/icon-button"; import { ToolbarButton } from "@/components/ui/toolbar"; import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw, Search, Printer } from "@/components/icons"; -import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator, MenuShortcut } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { startOfWeek } from "date-fns"; import { PhoneHeaderActions } from "@/components/layout/phone-header-actions"; @@ -482,9 +482,9 @@ export function CalendarToolbar({ v === viewMode ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted text-foreground", )} > - {t(`views.${v}`)} + {t(`views.${v}`)} {/* The key, on devices with a keyboard (R8). */} - {VIEW_SHORTCUTS[v]} + ))} diff --git a/components/layout/phone-header-actions.tsx b/components/layout/phone-header-actions.tsx index e33815115..7e74c7074 100644 --- a/components/layout/phone-header-actions.tsx +++ b/components/layout/phone-header-actions.tsx @@ -92,7 +92,7 @@ export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) { 0 ? formatBadgeCount(unread) : undefined} + badge={unread > 0 ? formatBadgeCount(unread) : undefined} onClick={() => { close(); setShowNotifications(true); }} /> { void run(action); }} /> ))} diff --git a/components/ui/__tests__/context-menu-placement.test.tsx b/components/ui/__tests__/context-menu-placement.test.tsx new file mode 100644 index 000000000..a856a4268 --- /dev/null +++ b/components/ui/__tests__/context-menu-placement.test.tsx @@ -0,0 +1,162 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ContextMenu, ContextMenuItem, ContextMenuSubMenu, type ContextMenuPosition } from '../context-menu'; +import { useContextMenu } from '@/hooks/use-context-menu'; +import { placeMenu } from '@/lib/menu-placement'; +import type { BulwarkDesktopBridge } from '@/lib/desktop/types'; + +// Every menu opens next to what opened it: under a button, lined up with its +// edge, or at the pointer, and towards the other side only where there's no +// room. Never clamped somewhere away from the button. + +function rect(left: number, top: number, width: number, height: number): DOMRect { + return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect; +} + +const MENU = { width: 220, height: 300 }; + +beforeEach(() => { + Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true }); + Object.defineProperty(window, 'innerHeight', { value: 720, configurable: true }); + Object.defineProperty(window, 'outerWidth', { value: 1280, configurable: true }); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + return this.getAttribute('role') === 'menu' ? rect(0, 0, MENU.width, MENU.height) : rect(0, 0, 0, 0); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + window.bulwarkDesktop = undefined; + document.documentElement.dir = ''; +}); + +function openAt(position: ContextMenuPosition) { + render( + {}}> + {}} /> + , + ); + const menu = screen.getByRole('menu'); + return { left: parseFloat(menu.style.left), top: parseFloat(menu.style.top) }; +} + +describe('a menu from a right-click', () => { + it('opens at the pointer where there is room', () => { + expect(openAt({ x: 300, y: 200 })).toEqual({ left: 300, top: 200 }); + }); + + it('opens to the left of the pointer near the right edge, its corner still at the pointer', () => { + // Clamped instead, the menu would sit under the pointer, away from where the click was. + expect(openAt({ x: 1200, y: 200 })).toEqual({ left: 1200 - MENU.width, top: 200 }); + }); + + it('opens upwards from the pointer near the bottom', () => { + expect(openAt({ x: 300, y: 650 })).toEqual({ left: 300, top: 650 - MENU.height }); + }); + + it('keeps the pointer where it was: the hook no longer guesses a size and moves it first', () => { + function Row() { + const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu(); + return ( + <> +
openContextMenu(e, 'row')}>Row
+ + {}} /> + + + ); + } + render(); + // A short menu right-clicked at 640: it fits below the pointer. + MENU.height = 60; + try { + fireEvent.contextMenu(screen.getByTestId('row'), { clientX: 400, clientY: 640 }); + const menu = screen.getByRole('menu'); + expect(menu.style.top).toBe('640px'); + expect(menu.style.left).toBe('400px'); + } finally { + MENU.height = 300; + } + }); +}); + +describe('a menu from a button', () => { + it('sits under the button, lined up with its start edge', () => { + expect(openAt({ x: 0, y: 0, anchor: { left: 100, top: 40, right: 132, bottom: 72 } })).toEqual({ left: 100, top: 76 }); + }); + + it('lines up with the button\'s end edge for a button at the end of a toolbar', () => { + expect(openAt({ x: 0, y: 0, anchor: { left: 900, top: 40, right: 932, bottom: 72, align: 'end' } })).toEqual({ left: 932 - MENU.width, top: 76 }); + }); + + it('switches to the button\'s other edge when it doesn\'t fit, instead of sliding away from it', () => { + const at = openAt({ x: 0, y: 0, anchor: { left: 1200, top: 40, right: 1232, bottom: 72 } }); + expect(at.left + MENU.width).toBe(1232); + }); + + it('goes above the button when there is no room below', () => { + expect(openAt({ x: 0, y: 0, anchor: { left: 100, top: 600, right: 132, bottom: 632 } }).top).toBe(600 - 4 - MENU.height); + }); + + it('mirrors in RTL: start is the right edge', () => { + document.documentElement.dir = 'rtl'; + const at = openAt({ x: 0, y: 0, anchor: { left: 500, top: 40, right: 532, bottom: 72 } }); + // jsdom computes no direction from dir; the pure function covers RTL below. + expect([500, 532 - MENU.width]).toContain(at.left); + }); +}); + +describe('placeMenu', () => { + const viewport = { width: 1280, height: 720 }; + const size = { width: 220, height: 300 }; + + it('RTL: under a button, its right edge with the button\'s right edge', () => { + expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 500, top: 40, right: 532, bottom: 72 }, size, viewport, rtl: true })) + .toEqual({ x: 532 - 220, y: 76 }); + }); + + it('RTL: an end-aligned button gets the left edge', () => { + expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 40, top: 40, right: 72, bottom: 72, align: 'end' }, size, viewport, rtl: true })) + .toEqual({ x: 40, y: 76 }); + }); + + it('RTL: a right-click opens to the left of the pointer', () => { + expect(placeMenu({ point: { x: 600, y: 100 }, size, viewport, rtl: true })).toEqual({ x: 380, y: 100 }); + }); + + it('taller than the window: inside it, from the top margin, and the menu scrolls', () => { + expect(placeMenu({ point: { x: 600, y: 100 }, size: { width: 220, height: 900 }, viewport })).toEqual({ x: 600, y: 10 }); + }); + + it('wider than the room on both sides of a button: clamped inside the window', () => { + expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 100, top: 40, right: 132, bottom: 72 }, size: { width: 1270, height: 100 }, viewport }).x).toBe(10); + }); +}); + +describe('a native menu from a button', () => { + it('lines its right edge up with an end-aligned button, by the native menu\'s width, not the hidden copy\'s', async () => { + const popup = vi.fn(async () => null); + window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as unknown as BulwarkDesktopBridge; + // The hidden copy holds every submenu open, so it is far wider than the menu the OS draws. + MENU.width = 700; + try { + render( + {}}> + {}} /> + + {}} /> + + {}} /> + , + ); + await waitFor(() => expect(popup).toHaveBeenCalled()); + const at = (popup.mock.calls[0] as unknown[])[1] as { x: number; y: number }; + expect(at.y).toBe(76); + // Under the button: its left edge somewhere left of the button, not at the window's edge. + expect(at.x).toBeGreaterThan(200); + expect(at.x).toBeLessThan(468); + } finally { + MENU.width = 220; + } + }); +}); diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 8241bd4d0..84e0c359c 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -18,7 +18,8 @@ import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { ChevronRight } from "@/components/icons"; import { useTranslations } from "next-intl"; -import { nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu"; +import { estimateNativeMenuWidth, nativeMenuPoint, nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu"; +import { placeMenu } from "@/lib/menu-placement"; import { toast } from "@/stores/toast-store"; interface Position { @@ -282,8 +283,11 @@ export const ContextMenu = forwardRef( } const { anchor: at } = positionRef.current; // From a right-click it opens at the pointer, which the OS knows best. + // From a button: under it, lined up with the edge the HTML menu would + // use. The hidden copy is as wide as its widest submenu row, not as the + // native menu, so the native menu's own width is estimated. const place = at - ? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP } + ? nativeMenuPoint(at, estimateNativeMenuWidth(read.items), getComputedStyle(root).direction === "rtl", ANCHOR_GAP) : undefined; const outcome = await showNativeMenu(read, root, place, tDesktopRef.current("from_plugins")); if (cancelled) return; @@ -322,32 +326,16 @@ export const ContextMenu = forwardRef( const clamp = () => { const rect = node.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; - - let x = position.x; - let y = position.y; - - if (anchor) { - // Under the anchor, lined up with its start edge (end edge for - // align="end"); above it when there is no room below. - const rtl = getComputedStyle(node).direction === "rtl"; - const alignEnd = (anchor.align === "end") !== rtl; - x = alignEnd ? anchor.right - rect.width : anchor.left; - y = anchor.bottom + ANCHOR_GAP; - if (y + rect.height > vh - VIEWPORT_MARGIN && anchor.top - ANCHOR_GAP - rect.height >= VIEWPORT_MARGIN) { - y = anchor.top - ANCHOR_GAP - rect.height; - } - } - - if (x + rect.width > vw - VIEWPORT_MARGIN) { - x = vw - rect.width - VIEWPORT_MARGIN; - } - if (y + rect.height > vh - VIEWPORT_MARGIN) { - y = vh - rect.height - VIEWPORT_MARGIN; - } - x = Math.max(VIEWPORT_MARGIN, x); - y = Math.max(VIEWPORT_MARGIN, y); + // Next to the button it opened from, or at the pointer (lib/menu-placement.ts). + const { x, y } = placeMenu({ + point: { x: position.x, y: position.y }, + anchor, + size: { width: rect.width, height: rect.height }, + viewport: { width: window.innerWidth, height: window.innerHeight }, + rtl: getComputedStyle(node).direction === "rtl", + margin: VIEWPORT_MARGIN, + gap: ANCHOR_GAP, + }); setAdjustedPosition((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y })); }; @@ -574,7 +562,10 @@ interface ContextMenuItemProps { onClick: () => void; disabled?: boolean; destructive?: boolean; + /** The key that does the same, shown apart from the label. */ shortcut?: string; + /** A count at the end of the row, e.g. unread notifications. Not a key. */ + badge?: string; /** Stable hook for integration tests (not user-visible). */ testId?: string; /** Tooltip, e.g. the address behind a display name. */ @@ -590,6 +581,7 @@ export function ContextMenuItem({ disabled = false, destructive = false, shortcut, + badge, testId, title, hint, @@ -636,14 +628,41 @@ export function ContextMenuItem({ ) : ( {label} )} - {/* Key hints mean nothing without a keyboard: hidden on touch (R8). */} - {shortcut && ( - {shortcut} - )} + {shortcut && } + {badge && } ); } +/** + * The key that does what a menu row does, at the row's end, muted, as a keycap. The + * desktop app's native menu shows it as the item's accelerator, apart from the label + * (lib/desktop/native-menu.ts reads `data-menu-shortcut`). Key hints mean nothing + * without a keyboard: hidden on touch (R8). + */ +export function MenuShortcut({ keys, className }: { keys: string; className?: string }) { + return ( + + {keys} + + ); +} + +/** A count at a row's end ("3" unread). Not a key: a native menu puts it after the label. */ +export function MenuBadge({ text }: { text: string }) { + return ( + + {text} + + ); +} + export function ContextMenuSeparator() { return
; } diff --git a/hooks/use-context-menu.ts b/hooks/use-context-menu.ts index eb412b42d..d76555be2 100644 --- a/hooks/use-context-menu.ts +++ b/hooks/use-context-menu.ts @@ -24,10 +24,6 @@ export interface UseContextMenuReturn { menuRef: React.RefObject; } -const MENU_WIDTH = 200; -const MENU_HEIGHT = 320; // Approximate max height -const VIEWPORT_MARGIN = 10; - /** Shift+F10 or the Menu key: open the focused element's context menu. */ export function isContextMenuKey(e: { key: string; shiftKey: boolean }): boolean { return e.key === "ContextMenu" || (e.shiftKey && e.key === "F10"); @@ -71,30 +67,6 @@ export function useContextMenu(): UseContextMenuReturn { const menuRef = useRef(null); - const calculatePosition = useCallback((clientX: number, clientY: number): ContextMenuPosition => { - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - let x = clientX; - let y = clientY; - - // Adjust for right edge - if (x + MENU_WIDTH > viewportWidth - VIEWPORT_MARGIN) { - x = viewportWidth - MENU_WIDTH - VIEWPORT_MARGIN; - } - - // Adjust for bottom edge - if (y + MENU_HEIGHT > viewportHeight - VIEWPORT_MARGIN) { - y = viewportHeight - MENU_HEIGHT - VIEWPORT_MARGIN; - } - - // Ensure minimum position - x = Math.max(VIEWPORT_MARGIN, x); - y = Math.max(VIEWPORT_MARGIN, y); - - return { x, y }; - }, []); - const openContextMenu = useCallback((e: React.MouseEvent, data: T) => { e.preventDefault(); e.stopPropagation(); @@ -103,14 +75,16 @@ export function useContextMenu(): UseContextMenuReturn { const trigger = target ? focusReturnTarget(target) : null; const position = target && openedByKeyboard(e) ? { ...anchorPosition(target), trigger } - : { ...calculatePosition(e.clientX, e.clientY), trigger }; + // At the pointer as it is: the menu places itself once it knows its size, + // opening up or to the side where there's no room (lib/menu-placement.ts). + : { x: e.clientX, y: e.clientY, trigger }; setContextMenu({ isOpen: true, position, data, }); - }, [calculatePosition]); + }, []); const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => { setContextMenu({ isOpen: true, position: anchorPosition(element, align), data }); diff --git a/lib/desktop/__tests__/native-menu-keys.test.tsx b/lib/desktop/__tests__/native-menu-keys.test.tsx new file mode 100644 index 000000000..3b4016619 --- /dev/null +++ b/lib/desktop/__tests__/native-menu-keys.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from '@testing-library/react'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { ContextMenuItem } from '@/components/ui/context-menu'; +import { readMenu, toAccelerator } from '../native-menu'; + +// A native menu built from an HTML one shows a row's key apart from its label, +// as the native menus' own accelerators do. The calendar's view menu came out +// as "DayD", "WeekW": the key was read as part of the label. + +function menuOf(children: React.ReactNode) { + render(
{children}
); + return readMenu(screen.getByTestId('root')).items; +} + +describe('a row\'s key in a native menu', () => { + it('is not part of the label, and becomes the accelerator', () => { + const items = menuOf( + , + ); + 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/native-menu.ts b/lib/desktop/native-menu.ts index 5fac69c9e..2ae8e4bcf 100644 --- a/lib/desktop/native-menu.ts +++ b/lib/desktop/native-menu.ts @@ -35,9 +35,68 @@ 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 ?? el.textContent ?? ''; - return named.replace(/\s+/g, ' ').trim(); + 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 { @@ -100,10 +159,12 @@ export function readMenu(root: HTMLElement): ReadMenu { 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' } : {}), @@ -182,6 +243,74 @@ export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x 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 69cbd18c0..1a36ccf10 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -84,6 +84,11 @@ export interface DesktopNativeMenuItem { type?: 'normal' | 'separator' | 'checkbox' | 'radio'; enabled?: boolean; checked?: boolean; + /** + * The key the page answers for this item, in Electron's accelerator syntax ("D", + * "Shift+I", "Ctrl+X"). Only shown, apart from the label: never registered. + */ + accelerator?: string; submenu?: DesktopNativeMenuItem[]; } diff --git a/lib/menu-placement.ts b/lib/menu-placement.ts new file mode 100644 index 000000000..a8397d1ee --- /dev/null +++ b/lib/menu-placement.ts @@ -0,0 +1,83 @@ +/** + * Where a menu goes: next to what opened it, inside the window. + * + * - Opened from a button (an anchor): under it, lined up with its start edge, or its + * end edge for a button at the end of a toolbar. In RTL start and end swap. When it + * doesn't fit on that side it lines up with the other edge, and when there's no room + * below it goes above. + * - Opened by a right-click (a point): its corner at the pointer, opening to the right + * and down (to the left in RTL), and towards the other side where there's no room, as + * the OS does with its own menus. + * + * Whatever is left over is clamped into the window. A menu taller than the window + * scrolls inside (the menu caps its own height). + */ + +export interface MenuAnchorRect { + left: number; + top: number; + right: number; + bottom: number; + /** Line up with the anchor's start (default) or end edge. */ + align?: "start" | "end"; +} + +export interface MenuPlacementInput { + /** Where a right-click happened. Ignored when there's an anchor. */ + point: { x: number; y: number }; + anchor?: MenuAnchorRect; + size: { width: number; height: number }; + viewport: { width: number; height: number }; + rtl?: boolean; + /** Room kept free along the window's edges. */ + margin?: number; + /** Between the anchor and the menu. */ + gap?: number; +} + +export const MENU_VIEWPORT_MARGIN = 10; +export const MENU_ANCHOR_GAP = 4; + +function clamp(value: number, size: number, room: number, margin: number): number { + return Math.max(margin, Math.min(value, room - size - margin)); +} + +export function placeMenu({ + point, + anchor, + size, + viewport, + rtl = false, + margin = MENU_VIEWPORT_MARGIN, + gap = MENU_ANCHOR_GAP, +}: MenuPlacementInput): { x: number; y: number } { + const { width: w, height: h } = size; + const { width: vw, height: vh } = viewport; + const fitsX = (x: number) => x >= margin && x + w <= vw - margin; + const fitsY = (y: number) => y >= margin && y + h <= vh - margin; + + let x: number; + let y: number; + if (anchor) { + const alignEnd = (anchor.align === "end") !== rtl; + const startEdge = anchor.left; + const endEdge = anchor.right - w; + const preferred = alignEnd ? endEdge : startEdge; + const other = alignEnd ? startEdge : endEdge; + x = fitsX(preferred) || !fitsX(other) ? preferred : other; + const below = anchor.bottom + gap; + const above = anchor.top - gap - h; + if (fitsY(below)) y = below; + else if (fitsY(above)) y = above; + // Neither fits whole: the side with more room, and the menu scrolls. + else y = vh - anchor.bottom >= anchor.top ? below : above; + } else { + const towardEnd = rtl ? point.x - w : point.x; + const towardStart = rtl ? point.x : point.x - w; + x = fitsX(towardEnd) || !fitsX(towardStart) ? towardEnd : towardStart; + const down = point.y; + const up = point.y - h; + y = fitsY(down) || !fitsY(up) ? down : up; + } + return { x: clamp(x, w, vw, margin), y: clamp(y, h, vh, margin) }; +} From f44be446bb29ac334e13987a7085a812e35fe220 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:09:06 +0100 Subject: [PATCH 03/46] fix: show the keys for reply, delete, star and the rest in the message menu --- .../email-context-menu-shortcuts.test.tsx | 89 +++++++++++++++++++ components/email/email-context-menu.tsx | 7 ++ components/email/message-action-shortcuts.ts | 39 ++++++++ 3 files changed, 135 insertions(+) create mode 100644 components/email/__tests__/email-context-menu-shortcuts.test.tsx create mode 100644 components/email/message-action-shortcuts.ts diff --git a/components/email/__tests__/email-context-menu-shortcuts.test.tsx b/components/email/__tests__/email-context-menu-shortcuts.test.tsx new file mode 100644 index 000000000..062e60278 --- /dev/null +++ b/components/email/__tests__/email-context-menu-shortcuts.test.tsx @@ -0,0 +1,89 @@ +import { render, screen, within } from '@testing-library/react'; +import { afterEach, describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useSettingsStore } from '@/stores/settings-store'; + +vi.mock('@/components/plugins/plugin-slot', () => ({ + PluginSlot: () => null, +})); + +import { EmailContextMenu } from '../email-context-menu'; +import { messageActionShortcut } from '../message-action-shortcuts'; + +const email = { + id: 'e1', + blobId: 'b1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: {}, + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', + hasAttachment: false, + size: 100, +} as unknown as Email; + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +function renderMenu(props: Partial> = {}) { + render( + , + ); +} + +const row = (name: string) => screen.getByText(name).closest('button') as HTMLElement; + +describe('message menu key hints', () => { + afterEach(() => useSettingsStore.setState({ singleKeyShortcuts: true })); + + it('shows the keys the list runs each action with', () => { + renderMenu(); + expect(within(row('reply')).getByText('R')).toBeInTheDocument(); + expect(within(row('reply_all')).getByText('Shift+R')).toBeInTheDocument(); + expect(within(row('forward')).getByText('F')).toBeInTheDocument(); + expect(within(row('star')).getByText('S')).toBeInTheDocument(); + expect(within(row('mark_read')).getByText('Shift+I')).toBeInTheDocument(); + expect(within(row('mark_as_spam')).getByText('!')).toBeInTheDocument(); + expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument(); + }); + + it('leaves single keys out when they are turned off, and keeps Del', () => { + useSettingsStore.setState({ singleKeyShortcuts: false }); + renderMenu(); + expect(within(row('reply')).queryByText('R')).toBeNull(); + expect(within(row('star')).queryByText('S')).toBeNull(); + expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument(); + }); + + it('shows them on a selection too', () => { + renderMenu({ isMultiSelect: true, selectedCount: 2, onBatchDelete: vi.fn(), onBatchMarkAsRead: vi.fn(), onBatchMarkAsSpam: vi.fn() }); + expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument(); + expect(within(screen.getByTestId('ctx-mark-read')).getByText('Shift+I')).toBeInTheDocument(); + expect(within(screen.getByTestId('ctx-spam')).getByText('!')).toBeInTheDocument(); + }); + + it('matches the read state: U marks a read message unread', () => { + expect(messageActionShortcut('read', { unread: false, singleKeys: true })).toBe('U'); + expect(messageActionShortcut('read', { unread: true, singleKeys: true })).toBe('Shift+I'); + expect(messageActionShortcut('print', { singleKeys: true })).toBeUndefined(); + }); +}); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index a27531ead..deaf6e91e 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -30,6 +30,7 @@ import { Fish, } from "@/components/icons"; import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; +import { useMessageActionShortcut } from "./message-action-shortcuts"; import { buildMailboxTree, MailboxNode } from "@/lib/utils"; import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { localizeMailboxName } from "@/lib/mailbox-label"; @@ -198,6 +199,7 @@ export function EmailContextMenu({ label, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); + const shortcutOf = useMessageActionShortcut(); const archiveActionEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); const tSidebar = useTranslations("sidebar"); @@ -356,6 +358,7 @@ export function EmailContextMenu({ handleAction(onBatchArchive!)} disabled={!onBatchArchive} /> @@ -368,6 +371,7 @@ export function EmailContextMenu({ icon={Trash2} label={t("delete")} testId="ctx-delete" + shortcut={shortcutOf("delete")} onClick={() => handleAction(onBatchDelete!)} disabled={!onBatchDelete} destructive={!mountainView} @@ -381,6 +385,7 @@ export function EmailContextMenu({ icon={isUnread ? MailOpen : Mail} label={isUnread ? t("mark_read") : t("mark_unread")} testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"} + shortcut={shortcutOf("read", isUnread)} onClick={() => handleAction(() => onBatchMarkAsRead?.(isUnread))} /> @@ -457,6 +462,7 @@ export function EmailContextMenu({ icon={isInJunkFolder ? ShieldCheck : ShieldAlert} label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")} testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"} + shortcut={shortcutOf("spam")} onClick={() => handleAction(isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)} disabled={isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam} destructive={!isInJunkFolder && !mountainView} @@ -511,6 +517,7 @@ export function EmailContextMenu({ testId={entry.testId} disabled={entry.disabled} destructive={entry.destructive} + shortcut={shortcutOf(entry.key, isUnread)} onClick={() => handleAction(entry.run)} /> ); diff --git a/components/email/message-action-shortcuts.ts b/components/email/message-action-shortcuts.ts new file mode 100644 index 000000000..ce9efe28c --- /dev/null +++ b/components/email/message-action-shortcuts.ts @@ -0,0 +1,39 @@ +"use client"; + +import { useCallback } from "react"; +import { useSettingsStore } from "@/stores/settings-store"; +import type { MessageActionKey } from "./message-actions"; + +/** + * The keys the mail list runs a message's actions with (hooks/use-keyboard-shortcuts.ts), + * as the message menus show them next to the entries. The native Message menu shows the + * same ones. A single key goes when the user turned single-key shortcuts off, since it + * does nothing then. + */ +const KEYS: Partial> = { + reply: { key: "R", single: true }, + reply_all: { key: "Shift+R", single: true }, + forward: { key: "F", single: true }, + archive: { key: "E", single: true }, + delete: { key: "Del", single: false }, + star: { key: "S", single: true }, + spam: { key: "!", single: true }, +}; + +export function messageActionShortcut( + key: MessageActionKey, + options: { unread?: boolean; singleKeys: boolean }, +): string | undefined { + const entry = key === "read" + ? { key: options.unread ? "Shift+I" : "U", single: true } + : KEYS[key]; + if (!entry) return undefined; + if (entry.single && !options.singleKeys) return undefined; + return entry.key; +} + +/** `messageActionShortcut` with the user's single-key setting. */ +export function useMessageActionShortcut(): (key: MessageActionKey, unread?: boolean) => string | undefined { + const singleKeys = useSettingsStore((state) => state.singleKeyShortcuts !== false); + return useCallback((key, unread) => messageActionShortcut(key, { unread, singleKeys }), [singleKeys]); +} From 551b5ce4fa90412e39dbd23bcd2f2dafe95ad15e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:09:09 +0100 Subject: [PATCH 04/46] fix: name the account sign out leaves in the command palette, add star, hide dead key hints --- .../__tests__/command-palette.test.tsx | 35 +++++++++++++++++++ .../__tests__/mail-palette-commands.test.ts | 28 +++++++++++++++ .../command-palette/command-palette.tsx | 21 ++++++++--- components/mail/mail-app.tsx | 6 +++- lib/single-key-shortcuts.ts | 9 +++++ 5 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 components/command-palette/__tests__/mail-palette-commands.test.ts diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx index 2bd4fae1e..927e2b174 100644 --- a/components/command-palette/__tests__/command-palette.test.tsx +++ b/components/command-palette/__tests__/command-palette.test.tsx @@ -119,6 +119,41 @@ describe('command palette', () => { expect(hint('search')).toBe(''); }); + it('shows no single-key hints while single-key shortcuts are off, since those keys do nothing then', async () => { + const { useSettingsStore } = await import('@/stores/settings-store'); + const hint = (label: string) => { + const row = screen.getAllByRole('option').find((o) => o.querySelector('.flex-1')?.textContent === label)!; + return [...row.querySelectorAll('kbd')].map((k) => k.textContent).join(' '); + }; + useSettingsStore.setState({ singleKeyShortcuts: false }); + try { + render(); + pressCtrlK(); + expect(hint('compose')).toBe(''); + expect(hint('shortcuts')).toBe(''); + } finally { + useSettingsStore.setState({ singleKeyShortcuts: true }); + } + }); + + it('names the account Sign out leaves, as the account menu does', async () => { + const { useAccountStore } = await import('@/stores/account-store'); + const before = useAccountStore.getState().accounts; + useAccountStore.setState({ accounts: [ + { id: 'a1', email: 'one@example.org', username: 'one@example.org' }, + { id: 'a2', email: 'two@example.org', username: 'two@example.org' }, + ] } as never); + useAuthStore.setState({ activeAccountId: 'a2' } as never); + try { + render(); + pressCtrlK(); + expect(screen.getByText('sign_out_of(two@example.org)')).toBeInTheDocument(); + expect(screen.queryByText('sign_out')).toBeNull(); + } finally { + useAccountStore.setState({ accounts: before } as never); + } + }); + it('shows plugin entries with the plugin name and runs them with Enter', async () => { act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }])); render(); diff --git a/components/command-palette/__tests__/mail-palette-commands.test.ts b/components/command-palette/__tests__/mail-palette-commands.test.ts new file mode 100644 index 000000000..247954e94 --- /dev/null +++ b/components/command-palette/__tests__/mail-palette-commands.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +// Mail registers its palette commands in mail-app.tsx, which is too big to render +// here. Each message action with a key in the shortcuts help is in the palette too, +// under the key the help names. +const source = readFileSync(path.join(process.cwd(), 'components/mail/mail-app.tsx'), 'utf8'); +const block = source.slice(source.indexOf("useRegisterCommands(t('sidebar.mail')")); + +describe('Mail commands in the palette', () => { + it.each([ + ['reply', 'r'], + ['reply-all', 'a'], + ['forward', 'f'], + ['star', 's'], + ['archive', 'e'], + ['delete', '#'], + ['mark-unread', 'u'], + ['mark-read', 'Shift + I'], + ['spam', '!'], + ])('has %s, shown with %s', (id, key) => { + const at = block.indexOf(`id: '${id}'`); + expect(at).toBeGreaterThan(-1); + const entry = block.slice(at, block.indexOf('}', at)); + expect(entry).toContain(`shortcut: '${key}'`); + }); +}); diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx index 058d4a448..8169769bd 100644 --- a/components/command-palette/command-palette.tsx +++ b/components/command-palette/command-palette.tsx @@ -27,6 +27,7 @@ import { useGlobalSearchStore } from "@/stores/global-search-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { usePolicyStore } from "@/stores/policy-store"; import { useThemeStore } from "@/stores/theme-store"; +import { useSettingsStore } from "@/stores/settings-store"; import { usePluginActions } from "@/components/plugins/plugin-actions"; import { usePluginPages, pluginPageHref } from "@/hooks/use-plugin-pages"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; @@ -38,7 +39,7 @@ import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-regist import { shortcutAlternatives } from "@/lib/shortcut-keys"; import { toUnicodeEmail } from "@/lib/idn"; import { isEditableEventTarget, isKeyboardLayerOpen } from "@/lib/keyboard"; -import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; +import { isSingleKeyHint, isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useMediaQuery } from "@/hooks/use-media-query"; import { IconButton } from "@/components/ui/icon-button"; @@ -162,6 +163,9 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS const activeAccountId = useAuthStore((s) => s.activeAccountId); // A touch screen has no keyboard to use the shortcuts with (R8). const touch = useMediaQuery("(pointer: coarse)"); + // With single-key shortcuts off, "r" or "#" does nothing: no hint for it. + const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts !== false); + const hintKeys = (shortcut: string) => shortcutAlternatives(shortcut).filter((key) => singleKeys || !isSingleKeyHint(key)); const inputRef = useRef(null); const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true, initialFocus: () => inputRef.current }); @@ -255,7 +259,16 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS run: () => { void useAuthStore.getState().switchAccount(entry.id); }, }); } - list.push({ id: "sign-out", group: account, icon: LogOut, label: tSidebar("sign_out"), run: () => { void useAuthStore.getState().logout(); } }); + // It signs out of the open account only, as the account menu's entry says. + const current = accounts.find((entry) => entry.id === activeAccountId); + const currentName = current ? toUnicodeEmail(current.email || current.username) : ""; + list.push({ + id: "sign-out", + group: account, + icon: LogOut, + label: currentName ? tSidebar("sign_out_of", { account: currentName }) : tSidebar("sign_out"), + run: () => { void useAuthStore.getState().logout(); }, + }); for (const action of pluginActions) { list.push({ id: `plugin:${action.pluginId}:${action.id}`, @@ -394,10 +407,10 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS {command.source && ( {command.source} )} - {command.shortcut && ( + {command.shortcut && hintKeys(command.shortcut).length > 0 && ( // Key hints only where there is a keyboard (R8). diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 11d4246c0..e22795c00 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -136,7 +136,7 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { openMailImport } from "@/stores/mail-transfer-store"; import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; -import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons"; +import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Star } from "@/components/icons"; import { useRegisterCommands } from "@/lib/command-registry"; import { MoveToDialog } from "@/components/email/move-to-dialog"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -4813,6 +4813,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { when: () => !!paletteEmail && !paletteEmail.keywords?.$seen, run: () => { if (client && paletteEmail) void markReadWithUndo(paletteEmail, true).catch(toastUpdateFailure); }, }, + { + id: 'star', label: paletteEmail?.keywords?.$flagged ? t('context_menu.unstar') : t('context_menu.star'), icon: Star, shortcut: 's', + when: hasPaletteEmail, run: () => void handleToggleStar(), + }, { id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'), icon: conversationInJunk ? ShieldCheck : ShieldAlert, shortcut: '!', diff --git a/lib/single-key-shortcuts.ts b/lib/single-key-shortcuts.ts index e3e8477d8..26cba959c 100644 --- a/lib/single-key-shortcuts.ts +++ b/lib/single-key-shortcuts.ts @@ -10,3 +10,12 @@ import { useSettingsStore } from "@/stores/settings-store"; export function isSingleKeyShortcutOff(event: Pick): boolean { return isSingleCharacterKey(event) && useSettingsStore.getState().singleKeyShortcuts === false; } + +/** + * A key hint as the help and menus write it ("r", "Shift + I", "#") that's a single-key + * shortcut, so it does nothing while those are off. Arrows aren't characters. + */ +export function isSingleKeyHint(combo: string): boolean { + const key = combo.replace(/^(shift\s*\+\s*)+/i, "").trim(); + return [...key].length === 1 && !/[←→↑↓]/.test(key); +} From cb183bc45f798a1565571388357da65fca7bb001 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:11:51 +0100 Subject: [PATCH 05/46] test: check that no text is cut off at common window sizes and zoom levels --- integration/tests/25-do-layout-fit.spec.ts | 149 +++++++++++++++++++++ integration/tests/helpers/layout-fit.ts | 80 +++++++++++ 2 files changed, 229 insertions(+) create mode 100644 integration/tests/25-do-layout-fit.spec.ts create mode 100644 integration/tests/helpers/layout-fit.ts diff --git a/integration/tests/25-do-layout-fit.spec.ts b/integration/tests/25-do-layout-fit.spec.ts new file mode 100644 index 000000000..77ec19577 --- /dev/null +++ b/integration/tests/25-do-layout-fit.spec.ts @@ -0,0 +1,149 @@ +/** + * Nothing cut off at common window sizes: the main screens at 1280×720 and 1366×768, + * and at 125 % and 150 % zoom (the same windows in CSS pixels), in English, German + * (long words) and Arabic (right to left). Each screen fails on a line of text that + * runs past the edge of the box it sits in or hides behind a scroll bar + * (helpers/layout-fit.ts). The message details panel also has to give the sender's + * address room and show its dates on one line. + * + * Runs as dso-menus@example.org on Stalwart 1.0: check or create the account on the + * VM first (~/desktop-offline/dso_menus_provision.py stw-v1, then + * dso_menus_authmail.py stw-v1 for the message with long sign-in results), then + * desktop-offline-rig/run-spec.sh with IT_DSO_PASSWORD from the vault item + * "Stalwart test – dso-* throwaway accounts". Reads only; changes nothing. + * IT_LAYOUT_SHOTS: a folder for a screenshot of each screen. IT_LAYOUT_SCREENS=mail: + * the mail screens only. + */ +import { test, expect, type Browser, type Page } from '@playwright/test'; +import { existsSync, mkdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { requireEntEnv } from './helpers/ent'; +import { accountSwitcher, neutralizeDevOverlay } from './helpers/app'; +import { WEBMAIL_URL } from './helpers/config'; +import { findCutOffText, type CutOff } from './helpers/layout-fit'; + +requireEntEnv(); +test.setTimeout(600_000); + +const USER = 'dso-menus@example.org'; +const PASSWORD = process.env.IT_DSO_PASSWORD ?? ''; +if (!PASSWORD) throw new Error('Set IT_DSO_PASSWORD, and check the account on the VM first: see the top of this file'); +const AUTH_SUBJECT = 'Your weekly account summary is ready for review'; +const SHOTS = process.env.IT_LAYOUT_SHOTS ?? ''; +const MAIL_ONLY = process.env.IT_LAYOUT_SCREENS === 'mail'; + +const SETTINGS_TABS = ['account', 'security', 'notifications', 'general', 'appearance', 'accessibility', 'reading', + 'composing', 'organize', 'filters', 'vacation', 'calendar', 'contacts', 'files', 'backup']; + +interface Scenario { name: string; locale: string; language: string; width: number; height: number } + +const SCENARIOS: Scenario[] = [ + { name: '1280x720-en', locale: 'en-US', language: 'en', width: 1280, height: 720 }, + { name: '1366x768-de', locale: 'de-DE', language: 'de', width: 1366, height: 768 }, + // 1280×720 at 125 % and 1366×768 at 150 %: the same window in CSS pixels. + { name: '1280x720-125-de', locale: 'de-DE', language: 'de', width: 1024, height: 576 }, + { name: '1366x768-150-de', locale: 'de-DE', language: 'de', width: 911, height: 512 }, + { name: '1280x720-ar', locale: 'ar', language: 'ar', width: 1280, height: 720 }, +]; + +function viewerText(language: string): Record { + const file = path.join(__dirname, '..', '..', 'locales', language, 'common.json'); + const viewer = JSON.parse(readFileSync(file, 'utf-8')).email_viewer; + return { showDetails: viewer.show_details, routing: viewer.details.recipients_routing }; +} + +async function signIn(browser: Browser, scenario: Scenario): Promise { + const context = await browser.newContext({ + baseURL: WEBMAIL_URL, + locale: scenario.locale, + viewport: { width: scenario.width, height: scenario.height }, + }); + const page = await context.newPage(); + await neutralizeDevOverlay(page); + await page.goto('/'); + await page.fill('#username', USER); + await page.fill('#password', PASSWORD); + await page.click('button[type="submit"]'); + await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 }); + return page; +} + +async function settle(page: Page): Promise { + await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {}); + await page.waitForTimeout(800); +} + +async function check(page: Page, scenario: Scenario, screen: string, found: Map): Promise { + await settle(page); + if (SHOTS) { + if (!existsSync(SHOTS)) mkdirSync(SHOTS, { recursive: true }); + await page.screenshot({ path: path.join(SHOTS, `${screen}-${scenario.name}.png`) }); + } + const cut = await findCutOffText(page); + if (cut.length) found.set(screen, cut); +} + +function report(found: Map): string { + return [...found].map(([screen, list]) => `${screen}:\n${list.map((c) => ` ${c.px}px ${c.where} "${c.text}"`).join('\n')}`).join('\n'); +} + +for (const scenario of SCENARIOS) { + test(`nothing is cut off at ${scenario.name}`, async ({ browser }) => { + const page = await signIn(browser, scenario); + const text = viewerText(scenario.language); + const found = new Map(); + + // Mail: the list, a message with long sign-in results and its details open. + const item = page.locator('[data-testid="email-list-item"]', { hasText: AUTH_SUBJECT }).first(); + await item.waitFor({ state: 'visible', timeout: 60000 }); + await check(page, scenario, 'mail-list', found); + await item.click(); + await page.getByRole('button', { name: text.showDetails }).first().click(); + const routing = page.locator('section', { has: page.getByText(text.routing, { exact: true }) }).first(); + await expect(routing).toBeVisible(); + await check(page, scenario, 'mail-details', found); + + // The sender's address gets a column of its own width, not a sliver, and a date + // takes one line. Rows: From, To, Sent, Received. + const fit = await routing.evaluate((section) => { + const values = Array.from(section.querySelectorAll('dl > dd')); + const from = values[0].getBoundingClientRect(); + const sent = values[values.length - 2]; + const lineHeight = parseFloat(getComputedStyle(sent).lineHeight) || 20; + return { fromWidth: Math.round(from.width), sentLines: Math.round(sent.getBoundingClientRect().height / lineHeight) }; + }); + expect.soft(fit.fromWidth, 'width of the From value in the details panel').toBeGreaterThanOrEqual(220); + expect.soft(fit.sentLines, 'lines the Sent date takes in the details panel').toBeLessThanOrEqual(1); + + if (scenario.language === 'ar' || MAIL_ONLY) { + expect(found, report(found)).toEqual(new Map()); + return; + } + + // Composer. + await page.keyboard.press('Escape'); + await page.keyboard.press('c'); + await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 }); + await check(page, scenario, 'composer', found); + await page.keyboard.press('Escape'); + + // Calendar, contacts, files. + await page.goto('/calendar'); + await page.waitForTimeout(1500); + await page.keyboard.press('w'); + await check(page, scenario, 'calendar-week', found); + await page.keyboard.press('m'); + await check(page, scenario, 'calendar-month', found); + await page.goto('/contacts'); + await check(page, scenario, 'contacts', found); + await page.goto('/files'); + await check(page, scenario, 'files', found); + + for (const tab of SETTINGS_TABS) { + await page.goto(`/settings/${tab}`); + await check(page, scenario, `settings-${tab}`, found); + } + + expect(found, report(found)).toEqual(new Map()); + }); +} diff --git a/integration/tests/helpers/layout-fit.ts b/integration/tests/helpers/layout-fit.ts new file mode 100644 index 000000000..127b2d785 --- /dev/null +++ b/integration/tests/helpers/layout-fit.ts @@ -0,0 +1,80 @@ +/** + * Finds text that's cut off at the side: a line of text that runs past the edge of + * the nearest box that clips it (overflow other than visible), or past the window + * when nothing clips it. A box's vertical scroll bar counts as its edge, so text + * hidden behind a scroll bar is found too. + * + * Not counted: text that's meant to be cut (text-overflow: ellipsis, line clamp), + * text inside a box that scrolls only sideways, text that's wholly outside its box (a + * closed drawer, a slide), text in a box 1-2px wide (visually hidden), screen-reader-only text, and anything marked + * data-layout-fit-ignore. Frames (a message body) aren't looked into. + */ +import type { Page } from '@playwright/test'; + +export interface CutOff { + /** Tag, test id and a few classes of the element holding the text. */ + where: string; + text: string; + /** How many CSS pixels are cut off. */ + px: number; +} + +export async function findCutOffText(page: Page): Promise { + return page.evaluate(() => { + const out: { where: string; text: string; px: number }[] = []; + const seen = new Set(); + const viewport = { left: 0, right: document.documentElement.clientWidth }; + const clipsX = (s: CSSStyleDeclaration) => s.overflowX !== 'visible'; + // A sideways scroller on purpose: one that scrolls sideways only. A box that + // scrolls up and down computes overflow-x: auto as well, and text running off + // its side is still cut off for anyone who doesn't think to scroll sideways. + const scrollsX = (s: CSSStyleDeclaration) => + (s.overflowX === 'auto' || s.overflowX === 'scroll') && s.overflowY !== 'auto' && s.overflowY !== 'scroll'; + const describe = (el: Element) => { + const id = el.getAttribute('data-testid'); + const cls = (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean).slice(0, 4).join('.'); + return `${el.tagName.toLowerCase()}${id ? `[data-testid=${id}]` : ''}${cls ? `.${cls}` : ''}`; + }; + const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim(); + const el = node.parentElement; + if (!text || !el || seen.has(el)) continue; + if (el.closest('script, style, noscript, [aria-hidden="true"], .sr-only, [data-layout-fit-ignore]')) continue; + const own = getComputedStyle(el); + if (own.visibility === 'hidden' || own.display === 'none' || Number(own.opacity) === 0) continue; + const range = document.createRange(); + range.selectNodeContents(node); + const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0.5 && r.height > 0.5); + if (rects.length === 0) continue; + const textLeft = Math.min(...rects.map((r) => r.left)); + const textRight = Math.max(...rects.map((r) => r.right)); + + let meant = false; + let box: { left: number; right: number } | null = null; + for (let at: Element | null = el; at && at !== document.documentElement; at = at.parentElement) { + const s = getComputedStyle(at); + if (s.textOverflow === 'ellipsis' || (s.webkitLineClamp && s.webkitLineClamp !== 'none')) meant = true; + if (Number(s.opacity) === 0 || s.visibility === 'hidden') { meant = true; break; } + if (!clipsX(s)) continue; + if (scrollsX(s)) { meant = true; break; } + // A 1px box is the visually-hidden pattern: read out, not shown. + if (at.clientWidth <= 2) { meant = true; break; } + const r = at.getBoundingClientRect(); + const left = r.left + at.clientLeft; + box = { left, right: left + at.clientWidth }; + break; + } + if (meant) continue; + const edge = box ?? viewport; + // Wholly outside: not cut, put away. + if (textRight <= edge.left || textLeft >= edge.right) continue; + const px = Math.max(textRight - edge.right, edge.left - textLeft); + if (px > 1.5) { + seen.add(el); + out.push({ where: describe(el), text: text.slice(0, 60), px: Math.round(px) }); + } + } + return out; + }); +} From 2525c27a7d3dbf01ad5bc227d37376657ee23e58 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:19:08 +0100 Subject: [PATCH 06/46] fix: group the message menus and keep app-level items out of the reader's more menu The reader's more menu no longer offers Star while the star sits by the subject, so the tests that expected Star in the menu and the phone panel now expect it gone. Import mail moved to the command palette (it stays in the folder menu and Settings); Keyboard shortcuts stay behind ?, the rail and Help. --- .../__tests__/message-action-list.test.tsx | 47 +++++++++++++++++-- components/email/email-context-menu.tsx | 6 ++- components/email/email-viewer.tsx | 26 ++++------ components/email/message-actions.ts | 24 +++++++--- .../__tests__/mail-palette-commands.test.tsx | 13 +++++ components/mail/mail-app.tsx | 8 +++- 6 files changed, 92 insertions(+), 32 deletions(-) diff --git a/components/email/__tests__/message-action-list.test.tsx b/components/email/__tests__/message-action-list.test.tsx index 9233cd61f..3abd6d75a 100644 --- a/components/email/__tests__/message-action-list.test.tsx +++ b/components/email/__tests__/message-action-list.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent, act, within } from '@testing-library/react'; +import { render, renderHook, screen, fireEvent, act, within } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Email, Mailbox } from '@/lib/jmap/types'; @@ -28,6 +28,7 @@ const { EmailViewer } = await import('../email-viewer'); const { EmailContextMenu } = await import('../email-context-menu'); const { usePrintRequestStore, requestPrint } = await import('@/hooks/use-message-file-actions'); const { emailKeyFor } = await import('@/lib/thread-utils'); +const { useMessageActionList } = await import('../message-actions'); const email = { id: 'e1', @@ -112,17 +113,53 @@ describe("the reader's ⋮ on a desktop", () => { renderViewer({ onTogglePinned }); fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); const menu = screen.getByRole('menu', { name: 'more_actions' }); - for (const id of ['ctx-star', 'ctx-move-to', 'ctx-tag', 'ctx-export', 'ctx-print', 'ctx-view-source']) { + for (const id of ['ctx-move-to', 'ctx-tag', 'ctx-export', 'ctx-print', 'ctx-view-source']) { expect(within(menu).getByTestId(id)).toBeTruthy(); } - // Delete is on the toolbar already. + // Delete is on the toolbar already, and the star by the subject. expect(within(menu).queryByTestId('ctx-delete')).toBeNull(); + expect(within(menu).queryByTestId('ctx-star')).toBeNull(); fireEvent.click(within(menu).getByRole('menuitem', { name: 'pin' })); expect(onTogglePinned).toHaveBeenCalledTimes(1); expect(screen.queryByRole('menu', { name: 'more_actions' })).toBeNull(); }); }); +describe("the reader's ⋮ in groups", () => { + it("has the message's actions only, in groups", () => { + renderViewer({ onTogglePinned: vi.fn(), onShowShortcuts: vi.fn() } as never); + fireEvent.click(screen.getByRole('button', { name: 'more_actions' })); + const menu = screen.getByRole('menu', { name: 'more_actions' }); + const rows = Array.from(menu.querySelectorAll('[role="menuitem"], [role="separator"]')) + .map((row) => (row.getAttribute('role') === 'separator' ? '|' : row.querySelector('[data-menu-label]')?.textContent ?? '')); + // No app-level entries: Import is in the folder's menu, Settings and the + // palette, the shortcuts behind ?. Answer; file away; mark; spam; link; + // how it shows; save. + expect(rows).toEqual([ + 'reply', 'reply_all', 'forward', 'forward_as_attachment', '|', + 'archive', '|', + 'move_to', '|', + 'mark_unread', 'pin', 'tag', '|', + 'mark_as_spam', '|', + 'copy_message', 'copy_conversation', '|', + 'reader_view', '|', + 'export_email', 'print', 'view_source', + ]); + }); + + it('puts how the reader shows the message in a group before Export, Print and View source', () => { + const { result } = renderHook(() => useMessageActionList({ + email, + onExport: () => {}, + onPrint: () => {}, + views: [{ kind: 'item', key: 'extra:reader_view', icon: () => null, label: 'reader_view', run: () => {} }], + extras: [{ kind: 'item', key: 'extra:other', icon: () => null, label: 'other', run: () => {} }], + })); + const labels = result.current.map((entry) => (entry.kind === 'separator' ? '|' : entry.label)); + expect(labels.slice(-7)).toEqual(['|', 'reader_view', '|', 'export_email', 'print', '|', 'other']); + }); +}); + describe("the reader toolbar's Move and Tag menus", () => { it('are the ContextMenu: in a portal, with its rows and keys', () => { renderViewer(); @@ -149,7 +186,9 @@ describe("the phone's More panel", () => { const panel = screen.getByRole('menu', { name: 'more_actions' }); const pin = within(panel).getByTestId('panel-pin'); expect(pin.className).toContain('min-h-[44px]'); - for (const id of ['panel-star', 'panel-export', 'panel-print', 'panel-view_source', 'panel-copy_message_link']) { + // The star is by the subject, on the phone too. + expect(within(panel).queryByTestId('panel-star')).toBeNull(); + for (const id of ['panel-export', 'panel-print', 'panel-view_source', 'panel-copy_message_link']) { expect(within(panel).getByTestId(id)).toBeTruthy(); } fireEvent.click(pin); diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index a27531ead..b7205a3f6 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -131,7 +131,9 @@ interface EmailContextMenuProps { onViewSource?: () => void; /** Entries the host already shows elsewhere (the reader's toolbar). */ omit?: ReadonlySet; - /** The host's own entries after the message's (the reader's view toggles). */ + /** How the host shows the message (the reader's view toggles), before Export, Print and View source. */ + views?: MessageActionEntry[]; + /** The host's own entries after the message's. */ extras?: MessageActionEntry[]; /** The row's plugin entries (message-row-actions). Default true. */ rowPlugins?: boolean; @@ -192,6 +194,7 @@ export function EmailContextMenu({ onPrint, onViewSource, omit, + views, extras, rowPlugins = true, extraEntries, @@ -319,6 +322,7 @@ export function EmailContextMenu({ onPrint, onViewSource, omit, + views, extras, }); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0b1b5fac7..b8b4e3ede 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -3,7 +3,6 @@ import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react"; import { Email, ContactCard, Mailbox } from "@/lib/jmap/types"; import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename"; -import { openMailImport } from "@/stores/mail-transfer-store"; import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization"; import { getRenderableHtmlBody } from "@/lib/email-body-selection"; import { mailLanguage } from "@/lib/mail-language"; @@ -106,7 +105,6 @@ import { Send, FolderInput, Sun, - Upload, Moon, PlayCircle, PenSquare, @@ -278,7 +276,6 @@ interface EmailViewerProps { onReplyAllConversationEmail?: (email: Email) => void; /** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */ mvInlineComposer?: React.ReactNode; - onShowShortcuts?: () => void; onEditDraft?: () => void; onCancelScheduledForEdit?: () => void; /** Stop a scheduled message from going out; it goes back to Drafts. */ @@ -1157,6 +1154,7 @@ function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMo onPrint={options.onPrint} onViewSource={options.onViewSource} omit={options.omit} + views={options.views} extras={options.extras} rowPlugins={false} extraEntries={pluginEntries} @@ -1341,7 +1339,6 @@ export function EmailViewer({ onReplyConversationEmail, onReplyAllConversationEmail, mvInlineComposer, - onShowShortcuts, onEditDraft, onCancelScheduledForEdit, onCancelScheduledSend, @@ -1364,7 +1361,6 @@ export function EmailViewer({ const tWindows = useTranslations('pwaWindows'); const tComposer = useTranslations('email_composer'); const tNotifications = useTranslations('notifications'); - const tImport = useTranslations('mail_import'); const tCommon = useTranslations('common'); const tFiles = useTranslations('files'); const tDemoWelcome = useTranslations('demo_welcome'); @@ -3768,11 +3764,6 @@ export function EmailViewer({ await exportEmail(email, { onArchive, onDelete, archiveEnabled: archiveActionEnabled }, blobSource ?? { client, accountId: undefined }); }; - // Import mail into the open folder: the import dialog (lib/mail-import) does the rest. - const handleImportEmail = () => { - openMailImport({ mailboxId: useEmailStore.getState().selectedMailbox || undefined }); - }; - // What a printout of the message shows, for the Print action's window and // for the page's print copy (Ctrl+P). Text fields are unescaped. const messagePrintFields = (): MessagePrintFields | null => { @@ -4228,6 +4219,8 @@ ${printWatermark ? printWatermark.html : ''} // the bottom bar has the replies. const toolbarShownActions = (): ReadonlySet => { const shown = new Set(['delete', 'edit_draft']); + // The star sits by the subject, or on the sender's line in Mountain View. + if (toolbarPosition === 'top' || mvViewer) shown.add('star'); toolbarRef.current?.querySelectorAll('[data-overflow-item]').forEach((item) => { const key = TOOLBAR_PRIORITY_ACTIONS[item.dataset.overflowPriority ?? '']; if (key && item.getClientRects().length > 0) shown.add(key); @@ -4248,9 +4241,10 @@ ${printWatermark ? printWatermark.html : ''} return hidden; }; const toggleViewMode = () => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); - // The reader's own entries after the message's: the view toggle, Import - // (into the open folder) and, with a keyboard, the shortcuts. - const readerExtras: MessageActionEntry[] = [ + // How the reader shows the message, a group of the ⋮ menu of its own. Import + // and the keyboard shortcuts are no message's: Import is in the folder's menu, + // Settings and the command palette, the shortcuts behind ?, the rail and Help. + const readerViews: MessageActionEntry[] = [ ...(readAloud.supported && !isBodyLoading ? [{ kind: 'item' as const, key: 'extra:read_aloud' as const, icon: Volume2, label: t('read_aloud'), run: readAloud.start }] : []), @@ -4263,10 +4257,6 @@ ${printWatermark ? printWatermark.html : ''} ...(effectiveEmailContent.isHtml ? [{ kind: 'item' as const, key: 'extra:view_mode' as const, icon: isDark ? Sun : Moon, label: isDark ? t('view_in_light_mode') : t('view_in_dark_mode'), run: toggleViewMode }] : []), - { kind: 'item', key: 'extra:import', icon: Upload, label: tImport('menu_import'), run: handleImportEmail }, - ...(onShowShortcuts && !isMobile - ? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }] - : []), ]; const readerActionOptions: MessageActionOptions = { email, @@ -4291,7 +4281,7 @@ ${printWatermark ? printWatermark.html : ''} onPrint: devices.noPrint ? undefined : () => handlePrint(), onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true), omit: readerMenuOmit, - extras: readerExtras.filter((entry) => entry.kind !== 'item' || !readerMenuOmit.has(entry.key)), + views: readerViews, }; // Shared toolbar items used by both 'top' and 'below-subject' positions diff --git a/components/email/message-actions.ts b/components/email/message-actions.ts index 8fafc6ba2..b9078819a 100644 --- a/components/email/message-actions.ts +++ b/components/email/message-actions.ts @@ -125,6 +125,8 @@ export interface MessageActionOptions { onViewSource?: () => void; /** Entries the host already shows elsewhere (the reader's toolbar). */ omit?: ReadonlySet; + /** How the host shows the message (the reader's view toggles): a group before Export, Print and View source. */ + views?: MessageActionEntry[]; /** The host's own entries, after the message's own and a separator. */ extras?: MessageActionEntry[]; } @@ -230,7 +232,6 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr void copyLink(buildMailPath({ mailboxId: null, emailId: null, threadId: email.threadId!, ...emailLinkSource(email) })); })] : []), - SEPARATOR, ]; const archive = archiveEnabled ? [item("archive", Archive, t("archive"), o.onArchive)] : []; const del = item("delete", Trash2, t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" }); @@ -267,25 +268,34 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr const find = o.onFindFromSender && sender?.email ? [SEPARATOR, item("find_from_sender", Search, t("find_emails_from", { name: sender.name || sender.email }), o.onFindFromSender, { testId: "ctx-find-from" })] : []; - // Download/Export, Print, View source (R6: after Copy, before the end). + const openWindow = o.onOpenInWindow + ? [item("open_window", ExternalLink, tWindows("open_in_window"), o.onOpenInWindow, { testId: "ctx-open-window" })] + : []; + // How the host shows it, then Download/Export, Print, View source, at the end. + const views = o.views ?? []; const file = [ - ...(o.onOpenInWindow ? [item("open_window", ExternalLink, tWindows("open_in_window"), o.onOpenInWindow, { testId: "ctx-open-window" })] : []), ...(o.onExport ? [item("export", Download, tViewer("export_email"), o.onExport, { disabled: !email.blobId, testId: "ctx-export" })] : []), ...(o.onPrint ? [item("print", Printer, tViewer("print"), o.onPrint, { testId: "ctx-print" })] : []), ...(o.onViewSource ? [item("view_source", Code, tViewer("view_source"), o.onViewSource, { disabled: !email.blobId, testId: "ctx-view-source" })] : []), ]; + // In groups, as mail clients have them: answer; file away; mark; spam; + // open and link; how it shows; save, print, source. list = mountainView ? [ ...draft, ...replies, ...archive, del, read, SEPARATOR, ...move, tag, ...rules, ...copy, SEPARATOR, star, ...pin, ...spam, ...reportPhishing, ...find, SEPARATOR, - ...links, ...file, + ...links, SEPARATOR, ...views, SEPARATOR, ...openWindow, ...file, ] : [ - ...draft, ...replies, ...links, ...archive, del, SEPARATOR, - ...move, ...copy, ...rules, star, ...pin, tag, ...spam, ...reportPhishing, SEPARATOR, - read, SEPARATOR, ...file, + ...draft, ...replies, ...archive, del, SEPARATOR, + ...move, ...copy, ...rules, SEPARATOR, + read, star, ...pin, tag, SEPARATOR, + ...spam, ...reportPhishing, SEPARATOR, + ...openWindow, ...links, SEPARATOR, + ...views, SEPARATOR, + ...file, ]; } diff --git a/components/mail/__tests__/mail-palette-commands.test.tsx b/components/mail/__tests__/mail-palette-commands.test.tsx index b630376c6..d19546878 100644 --- a/components/mail/__tests__/mail-palette-commands.test.tsx +++ b/components/mail/__tests__/mail-palette-commands.test.tsx @@ -8,6 +8,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { usePluginStore } from '@/stores/plugin-store'; import { useThemeStore } from '@/stores/theme-store'; import { useToastStore } from '@/stores/toast-store'; +import { useMailTransferStore } from '@/stores/mail-transfer-store'; import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; import { clearAllHooks } from '@/lib/plugin-hooks'; import type { CommandGroup } from '@/lib/command-registry'; @@ -108,6 +109,18 @@ describe('Mail in the command palette', () => { expect(ids).not.toContain('mark-read'); }); + it("offers Import mail into the open folder, which left the reader's ⋮ menu", async () => { + let groups: CommandGroup[] = []; + render(<> { groups = g; }} />); + await screen.findByText('viewer', {}, { timeout: 5000 }); + await waitFor(() => expect(groups.find((g) => g.label === 'sidebar.mail')).toBeDefined()); + const importMail = groups.find((g) => g.label === 'sidebar.mail')!.commands.find((c) => c.id === 'import'); + expect(importMail?.label).toBe('mail_import.menu_import'); + act(() => importMail!.run()); + expect(useMailTransferStore.getState().request).toMatchObject({ kind: 'import', mailboxId: 'inbox' }); + act(() => useMailTransferStore.setState({ request: null, visible: false })); + }); + it("says whose a shared folder is and where a subfolder is, so two Inboxes don't look the same", async () => { const shared = { ...folder('support:inbox', 'inbox'), isShared: true, accountName: 'Support', accountId: 'support', originalId: 'inbox' } as Mailbox; const archive = folder('archive', 'archive', 'Archive'); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 11d4246c0..555d2b7ca 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -136,7 +136,7 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { openMailImport } from "@/stores/mail-transfer-store"; import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity"; import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; -import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons"; +import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload } from "@/components/icons"; import { useRegisterCommands } from "@/lib/command-registry"; import { MoveToDialog } from "@/components/email/move-to-dialog"; import { ResizeHandle } from "@/components/layout/resize-handle"; @@ -4819,6 +4819,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { when: () => !!paletteEmail && conversationSpamApplicable, run: () => void (conversationInJunk ? handleUndoSpam(paletteEmail) : handleMarkAsSpam(paletteEmail)), }, + // Into the open folder, as the folder's own menu does it. + { + id: 'import', label: t('mail_import.menu_import'), icon: Upload, + run: () => openMailImport({ mailboxId: selectedMailbox || undefined }), + }, ...mailboxes .filter((mb) => !mb.id.startsWith('shared-') && mb.id !== selectedMailbox) .map((mb) => { @@ -6113,7 +6118,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined} onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined} isFullscreen={fullscreenReading} - onShowShortcuts={() => setShowShortcutsModal(true)} onEditDraft={handleEditDraft} onCancelScheduledForEdit={async () => { if (!client || !selectedEmail) return; From ff0e16c755cf71f8c1c28b84f819953cd0f7328f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:20:02 +0100 Subject: [PATCH 07/46] feat: show the desktop app's menu bar as a row at the top on Alt, or always --- app/globals.css | 15 + components/layout/window-menu-bar.tsx | 503 +++++++++++++++--- .../settings/desktop-window-settings.tsx | 16 + lib/__tests__/window-controls-host.test.tsx | 203 ++++++- lib/desktop/boot.ts | 11 +- lib/desktop/languages.ts | 12 + lib/desktop/menu-bar.ts | 79 +++ lib/desktop/types.ts | 30 ++ locales/ar/common.json | 12 + locales/ca/common.json | 12 + locales/cs/common.json | 12 + locales/da/common.json | 12 + locales/de/common.json | 12 + locales/en/common.json | 12 + locales/es/common.json | 12 + locales/et/common.json | 12 + locales/fa/common.json | 12 + locales/fr/common.json | 12 + locales/he/common.json | 12 + locales/hu/common.json | 12 + locales/it/common.json | 12 + locales/ja/common.json | 12 + locales/ko/common.json | 12 + locales/lv/common.json | 12 + locales/mn/common.json | 12 + locales/nb/common.json | 12 + locales/nl/common.json | 12 + locales/pl/common.json | 12 + locales/pt/common.json | 12 + locales/ro/common.json | 12 + locales/ru/common.json | 12 + locales/sk/common.json | 12 + locales/th/common.json | 12 + locales/tr/common.json | 12 + locales/uk/common.json | 12 + locales/vi/common.json | 12 + locales/zh-TW/common.json | 12 + locales/zh/common.json | 12 + 38 files changed, 1141 insertions(+), 88 deletions(-) create mode 100644 lib/desktop/menu-bar.ts diff --git a/app/globals.css b/app/globals.css index b9aee76aa..2dc1df83f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -629,6 +629,21 @@ html[data-window-controls] [data-window-row-left] { padding-left: calc(var(--titlebar-left-inset) + 0.5rem) !important; } +/* + * The desktop app's menu bar while it takes its row (components/layout/window-menu-bar.tsx). + * The app moves down under it, a page that fills the window starts below it, and the + * window's controls sit in the bar's row, so no column below makes room for them. + */ +html[data-menu-bar] { + --menu-bar-height: 2rem; + --app-top-inset: calc(env(safe-area-inset-top, 0px) + var(--menu-bar-height)); + --titlebar-inset: var(--menu-bar-height); +} + +html[data-menu-bar][data-window-controls] [data-window-end-column] { + padding-top: 0; +} + /* * The empty parts of the app's top rows are what the window is dragged by, and a * double-click there maximises. Anything to click stays clickable, and so does diff --git a/components/layout/window-menu-bar.tsx b/components/layout/window-menu-bar.tsx index 371cead30..548f18275 100644 --- a/components/layout/window-menu-bar.tsx +++ b/components/layout/window-menu-bar.tsx @@ -1,99 +1,478 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useTranslations } from "next-intl"; +import { Check, ChevronRight } from "@/components/icons"; import { getDesktopBridge } from "@/lib/desktop/bridge"; -import type { DesktopMenuBarEntry } from "@/lib/desktop/types"; +import { MENU_BAR_ATTRIBUTE, cachedMenuBarPinned, menuKeyText, rememberMenuBarPinned } from "@/lib/desktop/menu-bar"; +import type { DesktopMenuBarEntry, DesktopMenuBarItem, DesktopMenuBarMenu } from "@/lib/desktop/types"; +import { watchTitlebarArea } from "@/lib/window-controls-overlay"; +import { cn } from "@/lib/utils"; /** - * Alt floats the menu's names over the top left for a moment, since a window with no - * frame has no native bar. Each name opens the app's menu as a native one - * (src/main/menu.ts in the desktop repo). Dismissed, nothing is left of it. + * The desktop app's menu bar where the window's controls sit over the page (Windows and + * Linux), as Thunderbird has it. Alt shows it as a row at the top that moves the app down + * under it, and the window's controls shrink into that row; "Always show the menu bar" + * keeps it. Alt, Escape or a click elsewhere hides it again. Alt and a letter opens that + * menu, with the letters underlined while the keys are on the bar, and the arrows go + * between menus and lines. The menus are the shell's own (src/main/menu.ts in the desktop + * repo), with what's enabled and ticked now, and a pick runs the very item the native + * menu has. macOS keeps its system bar. + * + * A shell from before this gives only the names, and opens each menu natively. */ + +const LINE = "[data-menu-line]"; +const ENABLED_LINE = `${LINE}:not([aria-disabled="true"])`; + +type Focus = { kind: "name"; index: number } | { kind: "line" | "panel"; sub?: boolean }; + +/** The label with its letter underlined, while the keys are on the bar. */ +function Label({ text, at, cue }: { text: string; at: number | undefined; cue: boolean }) { + if (!cue || at === undefined || at < 0 || at >= text.length) return <>{text}; + return ( + <> + {text.slice(0, at)} + {text[at]} + {text.slice(at + 1)} + + ); +} + +function nameLetterAt(entry: DesktopMenuBarEntry): number | undefined { + if (!entry.mnemonic) return undefined; + const at = entry.label.toLowerCase().indexOf(entry.mnemonic); + return at >= 0 ? at : undefined; +} + +function letterOf(item: DesktopMenuBarItem): string | undefined { + return item.mnemonic !== undefined ? item.label[item.mnemonic]?.toLowerCase() : undefined; +} + +function usable(item: DesktopMenuBarItem | undefined): item is DesktopMenuBarItem { + return !!item && item.type !== "separator" && item.enabled; +} + +const EDGE = 4; + +/** + * A menu that would leave the window moves back in. A submenu sits beside its line, on the + * reading side, or the other side where there's no room, and never below the window. + */ +function keepInside(root: HTMLElement | null): void { + if (!root) return; + const width = document.documentElement.clientWidth; + const height = document.documentElement.clientHeight; + const panel = root.querySelector("[data-menu-panel]"); + if (panel) { + panel.style.translate = ""; + const rect = panel.getBoundingClientRect(); + if (rect.right > width - EDGE) panel.style.translate = `${Math.round(width - EDGE - rect.right)}px 0`; + else if (rect.left < EDGE) panel.style.translate = `${Math.round(EDGE - rect.left)}px 0`; + } + const subpanel = root.querySelector("[data-menu-subpanel]"); + const line = panel?.querySelector(`${LINE}[aria-expanded="true"]`); + if (!panel || !subpanel || !line) return; + const outer = panel.getBoundingClientRect(); + const at = line.getBoundingClientRect(); + const own = subpanel.getBoundingClientRect(); + const rtl = getComputedStyle(root).direction === "rtl"; + const after = outer.right - EDGE; + const before = outer.left + EDGE - own.width; + const fitsAfter = after + own.width <= width - EDGE; + const fitsBefore = before >= EDGE; + const left = rtl ? (fitsBefore ? before : after) : fitsAfter || !fitsBefore ? Math.min(after, width - EDGE - own.width) : before; + subpanel.style.left = `${Math.round(Math.max(EDGE, left))}px`; + subpanel.style.top = `${Math.round(Math.max(EDGE, Math.min(at.top - EDGE, height - EDGE - own.height)))}px`; +} + export function WindowMenuBar() { const t = useTranslations("desktopUi.menu"); - const [entries, setEntries] = useState(null); - const stripRef = useRef(null); + const [desktop, setDesktop] = useState(false); + const [hasOverlay, setHasOverlay] = useState(false); + const [pinned, setPinned] = useState(false); + /** The keys are on the bar: Alt was pressed, or a menu was opened. */ + const [active, setActive] = useState(false); + const [menus, setMenus] = useState(null); + const [open, setOpen] = useState(null); + /** The line of the open menu whose submenu is open. */ + const [sub, setSub] = useState(null); + /** Underlined letters: the keys brought the bar, not the mouse. */ + const [cue, setCue] = useState(false); + const barRef = useRef(null); const previousFocus = useRef(null); - // What to do once the names are on screen: open that menu, or null to focus the first name. - const afterShow = useRef(undefined); + const pendingFocus = useRef(null); - const openMenu = useCallback((id: string) => { - const button = stripRef.current?.querySelector(`[data-menu-id="${CSS.escape(id)}"]`); - if (!button) return; - const rect = button.getBoundingClientRect(); - getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom }); - }, []); - - const dismiss = useCallback(() => { - setEntries(null); - previousFocus.current?.focus?.(); - previousFocus.current = null; - }, []); + const shown = desktop && hasOverlay && (pinned || active); + const drawsMenus = desktop && !!getDesktopBridge()?.menu?.bar?.menus; useEffect(() => { + const bridge = getDesktopBridge(); + if (!bridge?.menu?.bar || bridge.platform === "darwin") return; + setDesktop(true); + setPinned(cachedMenuBarPinned()); + return watchTitlebarArea((area) => setHasOverlay(area !== null)); + }, []); + + const refresh = useCallback(async (): Promise => { + const bar = getDesktopBridge()?.menu?.bar; + if (!bar) return null; + const list = bar.menus + ? await bar.menus().catch(() => null) + : await bar.entries().then((entries) => entries.map((entry) => ({ ...entry, items: [] }))).catch(() => null); + if (list) setMenus(list); + return list; + }, []); + + const rememberFocus = useCallback(() => { + const now = document.activeElement; + if (now instanceof HTMLElement && now !== document.body && !barRef.current?.contains(now)) previousFocus.current = now; + }, []); + + /** Back to how it was before Alt: the menus shut, the keys back where they were. */ + const deactivate = useCallback(() => { + setOpen(null); + setSub(null); + setActive(false); + setCue(false); + const back = previousFocus.current; + previousFocus.current = null; + if (barRef.current?.contains(document.activeElement)) { + if (back?.isConnected) back.focus({ preventScroll: true }); + else (document.activeElement as HTMLElement | null)?.blur(); + } + }, []); + + // The shell: Alt, Alt and a letter, the window losing the focus, the setting. + useEffect(() => { + if (!desktop) return; const bar = getDesktopBridge()?.menu?.bar; if (!bar) return; - return bar.onEvent((event) => { + const stop = bar.onEvent((event) => { + if (typeof event.pinned === "boolean") { + setPinned(event.pinned); + rememberMenuBarPinned(event.pinned); + } if (!event.visible) { - dismiss(); + deactivate(); return; } - void bar.entries().then((list) => { - previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null; - afterShow.current = event.open ?? null; - setEntries(list); + rememberFocus(); + setActive(true); + setCue(true); + setSub(null); + // Set now: the names may be drawn before the menus arrive, and take it then. + pendingFocus.current = event.open && bar.menus ? { kind: "line" } : { kind: "name", index: 0 }; + void refresh().then((list) => { + const index = event.open ? (list ?? []).findIndex((menu) => menu.id === event.open) : -1; + if (index >= 0 && bar.menus) setOpen(index); + else setOpen(null); }); }); - }, [dismiss]); + // Listening now: the shell answers with whether the bar is always there. + bar.report?.({ height: 0, active: false }); + return stop; + }, [desktop, refresh, deactivate, rememberFocus]); + // An always-shown bar has its names from the start. useEffect(() => { - const next = afterShow.current; - if (!entries || next === undefined) return; - afterShow.current = undefined; - if (next) openMenu(next); - else stripRef.current?.querySelector("button")?.focus(); - }, [entries, openMenu]); + if (shown && !menus) void refresh(); + }, [shown, menus, refresh]); - if (!entries) return null; + // The row it takes: everything else moves down under it, and the controls fit into it. + useLayoutEffect(() => { + if (!desktop) return; + document.documentElement.toggleAttribute(MENU_BAR_ATTRIBUTE, shown); + const height = shown ? Math.round(barRef.current?.getBoundingClientRect().height ?? 0) : 0; + getDesktopBridge()?.menu?.bar?.report?.({ height, active }); + }, [desktop, shown, active]); - const onStripKey = (event: React.KeyboardEvent) => { - const buttons = Array.from(stripRef.current?.querySelectorAll("button") ?? []); - const at = buttons.indexOf(document.activeElement as HTMLElement); - if (event.key === "ArrowRight" || event.key === "ArrowLeft") { + useEffect(() => () => document.documentElement.removeAttribute(MENU_BAR_ATTRIBUTE), []); + + // Menus stay in the window; the focus goes where the keys asked, once it's drawn. + useLayoutEffect(() => { + const root = barRef.current; + keepInside(root); + const want = pendingFocus.current; + if (!want || !root) return; + let target: HTMLElement | null = null; + if (want.kind === "name") { + target = root.querySelectorAll("[data-menu-name]")[want.index] ?? null; + } else { + const panel = root.querySelector(want.sub ? "[data-menu-subpanel]" : "[data-menu-panel]"); + target = want.kind === "panel" ? panel : panel?.querySelector(ENABLED_LINE) ?? panel ?? null; + } + if (!target) return; + pendingFocus.current = null; + target.focus({ preventScroll: true }); + }); + + // A click anywhere else puts it all away. + useEffect(() => { + if (!active && open === null) return; + const onDown = (event: PointerEvent) => { + if (barRef.current?.contains(event.target as Node)) return; + deactivate(); + }; + document.addEventListener("pointerdown", onDown, true); + return () => document.removeEventListener("pointerdown", onDown, true); + }, [active, open, deactivate]); + + const openMenu = useCallback((index: number, focus: "line" | "panel") => { + if (!drawsMenus) { + const entry = menus?.[index]; + const button = barRef.current?.querySelectorAll("[data-menu-name]")[index]; + if (!entry || !button) return; + const rect = button.getBoundingClientRect(); + getDesktopBridge()?.menu?.bar?.open(entry.id, { x: rect.left, y: rect.bottom }); + return; + } + setActive(true); + setOpen(index); + setSub(null); + pendingFocus.current = { kind: focus }; + // What's enabled may have changed since the bar showed. + void refresh(); + }, [drawsMenus, menus, refresh]); + + const run = useCallback((item: DesktopMenuBarItem) => { + if (!usable(item)) return; + // Cut, Copy and Paste work on what had the focus, so it has it back first. + deactivate(); + getDesktopBridge()?.menu?.bar?.run?.(item.key, item.label); + }, [deactivate]); + + if (!shown) return null; + + const list = menus ?? []; + const openItems = open !== null ? list[open]?.items ?? [] : []; + const subItems = sub !== null ? openItems[sub]?.items ?? [] : []; + + const names = () => Array.from(barRef.current?.querySelectorAll("[data-menu-name]") ?? []); + const linesIn = (selector: string) => Array.from(barRef.current?.querySelector(selector)?.querySelectorAll(ENABLED_LINE) ?? []); + + const step = (lines: HTMLElement[], delta: number) => { + if (lines.length === 0) return; + const at = lines.indexOf(document.activeElement as HTMLElement); + const next = at < 0 ? (delta > 0 ? 0 : lines.length - 1) : (at + delta + lines.length) % lines.length; + lines[next]?.focus({ preventScroll: true }); + }; + + const openSub = (index: number) => { + setSub(index); + pendingFocus.current = { kind: "line", sub: true }; + }; + + const closeSub = () => { + const parent = sub; + setSub(null); + barRef.current?.querySelector(`[data-menu-panel] [data-menu-line="${parent}"]`)?.focus({ preventScroll: true }); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + const target = event.target as HTMLElement; + const rtl = getComputedStyle(barRef.current ?? document.documentElement).direction === "rtl"; + const forward = rtl ? "ArrowLeft" : "ArrowRight"; + const backward = rtl ? "ArrowRight" : "ArrowLeft"; + const inSub = !!target.closest("[data-menu-subpanel]"); + const inMenu = inSub || !!target.closest("[data-menu-panel]"); + const key = event.key; + const plainLetter = key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey; + const take = () => { event.preventDefault(); - const delta = event.key === "ArrowRight" ? 1 : -1; - buttons[(at + delta + buttons.length) % buttons.length]?.focus(); - } else if (event.key === "ArrowDown") { - event.preventDefault(); - buttons[at]?.click(); - } else if (event.key === "Escape") { - event.preventDefault(); - dismiss(); + event.stopPropagation(); + }; + setCue(true); + + if (key === "Tab") { + take(); + deactivate(); + return; + } + + if (!inMenu) { + const at = names().indexOf(target); + if (key === forward || key === backward) { + take(); + step(names(), key === forward ? 1 : -1); + } else if (key === "ArrowDown" || key === "Enter" || key === " ") { + take(); + if (at >= 0) openMenu(at, "line"); + } else if (key === "Escape") { + take(); + deactivate(); + } else if (plainLetter) { + const index = list.findIndex((menu) => menu.mnemonic === key.toLowerCase()); + if (index >= 0) { + take(); + openMenu(index, "line"); + } + } + return; + } + + const items = inSub ? subItems : openItems; + const lineIndex = Number(target.closest(LINE)?.dataset.menuLine); + const current = Number.isInteger(lineIndex) ? items[lineIndex] : undefined; + const neighbour = (delta: number) => { + if (open !== null && list.length) openMenu((open + delta + list.length) % list.length, "line"); + }; + + if (key === "ArrowDown" || key === "ArrowUp") { + take(); + step(linesIn(inSub ? "[data-menu-subpanel]" : "[data-menu-panel]"), key === "ArrowDown" ? 1 : -1); + } else if (key === "Home" || key === "End") { + take(); + const lines = linesIn(inSub ? "[data-menu-subpanel]" : "[data-menu-panel]"); + lines[key === "Home" ? 0 : lines.length - 1]?.focus({ preventScroll: true }); + } else if (key === forward) { + take(); + if (!inSub && usable(current) && current.type === "submenu") openSub(lineIndex); + else neighbour(1); + } else if (key === backward) { + take(); + if (inSub) closeSub(); + else neighbour(-1); + } else if (key === "Enter" || key === " ") { + take(); + if (!usable(current)) return; + if (current.type === "submenu") openSub(lineIndex); + else run(current); + } else if (key === "Escape") { + take(); + if (inSub) { + closeSub(); + } else { + const index = open ?? 0; + setOpen(null); + names()[index]?.focus({ preventScroll: true }); + } + } else if (plainLetter) { + const index = items.findIndex((item) => usable(item) && letterOf(item) === key.toLowerCase()); + if (index < 0) return; + take(); + const item = items[index]; + if (item.type === "submenu" && !inSub) openSub(index); + else run(item); } }; + const renderLines = (items: DesktopMenuBarItem[], level: "panel" | "subpanel") => + items.map((item, index) => { + if (item.type === "separator") return
; + const isSub = item.type === "submenu"; + const checkable = item.type === "checkbox" || item.type === "radio"; + const opened = level === "panel" && isSub && sub === index; + return ( +
+ +
+ ); + }); + return (
- {entries.map((entry) => ( - + {list.map((menu, index) => ( +
+ + {open === index && drawsMenus && ( +
+ {renderLines(openItems, "panel")} +
+ )} + {open === index && drawsMenus && sub !== null && ( +
+ {renderLines(subItems, "subpanel")} +
+ )} +
))}
); diff --git a/components/settings/desktop-window-settings.tsx b/components/settings/desktop-window-settings.tsx index 3d604728c..863772a64 100644 --- a/components/settings/desktop-window-settings.tsx +++ b/components/settings/desktop-window-settings.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { hasDesktopWindows } from "@/lib/popout/support"; +import { menuBarAvailable, useMenuBarPinned } from "@/lib/desktop/menu-bar"; import { setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences"; import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; @@ -21,6 +22,21 @@ export function DesktopWindowSettings() { setDesktopPref("restoreWindows", checked)} /> + ); } + +/** "Always show the menu bar", as in the View menu. Windows and Linux: macOS has the system's. */ +function MenuBarSetting() { + const t = useTranslations("desktopMenuBar"); + const [available, setAvailable] = useState(false); + const [pinned, setPinned] = useMenuBarPinned(); + useEffect(() => setAvailable(menuBarAvailable()), []); + if (!available) return null; + return ( + + + + ); +} diff --git a/lib/__tests__/window-controls-host.test.tsx b/lib/__tests__/window-controls-host.test.tsx index b4d6f8673..ee7075b60 100644 --- a/lib/__tests__/window-controls-host.test.tsx +++ b/lib/__tests__/window-controls-host.test.tsx @@ -1,9 +1,11 @@ -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { WindowControlsHost } from '@/components/layout/window-controls-host'; import { WindowMenuBar } from '@/components/layout/window-menu-bar'; +import { menuKeyText } from '@/lib/desktop/menu-bar'; +import { desktopBootScript } from '@/lib/desktop/boot'; import { CONTROLS_ATTRIBUTE, CONTROLS_LEFT_ATTRIBUTE, ROW_ATTRIBUTE, ROW_LEFT_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, TITLEBAR_RIGHT_VAR, TITLEBAR_ROW_VAR, markWindowRows, readTitlebarArea, watchWindowRows } from '@/lib/window-controls-overlay'; class FakeOverlay extends EventTarget { @@ -34,32 +36,56 @@ afterEach(() => { root.removeAttribute(CONTROLS_LEFT_ATTRIBUTE); }); -describe('the menu strip in the desktop app', () => { - type BarEvent = { visible: boolean; open?: string }; +describe('the menu bar in the desktop app', () => { + type BarEvent = { visible: boolean; open?: string; pinned?: boolean }; + type Line = { key: string; label: string; type: string; enabled: boolean; checked?: boolean; accelerator?: string; mnemonic?: number; items?: Line[] }; - function withBar() { + const line = (key: string, label: string, extra: Partial = {}): Line => ({ key, label, type: 'normal', enabled: true, mnemonic: 0, ...extra }); + const MENUS = [ + { id: 'file', label: 'File', mnemonic: 'f', items: [ + line('0/0', 'New message', { accelerator: 'CmdOrCtrl+N' }), + line('0/1', 'Print…', { enabled: false, mnemonic: 0 }), + { key: '0/2', label: '', type: 'separator', enabled: false }, + line('0/3', 'Exit', { mnemonic: 1, accelerator: 'Ctrl+Q' }), + ] }, + { id: 'view', label: 'View', mnemonic: 'v', items: [ + line('1/0', 'Reading pane', { type: 'submenu', items: [line('1/0/0', 'On the right', { type: 'radio', checked: true }), line('1/0/1', 'Off', { type: 'radio', mnemonic: 1 })] }), + line('1/1', 'Always show the menu bar', { type: 'checkbox', checked: false }), + line('1/2', 'Zoom in', { mnemonic: 0, accelerator: 'CmdOrCtrl+Plus' }), + ] }, + { id: 'help', label: 'Help', mnemonic: 'h', items: [line('2/0', 'Keyboard shortcuts', { mnemonic: 0, accelerator: '?' })] }, + ]; + + function withBar(options: { legacy?: boolean; platform?: string } = {}) { let send: (event: BarEvent) => void = () => {}; const open = vi.fn(); + const run = vi.fn(); + const report = vi.fn(); + const setPinned = vi.fn(); window.bulwarkDesktop = { version: 1, - platform: 'win32', + platform: options.platform ?? 'win32', menu: { setState: () => {}, bar: { - entries: async () => [{ id: 'file', label: 'File', mnemonic: 'f' }, { id: 'edit', label: 'Edit', mnemonic: 'e' }], + entries: async () => MENUS.map(({ id, label, mnemonic }) => ({ id, label, mnemonic })), open, onEvent: (listener: (event: BarEvent) => void) => { send = listener; return () => {}; }, + ...(options.legacy ? {} : { menus: async () => MENUS, run, report, setPinned }), }, }, } as never; - return { open, send: (event: BarEvent) => send(event) }; + return { open, run, report, setPinned, send: (event: BarEvent) => act(async () => send(event)) }; } afterEach(() => { window.bulwarkDesktop = undefined; + document.documentElement.removeAttribute('data-menu-bar'); + document.documentElement.removeAttribute('data-desktop'); + localStorage.removeItem('bulwark-menu-bar'); }); const overlayOn = () => { @@ -73,55 +99,184 @@ describe('the menu strip in the desktop app', () => { delete (navigator as unknown as { windowControlsOverlay?: unknown }).windowControlsOverlay; }); - it('Alt shows the names with the focus on the first, and Escape gives it back', async () => { - overlayOn(); - const bar = withBar(); - const before = document.createElement('button'); + const typed = () => { + const before = document.createElement('input'); document.body.appendChild(before); before.focus(); + return before; + }; + + it('Alt shows a row that moves the app down, with the keys on the first name, and Escape gives them back', async () => { + overlayOn(); + const bar = withBar(); + const before = typed(); render(); - await act(async () => bar.send({ visible: true })); + await bar.send({ visible: true }); const file = await screen.findByRole('menuitem', { name: 'File' }); await waitFor(() => expect(document.activeElement).toBe(file)); + expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true); + expect(screen.getByRole('menubar').hasAttribute('data-window-top-row')).toBe(true); + expect(bar.report).toHaveBeenLastCalledWith(expect.objectContaining({ active: true })); + // The letter that opens it is underlined while the keys are on the bar. + expect(file.querySelector('.underline')?.textContent).toBe('F'); fireEvent.keyDown(file, { key: 'ArrowRight' }); - expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Edit' })); + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'View' })); fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); expect(screen.queryByRole('menubar')).toBeNull(); + expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(false); + expect(bar.report).toHaveBeenLastCalledWith({ height: 0, active: false }); + expect(document.activeElement).toBe(before); + before.remove(); + }); + + it('draws the menu with its keys apart from the names, skips what is off, and runs a line where the keys were', async () => { + overlayOn(); + const bar = withBar(); + const before = typed(); + render(); + await bar.send({ visible: true }); + const file = await screen.findByRole('menuitem', { name: 'File' }); + await waitFor(() => expect(document.activeElement).toBe(file)); + fireEvent.keyDown(file, { key: 'ArrowDown' }); + const menu = await screen.findByRole('menu', { name: 'File' }); + const newMessage = within(menu).getByRole('menuitem', { name: /New message/ }); + await waitFor(() => expect(document.activeElement).toBe(newMessage)); + expect(newMessage.querySelector('[data-menu-label]')?.textContent).toBe('New message'); + expect(newMessage.querySelector('[data-menu-keys]')?.textContent).toBe('Ctrl+N'); + expect(within(menu).getByRole('menuitem', { name: /Print/ }).getAttribute('aria-disabled')).toBe('true'); + // Down skips the line that's off. + fireEvent.keyDown(newMessage, { key: 'ArrowDown' }); + expect(document.activeElement?.textContent).toContain('Exit'); + fireEvent.keyDown(document.activeElement!, { key: 'Enter' }); + expect(bar.run).toHaveBeenCalledWith('0/3', 'Exit'); + expect(screen.queryByRole('menubar')).toBeNull(); expect(document.activeElement).toBe(before); before.remove(); }); - it('a name opens that menu natively', async () => { + it('the arrows go across to the next menu and into a submenu', async () => { overlayOn(); const bar = withBar(); render(); - await act(async () => bar.send({ visible: true })); - fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit' })); - expect(bar.open).toHaveBeenCalledWith('edit', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) })); + await bar.send({ visible: true, open: 'file' }); + const menu = await screen.findByRole('menu', { name: 'File' }); + await waitFor(() => expect(menu.contains(document.activeElement)).toBe(true)); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' }); + const view = await screen.findByRole('menu', { name: 'View' }); + await waitFor(() => expect(document.activeElement?.textContent).toContain('Reading pane')); + expect(screen.queryByRole('menu', { name: 'File' })).toBeNull(); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' }); + const sub = await screen.findByRole('menu', { name: 'Reading pane' }); + await waitFor(() => expect(sub.contains(document.activeElement)).toBe(true)); + expect(within(sub).getByRole('menuitemradio', { name: /On the right/ }).getAttribute('aria-checked')).toBe('true'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' }); + expect(screen.queryByRole('menu', { name: 'Reading pane' })).toBeNull(); + expect(document.activeElement?.textContent).toContain('Reading pane'); + fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' }); + await screen.findByRole('menu', { name: 'File' }); + expect(view.isConnected).toBe(false); + // Escape closes the menu and leaves the keys on its name. + await waitFor(() => expect(screen.getByRole('menu', { name: 'File' }).contains(document.activeElement)).toBe(true)); + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); + expect(screen.queryByRole('menu')).toBeNull(); + expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'File' })); }); - it('Alt and a letter opens that one right away', async () => { + it('Alt and a letter opens that menu, and a line\'s letter runs it', async () => { overlayOn(); const bar = withBar(); render(); - await act(async () => bar.send({ visible: true, open: 'file' })); - await waitFor(() => expect(bar.open).toHaveBeenCalledWith('file', expect.anything())); + await bar.send({ visible: true, open: 'view' }); + const view = await screen.findByRole('menu', { name: 'View' }); + await waitFor(() => expect(view.contains(document.activeElement)).toBe(true)); + fireEvent.keyDown(document.activeElement!, { key: 'z' }); + expect(bar.run).toHaveBeenCalledWith('1/2', 'Zoom in'); + }); + + it('a click elsewhere puts it away', async () => { + overlayOn(); + const bar = withBar(); + render(); + await bar.send({ visible: true }); + fireEvent.pointerDown(await screen.findByRole('menuitem', { name: 'Help' }), { button: 0 }); + await screen.findByRole('menu', { name: 'Help' }); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole('menubar')).toBeNull(); + }); + + it('always shown, it is there without Alt and takes no keys, and a click on a name opens that menu', async () => { + overlayOn(); + const bar = withBar(); + const before = typed(); + render(); + await bar.send({ visible: false, pinned: true }); + const help = await screen.findByRole('menuitem', { name: 'Help' }); + expect(document.activeElement).toBe(before); + expect(help.querySelector('.underline')).toBeNull(); + expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true); + expect(localStorage.getItem('bulwark-menu-bar')).toBe('pinned'); + fireEvent.pointerDown(help, { button: 0 }); + const menu = await screen.findByRole('menu', { name: 'Help' }); + fireEvent.click(within(menu).getByRole('menuitem', { name: /Keyboard shortcuts/ })); + expect(bar.run).toHaveBeenCalledWith('2/0', 'Keyboard shortcuts'); + // Still there: it's always shown. + expect(screen.getByRole('menubar')).toBeTruthy(); + expect(document.activeElement).toBe(before); + await bar.send({ visible: false, pinned: false }); + expect(screen.queryByRole('menubar')).toBeNull(); + expect(localStorage.getItem('bulwark-menu-bar')).toBeNull(); + before.remove(); + }); + + it('an older shell opens the menu natively', async () => { + overlayOn(); + const bar = withBar({ legacy: true }); + render(); + await bar.send({ visible: true }); + fireEvent.pointerDown(await screen.findByRole('menuitem', { name: 'View' }), { button: 0 }); + expect(bar.open).toHaveBeenCalledWith('view', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) })); }); it('the shell can take it away again', async () => { overlayOn(); const bar = withBar(); render(); - await act(async () => bar.send({ visible: true })); + await bar.send({ visible: true }); await screen.findByRole('menubar'); - await act(async () => bar.send({ visible: false })); + await bar.send({ visible: false }); expect(screen.queryByRole('menubar')).toBeNull(); }); - it('with no bridge there is none, and the title stays', async () => { + it('with no bridge there is none, and none on a Mac, which has the system bar', async () => { overlayOn(); - render(); + const { unmount } = render(); expect(screen.queryByRole('menubar')).toBeNull(); + unmount(); + const bar = withBar({ platform: 'darwin' }); + render(); + await bar.send({ visible: true }); + expect(screen.queryByRole('menubar')).toBeNull(); + }); + + it('an always-shown bar has its row from the first frame, before any script of the app', () => { + overlayOn(); + localStorage.setItem('bulwark-menu-bar', 'pinned'); + window.bulwarkDesktop = { version: 1, platform: 'darwin' } as never; + new Function(desktopBootScript())(); + expect(document.documentElement.hasAttribute('data-menu-bar'), 'a Mac has the system bar').toBe(false); + window.bulwarkDesktop = { version: 1, platform: 'win32' } as never; + new Function(desktopBootScript())(); + expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true); + }); + + it('menu keys read the way Windows writes them', () => { + expect(menuKeyText('CmdOrCtrl+N', 'win32')).toBe('Ctrl+N'); + expect(menuKeyText('CmdOrCtrl+Plus', 'win32')).toBe('Ctrl++'); + expect(menuKeyText('Alt+Left', 'win32')).toBe('Alt+←'); + expect(menuKeyText('Shift+R', 'linux')).toBe('Shift+R'); + expect(menuKeyText('Delete', 'win32')).toBe('Del'); + expect(menuKeyText('?', 'win32')).toBe('?'); + expect(menuKeyText('CommandOrControl+Shift+Z', 'win32')).toBe('Ctrl+Shift+Z'); }); }); diff --git a/lib/desktop/boot.ts b/lib/desktop/boot.ts index a5652ccfe..a82615388 100644 --- a/lib/desktop/boot.ts +++ b/lib/desktop/boot.ts @@ -1,9 +1,16 @@ +/** What the boot script reads for "Always show the menu bar" (lib/desktop/menu-bar.ts). */ +export const MENU_BAR_STORAGE_KEY = 'bulwark-menu-bar'; +/** On while the menu bar takes its row: app/globals.css moves everything down under it. */ +export const MENU_BAR_ATTRIBUTE = 'data-menu-bar'; + /** * Runs in the inline boot script, before the first paint. The preload bridge * exists before any page script, so app/desktop.css holds from the first - * frame. Nothing happens in a browser, which has no bridge. + * frame. Nothing happens in a browser, which has no bridge. An always-shown + * menu bar has its row from the first frame too, so nothing jumps when it draws. */ export function desktopBootScript(): string { return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', ''); - if (navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible) document.documentElement.setAttribute('data-window-controls', '');`; + if (navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible) document.documentElement.setAttribute('data-window-controls', ''); + try { if (window.bulwarkDesktop && window.bulwarkDesktop.platform !== 'darwin' && navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible && localStorage.getItem('${MENU_BAR_STORAGE_KEY}') === 'pinned') document.documentElement.setAttribute('${MENU_BAR_ATTRIBUTE}', ''); } catch (e) {}`; } diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts index 28945be18..a877ef3dd 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -128,6 +128,18 @@ export function shellLabels(t: (key: string, values?: Record) => menuDocs: t('desktopUi.menu.docs'), menuSettings: t('desktopUi.menu.settings'), menuAbout: t('desktopUi.menu.about'), + menuExit: t('desktopMenuBar.exit'), + menuShowMenuBar: t('desktopMenuBar.always_show_label'), + trayNewMessage: t('email_composer.new_message'), + trayLockNow: t('appLock.lock_now'), + trayUnread: t('email_list.sections.unread'), + trayPause: t('desktopMenuBar.tray_pause'), + trayPauseHour: t('desktopMenuBar.tray_pause_hour'), + trayPauseTomorrow: t('desktopMenuBar.tray_pause_tomorrow'), + trayResume: t('desktopMenuBar.tray_resume'), + trayOpen: t('desktopMenuBar.tray_open'), + trayQuit: t('desktopMenuBar.tray_quit'), + trayExit: t('desktopMenuBar.tray_exit'), contextCut: t('desktopUi.menu.cut'), contextCopy: t('desktopUi.menu.copy'), contextPaste: t('desktopUi.menu.paste'), diff --git a/lib/desktop/menu-bar.ts b/lib/desktop/menu-bar.ts new file mode 100644 index 000000000..5ac75b267 --- /dev/null +++ b/lib/desktop/menu-bar.ts @@ -0,0 +1,79 @@ +/** + * The desktop app's menu bar on Windows and Linux, where a window with its controls over + * the page has no native bar: the page draws the app menu as a row at the top + * (components/layout/window-menu-bar.tsx). Alt shows it, or "Always show the menu bar" + * keeps it. The shell owns that setting; this keeps a copy for the first frame. + */ + +import { useEffect, useState } from 'react'; +import { getDesktopBridge } from './bridge'; +import { describeAccelerator } from './shortcut-keys'; + +import { MENU_BAR_STORAGE_KEY } from './boot'; + +export { MENU_BAR_ATTRIBUTE, MENU_BAR_STORAGE_KEY } from './boot'; + +/** A shell that lets the page draw the bar and keep it shown. Never on macOS: it has the system's. */ +export function menuBarAvailable(): boolean { + const bridge = getDesktopBridge(); + return !!bridge?.menu?.bar?.setPinned && bridge.platform !== 'darwin'; +} + +export function cachedMenuBarPinned(): boolean { + try { + return localStorage.getItem(MENU_BAR_STORAGE_KEY) === 'pinned'; + } catch { + return false; + } +} + +export function rememberMenuBarPinned(pinned: boolean): void { + try { + if (pinned) localStorage.setItem(MENU_BAR_STORAGE_KEY, 'pinned'); + else localStorage.removeItem(MENU_BAR_STORAGE_KEY); + } catch { + // Private storage off: the bar still follows the shell, a frame late. + } +} + +/** "Always show the menu bar" as the shell has it, and a way to change it in every window. */ +export function useMenuBarPinned(): [boolean, (pinned: boolean) => void] { + const [pinned, setPinned] = useState(false); + useEffect(() => { + setPinned(cachedMenuBarPinned()); + const bar = getDesktopBridge()?.menu?.bar; + if (!bar) return; + return bar.onEvent((event) => { + if (typeof event.pinned === 'boolean') setPinned(event.pinned); + }); + }, []); + const change = (next: boolean) => { + setPinned(next); + rememberMenuBarPinned(next); + getDesktopBridge()?.menu?.bar?.setPinned?.(next); + }; + return [pinned, change]; +} + +const KEY_NAMES: Record = { + plus: '+', + delete: 'Del', + escape: 'Esc', + left: '←', + right: '→', + up: '↑', + down: '↓', + space: 'Space', +}; + +/** An Electron accelerator the way a Windows or Linux menu shows it: `Ctrl+Shift+N`, `Alt+←`, `?`. */ +export function menuKeyText(accelerator: string, platform: string | undefined): string { + // A key that is itself "+", as in Ctrl++, ends in an empty part. + const parts = accelerator.split('+'); + const last = parts[parts.length - 1] === '' ? '+' : parts[parts.length - 1]; + const key = KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last); + const modifiers = parts.slice(0, parts[parts.length - 1] === '' ? -2 : -1); + if (modifiers.length === 0) return key; + const named = describeAccelerator(`${modifiers.join('+')}+X`, platform); + return `${named.slice(0, -1)}${key}`; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 69cbd18c0..256433cf9 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -95,9 +95,31 @@ export interface DesktopMenuBarEntry { } export interface DesktopMenuBarEvent { + /** The bar has the keys: Alt was pressed. With `pinned` it shows either way. */ visible: boolean; /** Open this menu right away: Alt plus its letter. */ open?: string; + /** "Always show the menu bar". */ + pinned?: boolean; +} + +/** One line of a menu the page draws as the menu bar's. */ +export interface DesktopMenuBarItem { + /** Its place in the app menu: handed back with its text when it's picked. */ + key: string; + label: string; + type: 'normal' | 'checkbox' | 'radio' | 'separator' | 'submenu'; + enabled: boolean; + checked?: boolean; + /** As Electron writes it: `CmdOrCtrl+N`. */ + accelerator?: string; + /** Where in `label` the letter for it is. */ + mnemonic?: number; + items?: DesktopMenuBarItem[]; +} + +export interface DesktopMenuBarMenu extends DesktopMenuBarEntry { + items: DesktopMenuBarItem[]; } /** What a page answers when asked to save before a window goes. */ @@ -124,6 +146,14 @@ export interface DesktopMenuBridge { /** Opens that menu natively, at page coordinates. */ open: (id: string, at: { x: number; y: number }) => void; onEvent: (listener: (event: DesktopMenuBarEvent) => void) => () => void; + /** Every menu, with what's enabled and ticked in this window now. An older shell has none. */ + menus?: () => Promise; + /** Runs a line of `menus`, by its key and text. */ + run?: (key: string, label: string) => void; + /** The room the bar takes now (CSS pixels, 0 when gone), and whether it has the keys. */ + report?: (report: { height: number; active: boolean }) => void; + /** "Always show the menu bar", in every window. */ + setPinned?: (pinned: boolean) => void; }; } diff --git a/locales/ar/common.json b/locales/ar/common.json index ed9cbecb0..b1b4b735d 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6810,6 +6810,18 @@ "close": "إغلاق" } }, + "desktopMenuBar": { + "always_show_label": "إظهار شريط القوائم دائمًا", + "always_show_description": "وإلا فسيظهر عند الضغط على Alt.", + "exit": "خروج", + "tray_pause": "إيقاف الإشعارات مؤقتًا", + "tray_pause_hour": "لمدة ساعة واحدة", + "tray_pause_tomorrow": "حتى الغد", + "tray_resume": "إعادة تشغيل الإشعارات", + "tray_open": "فتح Bulwark", + "tray_quit": "إنهاء Bulwark", + "tray_exit": "الخروج من Bulwark" + }, "appLock": { "section_title": "قفل التطبيق", "section_desc": "يطلب رمز PIN بعد فترة دون استخدام، في كل نافذة وعلامة تبويب. أثناء القفل يبقى البريد على هذا الجهاز مشفرًا ولا يملك التطبيق مفتاحه.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 5e3d4916d..dc08334c9 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6810,6 +6810,18 @@ "close": "Tanca" } }, + "desktopMenuBar": { + "always_show_label": "Mostra sempre la barra de menús", + "always_show_description": "Si no, apareix en prémer Alt.", + "exit": "Surt", + "tray_pause": "Posa en pausa les notificacions", + "tray_pause_hour": "Durant 1 hora", + "tray_pause_tomorrow": "Fins demà", + "tray_resume": "Torna a activar les notificacions", + "tray_open": "Obre Bulwark", + "tray_quit": "Surt de Bulwark", + "tray_exit": "Surt de Bulwark" + }, "appLock": { "section_title": "Bloqueig de l'app", "section_desc": "Demana un PIN després d'un temps sense ús, a cada finestra i pestanya. Mentre està bloquejada, el correu d'aquest dispositiu continua xifrat i l'app no en té la clau.", diff --git a/locales/cs/common.json b/locales/cs/common.json index ace3e4105..652817cea 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6810,6 +6810,18 @@ "close": "Zavřít" } }, + "desktopMenuBar": { + "always_show_label": "Vždy zobrazovat řádek nabídek", + "always_show_description": "Jinak se zobrazí po stisknutí klávesy Alt.", + "exit": "Konec", + "tray_pause": "Pozastavit oznámení", + "tray_pause_hour": "Na 1 hodinu", + "tray_pause_tomorrow": "Do zítřka", + "tray_resume": "Znovu zapnout oznámení", + "tray_open": "Otevřít Bulwark", + "tray_quit": "Ukončit Bulwark", + "tray_exit": "Ukončit Bulwark" + }, "appLock": { "section_title": "Zámek aplikace", "section_desc": "Po čase bez použití žádá o PIN, v každém okně a na každé kartě. Dokud je zamčeno, zůstává pošta na tomto zařízení zašifrovaná a aplikace k ní nemá klíč.", diff --git a/locales/da/common.json b/locales/da/common.json index b473c8379..991f6a822 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6810,6 +6810,18 @@ "close": "Luk" } }, + "desktopMenuBar": { + "always_show_label": "Vis altid menulinjen", + "always_show_description": "Ellers vises den, når du trykker på Alt.", + "exit": "Afslut", + "tray_pause": "Sæt notifikationer på pause", + "tray_pause_hour": "I 1 time", + "tray_pause_tomorrow": "Indtil i morgen", + "tray_resume": "Slå notifikationer til igen", + "tray_open": "Åbn Bulwark", + "tray_quit": "Afslut Bulwark", + "tray_exit": "Afslut Bulwark" + }, "appLock": { "section_title": "Applås", "section_desc": "Beder om en pinkode efter en tid uden brug, i alle vinduer og faner. Mens den er låst, forbliver mail på denne enhed krypteret, og appen har ingen nøgle til den.", diff --git a/locales/de/common.json b/locales/de/common.json index 9cb2097f7..e81bec01a 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6810,6 +6810,18 @@ "close": "Schließen" } }, + "desktopMenuBar": { + "always_show_label": "Menüleiste immer anzeigen", + "always_show_description": "Sonst erscheint sie, wenn Sie Alt drücken.", + "exit": "Beenden", + "tray_pause": "Benachrichtigungen pausieren", + "tray_pause_hour": "Für 1 Stunde", + "tray_pause_tomorrow": "Bis morgen", + "tray_resume": "Benachrichtigungen wieder einschalten", + "tray_open": "Bulwark öffnen", + "tray_quit": "Bulwark beenden", + "tray_exit": "Bulwark beenden" + }, "appLock": { "section_title": "App-Sperre", "section_desc": "Fragt nach einer Zeit ohne Nutzung in jedem Fenster und Tab nach einer PIN. Solange gesperrt ist, bleiben E-Mails auf diesem Gerät verschlüsselt und die App hat keinen Schlüssel dafür.", diff --git a/locales/en/common.json b/locales/en/common.json index 84f9389a7..3dd599069 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6810,6 +6810,18 @@ "close": "Close" } }, + "desktopMenuBar": { + "always_show_label": "Always show the menu bar", + "always_show_description": "Otherwise it shows when you press Alt.", + "exit": "Exit", + "tray_pause": "Pause notifications", + "tray_pause_hour": "For 1 hour", + "tray_pause_tomorrow": "Until tomorrow", + "tray_resume": "Turn notifications back on", + "tray_open": "Open Bulwark", + "tray_quit": "Quit Bulwark", + "tray_exit": "Exit Bulwark" + }, "appLock": { "section_title": "App lock", "section_desc": "Asks for a PIN after a time without use, in every window and tab. While it's locked, mail on this device stays encrypted and the app has no key for it.", diff --git a/locales/es/common.json b/locales/es/common.json index a44fce868..dfd992ca4 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6810,6 +6810,18 @@ "close": "Cerrar" } }, + "desktopMenuBar": { + "always_show_label": "Mostrar siempre la barra de menús", + "always_show_description": "Si no, aparece al pulsar Alt.", + "exit": "Salir", + "tray_pause": "Pausar notificaciones", + "tray_pause_hour": "Durante 1 hora", + "tray_pause_tomorrow": "Hasta mañana", + "tray_resume": "Volver a activar las notificaciones", + "tray_open": "Abrir Bulwark", + "tray_quit": "Salir de Bulwark", + "tray_exit": "Salir de Bulwark" + }, "appLock": { "section_title": "Bloqueo de la app", "section_desc": "Pide un PIN tras un tiempo sin uso, en cada ventana y pestaña. Mientras está bloqueada, el correo de este dispositivo sigue cifrado y la app no tiene la clave.", diff --git a/locales/et/common.json b/locales/et/common.json index ace51f3b5..bba893139 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6810,6 +6810,18 @@ "close": "Sulge" } }, + "desktopMenuBar": { + "always_show_label": "Näita menüüriba alati", + "always_show_description": "Muidu ilmub see Alt-klahvi vajutamisel.", + "exit": "Välju", + "tray_pause": "Peata teavitused", + "tray_pause_hour": "1 tunniks", + "tray_pause_tomorrow": "Homseni", + "tray_resume": "Lülita teavitused uuesti sisse", + "tray_open": "Ava Bulwark", + "tray_quit": "Sulge Bulwark", + "tray_exit": "Välju Bulwarkist" + }, "appLock": { "section_title": "Rakenduse lukk", "section_desc": "Küsib pärast kasutamata aega PIN-koodi, igas aknas ja vahekaardil. Lukus olles jääb selle seadme e-post krüpteerituks ja rakendusel pole selle võtit.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 9961aab01..9a10242b1 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6810,6 +6810,18 @@ "close": "بستن" } }, + "desktopMenuBar": { + "always_show_label": "همیشه نوار منو نمایش داده شود", + "always_show_description": "در غیر این صورت با فشردن Alt نمایش داده می‌شود.", + "exit": "خروج", + "tray_pause": "توقف موقت اعلان‌ها", + "tray_pause_hour": "به مدت ۱ ساعت", + "tray_pause_tomorrow": "تا فردا", + "tray_resume": "روشن کردن دوباره اعلان‌ها", + "tray_open": "باز کردن Bulwark", + "tray_quit": "بستن Bulwark", + "tray_exit": "خروج از Bulwark" + }, "appLock": { "section_title": "قفل برنامه", "section_desc": "پس از مدتی بی‌استفاده، در هر پنجره و زبانه پین می‌خواهد. تا وقتی قفل است، ایمیل‌های این دستگاه رمزگذاری‌شده می‌مانند و برنامه کلید آن‌ها را ندارد.", diff --git a/locales/fr/common.json b/locales/fr/common.json index 750e84417..14a9b0181 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6810,6 +6810,18 @@ "close": "Fermer" } }, + "desktopMenuBar": { + "always_show_label": "Toujours afficher la barre de menus", + "always_show_description": "Sinon, elle s’affiche quand vous appuyez sur Alt.", + "exit": "Quitter", + "tray_pause": "Suspendre les notifications", + "tray_pause_hour": "Pendant 1 heure", + "tray_pause_tomorrow": "Jusqu’à demain", + "tray_resume": "Réactiver les notifications", + "tray_open": "Ouvrir Bulwark", + "tray_quit": "Quitter Bulwark", + "tray_exit": "Quitter Bulwark" + }, "appLock": { "section_title": "Verrouillage de l'app", "section_desc": "Demande un code PIN après un temps sans utilisation, dans chaque fenêtre et onglet. Tant que l'app est verrouillée, les e-mails sur cet appareil restent chiffrés et l'app n'a pas de clé pour les lire.", diff --git a/locales/he/common.json b/locales/he/common.json index 23cb11fa8..11fefdefb 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6811,6 +6811,18 @@ "close": "סגירה" } }, + "desktopMenuBar": { + "always_show_label": "תמיד להציג את סרגל התפריטים", + "always_show_description": "אחרת הוא מופיע בלחיצה על Alt.", + "exit": "יציאה", + "tray_pause": "השהיית התראות", + "tray_pause_hour": "לשעה אחת", + "tray_pause_tomorrow": "עד מחר", + "tray_resume": "הפעלת ההתראות מחדש", + "tray_open": "פתיחת Bulwark", + "tray_quit": "סגירת Bulwark", + "tray_exit": "יציאה מ-Bulwark" + }, "appLock": { "section_title": "נעילת אפליקציה", "section_desc": "מבקשת קוד PIN אחרי זמן ללא שימוש, בכל חלון וכרטיסייה. בזמן הנעילה הדואר במכשיר הזה נשאר מוצפן ולאפליקציה אין מפתח אליו.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 172fa6df6..810d32d68 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6810,6 +6810,18 @@ "close": "Bezárás" } }, + "desktopMenuBar": { + "always_show_label": "Menüsor mindig látható", + "always_show_description": "Különben az Alt billentyű lenyomásakor jelenik meg.", + "exit": "Kilépés", + "tray_pause": "Értesítések szüneteltetése", + "tray_pause_hour": "1 órára", + "tray_pause_tomorrow": "Holnapig", + "tray_resume": "Értesítések visszakapcsolása", + "tray_open": "Bulwark megnyitása", + "tray_quit": "Kilépés a Bulwarkból", + "tray_exit": "Kilépés a Bulwarkból" + }, "appLock": { "section_title": "Alkalmazászár", "section_desc": "Használat nélküli idő után PIN-kódot kér, minden ablakban és lapon. Amíg zárolva van, az eszközön lévő levelek titkosítva maradnak, és az alkalmazásnak nincs hozzájuk kulcsa.", diff --git a/locales/it/common.json b/locales/it/common.json index 71f13cca0..9cbd379dd 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6810,6 +6810,18 @@ "close": "Chiudi" } }, + "desktopMenuBar": { + "always_show_label": "Mostra sempre la barra dei menu", + "always_show_description": "Altrimenti compare premendo Alt.", + "exit": "Esci", + "tray_pause": "Sospendi le notifiche", + "tray_pause_hour": "Per 1 ora", + "tray_pause_tomorrow": "Fino a domani", + "tray_resume": "Riattiva le notifiche", + "tray_open": "Apri Bulwark", + "tray_quit": "Esci da Bulwark", + "tray_exit": "Esci da Bulwark" + }, "appLock": { "section_title": "Blocco dell'app", "section_desc": "Chiede un PIN dopo un periodo senza utilizzo, in ogni finestra e scheda. Finché è bloccata, la posta su questo dispositivo resta cifrata e l'app non ha la chiave.", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7d382e843..b0a7b72f7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6810,6 +6810,18 @@ "close": "閉じる" } }, + "desktopMenuBar": { + "always_show_label": "メニューバーを常に表示", + "always_show_description": "オフにすると、Alt キーを押したときに表示されます。", + "exit": "終了", + "tray_pause": "通知を一時停止", + "tray_pause_hour": "1 時間", + "tray_pause_tomorrow": "明日まで", + "tray_resume": "通知を再開", + "tray_open": "Bulwark を開く", + "tray_quit": "Bulwark を終了", + "tray_exit": "Bulwark を終了" + }, "appLock": { "section_title": "アプリのロック", "section_desc": "一定時間使わないと、すべてのウィンドウとタブで PIN を求めます。ロック中、このデバイスのメールは暗号化されたままで、アプリには鍵がありません。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9f9643ad0..190384004 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6810,6 +6810,18 @@ "close": "닫기" } }, + "desktopMenuBar": { + "always_show_label": "메뉴 막대 항상 표시", + "always_show_description": "끄면 Alt 키를 누를 때 표시됩니다.", + "exit": "끝내기", + "tray_pause": "알림 일시 중지", + "tray_pause_hour": "1시간 동안", + "tray_pause_tomorrow": "내일까지", + "tray_resume": "알림 다시 켜기", + "tray_open": "Bulwark 열기", + "tray_quit": "Bulwark 종료", + "tray_exit": "Bulwark 종료" + }, "appLock": { "section_title": "앱 잠금", "section_desc": "일정 시간 사용하지 않으면 모든 창과 탭에서 PIN을 요청합니다. 잠겨 있는 동안 이 기기의 메일은 암호화된 상태로 유지되며 앱에는 키가 없습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index a4bc8ef83..ef012fb34 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6810,6 +6810,18 @@ "close": "Aizvērt" } }, + "desktopMenuBar": { + "always_show_label": "Vienmēr rādīt izvēļņu joslu", + "always_show_description": "Citādi tā parādās, nospiežot Alt.", + "exit": "Iziet", + "tray_pause": "Pauzēt paziņojumus", + "tray_pause_hour": "Uz 1 stundu", + "tray_pause_tomorrow": "Līdz rītdienai", + "tray_resume": "Atkal ieslēgt paziņojumus", + "tray_open": "Atvērt Bulwark", + "tray_quit": "Aizvērt Bulwark", + "tray_exit": "Iziet no Bulwark" + }, "appLock": { "section_title": "Lietotnes bloķēšana", "section_desc": "Pēc laika bez lietošanas prasa PIN kodu katrā logā un cilnē. Kamēr lietotne ir bloķēta, e-pasts šajā ierīcē paliek šifrēts, un lietotnei nav tā atslēgas.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 152574c4e..4fc549eb4 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6810,6 +6810,18 @@ "close": "Хаах" } }, + "desktopMenuBar": { + "always_show_label": "Цэсний мөрийг үргэлж харуулах", + "always_show_description": "Эс бөгөөс Alt товч дарахад гарч ирнэ.", + "exit": "Гарах", + "tray_pause": "Мэдэгдлийг түр зогсоох", + "tray_pause_hour": "1 цагийн турш", + "tray_pause_tomorrow": "Маргааш хүртэл", + "tray_resume": "Мэдэгдлийг дахин асаах", + "tray_open": "Bulwark-г нээх", + "tray_quit": "Bulwark-г хаах", + "tray_exit": "Bulwark-аас гарах" + }, "appLock": { "section_title": "Аппын түгжээ", "section_desc": "Хэсэг хугацаанд ашиглаагүй бол цонх, таб бүрт ПИН код асууна. Түгжээтэй үед энэ төхөөрөмжийн имэйл шифрлэгдсэн хэвээр байх бөгөөд аппад түлхүүр нь байхгүй.", diff --git a/locales/nb/common.json b/locales/nb/common.json index 9a6c14f76..7cbba575b 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6810,6 +6810,18 @@ "close": "Lukk" } }, + "desktopMenuBar": { + "always_show_label": "Vis alltid menylinjen", + "always_show_description": "Ellers vises den når du trykker på Alt.", + "exit": "Avslutt", + "tray_pause": "Sett varsler på pause", + "tray_pause_hour": "I 1 time", + "tray_pause_tomorrow": "Til i morgen", + "tray_resume": "Slå på varsler igjen", + "tray_open": "Åpne Bulwark", + "tray_quit": "Avslutt Bulwark", + "tray_exit": "Avslutt Bulwark" + }, "appLock": { "section_title": "Applås", "section_desc": "Ber om en PIN-kode etter en tid uten bruk, i alle vinduer og faner. Mens den er låst, forblir e-post på denne enheten kryptert, og appen har ingen nøkkel til den.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3c57a4007..3ee4342ce 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6810,6 +6810,18 @@ "close": "Sluit" } }, + "desktopMenuBar": { + "always_show_label": "Menubalk altijd tonen", + "always_show_description": "Anders verschijnt hij bij een druk op Alt.", + "exit": "Afsluiten", + "tray_pause": "Meldingen pauzeren", + "tray_pause_hour": "1 uur", + "tray_pause_tomorrow": "Tot morgen", + "tray_resume": "Meldingen weer aanzetten", + "tray_open": "Bulwark openen", + "tray_quit": "Bulwark afsluiten", + "tray_exit": "Bulwark afsluiten" + }, "appLock": { "section_title": "App-vergrendeling", "section_desc": "Vraagt na een tijd zonder gebruik om een pincode, in elk venster en tabblad. Zolang de app vergrendeld is, blijft de mail op dit apparaat versleuteld en heeft de app er geen sleutel voor.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 20bd079ae..1a18a86c0 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6810,6 +6810,18 @@ "close": "Zamknij" } }, + "desktopMenuBar": { + "always_show_label": "Zawsze pokazuj pasek menu", + "always_show_description": "W przeciwnym razie pojawia się po naciśnięciu Alt.", + "exit": "Zakończ", + "tray_pause": "Wstrzymaj powiadomienia", + "tray_pause_hour": "Na 1 godzinę", + "tray_pause_tomorrow": "Do jutra", + "tray_resume": "Włącz powiadomienia z powrotem", + "tray_open": "Otwórz Bulwark", + "tray_quit": "Zakończ Bulwark", + "tray_exit": "Zakończ Bulwark" + }, "appLock": { "section_title": "Blokada aplikacji", "section_desc": "Prosi o PIN po czasie bezczynności, w każdym oknie i na każdej karcie. Gdy aplikacja jest zablokowana, poczta na tym urządzeniu pozostaje zaszyfrowana, a aplikacja nie ma do niej klucza.", diff --git a/locales/pt/common.json b/locales/pt/common.json index 2452edf4a..88f508680 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6810,6 +6810,18 @@ "close": "Fechar" } }, + "desktopMenuBar": { + "always_show_label": "Mostrar sempre a barra de menus", + "always_show_description": "Caso contrário, aparece ao premir Alt.", + "exit": "Sair", + "tray_pause": "Pausar notificações", + "tray_pause_hour": "Durante 1 hora", + "tray_pause_tomorrow": "Até amanhã", + "tray_resume": "Voltar a ativar as notificações", + "tray_open": "Abrir o Bulwark", + "tray_quit": "Sair do Bulwark", + "tray_exit": "Sair do Bulwark" + }, "appLock": { "section_title": "Bloqueio da app", "section_desc": "Pede um PIN após um tempo sem uso, em todas as janelas e separadores. Enquanto está bloqueada, o correio neste dispositivo fica cifrado e a app não tem a chave.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 03bfef66a..3a5d1e993 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6810,6 +6810,18 @@ "close": "Închidere" } }, + "desktopMenuBar": { + "always_show_label": "Afișează mereu bara de meniu", + "always_show_description": "Altfel apare la apăsarea tastei Alt.", + "exit": "Ieșire", + "tray_pause": "Întrerupe notificările", + "tray_pause_hour": "Pentru 1 oră", + "tray_pause_tomorrow": "Până mâine", + "tray_resume": "Reactivează notificările", + "tray_open": "Deschide Bulwark", + "tray_quit": "Închide Bulwark", + "tray_exit": "Ieșire din Bulwark" + }, "appLock": { "section_title": "Blocarea aplicației", "section_desc": "Cere un PIN după un timp fără utilizare, în fiecare fereastră și filă. Cât timp e blocată, e-mailurile de pe acest dispozitiv rămân criptate și aplicația nu are cheia lor.", diff --git a/locales/ru/common.json b/locales/ru/common.json index ebb25a96f..0353ef0eb 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6810,6 +6810,18 @@ "close": "Закрыть" } }, + "desktopMenuBar": { + "always_show_label": "Всегда показывать строку меню", + "always_show_description": "Иначе она появляется при нажатии Alt.", + "exit": "Выход", + "tray_pause": "Приостановить уведомления", + "tray_pause_hour": "На 1 час", + "tray_pause_tomorrow": "До завтра", + "tray_resume": "Снова включить уведомления", + "tray_open": "Открыть Bulwark", + "tray_quit": "Закрыть Bulwark", + "tray_exit": "Выйти из Bulwark" + }, "appLock": { "section_title": "Блокировка приложения", "section_desc": "Запрашивает PIN-код после периода бездействия, в каждом окне и вкладке. Пока приложение заблокировано, почта на этом устройстве остаётся зашифрованной, и у приложения нет ключа к ней.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 43343c99e..f581b250b 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6810,6 +6810,18 @@ "close": "Zavrieť" } }, + "desktopMenuBar": { + "always_show_label": "Vždy zobrazovať panel ponúk", + "always_show_description": "Inak sa zobrazí po stlačení klávesu Alt.", + "exit": "Koniec", + "tray_pause": "Pozastaviť upozornenia", + "tray_pause_hour": "Na 1 hodinu", + "tray_pause_tomorrow": "Do zajtra", + "tray_resume": "Znova zapnúť upozornenia", + "tray_open": "Otvoriť Bulwark", + "tray_quit": "Ukončiť Bulwark", + "tray_exit": "Ukončiť Bulwark" + }, "appLock": { "section_title": "Zámok aplikácie", "section_desc": "Po čase bez používania žiada o PIN, v každom okne a na každej karte. Kým je zamknuté, pošta na tomto zariadení zostáva šifrovaná a aplikácia k nej nemá kľúč.", diff --git a/locales/th/common.json b/locales/th/common.json index d4aef2af2..099e9a1a4 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6818,6 +6818,18 @@ "close": "ปิด" } }, + "desktopMenuBar": { + "always_show_label": "แสดงแถบเมนูเสมอ", + "always_show_description": "มิฉะนั้นแถบเมนูจะแสดงเมื่อกด Alt", + "exit": "ออก", + "tray_pause": "หยุดการแจ้งเตือนชั่วคราว", + "tray_pause_hour": "1 ชั่วโมง", + "tray_pause_tomorrow": "จนถึงพรุ่งนี้", + "tray_resume": "เปิดการแจ้งเตือนอีกครั้ง", + "tray_open": "เปิด Bulwark", + "tray_quit": "ปิด Bulwark", + "tray_exit": "ออกจาก Bulwark" + }, "appLock": { "section_title": "ล็อกแอป", "section_desc": "ขอ PIN หลังจากไม่ได้ใช้งานระยะหนึ่ง ในทุกหน้าต่างและแท็บ ระหว่างล็อก อีเมลบนอุปกรณ์นี้ยังคงเข้ารหัสอยู่และแอปไม่มีกุญแจ", diff --git a/locales/tr/common.json b/locales/tr/common.json index 03393f7f0..8796602fd 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6810,6 +6810,18 @@ "close": "Kapat" } }, + "desktopMenuBar": { + "always_show_label": "Menü çubuğunu her zaman göster", + "always_show_description": "Aksi halde Alt tuşuna basınca görünür.", + "exit": "Çıkış", + "tray_pause": "Bildirimleri duraklat", + "tray_pause_hour": "1 saat", + "tray_pause_tomorrow": "Yarına kadar", + "tray_resume": "Bildirimleri yeniden aç", + "tray_open": "Bulwark'ı aç", + "tray_quit": "Bulwark'tan çık", + "tray_exit": "Bulwark'tan çık" + }, "appLock": { "section_title": "Uygulama kilidi", "section_desc": "Bir süre kullanılmadığında her pencerede ve sekmede PIN ister. Kilitliyken bu cihazdaki e-postalar şifreli kalır ve uygulamada anahtarı olmaz.", diff --git a/locales/uk/common.json b/locales/uk/common.json index f033cd1ee..cb17dc5d4 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6810,6 +6810,18 @@ "close": "Закрити" } }, + "desktopMenuBar": { + "always_show_label": "Завжди показувати рядок меню", + "always_show_description": "Інакше він з’являється після натискання Alt.", + "exit": "Вихід", + "tray_pause": "Призупинити сповіщення", + "tray_pause_hour": "На 1 годину", + "tray_pause_tomorrow": "До завтра", + "tray_resume": "Знову ввімкнути сповіщення", + "tray_open": "Відкрити Bulwark", + "tray_quit": "Закрити Bulwark", + "tray_exit": "Вийти з Bulwark" + }, "appLock": { "section_title": "Блокування застосунку", "section_desc": "Запитує PIN-код після часу без використання, у кожному вікні й вкладці. Поки застосунок заблоковано, пошта на цьому пристрої залишається зашифрованою, і застосунок не має ключа до неї.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 9faa09bde..775a8adfe 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6822,6 +6822,18 @@ "close": "Đóng" } }, + "desktopMenuBar": { + "always_show_label": "Luôn hiện thanh menu", + "always_show_description": "Nếu tắt, thanh menu hiện khi bạn nhấn Alt.", + "exit": "Thoát", + "tray_pause": "Tạm dừng thông báo", + "tray_pause_hour": "Trong 1 giờ", + "tray_pause_tomorrow": "Đến ngày mai", + "tray_resume": "Bật lại thông báo", + "tray_open": "Mở Bulwark", + "tray_quit": "Thoát Bulwark", + "tray_exit": "Thoát Bulwark" + }, "appLock": { "section_title": "Khóa ứng dụng", "section_desc": "Yêu cầu mã PIN sau một thời gian không dùng, trong mọi cửa sổ và thẻ. Khi đang khóa, thư trên thiết bị này vẫn được mã hóa và ứng dụng không có khóa của chúng.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index eed67bf21..335956906 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6810,6 +6810,18 @@ "close": "關閉" } }, + "desktopMenuBar": { + "always_show_label": "永遠顯示選單列", + "always_show_description": "否則按 Alt 鍵時顯示。", + "exit": "結束", + "tray_pause": "暫停通知", + "tray_pause_hour": "1 小時", + "tray_pause_tomorrow": "直到明天", + "tray_resume": "重新開啟通知", + "tray_open": "開啟 Bulwark", + "tray_quit": "結束 Bulwark", + "tray_exit": "結束 Bulwark" + }, "appLock": { "section_title": "應用程式鎖定", "section_desc": "一段時間未使用後,在每個視窗和分頁中要求輸入 PIN。鎖定期間,此裝置上的郵件保持加密,應用程式沒有金鑰。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0edeb8c81..081104f87 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6810,6 +6810,18 @@ "close": "关闭" } }, + "desktopMenuBar": { + "always_show_label": "始终显示菜单栏", + "always_show_description": "否则按 Alt 键时显示。", + "exit": "退出", + "tray_pause": "暂停通知", + "tray_pause_hour": "1 小时", + "tray_pause_tomorrow": "直到明天", + "tray_resume": "重新开启通知", + "tray_open": "打开 Bulwark", + "tray_quit": "退出 Bulwark", + "tray_exit": "退出 Bulwark" + }, "appLock": { "section_title": "应用锁", "section_desc": "一段时间未使用后,在每个窗口和标签页中要求输入 PIN。锁定期间,此设备上的邮件保持加密,应用没有密钥。", From 674845df4e9c3951f5f94d586676f6496e136f0e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:20:02 +0100 Subject: [PATCH 08/46] fix: keep the desktop menus and tray in the language the app shows --- .../desktop/__tests__/desktop-host.test.tsx | 24 +++++++++++++++++++ components/desktop/desktop-host.tsx | 13 ++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx index 61f8a3b8d..2d757cbf2 100644 --- a/components/desktop/__tests__/desktop-host.test.tsx +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -14,6 +14,14 @@ import { offerReturnedComposer, takeReturnedComposer } from '@/lib/popout/return const push = vi.fn(); vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) })); +// The catalog on screen: its language, and text that says which one it came from. +const intl = vi.hoisted(() => ({ locale: 'en', prefix: '', messages: {} as object })); +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => `${intl.prefix}${key}`, + useLocale: () => intl.locale, + useMessages: () => intl.messages, +})); + const canPrint = vi.fn(() => true); const canDownload = vi.fn(() => true); vi.mock('@/lib/device-policy', () => ({ canPrintAnywhere: () => canPrint(), canDownloadAnywhere: () => canDownload() })); @@ -68,6 +76,7 @@ function fakeBridge() { const original = useAuthStore.getState(); beforeEach(() => { + Object.assign(intl, { locale: 'en', prefix: '', messages: {} }); push.mockClear(); resolveExternalLink.mockClear(); resolveExternalLink.mockImplementation(async (ctx) => ctx.href); @@ -170,11 +179,26 @@ describe('DesktopHost', () => { it('says which languages are in use', async () => { const shell = fakeBridge(); act(() => useLocaleStore.setState({ locale: 'de' })); + intl.locale = 'de'; render(); await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled()); expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' }); }); + it('follows the text on screen, not only the stored choice', async () => { + const shell = fakeBridge(); + // English chosen, but the first render still has the catalog the server picked from the OS. + act(() => useLocaleStore.setState({ locale: 'en' })); + Object.assign(intl, { locale: 'de', prefix: 'de:', messages: { de: true } }); + const view = render(); + await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled()); + // The chosen catalog lands a render later. + Object.assign(intl, { locale: 'en', prefix: 'en:', messages: { en: true } }); + view.rerender(); + await waitFor(() => expect(shell.setLanguages.mock.lastCall![0].labels.menuFile).toBe('en:desktopUi.menu.file')); + expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'en' }); + }); + describe('Open Link in a native menu', () => { const openLink = (url: string) => `link.open:${JSON.stringify({ url, text: 'click' })}`; diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index 909ca657b..b80396b80 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useRef } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useMessages, useTranslations } from "next-intl"; import { useStrictestDevices } from "@/hooks/use-device-policy"; import { useRouter } from "@/i18n/navigation"; import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands"; @@ -45,6 +45,8 @@ export function DesktopHost() { const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts); const writingLanguage = useSettingsStore((s) => s.writingLanguage); const uiLocale = useLocaleStore((s) => s.locale); + const activeLocale = useLocale(); + const messages = useMessages(); const strictest = useStrictestDevices(); const { restoreWindows } = useDesktopPrefs(); const theme = useThemeStore((s) => s.theme); @@ -116,11 +118,14 @@ export function DesktopHost() { }); }, [isAuthenticated, singleKeys, strictest]); - // Menus and spellcheck follow the interface. + // Menus, the tray and spellcheck follow the interface: the language its text is in now, + // with that text. The stored choice isn't it: on the first render the catalog can still + // be the one the server picked from the OS language, and the chosen one lands a render + // later. Going by the choice alone left the menus in the OS language for good. useEffect(() => { - const ui = uiLocale || document.documentElement.lang; + const ui = activeLocale || uiLocale || document.documentElement.lang; if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as Parameters[0])); - }, [uiLocale]); + }, [activeLocale, uiLocale, messages]); // What the user writes in, whichever composer asks for it too. useEffect(() => { From ad970b35ef468cdedcdacf63958f7580cae75afd Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:20:03 +0100 Subject: [PATCH 09/46] =?UTF-8?q?feat:=20move=20a=20message=20from=20its?= =?UTF-8?q?=20own=20window=20with=20Message=20>=20Move=20to=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/pro-email-edit-draft.test.tsx | 38 ++++++++++++++++++- components/pro/pro-email-tab-body.tsx | 14 +++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/components/pro/__tests__/pro-email-edit-draft.test.tsx b/components/pro/__tests__/pro-email-edit-draft.test.tsx index 9c453ee6f..0edefdb22 100644 --- a/components/pro/__tests__/pro-email-edit-draft.test.tsx +++ b/components/pro/__tests__/pro-email-edit-draft.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { act, render, waitFor } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import React from 'react'; import { ProTabFocusContext } from '@/hooks/use-pane-context'; @@ -26,6 +26,20 @@ vi.mock('@/components/error', () => ({ })); vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key })); + +// A message window: the Message menu's Move to… opens the folder list there. +const popout = vi.hoisted(() => ({ value: false })); +vi.mock('@/lib/popout/window-role', async (importOriginal) => ({ + ...await importOriginal(), + isPopoutWindow: () => popout.value, +})); +const moveDialog = vi.hoisted(() => ({ current: null as { email: unknown } | null })); +vi.mock('@/components/email/move-to-dialog', () => ({ + MoveToDialog: (props: { email: unknown }) => { + moveDialog.current = props; + return null; + }, +})); vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } })); const multiAccount = vi.hoisted(() => ({ value: { enabled: false, groups: [] as unknown[], allIdentities: [] as Array<{ id: string; email: string; name: string }> }, @@ -79,6 +93,7 @@ vi.mock('@/stores/pro-tab-store', () => ({ })); import { ProEmailView } from '../pro-email-tab-body'; +import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands'; const DRAFT = { id: 'eaaaaab', @@ -307,3 +322,24 @@ describe('Pro email view - keyboard', () => { expect(openComposeTab).not.toHaveBeenCalled(); }); }); + +describe('Message > Move to… in a message window', () => { + it('opens the folder list for the message on screen', async () => { + popout.value = true; + window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: vi.fn() } } as never; + try { + const client = { getEmail: vi.fn().mockResolvedValue(DRAFT), getUsername: () => 'userb@example.org', getAccountId: () => 'e' }; + render(); + await waitFor(() => expect(viewerProps.current?.email).toBeTruthy()); + expect(moveDialog.current?.email ?? null).toBeNull(); + act(() => { + expect(runDesktopCommand('message.move')).toBe(true); + }); + await waitFor(() => expect(moveDialog.current?.email).toMatchObject({ id: 'eaaaaab' })); + } finally { + popout.value = false; + window.bulwarkDesktop = undefined; + resetDesktopCommands(); + } + }); +}); diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx index 569b13b0a..8e854bfcc 100644 --- a/components/pro/pro-email-tab-body.tsx +++ b/components/pro/pro-email-tab-body.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState, useMemo, useRef } from "react"; import { useTranslations } from "next-intl"; import { EmailViewer } from "@/components/email/email-viewer"; +import { MoveToDialog } from "@/components/email/move-to-dialog"; import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store"; @@ -472,6 +473,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, // In a pop-out this message is all there is, so the menu bar's Message menu means it. const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow(); const seen = !!email?.keywords?.$seen; + // Message > Move to… in a pop-out: the same folder list the command palette opens. + const [moveDialogOpen, setMoveDialogOpen] = useState(false); useDesktopCommands({ 'message.reply': () => handleReply(), 'message.replyAll': handleReplyAll, @@ -481,6 +484,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, 'message.star': () => void handleToggleStar(), 'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined, 'message.delete': () => void handleDelete(), + 'message.move': () => { if (email) setMoveDialogOpen(true); }, }, { 'message.reply': !!email, 'message.replyAll': !!email, @@ -490,6 +494,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, 'message.star': !!email, 'message.archive': !!email && archiveActionEnabled, 'message.delete': !!email, + 'message.move': !!email, }, inOwnWindow); const handleMoveToMailbox = useCallback(async (mailboxId: string) => { @@ -642,6 +647,15 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId, /> + {inOwnWindow && ( + setMoveDialogOpen(false)} + onMove={(mailboxId) => void handleMoveToMailbox(mailboxId)} + /> + )} {preview && ( Date: Wed, 4 Nov 2026 05:20:14 +0100 Subject: [PATCH 10/46] fix: show the message's own folders in Move to, in mail-client order, with recents and a filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Move lists (message menu, reader Move button and its phone panel, selection toolbar, Move dialog) now share one model in lib/move-menu.ts. In the desktop app the native copy of the menu lost the shared accounts' headers and every folder's nesting, so Inbox, Sent, Spam and Trash showed once per account with nothing to tell them apart. - Only the message's own account at the top; other accounts a move can reach are submenus under their names. - System folders first (Inbox, Sent, Archive, Spam, Trash), then the user's folders by name; subfolders nest, as submenus natively. - The open folder shows greyed instead of missing. - A Recent group, a filter field on long lists, and "Choose folder…" for the dialog, which has the filter too. Tests changed: the open folder is now shown disabled rather than left out, the own account of a shared-mailbox message is a submenu, and the selection toolbar's filter field has its own label. --- .../email-context-menu-copy.test.tsx | 6 +- ...-context-menu-move-to-open-folder.test.tsx | 7 +- .../email/__tests__/move-menu-native.test.tsx | 88 ++++++ .../email/__tests__/move-target-list.test.tsx | 107 +++++-- .../email/__tests__/move-to-dialog.test.tsx | 46 +++ .../email/__tests__/mv-list-toolbar.test.tsx | 5 +- .../email/conversation-header-actions.tsx | 24 +- components/email/email-context-menu.tsx | 116 +++---- components/email/email-viewer.tsx | 66 ++-- components/email/move-target-list.tsx | 299 +++++++++++++++--- components/email/move-to-dialog.tsx | 93 ++++-- components/email/mv-list-toolbar.tsx | 87 +---- components/mail/mail-app.tsx | 5 +- lib/__tests__/move-menu.test.ts | 106 +++++++ lib/desktop/native-menu.ts | 13 + lib/move-menu.ts | 253 +++++++++++++++ lib/move-recent.ts | 40 +++ locales/ar/common.json | 7 + locales/ca/common.json | 7 + locales/cs/common.json | 7 + locales/da/common.json | 7 + locales/de/common.json | 7 + locales/en/common.json | 7 + locales/es/common.json | 7 + locales/et/common.json | 7 + locales/fa/common.json | 7 + locales/fr/common.json | 7 + locales/he/common.json | 7 + locales/hu/common.json | 7 + locales/it/common.json | 7 + locales/ja/common.json | 7 + locales/ko/common.json | 7 + locales/lv/common.json | 7 + locales/mn/common.json | 7 + locales/nb/common.json | 7 + locales/nl/common.json | 7 + locales/pl/common.json | 7 + locales/pt/common.json | 7 + locales/ro/common.json | 7 + locales/ru/common.json | 7 + locales/sk/common.json | 7 + locales/th/common.json | 7 + locales/tr/common.json | 7 + locales/uk/common.json | 7 + locales/vi/common.json | 7 + locales/zh-TW/common.json | 7 + locales/zh/common.json | 7 + 47 files changed, 1296 insertions(+), 275 deletions(-) create mode 100644 components/email/__tests__/move-menu-native.test.tsx create mode 100644 components/email/__tests__/move-to-dialog.test.tsx create mode 100644 lib/__tests__/move-menu.test.ts create mode 100644 lib/move-menu.ts create mode 100644 lib/move-recent.ts diff --git a/components/email/__tests__/email-context-menu-copy.test.tsx b/components/email/__tests__/email-context-menu-copy.test.tsx index f888a4cb8..61411ed2e 100644 --- a/components/email/__tests__/email-context-menu-copy.test.tsx +++ b/components/email/__tests__/email-context-menu-copy.test.tsx @@ -89,8 +89,12 @@ describe('Copy to entry of the message menu', () => { }); fireEvent.mouseEnter(screen.getByTestId('ctx-move-to').parentElement!); + // The shared account's own folders at the top, the open one greyed; the + // login's own folders one level down, under its name. const items = screen.getAllByTestId(/^move-to:/).map((el) => el.getAttribute('data-testid')); - expect(items).toEqual(['move-to:S:junk', 'move-to:inbox', 'move-to:junk']); + expect(items).toEqual(['move-to:S:inbox', 'move-to:S:junk']); + expect(screen.getByTestId('move-to:S:inbox')).toBeDisabled(); + expect(screen.getByTestId('move-account:A')).toHaveTextContent('me@example.org'); fireEvent.click(screen.getByTestId('move-to:S:junk')); expect(onMoveToMailbox).toHaveBeenCalledWith('S:junk'); diff --git a/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx b/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx index 932a82b11..51de81f15 100644 --- a/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx +++ b/components/email/__tests__/email-context-menu-move-to-open-folder.test.tsx @@ -66,10 +66,11 @@ describe('Move to the open folder from search results', () => { expect(onMoveToMailbox).toHaveBeenCalledWith('inbox'); }); - it('does not offer the open folder for a message already in it', () => { + it('shows the open folder greyed for a message already in it', () => { openMoveMenu({ email: inInbox }); - expect(screen.queryByTestId('move-to:inbox')).toBeNull(); - expect(screen.getByTestId('move-to:work')).toBeTruthy(); + // Shown, as in the sidebar, but it can't be picked. + expect(screen.getByTestId('move-to:inbox')).toBeDisabled(); + expect(screen.getByTestId('move-to:work')).not.toBeDisabled(); }); it('offers it for a selection when one of the messages is elsewhere', () => { diff --git a/components/email/__tests__/move-menu-native.test.tsx b/components/email/__tests__/move-menu-native.test.tsx new file mode 100644 index 000000000..145de43d0 --- /dev/null +++ b/components/email/__tests__/move-menu-native.test.tsx @@ -0,0 +1,88 @@ +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__/move-target-list.test.tsx b/components/email/__tests__/move-target-list.test.tsx index a85de34bc..149615116 100644 --- a/components/email/__tests__/move-target-list.test.tsx +++ b/components/email/__tests__/move-target-list.test.tsx @@ -1,25 +1,41 @@ import { fireEvent, render, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; -import type { MailboxNode } from '@/lib/utils'; -import { MoveTargetList } from '../move-target-list'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; +import { buildMoveMenu } from '@/lib/move-menu'; +import { readRecentMoveTargets } from '@/lib/move-recent'; +import { MoveFolderList } from '../move-target-list'; -// The reader's Move menus show the names the sidebar shows (Stalwart's -// "Sent Items" / "Junk Mail" / "Deleted Items" are Sent, Spam and Trash, in -// the user's language), and indent subfolders on the reading side. +// The Move lists show the names the sidebar shows (Stalwart's "Sent Items" / +// "Junk Mail" are Sent and Spam, in the user's language), nest subfolders, +// and get a filter field once they're long. -const node = (id: string, name: string, role: string | undefined, children: MailboxNode[] = []) => - ({ id, name, role, children, depth: 0 }) as unknown as MailboxNode; +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 tree = [ - node('sent', 'Sent Items', 'sent'), - node('junk', 'Junk Mail', 'junk'), - node('archive', 'Archive', 'archive', [node('y2025', '2025', undefined)]), +const short = [ + box('inbox', 'Inbox', { role: 'inbox' }), + box('sent', 'Sent Items', { role: 'sent' }), + box('junk', 'Junk Mail', { role: 'junk' }), + box('archive', 'Archive', { role: 'archive' }), + box('y2025', '2025', { parentId: 'archive' }), ]; -describe('MoveTargetList', () => { +const long = [ + ...short, + ...Array.from({ length: 12 }, (_, i) => box(`f${i}`, `Folder ${i}`)), + box('acme', 'Acme', { parentId: 'f3' }), +]; + +afterEach(() => localStorage.clear()); + +describe('MoveFolderList', () => { it('shows the localized folder names and moves on a pick', () => { const onPick = vi.fn(); - render(); + render(); // The i18n mock returns the key: the role's name, not the server's. expect(screen.queryByText('Sent Items')).toBeNull(); expect(screen.getByText('mailboxes.sent')).toBeTruthy(); @@ -27,10 +43,63 @@ describe('MoveTargetList', () => { expect(onPick).toHaveBeenCalledWith('junk'); }); - it('indents subfolders with a logical padding', () => { - render(); - const row = screen.getByText('2025').closest('button')!; - expect(row.style.paddingInlineStart).toBe('1.75rem'); - expect(row.style.paddingLeft).toBe(''); + it('greys the open folder and keeps the pick as a recent folder', () => { + const onPick = vi.fn(); + render(); + expect(screen.getByTestId('move-to:inbox')).toBeDisabled(); + fireEvent.click(screen.getByTestId('move-to:inbox')); + expect(onPick).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByTestId('move-to:y2025')); + expect(readRecentMoveTargets('A')).toEqual(['y2025']); + }); + + it('puts recent folders at the top, with where they live', () => { + render( + ['y2025'] })} + onPick={vi.fn()} + />, + ); + expect(screen.getByText('recent')).toBeTruthy(); + expect(screen.getByText('Archive / 2025')).toBeTruthy(); + }); + + it('nests subfolders in a group the desktop app makes a submenu of', () => { + render(); + const group = screen.getByTestId('move-to:archive').closest('[data-native-submenu]')!; + expect(group).not.toBeNull(); + expect(group.contains(screen.getByTestId('move-to:y2025'))).toBe(true); + }); + + it('has no filter while short', () => { + render(); + expect(screen.queryByTestId('move-filter')).toBeNull(); + expect(screen.queryByTestId('move-choose-folder')).toBeNull(); + }); + + it('a long list gets a filter that keeps the folders above a match, and Enter moves to the first', () => { + const onPick = vi.fn(); + const onChooseFolder = vi.fn(); + render(); + fireEvent.click(screen.getByTestId('move-choose-folder')); + expect(onChooseFolder).toHaveBeenCalled(); + + const filter = screen.getByTestId('move-filter'); + fireEvent.change(filter, { target: { value: 'acm' } }); + expect(screen.getByTestId('move-to:f3')).toBeTruthy(); + expect(screen.getByTestId('move-to:acme')).toBeTruthy(); + expect(screen.queryByTestId('move-to:f4')).toBeNull(); + fireEvent.keyDown(filter, { key: 'Enter' }); + expect(onPick).toHaveBeenCalledWith('f3'); + + fireEvent.change(filter, { target: { value: 'nothing like it' } }); + expect(screen.getByText('no_match')).toBeTruthy(); + }); + + it('indents subfolders on the reading side in the phone panel', () => { + render(); + const row = screen.getByTestId('move-to:y2025'); + expect(row.parentElement!.className).toContain('ps-6'); }); }); diff --git a/components/email/__tests__/move-to-dialog.test.tsx b/components/email/__tests__/move-to-dialog.test.tsx new file mode 100644 index 000000000..571e1d07d --- /dev/null +++ b/components/email/__tests__/move-to-dialog.test.tsx @@ -0,0 +1,46 @@ +import { act, fireEvent, render, renderHook, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { MoveToDialogHost, useChooseMoveFolder, useMoveDialogStore } from '../move-to-dialog'; + +// "Choose folder…" at the end of a long Move menu opens this dialog: a native +// menu has no filter field, the dialog has one. + +const box = (id: string, name: string, extra: Partial = {}) => + ({ + id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: { mayAddItems: true }, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra, + }) as Mailbox; + +const mailboxes = [box('inbox', 'Inbox', { role: 'inbox' }), box('work', 'Work'), box('travel', 'Travel')]; +const email = { id: 'e1', mailboxIds: { inbox: true } } as unknown as Email; + +afterEach(() => { + act(() => useMoveDialogStore.setState({ request: null })); +}); + +describe('the Move dialog', () => { + it('is only offered where a host can show it', () => { + const without = renderHook(() => useChooseMoveFolder()); + expect(without.result.current).toBeUndefined(); + without.unmount(); + + render(); + const withHost = renderHook(() => useChooseMoveFolder()); + expect(withHost.result.current).toBeTypeOf('function'); + }); + + it('shows the folders with a filter, and moves to the one picked', () => { + render(); + const onMove = vi.fn(); + act(() => useMoveDialogStore.getState().open({ emails: [email], onMove })); + + expect(screen.getByTestId('move-to:inbox')).toBeDisabled(); + fireEvent.change(screen.getByTestId('move-filter'), { target: { value: 'tra' } }); + expect(screen.queryByTestId('move-to:work')).toBeNull(); + fireEvent.click(screen.getByTestId('move-to:travel')); + + expect(onMove).toHaveBeenCalledWith('travel'); + expect(useMoveDialogStore.getState().request).toBeNull(); + }); +}); diff --git a/components/email/__tests__/mv-list-toolbar.test.tsx b/components/email/__tests__/mv-list-toolbar.test.tsx index 082bb667b..96862dbfc 100644 --- a/components/email/__tests__/mv-list-toolbar.test.tsx +++ b/components/email/__tests__/mv-list-toolbar.test.tsx @@ -67,7 +67,8 @@ describe('MvListToolbar filing actions', () => { toolbar(); fireEvent.click(screen.getByLabelText('move_to')); - expect(screen.queryByText('mailboxes.inbox')).toBeNull(); + // Shown greyed, where the sidebar has it. + expect(screen.getByText('mailboxes.inbox').closest('button')).toBeDisabled(); }); it('puts a tag on the whole selection when only some of it carries the tag', async () => { @@ -105,7 +106,7 @@ describe('MvListToolbar filing actions', () => { fireEvent.click(screen.getByRole('button', { name: 'move_to' })); expect(screen.getByText('Travel')).toBeInTheDocument(); - fireEvent.change(screen.getByRole('textbox', { name: 'move_to' }), { target: { value: 'rec' } }); + fireEvent.change(screen.getByRole('textbox', { name: 'filter_placeholder' }), { target: { value: 'rec' } }); expect(screen.getByText('Receipts')).toBeInTheDocument(); expect(screen.queryByText('Travel')).toBeNull(); diff --git a/components/email/conversation-header-actions.tsx b/components/email/conversation-header-actions.tsx index daccc86de..fd1e6a735 100644 --- a/components/email/conversation-header-actions.tsx +++ b/components/email/conversation-header-actions.tsx @@ -8,12 +8,13 @@ import { ContextMenu } from "@/components/ui/context-menu"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useCopyToAccount, useEmailExport } from "@/hooks/use-message-file-actions"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { resolveEmailBlobContext } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; import { Archive, FolderInput, MoreVertical, Trash2 } from "@/components/icons"; import { EmailContextMenu } from "./email-context-menu"; -import { MoveTargetList } from "./move-target-list"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; +import { useChooseMoveFolder } from "./move-to-dialog"; import { ViewSourceDialog } from "./view-source-dialog"; import { canExport } from "@/lib/device-policy"; import type { MessageActionKey } from "./message-actions"; @@ -56,11 +57,10 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac const { mailboxes, selectedMailbox, onArchive, onDelete, onMoveToMailbox } = actions; const ownerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox); - const { tree: moveTree, targetIds: moveTargetIds } = useMemo( - () => buildMoveTargets(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId, emails: [email] }), - [mailboxes, selectedMailbox, ownerAccountId, email], - ); - const canMove = !!onMoveToMailbox && moveTree.length > 0; + const moveEmails = useMemo(() => [email], [email]); + const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId, emails: moveEmails }); + const canMove = !!onMoveToMailbox && !moveMenuIsEmpty(moveModel); + const chooseMoveFolder = useChooseMoveFolder(); const exportFollowUp = { onArchive, onDelete, archiveEnabled }; const source = resolveEmailBlobContext(email, client); @@ -107,11 +107,13 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac onClose={moveMenu.closeContextMenu} label={t("email_viewer.move_to")} > -
- + { moveMenu.closeContextMenu(); onMoveToMailbox!(mailboxId); }} + onChooseFolder={chooseMoveFolder + ? () => { moveMenu.closeContextMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox! }); } + : undefined} />
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx index a27531ead..45d6d96de 100644 --- a/components/email/email-context-menu.tsx +++ b/components/email/email-context-menu.tsx @@ -1,7 +1,7 @@ "use client"; import { useMemo } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useSettingsStore } from "@/stores/settings-store"; import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store"; import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags"; @@ -30,10 +30,10 @@ import { Fish, } from "@/components/icons"; import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions"; -import { buildMailboxTree, MailboxNode } from "@/lib/utils"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; -import { localizeMailboxName } from "@/lib/mailbox-label"; -import { moveMailboxIcon } from "./move-target-list"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; +import { useChooseMoveFolder } from "./move-to-dialog"; import { getEmailTagIds } from "@/lib/thread-utils"; import { TagPicker } from "./tag-picker"; import { RulesContextSubMenu } from "./rules-menu"; @@ -200,7 +200,6 @@ export function EmailContextMenu({ const t = useTranslations("context_menu"); const archiveActionEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); - const tSidebar = useTranslations("sidebar"); const isUnread = !email.keywords?.$seen; const currentTagIds = getEmailTagIds(email.keywords); // The row's account names its tags; a selection uses batchTagScope. @@ -221,68 +220,49 @@ export function EmailContextMenu({ ? !!onBatchRemoveFromFolder && !!batchEmails?.length && batchEmails.every((e) => canRemoveFromFolder(e, folderToLeave)) : !!onRemoveFromFolder && canRemoveFromFolder(email, folderToLeave); - // Move-to submenu: the message's own account first (#1149) - const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, { + // Move-to submenu: the message's own account first (#1149), shared with + // every other Move list (lib/move-menu.ts). + const movedEmails = useMemo( + () => (showBatchActions ? batchEmails ?? [] : [email]), + [showBatchActions, batchEmails, email], + ); + const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox), - emails: showBatchActions ? batchEmails : [email], + emails: movedEmails, }); - - // Filter tree to only include branches that contain valid targets - const filterTree = (nodes: MailboxNode[], targetIds: Set): MailboxNode[] => { - return nodes.reduce((acc, node) => { - const filteredChildren = filterTree(node.children, targetIds); - if (targetIds.has(node.id) || filteredChildren.length > 0) { - acc.push({ ...node, children: filteredChildren }); - } - return acc; - }, []); - }; + const canMoveAnywhere = !moveMenuIsEmpty(moveModel); + const chooseMoveFolder = useChooseMoveFolder(); // Folders of the other connected accounts a copy can land in. - const copyTrees = (copyTargets ?? []) - .map((target) => { - const targetIds = new Set( - target.mailboxes - .filter((m) => !m.isShared && m.role !== "drafts" && m.myRights?.mayAddItems) - .map((m) => m.id) - ); - return { ...target, targetIds, tree: filterTree(buildMailboxTree(target.mailboxes), targetIds) }; - }) - .filter((target) => target.tree.length > 0); + const locale = useLocale(); + const copyTrees = useMemo( + () => (copyTargets ?? []) + .map((target) => ({ + ...target, + model: buildMoveMenu(target.mailboxes.filter((m) => !m.isShared), { locale }), + })) + .filter((target) => target.model.targetCount > 0), + [copyTargets, locale], + ); - const renderMailboxNodes = ( - nodes: MailboxNode[], - targetIds: Set, - testIdPrefix: string, - onPick: (mailboxId: string) => void, - ): React.ReactNode => - nodes.map((node) => { - const Icon = moveMailboxIcon(node.role); - const nodeLabel = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); - return ( -
- {targetIds.has(node.id) ? ( - handleAction(() => onPick(node.id))} - /> - ) : ( -
- - {nodeLabel} -
- )} - {node.children.length > 0 && ( -
- {renderMailboxNodes(node.children, targetIds, testIdPrefix, onPick)} -
- )} -
- ); - }); + const renderMoveList = (onPick?: (mailboxId: string) => void) => ( + handleAction(() => onPick?.(mailboxId))} + onChooseFolder={chooseMoveFolder && onPick + ? () => handleAction(() => chooseMoveFolder({ emails: movedEmails, onMove: onPick })) + : undefined} + /> + ); + const renderCopyList = (target: { accountId: string; model: MoveMenuModel }) => ( + handleAction(() => onCopyToAccount?.(target.accountId, mailboxId))} + /> + ); const handleAction = (action: () => void) => { action(); @@ -294,7 +274,7 @@ export function EmailContextMenu({ const singleEntries = useMessageActionList({ email, currentMailboxRole, - canMove: moveTree.length > 0, + canMove: canMoveAnywhere, canRemoveFromFolder: canRemove, canCopyToAccount: !!onCopyToAccount && copyTrees.length > 0, onReply, @@ -388,9 +368,9 @@ export function EmailContextMenu({ const moveItem = ( <> {/* Move to submenu */} - {moveTree.length > 0 && ( + {canMoveAnywhere && ( - {renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onBatchMoveToMailbox?.(mailboxId))} + {renderMoveList(onBatchMoveToMailbox)} )} {canRemove && ( @@ -407,9 +387,7 @@ export function EmailContextMenu({ ? copyTrees.map((target) => (
{target.label} - {renderMailboxNodes(target.tree, target.targetIds, `copy-to:${target.accountId}:`, (mailboxId) => - onCopyToAccount(target.accountId, mailboxId) - )} + {renderCopyList(target)}
)) : null; @@ -478,7 +456,7 @@ export function EmailContextMenu({ case "move": return ( - {renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onMoveToMailbox?.(mailboxId))} + {renderMoveList(onMoveToMailbox)} ); case "copy_to_account": diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0b1b5fac7..ba0a2a066 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -47,11 +47,12 @@ import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/p import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry"; import { QuickReplyPluginActions } from "./quick-reply-plugin-actions"; import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope"; -import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils"; +import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils"; import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone"; import { itemsToHide } from "@/lib/toolbar-overflow"; import { formatScheduledSendAt } from "@/lib/scheduled-send-label"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu"; import { emailDisplayDate } from "@/lib/email-date"; import { TagBadge } from "./tag-badge"; import { TagPicker } from "./tag-picker"; @@ -63,7 +64,8 @@ import { getSecurityStatus, extractListHeaders, getSenderVerification, brandIndi import { emailToReadView } from "@/lib/plugin-projection"; import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types"; import { ViewSourceDialog } from "./view-source-dialog"; -import { MoveTargetList } from "./move-target-list"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list"; +import { useChooseMoveFolder } from "./move-to-dialog"; import { SaveToFilesDialog, type SaveableAttachment } from "./save-to-files-dialog"; import { usePolicyStore } from "@/stores/policy-store"; import { ScheduleSendDialog } from "./schedule-send-dialog"; @@ -122,7 +124,7 @@ import { Paperclip, } from "@/components/icons"; import { MailMessageSkeleton, MailMessageToolbarSkeleton } from "@/components/boot-skeleton/mail-skeleton"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import { useSettingsStore } from "@/stores/settings-store"; @@ -1172,8 +1174,7 @@ interface ReaderMorePanelBodyProps { openSub: (sub: MoreMenuSub) => void; close: () => void; entryRefs: React.MutableRefObject>; - moveTree: MailboxNode[]; - moveTargetIds: Set; + moveModel: MoveMenuModel; onMoveToMailbox?: (mailboxId: string) => void; currentTagIds: string[]; sortedTagIds: string[]; @@ -1197,8 +1198,7 @@ function ReaderMorePanelBody({ openSub, close, entryRefs, - moveTree, - moveTargetIds, + moveModel, onMoveToMailbox, currentTagIds, sortedTagIds, @@ -1209,6 +1209,7 @@ function ReaderMorePanelBody({ pluginTarget, }: ReaderMorePanelBodyProps) { const { email } = options; + const locale = useLocale(); const { tagColor } = useKeywordFormat(tagScope); const { copyTargets, copyToAccount } = useCopyToAccount(email, true); const entries = useMessageActionList({ @@ -1218,11 +1219,10 @@ function ReaderMorePanelBody({ }); if (sub === 'move') { - return moveTree.length > 0 && onMoveToMailbox ? ( - { onMoveToMailbox(mailboxId); close(); }} /> ) : null; @@ -1231,14 +1231,15 @@ function ReaderMorePanelBody({ return ( <> {copyTargets.map((target) => { - const { tree, targetIds } = buildMoveTargets(target.mailboxes.filter((m) => !m.isShared && m.role !== 'drafts'), {}); + const model = buildMoveMenu(target.mailboxes.filter((m) => !m.isShared), { locale }); return (
{target.label}
- { void copyToAccount([email], target.accountId, mailboxId); close(); }} />
@@ -1978,14 +1979,14 @@ export function EmailViewer({ // Move-to dropdown: the message's own account first (#1149) const moveOwnerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox); - const { tree: moveTree, targetIds: moveTargetIds } = useMemo( - () => buildMoveTargets(mailboxes, { - currentMailboxId: selectedMailbox, - ownerAccountId: moveOwnerAccountId, - emails: email ? [email] : [], - }), - [mailboxes, selectedMailbox, moveOwnerAccountId, email], - ); + const moveEmails = useMemo(() => (email ? [email] : []), [email]); + const moveModel = useMoveMenu(mailboxes, { + currentMailboxId: selectedMailbox, + ownerAccountId: moveOwnerAccountId, + emails: moveEmails, + }); + const canMoveAnywhere = !moveMenuIsEmpty(moveModel); + const chooseMoveFolder = useChooseMoveFolder(); // Close dropdown menus on click outside useEffect(() => { @@ -2164,7 +2165,7 @@ export function EmailViewer({ email?.id, showToolbarLabels, isLoading, - moveTree.length, + canMoveAnywhere, emailKeywords.length, currentColor, isInJunkFolder, @@ -4271,7 +4272,7 @@ ${printWatermark ? printWatermark.html : ''} const readerActionOptions: MessageActionOptions = { email, currentMailboxRole, - canMove: moveTree.length > 0 && !!onMoveToMailbox, + canMove: canMoveAnywhere && !!onMoveToMailbox, canRemoveFromFolder: !!onRemoveFromFolder, onReply: onReply ? () => onReply() : undefined, onReplyAll, @@ -4467,7 +4468,7 @@ ${printWatermark ? printWatermark.html : ''} {showToolbarLabels && {t('delete')}} {/* Move to folder */} - {moveTree.length > 0 && onMoveToMailbox && ( + {canMoveAnywhere && onMoveToMailbox && (
-
- + { onMoveToMailbox(mailboxId); closeMoveMenu(); }} + onChooseFolder={chooseMoveFolder ? () => { closeMoveMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox }); } : undefined} />
@@ -4831,8 +4832,7 @@ ${printWatermark ? printWatermark.html : ''} openSub={setMoreMenuSub} close={closeMoreMenu} entryRefs={moreEntryRefs} - moveTree={moveTree} - moveTargetIds={moveTargetIds} + moveModel={moveModel} onMoveToMailbox={onMoveToMailbox} currentTagIds={currentTagIds} sortedTagIds={sortedTagIds} diff --git a/components/email/move-target-list.tsx b/components/email/move-target-list.tsx index cc99987a4..51395ccb0 100644 --- a/components/email/move-target-list.tsx +++ b/components/email/move-target-list.tsx @@ -1,9 +1,21 @@ "use client"; -import { useTranslations } from "next-intl"; -import { Archive, Ban, File, Folder, Inbox, Send, Trash2, type AppIcon } from "@/components/icons"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useLocale, useTranslations } from "next-intl"; +import { Archive, Ban, Clock, File, Folder, FolderSearch, Inbox, Search, Send, Trash2, Users, type AppIcon } from "@/components/icons"; +import { ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; import { localizeMailboxName } from "@/lib/mailbox-label"; -import type { MailboxNode } from "@/lib/utils"; +import type { Email, Mailbox } from "@/lib/jmap/types"; +import { + buildMoveMenu, + countTargets, + filterMoveNodes, + firstEnabled, + MOVE_FILTER_THRESHOLD, + type MoveMenuModel, + type MoveMenuNode, +} from "@/lib/move-menu"; +import { noteMoveTarget, readRecentMoveTargets } from "@/lib/move-recent"; import { cn } from "@/lib/utils"; /** A folder's icon in a Move list, the sidebar's for the folders with a role. */ @@ -19,58 +31,257 @@ export function moveMailboxIcon(role?: string | null): AppIcon { } } -interface MoveTargetListProps { - /** From buildMoveTargets. */ - tree: MailboxNode[]; - targetIds: Set; +type MoveMenuEmails = Array>; + +/** The Move list for these messages, with this device's recent folders. */ +export function useMoveMenu( + mailboxes: Mailbox[], + opts: { currentMailboxId?: string | null; ownerAccountId?: string; emails?: MoveMenuEmails }, +): MoveMenuModel { + const locale = useLocale(); + const { currentMailboxId, ownerAccountId, emails } = opts; + return useMemo( + () => buildMoveMenu(mailboxes, { currentMailboxId, ownerAccountId, emails, locale, recentIds: readRecentMoveTargets }), + [mailboxes, currentMailboxId, ownerAccountId, emails, locale], + ); +} + +/** Whether the list is long enough for a filter field and "Choose folder…". */ +export function moveMenuIsLong(model: MoveMenuModel): boolean { + return model.targetCount > MOVE_FILTER_THRESHOLD; +} + +export function moveMenuIsEmpty(model: MoveMenuModel): boolean { + return model.targetCount === 0 && model.others.length === 0; +} + +interface MoveFolderListProps { + model: MoveMenuModel; onPick: (mailboxId: string) => void; - /** `menu` for a dropdown, `panel` for the phone's full-width panel (44px rows). */ - variant?: "menu" | "panel"; + /** + * `menu`: inside a ContextMenu; other accounts are submenus. + * `dialog`: a list in a dialog; other accounts follow under their names. + * `panel`: the phone's full-width panel (44px rows). + */ + variant?: "menu" | "dialog" | "panel"; + /** Opens the Move dialog. Offered at the end of a long list, since a native menu has no filter field. */ + onChooseFolder?: () => void; + /** Always show the filter field (the dialog). */ + alwaysFilter?: boolean; + /** Stable hook for integration tests: each row is ``. */ + testIdPrefix?: string; + /** Don't keep the pick as a recent folder (a copy, not a move). */ + noRecent?: boolean; } /** - * The folders a message can be moved to, as the reader's Move dropdown, its ⋮ - * and the phone panel show them: the names the sidebar shows (localized - * Sent, Spam, Trash), subfolders indented on the reading side. + * The folders messages can be moved to (lib/move-menu.ts): recent ones, the + * account's own folders nested and in mail-client order, the open folder + * greyed, other reachable accounts under their names, and a filter field + * once the list is long. + * + * Each folder with subfolders is wrapped in `data-native-submenu`: the + * desktop app's native copy of the menu makes it a submenu there, the folder + * itself first (lib/desktop/native-menu.ts). */ -export function MoveTargetList({ tree, targetIds, onPick, variant = "menu" }: MoveTargetListProps) { +export function MoveFolderList({ + model, + onPick, + variant = "menu", + onChooseFolder, + alwaysFilter = false, + testIdPrefix = "move-to:", + noRecent = false, +}: MoveFolderListProps) { + const t = useTranslations("menuMove"); const tSidebar = useTranslations("sidebar"); + const locale = useLocale(); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); const panel = variant === "panel"; - const indent = (depth: number) => `${(panel ? 1 : 0.75) + depth}rem`; + const showFilter = alwaysFilter || moveMenuIsLong(model); - const render = (nodes: MailboxNode[], depth: number): React.ReactNode => - nodes.map((node) => { - const Icon = moveMailboxIcon(node.role); - const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); - const rowClass = panel - ? "w-full pe-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3" - : "w-full pe-3 py-1.5 text-sm text-start flex items-center gap-2 pointer-coarse:min-h-11"; - const icon = ; + const labelOf = (node: Pick) => + localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); + + const nodes = filterMoveNodes(model.nodes, query, labelOf, locale); + const others = model.others + .map((account) => ({ ...account, nodes: filterMoveNodes(account.nodes, query, labelOf, locale) })) + .filter((account) => countTargets(account.nodes) > 0); + const filtering = query.trim().length > 0; + + const pick = (id: string) => { + if (!noRecent) noteMoveTarget(model.accountId, id); + onPick(id); + }; + + // The filter takes the focus as the list shows, so typing narrows it at + // once. It can be hidden for a frame while its panel is placed. + useEffect(() => { + if (!showFilter || panel) return; + let tries = 0; + let frame = 0; + const attempt = () => { + const input = inputRef.current; + if (!input) return; + const visible = input.getClientRects().length > 0 && getComputedStyle(input).visibility !== "hidden"; + if (visible) { + input.focus({ preventScroll: true }); + return; + } + if (++tries < 20) frame = requestAnimationFrame(attempt); + }; + frame = requestAnimationFrame(attempt); + return () => cancelAnimationFrame(frame); + }, [showFilter, panel]); + + const row = (node: MoveMenuNode, label: string, key: string, title?: string) => { + const Icon = moveMailboxIcon(node.role); + const rowTitle = title ?? (node.current ? t("current_folder", { folder: label }) : label); + if (panel) { return ( -
- {targetIds.has(node.id) ? ( - - ) : ( -
- {icon} - {label} -
- )} - {node.children.length > 0 && render(node.children, depth + 1)} + + ); + } + return ( + pick(node.id)} + /> + ); + }; + + const tree = (list: MoveMenuNode[]): React.ReactNode => + list.map((node) => { + const label = labelOf(node); + if (node.children.length === 0) return row(node, label, node.id); + return ( +
+ {row(node, label, node.id)} +
{tree(node.children)}
); }); - return <>{render(tree, 0)}; + const recent = !filtering && !noRecent ? model.recent : []; + const nothing = nodes.length === 0 && others.length === 0; + const separator = panel ?
: ; + + return ( +
+ {showFilter && ( +
+ +
+ )} + + {recent.length > 0 && ( +
+ {panel ? ( +
+ + {t("recent")} +
+ ) : ( + {t("recent")} + )} + {recent.map((node) => { + const label = [...node.path, labelOf(node)].join(" / "); + return row({ ...node, children: [] }, label, `recent-${node.id}`, label); + })} + {separator} +
+ )} + + {tree(nodes)} + + {nothing && ( +
{t("no_match")}
+ )} + + {others.length > 0 && (variant === "menu" ? ( + <> + {nodes.length > 0 && } + {others.map((account) => ( + + {tree(account.nodes)} + + ))} + + ) : ( + others.map((account) => ( +
+ {panel ? ( +
{account.name}
+ ) : ( + {account.name} + )} + {tree(account.nodes)} +
+ )) + ))} + + {onChooseFolder && moveMenuIsLong(model) && !filtering && ( + <> + {separator} + {panel ? ( + + ) : ( + + )} + + )} +
+ ); } diff --git a/components/email/move-to-dialog.tsx b/components/email/move-to-dialog.tsx index 591ef49c8..33a8c68fd 100644 --- a/components/email/move-to-dialog.tsx +++ b/components/email/move-to-dialog.tsx @@ -1,15 +1,47 @@ "use client"; -import { useMemo } from "react"; +import { useEffect } from "react"; import { useTranslations } from "next-intl"; +import { create } from "zustand"; import { Dialog } from "@/components/ui/dialog"; import type { Email, Mailbox } from "@/lib/jmap/types"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; -import { MoveTargetList } from "./move-target-list"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { MoveFolderList, useMoveMenu } from "./move-target-list"; + +interface MoveDialogRequest { + /** The messages to move. */ + emails: Email[]; + onMove: (mailboxId: string) => void; +} + +interface MoveDialogState { + request: MoveDialogRequest | null; + /** Mounted hosts: without one, nothing offers the dialog. */ + hosts: number; + open: (request: MoveDialogRequest) => void; + close: () => void; +} + +export const useMoveDialogStore = create((set) => ({ + request: null, + hosts: 0, + open: (request) => set({ request }), + close: () => set({ request: null }), +})); + +/** + * "Choose folder…" at the end of a long Move list: the dialog with the filter + * field. Undefined where no dialog host is mounted. + */ +export function useChooseMoveFolder(): ((request: MoveDialogRequest) => void) | undefined { + const hosts = useMoveDialogStore((s) => s.hosts); + const open = useMoveDialogStore((s) => s.open); + return hosts > 0 ? open : undefined; +} interface MoveToDialogProps { - /** The message to move; the dialog is open while it is set. */ - email: Email | null; + /** The messages to move; the dialog is open while there are some. */ + emails: Email[]; mailboxes: Mailbox[]; selectedMailbox: string; onClose: () => void; @@ -17,25 +49,24 @@ interface MoveToDialogProps { } /** - * "Move to…" from the command palette: the same folder list as the reader's - * Move menu (the message's own account first), in a dialog. + * Move to…: the same folders as the Move menus (lib/move-menu.ts), in a + * dialog with a filter field. From the command palette, the Message menu and + * "Choose folder…" in a long Move menu. */ -export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) { +export function MoveToDialog({ emails, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) { const t = useTranslations("context_menu"); - const { tree, targetIds } = useMemo( - () => buildMoveTargets(mailboxes, { - currentMailboxId: selectedMailbox, - ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox), - emails: email ? [email] : [], - }), - [email, mailboxes, selectedMailbox], - ); + const model = useMoveMenu(mailboxes, { + currentMailboxId: selectedMailbox, + ownerAccountId: resolveMoveOwnerAccountId(emails[0], mailboxes, selectedMailbox), + emails, + }); return ( - -
- 0} onClose={onClose} title={t("move_to_dialog")} bodyClassName="px-2" size="sm"> +
+ { onClose(); onMove(mailboxId); @@ -45,3 +76,23 @@ export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMov
); } + +/** Shows the dialog a Move menu asked for. Mounted once, where the mail is. */ +export function MoveToDialogHost({ mailboxes, selectedMailbox }: { mailboxes: Mailbox[]; selectedMailbox: string }) { + const request = useMoveDialogStore((s) => s.request); + const close = useMoveDialogStore((s) => s.close); + useEffect(() => { + useMoveDialogStore.setState((s) => ({ hosts: s.hosts + 1 })); + return () => useMoveDialogStore.setState((s) => ({ hosts: s.hosts - 1, request: s.hosts <= 1 ? null : s.request })); + }, []); + if (!request) return null; + return ( + + ); +} diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx index e3ddc9dcf..ae56cb2fc 100644 --- a/components/email/mv-list-toolbar.tsx +++ b/components/email/mv-list-toolbar.tsx @@ -1,17 +1,15 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Archive, CheckSquare, - Folder, FolderInput, FolderX, Loader2, Mail, MailOpen, MoreVertical, - Search, ShieldAlert, ShieldCheck, Square, @@ -31,14 +29,15 @@ import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo import { toast } from "@/stores/toast-store"; import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useContextMenu } from "@/hooks/use-context-menu"; -import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuHeader, CONTEXT_MENU_ITEM_CLASS } from "@/components/ui/context-menu"; +import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuHeader } from "@/components/ui/context-menu"; import { useArchiveActionEnabled } from "@/lib/archive-gate"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { TagPicker } from "@/components/email/tag-picker"; -import { localizeMailboxName } from "@/lib/mailbox-label"; -import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "@/components/email/move-target-list"; +import { useChooseMoveFolder } from "@/components/email/move-to-dialog"; import { getEmailTagIds } from "@/lib/thread-utils"; -import { cn, type MailboxNode } from "@/lib/utils"; +import { cn } from "@/lib/utils"; import { PluginActions } from "@/components/plugins/plugin-actions"; import { useUnreadFirstSection } from "@/hooks/use-unread-first-section"; import { useTimeoutWhileMounted } from "@/hooks/use-timeout-while-mounted"; @@ -170,8 +169,6 @@ export function MvListToolbar({ const moveOpen = moveMenu.contextMenu.isOpen; const closeMove = moveMenu.closeContextMenu; // Gmail's "Move to" menu opens on a search field that narrows the folders. - const [moveQuery, setMoveQuery] = useState(""); - useEffect(() => { if (!moveOpen) setMoveQuery(""); }, [moveOpen]); const tagMenu = useContextMenu(); const tagOpen = tagMenu.contextMenu.isOpen; const closeTag = tagMenu.closeContextMenu; @@ -288,58 +285,12 @@ export function MvListToolbar({ // The same targets the row's own context menu offers, with the account of // the selection leading (#1149). - const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, { + const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId: resolveMoveOwnerAccountId(selectedEmails[0], mailboxes, selectedMailbox), emails: selectedEmails, }); - - const renderMoveNodes = (nodes: MailboxNode[], depth = 0): React.ReactNode => - nodes.map((node) => { - const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); - const isTarget = moveTargetIds.has(node.id); - return ( -
- {isTarget ? ( - - ) : ( -
- - {label} -
- )} - {node.children.length > 0 && renderMoveNodes(node.children, depth + 1)} -
- ); - }); - - // While a search narrows the move menu it is a flat list of the matches. - const moveMatches = (() => { - const q = moveQuery.trim().toLowerCase(); - if (!q) return null; - const out: MailboxNode[] = []; - const walk = (nodes: MailboxNode[]) => { - for (const node of nodes) { - const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); - if (moveTargetIds.has(node.id) && label.toLowerCase().includes(q)) out.push({ ...node, children: [] }); - walk(node.children); - } - }; - walk(moveTree); - return out; - })(); + const chooseMoveFolder = useChooseMoveFolder(); // What the tag picker shows: a tag every selected message carries is // checked, one only some of them carry is drawn as partial. @@ -560,7 +511,7 @@ export function MvListToolbar({ disabled={isProcessing} />
- {moveTree.length > 0 && ( + {!moveMenuIsEmpty(moveModel) && (
)} {resource.isDirectory && !resource.isShared && ( diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx index 11a26656c..972e8cfad 100644 --- a/components/files/file-views.tsx +++ b/components/files/file-views.tsx @@ -223,6 +223,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget, note?: string; }) { const t = useTranslations("files"); + const tMenu = useTranslations("menuAudit"); const touchProps = useTouchPress(touch, selecting, resource, handlers); const icon = ; return ( @@ -299,7 +300,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget, handlers.onDownload?.(resource)} /> )} {!touch && handlers.canShare(resource) && ( - handlers.onShare(resource)} /> + handlers.onShare(resource)} /> )} (); const overflowMenu = useContextMenu(); const [searchOpen, setSearchOpen] = useState(false); @@ -159,7 +160,7 @@ export function FilesHeader({ )} {selection.canShare && ( - + )} {selection.canRename && ( diff --git a/components/files/files-sidebar.tsx b/components/files/files-sidebar.tsx index 58dc0b629..d47ea17a5 100644 --- a/components/files/files-sidebar.tsx +++ b/components/files/files-sidebar.tsx @@ -125,6 +125,7 @@ export function FilesSidebar({ onDropToTrash, }: FilesSidebarProps) { const t = useTranslations("files"); + const tMenu = useTranslations("menuAudit"); const newMenu = useContextMenu(); const itemMenu = useContextMenu(); const newButtonRef = useRef(null); @@ -274,10 +275,10 @@ export function FilesSidebar({ const createItems = (path: string, id: string | null) => ( <> - run(() => onCreateIn(path, id, "folder"))} /> - run(() => onCreateIn(path, id, "text"))} /> - run(() => onCreateIn(path, id, "files"))} /> - run(() => onCreateIn(path, id, "dir"))} /> + run(() => onCreateIn(path, id, "folder"))} /> + run(() => onCreateIn(path, id, "text"))} /> + run(() => onCreateIn(path, id, "files"))} /> + run(() => onCreateIn(path, id, "dir"))} /> ); @@ -455,11 +456,11 @@ export function FilesSidebar({ - { newMenu.closeContextMenu(); onUploadFiles(); }} /> - { newMenu.closeContextMenu(); onUploadFolder(); }} /> + { newMenu.closeContextMenu(); onUploadFiles(); }} /> + { newMenu.closeContextMenu(); onUploadFolder(); }} /> - { newMenu.closeContextMenu(); onNewFolder(); }} /> - { newMenu.closeContextMenu(); onNewTextFile(); }} /> + { newMenu.closeContextMenu(); onNewFolder(); }} /> + { newMenu.closeContextMenu(); onNewTextFile(); }} /> @@ -484,7 +485,7 @@ export function FilesSidebar({ Details act on the list and are left out here (R6). */} {(!folder.isShared || canShare(folder)) && } {canShare(folder) && ( - run(() => onShare(folder))} /> + run(() => onShare(folder))} /> )} {!folder.isShared && ( Date: Wed, 4 Nov 2026 05:32:24 +0100 Subject: [PATCH 15/46] fix: leave Edit, Move and Delete out for contacts in a read-only address book MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two "Add to group" tests click the entry by its new key, which ends in "…" because it opens a group picker. --- .../contacts/__tests__/contacts-app.test.tsx | 20 ++++++++++-- components/contacts/contact-context-menu.tsx | 31 ++++++++++++------- components/contacts/contact-detail.tsx | 7 +++-- components/contacts/contact-list.tsx | 4 +++ components/contacts/contacts-app.tsx | 5 +++ 5 files changed, 52 insertions(+), 15 deletions(-) diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx index cd153f9e8..802c8f994 100644 --- a/components/contacts/__tests__/contacts-app.test.tsx +++ b/components/contacts/__tests__/contacts-app.test.tsx @@ -182,7 +182,7 @@ describe('ContactsApp', () => { it('starts a new group with the contact when "Add to group" finds no group to add to', () => { renderContacts(); fireEvent.contextMenu(screen.getByText('Alice Smith')); - fireEvent.click(screen.getByText('context_menu.add_to_group')); + fireEvent.click(screen.getByText('contacts_add_to_group')); expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument(); }); @@ -190,11 +190,27 @@ describe('ContactsApp', () => { const team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: {}, name: { full: 'Team' }, members: {} }; renderContacts([alice, bob, team]); fireEvent.contextMenu(screen.getByText('Bob Jones')); - fireEvent.click(screen.getByText('context_menu.add_to_group')); + fireEvent.click(screen.getByText('contacts_add_to_group')); fireEvent.click(screen.getByText('bulk.new_group')); expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument(); }); + it('offers no Edit, Move or Delete for a contact in an address book shared read-only', () => { + const shared = { ...alice, addressBookIds: { 'ab-shared': true } }; + renderContacts([shared, bob]); + act(() => { + useContactStore.setState({ addressBooks: [{ id: 'ab-shared', name: 'Team', myRights: { mayRead: true, mayWrite: false } }] as never }); + }); + fireEvent.contextMenu(screen.getByText('Alice Smith')); + expect(screen.queryByRole('menuitem', { name: 'context_menu.edit' })).toBeNull(); + expect(screen.queryByRole('menuitem', { name: /context_menu.delete/ })).toBeNull(); + expect(screen.getByRole('menuitem', { name: 'context_menu.export_vcard' })).toBeInTheDocument(); + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); + + fireEvent.contextMenu(screen.getByText('Bob Jones')); + expect(screen.getByRole('menuitem', { name: 'context_menu.edit' })).toBeInTheDocument(); + }); + it('imports in a real dialog that Escape closes', () => { renderContacts(); fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]); diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx index 9b6eea288..6281846ca 100644 --- a/components/contacts/contact-context-menu.tsx +++ b/components/contacts/contact-context-menu.tsx @@ -65,6 +65,8 @@ interface ContactContextMenuProps { onBatchExport?: () => void; onBatchAddToGroup?: () => void; onBatchDelete?: () => void; + /** The contact is in an address book shared read-only: no Edit, Move or Delete. */ + readOnly?: boolean; } export function ContactContextMenu({ @@ -88,8 +90,10 @@ export function ContactContextMenu({ onBatchExport, onBatchAddToGroup, onBatchDelete, + readOnly = false, }: ContactContextMenuProps) { const t = useTranslations("contacts"); + const tMenu = useTranslations("menuAudit"); const printOff = useStrictestDevices().noPrint; const email = getContactPrimaryEmail(contact); const phone = getContactPrimaryPhone(contact); @@ -134,7 +138,7 @@ export function ContactContextMenu({ onBatchAddToGroup?.())} disabled={!onBatchAddToGroup} /> @@ -179,12 +183,12 @@ export function ContactContextMenu({ onClick={handle(handleCall)} /> )} - {onEdit && } + {onEdit && !readOnly && } {!inDetail && } {onAddToGroup && ( )} @@ -195,7 +199,7 @@ export function ContactContextMenu({ onClick={handle(onDuplicate)} /> )} - {onMoveTo && ( + {onMoveTo && !readOnly && ( )} {email && ( @@ -225,13 +229,18 @@ export function ContactContextMenu({ onClick={handle(handlePrint)} /> )} - - + {!readOnly && ( + <> + + + + )} ); } diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 77edb2cdc..66a3969e2 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -35,6 +35,8 @@ interface ContactDetailProps { onPrint?: () => void; isMobile?: boolean; className?: string; + /** In an address book shared read-only: no Edit, Move or Delete. */ + readOnly?: boolean; } // Contexts and phone features the contact form offers, shown with the same @@ -74,7 +76,7 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null { } -export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className }: ContactDetailProps) { +export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) { const t = useTranslations("contacts"); const tCommon = useTranslations("common"); const locale = useLocale(); @@ -173,7 +175,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli )} {/* A phone's row has no room for this label: the ⋮ fell onto a line of its own. */} - {isMobile ? ( + {readOnly ? null : isMobile ? ( @@ -207,6 +209,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli onClose={moreMenu.closeContextMenu} menuRef={moreMenu.menuRef} inDetail + readOnly={readOnly} onCompose={onCompose} onDelete={onDelete} onAddToGroup={onAddToGroup} diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx index 6a11b7031..c3eef9c7a 100644 --- a/components/contacts/contact-list.tsx +++ b/components/contacts/contact-list.tsx @@ -105,6 +105,8 @@ function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; la interface ContactListProps { contacts: ContactCard[]; + /** A contact in an address book shared read-only: its menu has no Edit, Move or Delete. */ + isContactReadOnly?: (contact: ContactCard) => boolean; selectedContactId: string | null; searchQuery: string; onSearchChange: (query: string) => void; @@ -144,6 +146,7 @@ interface ContactListProps { export function ContactList({ contacts, + isContactReadOnly, selectedContactId, searchQuery, onSearchChange, @@ -788,6 +791,7 @@ export function ContactList({ onBatchExport={onBulkExport} onBatchAddToGroup={onBulkAddToGroup} onBatchDelete={onBulkDelete} + readOnly={isContactReadOnly?.(contextMenu.data) ?? false} /> )}
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index a28c84ea1..83e2619d7 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -487,6 +487,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = // Address books contacts can be moved to; "Move to…" needs a second one. const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]); const canMoveContacts = !!client && supportsSync && writableBooks.length > 1; + // A contact in an address book shared read-only can't be changed: its menus leave out Edit, Move and Delete. + const isContactReadOnly = useCallback((contact: ContactCard) => Object.entries(contact.addressBookIds ?? {}) + .some(([id, inBook]) => inBook && addressBooks.find((b) => b.id === id)?.myRights?.mayWrite === false), [addressBooks]); const handleMoveContacts = useCallback((ids: string[]) => { if (ids.length > 0) setMovingContactIds(ids); }, []); @@ -1144,6 +1147,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined} onPrint={selectedContact && !printOff ? () => { void printContact(selectedContact); } : undefined} isMobile={isMobile} + readOnly={selectedContact ? isContactReadOnly(selectedContact) : false} /> ); } @@ -1398,6 +1402,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = ) : ( Date: Wed, 4 Nov 2026 05:35:44 +0100 Subject: [PATCH 16/46] fix: let a long submenu use the window's height, and draw one focus ring round the folder filter --- components/email/move-target-list.tsx | 9 +++++---- components/ui/context-menu.tsx | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/components/email/move-target-list.tsx b/components/email/move-target-list.tsx index 51395ccb0..34632ca49 100644 --- a/components/email/move-target-list.tsx +++ b/components/email/move-target-list.tsx @@ -189,8 +189,9 @@ export function MoveFolderList({
{showFilter && (
-
)} diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 8241bd4d0..e0468679b 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -781,7 +781,8 @@ export function ContextMenuSubMenu({ className="min-w-[180px] max-w-[min(20rem,calc(100vw-20px))] bg-background rounded-md shadow-lg border border-border" role="menu" > -
+ {/* As tall as the window allows (it's moved up to fit), then it scrolls. */} +
{children}
From 4508eddccb89c50213fbc7564f29f1072cbd6976 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:35:57 +0100 Subject: [PATCH 17/46] fix: keep a tall menu under its button and scroll it, instead of covering the button --- .../__tests__/context-menu-placement.test.tsx | 19 +++++++++++++++ components/ui/context-menu.tsx | 24 +++++++++++++++---- lib/menu-placement.ts | 17 +++++++++---- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/components/ui/__tests__/context-menu-placement.test.tsx b/components/ui/__tests__/context-menu-placement.test.tsx index a856a4268..d1345b377 100644 --- a/components/ui/__tests__/context-menu-placement.test.tsx +++ b/components/ui/__tests__/context-menu-placement.test.tsx @@ -98,6 +98,25 @@ describe('a menu from a button', () => { expect(openAt({ x: 0, y: 0, anchor: { left: 100, top: 600, right: 132, bottom: 632 } }).top).toBe(600 - 4 - MENU.height); }); + it('too tall for either side: stays under the button, as tall as the room, and scrolls', () => { + // The ⋮ menu at 150 % zoom: 480 CSS pixels of window. + Object.defineProperty(window, 'innerHeight', { value: 480, configurable: true }); + MENU.height = 460; + try { + render( + {}}> + {}} /> + , + ); + const menu = screen.getByRole('menu'); + // Clamped instead, it sat at 10 and covered its own button. + expect(menu.style.top).toBe('48px'); + expect((menu.firstElementChild as HTMLElement).style.maxHeight).toBe(`${480 - 10 - 48 - 2}px`); + } finally { + MENU.height = 300; + } + }); + it('mirrors in RTL: start is the right edge', () => { document.documentElement.dir = 'rtl'; const at = openAt({ x: 0, y: 0, anchor: { left: 500, top: 40, right: 532, bottom: 72 } }); diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 84e0c359c..0498a3dc3 100644 --- a/components/ui/context-menu.tsx +++ b/components/ui/context-menu.tsx @@ -242,7 +242,7 @@ function useMenuLevel(recentTouch: () => boolean): MenuLevel { export const ContextMenu = forwardRef( ({ isOpen, position, onClose, children, label }, ref) => { const [mounted, setMounted] = useState(false); - const [adjustedPosition, setAdjustedPosition] = useState(null); + const [adjustedPosition, setAdjustedPosition] = useState<(Position & { maxHeight?: number }) | null>(null); const localRef = useRef(null); const onCloseRef = useRef(onClose); useEffect(() => { @@ -326,18 +326,25 @@ export const ContextMenu = forwardRef( const clamp = () => { const rect = node.getBoundingClientRect(); + // The height it would have uncapped: a cap set last time mustn't decide the next. + const list = node.firstElementChild as HTMLElement | null; + const height = list && list.scrollHeight > list.clientHeight + ? rect.height + list.scrollHeight - list.clientHeight + : rect.height; // Next to the button it opened from, or at the pointer (lib/menu-placement.ts). - const { x, y } = placeMenu({ + const { x, y, maxHeight } = placeMenu({ point: { x: position.x, y: position.y }, anchor, - size: { width: rect.width, height: rect.height }, + size: { width: rect.width, height }, viewport: { width: window.innerWidth, height: window.innerHeight }, rtl: getComputedStyle(node).direction === "rtl", margin: VIEWPORT_MARGIN, gap: ANCHOR_GAP, }); - setAdjustedPosition((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y })); + setAdjustedPosition((prev) => + prev && prev.x === x && prev.y === y && prev.maxHeight === maxHeight ? prev : { x, y, maxHeight } + ); }; clamp(); if (typeof ResizeObserver === "undefined") return; @@ -528,7 +535,14 @@ export const ContextMenu = forwardRef( }} > {/* Taller than the window: scroll inside the menu. */} -
+
{children}
diff --git a/lib/menu-placement.ts b/lib/menu-placement.ts index a8397d1ee..146c6b391 100644 --- a/lib/menu-placement.ts +++ b/lib/menu-placement.ts @@ -50,8 +50,9 @@ export function placeMenu({ rtl = false, margin = MENU_VIEWPORT_MARGIN, gap = MENU_ANCHOR_GAP, -}: MenuPlacementInput): { x: number; y: number } { - const { width: w, height: h } = size; +}: MenuPlacementInput): { x: number; y: number; maxHeight?: number } { + const { width: w } = size; + let h = size.height; const { width: vw, height: vh } = viewport; const fitsX = (x: number) => x >= margin && x + w <= vw - margin; const fitsY = (y: number) => y >= margin && y + h <= vh - margin; @@ -69,8 +70,16 @@ export function placeMenu({ const above = anchor.top - gap - h; if (fitsY(below)) y = below; else if (fitsY(above)) y = above; - // Neither fits whole: the side with more room, and the menu scrolls. - else y = vh - anchor.bottom >= anchor.top ? below : above; + else { + // Neither fits whole: the side with more room, as tall as that room, and + // the menu scrolls. Clamped instead, it would cover its own button. + const roomBelow = vh - margin - below; + const roomAbove = anchor.top - gap - margin; + const maxHeight = Math.max(roomBelow, roomAbove); + h = Math.min(h, maxHeight); + y = roomBelow >= roomAbove ? below : anchor.top - gap - h; + return { x: clamp(x, w, vw, margin), y: Math.max(margin, y), maxHeight }; + } } else { const towardEnd = rtl ? point.x - w : point.x; const towardStart = rtl ? point.x : point.x - w; From e211ae905ec10a5b821522dbbeaba4f455dc8948 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:37:35 +0100 Subject: [PATCH 18/46] feat: offer attach or upload when files are dragged onto any screen Files dragged into the window from outside now bring up a full-window overlay with large drop zones: attach to a new message and upload to Files, with the open composer first, "Upload to " on the Files screen, and add to calendar, add to contacts, open or import for .ics, .vcf and .eml files. At most four zones, the one under the pointer highlights, Esc or dragging out cancels, a drop beside the zones does nothing. Drags that start in the app never show it. A file pasted outside a text field gets the same choices as a keyboard dialog. The desktop app used to turn any file dropped on the window into a new draft (lib/desktop/window-files.ts). That handling moved to the overlay, so the "files on the window" tests in os-window.test.tsx went with it: the same cases are now in components/file-drop/__tests__. --- app/(main)/[locale]/layout.tsx | 2 + components/desktop/desktop-os-host.tsx | 8 +- .../__tests__/file-drop-host.test.tsx | 297 ++++++++++++++++ components/file-drop/file-drop-host.tsx | 334 ++++++++++++++++++ components/file-drop/file-drop-zones.tsx | 114 ++++++ components/files/files-app.tsx | 36 +- lib/desktop/__tests__/os-window.test.tsx | 172 +-------- lib/desktop/window-files.ts | 72 +--- lib/file-drop/__tests__/drag-watch.test.ts | 106 ++++++ lib/file-drop/__tests__/zones.test.ts | 119 +++++++ lib/file-drop/actions.ts | 58 +++ lib/file-drop/choice.ts | 35 ++ lib/file-drop/drag-watch.ts | 116 ++++++ lib/file-drop/files-target.ts | 59 ++++ lib/file-drop/read.ts | 30 ++ lib/file-drop/zones.ts | 145 ++++++++ locales/ar/common.json | 29 ++ locales/ca/common.json | 29 ++ locales/cs/common.json | 29 ++ locales/da/common.json | 29 ++ locales/de/common.json | 29 ++ locales/en/common.json | 29 ++ locales/es/common.json | 29 ++ locales/et/common.json | 29 ++ locales/fa/common.json | 29 ++ locales/fr/common.json | 29 ++ locales/he/common.json | 29 ++ locales/hu/common.json | 29 ++ locales/it/common.json | 29 ++ locales/ja/common.json | 29 ++ locales/ko/common.json | 29 ++ locales/lv/common.json | 29 ++ locales/mn/common.json | 29 ++ locales/nb/common.json | 29 ++ locales/nl/common.json | 29 ++ locales/pl/common.json | 29 ++ locales/pt/common.json | 29 ++ locales/ro/common.json | 29 ++ locales/ru/common.json | 29 ++ locales/sk/common.json | 29 ++ locales/th/common.json | 29 ++ locales/tr/common.json | 29 ++ locales/uk/common.json | 29 ++ locales/vi/common.json | 29 ++ locales/zh-TW/common.json | 29 ++ locales/zh/common.json | 29 ++ 46 files changed, 2341 insertions(+), 232 deletions(-) create mode 100644 components/file-drop/__tests__/file-drop-host.test.tsx create mode 100644 components/file-drop/file-drop-host.tsx create mode 100644 components/file-drop/file-drop-zones.tsx create mode 100644 lib/file-drop/__tests__/drag-watch.test.ts create mode 100644 lib/file-drop/__tests__/zones.test.ts create mode 100644 lib/file-drop/actions.ts create mode 100644 lib/file-drop/choice.ts create mode 100644 lib/file-drop/drag-watch.ts create mode 100644 lib/file-drop/files-target.ts create mode 100644 lib/file-drop/read.ts create mode 100644 lib/file-drop/zones.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index ebd3dc38a..7c3a7151b 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -31,6 +31,7 @@ import { OfflineHost } from "@/components/offline/offline-host"; import { DeviceWipeHost } from "@/components/offline/device-wipe-host"; import { NotificationActionsHost } from "@/components/providers/notification-actions-host"; import { DesktopOsHost } from "@/components/desktop/desktop-os-host"; +import { FileDropHost } from "@/components/file-drop/file-drop-host"; import { EditedFilesHost } from "@/components/desktop/edited-files-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; @@ -103,6 +104,7 @@ export default async function LocaleLayout({ {children} + diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx index a6e522136..b462bc255 100644 --- a/components/desktop/desktop-os-host.tsx +++ b/components/desktop/desktop-os-host.tsx @@ -8,15 +8,15 @@ import { isAppLocked } from "@/lib/desktop/app-lock-state"; import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications"; import { startOsSummary } from "@/lib/desktop/os-summary"; import { startPowerWatch } from "@/lib/desktop/power"; -import { startWindowFiles } from "@/lib/desktop/window-files"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; /** * The main window's end of the desktop app's OS integration: new mail as native - * notifications, what the tray and jump list show, waking up after sleep, and files - * dropped or pasted on the window. Renders nothing, and does nothing in a browser. + * notifications, what the tray and jump list show, and waking up after sleep. Files + * dropped or pasted on the window are the file drop overlay's (components/file-drop). + * Renders nothing, and does nothing in a browser. * Lives inside MainWindowOnly, so pop-outs don't do any of it a second time. */ export function DesktopOsHost() { @@ -55,12 +55,10 @@ export function DesktopOsHost() { const summary = startOsSummary(() => localeRef.current); summaryRef.current = summary; const stopPower = startPowerWatch(); - const stopFiles = startWindowFiles(); return () => { mail.stop(); summary.stop(); stopPower(); - stopFiles(); mailRef.current = null; summaryRef.current = null; }; diff --git a/components/file-drop/__tests__/file-drop-host.test.tsx b/components/file-drop/__tests__/file-drop-host.test.tsx new file mode 100644 index 000000000..3000d3f7d --- /dev/null +++ b/components/file-drop/__tests__/file-drop-host.test.tsx @@ -0,0 +1,297 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The overlay on every screen: what shows for a drag from outside, where a drop goes, and +// the same choices as a dialog for a pasted file or one opened with the desktop app. + +const hoisted = vi.hoisted(() => ({ + push: vi.fn(), + pathname: { current: '/' }, + toastError: vi.fn(), + toastInfo: vi.fn(), + toastSuccess: vi.fn(), + locked: { current: false }, + flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })), +})); + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), +})); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: hoisted.push, replace: vi.fn(), back: vi.fn() }), + usePathname: () => hoisted.pathname.current, +})); +vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: hoisted.toastInfo, success: hoisted.toastSuccess } })); +vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers })); +vi.mock('@/lib/desktop/app-lock-state', () => ({ isAppLocked: () => hoisted.locked.current })); + +const { useAuthStore } = await import('@/stores/auth-store'); +const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); +const { setComposerFileTarget, setOwnDragActive } = await import('@/lib/desktop/window-files'); +const { setFilesDropTarget, takeQueuedFilesUpload } = await import('@/lib/file-drop/files-target'); +const { requestFileChoice } = await import('@/lib/file-drop/choice'); +const { FileDropHost } = await import('../file-drop-host'); + +interface FakeClient { + supportsFiles: () => boolean; + supportsContacts: () => boolean; + getMaxSizeUpload: () => number; + getMaxSizeAttachmentsPerEmail: () => number; +} + +function signIn(client: Partial = {}) { + useAuthStore.setState({ + isAuthenticated: true, + activeAccountId: null, + client: { + supportsFiles: () => true, + supportsContacts: () => false, + getMaxSizeUpload: () => 0, + getMaxSizeAttachmentsPerEmail: () => 0, + ...client, + } as never, + }); +} + +function transfer(files: File[]) { + return { + types: ['Files'], + files, + items: files.map((file) => ({ kind: 'file', type: file.type, getAsFile: () => file, webkitGetAsEntry: () => ({ isFile: true, isDirectory: false }) })), + dropEffect: 'none', + }; +} + +/** A drag from outside reaching the window, as the browser sends it. */ +function dragIn(files: File[]) { + const event = new Event('dragenter', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { value: transfer(files) }); + act(() => { + document.body.dispatchEvent(event); + }); +} + +function zone(id: string): HTMLElement { + const found = document.querySelector(`[data-testid="file-drop-overlay"] [data-drop-zone="${id}"]`); + if (!found) throw new Error(`no zone ${id}`); + return found; +} + +const flush = () => act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); +}); + +const file = new File(['hello'], 'report.pdf', { type: 'application/pdf' }); + +beforeEach(() => { + clearHandoffs(); + takeHandoff('compose'); + takeQueuedFilesUpload(); + hoisted.locked.current = false; + hoisted.pathname.current = '/'; + vi.clearAllMocks(); + signIn(); +}); + +afterEach(() => { + setOwnDragActive(false); + useAuthStore.setState({ isAuthenticated: false, client: null }); +}); + +describe('a drag from outside', () => { + it('covers the window with the two main choices and says how many files', () => { + render(); + dragIn([file, file]); + expect(screen.getByTestId('file-drop-overlay')).toBeTruthy(); + expect(zone('new-message').textContent).toContain('zone_new_message'); + expect(zone('upload').textContent).toContain('zone_upload_files'); + expect(screen.getByTestId('file-drop-count').textContent).toBe('files_summary:{"count":2}'); + }); + + it('a drop on "Attach to a new message" starts a draft with the file', async () => { + render(); + dragIn([file]); + fireEvent.drop(zone('new-message'), { dataTransfer: transfer([file]) }); + await flush(); + expect(takeHandoff('compose')?.files).toEqual([file]); + expect(screen.queryByTestId('file-drop-overlay')).toBeNull(); + }); + + it('with a composer on screen, its zone comes first and takes the file', async () => { + const take = vi.fn(); + const clear = setComposerFileTarget(take, () => true); + render(); + dragIn([file]); + const zones = Array.from(document.querySelectorAll('[data-drop-zone]')).map((element) => element.getAttribute('data-drop-zone')); + expect(zones[0]).toBe('attach-here'); + fireEvent.drop(zone('attach-here'), { dataTransfer: transfer([file]) }); + await flush(); + expect(take).toHaveBeenCalledWith([file]); + expect(takeHandoff('compose')).toBeNull(); + clear(); + }); + + it('on the Files screen, "Upload to " hands the files to that folder', async () => { + const take = vi.fn(); + const clear = setFilesDropTarget({ folder: 'Reports', take, visible: () => true }); + render(); + dragIn([file]); + expect(zone('upload').textContent).toContain('zone_upload_folder:{"folder":"Reports"}'); + fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) }); + await flush(); + expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false }); + clear(); + }); + + it('from another screen, Files gets the files after the app goes there', async () => { + render(); + dragIn([file]); + fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) }); + await flush(); + expect(hoisted.push).toHaveBeenCalledWith('/files'); + expect(takeQueuedFilesUpload()).toEqual({ files: [file], asFolder: false }); + }); + + it('a drop beside the zones does nothing', async () => { + render(); + dragIn([file]); + fireEvent.drop(screen.getByTestId('file-drop-overlay'), { dataTransfer: transfer([file]) }); + await flush(); + expect(takeHandoff('compose')).toBeNull(); + expect(takeQueuedFilesUpload()).toBeNull(); + }); + + it('a file over the server limit is named and left out before anything starts', async () => { + signIn({ getMaxSizeUpload: () => 3 }); + render(); + dragIn([file]); + expect(zone('upload').textContent).toContain('note_limit_file'); + fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) }); + await flush(); + expect(hoisted.toastError).toHaveBeenCalledWith(expect.stringContaining('too_large_file')); + expect(takeQueuedFilesUpload()).toBeNull(); + }); + + it('the app\'s own drags never bring it up', () => { + render(); + act(() => { + document.body.dispatchEvent(new Event('dragstart', { bubbles: true })); + }); + dragIn([file]); + expect(screen.queryByTestId('file-drop-overlay')).toBeNull(); + act(() => { + document.body.dispatchEvent(new Event('pointermove', { bubbles: true })); + }); + setOwnDragActive(true); + dragIn([file]); + expect(screen.queryByTestId('file-drop-overlay')).toBeNull(); + }); + + it('nothing while the app is locked, signed out, or behind a dialog of its own', () => { + render(); + hoisted.locked.current = true; + dragIn([file]); + expect(screen.queryByTestId('file-drop-overlay')).toBeNull(); + hoisted.locked.current = false; + const dialog = document.body.appendChild(document.createElement('div')); + dialog.setAttribute('aria-modal', 'true'); + dragIn([file]); + expect(screen.queryByTestId('file-drop-overlay')).toBeNull(); + dialog.remove(); + useAuthStore.setState({ isAuthenticated: false }); + dragIn([file]); + expect(screen.queryByTestId('file-drop-overlay')).toBeNull(); + }); + + it('leaves Files out on a server without it', () => { + signIn({ supportsFiles: () => false }); + render(); + dragIn([file]); + expect(document.querySelector('[data-drop-zone="upload"]')).toBeNull(); + expect(zone('new-message')).toBeTruthy(); + }); + + it('dragging out of the window takes it away', () => { + render(); + dragIn([file]); + const leave = new Event('dragleave', { bubbles: true }); + Object.defineProperty(leave, 'relatedTarget', { value: null }); + act(() => { + screen.getByTestId('file-drop-overlay').dispatchEvent(leave); + }); + expect(screen.queryByTestId('file-drop-overlay')).toBeNull(); + }); +}); + +describe('the same choices without a drag', () => { + function paste(target: EventTarget, files: File[], types = ['Files']) { + const event = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'clipboardData', { value: { files, types } }); + act(() => { + target.dispatchEvent(event); + }); + return event; + } + + it('a file pasted outside a text field asks, with the focus on a new message, and Enter takes it', async () => { + render(); + expect(paste(document.body, [file]).defaultPrevented).toBe(true); + const dialog = await screen.findByTestId('file-drop-choice'); + await waitFor(() => expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('new-message')); + expect(dialog.querySelector('[data-drop-zone="upload"]')).toBeTruthy(); + fireEvent.click(document.activeElement as HTMLElement); + await flush(); + expect(takeHandoff('compose')?.files).toEqual([file]); + expect(screen.queryByTestId('file-drop-choice')).toBeNull(); + }); + + it('arrow keys move between the choices', async () => { + render(); + paste(document.body, [file]); + await screen.findByTestId('file-drop-choice'); + await waitFor(() => expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('new-message')); + fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowRight' }); + expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('upload'); + }); + + it('with only one thing to do, it does it without asking', async () => { + signIn({ supportsFiles: () => false }); + render(); + paste(document.body, [file]); + await flush(); + expect(screen.queryByTestId('file-drop-choice')).toBeNull(); + expect(takeHandoff('compose')?.files).toEqual([file]); + }); + + it('pasted text, or a file in a search field, is left alone', () => { + render(); + expect(paste(document.body, [file], ['text/plain', 'Files']).defaultPrevented).toBe(false); + const input = document.body.appendChild(document.createElement('input')); + expect(paste(input, [file]).defaultPrevented).toBe(false); + input.remove(); + expect(screen.queryByTestId('file-drop-choice')).toBeNull(); + }); + + it('a bare file pasted into a composer on screen attaches to it', () => { + const take = vi.fn(); + const clear = setComposerFileTarget(take, () => true); + render(); + const composer = document.body.appendChild(document.createElement('div')); + composer.setAttribute('data-testid', 'email-composer'); + const body = composer.appendChild(document.createElement('div')); + body.setAttribute('contenteditable', 'true'); + paste(body, [file]); + expect(take).toHaveBeenCalledWith([file]); + composer.remove(); + clear(); + }); + + it('a file opened with the desktop app gets the dialog, and waits for a sign-in', async () => { + useAuthStore.setState({ isAuthenticated: false }); + render(); + act(() => requestFileChoice({ files: [file], preferred: 'new-message' })); + expect(screen.queryByTestId('file-drop-choice')).toBeNull(); + act(() => signIn()); + expect(await screen.findByTestId('file-drop-choice')).toBeTruthy(); + }); +}); diff --git a/components/file-drop/file-drop-host.tsx b/components/file-drop/file-drop-host.tsx new file mode 100644 index 000000000..4e4071bc5 --- /dev/null +++ b/components/file-drop/file-drop-host.tsx @@ -0,0 +1,334 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useTranslations } from "next-intl"; +import { usePathname, useRouter } from "@/i18n/navigation"; +import { Dialog } from "@/components/ui/dialog"; +import { Upload } from "@/components/icons"; +import { announce } from "@/lib/announce"; +import { flushComposers } from "@/lib/compose-flush-registry"; +import { matchSurface } from "@/lib/deep-links"; +import { isAppLocked } from "@/lib/desktop/app-lock-state"; +import { pasteTarget, visibleComposer } from "@/lib/desktop/window-files"; +import { runFileDropZone, type FileDropEnv } from "@/lib/file-drop/actions"; +import { listenForFileChoices, type FileChoiceRequest } from "@/lib/file-drop/choice"; +import { startDragWatch } from "@/lib/file-drop/drag-watch"; +import { filesDropTarget } from "@/lib/file-drop/files-target"; +import { readDroppedFiles, type DroppedFiles } from "@/lib/file-drop/read"; +import { + defaultZone, + fileDropZones, + filesWithinLimits, + kindForName, + type DropFiles, + type DropPlace, + type FileDropZone, + type FileDropZoneId, +} from "@/lib/file-drop/zones"; +import { localizeMailboxName } from "@/lib/mailbox-label"; +import { isOfflineNow, useOfflineStore } from "@/lib/offline/state"; +import { isPopoutPath } from "@/lib/popout/window-role"; +import { formatFileSize } from "@/lib/utils"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { usePolicyStore } from "@/stores/policy-store"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { toast } from "@/stores/toast-store"; +import { useZoneText, ZoneCard, zoneGridClass } from "./file-drop-zones"; +import { cn } from "@/lib/utils"; + +/** How long leaving the page may take to save an open composer. */ +const FLUSH_BEFORE_LEAVING_MS = 5000; +const NAMES_SHOWN = 3; + +/** Where a dropped .eml could go: the real folder in view on the mail screen, if new mail may go in. */ +function importFolder(pathname: string, translate: (key: string) => string): { id: string; name: string; accountId?: string } | null { + if (matchSurface(pathname)?.surface !== "mail") return null; + const { selectedMailbox, mailboxes } = useEmailStore.getState(); + const mailbox = mailboxes.find((entry) => entry.id === selectedMailbox); + if (!mailbox || mailbox.isShared || mailbox.myRights?.mayAddItems === false) return null; + return { id: mailbox.id, name: localizeMailboxName(mailbox.role, mailbox.name, translate), accountId: mailbox.accountId }; +} + +/** Whatever would stop a drop now. A dialog of its own (an import, a picker) keeps its own drop zone. */ +function overlayBlocked(): boolean { + if (isAppLocked() || !useAuthStore.getState().isAuthenticated) return true; + const modal = document.querySelector('[aria-modal="true"]'); + return !!modal && !modal.querySelector('[data-testid="email-composer"]'); +} + +/** What can be done with files here and now. Read at the moment of the drag, not kept. */ +function currentPlace(pathname: string, translate: (key: string) => string): { place: DropPlace; folder: ReturnType } { + const auth = useAuthStore.getState(); + const client = auth.client; + const features = usePolicyStore.getState(); + const account = auth.activeAccountId ? useAccountStore.getState().accounts.find((entry) => entry.id === auth.activeAccountId) : undefined; + const folder = importFolder(pathname, translate); + const place: DropPlace = { + composer: !!visibleComposer(), + popout: isPopoutPath(window.location.pathname), + files: !!client?.supportsFiles?.() && features.isFeatureEnabled("filesEnabled"), + filesFolder: filesDropTarget()?.folder ?? null, + calendar: useCalendarStore.getState().supportsCalendar && features.isFeatureEnabled("calendarEnabled"), + contacts: !!client?.supportsContacts?.() && features.isFeatureEnabled("contactsEnabled"), + importFolder: folder?.name ?? null, + offline: isOfflineNow(account?.serverUrl), + keepsOffline: !!auth.activeAccountId && useOfflineStore.getState().enabled.includes(auth.activeAccountId), + maxUploadBytes: client?.getMaxSizeUpload?.() ?? 0, + maxMessageBytes: client?.getMaxSizeAttachmentsPerEmail?.() ?? 0, + }; + return { place, folder }; +} + +interface DragState { + files: DropFiles; + zones: FileDropZone[]; + place: DropPlace; + folder: ReturnType; +} + +interface ChoiceState extends DragState { + list: File[]; + preferred?: FileDropZoneId; +} + +function asDropped(files: File[]): DroppedFiles { + return { plain: files, all: files, folders: false }; +} + +function summaryOf(files: readonly File[]): DropFiles { + return { count: files.length, kinds: files.map((file) => kindForName(file)), bytes: files.reduce((sum, file) => sum + file.size, 0) }; +} + +/** + * The file drop overlay on every screen: files dragged in from outside get big zones to drop + * on, the same zones come up as a dialog for a pasted file or a file opened with the desktop + * app. Mounted once in the locale layout, in pop-outs too, where only their own composer + * takes files. + */ +export function FileDropHost() { + const t = useTranslations("fileDrop"); + const tSidebar = useTranslations("sidebar"); + const { label, note } = useZoneText(); + const router = useRouter(); + const pathname = usePathname(); + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const [drag, setDrag] = useState(null); + const [active, setActive] = useState(null); + const [choice, setChoice] = useState(null); + const [pendingChoices, setPendingChoices] = useState([]); + const defaultRef = useRef(null); + + const ctx = useRef({ pathname, router, t, tSidebar, label }); + ctx.current = { pathname, router, t, tSidebar, label }; + const translateMailbox = useCallback((key: string) => ctx.current.tSidebar(`mailboxes.${key}`), []); + + const countText = useCallback((files: DropFiles) => { + if (files.count === null) return t("files_unknown"); + if (files.bytes !== null) return t("files_summary_size", { count: files.count, size: formatFileSize(files.bytes) }); + return t("files_summary", { count: files.count }); + }, [t]); + + const env = useCallback((folder: ReturnType): FileDropEnv => ({ + goTo: async (path) => { + const result = await flushComposers(FLUSH_BEFORE_LEAVING_MS); + if (result.failed > 0 || result.timedOut) { + toast.error(ctx.current.t("save_first_failed")); + return; + } + if (ctx.current.pathname !== path) ctx.current.router.push(path); + }, + importMessages: async (files) => { + const client = useAuthStore.getState().client; + if (!client || !folder) return; + let done = 0; + for (const file of files) { + try { + await client.importRawEmail(file, { [folder.id]: true }, { $seen: true }, folder.accountId); + done += 1; + } catch (error) { + console.error("Importing a dropped message failed:", error); + toast.error(ctx.current.t("import_failed", { name: file.name })); + } + } + if (done > 0) toast.success(ctx.current.t("import_done", { count: done, folder: folder.name })); + }, + }), []); + + /** Checks what only the files in hand can tell, then hands them over. */ + const run = useCallback((zone: FileDropZoneId, dropped: DroppedFiles, state: DragState) => { + const upload = zone === "upload"; + if (!upload && dropped.folders) toast.info(ctx.current.t("folders_left_out")); + const { fitting, tooLarge, overMessage } = filesWithinLimits(zone, upload ? dropped.all : dropped.plain, state.place); + for (const file of tooLarge) toast.error(ctx.current.t("too_large_file", { name: file.name, max: formatFileSize(state.place.maxUploadBytes) })); + if (overMessage) toast.error(ctx.current.t("too_large_message", { max: formatFileSize(state.place.maxMessageBytes) })); + if (fitting.length === 0) return; + const what = upload ? { files: fitting, asFolder: dropped.folders } : undefined; + void runFileDropZone(zone, fitting, env(state.folder), what).catch((error) => { + console.error("A file drop failed:", error); + }); + }, [env]); + + // Files dragged in from outside, anywhere. + useEffect(() => { + return startDragWatch({ + enter: (files) => { + if (overlayBlocked()) return false; + const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox); + const zones = fileDropZones(files, place); + if (zones.length === 0) return false; + setActive(null); + setDrag({ files, zones, place, folder }); + const choices = zones.map((zone) => ctx.current.label(zone)).join(", "); + announce(ctx.current.t("announce", { files: files.count ?? 0, choices })); + return true; + }, + leave: () => { + setDrag(null); + setActive(null); + }, + }); + }, [translateMailbox]); + + // A file pasted outside a text field: the same choices, from the keyboard. + useEffect(() => { + const onPaste = (event: ClipboardEvent) => { + const where = pasteTarget(event); + if (!where) return; + const files = Array.from(event.clipboardData?.files ?? []); + if (where === "composer") { + event.preventDefault(); + visibleComposer()?.take(files); + return; + } + if (overlayBlocked()) return; + event.preventDefault(); + setPendingChoices((list) => [...list, { files, preferred: visibleComposer() ? "attach-here" : "new-message" }]); + }; + window.addEventListener("paste", onPaste); + return () => window.removeEventListener("paste", onPaste); + }, []); + + // Files opened with the desktop app. + useEffect(() => listenForFileChoices((request) => setPendingChoices((list) => [...list, request])), []); + + // One question at a time, and none while signed out or locked. + useEffect(() => { + if (choice || pendingChoices.length === 0 || !isAuthenticated || isAppLocked()) return; + const [next, ...rest] = pendingChoices; + setPendingChoices(rest); + const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox); + const files = summaryOf(next.files); + const zones = fileDropZones(files, place); + const usable = zones.filter((zone) => !zone.disabled); + if (usable.length === 1) { + run(usable[0].id, asDropped(next.files), { files, zones, place, folder }); + return; + } + if (zones.length === 0) { + toast.error(ctx.current.t("nothing_here")); + return; + } + setChoice({ files, zones, place, folder, list: next.files, preferred: next.preferred }); + }, [choice, pendingChoices, isAuthenticated, run, translateMailbox]); + + const pick = (zone: FileDropZone) => { + if (!choice || zone.disabled) return; + const state = choice; + setChoice(null); + run(zone.id, asDropped(state.list), state); + }; + + const zoneAt = (target: EventTarget | null): FileDropZone | null => { + const element = (target as Element | null)?.closest?.("[data-drop-zone]"); + const id = element?.getAttribute("data-drop-zone"); + return drag?.zones.find((zone) => zone.id === id) ?? null; + }; + + const overlay = drag && typeof document !== "undefined" && createPortal( +
{ + event.preventDefault(); + const zone = zoneAt(event.target); + const usable = zone && !zone.disabled ? zone : null; + event.dataTransfer.dropEffect = usable ? "copy" : "none"; + if ((usable?.id ?? null) !== active) setActive(usable?.id ?? null); + }} + onDrop={(event) => { + event.preventDefault(); + const zone = zoneAt(event.target); + const state = drag; + setDrag(null); + setActive(null); + if (!zone || zone.disabled) return; + void readDroppedFiles(event.dataTransfer).then((dropped) => run(zone.id, dropped, state)); + }} + > +
+
+
+ {drag.zones.map((zone) => ( + + ))} +
+

{t("overlay_hint")}

+
, + document.body, + ); + + const preferred = choice ? defaultZone(choice.zones, choice.preferred) : undefined; + const names = choice?.list.map((file) => file.name) ?? []; + + return ( + <> + {overlay} + setChoice(null)} + title={t("choose_title", { count: choice?.list.length ?? 0 })} + description={choice ? `${names.slice(0, NAMES_SHOWN).join(", ")}${names.length > NAMES_SHOWN ? ", …" : ""} (${countText(choice.files)})` : undefined} + size="lg" + initialFocus={defaultRef} + testId="file-drop-choice" + > + {choice && ( +
{ + if (!["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp"].includes(event.key)) return; + const buttons = Array.from(event.currentTarget.querySelectorAll("button[data-drop-zone]")); + const at = buttons.indexOf(document.activeElement as HTMLButtonElement); + if (at === -1) return; + event.preventDefault(); + const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1; + buttons[(at + step + buttons.length) % buttons.length].focus(); + }} + > + {choice.zones.map((zone) => ( + pick(zone)} + /> + ))} +
+ )} +
+ + ); +} diff --git a/components/file-drop/file-drop-zones.tsx b/components/file-drop/file-drop-zones.tsx new file mode 100644 index 000000000..f47b07c7b --- /dev/null +++ b/components/file-drop/file-drop-zones.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { forwardRef, type ComponentType, type ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { CalendarPlus, FolderInput, MailOpen, Paperclip, SquarePen, Upload, UserPlus } from "@/components/icons"; +import type { FileDropNote, FileDropZone, FileDropZoneId } from "@/lib/file-drop/zones"; +import { cn, formatFileSize } from "@/lib/utils"; + +const ICONS: Record> = { + "attach-here": Paperclip, + "new-message": SquarePen, + upload: Upload, + calendar: CalendarPlus, + contacts: UserPlus, + "open-eml": MailOpen, + "import-eml": FolderInput, +}; + +export function useZoneText() { + const t = useTranslations("fileDrop"); + const label = (zone: FileDropZone): string => { + switch (zone.id) { + case "attach-here": return t("zone_attach_here"); + case "new-message": return t("zone_new_message"); + case "upload": return zone.folder ? t("zone_upload_folder", { folder: zone.folder }) : t("zone_upload_files"); + case "calendar": return t("zone_calendar"); + case "contacts": return t("zone_contacts"); + case "open-eml": return t("zone_open_eml"); + case "import-eml": return t("zone_import_eml", { folder: zone.folder ?? "" }); + } + }; + const note = (value: FileDropNote | undefined): string | null => { + if (!value) return null; + switch (value.key) { + case "note_limit_message": return t("note_limit_message", { size: formatFileSize(value.size) }); + case "note_limit_file": return t("note_limit_file", { size: formatFileSize(value.size) }); + case "note_kept_offline": return t("note_kept_offline"); + case "note_needs_connection": return t("note_needs_connection"); + } + }; + return { label, note }; +} + +interface ZoneCardProps { + zone: FileDropZone; + label: string; + note: string | null; + active?: boolean; + /** "button" in the dialog; a plain region during a drag, which nothing can focus. */ + as?: "button" | "div"; + onClick?: () => void; + onKeyDown?: (event: React.KeyboardEvent) => void; + children?: ReactNode; +} + +/** One big choice: the same look as a drop target and as a button. */ +export const ZoneCard = forwardRef(function ZoneCard( + { zone, label, note, active = false, as = "div", onClick, onKeyDown }, + ref, +) { + const Icon = ICONS[zone.id]; + const className = cn( + "group flex min-h-36 flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-6 text-center transition-colors select-none", + zone.disabled + ? "border-border bg-muted/40 text-muted-foreground cursor-not-allowed" + : active + ? "border-primary bg-primary/10 text-foreground ring-4 ring-primary/20" + : "border-border bg-background/90 text-foreground hover:border-primary/60", + as === "button" && !zone.disabled && "focus-visible:outline-none focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/30", + ); + const content = ( + <> + + {label} + {note && {note}} + + ); + if (as === "button") { + return ( + + ); + } + return ( +
+ {content} +
+ ); +}); + +/** Two zones side by side, three in a row, four as two by two (a row on a wide window). */ +export function zoneGridClass(count: number): string { + if (count <= 1) return "grid-cols-1 max-w-md"; + if (count === 2) return "grid-cols-1 sm:grid-cols-2 max-w-3xl"; + if (count === 3) return "grid-cols-1 sm:grid-cols-3 max-w-5xl"; + return "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 max-w-6xl"; +} diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 04fe72301..a4bdf9456 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -8,7 +8,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store'; import { useAccountStore } from "@/stores/account-store"; import { useEmailStore } from "@/stores/email-store"; -import { useFileStore, deletesToTrash, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store"; +import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store"; +import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target"; import { isTrashLocation } from "@/lib/files-trash"; import { fileErrorKind, isPartialCopy } from "@/lib/file-errors"; import { toast } from "@/stores/toast-store"; @@ -805,6 +806,37 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { useFileStore.setState({ supportsFiles: null }); }, [getClientForAccount]); + // The file drop overlay: "Upload to " while this screen is on view in a folder + // that takes files, and what was dropped elsewhere for Files once one is listed. + const dropRootRef = useRef(null); + const canTakeDrops = supportsFiles === true && !isAccountPicker && !isSharedFilesPage(currentParentId) && !isTrashLocation(currentParentId); + const dropUploadRef = useRef({ files: handleUploadFiles, folder: handleUploadFolder }); + dropUploadRef.current = { files: handleUploadFiles, folder: handleUploadFolder }; + useEffect(() => { + if (!canTakeDrops) return; + const folder = currentPath === "/" ? null : currentPath.split("/").filter(Boolean).pop() ?? null; + return setFilesDropTarget({ + folder, + take: ({ files, asFolder }) => void (asFolder ? dropUploadRef.current.folder(files) : dropUploadRef.current.files(files)), + visible: () => !!dropRootRef.current?.isConnected && dropRootRef.current.getClientRects().length > 0, + }); + }, [canTakeDrops, currentPath]); + useEffect(() => { + if (supportsFiles !== true || isLoading || !storeClient || isAccountPicker) return; + const take = () => { + if (!hasQueuedFilesUpload()) return; + // In the trash or the list of shared drives: to the top of Files first. + if (!canTakeDrops) { + void navigate(null); + return; + } + const upload = takeQueuedFilesUpload(); + if (upload) void (upload.asFolder ? dropUploadRef.current.folder(upload.files) : dropUploadRef.current.files(upload.files)); + }; + take(); + return subscribeQueuedFilesUpload(take); + }, [supportsFiles, isLoading, storeClient, isAccountPicker, canTakeDrops, navigate]); + // Until the session is restored: the page skeleton the server drew. if (!isAuthenticated) { return isEmbedded @@ -813,7 +845,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { } return ( -
+
{!isEmbedded && {tShell("skip_to_content")}} {refreshIndicator} diff --git a/lib/desktop/__tests__/os-window.test.tsx b/lib/desktop/__tests__/os-window.test.tsx index b6df496bb..84cbc30ff 100644 --- a/lib/desktop/__tests__/os-window.test.tsx +++ b/lib/desktop/__tests__/os-window.test.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { BulwarkDesktopBridge, DesktopOsPowerEvent, DesktopOsStatus } from '../types'; import type { CalendarEvent } from '@/lib/jmap/types'; -/** The window's end: badge, dropped and pasted files, drag-out, waking up, the settings section. */ +/** The window's end: badge, drag-out, waking up, the settings section. Dropped and pasted files: components/file-drop. */ vi.mock('next-intl', () => ({ useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), @@ -90,176 +90,6 @@ describe('the policy keeps refreshing', () => { }); }); -function dropEvent(type: string, files: File[]): Event { - const event = new Event(type, { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'dataTransfer', { value: { types: files.length ? ['Files'] : ['text/plain'], files, dropEffect: 'none' } }); - return event; -} - -describe('files on the window', () => { - const file = new File(['x'], 'report.pdf'); - - beforeEach(async () => { - const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff'); - clearHandoffs(); - takeHandoff('compose'); - }); - - it('nothing happens in a browser', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - const stop = startWindowFiles(); - const event = dropEvent('drop', [file]); - window.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - expect(takeHandoff('compose')).toBeNull(); - stop(); - }); - - it('a drop on the window starts a new draft with the file', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const over = dropEvent('dragover', [file]); - window.dispatchEvent(over); - expect(over.defaultPrevented).toBe(true); - const drop = dropEvent('drop', [file]); - window.dispatchEvent(drop); - expect(drop.defaultPrevented).toBe(true); - expect(takeHandoff('compose')?.files).toEqual([file]); - stop(); - }); - - it('a drop something else took is left alone', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const event = dropEvent('drop', [file]); - event.preventDefault(); - window.dispatchEvent(event); - expect(takeHandoff('compose')).toBeNull(); - stop(); - }); - - it('text dragged around is not a file', async () => { - const { startWindowFiles } = await import('../window-files'); - setBridge({}); - const stop = startWindowFiles(); - const event = dropEvent('dragover', []); - window.dispatchEvent(event); - expect(event.defaultPrevented).toBe(false); - stop(); - }); - - it('an open composer takes the file itself', async () => { - const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const target = vi.fn(); - const clear = setComposerFileTarget(target, () => true); - window.dispatchEvent(dropEvent('drop', [file])); - expect(target).toHaveBeenCalledWith([file]); - expect(takeHandoff('compose')).toBeNull(); - clear(); - stop(); - }); - - it('a composer out of sight on a hidden tab takes nothing', async () => { - const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const hidden = vi.fn(); - const clear = setComposerFileTarget(hidden, () => false); - window.dispatchEvent(dropEvent('drop', [file])); - expect(hidden).not.toHaveBeenCalled(); - expect(takeHandoff('compose')?.files).toEqual([file]); - clear(); - stop(); - }); - - it('a pasted file is taken; pasted text is not', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const paste = (files: File[]) => { - const event = new Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'clipboardData', { value: { files, types: files.length ? ['Files'] : ['text/plain'] } }); - window.dispatchEvent(event); - return event; - }; - expect(paste([]).defaultPrevented).toBe(false); - expect(takeHandoff('compose')).toBeNull(); - expect(paste([file]).defaultPrevented).toBe(true); - expect(takeHandoff('compose')?.files).toEqual([file]); - stop(); - }); - - describe('a paste that is not for us', () => { - const pasteOn = (target: EventTarget, types: string[]) => { - const event = new Event('paste', { bubbles: true, cancelable: true }); - Object.defineProperty(event, 'clipboardData', { value: { files: [file], types } }); - target.dispatchEvent(event); - return event; - }; - - it('text with a file, as a spreadsheet copies, stays text', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - expect(pasteOn(document.body, ['text/plain', 'Files']).defaultPrevented).toBe(false); - expect(takeHandoff('compose')).toBeNull(); - stop(); - }); - - it('in a search field or any other input, a file is not an attachment', async () => { - const { startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - const input = document.body.appendChild(document.createElement('input')); - expect(pasteOn(input, ['Files']).defaultPrevented).toBe(false); - expect(takeHandoff('compose')).toBeNull(); - input.remove(); - stop(); - }); - - it('in the body of a composer on screen, a bare file attaches', async () => { - const { setComposerFileTarget, startWindowFiles } = await import('../window-files'); - setBridge({}); - const stop = startWindowFiles(); - const target = vi.fn(); - const clear = setComposerFileTarget(target, () => true); - const composer = document.body.appendChild(document.createElement('div')); - composer.setAttribute('data-testid', 'email-composer'); - const body = composer.appendChild(document.createElement('div')); - body.setAttribute('contenteditable', 'true'); - pasteOn(body, ['Files']); - expect(target).toHaveBeenCalledWith([file]); - composer.remove(); - clear(); - stop(); - }); - }); - - it('our own native drag over our own window is not a new file', async () => { - const { setOwnDragActive, startWindowFiles } = await import('../window-files'); - const { takeHandoff } = await import('@/lib/launch-handoff'); - setBridge({}); - const stop = startWindowFiles(); - setOwnDragActive(true); - window.dispatchEvent(dropEvent('drop', [file])); - expect(takeHandoff('compose')).toBeNull(); - setOwnDragActive(false); - stop(); - }); -}); - describe('dragging an attachment out', () => { const blobUrl = 'blob:https://app/1'; const source = { diff --git a/lib/desktop/window-files.ts b/lib/desktop/window-files.ts index 63a67630b..4666b0e40 100644 --- a/lib/desktop/window-files.ts +++ b/lib/desktop/window-files.ts @@ -1,13 +1,9 @@ /** - * Files dropped on a window or pasted from the clipboard. Whatever already handles a - * drop keeps doing so, which it shows by preventDefault: the composer, the file page, - * calendar import. The rest becomes an attachment on a new draft, or on the composer - * that's open. + * Who takes files on this window: the composer on screen, for files pasted into it or + * dropped on its zone of the file drop overlay (lib/file-drop). And whether a drag going + * on is the app's own native one, which is no new file. */ -import { getDesktopBridge } from '@/lib/desktop/bridge'; -import { handOffDraft } from '@/lib/launch-handoff'; - interface ComposerTarget { take: (files: File[]) => unknown; /** The Pro shell keeps composers mounted on tabs out of sight. */ @@ -18,7 +14,7 @@ const composerTargets: ComposerTarget[] = []; /** Our own native drag passing over our own window isn't a new file. */ let ownDrag = false; -/** A composer on screen takes files itself. No effect outside the desktop app: only this file reads it. */ +/** A composer on screen takes files itself. */ export function setComposerFileTarget(take: ComposerTarget['take'], visible: ComposerTarget['visible']): () => void { const target = { take, visible }; composerTargets.push(target); @@ -28,7 +24,8 @@ export function setComposerFileTarget(take: ComposerTarget['take'], visible: Com }; } -function visibleComposer(): ComposerTarget | undefined { +/** The newest composer on screen, if any. */ +export function visibleComposer(): ComposerTarget | undefined { return [...composerTargets].reverse().find((target) => target.visible()); } @@ -36,10 +33,8 @@ export function setOwnDragActive(active: boolean): void { ownDrag = active; } -function take(files: File[]): void { - const composer = visibleComposer(); - if (composer) composer.take(files); - else handOffDraft({ files }); +export function isOwnDragActive(): boolean { + return ownDrag; } function isEditable(target: EventTarget | null): boolean { @@ -48,48 +43,17 @@ function isEditable(target: EventTarget | null): boolean { } /** - * Pasting a file is only meant where text isn't. A spreadsheet's cells also come as a - * picture of them, and a field that takes text wants the text. The one editable place a - * bare file belongs in is the composer it's being pasted into. + * Where a pasted file goes, if anywhere. Pasting a file is only meant where text isn't. A + * spreadsheet's cells also come as a picture of them, and a field that takes text wants + * the text. The one editable place a bare file belongs in is the composer it's being + * pasted into. */ -function pasteIsForUs(event: ClipboardEvent): boolean { +export function pasteTarget(event: ClipboardEvent): 'composer' | 'choose' | null { + if (event.defaultPrevented) return null; + if ((event.clipboardData?.files?.length ?? 0) === 0) return null; const types = [...(event.clipboardData?.types ?? [])]; - if (types.includes('text/plain') || types.includes('text/html')) return false; - if (!isEditable(event.target)) return true; + if (types.includes('text/plain') || types.includes('text/html')) return null; + if (!isEditable(event.target)) return 'choose'; const inComposer = !!(event.target as Partial | null)?.closest?.('[data-testid="email-composer"]'); - return inComposer && !!visibleComposer(); -} - -function hasFiles(event: DragEvent): boolean { - return !!event.dataTransfer && [...event.dataTransfer.types].includes('Files'); -} - -export function startWindowFiles(): () => void { - if (!getDesktopBridge()?.os) return () => {}; - const onDragOver = (event: DragEvent) => { - if (event.defaultPrevented || ownDrag || !hasFiles(event)) return; - event.preventDefault(); - if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; - }; - const onDrop = (event: DragEvent) => { - if (event.defaultPrevented || ownDrag || !hasFiles(event)) return; - event.preventDefault(); - const files = Array.from(event.dataTransfer?.files ?? []); - if (files.length > 0) take(files); - }; - const onPaste = (event: ClipboardEvent) => { - if (event.defaultPrevented) return; - const files = Array.from(event.clipboardData?.files ?? []); - if (files.length === 0 || !pasteIsForUs(event)) return; - event.preventDefault(); - take(files); - }; - window.addEventListener('dragover', onDragOver); - window.addEventListener('drop', onDrop); - window.addEventListener('paste', onPaste); - return () => { - window.removeEventListener('dragover', onDragOver); - window.removeEventListener('drop', onDrop); - window.removeEventListener('paste', onPaste); - }; + return inComposer && visibleComposer() ? 'composer' : null; } diff --git a/lib/file-drop/__tests__/drag-watch.test.ts b/lib/file-drop/__tests__/drag-watch.test.ts new file mode 100644 index 000000000..d0358dbba --- /dev/null +++ b/lib/file-drop/__tests__/drag-watch.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { DRAG_STALE_MS, startDragWatch } from '../drag-watch'; +import type { DropFiles } from '../zones'; +import { setOwnDragActive } from '@/lib/desktop/window-files'; + +/** jsdom has no DragEvent: an Event with the parts the watch reads. */ +function drag(type: string, options: { files?: number; types?: string[]; itemTypes?: string[]; relatedTarget?: EventTarget | null; target?: EventTarget } = {}): Event { + const event = new Event(type, { bubbles: true, cancelable: true }); + const count = options.files ?? 1; + const types = options.types ?? (count > 0 ? ['Files'] : ['text/plain']); + const items = (options.itemTypes ?? Array.from({ length: count }, () => '')).map((itemType) => ({ kind: 'file', type: itemType })); + Object.defineProperty(event, 'dataTransfer', { value: { types, items, files: [], dropEffect: 'none' } }); + if ('relatedTarget' in options) Object.defineProperty(event, 'relatedTarget', { value: options.relatedTarget }); + (options.target ?? document.body).dispatchEvent(event); + return event; +} + +describe('noticing files dragged in from outside', () => { + let enter: Mock<(files: DropFiles) => boolean>; + let leave: Mock<() => void>; + let stop: () => void; + + beforeEach(() => { + vi.useFakeTimers(); + enter = vi.fn((_files: DropFiles) => true); + leave = vi.fn(() => {}); + stop = startDragWatch({ enter, leave }); + }); + + afterEach(() => { + stop(); + setOwnDragActive(false); + vi.useRealTimers(); + }); + + it('tells how many files and what kind, as far as the drag shows', () => { + drag('dragenter', { files: 2, itemTypes: ['text/calendar', 'application/pdf'] }); + expect(enter).toHaveBeenCalledWith({ count: 2, kinds: ['calendar', 'other'], bytes: null }); + }); + + it('a drag that started in the page is the app\'s own, not files from outside', () => { + document.body.dispatchEvent(new Event('dragstart', { bubbles: true })); + drag('dragenter'); + expect(enter).not.toHaveBeenCalled(); + // The first pointer event after a drag means it's over, even if dragend went nowhere. + document.body.dispatchEvent(new Event('pointermove', { bubbles: true })); + drag('dragenter'); + expect(enter).toHaveBeenCalledTimes(1); + }); + + it('the app\'s own native drag of an attachment is not a new file either', () => { + setOwnDragActive(true); + drag('dragenter'); + expect(enter).not.toHaveBeenCalled(); + }); + + it('text dragged around is no file', () => { + drag('dragenter', { files: 0 }); + expect(enter).not.toHaveBeenCalled(); + }); + + it('a component under the pointer does not get the first enter once the overlay takes it', () => { + const inner = document.body.appendChild(document.createElement('div')); + const seen = vi.fn(); + inner.addEventListener('dragenter', seen); + drag('dragenter', { target: inner }); + expect(seen).not.toHaveBeenCalled(); + inner.remove(); + }); + + it('leaving the window ends it; moving between elements does not', () => { + drag('dragenter'); + drag('dragleave', { relatedTarget: document.body }); + expect(leave).not.toHaveBeenCalled(); + drag('dragleave', { relatedTarget: null }); + expect(leave).toHaveBeenCalledTimes(1); + }); + + it('ends by itself when the drag goes quiet, as after Esc in some browsers', () => { + drag('dragenter'); + vi.advanceTimersByTime(DRAG_STALE_MS / 2); + drag('dragover'); + vi.advanceTimersByTime(DRAG_STALE_MS / 2); + expect(leave).not.toHaveBeenCalled(); + vi.advanceTimersByTime(DRAG_STALE_MS); + expect(leave).toHaveBeenCalledTimes(1); + }); + + it('a drop anywhere ends it, and never lets the browser open the file instead of the app', async () => { + drag('dragenter'); + const over = drag('dragover'); + expect(over.defaultPrevented).toBe(true); + const dropped = drag('drop'); + expect(dropped.defaultPrevented).toBe(true); + await Promise.resolve(); + expect(leave).toHaveBeenCalledTimes(1); + }); + + it('a drag the host turns down (locked, a dialog open) still cannot open the file in place of the app', () => { + enter.mockReturnValue(false); + drag('dragenter'); + expect(drag('dragover').defaultPrevented).toBe(true); + expect(drag('drop').defaultPrevented).toBe(true); + expect(leave).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/file-drop/__tests__/zones.test.ts b/lib/file-drop/__tests__/zones.test.ts new file mode 100644 index 000000000..bc1297903 --- /dev/null +++ b/lib/file-drop/__tests__/zones.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { defaultZone, fileDropZones, filesWithinLimits, kindForType, MAX_ZONES, type DropFiles, type DropPlace } from '../zones'; + +const place = (patch: Partial = {}): DropPlace => ({ + composer: false, + popout: false, + files: true, + filesFolder: null, + calendar: true, + contacts: true, + importFolder: null, + offline: false, + keepsOffline: false, + maxUploadBytes: 0, + maxMessageBytes: 0, + ...patch, +}); + +const drag = (kinds: DropFiles['kinds']): DropFiles => ({ count: kinds?.length ?? null, kinds, bytes: null }); +const ids = (files: DropFiles, where: DropPlace) => fileDropZones(files, where).map((zone) => zone.id); + +describe('the file drop zones', () => { + it('offer a new message and Files for any file', () => { + expect(ids(drag(['other']), place())).toEqual(['new-message', 'upload']); + }); + + it('leave Files out where the server or the organisation has none', () => { + expect(ids(drag(['other']), place({ files: false }))).toEqual(['new-message']); + }); + + it('put an open composer first', () => { + expect(ids(drag(['other', 'other']), place({ composer: true }))).toEqual(['attach-here', 'new-message', 'upload']); + }); + + it('name the folder on the Files screen', () => { + const upload = fileDropZones(drag(['other']), place({ filesFolder: 'Reports' })).find((zone) => zone.id === 'upload'); + expect(upload?.folder).toBe('Reports'); + }); + + it('add the calendar for one .ics, contacts for one .vcf, open and import for an .eml', () => { + expect(ids(drag(['calendar']), place())).toEqual(['calendar', 'new-message', 'upload']); + expect(ids(drag(['contacts']), place())).toEqual(['contacts', 'new-message', 'upload']); + expect(ids(drag(['eml']), place({ importFolder: 'Inbox' }))).toEqual(['open-eml', 'import-eml', 'new-message', 'upload']); + }); + + it('offer nothing special for a mix or for a calendar the account does not have', () => { + expect(ids(drag(['calendar', 'other']), place())).toEqual(['new-message', 'upload']); + expect(ids(drag(['calendar']), place({ calendar: false }))).toEqual(['new-message', 'upload']); + expect(ids({ count: null, kinds: null, bytes: null }, place())).toEqual(['new-message', 'upload']); + }); + + it('import several messages, but open only one', () => { + expect(ids(drag(['eml', 'eml']), place({ importFolder: 'Inbox' }))).toEqual(['import-eml', 'new-message', 'upload']); + }); + + it('never show more than four, keeping both main ones and the composer', () => { + const zones = ids(drag(['eml']), place({ composer: true, importFolder: 'Inbox' })); + expect(zones).toHaveLength(MAX_ZONES); + expect(zones).toEqual(['attach-here', 'open-eml', 'new-message', 'upload']); + }); + + it('in a pop-out, only its own composer', () => { + expect(ids(drag(['calendar']), place({ popout: true, composer: true }))).toEqual(['attach-here']); + expect(ids(drag(['other']), place({ popout: true }))).toEqual([]); + }); + + it('offline: Files and the import say they need a connection, a draft without offline mode too', () => { + const zones = fileDropZones(drag(['eml']), place({ offline: true, importFolder: 'Inbox' })); + const byId = Object.fromEntries(zones.map((zone) => [zone.id, zone])); + expect(byId.upload).toMatchObject({ disabled: true, note: { key: 'note_needs_connection' } }); + expect(byId['import-eml']).toMatchObject({ disabled: true }); + expect(byId['new-message']).toMatchObject({ disabled: true, note: { key: 'note_needs_connection' } }); + expect(byId['open-eml'].disabled).toBeUndefined(); + }); + + it('offline with offline mode: a draft keeps the files until the connection is back', () => { + const zones = fileDropZones(drag(['other']), place({ offline: true, keepsOffline: true })); + expect(zones.find((zone) => zone.id === 'new-message')).toEqual({ id: 'new-message', note: { key: 'note_kept_offline' } }); + }); + + it('show the server limits while the sizes are still unknown', () => { + const zones = fileDropZones(drag(['other']), place({ maxUploadBytes: 50, maxMessageBytes: 100 })); + expect(zones.find((zone) => zone.id === 'new-message')?.note).toEqual({ key: 'note_limit_message', size: 100 }); + expect(zones.find((zone) => zone.id === 'upload')?.note).toEqual({ key: 'note_limit_file', size: 50 }); + }); + + it('read the kind from the MIME type a drag shows', () => { + expect(kindForType('text/calendar')).toBe('calendar'); + expect(kindForType('message/rfc822')).toBe('eml'); + expect(kindForType('text/x-vcard')).toBe('contacts'); + expect(kindForType('')).toBe('other'); + }); +}); + +describe('limits once the files are in hand', () => { + const sized = (name: string, size: number) => new File([new Uint8Array(size)], name); + + it('leave out a file over the per-file limit and keep the rest', () => { + const big = sized('big.zip', 60); + const small = sized('a.txt', 10); + expect(filesWithinLimits('upload', [big, small], { maxUploadBytes: 50, maxMessageBytes: 0 })).toEqual({ fitting: [small], tooLarge: [big], overMessage: false }); + }); + + it('refuse a message whose files are too large together', () => { + const files = [sized('a', 30), sized('b', 30)]; + expect(filesWithinLimits('new-message', files, { maxUploadBytes: 0, maxMessageBytes: 50 })).toMatchObject({ fitting: [], overMessage: true }); + expect(filesWithinLimits('upload', files, { maxUploadBytes: 0, maxMessageBytes: 50 }).fitting).toHaveLength(2); + }); +}); + +describe('the keyboard default', () => { + it('is the asked-for zone when usable, else the first usable', () => { + const zones = fileDropZones(drag(['other']), place({ composer: true })); + expect(defaultZone(zones, 'new-message')?.id).toBe('new-message'); + expect(defaultZone(zones)?.id).toBe('attach-here'); + const offline = fileDropZones(drag(['other']), place({ offline: true, keepsOffline: true })); + expect(defaultZone(offline, 'upload')?.id).toBe('new-message'); + }); +}); diff --git a/lib/file-drop/actions.ts b/lib/file-drop/actions.ts new file mode 100644 index 000000000..fed28072a --- /dev/null +++ b/lib/file-drop/actions.ts @@ -0,0 +1,58 @@ +/** + * What each zone of the file drop overlay does with the files, through the paths the app + * already has: a composer's own attach (its size checks and plugin hooks included), the + * launch handoff for a new draft and for .ics, .vcf and .eml, the Files screen's upload. + */ + +import { visibleComposer } from '@/lib/desktop/window-files'; +import { handOffDraft, handOffFiles } from '@/lib/launch-handoff'; +import { filesDropTarget, queueFilesUpload, type FilesUpload } from './files-target'; +import type { FileDropZoneId } from './zones'; + +export interface FileDropEnv { + /** Goes to a page, saving an open composer first. */ + goTo: (path: '/files') => void | Promise; + /** Email/import into the folder the zone named. */ + importMessages: (files: File[]) => void | Promise; +} + +/** `files` without folders for every zone; `upload` also gets what a dropped folder held. */ +export async function runFileDropZone(zone: FileDropZoneId, files: File[], env: FileDropEnv, upload?: FilesUpload): Promise { + if (zone === 'upload') { + const what = upload ?? { files, asFolder: false }; + if (what.files.length === 0) return; + const target = filesDropTarget(); + if (target) { + target.take(what); + return; + } + queueFilesUpload(what); + await env.goTo('/files'); + return; + } + if (files.length === 0) return; + switch (zone) { + case 'attach-here': { + const composer = visibleComposer(); + // Gone between the drag and the drop: a new message is the next best thing. + if (composer) composer.take(files); + else handOffDraft({ files }); + return; + } + case 'new-message': + handOffDraft({ files }); + return; + case 'calendar': + handOffFiles(files, 'calendar'); + return; + case 'contacts': + handOffFiles(files, 'contacts'); + return; + case 'open-eml': + handOffFiles(files.slice(0, 1), 'eml'); + return; + case 'import-eml': + await env.importMessages(files); + return; + } +} diff --git a/lib/file-drop/choice.ts b/lib/file-drop/choice.ts new file mode 100644 index 000000000..5fd6e2559 --- /dev/null +++ b/lib/file-drop/choice.ts @@ -0,0 +1,35 @@ +/** + * The overlay's choices without a drag: for files pasted outside a text field, and for a + * file opened with the desktop app ("Open with" in Explorer, Finder or a file manager). + * The host shows them as a dialog that works from the keyboard. Requests wait for the + * host to mount, and the host waits for a sign-in. + */ + +import type { FileDropZoneId } from './zones'; + +export interface FileChoiceRequest { + files: File[]; + /** Where the focus starts, if that choice is there. */ + preferred?: FileDropZoneId; +} + +type Listener = (request: FileChoiceRequest) => void; + +let listener: Listener | null = null; +const waiting: FileChoiceRequest[] = []; + +export function requestFileChoice(request: FileChoiceRequest): void { + if (request.files.length === 0) return; + if (listener) listener(request); + else waiting.push(request); +} + +/** One host at a time. What came before it mounted is handed over at once. */ +export function listenForFileChoices(next: Listener): () => void { + listener = next; + for (const request of waiting.splice(0)) next(request); + return () => { + if (listener === next) listener = null; + }; +} + diff --git a/lib/file-drop/drag-watch.ts b/lib/file-drop/drag-watch.ts new file mode 100644 index 000000000..64286d0c1 --- /dev/null +++ b/lib/file-drop/drag-watch.ts @@ -0,0 +1,116 @@ +/** + * Notices files dragged into the window from outside, and only those. A drag that started + * in this page (a message to a folder, an attachment to the composer, a tab) fires + * `dragstart` here first, and the app's own native drag of an attachment says so through + * setOwnDragActive. Neither shows the overlay. + * + * Listens in the capture phase, so a component's own drop zone doesn't hide the drag from + * it. Once the overlay is up it's on top of everything and takes the drag's events itself. + * A file dropped anywhere else does nothing: the browser's own default would leave the app + * to show the file. + */ + +import { isOwnDragActive } from '@/lib/desktop/window-files'; +import { kindForType, type DropFiles } from './zones'; + +/** Chromium sends dragover every 50 ms and Firefox about every 350 ms while the pointer is still. */ +export const DRAG_STALE_MS = 1000; + +export interface DragWatchHandlers { + /** Files came in from outside. Return false to leave the drag alone (locked, a dialog open). */ + enter: (files: DropFiles) => boolean; + /** Out of the window, Esc, a drop, or no word for a while. */ + leave: () => void; +} + +export function draggedFiles(transfer: DataTransfer | null | undefined): DropFiles | null { + if (!transfer || !Array.from(transfer.types ?? []).includes('Files')) return null; + const items = Array.from(transfer.items ?? []).filter((item) => item.kind === 'file'); + if (items.length === 0) return { count: null, kinds: null, bytes: null }; + return { count: items.length, kinds: items.map((item) => kindForType(item.type)), bytes: null }; +} + +export function startDragWatch(handlers: DragWatchHandlers): () => void { + let internal = false; + let active = false; + let staleTimer: ReturnType | null = null; + + const stop = () => { + if (staleTimer) clearTimeout(staleTimer); + staleTimer = null; + if (!active) return; + active = false; + handlers.leave(); + }; + const keepAlive = () => { + if (staleTimer) clearTimeout(staleTimer); + staleTimer = setTimeout(stop, DRAG_STALE_MS); + }; + + const fromOutside = (event: DragEvent) => !internal && !isOwnDragActive() && draggedFiles(event.dataTransfer) !== null; + + const onDragStart = () => { + internal = true; + }; + // dragend goes to the source, which may have left the page by then (a message that moved). + // No pointer events reach a page during a drag, so the first one after says it's over. + const onDragEnd = () => { + internal = false; + }; + const onPointer = () => { + internal = false; + }; + + const onDragEnter = (event: DragEvent) => { + if (!fromOutside(event)) return; + event.preventDefault(); + if (active) { + keepAlive(); + return; + } + if (handlers.enter(draggedFiles(event.dataTransfer)!)) { + active = true; + // The component under the pointer would start its own highlight for a moment. + event.stopPropagation(); + keepAlive(); + } + }; + const onDragOver = (event: DragEvent) => { + if (!fromOutside(event)) return; + // Without this a drop anywhere opens the file in place of the app. + event.preventDefault(); + if (active) keepAlive(); + }; + const onDragLeave = (event: DragEvent) => { + // Leaving the window, or the drag cancelled with Esc: no element is next. + if (active && event.relatedTarget === null) stop(); + }; + const onDrop = (event: DragEvent) => { + internal = false; + if (draggedFiles(event.dataTransfer) === null) return; + // The overlay's zones handle their own drop before this runs (bubble, after capture), + // so here it only stops the browser's default and ends the overlay. + event.preventDefault(); + queueMicrotask(stop); + }; + + window.addEventListener('dragstart', onDragStart, true); + window.addEventListener('dragend', onDragEnd, true); + window.addEventListener('pointermove', onPointer, true); + window.addEventListener('pointerdown', onPointer, true); + window.addEventListener('dragenter', onDragEnter, true); + window.addEventListener('dragover', onDragOver, true); + window.addEventListener('dragleave', onDragLeave, true); + window.addEventListener('drop', onDrop); + return () => { + stop(); + window.removeEventListener('dragstart', onDragStart, true); + window.removeEventListener('dragend', onDragEnd, true); + window.removeEventListener('pointermove', onPointer, true); + window.removeEventListener('pointerdown', onPointer, true); + window.removeEventListener('dragenter', onDragEnter, true); + window.removeEventListener('dragover', onDragOver, true); + window.removeEventListener('dragleave', onDragLeave, true); + window.removeEventListener('drop', onDrop); + }; +} diff --git a/lib/file-drop/files-target.ts b/lib/file-drop/files-target.ts new file mode 100644 index 000000000..418c0c88d --- /dev/null +++ b/lib/file-drop/files-target.ts @@ -0,0 +1,59 @@ +/** + * The Files screen's end of the file drop overlay. While it's open on a folder that takes + * uploads it says so here, and the overlay offers "Upload to ". From anywhere else + * the files wait here while the app goes to Files, which takes them once it has listed a + * folder they can go in. + */ + +export interface FilesUpload { + files: File[]; + /** A dropped folder: the files carry their paths, and the upload keeps them. */ + asFolder: boolean; +} + +interface FilesTarget { + /** The folder in view, as the user sees its name. Null at the top of Files. */ + folder: string | null; + take: (upload: FilesUpload) => unknown; + /** The Pro shell keeps Files mounted on a tab out of sight. */ + visible: () => boolean; +} + +let target: FilesTarget | null = null; +let waiting: FilesUpload | null = null; +const listeners = new Set<() => void>(); + +export function setFilesDropTarget(next: FilesTarget | null): () => void { + target = next; + return () => { + if (target === next) target = null; + }; +} + +/** The Files screen on view, if it can take files where it is. */ +export function filesDropTarget(): FilesTarget | null { + return target?.visible() ? target : null; +} + +/** Files for the Files screen to upload when it's ready. A newer drop replaces an older one nobody took. */ +export function queueFilesUpload(upload: FilesUpload): void { + waiting = upload; + for (const listener of listeners) listener(); +} + +export function takeQueuedFilesUpload(): FilesUpload | null { + const upload = waiting; + waiting = null; + return upload; +} + +export function hasQueuedFilesUpload(): boolean { + return waiting !== null; +} + +export function subscribeQueuedFilesUpload(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/lib/file-drop/read.ts b/lib/file-drop/read.ts new file mode 100644 index 000000000..5c9ebd960 --- /dev/null +++ b/lib/file-drop/read.ts @@ -0,0 +1,30 @@ +/** + * The files of a drop, read while the drop event lasts. A dropped folder only shows up + * through the entry API, and only Files can take one: its upload keeps the folder's + * layout. Everywhere else a folder is left out. + */ + +import { getDroppedFilesAndFolders } from '@/lib/webdav/drop-utils'; + +export interface DroppedFiles { + /** Files at the top of the drop, no folders. */ + plain: File[]; + /** Everything, folders walked, with their paths: for Files. */ + all: File[]; + folders: boolean; +} + +/** Call from the drop handler itself: the browser empties the transfer once it returns. */ +export function readDroppedFiles(transfer: DataTransfer): Promise { + const items = Array.from(transfer.items ?? []); + const entries = items.map((item) => (typeof item.webkitGetAsEntry === 'function' ? item.webkitGetAsEntry() : null)); + const plainNow = items.flatMap((item, index) => { + if (item.kind !== 'file' || entries[index]?.isDirectory) return []; + const file = item.getAsFile(); + return file ? [file] : []; + }); + const plain = items.length > 0 ? plainNow : Array.from(transfer.files ?? []); + const folders = entries.some((entry) => entry?.isDirectory); + if (!folders) return Promise.resolve({ plain, all: plain, folders: false }); + return getDroppedFilesAndFolders(transfer).then(({ files }) => ({ plain, all: files, folders: true })); +} diff --git a/lib/file-drop/zones.ts b/lib/file-drop/zones.ts new file mode 100644 index 000000000..d0ab21855 --- /dev/null +++ b/lib/file-drop/zones.ts @@ -0,0 +1,145 @@ +/** + * Which choices the file drop overlay offers, with nothing of React or the DOM in it. + * + * Two main choices everywhere: attach to a new message, upload to Files. An open composer + * comes first, and a file the app knows (.ics, .vcf, .eml) adds its own. Never more than + * four, so the zones stay big enough to hit with a drag. + * + * During a drag the browser shows only how many files there are and their MIME types, + * never names or sizes (Chromium's protected drag data). So kinds come from the type + * then, from the name once dropped, and a size limit is shown on the zone rather than + * checked against files nobody can measure yet. + */ + +import { kindForFile, type HandoffKind } from '@/lib/launch-handoff'; + +export type FileDropZoneId = 'attach-here' | 'new-message' | 'upload' | 'calendar' | 'contacts' | 'open-eml' | 'import-eml'; + +export type DroppedKind = Exclude | 'other'; + +export interface DropFiles { + /** Unknown during a drag in some browsers (Safari gives no items). */ + count: number | null; + /** One per file, when known. */ + kinds: DroppedKind[] | null; + /** Known only once the files are in hand. */ + bytes: number | null; +} + +export interface DropPlace { + /** A composer is on screen and takes files. */ + composer: boolean; + /** A pop-out window: only its own composer, nothing that needs the main window. */ + popout: boolean; + /** Files is on for this account and its server has it. */ + files: boolean; + /** On the Files screen, the folder in view, when files can go there. */ + filesFolder: string | null; + calendar: boolean; + contacts: boolean; + /** On the mail screen, a real folder in view that takes new messages. */ + importFolder: string | null; + /** No connection to the mail server. */ + offline: boolean; + /** Offline mode keeps a draft and its files until the connection is back. */ + keepsOffline: boolean; + /** 0: the server says nothing. */ + maxUploadBytes: number; + maxMessageBytes: number; +} + +export type FileDropNote = + | { key: 'note_limit_message'; size: number } + | { key: 'note_limit_file'; size: number } + | { key: 'note_kept_offline' } + | { key: 'note_needs_connection' }; + +export interface FileDropZone { + id: FileDropZoneId; + /** Names the folder for `upload` on the Files screen and for `import-eml`. */ + folder?: string; + note?: FileDropNote; + /** Shown, with the note saying why, and takes no drop. */ + disabled?: boolean; +} + +export const MAX_ZONES = 4; + +/** What a dragged item's MIME type says. Empty or anything else: no special choice. */ +function dropped(kind: HandoffKind | null): DroppedKind { + return kind && kind !== 'compose' ? kind : 'other'; +} + +export function kindForType(type: string): DroppedKind { + return dropped(kindForFile({ name: '', type })); +} + +export function kindForName(file: { name: string; type?: string }): DroppedKind { + return dropped(kindForFile(file)); +} + +/** The one kind every file shares, or null for a mix or for none known. */ +export function sharedKind(files: DropFiles): DroppedKind | null { + if (!files.kinds || files.kinds.length === 0) return null; + const [first] = files.kinds; + return files.kinds.every((kind) => kind === first) ? first : null; +} + +function mailNote(place: DropPlace): Pick { + if (place.offline && !place.keepsOffline) return { note: { key: 'note_needs_connection' }, disabled: true }; + if (place.offline) return { note: { key: 'note_kept_offline' } }; + return place.maxMessageBytes > 0 ? { note: { key: 'note_limit_message', size: place.maxMessageBytes } } : {}; +} + +function serverOnly(place: DropPlace, zone: FileDropZone): FileDropZone { + return place.offline ? { ...zone, note: { key: 'note_needs_connection' }, disabled: true } : zone; +} + +/** The zones for these files here, in the order they're shown. */ +export function fileDropZones(files: DropFiles, place: DropPlace): FileDropZone[] { + const kind = sharedKind(files); + const one = files.count === 1; + const main: FileDropZone[] = []; + if (place.composer) main.push({ id: 'attach-here', ...mailNote(place) }); + if (!place.popout) main.push({ id: 'new-message', ...mailNote(place) }); + if (!place.popout && place.files) { + const note: FileDropNote | undefined = place.maxUploadBytes > 0 ? { key: 'note_limit_file', size: place.maxUploadBytes } : undefined; + main.push(serverOnly(place, { id: 'upload', ...(place.filesFolder ? { folder: place.filesFolder } : {}), ...(note ? { note } : {}) })); + } + + // What this kind of file is for, best first. Not in a pop-out: each opens on its own page. + const special: FileDropZone[] = []; + if (!place.popout) { + if (kind === 'eml' && one) special.push({ id: 'open-eml' }); + if (kind === 'eml' && place.importFolder) special.push(serverOnly(place, { id: 'import-eml', folder: place.importFolder })); + if (kind === 'calendar' && one && place.calendar) special.push({ id: 'calendar' }); + if (kind === 'contacts' && one && place.contacts) special.push({ id: 'contacts' }); + } + + const room = Math.max(0, MAX_ZONES - main.length); + const kept = special.slice(0, room); + const attachHere = main.filter((zone) => zone.id === 'attach-here'); + const rest = main.filter((zone) => zone.id !== 'attach-here'); + return [...attachHere, ...kept, ...rest]; +} + +/** What the files may still do, once they're in hand: those too big for the zone stay out. */ +export function filesWithinLimits( + zone: FileDropZoneId, + files: readonly File[], + place: Pick, +): { fitting: File[]; tooLarge: File[]; overMessage: boolean } { + const perFile = place.maxUploadBytes; + const fitting = perFile > 0 ? files.filter((file) => file.size <= perFile) : [...files]; + const tooLarge = files.filter((file) => !fitting.includes(file)); + const mail = zone === 'attach-here' || zone === 'new-message'; + const total = fitting.reduce((sum, file) => sum + file.size, 0); + const overMessage = mail && place.maxMessageBytes > 0 && total > place.maxMessageBytes; + return { fitting: overMessage ? [] : fitting, tooLarge, overMessage }; +} + +/** The zone a keyboard user lands on: the asked-for one if it's there and usable, else the first usable. */ +export function defaultZone(zones: readonly FileDropZone[], preferred?: FileDropZoneId): FileDropZone | undefined { + const usable = zones.filter((zone) => !zone.disabled); + return usable.find((zone) => zone.id === preferred) ?? usable[0]; +} diff --git a/locales/ar/common.json b/locales/ar/common.json index ed9cbecb0..aa5807286 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7058,6 +7058,35 @@ "types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.", "reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى." }, + "fileDrop": { + "overlay_label": "أفلت الملفات", + "overlay_title": "إلى أين تذهب الملفات؟", + "overlay_hint": "أفلت خارج الخيارات أو اضغط Esc للإلغاء.", + "files_unknown": "ملفات", + "files_summary": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}", + "files_summary_size": "{count, plural, zero {لا ملفات} one {ملف واحد} two {ملفان} few {# ملفات} many {# ملفًا} other {# ملف}}, {size}", + "zone_attach_here": "إرفاق بهذه الرسالة", + "zone_new_message": "إرفاق برسالة جديدة", + "zone_upload_files": "رفع إلى الملفات", + "zone_upload_folder": "رفع إلى {folder}", + "zone_calendar": "إضافة إلى التقويم", + "zone_contacts": "إضافة إلى جهات الاتصال", + "zone_open_eml": "فتح الرسالة", + "zone_import_eml": "استيراد إلى {folder}", + "note_limit_message": "حتى {size} لكل رسالة", + "note_limit_file": "حتى {size} لكل ملف", + "note_kept_offline": "تبقى على هذا الجهاز حتى تعود متصلًا", + "note_needs_connection": "يحتاج إلى اتصال", + "announce": "{files, plural, =0 {ملفات مسحوبة} zero {لا ملفات مسحوبة} one {ملف واحد مسحوب} two {ملفان مسحوبان} few {# ملفات مسحوبة} many {# ملفًا مسحوبًا} other {# ملف مسحوب}}. الخيارات: {choices}.", + "choose_title": "{count, plural, zero {إلى أين تذهب هذه الملفات؟} one {إلى أين يذهب هذا الملف؟} two {إلى أين يذهب هذان الملفان؟} few {إلى أين تذهب هذه الملفات؟} many {إلى أين تذهب هذه الملفات؟} other {إلى أين تذهب هذه الملفات؟}}", + "save_first_failed": "تعذّر حفظ الرسالة المفتوحة، لذا بقي التطبيق حيث كان.", + "import_done": "{count, plural, zero {لم تُستورد أي رسالة إلى {folder}.} one {استُوردت رسالة واحدة إلى {folder}.} two {استُوردت رسالتان إلى {folder}.} few {استُوردت # رسائل إلى {folder}.} many {استُوردت # رسالة إلى {folder}.} other {استُوردت # رسالة إلى {folder}.}}", + "import_failed": "تعذّر استيراد {name}.", + "too_large_file": "{name} كبير جدًا. الحد هو {max} لكل ملف.", + "too_large_message": "هذه الملفات كبيرة جدًا لرسالة واحدة. الحد هو {max}.", + "folders_left_out": "يمكن أن تذهب المجلدات إلى الملفات فقط، لذا تُركت.", + "nothing_here": "لا يمكن استخدام هذه الملفات هنا." + }, "desktopReminders": { "generic_title": "تذكير", "snooze": "تأجيل {count} د", diff --git a/locales/ca/common.json b/locales/ca/common.json index 5e3d4916d..04f546b54 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -7058,6 +7058,35 @@ "types_character": "Aquestes tecles escriuen un caràcter al teu teclat, i deixaria de funcionar a totes les aplicacions. Prova altres tecles.", "reserved": "El sistema o la majoria d'aplicacions fan servir aquestes tecles. Prova altres tecles." }, + "fileDrop": { + "overlay_label": "Deixa anar fitxers", + "overlay_title": "On han d'anar els fitxers?", + "overlay_hint": "Deixa anar fora de les opcions o prem Esc per cancel·lar.", + "files_unknown": "Fitxers", + "files_summary": "{count, plural, one {# fitxer} other {# fitxers}}", + "files_summary_size": "{count, plural, one {# fitxer} other {# fitxers}}, {size}", + "zone_attach_here": "Adjunta a aquest missatge", + "zone_new_message": "Adjunta a un missatge nou", + "zone_upload_files": "Puja a Fitxers", + "zone_upload_folder": "Puja a {folder}", + "zone_calendar": "Afegeix al calendari", + "zone_contacts": "Afegeix als contactes", + "zone_open_eml": "Obre el missatge", + "zone_import_eml": "Importa a {folder}", + "note_limit_message": "Fins a {size} per missatge", + "note_limit_file": "Fins a {size} per fitxer", + "note_kept_offline": "Es queda en aquest dispositiu fins que tornis a tenir connexió", + "note_needs_connection": "Necessita connexió", + "announce": "{files, plural, =0 {Fitxers arrossegats} one {# fitxer arrossegat} other {# fitxers arrossegats}}. Opcions: {choices}.", + "choose_title": "{count, plural, one {On ha d'anar aquest fitxer?} other {On han d'anar aquests fitxers?}}", + "save_first_failed": "No s'ha pogut desar el missatge obert, així que l'app s'ha quedat on era.", + "import_done": "{count, plural, one {# missatge importat a {folder}.} other {# missatges importats a {folder}.}}", + "import_failed": "No s'ha pogut importar {name}.", + "too_large_file": "{name} és massa gran. El límit és {max} per fitxer.", + "too_large_message": "Aquests fitxers són massa grans per a un missatge. El límit és {max}.", + "folders_left_out": "Les carpetes només poden anar a Fitxers, així que s'han deixat fora.", + "nothing_here": "Aquests fitxers no es poden fer servir aquí." + }, "desktopReminders": { "generic_title": "Recordatori", "snooze": "Posposa {count} min", diff --git a/locales/cs/common.json b/locales/cs/common.json index ace3e4105..9c5721a75 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -7058,6 +7058,35 @@ "types_character": "Tyto klávesy na vaší klávesnici píší znak, který by pak nefungoval v žádné aplikaci. Zkuste jiné klávesy.", "reserved": "Tyto klávesy používá systém nebo většina aplikací. Zkuste jiné klávesy." }, + "fileDrop": { + "overlay_label": "Pustit soubory", + "overlay_title": "Kam mají soubory jít?", + "overlay_hint": "Pro zrušení pusťte mimo volby nebo stiskněte Esc.", + "files_unknown": "Soubory", + "files_summary": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}", + "files_summary_size": "{count, plural, one {# soubor} few {# soubory} many {# souboru} other {# souborů}}, {size}", + "zone_attach_here": "Připojit k této zprávě", + "zone_new_message": "Připojit k nové zprávě", + "zone_upload_files": "Nahrát do Souborů", + "zone_upload_folder": "Nahrát do {folder}", + "zone_calendar": "Přidat do kalendáře", + "zone_contacts": "Přidat do kontaktů", + "zone_open_eml": "Otevřít zprávu", + "zone_import_eml": "Importovat do {folder}", + "note_limit_message": "Až {size} na zprávu", + "note_limit_file": "Až {size} na soubor", + "note_kept_offline": "Zůstane v tomto zařízení, dokud nebudete online", + "note_needs_connection": "Vyžaduje připojení", + "announce": "{files, plural, =0 {Přetaženy soubory} one {Přetažen # soubor} few {Přetaženy # soubory} many {Přetaženo # souboru} other {Přetaženo # souborů}}. Volby: {choices}.", + "choose_title": "{count, plural, one {Kam má tento soubor jít?} few {Kam mají tyto soubory jít?} many {Kam mají tyto soubory jít?} other {Kam mají tyto soubory jít?}}", + "save_first_failed": "Otevřenou zprávu se nepodařilo uložit, proto aplikace zůstala, kde byla.", + "import_done": "{count, plural, one {# zpráva importována do {folder}.} few {# zprávy importovány do {folder}.} many {# zprávy importováno do {folder}.} other {# zpráv importováno do {folder}.}}", + "import_failed": "{name} se nepodařilo importovat.", + "too_large_file": "{name} je příliš velký. Limit je {max} na soubor.", + "too_large_message": "Tyto soubory jsou na jednu zprávu příliš velké. Limit je {max}.", + "folders_left_out": "Složky mohou jít jen do Souborů, proto byly vynechány.", + "nothing_here": "Tyto soubory zde nelze použít." + }, "desktopReminders": { "generic_title": "Připomenutí", "snooze": "Odložit o {count} min", diff --git a/locales/da/common.json b/locales/da/common.json index b473c8379..ad28302ef 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -7058,6 +7058,35 @@ "types_character": "Disse taster skriver et tegn på dit tastatur, og det ville holde op med at virke i alle apps. Prøv andre taster.", "reserved": "Dit system eller de fleste apps bruger disse taster. Prøv andre taster." }, + "fileDrop": { + "overlay_label": "Slip filer", + "overlay_title": "Hvor skal filerne hen?", + "overlay_hint": "Slip uden for valgene, eller tryk på Esc for at annullere.", + "files_unknown": "Filer", + "files_summary": "{count, plural, one {# fil} other {# filer}}", + "files_summary_size": "{count, plural, one {# fil} other {# filer}}, {size}", + "zone_attach_here": "Vedhæft til denne besked", + "zone_new_message": "Vedhæft til en ny besked", + "zone_upload_files": "Upload til Filer", + "zone_upload_folder": "Upload til {folder}", + "zone_calendar": "Føj til kalender", + "zone_contacts": "Føj til kontakter", + "zone_open_eml": "Åbn besked", + "zone_import_eml": "Importér til {folder}", + "note_limit_message": "Op til {size} pr. besked", + "note_limit_file": "Op til {size} pr. fil", + "note_kept_offline": "Bliver på denne enhed, til du er online igen", + "note_needs_connection": "Kræver forbindelse", + "announce": "{files, plural, =0 {Filer} one {# fil} other {# filer}} trukket ind. Valg: {choices}.", + "choose_title": "{count, plural, one {Hvor skal denne fil hen?} other {Hvor skal disse filer hen?}}", + "save_first_failed": "Den åbne besked kunne ikke gemmes, så appen blev, hvor den var.", + "import_done": "{count, plural, one {# besked importeret til {folder}.} other {# beskeder importeret til {folder}.}}", + "import_failed": "{name} kunne ikke importeres.", + "too_large_file": "{name} er for stor. Grænsen er {max} pr. fil.", + "too_large_message": "Disse filer er for store til én besked. Grænsen er {max}.", + "folders_left_out": "Mapper kan kun komme i Filer, så de blev udeladt.", + "nothing_here": "Disse filer kan ikke bruges her." + }, "desktopReminders": { "generic_title": "Påmindelse", "snooze": "Udsæt {count} min.", diff --git a/locales/de/common.json b/locales/de/common.json index 9cb2097f7..ae9368ddc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -7058,6 +7058,35 @@ "types_character": "Diese Tasten erzeugen auf Ihrer Tastatur ein Zeichen, das dann in keiner App mehr funktionieren würde. Versuchen Sie andere Tasten.", "reserved": "Ihr System oder die meisten Apps verwenden diese Tasten. Versuchen Sie andere Tasten." }, + "fileDrop": { + "overlay_label": "Dateien ablegen", + "overlay_title": "Wohin sollen die Dateien?", + "overlay_hint": "Zum Abbrechen außerhalb der Felder ablegen oder Esc drücken.", + "files_unknown": "Dateien", + "files_summary": "{count, plural, one {# Datei} other {# Dateien}}", + "files_summary_size": "{count, plural, one {# Datei} other {# Dateien}}, {size}", + "zone_attach_here": "An diese Nachricht anhängen", + "zone_new_message": "An neue Nachricht anhängen", + "zone_upload_files": "In Dateien hochladen", + "zone_upload_folder": "In {folder} hochladen", + "zone_calendar": "Zum Kalender hinzufügen", + "zone_contacts": "Zu Kontakten hinzufügen", + "zone_open_eml": "Nachricht öffnen", + "zone_import_eml": "In {folder} importieren", + "note_limit_message": "Bis zu {size} pro Nachricht", + "note_limit_file": "Bis zu {size} pro Datei", + "note_kept_offline": "Bleibt auf diesem Gerät, bis Sie wieder online sind", + "note_needs_connection": "Braucht eine Verbindung", + "announce": "{files, plural, =0 {Dateien} one {# Datei} other {# Dateien}} hereingezogen. Möglichkeiten: {choices}.", + "choose_title": "{count, plural, one {Wohin soll diese Datei?} other {Wohin sollen diese Dateien?}}", + "save_first_failed": "Die offene Nachricht konnte nicht gespeichert werden, daher bleibt die App, wo sie war.", + "import_done": "{count, plural, one {# Nachricht in {folder} importiert.} other {# Nachrichten in {folder} importiert.}}", + "import_failed": "{name} konnte nicht importiert werden.", + "too_large_file": "{name} ist zu groß. Die Grenze liegt bei {max} pro Datei.", + "too_large_message": "Diese Dateien sind zusammen zu groß für eine Nachricht. Die Grenze liegt bei {max}.", + "folders_left_out": "Ordner können nur in Dateien, daher wurden sie weggelassen.", + "nothing_here": "Diese Dateien lassen sich hier nicht verwenden." + }, "desktopReminders": { "generic_title": "Erinnerung", "snooze": "{count} Min. schlummern", diff --git a/locales/en/common.json b/locales/en/common.json index 84f9389a7..3cba72c6a 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -7058,6 +7058,35 @@ "types_character": "These keys type a character on your keyboard, and it would stop working in every app. Try other keys.", "reserved": "Your system or most apps use these keys. Try other keys." }, + "fileDrop": { + "overlay_label": "Drop files", + "overlay_title": "Where should the files go?", + "overlay_hint": "Drop outside the choices or press Esc to cancel.", + "files_unknown": "Files", + "files_summary": "{count, plural, one {# file} other {# files}}", + "files_summary_size": "{count, plural, one {# file} other {# files}}, {size}", + "zone_attach_here": "Attach to this message", + "zone_new_message": "Attach to a new message", + "zone_upload_files": "Upload to Files", + "zone_upload_folder": "Upload to {folder}", + "zone_calendar": "Add to calendar", + "zone_contacts": "Add to contacts", + "zone_open_eml": "Open message", + "zone_import_eml": "Import into {folder}", + "note_limit_message": "Up to {size} per message", + "note_limit_file": "Up to {size} per file", + "note_kept_offline": "Kept on this device until you're online", + "note_needs_connection": "Needs a connection", + "announce": "{files, plural, =0 {Files} one {# file} other {# files}} dragged in. Choices: {choices}.", + "choose_title": "{count, plural, one {Where should this file go?} other {Where should these files go?}}", + "save_first_failed": "The open message could not be saved, so the app stayed where it was.", + "import_done": "{count, plural, one {# message imported into {folder}.} other {# messages imported into {folder}.}}", + "import_failed": "{name} could not be imported.", + "too_large_file": "{name} is too large. The limit is {max} per file.", + "too_large_message": "These files are too large for one message. The limit is {max}.", + "folders_left_out": "Folders can only go to Files, so they were left out.", + "nothing_here": "These files can't be used here." + }, "desktopReminders": { "generic_title": "Reminder", "snooze": "Snooze {count} min", diff --git a/locales/es/common.json b/locales/es/common.json index a44fce868..398480475 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -7058,6 +7058,35 @@ "types_character": "Estas teclas escriben un carácter en tu teclado, y dejaría de funcionar en todas las aplicaciones. Prueba otras teclas.", "reserved": "Tu sistema o la mayoría de las aplicaciones usan estas teclas. Prueba otras teclas." }, + "fileDrop": { + "overlay_label": "Soltar archivos", + "overlay_title": "¿Dónde van los archivos?", + "overlay_hint": "Suelta fuera de las opciones o pulsa Esc para cancelar.", + "files_unknown": "Archivos", + "files_summary": "{count, plural, one {# archivo} other {# archivos}}", + "files_summary_size": "{count, plural, one {# archivo} other {# archivos}}, {size}", + "zone_attach_here": "Adjuntar a este mensaje", + "zone_new_message": "Adjuntar a un mensaje nuevo", + "zone_upload_files": "Subir a Archivos", + "zone_upload_folder": "Subir a {folder}", + "zone_calendar": "Añadir al calendario", + "zone_contacts": "Añadir a contactos", + "zone_open_eml": "Abrir mensaje", + "zone_import_eml": "Importar en {folder}", + "note_limit_message": "Hasta {size} por mensaje", + "note_limit_file": "Hasta {size} por archivo", + "note_kept_offline": "Se guarda en este dispositivo hasta que vuelvas a tener conexión", + "note_needs_connection": "Necesita conexión", + "announce": "{files, plural, =0 {Archivos arrastrados} one {# archivo arrastrado} other {# archivos arrastrados}}. Opciones: {choices}.", + "choose_title": "{count, plural, one {¿Dónde va este archivo?} other {¿Dónde van estos archivos?}}", + "save_first_failed": "No se pudo guardar el mensaje abierto, así que la app se quedó donde estaba.", + "import_done": "{count, plural, one {# mensaje importado en {folder}.} other {# mensajes importados en {folder}.}}", + "import_failed": "No se pudo importar {name}.", + "too_large_file": "{name} es demasiado grande. El límite es {max} por archivo.", + "too_large_message": "Estos archivos son demasiado grandes para un mensaje. El límite es {max}.", + "folders_left_out": "Las carpetas solo pueden ir a Archivos, así que se han dejado fuera.", + "nothing_here": "Estos archivos no se pueden usar aquí." + }, "desktopReminders": { "generic_title": "Recordatorio", "snooze": "Posponer {count} min", diff --git a/locales/et/common.json b/locales/et/common.json index ace51f3b5..25c3db37e 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -7058,6 +7058,35 @@ "types_character": "Need klahvid kirjutavad teie klaviatuuril märgi, mis lakkaks kõigis rakendustes töötamast. Proovige teisi klahve.", "reserved": "Neid klahve kasutab süsteem või enamik rakendusi. Proovige teisi klahve." }, + "fileDrop": { + "overlay_label": "Lohista failid siia", + "overlay_title": "Kuhu failid lähevad?", + "overlay_hint": "Tühistamiseks lase lahti väljaspool valikuid või vajuta Esc.", + "files_unknown": "Failid", + "files_summary": "{count, plural, one {# fail} other {# faili}}", + "files_summary_size": "{count, plural, one {# fail} other {# faili}}, {size}", + "zone_attach_here": "Lisa sellele kirjale", + "zone_new_message": "Lisa uuele kirjale", + "zone_upload_files": "Laadi üles kausta Failid", + "zone_upload_folder": "Laadi üles kausta {folder}", + "zone_calendar": "Lisa kalendrisse", + "zone_contacts": "Lisa kontaktidesse", + "zone_open_eml": "Ava kiri", + "zone_import_eml": "Impordi kausta {folder}", + "note_limit_message": "Kuni {size} kirja kohta", + "note_limit_file": "Kuni {size} faili kohta", + "note_kept_offline": "Jääb sellesse seadmesse, kuni oled taas võrgus", + "note_needs_connection": "Vajab ühendust", + "announce": "{files, plural, =0 {Failid} one {# fail} other {# faili}} lohistati sisse. Valikud: {choices}.", + "choose_title": "{count, plural, one {Kuhu see fail läheb?} other {Kuhu need failid lähevad?}}", + "save_first_failed": "Avatud kirja ei saanud salvestada, seega jäi rakendus sinna, kus ta oli.", + "import_done": "{count, plural, one {# kiri imporditi kausta {folder}.} other {# kirja imporditi kausta {folder}.}}", + "import_failed": "Faili {name} ei saanud importida.", + "too_large_file": "{name} on liiga suur. Piir on {max} faili kohta.", + "too_large_message": "Need failid on ühe kirja jaoks liiga suured. Piir on {max}.", + "folders_left_out": "Kaustu saab panna ainult kausta Failid, seega jäeti need välja.", + "nothing_here": "Neid faile ei saa siin kasutada." + }, "desktopReminders": { "generic_title": "Meeldetuletus", "snooze": "Lükka {count} min edasi", diff --git a/locales/fa/common.json b/locales/fa/common.json index 9961aab01..ee05eb3dc 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7058,6 +7058,35 @@ "types_character": "این کلیدها روی صفحه‌کلید شما نویسه‌ای می‌نویسند و آن نویسه دیگر در هیچ برنامه‌ای کار نمی‌کند. کلیدهای دیگری را امتحان کنید.", "reserved": "سیستم یا بیشتر برنامه‌ها از این کلیدها استفاده می‌کنند. کلیدهای دیگری را امتحان کنید." }, + "fileDrop": { + "overlay_label": "رها کردن فایل‌ها", + "overlay_title": "فایل‌ها کجا بروند؟", + "overlay_hint": "برای لغو، بیرون از گزینه‌ها رها کنید یا Esc را بزنید.", + "files_unknown": "فایل‌ها", + "files_summary": "{count, plural, one {# فایل} other {# فایل}}", + "files_summary_size": "{count, plural, one {# فایل} other {# فایل}}, {size}", + "zone_attach_here": "پیوست به این پیام", + "zone_new_message": "پیوست به پیام جدید", + "zone_upload_files": "بارگذاری در فایل‌ها", + "zone_upload_folder": "بارگذاری در {folder}", + "zone_calendar": "افزودن به تقویم", + "zone_contacts": "افزودن به مخاطبان", + "zone_open_eml": "باز کردن پیام", + "zone_import_eml": "وارد کردن به {folder}", + "note_limit_message": "تا {size} برای هر پیام", + "note_limit_file": "تا {size} برای هر فایل", + "note_kept_offline": "تا وقتی دوباره آنلاین شوید روی این دستگاه می‌ماند", + "note_needs_connection": "به اتصال نیاز دارد", + "announce": "{files, plural, =0 {فایل‌ها} one {# فایل} other {# فایل}} کشیده شد. گزینه‌ها: {choices}.", + "choose_title": "{count, plural, one {این فایل کجا برود؟} other {این فایل‌ها کجا بروند؟}}", + "save_first_failed": "پیام باز ذخیره نشد، پس برنامه همان‌جا ماند.", + "import_done": "{count, plural, one {# پیام به {folder} وارد شد.} other {# پیام به {folder} وارد شد.}}", + "import_failed": "{name} وارد نشد.", + "too_large_file": "{name} خیلی بزرگ است. حد آن {max} برای هر فایل است.", + "too_large_message": "این فایل‌ها برای یک پیام خیلی بزرگ‌اند. حد آن {max} است.", + "folders_left_out": "پوشه‌ها فقط می‌توانند به فایل‌ها بروند، پس کنار گذاشته شدند.", + "nothing_here": "این فایل‌ها اینجا قابل استفاده نیستند." + }, "desktopReminders": { "generic_title": "یادآوری", "snooze": "تعویق {count} دقیقه", diff --git a/locales/fr/common.json b/locales/fr/common.json index 750e84417..1c45bde45 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -7058,6 +7058,35 @@ "types_character": "Ces touches produisent un caractère sur votre clavier, et il ne fonctionnerait plus dans aucune application. Essayez d'autres touches.", "reserved": "Votre système ou la plupart des applications utilisent ces touches. Essayez d'autres touches." }, + "fileDrop": { + "overlay_label": "Déposer des fichiers", + "overlay_title": "Où mettre les fichiers ?", + "overlay_hint": "Déposez hors des choix ou appuyez sur Échap pour annuler.", + "files_unknown": "Fichiers", + "files_summary": "{count, plural, one {# fichier} other {# fichiers}}", + "files_summary_size": "{count, plural, one {# fichier} other {# fichiers}}, {size}", + "zone_attach_here": "Joindre à ce message", + "zone_new_message": "Joindre à un nouveau message", + "zone_upload_files": "Envoyer dans Fichiers", + "zone_upload_folder": "Envoyer dans {folder}", + "zone_calendar": "Ajouter au calendrier", + "zone_contacts": "Ajouter aux contacts", + "zone_open_eml": "Ouvrir le message", + "zone_import_eml": "Importer dans {folder}", + "note_limit_message": "Jusqu'à {size} par message", + "note_limit_file": "Jusqu'à {size} par fichier", + "note_kept_offline": "Gardé sur cet appareil jusqu'au retour de la connexion", + "note_needs_connection": "Nécessite une connexion", + "announce": "{files, plural, =0 {Fichiers glissés} one {# fichier glissé} other {# fichiers glissés}}. Choix : {choices}.", + "choose_title": "{count, plural, one {Où mettre ce fichier ?} other {Où mettre ces fichiers ?}}", + "save_first_failed": "Le message ouvert n'a pas pu être enregistré, l'app est donc restée où elle était.", + "import_done": "{count, plural, one {# message importé dans {folder}.} other {# messages importés dans {folder}.}}", + "import_failed": "{name} n'a pas pu être importé.", + "too_large_file": "{name} est trop volumineux. La limite est de {max} par fichier.", + "too_large_message": "Ces fichiers sont trop volumineux pour un seul message. La limite est de {max}.", + "folders_left_out": "Les dossiers ne peuvent aller que dans Fichiers, ils ont donc été laissés de côté.", + "nothing_here": "Ces fichiers ne peuvent pas être utilisés ici." + }, "desktopReminders": { "generic_title": "Rappel", "snooze": "Reporter de {count} min", diff --git a/locales/he/common.json b/locales/he/common.json index 23cb11fa8..5066ec4b6 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7059,6 +7059,35 @@ "types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.", "reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים." }, + "fileDrop": { + "overlay_label": "שחררו קבצים", + "overlay_title": "לאן הקבצים ילכו?", + "overlay_hint": "כדי לבטל, שחררו מחוץ לאפשרויות או הקישו Esc.", + "files_unknown": "קבצים", + "files_summary": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}", + "files_summary_size": "{count, plural, one {קובץ אחד} two {שני קבצים} other {# קבצים}}, {size}", + "zone_attach_here": "צירוף להודעה הזו", + "zone_new_message": "צירוף להודעה חדשה", + "zone_upload_files": "העלאה לקבצים", + "zone_upload_folder": "העלאה אל {folder}", + "zone_calendar": "הוספה ללוח השנה", + "zone_contacts": "הוספה לאנשי הקשר", + "zone_open_eml": "פתיחת ההודעה", + "zone_import_eml": "ייבוא אל {folder}", + "note_limit_message": "עד {size} להודעה", + "note_limit_file": "עד {size} לקובץ", + "note_kept_offline": "נשמר במכשיר הזה עד שתחזרו לרשת", + "note_needs_connection": "נדרש חיבור", + "announce": "{files, plural, =0 {קבצים נגררו} one {קובץ אחד נגרר} two {שני קבצים נגררו} other {# קבצים נגררו}}. אפשרויות: {choices}.", + "choose_title": "{count, plural, one {לאן הקובץ הזה ילך?} two {לאן הקבצים האלה ילכו?} other {לאן הקבצים האלה ילכו?}}", + "save_first_failed": "לא ניתן היה לשמור את ההודעה הפתוחה, לכן האפליקציה נשארה במקומה.", + "import_done": "{count, plural, one {הודעה אחת יובאה אל {folder}.} two {שתי הודעות יובאו אל {folder}.} other {# הודעות יובאו אל {folder}.}}", + "import_failed": "לא ניתן היה לייבא את {name}.", + "too_large_file": "{name} גדול מדי. המגבלה היא {max} לקובץ.", + "too_large_message": "הקבצים האלה גדולים מדי להודעה אחת. המגבלה היא {max}.", + "folders_left_out": "תיקיות יכולות ללכת רק לקבצים, לכן הן הושמטו.", + "nothing_here": "אי אפשר להשתמש בקבצים האלה כאן." + }, "desktopReminders": { "generic_title": "תזכורת", "snooze": "דחייה ב-{count} דק'", diff --git a/locales/hu/common.json b/locales/hu/common.json index 172fa6df6..db74f7bc4 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -7058,6 +7058,35 @@ "types_character": "Ezek a billentyűk karaktert írnak az Ön billentyűzetén, amely többé egyik alkalmazásban sem működne. Próbáljon más billentyűket.", "reserved": "Ezeket a billentyűket a rendszer vagy a legtöbb alkalmazás használja. Próbáljon más billentyűket." }, + "fileDrop": { + "overlay_label": "Fájlok ejtése", + "overlay_title": "Hová kerüljenek a fájlok?", + "overlay_hint": "A megszakításhoz engedje el a lehetőségeken kívül, vagy nyomja meg az Esc billentyűt.", + "files_unknown": "Fájlok", + "files_summary": "{count, plural, one {# fájl} other {# fájl}}", + "files_summary_size": "{count, plural, one {# fájl} other {# fájl}}, {size}", + "zone_attach_here": "Csatolás ehhez az üzenethez", + "zone_new_message": "Csatolás új üzenethez", + "zone_upload_files": "Feltöltés a Fájlokba", + "zone_upload_folder": "Feltöltés ide: {folder}", + "zone_calendar": "Hozzáadás a naptárhoz", + "zone_contacts": "Hozzáadás a névjegyekhez", + "zone_open_eml": "Üzenet megnyitása", + "zone_import_eml": "Importálás ide: {folder}", + "note_limit_message": "Legfeljebb {size} üzenetenként", + "note_limit_file": "Legfeljebb {size} fájlonként", + "note_kept_offline": "Ezen az eszközön marad, amíg újra online nem lesz", + "note_needs_connection": "Kapcsolat szükséges", + "announce": "{files, plural, =0 {Fájlok} one {# fájl} other {# fájl}} behúzva. Lehetőségek: {choices}.", + "choose_title": "{count, plural, one {Hová kerüljön ez a fájl?} other {Hová kerüljenek ezek a fájlok?}}", + "save_first_failed": "A megnyitott üzenetet nem sikerült menteni, ezért az alkalmazás ott maradt, ahol volt.", + "import_done": "{count, plural, one {# üzenet importálva ide: {folder}.} other {# üzenet importálva ide: {folder}.}}", + "import_failed": "{name} nem importálható.", + "too_large_file": "{name} túl nagy. A korlát {max} fájlonként.", + "too_large_message": "Ezek a fájlok együtt túl nagyok egy üzenethez. A korlát {max}.", + "folders_left_out": "Mappák csak a Fájlokba kerülhetnek, ezért kimaradtak.", + "nothing_here": "Ezek a fájlok itt nem használhatók." + }, "desktopReminders": { "generic_title": "Emlékeztető", "snooze": "Halasztás {count} perccel", diff --git a/locales/it/common.json b/locales/it/common.json index 71f13cca0..a6d8ff703 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -7058,6 +7058,35 @@ "types_character": "Questi tasti scrivono un carattere sulla tua tastiera, che smetterebbe di funzionare in tutte le app. Prova altri tasti.", "reserved": "Il sistema o la maggior parte delle app usano questi tasti. Prova altri tasti." }, + "fileDrop": { + "overlay_label": "Rilascia file", + "overlay_title": "Dove vanno i file?", + "overlay_hint": "Rilascia fuori dalle opzioni o premi Esc per annullare.", + "files_unknown": "File", + "files_summary": "{count, plural, one {# file} other {# file}}", + "files_summary_size": "{count, plural, one {# file} other {# file}}, {size}", + "zone_attach_here": "Allega a questo messaggio", + "zone_new_message": "Allega a un nuovo messaggio", + "zone_upload_files": "Carica in File", + "zone_upload_folder": "Carica in {folder}", + "zone_calendar": "Aggiungi al calendario", + "zone_contacts": "Aggiungi ai contatti", + "zone_open_eml": "Apri messaggio", + "zone_import_eml": "Importa in {folder}", + "note_limit_message": "Fino a {size} per messaggio", + "note_limit_file": "Fino a {size} per file", + "note_kept_offline": "Resta su questo dispositivo finché non torni online", + "note_needs_connection": "Serve una connessione", + "announce": "{files, plural, =0 {File trascinati} one {# file trascinato} other {# file trascinati}}. Opzioni: {choices}.", + "choose_title": "{count, plural, one {Dove va questo file?} other {Dove vanno questi file?}}", + "save_first_failed": "Non è stato possibile salvare il messaggio aperto, quindi l'app è rimasta dov'era.", + "import_done": "{count, plural, one {# messaggio importato in {folder}.} other {# messaggi importati in {folder}.}}", + "import_failed": "Non è stato possibile importare {name}.", + "too_large_file": "{name} è troppo grande. Il limite è {max} per file.", + "too_large_message": "Questi file sono troppo grandi per un solo messaggio. Il limite è {max}.", + "folders_left_out": "Le cartelle possono andare solo in File, quindi sono state escluse.", + "nothing_here": "Questi file non si possono usare qui." + }, "desktopReminders": { "generic_title": "Promemoria", "snooze": "Rimanda di {count} min", diff --git a/locales/ja/common.json b/locales/ja/common.json index 7d382e843..8b3443527 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7058,6 +7058,35 @@ "types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。", "reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。" }, + "fileDrop": { + "overlay_label": "ファイルをドロップ", + "overlay_title": "ファイルをどこに入れますか?", + "overlay_hint": "キャンセルするには選択肢の外でドロップするか、Esc キーを押します。", + "files_unknown": "ファイル", + "files_summary": "{count, plural, other {# 個のファイル}}", + "files_summary_size": "{count, plural, other {# 個のファイル}}, {size}", + "zone_attach_here": "このメッセージに添付", + "zone_new_message": "新規メッセージに添付", + "zone_upload_files": "ファイルにアップロード", + "zone_upload_folder": "{folder} にアップロード", + "zone_calendar": "カレンダーに追加", + "zone_contacts": "連絡先に追加", + "zone_open_eml": "メッセージを開く", + "zone_import_eml": "{folder} にインポート", + "note_limit_message": "1 通あたり {size} まで", + "note_limit_file": "1 ファイルあたり {size} まで", + "note_kept_offline": "オンラインに戻るまでこのデバイスに保存されます", + "note_needs_connection": "接続が必要です", + "announce": "{files, plural, =0 {ファイル} other {# 個のファイル}}がドラッグされました。選択肢: {choices}。", + "choose_title": "{count, plural, other {このファイルをどこに入れますか?}}", + "save_first_failed": "開いているメッセージを保存できなかったため、アプリはそのままです。", + "import_done": "{count, plural, other {# 通のメッセージを {folder} にインポートしました。}}", + "import_failed": "{name} をインポートできませんでした。", + "too_large_file": "{name} は大きすぎます。上限は 1 ファイルあたり {max} です。", + "too_large_message": "これらのファイルは 1 通のメッセージには大きすぎます。上限は {max} です。", + "folders_left_out": "フォルダーはファイルにしか入れられないため、除外しました。", + "nothing_here": "これらのファイルはここでは使えません。" + }, "desktopReminders": { "generic_title": "リマインダー", "snooze": "{count} 分後に再通知", diff --git a/locales/ko/common.json b/locales/ko/common.json index 9f9643ad0..2afd81a3d 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7058,6 +7058,35 @@ "types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.", "reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요." }, + "fileDrop": { + "overlay_label": "파일 놓기", + "overlay_title": "파일을 어디에 둘까요?", + "overlay_hint": "취소하려면 선택 항목 밖에 놓거나 Esc 키를 누르세요.", + "files_unknown": "파일", + "files_summary": "{count, plural, other {파일 #개}}", + "files_summary_size": "{count, plural, other {파일 #개}}, {size}", + "zone_attach_here": "이 메시지에 첨부", + "zone_new_message": "새 메시지에 첨부", + "zone_upload_files": "파일에 업로드", + "zone_upload_folder": "{folder}에 업로드", + "zone_calendar": "캘린더에 추가", + "zone_contacts": "연락처에 추가", + "zone_open_eml": "메시지 열기", + "zone_import_eml": "{folder}(으)로 가져오기", + "note_limit_message": "메시지당 최대 {size}", + "note_limit_file": "파일당 최대 {size}", + "note_kept_offline": "다시 온라인이 될 때까지 이 기기에 보관됩니다", + "note_needs_connection": "연결이 필요합니다", + "announce": "{files, plural, =0 {파일을} other {파일 #개를}} 끌어왔습니다. 선택 항목: {choices}.", + "choose_title": "{count, plural, other {이 파일을 어디에 둘까요?}}", + "save_first_failed": "열린 메시지를 저장하지 못해 앱이 그대로 있습니다.", + "import_done": "{count, plural, other {메시지 #개를 {folder}(으)로 가져왔습니다.}}", + "import_failed": "{name}을(를) 가져오지 못했습니다.", + "too_large_file": "{name}이(가) 너무 큽니다. 한도는 파일당 {max}입니다.", + "too_large_message": "이 파일들은 한 메시지에 넣기에 너무 큽니다. 한도는 {max}입니다.", + "folders_left_out": "폴더는 파일에만 넣을 수 있어 제외했습니다.", + "nothing_here": "이 파일은 여기에서 사용할 수 없습니다." + }, "desktopReminders": { "generic_title": "알림", "snooze": "{count}분 후 다시 알림", diff --git a/locales/lv/common.json b/locales/lv/common.json index a4bc8ef83..d17bb9fb0 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -7058,6 +7058,35 @@ "types_character": "Šie taustiņi jūsu tastatūrā raksta rakstzīmi, kas pārstātu darboties visās lietotnēs. Izmēģiniet citus taustiņus.", "reserved": "Šos taustiņus izmanto sistēma vai lielākā daļa lietotņu. Izmēģiniet citus taustiņus." }, + "fileDrop": { + "overlay_label": "Nometiet failus", + "overlay_title": "Kur likt failus?", + "overlay_hint": "Lai atceltu, nometiet ārpus izvēlēm vai nospiediet Esc.", + "files_unknown": "Faili", + "files_summary": "{count, plural, zero {# failu} one {# fails} other {# faili}}", + "files_summary_size": "{count, plural, zero {# failu} one {# fails} other {# faili}}, {size}", + "zone_attach_here": "Pievienot šim ziņojumam", + "zone_new_message": "Pievienot jaunam ziņojumam", + "zone_upload_files": "Augšupielādēt failos", + "zone_upload_folder": "Augšupielādēt mapē {folder}", + "zone_calendar": "Pievienot kalendāram", + "zone_contacts": "Pievienot kontaktiem", + "zone_open_eml": "Atvērt ziņojumu", + "zone_import_eml": "Importēt mapē {folder}", + "note_limit_message": "Līdz {size} vienam ziņojumam", + "note_limit_file": "Līdz {size} vienam failam", + "note_kept_offline": "Paliek šajā ierīcē, līdz atkal būsit tiešsaistē", + "note_needs_connection": "Vajadzīgs savienojums", + "announce": "{files, plural, =0 {Ievilkti faili} zero {Ievilkts # failu} one {Ievilkts # fails} other {Ievilkti # faili}}. Izvēles: {choices}.", + "choose_title": "{count, plural, zero {Kur likt šos failus?} one {Kur likt šo failu?} other {Kur likt šos failus?}}", + "save_first_failed": "Atvērto ziņojumu neizdevās saglabāt, tāpēc lietotne palika, kur bija.", + "import_done": "{count, plural, zero {# ziņojumu importēts mapē {folder}.} one {# ziņojums importēts mapē {folder}.} other {# ziņojumi importēti mapē {folder}.}}", + "import_failed": "{name} neizdevās importēt.", + "too_large_file": "{name} ir pārāk liels. Ierobežojums ir {max} vienam failam.", + "too_large_message": "Šie faili ir pārāk lieli vienam ziņojumam. Ierobežojums ir {max}.", + "folders_left_out": "Mapes var likt tikai failos, tāpēc tās tika izlaistas.", + "nothing_here": "Šos failus šeit nevar izmantot." + }, "desktopReminders": { "generic_title": "Atgādinājums", "snooze": "Atlikt par {count} min", diff --git a/locales/mn/common.json b/locales/mn/common.json index 152574c4e..2303cb1aa 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7058,6 +7058,35 @@ "types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.", "reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу." }, + "fileDrop": { + "overlay_label": "Файл буулгах", + "overlay_title": "Файлуудыг хаана хийх вэ?", + "overlay_hint": "Цуцлахын тулд сонголтуудын гадна буулгах эсвэл Esc дарна уу.", + "files_unknown": "Файлууд", + "files_summary": "{count, plural, one {# файл} other {# файл}}", + "files_summary_size": "{count, plural, one {# файл} other {# файл}}, {size}", + "zone_attach_here": "Энэ мессежид хавсаргах", + "zone_new_message": "Шинэ мессежид хавсаргах", + "zone_upload_files": "Файл руу байршуулах", + "zone_upload_folder": "{folder} руу байршуулах", + "zone_calendar": "Хуанлид нэмэх", + "zone_contacts": "Харилцагчид нэмэх", + "zone_open_eml": "Мессеж нээх", + "zone_import_eml": "{folder} руу импортлох", + "note_limit_message": "Нэг мессежид {size} хүртэл", + "note_limit_file": "Нэг файлд {size} хүртэл", + "note_kept_offline": "Дахин онлайн болох хүртэл энэ төхөөрөмжид хадгалагдана", + "note_needs_connection": "Холболт шаардлагатай", + "announce": "{files, plural, =0 {Файлууд} one {# файл} other {# файл}} чирэгдлээ. Сонголтууд: {choices}.", + "choose_title": "{count, plural, one {Энэ файлыг хаана хийх вэ?} other {Эдгээр файлыг хаана хийх вэ?}}", + "save_first_failed": "Нээлттэй мессежийг хадгалж чадсангүй, тиймээс апп байсан газартаа үлдлээ.", + "import_done": "{count, plural, one {# мессежийг {folder} руу импортоллоо.} other {# мессежийг {folder} руу импортоллоо.}}", + "import_failed": "{name}-г импортлож чадсангүй.", + "too_large_file": "{name} хэт том байна. Хязгаар нь нэг файлд {max}.", + "too_large_message": "Эдгээр файл нэг мессежид хэт том байна. Хязгаар нь {max}.", + "folders_left_out": "Хавтас зөвхөн Файл руу орох боломжтой тул орхигдлоо.", + "nothing_here": "Эдгээр файлыг энд ашиглах боломжгүй." + }, "desktopReminders": { "generic_title": "Сануулга", "snooze": "{count} минут хойшлуулах", diff --git a/locales/nb/common.json b/locales/nb/common.json index 9a6c14f76..f1e3cfd14 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -7058,6 +7058,35 @@ "types_character": "Disse tastene skriver et tegn på tastaturet ditt, og det ville slutte å virke i alle apper. Prøv andre taster.", "reserved": "Systemet ditt eller de fleste apper bruker disse tastene. Prøv andre taster." }, + "fileDrop": { + "overlay_label": "Slipp filer", + "overlay_title": "Hvor skal filene?", + "overlay_hint": "Slipp utenfor valgene eller trykk på Esc for å avbryte.", + "files_unknown": "Filer", + "files_summary": "{count, plural, one {# fil} other {# filer}}", + "files_summary_size": "{count, plural, one {# fil} other {# filer}}, {size}", + "zone_attach_here": "Legg ved denne meldingen", + "zone_new_message": "Legg ved en ny melding", + "zone_upload_files": "Last opp til Filer", + "zone_upload_folder": "Last opp til {folder}", + "zone_calendar": "Legg til i kalenderen", + "zone_contacts": "Legg til i kontakter", + "zone_open_eml": "Åpne melding", + "zone_import_eml": "Importer til {folder}", + "note_limit_message": "Opptil {size} per melding", + "note_limit_file": "Opptil {size} per fil", + "note_kept_offline": "Blir på denne enheten til du er på nett igjen", + "note_needs_connection": "Trenger tilkobling", + "announce": "{files, plural, =0 {Filer} one {# fil} other {# filer}} dratt inn. Valg: {choices}.", + "choose_title": "{count, plural, one {Hvor skal denne filen?} other {Hvor skal disse filene?}}", + "save_first_failed": "Den åpne meldingen kunne ikke lagres, så appen ble der den var.", + "import_done": "{count, plural, one {# melding importert til {folder}.} other {# meldinger importert til {folder}.}}", + "import_failed": "{name} kunne ikke importeres.", + "too_large_file": "{name} er for stor. Grensen er {max} per fil.", + "too_large_message": "Disse filene er for store for én melding. Grensen er {max}.", + "folders_left_out": "Mapper kan bare gå til Filer, så de ble utelatt.", + "nothing_here": "Disse filene kan ikke brukes her." + }, "desktopReminders": { "generic_title": "Påminnelse", "snooze": "Utsett {count} min", diff --git a/locales/nl/common.json b/locales/nl/common.json index 3c57a4007..b9ddfd6af 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -7058,6 +7058,35 @@ "types_character": "Deze toetsen typen een teken op je toetsenbord, en dat zou in geen enkele app meer werken. Probeer andere toetsen.", "reserved": "Je systeem of de meeste apps gebruiken deze toetsen. Probeer andere toetsen." }, + "fileDrop": { + "overlay_label": "Bestanden neerzetten", + "overlay_title": "Waar moeten de bestanden heen?", + "overlay_hint": "Zet buiten de keuzes neer of druk op Esc om te annuleren.", + "files_unknown": "Bestanden", + "files_summary": "{count, plural, one {# bestand} other {# bestanden}}", + "files_summary_size": "{count, plural, one {# bestand} other {# bestanden}}, {size}", + "zone_attach_here": "Bij dit bericht voegen", + "zone_new_message": "Bij een nieuw bericht voegen", + "zone_upload_files": "Uploaden naar Bestanden", + "zone_upload_folder": "Uploaden naar {folder}", + "zone_calendar": "Aan agenda toevoegen", + "zone_contacts": "Aan contacten toevoegen", + "zone_open_eml": "Bericht openen", + "zone_import_eml": "Importeren in {folder}", + "note_limit_message": "Tot {size} per bericht", + "note_limit_file": "Tot {size} per bestand", + "note_kept_offline": "Blijft op dit apparaat tot je weer online bent", + "note_needs_connection": "Heeft een verbinding nodig", + "announce": "{files, plural, =0 {Bestanden} one {# bestand} other {# bestanden}} binnengesleept. Keuzes: {choices}.", + "choose_title": "{count, plural, one {Waar moet dit bestand heen?} other {Waar moeten deze bestanden heen?}}", + "save_first_failed": "Het open bericht kon niet worden opgeslagen, dus de app bleef waar hij was.", + "import_done": "{count, plural, one {# bericht geïmporteerd in {folder}.} other {# berichten geïmporteerd in {folder}.}}", + "import_failed": "{name} kon niet worden geïmporteerd.", + "too_large_file": "{name} is te groot. De grens is {max} per bestand.", + "too_large_message": "Deze bestanden zijn samen te groot voor één bericht. De grens is {max}.", + "folders_left_out": "Mappen kunnen alleen naar Bestanden, dus ze zijn weggelaten.", + "nothing_here": "Deze bestanden kunnen hier niet worden gebruikt." + }, "desktopReminders": { "generic_title": "Herinnering", "snooze": "{count} min uitstellen", diff --git a/locales/pl/common.json b/locales/pl/common.json index 20bd079ae..65b613b33 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -7058,6 +7058,35 @@ "types_character": "Te klawisze wpisują znak na Twojej klawiaturze i przestałby on działać we wszystkich aplikacjach. Wypróbuj inne klawisze.", "reserved": "Te klawisze są używane przez system lub większość aplikacji. Wypróbuj inne klawisze." }, + "fileDrop": { + "overlay_label": "Upuść pliki", + "overlay_title": "Gdzie mają trafić pliki?", + "overlay_hint": "Upuść poza opcjami lub naciśnij Esc, aby anulować.", + "files_unknown": "Pliki", + "files_summary": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}", + "files_summary_size": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}, {size}", + "zone_attach_here": "Załącz do tej wiadomości", + "zone_new_message": "Załącz do nowej wiadomości", + "zone_upload_files": "Prześlij do Plików", + "zone_upload_folder": "Prześlij do {folder}", + "zone_calendar": "Dodaj do kalendarza", + "zone_contacts": "Dodaj do kontaktów", + "zone_open_eml": "Otwórz wiadomość", + "zone_import_eml": "Importuj do {folder}", + "note_limit_message": "Do {size} na wiadomość", + "note_limit_file": "Do {size} na plik", + "note_kept_offline": "Zostaje na tym urządzeniu, aż znów będziesz online", + "note_needs_connection": "Wymaga połączenia", + "announce": "{files, plural, =0 {Przeciągnięto pliki} one {Przeciągnięto # plik} few {Przeciągnięto # pliki} many {Przeciągnięto # plików} other {Przeciągnięto # pliku}}. Opcje: {choices}.", + "choose_title": "{count, plural, one {Gdzie ma trafić ten plik?} few {Gdzie mają trafić te pliki?} many {Gdzie mają trafić te pliki?} other {Gdzie mają trafić te pliki?}}", + "save_first_failed": "Nie udało się zapisać otwartej wiadomości, więc aplikacja została tam, gdzie była.", + "import_done": "{count, plural, one {Zaimportowano # wiadomość do {folder}.} few {Zaimportowano # wiadomości do {folder}.} many {Zaimportowano # wiadomości do {folder}.} other {Zaimportowano # wiadomości do {folder}.}}", + "import_failed": "Nie udało się zaimportować {name}.", + "too_large_file": "{name} jest za duży. Limit to {max} na plik.", + "too_large_message": "Te pliki są za duże na jedną wiadomość. Limit to {max}.", + "folders_left_out": "Foldery mogą trafić tylko do Plików, więc zostały pominięte.", + "nothing_here": "Tych plików nie można tu użyć." + }, "desktopReminders": { "generic_title": "Przypomnienie", "snooze": "Odłóż o {count} min", diff --git a/locales/pt/common.json b/locales/pt/common.json index 2452edf4a..f9a029c78 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -7058,6 +7058,35 @@ "types_character": "Estas teclas escrevem um carácter no seu teclado, e ele deixaria de funcionar em todas as aplicações. Experimente outras teclas.", "reserved": "O seu sistema ou a maioria das aplicações usam estas teclas. Experimente outras teclas." }, + "fileDrop": { + "overlay_label": "Soltar arquivos", + "overlay_title": "Para onde vão os arquivos?", + "overlay_hint": "Solte fora das opções ou prima Esc para cancelar.", + "files_unknown": "Arquivos", + "files_summary": "{count, plural, one {# arquivo} other {# arquivos}}", + "files_summary_size": "{count, plural, one {# arquivo} other {# arquivos}}, {size}", + "zone_attach_here": "Anexar a esta mensagem", + "zone_new_message": "Anexar a uma nova mensagem", + "zone_upload_files": "Enviar para Arquivos", + "zone_upload_folder": "Enviar para {folder}", + "zone_calendar": "Adicionar ao calendário", + "zone_contacts": "Adicionar aos contactos", + "zone_open_eml": "Abrir mensagem", + "zone_import_eml": "Importar para {folder}", + "note_limit_message": "Até {size} por mensagem", + "note_limit_file": "Até {size} por arquivo", + "note_kept_offline": "Fica neste dispositivo até voltar a estar online", + "note_needs_connection": "Precisa de ligação", + "announce": "{files, plural, =0 {Arquivos arrastados} one {# arquivo arrastado} other {# arquivos arrastados}}. Opções: {choices}.", + "choose_title": "{count, plural, one {Para onde vai este arquivo?} other {Para onde vão estes arquivos?}}", + "save_first_failed": "Não foi possível guardar a mensagem aberta, por isso a app ficou onde estava.", + "import_done": "{count, plural, one {# mensagem importada para {folder}.} other {# mensagens importadas para {folder}.}}", + "import_failed": "Não foi possível importar {name}.", + "too_large_file": "{name} é demasiado grande. O limite é {max} por arquivo.", + "too_large_message": "Estes arquivos são demasiado grandes para uma mensagem. O limite é {max}.", + "folders_left_out": "As pastas só podem ir para Arquivos, por isso ficaram de fora.", + "nothing_here": "Estes arquivos não podem ser usados aqui." + }, "desktopReminders": { "generic_title": "Lembrete", "snooze": "Adiar {count} min", diff --git a/locales/ro/common.json b/locales/ro/common.json index 03bfef66a..d72dd9ebd 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -7058,6 +7058,35 @@ "types_character": "Aceste taste scriu un caracter pe tastatura ta, iar el n-ar mai funcționa în nicio aplicație. Încearcă alte taste.", "reserved": "Sistemul sau majoritatea aplicațiilor folosesc aceste taste. Încearcă alte taste." }, + "fileDrop": { + "overlay_label": "Plasați fișiere", + "overlay_title": "Unde merg fișierele?", + "overlay_hint": "Plasați în afara opțiunilor sau apăsați Esc pentru a anula.", + "files_unknown": "Fișiere", + "files_summary": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}", + "files_summary_size": "{count, plural, one {# fișier} few {# fișiere} other {# de fișiere}}, {size}", + "zone_attach_here": "Atașați la acest mesaj", + "zone_new_message": "Atașați la un mesaj nou", + "zone_upload_files": "Încărcați în Fișiere", + "zone_upload_folder": "Încărcați în {folder}", + "zone_calendar": "Adăugați în calendar", + "zone_contacts": "Adăugați la contacte", + "zone_open_eml": "Deschideți mesajul", + "zone_import_eml": "Importați în {folder}", + "note_limit_message": "Până la {size} pe mesaj", + "note_limit_file": "Până la {size} pe fișier", + "note_kept_offline": "Rămâne pe acest dispozitiv până reveniți online", + "note_needs_connection": "Necesită conexiune", + "announce": "{files, plural, =0 {Fișiere trase} one {# fișier tras} few {# fișiere trase} other {# de fișiere trase}}. Opțiuni: {choices}.", + "choose_title": "{count, plural, one {Unde merge acest fișier?} few {Unde merg aceste fișiere?} other {Unde merg aceste fișiere?}}", + "save_first_failed": "Mesajul deschis nu a putut fi salvat, așa că aplicația a rămas unde era.", + "import_done": "{count, plural, one {# mesaj importat în {folder}.} few {# mesaje importate în {folder}.} other {# de mesaje importate în {folder}.}}", + "import_failed": "{name} nu a putut fi importat.", + "too_large_file": "{name} este prea mare. Limita este {max} pe fișier.", + "too_large_message": "Aceste fișiere sunt prea mari pentru un mesaj. Limita este {max}.", + "folders_left_out": "Dosarele pot merge doar în Fișiere, așa că au fost lăsate deoparte.", + "nothing_here": "Aceste fișiere nu pot fi folosite aici." + }, "desktopReminders": { "generic_title": "Memento", "snooze": "Amână {count} min", diff --git a/locales/ru/common.json b/locales/ru/common.json index ebb25a96f..2a34bc36b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7058,6 +7058,35 @@ "types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.", "reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши." }, + "fileDrop": { + "overlay_label": "Перетащите файлы", + "overlay_title": "Куда отправить файлы?", + "overlay_hint": "Чтобы отменить, отпустите вне вариантов или нажмите Esc.", + "files_unknown": "Файлы", + "files_summary": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}", + "files_summary_size": "{count, plural, one {# файл} few {# файла} many {# файлов} other {# файла}}, {size}", + "zone_attach_here": "Прикрепить к этому письму", + "zone_new_message": "Прикрепить к новому письму", + "zone_upload_files": "Загрузить в Файлы", + "zone_upload_folder": "Загрузить в {folder}", + "zone_calendar": "Добавить в календарь", + "zone_contacts": "Добавить в контакты", + "zone_open_eml": "Открыть письмо", + "zone_import_eml": "Импортировать в {folder}", + "note_limit_message": "До {size} на письмо", + "note_limit_file": "До {size} на файл", + "note_kept_offline": "Хранится на этом устройстве, пока вы снова не будете в сети", + "note_needs_connection": "Нужно подключение", + "announce": "{files, plural, =0 {Перетащены файлы} one {Перетащен # файл} few {Перетащено # файла} many {Перетащено # файлов} other {Перетащено # файла}}. Варианты: {choices}.", + "choose_title": "{count, plural, one {Куда отправить этот файл?} few {Куда отправить эти файлы?} many {Куда отправить эти файлы?} other {Куда отправить эти файлы?}}", + "save_first_failed": "Открытое письмо не удалось сохранить, поэтому приложение осталось на месте.", + "import_done": "{count, plural, one {# письмо импортировано в {folder}.} few {# письма импортировано в {folder}.} many {# писем импортировано в {folder}.} other {# письма импортировано в {folder}.}}", + "import_failed": "Не удалось импортировать {name}.", + "too_large_file": "{name} слишком большой. Ограничение: {max} на файл.", + "too_large_message": "Эти файлы слишком большие для одного письма. Ограничение: {max}.", + "folders_left_out": "Папки можно отправить только в Файлы, поэтому они пропущены.", + "nothing_here": "Эти файлы здесь нельзя использовать." + }, "desktopReminders": { "generic_title": "Напоминание", "snooze": "Отложить на {count} мин", diff --git a/locales/sk/common.json b/locales/sk/common.json index 43343c99e..8acfc3240 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -7058,6 +7058,35 @@ "types_character": "Tieto klávesy na vašej klávesnici píšu znak, ktorý by potom nefungoval v žiadnej aplikácii. Skúste iné klávesy.", "reserved": "Tieto klávesy používa systém alebo väčšina aplikácií. Skúste iné klávesy." }, + "fileDrop": { + "overlay_label": "Pustiť súbory", + "overlay_title": "Kam majú súbory ísť?", + "overlay_hint": "Na zrušenie pustite mimo volieb alebo stlačte Esc.", + "files_unknown": "Súbory", + "files_summary": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}", + "files_summary_size": "{count, plural, one {# súbor} few {# súbory} many {# súboru} other {# súborov}}, {size}", + "zone_attach_here": "Priložiť k tejto správe", + "zone_new_message": "Priložiť k novej správe", + "zone_upload_files": "Nahrať do Súborov", + "zone_upload_folder": "Nahrať do {folder}", + "zone_calendar": "Pridať do kalendára", + "zone_contacts": "Pridať do kontaktov", + "zone_open_eml": "Otvoriť správu", + "zone_import_eml": "Importovať do {folder}", + "note_limit_message": "Až {size} na správu", + "note_limit_file": "Až {size} na súbor", + "note_kept_offline": "Zostane v tomto zariadení, kým nebudete online", + "note_needs_connection": "Vyžaduje pripojenie", + "announce": "{files, plural, =0 {Pretiahnuté súbory} one {Pretiahnutý # súbor} few {Pretiahnuté # súbory} many {Pretiahnutého # súboru} other {Pretiahnutých # súborov}}. Voľby: {choices}.", + "choose_title": "{count, plural, one {Kam má tento súbor ísť?} few {Kam majú tieto súbory ísť?} many {Kam majú tieto súbory ísť?} other {Kam majú tieto súbory ísť?}}", + "save_first_failed": "Otvorenú správu sa nepodarilo uložiť, preto aplikácia zostala, kde bola.", + "import_done": "{count, plural, one {# správa importovaná do {folder}.} few {# správy importované do {folder}.} many {# správy importovanej do {folder}.} other {# správ importovaných do {folder}.}}", + "import_failed": "{name} sa nepodarilo importovať.", + "too_large_file": "{name} je príliš veľký. Limit je {max} na súbor.", + "too_large_message": "Tieto súbory sú na jednu správu príliš veľké. Limit je {max}.", + "folders_left_out": "Priečinky môžu ísť len do Súborov, preto boli vynechané.", + "nothing_here": "Tieto súbory sa tu nedajú použiť." + }, "desktopReminders": { "generic_title": "Pripomienka", "snooze": "Odložiť o {count} min", diff --git a/locales/th/common.json b/locales/th/common.json index d4aef2af2..c25f55fdf 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7066,6 +7066,35 @@ "types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น", "reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น" }, + "fileDrop": { + "overlay_label": "วางไฟล์", + "overlay_title": "จะให้ไฟล์ไปที่ไหน", + "overlay_hint": "วางนอกตัวเลือกหรือกด Esc เพื่อยกเลิก", + "files_unknown": "ไฟล์", + "files_summary": "{count, plural, other {# ไฟล์}}", + "files_summary_size": "{count, plural, other {# ไฟล์}}, {size}", + "zone_attach_here": "แนบกับข้อความนี้", + "zone_new_message": "แนบกับข้อความใหม่", + "zone_upload_files": "อัปโหลดไปยังไฟล์", + "zone_upload_folder": "อัปโหลดไปยัง {folder}", + "zone_calendar": "เพิ่มในปฏิทิน", + "zone_contacts": "เพิ่มในรายชื่อติดต่อ", + "zone_open_eml": "เปิดข้อความ", + "zone_import_eml": "นำเข้าไปยัง {folder}", + "note_limit_message": "สูงสุด {size} ต่อข้อความ", + "note_limit_file": "สูงสุด {size} ต่อไฟล์", + "note_kept_offline": "เก็บไว้ในอุปกรณ์นี้จนกว่าคุณจะออนไลน์อีกครั้ง", + "note_needs_connection": "ต้องใช้การเชื่อมต่อ", + "announce": "{files, plural, =0 {ลากไฟล์เข้ามาแล้ว} other {ลาก # ไฟล์เข้ามาแล้ว}} ตัวเลือก: {choices}", + "choose_title": "{count, plural, other {จะให้ไฟล์นี้ไปที่ไหน}}", + "save_first_failed": "บันทึกข้อความที่เปิดอยู่ไม่ได้ แอปจึงอยู่ที่เดิม", + "import_done": "{count, plural, other {นำเข้า # ข้อความไปยัง {folder} แล้ว}}", + "import_failed": "นำเข้า {name} ไม่ได้", + "too_large_file": "{name} ใหญ่เกินไป ขีดจำกัดคือ {max} ต่อไฟล์", + "too_large_message": "ไฟล์เหล่านี้ใหญ่เกินไปสำหรับข้อความเดียว ขีดจำกัดคือ {max}", + "folders_left_out": "โฟลเดอร์ไปได้เฉพาะที่ไฟล์ จึงไม่ได้รวมไว้", + "nothing_here": "ใช้ไฟล์เหล่านี้ที่นี่ไม่ได้" + }, "desktopReminders": { "generic_title": "การแจ้งเตือน", "snooze": "เลื่อน {count} นาที", diff --git a/locales/tr/common.json b/locales/tr/common.json index 03393f7f0..956d66056 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -7058,6 +7058,35 @@ "types_character": "Bu tuşlar klavyenizde bir karakter yazar ve bu karakter hiçbir uygulamada çalışmaz olur. Başka tuşlar deneyin.", "reserved": "Bu tuşları sisteminiz veya çoğu uygulama kullanıyor. Başka tuşlar deneyin." }, + "fileDrop": { + "overlay_label": "Dosyaları bırakın", + "overlay_title": "Dosyalar nereye gitsin?", + "overlay_hint": "İptal etmek için seçeneklerin dışına bırakın veya Esc tuşuna basın.", + "files_unknown": "Dosyalar", + "files_summary": "{count, plural, one {# dosya} other {# dosya}}", + "files_summary_size": "{count, plural, one {# dosya} other {# dosya}}, {size}", + "zone_attach_here": "Bu iletiye ekle", + "zone_new_message": "Yeni bir iletiye ekle", + "zone_upload_files": "Dosyalar'a yükle", + "zone_upload_folder": "{folder} klasörüne yükle", + "zone_calendar": "Takvime ekle", + "zone_contacts": "Kişilere ekle", + "zone_open_eml": "İletiyi aç", + "zone_import_eml": "{folder} klasörüne içe aktar", + "note_limit_message": "İleti başına en fazla {size}", + "note_limit_file": "Dosya başına en fazla {size}", + "note_kept_offline": "Tekrar çevrimiçi olana kadar bu cihazda kalır", + "note_needs_connection": "Bağlantı gerekir", + "announce": "{files, plural, =0 {Dosyalar} one {# dosya} other {# dosya}} sürüklendi. Seçenekler: {choices}.", + "choose_title": "{count, plural, one {Bu dosya nereye gitsin?} other {Bu dosyalar nereye gitsin?}}", + "save_first_failed": "Açık ileti kaydedilemedi, bu yüzden uygulama olduğu yerde kaldı.", + "import_done": "{count, plural, one {# ileti {folder} klasörüne aktarıldı.} other {# ileti {folder} klasörüne aktarıldı.}}", + "import_failed": "{name} içe aktarılamadı.", + "too_large_file": "{name} çok büyük. Sınır dosya başına {max}.", + "too_large_message": "Bu dosyalar tek bir ileti için çok büyük. Sınır {max}.", + "folders_left_out": "Klasörler yalnızca Dosyalar'a gidebilir, bu yüzden dışarıda bırakıldı.", + "nothing_here": "Bu dosyalar burada kullanılamaz." + }, "desktopReminders": { "generic_title": "Anımsatıcı", "snooze": "{count} dk ertele", diff --git a/locales/uk/common.json b/locales/uk/common.json index f033cd1ee..58b3f3d2d 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7058,6 +7058,35 @@ "types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.", "reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші." }, + "fileDrop": { + "overlay_label": "Перетягніть файли", + "overlay_title": "Куди надіслати файли?", + "overlay_hint": "Щоб скасувати, відпустіть поза варіантами або натисніть Esc.", + "files_unknown": "Файли", + "files_summary": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}", + "files_summary_size": "{count, plural, one {# файл} few {# файли} many {# файлів} other {# файлу}}, {size}", + "zone_attach_here": "Прикріпити до цього повідомлення", + "zone_new_message": "Прикріпити до нового повідомлення", + "zone_upload_files": "Завантажити у Файли", + "zone_upload_folder": "Завантажити в {folder}", + "zone_calendar": "Додати до календаря", + "zone_contacts": "Додати до контактів", + "zone_open_eml": "Відкрити повідомлення", + "zone_import_eml": "Імпортувати в {folder}", + "note_limit_message": "До {size} на повідомлення", + "note_limit_file": "До {size} на файл", + "note_kept_offline": "Зберігається на цьому пристрої, доки ви знову не будете в мережі", + "note_needs_connection": "Потрібне з’єднання", + "announce": "{files, plural, =0 {Перетягнуто файли} one {Перетягнуто # файл} few {Перетягнуто # файли} many {Перетягнуто # файлів} other {Перетягнуто # файлу}}. Варіанти: {choices}.", + "choose_title": "{count, plural, one {Куди надіслати цей файл?} few {Куди надіслати ці файли?} many {Куди надіслати ці файли?} other {Куди надіслати ці файли?}}", + "save_first_failed": "Відкрите повідомлення не вдалося зберегти, тому застосунок лишився на місці.", + "import_done": "{count, plural, one {# повідомлення імпортовано в {folder}.} few {# повідомлення імпортовано в {folder}.} many {# повідомлень імпортовано в {folder}.} other {# повідомлення імпортовано в {folder}.}}", + "import_failed": "Не вдалося імпортувати {name}.", + "too_large_file": "{name} завеликий. Обмеження: {max} на файл.", + "too_large_message": "Ці файли завеликі для одного повідомлення. Обмеження: {max}.", + "folders_left_out": "Теки можна надіслати лише у Файли, тому їх пропущено.", + "nothing_here": "Ці файли тут не можна використати." + }, "desktopReminders": { "generic_title": "Нагадування", "snooze": "Відкласти на {count} хв", diff --git a/locales/vi/common.json b/locales/vi/common.json index 9faa09bde..59b4ac37b 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -7070,6 +7070,35 @@ "types_character": "Các phím này gõ một ký tự trên bàn phím của bạn, và ký tự đó sẽ không dùng được trong mọi ứng dụng. Hãy thử phím khác.", "reserved": "Hệ thống hoặc hầu hết ứng dụng dùng các phím này. Hãy thử phím khác." }, + "fileDrop": { + "overlay_label": "Thả tệp", + "overlay_title": "Đưa tệp đi đâu?", + "overlay_hint": "Thả bên ngoài các lựa chọn hoặc nhấn Esc để hủy.", + "files_unknown": "Tệp", + "files_summary": "{count, plural, other {# tệp}}", + "files_summary_size": "{count, plural, other {# tệp}}, {size}", + "zone_attach_here": "Đính kèm vào thư này", + "zone_new_message": "Đính kèm vào thư mới", + "zone_upload_files": "Tải lên Tệp", + "zone_upload_folder": "Tải lên {folder}", + "zone_calendar": "Thêm vào lịch", + "zone_contacts": "Thêm vào danh bạ", + "zone_open_eml": "Mở thư", + "zone_import_eml": "Nhập vào {folder}", + "note_limit_message": "Tối đa {size} mỗi thư", + "note_limit_file": "Tối đa {size} mỗi tệp", + "note_kept_offline": "Được giữ trên thiết bị này đến khi bạn trực tuyến lại", + "note_needs_connection": "Cần kết nối", + "announce": "{files, plural, =0 {Đã kéo tệp vào} other {Đã kéo vào # tệp}}. Lựa chọn: {choices}.", + "choose_title": "{count, plural, other {Đưa các tệp này đi đâu?}}", + "save_first_failed": "Không lưu được thư đang mở, nên ứng dụng vẫn ở chỗ cũ.", + "import_done": "{count, plural, other {Đã nhập # thư vào {folder}.}}", + "import_failed": "Không nhập được {name}.", + "too_large_file": "{name} quá lớn. Giới hạn là {max} mỗi tệp.", + "too_large_message": "Các tệp này quá lớn cho một thư. Giới hạn là {max}.", + "folders_left_out": "Thư mục chỉ có thể đưa vào Tệp, nên đã bị bỏ qua.", + "nothing_here": "Không dùng được các tệp này ở đây." + }, "desktopReminders": { "generic_title": "Lời nhắc", "snooze": "Báo lại sau {count} phút", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index eed67bf21..4a6e8f67a 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7058,6 +7058,35 @@ "types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。", "reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。" }, + "fileDrop": { + "overlay_label": "放開檔案", + "overlay_title": "要把檔案放到哪裡?", + "overlay_hint": "在選項外放開或按 Esc 取消。", + "files_unknown": "檔案", + "files_summary": "{count, plural, other {# 個檔案}}", + "files_summary_size": "{count, plural, other {# 個檔案}}, {size}", + "zone_attach_here": "附加到此郵件", + "zone_new_message": "附加到新郵件", + "zone_upload_files": "上傳到檔案", + "zone_upload_folder": "上傳到 {folder}", + "zone_calendar": "加入行事曆", + "zone_contacts": "加入聯絡人", + "zone_open_eml": "開啟郵件", + "zone_import_eml": "匯入到 {folder}", + "note_limit_message": "每封郵件最多 {size}", + "note_limit_file": "每個檔案最多 {size}", + "note_kept_offline": "在您重新連線前保留在此裝置上", + "note_needs_connection": "需要連線", + "announce": "{files, plural, =0 {已拖入檔案} other {已拖入 # 個檔案}}。選項:{choices}。", + "choose_title": "{count, plural, other {要把這些檔案放到哪裡?}}", + "save_first_failed": "無法儲存開啟的郵件,因此應用程式停留在原處。", + "import_done": "{count, plural, other {已將 # 封郵件匯入到 {folder}。}}", + "import_failed": "無法匯入 {name}。", + "too_large_file": "{name} 太大。每個檔案的上限為 {max}。", + "too_large_message": "這些檔案對一封郵件來說太大。上限為 {max}。", + "folders_left_out": "資料夾只能放到檔案中,因此已略過。", + "nothing_here": "這些檔案無法在此使用。" + }, "desktopReminders": { "generic_title": "提醒", "snooze": "{count} 分鐘後再提醒", diff --git a/locales/zh/common.json b/locales/zh/common.json index 0edeb8c81..e17e37435 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7058,6 +7058,35 @@ "types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。", "reserved": "系统或大多数应用在使用这些键。请试试其他按键。" }, + "fileDrop": { + "overlay_label": "放下文件", + "overlay_title": "要把文件放到哪里?", + "overlay_hint": "在选项外放下或按 Esc 取消。", + "files_unknown": "文件", + "files_summary": "{count, plural, other {# 个文件}}", + "files_summary_size": "{count, plural, other {# 个文件}}, {size}", + "zone_attach_here": "附加到此邮件", + "zone_new_message": "附加到新邮件", + "zone_upload_files": "上传到文件", + "zone_upload_folder": "上传到 {folder}", + "zone_calendar": "添加到日历", + "zone_contacts": "添加到联系人", + "zone_open_eml": "打开邮件", + "zone_import_eml": "导入到 {folder}", + "note_limit_message": "每封邮件最多 {size}", + "note_limit_file": "每个文件最多 {size}", + "note_kept_offline": "在您重新联网前保存在此设备上", + "note_needs_connection": "需要连接", + "announce": "{files, plural, =0 {已拖入文件} other {已拖入 # 个文件}}。选项:{choices}。", + "choose_title": "{count, plural, other {要把这些文件放到哪里?}}", + "save_first_failed": "无法保存打开的邮件,因此应用停留在原处。", + "import_done": "{count, plural, other {已将 # 封邮件导入到 {folder}。}}", + "import_failed": "无法导入 {name}。", + "too_large_file": "{name} 太大。每个文件的上限为 {max}。", + "too_large_message": "这些文件对一封邮件来说太大。上限为 {max}。", + "folders_left_out": "文件夹只能放到文件中,因此已略过。", + "nothing_here": "这些文件无法在此使用。" + }, "desktopReminders": { "generic_title": "提醒", "snooze": "{count} 分钟后再提醒", From 1f4b0669ca7466acfff0afdf51532824905e9cf3 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:38:43 +0100 Subject: [PATCH 19/46] feat: ask between a new message and Files for a file opened with the desktop app --- lib/desktop/__tests__/os-commands.test.ts | 17 +++++++++++++++++ lib/desktop/os-commands.ts | 6 +++++- lib/desktop/types.ts | 3 ++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts index 35338c572..0410e2ce6 100644 --- a/lib/desktop/__tests__/os-commands.test.ts +++ b/lib/desktop/__tests__/os-commands.test.ts @@ -114,6 +114,23 @@ describe('launches', () => { expect(draft?.files.map((file) => file.name)).toEqual(['report.pdf']); }); + it('several files sent to the app go on one draft, without a question', async () => { + const { takeHandoff } = await import('@/lib/launch-handoff'); + await run({ type: 'files', reason: 'attach', failed: [], files: [{ name: 'a.pdf', data: text('a') }, { name: 'b.png', data: text('b') }] }); + expect(takeHandoff('compose')?.files.map((file) => file.name)).toEqual(['a.pdf', 'b.png']); + }); + + it('a file opened with the app asks: new message or Files, a new message first', async () => { + const { takeHandoff } = await import('@/lib/launch-handoff'); + const { listenForFileChoices } = await import('@/lib/file-drop/choice'); + const asked = vi.fn(); + const stop = listenForFileChoices(asked); + await run({ type: 'files', reason: 'attach', ask: true, failed: [], files: [{ name: 'report.pdf', data: text('pdf') }] }); + expect(asked).toHaveBeenCalledWith({ files: [expect.objectContaining({ name: 'report.pdf' })], preferred: 'new-message' }); + expect(takeHandoff('compose')).toBeNull(); + stop(); + }); + it('says so when a file could not be read', async () => { await run({ type: 'files', reason: 'open', failed: ['gone.eml'], files: [] }); expect(notifyFailed).toHaveBeenCalledWith(['gone.eml']); diff --git a/lib/desktop/os-commands.ts b/lib/desktop/os-commands.ts index c52c1baad..5209e07ea 100644 --- a/lib/desktop/os-commands.ts +++ b/lib/desktop/os-commands.ts @@ -9,6 +9,7 @@ import { isAppLocked } from '@/lib/desktop/app-lock-state'; import { getDesktopBridge } from '@/lib/desktop/bridge'; import type { DesktopOsCommand } from '@/lib/desktop/types'; import { openMailLink } from '@/lib/desktop/mail-links'; +import { requestFileChoice } from '@/lib/file-drop/choice'; import { handleReminderCommand } from '@/lib/desktop/reminder-notifications'; import type { MailNotificationContext } from '@/lib/desktop/mail-notifications'; import { handOffDraft, handOffFiles, handOffNewMessage, kindForFile } from '@/lib/launch-handoff'; @@ -131,7 +132,10 @@ async function handleNotification(action: string, rawContext: string, reply: str function handleFiles(command: Extract, env: OsCommandEnv): void { const made = command.files.map(({ name, data }) => new File([new Uint8Array(data)], name)); const failed = [...command.failed]; - if (command.reason === 'attach') { + if (command.reason === 'attach' && command.ask) { + // Opened with the app ("Open with"): the same choice as dropping it on the window. + requestFileChoice({ files: made, preferred: 'new-message' }); + } else if (command.reason === 'attach') { if (made.length > 0) handOffDraft({ files: made }); } else { for (const file of made) { diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 69cbd18c0..9bbdfdbc8 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -550,7 +550,8 @@ export type DesktopOsCommand = | { type: 'open-mailbox'; mailboxId: string } | { type: 'mailto'; url: string } | { type: 'webcal'; url: string } - | { type: 'files'; reason: 'open' | 'attach'; files: DesktopOsFile[]; failed: string[] } + /** `ask`: opened with the app, so the user picks between a new message and Files. Without it, attach. */ + | { type: 'files'; reason: 'open' | 'attach'; files: DesktopOsFile[]; failed: string[]; ask?: boolean } | { type: 'notification'; action: string; context: string; reply?: string }; export interface DesktopOsNotification { From 1cf03ff0e4b73931af73714b791dca3ac61dff2f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:38:59 +0100 Subject: [PATCH 20/46] feat: add a developer tools switch to the desktop app's troubleshooting settings --- .../desktop-devtools-settings.test.tsx | 70 +++++++++++++++++++ .../settings/desktop-devtools-settings.tsx | 51 ++++++++++++++ components/settings/general-settings.tsx | 2 + desktop-policies/Bulwark.admx | 14 ++++ desktop-policies/de-DE/Bulwark.adml | 6 ++ desktop-policies/en-US/Bulwark.adml | 6 ++ .../org.bulwarkmail.desktop.mobileconfig | 4 ++ desktop-policies/policies.json | 2 + lib/desktop/__tests__/policy-schema.test.ts | 17 +++++ lib/desktop/languages.ts | 1 + lib/desktop/policy-schema.ts | 35 +++++++++- lib/desktop/types.ts | 19 +++++ locales/ar/common.json | 8 +++ locales/ca/common.json | 8 +++ locales/cs/common.json | 8 +++ locales/da/common.json | 8 +++ locales/de/common.json | 8 +++ locales/en/common.json | 8 +++ locales/es/common.json | 8 +++ locales/et/common.json | 8 +++ locales/fa/common.json | 8 +++ locales/fr/common.json | 8 +++ locales/he/common.json | 8 +++ locales/hu/common.json | 8 +++ locales/it/common.json | 8 +++ locales/ja/common.json | 8 +++ locales/ko/common.json | 8 +++ locales/lv/common.json | 8 +++ locales/mn/common.json | 8 +++ locales/nb/common.json | 8 +++ locales/nl/common.json | 8 +++ locales/pl/common.json | 8 +++ locales/pt/common.json | 8 +++ locales/ro/common.json | 8 +++ locales/ru/common.json | 8 +++ locales/sk/common.json | 8 +++ locales/th/common.json | 8 +++ locales/tr/common.json | 8 +++ locales/uk/common.json | 8 +++ locales/vi/common.json | 8 +++ locales/zh-TW/common.json | 8 +++ locales/zh/common.json | 8 +++ 42 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 components/settings/__tests__/desktop-devtools-settings.test.tsx create mode 100644 components/settings/desktop-devtools-settings.tsx diff --git a/components/settings/__tests__/desktop-devtools-settings.test.tsx b/components/settings/__tests__/desktop-devtools-settings.test.tsx new file mode 100644 index 000000000..c19e2720c --- /dev/null +++ b/components/settings/__tests__/desktop-devtools-settings.test.tsx @@ -0,0 +1,70 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge, DesktopDevToolsState } from '@/lib/desktop/types'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string, values?: Record) => (values ? `${key}:${JSON.stringify(values)}` : key), +})); + +const { DesktopDevToolsSettings } = await import('../desktop-devtools-settings'); + +function setBridge(state: DesktopDevToolsState | null, platform: 'win32' | 'darwin' = 'win32') { + const listeners = new Set<(next: DesktopDevToolsState) => void>(); + const set = vi.fn(async (on: boolean) => ({ ...state!, on })); + window.bulwarkDesktop = (state + ? { + version: 1, + platform, + devTools: { + get: async () => state, + set, + onChange: (listener: (next: DesktopDevToolsState) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + } + : { version: 1, platform }) as unknown as BulwarkDesktopBridge; + return { set, emit: (next: DesktopDevToolsState) => listeners.forEach((listener) => listener(next)) }; +} + +afterEach(() => { + window.bulwarkDesktop = undefined; +}); + +describe('the developer tools setting', () => { + it('is not there in a browser or on a shell without it', () => { + const { container } = render(); + expect(container.innerHTML).toBe(''); + setBridge(null); + const second = render(); + expect(second.container.innerHTML).toBe(''); + }); + + it('is off by default, names the shortcut, and asks main to turn it on', async () => { + const { set } = setBridge({ on: false, forcedOff: false, locked: false }); + render(); + const toggle = await screen.findByTestId('desktop-devtools-toggle'); + expect(toggle.getAttribute('aria-checked')).toBe('false'); + expect(screen.getByText('label:{"shortcut":"Ctrl+Shift+I"}')).toBeTruthy(); + fireEvent.click(toggle); + expect(set).toHaveBeenCalledWith(true); + }); + + it('a Mac names its own keys', async () => { + setBridge({ on: false, forcedOff: false, locked: false }, 'darwin'); + render(); + expect(await screen.findByText('label:{"shortcut":"Cmd+Option+I"}')).toBeTruthy(); + }); + + it('a policy locks it off, and the app lock stops changes', async () => { + const { emit } = setBridge({ on: false, forcedOff: true, locked: false }); + render(); + const toggle = await screen.findByTestId('desktop-devtools-toggle'); + expect(toggle.hasAttribute('disabled')).toBe(true); + expect(screen.getByText('forced_off')).toBeTruthy(); + act(() => emit({ on: false, forcedOff: false, locked: true })); + expect(screen.getByText('locked')).toBeTruthy(); + expect(screen.getByTestId('desktop-devtools-toggle').hasAttribute('disabled')).toBe(true); + }); +}); diff --git a/components/settings/desktop-devtools-settings.tsx b/components/settings/desktop-devtools-settings.tsx new file mode 100644 index 000000000..e53180a1f --- /dev/null +++ b/components/settings/desktop-devtools-settings.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import type { DesktopDevToolsState } from "@/lib/desktop/types"; +import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section"; + +/** + * Troubleshooting in the desktop app: Chromium's developer tools on a key, off until the + * user turns them on. Main keeps the switch and asks with a native warning, so a page or + * plugin can't turn it on by itself. A browser never shows this. + */ +export function DesktopDevToolsSettings() { + const t = useTranslations("desktopDevTools"); + const bridge = getDesktopBridge(); + const devTools = bridge?.devTools; + const [state, setState] = useState(null); + + useEffect(() => { + if (!devTools) return; + let stale = false; + void devTools.get().then((next) => { + if (!stale) setState(next); + }, () => {}); + const stop = devTools.onChange((next) => { + if (!stale) setState(next); + }); + return () => { + stale = true; + stop(); + }; + }, [devTools]); + + if (!devTools || !state) return null; + const shortcut = bridge?.platform === "darwin" ? "Cmd+Option+I" : "Ctrl+Shift+I"; + const description = state.forcedOff ? t("forced_off") : state.locked ? t("locked") : t("description", { shortcut }); + + return ( + + + void devTools.set(on).then(setState, () => {})} + /> + + + ); +} diff --git a/components/settings/general-settings.tsx b/components/settings/general-settings.tsx index 115571fd3..ea4685522 100644 --- a/components/settings/general-settings.tsx +++ b/components/settings/general-settings.tsx @@ -15,6 +15,7 @@ import { PlayCircle } from '@/components/icons'; import { IS_LITE } from '@/lib/lite'; import { DesktopUpdateSettings } from './desktop-update-settings'; import { DesktopWindowSettings } from './desktop-window-settings'; +import { DesktopDevToolsSettings } from './desktop-devtools-settings'; /** * The General page: language and time, which links the browser hands to @@ -61,6 +62,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea {!IS_LITE && } +
); } diff --git a/desktop-policies/Bulwark.admx b/desktop-policies/Bulwark.admx index d9020a931..4cfa98e5a 100644 --- a/desktop-policies/Bulwark.admx +++ b/desktop-policies/Bulwark.admx @@ -20,6 +20,7 @@ + @@ -234,5 +235,18 @@ + + + + + + + + + + + + + diff --git a/desktop-policies/de-DE/Bulwark.adml b/desktop-policies/de-DE/Bulwark.adml index 2dc61f9cc..c9f2d6c12 100644 --- a/desktop-policies/de-DE/Bulwark.adml +++ b/desktop-policies/de-DE/Bulwark.adml @@ -15,6 +15,7 @@ Offline-E-Mails und App-Sperre Datenschutz Anmeldung und Zertifikate + Systemintegration und Fehlerbehebung Bulwark-Server Die Adresse Ihres Bulwark-Servers, beginnend mit https://. Wenn erzwungen, öffnet sich die App auf diesem Server, die Serverauswahl ist ausgeblendet und das Fenster bleibt auf diesem Server. Eine ungültige Adresse zeigt eine Fehlerseite mit dem Namen dieser Richtlinie, und die App weicht auf nichts anderes aus. Als Standardwert öffnet sich die App auf diesem Server, bis der Benutzer etwas anderes wählt. Direkte Verbindungen zu Mailservern zulassen @@ -62,6 +63,10 @@ Wenn aktiviert oder nicht konfiguriert, bietet die Anmeldeseite „Mit Ihrem Browser anmelden“ an, damit Benutzer dort ihren Passwort-Manager, Passkeys und Ihr Single Sign-on verwenden können. Wenn deaktiviert, fehlt die Schaltfläche und die App lehnt den Vorgang ab. Das Formular in der App bleibt, sofern der Server nicht nur OAuth erlaubt. Auf dem Gerät behaltene E-Mails löschen Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. + App unter „Öffnen mit“ und „Senden an“ anbieten + 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. @@ -80,6 +85,7 @@ Regeln Adressen + Wert (0, 1 oder 2) diff --git a/desktop-policies/en-US/Bulwark.adml b/desktop-policies/en-US/Bulwark.adml index 1ef436c69..353859e61 100644 --- a/desktop-policies/en-US/Bulwark.adml +++ b/desktop-policies/en-US/Bulwark.adml @@ -15,6 +15,7 @@ Offline mail and app lock Privacy Sign-in and certificates + System integration and troubleshooting Bulwark server The address of your Bulwark server, starting with https://. When forced, the app opens on this server, the choice of server is hidden, and the window stays on it. An invalid address shows an error page that names this policy, and the app does not fall back to anything else. As a default, the app opens on this server until the user picks something else. Allow direct connections to mail servers @@ -62,6 +63,10 @@ When enabled or not configured, the login page offers "Sign in with your browser", so people can use their password manager, passkeys and your single sign-on there. When disabled, the button is gone and the app refuses the flow. The form in the app stays, unless the server only allows OAuth. Delete mail kept on the device Set this to a new value, such as today's date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. + Offer the app in "Open with" and "Send to" + 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. @@ -80,6 +85,7 @@ Rules Addresses + Value (0, 1 or 2) diff --git a/desktop-policies/org.bulwarkmail.desktop.mobileconfig b/desktop-policies/org.bulwarkmail.desktop.mobileconfig index 3e3fe17c8..23ddb522c 100644 --- a/desktop-policies/org.bulwarkmail.desktop.mobileconfig +++ b/desktop-policies/org.bulwarkmail.desktop.mobileconfig @@ -65,6 +65,10 @@ BrowserSignIn + OpenWithEnabled + + DeveloperToolsAvailability + 2 Recommended UpdateChannel diff --git a/desktop-policies/policies.json b/desktop-policies/policies.json index c1720d153..89dd1e510 100644 --- a/desktop-policies/policies.json +++ b/desktop-policies/policies.json @@ -27,6 +27,8 @@ "https://mail.example.com" ], "BrowserSignIn": true, + "OpenWithEnabled": true, + "DeveloperToolsAvailability": 2, "Recommended": { "UpdateChannel": "stable" } diff --git a/lib/desktop/__tests__/policy-schema.test.ts b/lib/desktop/__tests__/policy-schema.test.ts index 369064355..ead231bb7 100644 --- a/lib/desktop/__tests__/policy-schema.test.ts +++ b/lib/desktop/__tests__/policy-schema.test.ts @@ -31,6 +31,7 @@ describe('the schema', () => { 'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed', 'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist', 'AutoSelectCertificateForUrls', 'KeystoreEnabled', 'KeystoreAllowedOrigins', 'BrowserSignIn', 'WipeOfflineData', + 'OpenWithEnabled', 'DeveloperToolsAvailability', ]); for (const key of POLICY_KEYS) { const { title, description } = POLICY_SCHEMA[key]; @@ -40,6 +41,22 @@ describe('the schema', () => { }); }); +describe('developer tools and Open with', () => { + it("takes Chrome values for developer tools, forced only, and a value nobody can read locks them off", () => { + expect(resolvePolicies(layers({ forcedMachine: { DeveloperToolsAvailability: 2 } })).DeveloperToolsAvailability).toMatchObject({ value: 2, locked: true }); + expect(resolvePolicies(layers({ recommendedMachine: { DeveloperToolsAvailability: 2 } })).DeveloperToolsAvailability).toMatchObject({ value: null, locked: false }); + // Not 0 or 1: the shell reads a locked entry that isn't an explicit yes as off. + expect(resolvePolicies(layers({ forcedUser: { DeveloperToolsAvailability: 7 } })).DeveloperToolsAvailability).toMatchObject({ value: null, locked: true }); + }); + + it('turn Open with off only by force, and a value nobody can read leaves it off', () => { + expect(resolvePolicies(layers({})).OpenWithEnabled).toMatchObject({ value: true, locked: false }); + expect(resolvePolicies(layers({ recommendedUser: { OpenWithEnabled: 0 } })).OpenWithEnabled).toMatchObject({ value: true, locked: false }); + expect(resolvePolicies(layers({ forcedMachine: { OpenWithEnabled: 0 } })).OpenWithEnabled).toMatchObject({ value: false, locked: true }); + expect(resolvePolicies(layers({ forcedMachine: { OpenWithEnabled: 'maybe' } })).OpenWithEnabled).toMatchObject({ value: false, locked: true }); + }); +}); + describe('precedence', () => { it('forced machine > forced user > recommended machine > recommended user > default', () => { const all = { diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts index 28945be18..93d42244d 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -99,6 +99,7 @@ export function shellLabels(t: (key: string, values?: Record) => menuZoomOut: t('desktopUi.menu.zoom_out'), menuZoomReset: t('desktopUi.menu.zoom_reset'), menuFullScreen: t('desktopUi.menu.full_screen'), + menuToggleDevTools: t('desktopDevTools.menu_toggle'), menuReply: t('context_menu.reply'), menuReplyAll: t('context_menu.reply_all'), menuForward: t('context_menu.forward'), diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index 448e3a9c0..e94fb8a40 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -29,7 +29,7 @@ export const POLICY_MAX_FILE_BYTES = 1024 * 1024; export type PolicyText = { en: string; de: string }; -export type PolicyCategory = 'connection' | 'updates' | 'plugins' | 'security' | 'privacy' | 'signIn'; +export type PolicyCategory = 'connection' | 'updates' | 'plugins' | 'security' | 'privacy' | 'signIn' | 'system'; export const POLICY_CATEGORIES: Record = { connection: { en: 'Connection', de: 'Verbindung' }, @@ -38,6 +38,7 @@ export const POLICY_CATEGORIES: Record = { security: { en: 'Offline mail and app lock', de: 'Offline-E-Mails und App-Sperre' }, privacy: { en: 'Privacy', de: 'Datenschutz' }, signIn: { en: 'Sign-in and certificates', de: 'Anmeldung und Zertifikate' }, + system: { en: 'System integration and troubleshooting', de: 'Systemintegration und Fehlerbehebung' }, }; export interface CertificateName { @@ -79,6 +80,8 @@ export interface PolicyValues { KeystoreAllowedOrigins: string[]; BrowserSignIn: boolean; WipeOfflineData: string | null; + OpenWithEnabled: boolean; + DeveloperToolsAvailability: number | null; } export type PolicyKey = keyof PolicyValues; @@ -426,6 +429,34 @@ export const POLICY_SCHEMA: Record = { de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.', }, }, + OpenWithEnabled: { + category: 'system', + type: 'boolean', + default: true, + onInvalid: false, + forcedOnly: true, + sample: true, + title: { en: 'Offer the app in "Open with" and "Send to"', de: 'App unter „Öffnen mit“ und „Senden an“ anbieten' }, + description: { + en: '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.', + de: '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.', + }, + }, + DeveloperToolsAvailability: { + category: 'system', + type: 'integer', + default: null, + min: 0, + max: 2, + forcedOnly: true, + unit: { en: 'Value (0, 1 or 2)', de: 'Wert (0, 1 oder 2)' }, + sample: 2, + title: { en: 'Developer tools', de: 'Entwicklertools' }, + description: { + en: '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.', + 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.', + }, + }, }; export const POLICY_KEYS = Object.keys(POLICY_SCHEMA) as PolicyKey[]; @@ -857,6 +888,8 @@ export const POLICY_CONSUMERS: Record = { KeystoreEnabled: { repo: 'shell', file: 'src/main/features/policies.ts' }, KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' }, 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' }, }; /** The part the embedded server enforces. Sent to it by message. */ diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts index 9bbdfdbc8..59253c9d4 100644 --- a/lib/desktop/types.ts +++ b/lib/desktop/types.ts @@ -675,6 +675,23 @@ export interface DesktopPrivacyBridge { onChange: (listener: (state: DesktopPrivacyState) => void) => () => void; } +/** `forcedOff`: the DeveloperToolsAvailability policy says no. `locked`: the app lock is on, so nothing changes. */ +export interface DesktopDevToolsState { + on: boolean; + forcedOff: boolean; + locked: boolean; +} + +/** + * Chromium's developer tools on Ctrl+Shift+I and F12, off by default. Main keeps the switch. + * Turning it on takes a real click and a native warning; the app lock turns it off again. + */ +export interface DesktopDevToolsBridge { + get: () => Promise; + set: (on: boolean) => Promise; + onChange: (listener: (state: DesktopDevToolsState) => void) => () => void; +} + /** An attachment copied to a private folder and open in its default program. */ export interface DesktopEditSession { id: string; @@ -793,6 +810,8 @@ export interface BulwarkDesktopBridge { localImport?: DesktopLocalImportBridge; privacy?: DesktopPrivacyBridge; edit?: DesktopEditBridge; + /** Absent on older shells. */ + devTools?: DesktopDevToolsBridge; } declare global { diff --git a/locales/ar/common.json b/locales/ar/common.json index aa5807286..b8431c4a3 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -7058,6 +7058,14 @@ "types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.", "reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى." }, + "desktopDevTools": { + "section_title": "استكشاف الأخطاء وإصلاحها", + "label": "أدوات المطورين ({shortcut})", + "description": "يتيح لـ {shortcut} وF12 فتح أدوات المطورين للنافذة التي أنت فيها. يتوقف مرة أخرى عند قفل التطبيق.", + "forced_off": "أوقفته مؤسستك.", + "locked": "ألغِ قفل التطبيق لتغيير هذا.", + "menu_toggle": "إظهار أدوات المطورين أو إخفاؤها" + }, "fileDrop": { "overlay_label": "أفلت الملفات", "overlay_title": "إلى أين تذهب الملفات؟", diff --git a/locales/ca/common.json b/locales/ca/common.json index 04f546b54..4ff3a4c90 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -7058,6 +7058,14 @@ "types_character": "Aquestes tecles escriuen un caràcter al teu teclat, i deixaria de funcionar a totes les aplicacions. Prova altres tecles.", "reserved": "El sistema o la majoria d'aplicacions fan servir aquestes tecles. Prova altres tecles." }, + "desktopDevTools": { + "section_title": "Resolució de problemes", + "label": "Eines per a desenvolupadors ({shortcut})", + "description": "Permet que {shortcut} i F12 obrin les eines per a desenvolupadors a la finestra on ets. Es desactiva quan l'app es bloqueja.", + "forced_off": "Desactivat per la teva organització.", + "locked": "Desbloqueja l'app per canviar-ho.", + "menu_toggle": "Mostra o amaga les eines per a desenvolupadors" + }, "fileDrop": { "overlay_label": "Deixa anar fitxers", "overlay_title": "On han d'anar els fitxers?", diff --git a/locales/cs/common.json b/locales/cs/common.json index 9c5721a75..3c91be0ad 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -7058,6 +7058,14 @@ "types_character": "Tyto klávesy na vaší klávesnici píší znak, který by pak nefungoval v žádné aplikaci. Zkuste jiné klávesy.", "reserved": "Tyto klávesy používá systém nebo většina aplikací. Zkuste jiné klávesy." }, + "desktopDevTools": { + "section_title": "Řešení potíží", + "label": "Nástroje pro vývojáře ({shortcut})", + "description": "Umožní, aby {shortcut} a F12 otevřely nástroje pro vývojáře v okně, kde jste. Vypne se, když se aplikace zamkne.", + "forced_off": "Vypnuto vaší organizací.", + "locked": "Pro změnu aplikaci odemkněte.", + "menu_toggle": "Zobrazit nebo skrýt nástroje pro vývojáře" + }, "fileDrop": { "overlay_label": "Pustit soubory", "overlay_title": "Kam mají soubory jít?", diff --git a/locales/da/common.json b/locales/da/common.json index ad28302ef..67b185400 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -7058,6 +7058,14 @@ "types_character": "Disse taster skriver et tegn på dit tastatur, og det ville holde op med at virke i alle apps. Prøv andre taster.", "reserved": "Dit system eller de fleste apps bruger disse taster. Prøv andre taster." }, + "desktopDevTools": { + "section_title": "Fejlfinding", + "label": "Udviklerværktøjer ({shortcut})", + "description": "Lader {shortcut} og F12 åbne udviklerværktøjerne for det vindue, du er i. Slås fra igen, når appen låses.", + "forced_off": "Slået fra af din organisation.", + "locked": "Lås appen op for at ændre dette.", + "menu_toggle": "Vis eller skjul udviklerværktøjer" + }, "fileDrop": { "overlay_label": "Slip filer", "overlay_title": "Hvor skal filerne hen?", diff --git a/locales/de/common.json b/locales/de/common.json index ae9368ddc..d59cbdb49 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -7058,6 +7058,14 @@ "types_character": "Diese Tasten erzeugen auf Ihrer Tastatur ein Zeichen, das dann in keiner App mehr funktionieren würde. Versuchen Sie andere Tasten.", "reserved": "Ihr System oder die meisten Apps verwenden diese Tasten. Versuchen Sie andere Tasten." }, + "desktopDevTools": { + "section_title": "Fehlerbehebung", + "label": "Entwicklertools ({shortcut})", + "description": "Mit {shortcut} und F12 öffnen sich die Entwicklertools für das Fenster, in dem Sie sind. Schaltet sich wieder aus, wenn die App gesperrt wird.", + "forced_off": "Von Ihrer Organisation ausgeschaltet.", + "locked": "Entsperren Sie die App, um das zu ändern.", + "menu_toggle": "Entwicklertools ein- oder ausblenden" + }, "fileDrop": { "overlay_label": "Dateien ablegen", "overlay_title": "Wohin sollen die Dateien?", diff --git a/locales/en/common.json b/locales/en/common.json index 3cba72c6a..4137aa9af 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -7058,6 +7058,14 @@ "types_character": "These keys type a character on your keyboard, and it would stop working in every app. Try other keys.", "reserved": "Your system or most apps use these keys. Try other keys." }, + "desktopDevTools": { + "section_title": "Troubleshooting", + "label": "Developer tools ({shortcut})", + "description": "Lets {shortcut} and F12 open developer tools for the window you are in. Turns off again when the app locks.", + "forced_off": "Turned off by your organisation.", + "locked": "Unlock the app to change this.", + "menu_toggle": "Toggle developer tools" + }, "fileDrop": { "overlay_label": "Drop files", "overlay_title": "Where should the files go?", diff --git a/locales/es/common.json b/locales/es/common.json index 398480475..ed1caf00a 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -7058,6 +7058,14 @@ "types_character": "Estas teclas escriben un carácter en tu teclado, y dejaría de funcionar en todas las aplicaciones. Prueba otras teclas.", "reserved": "Tu sistema o la mayoría de las aplicaciones usan estas teclas. Prueba otras teclas." }, + "desktopDevTools": { + "section_title": "Solución de problemas", + "label": "Herramientas para desarrolladores ({shortcut})", + "description": "Permite que {shortcut} y F12 abran las herramientas para desarrolladores en la ventana en la que estás. Se desactiva al bloquearse la app.", + "forced_off": "Desactivado por tu organización.", + "locked": "Desbloquea la app para cambiar esto.", + "menu_toggle": "Mostrar u ocultar herramientas para desarrolladores" + }, "fileDrop": { "overlay_label": "Soltar archivos", "overlay_title": "¿Dónde van los archivos?", diff --git a/locales/et/common.json b/locales/et/common.json index 25c3db37e..b34bed5be 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -7058,6 +7058,14 @@ "types_character": "Need klahvid kirjutavad teie klaviatuuril märgi, mis lakkaks kõigis rakendustes töötamast. Proovige teisi klahve.", "reserved": "Neid klahve kasutab süsteem või enamik rakendusi. Proovige teisi klahve." }, + "desktopDevTools": { + "section_title": "Tõrkeotsing", + "label": "Arendaja tööriistad ({shortcut})", + "description": "Laseb klahvidel {shortcut} ja F12 avada arendaja tööriistad aknas, kus sa oled. Lülitub välja, kui rakendus lukustub.", + "forced_off": "Teie organisatsioon on selle välja lülitanud.", + "locked": "Selle muutmiseks ava rakenduse lukk.", + "menu_toggle": "Näita või peida arendaja tööriistad" + }, "fileDrop": { "overlay_label": "Lohista failid siia", "overlay_title": "Kuhu failid lähevad?", diff --git a/locales/fa/common.json b/locales/fa/common.json index ee05eb3dc..49242030f 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -7058,6 +7058,14 @@ "types_character": "این کلیدها روی صفحه‌کلید شما نویسه‌ای می‌نویسند و آن نویسه دیگر در هیچ برنامه‌ای کار نمی‌کند. کلیدهای دیگری را امتحان کنید.", "reserved": "سیستم یا بیشتر برنامه‌ها از این کلیدها استفاده می‌کنند. کلیدهای دیگری را امتحان کنید." }, + "desktopDevTools": { + "section_title": "عیب‌یابی", + "label": "ابزارهای توسعه‌دهنده ({shortcut})", + "description": "با {shortcut} و F12 ابزارهای توسعه‌دهنده برای پنجره‌ای که در آن هستید باز می‌شود. وقتی برنامه قفل شود دوباره خاموش می‌شود.", + "forced_off": "سازمان شما این را خاموش کرده است.", + "locked": "برای تغییر این، قفل برنامه را باز کنید.", + "menu_toggle": "نمایش یا پنهان کردن ابزارهای توسعه‌دهنده" + }, "fileDrop": { "overlay_label": "رها کردن فایل‌ها", "overlay_title": "فایل‌ها کجا بروند؟", diff --git a/locales/fr/common.json b/locales/fr/common.json index 1c45bde45..12d176307 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -7058,6 +7058,14 @@ "types_character": "Ces touches produisent un caractère sur votre clavier, et il ne fonctionnerait plus dans aucune application. Essayez d'autres touches.", "reserved": "Votre système ou la plupart des applications utilisent ces touches. Essayez d'autres touches." }, + "desktopDevTools": { + "section_title": "Dépannage", + "label": "Outils de développement ({shortcut})", + "description": "{shortcut} et F12 ouvrent les outils de développement pour la fenêtre où vous êtes. Se désactive quand l'app se verrouille.", + "forced_off": "Désactivé par votre organisation.", + "locked": "Déverrouillez l'app pour modifier ce réglage.", + "menu_toggle": "Afficher ou masquer les outils de développement" + }, "fileDrop": { "overlay_label": "Déposer des fichiers", "overlay_title": "Où mettre les fichiers ?", diff --git a/locales/he/common.json b/locales/he/common.json index 5066ec4b6..e2cb47359 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -7059,6 +7059,14 @@ "types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.", "reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים." }, + "desktopDevTools": { + "section_title": "פתרון בעיות", + "label": "כלים למפתחים ({shortcut})", + "description": "מאפשר ל־{shortcut} ול־F12 לפתוח את הכלים למפתחים בחלון שבו אתם נמצאים. נכבה שוב כשהאפליקציה ננעלת.", + "forced_off": "הארגון שלכם כיבה את זה.", + "locked": "בטלו את נעילת האפליקציה כדי לשנות זאת.", + "menu_toggle": "הצגה או הסתרה של הכלים למפתחים" + }, "fileDrop": { "overlay_label": "שחררו קבצים", "overlay_title": "לאן הקבצים ילכו?", diff --git a/locales/hu/common.json b/locales/hu/common.json index db74f7bc4..74fc4ea73 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -7058,6 +7058,14 @@ "types_character": "Ezek a billentyűk karaktert írnak az Ön billentyűzetén, amely többé egyik alkalmazásban sem működne. Próbáljon más billentyűket.", "reserved": "Ezeket a billentyűket a rendszer vagy a legtöbb alkalmazás használja. Próbáljon más billentyűket." }, + "desktopDevTools": { + "section_title": "Hibaelhárítás", + "label": "Fejlesztői eszközök ({shortcut})", + "description": "A(z) {shortcut} és az F12 megnyitja a fejlesztői eszközöket abban az ablakban, ahol éppen van. Kikapcsol, amikor az alkalmazás zárolódik.", + "forced_off": "A szervezete kikapcsolta.", + "locked": "A módosításhoz oldja fel az alkalmazást.", + "menu_toggle": "Fejlesztői eszközök megjelenítése vagy elrejtése" + }, "fileDrop": { "overlay_label": "Fájlok ejtése", "overlay_title": "Hová kerüljenek a fájlok?", diff --git a/locales/it/common.json b/locales/it/common.json index a6d8ff703..b06b5dd23 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -7058,6 +7058,14 @@ "types_character": "Questi tasti scrivono un carattere sulla tua tastiera, che smetterebbe di funzionare in tutte le app. Prova altri tasti.", "reserved": "Il sistema o la maggior parte delle app usano questi tasti. Prova altri tasti." }, + "desktopDevTools": { + "section_title": "Risoluzione dei problemi", + "label": "Strumenti per sviluppatori ({shortcut})", + "description": "{shortcut} e F12 aprono gli strumenti per sviluppatori nella finestra in cui ti trovi. Si disattiva quando l'app si blocca.", + "forced_off": "Disattivato dalla tua organizzazione.", + "locked": "Sblocca l'app per modificare questa opzione.", + "menu_toggle": "Mostra o nascondi strumenti per sviluppatori" + }, "fileDrop": { "overlay_label": "Rilascia file", "overlay_title": "Dove vanno i file?", diff --git a/locales/ja/common.json b/locales/ja/common.json index 8b3443527..0906c45fc 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -7058,6 +7058,14 @@ "types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。", "reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。" }, + "desktopDevTools": { + "section_title": "トラブルシューティング", + "label": "デベロッパー ツール ({shortcut})", + "description": "{shortcut} と F12 で、今いるウィンドウのデベロッパー ツールを開けるようにします。アプリがロックされるとオフに戻ります。", + "forced_off": "組織によってオフにされています。", + "locked": "変更するにはアプリのロックを解除してください。", + "menu_toggle": "デベロッパー ツールの表示/非表示" + }, "fileDrop": { "overlay_label": "ファイルをドロップ", "overlay_title": "ファイルをどこに入れますか?", diff --git a/locales/ko/common.json b/locales/ko/common.json index 2afd81a3d..7b9e7bdd4 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -7058,6 +7058,14 @@ "types_character": "이 키는 사용 중인 키보드에서 문자를 입력하며, 지정하면 모든 앱에서 그 문자를 입력할 수 없게 됩니다. 다른 키를 사용해 보세요.", "reserved": "시스템 또는 대부분의 앱이 이 키를 사용합니다. 다른 키를 사용해 보세요." }, + "desktopDevTools": { + "section_title": "문제 해결", + "label": "개발자 도구 ({shortcut})", + "description": "{shortcut} 및 F12로 현재 창의 개발자 도구를 열 수 있게 합니다. 앱이 잠기면 다시 꺼집니다.", + "forced_off": "조직에서 이 기능을 껐습니다.", + "locked": "변경하려면 앱 잠금을 해제하세요.", + "menu_toggle": "개발자 도구 표시/숨기기" + }, "fileDrop": { "overlay_label": "파일 놓기", "overlay_title": "파일을 어디에 둘까요?", diff --git a/locales/lv/common.json b/locales/lv/common.json index d17bb9fb0..7c19c7567 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -7058,6 +7058,14 @@ "types_character": "Šie taustiņi jūsu tastatūrā raksta rakstzīmi, kas pārstātu darboties visās lietotnēs. Izmēģiniet citus taustiņus.", "reserved": "Šos taustiņus izmanto sistēma vai lielākā daļa lietotņu. Izmēģiniet citus taustiņus." }, + "desktopDevTools": { + "section_title": "Problēmu novēršana", + "label": "Izstrādātāju rīki ({shortcut})", + "description": "Ļauj {shortcut} un F12 atvērt izstrādātāju rīkus logā, kurā esat. Izslēdzas, kad lietotne tiek bloķēta.", + "forced_off": "Jūsu organizācija to ir izslēgusi.", + "locked": "Atbloķējiet lietotni, lai to mainītu.", + "menu_toggle": "Rādīt vai paslēpt izstrādātāju rīkus" + }, "fileDrop": { "overlay_label": "Nometiet failus", "overlay_title": "Kur likt failus?", diff --git a/locales/mn/common.json b/locales/mn/common.json index 2303cb1aa..a431d7bbf 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -7058,6 +7058,14 @@ "types_character": "Эдгээр товч таны гар дээр тэмдэгт бичдэг тул тэр тэмдэгт бүх аппад ажиллахаа болино. Өөр товч туршина уу.", "reserved": "Систем эсвэл ихэнх апп эдгээр товчийг ашигладаг. Өөр товч туршина уу." }, + "desktopDevTools": { + "section_title": "Алдаа засах", + "label": "Хөгжүүлэгчийн хэрэгсэл ({shortcut})", + "description": "{shortcut} болон F12 дарахад таны байгаа цонхонд хөгжүүлэгчийн хэрэгсэл нээгдэнэ. Апп түгжигдэхэд дахин унтарна.", + "forced_off": "Танай байгууллага унтраасан.", + "locked": "Үүнийг өөрчлөхийн тулд аппын түгжээг тайлна уу.", + "menu_toggle": "Хөгжүүлэгчийн хэрэгслийг харуулах эсвэл нуух" + }, "fileDrop": { "overlay_label": "Файл буулгах", "overlay_title": "Файлуудыг хаана хийх вэ?", diff --git a/locales/nb/common.json b/locales/nb/common.json index f1e3cfd14..9e9f44e2d 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -7058,6 +7058,14 @@ "types_character": "Disse tastene skriver et tegn på tastaturet ditt, og det ville slutte å virke i alle apper. Prøv andre taster.", "reserved": "Systemet ditt eller de fleste apper bruker disse tastene. Prøv andre taster." }, + "desktopDevTools": { + "section_title": "Feilsøking", + "label": "Utviklerverktøy ({shortcut})", + "description": "Lar {shortcut} og F12 åpne utviklerverktøyene for vinduet du er i. Slås av igjen når appen låses.", + "forced_off": "Slått av av organisasjonen din.", + "locked": "Lås opp appen for å endre dette.", + "menu_toggle": "Vis eller skjul utviklerverktøy" + }, "fileDrop": { "overlay_label": "Slipp filer", "overlay_title": "Hvor skal filene?", diff --git a/locales/nl/common.json b/locales/nl/common.json index b9ddfd6af..4fa2292a3 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -7058,6 +7058,14 @@ "types_character": "Deze toetsen typen een teken op je toetsenbord, en dat zou in geen enkele app meer werken. Probeer andere toetsen.", "reserved": "Je systeem of de meeste apps gebruiken deze toetsen. Probeer andere toetsen." }, + "desktopDevTools": { + "section_title": "Probleemoplossing", + "label": "Ontwikkelaarstools ({shortcut})", + "description": "Met {shortcut} en F12 openen de ontwikkelaarstools voor het venster waarin je bent. Gaat weer uit als de app vergrendelt.", + "forced_off": "Uitgezet door je organisatie.", + "locked": "Ontgrendel de app om dit te wijzigen.", + "menu_toggle": "Ontwikkelaarstools tonen of verbergen" + }, "fileDrop": { "overlay_label": "Bestanden neerzetten", "overlay_title": "Waar moeten de bestanden heen?", diff --git a/locales/pl/common.json b/locales/pl/common.json index 65b613b33..7caca2c96 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -7058,6 +7058,14 @@ "types_character": "Te klawisze wpisują znak na Twojej klawiaturze i przestałby on działać we wszystkich aplikacjach. Wypróbuj inne klawisze.", "reserved": "Te klawisze są używane przez system lub większość aplikacji. Wypróbuj inne klawisze." }, + "desktopDevTools": { + "section_title": "Rozwiązywanie problemów", + "label": "Narzędzia dla deweloperów ({shortcut})", + "description": "Pozwala, by {shortcut} i F12 otwierały narzędzia dla deweloperów w oknie, w którym jesteś. Wyłącza się, gdy aplikacja się zablokuje.", + "forced_off": "Wyłączone przez Twoją organizację.", + "locked": "Odblokuj aplikację, aby to zmienić.", + "menu_toggle": "Pokaż lub ukryj narzędzia dla deweloperów" + }, "fileDrop": { "overlay_label": "Upuść pliki", "overlay_title": "Gdzie mają trafić pliki?", diff --git a/locales/pt/common.json b/locales/pt/common.json index f9a029c78..1cba03239 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -7058,6 +7058,14 @@ "types_character": "Estas teclas escrevem um carácter no seu teclado, e ele deixaria de funcionar em todas as aplicações. Experimente outras teclas.", "reserved": "O seu sistema ou a maioria das aplicações usam estas teclas. Experimente outras teclas." }, + "desktopDevTools": { + "section_title": "Resolução de problemas", + "label": "Ferramentas de programador ({shortcut})", + "description": "Permite que {shortcut} e F12 abram as ferramentas de programador na janela em que está. Desliga-se quando a app bloqueia.", + "forced_off": "Desligado pela sua organização.", + "locked": "Desbloqueie a app para alterar isto.", + "menu_toggle": "Mostrar ou ocultar ferramentas de programador" + }, "fileDrop": { "overlay_label": "Soltar arquivos", "overlay_title": "Para onde vão os arquivos?", diff --git a/locales/ro/common.json b/locales/ro/common.json index d72dd9ebd..49132d4a3 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -7058,6 +7058,14 @@ "types_character": "Aceste taste scriu un caracter pe tastatura ta, iar el n-ar mai funcționa în nicio aplicație. Încearcă alte taste.", "reserved": "Sistemul sau majoritatea aplicațiilor folosesc aceste taste. Încearcă alte taste." }, + "desktopDevTools": { + "section_title": "Depanare", + "label": "Instrumente pentru dezvoltatori ({shortcut})", + "description": "Permite ca {shortcut} și F12 să deschidă instrumentele pentru dezvoltatori în fereastra în care sunteți. Se oprește când aplicația se blochează.", + "forced_off": "Oprit de organizația dvs.", + "locked": "Deblocați aplicația pentru a schimba asta.", + "menu_toggle": "Afișați sau ascundeți instrumentele pentru dezvoltatori" + }, "fileDrop": { "overlay_label": "Plasați fișiere", "overlay_title": "Unde merg fișierele?", diff --git a/locales/ru/common.json b/locales/ru/common.json index 2a34bc36b..94b1436d7 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -7058,6 +7058,14 @@ "types_character": "На вашей клавиатуре эти клавиши вводят символ, и он перестал бы работать во всех приложениях. Попробуйте другие клавиши.", "reserved": "Эти клавиши использует система или большинство приложений. Попробуйте другие клавиши." }, + "desktopDevTools": { + "section_title": "Устранение неполадок", + "label": "Инструменты разработчика ({shortcut})", + "description": "{shortcut} и F12 открывают инструменты разработчика для окна, в котором вы находитесь. Выключается при блокировке приложения.", + "forced_off": "Отключено вашей организацией.", + "locked": "Разблокируйте приложение, чтобы изменить это.", + "menu_toggle": "Показать или скрыть инструменты разработчика" + }, "fileDrop": { "overlay_label": "Перетащите файлы", "overlay_title": "Куда отправить файлы?", diff --git a/locales/sk/common.json b/locales/sk/common.json index 8acfc3240..5b0506c5c 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -7058,6 +7058,14 @@ "types_character": "Tieto klávesy na vašej klávesnici píšu znak, ktorý by potom nefungoval v žiadnej aplikácii. Skúste iné klávesy.", "reserved": "Tieto klávesy používa systém alebo väčšina aplikácií. Skúste iné klávesy." }, + "desktopDevTools": { + "section_title": "Riešenie problémov", + "label": "Nástroje pre vývojárov ({shortcut})", + "description": "Umožní, aby {shortcut} a F12 otvorili nástroje pre vývojárov v okne, kde ste. Vypne sa, keď sa aplikácia zamkne.", + "forced_off": "Vypnuté vašou organizáciou.", + "locked": "Na zmenu aplikáciu odomknite.", + "menu_toggle": "Zobraziť alebo skryť nástroje pre vývojárov" + }, "fileDrop": { "overlay_label": "Pustiť súbory", "overlay_title": "Kam majú súbory ísť?", diff --git a/locales/th/common.json b/locales/th/common.json index c25f55fdf..fc5a8ae01 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -7066,6 +7066,14 @@ "types_character": "ปุ่มเหล่านี้พิมพ์อักขระบนแป้นพิมพ์ของคุณ และอักขระนั้นจะใช้ไม่ได้ในทุกแอป ลองใช้ปุ่มอื่น", "reserved": "ระบบหรือแอปส่วนใหญ่ใช้ปุ่มเหล่านี้ ลองใช้ปุ่มอื่น" }, + "desktopDevTools": { + "section_title": "การแก้ไขปัญหา", + "label": "เครื่องมือสำหรับนักพัฒนาซอฟต์แวร์ ({shortcut})", + "description": "ให้ {shortcut} และ F12 เปิดเครื่องมือสำหรับนักพัฒนาซอฟต์แวร์ของหน้าต่างที่คุณอยู่ จะปิดอีกครั้งเมื่อแอปล็อก", + "forced_off": "องค์กรของคุณปิดสิ่งนี้แล้ว", + "locked": "ปลดล็อกแอปเพื่อเปลี่ยนการตั้งค่านี้", + "menu_toggle": "แสดงหรือซ่อนเครื่องมือสำหรับนักพัฒนาซอฟต์แวร์" + }, "fileDrop": { "overlay_label": "วางไฟล์", "overlay_title": "จะให้ไฟล์ไปที่ไหน", diff --git a/locales/tr/common.json b/locales/tr/common.json index 956d66056..fb1040c23 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -7058,6 +7058,14 @@ "types_character": "Bu tuşlar klavyenizde bir karakter yazar ve bu karakter hiçbir uygulamada çalışmaz olur. Başka tuşlar deneyin.", "reserved": "Bu tuşları sisteminiz veya çoğu uygulama kullanıyor. Başka tuşlar deneyin." }, + "desktopDevTools": { + "section_title": "Sorun giderme", + "label": "Geliştirici araçları ({shortcut})", + "description": "{shortcut} ve F12 ile bulunduğunuz pencere için geliştirici araçları açılır. Uygulama kilitlenince yeniden kapanır.", + "forced_off": "Kuruluşunuz tarafından kapatıldı.", + "locked": "Bunu değiştirmek için uygulamanın kilidini açın.", + "menu_toggle": "Geliştirici araçlarını göster veya gizle" + }, "fileDrop": { "overlay_label": "Dosyaları bırakın", "overlay_title": "Dosyalar nereye gitsin?", diff --git a/locales/uk/common.json b/locales/uk/common.json index 58b3f3d2d..0e8c1787a 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -7058,6 +7058,14 @@ "types_character": "На вашій клавіатурі ці клавіші вводять символ, і він перестав би працювати в усіх застосунках. Спробуйте інші клавіші.", "reserved": "Ці клавіші використовує система або більшість застосунків. Спробуйте інші клавіші." }, + "desktopDevTools": { + "section_title": "Усунення несправностей", + "label": "Інструменти розробника ({shortcut})", + "description": "{shortcut} і F12 відкривають інструменти розробника для вікна, у якому ви є. Вимикається, коли застосунок блокується.", + "forced_off": "Вимкнено вашою організацією.", + "locked": "Розблокуйте застосунок, щоб змінити це.", + "menu_toggle": "Показати або сховати інструменти розробника" + }, "fileDrop": { "overlay_label": "Перетягніть файли", "overlay_title": "Куди надіслати файли?", diff --git a/locales/vi/common.json b/locales/vi/common.json index 59b4ac37b..819e2acbf 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -7070,6 +7070,14 @@ "types_character": "Các phím này gõ một ký tự trên bàn phím của bạn, và ký tự đó sẽ không dùng được trong mọi ứng dụng. Hãy thử phím khác.", "reserved": "Hệ thống hoặc hầu hết ứng dụng dùng các phím này. Hãy thử phím khác." }, + "desktopDevTools": { + "section_title": "Khắc phục sự cố", + "label": "Công cụ cho nhà phát triển ({shortcut})", + "description": "Cho phép {shortcut} và F12 mở công cụ cho nhà phát triển của cửa sổ bạn đang dùng. Tự tắt lại khi ứng dụng khóa.", + "forced_off": "Tổ chức của bạn đã tắt mục này.", + "locked": "Hãy mở khóa ứng dụng để thay đổi.", + "menu_toggle": "Hiện hoặc ẩn công cụ cho nhà phát triển" + }, "fileDrop": { "overlay_label": "Thả tệp", "overlay_title": "Đưa tệp đi đâu?", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 4a6e8f67a..4271f73c7 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -7058,6 +7058,14 @@ "types_character": "這些鍵在您的鍵盤上會輸入一個字元,占用後該字元在所有應用程式中都無法輸入。請試試其他按鍵。", "reserved": "系統或大多數應用程式正在使用這些鍵。請試試其他按鍵。" }, + "desktopDevTools": { + "section_title": "疑難排解", + "label": "開發人員工具({shortcut})", + "description": "允許 {shortcut} 和 F12 為您所在的視窗開啟開發人員工具。應用程式鎖定時會再次關閉。", + "forced_off": "您的組織已關閉此功能。", + "locked": "請解除鎖定應用程式以變更此設定。", + "menu_toggle": "顯示或隱藏開發人員工具" + }, "fileDrop": { "overlay_label": "放開檔案", "overlay_title": "要把檔案放到哪裡?", diff --git a/locales/zh/common.json b/locales/zh/common.json index e17e37435..e21fe1c17 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -7058,6 +7058,14 @@ "types_character": "这些键在您的键盘上会输入一个字符,占用后该字符在所有应用中都无法输入。请试试其他按键。", "reserved": "系统或大多数应用在使用这些键。请试试其他按键。" }, + "desktopDevTools": { + "section_title": "故障排除", + "label": "开发者工具({shortcut})", + "description": "允许 {shortcut} 和 F12 为您所在的窗口打开开发者工具。应用锁定时会再次关闭。", + "forced_off": "您的组织已关闭此功能。", + "locked": "请解锁应用以更改此设置。", + "menu_toggle": "显示或隐藏开发者工具" + }, "fileDrop": { "overlay_label": "放下文件", "overlay_title": "要把文件放到哪里?", From 53eb91ab0ef35b2c0f0ddd90d4e47f938619df29 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:58:47 +0100 Subject: [PATCH 21/46] fix: keep the message details panel readable in a narrow reader, with addresses, dates and sign-in results that fit --- .../__tests__/viewer-details-fit.test.tsx | 84 +++++++++++++++++++ components/email/email-viewer.tsx | 51 ++++++----- components/email/recipient-popover.tsx | 6 +- components/ui/breakable-address.tsx | 17 ++++ lib/__tests__/text-breaks.test.ts | 27 ++++++ lib/text-breaks.ts | 34 ++++++++ 6 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 components/email/__tests__/viewer-details-fit.test.tsx create mode 100644 components/ui/breakable-address.tsx create mode 100644 lib/__tests__/text-breaks.test.ts create mode 100644 lib/text-breaks.ts diff --git a/components/email/__tests__/viewer-details-fit.test.tsx b/components/email/__tests__/viewer-details-fit.test.tsx new file mode 100644 index 000000000..580262959 --- /dev/null +++ b/components/email/__tests__/viewer-details-fit.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; + +// The message details panel in a narrow reader: the two columns followed the +// window's width, not the panel's, so they stayed side by side in a narrow pane; +// the sender's address went letter by letter down a sliver of a column, dates took +// three lines, and long sign-in results ran off the side. + +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }), + useMediaQuery: () => false, +})); +vi.mock('@/components/tour/tour-provider', () => ({ + useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }), +})); + +class NoopResizeObserver { + observe() {} + unobserve() {} + disconnect() {} +} +vi.stubGlobal('ResizeObserver', NoopResizeObserver); + +const { EmailViewer } = await import('../email-viewer'); + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +const ADDRESS = 'no-reply-x8@notifications.mail.example-service.example.net'; +const SPF_DOMAIN = 'bounces+31859940-0dcc@em9672.notifications.example-service.example.net'; + +const email = { + id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true }, + from: [{ name: 'Example Service', email: ADDRESS }], to: [{ name: 'Bob', email: 'bob@example.com' }], + subject: 'Hello', sentAt: '2026-11-04T04:33:50Z', receivedAt: '2026-11-04T04:33:53Z', preview: 'Hello', + hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {}, + authenticationResults: { spf: { result: 'pass', domain: SPF_DOMAIN } }, +} as unknown as Email; + +function openDetails() { + render(); + act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); }); + const heading = screen.getByText('details.recipients_routing'); + return heading.closest('section')!; +} + +describe('message details panel', () => { + it('lays out by its own width, not the window', () => { + const routing = openDetails(); + const grid = routing.parentElement!; + expect(grid.className).toMatch(/@3xl:grid-cols-2/); + expect(grid.className).not.toMatch(/\blg:grid-cols-2/); + expect(grid.parentElement!.className).toMatch(/@container/); + // The value column may shrink below its content's width. + expect(routing.querySelector('dl')!.className).toContain('minmax(0,1fr)'); + }); + + it('shows the sender name and address apart, the address breakable at its dots', () => { + const routing = openDetails(); + expect(routing.textContent).not.toContain(`<${ADDRESS}>`); + const address = routing.querySelector(`[title="${ADDRESS}"]`)!; + expect(address.textContent).toBe(ADDRESS); + expect(address.querySelectorAll('wbr').length).toBeGreaterThan(2); + }); + + it('shows dates short, with the full date on hover', () => { + const routing = openDetails(); + const times = routing.querySelectorAll('time'); + expect(times.length).toBe(2); + for (const time of Array.from(times)) { + expect(time.getAttribute('title')!.length).toBeGreaterThan(time.textContent!.length); + } + }); + + it('keeps sign-in result chips inside the panel, the long part cut with the whole on hover', () => { + openDetails(); + const domain = screen.getByText(SPF_DOMAIN); + expect(domain.className).toContain('truncate'); + expect(domain.getAttribute('title')).toBe(SPF_DOMAIN); + expect(domain.parentElement!.className).toContain('max-w-full'); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0b1b5fac7..eb050d200 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -155,6 +155,7 @@ import { useTour } from "@/components/tour/tour-provider"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation"; import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover"; +import { BreakableAddress } from "@/components/ui/breakable-address"; import { mentionFromLink } from "@/lib/recipient-mentions"; import { MailtoLink } from "@/components/ui/mailto-link"; import { requestInternalMailto } from "@/lib/protocol-handlers/mailto"; @@ -5413,6 +5414,11 @@ ${printWatermark ? printWatermark.html : ''} const fullDate = (iso?: string) => iso ? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' }) : '-'; + // A long date wraps over three lines in the narrow column: the short one + // shows, the full one is on hover. + const shownDate = (iso?: string) => iso + ? + : '-'; const auth = email.authenticationResults; const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0); const topMimeType = email.bodyStructure?.type; @@ -5423,7 +5429,7 @@ ${printWatermark ? printWatermark.html : ''} ); const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => ( <> -
{label}
+
{label}
- {name} - + {name} + {translateAuthResult(result)} {extra && ( <> · - {extra} + {extra} )} @@ -5478,17 +5484,24 @@ ${printWatermark ? printWatermark.html : ''} ); return ( -
-
+
+
{t('details.recipients_routing')} -
+
` : undefined} + label={sender?.name && sender?.email ? ( + <> + {sender.name} + + + + + ) : undefined} onViewContact={handleViewContactSidebar} className="text-sm text-start" /> @@ -5542,10 +5555,10 @@ ${printWatermark ? printWatermark.html : ''} )} {email.sentAt && ( - {fullDate(email.sentAt)} + {shownDate(email.sentAt)} )} - {fullDate(email.receivedAt)} + {shownDate(email.receivedAt)} {deliveryDeltaMs > 60000 && ( · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()} )} @@ -5590,7 +5603,7 @@ ${printWatermark ? printWatermark.html : ''} )} {email.spamScore !== undefined && ( 5 ? "bg-red-500/[0.07] border-red-500/30" : email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" : "bg-green-500/[0.07] border-green-500/30", @@ -5613,7 +5626,7 @@ ${printWatermark ? printWatermark.html : ''} {email.spamStatus && ( <> · - {email.spamStatus} + {email.spamStatus} )} @@ -5644,9 +5657,9 @@ ${printWatermark ? printWatermark.html : ''} {hasIdentifiers && (
{t('details.identifiers_threading')} -
+
{email.messageId && ( - {email.messageId} + )} {email.inReplyTo && email.inReplyTo.length > 0 && ( @@ -5678,7 +5691,7 @@ ${printWatermark ? printWatermark.html : ''}
{t('details.message_properties')} -
+
{email.subject !== undefined && ( {email.subject || {t('details.no_subject')}} )} @@ -5701,9 +5714,9 @@ ${printWatermark ? printWatermark.html : ''}
{hasListInfo && ( -
+
{t('details.mailing_list')} -
+
{listHeaders?.listId && ( {listHeaders.listId} )} @@ -5730,7 +5743,7 @@ ${printWatermark ? printWatermark.html : ''} {/* Plugin-supplied category. Plugins whose shouldShow accepts `category === null` render their own titled section here. */} {hasDetailsSlotOffers && ( -
+
)} diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx index caaf8bc78..1ea559311 100644 --- a/components/email/recipient-popover.tsx +++ b/components/email/recipient-popover.tsx @@ -251,12 +251,14 @@ interface RecipientPopoverProps { email: string; /** Display label override (e.g. "me") */ displayLabel?: string; + /** Rich content in place of the text label, such as a name over its address. */ + label?: React.ReactNode; /** Called when user clicks "View contact" - receives the contact and email */ onViewContact?: (contact: ContactCard | null, email: string) => void; className?: string; } -export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) { +export function RecipientPopover({ name, email, displayLabel, label, onViewContact, className }: RecipientPopoverProps) { const [anchor, setAnchor] = useState(null); const triggerRef = useRef(null); const shownEmail = useOwnDomainAddress()(email); @@ -280,7 +282,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla className )} > - {displayLabel || name || shownEmail} + {label ?? {displayLabel || name || shownEmail}} {anchor && ( diff --git a/components/ui/breakable-address.tsx b/components/ui/breakable-address.tsx new file mode 100644 index 000000000..c5e703132 --- /dev/null +++ b/components/ui/breakable-address.tsx @@ -0,0 +1,17 @@ +import { Fragment } from "react"; +import { addressBreakPieces } from "@/lib/text-breaks"; + +/** An address that wraps at its dots and after its @, not anywhere (lib/text-breaks.ts). */ +export function BreakableAddress({ text }: { text: string }) { + const pieces = addressBreakPieces(text); + return ( + <> + {pieces.map((piece, index) => ( + + {index > 0 && } + {piece} + + ))} + + ); +} diff --git a/lib/__tests__/text-breaks.test.ts b/lib/__tests__/text-breaks.test.ts new file mode 100644 index 000000000..4224f665d --- /dev/null +++ b/lib/__tests__/text-breaks.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { addressBreakPieces } from '../text-breaks'; + +describe('addressBreakPieces', () => { + it('breaks after the @ and before dots', () => { + expect(addressBreakPieces('dana.with.long@subdomain.example.net')).toEqual(['dana', '.with', '.long@', 'subdomain', '.example.net']); + }); + + it('breaks after + - and _', () => { + expect(addressBreakPieces('bounces+318-0dcc_x@em.example.org')).toEqual(['bounces+', '318-', '0dcc_', 'x@em', '.example.org']); + }); + + it('keeps short runs whole and loses no character', () => { + const text = 'a.b.c@d.io'; + const pieces = addressBreakPieces(text); + expect(pieces.join('')).toBe(text); + expect(pieces.every((piece) => piece.length >= 3)).toBe(true); + }); + + it('never leaves the top-level domain alone on a line', () => { + expect(addressBreakPieces('someone@notifications.mail.example-service.example.net').at(-1)).toBe('.example.net'); + }); + + it('leaves a name without break points as one piece', () => { + expect(addressBreakPieces('Anthropic')).toEqual(['Anthropic']); + }); +}); diff --git a/lib/text-breaks.ts b/lib/text-breaks.ts new file mode 100644 index 000000000..2bb86de85 --- /dev/null +++ b/lib/text-breaks.ts @@ -0,0 +1,34 @@ +/** + * Where a long address may wrap: after "@", "+", "-" and "_", and before a dot, as + * printed addresses do. A browser on its own wraps such a string anywhere once it + * doesn't fit, which leaves ragged two-letter lines in a narrow column. + * + * Returns the pieces; a line break opportunity () goes between each two. + * Pieces are never shorter than MIN_PIECE characters: "a.b.c" stays whole. The + * last few characters (".net") stay with the piece before them. + */ +const MIN_PIECE = 3; +const MAX_TAIL = 4; + +export function addressBreakPieces(text: string): string[] { + const pieces: string[] = []; + let current = ''; + for (const ch of text) { + if (ch === '.' && current.length >= MIN_PIECE) { + pieces.push(current); + current = ch; + continue; + } + current += ch; + if ((ch === '@' || ch === '+' || ch === '-' || ch === '_') && current.length >= MIN_PIECE) { + pieces.push(current); + current = ''; + } + } + if (current) { + // A short tail, such as ".net", joins the piece before it: no line holds only that. + if (current.length <= MAX_TAIL && pieces.length) pieces[pieces.length - 1] += current; + else pieces.push(current); + } + return pieces; +} From ea274b02e995335a730f03fe3f0bec3186a37c44 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:58:48 +0100 Subject: [PATCH 22/46] fix: end a field's text in an ellipsis when it doesn't fit, instead of cutting it mid-word --- app/globals.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/globals.css b/app/globals.css index b9aee76aa..46339ee6e 100644 --- a/app/globals.css +++ b/app/globals.css @@ -191,6 +191,12 @@ * { border-color: var(--color-border); } + + /* A placeholder or value too long for a narrow field ends in "…" instead of + stopping mid-word ("Termine durchs"), so it's clear there's more. */ + input { + text-overflow: ellipsis; + } } body { From eda317721c07795873889b19b026d16fc29fa055 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:58:50 +0100 Subject: [PATCH 23/46] test: find cut-off placeholders, and let the calendar's sideways strip of weeks scroll --- integration/tests/25-do-layout-fit.spec.ts | 7 ++++--- integration/tests/helpers/layout-fit.ts | 20 +++++++++++++++++++- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/integration/tests/25-do-layout-fit.spec.ts b/integration/tests/25-do-layout-fit.spec.ts index 77ec19577..9fc15ecba 100644 --- a/integration/tests/25-do-layout-fit.spec.ts +++ b/integration/tests/25-do-layout-fit.spec.ts @@ -99,14 +99,15 @@ for (const scenario of SCENARIOS) { await check(page, scenario, 'mail-list', found); await item.click(); await page.getByRole('button', { name: text.showDetails }).first().click(); - const routing = page.locator('section', { has: page.getByText(text.routing, { exact: true }) }).first(); + // Sections nest: the last match is the innermost, the routing section itself. + const routing = page.locator('section', { has: page.getByText(text.routing, { exact: true }) }).last(); await expect(routing).toBeVisible(); await check(page, scenario, 'mail-details', found); // The sender's address gets a column of its own width, not a sliver, and a date // takes one line. Rows: From, To, Sent, Received. - const fit = await routing.evaluate((section) => { - const values = Array.from(section.querySelectorAll('dl > dd')); + const fit = await routing.locator('dl').first().evaluate((list) => { + const values = Array.from(list.querySelectorAll(':scope > dd')); const from = values[0].getBoundingClientRect(); const sent = values[values.length - 2]; const lineHeight = parseFloat(getComputedStyle(sent).lineHeight) || 20; diff --git a/integration/tests/helpers/layout-fit.ts b/integration/tests/helpers/layout-fit.ts index 127b2d785..067a7b8cc 100644 --- a/integration/tests/helpers/layout-fit.ts +++ b/integration/tests/helpers/layout-fit.ts @@ -7,7 +7,8 @@ * Not counted: text that's meant to be cut (text-overflow: ellipsis, line clamp), * text inside a box that scrolls only sideways, text that's wholly outside its box (a * closed drawer, a slide), text in a box 1-2px wide (visually hidden), screen-reader-only text, and anything marked - * data-layout-fit-ignore. Frames (a message body) aren't looked into. + * data-layout-fit-ignore. Frames (a message body) aren't looked into. A field's + * placeholder counts too, unless it ends in an ellipsis. */ import type { Page } from '@playwright/test'; @@ -58,6 +59,10 @@ export async function findCutOffText(page: Page): Promise { if (Number(s.opacity) === 0 || s.visibility === 'hidden') { meant = true; break; } if (!clipsX(s)) continue; if (scrollsX(s)) { meant = true; break; } + // A strip of pages that scrolls both ways (the calendar's weeks side by side): + // the next page peeking in is meant. Text spilling over by accident is a few + // pixels to a column, never half the box again. + if (s.overflowX !== 'hidden' && s.overflowX !== 'clip' && at.scrollWidth > at.clientWidth * 1.5) { meant = true; break; } // A 1px box is the visually-hidden pattern: read out, not shown. if (at.clientWidth <= 2) { meant = true; break; } const r = at.getBoundingClientRect(); @@ -75,6 +80,19 @@ export async function findCutOffText(page: Page): Promise { out.push({ where: describe(el), text: text.slice(0, 60), px: Math.round(px) }); } } + // A placeholder cut off mid-word: "Termine durchs". With text-overflow: ellipsis + // it ends in "…" and says there's more. + const canvas = document.createElement('canvas').getContext('2d'); + for (const input of Array.from(document.querySelectorAll('input[placeholder]'))) { + if (input.value || !input.placeholder || input.closest('[aria-hidden="true"], [data-layout-fit-ignore]')) continue; + const s = getComputedStyle(input); + if (s.display === 'none' || s.visibility === 'hidden' || input.offsetParent === null) continue; + if (s.textOverflow === 'ellipsis' || !canvas) continue; + canvas.font = `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`; + const room = input.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight); + const px = canvas.measureText(input.placeholder).width - room; + if (px > 1.5) out.push({ where: describe(input), text: input.placeholder.slice(0, 60), px: Math.round(px) }); + } return out; }); } From 6d3d7707a3f6727189838b0833a230e846553b12 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:00:28 +0100 Subject: [PATCH 24/46] fix: let a page see the forced server it runs on, and only that --- lib/desktop/policy-schema.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/desktop/policy-schema.ts b/lib/desktop/policy-schema.ts index 781726d39..a6fc4c5c4 100644 --- a/lib/desktop/policy-schema.ts +++ b/lib/desktop/policy-schema.ts @@ -859,15 +859,15 @@ const PAGE_POLICY_KEYS: readonly PolicyKey[] = [ /** * The policies as a page gets them. A page can be any server someone chose, and the auth * lists and certificate rules name an organisation's internal hosts, so those stay at their - * defaults. Nor does it get a bad value's text, or the address of a server: only whether - * one is forced. + * defaults. Nor does it get a bad value's text, or a recommended server's address: a forced + * one is the only server such a page can be on. */ export function pagePolicySnapshot(snapshot: PolicySnapshot): PolicySnapshot { const page = { ...DEFAULT_POLICY_SNAPSHOT } as Record; for (const key of PAGE_POLICY_KEYS) { const entry: PolicyEntry = { ...snapshot[key] }; delete entry.raw; - if (key === 'InstanceUrl') (entry as { value: unknown }).value = null; + if (key === 'InstanceUrl' && !entry.locked) (entry as { value: unknown }).value = null; page[key] = entry; } return page as PolicySnapshot; From cbe7d5da49ced76b97124c2a8dc40e604cbe36e5 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:09:09 +0100 Subject: [PATCH 25/46] fix: drop the recommended forms of policies that do nothing as a recommendation from the templates --- desktop-policies/Bulwark.admx | 24 ------------------------ desktop-policies/de-DE/Bulwark.adml | 2 +- desktop-policies/en-US/Bulwark.adml | 2 +- 3 files changed, 2 insertions(+), 26 deletions(-) diff --git a/desktop-policies/Bulwark.admx b/desktop-policies/Bulwark.admx index d9020a931..c45e3459a 100644 --- a/desktop-policies/Bulwark.admx +++ b/desktop-policies/Bulwark.admx @@ -81,12 +81,6 @@ - - - - - - @@ -123,12 +117,6 @@ - - - - - - @@ -149,24 +137,12 @@ - - - - - - - - - - - - diff --git a/desktop-policies/de-DE/Bulwark.adml b/desktop-policies/de-DE/Bulwark.adml index 2dc61f9cc..7b8e93636 100644 --- a/desktop-policies/de-DE/Bulwark.adml +++ b/desktop-policies/de-DE/Bulwark.adml @@ -61,7 +61,7 @@ Anmeldung über den Browser anbieten Wenn aktiviert oder nicht konfiguriert, bietet die Anmeldeseite „Mit Ihrem Browser anmelden“ an, damit Benutzer dort ihren Passwort-Manager, Passkeys und Ihr Single Sign-on verwenden können. Wenn deaktiviert, fehlt die Schaltfläche und die App lehnt den Vorgang ab. Das Formular in der App bleibt, sofern der Server nicht nur OAuth erlaubt. Auf dem Gerät behaltene E-Mails löschen - Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. + Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen. Diese Liste liegt im Profil des Benutzers. Wer sie bearbeitet, kann also eine Löschung umgehen, von der er weiß: Dies räumt ein Gerät auf, es nimmt der Person am Gerät keine Daten weg. diff --git a/desktop-policies/en-US/Bulwark.adml b/desktop-policies/en-US/Bulwark.adml index 1ef436c69..167772e1b 100644 --- a/desktop-policies/en-US/Bulwark.adml +++ b/desktop-policies/en-US/Bulwark.adml @@ -61,7 +61,7 @@ Offer sign-in through the browser When enabled or not configured, the login page offers "Sign in with your browser", so people can use their password manager, passkeys and your single sign-on there. When disabled, the button is gone and the app refuses the flow. The form in the app stays, unless the server only allows OAuth. Delete mail kept on the device - Set this to a new value, such as today's date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. + Set this to a new value, such as today's date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again. That memory is in the user's own profile, so someone who edits it can skip a wipe they know is coming: this cleans up a device, it doesn't take data from the person using it. From 8bcf5248a4abb5c57934c91a63be503fe5395141 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:17:12 +0100 Subject: [PATCH 26/46] fix: translate Lock now in the desktop File menu --- lib/desktop/languages.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts index a877ef3dd..6f6840339 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -132,6 +132,7 @@ export function shellLabels(t: (key: string, values?: Record) => menuShowMenuBar: t('desktopMenuBar.always_show_label'), trayNewMessage: t('email_composer.new_message'), trayLockNow: t('appLock.lock_now'), + menuLockNow: t('appLock.lock_now'), trayUnread: t('email_list.sections.unread'), trayPause: t('desktopMenuBar.tray_pause'), trayPauseHour: t('desktopMenuBar.tray_pause_hour'), From 141748e1d668c24163a02af76bf740f3abde7a0a Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 4 Nov 2026 06:29:23 +0100 Subject: [PATCH 27/46] fix: show first-start notices one at a time above the message list, each with a close button The dictionary test now reads the notice queue instead of the toasts: the question is no longer a toast. --- .../desktop/__tests__/desktop-host.test.tsx | 31 ++++++- components/desktop/desktop-host.tsx | 22 +++-- components/mail/mail-app.tsx | 6 +- .../offline/__tests__/offline-host.test.tsx | 16 +++- components/offline/offline-host.tsx | 4 +- .../ui/__tests__/first-start-notice.test.tsx | 61 ++++++++++++++ components/ui/first-start-notice.tsx | 45 +++++++++++ components/ui/welcome-banner.tsx | 19 +++++ lib/first-start-notices.ts | 81 +++++++++++++++++++ 9 files changed, 273 insertions(+), 12 deletions(-) create mode 100644 components/ui/__tests__/first-start-notice.test.tsx create mode 100644 components/ui/first-start-notice.tsx create mode 100644 lib/first-start-notices.ts diff --git a/components/desktop/__tests__/desktop-host.test.tsx b/components/desktop/__tests__/desktop-host.test.tsx index 61f8a3b8d..76b83aaa3 100644 --- a/components/desktop/__tests__/desktop-host.test.tsx +++ b/components/desktop/__tests__/desktop-host.test.tsx @@ -6,6 +6,7 @@ import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '@/lib/ import { resetDesktopLanguages } from '@/lib/desktop/languages'; import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; import { useToastStore } from '@/stores/toast-store'; +import { closeFirstStartNotice, firstStartNoticesWaiting, resetFirstStartNotices } from '@/lib/first-start-notices'; import { useAuthStore } from '@/stores/auth-store'; import { useLocaleStore } from '@/stores/locale-store'; import { useOfflineStore } from '@/lib/offline/state'; @@ -116,6 +117,13 @@ describe('DesktopHost', () => { expect(push).toHaveBeenLastCalledWith('/contacts'); }); + it('Help > Settings opens settings as a page of this window', () => { + const shell = fakeBridge(); + render(); + shell.run('go.settings'); + expect(push).toHaveBeenLastCalledWith('/settings'); + }); + it('a link to a folder goes through the router, encoded', () => { const shell = fakeBridge(); render(); @@ -233,8 +241,10 @@ describe('DesktopHost', () => { }); describe('a language with no checker in the OS', () => { + beforeEach(() => resetFirstStartNotices()); afterEach(() => resetComposeFlushRegistry()); - const toasts = () => useToastStore.getState().toasts; + // A first-start notice above the message list, not a toast over the composer. + const toasts = () => firstStartNoticesWaiting(); it('the composer asks once, and only a yes opens the downloads', async () => { useToastStore.setState({ toasts: [] }); @@ -246,6 +256,7 @@ describe('DesktopHost', () => { expect(toasts()).toHaveLength(0); const stop = registerComposeFlush(async () => {}); await waitFor(() => expect(toasts()).toHaveLength(1)); + expect(useToastStore.getState().toasts).toHaveLength(0); expect(shell.allowDownload).not.toHaveBeenCalled(); act(() => toasts()[0].action!.onClick()); expect(shell.allowDownload).toHaveBeenCalledTimes(1); @@ -253,6 +264,24 @@ describe('DesktopHost', () => { stop(); }); + it("closing it says no, and it doesn't come back", async () => { + const shell = fakeBridge(); + render(); + await act(async () => {}); + const stop = registerComposeFlush(async () => {}); + shell.missing('nl-NL'); + await waitFor(() => expect(toasts()).toHaveLength(1)); + // Only asked: nothing decided until the user closes it. + expect(readDesktopPrefs().dictionaryDownload).toBe('ask'); + act(() => closeFirstStartNotice(toasts()[0].id)); + expect(readDesktopPrefs().dictionaryDownload).toBe('no'); + shell.missing('fr-FR'); + await act(async () => {}); + expect(toasts()).toHaveLength(0); + expect(shell.allowDownload).not.toHaveBeenCalled(); + stop(); + }); + it('never a second time', async () => { useToastStore.setState({ toasts: [] }); setDesktopPref('dictionaryDownload', 'no'); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index 909ca657b..57d427302 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -25,7 +25,7 @@ import { useEmailStore } from "@/stores/email-store"; import { useLocaleStore } from "@/stores/locale-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useThemeStore } from "@/stores/theme-store"; -import { toast } from "@/stores/toast-store"; +import { showFirstStartNotice } from "@/lib/first-start-notices"; /** * The webmail's side of what every desktop window needs: runs menu commands nothing @@ -71,6 +71,8 @@ export function DesktopHost() { "go.calendar": () => routerRef.current.push("/calendar"), "go.contacts": () => routerRef.current.push("/contacts"), "go.files": () => routerRef.current.push("/files"), + // Help > Settings: a page of this window, as the rail's gear opens it. + "go.settings": () => routerRef.current.push("/settings"), "help.shortcuts": () => openKeyboardShortcuts(), }; return menu.onCommand((id) => { @@ -127,9 +129,10 @@ export function DesktopHost() { noteComposerLanguage("settings", writingLanguage && writingLanguage !== "auto" ? writingLanguage : null); }, [writingLanguage]); - // A language with no checker in the OS needs a dictionary from Google: the composer - // asks once, and never in no-third-party mode. Until a yes, the shell has nowhere to - // fetch from at all. + // A language with no checker in the OS needs a dictionary from Google: once a composer + // is open, a first-start notice asks, never in no-third-party mode, and not again once + // closed. Until a yes, the shell has nowhere to fetch from at all. Windows never asks: + // it checks spelling itself (the shell's context-menu.ts). useEffect(() => { const spellcheck = getDesktopBridge()?.spellcheck; if (!spellcheck) return; @@ -139,8 +142,6 @@ export function DesktopHost() { const [language] = waiting; if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return; if (readDesktopPrefs().dictionaryDownload !== "ask") return; - // Asked once, whatever comes of it. - setDesktopPref("dictionaryDownload", "no"); const translate = tRef.current; let name = language; try { @@ -148,8 +149,9 @@ export function DesktopHost() { } catch { // The tag itself will do. } - toast.info(translate("desktopUi.spellcheck.offer", { language: name }), { - duration: 0, + showFirstStartNotice({ + id: "dictionary-download", + text: translate("desktopUi.spellcheck.offer", { language: name }), action: { label: translate("desktopUi.spellcheck.download"), onClick: () => { @@ -157,6 +159,10 @@ export function DesktopHost() { if (dictionaryDownloadAllowed()) spellcheck.allowDownload(); }, }, + // Closed without a yes: asked once, not again. + onClose: () => { + if (readDesktopPrefs().dictionaryDownload === "ask") setDesktopPref("dictionaryDownload", "no"); + }, }); }; const heard = (language: string) => { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 11d4246c0..25454c2a6 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -104,7 +104,8 @@ import type { Attachment, SendEmailResult } from "@/lib/jmap/types"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { peekListAttachments, requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments"; import { useSearchHistoryStore } from "@/stores/search-history-store"; -import { WelcomeBanner } from "@/components/ui/welcome-banner"; +import { useWelcomeBannerDue, WelcomeBanner } from "@/components/ui/welcome-banner"; +import { FirstStartNoticeBar } from "@/components/ui/first-start-notice"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { AppMain } from "@/components/layout/app-landmarks"; import { MvTopBar } from "@/components/layout/mv-top-bar"; @@ -253,6 +254,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Static Lite build: the route params are empty, read the link from the URL. const linkSegments = useLiteLinkSegments('mail', routeSegments); const t = useTranslations(); + // First-start notices wait until the welcome tips are closed: one thing at a time. + const welcomeDue = useWelcomeBannerDue(); // The page skeleton the gates below draw: the same one the server sent. const skeletonLabels = useSkeletonLabels(); const tNotifications = useTranslations('notifications'); @@ -5718,6 +5721,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { unless an enabled plugin registered tabs via api.tabs.set. */} {!isScheduledView && } +