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

# Conflicts:
#	components/email/email-composer.tsx
#	components/email/email-viewer.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:20:50 +01:00
197 changed files with 10150 additions and 1056 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
@@ -191,6 +191,12 @@
* {
border-color: var(--color-border);
}
/* A placeholder or value too long for a narrow field ends in "…" instead of
stopping mid-word ("Termine durchs"), so it's clear there's more. */
input {
text-overflow: ellipsis;
}
}
body {
@@ -606,6 +612,11 @@ html[data-window-controls] [data-own-window-close] {
display: none;
}
/* Toasts that step out of the focus's way go to the top end corner: below the controls there. */
html[data-window-controls] [data-toast-container][data-dodging] {
top: calc(var(--titlebar-row-height) + 0.75rem) !important;
}
/* A top row is at least as tall as the controls, so they don't hang over what's below it. */
html[data-window-controls] [data-window-row] {
min-height: var(--titlebar-row-height);
@@ -629,6 +640,21 @@ html[data-window-controls] [data-window-row-left] {
padding-left: calc(var(--titlebar-left-inset) + 0.5rem) !important;
}
/*
* The desktop app's menu bar while it takes its row (components/layout/window-menu-bar.tsx).
* The app moves down under it, a page that fills the window starts below it, and the
* window's controls sit in the bar's row, so no column below makes room for them.
*/
html[data-menu-bar] {
--menu-bar-height: 2rem;
--app-top-inset: calc(env(safe-area-inset-top, 0px) + var(--menu-bar-height));
--titlebar-inset: var(--menu-bar-height);
}
html[data-menu-bar][data-window-controls] [data-window-end-column] {
padding-top: 0;
}
/*
* The empty parts of the app's top rows are what the window is dragged by, and a
* double-click there maximises. Anything to click stays clickable, and so does
@@ -1524,3 +1550,14 @@ html[data-window-controls-left] [data-window-rail] {
width: var(--mv-rail-peek-w);
}
}
/* A list row while its right-click menu is open (components/ui/context-menu.tsx). */
[data-menu-row][data-menu-opener] {
outline: 2px solid var(--color-ring);
outline-offset: -2px;
}
/* A menu opened under the pointer: the item there isn't hovered until the pointer moves. */
[data-pointer-unarmed] :is([role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]):hover {
background-color: transparent;
}
+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);
}
+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";
@@ -1666,6 +1668,28 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{ mode: "agenda", key: "a" },
{ mode: "tasks", key: "k" },
];
// File > New event and New task in the desktop app's menus, from any app.
useAppCommands({
"new-event": () => openCreateModal(),
"new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); },
}, true);
// Its View menu's Day, Week, Month and Agenda, and Edit > Search events.
const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); };
useDesktopCommands({
"view.calendar.day": viewFromMenu("day"),
"view.calendar.week": viewFromMenu("week"),
"view.calendar.month": viewFromMenu("month"),
"view.calendar.agenda": viewFromMenu("agenda"),
"edit.search": openSearch,
}, {
"view.calendar.day": { enabled: true, checked: normalizedViewMode === "day" },
"view.calendar.week": { enabled: true, checked: normalizedViewMode === "week" },
"view.calendar.month": { enabled: true, checked: normalizedViewMode === "month" },
"view.calendar.agenda": { enabled: true, checked: normalizedViewMode === "agenda" },
"edit.search": normalizedViewMode !== "tasks",
"file.newTask": enableCalendarTasks,
});
useRegisterCommands(tSidebar("calendar"), [
{ id: "new-event", label: t("events.new_event"), icon: Plus, shortcut: "n", run: () => openCreateModal() },
{ id: "new-task", label: t("events.new_task"), icon: ListTodo, run: openCreateTaskModal, when: () => enableCalendarTasks },
+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";
@@ -487,6 +488,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Address books contacts can be moved to; "Move to…" needs a second one.
const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]);
const canMoveContacts = !!client && supportsSync && writableBooks.length > 1;
// A contact in an address book shared read-only can't be changed: its menus leave out Edit, Move and Delete.
const isContactReadOnly = useCallback((contact: ContactCard) => Object.entries(contact.addressBookIds ?? {})
.some(([id, inBook]) => inBook && addressBooks.find((b) => b.id === id)?.myRights?.mayWrite === false), [addressBooks]);
const handleMoveContacts = useCallback((ids: string[]) => {
if (ids.length > 0) setMovingContactIds(ids);
}, []);
@@ -965,6 +969,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
}
};
// File > New contact in the desktop app's menus, from any app.
useAppCommands({ "new-contact": () => guard(handleCreateNew)() }, contactsEnabled);
// The command palette's Contacts group (Ctrl/⌘+K).
useRegisterCommands(tSidebar("contacts"), [
{ id: "new-contact", label: t("create_new"), icon: UserPlus, run: () => guard(handleCreateNew)() },
@@ -1144,6 +1151,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
onPrint={selectedContact && !printOff ? () => { void printContact(selectedContact); } : undefined}
isMobile={isMobile}
readOnly={selectedContact ? isContactReadOnly(selectedContact) : false}
/>
);
}
@@ -1398,6 +1406,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
) : (
<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' })}`;
@@ -233,8 +294,10 @@ describe('DesktopHost', () => {
});
describe('a language with no checker in the OS', () => {
beforeEach(() => resetFirstStartNotices());
afterEach(() => resetComposeFlushRegistry());
const toasts = () => useToastStore.getState().toasts;
// A first-start notice above the message list, not a toast over the composer.
const toasts = () => firstStartNoticesWaiting();
it('the composer asks once, and only a yes opens the downloads', async () => {
useToastStore.setState({ toasts: [] });
@@ -246,6 +309,7 @@ describe('DesktopHost', () => {
expect(toasts()).toHaveLength(0);
const stop = registerComposeFlush(async () => {});
await waitFor(() => expect(toasts()).toHaveLength(1));
expect(useToastStore.getState().toasts).toHaveLength(0);
expect(shell.allowDownload).not.toHaveBeenCalled();
act(() => toasts()[0].action!.onClick());
expect(shell.allowDownload).toHaveBeenCalledTimes(1);
@@ -253,6 +317,24 @@ describe('DesktopHost', () => {
stop();
});
it("closing it says no, and it doesn't come back", async () => {
const shell = fakeBridge();
render(<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";
@@ -25,7 +25,15 @@ import { useEmailStore } from "@/stores/email-store";
import { useLocaleStore } from "@/stores/locale-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useThemeStore } from "@/stores/theme-store";
import { toast } from "@/stores/toast-store";
import { showFirstStartNotice } from "@/lib/first-start-notices";
import { openMailImport } from "@/stores/mail-transfer-store";
/** The part of the app a path shows, for the menus: Calendar has its own View lines, and no Message menu. */
export function desktopSectionOf(pathname: string): "mail" | "calendar" | "contacts" | "files" | "settings" {
const first = pathname.split("/").filter(Boolean)[0] ?? "";
if (first === "calendar" || first === "contacts" || first === "files" || first === "settings") return first;
return "mail";
}
/**
* The webmail's side of what every desktop window needs: runs menu commands nothing
@@ -35,6 +43,7 @@ import { toast } from "@/stores/toast-store";
*/
export function DesktopHost() {
const router = useRouter();
const pathname = usePathname();
const routerRef = useRef(router);
routerRef.current = router;
const t = useTranslations();
@@ -45,6 +54,8 @@ export function DesktopHost() {
const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts);
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
const uiLocale = useLocaleStore((s) => s.locale);
const activeLocale = useLocale();
const messages = useMessages();
const strictest = useStrictestDevices();
const { restoreWindows } = useDesktopPrefs();
const theme = useThemeStore((s) => s.theme);
@@ -66,11 +77,30 @@ export function DesktopHost() {
else toMail("compose");
},
"edit.search": () => toMail("search"),
// The other apps' New items, run once that app is on screen.
"file.newEvent": () => {
if (requestAppCommand("new-event")) return;
parkAppCommand("new-event");
routerRef.current.push("/calendar");
},
"file.newTask": () => {
if (requestAppCommand("new-task")) return;
parkAppCommand("new-task");
routerRef.current.push("/calendar");
},
"file.newContact": () => {
if (requestAppCommand("new-contact")) return;
parkAppCommand("new-contact");
routerRef.current.push("/contacts");
},
"file.import": () => openMailImport(),
"go.inbox": () => routerRef.current.push("/"),
"go.mail": () => routerRef.current.push("/"),
"go.calendar": () => routerRef.current.push("/calendar"),
"go.contacts": () => routerRef.current.push("/contacts"),
"go.files": () => routerRef.current.push("/files"),
// Help > Settings: a page of this window, as the rail's gear opens it.
"go.settings": () => routerRef.current.push("/settings"),
"help.shortcuts": () => openKeyboardShortcuts(),
};
return menu.onCommand((id) => {
@@ -99,6 +129,7 @@ export function DesktopHost() {
const canPrint = canPrintAnywhere();
const signedIn = isAuthenticated;
const popout = isPopoutWindow();
const section = desktopSectionOf(pathname ?? "/");
setDesktopBaseState({
"link.check": true,
"shortcuts.singleKeys": singleKeys,
@@ -106,6 +137,12 @@ export function DesktopHost() {
"file.print": canPrint,
"file.savePdf": canPrint,
"file.newMessage": signedIn,
"file.newEvent": signedIn,
"file.newTask": signedIn,
"file.newContact": signedIn,
"file.import": signedIn && !popout,
"file.settings": signedIn,
[`section.${section}`]: true,
"edit.search": signedIn && !popout,
"go.inbox": signedIn,
"go.mail": signedIn,
@@ -114,22 +151,26 @@ export function DesktopHost() {
"go.files": signedIn,
"help.shortcuts": true,
});
}, [isAuthenticated, singleKeys, strictest]);
}, [isAuthenticated, singleKeys, strictest, pathname]);
// Menus and spellcheck follow the interface.
// Menus, the tray and spellcheck follow the interface: the language its text is in now,
// with that text. The stored choice isn't it: on the first render the catalog can still
// be the one the server picked from the OS language, and the chosen one lands a render
// later. Going by the choice alone left the menus in the OS language for good.
useEffect(() => {
const ui = uiLocale || document.documentElement.lang;
const ui = activeLocale || uiLocale || document.documentElement.lang;
if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as Parameters<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;
@@ -139,8 +180,6 @@ export function DesktopHost() {
const [language] = waiting;
if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return;
if (readDesktopPrefs().dictionaryDownload !== "ask") return;
// Asked once, whatever comes of it.
setDesktopPref("dictionaryDownload", "no");
const translate = tRef.current;
let name = language;
try {
@@ -148,8 +187,9 @@ export function DesktopHost() {
} catch {
// The tag itself will do.
}
toast.info(translate("desktopUi.spellcheck.offer", { language: name }), {
duration: 0,
showFirstStartNotice({
id: "dictionary-download",
text: translate("desktopUi.spellcheck.offer", { language: name }),
action: {
label: translate("desktopUi.spellcheck.download"),
onClick: () => {
@@ -157,6 +197,10 @@ export function DesktopHost() {
if (dictionaryDownloadAllowed()) spellcheck.allowDownload();
},
},
// Closed without a yes: asked once, not again.
onClose: () => {
if (readDesktopPrefs().dictionaryDownload === "ask") setDesktopPref("dictionaryDownload", "no");
},
});
};
const heard = (language: string) => {
+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');
});
// compose-23: a menu with "Schedule send" as its only item.
@@ -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";
@@ -62,6 +63,7 @@ import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { formatShortcutCombo } from "@/lib/shortcut-keys";
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml, sanitizePluginBodyHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
@@ -186,6 +188,7 @@ import { usePolicyStore } from "@/stores/policy-store";
import { isAppLockedNow } from "@/lib/app-lock/state";
import { registerComposeFlush, registerComposeSnapshot } from "@/lib/compose-flush-registry";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { useDesktopCommands } from "@/lib/desktop/commands";
import { noteComposerLanguage } from "@/lib/desktop/languages";
import { noteOpenDraft } from "@/lib/desktop/window-restore";
import type { RescuedDraft } from "@/lib/popout/channel";
@@ -619,6 +622,7 @@ export function EmailComposer({
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
const tMenu = useTranslations('menuAudit');
const tCommon = useTranslations('common');
const tWindows = useTranslations('pwaWindows');
const tQuote = useTranslations('quote_header');
@@ -2063,6 +2067,10 @@ export function EmailComposer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [effectiveSessionKey]);
// The header's close goes to its start when the window's own close is at its end.
const [composerHeader, setComposerHeader] = useState<HTMLDivElement | null>(null);
const closeBesideControls = useCloseBesideControls(composerHeader);
// Auto-save draft to server on page close (best-effort)
const ownWindowRef = useRef(ownWindow);
ownWindowRef.current = ownWindow;
@@ -2850,6 +2858,19 @@ export function EmailComposer({
useEffect(() => setComposerFileTarget(
(files) => void addFiles(files),
() => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0,
// The file drop overlay's "Insert into the message": the editor's own paste, so the
// upload, the place and the question for a description are the same as pasting.
{
available: () => !!editorRef.current && !editorRef.current.isDestroyed,
insert: (files) => {
const editor = editorRef.current;
if (!editor || editor.isDestroyed) return;
const transfer = new DataTransfer();
for (const file of files) transfer.items.add(file);
editor.commands.focus();
editor.view.dom.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, cancelable: true }));
},
},
), [addFiles, composerRootEl]);
// The id plugins know this composer by (registered below, with the handle).
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
@@ -4636,6 +4657,21 @@ export function EmailComposer({
}
};
// Close at the top end (R4). On a phone it moves to the start, as the phone's
// mail apps have it, with Send at the end. Beside the window's own close it
// goes to the start too.
const composerCloseButton = (
<IconButton
onClick={() => handleClose()}
className={cn(closeBesideControls ? "-ms-2" : "-me-2", phoneLayout && "max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11")}
label={tCommon('close')}
data-testid="composer-close"
data-own-window-close={ownWindow ? "" : undefined}
>
<X className="w-5 h-5 md:w-4 md:h-4" />
</IconButton>
);
const popOutButton = (className: string, iconClassName: string) => popOut && (
<button
type="button"
@@ -4885,6 +4921,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}
@@ -5013,7 +5061,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">
@@ -5097,17 +5146,7 @@ export function EmailComposer({
</button>
)}
{popOutButton(cn(phoneLayout ? "hidden md:grid" : "grid", "h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"), "w-4 h-4")}
{/* Close at the top end (R4). On a phone it moves to the start, as
the phone's mail apps have it, with Send at the end. */}
<IconButton
onClick={() => handleClose()}
className={phoneLayout ? "-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11" : "-me-2"}
label={tCommon('close')}
data-testid="composer-close"
data-own-window-close={ownWindow ? "" : undefined}
>
<X className="w-5 h-5 md:w-4 md:h-4" />
</IconButton>
{!closeBesideControls && composerCloseButton}
</div>
)}
@@ -6084,7 +6123,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
@@ -1331,6 +1331,7 @@ export function EmailList({
{/* Context Menu */}
{contextMenuEmail && (
<EmailContextMenu
rowMenu
email={contextMenuEmail}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
+82 -70
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";
@@ -34,6 +34,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 { restoreTarget } 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";
@@ -47,11 +48,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, quickReplyRecipients } from "@/lib/outgoing/quick-reply-envelope";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu";
import { emailDisplayDate } from "@/lib/email-date";
import { TagBadge } from "./tag-badge";
import { TagPicker } from "./tag-picker";
@@ -63,7 +65,8 @@ import { getSecurityStatus, extractListHeaders, getSenderVerification, brandIndi
import { emailToReadView } from "@/lib/plugin-projection";
import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
import { ViewSourceDialog } from "./view-source-dialog";
import { MoveTargetList } from "./move-target-list";
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list";
import { useChooseMoveFolder } from "./move-to-dialog";
import { SaveToFilesDialog, type SaveableAttachment } from "./save-to-files-dialog";
import { usePolicyStore } from "@/stores/policy-store";
import { ScheduleSendDialog } from "./schedule-send-dialog";
@@ -106,7 +109,6 @@ import {
Send,
FolderInput,
Sun,
Upload,
Moon,
PlayCircle,
PenSquare,
@@ -122,7 +124,7 @@ import {
Paperclip,
} from "@/components/icons";
import { MailMessageSkeleton, MailMessageToolbarSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
@@ -155,6 +157,7 @@ import { useTour } from "@/components/tour/tour-provider";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
import { BreakableAddress } from "@/components/ui/breakable-address";
import { mentionFromLink } from "@/lib/recipient-mentions";
import { MailtoLink } from "@/components/ui/mailto-link";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
@@ -280,7 +283,6 @@ interface EmailViewerProps {
mvInlineComposer?: React.ReactNode;
/** A reply or forward to this message is open under it: no quick reply box as well. */
replyComposerOpen?: boolean;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCancelScheduledForEdit?: () => void;
/** Stop a scheduled message from going out; it goes back to Drafts. */
@@ -1159,6 +1161,7 @@ function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMo
onPrint={options.onPrint}
onViewSource={options.onViewSource}
omit={options.omit}
views={options.views}
extras={options.extras}
rowPlugins={false}
extraEntries={pluginEntries}
@@ -1174,8 +1177,7 @@ interface ReaderMorePanelBodyProps {
openSub: (sub: MoreMenuSub) => void;
close: () => void;
entryRefs: React.MutableRefObject<Record<MoreMenuSub, HTMLButtonElement | null>>;
moveTree: MailboxNode[];
moveTargetIds: Set<string>;
moveModel: MoveMenuModel;
onMoveToMailbox?: (mailboxId: string) => void;
currentTagIds: string[];
sortedTagIds: string[];
@@ -1199,8 +1201,7 @@ function ReaderMorePanelBody({
openSub,
close,
entryRefs,
moveTree,
moveTargetIds,
moveModel,
onMoveToMailbox,
currentTagIds,
sortedTagIds,
@@ -1211,6 +1212,7 @@ function ReaderMorePanelBody({
pluginTarget,
}: ReaderMorePanelBodyProps) {
const { email } = options;
const locale = useLocale();
const { tagColor } = useKeywordFormat(tagScope);
const { copyTargets, copyToAccount } = useCopyToAccount(email, true);
const entries = useMessageActionList({
@@ -1220,11 +1222,10 @@ function ReaderMorePanelBody({
});
if (sub === 'move') {
return moveTree.length > 0 && onMoveToMailbox ? (
<MoveTargetList
return !moveMenuIsEmpty(moveModel) && onMoveToMailbox ? (
<MoveFolderList
variant="panel"
tree={moveTree}
targetIds={moveTargetIds}
model={moveModel}
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); close(); }}
/>
) : null;
@@ -1233,14 +1234,15 @@ function ReaderMorePanelBody({
return (
<>
{copyTargets.map((target) => {
const { tree, targetIds } = buildMoveTargets(target.mailboxes.filter((m) => !m.isShared && m.role !== 'drafts'), {});
const model = buildMoveMenu(target.mailboxes.filter((m) => !m.isShared), { locale });
return (
<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>
@@ -1344,7 +1346,6 @@ export function EmailViewer({
onReplyAllConversationEmail,
mvInlineComposer,
replyComposerOpen,
onShowShortcuts,
onEditDraft,
onCancelScheduledForEdit,
onCancelScheduledSend,
@@ -1368,7 +1369,6 @@ export function EmailViewer({
const tComposer = useTranslations('email_composer');
const tWords = useTranslations('compose_words');
const tNotifications = useTranslations('notifications');
const tImport = useTranslations('mail_import');
const tCommon = useTranslations('common');
const tFiles = useTranslations('files');
const tDemoWelcome = useTranslations('demo_welcome');
@@ -1989,14 +1989,14 @@ export function EmailViewer({
// Move-to dropdown: the message's own account first (#1149)
const moveOwnerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox);
const { tree: moveTree, targetIds: moveTargetIds } = useMemo(
() => buildMoveTargets(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: moveOwnerAccountId,
emails: email ? [email] : [],
}),
[mailboxes, selectedMailbox, moveOwnerAccountId, email],
);
const moveEmails = useMemo(() => (email ? [email] : []), [email]);
const moveModel = useMoveMenu(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: moveOwnerAccountId,
emails: moveEmails,
});
const canMoveAnywhere = !moveMenuIsEmpty(moveModel);
const chooseMoveFolder = useChooseMoveFolder();
// Close dropdown menus on click outside
useEffect(() => {
@@ -2175,7 +2175,7 @@ export function EmailViewer({
email?.id,
showToolbarLabels,
isLoading,
moveTree.length,
canMoveAnywhere,
emailKeywords.length,
currentColor,
isInJunkFolder,
@@ -3779,11 +3779,6 @@ export function EmailViewer({
await exportEmail(email, { onArchive, onDelete, archiveEnabled: archiveActionEnabled }, blobSource ?? { client, accountId: undefined });
};
// Import mail into the open folder: the import dialog (lib/mail-import) does the rest.
const handleImportEmail = () => {
openMailImport({ mailboxId: useEmailStore.getState().selectedMailbox || undefined });
};
// What a printout of the message shows, for the Print action's window and
// for the page's print copy (Ctrl+P). Text fields are unescaped.
const messagePrintFields = (): MessagePrintFields | null => {
@@ -3809,6 +3804,7 @@ export function EmailViewer({
? effectiveEmailContent.html.replace(/<details>/g, '<details open>')
: sanitizeEmailHtml(effectiveEmailContent.html),
plain,
invitation: invitationPrint,
};
};
@@ -3906,6 +3902,8 @@ ${printWatermark ? printWatermark.html : ''}
() => printing && !!viewerRootEl && isShownOnScreen(viewerRootEl),
[printing, viewerRootEl],
);
// An invitation's when and where, from its card.
const invitationPrint = useInvitationPrint(email?.id);
const printCopyFields = printShown && !devices.noPrint && !isLoading && !!email?.bodyValues
? messagePrintFields()
: null;
@@ -4239,6 +4237,8 @@ ${printWatermark ? printWatermark.html : ''}
// the bottom bar has the replies.
const toolbarShownActions = (): ReadonlySet<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) => {
const key = TOOLBAR_PRIORITY_ACTIONS[item.dataset.overflowPriority ?? ''];
if (key && item.getClientRects().length > 0) shown.add(key);
@@ -4259,9 +4259,10 @@ ${printWatermark ? printWatermark.html : ''}
return hidden;
};
const toggleViewMode = () => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev);
// The reader's own entries after the message's: the view toggle, Import
// (into the open folder) and, with a keyboard, the shortcuts.
const readerExtras: MessageActionEntry[] = [
// How the reader shows the message, a group of the ⋮ menu of its own. Import
// and the keyboard shortcuts are no message's: Import is in the folder's menu,
// Settings and the command palette, the shortcuts behind ?, the rail and Help.
const readerViews: MessageActionEntry[] = [
...(readAloud.supported && !isBodyLoading
? [{ kind: 'item' as const, key: 'extra:read_aloud' as const, icon: Volume2, label: t('read_aloud'), run: readAloud.start }]
: []),
@@ -4274,15 +4275,11 @@ ${printWatermark ? printWatermark.html : ''}
...(effectiveEmailContent.isHtml
? [{ kind: 'item' as const, key: 'extra:view_mode' as const, icon: isDark ? Sun : Moon, label: isDark ? t('view_in_light_mode') : t('view_in_dark_mode'), run: toggleViewMode }]
: []),
{ kind: 'item', key: 'extra:import', icon: Upload, label: tImport('menu_import'), run: handleImportEmail },
...(onShowShortcuts && !isMobile
? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }]
: []),
];
const readerActionOptions: MessageActionOptions = {
email,
currentMailboxRole,
canMove: moveTree.length > 0 && !!onMoveToMailbox,
canMove: canMoveAnywhere && !!onMoveToMailbox,
canRemoveFromFolder: !!onRemoveFromFolder,
onReply: onReply ? () => onReply() : undefined,
onReplyAll,
@@ -4301,8 +4298,12 @@ ${printWatermark ? printWatermark.html : ''}
onExport: devices.noExport ? undefined : () => void handleExportEmail(),
onPrint: devices.noPrint ? undefined : () => handlePrint(),
onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true),
// The phone panel's Restore; the ⋮ menu works it out from onMoveToMailbox.
onRestore: currentMailboxRole === 'trash' && onMoveToMailbox
? () => { const to = restoreTarget(email, mailboxes, selectedMailbox); if (to) onMoveToMailbox(to); }
: undefined,
omit: readerMenuOmit,
extras: readerExtras.filter((entry) => entry.kind !== 'item' || !readerMenuOmit.has(entry.key)),
views: readerViews,
};
// Shared toolbar items used by both 'top' and 'below-subject' positions
@@ -4478,7 +4479,7 @@ ${printWatermark ? printWatermark.html : ''}
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{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"
@@ -4498,11 +4499,11 @@ ${printWatermark ? printWatermark.html : ''}
{showToolbarLabels && <span 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>
@@ -4842,8 +4843,7 @@ ${printWatermark ? printWatermark.html : ''}
openSub={setMoreMenuSub}
close={closeMoreMenu}
entryRefs={moreEntryRefs}
moveTree={moveTree}
moveTargetIds={moveTargetIds}
moveModel={moveModel}
onMoveToMailbox={onMoveToMailbox}
currentTagIds={currentTagIds}
sortedTagIds={sortedTagIds}
@@ -5424,6 +5424,11 @@ ${printWatermark ? printWatermark.html : ''}
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '-';
// A long date wraps over three lines in the narrow column: the short one
// shows, the full one is on hover.
const shownDate = (iso?: string) => iso
? <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;
@@ -5434,7 +5439,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",
@@ -5451,7 +5456,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",
@@ -5461,14 +5466,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>
@@ -5489,17 +5494,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"
/>
@@ -5553,10 +5565,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>
)}
@@ -5601,7 +5613,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",
@@ -5624,7 +5636,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>
@@ -5655,9 +5667,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>
@@ -5689,7 +5701,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>
)}
@@ -5712,9 +5724,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>
)}
@@ -5741,7 +5753,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
@@ -251,12 +251,14 @@ interface RecipientPopoverProps {
email: string;
/** Display label override (e.g. "me") */
displayLabel?: string;
/** Rich content in place of the text label, such as a name over its address. */
label?: React.ReactNode;
/** Called when user clicks "View contact" - receives the contact and email */
onViewContact?: (contact: ContactCard | null, email: string) => void;
className?: string;
}
export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) {
export function RecipientPopover({ name, email, displayLabel, label, onViewContact, className }: RecipientPopoverProps) {
const [anchor, setAnchor] = useState<CardAnchor | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const shownEmail = useOwnDomainAddress()(email);
@@ -280,7 +282,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";
@@ -299,6 +299,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
@@ -674,6 +674,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 && (
@@ -1381,6 +1382,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);
@@ -951,7 +953,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
@@ -992,10 +994,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); }} />
</>
);
@@ -1035,9 +1037,9 @@ export function FileBrowser({
const tShell = useTranslations("shell");
const isEmbedded = useIsEmbedded();
useRegisterCommands(tSidebar("files"), [
{ id: "upload-files", label: t("upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: t("upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
{ id: "new-folder", label: t("new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
{ id: "upload-files", label: tMenu("files_upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: tMenu("files_upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
{ id: "new-folder", label: tMenu("files_new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
{ id: "new-text-file", label: newFileLabel, icon: FilePlus, run: () => setShowNewTextFile(true), when: () => canCreate },
{ id: "trash", label: t("trash"), icon: Trash2, run: () => onNavigate(TRASH_PATH, TRASH_LOCATION), when: () => !inTrash && !accountPickerMode },
{ id: "empty-trash", label: t("empty_trash"), icon: Trash2, run: () => onEmptyTrash?.(), when: () => inTrash && trashCount > 0 && !!onEmptyTrash },
+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
@@ -31,6 +31,7 @@ import {
import { FolderRulesSubMenu } from "@/components/email/rules-menu";
import { openMailExport } from "@/stores/mail-transfer-store";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { useConnectionState } from "@/hooks/use-connection-state";
interface Position {
x: number;
@@ -45,6 +46,20 @@ export type MailboxContextTarget =
| { kind: "folders-section"; accountId?: string };
const PATH_SEPARATOR = " › ";
/** Unread messages in a folder and the folders under it; undefined where a count isn't known. */
export function unreadInTree(mailbox: Mailbox, mailboxes: Mailbox[]): number | undefined {
let total = 0;
const seen = new Set<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;
@@ -147,6 +162,11 @@ export function MailboxContextMenu({
const tExport = useTranslations("mail_export");
// Organisation > Devices > No export hides it; import stays.
const exportOff = useDevicePolicy().noExport;
// Folders, rules and sharing need the server. Marking read waits in the outbox
// where the account keeps its mail on this device.
const { offline, canChangeMail } = useConnectionState();
const tMenu = useTranslations("menuAudit");
const offlineHeader = offline ? <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader> : null;
const handleAction = (action: () => void) => {
action();
@@ -198,13 +218,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 />
</>
@@ -213,14 +234,14 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(() => onCreateFolder?.(target.accountId))}
disabled={!onCreateFolder}
disabled={!onCreateFolder || offline}
testId="mailbox-new-folder"
/>
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
disabled={!onRefresh || offline}
/>
{accountItems(target.accountId)}
</ContextMenu>
@@ -238,6 +259,8 @@ export function MailboxContextMenu({
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const canAddItems = mailbox.myRights?.mayAddItems !== false;
const nothingUnread = mailbox.unreadEmails === 0;
const nothingUnreadBelow = unreadInTree(mailbox, mailboxes) === 0;
// Sharing needs the mail:share extension (handler present) and, on a folder
// someone else shared with us, their permission to re-share it.
const canShare = !!onShareFolder && (!mailbox.isShared || mailbox.myRights?.mayShare === true);
@@ -252,19 +275,20 @@ export function MailboxContextMenu({
<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}
/>
)}
@@ -274,14 +298,14 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_subfolder")}
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
disabled={!onCreateSubfolder || !canCreateChild}
disabled={!onCreateSubfolder || !canCreateChild || offline}
testId="mailbox-new-subfolder"
/>
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename}
disabled={!onRenameFolder || !canRename || offline}
testId="mailbox-rename"
/>
{onShareFolder && (
@@ -289,11 +313,11 @@ export function MailboxContextMenu({
icon={Users}
label={t("share")}
onClick={() => handleAction(() => onShareFolder(mailbox.id))}
disabled={!canShare}
disabled={!canShare || offline}
testId="mailbox-share"
/>
)}
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} />
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} disabled={offline} />
{notifications && (() => {
const muted = notifications.isMailboxMuted(mailbox);
return (
@@ -313,7 +337,7 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(() => onCreateFolder?.(target.sharedAccountId))}
disabled={!onCreateFolder}
disabled={!onCreateFolder || offline}
testId="mailbox-new-folder"
/>
{accountItems(target.sharedAccountId)}
@@ -326,7 +350,7 @@ export function MailboxContextMenu({
icon={Upload}
label={tImport("menu_import")}
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems}
disabled={!onImportEmail || !canAddItems || offline}
/>
{!exportOff && (
<ContextMenuItem
@@ -341,7 +365,7 @@ export function MailboxContextMenu({
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
disabled={!onRefresh || offline}
/>
{/* Destructive entries last (R6). */}
@@ -351,14 +375,14 @@ export function MailboxContextMenu({
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline}
destructive
/>
<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
@@ -371,7 +371,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;
@@ -399,7 +398,6 @@ function SidebarRow({
isCollapsed,
dropHandlers,
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
muted = false,
testRole,
@@ -469,8 +467,9 @@ function SidebarRow({
: isSelected
? "bg-accent text-accent-foreground font-semibold border-s-2 border-primary"
: "hover:bg-muted/50 text-foreground border-s-2 border-transparent",
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
// Only a folder that takes the drop lights up. Elsewhere the pointer
// says no (dropEffect none), as in a native mail client.
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset"
)}
>
{!isCollapsed && (
@@ -756,7 +755,7 @@ function MailboxTreeItem({
: undefined;
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
const { dropHandlers, isValidDropTarget } = useMailboxDrop({
mailbox: node,
accountId: dragAccountId ?? undefined,
onSuccess: (count, mailboxName, restore, receipts) => {
@@ -800,7 +799,6 @@ function MailboxTreeItem({
: undefined
}
isValidDropTarget={isValidDropTarget && !pickOnly}
isInvalidDropTarget={isInvalidDropTarget && !pickOnly}
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
muted={muted}
/>
+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
@@ -67,7 +67,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";
@@ -138,9 +140,9 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { openMailImport } from "@/stores/mail-transfer-store";
import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons";
import { useRegisterCommands } from "@/lib/command-registry";
import { MoveToDialog } from "@/components/email/move-to-dialog";
import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { ToolbarButton, ToolbarSearchAction } from "@/components/ui/toolbar";
@@ -255,6 +257,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');
@@ -1341,6 +1345,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,
@@ -1367,6 +1373,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' },
@@ -1376,10 +1384,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,
@@ -1647,7 +1655,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
@@ -1666,6 +1700,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 {
@@ -1694,6 +1735,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;
}
@@ -2341,6 +2384,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
empty: () => { if (selectedMailbox) void handleEmptyFolderFromContextMenu(selectedMailbox); },
};
const folderMenuMarkRead = useCallback(() => folderMenuLatest.current.markRead(), []);
// "Mark folder as read" is greyed out with nothing unread in the open folder.
const selectedFolderHasUnread = mailboxes.find((m) => m.id === selectedMailbox)?.unreadEmails !== 0;
const folderMenuMarkAllRead = useCallback(() => folderMenuLatest.current.markAllRead(), []);
const folderMenuEmpty = useCallback(() => folderMenuLatest.current.empty(), []);
useEffect(() => {
@@ -4843,12 +4888,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) => {
@@ -5583,7 +5637,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}
@@ -5623,7 +5677,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()}
@@ -5693,7 +5747,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}
/>
@@ -5764,6 +5818,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
unless an enabled plugin registered tabs via api.tabs.set. */}
{!isScheduledView && <MessageListTabs panelId={messageListPanelId} />}
<WelcomeBanner />
<FirstStartNoticeBar hidden={welcomeDue} />
<div {...messageListPanelProps} className="flex-1 min-h-0 flex flex-col overflow-hidden">
<ErrorBoundary fallback={EmailListErrorFallback}>
@@ -6165,7 +6220,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;
@@ -6281,8 +6335,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)}
@@ -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: [] });
+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>
+3 -1
View File
@@ -9,6 +9,7 @@ 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';
/**
@@ -77,7 +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':
toast.info(tStorageRef.current('default_on'), { duration: 0, 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 });
+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, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store";
@@ -472,6 +473,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
// In a pop-out this message is all there is, so the menu bar's Message menu means it.
const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow();
const seen = !!email?.keywords?.$seen;
// Message > Move to… in a pop-out: the same folder list the command palette opens.
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
useDesktopCommands({
'message.reply': () => handleReply(),
'message.replyAll': handleReplyAll,
@@ -481,6 +484,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
'message.star': () => void handleToggleStar(),
'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined,
'message.delete': () => void handleDelete(),
'message.move': () => { if (email) setMoveDialogOpen(true); },
}, {
'message.reply': !!email,
'message.replyAll': !!email,
@@ -490,6 +494,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
'message.star': !!email,
'message.archive': !!email && archiveActionEnabled,
'message.delete': !!email,
'message.move': !!email,
}, inOwnWindow);
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
@@ -642,6 +647,15 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
/>
</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');
});
});
@@ -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';
@@ -409,6 +410,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();
});
});
@@ -0,0 +1,22 @@
import { describe, expect, it, vi } from 'vitest';
import { render } from '@testing-library/react';
import { ToastItem } from '../toast';
const closeOf = (container: HTMLElement) => container.querySelector<HTMLElement>('[data-toast-close]')!;
describe('the close button of a toast', () => {
it('shows all the time on a toast that stays until closed', () => {
const { container } = render(
<ToastItem onClose={vi.fn()} toast={{ id: 's', type: 'info', title: 'Mail is kept on this device', duration: 0, action: { label: 'Open settings', onClick: () => {} } }} />,
);
expect(closeOf(container).className).toContain('opacity-100');
expect(closeOf(container).className).not.toContain('opacity-0');
});
it('shows on hover or focus on a toast that goes by itself', () => {
vi.useFakeTimers();
const { container } = render(<ToastItem onClose={vi.fn()} toast={{ id: 't', type: 'success', title: 'Sent', duration: 5000 }} />);
expect(closeOf(container).className).toContain('opacity-0');
vi.useRealTimers();
});
});
+17
View File
@@ -0,0 +1,17 @@
import { Fragment } from "react";
import { addressBreakPieces } from "@/lib/text-breaks";
/** An address that wraps at its dots and after its @, not anywhere (lib/text-breaks.ts). */
export function BreakableAddress({ text }: { text: string }) {
const pieces = addressBreakPieces(text);
return (
<>
{pieces.map((piece, index) => (
<Fragment key={index}>
{index > 0 && <wbr />}
{piece}
</Fragment>
))}
</>
);
}
+115 -38
View File
@@ -18,7 +18,8 @@ import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { ChevronRight } from "@/components/icons";
import { useTranslations } from "next-intl";
import { nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu";
import { estimateNativeMenuWidth, nativeMenuPoint, nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu";
import { placeMenu } from "@/lib/menu-placement";
import { toast } from "@/stores/toast-store";
interface Position {
@@ -41,8 +42,15 @@ export interface ContextMenuPosition extends Position {
anchor?: ContextMenuAnchor;
/** Gets the focus back when the menu closes. Default: what had the focus when it opened. */
trigger?: HTMLElement | null;
/** What it's for: marked `data-menu-opener` while it's open, so a list row shows it. */
opener?: HTMLElement | null;
/** Opened by a right-click: the pointer sits on it, and only counts once it moves. */
fromPointer?: boolean;
}
/** How far the pointer moves before a menu opened under it answers it. */
const POINTER_SLOP = 3;
interface ContextMenuProps {
isOpen: boolean;
position: ContextMenuPosition;
@@ -241,7 +249,7 @@ function useMenuLevel(recentTouch: () => boolean): MenuLevel {
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
({ isOpen, position, onClose, children, label }, ref) => {
const [mounted, setMounted] = useState(false);
const [adjustedPosition, setAdjustedPosition] = useState<Position | null>(null);
const [adjustedPosition, setAdjustedPosition] = useState<(Position & { maxHeight?: number }) | null>(null);
const localRef = useRef<HTMLDivElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
@@ -282,8 +290,11 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
}
const { anchor: at } = positionRef.current;
// From a right-click it opens at the pointer, which the OS knows best.
// From a button: under it, lined up with the edge the HTML menu would
// use. The hidden copy is as wide as its widest submenu row, not as the
// native menu, so the native menu's own width is estimated.
const place = at
? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP }
? nativeMenuPoint(at, estimateNativeMenuWidth(read.items), getComputedStyle(root).direction === "rtl", ANCHOR_GAP)
: undefined;
const outcome = await showNativeMenu(read, root, place, tDesktopRef.current("from_plugins"));
if (cancelled) return;
@@ -322,34 +333,25 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
const clamp = () => {
const rect = node.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// The height it would have uncapped: a cap set last time mustn't decide the next.
const list = node.firstElementChild as HTMLElement | null;
const height = list && list.scrollHeight > list.clientHeight
? rect.height + list.scrollHeight - list.clientHeight
: rect.height;
// Next to the button it opened from, or at the pointer (lib/menu-placement.ts).
const { x, y, maxHeight } = placeMenu({
point: { x: position.x, y: position.y },
anchor,
size: { width: rect.width, height },
viewport: { width: window.innerWidth, height: window.innerHeight },
rtl: getComputedStyle(node).direction === "rtl",
margin: VIEWPORT_MARGIN,
gap: ANCHOR_GAP,
});
let x = position.x;
let y = position.y;
if (anchor) {
// Under the anchor, lined up with its start edge (end edge for
// align="end"); above it when there is no room below.
const rtl = getComputedStyle(node).direction === "rtl";
const alignEnd = (anchor.align === "end") !== rtl;
x = alignEnd ? anchor.right - rect.width : anchor.left;
y = anchor.bottom + ANCHOR_GAP;
if (y + rect.height > vh - VIEWPORT_MARGIN && anchor.top - ANCHOR_GAP - rect.height >= VIEWPORT_MARGIN) {
y = anchor.top - ANCHOR_GAP - rect.height;
}
}
if (x + rect.width > vw - VIEWPORT_MARGIN) {
x = vw - rect.width - VIEWPORT_MARGIN;
}
if (y + rect.height > vh - VIEWPORT_MARGIN) {
y = vh - rect.height - VIEWPORT_MARGIN;
}
x = Math.max(VIEWPORT_MARGIN, x);
y = Math.max(VIEWPORT_MARGIN, y);
setAdjustedPosition((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y }));
setAdjustedPosition((prev) =>
prev && prev.x === x && prev.y === y && prev.maxHeight === maxHeight ? prev : { x, y, maxHeight }
);
};
clamp();
if (typeof ResizeObserver === "undefined") return;
@@ -362,6 +364,40 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
const isPositioned = adjustedPosition !== null;
// Opened by a right-click, the menu comes up under the pointer: the item
// there isn't hovered, and its submenu doesn't open, until the pointer
// moves. A tap or a key counts at once.
const [pointerArmed, setPointerArmed] = useState(true);
const fromPointer = isOpen && position.fromPointer === true;
useLayoutEffect(() => {
if (!fromPointer) {
setPointerArmed(true);
return;
}
setPointerArmed(false);
const start = { x: position.x, y: position.y };
const arm = () => setPointerArmed(true);
const onMove = (e: PointerEvent) => {
if (Math.hypot(e.clientX - start.x, e.clientY - start.y) > POINTER_SLOP) arm();
};
document.addEventListener("pointermove", onMove, true);
document.addEventListener("pointerdown", arm, true);
document.addEventListener("keydown", arm, true);
return () => {
document.removeEventListener("pointermove", onMove, true);
document.removeEventListener("pointerdown", arm, true);
document.removeEventListener("keydown", arm, true);
};
}, [fromPointer, position.x, position.y]);
// The row the menu is for stays marked while it's open (app/globals.css).
const opener = isOpen ? position.opener ?? null : null;
useEffect(() => {
if (!opener) return;
opener.setAttribute("data-menu-opener", "");
return () => opener.removeAttribute("data-menu-opener");
}, [opener]);
// Where the focus goes back to: the trigger the opener named, else what
// had the focus when the menu opened. Read in a layout effect, before
// the menu takes the focus.
@@ -535,12 +571,20 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
aria-label={label}
aria-orientation="vertical"
tabIndex={-1}
data-pointer-unarmed={pointerArmed ? undefined : ""}
onPointerDownCapture={(e) => {
if (e.pointerType === "touch" || e.pointerType === "pen") lastTouchAt.current = Date.now();
}}
>
{/* Taller than the window: scroll inside the menu. */}
<div className="py-1 overflow-y-auto" style={{ maxHeight: `calc(100vh - ${2 * VIEWPORT_MARGIN + 2}px)` }}>
<div
className="py-1 overflow-y-auto"
style={{
maxHeight: adjustedPosition?.maxHeight !== undefined
? `${Math.max(0, adjustedPosition.maxHeight - 2)}px`
: `calc(100vh - ${2 * VIEWPORT_MARGIN + 2}px)`,
}}
>
{children}
</div>
</div>
@@ -574,7 +618,10 @@ interface ContextMenuItemProps {
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
/** The key that does the same, shown apart from the label. */
shortcut?: string;
/** A count at the end of the row, e.g. unread notifications. Not a key. */
badge?: string;
/** Stable hook for integration tests (not user-visible). */
testId?: string;
/** Tooltip, e.g. the address behind a display name. */
@@ -590,6 +637,7 @@ export function ContextMenuItem({
disabled = false,
destructive = false,
shortcut,
badge,
testId,
title,
hint,
@@ -636,14 +684,41 @@ export function ContextMenuItem({
) : (
<span className="flex-1 min-w-0 [overflow-wrap:anywhere]" data-menu-label="">{label}</span>
)}
{/* Key hints mean nothing without a keyboard: hidden on touch (R8). */}
{shortcut && (
<span className="text-xs text-muted-foreground ms-auto pointer-coarse:hidden">{shortcut}</span>
)}
{shortcut && <MenuShortcut keys={shortcut} />}
{badge && <MenuBadge text={badge} />}
</button>
);
}
/**
* The key that does what a menu row does, at the row's end, muted, as a keycap. The
* desktop app's native menu shows it as the item's accelerator, apart from the label
* (lib/desktop/native-menu.ts reads `data-menu-shortcut`). Key hints mean nothing
* without a keyboard: hidden on touch (R8).
*/
export function MenuShortcut({ keys, className }: { keys: string; className?: string }) {
return (
<kbd
data-menu-shortcut=""
className={cn(
"ms-auto shrink-0 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4 text-muted-foreground pointer-coarse:hidden",
className
)}
>
{keys}
</kbd>
);
}
/** A count at a row's end ("3" unread). Not a key: a native menu puts it after the label. */
export function MenuBadge({ text }: { text: string }) {
return (
<span data-menu-badge="" className="ms-auto shrink-0 text-xs tabular-nums text-muted-foreground">
{text}
</span>
);
}
export function ContextMenuSeparator() {
return <div className="h-px bg-border my-1" role="separator" />;
}
@@ -725,9 +800,10 @@ export function ContextMenuSubMenu({
return (
<div
className="relative"
onMouseEnter={() => {
onMouseEnter={(e) => {
// Settings > Accessibility can make submenus open on click or
// Enter only.
// Enter only. Not under the pointer that just opened the menu.
if (e.currentTarget.closest("[data-pointer-unarmed]")) return;
if (!disabled && hoverOpens()) setOpen(true);
}}
>
@@ -781,7 +857,8 @@ export function ContextMenuSubMenu({
className="min-w-[180px] max-w-[min(20rem,calc(100vw-20px))] bg-background rounded-md shadow-lg border border-border"
role="menu"
>
<div className="py-1 max-h-[min(300px,calc(100vh-40px))] overflow-y-auto">
{/* As tall as the window allows (it's moved up to fit), then it scrolls. */}
<div className="py-1 max-h-[calc(100vh-22px)] overflow-y-auto">
{children}
</div>
</SubMenuPanel>
+45
View File
@@ -0,0 +1,45 @@
"use client";
import { useTranslations } from "next-intl";
import { Info, X } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { closeFirstStartNotice, useCurrentFirstStartNotice } from "@/lib/first-start-notices";
/**
* The first-start notice that's waiting, as a bar above the message list (see
* lib/first-start-notices). In the page's flow, it covers nothing. `hidden` while
* something else greets the user there, so only one shows at a time.
*/
export function FirstStartNoticeBar({ hidden = false }: { hidden?: boolean }) {
const tCommon = useTranslations("common");
const notice = useCurrentFirstStartNotice();
if (!notice || hidden) return null;
return (
<div
role="status"
data-first-start-notice={notice.id}
className="flex items-start gap-3 border-b border-border bg-accent/30 px-4 py-2.5 text-sm"
>
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden="true" />
<p className="min-w-0 flex-1 text-foreground">{notice.text}</p>
{notice.action && (
<Button
variant="outline"
size="xs"
className="flex-shrink-0 text-xs"
onClick={() => {
const action = notice.action!;
closeFirstStartNotice(notice.id);
action.onClick();
}}
>
{notice.action.label}
</Button>
)}
<IconButton label={tCommon("close")} onClick={() => closeFirstStartNotice(notice.id)} className="-my-1 flex-shrink-0 text-muted-foreground">
<X />
</IconButton>
</div>
);
}
+5 -2
View File
@@ -305,7 +305,8 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
</div>
)}
{/* Close button. Phones swipe the toast away instead. */}
{/* Close button. Phones swipe the toast away instead. A toast that stays
until closed shows it all the time: that's the only way it goes. */}
<button
type="button"
data-toast-close=""
@@ -319,7 +320,9 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
"flex-shrink-0 p-1 rounded-md transition-all max-sm:hidden",
TOUCH_HIT_AREA,
"text-muted-foreground hover:text-foreground hover:bg-foreground/5",
"opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
!toast.duration || toast.duration <= 0
? "opacity-100"
: "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100"
)}
>
<X className="w-3.5 h-3.5" />
+19
View File
@@ -12,6 +12,25 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
const ONBOARDING_KEY = "onboarding_completed";
/** Whether the welcome tips are due on this device. Read after mount: localStorage. */
export function useWelcomeBannerDue(): boolean {
const onboardingCompleted = useSettingsStore((s) => s.onboardingCompleted);
const showOnboardingOnNewDevices = useSettingsStore((s) => s.showOnboardingOnNewDevices);
const [due, setDue] = useState(false);
useEffect(() => {
if (!onboardingCompleted) {
setDue(true);
return;
}
let seenHere = true;
try {
seenHere = localStorage.getItem(ONBOARDING_KEY) === "true";
} catch { /* localStorage unavailable */ }
setDue(showOnboardingOnNewDevices && !seenHere);
}, [onboardingCompleted, showOnboardingOnNewDevices]);
return due;
}
export function WelcomeBanner() {
const t = useTranslations("welcome");
const router = useRouter();

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