mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 16:14:07 +00:00
Merge branch 'feat/desktop-offline' into feat/do-ux-mail-fixes
# Conflicts: # components/email/email-viewer.tsx # components/mail/mail-app.tsx # components/offline/offline-host.tsx # components/ui/welcome-banner.tsx # locales/ar/common.json # locales/ca/common.json # locales/cs/common.json # locales/da/common.json # locales/de/common.json # locales/en/common.json # locales/es/common.json # locales/et/common.json # locales/fa/common.json # locales/fr/common.json # locales/he/common.json # locales/hu/common.json # locales/it/common.json # locales/ja/common.json # locales/ko/common.json # locales/lv/common.json # locales/mn/common.json # locales/nb/common.json # locales/nl/common.json # locales/pl/common.json # locales/pt/common.json # locales/ro/common.json # locales/ru/common.json # locales/sk/common.json # locales/th/common.json # locales/tr/common.json # locales/uk/common.json # locales/vi/common.json # locales/zh-TW/common.json # locales/zh/common.json
This commit is contained in:
@@ -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 />
|
||||
|
||||
@@ -212,6 +212,12 @@
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* A placeholder or value too long for a narrow field ends in "…" instead of
|
||||
stopping mid-word ("Termine durchs"), so it's clear there's more. */
|
||||
input {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -632,6 +638,11 @@ html[data-window-controls] [data-own-window-close] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Toasts that step out of the focus's way go to the top end corner: below the controls there. */
|
||||
html[data-window-controls] [data-toast-container][data-dodging] {
|
||||
top: calc(var(--titlebar-row-height) + 0.75rem) !important;
|
||||
}
|
||||
|
||||
/* A top row is at least as tall as the controls, so they don't hang over what's below it. */
|
||||
html[data-window-controls] [data-window-row] {
|
||||
min-height: var(--titlebar-row-height);
|
||||
@@ -655,6 +666,21 @@ html[data-window-controls] [data-window-row-left] {
|
||||
padding-left: calc(var(--titlebar-left-inset) + 0.5rem) !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* The desktop app's menu bar while it takes its row (components/layout/window-menu-bar.tsx).
|
||||
* The app moves down under it, a page that fills the window starts below it, and the
|
||||
* window's controls sit in the bar's row, so no column below makes room for them.
|
||||
*/
|
||||
html[data-menu-bar] {
|
||||
--menu-bar-height: 2rem;
|
||||
--app-top-inset: calc(env(safe-area-inset-top, 0px) + var(--menu-bar-height));
|
||||
--titlebar-inset: var(--menu-bar-height);
|
||||
}
|
||||
|
||||
html[data-menu-bar][data-window-controls] [data-window-end-column] {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* The empty parts of the app's top rows are what the window is dragged by, and a
|
||||
* double-click there maximises. Anything to click stays clickable, and so does
|
||||
@@ -1550,3 +1576,14 @@ html[data-window-controls-left] [data-window-rail] {
|
||||
width: var(--mv-rail-peek-w);
|
||||
}
|
||||
}
|
||||
|
||||
/* A list row while its right-click menu is open (components/ui/context-menu.tsx). */
|
||||
[data-menu-row][data-menu-opener] {
|
||||
outline: 2px solid var(--color-ring);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* A menu opened under the pointer: the item there isn't hovered until the pointer moves. */
|
||||
[data-pointer-unarmed] :is([role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]):hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
@@ -738,3 +738,24 @@
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* An invitation's card: when and where, in a box above the body. */
|
||||
.pr-invitation {
|
||||
margin: 4mm 0 0;
|
||||
padding: 2.5mm 3mm;
|
||||
border: 0.3mm solid var(--pr-rule, #bbb);
|
||||
border-radius: 1.5mm;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.pr-invitation-heading {
|
||||
margin: 0;
|
||||
font-size: 8pt;
|
||||
font-weight: 600;
|
||||
color: var(--pr-muted);
|
||||
}
|
||||
|
||||
.pr-invitation .pr-message-meta {
|
||||
font-size: 10pt;
|
||||
color: var(--pr-ink);
|
||||
}
|
||||
|
||||
@@ -117,8 +117,10 @@ export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
|
||||
</SettingItem>
|
||||
<p className="pt-2 text-xs text-muted-foreground">
|
||||
What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything),
|
||||
with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
|
||||
against someone using the unlocked browser or app. The limits above can only lower what people pick: where several
|
||||
with calendar, contacts and files, encrypted on the device. In a browser the key sits in the same profile, so this doesn't stop someone
|
||||
who copies its files: use full-disk encryption. In the desktop app the key also takes that person's login on
|
||||
that computer. Neither stops someone using the unlocked browser or app. An app lock adds a PIN, which someone with
|
||||
a copy can guess at their own speed, so ask for a long one. The limits above can only lower what people pick: where several
|
||||
scopes apply, the lowest value counts, so a scope can't raise what another one lowered. The app holds itself to
|
||||
them when it fetches the policy; the server can't check what is on a device. A sign-out someone asks for deletes everything.
|
||||
One that nobody asked for hides it, as the switch above says. With several accounts signed in, each account follows its own settings.
|
||||
|
||||
@@ -125,6 +125,8 @@ import { calendarShortcutAction } from "@/lib/calendar-shortcuts";
|
||||
import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard";
|
||||
import { isContextMenuOpen } from "@/components/ui/context-menu";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { useAppCommands } from "@/lib/app-commands";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { CalendarPrint, type CalendarPrintContent } from "@/components/calendar/calendar-print";
|
||||
import { usePrintListener, usePrinting } from "@/components/print/print-root";
|
||||
@@ -1667,6 +1669,28 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
|
||||
{ mode: "agenda", key: "a" },
|
||||
{ mode: "tasks", key: "k" },
|
||||
];
|
||||
// File > New event and New task in the desktop app's menus, from any app.
|
||||
useAppCommands({
|
||||
"new-event": () => openCreateModal(),
|
||||
"new-task": () => { if (enableCalendarTasks) openCreateTaskModal(); },
|
||||
}, true);
|
||||
// Its View menu's Day, Week, Month and Agenda, and Edit > Search events.
|
||||
const viewFromMenu = (mode: CalendarViewMode) => () => { setMobileReturnToMonth(false); leaveSearch(); setViewMode(mode); };
|
||||
useDesktopCommands({
|
||||
"view.calendar.day": viewFromMenu("day"),
|
||||
"view.calendar.week": viewFromMenu("week"),
|
||||
"view.calendar.month": viewFromMenu("month"),
|
||||
"view.calendar.agenda": viewFromMenu("agenda"),
|
||||
"edit.search": openSearch,
|
||||
}, {
|
||||
"view.calendar.day": { enabled: true, checked: normalizedViewMode === "day" },
|
||||
"view.calendar.week": { enabled: true, checked: normalizedViewMode === "week" },
|
||||
"view.calendar.month": { enabled: true, checked: normalizedViewMode === "month" },
|
||||
"view.calendar.agenda": { enabled: true, checked: normalizedViewMode === "agenda" },
|
||||
"edit.search": normalizedViewMode !== "tasks",
|
||||
"file.newTask": enableCalendarTasks,
|
||||
});
|
||||
|
||||
useRegisterCommands(tSidebar("calendar"), [
|
||||
{ id: "new-event", label: t("events.new_event"), icon: Plus, shortcut: "n", run: () => openCreateModal() },
|
||||
{ id: "new-task", label: t("events.new_task"), icon: ListTodo, run: openCreateTaskModal, when: () => enableCalendarTasks },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}'`);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -60,6 +60,7 @@ import { useDocumentTitle } from "@/hooks/use-document-title";
|
||||
import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { useAppCommands } from "@/lib/app-commands";
|
||||
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
||||
import { useLeaveGuard } from "@/lib/leave-guard";
|
||||
import { useDeleteCategory } from "@/hooks/use-delete-category";
|
||||
@@ -488,6 +489,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
// Address books contacts can be moved to; "Move to…" needs a second one.
|
||||
const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]);
|
||||
const canMoveContacts = !!client && supportsSync && writableBooks.length > 1;
|
||||
// A contact in an address book shared read-only can't be changed: its menus leave out Edit, Move and Delete.
|
||||
const isContactReadOnly = useCallback((contact: ContactCard) => Object.entries(contact.addressBookIds ?? {})
|
||||
.some(([id, inBook]) => inBook && addressBooks.find((b) => b.id === id)?.myRights?.mayWrite === false), [addressBooks]);
|
||||
const handleMoveContacts = useCallback((ids: string[]) => {
|
||||
if (ids.length > 0) setMovingContactIds(ids);
|
||||
}, []);
|
||||
@@ -966,6 +970,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
}
|
||||
};
|
||||
|
||||
// File > New contact in the desktop app's menus, from any app.
|
||||
useAppCommands({ "new-contact": () => guard(handleCreateNew)() }, contactsEnabled);
|
||||
|
||||
// The command palette's Contacts group (Ctrl/⌘+K).
|
||||
useRegisterCommands(tSidebar("contacts"), [
|
||||
{ id: "new-contact", label: t("create_new"), icon: UserPlus, run: () => guard(handleCreateNew)() },
|
||||
@@ -1145,6 +1152,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
|
||||
onPrint={selectedContact && !printOff ? () => { void printContact(selectedContact); } : undefined}
|
||||
isMobile={isMobile}
|
||||
readOnly={selectedContact ? isContactReadOnly(selectedContact) : false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1399,6 +1407,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
) : (
|
||||
<ContactList
|
||||
contacts={displayedContacts}
|
||||
isContactReadOnly={isContactReadOnly}
|
||||
selectedContactId={selectedContactId}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
|
||||
@@ -6,13 +6,23 @@ import { readDesktopPrefs, resetDesktopPrefsCache, setDesktopPref } from '@/lib/
|
||||
import { resetDesktopLanguages } from '@/lib/desktop/languages';
|
||||
import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { closeFirstStartNotice, firstStartNoticesWaiting, resetFirstStartNotices } from '@/lib/first-start-notices';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { offerReturnedComposer, takeReturnedComposer } from '@/lib/popout/return-to-main';
|
||||
|
||||
const push = vi.fn();
|
||||
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) }));
|
||||
const route = vi.hoisted(() => ({ pathname: '/' }));
|
||||
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }), usePathname: () => route.pathname }));
|
||||
|
||||
// The catalog on screen: its language, and text that says which one it came from.
|
||||
const intl = vi.hoisted(() => ({ locale: 'en', prefix: '', messages: {} as object }));
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => `${intl.prefix}${key}`,
|
||||
useLocale: () => intl.locale,
|
||||
useMessages: () => intl.messages,
|
||||
}));
|
||||
|
||||
const canPrint = vi.fn(() => true);
|
||||
const canDownload = vi.fn(() => true);
|
||||
@@ -68,6 +78,8 @@ function fakeBridge() {
|
||||
const original = useAuthStore.getState();
|
||||
|
||||
beforeEach(() => {
|
||||
route.pathname = '/';
|
||||
Object.assign(intl, { locale: 'en', prefix: '', messages: {} });
|
||||
push.mockClear();
|
||||
resolveExternalLink.mockClear();
|
||||
resolveExternalLink.mockImplementation(async (ctx) => ctx.href);
|
||||
@@ -97,6 +109,33 @@ describe('DesktopHost', () => {
|
||||
expect(() => render(<DesktopHost />)).not.toThrow();
|
||||
});
|
||||
|
||||
it('File > New event, task and contact open that app with its editor; Settings opens in this window', async () => {
|
||||
const { peekParkedAppCommand } = await import('@/lib/app-commands');
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run('file.newEvent');
|
||||
expect(push).toHaveBeenLastCalledWith('/calendar');
|
||||
expect(peekParkedAppCommand()).toBe('new-event');
|
||||
shell.run('file.newTask');
|
||||
expect(push).toHaveBeenLastCalledWith('/calendar');
|
||||
expect(peekParkedAppCommand()).toBe('new-task');
|
||||
shell.run('file.newContact');
|
||||
expect(push).toHaveBeenLastCalledWith('/contacts');
|
||||
expect(peekParkedAppCommand()).toBe('new-contact');
|
||||
shell.run('go.settings');
|
||||
expect(push).toHaveBeenLastCalledWith('/settings');
|
||||
});
|
||||
|
||||
it('tells the menus which part of the app is on screen, and offers the New lines once signed in', async () => {
|
||||
route.pathname = '/calendar';
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setState).toHaveBeenCalled());
|
||||
const state = shell.setState.mock.lastCall![0];
|
||||
expect(state).toMatchObject({ 'section.calendar': true, 'file.newEvent': true, 'file.newContact': true, 'file.settings': true, 'file.import': true });
|
||||
expect(state['section.mail']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('runs a command from what\'s on the page first', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
@@ -116,6 +155,13 @@ describe('DesktopHost', () => {
|
||||
expect(push).toHaveBeenLastCalledWith('/contacts');
|
||||
});
|
||||
|
||||
it('Help > Settings opens settings as a page of this window', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
shell.run('go.settings');
|
||||
expect(push).toHaveBeenLastCalledWith('/settings');
|
||||
});
|
||||
|
||||
it('a link to a folder goes through the router, encoded', () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
@@ -170,11 +216,26 @@ describe('DesktopHost', () => {
|
||||
it('says which languages are in use', async () => {
|
||||
const shell = fakeBridge();
|
||||
act(() => useLocaleStore.setState({ locale: 'de' }));
|
||||
intl.locale = 'de';
|
||||
render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled());
|
||||
expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'de' });
|
||||
});
|
||||
|
||||
it('follows the text on screen, not only the stored choice', async () => {
|
||||
const shell = fakeBridge();
|
||||
// English chosen, but the first render still has the catalog the server picked from the OS.
|
||||
act(() => useLocaleStore.setState({ locale: 'en' }));
|
||||
Object.assign(intl, { locale: 'de', prefix: 'de:', messages: { de: true } });
|
||||
const view = render(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setLanguages).toHaveBeenCalled());
|
||||
// The chosen catalog lands a render later.
|
||||
Object.assign(intl, { locale: 'en', prefix: 'en:', messages: { en: true } });
|
||||
view.rerender(<DesktopHost />);
|
||||
await waitFor(() => expect(shell.setLanguages.mock.lastCall![0].labels.menuFile).toBe('en:desktopUi.menu.file'));
|
||||
expect(shell.setLanguages.mock.lastCall![0]).toMatchObject({ ui: 'en' });
|
||||
});
|
||||
|
||||
describe('Open Link in a native menu', () => {
|
||||
const openLink = (url: string) => `link.open:${JSON.stringify({ url, text: 'click' })}`;
|
||||
|
||||
@@ -269,8 +330,10 @@ describe('DesktopHost', () => {
|
||||
});
|
||||
|
||||
describe('a language with no checker in the OS', () => {
|
||||
beforeEach(() => resetFirstStartNotices());
|
||||
afterEach(() => resetComposeFlushRegistry());
|
||||
const toasts = () => useToastStore.getState().toasts;
|
||||
// A first-start notice above the message list, not a toast over the composer.
|
||||
const toasts = () => firstStartNoticesWaiting();
|
||||
|
||||
it('the composer asks once, and only a yes opens the downloads', async () => {
|
||||
useToastStore.setState({ toasts: [] });
|
||||
@@ -282,6 +345,7 @@ describe('DesktopHost', () => {
|
||||
expect(toasts()).toHaveLength(0);
|
||||
const stop = registerComposeFlush(async () => {});
|
||||
await waitFor(() => expect(toasts()).toHaveLength(1));
|
||||
expect(useToastStore.getState().toasts).toHaveLength(0);
|
||||
expect(shell.allowDownload).not.toHaveBeenCalled();
|
||||
act(() => toasts()[0].action!.onClick());
|
||||
expect(shell.allowDownload).toHaveBeenCalledTimes(1);
|
||||
@@ -289,6 +353,24 @@ describe('DesktopHost', () => {
|
||||
stop();
|
||||
});
|
||||
|
||||
it("closing it says no, and it doesn't come back", async () => {
|
||||
const shell = fakeBridge();
|
||||
render(<DesktopHost />);
|
||||
await act(async () => {});
|
||||
const stop = registerComposeFlush(async () => {});
|
||||
shell.missing('nl-NL');
|
||||
await waitFor(() => expect(toasts()).toHaveLength(1));
|
||||
// Only asked: nothing decided until the user closes it.
|
||||
expect(readDesktopPrefs().dictionaryDownload).toBe('ask');
|
||||
act(() => closeFirstStartNotice(toasts()[0].id));
|
||||
expect(readDesktopPrefs().dictionaryDownload).toBe('no');
|
||||
shell.missing('fr-FR');
|
||||
await act(async () => {});
|
||||
expect(toasts()).toHaveLength(0);
|
||||
expect(shell.allowDownload).not.toHaveBeenCalled();
|
||||
stop();
|
||||
});
|
||||
|
||||
it('never a second time', async () => {
|
||||
useToastStore.setState({ toasts: [] });
|
||||
setDesktopPref('dictionaryDownload', 'no');
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useMessages, useTranslations } from "next-intl";
|
||||
import { useStrictestDevices } from "@/hooks/use-device-policy";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { usePathname, useRouter } from "@/i18n/navigation";
|
||||
import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
|
||||
import { onComposerOpened, composerIsOpen } from "@/lib/compose-flush-registry";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
@@ -26,7 +26,15 @@ import { useEmailStore } from "@/stores/email-store";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { showFirstStartNotice } from "@/lib/first-start-notices";
|
||||
import { openMailImport } from "@/stores/mail-transfer-store";
|
||||
|
||||
/** The part of the app a path shows, for the menus: Calendar has its own View lines, and no Message menu. */
|
||||
export function desktopSectionOf(pathname: string): "mail" | "calendar" | "contacts" | "files" | "settings" {
|
||||
const first = pathname.split("/").filter(Boolean)[0] ?? "";
|
||||
if (first === "calendar" || first === "contacts" || first === "files" || first === "settings") return first;
|
||||
return "mail";
|
||||
}
|
||||
|
||||
/**
|
||||
* The webmail's side of what every desktop window needs: runs menu commands nothing
|
||||
@@ -36,6 +44,7 @@ import { toast } from "@/stores/toast-store";
|
||||
*/
|
||||
export function DesktopHost() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const routerRef = useRef(router);
|
||||
routerRef.current = router;
|
||||
const t = useTranslations();
|
||||
@@ -46,6 +55,8 @@ export function DesktopHost() {
|
||||
const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts);
|
||||
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
|
||||
const uiLocale = useLocaleStore((s) => s.locale);
|
||||
const activeLocale = useLocale();
|
||||
const messages = useMessages();
|
||||
const strictest = useStrictestDevices();
|
||||
const { restoreWindows } = useDesktopPrefs();
|
||||
const theme = useThemeStore((s) => s.theme);
|
||||
@@ -67,11 +78,30 @@ export function DesktopHost() {
|
||||
else toMail("compose");
|
||||
},
|
||||
"edit.search": () => toMail("search"),
|
||||
// The other apps' New items, run once that app is on screen.
|
||||
"file.newEvent": () => {
|
||||
if (requestAppCommand("new-event")) return;
|
||||
parkAppCommand("new-event");
|
||||
routerRef.current.push("/calendar");
|
||||
},
|
||||
"file.newTask": () => {
|
||||
if (requestAppCommand("new-task")) return;
|
||||
parkAppCommand("new-task");
|
||||
routerRef.current.push("/calendar");
|
||||
},
|
||||
"file.newContact": () => {
|
||||
if (requestAppCommand("new-contact")) return;
|
||||
parkAppCommand("new-contact");
|
||||
routerRef.current.push("/contacts");
|
||||
},
|
||||
"file.import": () => openMailImport(),
|
||||
"go.inbox": () => routerRef.current.push("/"),
|
||||
"go.mail": () => routerRef.current.push("/"),
|
||||
"go.calendar": () => routerRef.current.push("/calendar"),
|
||||
"go.contacts": () => routerRef.current.push("/contacts"),
|
||||
"go.files": () => routerRef.current.push("/files"),
|
||||
// Help > Settings: a page of this window, as the rail's gear opens it.
|
||||
"go.settings": () => routerRef.current.push("/settings"),
|
||||
"help.shortcuts": () => openKeyboardShortcuts(),
|
||||
};
|
||||
return menu.onCommand((id) => {
|
||||
@@ -116,6 +146,7 @@ export function DesktopHost() {
|
||||
const canPrint = canPrintAnywhere();
|
||||
const signedIn = isAuthenticated;
|
||||
const popout = isPopoutWindow();
|
||||
const section = desktopSectionOf(pathname ?? "/");
|
||||
setDesktopBaseState({
|
||||
"link.check": true,
|
||||
"mail.searchText": signedIn && !popout,
|
||||
@@ -124,6 +155,12 @@ export function DesktopHost() {
|
||||
"file.print": canPrint,
|
||||
"file.savePdf": canPrint,
|
||||
"file.newMessage": signedIn,
|
||||
"file.newEvent": signedIn,
|
||||
"file.newTask": signedIn,
|
||||
"file.newContact": signedIn,
|
||||
"file.import": signedIn && !popout,
|
||||
"file.settings": signedIn,
|
||||
[`section.${section}`]: true,
|
||||
"edit.search": signedIn && !popout,
|
||||
"go.inbox": signedIn,
|
||||
"go.mail": signedIn,
|
||||
@@ -132,22 +169,26 @@ export function DesktopHost() {
|
||||
"go.files": signedIn,
|
||||
"help.shortcuts": true,
|
||||
});
|
||||
}, [isAuthenticated, singleKeys, strictest]);
|
||||
}, [isAuthenticated, singleKeys, strictest, pathname]);
|
||||
|
||||
// Menus and spellcheck follow the interface.
|
||||
// Menus, the tray and spellcheck follow the interface: the language its text is in now,
|
||||
// with that text. The stored choice isn't it: on the first render the catalog can still
|
||||
// be the one the server picked from the OS language, and the chosen one lands a render
|
||||
// later. Going by the choice alone left the menus in the OS language for good.
|
||||
useEffect(() => {
|
||||
const ui = uiLocale || document.documentElement.lang;
|
||||
const ui = activeLocale || uiLocale || document.documentElement.lang;
|
||||
if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as Parameters<typeof shellLabels>[0]));
|
||||
}, [uiLocale]);
|
||||
}, [activeLocale, uiLocale, messages]);
|
||||
|
||||
// What the user writes in, whichever composer asks for it too.
|
||||
useEffect(() => {
|
||||
noteComposerLanguage("settings", writingLanguage && writingLanguage !== "auto" ? writingLanguage : null);
|
||||
}, [writingLanguage]);
|
||||
|
||||
// A language with no checker in the OS needs a dictionary from Google: the composer
|
||||
// asks once, and never in no-third-party mode. Until a yes, the shell has nowhere to
|
||||
// fetch from at all.
|
||||
// A language with no checker in the OS needs a dictionary from Google: once a composer
|
||||
// is open, a first-start notice asks, never in no-third-party mode, and not again once
|
||||
// closed. Until a yes, the shell has nowhere to fetch from at all. Windows never asks:
|
||||
// it checks spelling itself (the shell's context-menu.ts).
|
||||
useEffect(() => {
|
||||
const spellcheck = getDesktopBridge()?.spellcheck;
|
||||
if (!spellcheck) return;
|
||||
@@ -157,8 +198,6 @@ export function DesktopHost() {
|
||||
const [language] = waiting;
|
||||
if (!language || !composerIsOpen() || !dictionaryDownloadAllowed()) return;
|
||||
if (readDesktopPrefs().dictionaryDownload !== "ask") return;
|
||||
// Asked once, whatever comes of it.
|
||||
setDesktopPref("dictionaryDownload", "no");
|
||||
const translate = tRef.current;
|
||||
let name = language;
|
||||
try {
|
||||
@@ -166,8 +205,9 @@ export function DesktopHost() {
|
||||
} catch {
|
||||
// The tag itself will do.
|
||||
}
|
||||
toast.info(translate("desktopUi.spellcheck.offer", { language: name }), {
|
||||
duration: 0,
|
||||
showFirstStartNotice({
|
||||
id: "dictionary-download",
|
||||
text: translate("desktopUi.spellcheck.offer", { language: name }),
|
||||
action: {
|
||||
label: translate("desktopUi.spellcheck.download"),
|
||||
onClick: () => {
|
||||
@@ -175,6 +215,10 @@ export function DesktopHost() {
|
||||
if (dictionaryDownloadAllowed()) spellcheck.allowDownload();
|
||||
},
|
||||
},
|
||||
// Closed without a yes: asked once, not again.
|
||||
onClose: () => {
|
||||
if (readDesktopPrefs().dictionaryDownload === "ask") setDesktopPref("dictionaryDownload", "no");
|
||||
},
|
||||
});
|
||||
};
|
||||
const heard = (language: string) => {
|
||||
|
||||
@@ -8,15 +8,15 @@ import { isAppLocked } from "@/lib/desktop/app-lock-state";
|
||||
import { contentHiddenFor, startMailNotifications, type MailNotificationLabels, type MailNotifications } from "@/lib/desktop/mail-notifications";
|
||||
import { startOsSummary } from "@/lib/desktop/os-summary";
|
||||
import { startPowerWatch } from "@/lib/desktop/power";
|
||||
import { startWindowFiles } from "@/lib/desktop/window-files";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
|
||||
/**
|
||||
* The main window's end of the desktop app's OS integration: new mail as native
|
||||
* notifications, what the tray and jump list show, waking up after sleep, and files
|
||||
* dropped or pasted on the window. Renders nothing, and does nothing in a browser.
|
||||
* notifications, what the tray and jump list show, and waking up after sleep. Files
|
||||
* dropped or pasted on the window are the file drop overlay's (components/file-drop).
|
||||
* Renders nothing, and does nothing in a browser.
|
||||
* Lives inside MainWindowOnly, so pop-outs don't do any of it a second time.
|
||||
*/
|
||||
export function DesktopOsHost() {
|
||||
@@ -55,12 +55,10 @@ export function DesktopOsHost() {
|
||||
const summary = startOsSummary(() => localeRef.current);
|
||||
summaryRef.current = summary;
|
||||
const stopPower = startPowerWatch();
|
||||
const stopFiles = startWindowFiles();
|
||||
return () => {
|
||||
mail.stop();
|
||||
summary.stop();
|
||||
stopPower();
|
||||
stopFiles();
|
||||
mailRef.current = null;
|
||||
summaryRef.current = null;
|
||||
};
|
||||
|
||||
@@ -327,6 +327,8 @@ describe('composer-send-menu', () => {
|
||||
const schedule = screen.getAllByRole('menuitem', { name: /schedule_send/ })[0];
|
||||
const plugin = screen.getAllByRole('menuitem', { name: 'Send with Later' })[0];
|
||||
expect(follows(schedule, plugin)).toBe(true);
|
||||
// It opens a dialog, and shows the key that opens it from the composer.
|
||||
expect(schedule).toHaveTextContent('Ctrl + Shift + Enter');
|
||||
});
|
||||
|
||||
it('keeps a plain Send button without plugin entries or scheduled send', () => {
|
||||
|
||||
@@ -89,8 +89,12 @@ describe('Copy to entry of the message menu', () => {
|
||||
});
|
||||
fireEvent.mouseEnter(screen.getByTestId('ctx-move-to').parentElement!);
|
||||
|
||||
// The shared account's own folders at the top, the open one greyed; the
|
||||
// login's own folders one level down, under its name.
|
||||
const items = screen.getAllByTestId(/^move-to:/).map((el) => el.getAttribute('data-testid'));
|
||||
expect(items).toEqual(['move-to:S:junk', 'move-to:inbox', 'move-to:junk']);
|
||||
expect(items).toEqual(['move-to:S:inbox', 'move-to:S:junk']);
|
||||
expect(screen.getByTestId('move-to:S:inbox')).toBeDisabled();
|
||||
expect(screen.getByTestId('move-account:A')).toHaveTextContent('me@example.org');
|
||||
|
||||
fireEvent.click(screen.getByTestId('move-to:S:junk'));
|
||||
expect(onMoveToMailbox).toHaveBeenCalledWith('S:junk');
|
||||
|
||||
@@ -66,10 +66,11 @@ describe('Move to the open folder from search results', () => {
|
||||
expect(onMoveToMailbox).toHaveBeenCalledWith('inbox');
|
||||
});
|
||||
|
||||
it('does not offer the open folder for a message already in it', () => {
|
||||
it('shows the open folder greyed for a message already in it', () => {
|
||||
openMoveMenu({ email: inInbox });
|
||||
expect(screen.queryByTestId('move-to:inbox')).toBeNull();
|
||||
expect(screen.getByTestId('move-to:work')).toBeTruthy();
|
||||
// Shown, as in the sidebar, but it can't be picked.
|
||||
expect(screen.getByTestId('move-to:inbox')).toBeDisabled();
|
||||
expect(screen.getByTestId('move-to:work')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('offers it for a selection when one of the messages is elsewhere', () => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { rememberTrashOrigins } from '@/lib/trash-origin';
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
|
||||
import { EmailContextMenu } from '../email-context-menu';
|
||||
|
||||
// A row's right-click menu had 22 entries and was 640 px tall. It now has what
|
||||
// people use from a row, as Outlook and Gmail have it, and the rest under
|
||||
// "More actions". Spam's "Not spam" and Trash's "Restore" stay at the top.
|
||||
|
||||
const email = {
|
||||
id: 'e1',
|
||||
blobId: 'b1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: { $seen: true },
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T00:00:00Z',
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
} as unknown as Email;
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
{ id: 'work', name: 'Work', role: null, parentId: null, myRights: { mayAddItems: true } },
|
||||
{ id: 'junk', name: 'Spam', role: 'junk', parentId: null, myRights: { mayAddItems: true } },
|
||||
{ id: 'trash', name: 'Trash', role: 'trash', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
type MenuProps = React.ComponentProps<typeof EmailContextMenu>;
|
||||
|
||||
function renderMenu(props: Partial<MenuProps> = {}) {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<EmailContextMenu
|
||||
rowMenu
|
||||
email={email}
|
||||
position={{ x: 10, y: 10 }}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
menuRef={{ current: null }}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox="inbox"
|
||||
currentMailboxRole="inbox"
|
||||
onReply={vi.fn()}
|
||||
onReplyAll={vi.fn()}
|
||||
onForward={vi.fn()}
|
||||
onForwardAsAttachment={vi.fn()}
|
||||
onArchive={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onMarkAsRead={vi.fn()}
|
||||
onToggleStar={vi.fn()}
|
||||
onTogglePinned={vi.fn()}
|
||||
onSetTag={vi.fn()}
|
||||
onMoveToMailbox={vi.fn()}
|
||||
onMarkAsSpam={vi.fn()}
|
||||
onUndoSpam={vi.fn()}
|
||||
onOpenInWindow={vi.fn()}
|
||||
onExport={vi.fn()}
|
||||
onPrint={vi.fn()}
|
||||
onViewSource={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { onClose };
|
||||
}
|
||||
|
||||
const topLevel = () => {
|
||||
const menu = screen.getAllByRole('menu')[0];
|
||||
return Array.from(menu.querySelectorAll<HTMLElement>(':scope > div > [role="menuitem"], :scope > div > div > [role="menuitem"]'))
|
||||
.map((row) => row.querySelector('[data-menu-label]')?.textContent ?? '');
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("a row's right-click menu", () => {
|
||||
it('has the usual few at the top and the rest under More actions', () => {
|
||||
renderMenu();
|
||||
const top = topLevel();
|
||||
expect(top).toEqual(expect.arrayContaining(['reply', 'reply_all', 'forward', 'archive', 'delete', 'move_to', 'mark_unread', 'star', 'tag', 'mark_as_spam', 'open_in_window', 'more_actions']));
|
||||
for (const label of ['forward_as_attachment', 'copy_message', 'pin', 'print', 'view_source', 'export_email', 'rules.title']) {
|
||||
expect(top).not.toContain(label);
|
||||
}
|
||||
fireEvent.click(screen.getByTestId('ctx-more'));
|
||||
const more = screen.getByRole('menu', { name: 'more_actions' });
|
||||
for (const id of ['ctx-print', 'ctx-view-source', 'ctx-export']) expect(within(more).getByTestId(id)).toBeTruthy();
|
||||
expect(within(more).getByText('forward_as_attachment')).toBeTruthy();
|
||||
expect(within(more).getByText('pin')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows Not spam at the top in Spam', () => {
|
||||
renderMenu({ selectedMailbox: 'junk', currentMailboxRole: 'junk', email: { ...email, mailboxIds: { junk: true } } as Email });
|
||||
expect(topLevel()).toContain('not_spam');
|
||||
});
|
||||
|
||||
it('the reader\'s ⋮ keeps every entry at the top: it is the "more" already', () => {
|
||||
renderMenu({ rowMenu: false });
|
||||
expect(screen.queryByTestId('ctx-more')).toBeNull();
|
||||
expect(screen.getByTestId('ctx-print')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Restore in Trash', () => {
|
||||
const trashed = { ...email, mailboxIds: { trash: true } } as Email;
|
||||
|
||||
it('puts a message back in the folder it was deleted from', () => {
|
||||
rememberTrashOrigins([{ id: 'e1', mailboxIds: { work: true } }], mailboxes);
|
||||
const onMoveToMailbox = vi.fn();
|
||||
renderMenu({ email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', onMoveToMailbox });
|
||||
expect(topLevel()).toContain('restore');
|
||||
fireEvent.click(screen.getByTestId('ctx-restore'));
|
||||
expect(onMoveToMailbox).toHaveBeenCalledWith('work');
|
||||
});
|
||||
|
||||
it('puts it in the Inbox when nothing says where it came from', () => {
|
||||
const onMoveToMailbox = vi.fn();
|
||||
renderMenu({ email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', onMoveToMailbox });
|
||||
fireEvent.click(screen.getByTestId('ctx-restore'));
|
||||
expect(onMoveToMailbox).toHaveBeenCalledWith('inbox');
|
||||
});
|
||||
|
||||
it('is in the selection menu too', () => {
|
||||
rememberTrashOrigins([{ id: 'e1', mailboxIds: { work: true } }, { id: 'e2', mailboxIds: { work: true } }], mailboxes);
|
||||
const onBatchMoveToMailbox = vi.fn();
|
||||
renderMenu({
|
||||
email: trashed, selectedMailbox: 'trash', currentMailboxRole: 'trash', isMultiSelect: true, selectedCount: 2,
|
||||
batchEmails: [trashed, { ...trashed, id: 'e2' } as Email], onBatchMoveToMailbox,
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('ctx-restore'));
|
||||
expect(onBatchMoveToMailbox).toHaveBeenCalledWith('work');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mark read and unread on a selection', () => {
|
||||
it('offers both when the selection has read and unread mail', () => {
|
||||
const onBatchMarkAsRead = vi.fn();
|
||||
renderMenu({
|
||||
isMultiSelect: true, selectedCount: 2, onBatchMarkAsRead,
|
||||
batchEmails: [email, { ...email, id: 'e2', keywords: {} } as Email],
|
||||
});
|
||||
expect(screen.getByTestId('ctx-mark-unread')).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId('ctx-mark-read'));
|
||||
expect(onBatchMarkAsRead).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('offers only Mark as unread when all are read', () => {
|
||||
renderMenu({ isMultiSelect: true, selectedCount: 2, onBatchMarkAsRead: vi.fn(), batchEmails: [email, { ...email, id: 'e2' } as Email] });
|
||||
expect(screen.queryByTestId('ctx-mark-read')).toBeNull();
|
||||
expect(screen.getByTestId('ctx-mark-unread')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import { afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({
|
||||
PluginSlot: () => null,
|
||||
}));
|
||||
|
||||
import { EmailContextMenu } from '../email-context-menu';
|
||||
import { messageActionShortcut } from '../message-action-shortcuts';
|
||||
|
||||
const email = {
|
||||
id: 'e1',
|
||||
blobId: 'b1',
|
||||
threadId: 't1',
|
||||
mailboxIds: { inbox: true },
|
||||
keywords: {},
|
||||
from: [{ name: 'Alice', email: 'alice@example.com' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T00:00:00Z',
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
} as unknown as Email;
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
function renderMenu(props: Partial<React.ComponentProps<typeof EmailContextMenu>> = {}) {
|
||||
render(
|
||||
<EmailContextMenu
|
||||
email={email}
|
||||
position={{ x: 10, y: 10 }}
|
||||
isOpen
|
||||
onClose={vi.fn()}
|
||||
menuRef={{ current: null }}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox="inbox"
|
||||
currentMailboxRole="inbox"
|
||||
onReply={vi.fn()}
|
||||
onReplyAll={vi.fn()}
|
||||
onForward={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
onToggleStar={vi.fn()}
|
||||
onMarkAsRead={vi.fn()}
|
||||
onMarkAsSpam={vi.fn()}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const row = (name: string) => screen.getByText(name).closest('button') as HTMLElement;
|
||||
|
||||
describe('message menu key hints', () => {
|
||||
afterEach(() => useSettingsStore.setState({ singleKeyShortcuts: true }));
|
||||
|
||||
it('shows the keys the list runs each action with', () => {
|
||||
renderMenu();
|
||||
expect(within(row('reply')).getByText('R')).toBeInTheDocument();
|
||||
expect(within(row('reply_all')).getByText('Shift + R')).toBeInTheDocument();
|
||||
expect(within(row('forward')).getByText('F')).toBeInTheDocument();
|
||||
expect(within(row('star')).getByText('S')).toBeInTheDocument();
|
||||
expect(within(row('mark_read')).getByText('Shift + I')).toBeInTheDocument();
|
||||
expect(within(row('mark_as_spam')).getByText('!')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves single keys out when they are turned off, and keeps Del', () => {
|
||||
useSettingsStore.setState({ singleKeyShortcuts: false });
|
||||
renderMenu();
|
||||
expect(within(row('reply')).queryByText('R')).toBeNull();
|
||||
expect(within(row('star')).queryByText('S')).toBeNull();
|
||||
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows them on a selection too', () => {
|
||||
renderMenu({ isMultiSelect: true, selectedCount: 2, onBatchDelete: vi.fn(), onBatchMarkAsRead: vi.fn(), onBatchMarkAsSpam: vi.fn() });
|
||||
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('ctx-mark-read')).getByText('Shift + I')).toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('ctx-spam')).getByText('!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('matches the read state: U marks a read message unread', () => {
|
||||
expect(messageActionShortcut('read', { unread: false, singleKeys: true })).toBe('U');
|
||||
expect(messageActionShortcut('read', { unread: true, singleKeys: true })).toBe('Shift + I');
|
||||
expect(messageActionShortcut('print', { singleKeys: true })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('message menu offline', () => {
|
||||
afterEach(async () => {
|
||||
const { useOfflineStore } = await import('@/lib/offline/state');
|
||||
useOfflineStore.setState({ browserOffline: false, enabled: [] });
|
||||
});
|
||||
|
||||
it('greys out changes that would fail without a connection, and says why', async () => {
|
||||
const { useOfflineStore } = await import('@/lib/offline/state');
|
||||
useOfflineStore.setState({ browserOffline: true, enabled: [] });
|
||||
renderMenu();
|
||||
expect(screen.getByText('offline_header')).toBeInTheDocument();
|
||||
expect((screen.getByTestId('ctx-delete') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByTestId('ctx-star') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByTestId('ctx-mark-read') as HTMLButtonElement).disabled).toBe(true);
|
||||
// Writing a reply still works: the composer keeps the draft.
|
||||
expect(row('reply')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('leaves them on while online', () => {
|
||||
renderMenu();
|
||||
expect(screen.queryByText('offline_header')).toBeNull();
|
||||
expect((screen.getByTestId('ctx-delete') as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent, act, within } from '@testing-library/react';
|
||||
import { render, renderHook, screen, fireEvent, act, within } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
@@ -28,6 +28,7 @@ const { EmailViewer } = await import('../email-viewer');
|
||||
const { EmailContextMenu } = await import('../email-context-menu');
|
||||
const { usePrintRequestStore, requestPrint } = await import('@/hooks/use-message-file-actions');
|
||||
const { emailKeyFor } = await import('@/lib/thread-utils');
|
||||
const { useMessageActionList } = await import('../message-actions');
|
||||
|
||||
const email = {
|
||||
id: 'e1',
|
||||
@@ -112,17 +113,53 @@ describe("the reader's ⋮ on a desktop", () => {
|
||||
renderViewer({ onTogglePinned });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
|
||||
const menu = screen.getByRole('menu', { name: 'more_actions' });
|
||||
for (const id of ['ctx-star', 'ctx-move-to', 'ctx-tag', 'ctx-export', 'ctx-print', 'ctx-view-source']) {
|
||||
for (const id of ['ctx-move-to', 'ctx-tag', 'ctx-export', 'ctx-print', 'ctx-view-source']) {
|
||||
expect(within(menu).getByTestId(id)).toBeTruthy();
|
||||
}
|
||||
// Delete is on the toolbar already.
|
||||
// Delete is on the toolbar already, and the star by the subject.
|
||||
expect(within(menu).queryByTestId('ctx-delete')).toBeNull();
|
||||
expect(within(menu).queryByTestId('ctx-star')).toBeNull();
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: 'pin' }));
|
||||
expect(onTogglePinned).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByRole('menu', { name: 'more_actions' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reader's ⋮ in groups", () => {
|
||||
it("has the message's actions only, in groups", () => {
|
||||
renderViewer({ onTogglePinned: vi.fn(), onShowShortcuts: vi.fn() } as never);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
|
||||
const menu = screen.getByRole('menu', { name: 'more_actions' });
|
||||
const rows = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"], [role="separator"]'))
|
||||
.map((row) => (row.getAttribute('role') === 'separator' ? '|' : row.querySelector('[data-menu-label]')?.textContent ?? ''));
|
||||
// No app-level entries: Import is in the folder's menu, Settings and the
|
||||
// palette, the shortcuts behind ?. Answer; file away; mark; spam; link;
|
||||
// how it shows; save.
|
||||
expect(rows).toEqual([
|
||||
'reply', 'reply_all', 'forward', 'forward_as_attachment', '|',
|
||||
'archive', '|',
|
||||
'move_to', '|',
|
||||
'mark_unread', 'pin', 'tag', '|',
|
||||
'mark_as_spam', '|',
|
||||
'copy_message', 'copy_conversation', '|',
|
||||
'reader_view', '|',
|
||||
'export_email', 'print', 'view_source',
|
||||
]);
|
||||
});
|
||||
|
||||
it('puts how the reader shows the message in a group before Export, Print and View source', () => {
|
||||
const { result } = renderHook(() => useMessageActionList({
|
||||
email,
|
||||
onExport: () => {},
|
||||
onPrint: () => {},
|
||||
views: [{ kind: 'item', key: 'extra:reader_view', icon: () => null, label: 'reader_view', run: () => {} }],
|
||||
extras: [{ kind: 'item', key: 'extra:other', icon: () => null, label: 'other', run: () => {} }],
|
||||
}));
|
||||
const labels = result.current.map((entry) => (entry.kind === 'separator' ? '|' : entry.label));
|
||||
expect(labels.slice(-7)).toEqual(['|', 'reader_view', '|', 'export_email', 'print', '|', 'other']);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reader toolbar's Move and Tag menus", () => {
|
||||
it('are the ContextMenu: in a portal, with its rows and keys', () => {
|
||||
renderViewer();
|
||||
@@ -149,7 +186,9 @@ describe("the phone's More panel", () => {
|
||||
const panel = screen.getByRole('menu', { name: 'more_actions' });
|
||||
const pin = within(panel).getByTestId('panel-pin');
|
||||
expect(pin.className).toContain('min-h-[44px]');
|
||||
for (const id of ['panel-star', 'panel-export', 'panel-print', 'panel-view_source', 'panel-copy_message_link']) {
|
||||
// The star is by the subject, on the phone too.
|
||||
expect(within(panel).queryByTestId('panel-star')).toBeNull();
|
||||
for (const id of ['panel-export', 'panel-print', 'panel-view_source', 'panel-copy_message_link']) {
|
||||
expect(within(panel).getByTestId(id)).toBeTruthy();
|
||||
}
|
||||
fireEvent.click(pin);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ContextMenu, ContextMenuSubMenu } from '@/components/ui/context-menu';
|
||||
import type { BulwarkDesktopBridge, DesktopNativeMenuItem } from '@/lib/desktop/types';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { buildMoveMenu } from '@/lib/move-menu';
|
||||
import { MoveFolderList } from '../move-target-list';
|
||||
|
||||
// The desktop app shows the Move menu natively, read back from the HTML one.
|
||||
// A native menu can't indent and the account headers used to be plain divs,
|
||||
// so it showed every account's Inbox, Sent, Spam and Trash in one flat list
|
||||
// with nothing to tell them apart.
|
||||
|
||||
const writable = { mayAddItems: true } as Mailbox['myRights'];
|
||||
const box = (id: string, name: string, extra: Partial<Mailbox> = {}) =>
|
||||
({
|
||||
id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
|
||||
myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra,
|
||||
}) as Mailbox;
|
||||
const shared = (id: string, name: string, extra: Partial<Mailbox> = {}) =>
|
||||
box(`S:${id}`, name, { accountId: 'S', accountName: 'team@example.org', isShared: true, ...extra });
|
||||
|
||||
const mailboxes = [
|
||||
box('inbox', 'Inbox', { role: 'inbox' }),
|
||||
box('trash', 'Trash', { role: 'trash' }),
|
||||
box('work', 'Work'),
|
||||
box('clients', 'Clients', { parentId: 'work' }),
|
||||
box('acme', 'Acme', { parentId: 'clients' }),
|
||||
box('rd', 'R&D'),
|
||||
shared('inbox', 'Inbox', { role: 'inbox' }),
|
||||
shared('trash', 'Trash', { role: 'trash' }),
|
||||
];
|
||||
|
||||
let popup: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
popup = vi.fn(() => new Promise<string | null>(() => {}));
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup: popup as never } } as unknown as BulwarkDesktopBridge;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
async function nativeMoveMenu(): Promise<DesktopNativeMenuItem[]> {
|
||||
const model = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', recentIds: () => ['acme'] });
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 10, y: 10 }} onClose={() => {}}>
|
||||
<ContextMenuSubMenu label="Move to">
|
||||
<MoveFolderList model={model} onPick={() => {}} />
|
||||
</ContextMenuSubMenu>
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
const items = popup.mock.calls[0][0] as DesktopNativeMenuItem[];
|
||||
return items[0].submenu!;
|
||||
}
|
||||
|
||||
const labels = (items: DesktopNativeMenuItem[] | undefined) => (items ?? []).map((item) => item.label ?? item.type);
|
||||
|
||||
describe('the Move menu shown natively', () => {
|
||||
it('has the recent folders under a header, then the account\'s own folders', async () => {
|
||||
const move = await nativeMoveMenu();
|
||||
expect(labels(move)).toEqual([
|
||||
'recent', 'Work / Clients / Acme', 'separator',
|
||||
'mailboxes.inbox', 'mailboxes.trash', 'R&D', 'Work',
|
||||
'separator', 'team@example.org',
|
||||
]);
|
||||
expect(move[0].enabled).toBe(false);
|
||||
// The open folder is there, greyed.
|
||||
expect(move[3].enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('nests subfolders as submenus, the folder itself first', async () => {
|
||||
const move = await nativeMoveMenu();
|
||||
const work = move.find((item) => item.label === 'Work')!;
|
||||
expect(labels(work.submenu)).toEqual(['Work', 'separator', 'Clients']);
|
||||
expect(work.submenu![0].id).toBeTruthy();
|
||||
const clients = work.submenu![2];
|
||||
expect(labels(clients.submenu)).toEqual(['Clients', 'separator', 'Acme']);
|
||||
});
|
||||
|
||||
it('puts another account\'s folders in a submenu under its name', async () => {
|
||||
const move = await nativeMoveMenu();
|
||||
const team = move.find((item) => item.label === 'team@example.org')!;
|
||||
expect(labels(team.submenu)).toEqual(['mailboxes.inbox', 'mailboxes.trash']);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,41 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { MailboxNode } from '@/lib/utils';
|
||||
import { MoveTargetList } from '../move-target-list';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
import { buildMoveMenu } from '@/lib/move-menu';
|
||||
import { readRecentMoveTargets } from '@/lib/move-recent';
|
||||
import { MoveFolderList } from '../move-target-list';
|
||||
|
||||
// The reader's Move menus show the names the sidebar shows (Stalwart's
|
||||
// "Sent Items" / "Junk Mail" / "Deleted Items" are Sent, Spam and Trash, in
|
||||
// the user's language), and indent subfolders on the reading side.
|
||||
// The Move lists show the names the sidebar shows (Stalwart's "Sent Items" /
|
||||
// "Junk Mail" are Sent and Spam, in the user's language), nest subfolders,
|
||||
// and get a filter field once they're long.
|
||||
|
||||
const node = (id: string, name: string, role: string | undefined, children: MailboxNode[] = []) =>
|
||||
({ id, name, role, children, depth: 0 }) as unknown as MailboxNode;
|
||||
const writable = { mayAddItems: true } as Mailbox['myRights'];
|
||||
const box = (id: string, name: string, extra: Partial<Mailbox> = {}) =>
|
||||
({
|
||||
id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
|
||||
myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra,
|
||||
}) as Mailbox;
|
||||
|
||||
const tree = [
|
||||
node('sent', 'Sent Items', 'sent'),
|
||||
node('junk', 'Junk Mail', 'junk'),
|
||||
node('archive', 'Archive', 'archive', [node('y2025', '2025', undefined)]),
|
||||
const short = [
|
||||
box('inbox', 'Inbox', { role: 'inbox' }),
|
||||
box('sent', 'Sent Items', { role: 'sent' }),
|
||||
box('junk', 'Junk Mail', { role: 'junk' }),
|
||||
box('archive', 'Archive', { role: 'archive' }),
|
||||
box('y2025', '2025', { parentId: 'archive' }),
|
||||
];
|
||||
|
||||
describe('MoveTargetList', () => {
|
||||
const long = [
|
||||
...short,
|
||||
...Array.from({ length: 12 }, (_, i) => box(`f${i}`, `Folder ${i}`)),
|
||||
box('acme', 'Acme', { parentId: 'f3' }),
|
||||
];
|
||||
|
||||
afterEach(() => localStorage.clear());
|
||||
|
||||
describe('MoveFolderList', () => {
|
||||
it('shows the localized folder names and moves on a pick', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<MoveTargetList tree={tree} targetIds={new Set(['sent', 'junk', 'archive', 'y2025'])} onPick={onPick} />);
|
||||
render(<MoveFolderList model={buildMoveMenu(short, { currentMailboxId: 'inbox' })} onPick={onPick} />);
|
||||
// The i18n mock returns the key: the role's name, not the server's.
|
||||
expect(screen.queryByText('Sent Items')).toBeNull();
|
||||
expect(screen.getByText('mailboxes.sent')).toBeTruthy();
|
||||
@@ -27,10 +43,63 @@ describe('MoveTargetList', () => {
|
||||
expect(onPick).toHaveBeenCalledWith('junk');
|
||||
});
|
||||
|
||||
it('indents subfolders with a logical padding', () => {
|
||||
render(<MoveTargetList tree={tree} targetIds={new Set(['y2025'])} onPick={vi.fn()} />);
|
||||
const row = screen.getByText('2025').closest('button')!;
|
||||
expect(row.style.paddingInlineStart).toBe('1.75rem');
|
||||
expect(row.style.paddingLeft).toBe('');
|
||||
it('greys the open folder and keeps the pick as a recent folder', () => {
|
||||
const onPick = vi.fn();
|
||||
render(<MoveFolderList model={buildMoveMenu(short, { currentMailboxId: 'inbox' })} onPick={onPick} />);
|
||||
expect(screen.getByTestId('move-to:inbox')).toBeDisabled();
|
||||
fireEvent.click(screen.getByTestId('move-to:inbox'));
|
||||
expect(onPick).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByTestId('move-to:y2025'));
|
||||
expect(readRecentMoveTargets('A')).toEqual(['y2025']);
|
||||
});
|
||||
|
||||
it('puts recent folders at the top, with where they live', () => {
|
||||
render(
|
||||
<MoveFolderList
|
||||
model={buildMoveMenu(short, { currentMailboxId: 'inbox', recentIds: () => ['y2025'] })}
|
||||
onPick={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('recent')).toBeTruthy();
|
||||
expect(screen.getByText('Archive / 2025')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('nests subfolders in a group the desktop app makes a submenu of', () => {
|
||||
render(<MoveFolderList model={buildMoveMenu(short, {})} onPick={vi.fn()} />);
|
||||
const group = screen.getByTestId('move-to:archive').closest('[data-native-submenu]')!;
|
||||
expect(group).not.toBeNull();
|
||||
expect(group.contains(screen.getByTestId('move-to:y2025'))).toBe(true);
|
||||
});
|
||||
|
||||
it('has no filter while short', () => {
|
||||
render(<MoveFolderList model={buildMoveMenu(short, {})} onPick={vi.fn()} onChooseFolder={vi.fn()} />);
|
||||
expect(screen.queryByTestId('move-filter')).toBeNull();
|
||||
expect(screen.queryByTestId('move-choose-folder')).toBeNull();
|
||||
});
|
||||
|
||||
it('a long list gets a filter that keeps the folders above a match, and Enter moves to the first', () => {
|
||||
const onPick = vi.fn();
|
||||
const onChooseFolder = vi.fn();
|
||||
render(<MoveFolderList model={buildMoveMenu(long, {})} onPick={onPick} onChooseFolder={onChooseFolder} />);
|
||||
fireEvent.click(screen.getByTestId('move-choose-folder'));
|
||||
expect(onChooseFolder).toHaveBeenCalled();
|
||||
|
||||
const filter = screen.getByTestId('move-filter');
|
||||
fireEvent.change(filter, { target: { value: 'acm' } });
|
||||
expect(screen.getByTestId('move-to:f3')).toBeTruthy();
|
||||
expect(screen.getByTestId('move-to:acme')).toBeTruthy();
|
||||
expect(screen.queryByTestId('move-to:f4')).toBeNull();
|
||||
fireEvent.keyDown(filter, { key: 'Enter' });
|
||||
expect(onPick).toHaveBeenCalledWith('f3');
|
||||
|
||||
fireEvent.change(filter, { target: { value: 'nothing like it' } });
|
||||
expect(screen.getByText('no_match')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('indents subfolders on the reading side in the phone panel', () => {
|
||||
render(<MoveFolderList variant="panel" model={buildMoveMenu(short, {})} onPick={vi.fn()} />);
|
||||
const row = screen.getByTestId('move-to:y2025');
|
||||
expect(row.parentElement!.className).toContain('ps-6');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { act, fireEvent, render, renderHook, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import { MoveToDialogHost, useChooseMoveFolder, useMoveDialogStore } from '../move-to-dialog';
|
||||
|
||||
// "Choose folder…" at the end of a long Move menu opens this dialog: a native
|
||||
// menu has no filter field, the dialog has one.
|
||||
|
||||
const box = (id: string, name: string, extra: Partial<Mailbox> = {}) =>
|
||||
({
|
||||
id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
|
||||
myRights: { mayAddItems: true }, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra,
|
||||
}) as Mailbox;
|
||||
|
||||
const mailboxes = [box('inbox', 'Inbox', { role: 'inbox' }), box('work', 'Work'), box('travel', 'Travel')];
|
||||
const email = { id: 'e1', mailboxIds: { inbox: true } } as unknown as Email;
|
||||
|
||||
afterEach(() => {
|
||||
act(() => useMoveDialogStore.setState({ request: null }));
|
||||
});
|
||||
|
||||
describe('the Move dialog', () => {
|
||||
it('is only offered where a host can show it', () => {
|
||||
const without = renderHook(() => useChooseMoveFolder());
|
||||
expect(without.result.current).toBeUndefined();
|
||||
without.unmount();
|
||||
|
||||
render(<MoveToDialogHost mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
const withHost = renderHook(() => useChooseMoveFolder());
|
||||
expect(withHost.result.current).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('shows the folders with a filter, and moves to the one picked', () => {
|
||||
render(<MoveToDialogHost mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
const onMove = vi.fn();
|
||||
act(() => useMoveDialogStore.getState().open({ emails: [email], onMove }));
|
||||
|
||||
expect(screen.getByTestId('move-to:inbox')).toBeDisabled();
|
||||
fireEvent.change(screen.getByTestId('move-filter'), { target: { value: 'tra' } });
|
||||
expect(screen.queryByTestId('move-to:work')).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('move-to:travel'));
|
||||
|
||||
expect(onMove).toHaveBeenCalledWith('travel');
|
||||
expect(useMoveDialogStore.getState().request).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -67,7 +67,8 @@ describe('MvListToolbar filing actions', () => {
|
||||
toolbar();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('move_to'));
|
||||
expect(screen.queryByText('mailboxes.inbox')).toBeNull();
|
||||
// Shown greyed, where the sidebar has it.
|
||||
expect(screen.getByText('mailboxes.inbox').closest('button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('puts a tag on the whole selection when only some of it carries the tag', async () => {
|
||||
@@ -105,7 +106,7 @@ describe('MvListToolbar filing actions', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'move_to' }));
|
||||
expect(screen.getByText('Travel')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'move_to' }), { target: { value: 'rec' } });
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'filter_placeholder' }), { target: { value: 'rec' } });
|
||||
expect(screen.getByText('Receipts')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Travel')).toBeNull();
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { RichTextEditor } from '../rich-text-editor';
|
||||
|
||||
// The composer's font size menu at 150 % zoom ran off the bottom of the window.
|
||||
// A popover that doesn't fit under its button opens above it when there's more
|
||||
// room there, and scrolls inside either way.
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function openFontSize(triggerTop: number, height = 300) {
|
||||
Object.defineProperty(window, 'innerHeight', { value: 480, configurable: true });
|
||||
Object.defineProperty(window, 'innerWidth', { value: 853, configurable: true });
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this.getAttribute('role') === 'menu') return rect(100, triggerTop + 36, 120, height);
|
||||
if (this.getAttribute('role') === 'toolbar') return rect(0, triggerTop - 4, 853, 40);
|
||||
if (this.dataset.toolbarItem === 'font-size') return rect(100, triggerTop, 32, 32);
|
||||
return rect(0, 0, 0, 0);
|
||||
});
|
||||
let editor: Editor | null = null;
|
||||
render(<RichTextEditor content="<p>Hello</p>" onChange={() => {}} onEditorReady={(ed) => { editor = ed as unknown as Editor; }} />);
|
||||
await waitFor(() => expect(editor).not.toBeNull());
|
||||
fireEvent.click(document.querySelector<HTMLButtonElement>('[data-toolbar-item="font-size"]')!);
|
||||
return screen.getByRole('menu', { name: 'font_size' });
|
||||
}
|
||||
|
||||
describe('a composer toolbar popover in a short window', () => {
|
||||
it('opens above its button when there is more room there, capped to it', async () => {
|
||||
const menu = await openFontSize(360);
|
||||
expect(menu.style.bottom).toBe('100%');
|
||||
expect(menu.style.maxHeight).toBe('300px');
|
||||
expect(menu.style.overflowY).toBe('auto');
|
||||
});
|
||||
|
||||
it('stays under its button and scrolls when there is more room below', async () => {
|
||||
const menu = await openFontSize(100, 400);
|
||||
expect(menu.style.bottom).toBe('');
|
||||
expect(menu.style.maxHeight).toBe(`${480 - 10 - 136}px`);
|
||||
expect(menu.style.overflowY).toBe('auto');
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,24 @@ describe('Rules entry of the message menu', () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
it('lists folders in Move-list order, with a read-only parent greyed above its subfolders', () => {
|
||||
const boxes = [
|
||||
{ id: 'zeta', name: 'Zeta', role: null, parentId: null, sortOrder: 0, myRights: { mayAddItems: true } },
|
||||
{ id: 'trash', name: 'Trash', role: 'trash', parentId: null, sortOrder: 9, myRights: { mayAddItems: true } },
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, sortOrder: 5, myRights: { mayAddItems: true } },
|
||||
{ id: 'shared', name: 'Shared', role: null, parentId: null, sortOrder: 0, myRights: { mayAddItems: false } },
|
||||
{ id: 'alpha', name: 'alpha', role: null, parentId: 'shared', sortOrder: 0, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
targets.set('e1', target({ mailboxes: boxes }));
|
||||
renderMenu(email('e1'));
|
||||
openRules();
|
||||
fireEvent.mouseEnter(screen.getByTestId('rules:move_sender').parentElement!);
|
||||
const rows = screen.getAllByTestId(/^rules:move_sender:/).map((el) => el.getAttribute('data-testid'));
|
||||
expect(rows).toEqual(['rules:move_sender:inbox', 'rules:move_sender:trash', 'rules:move_sender:shared', 'rules:move_sender:alpha', 'rules:move_sender:zeta', 'rules:move_sender:new-folder']);
|
||||
expect(screen.getByTestId('rules:move_sender:shared')).toBeDisabled();
|
||||
expect(screen.getByTestId('rules:move_sender:shared').closest('[data-native-submenu]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('disables Block sender with a hint when the account has no Junk folder', () => {
|
||||
targets.set('e1', target());
|
||||
renderMenu(email('e1'));
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// The message details panel in a narrow reader: the two columns followed the
|
||||
// window's width, not the panel's, so they stayed side by side in a narrow pane;
|
||||
// the sender's address went letter by letter down a sliver of a column, dates took
|
||||
// three lines, and long sign-in results ran off the side.
|
||||
|
||||
vi.mock('@/hooks/use-media-query', () => ({
|
||||
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
|
||||
useMediaQuery: () => false,
|
||||
}));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
|
||||
class NoopResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
|
||||
|
||||
const { EmailViewer } = await import('../email-viewer');
|
||||
|
||||
const mailboxes = [
|
||||
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
|
||||
] as unknown as Mailbox[];
|
||||
|
||||
const ADDRESS = 'no-reply-x8@notifications.mail.example-service.example.net';
|
||||
const SPF_DOMAIN = 'bounces+31859940-0dcc@em9672.notifications.example-service.example.net';
|
||||
|
||||
const email = {
|
||||
id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
|
||||
from: [{ name: 'Example Service', email: ADDRESS }], to: [{ name: 'Bob', email: 'bob@example.com' }],
|
||||
subject: 'Hello', sentAt: '2026-11-04T04:33:50Z', receivedAt: '2026-11-04T04:33:53Z', preview: 'Hello',
|
||||
hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {},
|
||||
authenticationResults: { spf: { result: 'pass', domain: SPF_DOMAIN } },
|
||||
} as unknown as Email;
|
||||
|
||||
function openDetails() {
|
||||
render(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" isLoading={false} />);
|
||||
act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); });
|
||||
const heading = screen.getByText('details.recipients_routing');
|
||||
return heading.closest('section')!;
|
||||
}
|
||||
|
||||
describe('message details panel', () => {
|
||||
it('lays out by its own width, not the window', () => {
|
||||
const routing = openDetails();
|
||||
const grid = routing.parentElement!;
|
||||
expect(grid.className).toMatch(/@3xl:grid-cols-2/);
|
||||
expect(grid.className).not.toMatch(/\blg:grid-cols-2/);
|
||||
expect(grid.parentElement!.className).toMatch(/@container/);
|
||||
// The value column may shrink below its content's width.
|
||||
expect(routing.querySelector('dl')!.className).toContain('minmax(0,1fr)');
|
||||
});
|
||||
|
||||
it('shows the sender name and address apart, the address breakable at its dots', () => {
|
||||
const routing = openDetails();
|
||||
expect(routing.textContent).not.toContain(`<${ADDRESS}>`);
|
||||
const address = routing.querySelector(`[title="${ADDRESS}"]`)!;
|
||||
expect(address.textContent).toBe(ADDRESS);
|
||||
expect(address.querySelectorAll('wbr').length).toBeGreaterThan(2);
|
||||
});
|
||||
|
||||
it('shows dates short, with the full date on hover', () => {
|
||||
const routing = openDetails();
|
||||
const times = routing.querySelectorAll('time');
|
||||
expect(times.length).toBe(2);
|
||||
for (const time of Array.from(times)) {
|
||||
expect(time.getAttribute('title')!.length).toBeGreaterThan(time.textContent!.length);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps sign-in result chips inside the panel, the long part cut with the whole on hover', () => {
|
||||
openDetails();
|
||||
const domain = screen.getByText(SPF_DOMAIN);
|
||||
expect(domain.className).toContain('truncate');
|
||||
expect(domain.getAttribute('title')).toBe(SPF_DOMAIN);
|
||||
expect(domain.parentElement!.className).toContain('max-w-full');
|
||||
});
|
||||
});
|
||||
@@ -61,6 +61,26 @@ function printCopy(): HTMLElement | null {
|
||||
}
|
||||
|
||||
describe('Ctrl+P on an open message', () => {
|
||||
it('prints an invitation with its card: when, where and who', async () => {
|
||||
const { InvitationPrintSync } = await import('@/lib/print/invitation-print');
|
||||
const card = { heading: 'Calendar Invitation', rows: [['Time', 'Thu 5 Nov, 13:00 to 14:00'], ['Location', 'Room 4'], ['Organized by', 'Anna Becker']] as Array<[string, string]> };
|
||||
const view = render(<>
|
||||
<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />
|
||||
<InvitationPrintSync emailId="e1" print={card} />
|
||||
</>);
|
||||
act(() => { window.dispatchEvent(new Event('beforeprint')); });
|
||||
const box = printCopy()!.querySelector('[data-message-print-invitation]')!;
|
||||
expect(box.textContent).toContain('Calendar Invitation');
|
||||
expect(box.textContent).toContain('Thu 5 Nov, 13:00 to 14:00');
|
||||
expect(box.textContent).toContain('Room 4');
|
||||
expect(box.textContent).toContain('Anna Becker');
|
||||
act(() => { window.dispatchEvent(new Event('afterprint')); });
|
||||
// The card gone (another message opened): the next printout has no box.
|
||||
view.rerender(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
act(() => { window.dispatchEvent(new Event('beforeprint')); });
|
||||
expect(printCopy()!.querySelector('[data-message-print-invitation]')).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('prints the message alone, next to the app', () => {
|
||||
render(<EmailViewer email={htmlMail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
|
||||
expect(printCopy()).toBeNull();
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ChevronUp,
|
||||
} from '@/components/icons';
|
||||
import { useTranslations, useFormatter } from 'next-intl';
|
||||
import { InvitationPrintSync } from '@/lib/print/invitation-print';
|
||||
import { useRouter } from '@/i18n/navigation';
|
||||
import { isDocumentRTL } from '@/i18n/direction';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
@@ -939,6 +940,21 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
|
||||
tabIndex={isCollapsed ? 0 : undefined}
|
||||
aria-expanded={isCollapsed ? false : undefined}
|
||||
>
|
||||
{/* When and where, for the printout of this message: its print copy has no card. */}
|
||||
{summary && (
|
||||
<InvitationPrintSync
|
||||
emailId={email.id}
|
||||
print={{
|
||||
heading: bannerTitle,
|
||||
rows: ([
|
||||
[t('change_title'), summary.title ?? ''],
|
||||
[t('change_time'), summary.start ? formatSchedule(summary) : ''],
|
||||
[t('change_location'), summary.location ?? ''],
|
||||
[t('organizer_label'), summary.organizer ? (summary.organizerEmail && summary.organizerEmail !== summary.organizer ? `${summary.organizer} <${summary.organizerEmail}>` : summary.organizer) : ''],
|
||||
] as Array<[string, string]>).filter(([, value]) => value),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Avatar-style icon */}
|
||||
<div className={cn(
|
||||
"w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 shadow-sm",
|
||||
|
||||
@@ -8,12 +8,13 @@ import { ContextMenu } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useCopyToAccount, useEmailExport } from "@/hooks/use-message-file-actions";
|
||||
import { useArchiveActionEnabled } from "@/lib/archive-gate";
|
||||
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { resolveEmailBlobContext } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { Archive, FolderInput, MoreVertical, Trash2 } from "@/components/icons";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { MoveTargetList } from "./move-target-list";
|
||||
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list";
|
||||
import { useChooseMoveFolder } from "./move-to-dialog";
|
||||
import { ViewSourceDialog } from "./view-source-dialog";
|
||||
import { canExport } from "@/lib/device-policy";
|
||||
import type { MessageActionKey } from "./message-actions";
|
||||
@@ -56,11 +57,10 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac
|
||||
const { mailboxes, selectedMailbox, onArchive, onDelete, onMoveToMailbox } = actions;
|
||||
|
||||
const ownerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox);
|
||||
const { tree: moveTree, targetIds: moveTargetIds } = useMemo(
|
||||
() => buildMoveTargets(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId, emails: [email] }),
|
||||
[mailboxes, selectedMailbox, ownerAccountId, email],
|
||||
);
|
||||
const canMove = !!onMoveToMailbox && moveTree.length > 0;
|
||||
const moveEmails = useMemo(() => [email], [email]);
|
||||
const moveModel = useMoveMenu(mailboxes, { currentMailboxId: selectedMailbox, ownerAccountId, emails: moveEmails });
|
||||
const canMove = !!onMoveToMailbox && !moveMenuIsEmpty(moveModel);
|
||||
const chooseMoveFolder = useChooseMoveFolder();
|
||||
|
||||
const exportFollowUp = { onArchive, onDelete, archiveEnabled };
|
||||
const source = resolveEmailBlobContext(email, client);
|
||||
@@ -107,11 +107,13 @@ export function ConversationHeaderActions({ email, actions }: { email: Email; ac
|
||||
onClose={moveMenu.closeContextMenu}
|
||||
label={t("email_viewer.move_to")}
|
||||
>
|
||||
<div className="w-56 max-h-72 overflow-y-auto">
|
||||
<MoveTargetList
|
||||
tree={moveTree}
|
||||
targetIds={moveTargetIds}
|
||||
<div className="w-64 max-w-full">
|
||||
<MoveFolderList
|
||||
model={moveModel}
|
||||
onPick={(mailboxId) => { moveMenu.closeContextMenu(); onMoveToMailbox!(mailboxId); }}
|
||||
onChooseFolder={chooseMoveFolder
|
||||
? () => { moveMenu.closeContextMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox! }); }
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { useCloseBesideControls } from "@/hooks/use-close-beside-controls";
|
||||
import { useMvComposeWindow } from "@/components/email/mv-compose-context";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { isServerOutage } from "@/lib/jmap/offline-hooks";
|
||||
@@ -56,6 +57,7 @@ import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { formatShortcutCombo } from "@/lib/shortcut-keys";
|
||||
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml, sanitizePluginBodyHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
@@ -174,6 +176,7 @@ import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { isAppLockedNow } from "@/lib/app-lock/state";
|
||||
import { registerComposeFlush, registerComposeSnapshot } from "@/lib/compose-flush-registry";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { noteComposerLanguage } from "@/lib/desktop/languages";
|
||||
import { noteOpenDraft } from "@/lib/desktop/window-restore";
|
||||
import type { RescuedDraft } from "@/lib/popout/channel";
|
||||
@@ -592,6 +595,7 @@ export function EmailComposer({
|
||||
replyTo
|
||||
}: EmailComposerProps) {
|
||||
const t = useTranslations('email_composer');
|
||||
const tMenu = useTranslations('menuAudit');
|
||||
const tCommon = useTranslations('common');
|
||||
const tWindows = useTranslations('pwaWindows');
|
||||
const tQuote = useTranslations('quote_header');
|
||||
@@ -2001,6 +2005,10 @@ export function EmailComposer({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [effectiveSessionKey]);
|
||||
|
||||
// The header's close goes to its start when the window's own close is at its end.
|
||||
const [composerHeader, setComposerHeader] = useState<HTMLDivElement | null>(null);
|
||||
const closeBesideControls = useCloseBesideControls(composerHeader);
|
||||
|
||||
// Auto-save draft to server on page close (best-effort)
|
||||
const ownWindowRef = useRef(ownWindow);
|
||||
ownWindowRef.current = ownWindow;
|
||||
@@ -2788,6 +2796,19 @@ export function EmailComposer({
|
||||
useEffect(() => setComposerFileTarget(
|
||||
(files) => void addFiles(files),
|
||||
() => !!composerRootEl?.isConnected && composerRootEl.getClientRects().length > 0,
|
||||
// The file drop overlay's "Insert into the message": the editor's own paste, so the
|
||||
// upload, the place and the question for a description are the same as pasting.
|
||||
{
|
||||
available: () => !!editorRef.current && !editorRef.current.isDestroyed,
|
||||
insert: (files) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor || editor.isDestroyed) return;
|
||||
const transfer = new DataTransfer();
|
||||
for (const file of files) transfer.items.add(file);
|
||||
editor.commands.focus();
|
||||
editor.view.dom.dispatchEvent(new ClipboardEvent('paste', { clipboardData: transfer, cancelable: true }));
|
||||
},
|
||||
},
|
||||
), [addFiles, composerRootEl]);
|
||||
// The id plugins know this composer by (registered below, with the handle).
|
||||
const [pluginComposerId, setPluginComposerId] = useState<string | null>(null);
|
||||
@@ -4514,6 +4535,21 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
// Close at the top end (R4). On a phone it moves to the start, as the phone's
|
||||
// mail apps have it, with Send at the end. Beside the window's own close it
|
||||
// goes to the start too.
|
||||
const composerCloseButton = (
|
||||
<IconButton
|
||||
onClick={() => handleClose()}
|
||||
className={cn(closeBesideControls ? "-ms-2" : "-me-2", "max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11")}
|
||||
label={tCommon('close')}
|
||||
data-testid="composer-close"
|
||||
data-own-window-close={ownWindow ? "" : undefined}
|
||||
>
|
||||
<X className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</IconButton>
|
||||
);
|
||||
|
||||
const popOutButton = (className: string, iconClassName: string) => popOut && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -4692,6 +4728,18 @@ export function EmailComposer({
|
||||
}
|
||||
};
|
||||
|
||||
// The desktop app's Message menu while this composer is open: the same handlers as
|
||||
// its buttons and keys (lib/desktop/commands.ts). Nothing in a browser.
|
||||
useDesktopCommands({
|
||||
'compose.send': () => { if (!isSavingAndClosing) void handleSend(); },
|
||||
'compose.saveDraft': () => { void saveDraft({ announce: true }); },
|
||||
'compose.attach': () => fileInputRef.current?.click(),
|
||||
}, {
|
||||
'compose.send': !!canSend && !isSending && !isSavingAndClosing,
|
||||
'compose.saveDraft': !isSavingAndClosing,
|
||||
'compose.attach': !isSavingAndClosing,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setComposerRootEl}
|
||||
@@ -4814,7 +4862,8 @@ export function EmailComposer({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 px-4 py-2 md:py-0 md:h-14 border-b bg-background shrink-0" data-composer-header="">
|
||||
<div ref={setComposerHeader} className="flex items-center gap-3 px-4 py-2 md:py-0 md:h-14 border-b bg-background shrink-0" data-composer-header="">
|
||||
{closeBesideControls && composerCloseButton}
|
||||
{/* The left panel's button at the start, the right one's at the end: where each panel is. */}
|
||||
{leftPanelToggle && <div className="hidden md:flex -ms-2">{leftPanelToggle}</div>}
|
||||
<div className="flex-1 min-w-0 flex items-center gap-3">
|
||||
@@ -4886,17 +4935,7 @@ export function EmailComposer({
|
||||
)}
|
||||
{rightPanelToggle}
|
||||
{popOutButton("hidden md:grid h-8 w-8 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", "w-4 h-4")}
|
||||
{/* Close at the top end (R4). On a phone it moves to the start, as
|
||||
the phone's mail apps have it, with Send at the end. */}
|
||||
<IconButton
|
||||
onClick={() => handleClose()}
|
||||
className="-me-2 max-md:order-first max-md:me-0 max-md:-ms-2 max-md:h-11 max-md:w-11"
|
||||
label={tCommon('close')}
|
||||
data-testid="composer-close"
|
||||
data-own-window-close={ownWindow ? "" : undefined}
|
||||
>
|
||||
<X className="w-5 h-5 md:w-4 md:h-4" />
|
||||
</IconButton>
|
||||
{!closeBesideControls && composerCloseButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -5813,7 +5852,7 @@ export function EmailComposer({
|
||||
|
||||
<ContextMenu ref={sendMenu.menuRef} isOpen={showSendMenu} position={sendMenu.contextMenu.position} onClose={closeSendMenu}>
|
||||
{composerClient?.hasDelayedSend() && (
|
||||
<ContextMenuItem icon={CalendarClock} label={t('schedule_send')} onClick={openScheduleDialog} testId="composer-schedule-send" />
|
||||
<ContextMenuItem icon={CalendarClock} label={tMenu('schedule_send')} shortcut={formatShortcutCombo('Mod + Shift + Enter')} onClick={openScheduleDialog} testId="composer-schedule-send" />
|
||||
)}
|
||||
<ComposerPluginMenuItems
|
||||
surface="composer-send-menu"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1451,6 +1451,7 @@ export function EmailList({
|
||||
{/* Context Menu */}
|
||||
{contextMenuEmail && (
|
||||
<EmailContextMenu
|
||||
rowMenu
|
||||
email={contextMenuEmail}
|
||||
position={contextMenu.position}
|
||||
isOpen={contextMenu.isOpen}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react";
|
||||
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
|
||||
import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
|
||||
import { openMailImport } from "@/stores/mail-transfer-store";
|
||||
import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
|
||||
import { mailLanguage } from "@/lib/mail-language";
|
||||
@@ -17,6 +16,7 @@ import { ReaderAttachments, type AttachmentDragProps } from "./reader-attachment
|
||||
import { editAccountOf, useEditAttachment } from "@/hooks/use-edit-attachment";
|
||||
import { ReaderViewDialog } from "./reader-view-dialog";
|
||||
import { MessagePrint, type MessagePrintFields } from "./message-print";
|
||||
import { useInvitationPrint } from "@/lib/print/invitation-print";
|
||||
import { usePrintListener, usePrinting } from "@/components/print/print-root";
|
||||
import { ReadingMask } from "./reading-mask";
|
||||
import { collectReferencedCids, isEmbeddedInBody, isReportPart, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
|
||||
@@ -36,6 +36,7 @@ import { Avatar } from "@/components/ui/avatar";
|
||||
import { ContextMenu, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu, type UseContextMenuReturn } from "@/hooks/use-context-menu";
|
||||
import { EmailContextMenu } from "./email-context-menu";
|
||||
import { rememberedTrashOrigin } from "@/lib/trash-origin";
|
||||
import { useMessageActionList, type MessageActionEntry, type MessageActionKey, type MessageActionOptions, type MessageSubmenuKey } from "./message-actions";
|
||||
import { PhishingMarkBanner } from "./phishing-mark-banner";
|
||||
import { turnOffLinks } from "@/lib/phishing-links";
|
||||
@@ -49,11 +50,12 @@ import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/p
|
||||
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
|
||||
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
|
||||
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
|
||||
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, cleanPreview, type MailboxNode } from "@/lib/utils";
|
||||
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils";
|
||||
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
|
||||
import { itemsToHide, toolbarHideOrder } from "@/lib/toolbar-overflow";
|
||||
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
|
||||
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
|
||||
import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu";
|
||||
import { emailDisplayDate } from "@/lib/email-date";
|
||||
import { TagBadge } from "./tag-badge";
|
||||
import { TagPicker } from "./tag-picker";
|
||||
@@ -65,7 +67,8 @@ import { getSecurityStatus, extractListHeaders, getSenderVerification, brandIndi
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
|
||||
import { ViewSourceDialog } from "./view-source-dialog";
|
||||
import { MoveTargetList } from "./move-target-list";
|
||||
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list";
|
||||
import { useChooseMoveFolder } from "./move-to-dialog";
|
||||
import { SaveToFilesDialog, type SaveableAttachment } from "./save-to-files-dialog";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import { ScheduleSendDialog } from "./schedule-send-dialog";
|
||||
@@ -109,7 +112,6 @@ import {
|
||||
Send,
|
||||
FolderInput,
|
||||
Sun,
|
||||
Upload,
|
||||
Moon,
|
||||
PlayCircle,
|
||||
PenSquare,
|
||||
@@ -125,7 +127,7 @@ import {
|
||||
Paperclip,
|
||||
} from "@/components/icons";
|
||||
import { MailMessageSkeleton, MailMessageToolbarSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -160,6 +162,7 @@ import { useMenuNavigation } from "@/hooks/use-menu-navigation";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
|
||||
import { AddressText } from "./address-context-menu";
|
||||
import { BreakableAddress } from "@/components/ui/breakable-address";
|
||||
import { mentionFromLink } from "@/lib/recipient-mentions";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
||||
@@ -299,7 +302,6 @@ interface EmailViewerProps {
|
||||
onReplyAllConversationEmail?: (email: Email) => void;
|
||||
/** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */
|
||||
mvInlineComposer?: React.ReactNode;
|
||||
onShowShortcuts?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
/** Stop a scheduled message from going out; it goes back to Drafts. */
|
||||
@@ -1186,6 +1188,7 @@ function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMo
|
||||
onViewSource={options.onViewSource}
|
||||
onOpenInWindow={options.onOpenInWindow}
|
||||
omit={options.omit}
|
||||
views={options.views}
|
||||
extras={options.extras}
|
||||
rowPlugins={false}
|
||||
extraEntries={pluginEntries}
|
||||
@@ -1201,8 +1204,7 @@ interface ReaderMorePanelBodyProps {
|
||||
openSub: (sub: MoreMenuSub) => void;
|
||||
close: () => void;
|
||||
entryRefs: React.MutableRefObject<Record<MoreMenuSub, HTMLButtonElement | null>>;
|
||||
moveTree: MailboxNode[];
|
||||
moveTargetIds: Set<string>;
|
||||
moveModel: MoveMenuModel;
|
||||
onMoveToMailbox?: (mailboxId: string) => void;
|
||||
currentTagIds: string[];
|
||||
sortedTagIds: string[];
|
||||
@@ -1226,8 +1228,7 @@ function ReaderMorePanelBody({
|
||||
openSub,
|
||||
close,
|
||||
entryRefs,
|
||||
moveTree,
|
||||
moveTargetIds,
|
||||
moveModel,
|
||||
onMoveToMailbox,
|
||||
currentTagIds,
|
||||
sortedTagIds,
|
||||
@@ -1238,6 +1239,7 @@ function ReaderMorePanelBody({
|
||||
pluginTarget,
|
||||
}: ReaderMorePanelBodyProps) {
|
||||
const { email } = options;
|
||||
const locale = useLocale();
|
||||
const { tagColor } = useKeywordFormat(tagScope);
|
||||
const { copyTargets, copyToAccount } = useCopyToAccount(email, true);
|
||||
const entries = useMessageActionList({
|
||||
@@ -1247,11 +1249,10 @@ function ReaderMorePanelBody({
|
||||
});
|
||||
|
||||
if (sub === 'move') {
|
||||
return moveTree.length > 0 && onMoveToMailbox ? (
|
||||
<MoveTargetList
|
||||
return !moveMenuIsEmpty(moveModel) && onMoveToMailbox ? (
|
||||
<MoveFolderList
|
||||
variant="panel"
|
||||
tree={moveTree}
|
||||
targetIds={moveTargetIds}
|
||||
model={moveModel}
|
||||
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); close(); }}
|
||||
/>
|
||||
) : null;
|
||||
@@ -1260,14 +1261,15 @@ function ReaderMorePanelBody({
|
||||
return (
|
||||
<>
|
||||
{copyTargets.map((target) => {
|
||||
const { tree, targetIds } = buildMoveTargets(target.mailboxes.filter((m) => !m.isShared && m.role !== 'drafts'), {});
|
||||
const model = buildMoveMenu(target.mailboxes.filter((m) => !m.isShared), { locale });
|
||||
return (
|
||||
<div key={target.accountId}>
|
||||
<div className="px-4 pt-3 pb-1 text-xs font-medium text-muted-foreground truncate" title={target.label}>{target.label}</div>
|
||||
<MoveTargetList
|
||||
<MoveFolderList
|
||||
variant="panel"
|
||||
tree={tree}
|
||||
targetIds={targetIds}
|
||||
noRecent
|
||||
model={model}
|
||||
testIdPrefix={`copy-to:${target.accountId}:`}
|
||||
onPick={(mailboxId) => { void copyToAccount([email], target.accountId, mailboxId); close(); }}
|
||||
/>
|
||||
</div>
|
||||
@@ -1371,7 +1373,6 @@ export function EmailViewer({
|
||||
onReplyConversationEmail,
|
||||
onReplyAllConversationEmail,
|
||||
mvInlineComposer,
|
||||
onShowShortcuts,
|
||||
onEditDraft,
|
||||
onCancelScheduledForEdit,
|
||||
onCancelScheduledSend,
|
||||
@@ -1396,7 +1397,6 @@ export function EmailViewer({
|
||||
const tWindows = useTranslations('pwaWindows');
|
||||
const tComposer = useTranslations('email_composer');
|
||||
const tNotifications = useTranslations('notifications');
|
||||
const tImport = useTranslations('mail_import');
|
||||
const tCommon = useTranslations('common');
|
||||
const tFiles = useTranslations('files');
|
||||
const tDemoWelcome = useTranslations('demo_welcome');
|
||||
@@ -2019,16 +2019,19 @@ export function EmailViewer({
|
||||
|
||||
// Move-to dropdown: the message's own account first (#1149)
|
||||
const moveOwnerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox);
|
||||
const { tree: moveTree, targetIds: moveTargetIds } = useMemo(
|
||||
() => buildMoveTargets(mailboxes, {
|
||||
currentMailboxId: selectedMailbox,
|
||||
ownerAccountId: moveOwnerAccountId,
|
||||
emails: email ? [email] : [],
|
||||
}),
|
||||
[mailboxes, selectedMailbox, moveOwnerAccountId, email],
|
||||
);
|
||||
const moveEmails = useMemo(() => (email ? [email] : []), [email]);
|
||||
const moveModel = useMoveMenu(mailboxes, {
|
||||
currentMailboxId: selectedMailbox,
|
||||
ownerAccountId: moveOwnerAccountId,
|
||||
emails: moveEmails,
|
||||
});
|
||||
const canMoveAnywhere = !moveMenuIsEmpty(moveModel);
|
||||
const chooseMoveFolder = useChooseMoveFolder();
|
||||
|
||||
const restoreTarget = isInTrashFolder && email ? trashRestoreTarget(email, mailboxes, identities, moveOwnerAccountId) : undefined;
|
||||
// Where this device saw it come from, else a guess by what the message is.
|
||||
const restoreTarget = isInTrashFolder && email
|
||||
? rememberedTrashOrigin(email, mailboxes) ?? trashRestoreTarget(email, mailboxes, identities, moveOwnerAccountId)
|
||||
: undefined;
|
||||
|
||||
// Close dropdown menus on click outside
|
||||
useEffect(() => {
|
||||
@@ -2216,7 +2219,7 @@ export function EmailViewer({
|
||||
email?.id,
|
||||
showToolbarLabels,
|
||||
isLoading,
|
||||
moveTree.length,
|
||||
canMoveAnywhere,
|
||||
emailKeywords.length,
|
||||
currentColor,
|
||||
isInJunkFolder,
|
||||
@@ -3822,11 +3825,6 @@ export function EmailViewer({
|
||||
await exportEmail(email, { onArchive, onDelete, archiveEnabled: archiveActionEnabled }, blobSource ?? { client, accountId: undefined });
|
||||
};
|
||||
|
||||
// Import mail into the open folder: the import dialog (lib/mail-import) does the rest.
|
||||
const handleImportEmail = () => {
|
||||
openMailImport({ mailboxId: useEmailStore.getState().selectedMailbox || undefined });
|
||||
};
|
||||
|
||||
// What a printout of the message shows, for the Print action's window and
|
||||
// for the page's print copy (Ctrl+P). Text fields are unescaped.
|
||||
const messagePrintFields = (): MessagePrintFields | null => {
|
||||
@@ -3852,6 +3850,7 @@ export function EmailViewer({
|
||||
? effectiveEmailContent.html.replace(/<details>/g, '<details open>')
|
||||
: sanitizeEmailHtml(effectiveEmailContent.html),
|
||||
plain,
|
||||
invitation: invitationPrint,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3949,6 +3948,8 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
() => printing && !!viewerRootEl && isShownOnScreen(viewerRootEl),
|
||||
[printing, viewerRootEl],
|
||||
);
|
||||
// An invitation's when and where, from its card.
|
||||
const invitationPrint = useInvitationPrint(email?.id);
|
||||
const printCopyFields = printShown && !devices.noPrint && !isLoading && !!email?.bodyValues
|
||||
? messagePrintFields()
|
||||
: null;
|
||||
@@ -4285,6 +4286,8 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
// the bottom bar has the replies.
|
||||
const toolbarShownActions = (): ReadonlySet<MessageActionKey> => {
|
||||
const shown = new Set<MessageActionKey>(['delete', 'edit_draft']);
|
||||
// The star sits by the subject, or on the sender's line in Mountain View.
|
||||
if (toolbarPosition === 'top' || mvViewer) shown.add('star');
|
||||
toolbarRef.current?.querySelectorAll<HTMLElement>('[data-overflow-item]').forEach((item) => {
|
||||
// A button standing in for another (Not spam in Archive's place) says which entry it is.
|
||||
const key = item.dataset.overflowAction !== undefined
|
||||
@@ -4308,9 +4311,10 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
return hidden;
|
||||
};
|
||||
const toggleViewMode = () => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev);
|
||||
// The reader's own entries after the message's: the view toggle, Import
|
||||
// (into the open folder) and, with a keyboard, the shortcuts.
|
||||
const readerExtras: MessageActionEntry[] = [
|
||||
// How the reader shows the message, a group of the ⋮ menu of its own. Import
|
||||
// and the keyboard shortcuts are no message's: Import is in the folder's menu,
|
||||
// Settings and the command palette, the shortcuts behind ?, the rail and Help.
|
||||
const readerViews: MessageActionEntry[] = [
|
||||
...(readAloud.supported && !isBodyLoading
|
||||
? [{ kind: 'item' as const, key: 'extra:read_aloud' as const, icon: Volume2, label: t('read_aloud'), run: readAloud.start }]
|
||||
: []),
|
||||
@@ -4323,10 +4327,6 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
...(effectiveEmailContent.isHtml
|
||||
? [{ kind: 'item' as const, key: 'extra:view_mode' as const, icon: isDark ? Sun : Moon, label: isDark ? t('view_in_light_mode') : t('view_in_dark_mode'), run: toggleViewMode }]
|
||||
: []),
|
||||
{ kind: 'item', key: 'extra:import', icon: Upload, label: tImport('menu_import'), run: handleImportEmail },
|
||||
...(onShowShortcuts && !isMobile
|
||||
? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }]
|
||||
: []),
|
||||
// Only while the toolbar has no room for its button (see toolbarShownActions).
|
||||
...(onToggleFullscreen && !isFullscreen && !mvViewer && !isMobile
|
||||
? [{ kind: 'item' as const, key: 'extra:fullscreen' as const, icon: Maximize2, label: t('fullscreen'), run: onToggleFullscreen }]
|
||||
@@ -4335,7 +4335,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
const readerActionOptions: MessageActionOptions = {
|
||||
email,
|
||||
currentMailboxRole,
|
||||
canMove: moveTree.length > 0 && !!onMoveToMailbox,
|
||||
canMove: canMoveAnywhere && !!onMoveToMailbox,
|
||||
canRemoveFromFolder: !!onRemoveFromFolder,
|
||||
onReply: onReply ? () => onReply() : undefined,
|
||||
onReplyAll,
|
||||
@@ -4356,8 +4356,12 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true),
|
||||
// Only while the toolbar has no room for its button (see toolbarShownActions).
|
||||
onOpenInWindow: onOpenInWindow && !mvViewer && !isMobile ? onOpenInWindow : undefined,
|
||||
// The phone panel's Restore; the ⋮ menu works it out from onMoveToMailbox.
|
||||
onRestore: currentMailboxRole === 'trash' && onMoveToMailbox
|
||||
? () => { if (restoreTarget) onMoveToMailbox(restoreTarget.id); }
|
||||
: undefined,
|
||||
omit: readerMenuOmit,
|
||||
extras: readerExtras.filter((entry) => entry.kind !== 'item' || !readerMenuOmit.has(entry.key)),
|
||||
views: readerViews,
|
||||
};
|
||||
|
||||
// Shared toolbar items used by both 'top' and 'below-subject' positions
|
||||
@@ -4566,7 +4570,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{deleteIsPermanent ? tActions('delete_forever') : t('delete')}</span>}
|
||||
</Button>
|
||||
{/* Move to folder */}
|
||||
{moveTree.length > 0 && onMoveToMailbox && (
|
||||
{canMoveAnywhere && onMoveToMailbox && (
|
||||
<div data-overflow-item data-overflow-priority="5" className={cn("relative", mvViewer && "order-6")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -4586,11 +4590,11 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{showToolbarLabels && <span data-toolbar-label="" className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('move')}</span>}
|
||||
</Button>
|
||||
<ContextMenu ref={moveMenu.menuRef} isOpen={moveMenuOpen} position={moveMenu.contextMenu.position} onClose={closeMoveMenu} label={t('move_to')}>
|
||||
<div className="w-56 max-h-72 overflow-y-auto">
|
||||
<MoveTargetList
|
||||
tree={moveTree}
|
||||
targetIds={moveTargetIds}
|
||||
<div className="w-64 max-w-full" data-reader-move-menu="">
|
||||
<MoveFolderList
|
||||
model={moveModel}
|
||||
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); closeMoveMenu(); }}
|
||||
onChooseFolder={chooseMoveFolder ? () => { closeMoveMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox }); } : undefined}
|
||||
/>
|
||||
</div>
|
||||
</ContextMenu>
|
||||
@@ -4933,8 +4937,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
openSub={setMoreMenuSub}
|
||||
close={closeMoreMenu}
|
||||
entryRefs={moreEntryRefs}
|
||||
moveTree={moveTree}
|
||||
moveTargetIds={moveTargetIds}
|
||||
moveModel={moveModel}
|
||||
onMoveToMailbox={onMoveToMailbox}
|
||||
currentTagIds={currentTagIds}
|
||||
sortedTagIds={sortedTagIds}
|
||||
@@ -5506,6 +5509,11 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
const fullDate = (iso?: string) => iso
|
||||
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
|
||||
: '-';
|
||||
// A long date wraps over three lines in the narrow column: the short one
|
||||
// shows, the full one is on hover.
|
||||
const shownDate = (iso?: string) => iso
|
||||
? <time dateTime={iso} title={fullDate(iso)}>{formatDateTime(iso, timeFormat, { year: 'numeric', month: 'short', day: 'numeric' })}</time>
|
||||
: '-';
|
||||
const auth = email.authenticationResults;
|
||||
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
|
||||
const topMimeType = email.bodyStructure?.type;
|
||||
@@ -5516,7 +5524,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
);
|
||||
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
|
||||
<>
|
||||
<dt className="text-muted-foreground text-xs pt-1">{label}</dt>
|
||||
<dt className="text-muted-foreground text-xs @sm:pt-1">{label}</dt>
|
||||
<dd className={cn(
|
||||
"text-sm text-foreground min-w-0 break-words",
|
||||
mono && "font-mono text-xs",
|
||||
@@ -5533,7 +5541,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
|
||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs max-w-full min-w-0",
|
||||
tooltip && "cursor-help",
|
||||
status.icon === 'check' && "bg-green-500/[0.07] border-green-500/30",
|
||||
status.icon === 'x' && "bg-red-500/[0.07] border-red-500/30",
|
||||
@@ -5543,14 +5551,14 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
title={tooltip}
|
||||
>
|
||||
<Icon className={cn("w-3.5 h-3.5 flex-shrink-0", status.color)} />
|
||||
<span className="font-medium text-foreground">{name}</span>
|
||||
<span className={cn("text-[0.6875rem]", status.color)}>
|
||||
<span className="font-medium text-foreground whitespace-nowrap">{name}</span>
|
||||
<span className={cn("text-[0.6875rem] whitespace-nowrap", status.color)}>
|
||||
{translateAuthResult(result)}
|
||||
</span>
|
||||
{extra && (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">{extra}</span>
|
||||
<span className="text-muted-foreground truncate min-w-0" title={typeof extra === 'string' ? extra : undefined}>{extra}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -5571,17 +5579,24 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-10 gap-y-5">
|
||||
<div className="@container bg-background border-b border-border px-4 lg:px-6" style={{ paddingBlock: 'var(--density-header-py)' }}>
|
||||
<div className="grid grid-cols-1 @3xl:grid-cols-2 gap-x-10 gap-y-5">
|
||||
<section className="min-w-0">
|
||||
<SectionHeader>{t('details.recipients_routing')}</SectionHeader>
|
||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
|
||||
<Row label={t('from')}>
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<RecipientPopover
|
||||
name={sender?.name}
|
||||
email={sender?.email || ''}
|
||||
displayLabel={sender?.name && sender?.email ? `${sender.name} <${sender.email}>` : undefined}
|
||||
label={sender?.name && sender?.email ? (
|
||||
<>
|
||||
<bdi className="block">{sender.name}</bdi>
|
||||
<bdi className="block text-muted-foreground" title={sender.email}>
|
||||
<BreakableAddress text={sender.email} />
|
||||
</bdi>
|
||||
</>
|
||||
) : undefined}
|
||||
onViewContact={handleViewContactSidebar}
|
||||
className="text-sm text-start"
|
||||
/>
|
||||
@@ -5635,10 +5650,10 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
</Row>
|
||||
)}
|
||||
{email.sentAt && (
|
||||
<Row label={t('details.sent')}>{fullDate(email.sentAt)}</Row>
|
||||
<Row label={t('details.sent')}>{shownDate(email.sentAt)}</Row>
|
||||
)}
|
||||
<Row label={t('details.received')}>
|
||||
{fullDate(email.receivedAt)}
|
||||
{shownDate(email.receivedAt)}
|
||||
{deliveryDeltaMs > 60000 && (
|
||||
<span className="text-muted-foreground"> · {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}</span>
|
||||
)}
|
||||
@@ -5683,7 +5698,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
)}
|
||||
{email.spamScore !== undefined && (
|
||||
<span className={cn(
|
||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs",
|
||||
"inline-flex items-center gap-1.5 px-2 py-1 rounded-md border text-xs max-w-full min-w-0",
|
||||
email.spamScore > 5 ? "bg-red-500/[0.07] border-red-500/30" :
|
||||
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
|
||||
"bg-green-500/[0.07] border-green-500/30",
|
||||
@@ -5706,7 +5721,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{email.spamStatus && (
|
||||
<>
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="text-muted-foreground">{email.spamStatus}</span>
|
||||
<span className="text-muted-foreground truncate min-w-0" title={email.spamStatus}>{email.spamStatus}</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
@@ -5737,9 +5752,9 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{hasIdentifiers && (
|
||||
<section className="min-w-0">
|
||||
<SectionHeader>{t('details.identifiers_threading')}</SectionHeader>
|
||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
|
||||
{email.messageId && (
|
||||
<Row label={t('headers.message_id')} mono>{email.messageId}</Row>
|
||||
<Row label={t('headers.message_id')} mono><BreakableAddress text={email.messageId} /></Row>
|
||||
)}
|
||||
{email.inReplyTo && email.inReplyTo.length > 0 && (
|
||||
<Row label={t('details.in_reply_to')} mono>
|
||||
@@ -5771,7 +5786,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
|
||||
<section className="min-w-0">
|
||||
<SectionHeader>{t('details.message_properties')}</SectionHeader>
|
||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
|
||||
{email.subject !== undefined && (
|
||||
<Row label={t('subject')}>{email.subject || <span className="italic text-muted-foreground">{t('details.no_subject')}</span>}</Row>
|
||||
)}
|
||||
@@ -5794,9 +5809,9 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
</section>
|
||||
|
||||
{hasListInfo && (
|
||||
<section className="lg:col-span-2 min-w-0">
|
||||
<section className="@3xl:col-span-2 min-w-0">
|
||||
<SectionHeader>{t('details.mailing_list')}</SectionHeader>
|
||||
<dl className="grid grid-cols-[7rem_1fr] gap-x-4 gap-y-1.5">
|
||||
<dl className="grid grid-cols-1 @sm:grid-cols-[minmax(4.5rem,7rem)_minmax(0,1fr)] gap-x-4 gap-y-1.5">
|
||||
{listHeaders?.listId && (
|
||||
<Row label={t('details.list_id')} mono>{listHeaders.listId}</Row>
|
||||
)}
|
||||
@@ -5823,7 +5838,7 @@ ${printWatermark ? printWatermark.html : ''}
|
||||
{/* Plugin-supplied category. Plugins whose shouldShow accepts
|
||||
`category === null` render their own titled section here. */}
|
||||
{hasDetailsSlotOffers && (
|
||||
<section className="lg:col-span-2 min-w-0">
|
||||
<section className="@3xl:col-span-2 min-w-0">
|
||||
<DetailsCategorySlot email={pluginEmailView} category={null} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { MessageActionKey } from "./message-actions";
|
||||
|
||||
/**
|
||||
* The keys the mail list runs a message's actions with (hooks/use-keyboard-shortcuts.ts),
|
||||
* as the message menus show them next to the entries. The native Message menu shows the
|
||||
* same ones. A single key goes when the user turned single-key shortcuts off, since it
|
||||
* does nothing then.
|
||||
*/
|
||||
const KEYS: Partial<Record<MessageActionKey, { key: string; single: boolean }>> = {
|
||||
reply: { key: "R", single: true },
|
||||
reply_all: { key: "Shift + R", single: true },
|
||||
forward: { key: "F", single: true },
|
||||
archive: { key: "E", single: true },
|
||||
delete: { key: "Del", single: false },
|
||||
star: { key: "S", single: true },
|
||||
spam: { key: "!", single: true },
|
||||
};
|
||||
|
||||
export function messageActionShortcut(
|
||||
key: MessageActionKey,
|
||||
options: { unread?: boolean; singleKeys: boolean },
|
||||
): string | undefined {
|
||||
const entry = key === "read"
|
||||
? { key: options.unread ? "Shift + I" : "U", single: true }
|
||||
: KEYS[key];
|
||||
if (!entry) return undefined;
|
||||
if (entry.single && !options.singleKeys) return undefined;
|
||||
return entry.key;
|
||||
}
|
||||
|
||||
/** `messageActionShortcut` with the user's single-key setting. */
|
||||
export function useMessageActionShortcut(): (key: MessageActionKey, unread?: boolean) => string | undefined {
|
||||
const singleKeys = useSettingsStore((state) => state.singleKeyShortcuts !== false);
|
||||
return useCallback((key, unread) => messageActionShortcut(key, { unread, singleKeys }), [singleKeys]);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -252,12 +252,14 @@ interface RecipientPopoverProps {
|
||||
email: string;
|
||||
/** Display label override (e.g. "me") */
|
||||
displayLabel?: string;
|
||||
/** Rich content in place of the text label, such as a name over its address. */
|
||||
label?: React.ReactNode;
|
||||
/** Called when user clicks "View contact" - receives the contact and email */
|
||||
onViewContact?: (contact: ContactCard | null, email: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) {
|
||||
export function RecipientPopover({ name, email, displayLabel, label, onViewContact, className }: RecipientPopoverProps) {
|
||||
const [anchor, setAnchor] = useState<CardAnchor | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const shownEmail = useOwnDomainAddress()(email);
|
||||
@@ -286,7 +288,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
className
|
||||
)}
|
||||
>
|
||||
<bdi>{displayLabel || name || shownEmail}</bdi>
|
||||
{label ?? <bdi>{displayLabel || name || shownEmail}</bdi>}
|
||||
</button>
|
||||
|
||||
{anchor && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useCallback, useLayoutEffect, useState, useRef } from "react";
|
||||
import { popoverShift } from "@/lib/popover-shift";
|
||||
import { popoverFitHeight, popoverShift } from "@/lib/popover-shift";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useEditor, useEditorState, EditorContent, type Editor } from "@tiptap/react";
|
||||
import type { SuggestionProps } from "@tiptap/suggestion";
|
||||
@@ -251,6 +251,23 @@ function ToolbarPopover({
|
||||
right: Math.min(box.right, window.innerWidth),
|
||||
});
|
||||
if (shift !== 0) el.style.transform = `translateX(${shift}px)`;
|
||||
// Too tall for the room under its button (a short window, 150 % zoom): above
|
||||
// it when there's more room there, and scrolling inside either way.
|
||||
const trigger = triggerRef.current?.getBoundingClientRect();
|
||||
const rect = el.getBoundingClientRect();
|
||||
const fit = trigger ? popoverFitHeight({ top: rect.top, height: rect.height }, trigger, window.innerHeight) : null;
|
||||
if (fit) {
|
||||
if (fit.above) {
|
||||
el.style.top = "auto";
|
||||
el.style.bottom = "100%";
|
||||
el.style.marginTop = "0";
|
||||
el.style.marginBottom = "0.25rem";
|
||||
}
|
||||
el.style.maxHeight = `${fit.maxHeight}px`;
|
||||
el.style.overflowY = "auto";
|
||||
}
|
||||
// triggerRef is a ref: it doesn't change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const close = (returnFocus: boolean) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -692,6 +692,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
onDoubleClick();
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
data-menu-row=""
|
||||
style={{ minHeight: oneLineRow ? undefined : 'var(--list-item-height)', touchAction: swipeEnabled ? 'pan-y' : undefined }}
|
||||
>
|
||||
{showRowActionButton && onContextMenu && (
|
||||
@@ -1402,6 +1403,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
onEmailDoubleClick(latestEmail);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
data-menu-row=""
|
||||
style={{ minHeight: oneLineRow ? undefined : 'var(--list-item-height)', touchAction: threadSwipeEnabled ? 'pan-y' : undefined }}
|
||||
>
|
||||
{showRowActionButton && onContextMenu && (
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// The overlay on every screen: what shows for a drag from outside, where a drop goes, and
|
||||
// the same choices as a dialog for a pasted file or one opened with the desktop app.
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
push: vi.fn(),
|
||||
pathname: { current: '/' },
|
||||
toastError: vi.fn(),
|
||||
toastInfo: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
locked: { current: false },
|
||||
flushComposers: vi.fn(async () => ({ total: 0, settled: 0, failed: 0, timedOut: false })),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
|
||||
}));
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
useRouter: () => ({ push: hoisted.push, replace: vi.fn(), back: vi.fn() }),
|
||||
usePathname: () => hoisted.pathname.current,
|
||||
}));
|
||||
vi.mock('@/stores/toast-store', () => ({ toast: { error: hoisted.toastError, info: hoisted.toastInfo, success: hoisted.toastSuccess } }));
|
||||
vi.mock('@/lib/compose-flush-registry', () => ({ flushComposers: hoisted.flushComposers }));
|
||||
vi.mock('@/lib/desktop/app-lock-state', () => ({ isAppLocked: () => hoisted.locked.current }));
|
||||
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { clearHandoffs, takeHandoff } = await import('@/lib/launch-handoff');
|
||||
const { setComposerFileTarget, setOwnDragActive } = await import('@/lib/desktop/window-files');
|
||||
const { setFilesDropTarget, takeQueuedFilesUpload } = await import('@/lib/file-drop/files-target');
|
||||
const { requestFileChoice } = await import('@/lib/file-drop/choice');
|
||||
const { FileDropHost } = await import('../file-drop-host');
|
||||
|
||||
interface FakeClient {
|
||||
supportsFiles: () => boolean;
|
||||
supportsContacts: () => boolean;
|
||||
getMaxSizeUpload: () => number;
|
||||
getMaxSizeAttachmentsPerEmail: () => number;
|
||||
}
|
||||
|
||||
function signIn(client: Partial<FakeClient> = {}) {
|
||||
useAuthStore.setState({
|
||||
isAuthenticated: true,
|
||||
activeAccountId: null,
|
||||
client: {
|
||||
supportsFiles: () => true,
|
||||
supportsContacts: () => false,
|
||||
getMaxSizeUpload: () => 0,
|
||||
getMaxSizeAttachmentsPerEmail: () => 0,
|
||||
...client,
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
function transfer(files: File[]) {
|
||||
return {
|
||||
types: ['Files'],
|
||||
files,
|
||||
items: files.map((file) => ({ kind: 'file', type: file.type, getAsFile: () => file, webkitGetAsEntry: () => ({ isFile: true, isDirectory: false }) })),
|
||||
dropEffect: 'none',
|
||||
};
|
||||
}
|
||||
|
||||
/** A drag from outside reaching the window, as the browser sends it. */
|
||||
function dragIn(files: File[]) {
|
||||
const event = new Event('dragenter', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'dataTransfer', { value: transfer(files) });
|
||||
act(() => {
|
||||
document.body.dispatchEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
function zone(id: string): HTMLElement {
|
||||
const found = document.querySelector<HTMLElement>(`[data-testid="file-drop-overlay"] [data-drop-zone="${id}"]`);
|
||||
if (!found) throw new Error(`no zone ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
const flush = () => act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
const file = new File(['hello'], 'report.pdf', { type: 'application/pdf' });
|
||||
|
||||
beforeEach(() => {
|
||||
clearHandoffs();
|
||||
takeHandoff('compose');
|
||||
takeQueuedFilesUpload();
|
||||
hoisted.locked.current = false;
|
||||
hoisted.pathname.current = '/';
|
||||
vi.clearAllMocks();
|
||||
signIn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setOwnDragActive(false);
|
||||
useAuthStore.setState({ isAuthenticated: false, client: null });
|
||||
});
|
||||
|
||||
describe('a drag from outside', () => {
|
||||
it('covers the window with the two main choices and says how many files', () => {
|
||||
render(<FileDropHost />);
|
||||
dragIn([file, file]);
|
||||
expect(screen.getByTestId('file-drop-overlay')).toBeTruthy();
|
||||
expect(zone('new-message').textContent).toContain('zone_new_message');
|
||||
expect(zone('upload').textContent).toContain('zone_upload_files');
|
||||
expect(screen.getByTestId('file-drop-count').textContent).toBe('files_summary:{"count":2}');
|
||||
});
|
||||
|
||||
it('a drop on "Attach to a new message" starts a draft with the file', async () => {
|
||||
render(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
fireEvent.drop(zone('new-message'), { dataTransfer: transfer([file]) });
|
||||
await flush();
|
||||
expect(takeHandoff('compose')?.files).toEqual([file]);
|
||||
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('with a composer on screen, its zone comes first and takes the file', async () => {
|
||||
const take = vi.fn();
|
||||
const clear = setComposerFileTarget(take, () => true);
|
||||
render(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
const zones = Array.from(document.querySelectorAll('[data-drop-zone]')).map((element) => element.getAttribute('data-drop-zone'));
|
||||
expect(zones[0]).toBe('attach-here');
|
||||
fireEvent.drop(zone('attach-here'), { dataTransfer: transfer([file]) });
|
||||
await flush();
|
||||
expect(take).toHaveBeenCalledWith([file]);
|
||||
expect(takeHandoff('compose')).toBeNull();
|
||||
clear();
|
||||
});
|
||||
|
||||
it('a picture dropped on "Insert into the message" goes into the text, as a paste would', async () => {
|
||||
const take = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const clear = setComposerFileTarget(take, () => true, { available: () => true, insert });
|
||||
const picture = new File(['png'], 'chart.png', { type: 'image/png' });
|
||||
render(<FileDropHost />);
|
||||
dragIn([picture]);
|
||||
expect(zone('insert-inline').textContent).toContain('zone_insert_inline');
|
||||
fireEvent.drop(zone('insert-inline'), { dataTransfer: transfer([picture]) });
|
||||
await flush();
|
||||
expect(insert).toHaveBeenCalledWith([picture]);
|
||||
expect(take).not.toHaveBeenCalled();
|
||||
clear();
|
||||
});
|
||||
|
||||
it('on the Files screen, "Upload to <folder>" hands the files to that folder', async () => {
|
||||
const take = vi.fn();
|
||||
const clear = setFilesDropTarget({ folder: 'Reports', take, visible: () => true });
|
||||
render(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
expect(zone('upload').textContent).toContain('zone_upload_folder:{"folder":"Reports"}');
|
||||
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
|
||||
await flush();
|
||||
expect(take).toHaveBeenCalledWith({ files: [file], asFolder: false });
|
||||
clear();
|
||||
});
|
||||
|
||||
it('from another screen, Files gets the files after the app goes there', async () => {
|
||||
render(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
|
||||
await flush();
|
||||
expect(hoisted.push).toHaveBeenCalledWith('/files');
|
||||
expect(takeQueuedFilesUpload()).toEqual({ files: [file], asFolder: false });
|
||||
});
|
||||
|
||||
it('a drop beside the zones does nothing', async () => {
|
||||
render(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
fireEvent.drop(screen.getByTestId('file-drop-overlay'), { dataTransfer: transfer([file]) });
|
||||
await flush();
|
||||
expect(takeHandoff('compose')).toBeNull();
|
||||
expect(takeQueuedFilesUpload()).toBeNull();
|
||||
});
|
||||
|
||||
it('a file over the server limit is named and left out before anything starts', async () => {
|
||||
signIn({ getMaxSizeUpload: () => 3 });
|
||||
render(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
expect(zone('upload').textContent).toContain('note_limit_file');
|
||||
fireEvent.drop(zone('upload'), { dataTransfer: transfer([file]) });
|
||||
await flush();
|
||||
expect(hoisted.toastError).toHaveBeenCalledWith(expect.stringContaining('too_large_file'));
|
||||
expect(takeQueuedFilesUpload()).toBeNull();
|
||||
});
|
||||
|
||||
it('the app\'s own drags never bring it up', () => {
|
||||
render(<FileDropHost />);
|
||||
act(() => {
|
||||
document.body.dispatchEvent(new Event('dragstart', { bubbles: true }));
|
||||
});
|
||||
dragIn([file]);
|
||||
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
|
||||
act(() => {
|
||||
document.body.dispatchEvent(new Event('pointermove', { bubbles: true }));
|
||||
});
|
||||
setOwnDragActive(true);
|
||||
dragIn([file]);
|
||||
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('nothing while the app is locked, signed out, or behind a dialog of its own', () => {
|
||||
render(<FileDropHost />);
|
||||
hoisted.locked.current = true;
|
||||
dragIn([file]);
|
||||
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
|
||||
hoisted.locked.current = false;
|
||||
const dialog = document.body.appendChild(document.createElement('div'));
|
||||
dialog.setAttribute('aria-modal', 'true');
|
||||
dragIn([file]);
|
||||
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
|
||||
dialog.remove();
|
||||
useAuthStore.setState({ isAuthenticated: false });
|
||||
dragIn([file]);
|
||||
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('leaves Files out on a server without it', () => {
|
||||
signIn({ supportsFiles: () => false });
|
||||
render(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
expect(document.querySelector('[data-drop-zone="upload"]')).toBeNull();
|
||||
expect(zone('new-message')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('dragging out of the window takes it away', () => {
|
||||
render(<FileDropHost />);
|
||||
dragIn([file]);
|
||||
const leave = new Event('dragleave', { bubbles: true });
|
||||
Object.defineProperty(leave, 'relatedTarget', { value: null });
|
||||
act(() => {
|
||||
screen.getByTestId('file-drop-overlay').dispatchEvent(leave);
|
||||
});
|
||||
expect(screen.queryByTestId('file-drop-overlay')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the same choices without a drag', () => {
|
||||
function paste(target: EventTarget, files: File[], types = ['Files']) {
|
||||
const event = new Event('paste', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'clipboardData', { value: { files, types } });
|
||||
act(() => {
|
||||
target.dispatchEvent(event);
|
||||
});
|
||||
return event;
|
||||
}
|
||||
|
||||
it('a file pasted outside a text field asks, with the focus on a new message, and Enter takes it', async () => {
|
||||
render(<FileDropHost />);
|
||||
expect(paste(document.body, [file]).defaultPrevented).toBe(true);
|
||||
const dialog = await screen.findByTestId('file-drop-choice');
|
||||
await waitFor(() => expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('new-message'));
|
||||
expect(dialog.querySelector('[data-drop-zone="upload"]')).toBeTruthy();
|
||||
fireEvent.click(document.activeElement as HTMLElement);
|
||||
await flush();
|
||||
expect(takeHandoff('compose')?.files).toEqual([file]);
|
||||
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
|
||||
});
|
||||
|
||||
it('arrow keys move between the choices', async () => {
|
||||
render(<FileDropHost />);
|
||||
paste(document.body, [file]);
|
||||
await screen.findByTestId('file-drop-choice');
|
||||
await waitFor(() => expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('new-message'));
|
||||
fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowRight' });
|
||||
expect(document.activeElement?.getAttribute('data-drop-zone')).toBe('upload');
|
||||
});
|
||||
|
||||
it('with only one thing to do, it does it without asking', async () => {
|
||||
signIn({ supportsFiles: () => false });
|
||||
render(<FileDropHost />);
|
||||
paste(document.body, [file]);
|
||||
await flush();
|
||||
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
|
||||
expect(takeHandoff('compose')?.files).toEqual([file]);
|
||||
});
|
||||
|
||||
it('pasted text, or a file in a search field, is left alone', () => {
|
||||
render(<FileDropHost />);
|
||||
expect(paste(document.body, [file], ['text/plain', 'Files']).defaultPrevented).toBe(false);
|
||||
const input = document.body.appendChild(document.createElement('input'));
|
||||
expect(paste(input, [file]).defaultPrevented).toBe(false);
|
||||
input.remove();
|
||||
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
|
||||
});
|
||||
|
||||
it('a bare file pasted into a composer on screen attaches to it', () => {
|
||||
const take = vi.fn();
|
||||
const clear = setComposerFileTarget(take, () => true);
|
||||
render(<FileDropHost />);
|
||||
const composer = document.body.appendChild(document.createElement('div'));
|
||||
composer.setAttribute('data-testid', 'email-composer');
|
||||
const body = composer.appendChild(document.createElement('div'));
|
||||
body.setAttribute('contenteditable', 'true');
|
||||
paste(body, [file]);
|
||||
expect(take).toHaveBeenCalledWith([file]);
|
||||
composer.remove();
|
||||
clear();
|
||||
});
|
||||
|
||||
it('a file opened with the desktop app gets the dialog, and waits for a sign-in', async () => {
|
||||
useAuthStore.setState({ isAuthenticated: false });
|
||||
render(<FileDropHost />);
|
||||
act(() => requestFileChoice({ files: [file], preferred: 'new-message' }));
|
||||
expect(screen.queryByTestId('file-drop-choice')).toBeNull();
|
||||
act(() => signIn());
|
||||
expect(await screen.findByTestId('file-drop-choice')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -222,7 +222,9 @@ export function FileBrowser({
|
||||
onShare,
|
||||
}: FileBrowserProps) {
|
||||
const t = useTranslations("files");
|
||||
const newFileLabel = newFileFormats.length > 0 ? t("new_file") : t("new_text_file");
|
||||
const tMenu = useTranslations("menuAudit");
|
||||
// Menu entries that open a dialog end in "…".
|
||||
const newFileLabel = newFileFormats.length > 0 ? tMenu("files_new_file") : tMenu("files_new_text_file");
|
||||
const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]);
|
||||
const validateFileName = useCallback((name: string) => {
|
||||
const problem = fileNameProblem(name, fileNameRules);
|
||||
@@ -952,7 +954,7 @@ export function FileBrowser({
|
||||
)}
|
||||
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
|
||||
{canShare(resource) && (
|
||||
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
|
||||
<ContextMenuItem icon={Share2} label={tMenu("files_share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
|
||||
)}
|
||||
{resource.isDirectory && !resource.isShared && (
|
||||
<ContextMenuItem
|
||||
@@ -993,10 +995,10 @@ export function FileBrowser({
|
||||
|
||||
const newItems = (
|
||||
<>
|
||||
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => { closeMenu(); fileInputRef.current?.click(); }} />
|
||||
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => { closeMenu(); folderInputRef.current?.click(); }} />
|
||||
<ContextMenuItem icon={Upload} label={tMenu("files_upload_files")} onClick={() => { closeMenu(); fileInputRef.current?.click(); }} />
|
||||
<ContextMenuItem icon={FolderUp} label={tMenu("files_upload_folder")} onClick={() => { closeMenu(); folderInputRef.current?.click(); }} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => { closeMenu(); setShowNewFolder(true); }} />
|
||||
<ContextMenuItem icon={FolderPlus} label={tMenu("files_new_folder")} onClick={() => { closeMenu(); setShowNewFolder(true); }} />
|
||||
<ContextMenuItem icon={FilePlus} label={newFileLabel} onClick={() => { closeMenu(); setShowNewTextFile(true); }} />
|
||||
</>
|
||||
);
|
||||
@@ -1036,9 +1038,9 @@ export function FileBrowser({
|
||||
const tShell = useTranslations("shell");
|
||||
const isEmbedded = useIsEmbedded();
|
||||
useRegisterCommands(tSidebar("files"), [
|
||||
{ id: "upload-files", label: t("upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
|
||||
{ id: "upload-folder", label: t("upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
|
||||
{ id: "new-folder", label: t("new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
|
||||
{ id: "upload-files", label: tMenu("files_upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
|
||||
{ id: "upload-folder", label: tMenu("files_upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
|
||||
{ id: "new-folder", label: tMenu("files_new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
|
||||
{ id: "new-text-file", label: newFileLabel, icon: FilePlus, run: () => setShowNewTextFile(true), when: () => canCreate },
|
||||
{ id: "trash", label: t("trash"), icon: Trash2, run: () => onNavigate(TRASH_PATH, TRASH_LOCATION), when: () => !inTrash && !accountPickerMode },
|
||||
{ id: "empty-trash", label: t("empty_trash"), icon: Trash2, run: () => onEmptyTrash?.(), when: () => inTrash && trashCount > 0 && !!onEmptyTrash },
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import { FolderRulesSubMenu } from "@/components/email/rules-menu";
|
||||
import { openMailExport } from "@/stores/mail-transfer-store";
|
||||
import { useDevicePolicy } from "@/hooks/use-device-policy";
|
||||
import { useConnectionState } from "@/hooks/use-connection-state";
|
||||
|
||||
interface Position {
|
||||
x: number;
|
||||
@@ -46,6 +47,20 @@ export type MailboxContextTarget =
|
||||
| { kind: "folders-section"; accountId?: string };
|
||||
|
||||
const PATH_SEPARATOR = " › ";
|
||||
|
||||
/** Unread messages in a folder and the folders under it; undefined where a count isn't known. */
|
||||
export function unreadInTree(mailbox: Mailbox, mailboxes: Mailbox[]): number | undefined {
|
||||
let total = 0;
|
||||
const seen = new Set<string>();
|
||||
const visit = (box: Mailbox): boolean => {
|
||||
if (seen.has(box.id)) return true;
|
||||
seen.add(box.id);
|
||||
if (typeof box.unreadEmails !== "number") return false;
|
||||
total += box.unreadEmails;
|
||||
return mailboxes.filter((child) => child.parentId === box.id).every(visit);
|
||||
};
|
||||
return visit(mailbox) ? total : undefined;
|
||||
}
|
||||
const MAX_PATH_LENGTH = 40;
|
||||
const MAX_SEGMENT_LENGTH = 16;
|
||||
|
||||
@@ -149,6 +164,11 @@ export function MailboxContextMenu({
|
||||
const tExport = useTranslations("mail_export");
|
||||
// Organisation > Devices > No export hides it; import stays.
|
||||
const exportOff = useDevicePolicy().noExport;
|
||||
// Folders, rules and sharing need the server. Marking read waits in the outbox
|
||||
// where the account keeps its mail on this device.
|
||||
const { offline, canChangeMail } = useConnectionState();
|
||||
const tMenu = useTranslations("menuAudit");
|
||||
const offlineHeader = offline ? <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader> : null;
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
@@ -200,13 +220,14 @@ export function MailboxContextMenu({
|
||||
if (target.kind === "folders-section") {
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
{offlineHeader}
|
||||
{!target.accountId && (
|
||||
<>
|
||||
<ContextMenuItem
|
||||
icon={CheckCheck}
|
||||
label={t("mark_all_folders_read")}
|
||||
onClick={() => handleAction(onMarkAllFoldersRead!)}
|
||||
disabled={!onMarkAllFoldersRead}
|
||||
disabled={!onMarkAllFoldersRead || !canChangeMail}
|
||||
/>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
@@ -215,14 +236,14 @@ export function MailboxContextMenu({
|
||||
icon={FolderPlus}
|
||||
label={t("new_folder")}
|
||||
onClick={() => handleAction(() => onCreateFolder?.(target.accountId))}
|
||||
disabled={!onCreateFolder}
|
||||
disabled={!onCreateFolder || offline}
|
||||
testId="mailbox-new-folder"
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={RefreshCw}
|
||||
label={t("refresh")}
|
||||
onClick={() => handleAction(onRefresh!)}
|
||||
disabled={!onRefresh}
|
||||
disabled={!onRefresh || offline}
|
||||
/>
|
||||
{accountItems(target.accountId)}
|
||||
</ContextMenu>
|
||||
@@ -240,6 +261,8 @@ export function MailboxContextMenu({
|
||||
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
|
||||
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
|
||||
const canAddItems = mailbox.myRights?.mayAddItems !== false;
|
||||
const nothingUnread = mailbox.unreadEmails === 0;
|
||||
const nothingUnreadBelow = unreadInTree(mailbox, mailboxes) === 0;
|
||||
// Sharing needs the mail:share extension (handler present) and, on a folder
|
||||
// someone else shared with us, their permission to re-share it.
|
||||
const canShare = !!onShareFolder && (!mailbox.isShared || mailbox.myRights?.mayShare === true);
|
||||
@@ -256,19 +279,20 @@ export function MailboxContextMenu({
|
||||
<ContextMenuHeader>
|
||||
<span title={fullPath}>{renderShortenedPath(fullPath)}</span>
|
||||
</ContextMenuHeader>
|
||||
{offlineHeader}
|
||||
|
||||
<ContextMenuItem
|
||||
icon={MailOpen}
|
||||
label={t("mark_folder_read")}
|
||||
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
|
||||
disabled={!onMarkFolderRead || !canSetSeen}
|
||||
disabled={!onMarkFolderRead || !canSetSeen || !canChangeMail || nothingUnread}
|
||||
/>
|
||||
{target.hasChildren && (
|
||||
<ContextMenuItem
|
||||
icon={Mails}
|
||||
label={t("mark_folder_tree_read")}
|
||||
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
|
||||
disabled={!onMarkFolderTreeRead || !canSetSeen}
|
||||
disabled={!onMarkFolderTreeRead || !canSetSeen || !canChangeMail || nothingUnreadBelow}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -278,14 +302,14 @@ export function MailboxContextMenu({
|
||||
icon={FolderPlus}
|
||||
label={t("new_subfolder")}
|
||||
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
|
||||
disabled={!onCreateSubfolder || !canCreateChild}
|
||||
disabled={!onCreateSubfolder || !canCreateChild || offline}
|
||||
testId="mailbox-new-subfolder"
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Pencil}
|
||||
label={t("rename")}
|
||||
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
|
||||
disabled={!onRenameFolder || !canRename}
|
||||
disabled={!onRenameFolder || !canRename || offline}
|
||||
testId="mailbox-rename"
|
||||
/>
|
||||
{onShareFolder && (
|
||||
@@ -293,11 +317,11 @@ export function MailboxContextMenu({
|
||||
icon={Users}
|
||||
label={t("share")}
|
||||
onClick={() => handleAction(() => onShareFolder(mailbox.id))}
|
||||
disabled={!canShare}
|
||||
disabled={!canShare || offline}
|
||||
testId="mailbox-share"
|
||||
/>
|
||||
)}
|
||||
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} />
|
||||
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} disabled={offline} />
|
||||
{notifications && (() => {
|
||||
const muted = notifications.isMailboxMuted(mailbox);
|
||||
return (
|
||||
@@ -317,7 +341,7 @@ export function MailboxContextMenu({
|
||||
icon={FolderPlus}
|
||||
label={t("new_folder")}
|
||||
onClick={() => handleAction(() => onCreateFolder?.(target.sharedAccountId))}
|
||||
disabled={!onCreateFolder}
|
||||
disabled={!onCreateFolder || offline}
|
||||
testId="mailbox-new-folder"
|
||||
/>
|
||||
{accountItems(target.sharedAccountId)}
|
||||
@@ -330,7 +354,7 @@ export function MailboxContextMenu({
|
||||
icon={Upload}
|
||||
label={tImport("menu_import")}
|
||||
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
|
||||
disabled={!onImportEmail || !canAddItems}
|
||||
disabled={!onImportEmail || !canAddItems || offline}
|
||||
/>
|
||||
{!exportOff && (
|
||||
<ContextMenuItem
|
||||
@@ -345,7 +369,7 @@ export function MailboxContextMenu({
|
||||
icon={RefreshCw}
|
||||
label={t("refresh")}
|
||||
onClick={() => handleAction(onRefresh!)}
|
||||
disabled={!onRefresh}
|
||||
disabled={!onRefresh || offline}
|
||||
/>
|
||||
|
||||
{/* Destructive entries last (R6). */}
|
||||
@@ -355,14 +379,14 @@ export function MailboxContextMenu({
|
||||
icon={FolderX}
|
||||
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
|
||||
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
|
||||
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
|
||||
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline}
|
||||
destructive
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={Trash2}
|
||||
label={t("delete_folder")}
|
||||
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
|
||||
disabled={!onDeleteFolder || !canDelete}
|
||||
disabled={!onDeleteFolder || !canDelete || offline}
|
||||
destructive
|
||||
testId="mailbox-delete"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -351,7 +351,6 @@ interface SidebarRowProps {
|
||||
isCollapsed: boolean;
|
||||
dropHandlers?: Record<string, unknown>;
|
||||
isValidDropTarget?: boolean;
|
||||
isInvalidDropTarget?: boolean;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
/** New mail here does not notify (#1169). */
|
||||
muted?: boolean;
|
||||
@@ -378,7 +377,6 @@ function SidebarRow({
|
||||
isCollapsed,
|
||||
dropHandlers,
|
||||
isValidDropTarget,
|
||||
isInvalidDropTarget,
|
||||
onContextMenu,
|
||||
muted = false,
|
||||
testRole,
|
||||
@@ -448,8 +446,9 @@ function SidebarRow({
|
||||
: isSelected
|
||||
? "bg-accent text-accent-foreground font-semibold border-s-2 border-primary"
|
||||
: "hover:bg-muted/50 text-foreground border-s-2 border-transparent",
|
||||
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
|
||||
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
|
||||
// Only a folder that takes the drop lights up. Elsewhere the pointer
|
||||
// says no (dropEffect none), as in a native mail client.
|
||||
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset"
|
||||
)}
|
||||
>
|
||||
{!isCollapsed && (
|
||||
@@ -732,7 +731,7 @@ function MailboxTreeItem({
|
||||
: undefined;
|
||||
|
||||
const { isDragging: globalDragging } = useDragDropContext();
|
||||
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
|
||||
const { dropHandlers, isValidDropTarget } = useMailboxDrop({
|
||||
mailbox: node,
|
||||
accountId: dragAccountId ?? undefined,
|
||||
onSuccess: (count, mailboxName, restore, receipts) => {
|
||||
@@ -777,7 +776,6 @@ function MailboxTreeItem({
|
||||
: undefined
|
||||
}
|
||||
isValidDropTarget={isValidDropTarget && !pickOnly}
|
||||
isInvalidDropTarget={isInvalidDropTarget && !pickOnly}
|
||||
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
|
||||
muted={muted}
|
||||
/>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -65,7 +65,8 @@ import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
|
||||
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
|
||||
import { useDesktopCommands } from "@/lib/desktop/commands";
|
||||
import { useDesktopPrefs } from "@/lib/desktop/preferences";
|
||||
import { hasDesktopWindows } from "@/lib/popout/support";
|
||||
import { hasDesktopWindows, popoutsAvailable } from "@/lib/popout/support";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { composerDataFromDraft } from "@/lib/draft-composer-data";
|
||||
import { useUndoShortcut } from "@/hooks/use-undo-shortcut";
|
||||
import { confirmMailRemoval } from "@/lib/mail-removal-confirm";
|
||||
@@ -106,7 +107,8 @@ import type { Attachment, SendEmailResult } from "@/lib/jmap/types";
|
||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { peekListAttachments, requestListAttachments, type ListAttachmentSource, type LoadListAttachments } from "@/lib/list-attachments";
|
||||
import { useSearchHistoryStore } from "@/stores/search-history-store";
|
||||
import { WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
import { useWelcomeBannerDue, WelcomeBanner } from "@/components/ui/welcome-banner";
|
||||
import { FirstStartNoticeBar } from "@/components/ui/first-start-notice";
|
||||
import { NavigationRail } from "@/components/layout/navigation-rail";
|
||||
import { AppMain } from "@/components/layout/app-landmarks";
|
||||
import { MvTopBar } from "@/components/layout/mv-top-bar";
|
||||
@@ -139,9 +141,9 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading";
|
||||
import { openMailImport } from "@/stores/mail-transfer-store";
|
||||
import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity";
|
||||
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
|
||||
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons";
|
||||
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons";
|
||||
import { useRegisterCommands } from "@/lib/command-registry";
|
||||
import { MoveToDialog } from "@/components/email/move-to-dialog";
|
||||
import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog";
|
||||
import { SearchResultCount } from "@/components/email/search-result-count";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -257,6 +259,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
// Static Lite build: the route params are empty, read the link from the URL.
|
||||
const linkSegments = useLiteLinkSegments('mail', routeSegments);
|
||||
const t = useTranslations();
|
||||
// First-start notices wait until the welcome tips are closed: one thing at a time.
|
||||
const welcomeDue = useWelcomeBannerDue();
|
||||
// The page skeleton the gates below draw: the same one the server sent.
|
||||
const skeletonLabels = useSkeletonLabels();
|
||||
const tNotifications = useTranslations('notifications');
|
||||
@@ -1359,6 +1363,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const desktopSingle = !showComposer && !!selectedEmail;
|
||||
const mailLayoutSetting = useSettingsStore((s) => s.mailLayout);
|
||||
const layoutFixed = interfaceLayout === 'mountain-view';
|
||||
// A shared folder you may only read from: nothing can leave it.
|
||||
const desktopMayRemove = mailboxes.find((m) => m.id === selectedMailbox)?.myRights?.mayRemoveItems !== false;
|
||||
useDesktopCommands({
|
||||
'file.newMessage': keyboardHandlers.onCompose,
|
||||
'edit.search': keyboardHandlers.onFocusSearch,
|
||||
@@ -1385,6 +1391,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
'go.nextUnread': keyboardHandlers.onNextUnread,
|
||||
}, {
|
||||
'view.refresh': true,
|
||||
// Ctrl+E in the composer belongs to its editor, not to search.
|
||||
'edit.search': !showComposer,
|
||||
'view.layout.split': { enabled: !layoutFixed, checked: mailLayoutSetting === 'split' },
|
||||
'view.layout.horizontal': { enabled: !layoutFixed, checked: mailLayoutSetting === 'horizontal' },
|
||||
'view.layout.focus': { enabled: !layoutFixed, checked: mailLayoutSetting === 'focus' },
|
||||
@@ -1394,10 +1402,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen),
|
||||
'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen),
|
||||
'message.star': desktopSingle,
|
||||
'message.move': desktopSingle,
|
||||
'message.archive': desktopTarget && archiveActionEnabled,
|
||||
'message.spam': desktopTarget,
|
||||
'message.delete': desktopTarget,
|
||||
'message.move': desktopSingle && desktopMayRemove,
|
||||
'message.archive': desktopTarget && archiveActionEnabled && desktopMayRemove,
|
||||
'message.spam': desktopTarget && desktopMayRemove,
|
||||
'message.delete': desktopTarget && desktopMayRemove,
|
||||
'message.openWindow': desktopSingle && !!openEmailInWindow,
|
||||
'go.sent': true,
|
||||
'go.drafts': true,
|
||||
@@ -1665,7 +1673,33 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
if (isMobile) setActiveView("viewer");
|
||||
}, [composerSessionId, isMobile, setActiveView]);
|
||||
|
||||
const openMailtoDraft = useCallback((pending: DraftRequest) => withComposeGate('compose', {
|
||||
// A mail link from another program, in the desktop app: a compose window of its own,
|
||||
// as Outlook and Thunderbird do, and the main window stays where it was. Plugins get
|
||||
// their say first. False when it has to open here instead.
|
||||
const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise<boolean> => {
|
||||
if (!pending.fromOs || pending.initialFiles?.length || !hasDesktopWindows() || !popoutsAvailable()) return false;
|
||||
let prefill: ComposePrefill | null = null;
|
||||
if (emailHooks.onBeforeCompose.size > 0) {
|
||||
prefill = await runBeforeCompose(composeOptionsFor('compose', {
|
||||
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
|
||||
ownEmails: identities.map((i) => i.email).filter((e): e is string => Boolean(e)),
|
||||
replyPrefix: t('email_composer.prefix.reply'),
|
||||
forwardPrefix: t('email_composer.prefix.forward'),
|
||||
}));
|
||||
// A plugin said no: nothing opens, here or there.
|
||||
if (!prefill) return true;
|
||||
}
|
||||
const opened = openComposerWindow(useAuthStore.getState().activeAccountId);
|
||||
if (!opened) return false;
|
||||
void opened.handOver({
|
||||
mode: 'compose',
|
||||
initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode),
|
||||
prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null,
|
||||
});
|
||||
return true;
|
||||
}, [identities, t]);
|
||||
|
||||
const openMailtoHere = useCallback((pending: DraftRequest) => withComposeGate('compose', {
|
||||
to: pending.to, cc: pending.cc, subject: pending.subject, body: pending.body,
|
||||
}, () => {
|
||||
// A live composer owns text the user has not sent. Replacing it outright
|
||||
@@ -1684,6 +1718,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
applyMailtoDraft(pending, showComposer);
|
||||
}), [applyMailtoDraft, showComposer, withComposeGate]);
|
||||
|
||||
const openMailtoDraft = useCallback(async (pending: DraftRequest): Promise<boolean> => {
|
||||
if (await openMailtoInWindow(pending)) return true;
|
||||
// Opening here after all: the shell left this window where it was.
|
||||
if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
|
||||
return openMailtoHere(pending);
|
||||
}, [openMailtoInWindow, openMailtoHere]);
|
||||
|
||||
const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => {
|
||||
setIsProtocolAccountSwitching(true);
|
||||
try {
|
||||
@@ -1712,6 +1753,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise<void> => {
|
||||
const protocolAccounts = getMailtoProtocolAccounts();
|
||||
if (protocolAccounts.length > 1) {
|
||||
// The question is asked in this window: bring it up.
|
||||
if (pending.fromOs) getDesktopBridge()?.windows?.focus?.();
|
||||
setPendingMailtoAccountChoice(pending);
|
||||
return;
|
||||
}
|
||||
@@ -2351,6 +2394,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
empty: () => { if (selectedMailbox) void handleEmptyFolderFromContextMenu(selectedMailbox); },
|
||||
};
|
||||
const folderMenuMarkRead = useCallback(() => folderMenuLatest.current.markRead(), []);
|
||||
// "Mark folder as read" is greyed out with nothing unread in the open folder.
|
||||
const selectedFolderHasUnread = mailboxes.find((m) => m.id === selectedMailbox)?.unreadEmails !== 0;
|
||||
const folderMenuMarkAllRead = useCallback(() => folderMenuLatest.current.markAllRead(), []);
|
||||
const folderMenuEmpty = useCallback(() => folderMenuLatest.current.empty(), []);
|
||||
|
||||
@@ -4787,12 +4832,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
when: () => !!paletteEmail && !paletteEmail.keywords?.$seen,
|
||||
run: () => { if (client && paletteEmail) void markReadWithUndo(paletteEmail, true).catch(toastUpdateFailure); },
|
||||
},
|
||||
{
|
||||
id: 'star', label: paletteEmail?.keywords?.$flagged ? t('context_menu.unstar') : t('context_menu.star'), icon: Star, shortcut: 's',
|
||||
when: hasPaletteEmail, run: () => void handleToggleStar(),
|
||||
},
|
||||
{
|
||||
id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'),
|
||||
icon: conversationInJunk ? ShieldCheck : ShieldAlert, shortcut: '!',
|
||||
when: () => !!paletteEmail && conversationSpamApplicable,
|
||||
run: () => void (conversationInJunk ? handleUndoSpam(paletteEmail) : handleMarkAsSpam(paletteEmail)),
|
||||
},
|
||||
// Into the open folder, as the folder's own menu does it.
|
||||
{
|
||||
id: 'import', label: t('mail_import.menu_import'), icon: Upload,
|
||||
run: () => openMailImport({ mailboxId: selectedMailbox || undefined }),
|
||||
},
|
||||
...mailboxes
|
||||
.filter((mb) => !mb.id.startsWith('shared-') && mb.id !== selectedMailbox)
|
||||
.map((mb) => {
|
||||
@@ -5502,7 +5556,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onClick={() => { close(); void handleManualRefresh(); }}
|
||||
/>
|
||||
<MailFolderMenuItems
|
||||
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
|
||||
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined}
|
||||
onMarkAllFoldersRead={handleMarkAllFoldersRead}
|
||||
onEmptyFolder={folderIsEmptyable && selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
|
||||
onPicked={close}
|
||||
@@ -5542,7 +5596,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
totalCount={searchQuery || !isFilterEmpty(searchFilters) ? undefined : mailboxes.find((mb) => mb.id === selectedMailbox)?.totalEmails}
|
||||
onRefresh={handleManualRefresh}
|
||||
isRefreshing={isManualRefreshing}
|
||||
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
|
||||
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined}
|
||||
onMarkAllFoldersRead={handleMarkAllFoldersRead}
|
||||
onEmptyFolder={selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
|
||||
onCreateFolder={() => handleCreateFolderFromContextMenu()}
|
||||
@@ -5612,7 +5666,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
folderRole={conversationRole}
|
||||
onRefresh={handleManualRefresh}
|
||||
refreshDisabled={!client || isManualRefreshing}
|
||||
onMarkFolderRead={selectedMailbox ? folderMenuMarkRead : undefined}
|
||||
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? folderMenuMarkRead : undefined}
|
||||
onMarkAllFoldersRead={folderMenuMarkAllRead}
|
||||
onEmptyFolder={folderIsEmptyable && selectedMailbox ? folderMenuEmpty : undefined}
|
||||
/>
|
||||
@@ -5681,6 +5735,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
unless an enabled plugin registered tabs via api.tabs.set. */}
|
||||
{!isScheduledView && <MessageListTabs panelId={messageListPanelId} />}
|
||||
{!emptyPaneBesideList && <WelcomeBanner />}
|
||||
<FirstStartNoticeBar hidden={welcomeDue && !emptyPaneBesideList} />
|
||||
|
||||
<div {...messageListPanelProps} className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
<ErrorBoundary fallback={EmailListErrorFallback}>
|
||||
@@ -6080,7 +6135,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
|
||||
onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined}
|
||||
isFullscreen={fullscreenReading}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onEditDraft={handleEditDraft}
|
||||
onCancelScheduledForEdit={async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
@@ -6195,8 +6249,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<MoveToDialogHost mailboxes={mailboxes} selectedMailbox={selectedMailbox} />
|
||||
{moveDialogEmail && <MoveToDialog
|
||||
email={moveDialogEmail}
|
||||
emails={[moveDialogEmail]}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onClose={() => setMoveDialogEmail(null)}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
import { KeyProtectionNotes } from '../key-protection-notes';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
|
||||
}));
|
||||
|
||||
function withShell(wrap: boolean): void {
|
||||
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'linux',
|
||||
appLock: { status: async () => ({ wrap }) },
|
||||
};
|
||||
}
|
||||
|
||||
describe('what settings say about the key', () => {
|
||||
beforeEach(() => useOfflineStore.setState({ keyUnprotected: false, rekey: {} }));
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop;
|
||||
});
|
||||
|
||||
it('a browser says it has no layer of the OS', async () => {
|
||||
await act(async () => { render(<KeyProtectionNotes browser />); });
|
||||
expect(screen.getByText('browser_note')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('the desktop app with a real key store says nothing', async () => {
|
||||
withShell(true);
|
||||
await act(async () => { render(<KeyProtectionNotes browser />); });
|
||||
expect(screen.queryByTestId('key-protection-notes')).toBeNull();
|
||||
});
|
||||
|
||||
it('Linux with no secret service says secrets are only obfuscated', async () => {
|
||||
withShell(false);
|
||||
await act(async () => { render(<KeyProtectionNotes browser />); });
|
||||
expect(screen.getByText('no_secure_storage')).toBeTruthy();
|
||||
expect(screen.queryByText('browser_note')).toBeNull();
|
||||
});
|
||||
|
||||
it('says out loud when the key store refused, and how a re-encryption goes', async () => {
|
||||
withShell(true);
|
||||
useOfflineStore.setState({ keyUnprotected: true, rekey: { a: 40, b: 'failed' } });
|
||||
await act(async () => { render(<KeyProtectionNotes />); });
|
||||
expect(screen.getByText('wrap_failed')).toBeTruthy();
|
||||
expect(screen.getByText('rekey_failed')).toBeTruthy();
|
||||
expect(screen.getByText('rekey_progress:{"percent":40}')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,38 +0,0 @@
|
||||
import { cleanup, render } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { OfflineNotice } from '@/lib/offline/offline-mode';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
|
||||
// mail-26: the desktop app's "Bulwark keeps your mail on this device" notice
|
||||
// stayed over the quick reply box until someone closed it.
|
||||
|
||||
const notifier = vi.hoisted(() => ({ show: null as ((notice: OfflineNotice) => void) | null }));
|
||||
vi.mock('@/lib/offline/offline-mode', () => ({
|
||||
setOfflineNotifier: (fn: ((notice: OfflineNotice) => void) | null) => {
|
||||
notifier.show = fn;
|
||||
},
|
||||
startOfflineMode: () => () => {},
|
||||
confirmLoss: () => {},
|
||||
unsentCount: async () => ({ messages: 0, changes: 0 }),
|
||||
}));
|
||||
|
||||
const { OfflineHost } = await import('../offline-host');
|
||||
|
||||
beforeEach(() => {
|
||||
useToastStore.getState().clearToasts();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('the notice that the desktop app keeps mail on this device', () => {
|
||||
it('goes away by itself, and still offers Settings', () => {
|
||||
render(<OfflineHost />);
|
||||
notifier.show!({ kind: 'default-on' });
|
||||
const [notice] = useToastStore.getState().toasts;
|
||||
expect(notice.title).toBe('default_on');
|
||||
expect(notice.duration).toBeGreaterThan(0);
|
||||
expect(notice.action?.label).toBe('open_settings');
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,9 @@ import { useOfflineStore, writeEnabled } from '@/lib/offline/state';
|
||||
import { passSignOutGates } from '@/lib/sign-out-cleanup';
|
||||
import { setStoreBackend } from '@/lib/store/client/api';
|
||||
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
|
||||
import { wipeEverything } from '@/lib/offline/offline-mode';
|
||||
import { offlineNotify, wipeEverything } from '@/lib/offline/offline-mode';
|
||||
import { firstStartNoticesWaiting, resetFirstStartNotices } from '@/lib/first-start-notices';
|
||||
import { useToastStore } from '@/stores/toast-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
|
||||
let store: InProcessStore;
|
||||
@@ -53,3 +55,15 @@ describe('the question before a sign-out', () => {
|
||||
expect(screen.queryByRole('button', { name: /signout\.confirm/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the notice that the desktop app keeps mail on this device', () => {
|
||||
it('is a first-start notice above the list, not a toast that covers the composer', () => {
|
||||
resetFirstStartNotices();
|
||||
useToastStore.setState({ toasts: [] });
|
||||
render(<OfflineHost />);
|
||||
offlineNotify({ kind: 'default-on' });
|
||||
expect(useToastStore.getState().toasts).toHaveLength(0);
|
||||
expect(firstStartNoticesWaiting().map((n) => n.text)).toEqual(['default_on']);
|
||||
expect(firstStartNoticesWaiting()[0].action?.label).toBe('open_settings');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,6 +150,23 @@ describe('the offline banner', () => {
|
||||
expect(screen.getByTestId('offline-outbox-row').textContent).toContain('Quarterly');
|
||||
});
|
||||
|
||||
it('can be hidden, and comes back when what it says changes', async () => {
|
||||
const { OfflineBanner } = await import('../offline-banner');
|
||||
sessionStorage.clear();
|
||||
useOfflineStore.setState({ enabled: ['acc'], browserOffline: true, outbox: [] });
|
||||
const { unmount } = render(<OfflineBanner />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'hide_offline_notice' }));
|
||||
expect(screen.queryByTestId('offline-banner')).toBeNull();
|
||||
// Another page of the app, the same state: still hidden.
|
||||
unmount();
|
||||
render(<OfflineBanner />);
|
||||
expect(screen.queryByTestId('offline-banner')).toBeNull();
|
||||
// A message now waits: that's news.
|
||||
act(() => useOfflineStore.setState({ outbox: [{ id: 2, account: 'acc', createdAt: Date.now(), subject: 'Later', to: ['b@example.org'], status: 'waiting' }] }));
|
||||
expect(screen.getByTestId('offline-banner')).toBeInTheDocument();
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('stays away for an account that doesn\'t keep mail here', async () => {
|
||||
const { OfflineBanner } = await import('../offline-banner');
|
||||
useOfflineStore.setState({ enabled: [], browserOffline: true, outbox: [] });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { useOfflineStore } from '@/lib/offline/state';
|
||||
|
||||
/**
|
||||
* What protects the key to the mail on this device, where that's less than one would think, and
|
||||
* how a re-encryption is getting on. `browser`: also says what a browser can't do. The app lock's
|
||||
* own settings have said that already.
|
||||
*/
|
||||
export function KeyProtectionNotes({ browser = false }: { browser?: boolean }) {
|
||||
const t = useTranslations('keyProtection');
|
||||
const keyUnprotected = useOfflineStore((state) => state.keyUnprotected);
|
||||
const rekey = useOfflineStore((state) => state.rekey);
|
||||
const [bridge, setBridge] = useState<'none' | 'wraps' | 'obfuscates'>('wraps');
|
||||
|
||||
useEffect(() => {
|
||||
const member = getDesktopBridge()?.appLock;
|
||||
if (!member) {
|
||||
setBridge('none');
|
||||
return;
|
||||
}
|
||||
let live = true;
|
||||
void member.status().then((status) => {
|
||||
if (live) setBridge(status.wrap ? 'wraps' : 'obfuscates');
|
||||
}).catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const states = Object.values(rekey);
|
||||
const running = states.filter((state): state is number => typeof state === 'number');
|
||||
const lines = [
|
||||
bridge === 'obfuscates' ? t('no_secure_storage') : null,
|
||||
bridge === 'none' && browser ? t('browser_note') : null,
|
||||
keyUnprotected ? t('wrap_failed') : null,
|
||||
states.includes('failed') ? t('rekey_failed') : null,
|
||||
running.length > 0 ? t('rekey_progress', { percent: Math.min(...running) }) : null,
|
||||
].filter((line): line is string => line !== null);
|
||||
if (lines.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1 rounded-md border border-border bg-muted px-3 py-2 text-xs text-foreground" data-testid="key-protection-notes">
|
||||
{lines.map((line) => <p key={line}>{line}</p>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -9,11 +9,9 @@ import { addSignOutGate } from '@/lib/sign-out-cleanup';
|
||||
import { reloadToNewBuild } from '@/lib/app-update';
|
||||
import { buildSettingsPath } from '@/lib/deep-links';
|
||||
import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode';
|
||||
import { showFirstStartNotice } from '@/lib/first-start-notices';
|
||||
import { toast } from '@/stores/toast-store';
|
||||
|
||||
/** How long the notice that the desktop app keeps mail stays: a long sentence, so twice a plain toast. */
|
||||
const DEFAULT_ON_NOTICE_MS = 10_000;
|
||||
|
||||
/**
|
||||
* Runs offline mode (#523) in this tab: the JMAP client's offline hooks,
|
||||
* the outbox, the wipes. Its notices become toasts, and a sign-out the
|
||||
@@ -30,6 +28,9 @@ export function OfflineHost() {
|
||||
const tStorage = useTranslations('offlineStorage');
|
||||
const tStorageRef = useRef(tStorage);
|
||||
tStorageRef.current = tStorage;
|
||||
const tKey = useTranslations('keyProtection');
|
||||
const tKeyRef = useRef(tKey);
|
||||
tKeyRef.current = tKey;
|
||||
const router = useRouter();
|
||||
const routerRef = useRef(router);
|
||||
routerRef.current = router;
|
||||
@@ -77,8 +78,8 @@ export function OfflineHost() {
|
||||
toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage });
|
||||
break;
|
||||
case 'default-on':
|
||||
// Said once; it goes like other notices, and Settings has the switch.
|
||||
toast.info(tStorageRef.current('default_on'), { duration: DEFAULT_ON_NOTICE_MS, action: openStorage });
|
||||
// A first-start notice, not a toast: it stays until closed, and covers nothing.
|
||||
showFirstStartNotice({ id: 'offline-default-on', text: tStorageRef.current('default_on'), action: openStorage });
|
||||
break;
|
||||
case 'legacy-stranded':
|
||||
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
|
||||
@@ -93,6 +94,12 @@ export function OfflineHost() {
|
||||
case 'store-unavailable':
|
||||
toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 });
|
||||
break;
|
||||
case 'key-unprotected':
|
||||
toast.warning(tKeyRef.current('wrap_failed'), { duration: 0 });
|
||||
break;
|
||||
case 'rekey-failed':
|
||||
toast.warning(tKeyRef.current('rekey_failed'), { duration: 0 });
|
||||
break;
|
||||
case 'refused-delete':
|
||||
toast.warning(tr('toast.refused_delete'));
|
||||
break;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAccountStore } from '@/stores/account-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { AccountStorage } from './account-storage';
|
||||
import { OutboxList } from './outbox-list';
|
||||
import { KeyProtectionNotes } from './key-protection-notes';
|
||||
import { WipeDeviceSection } from './wipe-device-section';
|
||||
|
||||
/**
|
||||
@@ -65,6 +66,7 @@ export function OfflineSettings() {
|
||||
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
|
||||
<p data-testid="offline-warning">{isDesktopApp() ? tStorage('warning_desktop') : t('warning')}</p>
|
||||
</div>
|
||||
<KeyProtectionNotes browser />
|
||||
{enabledCount > 0 && persist === 'refused' && (
|
||||
<div className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="status" data-testid="offline-persist-refused">
|
||||
<p className="min-w-0">{tStorage('persist_refused')}</p>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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); }}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { EmailViewer } from "@/components/email/email-viewer";
|
||||
import { MoveToDialog } from "@/components/email/move-to-dialog";
|
||||
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useEmailStore, resolveEmailBlobContext, bulkDeleteIsPermanent, restoreBatchesFor, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store";
|
||||
@@ -480,6 +481,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
// In a pop-out this message is all there is, so the menu bar's Message menu means it.
|
||||
const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow();
|
||||
const seen = !!email?.keywords?.$seen;
|
||||
// Message > Move to… in a pop-out: the same folder list the command palette opens.
|
||||
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||
useDesktopCommands({
|
||||
'message.reply': () => handleReply(),
|
||||
'message.replyAll': handleReplyAll,
|
||||
@@ -489,6 +492,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
'message.star': () => void handleToggleStar(),
|
||||
'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined,
|
||||
'message.delete': () => void handleDelete(),
|
||||
'message.move': () => { if (email) setMoveDialogOpen(true); },
|
||||
}, {
|
||||
'message.reply': !!email,
|
||||
'message.replyAll': !!email,
|
||||
@@ -498,6 +502,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
'message.star': !!email,
|
||||
'message.archive': !!email && archiveActionEnabled,
|
||||
'message.delete': !!email,
|
||||
'message.move': !!email,
|
||||
}, inOwnWindow);
|
||||
|
||||
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
|
||||
@@ -654,6 +659,15 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
{inOwnWindow && (
|
||||
<MoveToDialog
|
||||
emails={moveDialogOpen && email ? [email] : []}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox=""
|
||||
onClose={() => setMoveDialogOpen(false)}
|
||||
onMove={(mailboxId) => void handleMoveToMailbox(mailboxId)}
|
||||
/>
|
||||
)}
|
||||
{preview && (
|
||||
<FilePreviewModal
|
||||
name={preview.name}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BulwarkDesktopBridge, DesktopDevToolsState } from '@/lib/desktop/types';
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string, values?: Record<string, unknown>) => (values ? `${key}:${JSON.stringify(values)}` : key),
|
||||
}));
|
||||
|
||||
const { DesktopDevToolsSettings } = await import('../desktop-devtools-settings');
|
||||
|
||||
function setBridge(state: DesktopDevToolsState | null, platform: 'win32' | 'darwin' = 'win32') {
|
||||
const listeners = new Set<(next: DesktopDevToolsState) => void>();
|
||||
const set = vi.fn(async (on: boolean) => ({ ...state!, on }));
|
||||
window.bulwarkDesktop = (state
|
||||
? {
|
||||
version: 1,
|
||||
platform,
|
||||
devTools: {
|
||||
get: async () => state,
|
||||
set,
|
||||
onChange: (listener: (next: DesktopDevToolsState) => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
},
|
||||
}
|
||||
: { version: 1, platform }) as unknown as BulwarkDesktopBridge;
|
||||
return { set, emit: (next: DesktopDevToolsState) => listeners.forEach((listener) => listener(next)) };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
describe('the developer tools setting', () => {
|
||||
it('is not there in a browser or on a shell without it', () => {
|
||||
const { container } = render(<DesktopDevToolsSettings />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
setBridge(null);
|
||||
const second = render(<DesktopDevToolsSettings />);
|
||||
expect(second.container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('is off by default, names the shortcut, and asks main to turn it on', async () => {
|
||||
const { set } = setBridge({ on: false, forcedOff: false, locked: false });
|
||||
render(<DesktopDevToolsSettings />);
|
||||
const toggle = await screen.findByTestId('desktop-devtools-toggle');
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
||||
expect(screen.getByText('label:{"shortcut":"Ctrl+Shift+I"}')).toBeTruthy();
|
||||
fireEvent.click(toggle);
|
||||
expect(set).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('a Mac names its own keys', async () => {
|
||||
setBridge({ on: false, forcedOff: false, locked: false }, 'darwin');
|
||||
render(<DesktopDevToolsSettings />);
|
||||
expect(await screen.findByText('label:{"shortcut":"Cmd+Option+I"}')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('a policy locks it off, and the app lock stops changes', async () => {
|
||||
const { emit } = setBridge({ on: false, forcedOff: true, locked: false });
|
||||
render(<DesktopDevToolsSettings />);
|
||||
const toggle = await screen.findByTestId('desktop-devtools-toggle');
|
||||
expect(toggle.hasAttribute('disabled')).toBe(true);
|
||||
expect(screen.getByText('forced_off')).toBeTruthy();
|
||||
act(() => emit({ on: false, forcedOff: false, locked: true }));
|
||||
expect(screen.getByText('locked')).toBeTruthy();
|
||||
expect(screen.getByTestId('desktop-devtools-toggle').hasAttribute('disabled')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
import en from '@/locales/en/common.json';
|
||||
|
||||
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Bulwark' }) }));
|
||||
|
||||
const { ProtocolHandlerSettings } = await import('../protocol-handler-settings');
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
window.bulwarkDesktop = undefined;
|
||||
});
|
||||
|
||||
describe('Settings > General in the desktop app', () => {
|
||||
it('a browser keeps its "Default apps" section', () => {
|
||||
render(<ProtocolHandlerSettings supportsCalendar />);
|
||||
expect(screen.getByText('register_mailto')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('the desktop app shows only its own "Default mail app" row, not the browser\'s handlers', () => {
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32' } as unknown as BulwarkDesktopBridge;
|
||||
render(<ProtocolHandlerSettings supportsCalendar />);
|
||||
expect(screen.queryByText('register_mailto')).toBeNull();
|
||||
expect(screen.queryByText('title')).toBeNull();
|
||||
});
|
||||
|
||||
it('the section about the app\'s windows isn\'t called "Windows", which reads as the OS', () => {
|
||||
expect(en.desktopUi.settings.title).not.toBe('Windows');
|
||||
expect(en.desktopUi.settings.title).toBe('App windows');
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import type { DesktopOsUnlockKind } from "@/lib/desktop/types";
|
||||
import { IS_LITE } from "@/lib/lite";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { KeyProtectionNotes } from "@/components/offline/key-protection-notes";
|
||||
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
|
||||
|
||||
type Pending = "enable" | "disable" | "change" | "passkey" | "os" | null;
|
||||
@@ -39,6 +40,7 @@ function useLockFacts() {
|
||||
/** The app lock's section on the General page: it has to exist on any server. */
|
||||
export function AppLockSettings() {
|
||||
const t = useTranslations("appLock");
|
||||
const tKey = useTranslations("keyProtection");
|
||||
const tEdit = useTranslations("desktopEdit");
|
||||
const { appName } = useConfig();
|
||||
const { on, required, timeout, update } = useLockFacts();
|
||||
@@ -95,7 +97,12 @@ export function AppLockSettings() {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
if (pending === "enable" && newPinReady(pin, repeat)) void run(() => enableAppLock(pin));
|
||||
if (pending === "change" && current && newPinReady(pin, repeat)) void run(() => changeAppLockPin(current, pin));
|
||||
if (pending === "change" && current && newPinReady(pin, repeat)) {
|
||||
void run(async () => {
|
||||
const { reenroll } = await changeAppLockPin(current, pin);
|
||||
if (reenroll) toast.info(tKey("reenroll_note"), { duration: 0 });
|
||||
});
|
||||
}
|
||||
if (pending === "disable" && current) void run(() => disableAppLock(current));
|
||||
if (pending === "passkey" && current) {
|
||||
void run(async () => {
|
||||
@@ -130,6 +137,7 @@ export function AppLockSettings() {
|
||||
<ToggleSwitch checked={on || required} onChange={(next) => setPending(next ? "enable" : "disable")} testId="app-lock-switch" />
|
||||
</SettingItem>
|
||||
<p className="text-xs text-muted-foreground" data-testid="app-lock-protects">{t("protects_desc")}</p>
|
||||
<KeyProtectionNotes />
|
||||
|
||||
{on && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import type { DesktopDevToolsState } from "@/lib/desktop/types";
|
||||
import { SettingItem, SettingsSection, ToggleSwitch } from "./settings-section";
|
||||
|
||||
/**
|
||||
* Troubleshooting in the desktop app: Chromium's developer tools on a key, off until the
|
||||
* user turns them on. Main keeps the switch and asks with a native warning, so a page or
|
||||
* plugin can't turn it on by itself. A browser never shows this.
|
||||
*/
|
||||
export function DesktopDevToolsSettings() {
|
||||
const t = useTranslations("desktopDevTools");
|
||||
const bridge = getDesktopBridge();
|
||||
const devTools = bridge?.devTools;
|
||||
const [state, setState] = useState<DesktopDevToolsState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!devTools) return;
|
||||
let stale = false;
|
||||
void devTools.get().then((next) => {
|
||||
if (!stale) setState(next);
|
||||
}, () => {});
|
||||
const stop = devTools.onChange((next) => {
|
||||
if (!stale) setState(next);
|
||||
});
|
||||
return () => {
|
||||
stale = true;
|
||||
stop();
|
||||
};
|
||||
}, [devTools]);
|
||||
|
||||
if (!devTools || !state) return null;
|
||||
const shortcut = bridge?.platform === "darwin" ? "Cmd+Option+I" : "Ctrl+Shift+I";
|
||||
const description = state.forcedOff ? t("forced_off") : state.locked ? t("locked") : t("description", { shortcut });
|
||||
|
||||
return (
|
||||
<SettingsSection id="desktop-troubleshooting" title={t("section_title")}>
|
||||
<SettingItem label={t("label", { shortcut })} description={description} locked={state.forcedOff}>
|
||||
<ToggleSwitch
|
||||
checked={state.on}
|
||||
disabled={state.forcedOff || state.locked}
|
||||
testId="desktop-devtools-toggle"
|
||||
onChange={(on) => void devTools.set(on).then(setState, () => {})}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PlayCircle } from '@/components/icons';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { DesktopUpdateSettings } from './desktop-update-settings';
|
||||
import { DesktopWindowSettings } from './desktop-window-settings';
|
||||
import { DesktopDevToolsSettings } from './desktop-devtools-settings';
|
||||
|
||||
/**
|
||||
* The General page: language and time, which links the browser hands to
|
||||
@@ -61,6 +62,7 @@ export function GeneralSettings({ supportsCalendar }: { supportsCalendar: boolea
|
||||
</SettingsSection>
|
||||
|
||||
{!IS_LITE && <DesktopUpdateSettings />}
|
||||
<DesktopDevToolsSettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
import { useIdentityStore } from '@/stores/identity-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||
import { DesktopOsSettings } from './desktop-os-settings';
|
||||
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -424,6 +425,9 @@ export function NotificationSettings() {
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
|
||||
{/* The desktop app's taskbar flash and quiet hours. Nothing in a browser. */}
|
||||
<DesktopOsSettings part="notifications" />
|
||||
|
||||
<SettingsSection id="push" title={t('push.title')} description={t('push.description')} term="push">
|
||||
<SettingItem label={t('push.enable')} description={statusDescription}>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { productName } from "@/lib/product-name";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import type { ProtocolOpenMode } from "@/stores/settings-store";
|
||||
@@ -41,9 +42,13 @@ export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSet
|
||||
const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode);
|
||||
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
||||
const [supported, setSupported] = useState(false);
|
||||
// The desktop app is a mail app of its own: its "Default mail app" row (Desktop app
|
||||
// section) is the one that counts, and a browser's handler means nothing there.
|
||||
const [desktop, setDesktop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSupported(canRegisterProtocolHandler());
|
||||
setDesktop(!!getDesktopBridge());
|
||||
}, []);
|
||||
|
||||
const handleOpenModeChange = async (value: string) => {
|
||||
@@ -76,6 +81,8 @@ export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSet
|
||||
);
|
||||
};
|
||||
|
||||
if (desktop) return null;
|
||||
|
||||
return (
|
||||
<SettingsSection id="links" title={t("title")} description={t("description", { appName })}>
|
||||
{!supported && (
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSubMenu, type ContextMenuPosition } from '../context-menu';
|
||||
import { useContextMenu } from '@/hooks/use-context-menu';
|
||||
import { placeMenu } from '@/lib/menu-placement';
|
||||
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
|
||||
|
||||
// Every menu opens next to what opened it: under a button, lined up with its
|
||||
// edge, or at the pointer, and towards the other side only where there's no
|
||||
// room. Never clamped somewhere away from the button.
|
||||
|
||||
function rect(left: number, top: number, width: number, height: number): DOMRect {
|
||||
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
|
||||
}
|
||||
|
||||
const MENU = { width: 220, height: 300 };
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
|
||||
Object.defineProperty(window, 'innerHeight', { value: 720, configurable: true });
|
||||
Object.defineProperty(window, 'outerWidth', { value: 1280, configurable: true });
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
return this.getAttribute('role') === 'menu' ? rect(0, 0, MENU.width, MENU.height) : rect(0, 0, 0, 0);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.bulwarkDesktop = undefined;
|
||||
document.documentElement.dir = '';
|
||||
});
|
||||
|
||||
function openAt(position: ContextMenuPosition) {
|
||||
render(
|
||||
<ContextMenu isOpen position={position} onClose={() => {}}>
|
||||
<ContextMenuItem label="Reply" onClick={() => {}} />
|
||||
</ContextMenu>,
|
||||
);
|
||||
const menu = screen.getByRole('menu');
|
||||
return { left: parseFloat(menu.style.left), top: parseFloat(menu.style.top) };
|
||||
}
|
||||
|
||||
describe('a menu from a right-click', () => {
|
||||
it('opens at the pointer where there is room', () => {
|
||||
expect(openAt({ x: 300, y: 200 })).toEqual({ left: 300, top: 200 });
|
||||
});
|
||||
|
||||
it('opens to the left of the pointer near the right edge, its corner still at the pointer', () => {
|
||||
// Clamped instead, the menu would sit under the pointer, away from where the click was.
|
||||
expect(openAt({ x: 1200, y: 200 })).toEqual({ left: 1200 - MENU.width, top: 200 });
|
||||
});
|
||||
|
||||
it('opens upwards from the pointer near the bottom', () => {
|
||||
expect(openAt({ x: 300, y: 650 })).toEqual({ left: 300, top: 650 - MENU.height });
|
||||
});
|
||||
|
||||
it('keeps the pointer where it was: the hook no longer guesses a size and moves it first', () => {
|
||||
function Row() {
|
||||
const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu<string>();
|
||||
return (
|
||||
<>
|
||||
<div data-testid="row" onContextMenu={(e) => openContextMenu(e, 'row')}>Row</div>
|
||||
<ContextMenu isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
<ContextMenuItem label="Reply" onClick={() => {}} />
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Row />);
|
||||
// A short menu right-clicked at 640: it fits below the pointer.
|
||||
MENU.height = 60;
|
||||
try {
|
||||
fireEvent.contextMenu(screen.getByTestId('row'), { clientX: 400, clientY: 640 });
|
||||
const menu = screen.getByRole('menu');
|
||||
expect(menu.style.top).toBe('640px');
|
||||
expect(menu.style.left).toBe('400px');
|
||||
} finally {
|
||||
MENU.height = 300;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a menu from a button', () => {
|
||||
it('sits under the button, lined up with its start edge', () => {
|
||||
expect(openAt({ x: 0, y: 0, anchor: { left: 100, top: 40, right: 132, bottom: 72 } })).toEqual({ left: 100, top: 76 });
|
||||
});
|
||||
|
||||
it('lines up with the button\'s end edge for a button at the end of a toolbar', () => {
|
||||
expect(openAt({ x: 0, y: 0, anchor: { left: 900, top: 40, right: 932, bottom: 72, align: 'end' } })).toEqual({ left: 932 - MENU.width, top: 76 });
|
||||
});
|
||||
|
||||
it('switches to the button\'s other edge when it doesn\'t fit, instead of sliding away from it', () => {
|
||||
const at = openAt({ x: 0, y: 0, anchor: { left: 1200, top: 40, right: 1232, bottom: 72 } });
|
||||
expect(at.left + MENU.width).toBe(1232);
|
||||
});
|
||||
|
||||
it('goes above the button when there is no room below', () => {
|
||||
expect(openAt({ x: 0, y: 0, anchor: { left: 100, top: 600, right: 132, bottom: 632 } }).top).toBe(600 - 4 - MENU.height);
|
||||
});
|
||||
|
||||
it('too tall for either side: stays under the button, as tall as the room, and scrolls', () => {
|
||||
// The ⋮ menu at 150 % zoom: 480 CSS pixels of window.
|
||||
Object.defineProperty(window, 'innerHeight', { value: 480, configurable: true });
|
||||
MENU.height = 460;
|
||||
try {
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 0, y: 0, anchor: { left: 805, top: 12, right: 837, bottom: 44, align: 'end' } }} onClose={() => {}}>
|
||||
<ContextMenuItem label="Reply" onClick={() => {}} />
|
||||
</ContextMenu>,
|
||||
);
|
||||
const menu = screen.getByRole('menu');
|
||||
// Clamped instead, it sat at 10 and covered its own button.
|
||||
expect(menu.style.top).toBe('48px');
|
||||
expect((menu.firstElementChild as HTMLElement).style.maxHeight).toBe(`${480 - 10 - 48 - 2}px`);
|
||||
} finally {
|
||||
MENU.height = 300;
|
||||
}
|
||||
});
|
||||
|
||||
it('mirrors in RTL: start is the right edge', () => {
|
||||
document.documentElement.dir = 'rtl';
|
||||
const at = openAt({ x: 0, y: 0, anchor: { left: 500, top: 40, right: 532, bottom: 72 } });
|
||||
// jsdom computes no direction from dir; the pure function covers RTL below.
|
||||
expect([500, 532 - MENU.width]).toContain(at.left);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeMenu', () => {
|
||||
const viewport = { width: 1280, height: 720 };
|
||||
const size = { width: 220, height: 300 };
|
||||
|
||||
it('RTL: under a button, its right edge with the button\'s right edge', () => {
|
||||
expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 500, top: 40, right: 532, bottom: 72 }, size, viewport, rtl: true }))
|
||||
.toEqual({ x: 532 - 220, y: 76 });
|
||||
});
|
||||
|
||||
it('RTL: an end-aligned button gets the left edge', () => {
|
||||
expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 40, top: 40, right: 72, bottom: 72, align: 'end' }, size, viewport, rtl: true }))
|
||||
.toEqual({ x: 40, y: 76 });
|
||||
});
|
||||
|
||||
it('RTL: a right-click opens to the left of the pointer', () => {
|
||||
expect(placeMenu({ point: { x: 600, y: 100 }, size, viewport, rtl: true })).toEqual({ x: 380, y: 100 });
|
||||
});
|
||||
|
||||
it('taller than the window: inside it, from the top margin, and the menu scrolls', () => {
|
||||
expect(placeMenu({ point: { x: 600, y: 100 }, size: { width: 220, height: 900 }, viewport })).toEqual({ x: 600, y: 10 });
|
||||
});
|
||||
|
||||
it('wider than the room on both sides of a button: clamped inside the window', () => {
|
||||
expect(placeMenu({ point: { x: 0, y: 0 }, anchor: { left: 100, top: 40, right: 132, bottom: 72 }, size: { width: 1270, height: 100 }, viewport }).x).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a native menu from a button', () => {
|
||||
it('lines its right edge up with an end-aligned button, by the native menu\'s width, not the hidden copy\'s', async () => {
|
||||
const popup = vi.fn(async () => null);
|
||||
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: () => {}, popup } } as unknown as BulwarkDesktopBridge;
|
||||
// The hidden copy holds every submenu open, so it is far wider than the menu the OS draws.
|
||||
MENU.width = 700;
|
||||
try {
|
||||
render(
|
||||
<ContextMenu isOpen position={{ x: 0, y: 0, anchor: { left: 468, top: 40, right: 500, bottom: 72, align: 'end' } }} onClose={() => {}}>
|
||||
<ContextMenuItem label="Forward as attachment" onClick={() => {}} />
|
||||
<ContextMenuSubMenu label="Rules">
|
||||
<ContextMenuItem label="Always move mail from this sender to a folder of its own" onClick={() => {}} />
|
||||
</ContextMenuSubMenu>
|
||||
<ContextMenuItem label="Print" onClick={() => {}} />
|
||||
</ContextMenu>,
|
||||
);
|
||||
await waitFor(() => expect(popup).toHaveBeenCalled());
|
||||
const at = (popup.mock.calls[0] as unknown[])[1] as { x: number; y: number };
|
||||
expect(at.y).toBe(76);
|
||||
// Under the button: its left edge somewhere left of the button, not at the window's edge.
|
||||
expect(at.x).toBeGreaterThan(200);
|
||||
expect(at.x).toBeLessThan(468);
|
||||
} finally {
|
||||
MENU.width = 220;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('a menu from a right-click on a row', () => {
|
||||
function Row() {
|
||||
const { contextMenu, openContextMenu, closeContextMenu } = useContextMenu<string>();
|
||||
return (
|
||||
<>
|
||||
<div data-testid="row" data-menu-row="" onContextMenu={(e) => openContextMenu(e, 'row')}>Row</div>
|
||||
<ContextMenu isOpen={contextMenu.isOpen} position={contextMenu.position} onClose={closeContextMenu}>
|
||||
<ContextMenuItem label="Reply" onClick={() => {}} />
|
||||
<ContextMenuSubMenu label="Move to" testId="sub">
|
||||
<ContextMenuItem label="Inbox" onClick={() => {}} />
|
||||
</ContextMenuSubMenu>
|
||||
</ContextMenu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
it('marks the row while the menu is open', () => {
|
||||
render(<Row />);
|
||||
const row = screen.getByTestId('row');
|
||||
fireEvent.contextMenu(row, { clientX: 300, clientY: 200 });
|
||||
expect(row).toHaveAttribute('data-menu-opener');
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
|
||||
expect(row).not.toHaveAttribute('data-menu-opener');
|
||||
});
|
||||
|
||||
it("doesn't answer the pointer that opened it until the pointer moves", () => {
|
||||
render(<Row />);
|
||||
fireEvent.contextMenu(screen.getByTestId('row'), { clientX: 300, clientY: 200 });
|
||||
const menu = screen.getByRole('menu');
|
||||
expect(menu).toHaveAttribute('data-pointer-unarmed');
|
||||
// Still under the pointer: hovering the submenu's entry doesn't open it.
|
||||
fireEvent.mouseEnter(screen.getByTestId('sub').parentElement!);
|
||||
expect(screen.queryByRole('menu', { name: 'Move to' })).toBeNull();
|
||||
fireEvent.pointerMove(document, { clientX: 301, clientY: 201 });
|
||||
expect(menu).toHaveAttribute('data-pointer-unarmed');
|
||||
fireEvent.pointerMove(document, { clientX: 310, clientY: 210 });
|
||||
expect(menu).not.toHaveAttribute('data-pointer-unarmed');
|
||||
fireEvent.mouseEnter(screen.getByTestId('sub').parentElement!);
|
||||
expect(screen.getByRole('menu', { name: 'Move to' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('placeMenu near the bottom', () => {
|
||||
it('a button low in the window with more room above: above it, capped, not over it', () => {
|
||||
const at = placeMenu({
|
||||
point: { x: 0, y: 0 },
|
||||
anchor: { left: 100, top: 600, right: 132, bottom: 632 },
|
||||
size: { width: 220, height: 700 },
|
||||
viewport: { width: 1280, height: 720 },
|
||||
});
|
||||
expect(at.maxHeight).toBe(600 - 4 - 10);
|
||||
expect(at.y + at.maxHeight!).toBe(596);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { FirstStartNoticeBar } from '../first-start-notice';
|
||||
import { resetFirstStartNotices, showFirstStartNotice } from '@/lib/first-start-notices';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
resetFirstStartNotices();
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe('first-start notices', () => {
|
||||
it('show one at a time, each with a close button, the next once the first is closed', () => {
|
||||
render(<FirstStartNoticeBar />);
|
||||
act(() => {
|
||||
showFirstStartNotice({ id: 'one', text: 'Mail is kept on this device.' });
|
||||
showFirstStartNotice({ id: 'two', text: 'Spelling needs a dictionary.' });
|
||||
});
|
||||
expect(screen.getByText('Mail is kept on this device.')).toBeTruthy();
|
||||
expect(screen.queryByText('Spelling needs a dictionary.')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
|
||||
expect(screen.getByText('Spelling needs a dictionary.')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('once closed, never again on this device', () => {
|
||||
const onClose = vi.fn();
|
||||
const view = render(<FirstStartNoticeBar />);
|
||||
act(() => showFirstStartNotice({ id: 'kept', text: 'Mail is kept on this device.', onClose }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
view.unmount();
|
||||
resetFirstStartNotices();
|
||||
render(<FirstStartNoticeBar />);
|
||||
act(() => showFirstStartNotice({ id: 'kept', text: 'Mail is kept on this device.' }));
|
||||
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
|
||||
});
|
||||
|
||||
it('its button does its job and closes it', () => {
|
||||
const open = vi.fn();
|
||||
render(<FirstStartNoticeBar />);
|
||||
act(() => showFirstStartNotice({ id: 'act', text: 'Mail is kept on this device.', action: { label: 'Open settings', onClick: open } }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open settings' }));
|
||||
expect(open).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
|
||||
});
|
||||
|
||||
it('waits while something else greets the user, and the same notice isn\'t queued twice', () => {
|
||||
const view = render(<FirstStartNoticeBar hidden />);
|
||||
act(() => {
|
||||
showFirstStartNotice({ id: 'wait', text: 'Mail is kept on this device.' });
|
||||
showFirstStartNotice({ id: 'wait', text: 'Mail is kept on this device.' });
|
||||
});
|
||||
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
|
||||
view.rerender(<FirstStartNoticeBar />);
|
||||
expect(screen.getAllByText('Mail is kept on this device.')).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'close' }));
|
||||
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user