Merge branch 'feat/desktop-offline' into feat/do-ux-mail-fixes

# Conflicts:
#	components/email/email-viewer.tsx
#	components/mail/mail-app.tsx
#	components/offline/offline-host.tsx
#	components/ui/welcome-banner.tsx
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/et/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/th/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/vi/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
This commit is contained in:
Linus Rath
2026-11-04 09:11:09 +01:00
233 changed files with 12219 additions and 1267 deletions
+2
View File
@@ -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({
<SessionTimeoutGuard />
<AccountTemplateSync />
{children}
<FileDropHost />
<PluginDialogHost />
<ExternalRecipientConfirmHost />
<PhishingReportHost />
+37
View File
@@ -212,6 +212,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 {
@@ -632,6 +638,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);
@@ -655,6 +666,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
@@ -1550,3 +1576,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;
}
+21
View File
@@ -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);
}
@@ -117,8 +117,10 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
</SettingItem>
<p className="pt-2 text-xs text-muted-foreground">
What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything),
with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
against someone using the unlocked browser or app. The limits above can only lower what people pick: where several
with calendar, contacts and files, encrypted on the device. In a browser the key sits in the same profile, so this doesn&apos;t stop someone
who copies its files: use full-disk encryption. In the desktop app the key also takes that person&apos;s login on
that computer. Neither stops someone using the unlocked browser or app. An app lock adds a PIN, which someone with
a copy can guess at their own speed, so ask for a long one. The limits above can only lower what people pick: where several
scopes apply, the lowest value counts, so a scope can&apos;t raise what another one lowered. The app holds itself to
them when it fetches the policy; the server can&apos;t check what is on a device. A sign-out someone asks for deletes everything.
One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings.
+24
View File
@@ -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";
@@ -1667,6 +1669,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 },
+3 -3
View File
@@ -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",
)}
>
<span>{t(`views.${v}`)}</span>
<span data-menu-label="">{t(`views.${v}`)}</span>
{/* The key, on devices with a keyboard (R8). */}
<kbd className="font-sans text-xs text-muted-foreground pointer-coarse:hidden">{VIEW_SHORTCUTS[v]}</kbd>
<MenuShortcut keys={VIEW_SHORTCUTS[v]} />
</button>
))}
</ContextMenu>
+23 -9
View File
@@ -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<HTMLDivElement | null>(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 = (
<IconButton label={tCommon("close")} onClick={onClose} className="flex-shrink-0 mt-0.5 text-muted-foreground">
<X />
</IconButton>
);
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={event.title || t("events.no_title")} className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
{/* Color accent bar */}
<div className="h-1 w-full flex-shrink-0" style={{ backgroundColor: color }} />
{/* Header */}
<div data-window-top-row="" className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
<div ref={setHeader} data-window-top-row="" className="flex items-start justify-between gap-2 px-6 py-4 border-b border-border flex-shrink-0">
{closeFirst && viewClose}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ backgroundColor: color }} />
@@ -1052,9 +1063,7 @@ export function EventModal({
<MoreVertical />
</IconButton>
)}
<IconButton label={tCommon("close")} onClick={onClose} className="flex-shrink-0 mt-0.5 text-muted-foreground">
<X />
</IconButton>
{!closeFirst && viewClose}
</div>
{/* Content */}
@@ -1245,15 +1254,20 @@ export function EventModal({
);
}
const editClose = (
<IconButton label={tCommon("close")} onClick={requestClose} className="flex-shrink-0 text-muted-foreground">
<X />
</IconButton>
);
return (
<div ref={modalRef} role="dialog" aria-modal={isMobile || undefined} aria-label={isEdit ? t("events.edit") : t("events.create")} data-tour="event-modal" className={isMobile ? mobileRootClass : "flex flex-col h-full bg-background"}>
<div className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
<div ref={setHeader} data-window-top-row="" className="flex items-center gap-2 px-6 py-4 border-b border-border flex-shrink-0">
{closeFirst && editClose}
<h2 className="min-w-0 flex-1 text-lg font-semibold">
{isEdit ? t("events.edit") : t("events.create")}
</h2>
<IconButton label={tCommon("close")} onClick={requestClose} className="flex-shrink-0 text-muted-foreground">
<X />
</IconButton>
{!closeFirst && editClose}
</div>
<div className="flex-1 overflow-y-auto">
+14 -5
View File
@@ -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<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(header);
const id = useId();
const isEdit = !!task;
const titleRef = useRef<HTMLInputElement>(null);
@@ -212,6 +216,12 @@ export function TaskModal({
}
}, [requestClose, handleSave]);
const close = (
<IconButton label={tCommon("close")} onClick={requestClose}>
<X />
</IconButton>
);
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 */}
<div data-window-top-row="" className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 id={`${id}-heading`} className="text-sm font-semibold">
<div ref={setHeader} data-window-top-row="" className="flex items-center gap-2 px-4 py-3 border-b border-border">
{closeFirst && close}
<h2 id={`${id}-heading`} className="min-w-0 flex-1 text-sm font-semibold">
{isEdit ? t("tasks.edit") : t("tasks.create")}
</h2>
<IconButton label={tCommon("close")} onClick={requestClose}>
<X />
</IconButton>
{!closeFirst && close}
</div>
{/* Body */}
@@ -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(<CommandPaletteHost />);
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(<CommandPaletteHost />);
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(<CommandPaletteHost />);
@@ -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}'`);
});
});
+17 -4
View File
@@ -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<HTMLInputElement>(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 && (
<span className="flex-shrink-0 truncate max-w-[40%] text-xs text-muted-foreground">{command.source}</span>
)}
{command.shortcut && (
{command.shortcut && hintKeys(command.shortcut).length > 0 && (
// Key hints only where there is a keyboard (R8).
<span className="flex-shrink-0 flex items-center gap-1 pointer-coarse:hidden" aria-hidden="true">
{shortcutAlternatives(command.shortcut).map((key) => (
{hintKeys(command.shortcut).map((key) => (
<kbd key={key} className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground">{key}</kbd>
))}
</span>
@@ -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]);
+20 -11
View File
@@ -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({
</ContextMenuHeader>
<ContextMenuItem
icon={Users}
label={t("bulk.add_to_group")}
label={tMenu("contacts_bulk_add_to_group")}
onClick={handle(() => onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
@@ -179,12 +183,12 @@ export function ContactContextMenu({
onClick={handle(handleCall)}
/>
)}
{onEdit && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
{onEdit && !readOnly && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
{!inDetail && <ContextMenuSeparator />}
{onAddToGroup && (
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
label={tMenu("contacts_add_to_group")}
onClick={handle(onAddToGroup)}
/>
)}
@@ -195,7 +199,7 @@ export function ContactContextMenu({
onClick={handle(onDuplicate)}
/>
)}
{onMoveTo && (
{onMoveTo && !readOnly && (
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
)}
{email && (
@@ -225,13 +229,18 @@ export function ContactContextMenu({
onClick={handle(handlePrint)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
onClick={handle(onDelete)}
destructive
/>
{!readOnly && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
shortcut="Del"
onClick={handle(onDelete)}
destructive
/>
</>
)}
</ContextMenu>
);
}
+5 -2
View File
@@ -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>
)}
{/* A phone's row has no room for this label: the ⋮ fell onto a line of its own. */}
{isMobile ? (
{readOnly ? null : isMobile ? (
<ToolbarButton label={t("form.edit_title")} onClick={onEdit} className="touch-manipulation">
<Pencil />
</ToolbarButton>
@@ -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}
+14 -5
View File
@@ -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<HTMLDivElement | null>(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 = (
<IconButton label={t("close")} onClick={onCancel}>
<X />
</IconButton>
);
return (
// The app checks the fields itself and says what is wrong inline; the
// browser's own bubble covered that message.
<form onSubmit={handleSubmit} noValidate className="flex flex-col h-full bg-background">
<div data-window-top-row="" className="flex items-center justify-between px-6 py-4 border-b border-border flex-shrink-0">
<h2 className="text-lg font-semibold">
<div ref={setHeader} data-window-top-row="" className="flex items-center gap-2 px-6 py-4 border-b border-border flex-shrink-0">
{closeFirst && close}
<h2 className="min-w-0 flex-1 text-lg font-semibold">
{isEditing ? t("edit_title") : t("create_title")}
</h2>
<IconButton label={t("close")} onClick={onCancel}>
<X />
</IconButton>
{!closeFirst && close}
</div>
<div className="flex-1 overflow-y-auto">
+4
View File
@@ -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}
/>
)}
</div>
+9
View File
@@ -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";
@@ -488,6 +489,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);
}, []);
@@ -966,6 +970,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)() },
@@ -1145,6 +1152,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}
/>
);
}
@@ -1399,6 +1407,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
) : (
<ContactList
contacts={displayedContacts}
isContactReadOnly={isContactReadOnly}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
@@ -6,13 +6,23 @@ import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '@/lib/
import { resetDesktopLanguages } from '@/lib/desktop/languages';
import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
import { useToastStore } from '@/stores/toast-store';
import { closeFirstStartNotice, firstStartNoticesWaiting, resetFirstStartNotices } from '@/lib/first-start-notices';
import { useAuthStore } from '@/stores/auth-store';
import { useLocaleStore } from '@/stores/locale-store';
import { useOfflineStore } from '@/lib/offline/state';
import { offerReturnedComposer, takeReturnedComposer } from '@/lib/popout/return-to-main';
const push = vi.fn();
vi.mock('@/i18n/navigation', () => ({ 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(<DesktopHost />)).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(<DesktopHost />);
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(<DesktopHost />);
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(<DesktopHost />);
@@ -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(<DesktopHost />);
shell.run('go.settings');
expect(push).toHaveBeenLastCalledWith('/settings');
});
it('a link to a folder goes through the router, encoded', () => {
const shell = fakeBridge();
render(<DesktopHost />);
@@ -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(<DesktopHost />);
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(<DesktopHost />);
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(<DesktopHost />);
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' })}`;
@@ -269,8 +330,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: [] });
@@ -282,6 +345,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);
@@ -289,6 +353,24 @@ describe('DesktopHost', () => {
stop();
});
it("closing it says no, and it doesn't come back", async () => {
const shell = fakeBridge();
render(<DesktopHost />);
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');
+58 -14
View File
@@ -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";
@@ -26,7 +26,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
@@ -36,6 +44,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();
@@ -46,6 +55,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);
@@ -67,11 +78,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) => {
@@ -116,6 +146,7 @@ export function DesktopHost() {
const canPrint = canPrintAnywhere();
const signedIn = isAuthenticated;
const popout = isPopoutWindow();
const section = desktopSectionOf(pathname ?? "/");
setDesktopBaseState({
"link.check": true,
"mail.searchText": signedIn && !popout,
@@ -124,6 +155,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,
@@ -132,22 +169,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<typeof shellLabels>[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;
@@ -157,8 +198,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 {
@@ -166,8 +205,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: () => {
@@ -175,6 +215,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) => {
+3 -5
View File
@@ -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;
};
@@ -327,6 +327,8 @@ describe('composer-send-menu', () => {
const schedule = screen.getAllByRole('menuitem', { name: /schedule_send/ })[0];
const plugin = screen.getAllByRole('menuitem', { name: 'Send with Later' })[0];
expect(follows(schedule, plugin)).toBe(true);
// It opens a dialog, and shows the key that opens it from the composer.
expect(schedule).toHaveTextContent('Ctrl + Shift + Enter');
});
it('keeps a plain Send button without plugin entries or scheduled send', () => {
@@ -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');
@@ -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', () => {
@@ -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<typeof EmailContextMenu>;
function renderMenu(props: Partial<MenuProps> = {}) {
const onClose = vi.fn();
render(
<EmailContextMenu
rowMenu
email={email}
position={{ x: 10, y: 10 }}
isOpen
onClose={onClose}
menuRef={{ current: null }}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
onReply={vi.fn()}
onReplyAll={vi.fn()}
onForward={vi.fn()}
onForwardAsAttachment={vi.fn()}
onArchive={vi.fn()}
onDelete={vi.fn()}
onMarkAsRead={vi.fn()}
onToggleStar={vi.fn()}
onTogglePinned={vi.fn()}
onSetTag={vi.fn()}
onMoveToMailbox={vi.fn()}
onMarkAsSpam={vi.fn()}
onUndoSpam={vi.fn()}
onOpenInWindow={vi.fn()}
onExport={vi.fn()}
onPrint={vi.fn()}
onViewSource={vi.fn()}
{...props}
/>,
);
return { onClose };
}
const topLevel = () => {
const menu = screen.getAllByRole('menu')[0];
return Array.from(menu.querySelectorAll<HTMLElement>(':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();
});
});
@@ -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<React.ComponentProps<typeof EmailContextMenu>> = {}) {
render(
<EmailContextMenu
email={email}
position={{ x: 10, y: 10 }}
isOpen
onClose={vi.fn()}
menuRef={{ current: null }}
mailboxes={mailboxes}
selectedMailbox="inbox"
currentMailboxRole="inbox"
onReply={vi.fn()}
onReplyAll={vi.fn()}
onForward={vi.fn()}
onDelete={vi.fn()}
onToggleStar={vi.fn()}
onMarkAsRead={vi.fn()}
onMarkAsSpam={vi.fn()}
{...props}
/>,
);
}
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);
});
});
@@ -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<HTMLElement>('[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);
@@ -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<Mailbox> = {}) =>
({
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<Mailbox> = {}) =>
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<typeof vi.fn>;
beforeEach(() => {
popup = vi.fn(() => new Promise<string | null>(() => {}));
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } } as unknown as BulwarkDesktopBridge;
});
afterEach(() => {
window.bulwarkDesktop = undefined;
});
async function nativeMoveMenu(): Promise<DesktopNativeMenuItem[]> {
const model = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', recentIds: () => ['acme'] });
render(
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
<ContextMenuSubMenu label="Move to">
<MoveFolderList model={model} onPick={() => {}} />
</ContextMenuSubMenu>
</ContextMenu>,
);
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']);
});
});
@@ -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<Mailbox> = {}) =>
({
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(<MoveTargetList tree={tree} targetIds={new Set(['sent', 'junk', 'archive', 'y2025'])} onPick={onPick} />);
render(<MoveFolderList model={buildMoveMenu(short, { currentMailboxId: 'inbox' })} onPick={onPick} />);
// 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(<MoveTargetList tree={tree} targetIds={new Set(['y2025'])} onPick={vi.fn()} />);
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(<MoveFolderList model={buildMoveMenu(short, { currentMailboxId: 'inbox' })} onPick={onPick} />);
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(
<MoveFolderList
model={buildMoveMenu(short, { currentMailboxId: 'inbox', recentIds: () => ['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(<MoveFolderList model={buildMoveMenu(short, {})} onPick={vi.fn()} />);
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(<MoveFolderList model={buildMoveMenu(short, {})} onPick={vi.fn()} onChooseFolder={vi.fn()} />);
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(<MoveFolderList model={buildMoveMenu(long, {})} onPick={onPick} onChooseFolder={onChooseFolder} />);
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(<MoveFolderList variant="panel" model={buildMoveMenu(short, {})} onPick={vi.fn()} />);
const row = screen.getByTestId('move-to:y2025');
expect(row.parentElement!.className).toContain('ps-6');
});
});
@@ -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<Mailbox> = {}) =>
({
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(<MoveToDialogHost mailboxes={mailboxes} selectedMailbox="inbox" />);
const withHost = renderHook(() => useChooseMoveFolder());
expect(withHost.result.current).toBeTypeOf('function');
});
it('shows the folders with a filter, and moves to the one picked', () => {
render(<MoveToDialogHost mailboxes={mailboxes} selectedMailbox="inbox" />);
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();
});
});
@@ -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();
@@ -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(<RichTextEditor content="<p>Hello</p>" onChange={() => {}} onEditorReady={(ed) => { editor = ed as unknown as Editor; }} />);
await waitFor(() => expect(editor).not.toBeNull());
fireEvent.click(document.querySelector<HTMLButtonElement>('[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');
});
});
@@ -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'));
@@ -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(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" isLoading={false} />);
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');
});
});
@@ -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(<>
<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />
<InvitationPrintSync emailId="e1" print={card} />
</>);
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(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
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(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
expect(printCopy()).toBeNull();
@@ -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 && (
<InvitationPrintSync
emailId={email.id}
print={{
heading: bannerTitle,
rows: ([
[t('change_title'), summary.title ?? ''],
[t('change_time'), summary.start ? formatSchedule(summary) : ''],
[t('change_location'), summary.location ?? ''],
[t('organizer_label'), summary.organizer ? (summary.organizerEmail && summary.organizerEmail !== summary.organizer ? `${summary.organizer} <${summary.organizerEmail}>` : summary.organizer) : ''],
] as Array<[string, string]>).filter(([, value]) => value),
}}
/>
)}
{/* Avatar-style icon */}
<div className={cn(
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
@@ -8,12 +8,13 @@ import { ContextMenu } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useCopyToAccount, useEmailExport } from "@/hooks/use-message-file-actions";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { resolveEmailBlobContext } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { Archive, FolderInput, MoreVertical, Trash2 } from "@/components/icons";
import { EmailContextMenu } from "./email-context-menu";
import { MoveTargetList } from "./move-target-list";
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list";
import { useChooseMoveFolder } from "./move-to-dialog";
import { ViewSourceDialog } from "./view-source-dialog";
import { canExport } from "@/lib/device-policy";
import type { MessageActionKey } from "./message-actions";
@@ -56,11 +57,10 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac
const { mailboxes, selectedMailbox, onArchive, onDelete, onMoveToMailbox } = actions;
const ownerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox);
const { tree: moveTree, targetIds: moveTargetIds } = useMemo(
() => buildMoveTargets(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId, emails: [email] }),
[mailboxes, selectedMailbox, ownerAccountId, email],
);
const canMove = !!onMoveToMailbox && moveTree.length > 0;
const moveEmails = useMemo(() => [email], [email]);
const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId, emails: moveEmails });
const canMove = !!onMoveToMailbox && !moveMenuIsEmpty(moveModel);
const chooseMoveFolder = useChooseMoveFolder();
const exportFollowUp = { onArchive, onDelete, archiveEnabled };
const source = resolveEmailBlobContext(email, client);
@@ -107,11 +107,13 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac
onClose={moveMenu.closeContextMenu}
label={t("email_viewer.move_to")}
>
<div className="w-56 max-h-72 overflow-y-auto">
<MoveTargetList
tree={moveTree}
targetIds={moveTargetIds}
<div className="w-64 max-w-full">
<MoveFolderList
model={moveModel}
onPick={(mailboxId) => { moveMenu.closeContextMenu(); onMoveToMailbox!(mailboxId); }}
onChooseFolder={chooseMoveFolder
? () => { moveMenu.closeContextMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox! }); }
: undefined}
/>
</div>
</ContextMenu>
+52 -13
View File
@@ -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";
@@ -56,6 +57,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";
@@ -174,6 +176,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";
@@ -592,6 +595,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');
@@ -2001,6 +2005,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<HTMLDivElement | null>(null);
const closeBesideControls = useCloseBesideControls(composerHeader);
// Auto-save draft to server on page close (best-effort)
const ownWindowRef = useRef(ownWindow);
ownWindowRef.current = ownWindow;
@@ -2788,6 +2796,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<string | null>(null);
@@ -4514,6 +4535,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 = (
<IconButton
onClick={() => handleClose()}
className={cn(closeBesideControls ? "-ms-2" : "-me-2", "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}
>
<X className="w-5 h-5 md:w-4 md:h-4" />
</IconButton>
);
const popOutButton = (className: string, iconClassName: string) => popOut && (
<button
type="button"
@@ -4692,6 +4728,18 @@ export function EmailComposer({
}
};
// The desktop app's Message menu while this composer is open: the same handlers as
// its buttons and keys (lib/desktop/commands.ts). Nothing in a browser.
useDesktopCommands({
'compose.send': () => { if (!isSavingAndClosing) void handleSend(); },
'compose.saveDraft': () => { void saveDraft({ announce: true }); },
'compose.attach': () => fileInputRef.current?.click(),
}, {
'compose.send': !!canSend && !isSending && !isSavingAndClosing,
'compose.saveDraft': !isSavingAndClosing,
'compose.attach': !isSavingAndClosing,
});
return (
<div
ref={setComposerRootEl}
@@ -4814,7 +4862,8 @@ export function EmailComposer({
</button>
</div>
) : (
<div className="flex items-center gap-3 px-4 py-2 md:py-0 md:h-14 border-b bg-background shrink-0" data-composer-header="">
<div ref={setComposerHeader} className="flex items-center gap-3 px-4 py-2 md:py-0 md:h-14 border-b bg-background shrink-0" data-composer-header="">
{closeBesideControls && composerCloseButton}
{/* The left panel's button at the start, the right one's at the end: where each panel is. */}
{leftPanelToggle && <div className="hidden md:flex -ms-2">{leftPanelToggle}</div>}
<div className="flex-1 min-w-0 flex items-center gap-3">
@@ -4886,17 +4935,7 @@ export function EmailComposer({
)}
{rightPanelToggle}
{popOutButton("hidden md: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. */}
<IconButton
onClick={() => handleClose()}
className="-me-2 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}
>
<X className="w-5 h-5 md:w-4 md:h-4" />
</IconButton>
{!closeBesideControls && composerCloseButton}
</div>
)}
@@ -5813,7 +5852,7 @@ export function EmailComposer({
<ContextMenu ref={sendMenu.menuRef} isOpen={showSendMenu} position={sendMenu.contextMenu.position} onClose={closeSendMenu}>
{composerClient?.hasDelayedSend() && (
<ContextMenuItem icon={CalendarClock} label={t('schedule_send')} onClick={openScheduleDialog} testId="composer-schedule-send" />
<ContextMenuItem icon={CalendarClock} label={tMenu('schedule_send')} shortcut={formatShortcutCombo('Mod + Shift + Enter')} onClick={openScheduleDialog} testId="composer-schedule-send" />
)}
<ComposerPluginMenuItems
surface="composer-send-menu"
+177 -92
View File
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store";
import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags";
@@ -28,12 +28,17 @@ import {
Paperclip,
Copy,
Fish,
MoreHorizontal,
Undo,
} from "@/components/icons";
import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions";
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { moveMailboxIcon } from "./move-target-list";
import { tidySeparators, useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions";
import { restoreTarget, restoreTargetForAll } from "@/lib/trash-origin";
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu";
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list";
import { useChooseMoveFolder } from "./move-to-dialog";
import { useMessageActionShortcut } from "./message-action-shortcuts";
import { useConnectionState } from "@/hooks/use-connection-state";
import { getEmailTagIds } from "@/lib/thread-utils";
import { TagPicker } from "./tag-picker";
import { RulesContextSubMenu } from "./rules-menu";
@@ -131,7 +136,9 @@ interface EmailContextMenuProps {
onViewSource?: () => void;
/** Entries the host already shows elsewhere (the reader's toolbar). */
omit?: ReadonlySet<MessageActionKey>;
/** 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<MessageActionKey>([
"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<MessageActionKey>([
"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<string>): MailboxNode[] => {
return nodes.reduce<MailboxNode[]>((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<string>,
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 (
<div key={node.id}>
{targetIds.has(node.id) ? (
<ContextMenuItem
icon={Icon}
label={nodeLabel}
testId={`${testIdPrefix}${node.id}`}
onClick={() => handleAction(() => onPick(node.id))}
/>
) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{nodeLabel}</span>
</div>
)}
{node.children.length > 0 && (
<div className="ps-4">
{renderMailboxNodes(node.children, targetIds, testIdPrefix, onPick)}
</div>
)}
</div>
);
});
const renderMoveList = (onPick?: (mailboxId: string) => void) => (
<MoveFolderList
model={moveModel}
onPick={(mailboxId) => handleAction(() => onPick?.(mailboxId))}
onChooseFolder={chooseMoveFolder && onPick
? () => handleAction(() => chooseMoveFolder({ emails: movedEmails, onMove: onPick }))
: undefined}
/>
);
const renderCopyList = (target: { accountId: string; model: MoveMenuModel }) => (
<MoveFolderList
model={target.model}
noRecent
testIdPrefix={`copy-to:${target.accountId}:`}
onPick={(mailboxId) => 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({
<ContextMenuItem
icon={Archive}
label={t("archive")}
shortcut={shortcutOf("archive")}
onClick={() => 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 */}
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
onClick={() => handleAction(() => onBatchMarkAsRead?.(isUnread))}
/>
{someUnread && (
<ContextMenuItem
icon={MailOpen}
label={t("mark_read")}
testId="ctx-mark-read"
shortcut={shortcutOf("read", true)}
disabled={!canChangeMail}
onClick={() => handleAction(() => onBatchMarkAsRead?.(true))}
/>
)}
{someRead && (
<ContextMenuItem
icon={Mail}
label={t("mark_unread")}
testId="ctx-mark-unread"
shortcut={shortcutOf("read", false)}
disabled={!canChangeMail}
onClick={() => handleAction(() => onBatchMarkAsRead?.(false))}
/>
)}
</>
);
const batchRestoreItem = batchRestoreTo ? (
<>
<ContextMenuItem
icon={Undo}
label={tRecover("restore")}
testId="ctx-restore"
disabled={!canChangeMail}
onClick={() => handleAction(() => onBatchMoveToMailbox?.(batchRestoreTo))}
/>
<ContextMenuSeparator />
</>
) : null;
const moveItem = (
<>
{/* Move to submenu */}
{moveTree.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onBatchMoveToMailbox?.(mailboxId))}
{canMoveAnywhere && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to" disabled={!canChangeMail}>
{renderMoveList(onBatchMoveToMailbox)}
</ContextMenuSubMenu>
)}
{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) => (
<div key={target.accountId}>
<ContextMenuHeader>{target.label}</ContextMenuHeader>
{renderMailboxNodes(target.tree, target.targetIds, `copy-to:${target.accountId}:`, (mailboxId) =>
onCopyToAccount(target.accountId, mailboxId)
)}
{renderCopyList(target)}
</div>
))
: null;
@@ -417,7 +477,7 @@ export function EmailContextMenu({
<>
{/* Copy to another connected account, keeping the originals */}
{onCopyToAccount && copyTrees.length > 0 && (
<ContextMenuSubMenu icon={Copy} label={t("copy_to_account")} testId="ctx-copy-to">
<ContextMenuSubMenu icon={Copy} label={t("copy_to_account")} testId="ctx-copy-to" disabled={offline}>
{copyTargetsContent}
</ContextMenuSubMenu>
)}
@@ -426,14 +486,14 @@ export function EmailContextMenu({
const rulesItem = (
<>
{/* Rules: filter rules made from the sender(s), saved as Sieve */}
<RulesContextSubMenu anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} />
<RulesContextSubMenu anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} disabled={offline} />
</>
);
const tagItem = (
<>
{/* Set tag submenu - the whole selection's tags (#1077) */}
{onBatchToggleTag && (
<ContextMenuSubMenu icon={Tag} label={t("tag")} testId="ctx-tag">
<ContextMenuSubMenu icon={Tag} label={t("tag")} testId="ctx-tag" disabled={!canChangeMail}>
<div className="w-56 max-w-[18rem]">
<TagPicker
selectedIds={batchTagIds ?? []}
@@ -457,8 +517,9 @@ export function EmailContextMenu({
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
shortcut={shortcutOf("spam")}
onClick={() => handleAction(isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)}
disabled={isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam}
disabled={(isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) || !canChangeMail}
destructive={!isInJunkFolder && !mountainView}
/>
{batchPhishing.available && (
@@ -477,31 +538,31 @@ export function EmailContextMenu({
switch (entry.key) {
case "move":
return (
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId}>
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onMoveToMailbox?.(mailboxId))}
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId} disabled={!canChangeMail}>
{renderMoveList(onMoveToMailbox)}
</ContextMenuSubMenu>
);
case "copy_to_account":
return (
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId}>
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId} disabled={offline}>
{copyTargetsContent}
</ContextMenuSubMenu>
);
case "tag":
return (
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId}>
<ContextMenuSubMenu key={entry.key} icon={entry.icon} label={entry.label} testId={entry.testId} disabled={!canChangeMail}>
<div className="w-56 max-w-[18rem]">
<TagPicker selectedIds={currentTagIds} onToggle={(tagId) => onSetTag?.(tagId)} scope={tagScope} />
</div>
</ContextMenuSubMenu>
);
case "rules":
return <RulesContextSubMenu key={entry.key} anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} />;
return <RulesContextSubMenu key={entry.key} anchor={email} senderEmails={ruleSenderEmails} onClose={onClose} disabled={offline} />;
}
};
const singleMenu = singleEntries.map((entry, index) => {
if (entry.kind === "separator") return <ContextMenuSeparator key={`sep-${index}`} />;
const renderEntries = (entries: MessageActionEntry[], keyPrefix = "") => entries.map((entry, index) => {
if (entry.kind === "separator") return <ContextMenuSeparator key={`${keyPrefix}sep-${index}`} />;
if (entry.kind === "submenu") return renderSubmenu(entry);
return (
<ContextMenuItem
@@ -509,12 +570,34 @@ export function EmailContextMenu({
icon={entry.icon}
label={entry.label}
testId={entry.testId}
disabled={entry.disabled}
disabled={entry.disabled || (!canChangeMail && CHANGES_MAIL.has(entry.key))}
destructive={entry.destructive}
shortcut={shortcutOf(entry.key, isUnread)}
onClick={() => handleAction(entry.run)}
/>
);
});
// 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 && (
<>
<ContextMenuSeparator />
<ContextMenuSubMenu icon={MoreHorizontal} label={tViewer("more_actions")} testId="ctx-more">
{renderEntries(rowMore, "more-")}
</ContextMenuSubMenu>
</>
)}
</>
);
return (
<ContextMenu
@@ -524,6 +607,7 @@ export function EmailContextMenu({
onClose={onClose}
label={label}
>
{offline && <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader>}
{showBatchActions ? (
<>
<ContextMenuHeader>
@@ -531,6 +615,7 @@ export function EmailContextMenu({
</ContextMenuHeader>
{!isScheduled && (<>
{batchRestoreItem}
{/* Compose-style actions first, above the archive/delete group,
mirroring the single menu's ordering. */}
<ContextMenuItem
+1
View File
@@ -1451,6 +1451,7 @@ export function EmailList({
{/* Context Menu */}
{contextMenuEmail && (
<EmailContextMenu
rowMenu
email={contextMenuEmail}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
+86 -71
View File
@@ -3,7 +3,6 @@
import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { openMailImport } from "@/stores/mail-transfer-store";
import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { mailLanguage } from "@/lib/mail-language";
@@ -17,6 +16,7 @@ import { ReaderAttachments, type AttachmentDragProps } from "./reader-attachment
import { editAccountOf, useEditAttachment } from "@/hooks/use-edit-attachment";
import { ReaderViewDialog } from "./reader-view-dialog";
import { MessagePrint, type MessagePrintFields } from "./message-print";
import { useInvitationPrint } from "@/lib/print/invitation-print";
import { usePrintListener, usePrinting } from "@/components/print/print-root";
import { ReadingMask } from "./reading-mask";
import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
@@ -36,6 +36,7 @@ import { Avatar } from "@/components/ui/avatar";
import { ContextMenu, ContextMenuSeparator } from "@/components/ui/context-menu";
import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-menu";
import { EmailContextMenu } from "./email-context-menu";
import { rememberedTrashOrigin } from "@/lib/trash-origin";
import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions";
import { PhishingMarkBanner } from "./phishing-mark-banner";
import { turnOffLinks } from "@/lib/phishing-links";
@@ -49,11 +50,12 @@ import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/p
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, cleanPreview, type MailboxNode } from "@/lib/utils";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide, toolbarHideOrder } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu";
import { emailDisplayDate } from "@/lib/email-date";
import { TagBadge } from "./tag-badge";
import { TagPicker } from "./tag-picker";
@@ -65,7 +67,8 @@ import { getSecurityStatus, extractListHeaders, getSenderVerification, brandIndi
import { emailToReadView } from "@/lib/plugin-projection";
import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
import { ViewSourceDialog } from "./view-source-dialog";
import { MoveTargetList } from "./move-target-list";
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list";
import { useChooseMoveFolder } from "./move-to-dialog";
import { SaveToFilesDialog, type SaveableAttachment } from "./save-to-files-dialog";
import { usePolicyStore } from "@/stores/policy-store";
import { ScheduleSendDialog } from "./schedule-send-dialog";
@@ -109,7 +112,6 @@ import {
Send,
FolderInput,
Sun,
Upload,
Moon,
PlayCircle,
PenSquare,
@@ -125,7 +127,7 @@ import {
Paperclip,
} from "@/components/icons";
import { MailMessageSkeleton, MailMessageToolbarSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
@@ -160,6 +162,7 @@ import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
import { AddressText } from "./address-context-menu";
import { BreakableAddress } from "@/components/ui/breakable-address";
import { mentionFromLink } from "@/lib/recipient-mentions";
import { MailtoLink } from "@/components/ui/mailto-link";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
@@ -299,7 +302,6 @@ interface EmailViewerProps {
onReplyAllConversationEmail?: (email: Email) => void;
/** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */
mvInlineComposer?: React.ReactNode;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCancelScheduledForEdit?: () => void;
/** Stop a scheduled message from going out; it goes back to Drafts. */
@@ -1186,6 +1188,7 @@ function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMo
onViewSource={options.onViewSource}
onOpenInWindow={options.onOpenInWindow}
omit={options.omit}
views={options.views}
extras={options.extras}
rowPlugins={false}
extraEntries={pluginEntries}
@@ -1201,8 +1204,7 @@ interface ReaderMorePanelBodyProps {
openSub: (sub: MoreMenuSub) => void;
close: () => void;
entryRefs: React.MutableRefObject<Record<MoreMenuSub, HTMLButtonElement | null>>;
moveTree: MailboxNode[];
moveTargetIds: Set<string>;
moveModel: MoveMenuModel;
onMoveToMailbox?: (mailboxId: string) => void;
currentTagIds: string[];
sortedTagIds: string[];
@@ -1226,8 +1228,7 @@ function ReaderMorePanelBody({
openSub,
close,
entryRefs,
moveTree,
moveTargetIds,
moveModel,
onMoveToMailbox,
currentTagIds,
sortedTagIds,
@@ -1238,6 +1239,7 @@ function ReaderMorePanelBody({
pluginTarget,
}: ReaderMorePanelBodyProps) {
const { email } = options;
const locale = useLocale();
const { tagColor } = useKeywordFormat(tagScope);
const { copyTargets, copyToAccount } = useCopyToAccount(email, true);
const entries = useMessageActionList({
@@ -1247,11 +1249,10 @@ function ReaderMorePanelBody({
});
if (sub === 'move') {
return moveTree.length > 0 && onMoveToMailbox ? (
<MoveTargetList
return !moveMenuIsEmpty(moveModel) && onMoveToMailbox ? (
<MoveFolderList
variant="panel"
tree={moveTree}
targetIds={moveTargetIds}
model={moveModel}
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); close(); }}
/>
) : null;
@@ -1260,14 +1261,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 (
<div key={target.accountId}>
<div className="px-4 pt-3 pb-1 text-xs font-medium text-muted-foreground truncate" title={target.label}>{target.label}</div>
<MoveTargetList
<MoveFolderList
variant="panel"
tree={tree}
targetIds={targetIds}
noRecent
model={model}
testIdPrefix={`copy-to:${target.accountId}:`}
onPick={(mailboxId) => { void copyToAccount([email], target.accountId, mailboxId); close(); }}
/>
</div>
@@ -1371,7 +1373,6 @@ export function EmailViewer({
onReplyConversationEmail,
onReplyAllConversationEmail,
mvInlineComposer,
onShowShortcuts,
onEditDraft,
onCancelScheduledForEdit,
onCancelScheduledSend,
@@ -1396,7 +1397,6 @@ export function EmailViewer({
const tWindows = useTranslations('pwaWindows');
const tComposer = useTranslations('email_composer');
const tNotifications = useTranslations('notifications');
const tImport = useTranslations('mail_import');
const tCommon = useTranslations('common');
const tFiles = useTranslations('files');
const tDemoWelcome = useTranslations('demo_welcome');
@@ -2019,16 +2019,19 @@ 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();
const restoreTarget = isInTrashFolder && email ? trashRestoreTarget(email, mailboxes, identities, moveOwnerAccountId) : undefined;
// Where this device saw it come from, else a guess by what the message is.
const restoreTarget = isInTrashFolder && email
? rememberedTrashOrigin(email, mailboxes) ?? trashRestoreTarget(email, mailboxes, identities, moveOwnerAccountId)
: undefined;
// Close dropdown menus on click outside
useEffect(() => {
@@ -2216,7 +2219,7 @@ export function EmailViewer({
email?.id,
showToolbarLabels,
isLoading,
moveTree.length,
canMoveAnywhere,
emailKeywords.length,
currentColor,
isInJunkFolder,
@@ -3822,11 +3825,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 => {
@@ -3852,6 +3850,7 @@ export function EmailViewer({
? effectiveEmailContent.html.replace(/<details>/g, '<details open>')
: sanitizeEmailHtml(effectiveEmailContent.html),
plain,
invitation: invitationPrint,
};
};
@@ -3949,6 +3948,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;
@@ -4285,6 +4286,8 @@ ${printWatermark ? printWatermark.html : ''}
// the bottom bar has the replies.
const toolbarShownActions = (): ReadonlySet<MessageActionKey> => {
const shown = new Set<MessageActionKey>(['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<HTMLElement>('[data-overflow-item]').forEach((item) => {
// A button standing in for another (Not spam in Archive's place) says which entry it is.
const key = item.dataset.overflowAction !== undefined
@@ -4308,9 +4311,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 }]
: []),
@@ -4323,10 +4327,6 @@ ${printWatermark ? printWatermark.html : ''}
...(effectiveEmailContent.isHtml
? [{ kind: 'item' as const, key: 'extra:view_mode' as const, icon: isDark ? Sun : Moon, label: isDark ? t('view_in_light_mode') : t('view_in_dark_mode'), run: toggleViewMode }]
: []),
{ kind: 'item', key: 'extra:import', icon: Upload, label: tImport('menu_import'), run: handleImportEmail },
...(onShowShortcuts && !isMobile
? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }]
: []),
// Only while the toolbar has no room for its button (see toolbarShownActions).
...(onToggleFullscreen && !isFullscreen && !mvViewer && !isMobile
? [{ kind: 'item' as const, key: 'extra:fullscreen' as const, icon: Maximize2, label: t('fullscreen'), run: onToggleFullscreen }]
@@ -4335,7 +4335,7 @@ ${printWatermark ? printWatermark.html : ''}
const readerActionOptions: MessageActionOptions = {
email,
currentMailboxRole,
canMove: moveTree.length > 0 && !!onMoveToMailbox,
canMove: canMoveAnywhere && !!onMoveToMailbox,
canRemoveFromFolder: !!onRemoveFromFolder,
onReply: onReply ? () => onReply() : undefined,
onReplyAll,
@@ -4356,8 +4356,12 @@ ${printWatermark ? printWatermark.html : ''}
onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true),
// Only while the toolbar has no room for its button (see toolbarShownActions).
onOpenInWindow: onOpenInWindow && !mvViewer && !isMobile ? onOpenInWindow : undefined,
// The phone panel's Restore; the ⋮ menu works it out from onMoveToMailbox.
onRestore: currentMailboxRole === 'trash' && onMoveToMailbox
? () => { if (restoreTarget) onMoveToMailbox(restoreTarget.id); }
: 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
@@ -4566,7 +4570,7 @@ ${printWatermark ? printWatermark.html : ''}
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{deleteIsPermanent ? tActions('delete_forever') : t('delete')}</span>}
</Button>
{/* Move to folder */}
{moveTree.length > 0 && onMoveToMailbox && (
{canMoveAnywhere && onMoveToMailbox && (
<div data-overflow-item data-overflow-priority="5" className={cn("relative", mvViewer && "order-6")}>
<Button
variant="ghost"
@@ -4586,11 +4590,11 @@ ${printWatermark ? printWatermark.html : ''}
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('move')}</span>}
</Button>
<ContextMenu ref={moveMenu.menuRef} isOpen={moveMenuOpen} position={moveMenu.contextMenu.position} onClose={closeMoveMenu} label={t('move_to')}>
<div className="w-56 max-h-72 overflow-y-auto">
<MoveTargetList
tree={moveTree}
targetIds={moveTargetIds}
<div className="w-64 max-w-full" data-reader-move-menu="">
<MoveFolderList
model={moveModel}
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); closeMoveMenu(); }}
onChooseFolder={chooseMoveFolder ? () => { closeMoveMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox }); } : undefined}
/>
</div>
</ContextMenu>
@@ -4933,8 +4937,7 @@ ${printWatermark ? printWatermark.html : ''}
openSub={setMoreMenuSub}
close={closeMoreMenu}
entryRefs={moreEntryRefs}
moveTree={moveTree}
moveTargetIds={moveTargetIds}
moveModel={moveModel}
onMoveToMailbox={onMoveToMailbox}
currentTagIds={currentTagIds}
sortedTagIds={sortedTagIds}
@@ -5506,6 +5509,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
? <time dateTime={iso} title={fullDate(iso)}>{formatDateTime(iso, timeFormat, { year: 'numeric', month: 'short', day: 'numeric' })}</time>
: '-';
const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type;
@@ -5516,7 +5524,7 @@ ${printWatermark ? printWatermark.html : ''}
);
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
<>
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
<dt className="text-muted-foreground text-xs @sm:pt-1">{label}</dt>
<dd className={cn(
"text-sm text-foreground min-w-0 break-words",
mono && "font-mono text-xs",
@@ -5533,7 +5541,7 @@ ${printWatermark ? printWatermark.html : ''}
return (
<span
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs max-w-full min-w-0",
tooltip && "cursor-help",
status.icon === 'check' && "bg-green-500/[0.07] border-green-500/30",
status.icon === 'x' && "bg-red-500/[0.07] border-red-500/30",
@@ -5543,14 +5551,14 @@ ${printWatermark ? printWatermark.html : ''}
title={tooltip}
>
<Icon className={cn("w-3.5 h-3.5 flex-shrink-0", status.color)} />
<span className="font-medium text-foreground">{name}</span>
<span className={cn("text-[0.6875rem]", status.color)}>
<span className="font-medium text-foreground whitespace-nowrap">{name}</span>
<span className={cn("text-[0.6875rem] whitespace-nowrap", status.color)}>
{translateAuthResult(result)}
</span>
{extra && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">{extra}</span>
<span className="text-muted-foreground truncate min-w-0" title={typeof extra === 'string' ? extra : undefined}>{extra}</span>
</>
)}
</span>
@@ -5571,17 +5579,24 @@ ${printWatermark ? printWatermark.html : ''}
);
return (
<div className="bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
<div className="@container bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
<div className="grid grid-cols-1 @3xl:grid-cols-2 gap-x-10 gap-y-5">
<section className="min-w-0">
<SectionHeader>{t('details.recipients_routing')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
<Row label={t('from')}>
<div className="flex flex-wrap items-center gap-1">
<RecipientPopover
name={sender?.name}
email={sender?.email || ''}
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
label={sender?.name && sender?.email ? (
<>
<bdi className="block">{sender.name}</bdi>
<bdi className="block text-muted-foreground" title={sender.email}>
<BreakableAddress text={sender.email} />
</bdi>
</>
) : undefined}
onViewContact={handleViewContactSidebar}
className="text-sm text-start"
/>
@@ -5635,10 +5650,10 @@ ${printWatermark ? printWatermark.html : ''}
</Row>
)}
{email.sentAt && (
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
<Row label={t('details.sent')}>{shownDate(email.sentAt)}</Row>
)}
<Row label={t('details.received')}>
{fullDate(email.receivedAt)}
{shownDate(email.receivedAt)}
{deliveryDeltaMs > 60000 && (
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
)}
@@ -5683,7 +5698,7 @@ ${printWatermark ? printWatermark.html : ''}
)}
{email.spamScore !== undefined && (
<span className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs max-w-full min-w-0",
email.spamScore > 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",
@@ -5706,7 +5721,7 @@ ${printWatermark ? printWatermark.html : ''}
{email.spamStatus && (
<>
<span className="text-muted-foreground">·</span>
<span className="text-muted-foreground">{email.spamStatus}</span>
<span className="text-muted-foreground truncate min-w-0" title={email.spamStatus}>{email.spamStatus}</span>
</>
)}
</span>
@@ -5737,9 +5752,9 @@ ${printWatermark ? printWatermark.html : ''}
{hasIdentifiers && (
<section className="min-w-0">
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
{email.messageId && (
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
<Row label={t('headers.message_id')} mono><BreakableAddress text={email.messageId} /></Row>
)}
{email.inReplyTo && email.inReplyTo.length > 0 && (
<Row label={t('details.in_reply_to')} mono>
@@ -5771,7 +5786,7 @@ ${printWatermark ? printWatermark.html : ''}
<section className="min-w-0">
<SectionHeader>{t('details.message_properties')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
{email.subject !== undefined && (
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
)}
@@ -5794,9 +5809,9 @@ ${printWatermark ? printWatermark.html : ''}
</section>
{hasListInfo && (
<section className="lg:col-span-2 min-w-0">
<section className="@3xl:col-span-2 min-w-0">
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
{listHeaders?.listId && (
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
)}
@@ -5823,7 +5838,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Plugin-supplied category. Plugins whose shouldShow accepts
`category === null` render their own titled section here. */}
{hasDetailsSlotOffers && (
<section className="lg:col-span-2 min-w-0">
<section className="@3xl:col-span-2 min-w-0">
<DetailsCategorySlot email={pluginEmailView} category={null} />
</section>
)}
@@ -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<Record<MessageActionKey, { key: string; single: boolean }>> = {
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]);
}
+27 -8
View File
@@ -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<MessageActionKey>;
/** 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,
];
}
+16
View File
@@ -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 <style>: a re-sanitized HTML body, or the escaped plain-text one. */
bodyHtml: string;
plain: boolean;
/** An invitation's card: what, when, where and who, as the reader shows it. */
invitation?: InvitationPrint | null;
}
// The reader's image rule: cap only images that set no max-width of their own.
@@ -66,6 +69,19 @@ export function MessagePrint({ fields }: { fields: MessagePrintFields }) {
))}
</dl>
</header>
{fields.invitation && fields.invitation.rows.length > 0 && (
<section className="pr-invitation" data-message-print-invitation="">
<h2 className="pr-invitation-heading">{fields.invitation.heading}</h2>
<dl className="pr-message-meta">
{fields.invitation.rows.map(([label, value]) => (
<div key={label}>
<dt>{label}:</dt>
<dd dir="auto">{value}</dd>
</div>
))}
</dl>
</section>
)}
<div className="pr-message-body" data-message-print-body="" ref={fillBody} />
</article>
</PrintRoot>
+256 -44
View File
@@ -1,9 +1,21 @@
"use client";
import { useTranslations } from "next-intl";
import { Archive, Ban, File, Folder, Inbox, Send, Trash2, type AppIcon } from "@/components/icons";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Archive, Ban, Clock, File, Folder, FolderSearch, Inbox, Search, Send, Trash2, Users, type AppIcon } from "@/components/icons";
import { ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { localizeMailboxName } from "@/lib/mailbox-label";
import type { MailboxNode } from "@/lib/utils";
import type { Email, Mailbox } from "@/lib/jmap/types";
import {
buildMoveMenu,
countTargets,
filterMoveNodes,
firstEnabled,
MOVE_FILTER_THRESHOLD,
type MoveMenuModel,
type MoveMenuNode,
} from "@/lib/move-menu";
import { noteMoveTarget, readRecentMoveTargets } from "@/lib/move-recent";
import { cn } from "@/lib/utils";
/** A folder's icon in a Move list, the sidebar's for the folders with a role. */
@@ -19,58 +31,258 @@ export function moveMailboxIcon(role?: string | null): AppIcon {
}
}
interface MoveTargetListProps {
/** From buildMoveTargets. */
tree: MailboxNode[];
targetIds: Set<string>;
type MoveMenuEmails = Array<Pick<Email, "mailboxIds" | "sourceAccountId">>;
/** The Move list for these messages, with this device's recent folders. */
export function useMoveMenu(
mailboxes: Mailbox[],
opts: { currentMailboxId?: string | null; ownerAccountId?: string; emails?: MoveMenuEmails },
): MoveMenuModel {
const locale = useLocale();
const { currentMailboxId, ownerAccountId, emails } = opts;
return useMemo(
() => buildMoveMenu(mailboxes, { currentMailboxId, ownerAccountId, emails, locale, recentIds: readRecentMoveTargets }),
[mailboxes, currentMailboxId, ownerAccountId, emails, locale],
);
}
/** Whether the list is long enough for a filter field and "Choose folder…". */
export function moveMenuIsLong(model: MoveMenuModel): boolean {
return model.targetCount > MOVE_FILTER_THRESHOLD;
}
export function moveMenuIsEmpty(model: MoveMenuModel): boolean {
return model.targetCount === 0 && model.others.length === 0;
}
interface MoveFolderListProps {
model: MoveMenuModel;
onPick: (mailboxId: string) => void;
/** `menu` for a dropdown, `panel` for the phone's full-width panel (44px rows). */
variant?: "menu" | "panel";
/**
* `menu`: inside a ContextMenu; other accounts are submenus.
* `dialog`: a list in a dialog; other accounts follow under their names.
* `panel`: the phone's full-width panel (44px rows).
*/
variant?: "menu" | "dialog" | "panel";
/** Opens the Move dialog. Offered at the end of a long list, since a native menu has no filter field. */
onChooseFolder?: () => void;
/** Always show the filter field (the dialog). */
alwaysFilter?: boolean;
/** Stable hook for integration tests: each row is `<prefix><mailbox id>`. */
testIdPrefix?: string;
/** Don't keep the pick as a recent folder (a copy, not a move). */
noRecent?: boolean;
}
/**
* The folders a message can be moved to, as the reader's Move dropdown, its ⋮
* and the phone panel show them: the names the sidebar shows (localized
* Sent, Spam, Trash), subfolders indented on the reading side.
* The folders messages can be moved to (lib/move-menu.ts): recent ones, the
* account's own folders nested and in mail-client order, the open folder
* greyed, other reachable accounts under their names, and a filter field
* once the list is long.
*
* Each folder with subfolders is wrapped in `data-native-submenu`: the
* desktop app's native copy of the menu makes it a submenu there, the folder
* itself first (lib/desktop/native-menu.ts).
*/
export function MoveTargetList({ tree, targetIds, onPick, variant = "menu" }: MoveTargetListProps) {
export function MoveFolderList({
model,
onPick,
variant = "menu",
onChooseFolder,
alwaysFilter = false,
testIdPrefix = "move-to:",
noRecent = false,
}: MoveFolderListProps) {
const t = useTranslations("menuMove");
const tSidebar = useTranslations("sidebar");
const locale = useLocale();
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const panel = variant === "panel";
const indent = (depth: number) => `${(panel ? 1 : 0.75) + depth}rem`;
const showFilter = alwaysFilter || moveMenuIsLong(model);
const render = (nodes: MailboxNode[], depth: number): React.ReactNode =>
nodes.map((node) => {
const Icon = moveMailboxIcon(node.role);
const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`));
const rowClass = panel
? "w-full pe-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3"
: "w-full pe-3 py-1.5 text-sm text-start flex items-center gap-2 pointer-coarse:min-h-11";
const icon = <Icon className={cn(panel ? "w-5 h-5" : "w-4 h-4", "flex-shrink-0")} />;
const labelOf = (node: Pick<MoveMenuNode, "role" | "name">) =>
localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`));
const nodes = filterMoveNodes(model.nodes, query, labelOf, locale);
const others = model.others
.map((account) => ({ ...account, nodes: filterMoveNodes(account.nodes, query, labelOf, locale) }))
.filter((account) => countTargets(account.nodes) > 0);
const filtering = query.trim().length > 0;
const pick = (id: string) => {
if (!noRecent) noteMoveTarget(model.accountId, id);
onPick(id);
};
// The filter takes the focus as the list shows, so typing narrows it at
// once. It can be hidden for a frame while its panel is placed.
useEffect(() => {
if (!showFilter || panel) return;
let tries = 0;
let frame = 0;
const attempt = () => {
const input = inputRef.current;
if (!input) return;
const visible = input.getClientRects().length > 0 && getComputedStyle(input).visibility !== "hidden";
if (visible) {
input.focus({ preventScroll: true });
return;
}
if (++tries < 20) frame = requestAnimationFrame(attempt);
};
frame = requestAnimationFrame(attempt);
return () => cancelAnimationFrame(frame);
}, [showFilter, panel]);
const row = (node: MoveMenuNode, label: string, key: string, title?: string) => {
const Icon = moveMailboxIcon(node.role);
const rowTitle = title ?? (node.current ? t("current_folder", { folder: label }) : label);
if (panel) {
return (
<div key={node.id}>
{targetIds.has(node.id) ? (
<button
type="button"
role="menuitem"
data-mailbox-id={node.id}
onClick={() => onPick(node.id)}
className={cn(rowClass, "hover:bg-muted focus-visible:bg-muted focus-visible:outline-none")}
style={{ paddingInlineStart: indent(depth) }}
title={label}
>
{icon}
<span className="truncate" dir="auto">{label}</span>
</button>
) : (
<div className={cn(rowClass, "text-muted-foreground")} style={{ paddingInlineStart: indent(depth) }}>
{icon}
<span className="truncate" dir="auto" title={label}>{label}</span>
</div>
)}
{node.children.length > 0 && render(node.children, depth + 1)}
<button
key={key}
type="button"
role="menuitem"
data-mailbox-id={node.id}
data-testid={`${testIdPrefix}${node.id}`}
disabled={!node.enabled}
onClick={() => pick(node.id)}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none disabled:text-muted-foreground disabled:hover:bg-transparent"
title={rowTitle}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="truncate" dir="auto" data-menu-label="">{label}</span>
</button>
);
}
return (
<ContextMenuItem
key={key}
icon={Icon}
label={label}
title={rowTitle}
disabled={!node.enabled}
testId={`${testIdPrefix}${node.id}`}
onClick={() => pick(node.id)}
/>
);
};
const tree = (list: MoveMenuNode[]): React.ReactNode =>
list.map((node) => {
const label = labelOf(node);
if (node.children.length === 0) return row(node, label, node.id);
return (
<div key={node.id} data-native-submenu="">
{row(node, label, node.id)}
<div className={panel ? "ps-6" : "ps-4"}>{tree(node.children)}</div>
</div>
);
});
return <>{render(tree, 0)}</>;
const recent = !filtering && !noRecent ? model.recent : [];
const nothing = nodes.length === 0 && others.length === 0;
const separator = panel ? <div className="h-px bg-border my-1" role="separator" /> : <ContextMenuSeparator />;
return (
<div data-move-folder-list="">
{showFilter && (
<div className={cn("sticky top-0 z-10 bg-background", panel ? "px-4 py-2" : "px-2 pt-1 pb-1")}>
{/* The field is the whole box, so the app's focus ring goes round all of it. */}
<div className="relative">
<Search className="pointer-events-none absolute start-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
e.stopPropagation();
const first = firstEnabled(nodes) ?? others.map((a) => firstEnabled(a.nodes)).find(Boolean);
if (first) pick(first.id);
}}
placeholder={t("filter_placeholder")}
aria-label={t("filter_placeholder")}
className={cn("focus-inset w-full min-w-0 rounded-md border border-border bg-transparent ps-8 pe-2 text-sm", panel ? "h-10" : "h-8")}
data-testid="move-filter"
/>
</div>
</div>
)}
{recent.length > 0 && (
<div data-move-recent="">
{panel ? (
<div className="px-4 pt-3 pb-1 text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{t("recent")}
</div>
) : (
<ContextMenuHeader>{t("recent")}</ContextMenuHeader>
)}
{recent.map((node) => {
const label = [...node.path, labelOf(node)].join(" / ");
return row({ ...node, children: [] }, label, `recent-${node.id}`, label);
})}
{separator}
</div>
)}
{tree(nodes)}
{nothing && (
<div className={cn("text-sm text-muted-foreground", panel ? "px-4 py-3" : "px-3 py-2")}>{t("no_match")}</div>
)}
{others.length > 0 && (variant === "menu" ? (
<>
{nodes.length > 0 && <ContextMenuSeparator />}
{others.map((account) => (
<ContextMenuSubMenu
key={account.accountId}
icon={Users}
label={account.name}
testId={`move-account:${account.accountId}`}
>
{tree(account.nodes)}
</ContextMenuSubMenu>
))}
</>
) : (
others.map((account) => (
<div key={account.accountId} data-move-account={account.accountId}>
{panel ? (
<div className="px-4 pt-3 pb-1 text-xs font-medium text-muted-foreground truncate" title={account.name}>{account.name}</div>
) : (
<ContextMenuHeader>{account.name}</ContextMenuHeader>
)}
{tree(account.nodes)}
</div>
))
))}
{onChooseFolder && moveMenuIsLong(model) && !filtering && (
<>
{separator}
{panel ? (
<button
type="button"
role="menuitem"
onClick={onChooseFolder}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3 hover:bg-muted"
data-testid="move-choose-folder"
>
<FolderSearch className="w-5 h-5 flex-shrink-0" />
<span data-menu-label="">{t("choose_folder")}</span>
</button>
) : (
<ContextMenuItem icon={FolderSearch} label={t("choose_folder")} onClick={onChooseFolder} testId="move-choose-folder" />
)}
</>
)}
</div>
);
}
+72 -21
View File
@@ -1,15 +1,47 @@
"use client";
import { useMemo } from "react";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { create } from "zustand";
import { Dialog } from "@/components/ui/dialog";
import type { Email, Mailbox } from "@/lib/jmap/types";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { MoveTargetList } from "./move-target-list";
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { MoveFolderList, useMoveMenu } from "./move-target-list";
interface MoveDialogRequest {
/** The messages to move. */
emails: Email[];
onMove: (mailboxId: string) => void;
}
interface MoveDialogState {
request: MoveDialogRequest | null;
/** Mounted hosts: without one, nothing offers the dialog. */
hosts: number;
open: (request: MoveDialogRequest) => void;
close: () => void;
}
export const useMoveDialogStore = create<MoveDialogState>((set) => ({
request: null,
hosts: 0,
open: (request) => set({ request }),
close: () => set({ request: null }),
}));
/**
* "Choose folder…" at the end of a long Move list: the dialog with the filter
* field. Undefined where no dialog host is mounted.
*/
export function useChooseMoveFolder(): ((request: MoveDialogRequest) => void) | undefined {
const hosts = useMoveDialogStore((s) => s.hosts);
const open = useMoveDialogStore((s) => s.open);
return hosts > 0 ? open : undefined;
}
interface MoveToDialogProps {
/** The message to move; the dialog is open while it is set. */
email: Email | null;
/** The messages to move; the dialog is open while there are some. */
emails: Email[];
mailboxes: Mailbox[];
selectedMailbox: string;
onClose: () => void;
@@ -17,25 +49,24 @@ interface MoveToDialogProps {
}
/**
* "Move to…" from the command palette: the same folder list as the reader's
* Move menu (the message's own account first), in a dialog.
* Move to…: the same folders as the Move menus (lib/move-menu.ts), in a
* dialog with a filter field. From the command palette, the Message menu and
* "Choose folder…" in a long Move menu.
*/
export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) {
export function MoveToDialog({ emails, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) {
const t = useTranslations("context_menu");
const { tree, targetIds } = useMemo(
() => buildMoveTargets(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox),
emails: email ? [email] : [],
}),
[email, mailboxes, selectedMailbox],
);
const model = useMoveMenu(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: resolveMoveOwnerAccountId(emails[0], mailboxes, selectedMailbox),
emails,
});
return (
<Dialog isOpen={!!email} onClose={onClose} title={t("move_to_dialog")} bodyClassName="px-2" size="sm">
<div role="menu" aria-label={t("move_to_dialog")} className="max-h-[60vh] overflow-y-auto">
<MoveTargetList
tree={tree}
targetIds={targetIds}
<Dialog isOpen={emails.length > 0} onClose={onClose} title={t("move_to_dialog")} bodyClassName="px-2" size="sm">
<div role="menu" aria-label={t("move_to_dialog")} className="max-h-[60vh] overflow-y-auto" data-testid="move-dialog">
<MoveFolderList
variant="dialog"
alwaysFilter
model={model}
onPick={(mailboxId) => {
onClose();
onMove(mailboxId);
@@ -45,3 +76,23 @@ export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMov
</Dialog>
);
}
/** Shows the dialog a Move menu asked for. Mounted once, where the mail is. */
export function MoveToDialogHost({ mailboxes, selectedMailbox }: { mailboxes: Mailbox[]; selectedMailbox: string }) {
const request = useMoveDialogStore((s) => s.request);
const close = useMoveDialogStore((s) => s.close);
useEffect(() => {
useMoveDialogStore.setState((s) => ({ hosts: s.hosts + 1 }));
return () => useMoveDialogStore.setState((s) => ({ hosts: s.hosts - 1, request: s.hosts <= 1 ? null : s.request }));
}, []);
if (!request) return null;
return (
<MoveToDialog
emails={request.emails}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onClose={close}
onMove={request.onMove}
/>
);
}
+17 -70
View File
@@ -1,17 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import {
Archive,
CheckSquare,
Folder,
FolderInput,
FolderX,
Loader2,
Mail,
MailOpen,
MoreVertical,
Search,
ShieldAlert,
ShieldCheck,
Square,
@@ -31,14 +29,15 @@ import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo
import { toast } from "@/stores/toast-store";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useContextMenu } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuHeader, CONTEXT_MENU_ITEM_CLASS } from "@/components/ui/context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuHeader } from "@/components/ui/context-menu";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { TagPicker } from "@/components/email/tag-picker";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "@/components/email/move-target-list";
import { useChooseMoveFolder } from "@/components/email/move-to-dialog";
import { getEmailTagIds } from "@/lib/thread-utils";
import { cn, type MailboxNode } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
import { useTimeoutWhileMounted } from "@/hooks/use-timeout-while-mounted";
@@ -170,8 +169,6 @@ export function MvListToolbar({
const moveOpen = moveMenu.contextMenu.isOpen;
const closeMove = moveMenu.closeContextMenu;
// Gmail's "Move to" menu opens on a search field that narrows the folders.
const [moveQuery, setMoveQuery] = useState("");
useEffect(() => { if (!moveOpen) setMoveQuery(""); }, [moveOpen]);
const tagMenu = useContextMenu<null>();
const tagOpen = tagMenu.contextMenu.isOpen;
const closeTag = tagMenu.closeContextMenu;
@@ -288,58 +285,12 @@ export function MvListToolbar({
// The same targets the row's own context menu offers, with the account of
// the selection leading (#1149).
const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, {
const moveModel = useMoveMenu(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: resolveMoveOwnerAccountId(selectedEmails[0], mailboxes, selectedMailbox),
emails: selectedEmails,
});
const renderMoveNodes = (nodes: MailboxNode[], depth = 0): React.ReactNode =>
nodes.map((node) => {
const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`));
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<button
type="button"
role="menuitem"
onClick={() => handleMove(node.id)}
style={{ paddingInlineStart: `${12 + depth * 16}px` }}
className={CONTEXT_MENU_ITEM_CLASS}
>
<Folder className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{label}</span>
</button>
) : (
<div
style={{ paddingInlineStart: `${12 + depth * 16}px` }}
className="flex items-center gap-2 pe-3 py-1.5 text-sm text-muted-foreground"
>
<Folder className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{label}</span>
</div>
)}
{node.children.length > 0 && renderMoveNodes(node.children, depth + 1)}
</div>
);
});
// While a search narrows the move menu it is a flat list of the matches.
const moveMatches = (() => {
const q = moveQuery.trim().toLowerCase();
if (!q) return null;
const out: MailboxNode[] = [];
const walk = (nodes: MailboxNode[]) => {
for (const node of nodes) {
const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`));
if (moveTargetIds.has(node.id) && label.toLowerCase().includes(q)) out.push({ ...node, children: [] });
walk(node.children);
}
};
walk(moveTree);
return out;
})();
const chooseMoveFolder = useChooseMoveFolder();
// What the tag picker shows: a tag every selected message carries is
// checked, one only some of them carry is drawn as partial.
@@ -560,7 +511,7 @@ export function MvListToolbar({
disabled={isProcessing}
/>
<div className="w-px h-6 bg-border mx-1 shrink-0" />
{moveTree.length > 0 && (
{!moveMenuIsEmpty(moveModel) && (
<div className="relative shrink-0">
<button
type="button"
@@ -581,18 +532,14 @@ export function MvListToolbar({
<ContextMenu ref={moveMenu.menuRef} isOpen={moveOpen} position={moveMenu.contextMenu.position} onClose={closeMove}>
<div className="min-w-64" data-mv-move-menu="">
<ContextMenuHeader>{tMenu("move_to")}</ContextMenuHeader>
<label className="mx-3 my-1 flex items-center gap-2 border-b border-border">
<input
value={moveQuery}
onChange={(e) => setMoveQuery(e.target.value)}
className="h-8 min-w-0 flex-1 bg-transparent text-sm focus:outline-none"
aria-label={tMenu("move_to")}
/>
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
</label>
<div className="max-h-72 overflow-y-auto">
{moveMatches ? renderMoveNodes(moveMatches) : renderMoveNodes(moveTree)}
</div>
<MoveFolderList
model={moveModel}
alwaysFilter
onPick={handleMove}
onChooseFolder={chooseMoveFolder
? () => { closeMove(); chooseMoveFolder({ emails: selectedEmails, onMove: handleMove }); }
: undefined}
/>
{onCreateFolder && (
<>
<ContextMenuSeparator />
+4 -2
View File
@@ -252,12 +252,14 @@ interface RecipientPopoverProps {
email: string;
/** Display label override (e.g. "me") */
displayLabel?: string;
/** Rich content in place of the text label, such as a name over its address. */
label?: React.ReactNode;
/** Called when user clicks "View contact" - receives the contact and email */
onViewContact?: (contact: ContactCard | null, email: string) => void;
className?: string;
}
export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) {
export function RecipientPopover({ name, email, displayLabel, label, onViewContact, className }: RecipientPopoverProps) {
const [anchor, setAnchor] = useState<CardAnchor | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const shownEmail = useOwnDomainAddress()(email);
@@ -286,7 +288,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
className
)}
>
<bdi>{displayLabel || name || shownEmail}</bdi>
{label ?? <bdi>{displayLabel || name || shownEmail}</bdi>}
</button>
{anchor && (
+18 -1
View File
@@ -1,7 +1,7 @@
"use client";
import React, { useEffect, useCallback, useLayoutEffect, useState, useRef } from "react";
import { popoverShift } from "@/lib/popover-shift";
import { popoverFitHeight, popoverShift } from "@/lib/popover-shift";
import { createPortal } from "react-dom";
import { useEditor, useEditorState, EditorContent, type Editor } from "@tiptap/react";
import type { SuggestionProps } from "@tiptap/suggestion";
@@ -251,6 +251,23 @@ function ToolbarPopover({
right: Math.min(box.right, window.innerWidth),
});
if (shift !== 0) el.style.transform = `translateX(${shift}px)`;
// Too tall for the room under its button (a short window, 150 % zoom): above
// it when there's more room there, and scrolling inside either way.
const trigger = triggerRef.current?.getBoundingClientRect();
const rect = el.getBoundingClientRect();
const fit = trigger ? popoverFitHeight({ top: rect.top, height: rect.height }, trigger, window.innerHeight) : null;
if (fit) {
if (fit.above) {
el.style.top = "auto";
el.style.bottom = "100%";
el.style.marginTop = "0";
el.style.marginBottom = "0.25rem";
}
el.style.maxHeight = `${fit.maxHeight}px`;
el.style.overflowY = "auto";
}
// triggerRef is a ref: it doesn't change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const close = (returnFocus: boolean) => {
+25 -19
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import type { Email, Mailbox } from "@/lib/jmap/types";
import { ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import {
@@ -27,6 +27,7 @@ import {
import { useRouter } from "@/i18n/navigation";
import { buildMailboxTree, cn, generateUUID, type MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { sortFolderTree } from "@/lib/move-menu";
import { useAuthStore } from "@/stores/auth-store";
import { useFilterStore } from "@/stores/filter-store";
import { useQuickRuleStore, type FiltersStatus } from "@/stores/quick-rule-store";
@@ -173,7 +174,12 @@ function useRulesModel(anchor: Email, senderEmails: Email[], target: QuickRuleTa
}), [senders, listIds]);
const targetIds = useMemo(() => ruleTargetMailboxIds(target.mailboxes), [target]);
const tree = useMemo(() => filterTree(buildMailboxTree(target.mailboxes), targetIds), [target, targetIds]);
// In the order every Move list has (lib/move-menu.ts).
const locale = useLocale();
const tree = useMemo(
() => sortFolderTree(filterTree(buildMailboxTree(target.mailboxes), targetIds), locale),
[target, targetIds, locale],
);
const junk = useMemo(() => findJunkMailbox(target.mailboxes), [target]);
const sourceMailboxId = useMemo(() => sourceMailboxOf(anchor, target)?.id ?? null, [anchor, target]);
@@ -248,21 +254,18 @@ function ContextFolderItems({
const render = (nodes: MailboxNode[]): React.ReactNode => nodes.map((node) => {
const Icon = mailboxIcon(node.role);
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
// Greyed, not plain text, so the desktop app's native copy keeps it as
// the head of its subfolders' submenu (data-native-submenu).
const enabled = model.targetIds.has(node.id);
return (
<div key={node.id}>
{model.targetIds.has(node.id) ? (
<ContextMenuItem
icon={Icon}
label={label}
testId={`rules:${kind}:${node.id}`}
onClick={() => { model.moveTo(kind, node); onDone(); }}
/>
) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{label}</span>
</div>
)}
<div key={node.id} data-native-submenu={node.children.length > 0 ? "" : undefined}>
<ContextMenuItem
icon={Icon}
label={label}
disabled={!enabled}
testId={`rules:${kind}:${node.id}`}
onClick={() => { model.moveTo(kind, node); onDone(); }}
/>
{node.children.length > 0 && <div className="ps-4">{render(node.children)}</div>}
</div>
);
@@ -407,10 +410,13 @@ export function RulesContextSubMenu({
anchor,
senderEmails,
onClose,
disabled = false,
}: {
anchor: Email;
senderEmails: Email[];
onClose: () => void;
/** Rules are saved on the server: greyed out offline. */
disabled?: boolean;
}) {
const t = useTranslations("context_menu");
const accountEmails = useMemo(
@@ -424,7 +430,7 @@ export function RulesContextSubMenu({
icon={Filter}
label={t("rules.title")}
testId="ctx-rules"
disabled={availability === "cross_account"}
disabled={disabled || availability === "cross_account"}
hint={availability === "cross_account" ? t("rules.cross_account") : undefined}
>
{target && <ContextRulesContent anchor={anchor} senderEmails={senderEmails} target={target} onDone={onClose} />}
@@ -801,7 +807,7 @@ function FolderRulesContent({
* saved rules over the mail already in the folder (#1166). Hidden for shared
* folders and accounts without Sieve.
*/
export function FolderRulesSubMenu({ mailbox, onClose }: { mailbox: Mailbox; onClose: () => void }) {
export function FolderRulesSubMenu({ mailbox, onClose, disabled = false }: { mailbox: Mailbox; onClose: () => void; disabled?: boolean }) {
const t = useTranslations("context_menu");
const client = useAuthStore((s) => s.client);
const target = useMemo(
@@ -810,7 +816,7 @@ export function FolderRulesSubMenu({ mailbox, onClose }: { mailbox: Mailbox; onC
);
if (!target || !target.supportsSieve) return null;
return (
<ContextMenuSubMenu icon={Filter} label={t("rules.title")} testId="mailbox-rules">
<ContextMenuSubMenu icon={Filter} label={t("rules.title")} testId="mailbox-rules" disabled={disabled}>
<FolderRulesContent mailbox={mailbox} target={target} onDone={onClose} />
</ContextMenuSubMenu>
);
+2
View File
@@ -692,6 +692,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onDoubleClick();
}}
onContextMenu={handleContextMenu}
data-menu-row=""
style={{ minHeight: oneLineRow ? undefined : 'var(--list-item-height)', touchAction: swipeEnabled ? 'pan-y' : undefined }}
>
{showRowActionButton && onContextMenu && (
@@ -1402,6 +1403,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onEmailDoubleClick(latestEmail);
}}
onContextMenu={handleContextMenu}
data-menu-row=""
style={{ minHeight: oneLineRow ? undefined : 'var(--list-item-height)', touchAction: threadSwipeEnabled ? 'pan-y' : undefined }}
>
{showRowActionButton && onContextMenu && (
@@ -0,0 +1,312 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// The overlay on every screen: what shows for a drag from outside, where a drop goes, and
// the same choices as a dialog for a pasted file or one opened with the desktop app.
const hoisted = vi.hoisted(() => ({
push: vi.fn(),
pathname: { current: '/' },
toastError: vi.fn(),
toastInfo: vi.fn(),
toastSuccess: vi.fn(),
locked: { current: false },
flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })),
}));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: hoisted.push, replace: vi.fn(), back: vi.fn() }),
usePathname: () => hoisted.pathname.current,
}));
vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: hoisted.toastInfo, success: hoisted.toastSuccess } }));
vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers }));
vi.mock('@/lib/desktop/app-lock-state', () => ({ isAppLocked: () => hoisted.locked.current }));
const { useAuthStore } = await import('@/stores/auth-store');
const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
const { setComposerFileTarget, setOwnDragActive } = await import('@/lib/desktop/window-files');
const { setFilesDropTarget, takeQueuedFilesUpload } = await import('@/lib/file-drop/files-target');
const { requestFileChoice } = await import('@/lib/file-drop/choice');
const { FileDropHost } = await import('../file-drop-host');
interface FakeClient {
supportsFiles: () => boolean;
supportsContacts: () => boolean;
getMaxSizeUpload: () => number;
getMaxSizeAttachmentsPerEmail: () => number;
}
function signIn(client: Partial<FakeClient> = {}) {
useAuthStore.setState({
isAuthenticated: true,
activeAccountId: null,
client: {
supportsFiles: () => true,
supportsContacts: () => false,
getMaxSizeUpload: () => 0,
getMaxSizeAttachmentsPerEmail: () => 0,
...client,
} as never,
});
}
function transfer(files: File[]) {
return {
types: ['Files'],
files,
items: files.map((file) => ({ kind: 'file', type: file.type, getAsFile: () => file, webkitGetAsEntry: () => ({ isFile: true, isDirectory: false }) })),
dropEffect: 'none',
};
}
/** A drag from outside reaching the window, as the browser sends it. */
function dragIn(files: File[]) {
const event = new Event('dragenter', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: transfer(files) });
act(() => {
document.body.dispatchEvent(event);
});
}
function zone(id: string): HTMLElement {
const found = document.querySelector<HTMLElement>(`[data-testid="file-drop-overlay"] [data-drop-zone="${id}"]`);
if (!found) throw new Error(`no zone ${id}`);
return found;
}
const flush = () => act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const file = new File(['hello'], 'report.pdf', { type: 'application/pdf' });
beforeEach(() => {
clearHandoffs();
takeHandoff('compose');
takeQueuedFilesUpload();
hoisted.locked.current = false;
hoisted.pathname.current = '/';
vi.clearAllMocks();
signIn();
});
afterEach(() => {
setOwnDragActive(false);
useAuthStore.setState({ isAuthenticated: false, client: null });
});
describe('a drag from outside', () => {
it('covers the window with the two main choices and says how many files', () => {
render(<FileDropHost />);
dragIn([file, file]);
expect(screen.getByTestId('file-drop-overlay')).toBeTruthy();
expect(zone('new-message').textContent).toContain('zone_new_message');
expect(zone('upload').textContent).toContain('zone_upload_files');
expect(screen.getByTestId('file-drop-count').textContent).toBe('files_summary:{"count":2}');
});
it('a drop on "Attach to a new message" starts a draft with the file', async () => {
render(<FileDropHost />);
dragIn([file]);
fireEvent.drop(zone('new-message'), { dataTransfer: transfer([file]) });
await flush();
expect(takeHandoff('compose')?.files).toEqual([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
it('with a composer on screen, its zone comes first and takes the file', async () => {
const take = vi.fn();
const clear = setComposerFileTarget(take, () => true);
render(<FileDropHost />);
dragIn([file]);
const zones = Array.from(document.querySelectorAll('[data-drop-zone]')).map((element) => element.getAttribute('data-drop-zone'));
expect(zones[0]).toBe('attach-here');
fireEvent.drop(zone('attach-here'), { dataTransfer: transfer([file]) });
await flush();
expect(take).toHaveBeenCalledWith([file]);
expect(takeHandoff('compose')).toBeNull();
clear();
});
it('a picture dropped on "Insert into the message" goes into the text, as a paste would', async () => {
const take = vi.fn();
const insert = vi.fn();
const clear = setComposerFileTarget(take, () => true, { available: () => true, insert });
const picture = new File(['png'], 'chart.png', { type: 'image/png' });
render(<FileDropHost />);
dragIn([picture]);
expect(zone('insert-inline').textContent).toContain('zone_insert_inline');
fireEvent.drop(zone('insert-inline'), { dataTransfer: transfer([picture]) });
await flush();
expect(insert).toHaveBeenCalledWith([picture]);
expect(take).not.toHaveBeenCalled();
clear();
});
it('on the Files screen, "Upload to <folder>" hands the files to that folder', async () => {
const take = vi.fn();
const clear = setFilesDropTarget({ folder: 'Reports', take, visible: () => true });
render(<FileDropHost />);
dragIn([file]);
expect(zone('upload').textContent).toContain('zone_upload_folder:{"folder":"Reports"}');
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
await flush();
expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false });
clear();
});
it('from another screen, Files gets the files after the app goes there', async () => {
render(<FileDropHost />);
dragIn([file]);
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
await flush();
expect(hoisted.push).toHaveBeenCalledWith('/files');
expect(takeQueuedFilesUpload()).toEqual({ files: [file], asFolder: false });
});
it('a drop beside the zones does nothing', async () => {
render(<FileDropHost />);
dragIn([file]);
fireEvent.drop(screen.getByTestId('file-drop-overlay'), { dataTransfer: transfer([file]) });
await flush();
expect(takeHandoff('compose')).toBeNull();
expect(takeQueuedFilesUpload()).toBeNull();
});
it('a file over the server limit is named and left out before anything starts', async () => {
signIn({ getMaxSizeUpload: () => 3 });
render(<FileDropHost />);
dragIn([file]);
expect(zone('upload').textContent).toContain('note_limit_file');
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
await flush();
expect(hoisted.toastError).toHaveBeenCalledWith(expect.stringContaining('too_large_file'));
expect(takeQueuedFilesUpload()).toBeNull();
});
it('the app\'s own drags never bring it up', () => {
render(<FileDropHost />);
act(() => {
document.body.dispatchEvent(new Event('dragstart', { bubbles: true }));
});
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
act(() => {
document.body.dispatchEvent(new Event('pointermove', { bubbles: true }));
});
setOwnDragActive(true);
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
it('nothing while the app is locked, signed out, or behind a dialog of its own', () => {
render(<FileDropHost />);
hoisted.locked.current = true;
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
hoisted.locked.current = false;
const dialog = document.body.appendChild(document.createElement('div'));
dialog.setAttribute('aria-modal', 'true');
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
dialog.remove();
useAuthStore.setState({ isAuthenticated: false });
dragIn([file]);
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
it('leaves Files out on a server without it', () => {
signIn({ supportsFiles: () => false });
render(<FileDropHost />);
dragIn([file]);
expect(document.querySelector('[data-drop-zone="upload"]')).toBeNull();
expect(zone('new-message')).toBeTruthy();
});
it('dragging out of the window takes it away', () => {
render(<FileDropHost />);
dragIn([file]);
const leave = new Event('dragleave', { bubbles: true });
Object.defineProperty(leave, 'relatedTarget', { value: null });
act(() => {
screen.getByTestId('file-drop-overlay').dispatchEvent(leave);
});
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
});
});
describe('the same choices without a drag', () => {
function paste(target: EventTarget, files: File[], types = ['Files']) {
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { files, types } });
act(() => {
target.dispatchEvent(event);
});
return event;
}
it('a file pasted outside a text field asks, with the focus on a new message, and Enter takes it', async () => {
render(<FileDropHost />);
expect(paste(document.body, [file]).defaultPrevented).toBe(true);
const dialog = await screen.findByTestId('file-drop-choice');
await waitFor(() => expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('new-message'));
expect(dialog.querySelector('[data-drop-zone="upload"]')).toBeTruthy();
fireEvent.click(document.activeElement as HTMLElement);
await flush();
expect(takeHandoff('compose')?.files).toEqual([file]);
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
});
it('arrow keys move between the choices', async () => {
render(<FileDropHost />);
paste(document.body, [file]);
await screen.findByTestId('file-drop-choice');
await waitFor(() => expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('new-message'));
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowRight' });
expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('upload');
});
it('with only one thing to do, it does it without asking', async () => {
signIn({ supportsFiles: () => false });
render(<FileDropHost />);
paste(document.body, [file]);
await flush();
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
expect(takeHandoff('compose')?.files).toEqual([file]);
});
it('pasted text, or a file in a search field, is left alone', () => {
render(<FileDropHost />);
expect(paste(document.body, [file], ['text/plain', 'Files']).defaultPrevented).toBe(false);
const input = document.body.appendChild(document.createElement('input'));
expect(paste(input, [file]).defaultPrevented).toBe(false);
input.remove();
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
});
it('a bare file pasted into a composer on screen attaches to it', () => {
const take = vi.fn();
const clear = setComposerFileTarget(take, () => true);
render(<FileDropHost />);
const composer = document.body.appendChild(document.createElement('div'));
composer.setAttribute('data-testid', 'email-composer');
const body = composer.appendChild(document.createElement('div'));
body.setAttribute('contenteditable', 'true');
paste(body, [file]);
expect(take).toHaveBeenCalledWith([file]);
composer.remove();
clear();
});
it('a file opened with the desktop app gets the dialog, and waits for a sign-in', async () => {
useAuthStore.setState({ isAuthenticated: false });
render(<FileDropHost />);
act(() => requestFileChoice({ files: [file], preferred: 'new-message' }));
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
act(() => signIn());
expect(await screen.findByTestId('file-drop-choice')).toBeTruthy();
});
});
+335
View File
@@ -0,0 +1,335 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { usePathname, useRouter } from "@/i18n/navigation";
import { Dialog } from "@/components/ui/dialog";
import { Upload } from "@/components/icons";
import { announce } from "@/lib/announce";
import { flushComposers } from "@/lib/compose-flush-registry";
import { matchSurface } from "@/lib/deep-links";
import { isAppLocked } from "@/lib/desktop/app-lock-state";
import { pasteTarget, visibleComposer } from "@/lib/desktop/window-files";
import { runFileDropZone, type FileDropEnv } from "@/lib/file-drop/actions";
import { listenForFileChoices, type FileChoiceRequest } from "@/lib/file-drop/choice";
import { startDragWatch } from "@/lib/file-drop/drag-watch";
import { filesDropTarget } from "@/lib/file-drop/files-target";
import { readDroppedFiles, type DroppedFiles } from "@/lib/file-drop/read";
import {
defaultZone,
fileDropZones,
filesWithinLimits,
kindForName,
type DropFiles,
type DropPlace,
type FileDropZone,
type FileDropZoneId,
} from "@/lib/file-drop/zones";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { isOfflineNow, useOfflineStore } from "@/lib/offline/state";
import { isPopoutPath } from "@/lib/popout/window-role";
import { formatFileSize } from "@/lib/utils";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { toast } from "@/stores/toast-store";
import { useZoneText, ZoneCard, zoneGridClass } from "./file-drop-zones";
import { cn } from "@/lib/utils";
/** How long leaving the page may take to save an open composer. */
const FLUSH_BEFORE_LEAVING_MS = 5000;
const NAMES_SHOWN = 3;
/** Where a dropped .eml could go: the real folder in view on the mail screen, if new mail may go in. */
function importFolder(pathname: string, translate: (key: string) => string): { id: string; name: string; accountId?: string } | null {
if (matchSurface(pathname)?.surface !== "mail") return null;
const { selectedMailbox, mailboxes } = useEmailStore.getState();
const mailbox = mailboxes.find((entry) => entry.id === selectedMailbox);
if (!mailbox || mailbox.isShared || mailbox.myRights?.mayAddItems === false) return null;
return { id: mailbox.id, name: localizeMailboxName(mailbox.role, mailbox.name, translate), accountId: mailbox.accountId };
}
/** Whatever would stop a drop now. A dialog of its own (an import, a picker) keeps its own drop zone. */
function overlayBlocked(): boolean {
if (isAppLocked() || !useAuthStore.getState().isAuthenticated) return true;
const modal = document.querySelector('[aria-modal="true"]');
return !!modal && !modal.querySelector('[data-testid="email-composer"]');
}
/** What can be done with files here and now. Read at the moment of the drag, not kept. */
function currentPlace(pathname: string, translate: (key: string) => string): { place: DropPlace; folder: ReturnType<typeof importFolder> } {
const auth = useAuthStore.getState();
const client = auth.client;
const features = usePolicyStore.getState();
const account = auth.activeAccountId ? useAccountStore.getState().accounts.find((entry) => entry.id === auth.activeAccountId) : undefined;
const folder = importFolder(pathname, translate);
const place: DropPlace = {
composer: !!visibleComposer(),
inlineImages: !!visibleComposer()?.images?.available(),
popout: isPopoutPath(window.location.pathname),
files: !!client?.supportsFiles?.() && features.isFeatureEnabled("filesEnabled"),
filesFolder: filesDropTarget()?.folder ?? null,
calendar: useCalendarStore.getState().supportsCalendar && features.isFeatureEnabled("calendarEnabled"),
contacts: !!client?.supportsContacts?.() && features.isFeatureEnabled("contactsEnabled"),
importFolder: folder?.name ?? null,
offline: isOfflineNow(account?.serverUrl),
keepsOffline: !!auth.activeAccountId && useOfflineStore.getState().enabled.includes(auth.activeAccountId),
maxUploadBytes: client?.getMaxSizeUpload?.() ?? 0,
maxMessageBytes: client?.getMaxSizeAttachmentsPerEmail?.() ?? 0,
};
return { place, folder };
}
interface DragState {
files: DropFiles;
zones: FileDropZone[];
place: DropPlace;
folder: ReturnType<typeof importFolder>;
}
interface ChoiceState extends DragState {
list: File[];
preferred?: FileDropZoneId;
}
function asDropped(files: File[]): DroppedFiles {
return { plain: files, all: files, folders: false };
}
function summaryOf(files: readonly File[]): DropFiles {
return { count: files.length, kinds: files.map((file) => kindForName(file)), bytes: files.reduce((sum, file) => sum + file.size, 0) };
}
/**
* The file drop overlay on every screen: files dragged in from outside get big zones to drop
* on, the same zones come up as a dialog for a pasted file or a file opened with the desktop
* app. Mounted once in the locale layout, in pop-outs too, where only their own composer
* takes files.
*/
export function FileDropHost() {
const t = useTranslations("fileDrop");
const tSidebar = useTranslations("sidebar");
const { label, note } = useZoneText();
const router = useRouter();
const pathname = usePathname();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [drag, setDrag] = useState<DragState | null>(null);
const [active, setActive] = useState<FileDropZoneId | null>(null);
const [choice, setChoice] = useState<ChoiceState | null>(null);
const [pendingChoices, setPendingChoices] = useState<FileChoiceRequest[]>([]);
const defaultRef = useRef<HTMLButtonElement & HTMLDivElement>(null);
const ctx = useRef({ pathname, router, t, tSidebar, label });
ctx.current = { pathname, router, t, tSidebar, label };
const translateMailbox = useCallback((key: string) => ctx.current.tSidebar(`mailboxes.${key}`), []);
const countText = useCallback((files: DropFiles) => {
if (files.count === null) return t("files_unknown");
if (files.bytes !== null) return t("files_summary_size", { count: files.count, size: formatFileSize(files.bytes) });
return t("files_summary", { count: files.count });
}, [t]);
const env = useCallback((folder: ReturnType<typeof importFolder>): FileDropEnv => ({
goTo: async (path) => {
const result = await flushComposers(FLUSH_BEFORE_LEAVING_MS);
if (result.failed > 0 || result.timedOut) {
toast.error(ctx.current.t("save_first_failed"));
return;
}
if (ctx.current.pathname !== path) ctx.current.router.push(path);
},
importMessages: async (files) => {
const client = useAuthStore.getState().client;
if (!client || !folder) return;
let done = 0;
for (const file of files) {
try {
await client.importRawEmail(file, { [folder.id]: true }, { $seen: true }, folder.accountId);
done += 1;
} catch (error) {
console.error("Importing a dropped message failed:", error);
toast.error(ctx.current.t("import_failed", { name: file.name }));
}
}
if (done > 0) toast.success(ctx.current.t("import_done", { count: done, folder: folder.name }));
},
}), []);
/** Checks what only the files in hand can tell, then hands them over. */
const run = useCallback((zone: FileDropZoneId, dropped: DroppedFiles, state: DragState) => {
const upload = zone === "upload";
if (!upload && dropped.folders) toast.info(ctx.current.t("folders_left_out"));
const { fitting, tooLarge, overMessage } = filesWithinLimits(zone, upload ? dropped.all : dropped.plain, state.place);
for (const file of tooLarge) toast.error(ctx.current.t("too_large_file", { name: file.name, max: formatFileSize(state.place.maxUploadBytes) }));
if (overMessage) toast.error(ctx.current.t("too_large_message", { max: formatFileSize(state.place.maxMessageBytes) }));
if (fitting.length === 0) return;
const what = upload ? { files: fitting, asFolder: dropped.folders } : undefined;
void runFileDropZone(zone, fitting, env(state.folder), what).catch((error) => {
console.error("A file drop failed:", error);
});
}, [env]);
// Files dragged in from outside, anywhere.
useEffect(() => {
return startDragWatch({
enter: (files) => {
if (overlayBlocked()) return false;
const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox);
const zones = fileDropZones(files, place);
if (zones.length === 0) return false;
setActive(null);
setDrag({ files, zones, place, folder });
const choices = zones.map((zone) => ctx.current.label(zone)).join(", ");
announce(ctx.current.t("announce", { files: files.count ?? 0, choices }));
return true;
},
leave: () => {
setDrag(null);
setActive(null);
},
});
}, [translateMailbox]);
// A file pasted outside a text field: the same choices, from the keyboard.
useEffect(() => {
const onPaste = (event: ClipboardEvent) => {
const where = pasteTarget(event);
if (!where) return;
const files = Array.from(event.clipboardData?.files ?? []);
if (where === "composer") {
event.preventDefault();
visibleComposer()?.take(files);
return;
}
if (overlayBlocked()) return;
event.preventDefault();
setPendingChoices((list) => [...list, { files, preferred: visibleComposer() ? "attach-here" : "new-message" }]);
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, []);
// Files opened with the desktop app.
useEffect(() => listenForFileChoices((request) => setPendingChoices((list) => [...list, request])), []);
// One question at a time, and none while signed out or locked.
useEffect(() => {
if (choice || pendingChoices.length === 0 || !isAuthenticated || isAppLocked()) return;
const [next, ...rest] = pendingChoices;
setPendingChoices(rest);
const { place, folder } = currentPlace(ctx.current.pathname, translateMailbox);
const files = summaryOf(next.files);
const zones = fileDropZones(files, place);
const usable = zones.filter((zone) => !zone.disabled);
if (usable.length === 1) {
run(usable[0].id, asDropped(next.files), { files, zones, place, folder });
return;
}
if (zones.length === 0) {
toast.error(ctx.current.t("nothing_here"));
return;
}
setChoice({ files, zones, place, folder, list: next.files, preferred: next.preferred });
}, [choice, pendingChoices, isAuthenticated, run, translateMailbox]);
const pick = (zone: FileDropZone) => {
if (!choice || zone.disabled) return;
const state = choice;
setChoice(null);
run(zone.id, asDropped(state.list), state);
};
const zoneAt = (target: EventTarget | null): FileDropZone | null => {
const element = (target as Element | null)?.closest?.("[data-drop-zone]");
const id = element?.getAttribute("data-drop-zone");
return drag?.zones.find((zone) => zone.id === id) ?? null;
};
const overlay = drag && typeof document !== "undefined" && createPortal(
<div
className="fixed inset-0 z-[70] flex flex-col items-center justify-center gap-6 bg-background/85 p-6 backdrop-blur-sm pt-[calc(1.5rem+var(--titlebar-row-height,0px))] animate-in fade-in duration-100"
data-testid="file-drop-overlay"
role="region"
aria-label={t("overlay_label")}
onDragOver={(event) => {
event.preventDefault();
const zone = zoneAt(event.target);
const usable = zone && !zone.disabled ? zone : null;
event.dataTransfer.dropEffect = usable ? "copy" : "none";
if ((usable?.id ?? null) !== active) setActive(usable?.id ?? null);
}}
onDrop={(event) => {
event.preventDefault();
const zone = zoneAt(event.target);
const state = drag;
setDrag(null);
setActive(null);
if (!zone || zone.disabled) return;
void readDroppedFiles(event.dataTransfer).then((dropped) => run(zone.id, dropped, state));
}}
>
<div className="flex flex-col items-center gap-1 text-center">
<Upload className="h-8 w-8 text-primary" aria-hidden="true" />
<p className="text-xl font-semibold text-foreground">{t("overlay_title")}</p>
<p className="text-sm text-muted-foreground" data-testid="file-drop-count">{countText(drag.files)}</p>
</div>
<div className={cn("grid w-full gap-4", zoneGridClass(drag.zones.length))}>
{drag.zones.map((zone) => (
<ZoneCard key={zone.id} zone={zone} label={label(zone)} note={note(zone.note)} active={active === zone.id} />
))}
</div>
<p className="text-sm text-muted-foreground">{t("overlay_hint")}</p>
</div>,
document.body,
);
const preferred = choice ? defaultZone(choice.zones, choice.preferred) : undefined;
const names = choice?.list.map((file) => file.name) ?? [];
return (
<>
{overlay}
<Dialog
isOpen={!!choice}
onClose={() => setChoice(null)}
title={t("choose_title", { count: choice?.list.length ?? 0 })}
description={choice ? `${names.slice(0, NAMES_SHOWN).join(", ")}${names.length > NAMES_SHOWN ? ", …" : ""} (${countText(choice.files)})` : undefined}
size="lg"
initialFocus={defaultRef}
testId="file-drop-choice"
>
{choice && (
<div
className={cn("mt-4 grid w-full gap-3", zoneGridClass(Math.min(choice.zones.length, 2)))}
role="group"
aria-label={t("choose_title", { count: choice.list.length })}
onKeyDown={(event) => {
if (!["ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp"].includes(event.key)) return;
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("button[data-drop-zone]"));
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (at === -1) return;
event.preventDefault();
const step = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
buttons[(at + step + buttons.length) % buttons.length].focus();
}}
>
{choice.zones.map((zone) => (
<ZoneCard
key={zone.id}
ref={zone.id === preferred?.id ? defaultRef : undefined}
as="button"
zone={zone}
label={label(zone)}
note={note(zone.note)}
onClick={() => pick(zone)}
/>
))}
</div>
)}
</Dialog>
</>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { forwardRef, type ComponentType, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { CalendarPlus, FolderInput, ImageIcon, MailOpen, Paperclip, SquarePen, Upload, UserPlus } from "@/components/icons";
import type { FileDropNote, FileDropZone, FileDropZoneId } from "@/lib/file-drop/zones";
import { cn, formatFileSize } from "@/lib/utils";
const ICONS: Record<FileDropZoneId, ComponentType<{ className?: string }>> = {
"attach-here": Paperclip,
"insert-inline": ImageIcon,
"new-message": SquarePen,
upload: Upload,
calendar: CalendarPlus,
contacts: UserPlus,
"open-eml": MailOpen,
"import-eml": FolderInput,
};
export function useZoneText() {
const t = useTranslations("fileDrop");
const label = (zone: FileDropZone): string => {
switch (zone.id) {
case "attach-here": return t("zone_attach_here");
case "insert-inline": return t("zone_insert_inline");
case "new-message": return t("zone_new_message");
case "upload": return zone.folder ? t("zone_upload_folder", { folder: zone.folder }) : t("zone_upload_files");
case "calendar": return t("zone_calendar");
case "contacts": return t("zone_contacts");
case "open-eml": return t("zone_open_eml");
case "import-eml": return t("zone_import_eml", { folder: zone.folder ?? "" });
}
};
const note = (value: FileDropNote | undefined): string | null => {
if (!value) return null;
switch (value.key) {
case "note_limit_message": return t("note_limit_message", { size: formatFileSize(value.size) });
case "note_limit_file": return t("note_limit_file", { size: formatFileSize(value.size) });
case "note_kept_offline": return t("note_kept_offline");
case "note_needs_connection": return t("note_needs_connection");
}
};
return { label, note };
}
interface ZoneCardProps {
zone: FileDropZone;
label: string;
note: string | null;
active?: boolean;
/** "button" in the dialog; a plain region during a drag, which nothing can focus. */
as?: "button" | "div";
onClick?: () => void;
onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void;
children?: ReactNode;
}
/** One big choice: the same look as a drop target and as a button. */
export const ZoneCard = forwardRef<HTMLButtonElement & HTMLDivElement, ZoneCardProps>(function ZoneCard(
{ zone, label, note, active = false, as = "div", onClick, onKeyDown },
ref,
) {
const Icon = ICONS[zone.id];
const className = cn(
"group flex min-h-36 flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-6 text-center transition-colors select-none",
zone.disabled
? "border-border bg-muted/40 text-muted-foreground cursor-not-allowed"
: active
? "border-primary bg-primary/10 text-foreground ring-4 ring-primary/20"
: "border-border bg-background/90 text-foreground hover:border-primary/60",
as === "button" && !zone.disabled && "focus-visible:outline-none focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/30",
);
const content = (
<>
<Icon className={cn("h-10 w-10", zone.disabled ? "text-muted-foreground" : active ? "text-primary" : "text-muted-foreground group-hover:text-primary")} />
<span className="text-lg font-semibold leading-snug">{label}</span>
{note && <span className="text-sm text-muted-foreground">{note}</span>}
</>
);
if (as === "button") {
return (
<button
ref={ref}
type="button"
className={className}
data-drop-zone={zone.id}
aria-disabled={zone.disabled || undefined}
onClick={zone.disabled ? undefined : onClick}
onKeyDown={onKeyDown}
>
{content}
</button>
);
}
return (
<div
ref={ref}
className={className}
data-drop-zone={zone.id}
data-active={active || undefined}
data-disabled={zone.disabled || undefined}
role="group"
aria-label={note ? `${label}. ${note}` : label}
>
{content}
</div>
);
});
/** Two zones side by side, three in a row, four as two by two (a row on a wide window). */
export function zoneGridClass(count: number): string {
if (count <= 1) return "grid-cols-1 max-w-md";
if (count === 2) return "grid-cols-1 sm:grid-cols-2 max-w-3xl";
if (count === 3) return "grid-cols-1 sm:grid-cols-3 max-w-5xl";
return "grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 max-w-6xl";
}
@@ -221,7 +221,7 @@ describe('FileBrowser', () => {
renderBrowser({ resources: [resource(OWN[1])], allFolders: [shared] });
fireEvent.contextMenu(within(tree()).getByText('Documents'));
fireEvent.click(await screen.findByRole('menuitem', { name: 'share' }));
fireEvent.click(await screen.findByRole('menuitem', { name: 'files_share' }));
expect(await screen.findByTestId('share-dialog')).toHaveTextContent('Documents:p1');
});
@@ -230,7 +230,7 @@ describe('FileBrowser', () => {
renderBrowser();
await screen.findAllByText('Team');
fireEvent.contextMenu(within(tree()).getByText('Team'));
expect(screen.queryByRole('menuitem', { name: 'share' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: 'files_share' })).toBeNull();
});
it('hides dot files unless the setting shows them', () => {
@@ -284,7 +284,7 @@ describe('FileBrowser', () => {
it('offers new and upload entries on My files in the sidebar', () => {
renderBrowser();
fireEvent.contextMenu(within(tree()).getByText('my_files'));
for (const name of ['open', 'new_folder', 'new_text_file', 'upload_files', 'upload_folder']) {
for (const name of ['open', 'files_new_folder', 'files_new_text_file', 'files_upload_files', 'files_upload_folder']) {
expect(screen.getByRole('menuitem', { name })).toBeInTheDocument();
}
});
@@ -457,7 +457,7 @@ describe('FileBrowser', () => {
}
render(<Probe />);
const files = groups.find((g) => g.label === 'files');
expect(files?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['upload_files', 'new_folder', 'trash']));
expect(files?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['files_upload_files', 'files_new_folder', 'trash']));
fireEvent.keyDown(document.body, { key: '/' });
expect(document.activeElement).toHaveAttribute('role', 'searchbox');
+10 -8
View File
@@ -222,7 +222,9 @@ export function FileBrowser({
onShare,
}: FileBrowserProps) {
const t = useTranslations("files");
const newFileLabel = newFileFormats.length > 0 ? t("new_file") : t("new_text_file");
const tMenu = useTranslations("menuAudit");
// Menu entries that open a dialog end in "…".
const newFileLabel = newFileFormats.length > 0 ? tMenu("files_new_file") : tMenu("files_new_text_file");
const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]);
const validateFileName = useCallback((name: string) => {
const problem = fileNameProblem(name, fileNameRules);
@@ -952,7 +954,7 @@ export function FileBrowser({
)}
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
{canShare(resource) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
<ContextMenuItem icon={Share2} label={tMenu("files_share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
)}
{resource.isDirectory && !resource.isShared && (
<ContextMenuItem
@@ -993,10 +995,10 @@ export function FileBrowser({
const newItems = (
<>
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => { closeMenu(); fileInputRef.current?.click(); }} />
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => { closeMenu(); folderInputRef.current?.click(); }} />
<ContextMenuItem icon={Upload} label={tMenu("files_upload_files")} onClick={() => { closeMenu(); fileInputRef.current?.click(); }} />
<ContextMenuItem icon={FolderUp} label={tMenu("files_upload_folder")} onClick={() => { closeMenu(); folderInputRef.current?.click(); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => { closeMenu(); setShowNewFolder(true); }} />
<ContextMenuItem icon={FolderPlus} label={tMenu("files_new_folder")} onClick={() => { closeMenu(); setShowNewFolder(true); }} />
<ContextMenuItem icon={FilePlus} label={newFileLabel} onClick={() => { closeMenu(); setShowNewTextFile(true); }} />
</>
);
@@ -1036,9 +1038,9 @@ export function FileBrowser({
const tShell = useTranslations("shell");
const isEmbedded = useIsEmbedded();
useRegisterCommands(tSidebar("files"), [
{ id: "upload-files", label: t("upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: t("upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
{ id: "new-folder", label: t("new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
{ id: "upload-files", label: tMenu("files_upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: tMenu("files_upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
{ id: "new-folder", label: tMenu("files_new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
{ id: "new-text-file", label: newFileLabel, icon: FilePlus, run: () => setShowNewTextFile(true), when: () => canCreate },
{ id: "trash", label: t("trash"), icon: Trash2, run: () => onNavigate(TRASH_PATH, TRASH_LOCATION), when: () => !inTrash && !accountPickerMode },
{ id: "empty-trash", label: t("empty_trash"), icon: Trash2, run: () => onEmptyTrash?.(), when: () => inTrash && trashCount > 0 && !!onEmptyTrash },
+2 -1
View File
@@ -56,6 +56,7 @@ export function FileDetailsPanel({
iconOptions, getImageUrl, onClose, onOpen, onEdit, onDownload, onShare, onToggleStar, className, pluginFile,
}: FileDetailsPanelProps) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const pluginProps = useMemo(() => (pluginFile ? { file: pluginFile } : null), [pluginFile]);
const header = (
@@ -147,7 +148,7 @@ export function FileDetailsPanel({
)}
{canShare && (
<Button size="sm" variant="outline" className="h-8 gap-1.5" onClick={onShare}>
<Share2 className="w-4 h-4" />{t("share")}
<Share2 className="w-4 h-4" />{tMenu("files_share_button")}
</Button>
)}
{resource.isDirectory && !resource.isShared && (
+2 -1
View File
@@ -223,6 +223,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
note?: string;
}) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const touchProps = useTouchPress(touch, selecting, resource, handlers);
const icon = <ResourceIcon resource={resource} size="sm" options={iconOptions} />;
return (
@@ -299,7 +300,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
<RowAction icon={Download} label={t("download")} onClick={() => handlers.onDownload?.(resource)} />
)}
{!touch && handlers.canShare(resource) && (
<RowAction icon={Share2} label={t("share")} onClick={() => handlers.onShare(resource)} />
<RowAction icon={Share2} label={tMenu("files_share_button")} onClick={() => handlers.onShare(resource)} />
)}
<IconButton
data-item-action
+34 -2
View File
@@ -8,7 +8,8 @@ import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore, deletesToTrash, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
import { useFileStore, deletesToTrash, isSharedFilesPage, isSharedLocation, linkedPreviewResource, resourceServerRef, resourceToView, type UploadReport } from "@/stores/file-store";
import { hasQueuedFilesUpload, setFilesDropTarget, subscribeQueuedFilesUpload, takeQueuedFilesUpload } from "@/lib/file-drop/files-target";
import { isTrashLocation } from "@/lib/files-trash";
import { fileErrorKind, isPartialCopy } from "@/lib/file-errors";
import { toast } from "@/stores/toast-store";
@@ -805,6 +806,37 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
useFileStore.setState({ supportsFiles: null });
}, [getClientForAccount]);
// The file drop overlay: "Upload to <folder>" while this screen is on view in a folder
// that takes files, and what was dropped elsewhere for Files once one is listed.
const dropRootRef = useRef<HTMLDivElement>(null);
const canTakeDrops = supportsFiles === true && !isAccountPicker && !isSharedFilesPage(currentParentId) && !isTrashLocation(currentParentId);
const dropUploadRef = useRef({ files: handleUploadFiles, folder: handleUploadFolder });
dropUploadRef.current = { files: handleUploadFiles, folder: handleUploadFolder };
useEffect(() => {
if (!canTakeDrops) return;
const folder = currentPath === "/" ? null : currentPath.split("/").filter(Boolean).pop() ?? null;
return setFilesDropTarget({
folder,
take: ({ files, asFolder }) => void (asFolder ? dropUploadRef.current.folder(files) : dropUploadRef.current.files(files)),
visible: () => !!dropRootRef.current?.isConnected && dropRootRef.current.getClientRects().length > 0,
});
}, [canTakeDrops, currentPath]);
useEffect(() => {
if (supportsFiles !== true || isLoading || !storeClient || isAccountPicker) return;
const take = () => {
if (!hasQueuedFilesUpload()) return;
// In the trash or the list of shared drives: to the top of Files first.
if (!canTakeDrops) {
void navigate(null);
return;
}
const upload = takeQueuedFilesUpload();
if (upload) void (upload.asFolder ? dropUploadRef.current.folder(upload.files) : dropUploadRef.current.files(upload.files));
};
take();
return subscribeQueuedFilesUpload(take);
}, [supportsFiles, isLoading, storeClient, isAccountPicker, canTakeDrops, navigate]);
// Until the session is restored: the page skeleton the server drew.
if (!isAuthenticated) {
return isEmbedded
@@ -813,7 +845,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) {
}
return (
<div className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
<div ref={dropRootRef} className={cn("flex flex-col overflow-hidden pt-[var(--app-top-inset)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
<AppTopBannerSlot />
{refreshIndicator}
+2 -1
View File
@@ -120,6 +120,7 @@ export function FilesHeader({
panelToggle,
}: FilesHeaderProps) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const sortMenu = useContextMenu<null>();
const overflowMenu = useContextMenu<Crumb[]>();
const [searchOpen, setSearchOpen] = useState(false);
@@ -159,7 +160,7 @@ export function FilesHeader({
<ToolButton icon={Download} label={t("download")} onClick={selection.onDownload} compact />
)}
{selection.canShare && (
<ToolButton icon={Share2} label={t("share")} onClick={selection.onShare} compact />
<ToolButton icon={Share2} label={tMenu("files_share_button")} onClick={selection.onShare} compact />
)}
{selection.canRename && (
<ToolButton icon={Pencil} label={t("rename")} onClick={selection.onRename} compact />
+10 -9
View File
@@ -125,6 +125,7 @@ export function FilesSidebar({
onDropToTrash,
}: FilesSidebarProps) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const newMenu = useContextMenu<null>();
const itemMenu = useContextMenu<MenuTarget>();
const newButtonRef = useRef<HTMLButtonElement>(null);
@@ -274,10 +275,10 @@ export function FilesSidebar({
const createItems = (path: string, id: string | null) => (
<>
<ContextMenuSeparator />
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => run(() => onCreateIn(path, id, "folder"))} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? t("new_text_file")} onClick={() => run(() => onCreateIn(path, id, "text"))} />
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => run(() => onCreateIn(path, id, "files"))} />
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => run(() => onCreateIn(path, id, "dir"))} />
<ContextMenuItem icon={FolderPlus} label={tMenu("files_new_folder")} onClick={() => run(() => onCreateIn(path, id, "folder"))} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? tMenu("files_new_text_file")} onClick={() => run(() => onCreateIn(path, id, "text"))} />
<ContextMenuItem icon={Upload} label={tMenu("files_upload_files")} onClick={() => run(() => onCreateIn(path, id, "files"))} />
<ContextMenuItem icon={FolderUp} label={tMenu("files_upload_folder")} onClick={() => run(() => onCreateIn(path, id, "dir"))} />
</>
);
@@ -455,11 +456,11 @@ export function FilesSidebar({
</nav>
<ContextMenu ref={newMenu.menuRef} isOpen={newMenu.contextMenu.isOpen} position={newMenu.contextMenu.position} onClose={newMenu.closeContextMenu}>
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => { newMenu.closeContextMenu(); onUploadFiles(); }} />
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => { newMenu.closeContextMenu(); onUploadFolder(); }} />
<ContextMenuItem icon={Upload} label={tMenu("files_upload_files")} onClick={() => { newMenu.closeContextMenu(); onUploadFiles(); }} />
<ContextMenuItem icon={FolderUp} label={tMenu("files_upload_folder")} onClick={() => { newMenu.closeContextMenu(); onUploadFolder(); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => { newMenu.closeContextMenu(); onNewFolder(); }} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? t("new_text_file")} onClick={() => { newMenu.closeContextMenu(); onNewTextFile(); }} />
<ContextMenuItem icon={FolderPlus} label={tMenu("files_new_folder")} onClick={() => { newMenu.closeContextMenu(); onNewFolder(); }} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? tMenu("files_new_text_file")} onClick={() => { newMenu.closeContextMenu(); onNewTextFile(); }} />
</ContextMenu>
<ContextMenu ref={itemMenu.menuRef} isOpen={itemMenu.contextMenu.isOpen} position={itemMenu.contextMenu.position} onClose={itemMenu.closeContextMenu}>
@@ -484,7 +485,7 @@ export function FilesSidebar({
Details act on the list and are left out here (R6). */}
{(!folder.isShared || canShare(folder)) && <ContextMenuSeparator />}
{canShare(folder) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
<ContextMenuItem icon={Share2} label={tMenu("files_share")} onClick={() => run(() => onShare(folder))} />
)}
{!folder.isShared && (
<ContextMenuItem
+14 -5
View File
@@ -1,10 +1,11 @@
"use client";
import { useEffect, type ReactNode } from "react";
import { useEffect, useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import { ChevronLeft, ChevronRight, Download, X } from "@/components/icons";
import { IconButton } from "@/components/ui/icon-button";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
export interface PreviewNeighbours {
/** The previous and next previewable file of the folder, if any. */
@@ -36,6 +37,14 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
// Tab stays in the preview and the focus goes back to the file that was
// opened when it closes. Escape is the listener below.
const shellRef = useFocusTrap({ isActive: true, restoreFocus: true });
// Its own close goes to the bar's start when the window's close is at its end.
const [bar, setBar] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(bar);
const close = (
<IconButton label={t("close_preview")} onClick={onClose} className="shrink-0">
<X />
</IconButton>
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -82,12 +91,14 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
{/* Full-screen under `viewport-fit=cover`: the bar pads itself out of
the iOS status bar (#936). The insets are physical, so pl/pr. */}
<div
ref={setBar}
data-window-top-row=""
className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+var(--app-top-inset))] pb-3 bg-background/90 backdrop-blur border-b border-border"
onClick={(e) => e.stopPropagation()}
>
{closeFirst && close}
<h3 className="text-sm font-medium truncate" title={name}>{name}</h3>
<div className="flex items-center gap-1 shrink-0">
<div className="flex items-center gap-1 shrink-0 ms-auto">
{neighbours && (
<>
<IconButton label={t("previous_file")} disabled={!neighbours.previous} onClick={() => neighbours.previous && neighbours.onGo(neighbours.previous)}>
@@ -104,10 +115,8 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
<Download />
</IconButton>
)}
<IconButton label={t("close_preview")} onClick={onClose}>
<X />
</IconButton>
</div>
{!closeFirst && close}
</div>
{children}
</div>
+6 -3
View File
@@ -1,7 +1,8 @@
"use client";
import { useState, useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { sortFolderTree } from "@/lib/move-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, X } from "@/components/icons";
@@ -221,8 +222,10 @@ export function FilterRuleModal({
}, [confirmDiscard, dirty, onClose]);
const nameRef = useRef<HTMLInputElement>(null);
// The folder list in the order every Move list has (lib/move-menu.ts).
const locale = useLocale();
const { hierarchicalMailboxes, mailboxPathMap } = useMemo(() => {
const tree = buildMailboxTree(mailboxes.filter((mb) => !mb.isShared));
const tree = sortFolderTree(buildMailboxTree(mailboxes.filter((mb) => !mb.isShared)), locale);
const pathMap = new Map<string, string>();
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
for (const node of nodes) {
@@ -236,7 +239,7 @@ export function FilterRuleModal({
};
buildPaths(tree);
return { hierarchicalMailboxes: flattenMailboxTree(tree), mailboxPathMap: pathMap };
}, [mailboxes]);
}, [mailboxes, locale]);
// Rules saved before folder ids were stored only carry the path; resolve it
// so the select shows the folder and the next save adds the id.
+15 -3
View File
@@ -8,11 +8,14 @@ import {
SHORTCUT_APPS,
sectionsWithGeneral,
shortcutAppForPath,
shortcutKeyFor,
type ShortcutAppId,
type ShortcutEntry,
type ShortcutHost,
type ShortcutSection,
} from "@/lib/keyboard-shortcuts";
import { shortcutAlternatives } from "@/lib/shortcut-keys";
import { isApplePlatform, shortcutAlternatives } from "@/lib/shortcut-keys";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { useSettingsStore } from "@/stores/settings-store";
import { cn } from "@/lib/utils";
import { usePathname } from "@/i18n/navigation";
@@ -50,8 +53,10 @@ export function KeyboardShortcutsModal({ isOpen, onClose, app }: KeyboardShortcu
// Rows that belong to one interface layout (or one shell) are listed only under it,
// and a row of a gated feature only while the admin has it on.
const host = shortcutHost();
const shown = (entries: readonly ShortcutEntry[]) =>
entries.filter((entry) => (!entry.layout || entry.layout === interfaceLayout)
entries.filter((entry) => shortcutKeyFor(entry, host) !== null
&& (!entry.layout || entry.layout === interfaceLayout)
&& (!entry.shell || entry.shell === shell)
&& (!entry.feature || (features[entry.feature] ?? true)));
@@ -142,7 +147,7 @@ export function KeyboardShortcutsModal({ isOpen, onClose, app }: KeyboardShortcu
{rows.map((shortcut) => (
<ShortcutRow
key={`${shortcut.key}:${shortcut.description}`}
shortcutKey={shortcut.key}
shortcutKey={shortcutKeyFor(shortcut, host)!}
description={t(shortcut.description)}
/>
))}
@@ -173,6 +178,13 @@ export function KeyboardShortcutsModal({ isOpen, onClose, app }: KeyboardShortcu
);
}
/** The desktop app has keys of its own (its menus), and they differ by system. */
function shortcutHost(): ShortcutHost {
if (!getDesktopBridge()) return "browser";
if (isApplePlatform()) return "mac";
return typeof navigator !== "undefined" && /windows/i.test(navigator.userAgent) ? "windows" : "linux";
}
function ShortcutRow({
shortcutKey,
description,
@@ -0,0 +1,81 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
// A folder menu greys out what can't work: nothing to mark read, or no connection
// for what needs the server.
vi.mock('@/components/email/rules-menu', () => ({ FolderRulesSubMenu: () => null }));
vi.mock('@/lib/offline/offline-mode', () => ({ offlineAllowed: () => true }));
const { MailboxContextMenu, unreadInTree } = await import('../mailbox-context-menu');
const { useOfflineStore } = await import('@/lib/offline/state');
const { useAuthStore } = await import('@/stores/auth-store');
const box = (id: string, unread: number, parentId: string | null = null) =>
({ id, name: id, parentId, sortOrder: 1, totalEmails: 5, unreadEmails: unread, myRights: {} }) as unknown as Mailbox;
function renderMenu(mailbox: Mailbox, mailboxes: Mailbox[], hasChildren = false) {
render(
<MailboxContextMenu
target={{ kind: 'mailbox', mailbox, hasChildren }}
position={{ x: 0, y: 0 }}
isOpen
onClose={vi.fn()}
menuRef={{ current: null }}
mailboxes={mailboxes}
onMarkFolderRead={vi.fn()}
onMarkFolderTreeRead={vi.fn()}
onCreateSubfolder={vi.fn()}
onRenameFolder={vi.fn()}
onDeleteFolder={vi.fn()}
onEmptyFolder={vi.fn()}
onImportEmail={vi.fn()}
onRefresh={vi.fn()}
/>,
);
}
const item = (name: string) => screen.getByRole('menuitem', { name }) as HTMLButtonElement;
describe('folder menu states', () => {
afterEach(() => {
useOfflineStore.setState({ browserOffline: false, downServers: [], enabled: [] });
});
it('greys out Mark folder as read when nothing in it is unread', () => {
const read = box('read', 0);
renderMenu(read, [read]);
expect(item('mark_folder_read').disabled).toBe(true);
});
it('keeps it for a folder with unread mail, and the tree entry for unread mail below', () => {
const parent = box('parent', 0);
const child = box('child', 3, 'parent');
renderMenu(parent, [parent, child], true);
expect(item('mark_folder_read').disabled).toBe(true);
expect(item('mark_folder_tree_read').disabled).toBe(false);
expect(unreadInTree(parent, [parent, child])).toBe(3);
});
it('offline, greys out what needs the server and says why', () => {
useOfflineStore.setState({ browserOffline: true });
const folder = box('f', 2);
renderMenu(folder, [folder]);
expect(screen.getByText('offline_header')).toBeInTheDocument();
for (const name of ['new_subfolder', 'rename', 'menu_import', 'refresh', 'empty_folder_generic', 'delete_folder']) {
expect(item(name).disabled).toBe(true);
}
// No mail kept on this device: marking read would fail too.
expect(item('mark_folder_read').disabled).toBe(true);
});
it('offline with the mail kept on this device, marking read still works', () => {
useAuthStore.setState({ activeAccountId: 'acc' } as never);
useOfflineStore.setState({ browserOffline: true, enabled: ['acc'] });
const folder = box('f', 2);
renderMenu(folder, [folder]);
expect(item('mark_folder_read').disabled).toBe(false);
expect(item('rename').disabled).toBe(true);
});
});
+13 -5
View File
@@ -1,10 +1,12 @@
'use client';
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { X } from '@/components/icons';
import { IconButton } from '@/components/ui/icon-button';
import { cn } from '@/lib/utils';
import type { InlineAppState } from '@/hooks/use-sidebar-apps';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
interface InlineAppViewProps {
apps: InlineAppState[];
@@ -16,15 +18,21 @@ interface InlineAppViewProps {
export function InlineAppView({ apps, activeAppId, onClose, className }: InlineAppViewProps) {
const tCommon = useTranslations('common');
const activeApp = apps.find((a) => a.id === activeAppId);
const [header, setHeader] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(header);
const close = (
<IconButton label={tCommon("close")} onClick={onClose} className="shrink-0 text-muted-foreground">
<X />
</IconButton>
);
return (
<div className={cn('flex flex-col h-full bg-background', className)}>
{/* Header bar */}
<div data-window-top-row="" className="flex items-center justify-between px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
<h3 className="text-sm font-medium truncate">{activeApp?.name}</h3>
<IconButton label={tCommon("close")} onClick={onClose} className="text-muted-foreground">
<X />
</IconButton>
<div ref={setHeader} data-window-top-row="" className="flex items-center gap-2 px-4 py-2 border-b border-border bg-secondary/50 flex-shrink-0">
{closeFirst && close}
<h3 className="min-w-0 flex-1 text-sm font-medium truncate">{activeApp?.name}</h3>
{!closeFirst && close}
</div>
{/* Iframes - active one visible, rest hidden but alive */}
<div className="flex-1 relative">
+38 -14
View File
@@ -32,6 +32,7 @@ import {
import { FolderRulesSubMenu } from "@/components/email/rules-menu";
import { openMailExport } from "@/stores/mail-transfer-store";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { useConnectionState } from "@/hooks/use-connection-state";
interface Position {
x: number;
@@ -46,6 +47,20 @@ export type MailboxContextTarget =
| { kind: "folders-section"; accountId?: string };
const PATH_SEPARATOR = " › ";
/** Unread messages in a folder and the folders under it; undefined where a count isn't known. */
export function unreadInTree(mailbox: Mailbox, mailboxes: Mailbox[]): number | undefined {
let total = 0;
const seen = new Set<string>();
const visit = (box: Mailbox): boolean => {
if (seen.has(box.id)) return true;
seen.add(box.id);
if (typeof box.unreadEmails !== "number") return false;
total += box.unreadEmails;
return mailboxes.filter((child) => child.parentId === box.id).every(visit);
};
return visit(mailbox) ? total : undefined;
}
const MAX_PATH_LENGTH = 40;
const MAX_SEGMENT_LENGTH = 16;
@@ -149,6 +164,11 @@ export function MailboxContextMenu({
const tExport = useTranslations("mail_export");
// Organisation > Devices > No export hides it; import stays.
const exportOff = useDevicePolicy().noExport;
// Folders, rules and sharing need the server. Marking read waits in the outbox
// where the account keeps its mail on this device.
const { offline, canChangeMail } = useConnectionState();
const tMenu = useTranslations("menuAudit");
const offlineHeader = offline ? <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader> : null;
const handleAction = (action: () => void) => {
action();
@@ -200,13 +220,14 @@ export function MailboxContextMenu({
if (target.kind === "folders-section") {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
{offlineHeader}
{!target.accountId && (
<>
<ContextMenuItem
icon={CheckCheck}
label={t("mark_all_folders_read")}
onClick={() => handleAction(onMarkAllFoldersRead!)}
disabled={!onMarkAllFoldersRead}
disabled={!onMarkAllFoldersRead || !canChangeMail}
/>
<ContextMenuSeparator />
</>
@@ -215,14 +236,14 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(() => onCreateFolder?.(target.accountId))}
disabled={!onCreateFolder}
disabled={!onCreateFolder || offline}
testId="mailbox-new-folder"
/>
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
disabled={!onRefresh || offline}
/>
{accountItems(target.accountId)}
</ContextMenu>
@@ -240,6 +261,8 @@ export function MailboxContextMenu({
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const canAddItems = mailbox.myRights?.mayAddItems !== false;
const nothingUnread = mailbox.unreadEmails === 0;
const nothingUnreadBelow = unreadInTree(mailbox, mailboxes) === 0;
// Sharing needs the mail:share extension (handler present) and, on a folder
// someone else shared with us, their permission to re-share it.
const canShare = !!onShareFolder && (!mailbox.isShared || mailbox.myRights?.mayShare === true);
@@ -256,19 +279,20 @@ export function MailboxContextMenu({
<ContextMenuHeader>
<span title={fullPath}>{renderShortenedPath(fullPath)}</span>
</ContextMenuHeader>
{offlineHeader}
<ContextMenuItem
icon={MailOpen}
label={t("mark_folder_read")}
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
disabled={!onMarkFolderRead || !canSetSeen}
disabled={!onMarkFolderRead || !canSetSeen || !canChangeMail || nothingUnread}
/>
{target.hasChildren && (
<ContextMenuItem
icon={Mails}
label={t("mark_folder_tree_read")}
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
disabled={!onMarkFolderTreeRead || !canSetSeen}
disabled={!onMarkFolderTreeRead || !canSetSeen || !canChangeMail || nothingUnreadBelow}
/>
)}
@@ -278,14 +302,14 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_subfolder")}
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
disabled={!onCreateSubfolder || !canCreateChild}
disabled={!onCreateSubfolder || !canCreateChild || offline}
testId="mailbox-new-subfolder"
/>
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename}
disabled={!onRenameFolder || !canRename || offline}
testId="mailbox-rename"
/>
{onShareFolder && (
@@ -293,11 +317,11 @@ export function MailboxContextMenu({
icon={Users}
label={t("share")}
onClick={() => handleAction(() => onShareFolder(mailbox.id))}
disabled={!canShare}
disabled={!canShare || offline}
testId="mailbox-share"
/>
)}
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} />
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} disabled={offline} />
{notifications && (() => {
const muted = notifications.isMailboxMuted(mailbox);
return (
@@ -317,7 +341,7 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(() => onCreateFolder?.(target.sharedAccountId))}
disabled={!onCreateFolder}
disabled={!onCreateFolder || offline}
testId="mailbox-new-folder"
/>
{accountItems(target.sharedAccountId)}
@@ -330,7 +354,7 @@ export function MailboxContextMenu({
icon={Upload}
label={tImport("menu_import")}
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems}
disabled={!onImportEmail || !canAddItems || offline}
/>
{!exportOff && (
<ContextMenuItem
@@ -345,7 +369,7 @@ export function MailboxContextMenu({
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
disabled={!onRefresh || offline}
/>
{/* Destructive entries last (R6). */}
@@ -355,14 +379,14 @@ export function MailboxContextMenu({
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline}
destructive
/>
<ContextMenuItem
icon={Trash2}
label={t("delete_folder")}
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
disabled={!onDeleteFolder || !canDelete}
disabled={!onDeleteFolder || !canDelete || offline}
destructive
testId="mailbox-delete"
/>
+18 -10
View File
@@ -28,6 +28,7 @@ import { readableTextOn } from "@/lib/color-transform";
import { Avatar } from "@/components/ui/avatar";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
import { PluginSidePanelBody, SidePanelButton, usePluginSidePanel } from "@/components/plugins/plugin-side-panels";
export type MvSidePanelId = "calendar" | "tasks" | "contacts";
@@ -146,8 +147,23 @@ function PanelHeader({ title, subtitle, href, onClose, children }: {
}) {
const t = useTranslations("sidebar");
const tCommon = useTranslations("common");
// Right under the window's own close, at the window's end: this one goes to the start.
const [head, setHead] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(head);
const close = (
<button
type="button"
onClick={onClose}
className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon("close")}
aria-label={tCommon("close")}
>
<X className="h-[18px] w-[18px]" />
</button>
);
return (
<div className="flex items-start gap-1 px-4 pt-4 pb-2">
<div ref={setHead} data-window-pane-head="" className={cn("flex items-start gap-1 pt-4 pb-2", closeFirst ? "ps-2 pe-4" : "px-4")}>
{closeFirst && close}
<div className="min-w-0 flex-1">
<div className={cn(subtitle ? "text-[0.6875rem] font-medium text-muted-foreground" : "text-[1.375rem] leading-7")}>{title}</div>
{subtitle && <div className="text-sm font-medium text-primary">{subtitle}</div>}
@@ -161,15 +177,7 @@ function PanelHeader({ title, subtitle, href, onClose, children }: {
>
<ExternalLink className="h-[18px] w-[18px]" />
</Link>
<button
type="button"
onClick={onClose}
className="grid h-9 w-9 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon("close")}
aria-label={tCommon("close")}
>
<X className="h-[18px] w-[18px]" />
</button>
{!closeFirst && close}
</div>
);
}
+1 -1
View File
@@ -92,7 +92,7 @@ export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) {
<ContextMenuItem
icon={Bell}
label={tNotifications("title")}
shortcut={unread > 0 ? formatBadgeCount(unread) : undefined}
badge={unread > 0 ? formatBadgeCount(unread) : undefined}
onClick={() => { close(); setShowNotifications(true); }}
/>
<ContextMenuItem
+4 -6
View File
@@ -351,7 +351,6 @@ interface SidebarRowProps {
isCollapsed: boolean;
dropHandlers?: Record<string, unknown>;
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
/** New mail here does not notify (#1169). */
muted?: boolean;
@@ -378,7 +377,6 @@ function SidebarRow({
isCollapsed,
dropHandlers,
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
muted = false,
testRole,
@@ -448,8 +446,9 @@ function SidebarRow({
: isSelected
? "bg-accent text-accent-foreground font-semibold border-s-2 border-primary"
: "hover:bg-muted/50 text-foreground border-s-2 border-transparent",
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
// Only a folder that takes the drop lights up. Elsewhere the pointer
// says no (dropEffect none), as in a native mail client.
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset"
)}
>
{!isCollapsed && (
@@ -732,7 +731,7 @@ function MailboxTreeItem({
: undefined;
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
const { dropHandlers, isValidDropTarget } = useMailboxDrop({
mailbox: node,
accountId: dragAccountId ?? undefined,
onSuccess: (count, mailboxName, restore, receipts) => {
@@ -777,7 +776,6 @@ function MailboxTreeItem({
: undefined
}
isValidDropTarget={isValidDropTarget && !pickOnly}
isInvalidDropTarget={isInvalidDropTarget && !pickOnly}
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
muted={muted}
/>
+441 -62
View File
@@ -1,99 +1,478 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Check, ChevronRight } from "@/components/icons";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopMenuBarEntry } from "@/lib/desktop/types";
import { MENU_BAR_ATTRIBUTE, cachedMenuBarPinned, menuKeyText, rememberMenuBarPinned } from "@/lib/desktop/menu-bar";
import type { DesktopMenuBarEntry, DesktopMenuBarItem, DesktopMenuBarMenu } from "@/lib/desktop/types";
import { watchTitlebarArea } from "@/lib/window-controls-overlay";
import { cn } from "@/lib/utils";
/**
* Alt floats the menu's names over the top left for a moment, since a window with no
* frame has no native bar. Each name opens the app's menu as a native one
* (src/main/menu.ts in the desktop repo). Dismissed, nothing is left of it.
* The desktop app's menu bar where the window's controls sit over the page (Windows and
* Linux), as Thunderbird has it. Alt shows it as a row at the top that moves the app down
* under it, and the window's controls shrink into that row; "Always show the menu bar"
* keeps it. Alt, Escape or a click elsewhere hides it again. Alt and a letter opens that
* menu, with the letters underlined while the keys are on the bar, and the arrows go
* between menus and lines. The menus are the shell's own (src/main/menu.ts in the desktop
* repo), with what's enabled and ticked now, and a pick runs the very item the native
* menu has. macOS keeps its system bar.
*
* A shell from before this gives only the names, and opens each menu natively.
*/
const LINE = "[data-menu-line]";
const ENABLED_LINE = `${LINE}:not([aria-disabled="true"])`;
type Focus = { kind: "name"; index: number } | { kind: "line" | "panel"; sub?: boolean };
/** The label with its letter underlined, while the keys are on the bar. */
function Label({ text, at, cue }: { text: string; at: number | undefined; cue: boolean }) {
if (!cue || at === undefined || at < 0 || at >= text.length) return <>{text}</>;
return (
<>
{text.slice(0, at)}
<span className="underline underline-offset-2">{text[at]}</span>
{text.slice(at + 1)}
</>
);
}
function nameLetterAt(entry: DesktopMenuBarEntry): number | undefined {
if (!entry.mnemonic) return undefined;
const at = entry.label.toLowerCase().indexOf(entry.mnemonic);
return at >= 0 ? at : undefined;
}
function letterOf(item: DesktopMenuBarItem): string | undefined {
return item.mnemonic !== undefined ? item.label[item.mnemonic]?.toLowerCase() : undefined;
}
function usable(item: DesktopMenuBarItem | undefined): item is DesktopMenuBarItem {
return !!item && item.type !== "separator" && item.enabled;
}
const EDGE = 4;
/**
* A menu that would leave the window moves back in. A submenu sits beside its line, on the
* reading side, or the other side where there's no room, and never below the window.
*/
function keepInside(root: HTMLElement | null): void {
if (!root) return;
const width = document.documentElement.clientWidth;
const height = document.documentElement.clientHeight;
const panel = root.querySelector<HTMLElement>("[data-menu-panel]");
if (panel) {
panel.style.translate = "";
const rect = panel.getBoundingClientRect();
if (rect.right > width - EDGE) panel.style.translate = `${Math.round(width - EDGE - rect.right)}px 0`;
else if (rect.left < EDGE) panel.style.translate = `${Math.round(EDGE - rect.left)}px 0`;
}
const subpanel = root.querySelector<HTMLElement>("[data-menu-subpanel]");
const line = panel?.querySelector<HTMLElement>(`${LINE}[aria-expanded="true"]`);
if (!panel || !subpanel || !line) return;
const outer = panel.getBoundingClientRect();
const at = line.getBoundingClientRect();
const own = subpanel.getBoundingClientRect();
const rtl = getComputedStyle(root).direction === "rtl";
const after = outer.right - EDGE;
const before = outer.left + EDGE - own.width;
const fitsAfter = after + own.width <= width - EDGE;
const fitsBefore = before >= EDGE;
const left = rtl ? (fitsBefore ? before : after) : fitsAfter || !fitsBefore ? Math.min(after, width - EDGE - own.width) : before;
subpanel.style.left = `${Math.round(Math.max(EDGE, left))}px`;
subpanel.style.top = `${Math.round(Math.max(EDGE, Math.min(at.top - EDGE, height - EDGE - own.height)))}px`;
}
export function WindowMenuBar() {
const t = useTranslations("desktopUi.menu");
const [entries, setEntries] = useState<DesktopMenuBarEntry[] | null>(null);
const stripRef = useRef<HTMLDivElement>(null);
const [desktop, setDesktop] = useState(false);
const [hasOverlay, setHasOverlay] = useState(false);
const [pinned, setPinned] = useState(false);
/** The keys are on the bar: Alt was pressed, or a menu was opened. */
const [active, setActive] = useState(false);
const [menus, setMenus] = useState<DesktopMenuBarMenu[] | null>(null);
const [open, setOpen] = useState<number | null>(null);
/** The line of the open menu whose submenu is open. */
const [sub, setSub] = useState<number | null>(null);
/** Underlined letters: the keys brought the bar, not the mouse. */
const [cue, setCue] = useState(false);
const barRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
// What to do once the names are on screen: open that menu, or null to focus the first name.
const afterShow = useRef<string | null | undefined>(undefined);
const pendingFocus = useRef<Focus | null>(null);
const openMenu = useCallback((id: string) => {
const button = stripRef.current?.querySelector<HTMLElement>(`[data-menu-id="${CSS.escape(id)}"]`);
if (!button) return;
const rect = button.getBoundingClientRect();
getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom });
}, []);
const dismiss = useCallback(() => {
setEntries(null);
previousFocus.current?.focus?.();
previousFocus.current = null;
}, []);
const shown = desktop && hasOverlay && (pinned || active);
const drawsMenus = desktop && !!getDesktopBridge()?.menu?.bar?.menus;
useEffect(() => {
const bridge = getDesktopBridge();
if (!bridge?.menu?.bar || bridge.platform === "darwin") return;
setDesktop(true);
setPinned(cachedMenuBarPinned());
return watchTitlebarArea((area) => setHasOverlay(area !== null));
}, []);
const refresh = useCallback(async (): Promise<DesktopMenuBarMenu[] | null> => {
const bar = getDesktopBridge()?.menu?.bar;
if (!bar) return null;
const list = bar.menus
? await bar.menus().catch(() => null)
: await bar.entries().then((entries) => entries.map((entry) => ({ ...entry, items: [] }))).catch(() => null);
if (list) setMenus(list);
return list;
}, []);
const rememberFocus = useCallback(() => {
const now = document.activeElement;
if (now instanceof HTMLElement && now !== document.body && !barRef.current?.contains(now)) previousFocus.current = now;
}, []);
/** Back to how it was before Alt: the menus shut, the keys back where they were. */
const deactivate = useCallback(() => {
setOpen(null);
setSub(null);
setActive(false);
setCue(false);
const back = previousFocus.current;
previousFocus.current = null;
if (barRef.current?.contains(document.activeElement)) {
if (back?.isConnected) back.focus({ preventScroll: true });
else (document.activeElement as HTMLElement | null)?.blur();
}
}, []);
// The shell: Alt, Alt and a letter, the window losing the focus, the setting.
useEffect(() => {
if (!desktop) return;
const bar = getDesktopBridge()?.menu?.bar;
if (!bar) return;
return bar.onEvent((event) => {
const stop = bar.onEvent((event) => {
if (typeof event.pinned === "boolean") {
setPinned(event.pinned);
rememberMenuBarPinned(event.pinned);
}
if (!event.visible) {
dismiss();
deactivate();
return;
}
void bar.entries().then((list) => {
previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null;
afterShow.current = event.open ?? null;
setEntries(list);
rememberFocus();
setActive(true);
setCue(true);
setSub(null);
// Set now: the names may be drawn before the menus arrive, and take it then.
pendingFocus.current = event.open && bar.menus ? { kind: "line" } : { kind: "name", index: 0 };
void refresh().then((list) => {
const index = event.open ? (list ?? []).findIndex((menu) => menu.id === event.open) : -1;
if (index >= 0 && bar.menus) setOpen(index);
else setOpen(null);
});
});
}, [dismiss]);
// Listening now: the shell answers with whether the bar is always there.
bar.report?.({ height: 0, active: false });
return stop;
}, [desktop, refresh, deactivate, rememberFocus]);
// An always-shown bar has its names from the start.
useEffect(() => {
const next = afterShow.current;
if (!entries || next === undefined) return;
afterShow.current = undefined;
if (next) openMenu(next);
else stripRef.current?.querySelector<HTMLElement>("button")?.focus();
}, [entries, openMenu]);
if (shown && !menus) void refresh();
}, [shown, menus, refresh]);
if (!entries) return null;
// The row it takes: everything else moves down under it, and the controls fit into it.
useLayoutEffect(() => {
if (!desktop) return;
document.documentElement.toggleAttribute(MENU_BAR_ATTRIBUTE, shown);
const height = shown ? Math.round(barRef.current?.getBoundingClientRect().height ?? 0) : 0;
getDesktopBridge()?.menu?.bar?.report?.({ height, active });
}, [desktop, shown, active]);
const onStripKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
const buttons = Array.from(stripRef.current?.querySelectorAll<HTMLElement>("button") ?? []);
const at = buttons.indexOf(document.activeElement as HTMLElement);
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
useEffect(() => () => document.documentElement.removeAttribute(MENU_BAR_ATTRIBUTE), []);
// Menus stay in the window; the focus goes where the keys asked, once it's drawn.
useLayoutEffect(() => {
const root = barRef.current;
keepInside(root);
const want = pendingFocus.current;
if (!want || !root) return;
let target: HTMLElement | null = null;
if (want.kind === "name") {
target = root.querySelectorAll<HTMLElement>("[data-menu-name]")[want.index] ?? null;
} else {
const panel = root.querySelector<HTMLElement>(want.sub ? "[data-menu-subpanel]" : "[data-menu-panel]");
target = want.kind === "panel" ? panel : panel?.querySelector<HTMLElement>(ENABLED_LINE) ?? panel ?? null;
}
if (!target) return;
pendingFocus.current = null;
target.focus({ preventScroll: true });
});
// A click anywhere else puts it all away.
useEffect(() => {
if (!active && open === null) return;
const onDown = (event: PointerEvent) => {
if (barRef.current?.contains(event.target as Node)) return;
deactivate();
};
document.addEventListener("pointerdown", onDown, true);
return () => document.removeEventListener("pointerdown", onDown, true);
}, [active, open, deactivate]);
const openMenu = useCallback((index: number, focus: "line" | "panel") => {
if (!drawsMenus) {
const entry = menus?.[index];
const button = barRef.current?.querySelectorAll<HTMLElement>("[data-menu-name]")[index];
if (!entry || !button) return;
const rect = button.getBoundingClientRect();
getDesktopBridge()?.menu?.bar?.open(entry.id, { x: rect.left, y: rect.bottom });
return;
}
setActive(true);
setOpen(index);
setSub(null);
pendingFocus.current = { kind: focus };
// What's enabled may have changed since the bar showed.
void refresh();
}, [drawsMenus, menus, refresh]);
const run = useCallback((item: DesktopMenuBarItem) => {
if (!usable(item)) return;
// Cut, Copy and Paste work on what had the focus, so it has it back first.
deactivate();
getDesktopBridge()?.menu?.bar?.run?.(item.key, item.label);
}, [deactivate]);
if (!shown) return null;
const list = menus ?? [];
const openItems = open !== null ? list[open]?.items ?? [] : [];
const subItems = sub !== null ? openItems[sub]?.items ?? [] : [];
const names = () => Array.from(barRef.current?.querySelectorAll<HTMLElement>("[data-menu-name]") ?? []);
const linesIn = (selector: string) => Array.from(barRef.current?.querySelector(selector)?.querySelectorAll<HTMLElement>(ENABLED_LINE) ?? []);
const step = (lines: HTMLElement[], delta: number) => {
if (lines.length === 0) return;
const at = lines.indexOf(document.activeElement as HTMLElement);
const next = at < 0 ? (delta > 0 ? 0 : lines.length - 1) : (at + delta + lines.length) % lines.length;
lines[next]?.focus({ preventScroll: true });
};
const openSub = (index: number) => {
setSub(index);
pendingFocus.current = { kind: "line", sub: true };
};
const closeSub = () => {
const parent = sub;
setSub(null);
barRef.current?.querySelector<HTMLElement>(`[data-menu-panel] [data-menu-line="${parent}"]`)?.focus({ preventScroll: true });
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
const rtl = getComputedStyle(barRef.current ?? document.documentElement).direction === "rtl";
const forward = rtl ? "ArrowLeft" : "ArrowRight";
const backward = rtl ? "ArrowRight" : "ArrowLeft";
const inSub = !!target.closest("[data-menu-subpanel]");
const inMenu = inSub || !!target.closest("[data-menu-panel]");
const key = event.key;
const plainLetter = key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
const take = () => {
event.preventDefault();
const delta = event.key === "ArrowRight" ? 1 : -1;
buttons[(at + delta + buttons.length) % buttons.length]?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
buttons[at]?.click();
} else if (event.key === "Escape") {
event.preventDefault();
dismiss();
event.stopPropagation();
};
setCue(true);
if (key === "Tab") {
take();
deactivate();
return;
}
if (!inMenu) {
const at = names().indexOf(target);
if (key === forward || key === backward) {
take();
step(names(), key === forward ? 1 : -1);
} else if (key === "ArrowDown" || key === "Enter" || key === " ") {
take();
if (at >= 0) openMenu(at, "line");
} else if (key === "Escape") {
take();
deactivate();
} else if (plainLetter) {
const index = list.findIndex((menu) => menu.mnemonic === key.toLowerCase());
if (index >= 0) {
take();
openMenu(index, "line");
}
}
return;
}
const items = inSub ? subItems : openItems;
const lineIndex = Number(target.closest<HTMLElement>(LINE)?.dataset.menuLine);
const current = Number.isInteger(lineIndex) ? items[lineIndex] : undefined;
const neighbour = (delta: number) => {
if (open !== null && list.length) openMenu((open + delta + list.length) % list.length, "line");
};
if (key === "ArrowDown" || key === "ArrowUp") {
take();
step(linesIn(inSub ? "[data-menu-subpanel]" : "[data-menu-panel]"), key === "ArrowDown" ? 1 : -1);
} else if (key === "Home" || key === "End") {
take();
const lines = linesIn(inSub ? "[data-menu-subpanel]" : "[data-menu-panel]");
lines[key === "Home" ? 0 : lines.length - 1]?.focus({ preventScroll: true });
} else if (key === forward) {
take();
if (!inSub && usable(current) && current.type === "submenu") openSub(lineIndex);
else neighbour(1);
} else if (key === backward) {
take();
if (inSub) closeSub();
else neighbour(-1);
} else if (key === "Enter" || key === " ") {
take();
if (!usable(current)) return;
if (current.type === "submenu") openSub(lineIndex);
else run(current);
} else if (key === "Escape") {
take();
if (inSub) {
closeSub();
} else {
const index = open ?? 0;
setOpen(null);
names()[index]?.focus({ preventScroll: true });
}
} else if (plainLetter) {
const index = items.findIndex((item) => usable(item) && letterOf(item) === key.toLowerCase());
if (index < 0) return;
take();
const item = items[index];
if (item.type === "submenu" && !inSub) openSub(index);
else run(item);
}
};
const renderLines = (items: DesktopMenuBarItem[], level: "panel" | "subpanel") =>
items.map((item, index) => {
if (item.type === "separator") return <div key={item.key} role="separator" className="my-1 h-px bg-border" />;
const isSub = item.type === "submenu";
const checkable = item.type === "checkbox" || item.type === "radio";
const opened = level === "panel" && isSub && sub === index;
return (
<div key={item.key} className="relative">
<button
type="button"
role={item.type === "checkbox" ? "menuitemcheckbox" : item.type === "radio" ? "menuitemradio" : "menuitem"}
aria-checked={checkable ? !!item.checked : undefined}
aria-haspopup={isSub ? "menu" : undefined}
aria-expanded={isSub ? opened : undefined}
aria-disabled={item.enabled ? undefined : true}
aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform) : undefined}
data-menu-line={index}
tabIndex={-1}
onMouseEnter={(event) => {
if (level === "panel") setSub(isSub && item.enabled ? index : null);
if (item.enabled) event.currentTarget.focus({ preventScroll: true });
}}
onClick={() => {
if (!item.enabled) return;
if (isSub) openSub(index);
else run(item);
}}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm outline-none",
item.enabled ? "focus:bg-muted" : "cursor-default text-muted-foreground/60",
opened && "bg-muted",
)}
>
<span className="flex w-4 shrink-0 justify-center" aria-hidden="true">
{checkable && item.checked && (item.type === "radio" ? <span className="h-1.5 w-1.5 rounded-full bg-current" /> : <Check className="h-3.5 w-3.5" />)}
</span>
<span className="min-w-0 flex-1 truncate" data-menu-label="">
<Label text={item.label} at={item.mnemonic} cue={cue} />
</span>
{item.accelerator && (
<span className="shrink-0 ps-6 text-xs text-muted-foreground" data-menu-keys="" aria-hidden="true">
{menuKeyText(item.accelerator, getDesktopBridge()?.platform)}
</span>
)}
{isSub && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true" />}
</button>
</div>
);
});
return (
<div
ref={stripRef}
ref={barRef}
role="menubar"
aria-label={t("bar_label")}
data-window-menu-bar=""
onKeyDown={onStripKey}
className="fixed start-2 top-2 z-[100002] flex items-center gap-0.5 rounded-lg border border-border bg-popover p-1 text-sm text-popover-foreground shadow-lg select-none"
data-window-top-row=""
onKeyDown={onKeyDown}
className="fixed inset-x-0 top-0 z-[100002] flex h-8 items-center gap-0.5 border-b border-border bg-background px-1 text-sm text-foreground select-none"
>
{entries.map((entry) => (
<button
key={entry.id}
type="button"
role="menuitem"
aria-haspopup="menu"
data-menu-id={entry.id}
onClick={() => openMenu(entry.id)}
className="rounded px-2.5 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
>
{entry.label}
</button>
{list.map((menu, index) => (
<div key={menu.id} className="relative">
<button
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={open === index}
data-menu-name=""
data-menu-id={menu.id}
tabIndex={-1}
onPointerDown={(event) => {
if (event.button !== 0) return;
// The focus stays put until a menu takes it: Cut and Paste need to know where it was.
event.preventDefault();
rememberFocus();
setCue(false);
if (open === index) deactivate();
else openMenu(index, "panel");
}}
onClick={(event) => {
// Enter and Space; the pointer was handled on the way down.
if (event.detail === 0) openMenu(index, "line");
}}
onMouseEnter={() => {
if (open !== null && open !== index) openMenu(index, "panel");
}}
className={cn(
"flex h-7 items-center rounded px-2.5 outline-none hover:bg-muted focus-visible:bg-muted",
open === index && "bg-muted",
)}
>
<Label text={menu.label} at={nameLetterAt(menu)} cue={cue} />
</button>
{open === index && drawsMenus && (
<div
role="menu"
aria-label={menu.label}
data-menu-panel=""
tabIndex={-1}
className="absolute start-0 top-full mt-0.5 min-w-56 max-w-[min(24rem,90vw)] max-h-[calc(100dvh-2.75rem)] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none"
>
{renderLines(openItems, "panel")}
</div>
)}
{open === index && drawsMenus && sub !== null && (
<div
role="menu"
aria-label={openItems[sub]?.label}
data-menu-subpanel=""
className="fixed z-10 min-w-44 max-w-[min(24rem,90vw)] max-h-[calc(100dvh-0.5rem)] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
style={{ top: -9999, left: -9999 }}
>
{renderLines(subItems, "subpanel")}
</div>
)}
</div>
))}
</div>
);
@@ -50,6 +50,20 @@ vi.mock('@/hooks/use-media-query', () => ({
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
// The desktop app's compose windows, for a mail link from another program.
const desktopWindows = vi.hoisted(() => ({ on: false, handOver: vi.fn(async () => true), open: vi.fn() }));
vi.mock('@/lib/popout/support', async (original) => ({
...(await original<object>()),
hasDesktopWindows: () => desktopWindows.on,
popoutsAvailable: () => desktopWindows.on,
}));
vi.mock('@/lib/popout/open', async (original) => ({
...(await original<object>()),
openComposerWindow: (account?: string | null) => {
desktopWindows.open(account);
return { handOver: desktopWindows.handOver, cancel: vi.fn() };
},
}));
const { MailApp } = await import('../mail-app');
@@ -164,3 +178,52 @@ describe('onMailtoIntercept', () => {
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
});
});
describe('a mail link from another program in the desktop app', () => {
beforeEach(() => {
clearAllHooks();
composerProps.last = null;
desktopWindows.on = true;
desktopWindows.open.mockClear();
desktopWindows.handOver.mockClear();
});
afterEach(() => {
desktopWindows.on = false;
});
const fromOs = async () => {
const { savePendingMailto, notifyPendingMailto } = await import('@/lib/protocol-handlers/session');
act(() => {
savePendingMailto({ to: ['bob@example.com'], cc: [], bcc: [], subject: 'Hi', body: 'Text', fromOs: true });
notifyPendingMailto();
});
};
it('opens in a compose window of its own, not in the main window', async () => {
render(<MailApp />);
await fromOs();
await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1));
expect(desktopWindows.handOver).toHaveBeenCalledWith(expect.objectContaining({
mode: 'compose',
initialData: expect.objectContaining({ to: 'bob@example.com', subject: 'Hi' }),
}));
await act(() => new Promise((r) => setTimeout(r, 50)));
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
});
it('a link inside the app still opens here', async () => {
render(<MailApp />);
act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); });
await screen.findByText('stub composer', {}, { timeout: 5000 });
expect(desktopWindows.open).not.toHaveBeenCalled();
});
it('a plugin that says no keeps both closed', async () => {
emailHooks.onBeforeCompose.register('test', () => false);
render(<MailApp />);
await fromOs();
await act(() => new Promise((r) => setTimeout(r, 100)));
expect(desktopWindows.open).not.toHaveBeenCalled();
expect(screen.queryByText('stub composer')).not.toBeInTheDocument();
});
});
@@ -8,6 +8,7 @@ import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useToastStore } from '@/stores/toast-store';
import { useMailTransferStore } from '@/stores/mail-transfer-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { clearAllHooks } from '@/lib/plugin-hooks';
import type { CommandGroup } from '@/lib/command-registry';
@@ -108,6 +109,18 @@ describe('Mail in the command palette', () => {
expect(ids).not.toContain('mark-read');
});
it("offers Import mail into the open folder, which left the reader's ⋮ menu", async () => {
let groups: CommandGroup[] = [];
render(<><MailApp /><Palette onGroups={(g) => { groups = g; }} /></>);
await screen.findByText('viewer', {}, { timeout: 5000 });
await waitFor(() => expect(groups.find((g) => g.label === 'sidebar.mail')).toBeDefined());
const importMail = groups.find((g) => g.label === 'sidebar.mail')!.commands.find((c) => c.id === 'import');
expect(importMail?.label).toBe('mail_import.menu_import');
act(() => importMail!.run());
expect(useMailTransferStore.getState().request).toMatchObject({ kind: 'import', mailboxId: 'inbox' });
act(() => useMailTransferStore.setState({ request: null, visible: false }));
});
it("says whose a shared folder is and where a subfolder is, so two Inboxes don't look the same", async () => {
const shared = { ...folder('support:inbox', 'inbox'), isShared: true, accountName: 'Support', accountId: 'support', originalId: 'inbox' } as Mailbox;
const archive = folder('archive', 'archive', 'Archive');
+69 -14
View File
@@ -65,7 +65,8 @@ import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
import { useDesktopCommands } from "@/lib/desktop/commands";
import { useDesktopPrefs } from "@/lib/desktop/preferences";
import { hasDesktopWindows } from "@/lib/popout/support";
import { hasDesktopWindows, popoutsAvailable } from "@/lib/popout/support";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { composerDataFromDraft } from "@/lib/draft-composer-data";
import { useUndoShortcut } from "@/hooks/use-undo-shortcut";
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
@@ -106,7 +107,8 @@ import type { Attachment, SendEmailResult } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { peekListAttachments, requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments";
import { useSearchHistoryStore } from "@/stores/search-history-store";
import { WelcomeBanner } from "@/components/ui/welcome-banner";
import { useWelcomeBannerDue, WelcomeBanner } from "@/components/ui/welcome-banner";
import { FirstStartNoticeBar } from "@/components/ui/first-start-notice";
import { NavigationRail } from "@/components/layout/navigation-rail";
import { AppMain } from "@/components/layout/app-landmarks";
import { MvTopBar } from "@/components/layout/mv-top-bar";
@@ -139,9 +141,9 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { openMailImport } from "@/stores/mail-transfer-store";
import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons";
import { useRegisterCommands } from "@/lib/command-registry";
import { MoveToDialog } from "@/components/email/move-to-dialog";
import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog";
import { SearchResultCount } from "@/components/email/search-result-count";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
@@ -257,6 +259,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('mail', routeSegments);
const t = useTranslations();
// First-start notices wait until the welcome tips are closed: one thing at a time.
const welcomeDue = useWelcomeBannerDue();
// The page skeleton the gates below draw: the same one the server sent.
const skeletonLabels = useSkeletonLabels();
const tNotifications = useTranslations('notifications');
@@ -1359,6 +1363,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const desktopSingle = !showComposer && !!selectedEmail;
const mailLayoutSetting = useSettingsStore((s) => s.mailLayout);
const layoutFixed = interfaceLayout === 'mountain-view';
// A shared folder you may only read from: nothing can leave it.
const desktopMayRemove = mailboxes.find((m) => m.id === selectedMailbox)?.myRights?.mayRemoveItems !== false;
useDesktopCommands({
'file.newMessage': keyboardHandlers.onCompose,
'edit.search': keyboardHandlers.onFocusSearch,
@@ -1385,6 +1391,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
'go.nextUnread': keyboardHandlers.onNextUnread,
}, {
'view.refresh': true,
// Ctrl+E in the composer belongs to its editor, not to search.
'edit.search': !showComposer,
'view.layout.split': { enabled: !layoutFixed, checked: mailLayoutSetting === 'split' },
'view.layout.horizontal': { enabled: !layoutFixed, checked: mailLayoutSetting === 'horizontal' },
'view.layout.focus': { enabled: !layoutFixed, checked: mailLayoutSetting === 'focus' },
@@ -1394,10 +1402,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen),
'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen),
'message.star': desktopSingle,
'message.move': desktopSingle,
'message.archive': desktopTarget && archiveActionEnabled,
'message.spam': desktopTarget,
'message.delete': desktopTarget,
'message.move': desktopSingle && desktopMayRemove,
'message.archive': desktopTarget && archiveActionEnabled && desktopMayRemove,
'message.spam': desktopTarget && desktopMayRemove,
'message.delete': desktopTarget && desktopMayRemove,
'message.openWindow': desktopSingle && !!openEmailInWindow,
'go.sent': true,
'go.drafts': true,
@@ -1665,7 +1673,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (isMobile) setActiveView("viewer");
}, [composerSessionId, isMobile, setActiveView]);
const openMailtoDraft = useCallback((pending: DraftRequest) => withComposeGate('compose', {
// A mail link from another program, in the desktop app: a compose window of its own,
// as Outlook and Thunderbird do, and the main window stays where it was. Plugins get
// their say first. False when it has to open here instead.
const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise<boolean> => {
if (!pending.fromOs || pending.initialFiles?.length || !hasDesktopWindows() || !popoutsAvailable()) return false;
let prefill: ComposePrefill | null = null;
if (emailHooks.onBeforeCompose.size > 0) {
prefill = await runBeforeCompose(composeOptionsFor('compose', {
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
ownEmails: identities.map((i) => i.email).filter((e): e is string => Boolean(e)),
replyPrefix: t('email_composer.prefix.reply'),
forwardPrefix: t('email_composer.prefix.forward'),
}));
// A plugin said no: nothing opens, here or there.
if (!prefill) return true;
}
const opened = openComposerWindow(useAuthStore.getState().activeAccountId);
if (!opened) return false;
void opened.handOver({
mode: 'compose',
initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode),
prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null,
});
return true;
}, [identities, t]);
const openMailtoHere = useCallback((pending: DraftRequest) => withComposeGate('compose', {
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
}, () => {
// A live composer owns text the user has not sent. Replacing it outright
@@ -1684,6 +1718,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
applyMailtoDraft(pending, showComposer);
}), [applyMailtoDraft, showComposer, withComposeGate]);
const openMailtoDraft = useCallback(async (pending: DraftRequest): Promise<boolean> => {
if (await openMailtoInWindow(pending)) return true;
// Opening here after all: the shell left this window where it was.
if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
return openMailtoHere(pending);
}, [openMailtoInWindow, openMailtoHere]);
const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => {
setIsProtocolAccountSwitching(true);
try {
@@ -1712,6 +1753,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise<void> => {
const protocolAccounts = getMailtoProtocolAccounts();
if (protocolAccounts.length > 1) {
// The question is asked in this window: bring it up.
if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
setPendingMailtoAccountChoice(pending);
return;
}
@@ -2351,6 +2394,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
empty: () => { if (selectedMailbox) void handleEmptyFolderFromContextMenu(selectedMailbox); },
};
const folderMenuMarkRead = useCallback(() => folderMenuLatest.current.markRead(), []);
// "Mark folder as read" is greyed out with nothing unread in the open folder.
const selectedFolderHasUnread = mailboxes.find((m) => m.id === selectedMailbox)?.unreadEmails !== 0;
const folderMenuMarkAllRead = useCallback(() => folderMenuLatest.current.markAllRead(), []);
const folderMenuEmpty = useCallback(() => folderMenuLatest.current.empty(), []);
@@ -4787,12 +4832,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
when: () => !!paletteEmail && !paletteEmail.keywords?.$seen,
run: () => { if (client && paletteEmail) void markReadWithUndo(paletteEmail, true).catch(toastUpdateFailure); },
},
{
id: 'star', label: paletteEmail?.keywords?.$flagged ? t('context_menu.unstar') : t('context_menu.star'), icon: Star, shortcut: 's',
when: hasPaletteEmail, run: () => void handleToggleStar(),
},
{
id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'),
icon: conversationInJunk ? ShieldCheck : ShieldAlert, shortcut: '!',
when: () => !!paletteEmail && conversationSpamApplicable,
run: () => void (conversationInJunk ? handleUndoSpam(paletteEmail) : handleMarkAsSpam(paletteEmail)),
},
// Into the open folder, as the folder's own menu does it.
{
id: 'import', label: t('mail_import.menu_import'), icon: Upload,
run: () => openMailImport({ mailboxId: selectedMailbox || undefined }),
},
...mailboxes
.filter((mb) => !mb.id.startsWith('shared-') && mb.id !== selectedMailbox)
.map((mb) => {
@@ -5502,7 +5556,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onClick={() => { close(); void handleManualRefresh(); }}
/>
<MailFolderMenuItems
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
onPicked={close}
@@ -5542,7 +5596,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
totalCount={searchQuery || !isFilterEmpty(searchFilters) ? undefined : mailboxes.find((mb) => mb.id === selectedMailbox)?.totalEmails}
onRefresh={handleManualRefresh}
isRefreshing={isManualRefreshing}
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
onCreateFolder={() => handleCreateFolderFromContextMenu()}
@@ -5612,7 +5666,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
folderRole={conversationRole}
onRefresh={handleManualRefresh}
refreshDisabled={!client || isManualRefreshing}
onMarkFolderRead={selectedMailbox ? folderMenuMarkRead : undefined}
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? folderMenuMarkRead : undefined}
onMarkAllFoldersRead={folderMenuMarkAllRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? folderMenuEmpty : undefined}
/>
@@ -5681,6 +5735,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
unless an enabled plugin registered tabs via api.tabs.set. */}
{!isScheduledView && <MessageListTabs panelId={messageListPanelId} />}
{!emptyPaneBesideList && <WelcomeBanner />}
<FirstStartNoticeBar hidden={welcomeDue && !emptyPaneBesideList} />
<div {...messageListPanelProps} className="flex-1 min-h-0 flex flex-col overflow-hidden">
<ErrorBoundary fallback={EmailListErrorFallback}>
@@ -6080,7 +6135,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined}
isFullscreen={fullscreenReading}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
onCancelScheduledForEdit={async () => {
if (!client || !selectedEmail) return;
@@ -6195,8 +6249,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
/>
)}
<ConfirmDialog {...confirmDialogProps} />
<MoveToDialogHost mailboxes={mailboxes} selectedMailbox={selectedMailbox} />
{moveDialogEmail && <MoveToDialog
email={moveDialogEmail}
emails={[moveDialogEmail]}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onClose={() => setMoveDialogEmail(null)}
@@ -0,0 +1,50 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useOfflineStore } from '@/lib/offline/state';
import { KeyProtectionNotes } from '../key-protection-notes';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
}));
function withShell(wrap: boolean): void {
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
version: 1,
platform: 'linux',
appLock: { status: async () => ({ wrap }) },
};
}
describe('what settings say about the key', () => {
beforeEach(() => useOfflineStore.setState({ keyUnprotected: false, rekey: {} }));
afterEach(() => {
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
it('a browser says it has no layer of the OS', async () => {
await act(async () => { render(<KeyProtectionNotes browser />); });
expect(screen.getByText('browser_note')).toBeTruthy();
});
it('the desktop app with a real key store says nothing', async () => {
withShell(true);
await act(async () => { render(<KeyProtectionNotes browser />); });
expect(screen.queryByTestId('key-protection-notes')).toBeNull();
});
it('Linux with no secret service says secrets are only obfuscated', async () => {
withShell(false);
await act(async () => { render(<KeyProtectionNotes browser />); });
expect(screen.getByText('no_secure_storage')).toBeTruthy();
expect(screen.queryByText('browser_note')).toBeNull();
});
it('says out loud when the key store refused, and how a re-encryption goes', async () => {
withShell(true);
useOfflineStore.setState({ keyUnprotected: true, rekey: { a: 40, b: 'failed' } });
await act(async () => { render(<KeyProtectionNotes />); });
expect(screen.getByText('wrap_failed')).toBeTruthy();
expect(screen.getByText('rekey_failed')).toBeTruthy();
expect(screen.getByText('rekey_progress:{"percent":40}')).toBeTruthy();
});
});
@@ -1,38 +0,0 @@
import { cleanup, render } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { OfflineNotice } from '@/lib/offline/offline-mode';
import { useToastStore } from '@/stores/toast-store';
// mail-26: the desktop app's "Bulwark keeps your mail on this device" notice
// stayed over the quick reply box until someone closed it.
const notifier = vi.hoisted(() => ({ show: null as ((notice: OfflineNotice) => void) | null }));
vi.mock('@/lib/offline/offline-mode', () => ({
setOfflineNotifier: (fn: ((notice: OfflineNotice) => void) | null) => {
notifier.show = fn;
},
startOfflineMode: () => () => {},
confirmLoss: () => {},
unsentCount: async () => ({ messages: 0, changes: 0 }),
}));
const { OfflineHost } = await import('../offline-host');
beforeEach(() => {
useToastStore.getState().clearToasts();
});
afterEach(() => {
cleanup();
});
describe('the notice that the desktop app keeps mail on this device', () => {
it('goes away by itself, and still offers Settings', () => {
render(<OfflineHost />);
notifier.show!({ kind: 'default-on' });
const [notice] = useToastStore.getState().toasts;
expect(notice.title).toBe('default_on');
expect(notice.duration).toBeGreaterThan(0);
expect(notice.action?.label).toBe('open_settings');
});
});
@@ -7,7 +7,9 @@ import { useOfflineStore, writeEnabled } from '@/lib/offline/state';
import { passSignOutGates } from '@/lib/sign-out-cleanup';
import { setStoreBackend } from '@/lib/store/client/api';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { wipeEverything } from '@/lib/offline/offline-mode';
import { offlineNotify, wipeEverything } from '@/lib/offline/offline-mode';
import { firstStartNoticesWaiting, resetFirstStartNotices } from '@/lib/first-start-notices';
import { useToastStore } from '@/stores/toast-store';
import { useAccountStore } from '@/stores/account-store';
let store: InProcessStore;
@@ -53,3 +55,15 @@ describe('the question before a sign-out', () => {
expect(screen.queryByRole('button', { name: /signout\.confirm/ })).toBeNull();
});
});
describe('the notice that the desktop app keeps mail on this device', () => {
it('is a first-start notice above the list, not a toast that covers the composer', () => {
resetFirstStartNotices();
useToastStore.setState({ toasts: [] });
render(<OfflineHost />);
offlineNotify({ kind: 'default-on' });
expect(useToastStore.getState().toasts).toHaveLength(0);
expect(firstStartNoticesWaiting().map((n) => n.text)).toEqual(['default_on']);
expect(firstStartNoticesWaiting()[0].action?.label).toBe('open_settings');
});
});
@@ -150,6 +150,23 @@ describe('the offline banner', () => {
expect(screen.getByTestId('offline-outbox-row').textContent).toContain('Quarterly');
});
it('can be hidden, and comes back when what it says changes', async () => {
const { OfflineBanner } = await import('../offline-banner');
sessionStorage.clear();
useOfflineStore.setState({ enabled: ['acc'], browserOffline: true, outbox: [] });
const { unmount } = render(<OfflineBanner />);
fireEvent.click(screen.getByRole('button', { name: 'hide_offline_notice' }));
expect(screen.queryByTestId('offline-banner')).toBeNull();
// Another page of the app, the same state: still hidden.
unmount();
render(<OfflineBanner />);
expect(screen.queryByTestId('offline-banner')).toBeNull();
// A message now waits: that's news.
act(() => useOfflineStore.setState({ outbox: [{ id: 2, account: 'acc', createdAt: Date.now(), subject: 'Later', to: ['b@example.org'], status: 'waiting' }] }));
expect(screen.getByTestId('offline-banner')).toBeInTheDocument();
sessionStorage.clear();
});
it('stays away for an account that doesn\'t keep mail here', async () => {
const { OfflineBanner } = await import('../offline-banner');
useOfflineStore.setState({ enabled: [], browserOffline: true, outbox: [] });
@@ -0,0 +1,49 @@
'use client';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { useOfflineStore } from '@/lib/offline/state';
/**
* What protects the key to the mail on this device, where that's less than one would think, and
* how a re-encryption is getting on. `browser`: also says what a browser can't do. The app lock's
* own settings have said that already.
*/
export function KeyProtectionNotes({ browser = false }: { browser?: boolean }) {
const t = useTranslations('keyProtection');
const keyUnprotected = useOfflineStore((state) => state.keyUnprotected);
const rekey = useOfflineStore((state) => state.rekey);
const [bridge, setBridge] = useState<'none' | 'wraps' | 'obfuscates'>('wraps');
useEffect(() => {
const member = getDesktopBridge()?.appLock;
if (!member) {
setBridge('none');
return;
}
let live = true;
void member.status().then((status) => {
if (live) setBridge(status.wrap ? 'wraps' : 'obfuscates');
}).catch(() => {});
return () => {
live = false;
};
}, []);
const states = Object.values(rekey);
const running = states.filter((state): state is number => typeof state === 'number');
const lines = [
bridge === 'obfuscates' ? t('no_secure_storage') : null,
bridge === 'none' && browser ? t('browser_note') : null,
keyUnprotected ? t('wrap_failed') : null,
states.includes('failed') ? t('rekey_failed') : null,
running.length > 0 ? t('rekey_progress', { percent: Math.min(...running) }) : null,
].filter((line): line is string => line !== null);
if (lines.length === 0) return null;
return (
<div className="space-y-1 rounded-md border border-border bg-muted px-3 py-2 text-xs text-foreground" data-testid="key-protection-notes">
{lines.map((line) => <p key={line}>{line}</p>)}
</div>
);
}
+43 -2
View File
@@ -2,8 +2,10 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { CloudOff } from '@/components/icons';
import { CloudOff, X } from '@/components/icons';
import { Dialog } from '@/components/ui/dialog';
import { IconButton } from '@/components/ui/icon-button';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
import { IS_LITE } from '@/lib/lite';
import { serverKey, useOfflineStore } from '@/lib/offline/state';
import { useAuthStore } from '@/stores/auth-store';
@@ -15,7 +17,20 @@ import { OutboxList } from './outbox-list';
* reached), and how many messages wait in the outbox. The count opens the
* outbox in a dialog, which works without a network (a page change might
* not). Shown only for an account that keeps mail here.
*
* It can be hidden until what it says changes (going offline again, another
* message waiting), for this session of the window.
*/
const HIDDEN_KEY = 'bulwark.offlineBanner.hidden';
function hiddenFor(): string | null {
try {
return sessionStorage.getItem(HIDDEN_KEY);
} catch {
return null;
}
}
export function OfflineBanner() {
const t = useTranslations('offline');
const account = useAuthStore((s) => s.activeAccountId);
@@ -29,15 +44,38 @@ export function OfflineBanner() {
const waitingDrafts = useOfflineStore((s) => s.localDrafts.filter((draft) => draft.status !== 'refused').length);
const tConflicts = useTranslations('offlineConflicts');
const tDrafts = useTranslations('offlineDrafts');
const tChrome = useTranslations('windowChrome');
const [open, setOpen] = useState(false);
const [hidden, setHidden] = useState<string | null>(hiddenFor);
const [banner, setBanner] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(banner);
const offline = browserOffline || serverDown;
const says = `${browserOffline ? 'offline' : serverDown ? 'down' : 'online'}|${waiting}|${conflicts}|${waitingDrafts}|${refusedDrafts}`;
if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && refusedDrafts === 0 && waitingDrafts === 0 && !open)) return null;
const hide = () => {
try {
sessionStorage.setItem(HIDDEN_KEY, says);
} catch {
// Hidden for now all the same.
}
setHidden(says);
};
const close = (
<IconButton label={tChrome('hide_offline_notice')} onClick={hide} className="-my-1 shrink-0" data-testid="offline-banner-hide">
<X />
</IconButton>
);
return (
<>
{hidden !== says && (
<div
ref={setBanner}
data-testid="offline-banner"
className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-b border-border bg-muted px-3 py-2 text-sm text-foreground sm:px-4"
data-window-top-row=""
className="flex shrink-0 items-center gap-2 border-b border-border bg-muted px-3 py-2 text-sm text-foreground sm:px-4"
>
{closeFirst && close}
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-3 gap-y-1">
{offline && (
<span className="flex min-w-0 items-start gap-2" role="status">
<CloudOff className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
@@ -88,7 +126,10 @@ export function OfflineBanner() {
{tDrafts('banner_refused', { count: refusedDrafts })}
</button>
)}
</div>
{!closeFirst && close}
</div>
)}
<Dialog isOpen={open} onClose={() => setOpen(false)} title={t('outbox.title')} testId="offline-outbox-dialog">
<OutboxList heading={false} onOpenDraft={() => setOpen(false)} />
</Dialog>
+12 -5
View File
@@ -9,11 +9,9 @@ import { addSignOutGate } from '@/lib/sign-out-cleanup';
import { reloadToNewBuild } from '@/lib/app-update';
import { buildSettingsPath } from '@/lib/deep-links';
import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode';
import { showFirstStartNotice } from '@/lib/first-start-notices';
import { toast } from '@/stores/toast-store';
/** How long the notice that the desktop app keeps mail stays: a long sentence, so twice a plain toast. */
const DEFAULT_ON_NOTICE_MS = 10_000;
/**
* Runs offline mode (#523) in this tab: the JMAP client's offline hooks,
* the outbox, the wipes. Its notices become toasts, and a sign-out the
@@ -30,6 +28,9 @@ export function OfflineHost() {
const tStorage = useTranslations('offlineStorage');
const tStorageRef = useRef(tStorage);
tStorageRef.current = tStorage;
const tKey = useTranslations('keyProtection');
const tKeyRef = useRef(tKey);
tKeyRef.current = tKey;
const router = useRouter();
const routerRef = useRef(router);
routerRef.current = router;
@@ -77,8 +78,8 @@ export function OfflineHost() {
toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage });
break;
case 'default-on':
// Said once; it goes like other notices, and Settings has the switch.
toast.info(tStorageRef.current('default_on'), { duration: DEFAULT_ON_NOTICE_MS, action: openStorage });
// A first-start notice, not a toast: it stays until closed, and covers nothing.
showFirstStartNotice({ id: 'offline-default-on', text: tStorageRef.current('default_on'), action: openStorage });
break;
case 'legacy-stranded':
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
@@ -93,6 +94,12 @@ export function OfflineHost() {
case 'store-unavailable':
toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 });
break;
case 'key-unprotected':
toast.warning(tKeyRef.current('wrap_failed'), { duration: 0 });
break;
case 'rekey-failed':
toast.warning(tKeyRef.current('rekey_failed'), { duration: 0 });
break;
case 'refused-delete':
toast.warning(tr('toast.refused_delete'));
break;
+2
View File
@@ -15,6 +15,7 @@ import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { AccountStorage } from './account-storage';
import { OutboxList } from './outbox-list';
import { KeyProtectionNotes } from './key-protection-notes';
import { WipeDeviceSection } from './wipe-device-section';
/**
@@ -65,6 +66,7 @@ export function OfflineSettings() {
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p data-testid="offline-warning">{isDesktopApp() ? tStorage('warning_desktop') : t('warning')}</p>
</div>
<KeyProtectionNotes browser />
{enabledCount > 0 && persist === 'refused' && (
<div className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="status" data-testid="offline-persist-refused">
<p className="min-w-0">{tStorage('persist_refused')}</p>
+24 -15
View File
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useState, type MouseEvent } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { AlertCircle, AlertTriangle, Info, X } from '@/components/icons';
import { usePolicyRefresh } from '@/hooks/use-policy-refresh';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
import {
announcementActive,
announcementKey,
@@ -93,6 +94,9 @@ export function OrgAnnouncement() {
const announcement = usePolicyStore((s) => s.policy.organisation?.announcement);
const accountId = useAuthStore((s) => s.activeAccountId);
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
// At the window's top, its close goes to the start when the window's own close is at its end.
const [row, setRow] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(row);
const text = announcement ? announcementText(announcement, locale) : '';
const now = useClock(text ? nextAnnouncementChange(announcement, Date.now()) : null);
@@ -113,14 +117,33 @@ export function OrgAnnouncement() {
const Icon = ICONS[severity];
const severityLabel = t(`severity_${severity}`);
const dismiss = announcement.dismissible && (
<button
type="button"
onClick={() => {
dismissAnnouncement(announcement, accountId);
setDismissedKey(`${accountId}|${key}`);
}}
aria-label={t('dismiss')}
title={t('dismiss')}
data-testid="org-announcement-dismiss"
className={cn(closeFirst ? '-ms-2' : '-me-2', 'inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-md hover:bg-foreground/10 focus-visible:outline-2 focus-visible:outline-offset-[-2px]')}
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
);
return (
<div
ref={setRow}
role="region"
aria-label={t('label')}
data-testid="org-announcement"
data-severity={severity}
data-window-top-row=""
className={cn('flex shrink-0 items-start gap-2 border-b px-3 text-sm sm:px-4', STYLES[severity])}
>
{closeFirst && dismiss}
{firstShow && <LiveOnce text={`${severityLabel}: ${text}`} assertive={severity === 'critical'} />}
<Icon className="mt-3 h-4 w-4 shrink-0" aria-hidden="true" />
<p className="min-w-0 flex-1 whitespace-pre-line break-words py-2.5 leading-snug [overflow-wrap:anywhere]">
@@ -141,21 +164,7 @@ export function OrgAnnouncement() {
<span key={index}>{part.text}</span>
))}
</p>
{announcement.dismissible && (
<button
type="button"
onClick={() => {
dismissAnnouncement(announcement, accountId);
setDismissedKey(`${accountId}|${key}`);
}}
aria-label={t('dismiss')}
title={t('dismiss')}
data-testid="org-announcement-dismiss"
className="-me-2 inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-md hover:bg-foreground/10 focus-visible:outline-2 focus-visible:outline-offset-[-2px]"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
{!closeFirst && dismiss}
</div>
);
}
+1 -1
View File
@@ -160,7 +160,7 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
label={action.label}
title={action.pluginName}
destructive={action.danger}
shortcut={action.badge ? badgeText(action.badge) : undefined}
badge={action.badge ? badgeText(action.badge) : undefined}
onClick={() => { void run(action); }}
/>
))}
+18 -10
View File
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore
import { useTranslations } from 'next-intl';
import { iconForName, Puzzle, X, type AppIcon } from '@/components/icons';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
import { sidePanelLooks, subscribeSidePanels } from '@/lib/plugin-sandbox/side-panels';
@@ -102,19 +103,26 @@ export function PluginSidePanelBody({ entry, focusRequest, onClose }: {
}) {
const tCommon = useTranslations('common');
const extraProps = useMemo(() => ({ focusRequest }), [focusRequest]);
// Right under the window's own close, at the window's end: this one goes to the start.
const [head, setHead] = useState<HTMLDivElement | null>(null);
const closeFirst = useCloseBesideControls(head);
const close = (
<button
type="button"
onClick={onClose}
className="grid h-8 w-8 shrink-0 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon('close')}
aria-label={tCommon('close')}
>
<X className="h-4 w-4" />
</button>
);
return (
<>
<div className="flex shrink-0 items-center gap-1 ps-4 pe-2 pt-3 pb-1">
<div ref={setHead} data-window-pane-head="" className={cn("flex shrink-0 items-center gap-1 pt-3 pb-1", closeFirst ? "ps-2 pe-4" : "ps-4 pe-2")}>
{closeFirst && close}
<div className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">{entry.title}</div>
<button
type="button"
onClick={onClose}
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
title={tCommon('close')}
aria-label={tCommon('close')}
>
<X className="h-4 w-4" />
</button>
{!closeFirst && close}
</div>
<div
data-plugin-slot="side-panel"
@@ -74,7 +74,12 @@ describe('when the session is refused', () => {
endSession();
expect(screen.getByTestId('content')).toBeTruthy();
expect(mounts).toBe(1);
expect(document.querySelector('[data-popout-session-ended]')).toBeTruthy();
const line = document.querySelector<HTMLElement>('[data-popout-session-ended]')!;
expect(line).toBeTruthy();
// It's the window's top row: room for the window's controls, and its own
// "Close window" gives way to the window's close where there is one.
expect(line.hasAttribute('data-window-top-row')).toBe(true);
expect(screen.getByRole('button', { name: 'close_window' }).hasAttribute('data-own-window-close')).toBe(true);
});
});
+1 -1
View File
@@ -178,7 +178,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email
return (
<>
{moved && (
<div role="status" data-popout-moved="" className="shrink-0 border-b border-border bg-muted px-4 py-2 text-sm text-foreground">
<div role="status" data-popout-moved="" data-window-top-row="" className="flex shrink-0 items-center border-b border-border bg-muted px-4 py-2 text-sm text-foreground">
{movedTo
? t("message_moved_to", { folder: localizeMailboxName(movedTo.role, movedTo.name, (key) => tSidebar(`mailboxes.${key}`)) })
: t("message_moved")}
+4 -2
View File
@@ -85,9 +85,11 @@ export function PopoutShell({ kind, accountId, children }: {
<PopoutNotice title={t("session_ended_title")} body={t("session_ended_body")}>{actions}</PopoutNotice>
)}
{status === "no-session" && kind === "compose" && (
<div role="alert" data-popout-session-ended="" className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border bg-muted px-4 py-2 text-sm text-foreground">
// The window's top row: room for the window's controls, and no second close beside its own.
<div role="alert" data-popout-session-ended="" data-window-top-row="" className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border bg-muted px-4 py-2 text-sm text-foreground">
<span className="min-w-0 flex-1">{t("session_ended_title")}. {t("session_ended_body")}</span>
{actions}
<Button onClick={() => window.location.replace(withBasePath("/"))}>{t("open_app")}</Button>
<Button variant="ghost" onClick={closePopoutWindow} data-own-window-close="">{t("close_window")}</Button>
</div>
)}
{/* One place in the tree, so a composer stays mounted, text and all, when the session ends. */}
@@ -1,4 +1,4 @@
import { render, waitFor } from '@testing-library/react';
import { act, render, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
import { ProTabFocusContext } from '@/hooks/use-pane-context';
@@ -26,6 +26,20 @@ vi.mock('@/components/error', () => ({
}));
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
// A message window: the Message menu's Move to… opens the folder list there.
const popout = vi.hoisted(() => ({ value: false }));
vi.mock('@/lib/popout/window-role', async (importOriginal) => ({
...await importOriginal<typeof import('@/lib/popout/window-role')>(),
isPopoutWindow: () => popout.value,
}));
const moveDialog = vi.hoisted(() => ({ current: null as { emails: unknown[] } | null }));
vi.mock('@/components/email/move-to-dialog', () => ({
MoveToDialog: (props: { emails: unknown[] }) => {
moveDialog.current = props;
return null;
},
}));
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
const multiAccount = vi.hoisted(() => ({
value: { enabled: false, groups: [] as unknown[], allIdentities: [] as Array<{ id: string; email: string; name: string }> },
@@ -79,6 +93,7 @@ vi.mock('@/stores/pro-tab-store', () => ({
}));
import { ProEmailView } from '../pro-email-tab-body';
import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
const DRAFT = {
id: 'eaaaaab',
@@ -307,3 +322,24 @@ describe('Pro email view - keyboard', () => {
expect(openComposeTab).not.toHaveBeenCalled();
});
});
describe('Message > Move to… in a message window', () => {
it('opens the folder list for the message on screen', async () => {
popout.value = true;
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: vi.fn() } } as never;
try {
const client = { getEmail: vi.fn().mockResolvedValue(DRAFT), getUsername: () => 'userb@example.org', getAccountId: () => 'e' };
render(<ProEmailView emailId="eaaaaab" client={client as never} onClose={vi.fn()} />);
await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
expect(moveDialog.current?.emails ?? []).toEqual([]);
act(() => {
expect(runDesktopCommand('message.move')).toBe(true);
});
await waitFor(() => expect(moveDialog.current?.emails?.[0]).toMatchObject({ id: 'eaaaaab' }));
} finally {
popout.value = false;
window.bulwarkDesktop = undefined;
resetDesktopCommands();
}
});
});
+14
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { EmailViewer } from "@/components/email/email-viewer";
import { MoveToDialog } from "@/components/email/move-to-dialog";
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore, resolveEmailBlobContext, bulkDeleteIsPermanent, restoreBatchesFor, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store";
@@ -480,6 +481,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
// In a pop-out this message is all there is, so the menu bar's Message menu means it.
const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow();
const seen = !!email?.keywords?.$seen;
// Message > Move to… in a pop-out: the same folder list the command palette opens.
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
useDesktopCommands({
'message.reply': () => handleReply(),
'message.replyAll': handleReplyAll,
@@ -489,6 +492,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
'message.star': () => void handleToggleStar(),
'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined,
'message.delete': () => void handleDelete(),
'message.move': () => { if (email) setMoveDialogOpen(true); },
}, {
'message.reply': !!email,
'message.replyAll': !!email,
@@ -498,6 +502,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
'message.star': !!email,
'message.archive': !!email && archiveActionEnabled,
'message.delete': !!email,
'message.move': !!email,
}, inOwnWindow);
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
@@ -654,6 +659,15 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
/>
</ErrorBoundary>
<ConfirmDialog {...confirmDialogProps} />
{inOwnWindow && (
<MoveToDialog
emails={moveDialogOpen && email ? [email] : []}
mailboxes={mailboxes}
selectedMailbox=""
onClose={() => setMoveDialogOpen(false)}
onMove={(mailboxId) => void handleMoveToMailbox(mailboxId)}
/>
)}
{preview && (
<FilePreviewModal
name={preview.name}
@@ -0,0 +1,70 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge, DesktopDevToolsState } from '@/lib/desktop/types';
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
}));
const { DesktopDevToolsSettings } = await import('../desktop-devtools-settings');
function setBridge(state: DesktopDevToolsState | null, platform: 'win32' | 'darwin' = 'win32') {
const listeners = new Set<(next: DesktopDevToolsState) => void>();
const set = vi.fn(async (on: boolean) => ({ ...state!, on }));
window.bulwarkDesktop = (state
? {
version: 1,
platform,
devTools: {
get: async () => state,
set,
onChange: (listener: (next: DesktopDevToolsState) => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
}
: { version: 1, platform }) as unknown as BulwarkDesktopBridge;
return { set, emit: (next: DesktopDevToolsState) => listeners.forEach((listener) => listener(next)) };
}
afterEach(() => {
window.bulwarkDesktop = undefined;
});
describe('the developer tools setting', () => {
it('is not there in a browser or on a shell without it', () => {
const { container } = render(<DesktopDevToolsSettings />);
expect(container.innerHTML).toBe('');
setBridge(null);
const second = render(<DesktopDevToolsSettings />);
expect(second.container.innerHTML).toBe('');
});
it('is off by default, names the shortcut, and asks main to turn it on', async () => {
const { set } = setBridge({ on: false, forcedOff: false, locked: false });
render(<DesktopDevToolsSettings />);
const toggle = await screen.findByTestId('desktop-devtools-toggle');
expect(toggle.getAttribute('aria-checked')).toBe('false');
expect(screen.getByText('label:{"shortcut":"Ctrl+Shift+I"}')).toBeTruthy();
fireEvent.click(toggle);
expect(set).toHaveBeenCalledWith(true);
});
it('a Mac names its own keys', async () => {
setBridge({ on: false, forcedOff: false, locked: false }, 'darwin');
render(<DesktopDevToolsSettings />);
expect(await screen.findByText('label:{"shortcut":"Cmd+Option+I"}')).toBeTruthy();
});
it('a policy locks it off, and the app lock stops changes', async () => {
const { emit } = setBridge({ on: false, forcedOff: true, locked: false });
render(<DesktopDevToolsSettings />);
const toggle = await screen.findByTestId('desktop-devtools-toggle');
expect(toggle.hasAttribute('disabled')).toBe(true);
expect(screen.getByText('forced_off')).toBeTruthy();
act(() => emit({ on: false, forcedOff: false, locked: true }));
expect(screen.getByText('locked')).toBeTruthy();
expect(screen.getByTestId('desktop-devtools-toggle').hasAttribute('disabled')).toBe(true);
});
});
@@ -0,0 +1,32 @@
import { cleanup, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
import en from '@/locales/en/common.json';
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
const { ProtocolHandlerSettings } = await import('../protocol-handler-settings');
afterEach(() => {
cleanup();
window.bulwarkDesktop = undefined;
});
describe('Settings > General in the desktop app', () => {
it('a browser keeps its "Default apps" section', () => {
render(<ProtocolHandlerSettings supportsCalendar />);
expect(screen.getByText('register_mailto')).toBeTruthy();
});
it('the desktop app shows only its own "Default mail app" row, not the browser\'s handlers', () => {
window.bulwarkDesktop = { version: 1, platform: 'win32' } as unknown as BulwarkDesktopBridge;
render(<ProtocolHandlerSettings supportsCalendar />);
expect(screen.queryByText('register_mailto')).toBeNull();
expect(screen.queryByText('title')).toBeNull();
});
it('the section about the app\'s windows isn\'t called "Windows", which reads as the OS', () => {
expect(en.desktopUi.settings.title).not.toBe('Windows');
expect(en.desktopUi.settings.title).toBe('App windows');
});
});
+9 -1
View File
@@ -24,6 +24,7 @@ import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopOsUnlockKind } from "@/lib/desktop/types";
import { IS_LITE } from "@/lib/lite";
import { toast } from "@/stores/toast-store";
import { KeyProtectionNotes } from "@/components/offline/key-protection-notes";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
type Pending = "enable" | "disable" | "change" | "passkey" | "os" | null;
@@ -39,6 +40,7 @@ function useLockFacts() {
/** The app lock's section on the General page: it has to exist on any server. */
export function AppLockSettings() {
const t = useTranslations("appLock");
const tKey = useTranslations("keyProtection");
const tEdit = useTranslations("desktopEdit");
const { appName } = useConfig();
const { on, required, timeout, update } = useLockFacts();
@@ -95,7 +97,12 @@ export function AppLockSettings() {
event.preventDefault();
if (busy) return;
if (pending === "enable" && newPinReady(pin, repeat)) void run(() => enableAppLock(pin));
if (pending === "change" && current && newPinReady(pin, repeat)) void run(() => changeAppLockPin(current, pin));
if (pending === "change" && current && newPinReady(pin, repeat)) {
void run(async () => {
const { reenroll } = await changeAppLockPin(current, pin);
if (reenroll) toast.info(tKey("reenroll_note"), { duration: 0 });
});
}
if (pending === "disable" && current) void run(() => disableAppLock(current));
if (pending === "passkey" && current) {
void run(async () => {
@@ -130,6 +137,7 @@ export function AppLockSettings() {
<ToggleSwitch checked={on || required} onChange={(next) => setPending(next ? "enable" : "disable")} testId="app-lock-switch" />
</SettingItem>
<p className="text-xs text-muted-foreground" data-testid="app-lock-protects">{t("protects_desc")}</p>
<KeyProtectionNotes />
{on && (
<>
@@ -0,0 +1,51 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopDevToolsState } from "@/lib/desktop/types";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
/**
* Troubleshooting in the desktop app: Chromium's developer tools on a key, off until the
* user turns them on. Main keeps the switch and asks with a native warning, so a page or
* plugin can't turn it on by itself. A browser never shows this.
*/
export function DesktopDevToolsSettings() {
const t = useTranslations("desktopDevTools");
const bridge = getDesktopBridge();
const devTools = bridge?.devTools;
const [state, setState] = useState<DesktopDevToolsState | null>(null);
useEffect(() => {
if (!devTools) return;
let stale = false;
void devTools.get().then((next) => {
if (!stale) setState(next);
}, () => {});
const stop = devTools.onChange((next) => {
if (!stale) setState(next);
});
return () => {
stale = true;
stop();
};
}, [devTools]);
if (!devTools || !state) return null;
const shortcut = bridge?.platform === "darwin" ? "Cmd+Option+I" : "Ctrl+Shift+I";
const description = state.forcedOff ? t("forced_off") : state.locked ? t("locked") : t("description", { shortcut });
return (
<SettingsSection id="desktop-troubleshooting" title={t("section_title")}>
<SettingItem label={t("label", { shortcut })} description={description} locked={state.forcedOff}>
<ToggleSwitch
checked={state.on}
disabled={state.forcedOff || state.locked}
testId="desktop-devtools-toggle"
onChange={(on) => void devTools.set(on).then(setState, () => {})}
/>
</SettingItem>
</SettingsSection>
);
}
+42 -33
View File
@@ -16,10 +16,12 @@ function shortcutLabel(platform: string | undefined): string {
}
/**
* The desktop app's own settings, on the General page: only where there's an app to
* set. A browser never sees it, and it renders nothing until the bridge has answered.
* The desktop app's own settings: only where there's an app to set. A browser never sees
* them, and they render nothing until the bridge has answered. The General page has how
* the app runs; the Notifications page has the taskbar flash and quiet hours, next to
* the rest of what notifies.
*/
export function DesktopOsSettings() {
export function DesktopOsSettings({ part = "app" }: { part?: "app" | "notifications" } = {}) {
const t = useTranslations("desktopOs");
const [status, setStatus] = useState<DesktopOsStatus | null>(null);
const [defaultApp, setDefaultApp] = useState<DesktopOsDefaultAppStatus | null>(null);
@@ -48,6 +50,43 @@ export function DesktopOsSettings() {
void os.setSettings(patch).then(setStatus);
};
if (part === "notifications") {
return (
<SettingsSection id="desktop-notifications" title={t("section_title")}>
<SettingItem
label={bridge?.platform === "darwin" ? t("attention_mac_label") : t("attention_label")}
description={t("attention_description")}
>
<ToggleSwitch checked={settings.attentionOnNewMail} onChange={(attentionOnNewMail) => change({ attentionOnNewMail })} />
</SettingItem>
<SettingItem label={t("quiet_hours_label")} description={t("quiet_hours_description")}>
<ToggleSwitch checked={settings.quietHoursEnabled} onChange={(quietHoursEnabled) => change({ quietHoursEnabled })} />
</SettingItem>
{settings.quietHoursEnabled && (
<SettingItem nested label={t("quiet_hours_from")}>
<div className="flex items-center gap-2">
<input
type="time"
aria-label={t("quiet_hours_from")}
value={settings.quietHoursStart}
onChange={(event) => change({ quietHoursStart: event.target.value })}
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
/>
<span className="text-sm text-muted-foreground">{t("quiet_hours_to")}</span>
<input
type="time"
aria-label={t("quiet_hours_to")}
value={settings.quietHoursEnd}
onChange={(event) => change({ quietHoursEnd: event.target.value })}
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
/>
</div>
</SettingItem>
)}
</SettingsSection>
);
}
return (
<SettingsSection id="desktop-app" title={t("section_title")} description={t("section_description")}>
<SettingItem label={t("close_to_tray_label")} description={t("close_to_tray_description")}>
@@ -61,36 +100,6 @@ export function DesktopOsSettings() {
<ToggleSwitch checked={settings.startMinimized} onChange={(startMinimized) => change({ startMinimized })} />
</SettingItem>
)}
<SettingItem
label={bridge?.platform === "darwin" ? t("attention_mac_label") : t("attention_label")}
description={t("attention_description")}
>
<ToggleSwitch checked={settings.attentionOnNewMail} onChange={(attentionOnNewMail) => change({ attentionOnNewMail })} />
</SettingItem>
<SettingItem label={t("quiet_hours_label")} description={t("quiet_hours_description")}>
<ToggleSwitch checked={settings.quietHoursEnabled} onChange={(quietHoursEnabled) => change({ quietHoursEnabled })} />
</SettingItem>
{settings.quietHoursEnabled && (
<SettingItem nested label={t("quiet_hours_from")}>
<div className="flex items-center gap-2">
<input
type="time"
aria-label={t("quiet_hours_from")}
value={settings.quietHoursStart}
onChange={(event) => change({ quietHoursStart: event.target.value })}
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
/>
<span className="text-sm text-muted-foreground">{t("quiet_hours_to")}</span>
<input
type="time"
aria-label={t("quiet_hours_to")}
value={settings.quietHoursEnd}
onChange={(event) => change({ quietHoursEnd: event.target.value })}
className="rounded-md border border-border bg-background px-2 py-1 text-sm"
/>
</div>
</SettingItem>
)}
<SettingItem
label={t("show_shortcut_label")}
description={settings.showShortcut && !status.shortcutActive
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { hasDesktopWindows } from "@/lib/popout/support";
import { menuBarAvailable, useMenuBarPinned } from "@/lib/desktop/menu-bar";
import { setDesktopPref, useDesktopPrefs } from "@/lib/desktop/preferences";
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
@@ -21,6 +22,21 @@ export function DesktopWindowSettings() {
<SettingItem label={t("restore_label")} description={t("restore_description")}>
<ToggleSwitch checked={prefs.restoreWindows} onChange={(checked) => setDesktopPref("restoreWindows", checked)} />
</SettingItem>
<MenuBarSetting />
</SettingsSection>
);
}
/** "Always show the menu bar", as in the View menu. Windows and Linux: macOS has the system's. */
function MenuBarSetting() {
const t = useTranslations("desktopMenuBar");
const [available, setAvailable] = useState(false);
const [pinned, setPinned] = useMenuBarPinned();
useEffect(() => setAvailable(menuBarAvailable()), []);
if (!available) return null;
return (
<SettingItem label={t("always_show_label")} description={t("always_show_description")}>
<ToggleSwitch checked={pinned} onChange={setPinned} />
</SettingItem>
);
}
+2
View File
@@ -15,6 +15,7 @@ import { PlayCircle } from '@/components/icons';
import { IS_LITE } from '@/lib/lite';
import { DesktopUpdateSettings } from './desktop-update-settings';
import { DesktopWindowSettings } from './desktop-window-settings';
import { DesktopDevToolsSettings } from './desktop-devtools-settings';
/**
* The General page: language and time, which links the browser hands to
@@ -61,6 +62,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
</SettingsSection>
{!IS_LITE && <DesktopUpdateSettings />}
<DesktopDevToolsSettings />
</div>
);
}
@@ -6,6 +6,7 @@ import { useSettingsStore } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
import { useIdentityStore } from '@/stores/identity-store';
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
import { DesktopOsSettings } from './desktop-os-settings';
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { Button } from '@/components/ui/button';
@@ -424,6 +425,9 @@ export function NotificationSettings() {
</SettingItem>
</SettingsSection>
{/* The desktop app's taskbar flash and quiet hours. Nothing in a browser. */}
<DesktopOsSettings part="notifications" />
<SettingsSection id="push" title={t('push.title')} description={t('push.description')} term="push">
<SettingItem label={t('push.enable')} description={statusDescription}>
<div className="flex items-center gap-2">
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { useConfig } from "@/hooks/use-config";
import { getPathPrefix } from "@/lib/browser-navigation";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { productName } from "@/lib/product-name";
import { useSettingsStore } from "@/stores/settings-store";
import type { ProtocolOpenMode } from "@/stores/settings-store";
@@ -41,9 +42,13 @@ export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSet
const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode);
const updateSetting = useSettingsStore((state) => state.updateSetting);
const [supported, setSupported] = useState(false);
// The desktop app is a mail app of its own: its "Default mail app" row (Desktop app
// section) is the one that counts, and a browser's handler means nothing there.
const [desktop, setDesktop] = useState(false);
useEffect(() => {
setSupported(canRegisterProtocolHandler());
setDesktop(!!getDesktopBridge());
}, []);
const handleOpenModeChange = async (value: string) => {
@@ -76,6 +81,8 @@ export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSet
);
};
if (desktop) return null;
return (
<SettingsSection id="links" title={t("title")} description={t("description", { appName })}>
{!supported && (
@@ -0,0 +1,236 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ContextMenu, ContextMenuItem, ContextMenuSubMenu, type ContextMenuPosition } from '../context-menu';
import { useContextMenu } from '@/hooks/use-context-menu';
import { placeMenu } from '@/lib/menu-placement';
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
// Every menu opens next to what opened it: under a button, lined up with its
// edge, or at the pointer, and towards the other side only where there's no
// room. Never clamped somewhere away from the button.
function rect(left: number, top: number, width: number, height: number): DOMRect {
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
}
const MENU = { width: 220, height: 300 };
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: 720, configurable: true });
Object.defineProperty(window, 'outerWidth', { value: 1280, configurable: true });
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('role') === 'menu' ? rect(0, 0, MENU.width, MENU.height) : rect(0, 0, 0, 0);
});
});
afterEach(() => {
vi.restoreAllMocks();
window.bulwarkDesktop = undefined;
document.documentElement.dir = '';
});
function openAt(position: ContextMenuPosition) {
render(
<ContextMenu isOpen position={position} onClose={() => {}}>
<ContextMenuItem label="Reply" onClick={() => {}} />
</ContextMenu>,
);
const menu = screen.getByRole('menu');
return { left: parseFloat(menu.style.left), top: parseFloat(menu.style.top) };
}
describe('a menu from a right-click', () => {
it('opens at the pointer where there is room', () => {
expect(openAt({ x: 300, y: 200 })).toEqual({ left: 300, top: 200 });
});
it('opens to the left of the pointer near the right edge, its corner still at the pointer', () => {
// Clamped instead, the menu would sit under the pointer, away from where the click was.
expect(openAt({ x: 1200, y: 200 })).toEqual({ left: 1200 - MENU.width, top: 200 });
});
it('opens upwards from the pointer near the bottom', () => {
expect(openAt({ x: 300, y: 650 })).toEqual({ left: 300, top: 650 - MENU.height });
});
it('keeps the pointer where it was: the hook no longer guesses a size and moves it first', () => {
function Row() {
const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu<string>();
return (
<>
<div data-testid="row" onContextMenu={(e) => openContextMenu(e, 'row')}>Row</div>
<ContextMenu isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem label="Reply" onClick={() => {}} />
</ContextMenu>
</>
);
}
render(<Row />);
// A short menu right-clicked at 640: it fits below the pointer.
MENU.height = 60;
try {
fireEvent.contextMenu(screen.getByTestId('row'), { clientX: 400, clientY: 640 });
const menu = screen.getByRole('menu');
expect(menu.style.top).toBe('640px');
expect(menu.style.left).toBe('400px');
} finally {
MENU.height = 300;
}
});
});
describe('a menu from a button', () => {
it('sits under the button, lined up with its start edge', () => {
expect(openAt({ x: 0, y: 0, anchor: { left: 100, top: 40, right: 132, bottom: 72 } })).toEqual({ left: 100, top: 76 });
});
it('lines up with the button\'s end edge for a button at the end of a toolbar', () => {
expect(openAt({ x: 0, y: 0, anchor: { left: 900, top: 40, right: 932, bottom: 72, align: 'end' } })).toEqual({ left: 932 - MENU.width, top: 76 });
});
it('switches to the button\'s other edge when it doesn\'t fit, instead of sliding away from it', () => {
const at = openAt({ x: 0, y: 0, anchor: { left: 1200, top: 40, right: 1232, bottom: 72 } });
expect(at.left + MENU.width).toBe(1232);
});
it('goes above the button when there is no room below', () => {
expect(openAt({ x: 0, y: 0, anchor: { left: 100, top: 600, right: 132, bottom: 632 } }).top).toBe(600 - 4 - MENU.height);
});
it('too tall for either side: stays under the button, as tall as the room, and scrolls', () => {
// The ⋮ menu at 150 % zoom: 480 CSS pixels of window.
Object.defineProperty(window, 'innerHeight', { value: 480, configurable: true });
MENU.height = 460;
try {
render(
<ContextMenu isOpen position={{ x: 0, y: 0, anchor: { left: 805, top: 12, right: 837, bottom: 44, align: 'end' } }} onClose={() => {}}>
<ContextMenuItem label="Reply" onClick={() => {}} />
</ContextMenu>,
);
const menu = screen.getByRole('menu');
// Clamped instead, it sat at 10 and covered its own button.
expect(menu.style.top).toBe('48px');
expect((menu.firstElementChild as HTMLElement).style.maxHeight).toBe(`${480 - 10 - 48 - 2}px`);
} finally {
MENU.height = 300;
}
});
it('mirrors in RTL: start is the right edge', () => {
document.documentElement.dir = 'rtl';
const at = openAt({ x: 0, y: 0, anchor: { left: 500, top: 40, right: 532, bottom: 72 } });
// jsdom computes no direction from dir; the pure function covers RTL below.
expect([500, 532 - MENU.width]).toContain(at.left);
});
});
describe('placeMenu', () => {
const viewport = { width: 1280, height: 720 };
const size = { width: 220, height: 300 };
it('RTL: under a button, its right edge with the button\'s right edge', () => {
expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 500, top: 40, right: 532, bottom: 72 }, size, viewport, rtl: true }))
.toEqual({ x: 532 - 220, y: 76 });
});
it('RTL: an end-aligned button gets the left edge', () => {
expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 40, top: 40, right: 72, bottom: 72, align: 'end' }, size, viewport, rtl: true }))
.toEqual({ x: 40, y: 76 });
});
it('RTL: a right-click opens to the left of the pointer', () => {
expect(placeMenu({ point: { x: 600, y: 100 }, size, viewport, rtl: true })).toEqual({ x: 380, y: 100 });
});
it('taller than the window: inside it, from the top margin, and the menu scrolls', () => {
expect(placeMenu({ point: { x: 600, y: 100 }, size: { width: 220, height: 900 }, viewport })).toEqual({ x: 600, y: 10 });
});
it('wider than the room on both sides of a button: clamped inside the window', () => {
expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 100, top: 40, right: 132, bottom: 72 }, size: { width: 1270, height: 100 }, viewport }).x).toBe(10);
});
});
describe('a native menu from a button', () => {
it('lines its right edge up with an end-aligned button, by the native menu\'s width, not the hidden copy\'s', async () => {
const popup = vi.fn(async () => null);
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as unknown as BulwarkDesktopBridge;
// The hidden copy holds every submenu open, so it is far wider than the menu the OS draws.
MENU.width = 700;
try {
render(
<ContextMenu isOpen position={{ x: 0, y: 0, anchor: { left: 468, top: 40, right: 500, bottom: 72, align: 'end' } }} onClose={() => {}}>
<ContextMenuItem label="Forward as attachment" onClick={() => {}} />
<ContextMenuSubMenu label="Rules">
<ContextMenuItem label="Always move mail from this sender to a folder of its own" onClick={() => {}} />
</ContextMenuSubMenu>
<ContextMenuItem label="Print" onClick={() => {}} />
</ContextMenu>,
);
await waitFor(() => expect(popup).toHaveBeenCalled());
const at = (popup.mock.calls[0] as unknown[])[1] as { x: number; y: number };
expect(at.y).toBe(76);
// Under the button: its left edge somewhere left of the button, not at the window's edge.
expect(at.x).toBeGreaterThan(200);
expect(at.x).toBeLessThan(468);
} finally {
MENU.width = 220;
}
});
});
describe('a menu from a right-click on a row', () => {
function Row() {
const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu<string>();
return (
<>
<div data-testid="row" data-menu-row="" onContextMenu={(e) => openContextMenu(e, 'row')}>Row</div>
<ContextMenu isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
<ContextMenuItem label="Reply" onClick={() => {}} />
<ContextMenuSubMenu label="Move to" testId="sub">
<ContextMenuItem label="Inbox" onClick={() => {}} />
</ContextMenuSubMenu>
</ContextMenu>
</>
);
}
it('marks the row while the menu is open', () => {
render(<Row />);
const row = screen.getByTestId('row');
fireEvent.contextMenu(row, { clientX: 300, clientY: 200 });
expect(row).toHaveAttribute('data-menu-opener');
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
expect(row).not.toHaveAttribute('data-menu-opener');
});
it("doesn't answer the pointer that opened it until the pointer moves", () => {
render(<Row />);
fireEvent.contextMenu(screen.getByTestId('row'), { clientX: 300, clientY: 200 });
const menu = screen.getByRole('menu');
expect(menu).toHaveAttribute('data-pointer-unarmed');
// Still under the pointer: hovering the submenu's entry doesn't open it.
fireEvent.mouseEnter(screen.getByTestId('sub').parentElement!);
expect(screen.queryByRole('menu', { name: 'Move to' })).toBeNull();
fireEvent.pointerMove(document, { clientX: 301, clientY: 201 });
expect(menu).toHaveAttribute('data-pointer-unarmed');
fireEvent.pointerMove(document, { clientX: 310, clientY: 210 });
expect(menu).not.toHaveAttribute('data-pointer-unarmed');
fireEvent.mouseEnter(screen.getByTestId('sub').parentElement!);
expect(screen.getByRole('menu', { name: 'Move to' })).toBeTruthy();
});
});
describe('placeMenu near the bottom', () => {
it('a button low in the window with more room above: above it, capped, not over it', () => {
const at = placeMenu({
point: { x: 0, y: 0 },
anchor: { left: 100, top: 600, right: 132, bottom: 632 },
size: { width: 220, height: 700 },
viewport: { width: 1280, height: 720 },
});
expect(at.maxHeight).toBe(600 - 4 - 10);
expect(at.y + at.maxHeight!).toBe(596);
});
});
@@ -0,0 +1,61 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { FirstStartNoticeBar } from '../first-start-notice';
import { resetFirstStartNotices, showFirstStartNotice } from '@/lib/first-start-notices';
beforeEach(() => {
localStorage.clear();
resetFirstStartNotices();
});
afterEach(() => cleanup());
describe('first-start notices', () => {
it('show one at a time, each with a close button, the next once the first is closed', () => {
render(<FirstStartNoticeBar />);
act(() => {
showFirstStartNotice({ id: 'one', text: 'Mail is kept on this device.' });
showFirstStartNotice({ id: 'two', text: 'Spelling needs a dictionary.' });
});
expect(screen.getByText('Mail is kept on this device.')).toBeTruthy();
expect(screen.queryByText('Spelling needs a dictionary.')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'close' }));
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
expect(screen.getByText('Spelling needs a dictionary.')).toBeTruthy();
});
it('once closed, never again on this device', () => {
const onClose = vi.fn();
const view = render(<FirstStartNoticeBar />);
act(() => showFirstStartNotice({ id: 'kept', text: 'Mail is kept on this device.', onClose }));
fireEvent.click(screen.getByRole('button', { name: 'close' }));
expect(onClose).toHaveBeenCalledTimes(1);
view.unmount();
resetFirstStartNotices();
render(<FirstStartNoticeBar />);
act(() => showFirstStartNotice({ id: 'kept', text: 'Mail is kept on this device.' }));
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
});
it('its button does its job and closes it', () => {
const open = vi.fn();
render(<FirstStartNoticeBar />);
act(() => showFirstStartNotice({ id: 'act', text: 'Mail is kept on this device.', action: { label: 'Open settings', onClick: open } }));
fireEvent.click(screen.getByRole('button', { name: 'Open settings' }));
expect(open).toHaveBeenCalledTimes(1);
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
});
it('waits while something else greets the user, and the same notice isn\'t queued twice', () => {
const view = render(<FirstStartNoticeBar hidden />);
act(() => {
showFirstStartNotice({ id: 'wait', text: 'Mail is kept on this device.' });
showFirstStartNotice({ id: 'wait', text: 'Mail is kept on this device.' });
});
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
view.rerender(<FirstStartNoticeBar />);
expect(screen.getAllByText('Mail is kept on this device.')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'close' }));
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
});
});

Some files were not shown because too many files have changed in this diff Show More