diff --git a/app/globals.css b/app/globals.css index b9aee76aa..ffdf51c34 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 { @@ -606,6 +612,23 @@ html[data-window-controls] [data-own-window-close] { display: none; } +/* + * A pane's own close right beside the window's read as a second close for the + * window. Where the row ends at the controls, it goes to the row's other end. + * Geometry, not direction: the controls stay at the right on Windows in RTL too. + */ +html[data-window-controls]:not([dir="rtl"]) [data-window-row-right] > [data-pane-close], +html[data-window-controls][dir="rtl"] [data-window-row-left] > [data-pane-close] { + order: -1; + margin-inline-start: -0.25rem; + margin-inline-end: 0; +} + +/* Toasts that step out of the focus's way go to the top end corner: below the controls there. */ +html[data-window-controls] [data-toast-container][data-dodging] { + top: calc(var(--titlebar-row-height) + 0.75rem) !important; +} + /* A top row is at least as tall as the controls, so they don't hang over what's below it. */ html[data-window-controls] [data-window-row] { min-height: var(--titlebar-row-height); @@ -629,6 +652,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/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/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 94438b553..0d92ac0e0 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -1052,7 +1052,7 @@ export function EventModal({ )} - + @@ -1247,11 +1247,11 @@ export function EventModal({ return (
-
+

{isEdit ? t("events.edit") : t("events.create")}

- +
diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 75ea6439a..b6c111697 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -226,7 +226,7 @@ export function TaskModal({

{isEdit ? t("tasks.edit") : t("tasks.create")}

- +
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/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-form.tsx b/components/contacts/contact-form.tsx index 2191820e5..b5390e778 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -708,7 +708,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress

{isEditing ? t("edit_title") : t("create_title")}

- +
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 = ) : ( ({ 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(() => { diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 787389c18..082e40541 100644 --- a/components/email/__tests__/composer-plugin-surfaces.test.tsx +++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx @@ -327,6 +327,8 @@ describe('composer-send-menu', () => { const schedule = screen.getAllByRole('menuitem', { name: /schedule_send/ })[0]; const plugin = screen.getAllByRole('menuitem', { name: 'Send with Later' })[0]; expect(follows(schedule, plugin)).toBe(true); + // It opens a dialog, and shows the key that opens it from the composer. + expect(schedule).toHaveTextContent('Ctrl + Shift + Enter'); }); it('keeps a plain Send button without plugin entries or scheduled send', () => { 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__/email-context-menu-shortcuts.test.tsx b/components/email/__tests__/email-context-menu-shortcuts.test.tsx new file mode 100644 index 000000000..cc3c8f8f5 --- /dev/null +++ b/components/email/__tests__/email-context-menu-shortcuts.test.tsx @@ -0,0 +1,114 @@ +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(); + }); +}); + +describe('message menu offline', () => { + afterEach(async () => { + const { useOfflineStore } = await import('@/lib/offline/state'); + useOfflineStore.setState({ browserOffline: false, enabled: [] }); + }); + + it('greys out changes that would fail without a connection, and says why', async () => { + const { useOfflineStore } = await import('@/lib/offline/state'); + useOfflineStore.setState({ browserOffline: true, enabled: [] }); + renderMenu(); + expect(screen.getByText('offline_header')).toBeInTheDocument(); + expect((screen.getByTestId('ctx-delete') as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByTestId('ctx-star') as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByTestId('ctx-mark-read') as HTMLButtonElement).disabled).toBe(true); + // Writing a reply still works: the composer keeps the draft. + expect(row('reply')).not.toBeDisabled(); + }); + + it('leaves them on while online', () => { + renderMenu(); + expect(screen.queryByText('offline_header')).toBeNull(); + expect((screen.getByTestId('ctx-delete') as HTMLButtonElement).disabled).toBe(false); + }); +}); 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/__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/__tests__/rules-menu.test.tsx b/components/email/__tests__/rules-menu.test.tsx index c230200e3..baa1de7c1 100644 --- a/components/email/__tests__/rules-menu.test.tsx +++ b/components/email/__tests__/rules-menu.test.tsx @@ -124,6 +124,24 @@ describe('Rules entry of the message menu', () => { }]); }); + it('lists folders in Move-list order, with a read-only parent greyed above its subfolders', () => { + const boxes = [ + { id: 'zeta', name: 'Zeta', role: null, parentId: null, sortOrder: 0, myRights: { mayAddItems: true } }, + { id: 'trash', name: 'Trash', role: 'trash', parentId: null, sortOrder: 9, myRights: { mayAddItems: true } }, + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, sortOrder: 5, myRights: { mayAddItems: true } }, + { id: 'shared', name: 'Shared', role: null, parentId: null, sortOrder: 0, myRights: { mayAddItems: false } }, + { id: 'alpha', name: 'alpha', role: null, parentId: 'shared', sortOrder: 0, myRights: { mayAddItems: true } }, + ] as unknown as Mailbox[]; + targets.set('e1', target({ mailboxes: boxes })); + renderMenu(email('e1')); + openRules(); + fireEvent.mouseEnter(screen.getByTestId('rules:move_sender').parentElement!); + const rows = screen.getAllByTestId(/^rules:move_sender:/).map((el) => el.getAttribute('data-testid')); + expect(rows).toEqual(['rules:move_sender:inbox', 'rules:move_sender:trash', 'rules:move_sender:shared', 'rules:move_sender:alpha', 'rules:move_sender:zeta', 'rules:move_sender:new-folder']); + expect(screen.getByTestId('rules:move_sender:shared')).toBeDisabled(); + expect(screen.getByTestId('rules:move_sender:shared').closest('[data-native-submenu]')).not.toBeNull(); + }); + it('disables Block sender with a hint when the account has no Junk folder', () => { targets.set('e1', target()); renderMenu(email('e1')); 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/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-composer.tsx b/components/email/email-composer.tsx index 3409f77db..0826f1069 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -56,6 +56,7 @@ import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; +import { formatShortcutCombo } from "@/lib/shortcut-keys"; import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml, sanitizePluginBodyHtml } from "@/lib/email-sanitization"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; @@ -592,6 +593,7 @@ export function EmailComposer({ replyTo }: EmailComposerProps) { const t = useTranslations('email_composer'); + const tMenu = useTranslations('menuAudit'); const tCommon = useTranslations('common'); const tWindows = useTranslations('pwaWindows'); const tQuote = useTranslations('quote_header'); @@ -4894,6 +4896,7 @@ export function EmailComposer({ label={tCommon('close')} data-testid="composer-close" data-own-window-close={ownWindow ? "" : undefined} + data-pane-close="" >
@@ -5813,7 +5816,7 @@ export function EmailComposer({ {composerClient?.hasDelayedSend() && ( - + )} 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; @@ -141,6 +145,12 @@ interface EmailContextMenuProps { label?: string; } +/** Entries that change the message on the server, or queue the change offline. */ +const CHANGES_MAIL = new Set([ + "archive", "delete", "read", "remove_from_folder", "star", "pin", "spam", + "reschedule", "cancel_send", "cancel_and_edit", +]); + export function EmailContextMenu({ email, position, @@ -192,15 +202,21 @@ export function EmailContextMenu({ onPrint, onViewSource, omit, + views, extras, rowPlugins = true, extraEntries, label, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); + const shortcutOf = useMessageActionShortcut(); + // Offline, a change to a message waits in the outbox only where the account keeps + // its mail on this device; otherwise it would fail, so it's greyed out. Rules and + // copies to another account always need the server. + const { offline, canChangeMail } = useConnectionState(); + const tMenu = useTranslations("menuAudit"); const archiveActionEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); - const 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 +237,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 +291,7 @@ export function EmailContextMenu({ const singleEntries = useMessageActionList({ email, currentMailboxRole, - canMove: moveTree.length > 0, + canMove: canMoveAnywhere, canRemoveFromFolder: canRemove, canCopyToAccount: !!onCopyToAccount && copyTrees.length > 0, onReply, @@ -319,6 +316,7 @@ export function EmailContextMenu({ onPrint, onViewSource, omit, + views, extras, }); @@ -356,8 +354,9 @@ export function EmailContextMenu({ handleAction(onBatchArchive!)} - disabled={!onBatchArchive} + disabled={!onBatchArchive || !canChangeMail} /> ); @@ -368,8 +367,9 @@ export function EmailContextMenu({ icon={Trash2} label={t("delete")} testId="ctx-delete" + shortcut={shortcutOf("delete")} onClick={() => handleAction(onBatchDelete!)} - disabled={!onBatchDelete} + disabled={!onBatchDelete || !canChangeMail} destructive={!mountainView} /> @@ -381,6 +381,8 @@ 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)} + disabled={!canChangeMail} onClick={() => handleAction(() => onBatchMarkAsRead?.(isUnread))} /> @@ -388,9 +390,9 @@ export function EmailContextMenu({ const moveItem = ( <> {/* Move to submenu */} - {moveTree.length > 0 && ( - - {renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onBatchMoveToMailbox?.(mailboxId))} + {canMoveAnywhere && ( + + {renderMoveList(onBatchMoveToMailbox)} )} {canRemove && ( @@ -398,6 +400,7 @@ export function EmailContextMenu({ icon={FolderX} label={t("remove_from_folder")} testId="ctx-remove-from-folder" + disabled={!canChangeMail} onClick={() => handleAction(onBatchRemoveFromFolder!)} /> )} @@ -407,9 +410,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; @@ -417,7 +418,7 @@ export function EmailContextMenu({ <> {/* Copy to another connected account, keeping the originals */} {onCopyToAccount && copyTrees.length > 0 && ( - + {copyTargetsContent} )} @@ -426,14 +427,14 @@ export function EmailContextMenu({ const rulesItem = ( <> {/* Rules: filter rules made from the sender(s), saved as Sieve */} - + ); const tagItem = ( <> {/* Set tag submenu - the whole selection's tags (#1077) */} {onBatchToggleTag && ( - +
handleAction(isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)} - disabled={isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam} + disabled={(isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) || !canChangeMail} destructive={!isInJunkFolder && !mountainView} /> {batchPhishing.available && ( @@ -477,26 +479,26 @@ export function EmailContextMenu({ switch (entry.key) { case "move": return ( - - {renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onMoveToMailbox?.(mailboxId))} + + {renderMoveList(onMoveToMailbox)} ); case "copy_to_account": return ( - + {copyTargetsContent} ); case "tag": return ( - +
onSetTag?.(tagId)} scope={tagScope} />
); case "rules": - return ; + return ; } }; @@ -509,8 +511,9 @@ export function EmailContextMenu({ icon={entry.icon} label={entry.label} testId={entry.testId} - disabled={entry.disabled} + disabled={entry.disabled || (!canChangeMail && CHANGES_MAIL.has(entry.key))} destructive={entry.destructive} + shortcut={shortcutOf(entry.key, isUnread)} onClick={() => handleAction(entry.run)} /> ); @@ -524,6 +527,7 @@ export function EmailContextMenu({ onClose={onClose} label={label} > + {offline && {tMenu("offline_header")}} {showBatchActions ? ( <> diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0b1b5fac7..6762ae220 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"; @@ -47,11 +46,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 +63,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"; @@ -106,7 +107,6 @@ import { Send, FolderInput, Sun, - Upload, Moon, PlayCircle, PenSquare, @@ -122,7 +122,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"; @@ -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"; @@ -278,7 +279,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 +1157,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} @@ -1172,8 +1173,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 +1197,7 @@ function ReaderMorePanelBody({ openSub, close, entryRefs, - moveTree, - moveTargetIds, + moveModel, onMoveToMailbox, currentTagIds, sortedTagIds, @@ -1209,6 +1208,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 +1218,10 @@ function ReaderMorePanelBody({ }); if (sub === 'move') { - return moveTree.length > 0 && onMoveToMailbox ? ( - { onMoveToMailbox(mailboxId); close(); }} /> ) : null; @@ -1231,14 +1230,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(); }} />
@@ -1341,7 +1341,6 @@ export function EmailViewer({ onReplyConversationEmail, onReplyAllConversationEmail, mvInlineComposer, - onShowShortcuts, onEditDraft, onCancelScheduledForEdit, onCancelScheduledSend, @@ -1364,7 +1363,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'); @@ -1978,14 +1976,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 +2162,7 @@ export function EmailViewer({ email?.id, showToolbarLabels, isLoading, - moveTree.length, + canMoveAnywhere, emailKeywords.length, currentColor, isInJunkFolder, @@ -3768,11 +3766,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 +4221,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 +4243,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,15 +4259,11 @@ ${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, currentMailboxRole, - canMove: moveTree.length > 0 && !!onMoveToMailbox, + canMove: canMoveAnywhere && !!onMoveToMailbox, canRemoveFromFolder: !!onRemoveFromFolder, onReply: onReply ? () => onReply() : undefined, onReplyAll, @@ -4291,7 +4283,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 @@ -4467,7 +4459,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 +4823,7 @@ ${printWatermark ? printWatermark.html : ''} openSub={setMoreMenuSub} close={closeMoreMenu} entryRefs={moreEntryRefs} - moveTree={moveTree} - moveTargetIds={moveTargetIds} + moveModel={moveModel} onMoveToMailbox={onMoveToMailbox} currentTagIds={currentTagIds} sortedTagIds={sortedTagIds} @@ -5413,6 +5404,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 +5419,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 +5474,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 +5545,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 +5593,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 +5616,7 @@ ${printWatermark ? printWatermark.html : ''} {email.spamStatus && ( <> · - {email.spamStatus} + {email.spamStatus} )} @@ -5644,9 +5647,9 @@ ${printWatermark ? printWatermark.html : ''} {hasIdentifiers && (
{t('details.identifiers_threading')} -
+
{email.messageId && ( - {email.messageId} + )} {email.inReplyTo && email.inReplyTo.length > 0 && ( @@ -5678,7 +5681,7 @@ ${printWatermark ? printWatermark.html : ''}
{t('details.message_properties')} -
+
{email.subject !== undefined && ( {email.subject || {t('details.no_subject')}} )} @@ -5701,9 +5704,9 @@ ${printWatermark ? printWatermark.html : ''}
{hasListInfo && ( -
+
{t('details.mailing_list')} -
+
{listHeaders?.listId && ( {listHeaders.listId} )} @@ -5730,7 +5733,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/message-action-shortcuts.ts b/components/email/message-action-shortcuts.ts new file mode 100644 index 000000000..944488657 --- /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]); +} 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/email/move-target-list.tsx b/components/email/move-target-list.tsx index cc99987a4..34632ca49 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,258 @@ 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 && ( +
+ {/* The field is the whole box, so the app's focus ring goes round all of it. */} +
+ + setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key !== "Enter") return; + e.preventDefault(); + e.stopPropagation(); + const first = firstEnabled(nodes) ?? others.map((a) => firstEnabled(a.nodes)).find(Boolean); + if (first) pick(first.id); + }} + placeholder={t("filter_placeholder")} + aria-label={t("filter_placeholder")} + className={cn("focus-inset w-full min-w-0 rounded-md border border-border bg-transparent ps-8 pe-2 text-sm", panel ? "h-10" : "h-8")} + data-testid="move-filter" + /> +
+
+ )} + + {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) && (
{anchor && ( diff --git a/components/email/rules-menu.tsx b/components/email/rules-menu.tsx index 643d11376..1c27b8b0f 100644 --- a/components/email/rules-menu.tsx +++ b/components/email/rules-menu.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; import type { Email, Mailbox } from "@/lib/jmap/types"; import { ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu"; import { @@ -27,6 +27,7 @@ import { import { useRouter } from "@/i18n/navigation"; import { buildMailboxTree, cn, generateUUID, type MailboxNode } from "@/lib/utils"; import { localizeMailboxName } from "@/lib/mailbox-label"; +import { sortFolderTree } from "@/lib/move-menu"; import { useAuthStore } from "@/stores/auth-store"; import { useFilterStore } from "@/stores/filter-store"; import { useQuickRuleStore, type FiltersStatus } from "@/stores/quick-rule-store"; @@ -173,7 +174,12 @@ function useRulesModel(anchor: Email, senderEmails: Email[], target: QuickRuleTa }), [senders, listIds]); const targetIds = useMemo(() => ruleTargetMailboxIds(target.mailboxes), [target]); - const tree = useMemo(() => filterTree(buildMailboxTree(target.mailboxes), targetIds), [target, targetIds]); + // In the order every Move list has (lib/move-menu.ts). + const locale = useLocale(); + const tree = useMemo( + () => sortFolderTree(filterTree(buildMailboxTree(target.mailboxes), targetIds), locale), + [target, targetIds, locale], + ); const junk = useMemo(() => findJunkMailbox(target.mailboxes), [target]); const sourceMailboxId = useMemo(() => sourceMailboxOf(anchor, target)?.id ?? null, [anchor, target]); @@ -248,21 +254,18 @@ function ContextFolderItems({ const render = (nodes: MailboxNode[]): React.ReactNode => nodes.map((node) => { const Icon = mailboxIcon(node.role); const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`)); + // Greyed, not plain text, so the desktop app's native copy keeps it as + // the head of its subfolders' submenu (data-native-submenu). + const enabled = model.targetIds.has(node.id); return ( -
- {model.targetIds.has(node.id) ? ( - { model.moveTo(kind, node); onDone(); }} - /> - ) : ( -
- - {label} -
- )} +
0 ? "" : undefined}> + { model.moveTo(kind, node); onDone(); }} + /> {node.children.length > 0 &&
{render(node.children)}
}
); @@ -407,10 +410,13 @@ export function RulesContextSubMenu({ anchor, senderEmails, onClose, + disabled = false, }: { anchor: Email; senderEmails: Email[]; onClose: () => void; + /** Rules are saved on the server: greyed out offline. */ + disabled?: boolean; }) { const t = useTranslations("context_menu"); const accountEmails = useMemo( @@ -424,7 +430,7 @@ export function RulesContextSubMenu({ icon={Filter} label={t("rules.title")} testId="ctx-rules" - disabled={availability === "cross_account"} + disabled={disabled || availability === "cross_account"} hint={availability === "cross_account" ? t("rules.cross_account") : undefined} > {target && } @@ -801,7 +807,7 @@ function FolderRulesContent({ * saved rules over the mail already in the folder (#1166). Hidden for shared * folders and accounts without Sieve. */ -export function FolderRulesSubMenu({ mailbox, onClose }: { mailbox: Mailbox; onClose: () => void }) { +export function FolderRulesSubMenu({ mailbox, onClose, disabled = false }: { mailbox: Mailbox; onClose: () => void; disabled?: boolean }) { const t = useTranslations("context_menu"); const client = useAuthStore((s) => s.client); const target = useMemo( @@ -810,7 +816,7 @@ export function FolderRulesSubMenu({ mailbox, onClose }: { mailbox: Mailbox; onC ); if (!target || !target.supportsSieve) return null; return ( - + ); diff --git a/components/files/__tests__/file-browser-shared-folders.test.tsx b/components/files/__tests__/file-browser-shared-folders.test.tsx index 14a53e064..a035a9368 100644 --- a/components/files/__tests__/file-browser-shared-folders.test.tsx +++ b/components/files/__tests__/file-browser-shared-folders.test.tsx @@ -221,7 +221,7 @@ describe('FileBrowser', () => { renderBrowser({ resources: [resource(OWN[1])], allFolders: [shared] }); fireEvent.contextMenu(within(tree()).getByText('Documents')); - fireEvent.click(await screen.findByRole('menuitem', { name: 'share' })); + fireEvent.click(await screen.findByRole('menuitem', { name: 'files_share' })); expect(await screen.findByTestId('share-dialog')).toHaveTextContent('Documents:p1'); }); @@ -230,7 +230,7 @@ describe('FileBrowser', () => { renderBrowser(); await screen.findAllByText('Team'); fireEvent.contextMenu(within(tree()).getByText('Team')); - expect(screen.queryByRole('menuitem', { name: 'share' })).toBeNull(); + expect(screen.queryByRole('menuitem', { name: 'files_share' })).toBeNull(); }); it('hides dot files unless the setting shows them', () => { @@ -284,7 +284,7 @@ describe('FileBrowser', () => { it('offers new and upload entries on My files in the sidebar', () => { renderBrowser(); fireEvent.contextMenu(within(tree()).getByText('my_files')); - for (const name of ['open', 'new_folder', 'new_text_file', 'upload_files', 'upload_folder']) { + for (const name of ['open', 'files_new_folder', 'files_new_text_file', 'files_upload_files', 'files_upload_folder']) { expect(screen.getByRole('menuitem', { name })).toBeInTheDocument(); } }); @@ -457,7 +457,7 @@ describe('FileBrowser', () => { } render(); const files = groups.find((g) => g.label === 'files'); - expect(files?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['upload_files', 'new_folder', 'trash'])); + expect(files?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['files_upload_files', 'files_new_folder', 'trash'])); fireEvent.keyDown(document.body, { key: '/' }); expect(document.activeElement).toHaveAttribute('role', 'searchbox'); diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx index 5338bf98b..8d7c6ca00 100644 --- a/components/files/file-browser.tsx +++ b/components/files/file-browser.tsx @@ -222,7 +222,9 @@ export function FileBrowser({ onShare, }: FileBrowserProps) { const t = useTranslations("files"); - const newFileLabel = newFileFormats.length > 0 ? t("new_file") : t("new_text_file"); + const tMenu = useTranslations("menuAudit"); + // Menu entries that open a dialog end in "…". + const newFileLabel = newFileFormats.length > 0 ? tMenu("files_new_file") : tMenu("files_new_text_file"); const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]); const validateFileName = useCallback((name: string) => { const problem = fileNameProblem(name, fileNameRules); @@ -951,7 +953,7 @@ export function FileBrowser({ )} {(canShare(resource) || (resource.isDirectory && !resource.isShared)) && } {canShare(resource) && ( - { closeMenu(); setShareTargetId(resource.id); }} /> + { closeMenu(); setShareTargetId(resource.id); }} /> )} {resource.isDirectory && !resource.isShared && ( - { closeMenu(); fileInputRef.current?.click(); }} /> - { closeMenu(); folderInputRef.current?.click(); }} /> + { closeMenu(); fileInputRef.current?.click(); }} /> + { closeMenu(); folderInputRef.current?.click(); }} /> - { closeMenu(); setShowNewFolder(true); }} /> + { closeMenu(); setShowNewFolder(true); }} /> { closeMenu(); setShowNewTextFile(true); }} /> ); @@ -1035,9 +1037,9 @@ export function FileBrowser({ const tShell = useTranslations("shell"); const isEmbedded = useIsEmbedded(); useRegisterCommands(tSidebar("files"), [ - { id: "upload-files", label: t("upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate }, - { id: "upload-folder", label: t("upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate }, - { id: "new-folder", label: t("new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate }, + { id: "upload-files", label: tMenu("files_upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate }, + { id: "upload-folder", label: tMenu("files_upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate }, + { id: "new-folder", label: tMenu("files_new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate }, { id: "new-text-file", label: newFileLabel, icon: FilePlus, run: () => setShowNewTextFile(true), when: () => canCreate }, { id: "trash", label: t("trash"), icon: Trash2, run: () => onNavigate(TRASH_PATH, TRASH_LOCATION), when: () => !inTrash && !accountPickerMode }, { id: "empty-trash", label: t("empty_trash"), icon: Trash2, run: () => onEmptyTrash?.(), when: () => inTrash && trashCount > 0 && !!onEmptyTrash }, diff --git a/components/files/file-details-panel.tsx b/components/files/file-details-panel.tsx index 34facf37d..57c2e135d 100644 --- a/components/files/file-details-panel.tsx +++ b/components/files/file-details-panel.tsx @@ -56,6 +56,7 @@ export function FileDetailsPanel({ iconOptions, getImageUrl, onClose, onOpen, onEdit, onDownload, onShare, onToggleStar, className, pluginFile, }: FileDetailsPanelProps) { const t = useTranslations("files"); + const tMenu = useTranslations("menuAudit"); const pluginProps = useMemo(() => (pluginFile ? { file: pluginFile } : null), [pluginFile]); const header = ( @@ -147,7 +148,7 @@ export function FileDetailsPanel({ )} {canShare && ( )} {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 && ( e.stopPropagation()} >

{name}

-
+
{neighbours && ( <> neighbours.previous && neighbours.onGo(neighbours.previous)}> @@ -104,10 +104,11 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c )} - - -
+ {/* Its own child of the bar, so it can move to the bar's other end beside the window's controls. */} + + +
{children}
diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 71a23aac4..4585948db 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -1,7 +1,8 @@ "use client"; import { useState, useCallback, useMemo, useRef } from "react"; -import { useTranslations } from "next-intl"; +import { useLocale, useTranslations } from "next-intl"; +import { sortFolderTree } from "@/lib/move-menu"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Plus, X } from "@/components/icons"; @@ -221,8 +222,10 @@ export function FilterRuleModal({ }, [confirmDiscard, dirty, onClose]); const nameRef = useRef(null); + // The folder list in the order every Move list has (lib/move-menu.ts). + const locale = useLocale(); const { hierarchicalMailboxes, mailboxPathMap } = useMemo(() => { - const tree = buildMailboxTree(mailboxes.filter((mb) => !mb.isShared)); + const tree = sortFolderTree(buildMailboxTree(mailboxes.filter((mb) => !mb.isShared)), locale); const pathMap = new Map(); const buildPaths = (nodes: MailboxNode[], parentPath = "") => { for (const node of nodes) { @@ -236,7 +239,7 @@ export function FilterRuleModal({ }; buildPaths(tree); return { hierarchicalMailboxes: flattenMailboxTree(tree), mailboxPathMap: pathMap }; - }, [mailboxes]); + }, [mailboxes, locale]); // Rules saved before folder ids were stored only carry the path; resolve it // so the select shows the folder and the next save adds the id. diff --git a/components/layout/__tests__/mailbox-context-menu-states.test.tsx b/components/layout/__tests__/mailbox-context-menu-states.test.tsx new file mode 100644 index 000000000..5f6447986 --- /dev/null +++ b/components/layout/__tests__/mailbox-context-menu-states.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Mailbox } from '@/lib/jmap/types'; + +// A folder menu greys out what can't work: nothing to mark read, or no connection +// for what needs the server. + +vi.mock('@/components/email/rules-menu', () => ({ FolderRulesSubMenu: () => null })); +vi.mock('@/lib/offline/offline-mode', () => ({ offlineAllowed: () => true })); + +const { MailboxContextMenu, unreadInTree } = await import('../mailbox-context-menu'); +const { useOfflineStore } = await import('@/lib/offline/state'); +const { useAuthStore } = await import('@/stores/auth-store'); + +const box = (id: string, unread: number, parentId: string | null = null) => + ({ id, name: id, parentId, sortOrder: 1, totalEmails: 5, unreadEmails: unread, myRights: {} }) as unknown as Mailbox; + +function renderMenu(mailbox: Mailbox, mailboxes: Mailbox[], hasChildren = false) { + render( + , + ); +} + +const item = (name: string) => screen.getByRole('menuitem', { name }) as HTMLButtonElement; + +describe('folder menu states', () => { + afterEach(() => { + useOfflineStore.setState({ browserOffline: false, downServers: [], enabled: [] }); + }); + + it('greys out Mark folder as read when nothing in it is unread', () => { + const read = box('read', 0); + renderMenu(read, [read]); + expect(item('mark_folder_read').disabled).toBe(true); + }); + + it('keeps it for a folder with unread mail, and the tree entry for unread mail below', () => { + const parent = box('parent', 0); + const child = box('child', 3, 'parent'); + renderMenu(parent, [parent, child], true); + expect(item('mark_folder_read').disabled).toBe(true); + expect(item('mark_folder_tree_read').disabled).toBe(false); + expect(unreadInTree(parent, [parent, child])).toBe(3); + }); + + it('offline, greys out what needs the server and says why', () => { + useOfflineStore.setState({ browserOffline: true }); + const folder = box('f', 2); + renderMenu(folder, [folder]); + expect(screen.getByText('offline_header')).toBeInTheDocument(); + for (const name of ['new_subfolder', 'rename', 'menu_import', 'refresh', 'empty_folder_generic', 'delete_folder']) { + expect(item(name).disabled).toBe(true); + } + // No mail kept on this device: marking read would fail too. + expect(item('mark_folder_read').disabled).toBe(true); + }); + + it('offline with the mail kept on this device, marking read still works', () => { + useAuthStore.setState({ activeAccountId: 'acc' } as never); + useOfflineStore.setState({ browserOffline: true, enabled: ['acc'] }); + const folder = box('f', 2); + renderMenu(folder, [folder]); + expect(item('mark_folder_read').disabled).toBe(false); + expect(item('rename').disabled).toBe(true); + }); +}); diff --git a/components/layout/inline-app-view.tsx b/components/layout/inline-app-view.tsx index 970116fef..926971b98 100644 --- a/components/layout/inline-app-view.tsx +++ b/components/layout/inline-app-view.tsx @@ -22,7 +22,7 @@ export function InlineAppView({ apps, activeAppId, onClose, className }: InlineA {/* Header bar */}

{activeApp?.name}

- +
diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx index 827d1d592..0e2f7d40d 100644 --- a/components/layout/mailbox-context-menu.tsx +++ b/components/layout/mailbox-context-menu.tsx @@ -31,6 +31,7 @@ import { import { FolderRulesSubMenu } from "@/components/email/rules-menu"; import { openMailExport } from "@/stores/mail-transfer-store"; import { useDevicePolicy } from "@/hooks/use-device-policy"; +import { useConnectionState } from "@/hooks/use-connection-state"; interface Position { x: number; @@ -45,6 +46,20 @@ export type MailboxContextTarget = | { kind: "folders-section"; accountId?: string }; const PATH_SEPARATOR = " › "; + +/** Unread messages in a folder and the folders under it; undefined where a count isn't known. */ +export function unreadInTree(mailbox: Mailbox, mailboxes: Mailbox[]): number | undefined { + let total = 0; + const seen = new Set(); + const visit = (box: Mailbox): boolean => { + if (seen.has(box.id)) return true; + seen.add(box.id); + if (typeof box.unreadEmails !== "number") return false; + total += box.unreadEmails; + return mailboxes.filter((child) => child.parentId === box.id).every(visit); + }; + return visit(mailbox) ? total : undefined; +} const MAX_PATH_LENGTH = 40; const MAX_SEGMENT_LENGTH = 16; @@ -147,6 +162,11 @@ export function MailboxContextMenu({ const tExport = useTranslations("mail_export"); // Organisation > Devices > No export hides it; import stays. const exportOff = useDevicePolicy().noExport; + // Folders, rules and sharing need the server. Marking read waits in the outbox + // where the account keeps its mail on this device. + const { offline, canChangeMail } = useConnectionState(); + const tMenu = useTranslations("menuAudit"); + const offlineHeader = offline ? {tMenu("offline_header")} : null; const handleAction = (action: () => void) => { action(); @@ -198,13 +218,14 @@ export function MailboxContextMenu({ if (target.kind === "folders-section") { return ( + {offlineHeader} {!target.accountId && ( <> handleAction(onMarkAllFoldersRead!)} - disabled={!onMarkAllFoldersRead} + disabled={!onMarkAllFoldersRead || !canChangeMail} /> @@ -213,14 +234,14 @@ export function MailboxContextMenu({ icon={FolderPlus} label={t("new_folder")} onClick={() => handleAction(() => onCreateFolder?.(target.accountId))} - disabled={!onCreateFolder} + disabled={!onCreateFolder || offline} testId="mailbox-new-folder" /> handleAction(onRefresh!)} - disabled={!onRefresh} + disabled={!onRefresh || offline} /> {accountItems(target.accountId)} @@ -238,6 +259,8 @@ export function MailboxContextMenu({ const canSetSeen = mailbox.myRights?.maySetSeen !== false; const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false; const canAddItems = mailbox.myRights?.mayAddItems !== false; + const nothingUnread = mailbox.unreadEmails === 0; + const nothingUnreadBelow = unreadInTree(mailbox, mailboxes) === 0; // Sharing needs the mail:share extension (handler present) and, on a folder // someone else shared with us, their permission to re-share it. const canShare = !!onShareFolder && (!mailbox.isShared || mailbox.myRights?.mayShare === true); @@ -252,19 +275,20 @@ export function MailboxContextMenu({ {renderShortenedPath(fullPath)} + {offlineHeader} handleAction(() => onMarkFolderRead?.(mailbox.id))} - disabled={!onMarkFolderRead || !canSetSeen} + disabled={!onMarkFolderRead || !canSetSeen || !canChangeMail || nothingUnread} /> {target.hasChildren && ( handleAction(() => onMarkFolderTreeRead?.(mailbox.id))} - disabled={!onMarkFolderTreeRead || !canSetSeen} + disabled={!onMarkFolderTreeRead || !canSetSeen || !canChangeMail || nothingUnreadBelow} /> )} @@ -274,14 +298,14 @@ export function MailboxContextMenu({ icon={FolderPlus} label={t("new_subfolder")} onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))} - disabled={!onCreateSubfolder || !canCreateChild} + disabled={!onCreateSubfolder || !canCreateChild || offline} testId="mailbox-new-subfolder" /> handleAction(() => onRenameFolder?.(mailbox.id))} - disabled={!onRenameFolder || !canRename} + disabled={!onRenameFolder || !canRename || offline} testId="mailbox-rename" /> {onShareFolder && ( @@ -289,11 +313,11 @@ export function MailboxContextMenu({ icon={Users} label={t("share")} onClick={() => handleAction(() => onShareFolder(mailbox.id))} - disabled={!canShare} + disabled={!canShare || offline} testId="mailbox-share" /> )} - + {notifications && (() => { const muted = notifications.isMailboxMuted(mailbox); return ( @@ -313,7 +337,7 @@ export function MailboxContextMenu({ icon={FolderPlus} label={t("new_folder")} onClick={() => handleAction(() => onCreateFolder?.(target.sharedAccountId))} - disabled={!onCreateFolder} + disabled={!onCreateFolder || offline} testId="mailbox-new-folder" /> {accountItems(target.sharedAccountId)} @@ -326,7 +350,7 @@ export function MailboxContextMenu({ icon={Upload} label={tImport("menu_import")} onClick={() => handleAction(() => onImportEmail?.(mailbox.id))} - disabled={!onImportEmail || !canAddItems} + disabled={!onImportEmail || !canAddItems || offline} /> {!exportOff && ( handleAction(onRefresh!)} - disabled={!onRefresh} + disabled={!onRefresh || offline} /> {/* Destructive entries last (R6). */} @@ -351,14 +375,14 @@ export function MailboxContextMenu({ icon={FolderX} label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")} onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))} - disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems} + disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline} destructive /> handleAction(() => onDeleteFolder?.(mailbox.id))} - disabled={!onDeleteFolder || !canDelete} + disabled={!onDeleteFolder || !canDelete || offline} destructive testId="mailbox-delete" /> 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); }} /> ; isValidDropTarget?: boolean; - isInvalidDropTarget?: boolean; onContextMenu?: (e: React.MouseEvent) => void; /** New mail here does not notify (#1169). */ muted?: boolean; @@ -399,7 +398,6 @@ function SidebarRow({ isCollapsed, dropHandlers, isValidDropTarget, - isInvalidDropTarget, onContextMenu, muted = false, testRole, @@ -469,8 +467,9 @@ function SidebarRow({ : isSelected ? "bg-accent text-accent-foreground font-semibold border-s-2 border-primary" : "hover:bg-muted/50 text-foreground border-s-2 border-transparent", - isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset", - isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50" + // Only a folder that takes the drop lights up. Elsewhere the pointer + // says no (dropEffect none), as in a native mail client. + isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset" )} > {!isCollapsed && ( @@ -756,7 +755,7 @@ function MailboxTreeItem({ : undefined; const { isDragging: globalDragging } = useDragDropContext(); - const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({ + const { dropHandlers, isValidDropTarget } = useMailboxDrop({ mailbox: node, accountId: dragAccountId ?? undefined, onSuccess: (count, mailboxName, restore, receipts) => { @@ -800,7 +799,6 @@ function MailboxTreeItem({ : undefined } isValidDropTarget={isValidDropTarget && !pickOnly} - isInvalidDropTarget={isInvalidDropTarget && !pickOnly} onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined} muted={muted} /> 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/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..05401ea67 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -136,9 +136,9 @@ 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, Star } from "@/components/icons"; import { useRegisterCommands } from "@/lib/command-registry"; -import { MoveToDialog } from "@/components/email/move-to-dialog"; +import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; import { ToolbarButton, ToolbarSearchAction } from "@/components/ui/toolbar"; @@ -2335,6 +2335,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { empty: () => { if (selectedMailbox) void handleEmptyFolderFromContextMenu(selectedMailbox); }, }; const folderMenuMarkRead = useCallback(() => folderMenuLatest.current.markRead(), []); + // "Mark folder as read" is greyed out with nothing unread in the open folder. + const selectedFolderHasUnread = mailboxes.find((m) => m.id === selectedMailbox)?.unreadEmails !== 0; const folderMenuMarkAllRead = useCallback(() => folderMenuLatest.current.markAllRead(), []); const folderMenuEmpty = useCallback(() => folderMenuLatest.current.empty(), []); useEffect(() => { @@ -4813,12 +4815,21 @@ 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: '!', 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) => { @@ -5537,7 +5548,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { onClick={() => { close(); void handleManualRefresh(); }} /> handleMarkFolderRead(selectedMailbox) : undefined} + onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined} onMarkAllFoldersRead={handleMarkAllFoldersRead} onEmptyFolder={folderIsEmptyable && selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined} onPicked={close} @@ -5577,7 +5588,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { totalCount={searchQuery || !isFilterEmpty(searchFilters) ? undefined : mailboxes.find((mb) => mb.id === selectedMailbox)?.totalEmails} onRefresh={handleManualRefresh} isRefreshing={isManualRefreshing} - onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined} + onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined} onMarkAllFoldersRead={handleMarkAllFoldersRead} onEmptyFolder={selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined} onCreateFolder={() => handleCreateFolderFromContextMenu()} @@ -5647,7 +5658,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { folderRole={conversationRole} onRefresh={handleManualRefresh} refreshDisabled={!client || isManualRefreshing} - onMarkFolderRead={selectedMailbox ? folderMenuMarkRead : undefined} + onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? folderMenuMarkRead : undefined} onMarkAllFoldersRead={folderMenuMarkAllRead} onEmptyFolder={folderIsEmptyable && selectedMailbox ? folderMenuEmpty : undefined} /> @@ -6113,7 +6124,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; @@ -6224,8 +6234,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { /> )} + {moveDialogEmail && setMoveDialogEmail(null)} diff --git a/components/plugins/plugin-actions.tsx b/components/plugins/plugin-actions.tsx index d86ac6145..533aee0db 100644 --- a/components/plugins/plugin-actions.tsx +++ b/components/plugins/plugin-actions.tsx @@ -160,7 +160,7 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo label={action.label} title={action.pluginName} destructive={action.danger} - shortcut={action.badge ? badgeText(action.badge) : undefined} + badge={action.badge ? badgeText(action.badge) : undefined} onClick={() => { void run(action); }} /> ))} diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx index 4f0ae12ad..0c68ce71a 100644 --- a/components/popout/popout-message-app.tsx +++ b/components/popout/popout-message-app.tsx @@ -178,7 +178,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email return ( <> {moved && ( -
+
{movedTo ? t("message_moved_to", { folder: localizeMailboxName(movedTo.role, movedTo.name, (key) => tSidebar(`mailboxes.${key}`)) }) : t("message_moved")} 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 && ( 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/components/ui/__tests__/context-menu-placement.test.tsx b/components/ui/__tests__/context-menu-placement.test.tsx new file mode 100644 index 000000000..d1345b377 --- /dev/null +++ b/components/ui/__tests__/context-menu-placement.test.tsx @@ -0,0 +1,181 @@ +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('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 } }); + // 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/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/components/ui/context-menu.tsx b/components/ui/context-menu.tsx index 8241bd4d0..d1a5b909d 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 { @@ -241,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(() => { @@ -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,34 +326,25 @@ export const ContextMenu = forwardRef( const clamp = () => { const rect = node.getBoundingClientRect(); - const vw = window.innerWidth; - const vh = window.innerHeight; + // 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, maxHeight } = placeMenu({ + point: { x: position.x, y: position.y }, + anchor, + size: { width: rect.width, height }, + viewport: { width: window.innerWidth, height: window.innerHeight }, + rtl: getComputedStyle(node).direction === "rtl", + margin: VIEWPORT_MARGIN, + gap: ANCHOR_GAP, + }); - 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); - - 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; @@ -540,7 +535,14 @@ export const ContextMenu = forwardRef( }} > {/* Taller than the window: scroll inside the menu. */} -
+
{children}
@@ -574,7 +576,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 +595,7 @@ export function ContextMenuItem({ disabled = false, destructive = false, shortcut, + badge, testId, title, hint, @@ -636,14 +642,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
; } @@ -781,7 +814,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}
diff --git a/hooks/__tests__/use-mailbox-drop-targets.test.tsx b/hooks/__tests__/use-mailbox-drop-targets.test.tsx new file mode 100644 index 000000000..184a2f510 --- /dev/null +++ b/hooks/__tests__/use-mailbox-drop-targets.test.tsx @@ -0,0 +1,37 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { DragEvent } from 'react'; +import { DragDropProvider, useDragDropContext } from '@/contexts/drag-drop-context'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useMailboxDrop } from '../use-mailbox-drop'; + +// A folder in the sidebar takes dragged mail where a Move list would offer +// it, and nowhere else: not the open folder, not one without write rights, +// not Drafts. + +const box = (id: string, extra: Partial = {}) => + ({ id, name: id, myRights: { mayAddItems: true }, ...extra }) as Mailbox; + +function dragOver(mailbox: Mailbox) { + const { result } = renderHook( + () => ({ drag: useDragDropContext(), drop: useMailboxDrop({ mailbox }) }), + { wrapper: DragDropProvider }, + ); + act(() => result.current.drag.startDrag([{ id: 'e1' } as Email], 'inbox')); + const event = { preventDefault() {}, stopPropagation() {}, dataTransfer: { dropEffect: 'move' } } as unknown as DragEvent; + act(() => result.current.drop.dropHandlers.onDragEnter(event)); + act(() => result.current.drop.dropHandlers.onDragOver(event)); + return { effect: event.dataTransfer.dropEffect, valid: result.current.drop.isValidDropTarget }; +} + +describe('dropping mail on a folder', () => { + it('is allowed on another writable folder', () => { + expect(dragOver(box('work'))).toEqual({ effect: 'move', valid: true }); + }); + + it('is refused on the open folder, a read-only one and Drafts', () => { + expect(dragOver(box('inbox'))).toEqual({ effect: 'none', valid: false }); + expect(dragOver(box('ro', { myRights: { mayAddItems: false } as Mailbox['myRights'] }))).toEqual({ effect: 'none', valid: false }); + expect(dragOver(box('drafts', { role: 'drafts' }))).toEqual({ effect: 'none', valid: false }); + }); +}); diff --git a/hooks/use-connection-state.ts b/hooks/use-connection-state.ts new file mode 100644 index 000000000..e870307e5 --- /dev/null +++ b/hooks/use-connection-state.ts @@ -0,0 +1,41 @@ +"use client"; + +import { useSyncExternalStore } from "react"; +import { serverKey, useOfflineStore } from "@/lib/offline/state"; +import { offlineAllowed } from "@/lib/offline/offline-mode"; +import { useAuthStore } from "@/stores/auth-store"; + +function subscribeOnline(callback: () => void): () => void { + window.addEventListener("online", callback); + window.addEventListener("offline", callback); + return () => { + window.removeEventListener("online", callback); + window.removeEventListener("offline", callback); + }; +} + +export interface ConnectionState { + /** No network, or the open account's mail server can't be reached. */ + offline: boolean; + /** + * Changes to messages still work: online, or the account keeps its mail on this + * device and the change waits for the connection. Folders, rules and sharing + * always need the server. + */ + canChangeMail: boolean; +} + +/** What the menus go by to grey out what can't work without a connection. */ +export function useConnectionState(): ConnectionState { + const account = useAuthStore((s) => s.activeAccountId); + const server = useAuthStore((s) => s.serverUrl); + const navigatorOffline = useSyncExternalStore( + subscribeOnline, + () => navigator.onLine === false, + () => false, + ); + const storeOffline = useOfflineStore((s) => s.browserOffline || (!!server && s.downServers.includes(serverKey(server)))); + const keepsMail = useOfflineStore((s) => !!account && s.enabled.includes(account)); + const offline = navigatorOffline || storeOffline; + return { offline, canChangeMail: !offline || (keepsMail && offlineAllowed(account)) }; +} 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/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts index c073550c0..a6534878b 100644 --- a/hooks/use-mailbox-drop.ts +++ b/hooks/use-mailbox-drop.ts @@ -83,6 +83,9 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess, // Virtual nodes (shared folder headers) cannot be drop targets if (mailbox.id.startsWith("shared-")) return false; + // Drafts holds what's being written: no Move list offers it either. + if (mailbox.role === "drafts") return false; + return true; }, [isDragging, mailbox, sourceMailboxId]); 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..9fc15ecba --- /dev/null +++ b/integration/tests/25-do-layout-fit.spec.ts @@ -0,0 +1,150 @@ +/** + * 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(); + // 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.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; + 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..067a7b8cc --- /dev/null +++ b/integration/tests/helpers/layout-fit.ts @@ -0,0 +1,98 @@ +/** + * 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. A field's + * placeholder counts too, unless it ends in an ellipsis. + */ +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 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(); + 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) }); + } + } + // 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; + }); +} diff --git a/lib/__tests__/move-menu.test.ts b/lib/__tests__/move-menu.test.ts new file mode 100644 index 000000000..1cfa8fa36 --- /dev/null +++ b/lib/__tests__/move-menu.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { buildMoveMenu, filterMoveNodes, type MoveMenuNode } from '../move-menu'; + +const writable = { mayAddItems: true } as Mailbox['myRights']; +const readOnly = { mayAddItems: false } as Mailbox['myRights']; + +function own(id: string, name: string, extra: Partial = {}): Mailbox { + return { + id, originalId: 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; +} + +function shared(account: string, id: string, name: string, extra: Partial = {}): Mailbox { + return own(`${account}:${id}`, name, { originalId: id, accountId: account, accountName: `${account.toLowerCase()}@example.org`, isShared: true, ...extra }); +} + +// What a real server hands over: system folders with a high sortOrder, the +// user's folders in creation order, nesting three deep. +const mailboxes = [ + own('zzz', 'Zettelkasten'), + own('trash', 'Trash', { role: 'trash', sortOrder: 50 }), + own('work', 'Work'), + own('inbox', 'Inbox', { role: 'inbox', sortOrder: 10 }), + own('y10', '10', { parentId: 'receipts' }), + own('y9', '9', { parentId: 'receipts' }), + own('junk', 'Junk Mail', { role: 'junk', sortOrder: 40 }), + own('receipts', 'Receipts'), + own('sent', 'Sent Items', { role: 'sent', sortOrder: 30 }), + own('drafts', 'Drafts', { role: 'drafts', sortOrder: 20 }), + own('archive', 'Archive', { role: 'archive', sortOrder: 35 }), + own('clients', 'Clients', { parentId: 'work' }), + own('acme', 'Acme', { parentId: 'clients' }), + own('apple', 'apple'), + own('ro', 'Read only', { myRights: readOnly }), + own('ro-child', 'Below read only', { parentId: 'ro' }), + shared('S', 'inbox', 'Inbox', { role: 'inbox' }), + shared('S', 'projects', 'Projects'), + shared('T', 'x', 'Nothing writable', { myRights: readOnly }), +]; + +const names = (nodes: MoveMenuNode[]) => nodes.map((n) => n.name); +const ids = (nodes: MoveMenuNode[]): string[] => nodes.flatMap((n) => [n.id, ...ids(n.children)]); + +describe('buildMoveMenu', () => { + it('lists system folders first in mail-client order, then the rest by name', () => { + const { nodes } = buildMoveMenu(mailboxes, { currentMailboxId: 'work', ownerAccountId: 'A', locale: 'en' }); + // Drafts can't take a moved message; the server's sortOrder doesn't count. + expect(names(nodes)).toEqual(['Inbox', 'Sent Items', 'Archive', 'Junk Mail', 'Trash', 'apple', 'Read only', 'Receipts', 'Work', 'Zettelkasten']); + }); + + it('keeps the nesting, sorts numbers as numbers, and keeps a read-only parent as a step', () => { + const { nodes } = buildMoveMenu(mailboxes, { ownerAccountId: 'A', locale: 'en' }); + const receipts = nodes.find((n) => n.id === 'receipts')!; + expect(names(receipts.children)).toEqual(['9', '10']); + const work = nodes.find((n) => n.id === 'work')!; + expect(work.children[0].children[0]).toMatchObject({ id: 'acme', path: ['Work', 'Clients'] }); + const ro = nodes.find((n) => n.id === 'ro')!; + expect(ro.enabled).toBe(false); + expect(ro.children.map((n) => n.id)).toEqual(['ro-child']); + }); + + it('shows the open folder greyed when the messages are all in it', () => { + const inInbox = { mailboxIds: { inbox: true } } as unknown as Email; + const { nodes, targetCount } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [inInbox] }); + expect(nodes[0]).toMatchObject({ id: 'inbox', enabled: false, current: true }); + expect(targetCount).toBe(13); + }); + + it('lets a search hit from another folder move into the open one (#1452)', () => { + const elsewhere = { mailboxIds: { work: true } } as unknown as Email; + const { nodes } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [elsewhere] }); + expect(nodes[0]).toMatchObject({ id: 'inbox', enabled: true, current: true }); + }); + + it('keeps only the message\'s own account at the top, the others under their names', () => { + const own = buildMoveMenu(mailboxes, { ownerAccountId: 'A' }); + expect(ids(own.nodes).some((id) => id.startsWith('S:'))).toBe(false); + // An account with nothing writable isn't offered. + expect(own.others.map((a) => [a.accountId, a.name])).toEqual([['S', 's@example.org']]); + + const inShared = buildMoveMenu(mailboxes, { ownerAccountId: 'S', currentMailboxId: 'S:inbox' }); + expect(inShared.accountId).toBe('S'); + expect(names(inShared.nodes)).toEqual(['Inbox', 'Projects']); + expect(inShared.others.map((a) => [a.accountId, a.name])).toEqual([['A', 'me@example.org']]); + }); + + it('offers the last folders moved to that are still there and can still be picked', () => { + const inInbox = { mailboxIds: { inbox: true } } as unknown as Email; + const recentIds = (account: string | undefined) => (account === 'A' ? ['gone', 'inbox', 'acme', 'S:projects', 'sent', 'trash'] : []); + const { recent } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [inInbox], recentIds }); + // A deleted folder and the open one drop out; three show. + expect(recent.map((n) => n.id)).toEqual(['acme', 'S:projects', 'sent']); + }); +}); + +describe('filterMoveNodes', () => { + it('keeps the matches with the folders above them, ignoring case and accents', () => { + const { nodes } = buildMoveMenu([...mailboxes, own('kueche', 'Küche')], { ownerAccountId: 'A', locale: 'de' }); + const label = (n: MoveMenuNode) => n.name; + expect(ids(filterMoveNodes(nodes, 'ACM', label))).toEqual(['work', 'clients', 'acme']); + expect(ids(filterMoveNodes(nodes, 'kuche', label))).toEqual(['kueche']); + expect(filterMoveNodes(nodes, ' ', label)).toBe(nodes); + }); +}); 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/__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/__tests__/window-controls-rows.test.ts b/lib/__tests__/window-controls-rows.test.ts new file mode 100644 index 000000000..b41cec67f --- /dev/null +++ b/lib/__tests__/window-controls-rows.test.ts @@ -0,0 +1,93 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { ROW_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, markWindowRows } from '@/lib/window-controls-overlay'; + +/** The controls take the right 138px of the window, 56px high. */ +const AREA = { x: 0, y: 0, width: 1024 - 138, height: 56 }; + +function box(el: HTMLElement, top: number, height: number, left = 0, right = 1024) { + el.getBoundingClientRect = () => ({ top, bottom: top + height, left, right, width: right - left, height, x: left, y: top, toJSON: () => ({}) }) as DOMRect; +} + +function row(parent: HTMLElement = document.body): HTMLElement { + const el = document.createElement('div'); + el.className = 'h-14'; + parent.appendChild(el); + return el; +} + +afterEach(() => { + document.body.innerHTML = ''; +}); + +describe('rows under the window\'s controls', () => { + it('a toolbar pushed down by a line above it still makes room for the controls', () => { + // "This message was moved", an offline note, an update banner: the toolbar starts at 37px. + const toolbar = row(); + box(toolbar, 37, 56); + markWindowRows(AREA); + expect(toolbar.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true); + // It isn't the window's top, so it isn't what the window is dragged by. + expect(toolbar.hasAttribute(ROW_ATTRIBUTE)).toBe(false); + }); + + it('a row at the top is still a row to drag by, with room at its end', () => { + const toolbar = row(); + box(toolbar, 0, 56); + markWindowRows(AREA); + expect(toolbar.hasAttribute(ROW_ATTRIBUTE)).toBe(true); + expect(toolbar.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true); + }); + + it('a row that starts below the controls is left alone', () => { + const below = row(); + box(below, 56, 56); + markWindowRows(AREA); + expect(below.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false); + }); + + it('a row of a list scrolled up under the header is left alone', () => { + const list = document.createElement('div'); + document.body.appendChild(list); + Object.defineProperty(list, 'scrollTop', { value: 120, configurable: true }); + const item = row(list); + box(item, 20, 56); + markWindowRows(AREA); + expect(item.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false); + }); + + it('a row that ends before the controls gets no room for them', () => { + const left = row(); + box(left, 30, 56, 0, 500); + markWindowRows(AREA); + expect(left.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false); + }); +}); + +const read = (file: string) => readFileSync(join(process.cwd(), file), 'utf8'); + +describe('a pane\'s close next to the window\'s', () => { + it('moves to the other end of the row, on the side away from the controls', () => { + const css = read('app/globals.css'); + expect(css).toMatch(/html\[data-window-controls\]:not\(\[dir="rtl"\]\) \[data-window-row-right\] > \[data-pane-close\]/); + expect(css).toMatch(/html\[data-window-controls\]\[dir="rtl"\] \[data-window-row-left\] > \[data-pane-close\]/); + }); + + it('is marked on every pane that can end up at the window\'s top right', () => { + for (const file of [ + 'components/email/email-composer.tsx', + 'components/layout/inline-app-view.tsx', + 'components/calendar/event-modal.tsx', + 'components/files/preview-shell.tsx', + 'components/calendar/task-modal.tsx', + 'components/contacts/contact-form.tsx', + ]) { + expect(read(file), file).toContain('data-pane-close'); + } + }); + + it('toasts that step out of the way stay below the controls', () => { + expect(read('app/globals.css')).toMatch(/html\[data-window-controls\] \[data-toast-container\]\[data-dodging\]\s*\{[^}]*--titlebar-row-height/); + }); +}); 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/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 93d42244d..9d842594f 100644 --- a/lib/desktop/languages.ts +++ b/lib/desktop/languages.ts @@ -129,6 +129,19 @@ 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'), + menuLockNow: 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/native-menu.ts b/lib/desktop/native-menu.ts index 5fac69c9e..c920c2fe6 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 { @@ -64,6 +123,19 @@ export function readMenu(root: HTMLElement): ReadMenu { continue; } if (child.dataset.submenuPanel !== undefined) continue; + if (child.dataset.nativeSubmenu !== undefined) { + // A folder with subfolders (a Move list): a native menu can't indent, + // so it's a submenu with the folder itself first, as Thunderbird has it. + const group: DesktopNativeMenuItem[] = []; + walk(child, group); + const [head, ...rest] = group; + if (head && rest.length && head.type !== 'separator' && head.label) { + into.push({ label: head.label, submenu: [head, { type: 'separator' }, ...rest] }); + } else { + into.push(...group); + } + continue; + } if (child.dataset.contextMenuHeader !== undefined) { const label = labelOf(child); if (label) into.push({ id: `h${next++}`, label, enabled: false }); @@ -100,10 +172,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 +256,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 56be248ae..c8d65d521 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[]; } @@ -95,9 +100,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 +151,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/lib/menu-placement.ts b/lib/menu-placement.ts new file mode 100644 index 000000000..146c6b391 --- /dev/null +++ b/lib/menu-placement.ts @@ -0,0 +1,92 @@ +/** + * 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; 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; + + 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; + 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; + 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) }; +} diff --git a/lib/move-menu.ts b/lib/move-menu.ts new file mode 100644 index 000000000..a2f320d1b --- /dev/null +++ b/lib/move-menu.ts @@ -0,0 +1,253 @@ +/** + * What every "Move to" list shows: the context menu (and the desktop app's + * native copy of it), the reader's Move button, the phone panel, the + * selection toolbar and the Move dialog. One model, so they can't drift. + * + * - The message's own account at the top. Other accounts a move can reach + * (shared ones) come after it, each under its own name. + * - System folders first, in the order mail clients use (Inbox, Drafts, + * Sent, Archive, Spam, Trash), then the user's folders by name, sorted + * for the interface language. The server's sortOrder doesn't count here: + * the sidebar honours it, a Move list is for finding a folder by name. + * - Nesting kept. The open folder stays in the list, disabled, when the + * messages are all in it (#1452: a search hit from elsewhere can move in). + * - The last few folders moved to, per account, as "Recent". + */ + +import type { Email, Mailbox } from "./jmap/types"; +import { emailInMailbox } from "./move-targets"; + +export const SYSTEM_FOLDER_ORDER = ["inbox", "drafts", "sent", "archive", "junk", "trash"] as const; + +/** Past this many folders to pick from, the list gets a filter field. */ +export const MOVE_FILTER_THRESHOLD = 12; + +/** How many recent folders show. */ +export const MOVE_RECENT_SHOWN = 3; + +export interface MoveMenuNode { + /** The mailbox id, as the store knows it. */ + id: string; + name: string; + role?: string | null; + /** Can be picked. */ + enabled: boolean; + /** The open folder, which the messages are already in. */ + current: boolean; + /** Names of the folders above it, outermost first. */ + path: string[]; + children: MoveMenuNode[]; +} + +export interface MoveMenuAccount { + accountId: string; + name: string; + nodes: MoveMenuNode[]; +} + +export interface MoveMenuModel { + /** The account the messages are in; recents are kept per account. */ + accountId?: string; + nodes: MoveMenuNode[]; + /** Other accounts the messages can be moved to, each a submenu. */ + others: MoveMenuAccount[]; + recent: MoveMenuNode[]; + /** Pickable folders in the own account. */ + targetCount: number; +} + +export interface MoveMenuOptions { + currentMailboxId?: string | null; + /** The account the messages are in (resolveMoveOwnerAccountId). */ + ownerAccountId?: string; + /** The messages being moved; without them the open folder counts as theirs. */ + emails?: Array>; + /** The folders last moved to in an account, most recent first (readRecentMoveTargets). */ + recentIds?: (accountId: string | undefined) => string[]; + /** The interface language, for sorting names. */ + locale?: string; +} + +function rolePosition(role: string | null | undefined): number { + if (!role) return SYSTEM_FOLDER_ORDER.length; + const normal = role === "spam" ? "junk" : role; + const at = (SYSTEM_FOLDER_ORDER as readonly string[]).indexOf(normal); + return at < 0 ? SYSTEM_FOLDER_ORDER.length : at; +} + +export function compareMoveNodes(collator: Intl.Collator) { + return (a: Pick, b: Pick): number => { + const byRole = rolePosition(a.role) - rolePosition(b.role); + if (byRole !== 0) return byRole; + return collator.compare(a.name, b.name); + }; +} + +function makeCollator(locale?: string): Intl.Collator { + try { + return new Intl.Collator(locale || undefined, { numeric: true, sensitivity: "base" }); + } catch { + return new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + } +} + +/** A target: may take mail, isn't Drafts, isn't a virtual header. */ +function isTarget(mailbox: Mailbox): boolean { + return mailbox.role !== "drafts" && !mailbox.id.startsWith("shared-") && !!mailbox.myRights?.mayAddItems; +} + +/** One account's folders as a sorted tree, with only the branches that lead somewhere. */ +function accountTree( + boxes: Mailbox[], + currentId: string | undefined, + hideCurrent: boolean, + collator: Intl.Collator, +): MoveMenuNode[] { + const ids = new Set(boxes.map((m) => m.id)); + const childrenOf = new Map(); + const roots: Mailbox[] = []; + for (const box of boxes) { + if (box.parentId && ids.has(box.parentId) && box.parentId !== box.id) { + const list = childrenOf.get(box.parentId) ?? []; + list.push(box); + childrenOf.set(box.parentId, list); + } else { + roots.push(box); + } + } + const compare = compareMoveNodes(collator); + const seen = new Set(); + const build = (list: Mailbox[], path: string[]): MoveMenuNode[] => { + const out: MoveMenuNode[] = []; + for (const box of list) { + if (seen.has(box.id)) continue; + seen.add(box.id); + const current = box.id === currentId; + const children = build(childrenOf.get(box.id) ?? [], [...path, box.name]); + const enabled = isTarget(box) && !(current && hideCurrent); + // A folder that can't take mail stays as a step to the ones below it; + // the open folder stays so the list reads like the sidebar. + if (!enabled && !children.length && !(current && isTarget(box))) continue; + out.push({ id: box.id, name: box.name, role: box.role ?? null, enabled, current, path, children }); + } + return out.sort(compare); + }; + return build(roots, []); +} + +function walk(nodes: MoveMenuNode[], visit: (node: MoveMenuNode) => void): void { + for (const node of nodes) { + visit(node); + walk(node.children, visit); + } +} + +export function countTargets(nodes: MoveMenuNode[]): number { + let count = 0; + walk(nodes, (node) => { + if (node.enabled) count++; + }); + return count; +} + +export function buildMoveMenu(mailboxes: Mailbox[], opts: MoveMenuOptions = {}): MoveMenuModel { + const collator = makeCollator(opts.locale); + const current = mailboxes.find((m) => m.id === opts.currentMailboxId); + const emails = opts.emails ?? []; + const hideCurrent = !!current && (emails.length === 0 || emails.every((e) => emailInMailbox(e, current))); + + // Folders by account: the login's own, then each shared account. + const groups = new Map(); + const OWN = "__own_account__"; + for (const box of mailboxes) { + if (box.id.startsWith("shared-")) continue; + const key = box.isShared ? box.accountId ?? "unknown" : OWN; + let group = groups.get(key); + if (!group) { + group = { name: box.accountName || box.accountId || "", shared: !!box.isShared, boxes: [] }; + groups.set(key, group); + } + if (!group.name && box.accountName) group.name = box.accountName; + group.boxes.push(box); + } + const ownAccountId = groups.get(OWN)?.boxes.find((m) => m.accountId)?.accountId; + const ownerKey = opts.ownerAccountId && groups.get(opts.ownerAccountId)?.shared ? opts.ownerAccountId : OWN; + + const treeOf = (key: string) => { + const group = groups.get(key); + return group ? accountTree(group.boxes, current?.id, hideCurrent, collator) : []; + }; + const nodes = treeOf(ownerKey); + const others: MoveMenuAccount[] = []; + for (const [key, group] of groups) { + if (key === ownerKey) continue; + const tree = treeOf(key); + if (countTargets(tree) === 0) continue; + others.push({ accountId: key === OWN ? ownAccountId ?? "" : key, name: group.name, nodes: tree }); + } + // The login's own account first among the others, then shared ones by name. + others.sort((a, b) => { + const aOwn = a.accountId === ownAccountId ? 0 : 1; + const bOwn = b.accountId === ownAccountId ? 0 : 1; + return aOwn - bOwn || collator.compare(a.name, b.name); + }); + + const accountId = ownerKey === OWN ? ownAccountId : ownerKey; + const byId = new Map(); + walk(nodes, (node) => byId.set(node.id, node)); + for (const other of others) walk(other.nodes, (node) => byId.set(node.id, node)); + const recent: MoveMenuNode[] = []; + for (const id of opts.recentIds?.(accountId) ?? []) { + const node = byId.get(id); + if (node?.enabled && !recent.includes(node)) recent.push(node); + if (recent.length >= MOVE_RECENT_SHOWN) break; + } + + return { accountId, nodes, others, recent, targetCount: countTargets(nodes) }; +} + +/** + * The nodes whose name has the query in it, with the folders above them kept + * so a match still shows where it lives. Case and accents don't count. + */ +export function filterMoveNodes(nodes: MoveMenuNode[], query: string, labelOf: (node: MoveMenuNode) => string, locale?: string): MoveMenuNode[] { + const q = fold(query.trim(), locale); + if (!q) return nodes; + const keep = (list: MoveMenuNode[]): MoveMenuNode[] => + list.reduce((acc, node) => { + const children = keep(node.children); + if (fold(labelOf(node), locale).includes(q) || children.length) acc.push({ ...node, children }); + return acc; + }, []); + return keep(nodes); +} + +function fold(text: string, locale?: string): string { + let lower: string; + try { + lower = text.toLocaleLowerCase(locale || undefined); + } catch { + lower = text.toLowerCase(); + } + return lower.normalize("NFD").replace(/\p{M}/gu, ""); +} + +/** The first folder that can be picked, in list order. */ +export function firstEnabled(nodes: MoveMenuNode[]): MoveMenuNode | undefined { + for (const node of nodes) { + if (node.enabled) return node; + const below = firstEnabled(node.children); + if (below) return below; + } + return undefined; +} + +/** + * Any folder tree in Move-list order (system folders first, then by name), + * for pickers that build their own tree, such as the Rules menu's. + */ +export function sortFolderTree(nodes: T[], locale?: string): T[] { + const compare = compareMoveNodes(makeCollator(locale)); + const sort = (list: T[]): T[] => [...list].map((node) => ({ ...node, children: sort(node.children) })).sort(compare); + return sort(nodes); +} diff --git a/lib/move-recent.ts b/lib/move-recent.ts new file mode 100644 index 000000000..afcf3796a --- /dev/null +++ b/lib/move-recent.ts @@ -0,0 +1,40 @@ +/** + * The folders messages were last moved to, per account, most recent first. + * Kept on this device only; the Move lists show the few that still exist. + */ + +const KEY = "bulwark.moveRecent.v1"; +const KEPT = 8; + +type Store = Record; + +function read(): Store { + try { + const raw = typeof localStorage === "undefined" ? null : localStorage.getItem(KEY); + const parsed: unknown = raw ? JSON.parse(raw) : null; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + const out: Store = {}; + for (const [account, ids] of Object.entries(parsed as Record)) { + if (Array.isArray(ids)) out[account] = ids.filter((id): id is string => typeof id === "string").slice(0, KEPT); + } + return out; + } catch { + return {}; + } +} + +export function readRecentMoveTargets(accountId: string | undefined): string[] { + if (!accountId) return []; + return read()[accountId] ?? []; +} + +export function noteMoveTarget(accountId: string | undefined, mailboxId: string): void { + if (!accountId || !mailboxId) return; + const store = read(); + store[accountId] = [mailboxId, ...(store[accountId] ?? []).filter((id) => id !== mailboxId)].slice(0, KEPT); + try { + localStorage.setItem(KEY, JSON.stringify(store)); + } catch { + // Storage full or off: the list just has no recents. + } +} 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); +} 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; +} diff --git a/lib/window-controls-overlay.ts b/lib/window-controls-overlay.ts index f69cdca13..0e3989135 100644 --- a/lib/window-controls-overlay.ts +++ b/lib/window-controls-overlay.ts @@ -114,23 +114,37 @@ export function markWindowRows(area: TitlebarArea, doc: Document = document): HT for (const el of Array.from(doc.querySelectorAll(ROW_CANDIDATES))) { const rect = el.getBoundingClientRect(); const explicit = el.hasAttribute('data-window-top-row'); - const fits = rect.top <= 1 && rect.height > 0 && rect.width > 0 && (explicit || rect.height <= MAX_ROW_HEIGHT); + const shaped = rect.height > 0 && rect.width > 0 && (explicit || rect.height <= MAX_ROW_HEIGHT); + const atTop = shaped && rect.top <= 1; + // A row a line above pushed down (a note that the message moved, that the app is + // offline, an update) still starts beside the controls and needs the same room. + // It isn't what the window is dragged by, though: that's the top. A list's row + // scrolled up under its header isn't one of these. + const underControls = shaped && !atTop && rect.top < area.height && !inScrolledList(el); // The outermost box of a row: an inner one sits inside its padding already. - const nested = fits && [...seen].some((outer) => outer.contains(el)); - if (!fits || nested) { + const nested = (atTop || underControls) && [...seen].some((outer) => outer.contains(el)); + if ((!atTop && !underControls) || nested) { el.removeAttribute(ROW_ATTRIBUTE); el.removeAttribute(ROW_RIGHT_ATTRIBUTE); el.removeAttribute(ROW_LEFT_ATTRIBUTE); continue; } seen.add(el); - el.setAttribute(ROW_ATTRIBUTE, ''); + el.toggleAttribute(ROW_ATTRIBUTE, atTop); el.toggleAttribute(ROW_RIGHT_ATTRIBUTE, right > 0 && rect.right > viewportWidth - right); el.toggleAttribute(ROW_LEFT_ATTRIBUTE, left > 0 && rect.left < left); } return [...seen]; } +/** Inside something scrolled away from its top: a row passing under, not a toolbar. */ +function inScrolledList(el: HTMLElement): boolean { + for (let at = el.parentElement; at && at !== el.ownerDocument.body; at = at.parentElement) { + if (at.scrollTop > 0) return true; + } + return false; +} + /** * Keeps those marks right as the page changes, before the next paint. Mutation and * resize callbacks both run ahead of painting, so a row never shows without its room. diff --git a/locales/ar/common.json b/locales/ar/common.json index cb8461b41..bc50f43e8 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "البحث عن رسائل من {name}" }, + "menuMove": { + "filter_placeholder": "ابحث عن مجلد", + "recent": "الأخيرة", + "no_match": "لا يوجد مجلد مطابق", + "choose_folder": "اختيار مجلد…", + "current_folder": "موجودة بالفعل في {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "تمييز المجلد كمقروء", "mark_folder_tree_read": "تمييز المجلد والمجلدات الفرعية كمقروءة", @@ -6463,6 +6470,19 @@ "group_account": "الحساب", "switch_to": "التبديل إلى {account}" }, + "menuAudit": { + "offline_header": "أنت غير متصل. بعض الإجراءات تحتاج إلى اتصال.", + "files_new_folder": "مجلد جديد…", + "files_new_file": "ملف جديد…", + "files_new_text_file": "ملف نصي جديد…", + "files_upload_files": "رفع الملفات…", + "files_upload_folder": "رفع مجلد…", + "files_share": "مشاركة…", + "files_share_button": "مشاركة", + "contacts_add_to_group": "إضافة إلى مجموعة…", + "contacts_bulk_add_to_group": "إضافة إلى مجموعة…", + "schedule_send": "جدولة الإرسال…" + }, "notification_center": { "title": "الإشعارات", "mark_all_read": "تعليم الكل كمقروء", @@ -6818,6 +6838,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 بعد فترة من عدم الاستخدام، في كل نافذة وعلامة تبويب. أثناء القفل، لا يستطيع التطبيق قراءة البريد المخزن على هذا الجهاز حتى يدخل أحدهم رمز PIN.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 7c1427cb2..70576d362 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Cerca correus de {name}" }, + "menuMove": { + "filter_placeholder": "Cerca una carpeta", + "recent": "Recents", + "no_match": "Cap carpeta coincideix", + "choose_folder": "Tria una carpeta…", + "current_folder": "Ja és a {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Marca la carpeta com a llegida", "mark_folder_tree_read": "Marca la carpeta i les subcarpetes com a llegides", @@ -6463,6 +6470,19 @@ "group_account": "Compte", "switch_to": "Canvia a {account}" }, + "menuAudit": { + "offline_header": "No teniu connexió. Algunes accions la necessiten.", + "files_new_folder": "Carpeta nova…", + "files_new_file": "Fitxer nou…", + "files_new_text_file": "Fitxer de text nou…", + "files_upload_files": "Puja fitxers…", + "files_upload_folder": "Puja una carpeta…", + "files_share": "Comparteix…", + "files_share_button": "Comparteix", + "contacts_add_to_group": "Afegeix al grup…", + "contacts_bulk_add_to_group": "Afegeix al grup…", + "schedule_send": "Programa l'enviament…" + }, "notification_center": { "title": "Notificacions", "mark_all_read": "Marca-ho tot com a llegit", @@ -6818,6 +6838,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, l'aplicació no pot llegir el correu desat en aquest dispositiu fins que algú introdueixi el PIN.", diff --git a/locales/cs/common.json b/locales/cs/common.json index dd729778a..082118a7b 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Najít e-maily od {name}" }, + "menuMove": { + "filter_placeholder": "Najít složku", + "recent": "Nedávné", + "no_match": "Žádná složka neodpovídá", + "choose_folder": "Vybrat složku…", + "current_folder": "Už je ve složce {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Označit složku jako přečtenou", "mark_folder_tree_read": "Označit složku a podsložky jako přečtené", @@ -6463,6 +6470,19 @@ "group_account": "Účet", "switch_to": "Přepnout na {account}" }, + "menuAudit": { + "offline_header": "Jste offline. Některé akce potřebují připojení.", + "files_new_folder": "Nová složka…", + "files_new_file": "Nový soubor…", + "files_new_text_file": "Nový textový soubor…", + "files_upload_files": "Nahrát soubory…", + "files_upload_folder": "Nahrát složku…", + "files_share": "Sdílet…", + "files_share_button": "Sdílet", + "contacts_add_to_group": "Přidat do skupiny…", + "contacts_bulk_add_to_group": "Přidat do skupiny…", + "schedule_send": "Naplánovat odeslání…" + }, "notification_center": { "title": "Oznámení", "mark_all_read": "Označit vše jako přečtené", @@ -6818,6 +6838,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 době nečinnosti žádá o PIN v každém okně a na každé kartě. Dokud je zamčeno, aplikace nemůže číst poštu uloženou v tomto zařízení, dokud někdo nezadá PIN.", diff --git a/locales/da/common.json b/locales/da/common.json index f1824294f..ac342796d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Find e-mails fra {name}" }, + "menuMove": { + "filter_placeholder": "Find en mappe", + "recent": "Seneste", + "no_match": "Ingen mapper passer", + "choose_folder": "Vælg mappe…", + "current_folder": "Allerede i {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Markér mappe som læst", "mark_folder_tree_read": "Markér mappe & undermapper som læst", @@ -6463,6 +6470,19 @@ "group_account": "Konto", "switch_to": "Skift til {account}" }, + "menuAudit": { + "offline_header": "Du er offline. Nogle handlinger kræver forbindelse.", + "files_new_folder": "Ny mappe…", + "files_new_file": "Ny fil…", + "files_new_text_file": "Ny tekstfil…", + "files_upload_files": "Upload filer…", + "files_upload_folder": "Upload mappe…", + "files_share": "Del…", + "files_share_button": "Del", + "contacts_add_to_group": "Tilføj til gruppe…", + "contacts_bulk_add_to_group": "Tilføj til gruppe…", + "schedule_send": "Planlæg afsendelse…" + }, "notification_center": { "title": "Notifikationer", "mark_all_read": "Markér alle som læst", @@ -6818,6 +6838,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 et stykke tid uden brug, i alle vinduer og faner. Mens den er låst, kan appen ikke læse den mail, der er gemt på denne enhed, før nogen indtaster pinkoden.", diff --git a/locales/de/common.json b/locales/de/common.json index 5d0908194..25dc6cbdc 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "E-Mails von {name} finden" }, + "menuMove": { + "filter_placeholder": "Ordner suchen", + "recent": "Zuletzt verwendet", + "no_match": "Kein Ordner gefunden", + "choose_folder": "Ordner auswählen…", + "current_folder": "Bereits in {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Ordner als gelesen markieren", "mark_folder_tree_read": "Ordner & Unterordner als gelesen markieren", @@ -6463,6 +6470,19 @@ "group_account": "Konto", "switch_to": "Zu {account} wechseln" }, + "menuAudit": { + "offline_header": "Sie sind offline. Manche Aktionen brauchen eine Verbindung.", + "files_new_folder": "Neuer Ordner…", + "files_new_file": "Neue Datei…", + "files_new_text_file": "Neue Textdatei…", + "files_upload_files": "Dateien hochladen…", + "files_upload_folder": "Ordner hochladen…", + "files_share": "Teilen…", + "files_share_button": "Teilen", + "contacts_add_to_group": "Zur Gruppe hinzufügen…", + "contacts_bulk_add_to_group": "Zur Gruppe hinzufügen…", + "schedule_send": "Senden planen…" + }, "notification_center": { "title": "Benachrichtigungen", "mark_all_read": "Alle als gelesen markieren", @@ -6818,6 +6838,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, kann die App die auf diesem Gerät gespeicherte Mail nicht lesen, bis jemand die PIN eingibt.", diff --git a/locales/en/common.json b/locales/en/common.json index 45ab28091..4215b02a4 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Find emails from {name}" }, + "menuMove": { + "filter_placeholder": "Find a folder", + "recent": "Recent", + "no_match": "No folder matches", + "choose_folder": "Choose folder…", + "current_folder": "Already in {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Mark folder as read", "mark_folder_tree_read": "Mark folder & subfolders as read", @@ -6463,6 +6470,19 @@ "group_account": "Account", "switch_to": "Switch to {account}" }, + "menuAudit": { + "offline_header": "You're offline. Some actions need a connection.", + "files_new_folder": "New folder…", + "files_new_file": "New file…", + "files_new_text_file": "New text file…", + "files_upload_files": "Upload files…", + "files_upload_folder": "Upload folder…", + "files_share": "Share…", + "files_share_button": "Share", + "contacts_add_to_group": "Add to group…", + "contacts_bulk_add_to_group": "Add to group…", + "schedule_send": "Schedule send…" + }, "notification_center": { "title": "Notifications", "mark_all_read": "Mark all read", @@ -6818,6 +6838,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, the app can't read the mail stored on this device until someone enters the PIN.", diff --git a/locales/es/common.json b/locales/es/common.json index 79bcee4b3..6cf304082 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Buscar correos de {name}" }, + "menuMove": { + "filter_placeholder": "Buscar una carpeta", + "recent": "Recientes", + "no_match": "Ninguna carpeta coincide", + "choose_folder": "Elegir carpeta…", + "current_folder": "Ya está en {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Marcar carpeta como leída", "mark_folder_tree_read": "Marcar carpeta y subcarpetas como leídas", @@ -6463,6 +6470,19 @@ "group_account": "Cuenta", "switch_to": "Cambiar a {account}" }, + "menuAudit": { + "offline_header": "No tiene conexión. Algunas acciones la necesitan.", + "files_new_folder": "Nueva carpeta…", + "files_new_file": "Nuevo archivo…", + "files_new_text_file": "Nuevo archivo de texto…", + "files_upload_files": "Subir archivos…", + "files_upload_folder": "Subir carpeta…", + "files_share": "Compartir…", + "files_share_button": "Compartir", + "contacts_add_to_group": "Añadir al grupo…", + "contacts_bulk_add_to_group": "Agregar al grupo…", + "schedule_send": "Programar envío…" + }, "notification_center": { "title": "Notificaciones", "mark_all_read": "Marcar todo como leído", @@ -6818,6 +6838,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, la aplicación no puede leer el correo guardado en este dispositivo hasta que alguien introduzca el PIN.", diff --git a/locales/et/common.json b/locales/et/common.json index a539ed5bd..b06789809 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Leia kirjad saatjalt {name}" }, + "menuMove": { + "filter_placeholder": "Otsi kausta", + "recent": "Viimati kasutatud", + "no_match": "Ükski kaust ei sobi", + "choose_folder": "Vali kaust…", + "current_folder": "Juba kaustas {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Märgi kaust loetuks", "mark_folder_tree_read": "Märgi kaust ja alamkaustad loetuks", @@ -6463,6 +6470,19 @@ "group_account": "Konto", "switch_to": "Lülitu kontole {account}" }, + "menuAudit": { + "offline_header": "Olete võrguühenduseta. Mõni toiming vajab ühendust.", + "files_new_folder": "Uus kaust…", + "files_new_file": "Uus fail…", + "files_new_text_file": "Uus tekstifail…", + "files_upload_files": "Laadi failid üles…", + "files_upload_folder": "Laadi kaust üles…", + "files_share": "Jaga…", + "files_share_button": "Jaga", + "contacts_add_to_group": "Lisa gruppi…", + "contacts_bulk_add_to_group": "Lisa gruppi…", + "schedule_send": "Ajasta saatmine…" + }, "notification_center": { "title": "Märguanded", "mark_all_read": "Märgi kõik loetuks", @@ -6818,6 +6838,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 kasutuseta aega PIN-koodi igas aknas ja vahekaardil. Lukus olles ei saa rakendus selles seadmes hoitavat kirja lugeda enne, kui keegi PIN-koodi sisestab.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 99a45e88d..e3c39ad2a 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "یافتن ایمیل‌های {name}" }, + "menuMove": { + "filter_placeholder": "یافتن پوشه", + "recent": "اخیر", + "no_match": "هیچ پوشه‌ای مطابق نیست", + "choose_folder": "انتخاب پوشه…", + "current_folder": "از قبل در {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "علامت‌گذاری پوشه خوانده شده", "mark_folder_tree_read": "علامت‌گذاری پوشه و زیرپوشه‌ها", @@ -6463,6 +6470,19 @@ "group_account": "حساب", "switch_to": "رفتن به {account}" }, + "menuAudit": { + "offline_header": "آفلاین هستید. برخی کارها به اتصال نیاز دارند.", + "files_new_folder": "پوشه جدید…", + "files_new_file": "فایل جدید…", + "files_new_text_file": "فایل متنی جدید…", + "files_upload_files": "بارگذاری فایل‌ها…", + "files_upload_folder": "بارگذاری پوشه…", + "files_share": "اشتراک‌گذاری…", + "files_share_button": "اشتراک‌گذاری", + "contacts_add_to_group": "افزودن به گروه…", + "contacts_bulk_add_to_group": "افزودن به گروه…", + "schedule_send": "زمان‌بندی ارسال…" + }, "notification_center": { "title": "اعلان‌ها", "mark_all_read": "علامت‌گذاری همه به‌عنوان خوانده‌شده", @@ -6818,6 +6838,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 bc327b698..cc650dca0 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Rechercher les e-mails de {name}" }, + "menuMove": { + "filter_placeholder": "Rechercher un dossier", + "recent": "Récents", + "no_match": "Aucun dossier ne correspond", + "choose_folder": "Choisir un dossier…", + "current_folder": "Déjà dans {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Marquer le dossier comme lu", "mark_folder_tree_read": "Marquer le dossier et les sous-dossiers comme lus", @@ -6463,6 +6470,19 @@ "group_account": "Compte", "switch_to": "Passer à {account}" }, + "menuAudit": { + "offline_header": "Vous êtes hors ligne. Certaines actions ont besoin d’une connexion.", + "files_new_folder": "Nouveau dossier…", + "files_new_file": "Nouveau fichier…", + "files_new_text_file": "Nouveau fichier texte…", + "files_upload_files": "Téléverser des fichiers…", + "files_upload_folder": "Téléverser un dossier…", + "files_share": "Partager…", + "files_share_button": "Partager", + "contacts_add_to_group": "Ajouter au groupe…", + "contacts_bulk_add_to_group": "Ajouter au groupe…", + "schedule_send": "Planifier l’envoi…" + }, "notification_center": { "title": "Notifications", "mark_all_read": "Tout marquer comme lu", @@ -6818,6 +6838,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 c'est verrouillé, l'application ne peut pas lire les messages stockés sur cet appareil avant la saisie du code PIN.", diff --git a/locales/he/common.json b/locales/he/common.json index f42c9cb7d..97bfa5531 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -3592,6 +3592,13 @@ }, "find_emails_from": "חיפוש הודעות מ{name}" }, + "menuMove": { + "filter_placeholder": "חיפוש תיקייה", + "recent": "אחרונות", + "no_match": "אין תיקייה תואמת", + "choose_folder": "בחירת תיקייה…", + "current_folder": "כבר בתיקייה {folder}" + }, "shortcuts": { "title": "קיצורי מקלדת", "tip": "לחץ על? בכל עת כדי להראות את העזרה הזו", @@ -6464,6 +6471,19 @@ "group_account": "חשבון", "switch_to": "מעבר אל {account}" }, + "menuAudit": { + "offline_header": "אין חיבור. חלק מהפעולות צריכות חיבור.", + "files_new_folder": "תיקיה חדשה…", + "files_new_file": "קובץ חדש…", + "files_new_text_file": "קובץ טקסט חדש…", + "files_upload_files": "העלה קבצים…", + "files_upload_folder": "העלה תיקייה…", + "files_share": "שתף…", + "files_share_button": "שתף", + "contacts_add_to_group": "הוסף לקבוצה…", + "contacts_bulk_add_to_group": "הוסף לקבוצה…", + "schedule_send": "תזמן שליחה…" + }, "notification_center": { "title": "התראות", "mark_all_read": "סימון הכול כנקרא", @@ -6819,6 +6839,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 אחרי זמן ללא שימוש, בכל חלון ולשונית. בזמן הנעילה האפליקציה לא יכולה לקרוא את הדואר השמור במכשיר הזה עד שמישהו מזין את ה-PIN.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 530b29276..e61f445d9 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "{name} e-mailjeinek keresése" }, + "menuMove": { + "filter_placeholder": "Mappa keresése", + "recent": "Legutóbbiak", + "no_match": "Nincs ilyen mappa", + "choose_folder": "Mappa kiválasztása…", + "current_folder": "Már itt van: {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Mappa olvasottnak jelölése", "mark_folder_tree_read": "Mappa és almappák olvasottnak jelölése", @@ -6463,6 +6470,19 @@ "group_account": "Fiók", "switch_to": "Váltás erre: {account}" }, + "menuAudit": { + "offline_header": "Nincs kapcsolat. Néhány művelethez kapcsolat kell.", + "files_new_folder": "Új mappa…", + "files_new_file": "Új fájl…", + "files_new_text_file": "Új szöveges fájl…", + "files_upload_files": "Fájlok feltöltése…", + "files_upload_folder": "Mappa feltöltése…", + "files_share": "Megosztás…", + "files_share_button": "Megosztás", + "contacts_add_to_group": "Hozzáadás a csoporthoz…", + "contacts_bulk_add_to_group": "Hozzáadás a csoporthoz…", + "schedule_send": "Késleltetett küldés…" + }, "notification_center": { "title": "Értesítések", "mark_all_read": "Összes megjelölése olvasottként", @@ -6818,6 +6838,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": "Egy idő után minden ablakban és lapon PIN-kódot kér. Zárolt állapotban az alkalmazás nem tudja olvasni az eszközön tárolt leveleket, amíg valaki meg nem adja a PIN-kódot.", diff --git a/locales/it/common.json b/locales/it/common.json index 7179c8842..48880cdc2 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Trova email da {name}" }, + "menuMove": { + "filter_placeholder": "Cerca una cartella", + "recent": "Recenti", + "no_match": "Nessuna cartella corrisponde", + "choose_folder": "Scegli cartella…", + "current_folder": "Già in {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Segna cartella come letta", "mark_folder_tree_read": "Segna cartella e sottocartelle come lette", @@ -6463,6 +6470,19 @@ "group_account": "Account", "switch_to": "Passa a {account}" }, + "menuAudit": { + "offline_header": "Sei offline. Alcune azioni richiedono una connessione.", + "files_new_folder": "Nuova cartella…", + "files_new_file": "Nuovo file…", + "files_new_text_file": "Nuovo file di testo…", + "files_upload_files": "Carica file…", + "files_upload_folder": "Carica cartella…", + "files_share": "Condividi…", + "files_share_button": "Condividi", + "contacts_add_to_group": "Aggiungi al gruppo…", + "contacts_bulk_add_to_group": "Aggiungi al gruppo…", + "schedule_send": "Programma invio…" + }, "notification_center": { "title": "Notifiche", "mark_all_read": "Segna tutte come lette", @@ -6818,6 +6838,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 di inattività, in ogni finestra e scheda. Mentre è bloccata, l'app non può leggere la posta salvata su questo dispositivo finché qualcuno non inserisce il PIN.", diff --git a/locales/ja/common.json b/locales/ja/common.json index a37bff51d..98fa6f9a5 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "{name} からのメールを検索" }, + "menuMove": { + "filter_placeholder": "フォルダーを検索", + "recent": "最近使用", + "no_match": "一致するフォルダーはありません", + "choose_folder": "フォルダーを選択…", + "current_folder": "すでに {folder} にあります" + }, "mailbox_context_menu": { "mark_folder_read": "フォルダーを既読にする", "mark_folder_tree_read": "フォルダーとサブフォルダーを既読にする", @@ -6463,6 +6470,19 @@ "group_account": "アカウント", "switch_to": "{account} に切り替え" }, + "menuAudit": { + "offline_header": "オフラインです。一部の操作には接続が必要です。", + "files_new_folder": "新しいフォルダー…", + "files_new_file": "新規ファイル…", + "files_new_text_file": "新規テキストファイル…", + "files_upload_files": "ファイルをアップロード…", + "files_upload_folder": "フォルダをアップロード…", + "files_share": "共有…", + "files_share_button": "共有", + "contacts_add_to_group": "グループに追加…", + "contacts_bulk_add_to_group": "グループに追加…", + "schedule_send": "送信を予約…" + }, "notification_center": { "title": "通知", "mark_all_read": "すべて既読にする", @@ -6818,6 +6838,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 を求めます。ロック中は、誰かが PIN を入力するまで、アプリはこのデバイスに保存されたメールを読めません。", diff --git a/locales/ko/common.json b/locales/ko/common.json index 446b50867..649772caf 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "{name}님이 보낸 이메일 찾기" }, + "menuMove": { + "filter_placeholder": "폴더 찾기", + "recent": "최근", + "no_match": "일치하는 폴더 없음", + "choose_folder": "폴더 선택…", + "current_folder": "이미 {folder}에 있음" + }, "mailbox_context_menu": { "mark_folder_read": "폴더를 읽음으로 표시", "mark_folder_tree_read": "폴더 및 하위 폴더를 읽음으로 표시", @@ -6463,6 +6470,19 @@ "group_account": "계정", "switch_to": "{account}(으)로 전환" }, + "menuAudit": { + "offline_header": "오프라인 상태입니다. 일부 작업에는 연결이 필요합니다.", + "files_new_folder": "새 폴더…", + "files_new_file": "새 파일…", + "files_new_text_file": "새 텍스트 파일…", + "files_upload_files": "파일 업로드…", + "files_upload_folder": "폴더 업로드…", + "files_share": "공유…", + "files_share_button": "공유", + "contacts_add_to_group": "그룹에 추가…", + "contacts_bulk_add_to_group": "그룹에 추가…", + "schedule_send": "보내기 예약…" + }, "notification_center": { "title": "알림", "mark_all_read": "모두 읽음으로 표시", @@ -6818,6 +6838,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을 요청합니다. 잠겨 있는 동안 앱은 누군가 PIN을 입력할 때까지 이 기기에 저장된 메일을 읽을 수 없습니다.", diff --git a/locales/lv/common.json b/locales/lv/common.json index bace3548b..f97fce61e 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Atrast e-pastus no {name}" }, + "menuMove": { + "filter_placeholder": "Meklēt mapi", + "recent": "Nesen izmantotās", + "no_match": "Neviena mape neatbilst", + "choose_folder": "Izvēlēties mapi…", + "current_folder": "Jau mapē {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Atzīmēt mapi kā lasītu", "mark_folder_tree_read": "Atzīmēt mapi un apakšmapes kā lasītas", @@ -6463,6 +6470,19 @@ "group_account": "Konts", "switch_to": "Pārslēgties uz {account}" }, + "menuAudit": { + "offline_header": "Jūs esat bezsaistē. Dažām darbībām vajag savienojumu.", + "files_new_folder": "Jauna mape…", + "files_new_file": "Jauns fails…", + "files_new_text_file": "Jauns teksta fails…", + "files_upload_files": "Augšupielādēt failus…", + "files_upload_folder": "Augšupielādēt mapi…", + "files_share": "Kopīgot…", + "files_share_button": "Kopīgot", + "contacts_add_to_group": "Pievienot grupai…", + "contacts_bulk_add_to_group": "Pievienot grupai…", + "schedule_send": "Ieplānot sūtīšanu…" + }, "notification_center": { "title": "Paziņojumi", "mark_all_read": "Atzīmēt visus kā lasītus", @@ -6818,6 +6838,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 neizmantošanas laika prasa PIN katrā logā un cilnē. Kamēr ir bloķēts, lietotne nevar lasīt šajā ierīcē glabāto pastu, līdz kāds ievada PIN.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 9aaaf5343..dee9e8924 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "{name}-с ирсэн имэйлүүдийг хайх" }, + "menuMove": { + "filter_placeholder": "Хавтас хайх", + "recent": "Сүүлд ашигласан", + "no_match": "Тохирох хавтас алга", + "choose_folder": "Хавтас сонгох…", + "current_folder": "Аль хэдийн {folder}-д байна" + }, "mailbox_context_menu": { "mark_folder_read": "Хавтсыг уншсан гэж тэмдэглэх", "mark_folder_tree_read": "Хавтас болон дэд хавтаснуудыг уншсан гэж тэмдэглэх", @@ -6463,6 +6470,19 @@ "group_account": "Бүртгэл", "switch_to": "{account} руу шилжих" }, + "menuAudit": { + "offline_header": "Та офлайн байна. Зарим үйлдэлд холболт хэрэгтэй.", + "files_new_folder": "Шинэ хавтас…", + "files_new_file": "Шинэ файл…", + "files_new_text_file": "Шинэ текст файл…", + "files_upload_files": "Файл байршуулах…", + "files_upload_folder": "Хавтас байршуулах…", + "files_share": "Хуваалцах…", + "files_share_button": "Хуваалцах", + "contacts_add_to_group": "Бүлэгт нэмэх…", + "contacts_bulk_add_to_group": "Бүлэгт нэмэх…", + "schedule_send": "Хуваарь илгээх…" + }, "notification_center": { "title": "Мэдэгдэл", "mark_all_read": "Бүгдийг уншсан болгох", @@ -6818,6 +6838,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 266b6ebed..8cc073b1c 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Finn e-poster fra {name}" }, + "menuMove": { + "filter_placeholder": "Finn en mappe", + "recent": "Nylig brukt", + "no_match": "Ingen mapper passer", + "choose_folder": "Velg mappe…", + "current_folder": "Allerede i {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Merk mappen som lest", "mark_folder_tree_read": "Merk mappe og undermapper som lest", @@ -6463,6 +6470,19 @@ "group_account": "Konto", "switch_to": "Bytt til {account}" }, + "menuAudit": { + "offline_header": "Du er frakoblet. Noen handlinger trenger tilkobling.", + "files_new_folder": "Ny mappe…", + "files_new_file": "Ny fil…", + "files_new_text_file": "Ny tekstfil…", + "files_upload_files": "Last opp filer…", + "files_upload_folder": "Last opp mappe…", + "files_share": "Del…", + "files_share_button": "Del", + "contacts_add_to_group": "Legg til i gruppe…", + "contacts_bulk_add_to_group": "Legg til i gruppe…", + "schedule_send": "Planlegg sending…" + }, "notification_center": { "title": "Varsler", "mark_all_read": "Merk alle som lest", @@ -6818,6 +6838,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, kan ikke appen lese e-posten som er lagret på denne enheten, før noen skriver inn PIN-koden.", diff --git a/locales/nl/common.json b/locales/nl/common.json index f6405b04d..3a615fc79 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "E-mails van {name} zoeken" }, + "menuMove": { + "filter_placeholder": "Map zoeken", + "recent": "Recent", + "no_match": "Geen map gevonden", + "choose_folder": "Map kiezen…", + "current_folder": "Staat al in {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Map markeren als gelezen", "mark_folder_tree_read": "Map en submappen markeren als gelezen", @@ -6463,6 +6470,19 @@ "group_account": "Account", "switch_to": "Overschakelen naar {account}" }, + "menuAudit": { + "offline_header": "U bent offline. Sommige acties hebben een verbinding nodig.", + "files_new_folder": "Nieuwe map…", + "files_new_file": "Nieuw bestand…", + "files_new_text_file": "Nieuw tekstbestand…", + "files_upload_files": "Bestanden uploaden…", + "files_upload_folder": "Map uploaden…", + "files_share": "Delen…", + "files_share_button": "Delen", + "contacts_add_to_group": "Aan groep toevoegen…", + "contacts_bulk_add_to_group": "Aan groep toevoegen…", + "schedule_send": "Verzenden plannen…" + }, "notification_center": { "title": "Meldingen", "mark_all_read": "Alles als gelezen markeren", @@ -6818,6 +6838,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 om een pincode na een tijd zonder gebruik, in elk venster en tabblad. Zolang de app vergrendeld is, kan die de mail op dit apparaat niet lezen totdat iemand de pincode invoert.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 19278b9b4..624bb3ff8 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Znajdź e-maile od {name}" }, + "menuMove": { + "filter_placeholder": "Znajdź folder", + "recent": "Ostatnie", + "no_match": "Brak pasującego folderu", + "choose_folder": "Wybierz folder…", + "current_folder": "Już w folderze {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Oznacz folder jako przeczytany", "mark_folder_tree_read": "Oznacz folder i podfoldery jako przeczytane", @@ -6463,6 +6470,19 @@ "group_account": "Konto", "switch_to": "Przełącz na {account}" }, + "menuAudit": { + "offline_header": "Jesteś offline. Niektóre działania wymagają połączenia.", + "files_new_folder": "Nowy folder…", + "files_new_file": "Nowy plik…", + "files_new_text_file": "Nowy plik tekstowy…", + "files_upload_files": "Prześlij pliki…", + "files_upload_folder": "Prześlij folder…", + "files_share": "Udostępnij…", + "files_share_button": "Udostępnij", + "contacts_add_to_group": "Dodaj do grupy…", + "contacts_bulk_add_to_group": "Dodaj do grupy…", + "schedule_send": "Zaplanuj wysyłkę…" + }, "notification_center": { "title": "Powiadomienia", "mark_all_read": "Oznacz wszystkie jako przeczytane", @@ -6818,6 +6838,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 pewnym czasie bezczynności, w każdym oknie i na każdej karcie. Gdy jest zablokowana, aplikacja nie może czytać poczty zapisanej na tym urządzeniu, dopóki ktoś nie poda PIN-u.", diff --git a/locales/pt/common.json b/locales/pt/common.json index c35969af0..254629df6 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Encontrar e-mails de {name}" }, + "menuMove": { + "filter_placeholder": "Procurar uma pasta", + "recent": "Recentes", + "no_match": "Nenhuma pasta corresponde", + "choose_folder": "Escolher pasta…", + "current_folder": "Já está em {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Marcar pasta como lida", "mark_folder_tree_read": "Marcar pasta e subpastas como lidas", @@ -6463,6 +6470,19 @@ "group_account": "Conta", "switch_to": "Mudar para {account}" }, + "menuAudit": { + "offline_header": "Está offline. Algumas ações precisam de ligação.", + "files_new_folder": "Nova pasta…", + "files_new_file": "Novo arquivo…", + "files_new_text_file": "Novo arquivo de texto…", + "files_upload_files": "Carregar arquivos…", + "files_upload_folder": "Carregar pasta…", + "files_share": "Compartilhar…", + "files_share_button": "Compartilhar", + "contacts_add_to_group": "Adicionar ao grupo…", + "contacts_bulk_add_to_group": "Adicionar ao grupo…", + "schedule_send": "Agendar envio…" + }, "notification_center": { "title": "Notificações", "mark_all_read": "Marcar todas como lidas", @@ -6818,6 +6838,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 algum tempo sem uso, em todas as janelas e separadores. Enquanto está bloqueada, a aplicação não consegue ler o correio guardado neste dispositivo até alguém introduzir o PIN.", diff --git a/locales/ro/common.json b/locales/ro/common.json index 1ea4314b5..b739af387 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Găsește e-mailurile de la {name}" }, + "menuMove": { + "filter_placeholder": "Caută un folder", + "recent": "Recente", + "no_match": "Niciun folder nu se potrivește", + "choose_folder": "Alege folderul…", + "current_folder": "Deja în {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Marcați folderul ca citit", "mark_folder_tree_read": "Marcați folderul și subfolderele ca citite", @@ -6463,6 +6470,19 @@ "group_account": "Cont", "switch_to": "Comută la {account}" }, + "menuAudit": { + "offline_header": "Sunteți offline. Unele acțiuni au nevoie de conexiune.", + "files_new_folder": "Dosar nou…", + "files_new_file": "Fișier nou…", + "files_new_text_file": "Fișier text nou…", + "files_upload_files": "Încărcați fișiere…", + "files_upload_folder": "Încărcare folder…", + "files_share": "Distribuie…", + "files_share_button": "Distribuie", + "contacts_add_to_group": "Adaugă la grup…", + "contacts_bulk_add_to_group": "Adaugă la grup…", + "schedule_send": "Programează trimiterea…" + }, "notification_center": { "title": "Notificări", "mark_all_read": "Marchează toate ca citite", @@ -6818,6 +6838,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ă o perioadă fără utilizare, în fiecare fereastră și filă. Cât timp este blocată, aplicația nu poate citi mesajele stocate pe acest dispozitiv până când cineva introduce PIN-ul.", diff --git a/locales/ru/common.json b/locales/ru/common.json index bc9eba4d9..0237fb12b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Найти письма от {name}" }, + "menuMove": { + "filter_placeholder": "Найти папку", + "recent": "Недавние", + "no_match": "Подходящих папок нет", + "choose_folder": "Выбрать папку…", + "current_folder": "Уже в папке {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Отметить папку как прочитанную", "mark_folder_tree_read": "Отметить папку и вложенные папки как прочитанные", @@ -6463,6 +6470,19 @@ "group_account": "Аккаунт", "switch_to": "Переключиться на {account}" }, + "menuAudit": { + "offline_header": "Нет подключения. Для некоторых действий нужна сеть.", + "files_new_folder": "Новая папка…", + "files_new_file": "Новый файл…", + "files_new_text_file": "Новый текстовый файл…", + "files_upload_files": "Загрузить файлы…", + "files_upload_folder": "Загрузить папку…", + "files_share": "Поделиться…", + "files_share_button": "Поделиться", + "contacts_add_to_group": "Добавить в группу…", + "contacts_bulk_add_to_group": "Добавить в группу…", + "schedule_send": "Запланировать отправку…" + }, "notification_center": { "title": "Уведомления", "mark_all_read": "Отметить все как прочитанные", @@ -6818,6 +6838,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-код после периода бездействия во всех окнах и вкладках. Пока приложение заблокировано, оно не может читать почту, сохранённую на этом устройстве, пока кто-нибудь не введёт PIN-код.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 04af25e02..d2b3261d8 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Nájsť e-maily od {name}" }, + "menuMove": { + "filter_placeholder": "Nájsť priečinok", + "recent": "Nedávne", + "no_match": "Žiadny priečinok nevyhovuje", + "choose_folder": "Vybrať priečinok…", + "current_folder": "Už je v priečinku {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Označiť priečinok ako prečítaný", "mark_folder_tree_read": "Označiť priečinok a podpriečinky ako prečítané", @@ -6463,6 +6470,19 @@ "group_account": "Účet", "switch_to": "Prepnúť na {account}" }, + "menuAudit": { + "offline_header": "Ste offline. Niektoré akcie potrebujú pripojenie.", + "files_new_folder": "Nový priečinok…", + "files_new_file": "Nový súbor…", + "files_new_text_file": "Nový textový súbor…", + "files_upload_files": "Nahrať súbory…", + "files_upload_folder": "Nahrať priečinok…", + "files_share": "Zdieľať…", + "files_share_button": "Zdieľať", + "contacts_add_to_group": "Pridať do skupiny…", + "contacts_bulk_add_to_group": "Pridať do skupiny…", + "schedule_send": "Naplánovať odoslanie…" + }, "notification_center": { "title": "Oznámenia", "mark_all_read": "Označiť všetko ako prečítané", @@ -6818,6 +6838,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 nečinnosti žiada PIN v každom okne a na každej karte. Kým je zamknuté, aplikácia nemôže čítať poštu uloženú v tomto zariadení, kým niekto nezadá PIN.", diff --git a/locales/th/common.json b/locales/th/common.json index d5a8e6a53..f271b1af6 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -98,6 +98,19 @@ "group_account": "บัญชี", "switch_to": "สลับไปที่ {account}" }, + "menuAudit": { + "offline_header": "คุณออฟไลน์อยู่ บางการทำงานต้องใช้การเชื่อมต่อ", + "files_new_folder": "โฟลเดอร์ใหม่…", + "files_new_file": "ไฟล์ใหม่…", + "files_new_text_file": "ไฟล์ข้อความใหม่…", + "files_upload_files": "อัปโหลดไฟล์…", + "files_upload_folder": "อัปโหลดโฟลเดอร์…", + "files_share": "แชร์…", + "files_share_button": "แชร์", + "contacts_add_to_group": "เพิ่มในกลุ่ม…", + "contacts_bulk_add_to_group": "เพิ่มในกลุ่ม…", + "schedule_send": "กำหนดเวลาส่ง…" + }, "notification_center": { "title": "การแจ้งเตือน", "mark_all_read": "ทำเครื่องหมายว่าอ่านแล้วทั้งหมด", @@ -915,6 +928,13 @@ }, "find_emails_from": "ค้นหาอีเมลจาก {name}" }, + "menuMove": { + "filter_placeholder": "ค้นหาโฟลเดอร์", + "recent": "ล่าสุด", + "no_match": "ไม่มีโฟลเดอร์ที่ตรงกัน", + "choose_folder": "เลือกโฟลเดอร์…", + "current_folder": "อยู่ใน {folder} แล้ว" + }, "mailbox_context_menu": { "mark_folder_read": "ทำเครื่องหมายโฟลเดอร์ว่าอ่านแล้ว", "mark_folder_tree_read": "ทำเครื่องหมายโฟลเดอร์และโฟลเดอร์ย่อยว่าอ่านแล้ว", @@ -6826,6 +6846,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 หลังจากไม่ได้ใช้งานระยะหนึ่ง ในทุกหน้าต่างและแท็บ ระหว่างที่ล็อกอยู่ แอปจะอ่านอีเมลที่เก็บบนอุปกรณ์นี้ไม่ได้จนกว่าจะมีผู้ป้อน PIN", diff --git a/locales/tr/common.json b/locales/tr/common.json index 6aa4eec91..b921ea805 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "{name} adlı kişiden gelen e-postaları bul" }, + "menuMove": { + "filter_placeholder": "Klasör bul", + "recent": "Son kullanılanlar", + "no_match": "Eşleşen klasör yok", + "choose_folder": "Klasör seç…", + "current_folder": "Zaten {folder} içinde" + }, "mailbox_context_menu": { "mark_folder_read": "Klasörü okundu olarak işaretle", "mark_folder_tree_read": "Klasörü ve alt klasörleri okundu olarak işaretle", @@ -6463,6 +6470,19 @@ "group_account": "Hesap", "switch_to": "{account} hesabına geç" }, + "menuAudit": { + "offline_header": "Çevrimdışısınız. Bazı işlemler bağlantı gerektirir.", + "files_new_folder": "Yeni Klasör…", + "files_new_file": "Yeni Dosya…", + "files_new_text_file": "Yeni Metin Dosyası…", + "files_upload_files": "Dosya Yükle…", + "files_upload_folder": "Klasör Yükle…", + "files_share": "Paylaş…", + "files_share_button": "Paylaş", + "contacts_add_to_group": "Gruba ekle…", + "contacts_bulk_add_to_group": "Gruba ekle…", + "schedule_send": "Göndermeyi zamanla…" + }, "notification_center": { "title": "Bildirimler", "mark_all_read": "Tümünü okundu işaretle", @@ -6818,6 +6838,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 uygulama, biri PIN'i girene kadar bu cihazda saklanan postayı okuyamaz.", diff --git a/locales/uk/common.json b/locales/uk/common.json index 3737f8c12..168d521e5 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "Знайти листи від {name}" }, + "menuMove": { + "filter_placeholder": "Знайти папку", + "recent": "Нещодавні", + "no_match": "Немає відповідних папок", + "choose_folder": "Вибрати папку…", + "current_folder": "Уже в папці {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Позначити папку як прочитану", "mark_folder_tree_read": "Позначити папку та вкладені папки як прочитані", @@ -6463,6 +6470,19 @@ "group_account": "Обліковий запис", "switch_to": "Перейти до {account}" }, + "menuAudit": { + "offline_header": "Немає з’єднання. Для деяких дій потрібна мережа.", + "files_new_folder": "Нова папка…", + "files_new_file": "Новий файл…", + "files_new_text_file": "Новий текстовий файл…", + "files_upload_files": "Завантажити файли…", + "files_upload_folder": "Завантажити папку…", + "files_share": "Поділитися…", + "files_share_button": "Поділитися", + "contacts_add_to_group": "Додати до групи…", + "contacts_bulk_add_to_group": "Додати до групи…", + "schedule_send": "Запланувати надсилання…" + }, "notification_center": { "title": "Сповіщення", "mark_all_read": "Позначити всі як прочитані", @@ -6818,6 +6838,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-код після періоду без використання в кожному вікні та вкладці. Поки застосунок заблоковано, він не може читати пошту, збережену на цьому пристрої, доки хтось не введе PIN-код.", diff --git a/locales/vi/common.json b/locales/vi/common.json index 9358c7d82..5523a37cd 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -3660,6 +3660,13 @@ }, "find_emails_from": "Tìm email từ {name}" }, + "menuMove": { + "filter_placeholder": "Tìm thư mục", + "recent": "Gần đây", + "no_match": "Không có thư mục phù hợp", + "choose_folder": "Chọn thư mục…", + "current_folder": "Đã ở trong {folder}" + }, "mailbox_context_menu": { "mark_folder_read": "Đánh dấu thư mục là đã đọc", "mark_folder_tree_read": "Đánh dấu thư mục và thư mục con là đã đọc", @@ -6475,6 +6482,19 @@ "group_account": "Tài khoản", "switch_to": "Chuyển sang {account}" }, + "menuAudit": { + "offline_header": "Bạn đang ngoại tuyến. Một số thao tác cần kết nối.", + "files_new_folder": "Thư mục mới…", + "files_new_file": "Tệp mới…", + "files_new_text_file": "Tệp văn bản mới…", + "files_upload_files": "Tải tệp lên…", + "files_upload_folder": "Tải lên thư mục…", + "files_share": "Chia sẻ…", + "files_share_button": "Chia sẻ", + "contacts_add_to_group": "Thêm vào nhóm…", + "contacts_bulk_add_to_group": "Thêm vào nhóm…", + "schedule_send": "Lên lịch gửi…" + }, "notification_center": { "title": "Thông báo", "mark_all_read": "Đánh dấu tất cả đã đọc", @@ -6830,6 +6850,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à tab. Khi đang khóa, ứng dụng không thể đọc thư lưu trên thiết bị này cho đến khi có người nhập mã PIN.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 6e1c1c06b..5a2b723be 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "尋找來自 {name} 的郵件" }, + "menuMove": { + "filter_placeholder": "尋找資料夾", + "recent": "最近使用", + "no_match": "沒有符合的資料夾", + "choose_folder": "選擇資料夾…", + "current_folder": "已在「{folder}」中" + }, "mailbox_context_menu": { "mark_folder_read": "將信件匣標示為已讀", "mark_folder_tree_read": "將信件匣與子信件匣標示為已讀", @@ -6463,6 +6470,19 @@ "group_account": "帳戶", "switch_to": "切換到 {account}" }, + "menuAudit": { + "offline_header": "您已離線。部分操作需要連線。", + "files_new_folder": "新增資料夾…", + "files_new_file": "新增檔案…", + "files_new_text_file": "新增文字檔案…", + "files_upload_files": "上傳檔案…", + "files_upload_folder": "上傳資料夾…", + "files_share": "分享…", + "files_share_button": "分享", + "contacts_add_to_group": "新增至群組…", + "contacts_bulk_add_to_group": "新增至群組…", + "schedule_send": "排程寄送…" + }, "notification_center": { "title": "通知", "mark_all_read": "全部標示為已讀", @@ -6818,6 +6838,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。鎖定期間,在有人輸入 PIN 之前,應用程式無法讀取儲存在此裝置上的郵件。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 809b381dc..24ac15c75 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -3648,6 +3648,13 @@ }, "find_emails_from": "查找来自 {name} 的邮件" }, + "menuMove": { + "filter_placeholder": "查找文件夹", + "recent": "最近使用", + "no_match": "没有匹配的文件夹", + "choose_folder": "选择文件夹…", + "current_folder": "已在“{folder}”中" + }, "mailbox_context_menu": { "mark_folder_read": "将文件夹标记为已读", "mark_folder_tree_read": "将文件夹及子文件夹标记为已读", @@ -6463,6 +6470,19 @@ "group_account": "账户", "switch_to": "切换到 {account}" }, + "menuAudit": { + "offline_header": "您已离线。部分操作需要联网。", + "files_new_folder": "新建文件夹…", + "files_new_file": "新建文件…", + "files_new_text_file": "新建文本文件…", + "files_upload_files": "上传文件…", + "files_upload_folder": "上传文件夹…", + "files_share": "共享…", + "files_share_button": "共享", + "contacts_add_to_group": "加入群组…", + "contacts_bulk_add_to_group": "添加到群组…", + "schedule_send": "计划发送…" + }, "notification_center": { "title": "通知", "mark_all_read": "全部标为已读", @@ -6818,6 +6838,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。锁定期间,在有人输入 PIN 之前,应用无法读取存储在此设备上的邮件。",