diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index ebd3dc38a..7c3a7151b 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -31,6 +31,7 @@ import { OfflineHost } from "@/components/offline/offline-host"; import { DeviceWipeHost } from "@/components/offline/device-wipe-host"; import { NotificationActionsHost } from "@/components/providers/notification-actions-host"; import { DesktopOsHost } from "@/components/desktop/desktop-os-host"; +import { FileDropHost } from "@/components/file-drop/file-drop-host"; import { EditedFilesHost } from "@/components/desktop/edited-files-host"; import { DevicePolicyHost } from "@/components/device/device-policy-host"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; @@ -103,6 +104,7 @@ export default async function LocaleLayout({ {children} + diff --git a/app/globals.css b/app/globals.css index b9aee76aa..bb677dffe 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,11 @@ html[data-window-controls] [data-own-window-close] { display: none; } +/* 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 +640,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 @@ -1524,3 +1550,14 @@ html[data-window-controls-left] [data-window-rail] { width: var(--mv-rail-peek-w); } } + +/* A list row while its right-click menu is open (components/ui/context-menu.tsx). */ +[data-menu-row][data-menu-opener] { + outline: 2px solid var(--color-ring); + outline-offset: -2px; +} + +/* A menu opened under the pointer: the item there isn't hovered until the pointer moves. */ +[data-pointer-unarmed] :is([role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]):hover { + background-color: transparent; +} diff --git a/app/print.css b/app/print.css index 9cf992ad5..2a754311b 100644 --- a/app/print.css +++ b/app/print.css @@ -738,3 +738,24 @@ margin: 0; overflow-wrap: anywhere; } + +/* An invitation's card: when and where, in a box above the body. */ +.pr-invitation { + margin: 4mm 0 0; + padding: 2.5mm 3mm; + border: 0.3mm solid var(--pr-rule, #bbb); + border-radius: 1.5mm; + break-inside: avoid; +} + +.pr-invitation-heading { + margin: 0; + font-size: 8pt; + font-weight: 600; + color: var(--pr-muted); +} + +.pr-invitation .pr-message-meta { + font-size: 10pt; + color: var(--pr-ink); +} diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 291c715b6..08d229ff5 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -125,6 +125,8 @@ import { calendarShortcutAction } from "@/lib/calendar-shortcuts"; import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard"; import { isContextMenuOpen } from "@/components/ui/context-menu"; import { useRegisterCommands } from "@/lib/command-registry"; +import { useAppCommands } from "@/lib/app-commands"; +import { useDesktopCommands } from "@/lib/desktop/commands"; import { useConfig } from "@/hooks/use-config"; import { CalendarPrint, type CalendarPrintContent } from "@/components/calendar/calendar-print"; import { usePrintListener, usePrinting } from "@/components/print/print-root"; @@ -1666,6 +1668,28 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = { mode: "agenda", key: "a" }, { mode: "tasks", key: "k" }, ]; + // File > New event and New task in the desktop app's menus, from any app. + useAppCommands({ + "new-event": () => openCreateModal(), + "new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); }, + }, true); + // Its View menu's Day, Week, Month and Agenda, and Edit > Search events. + const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); }; + useDesktopCommands({ + "view.calendar.day": viewFromMenu("day"), + "view.calendar.week": viewFromMenu("week"), + "view.calendar.month": viewFromMenu("month"), + "view.calendar.agenda": viewFromMenu("agenda"), + "edit.search": openSearch, + }, { + "view.calendar.day": { enabled: true, checked: normalizedViewMode === "day" }, + "view.calendar.week": { enabled: true, checked: normalizedViewMode === "week" }, + "view.calendar.month": { enabled: true, checked: normalizedViewMode === "month" }, + "view.calendar.agenda": { enabled: true, checked: normalizedViewMode === "agenda" }, + "edit.search": normalizedViewMode !== "tasks", + "file.newTask": enableCalendarTasks, + }); + useRegisterCommands(tSidebar("calendar"), [ { id: "new-event", label: t("events.new_event"), icon: Plus, shortcut: "n", run: () => openCreateModal() }, { id: "new-task", label: t("events.new_task"), icon: ListTodo, run: openCreateTaskModal, when: () => enableCalendarTasks }, 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..4532896ac 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef, useMemo, useId } from "react"; +import { useCloseBesideControls } from "@/hooks/use-close-beside-controls"; import { useTranslations, useLocale } from "next-intl"; import { toast } from "@/stores/toast-store"; import { Button } from "@/components/ui/button"; @@ -278,6 +279,9 @@ export function EventModal({ const labelIdBase = useId(); const labelFor = (key: string) => `${labelIdBase}-${key}`; const tCommon = useTranslations("common"); + // The header's close goes to its start when the window's own close is at its end. + const [header, setHeader] = useState(null); + const closeFirst = useCloseBesideControls(header); const locale = useLocale(); const timeFormat = useSettingsStore((s) => s.timeFormat); const timeDisplayFmt = timeFormat === "12h" ? "h:mm a" : "HH:mm"; @@ -1031,13 +1035,20 @@ export function EventModal({ const eventCalendar = calendars.find(c => event.calendarIds[c.id]); const color = paint(getEventColor(event, eventCalendar)); + const viewClose = ( + + + + ); + return (
{/* Color accent bar */}
{/* Header */} -
+
+ {closeFirst && viewClose}
@@ -1052,9 +1063,7 @@ export function EventModal({ )} - - - + {!closeFirst && viewClose}
{/* Content */} @@ -1245,15 +1254,20 @@ export function EventModal({ ); } + const editClose = ( + + + + ); + return (
-
-

+
+ {closeFirst && editClose} +

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

- - - + {!closeFirst && editClose}
diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx index 75ea6439a..0c2d5a45c 100644 --- a/components/calendar/task-modal.tsx +++ b/components/calendar/task-modal.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef, useMemo, useId } from "react"; +import { useCloseBesideControls } from "@/hooks/use-close-beside-controls"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -61,6 +62,9 @@ export function TaskModal({ }: TaskModalProps) { const t = useTranslations("calendar"); const tCommon = useTranslations("common"); + // The header's close goes to its start when the window's own close is at its end. + const [header, setHeader] = useState(null); + const closeFirst = useCloseBesideControls(header); const id = useId(); const isEdit = !!task; const titleRef = useRef(null); @@ -212,6 +216,12 @@ export function TaskModal({ } }, [requestClose, handleSave]); + const close = ( + + + + ); + return ( // The desktop side panel is a dialog named by its title, like the event // editor; on a phone the full-screen wrapper around it is. @@ -222,13 +232,12 @@ export function TaskModal({ onKeyDown={handleKeyDown} > {/* Header */} -
-

+
+ {closeFirst && close} +

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

- - - + {!closeFirst && close}
{/* Body */} 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..fac3205b0 100644 --- a/components/contacts/contact-form.tsx +++ b/components/contacts/contact-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useMemo, useCallback, useRef, useEffect, useId } from "react"; +import { useCloseBesideControls } from "@/hooks/use-close-beside-controls"; import { useTranslations } from "next-intl"; import { X, Plus, ChevronDown, ChevronRight, User, Building, MapPin, Globe, Cake, Heart, Tag, StickyNote, Mail, Phone, Calendar, UserCircle, Book, Camera, Trash2 } from "@/components/icons"; import { Button } from "@/components/ui/button"; @@ -220,6 +221,9 @@ function Select({ value, onChange, children, className, id, "aria-label": ariaLa export function ContactForm({ contact, addressBooks, allKeywords, defaultAddressBookId, prefill, onSave, onCancel, loadCurrentMedia, onDirtyChange }: ContactFormProps) { const t = useTranslations("contacts.form"); const isEditing = !!contact; + // The header's close goes to its start when the window's own close is at its end. + const [header, setHeader] = useState(null); + const closeFirst = useCloseBesideControls(header); // Ids that tie each visible label to its field. const fieldIdBase = useId(); const fieldId = (key: string) => `${fieldIdBase}-${key}`; @@ -700,17 +704,22 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress const previewName = (isOrg ? "" : [givenName, surname].filter(Boolean).join(" ").trim()) || organization.trim(); const previewEmail = emails.find(e => e.address.trim())?.address.trim() || ""; + const close = ( + + + + ); + return ( // The app checks the fields itself and says what is wrong inline; the // browser's own bubble covered that message.
-
-

+
+ {closeFirst && close} +

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

- - - + {!closeFirst && close}
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..3a668a710 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -60,6 +60,7 @@ import { useDocumentTitle } from "@/hooks/use-document-title"; import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton"; import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels"; import { useRegisterCommands } from "@/lib/command-registry"; +import { useAppCommands } from "@/lib/app-commands"; import { useCloseOnBack } from "@/hooks/use-close-on-back"; import { useLeaveGuard } from "@/lib/leave-guard"; import { useDeleteCategory } from "@/hooks/use-delete-category"; @@ -487,6 +488,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); }, []); @@ -965,6 +969,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = } }; + // File > New contact in the desktop app's menus, from any app. + useAppCommands({ "new-contact": () => guard(handleCreateNew)() }, contactsEnabled); + // The command palette's Contacts group (Ctrl/⌘+K). useRegisterCommands(tSidebar("contacts"), [ { id: "new-contact", label: t("create_new"), icon: UserPlus, run: () => guard(handleCreateNew)() }, @@ -1144,6 +1151,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 +1406,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = ) : ( ({ useRouter: () => ({ push }) })); +const route = vi.hoisted(() => ({ pathname: '/' })); +vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }), usePathname: () => route.pathname })); + +// 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); @@ -68,6 +78,8 @@ function fakeBridge() { const original = useAuthStore.getState(); beforeEach(() => { + route.pathname = '/'; + Object.assign(intl, { locale: 'en', prefix: '', messages: {} }); push.mockClear(); resolveExternalLink.mockClear(); resolveExternalLink.mockImplementation(async (ctx) => ctx.href); @@ -97,6 +109,33 @@ describe('DesktopHost', () => { expect(() => render()).not.toThrow(); }); + it('File > New event, task and contact open that app with its editor; Settings opens in this window', async () => { + const { peekParkedAppCommand } = await import('@/lib/app-commands'); + const shell = fakeBridge(); + render(); + shell.run('file.newEvent'); + expect(push).toHaveBeenLastCalledWith('/calendar'); + expect(peekParkedAppCommand()).toBe('new-event'); + shell.run('file.newTask'); + expect(push).toHaveBeenLastCalledWith('/calendar'); + expect(peekParkedAppCommand()).toBe('new-task'); + shell.run('file.newContact'); + expect(push).toHaveBeenLastCalledWith('/contacts'); + expect(peekParkedAppCommand()).toBe('new-contact'); + shell.run('go.settings'); + expect(push).toHaveBeenLastCalledWith('/settings'); + }); + + it('tells the menus which part of the app is on screen, and offers the New lines once signed in', async () => { + route.pathname = '/calendar'; + const shell = fakeBridge(); + render(); + await waitFor(() => expect(shell.setState).toHaveBeenCalled()); + const state = shell.setState.mock.lastCall![0]; + expect(state).toMatchObject({ 'section.calendar': true, 'file.newEvent': true, 'file.newContact': true, 'file.settings': true, 'file.import': true }); + expect(state['section.mail']).toBeUndefined(); + }); + it('runs a command from what\'s on the page first', () => { const shell = fakeBridge(); render(); @@ -116,6 +155,13 @@ describe('DesktopHost', () => { expect(push).toHaveBeenLastCalledWith('/contacts'); }); + it('Help > Settings opens settings as a page of this window', () => { + const shell = fakeBridge(); + render(); + shell.run('go.settings'); + expect(push).toHaveBeenLastCalledWith('/settings'); + }); + it('a link to a folder goes through the router, encoded', () => { const shell = fakeBridge(); render(); @@ -170,11 +216,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' })}`; @@ -233,8 +294,10 @@ describe('DesktopHost', () => { }); describe('a language with no checker in the OS', () => { + beforeEach(() => resetFirstStartNotices()); afterEach(() => resetComposeFlushRegistry()); - const toasts = () => useToastStore.getState().toasts; + // A first-start notice above the message list, not a toast over the composer. + const toasts = () => firstStartNoticesWaiting(); it('the composer asks once, and only a yes opens the downloads', async () => { useToastStore.setState({ toasts: [] }); @@ -246,6 +309,7 @@ describe('DesktopHost', () => { expect(toasts()).toHaveLength(0); const stop = registerComposeFlush(async () => {}); await waitFor(() => expect(toasts()).toHaveLength(1)); + expect(useToastStore.getState().toasts).toHaveLength(0); expect(shell.allowDownload).not.toHaveBeenCalled(); act(() => toasts()[0].action!.onClick()); expect(shell.allowDownload).toHaveBeenCalledTimes(1); @@ -253,6 +317,24 @@ describe('DesktopHost', () => { stop(); }); + it("closing it says no, and it doesn't come back", async () => { + const shell = fakeBridge(); + render(); + await act(async () => {}); + const stop = registerComposeFlush(async () => {}); + shell.missing('nl-NL'); + await waitFor(() => expect(toasts()).toHaveLength(1)); + // Only asked: nothing decided until the user closes it. + expect(readDesktopPrefs().dictionaryDownload).toBe('ask'); + act(() => closeFirstStartNotice(toasts()[0].id)); + expect(readDesktopPrefs().dictionaryDownload).toBe('no'); + shell.missing('fr-FR'); + await act(async () => {}); + expect(toasts()).toHaveLength(0); + expect(shell.allowDownload).not.toHaveBeenCalled(); + stop(); + }); + it('never a second time', async () => { useToastStore.setState({ toasts: [] }); setDesktopPref('dictionaryDownload', 'no'); diff --git a/components/desktop/desktop-host.tsx b/components/desktop/desktop-host.tsx index 909ca657b..6e0a5425f 100644 --- a/components/desktop/desktop-host.tsx +++ b/components/desktop/desktop-host.tsx @@ -1,9 +1,9 @@ "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 { usePathname, useRouter } from "@/i18n/navigation"; import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands"; import { onComposerOpened, composerIsOpen } from "@/lib/compose-flush-registry"; import { getDesktopBridge } from "@/lib/desktop/bridge"; @@ -25,7 +25,15 @@ import { useEmailStore } from "@/stores/email-store"; import { useLocaleStore } from "@/stores/locale-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useThemeStore } from "@/stores/theme-store"; -import { toast } from "@/stores/toast-store"; +import { showFirstStartNotice } from "@/lib/first-start-notices"; +import { openMailImport } from "@/stores/mail-transfer-store"; + +/** The part of the app a path shows, for the menus: Calendar has its own View lines, and no Message menu. */ +export function desktopSectionOf(pathname: string): "mail" | "calendar" | "contacts" | "files" | "settings" { + const first = pathname.split("/").filter(Boolean)[0] ?? ""; + if (first === "calendar" || first === "contacts" || first === "files" || first === "settings") return first; + return "mail"; +} /** * The webmail's side of what every desktop window needs: runs menu commands nothing @@ -35,6 +43,7 @@ import { toast } from "@/stores/toast-store"; */ export function DesktopHost() { const router = useRouter(); + const pathname = usePathname(); const routerRef = useRef(router); routerRef.current = router; const t = useTranslations(); @@ -45,6 +54,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); @@ -66,11 +77,30 @@ export function DesktopHost() { else toMail("compose"); }, "edit.search": () => toMail("search"), + // The other apps' New items, run once that app is on screen. + "file.newEvent": () => { + if (requestAppCommand("new-event")) return; + parkAppCommand("new-event"); + routerRef.current.push("/calendar"); + }, + "file.newTask": () => { + if (requestAppCommand("new-task")) return; + parkAppCommand("new-task"); + routerRef.current.push("/calendar"); + }, + "file.newContact": () => { + if (requestAppCommand("new-contact")) return; + parkAppCommand("new-contact"); + routerRef.current.push("/contacts"); + }, + "file.import": () => openMailImport(), "go.inbox": () => routerRef.current.push("/"), "go.mail": () => routerRef.current.push("/"), "go.calendar": () => routerRef.current.push("/calendar"), "go.contacts": () => routerRef.current.push("/contacts"), "go.files": () => routerRef.current.push("/files"), + // Help > Settings: a page of this window, as the rail's gear opens it. + "go.settings": () => routerRef.current.push("/settings"), "help.shortcuts": () => openKeyboardShortcuts(), }; return menu.onCommand((id) => { @@ -99,6 +129,7 @@ export function DesktopHost() { const canPrint = canPrintAnywhere(); const signedIn = isAuthenticated; const popout = isPopoutWindow(); + const section = desktopSectionOf(pathname ?? "/"); setDesktopBaseState({ "link.check": true, "shortcuts.singleKeys": singleKeys, @@ -106,6 +137,12 @@ export function DesktopHost() { "file.print": canPrint, "file.savePdf": canPrint, "file.newMessage": signedIn, + "file.newEvent": signedIn, + "file.newTask": signedIn, + "file.newContact": signedIn, + "file.import": signedIn && !popout, + "file.settings": signedIn, + [`section.${section}`]: true, "edit.search": signedIn && !popout, "go.inbox": signedIn, "go.mail": signedIn, @@ -114,22 +151,26 @@ export function DesktopHost() { "go.files": signedIn, "help.shortcuts": true, }); - }, [isAuthenticated, singleKeys, strictest]); + }, [isAuthenticated, singleKeys, strictest, pathname]); - // 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(() => { noteComposerLanguage("settings", writingLanguage && writingLanguage !== "auto" ? writingLanguage : null); }, [writingLanguage]); - // A language with no checker in the OS needs a dictionary from Google: the composer - // asks once, and never in no-third-party mode. Until a yes, the shell has nowhere to - // fetch from at all. + // A language with no checker in the OS needs a dictionary from Google: once a composer + // is open, a first-start notice asks, never in no-third-party mode, and not again once + // closed. Until a yes, the shell has nowhere to fetch from at all. Windows never asks: + // it checks spelling itself (the shell's context-menu.ts). useEffect(() => { const spellcheck = getDesktopBridge()?.spellcheck; if (!spellcheck) return; @@ -139,8 +180,6 @@ export function DesktopHost() { const [language] = waiting; if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return; if (readDesktopPrefs().dictionaryDownload !== "ask") return; - // Asked once, whatever comes of it. - setDesktopPref("dictionaryDownload", "no"); const translate = tRef.current; let name = language; try { @@ -148,8 +187,9 @@ export function DesktopHost() { } catch { // The tag itself will do. } - toast.info(translate("desktopUi.spellcheck.offer", { language: name }), { - duration: 0, + showFirstStartNotice({ + id: "dictionary-download", + text: translate("desktopUi.spellcheck.offer", { language: name }), action: { label: translate("desktopUi.spellcheck.download"), onClick: () => { @@ -157,6 +197,10 @@ export function DesktopHost() { if (dictionaryDownloadAllowed()) spellcheck.allowDownload(); }, }, + // Closed without a yes: asked once, not again. + onClose: () => { + if (readDesktopPrefs().dictionaryDownload === "ask") setDesktopPref("dictionaryDownload", "no"); + }, }); }; const heard = (language: string) => { diff --git a/components/desktop/desktop-os-host.tsx b/components/desktop/desktop-os-host.tsx index a6e522136..b462bc255 100644 --- a/components/desktop/desktop-os-host.tsx +++ b/components/desktop/desktop-os-host.tsx @@ -8,15 +8,15 @@ import { isAppLocked } from "@/lib/desktop/app-lock-state"; import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications"; import { startOsSummary } from "@/lib/desktop/os-summary"; import { startPowerWatch } from "@/lib/desktop/power"; -import { startWindowFiles } from "@/lib/desktop/window-files"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; /** * The main window's end of the desktop app's OS integration: new mail as native - * notifications, what the tray and jump list show, waking up after sleep, and files - * dropped or pasted on the window. Renders nothing, and does nothing in a browser. + * notifications, what the tray and jump list show, and waking up after sleep. Files + * dropped or pasted on the window are the file drop overlay's (components/file-drop). + * Renders nothing, and does nothing in a browser. * Lives inside MainWindowOnly, so pop-outs don't do any of it a second time. */ export function DesktopOsHost() { @@ -55,12 +55,10 @@ export function DesktopOsHost() { const summary = startOsSummary(() => localeRef.current); summaryRef.current = summary; const stopPower = startPowerWatch(); - const stopFiles = startWindowFiles(); return () => { mail.stop(); summary.stop(); stopPower(); - stopFiles(); mailRef.current = null; summaryRef.current = null; }; diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx index 51927e1d5..817a277ad 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'); }); // compose-23: a menu with "Schedule send" as its only item. 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-row.test.tsx b/components/email/__tests__/email-context-menu-row.test.tsx new file mode 100644 index 000000000..87eb9c323 --- /dev/null +++ b/components/email/__tests__/email-context-menu-row.test.tsx @@ -0,0 +1,158 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { rememberTrashOrigins } from '@/lib/trash-origin'; + +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); + +import { EmailContextMenu } from '../email-context-menu'; + +// A row's right-click menu had 22 entries and was 640 px tall. It now has what +// people use from a row, as Outlook and Gmail have it, and the rest under +// "More actions". Spam's "Not spam" and Trash's "Restore" stay at the top. + +const email = { + id: 'e1', + blobId: 'b1', + threadId: 't1', + mailboxIds: { inbox: true }, + keywords: { $seen: true }, + from: [{ name: 'Alice', email: 'alice@example.com' }], + subject: 'Hello', + receivedAt: '2026-01-01T00:00:00Z', + hasAttachment: false, + size: 100, +} as unknown as Email; + +const mailboxes = [ + { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }, + { id: 'work', name: 'Work', role: null, parentId: null, myRights: { mayAddItems: true } }, + { id: 'junk', name: 'Spam', role: 'junk', parentId: null, myRights: { mayAddItems: true } }, + { id: 'trash', name: 'Trash', role: 'trash', parentId: null, myRights: { mayAddItems: true } }, +] as unknown as Mailbox[]; + +type MenuProps = React.ComponentProps; + +function renderMenu(props: Partial = {}) { + const onClose = vi.fn(); + render( + , + ); + return { onClose }; +} + +const topLevel = () => { + const menu = screen.getAllByRole('menu')[0]; + return Array.from(menu.querySelectorAll(':scope > div > [role="menuitem"], :scope > div > div > [role="menuitem"]')) + .map((row) => row.querySelector('[data-menu-label]')?.textContent ?? ''); +}; + +afterEach(() => { + localStorage.clear(); +}); + +describe("a row's right-click menu", () => { + it('has the usual few at the top and the rest under More actions', () => { + renderMenu(); + const top = topLevel(); + expect(top).toEqual(expect.arrayContaining(['reply', 'reply_all', 'forward', 'archive', 'delete', 'move_to', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'open_in_window', 'more_actions'])); + for (const label of ['forward_as_attachment', 'copy_message', 'pin', 'print', 'view_source', 'export_email', 'rules.title']) { + expect(top).not.toContain(label); + } + fireEvent.click(screen.getByTestId('ctx-more')); + const more = screen.getByRole('menu', { name: 'more_actions' }); + for (const id of ['ctx-print', 'ctx-view-source', 'ctx-export']) expect(within(more).getByTestId(id)).toBeTruthy(); + expect(within(more).getByText('forward_as_attachment')).toBeTruthy(); + expect(within(more).getByText('pin')).toBeTruthy(); + }); + + it('shows Not spam at the top in Spam', () => { + renderMenu({ selectedMailbox: 'junk', currentMailboxRole: 'junk', email: { ...email, mailboxIds: { junk: true } } as Email }); + expect(topLevel()).toContain('not_spam'); + }); + + it('the reader\'s ⋮ keeps every entry at the top: it is the "more" already', () => { + renderMenu({ rowMenu: false }); + expect(screen.queryByTestId('ctx-more')).toBeNull(); + expect(screen.getByTestId('ctx-print')).toBeTruthy(); + }); +}); + +describe('Restore in Trash', () => { + const trashed = { ...email, mailboxIds: { trash: true } } as Email; + + it('puts a message back in the folder it was deleted from', () => { + rememberTrashOrigins([{ id: 'e1', mailboxIds: { work: true } }], mailboxes); + const onMoveToMailbox = vi.fn(); + renderMenu({ email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', onMoveToMailbox }); + expect(topLevel()).toContain('restore'); + fireEvent.click(screen.getByTestId('ctx-restore')); + expect(onMoveToMailbox).toHaveBeenCalledWith('work'); + }); + + it('puts it in the Inbox when nothing says where it came from', () => { + const onMoveToMailbox = vi.fn(); + renderMenu({ email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', onMoveToMailbox }); + fireEvent.click(screen.getByTestId('ctx-restore')); + expect(onMoveToMailbox).toHaveBeenCalledWith('inbox'); + }); + + it('is in the selection menu too', () => { + rememberTrashOrigins([{ id: 'e1', mailboxIds: { work: true } }, { id: 'e2', mailboxIds: { work: true } }], mailboxes); + const onBatchMoveToMailbox = vi.fn(); + renderMenu({ + email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', isMultiSelect: true, selectedCount: 2, + batchEmails: [trashed, { ...trashed, id: 'e2' } as Email], onBatchMoveToMailbox, + }); + fireEvent.click(screen.getByTestId('ctx-restore')); + expect(onBatchMoveToMailbox).toHaveBeenCalledWith('work'); + }); +}); + +describe('Mark read and unread on a selection', () => { + it('offers both when the selection has read and unread mail', () => { + const onBatchMarkAsRead = vi.fn(); + renderMenu({ + isMultiSelect: true, selectedCount: 2, onBatchMarkAsRead, + batchEmails: [email, { ...email, id: 'e2', keywords: {} } as Email], + }); + expect(screen.getByTestId('ctx-mark-unread')).toBeTruthy(); + fireEvent.click(screen.getByTestId('ctx-mark-read')); + expect(onBatchMarkAsRead).toHaveBeenCalledWith(true); + }); + + it('offers only Mark as unread when all are read', () => { + renderMenu({ isMultiSelect: true, selectedCount: 2, onBatchMarkAsRead: vi.fn(), batchEmails: [email, { ...email, id: 'e2' } as Email] }); + expect(screen.queryByTestId('ctx-mark-read')).toBeNull(); + expect(screen.getByTestId('ctx-mark-unread')).toBeTruthy(); + }); +}); + 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__/rich-text-editor-popover-fit.test.tsx b/components/email/__tests__/rich-text-editor-popover-fit.test.tsx new file mode 100644 index 000000000..2dc4dbc24 --- /dev/null +++ b/components/email/__tests__/rich-text-editor-popover-fit.test.tsx @@ -0,0 +1,48 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Editor } from '@tiptap/core'; +import { RichTextEditor } from '../rich-text-editor'; + +// The composer's font size menu at 150 % zoom ran off the bottom of the window. +// A popover that doesn't fit under its button opens above it when there's more +// room there, and scrolls inside either way. + +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; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +async function openFontSize(triggerTop: number, height = 300) { + Object.defineProperty(window, 'innerHeight', { value: 480, configurable: true }); + Object.defineProperty(window, 'innerWidth', { value: 853, configurable: true }); + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) { + if (this.getAttribute('role') === 'menu') return rect(100, triggerTop + 36, 120, height); + if (this.getAttribute('role') === 'toolbar') return rect(0, triggerTop - 4, 853, 40); + if (this.dataset.toolbarItem === 'font-size') return rect(100, triggerTop, 32, 32); + return rect(0, 0, 0, 0); + }); + let editor: Editor | null = null; + render( {}} onEditorReady={(ed) => { editor = ed as unknown as Editor; }} />); + await waitFor(() => expect(editor).not.toBeNull()); + fireEvent.click(document.querySelector('[data-toolbar-item="font-size"]')!); + return screen.getByRole('menu', { name: 'font_size' }); +} + +describe('a composer toolbar popover in a short window', () => { + it('opens above its button when there is more room there, capped to it', async () => { + const menu = await openFontSize(360); + expect(menu.style.bottom).toBe('100%'); + expect(menu.style.maxHeight).toBe('300px'); + expect(menu.style.overflowY).toBe('auto'); + }); + + it('stays under its button and scrolls when there is more room below', async () => { + const menu = await openFontSize(100, 400); + expect(menu.style.bottom).toBe(''); + expect(menu.style.maxHeight).toBe(`${480 - 10 - 136}px`); + expect(menu.style.overflowY).toBe('auto'); + }); +}); 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/__tests__/viewer-print-copy.test.tsx b/components/email/__tests__/viewer-print-copy.test.tsx index 8df96af39..d5e020f5d 100644 --- a/components/email/__tests__/viewer-print-copy.test.tsx +++ b/components/email/__tests__/viewer-print-copy.test.tsx @@ -61,6 +61,26 @@ function printCopy(): HTMLElement | null { } describe('Ctrl+P on an open message', () => { + it('prints an invitation with its card: when, where and who', async () => { + const { InvitationPrintSync } = await import('@/lib/print/invitation-print'); + const card = { heading: 'Calendar Invitation', rows: [['Time', 'Thu 5 Nov, 13:00 to 14:00'], ['Location', 'Room 4'], ['Organized by', 'Anna Becker']] as Array<[string, string]> }; + const view = render(<> + + + ); + act(() => { window.dispatchEvent(new Event('beforeprint')); }); + const box = printCopy()!.querySelector('[data-message-print-invitation]')!; + expect(box.textContent).toContain('Calendar Invitation'); + expect(box.textContent).toContain('Thu 5 Nov, 13:00 to 14:00'); + expect(box.textContent).toContain('Room 4'); + expect(box.textContent).toContain('Anna Becker'); + act(() => { window.dispatchEvent(new Event('afterprint')); }); + // The card gone (another message opened): the next printout has no box. + view.rerender(); + act(() => { window.dispatchEvent(new Event('beforeprint')); }); + expect(printCopy()!.querySelector('[data-message-print-invitation]')).toBeNull(); + }, 30_000); + it('prints the message alone, next to the app', () => { render(); expect(printCopy()).toBeNull(); diff --git a/components/email/calendar-invitation-banner.tsx b/components/email/calendar-invitation-banner.tsx index 5a14eb71d..0f29cd8b0 100644 --- a/components/email/calendar-invitation-banner.tsx +++ b/components/email/calendar-invitation-banner.tsx @@ -20,6 +20,7 @@ import { ChevronUp, } from '@/components/icons'; import { useTranslations, useFormatter } from 'next-intl'; +import { InvitationPrintSync } from '@/lib/print/invitation-print'; import { useRouter } from '@/i18n/navigation'; import { isDocumentRTL } from '@/i18n/direction'; import { useAuthStore } from '@/stores/auth-store'; @@ -939,6 +940,21 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp tabIndex={isCollapsed ? 0 : undefined} aria-expanded={isCollapsed ? false : undefined} > + {/* When and where, for the printout of this message: its print copy has no card. */} + {summary && ( + ` : summary.organizer) : ''], + ] as Array<[string, string]>).filter(([, value]) => value), + }} + /> + )} {/* Avatar-style icon */}
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 05c4fb78c..c32143122 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect, useRef, useCallback, useMemo } from "react"; +import { useCloseBesideControls } from "@/hooks/use-close-beside-controls"; import { useMvComposeWindow } from "@/components/email/mv-compose-context"; import { useLocale, useTranslations } from "next-intl"; import { isServerOutage } from "@/lib/jmap/offline-hooks"; @@ -62,6 +63,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"; @@ -186,6 +188,7 @@ import { usePolicyStore } from "@/stores/policy-store"; import { isAppLockedNow } from "@/lib/app-lock/state"; import { registerComposeFlush, registerComposeSnapshot } from "@/lib/compose-flush-registry"; import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { useDesktopCommands } from "@/lib/desktop/commands"; import { noteComposerLanguage } from "@/lib/desktop/languages"; import { noteOpenDraft } from "@/lib/desktop/window-restore"; import type { RescuedDraft } from "@/lib/popout/channel"; @@ -619,6 +622,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'); @@ -2063,6 +2067,10 @@ export function EmailComposer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [effectiveSessionKey]); + // The header's close goes to its start when the window's own close is at its end. + const [composerHeader, setComposerHeader] = useState(null); + const closeBesideControls = useCloseBesideControls(composerHeader); + // Auto-save draft to server on page close (best-effort) const ownWindowRef = useRef(ownWindow); ownWindowRef.current = ownWindow; @@ -2850,6 +2858,19 @@ export function EmailComposer({ useEffect(() => setComposerFileTarget( (files) => void addFiles(files), () => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0, + // The file drop overlay's "Insert into the message": the editor's own paste, so the + // upload, the place and the question for a description are the same as pasting. + { + available: () => !!editorRef.current && !editorRef.current.isDestroyed, + insert: (files) => { + const editor = editorRef.current; + if (!editor || editor.isDestroyed) return; + const transfer = new DataTransfer(); + for (const file of files) transfer.items.add(file); + editor.commands.focus(); + editor.view.dom.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, cancelable: true })); + }, + }, ), [addFiles, composerRootEl]); // The id plugins know this composer by (registered below, with the handle). const [pluginComposerId, setPluginComposerId] = useState(null); @@ -4636,6 +4657,21 @@ export function EmailComposer({ } }; + // Close at the top end (R4). On a phone it moves to the start, as the phone's + // mail apps have it, with Send at the end. Beside the window's own close it + // goes to the start too. + const composerCloseButton = ( + handleClose()} + className={cn(closeBesideControls ? "-ms-2" : "-me-2", phoneLayout && "max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11")} + label={tCommon('close')} + data-testid="composer-close" + data-own-window-close={ownWindow ? "" : undefined} + > + + + ); + const popOutButton = (className: string, iconClassName: string) => popOut && ( )} {popOutButton(cn(phoneLayout ? "hidden md:grid" : "grid", "h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"), "w-4 h-4")} - {/* Close at the top end (R4). On a phone it moves to the start, as - the phone's mail apps have it, with Send at the end. */} - handleClose()} - className={phoneLayout ? "-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11" : "-me-2"} - label={tCommon('close')} - data-testid="composer-close" - data-own-window-close={ownWindow ? "" : undefined} - > - - + {!closeBesideControls && composerCloseButton}
)} @@ -6084,7 +6123,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; @@ -139,8 +146,32 @@ interface EmailContextMenuProps { extraEntries?: React.ReactNode; /** The menu's accessible name, e.g. "More actions" from the reader's ⋮. */ label?: string; + /** + * A list row's right-click menu: what people use from a row, and the rest under + * "More actions". The reader's ⋮ is that "more" already, so it shows them all. + */ + rowMenu?: boolean; } +/** + * What a row's menu shows at its top level, as Outlook and Gmail have it. The rest + * goes under "More actions". + */ +export const ROW_MENU_KEYS = new Set([ + "reschedule", "cancel_and_edit", "cancel_send", "edit_draft", + "reply", "reply_all", "forward", + "restore", "archive", "delete", "move", + "read", "star", "tag", + "spam", + "open_window", +]); + +/** Entries that change the message on the server, or queue the change offline. */ +const CHANGES_MAIL = new Set([ + "archive", "delete", "read", "remove_from_folder", "star", "pin", "spam", + "reschedule", "cancel_send", "cancel_and_edit", "restore", +]); + export function EmailContextMenu({ email, position, @@ -192,15 +223,24 @@ export function EmailContextMenu({ onPrint, onViewSource, omit, + views, extras, rowPlugins = true, extraEntries, label, + rowMenu = false, }: EmailContextMenuProps) { const t = useTranslations("context_menu"); + const tViewer = useTranslations("email_viewer"); + const tRecover = useTranslations("recover_deleted"); + const shortcutOf = useMessageActionShortcut(); + // Offline, a change to a message waits in the outbox only where the account keeps + // its mail on this device; otherwise it would fail, so it's greyed out. Rules and + // copies to another account always need the server. + const { offline, canChangeMail } = useConnectionState(); + const tMenu = useTranslations("menuAudit"); const archiveActionEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); - const 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,80 +261,68 @@ 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(); onClose(); }; + // In Trash: back to the folder it came from, or its account's Inbox. + const inTrash = currentMailboxRole === "trash"; + const restoreTo = inTrash && onMoveToMailbox ? restoreTarget(email, mailboxes, selectedMailbox) : undefined; + const batchRestoreTo = inTrash && onBatchMoveToMailbox && showBatchActions + ? restoreTargetForAll(batchEmails ?? [], mailboxes, selectedMailbox) + : undefined; + // One message: the shared action list (R6), the same the reader's ⋮ and // the phone panel show. const singleEntries = useMessageActionList({ email, currentMailboxRole, - canMove: moveTree.length > 0, + canMove: canMoveAnywhere, canRemoveFromFolder: canRemove, canCopyToAccount: !!onCopyToAccount && copyTrees.length > 0, onReply, @@ -318,7 +346,9 @@ export function EmailContextMenu({ onExport, onPrint, onViewSource, + onRestore: restoreTo ? () => onMoveToMailbox?.(restoreTo) : undefined, omit, + views, extras, }); @@ -356,8 +386,9 @@ export function EmailContextMenu({ handleAction(onBatchArchive!)} - disabled={!onBatchArchive} + disabled={!onBatchArchive || !canChangeMail} /> ); @@ -368,29 +399,59 @@ export function EmailContextMenu({ icon={Trash2} label={t("delete")} testId="ctx-delete" + shortcut={shortcutOf("delete")} onClick={() => handleAction(onBatchDelete!)} - disabled={!onBatchDelete} + disabled={!onBatchDelete || !canChangeMail} destructive={!mountainView} /> ); + // A selection of read and unread mail gets both; otherwise the one that changes something. + const selection = batchEmails?.length ? batchEmails : [email]; + const someUnread = selection.some((e) => !e.keywords?.$seen); + const someRead = selection.some((e) => !!e.keywords?.$seen); const readItem = ( <> - {/* Mark as read/unread */} - handleAction(() => onBatchMarkAsRead?.(isUnread))} - /> + {someUnread && ( + handleAction(() => onBatchMarkAsRead?.(true))} + /> + )} + {someRead && ( + handleAction(() => onBatchMarkAsRead?.(false))} + /> + )} ); + const batchRestoreItem = batchRestoreTo ? ( + <> + handleAction(() => onBatchMoveToMailbox?.(batchRestoreTo))} + /> + + + ) : null; const moveItem = ( <> {/* Move to submenu */} - {moveTree.length > 0 && ( - - {renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onBatchMoveToMailbox?.(mailboxId))} + {canMoveAnywhere && ( + + {renderMoveList(onBatchMoveToMailbox)} )} {canRemove && ( @@ -398,6 +459,7 @@ export function EmailContextMenu({ icon={FolderX} label={t("remove_from_folder")} testId="ctx-remove-from-folder" + disabled={!canChangeMail} onClick={() => handleAction(onBatchRemoveFromFolder!)} /> )} @@ -407,9 +469,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 +477,7 @@ export function EmailContextMenu({ <> {/* Copy to another connected account, keeping the originals */} {onCopyToAccount && copyTrees.length > 0 && ( - + {copyTargetsContent} )} @@ -426,14 +486,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,31 +538,31 @@ 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 ; } }; - const singleMenu = singleEntries.map((entry, index) => { - if (entry.kind === "separator") return ; + const renderEntries = (entries: MessageActionEntry[], keyPrefix = "") => entries.map((entry, index) => { + if (entry.kind === "separator") return ; if (entry.kind === "submenu") return renderSubmenu(entry); return ( handleAction(entry.run)} /> ); }); + // A row's menu: the usual few, the rest one level down. Not Spam in Spam + // and Restore in Trash stay at the top. + const rowTop = rowMenu + ? tidySeparators(singleEntries.filter((entry) => entry.kind === "separator" || ROW_MENU_KEYS.has(entry.key))) + : singleEntries; + const rowMore = rowMenu + ? tidySeparators(singleEntries.filter((entry) => entry.kind === "separator" || !ROW_MENU_KEYS.has(entry.key))) + : []; + const singleMenu = ( + <> + {renderEntries(rowTop)} + {rowMore.length > 0 && ( + <> + + + {renderEntries(rowMore, "more-")} + + + )} + + ); return ( + {offline && {tMenu("offline_header")}} {showBatchActions ? ( <> @@ -531,6 +615,7 @@ export function EmailContextMenu({ {!isScheduled && (<> + {batchRestoreItem} {/* Compose-style actions first, above the archive/delete group, mirroring the single menu's ordering. */} void; onEditDraft?: () => void; onCancelScheduledForEdit?: () => void; /** Stop a scheduled message from going out; it goes back to Drafts. */ @@ -1159,6 +1161,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} @@ -1174,8 +1177,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[]; @@ -1199,8 +1201,7 @@ function ReaderMorePanelBody({ openSub, close, entryRefs, - moveTree, - moveTargetIds, + moveModel, onMoveToMailbox, currentTagIds, sortedTagIds, @@ -1211,6 +1212,7 @@ function ReaderMorePanelBody({ pluginTarget, }: ReaderMorePanelBodyProps) { const { email } = options; + const locale = useLocale(); const { tagColor } = useKeywordFormat(tagScope); const { copyTargets, copyToAccount } = useCopyToAccount(email, true); const entries = useMessageActionList({ @@ -1220,11 +1222,10 @@ function ReaderMorePanelBody({ }); if (sub === 'move') { - return moveTree.length > 0 && onMoveToMailbox ? ( - { onMoveToMailbox(mailboxId); close(); }} /> ) : null; @@ -1233,14 +1234,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(); }} />
@@ -1344,7 +1346,6 @@ export function EmailViewer({ onReplyAllConversationEmail, mvInlineComposer, replyComposerOpen, - onShowShortcuts, onEditDraft, onCancelScheduledForEdit, onCancelScheduledSend, @@ -1368,7 +1369,6 @@ export function EmailViewer({ const tComposer = useTranslations('email_composer'); const tWords = useTranslations('compose_words'); const tNotifications = useTranslations('notifications'); - const tImport = useTranslations('mail_import'); const tCommon = useTranslations('common'); const tFiles = useTranslations('files'); const tDemoWelcome = useTranslations('demo_welcome'); @@ -1989,14 +1989,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(() => { @@ -2175,7 +2175,7 @@ export function EmailViewer({ email?.id, showToolbarLabels, isLoading, - moveTree.length, + canMoveAnywhere, emailKeywords.length, currentColor, isInJunkFolder, @@ -3779,11 +3779,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 => { @@ -3809,6 +3804,7 @@ export function EmailViewer({ ? effectiveEmailContent.html.replace(/
/g, '
') : sanitizeEmailHtml(effectiveEmailContent.html), plain, + invitation: invitationPrint, }; }; @@ -3906,6 +3902,8 @@ ${printWatermark ? printWatermark.html : ''} () => printing && !!viewerRootEl && isShownOnScreen(viewerRootEl), [printing, viewerRootEl], ); + // An invitation's when and where, from its card. + const invitationPrint = useInvitationPrint(email?.id); const printCopyFields = printShown && !devices.noPrint && !isLoading && !!email?.bodyValues ? messagePrintFields() : null; @@ -4239,6 +4237,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); @@ -4259,9 +4259,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 }] : []), @@ -4274,15 +4275,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, @@ -4301,8 +4298,12 @@ ${printWatermark ? printWatermark.html : ''} onExport: devices.noExport ? undefined : () => void handleExportEmail(), onPrint: devices.noPrint ? undefined : () => handlePrint(), onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true), + // The phone panel's Restore; the ⋮ menu works it out from onMoveToMailbox. + onRestore: currentMailboxRole === 'trash' && onMoveToMailbox + ? () => { const to = restoreTarget(email, mailboxes, selectedMailbox); if (to) onMoveToMailbox(to); } + : undefined, 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 @@ -4478,7 +4479,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} />
@@ -4842,8 +4843,7 @@ ${printWatermark ? printWatermark.html : ''} openSub={setMoreMenuSub} close={closeMoreMenu} entryRefs={moreEntryRefs} - moveTree={moveTree} - moveTargetIds={moveTargetIds} + moveModel={moveModel} onMoveToMailbox={onMoveToMailbox} currentTagIds={currentTagIds} sortedTagIds={sortedTagIds} @@ -5424,6 +5424,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; @@ -5434,7 +5439,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} )} @@ -5489,17 +5494,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" /> @@ -5553,10 +5565,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()} )} @@ -5601,7 +5613,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", @@ -5624,7 +5636,7 @@ ${printWatermark ? printWatermark.html : ''} {email.spamStatus && ( <> · - {email.spamStatus} + {email.spamStatus} )} @@ -5655,9 +5667,9 @@ ${printWatermark ? printWatermark.html : ''} {hasIdentifiers && (
{t('details.identifiers_threading')} -
+
{email.messageId && ( - {email.messageId} + )} {email.inReplyTo && email.inReplyTo.length > 0 && ( @@ -5689,7 +5701,7 @@ ${printWatermark ? printWatermark.html : ''}
{t('details.message_properties')} -
+
{email.subject !== undefined && ( {email.subject || {t('details.no_subject')}} )} @@ -5712,9 +5724,9 @@ ${printWatermark ? printWatermark.html : ''}
{hasListInfo && ( -
+
{t('details.mailing_list')} -
+
{listHeaders?.listId && ( {listHeaders.listId} )} @@ -5741,7 +5753,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..6fdfb03f7 100644 --- a/components/email/message-actions.ts +++ b/components/email/message-actions.ts @@ -39,6 +39,7 @@ import { Printer, Code, Fish, + Undo, } from "@/components/icons"; type Icon = ComponentType<{ className?: string }>; @@ -70,6 +71,7 @@ export type MessageActionKey = | "export" | "print" | "view_source" + | "restore" | MessageSubmenuKey // A host's own entries (the reader's view toggles), appended after the list. | `extra:${string}`; @@ -123,8 +125,12 @@ export interface MessageActionOptions { onExport?: () => void; onPrint?: () => void; onViewSource?: () => void; + /** In Trash: back to the folder it came from, or the Inbox (lib/trash-origin.ts). */ + onRestore?: () => 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[]; } @@ -154,6 +160,7 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr const tDeepLink = useTranslations("deep_link"); const tPhishing = useTranslations("phishing_report"); const tWindows = useTranslations("pwaWindows"); + const tRecover = useTranslations("recover_deleted"); const copyLink = useCopyLink(); const archiveEnabled = useArchiveActionEnabled(); const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view"); @@ -230,8 +237,11 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr void copyLink(buildMailPath({ mailboxId: null, emailId: null, threadId: email.threadId!, ...emailLinkSource(email) })); })] : []), - SEPARATOR, ]; + // In Trash the first thing after answering: getting it back. + const restore = o.onRestore && o.currentMailboxRole === "trash" + ? [item("restore", Undo, tRecover("restore"), o.onRestore, { testId: "ctx-restore" }), 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" }); const read = item("read", isUnread ? MailOpen : Mail, isUnread ? t("mark_read") : t("mark_unread"), () => o.onMarkAsRead?.(isUnread), { @@ -267,25 +277,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, + ...draft, ...replies, ...restore, ...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, ...restore, ...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/message-print.tsx b/components/email/message-print.tsx index 53cfd7c44..43aff624d 100644 --- a/components/email/message-print.tsx +++ b/components/email/message-print.tsx @@ -3,6 +3,7 @@ import { useCallback } from "react"; import { useTranslations } from "next-intl"; import { PrintRoot } from "@/components/print/print-root"; +import type { InvitationPrint } from "@/lib/print/invitation-print"; export interface MessagePrintFields { subject: string; @@ -13,6 +14,8 @@ export interface MessagePrintFields { /** Sanitized body HTML without