Merge branch 'feat/do-menu-fixes' into feat/do-drop-overlay

This commit is contained in:
Linus Rath
2026-11-04 06:20:02 +01:00
113 changed files with 4809 additions and 610 deletions
+38
View File
@@ -191,6 +191,12 @@
* {
border-color: var(--color-border);
}
/* A placeholder or value too long for a narrow field ends in "…" instead of
stopping mid-word ("Termine durchs"), so it's clear there's more. */
input {
text-overflow: ellipsis;
}
}
body {
@@ -606,6 +612,23 @@ html[data-window-controls] [data-own-window-close] {
display: none;
}
/*
* A pane's own close right beside the window's read as a second close for the
* window. Where the row ends at the controls, it goes to the row's other end.
* Geometry, not direction: the controls stay at the right on Windows in RTL too.
*/
html[data-window-controls]:not([dir="rtl"]) [data-window-row-right] > [data-pane-close],
html[data-window-controls][dir="rtl"] [data-window-row-left] > [data-pane-close] {
order: -1;
margin-inline-start: -0.25rem;
margin-inline-end: 0;
}
/* Toasts that step out of the focus's way go to the top end corner: below the controls there. */
html[data-window-controls] [data-toast-container][data-dodging] {
top: calc(var(--titlebar-row-height) + 0.75rem) !important;
}
/* A top row is at least as tall as the controls, so they don't hang over what's below it. */
html[data-window-controls] [data-window-row] {
min-height: var(--titlebar-row-height);
@@ -629,6 +652,21 @@ html[data-window-controls] [data-window-row-left] {
padding-left: calc(var(--titlebar-left-inset) + 0.5rem) !important;
}
/*
* The desktop app's menu bar while it takes its row (components/layout/window-menu-bar.tsx).
* The app moves down under it, a page that fills the window starts below it, and the
* window's controls sit in the bar's row, so no column below makes room for them.
*/
html[data-menu-bar] {
--menu-bar-height: 2rem;
--app-top-inset: calc(env(safe-area-inset-top, 0px) + var(--menu-bar-height));
--titlebar-inset: var(--menu-bar-height);
}
html[data-menu-bar][data-window-controls] [data-window-end-column] {
padding-top: 0;
}
/*
* The empty parts of the app's top rows are what the window is dragged by, and a
* double-click there maximises. Anything to click stays clickable, and so does
+3 -3
View File
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { ToolbarButton } from "@/components/ui/toolbar";
import { ChevronLeft, ChevronRight, Plus, Upload, CalendarDays, Globe, ChevronDown, ArrowLeft, Menu, MoreVertical, RefreshCw, Search, Printer } from "@/components/icons";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, MenuShortcut } from "@/components/ui/context-menu";
import { useContextMenu } from "@/hooks/use-context-menu";
import { startOfWeek } from "date-fns";
import { PhoneHeaderActions } from "@/components/layout/phone-header-actions";
@@ -482,9 +482,9 @@ export function CalendarToolbar({
v === viewMode ? "bg-accent text-accent-foreground font-medium" : "hover:bg-muted text-foreground",
)}
>
<span>{t(`views.${v}`)}</span>
<span data-menu-label="">{t(`views.${v}`)}</span>
{/* The key, on devices with a keyboard (R8). */}
<kbd className="font-sans text-xs text-muted-foreground pointer-coarse:hidden">{VIEW_SHORTCUTS[v]}</kbd>
<MenuShortcut keys={VIEW_SHORTCUTS[v]} />
</button>
))}
</ContextMenu>
+3 -3
View File
@@ -1052,7 +1052,7 @@ export function EventModal({
<MoreVertical />
</IconButton>
)}
<IconButton label={tCommon("close")} onClick={onClose} className="flex-shrink-0 mt-0.5 text-muted-foreground">
<IconButton label={tCommon("close")} onClick={onClose} className="flex-shrink-0 mt-0.5 text-muted-foreground" data-pane-close="">
<X />
</IconButton>
</div>
@@ -1247,11 +1247,11 @@ export function EventModal({
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">
<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">
{isEdit ? t("events.edit") : t("events.create")}
</h2>
<IconButton label={tCommon("close")} onClick={requestClose} className="flex-shrink-0 text-muted-foreground">
<IconButton label={tCommon("close")} onClick={requestClose} className="flex-shrink-0 text-muted-foreground" data-pane-close="">
<X />
</IconButton>
</div>
+1 -1
View File
@@ -226,7 +226,7 @@ export function TaskModal({
<h2 id={`${id}-heading`} className="text-sm font-semibold">
{isEdit ? t("tasks.edit") : t("tasks.create")}
</h2>
<IconButton label={tCommon("close")} onClick={requestClose}>
<IconButton label={tCommon("close")} onClick={requestClose} data-pane-close="">
<X />
</IconButton>
</div>
@@ -119,6 +119,41 @@ describe('command palette', () => {
expect(hint('search')).toBe('');
});
it('shows no single-key hints while single-key shortcuts are off, since those keys do nothing then', async () => {
const { useSettingsStore } = await import('@/stores/settings-store');
const hint = (label: string) => {
const row = screen.getAllByRole('option').find((o) => o.querySelector('.flex-1')?.textContent === label)!;
return [...row.querySelectorAll('kbd')].map((k) => k.textContent).join(' ');
};
useSettingsStore.setState({ singleKeyShortcuts: false });
try {
render(<CommandPaletteHost />);
pressCtrlK();
expect(hint('compose')).toBe('');
expect(hint('shortcuts')).toBe('');
} finally {
useSettingsStore.setState({ singleKeyShortcuts: true });
}
});
it('names the account Sign out leaves, as the account menu does', async () => {
const { useAccountStore } = await import('@/stores/account-store');
const before = useAccountStore.getState().accounts;
useAccountStore.setState({ accounts: [
{ id: 'a1', email: 'one@example.org', username: 'one@example.org' },
{ id: 'a2', email: 'two@example.org', username: 'two@example.org' },
] } as never);
useAuthStore.setState({ activeAccountId: 'a2' } as never);
try {
render(<CommandPaletteHost />);
pressCtrlK();
expect(screen.getByText('sign_out_of(two@example.org)')).toBeInTheDocument();
expect(screen.queryByText('sign_out')).toBeNull();
} finally {
useAccountStore.setState({ accounts: before } as never);
}
});
it('shows plugin entries with the plugin name and runs them with Enter', async () => {
act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
render(<CommandPaletteHost />);
@@ -0,0 +1,28 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
// Mail registers its palette commands in mail-app.tsx, which is too big to render
// here. Each message action with a key in the shortcuts help is in the palette too,
// under the key the help names.
const source = readFileSync(path.join(process.cwd(), 'components/mail/mail-app.tsx'), 'utf8');
const block = source.slice(source.indexOf("useRegisterCommands(t('sidebar.mail')"));
describe('Mail commands in the palette', () => {
it.each([
['reply', 'r'],
['reply-all', 'a'],
['forward', 'f'],
['star', 's'],
['archive', 'e'],
['delete', '#'],
['mark-unread', 'u'],
['mark-read', 'Shift + I'],
['spam', '!'],
])('has %s, shown with %s', (id, key) => {
const at = block.indexOf(`id: '${id}'`);
expect(at).toBeGreaterThan(-1);
const entry = block.slice(at, block.indexOf('}', at));
expect(entry).toContain(`shortcut: '${key}'`);
});
});
+17 -4
View File
@@ -27,6 +27,7 @@ import { useGlobalSearchStore } from "@/stores/global-search-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useThemeStore } from "@/stores/theme-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePluginActions } from "@/components/plugins/plugin-actions";
import { usePluginPages, pluginPageHref } from "@/hooks/use-plugin-pages";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
@@ -38,7 +39,7 @@ import { commandMatches, useRegisteredCommandGroups } from "@/lib/command-regist
import { shortcutAlternatives } from "@/lib/shortcut-keys";
import { toUnicodeEmail } from "@/lib/idn";
import { isEditableEventTarget, isKeyboardLayerOpen } from "@/lib/keyboard";
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { isSingleKeyHint, isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useMediaQuery } from "@/hooks/use-media-query";
import { IconButton } from "@/components/ui/icon-button";
@@ -162,6 +163,9 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
const activeAccountId = useAuthStore((s) => s.activeAccountId);
// A touch screen has no keyboard to use the shortcuts with (R8).
const touch = useMediaQuery("(pointer: coarse)");
// With single-key shortcuts off, "r" or "#" does nothing: no hint for it.
const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts !== false);
const hintKeys = (shortcut: string) => shortcutAlternatives(shortcut).filter((key) => singleKeys || !isSingleKeyHint(key));
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true, initialFocus: () => inputRef.current });
@@ -255,7 +259,16 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
run: () => { void useAuthStore.getState().switchAccount(entry.id); },
});
}
list.push({ id: "sign-out", group: account, icon: LogOut, label: tSidebar("sign_out"), run: () => { void useAuthStore.getState().logout(); } });
// It signs out of the open account only, as the account menu's entry says.
const current = accounts.find((entry) => entry.id === activeAccountId);
const currentName = current ? toUnicodeEmail(current.email || current.username) : "";
list.push({
id: "sign-out",
group: account,
icon: LogOut,
label: currentName ? tSidebar("sign_out_of", { account: currentName }) : tSidebar("sign_out"),
run: () => { void useAuthStore.getState().logout(); },
});
for (const action of pluginActions) {
list.push({
id: `plugin:${action.pluginId}:${action.id}`,
@@ -394,10 +407,10 @@ function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onS
{command.source && (
<span className="flex-shrink-0 truncate max-w-[40%] text-xs text-muted-foreground">{command.source}</span>
)}
{command.shortcut && (
{command.shortcut && hintKeys(command.shortcut).length > 0 && (
// Key hints only where there is a keyboard (R8).
<span className="flex-shrink-0 flex items-center gap-1 pointer-coarse:hidden" aria-hidden="true">
{shortcutAlternatives(command.shortcut).map((key) => (
{hintKeys(command.shortcut).map((key) => (
<kbd key={key} className="rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.625rem] text-muted-foreground">{key}</kbd>
))}
</span>
@@ -182,7 +182,7 @@ describe('ContactsApp', () => {
it('starts a new group with the contact when "Add to group" finds no group to add to', () => {
renderContacts();
fireEvent.contextMenu(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByText('context_menu.add_to_group'));
fireEvent.click(screen.getByText('contacts_add_to_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
@@ -190,11 +190,27 @@ describe('ContactsApp', () => {
const team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: {}, name: { full: 'Team' }, members: {} };
renderContacts([alice, bob, team]);
fireEvent.contextMenu(screen.getByText('Bob Jones'));
fireEvent.click(screen.getByText('context_menu.add_to_group'));
fireEvent.click(screen.getByText('contacts_add_to_group'));
fireEvent.click(screen.getByText('bulk.new_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
it('offers no Edit, Move or Delete for a contact in an address book shared read-only', () => {
const shared = { ...alice, addressBookIds: { 'ab-shared': true } };
renderContacts([shared, bob]);
act(() => {
useContactStore.setState({ addressBooks: [{ id: 'ab-shared', name: 'Team', myRights: { mayRead: true, mayWrite: false } }] as never });
});
fireEvent.contextMenu(screen.getByText('Alice Smith'));
expect(screen.queryByRole('menuitem', { name: 'context_menu.edit' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: /context_menu.delete/ })).toBeNull();
expect(screen.getByRole('menuitem', { name: 'context_menu.export_vcard' })).toBeInTheDocument();
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
fireEvent.contextMenu(screen.getByText('Bob Jones'));
expect(screen.getByRole('menuitem', { name: 'context_menu.edit' })).toBeInTheDocument();
});
it('imports in a real dialog that Escape closes', () => {
renderContacts();
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
+20 -11
View File
@@ -65,6 +65,8 @@ interface ContactContextMenuProps {
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
/** The contact is in an address book shared read-only: no Edit, Move or Delete. */
readOnly?: boolean;
}
export function ContactContextMenu({
@@ -88,8 +90,10 @@ export function ContactContextMenu({
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
readOnly = false,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
const tMenu = useTranslations("menuAudit");
const printOff = useStrictestDevices().noPrint;
const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
@@ -134,7 +138,7 @@ export function ContactContextMenu({
</ContextMenuHeader>
<ContextMenuItem
icon={Users}
label={t("bulk.add_to_group")}
label={tMenu("contacts_bulk_add_to_group")}
onClick={handle(() => onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
@@ -179,12 +183,12 @@ export function ContactContextMenu({
onClick={handle(handleCall)}
/>
)}
{onEdit && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
{onEdit && !readOnly && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
{!inDetail && <ContextMenuSeparator />}
{onAddToGroup && (
<ContextMenuItem
icon={Users}
label={t("context_menu.add_to_group")}
label={tMenu("contacts_add_to_group")}
onClick={handle(onAddToGroup)}
/>
)}
@@ -195,7 +199,7 @@ export function ContactContextMenu({
onClick={handle(onDuplicate)}
/>
)}
{onMoveTo && (
{onMoveTo && !readOnly && (
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
)}
{email && (
@@ -225,13 +229,18 @@ export function ContactContextMenu({
onClick={handle(handlePrint)}
/>
)}
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
onClick={handle(onDelete)}
destructive
/>
{!readOnly && (
<>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
label={t("context_menu.delete")}
shortcut="Del"
onClick={handle(onDelete)}
destructive
/>
</>
)}
</ContextMenu>
);
}
+5 -2
View File
@@ -35,6 +35,8 @@ interface ContactDetailProps {
onPrint?: () => void;
isMobile?: boolean;
className?: string;
/** In an address book shared read-only: no Edit, Move or Delete. */
readOnly?: boolean;
}
// Contexts and phone features the contact form offers, shown with the same
@@ -74,7 +76,7 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
}
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className }: ContactDetailProps) {
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) {
const t = useTranslations("contacts");
const tCommon = useTranslations("common");
const locale = useLocale();
@@ -173,7 +175,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
</a>
)}
{/* A phone's row has no room for this label: the ⋮ fell onto a line of its own. */}
{isMobile ? (
{readOnly ? null : isMobile ? (
<ToolbarButton label={t("form.edit_title")} onClick={onEdit} className="touch-manipulation">
<Pencil />
</ToolbarButton>
@@ -207,6 +209,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
onClose={moreMenu.closeContextMenu}
menuRef={moreMenu.menuRef}
inDetail
readOnly={readOnly}
onCompose={onCompose}
onDelete={onDelete}
onAddToGroup={onAddToGroup}
+1 -1
View File
@@ -708,7 +708,7 @@ export function ContactForm({ contact, addressBooks, allKeywords, defaultAddress
<h2 className="text-lg font-semibold">
{isEditing ? t("edit_title") : t("create_title")}
</h2>
<IconButton label={t("close")} onClick={onCancel}>
<IconButton label={t("close")} onClick={onCancel} data-pane-close="">
<X />
</IconButton>
</div>
+4
View File
@@ -105,6 +105,8 @@ function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; la
interface ContactListProps {
contacts: ContactCard[];
/** A contact in an address book shared read-only: its menu has no Edit, Move or Delete. */
isContactReadOnly?: (contact: ContactCard) => boolean;
selectedContactId: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
@@ -144,6 +146,7 @@ interface ContactListProps {
export function ContactList({
contacts,
isContactReadOnly,
selectedContactId,
searchQuery,
onSearchChange,
@@ -788,6 +791,7 @@ export function ContactList({
onBatchExport={onBulkExport}
onBatchAddToGroup={onBulkAddToGroup}
onBatchDelete={onBulkDelete}
readOnly={isContactReadOnly?.(contextMenu.data) ?? false}
/>
)}
</div>
+5
View File
@@ -487,6 +487,9 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
// Address books contacts can be moved to; "Move to…" needs a second one.
const writableBooks = useMemo(() => addressBooks.filter((b) => b.myRights?.mayWrite !== false), [addressBooks]);
const canMoveContacts = !!client && supportsSync && writableBooks.length > 1;
// A contact in an address book shared read-only can't be changed: its menus leave out Edit, Move and Delete.
const isContactReadOnly = useCallback((contact: ContactCard) => Object.entries(contact.addressBookIds ?? {})
.some(([id, inBook]) => inBook && addressBooks.find((b) => b.id === id)?.myRights?.mayWrite === false), [addressBooks]);
const handleMoveContacts = useCallback((ids: string[]) => {
if (ids.length > 0) setMovingContactIds(ids);
}, []);
@@ -1144,6 +1147,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
onMoveTo={selectedContact && canMoveContacts ? () => handleMoveContacts([selectedContact.id]) : undefined}
onPrint={selectedContact && !printOff ? () => { void printContact(selectedContact); } : undefined}
isMobile={isMobile}
readOnly={selectedContact ? isContactReadOnly(selectedContact) : false}
/>
);
}
@@ -1398,6 +1402,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
) : (
<ContactList
contacts={displayedContacts}
isContactReadOnly={isContactReadOnly}
selectedContactId={selectedContactId}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
@@ -14,6 +14,14 @@ import { offerReturnedComposer, takeReturnedComposer } from '@/lib/popout/return
const push = vi.fn();
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push }) }));
// The catalog on screen: its language, and text that says which one it came from.
const intl = vi.hoisted(() => ({ locale: 'en', prefix: '', messages: {} as object }));
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => `${intl.prefix}${key}`,
useLocale: () => intl.locale,
useMessages: () => intl.messages,
}));
const canPrint = vi.fn(() => true);
const canDownload = vi.fn(() => true);
vi.mock('@/lib/device-policy', () => ({ canPrintAnywhere: () => canPrint(), canDownloadAnywhere: () => canDownload() }));
@@ -68,6 +76,7 @@ function fakeBridge() {
const original = useAuthStore.getState();
beforeEach(() => {
Object.assign(intl, { locale: 'en', prefix: '', messages: {} });
push.mockClear();
resolveExternalLink.mockClear();
resolveExternalLink.mockImplementation(async (ctx) => ctx.href);
@@ -170,11 +179,26 @@ describe('DesktopHost', () => {
it('says which languages are in use', async () => {
const shell = fakeBridge();
act(() => useLocaleStore.setState({ locale: 'de' }));
intl.locale = 'de';
render(<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' })}`;
+9 -4
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useMessages, useTranslations } from "next-intl";
import { useStrictestDevices } from "@/hooks/use-device-policy";
import { useRouter } from "@/i18n/navigation";
import { openKeyboardShortcuts, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
@@ -45,6 +45,8 @@ export function DesktopHost() {
const singleKeys = useSettingsStore((s) => s.singleKeyShortcuts);
const writingLanguage = useSettingsStore((s) => s.writingLanguage);
const uiLocale = useLocaleStore((s) => s.locale);
const activeLocale = useLocale();
const messages = useMessages();
const strictest = useStrictestDevices();
const { restoreWindows } = useDesktopPrefs();
const theme = useThemeStore((s) => s.theme);
@@ -116,11 +118,14 @@ export function DesktopHost() {
});
}, [isAuthenticated, singleKeys, strictest]);
// Menus and spellcheck follow the interface.
// Menus, the tray and spellcheck follow the interface: the language its text is in now,
// with that text. The stored choice isn't it: on the first render the catalog can still
// be the one the server picked from the OS language, and the chosen one lands a render
// later. Going by the choice alone left the menus in the OS language for good.
useEffect(() => {
const ui = uiLocale || document.documentElement.lang;
const ui = activeLocale || uiLocale || document.documentElement.lang;
if (ui) setDesktopInterfaceLanguage(ui, shellLabels(tRef.current as unknown as Parameters<typeof shellLabels>[0]));
}, [uiLocale]);
}, [activeLocale, uiLocale, messages]);
// What the user writes in, whichever composer asks for it too.
useEffect(() => {
@@ -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,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();
@@ -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');
});
});
@@ -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>
+4 -1
View File
@@ -56,6 +56,7 @@ import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { formatShortcutCombo } from "@/lib/shortcut-keys";
import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml, sanitizePluginBodyHtml } from "@/lib/email-sanitization";
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
import { isFilePreviewable } from "@/lib/file-preview";
@@ -592,6 +593,7 @@ export function EmailComposer({
replyTo
}: EmailComposerProps) {
const t = useTranslations('email_composer');
const tMenu = useTranslations('menuAudit');
const tCommon = useTranslations('common');
const tWindows = useTranslations('pwaWindows');
const tQuote = useTranslations('quote_header');
@@ -4894,6 +4896,7 @@ export function EmailComposer({
label={tCommon('close')}
data-testid="composer-close"
data-own-window-close={ownWindow ? "" : undefined}
data-pane-close=""
>
<X className="w-5 h-5 md:w-4 md:h-4" />
</IconButton>
@@ -5813,7 +5816,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"
+86 -82
View File
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { useSettingsStore } from "@/stores/settings-store";
import { useEmailStore, removalFolder, canRemoveFromFolder } from "@/stores/email-store";
import { useEmailTagScope, type TagScope } from "@/hooks/use-account-tags";
@@ -30,10 +30,12 @@ import {
Fish,
} 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 { 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 +133,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;
@@ -141,6 +145,12 @@ interface EmailContextMenuProps {
label?: string;
}
/** Entries that change the message on the server, or queue the change offline. */
const CHANGES_MAIL = new Set<MessageActionKey>([
"archive", "delete", "read", "remove_from_folder", "star", "pin", "spam",
"reschedule", "cancel_send", "cancel_and_edit",
]);
export function EmailContextMenu({
email,
position,
@@ -192,15 +202,21 @@ export function EmailContextMenu({
onPrint,
onViewSource,
omit,
views,
extras,
rowPlugins = true,
extraEntries,
label,
}: EmailContextMenuProps) {
const t = useTranslations("context_menu");
const shortcutOf = useMessageActionShortcut();
// Offline, a change to a message waits in the outbox only where the account keeps
// its mail on this device; otherwise it would fail, so it's greyed out. Rules and
// copies to another account always need the server.
const { offline, canChangeMail } = useConnectionState();
const tMenu = useTranslations("menuAudit");
const archiveActionEnabled = useArchiveActionEnabled();
const mountainView = useSettingsStore((state) => state.interfaceLayout === "mountain-view");
const tSidebar = useTranslations("sidebar");
const isUnread = !email.keywords?.$seen;
const currentTagIds = getEmailTagIds(email.keywords);
// The row's account names its tags; a selection uses batchTagScope.
@@ -221,68 +237,49 @@ export function EmailContextMenu({
? !!onBatchRemoveFromFolder && !!batchEmails?.length && batchEmails.every((e) => canRemoveFromFolder(e, folderToLeave))
: !!onRemoveFromFolder && canRemoveFromFolder(email, folderToLeave);
// Move-to submenu: the message's own account first (#1149)
const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, {
// Move-to submenu: the message's own account first (#1149), shared with
// every other Move list (lib/move-menu.ts).
const movedEmails = useMemo(
() => (showBatchActions ? batchEmails ?? [] : [email]),
[showBatchActions, batchEmails, email],
);
const moveModel = useMoveMenu(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox),
emails: showBatchActions ? batchEmails : [email],
emails: movedEmails,
});
// Filter tree to only include branches that contain valid targets
const filterTree = (nodes: MailboxNode[], targetIds: Set<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();
@@ -294,7 +291,7 @@ export function EmailContextMenu({
const singleEntries = useMessageActionList({
email,
currentMailboxRole,
canMove: moveTree.length > 0,
canMove: canMoveAnywhere,
canRemoveFromFolder: canRemove,
canCopyToAccount: !!onCopyToAccount && copyTrees.length > 0,
onReply,
@@ -319,6 +316,7 @@ export function EmailContextMenu({
onPrint,
onViewSource,
omit,
views,
extras,
});
@@ -356,8 +354,9 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Archive}
label={t("archive")}
shortcut={shortcutOf("archive")}
onClick={() => handleAction(onBatchArchive!)}
disabled={!onBatchArchive}
disabled={!onBatchArchive || !canChangeMail}
/>
</>
);
@@ -368,8 +367,9 @@ export function EmailContextMenu({
icon={Trash2}
label={t("delete")}
testId="ctx-delete"
shortcut={shortcutOf("delete")}
onClick={() => handleAction(onBatchDelete!)}
disabled={!onBatchDelete}
disabled={!onBatchDelete || !canChangeMail}
destructive={!mountainView}
/>
</>
@@ -381,6 +381,8 @@ export function EmailContextMenu({
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
shortcut={shortcutOf("read", isUnread)}
disabled={!canChangeMail}
onClick={() => handleAction(() => onBatchMarkAsRead?.(isUnread))}
/>
</>
@@ -388,9 +390,9 @@ export function EmailContextMenu({
const moveItem = (
<>
{/* Move to submenu */}
{moveTree.length > 0 && (
<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 +400,7 @@ export function EmailContextMenu({
icon={FolderX}
label={t("remove_from_folder")}
testId="ctx-remove-from-folder"
disabled={!canChangeMail}
onClick={() => handleAction(onBatchRemoveFromFolder!)}
/>
)}
@@ -407,9 +410,7 @@ export function EmailContextMenu({
? copyTrees.map((target) => (
<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 +418,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 +427,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 +458,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,26 +479,26 @@ 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} />;
}
};
@@ -509,8 +511,9 @@ export function EmailContextMenu({
icon={entry.icon}
label={entry.label}
testId={entry.testId}
disabled={entry.disabled}
disabled={entry.disabled || (!canChangeMail && CHANGES_MAIL.has(entry.key))}
destructive={entry.destructive}
shortcut={shortcutOf(entry.key, isUnread)}
onClick={() => handleAction(entry.run)}
/>
);
@@ -524,6 +527,7 @@ export function EmailContextMenu({
onClose={onClose}
label={label}
>
{offline && <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader>}
{showBatchActions ? (
<>
<ContextMenuHeader>
+73 -70
View File
@@ -3,7 +3,6 @@
import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { openMailImport } from "@/stores/mail-transfer-store";
import { TRANSPARENT_BLOCKED_PIXEL, applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { mailLanguage } from "@/lib/mail-language";
@@ -47,11 +46,12 @@ import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/p
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { buildMoveMenu, type MoveMenuModel } from "@/lib/move-menu";
import { emailDisplayDate } from "@/lib/email-date";
import { TagBadge } from "./tag-badge";
import { TagPicker } from "./tag-picker";
@@ -63,7 +63,8 @@ import { getSecurityStatus, extractListHeaders, getSenderVerification, brandIndi
import { emailToReadView } from "@/lib/plugin-projection";
import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
import { ViewSourceDialog } from "./view-source-dialog";
import { MoveTargetList } from "./move-target-list";
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "./move-target-list";
import { useChooseMoveFolder } from "./move-to-dialog";
import { SaveToFilesDialog, type SaveableAttachment } from "./save-to-files-dialog";
import { usePolicyStore } from "@/stores/policy-store";
import { ScheduleSendDialog } from "./schedule-send-dialog";
@@ -106,7 +107,6 @@ import {
Send,
FolderInput,
Sun,
Upload,
Moon,
PlayCircle,
PenSquare,
@@ -122,7 +122,7 @@ import {
Paperclip,
} from "@/components/icons";
import { MailMessageSkeleton, MailMessageToolbarSkeleton } from "@/components/boot-skeleton/mail-skeleton";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
@@ -155,6 +155,7 @@ import { useTour } from "@/components/tour/tour-provider";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover, RecipientCardPopover, type CardAnchor } from "./recipient-popover";
import { BreakableAddress } from "@/components/ui/breakable-address";
import { mentionFromLink } from "@/lib/recipient-mentions";
import { MailtoLink } from "@/components/ui/mailto-link";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
@@ -278,7 +279,6 @@ interface EmailViewerProps {
onReplyAllConversationEmail?: (email: Email) => void;
/** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */
mvInlineComposer?: React.ReactNode;
onShowShortcuts?: () => void;
onEditDraft?: () => void;
onCancelScheduledForEdit?: () => void;
/** Stop a scheduled message from going out; it goes back to Drafts. */
@@ -1157,6 +1157,7 @@ function ReaderMoreMenu({ menu, options, label, mailboxes, selectedMailbox, onMo
onPrint={options.onPrint}
onViewSource={options.onViewSource}
omit={options.omit}
views={options.views}
extras={options.extras}
rowPlugins={false}
extraEntries={pluginEntries}
@@ -1172,8 +1173,7 @@ interface ReaderMorePanelBodyProps {
openSub: (sub: MoreMenuSub) => void;
close: () => void;
entryRefs: React.MutableRefObject<Record<MoreMenuSub, HTMLButtonElement | null>>;
moveTree: MailboxNode[];
moveTargetIds: Set<string>;
moveModel: MoveMenuModel;
onMoveToMailbox?: (mailboxId: string) => void;
currentTagIds: string[];
sortedTagIds: string[];
@@ -1197,8 +1197,7 @@ function ReaderMorePanelBody({
openSub,
close,
entryRefs,
moveTree,
moveTargetIds,
moveModel,
onMoveToMailbox,
currentTagIds,
sortedTagIds,
@@ -1209,6 +1208,7 @@ function ReaderMorePanelBody({
pluginTarget,
}: ReaderMorePanelBodyProps) {
const { email } = options;
const locale = useLocale();
const { tagColor } = useKeywordFormat(tagScope);
const { copyTargets, copyToAccount } = useCopyToAccount(email, true);
const entries = useMessageActionList({
@@ -1218,11 +1218,10 @@ function ReaderMorePanelBody({
});
if (sub === 'move') {
return moveTree.length > 0 && onMoveToMailbox ? (
<MoveTargetList
return !moveMenuIsEmpty(moveModel) && onMoveToMailbox ? (
<MoveFolderList
variant="panel"
tree={moveTree}
targetIds={moveTargetIds}
model={moveModel}
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); close(); }}
/>
) : null;
@@ -1231,14 +1230,15 @@ function ReaderMorePanelBody({
return (
<>
{copyTargets.map((target) => {
const { tree, targetIds } = buildMoveTargets(target.mailboxes.filter((m) => !m.isShared && m.role !== 'drafts'), {});
const model = buildMoveMenu(target.mailboxes.filter((m) => !m.isShared), { locale });
return (
<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>
@@ -1341,7 +1341,6 @@ export function EmailViewer({
onReplyConversationEmail,
onReplyAllConversationEmail,
mvInlineComposer,
onShowShortcuts,
onEditDraft,
onCancelScheduledForEdit,
onCancelScheduledSend,
@@ -1364,7 +1363,6 @@ export function EmailViewer({
const tWindows = useTranslations('pwaWindows');
const tComposer = useTranslations('email_composer');
const tNotifications = useTranslations('notifications');
const tImport = useTranslations('mail_import');
const tCommon = useTranslations('common');
const tFiles = useTranslations('files');
const tDemoWelcome = useTranslations('demo_welcome');
@@ -1978,14 +1976,14 @@ export function EmailViewer({
// Move-to dropdown: the message's own account first (#1149)
const moveOwnerAccountId = resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox);
const { tree: moveTree, targetIds: moveTargetIds } = useMemo(
() => buildMoveTargets(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: moveOwnerAccountId,
emails: email ? [email] : [],
}),
[mailboxes, selectedMailbox, moveOwnerAccountId, email],
);
const moveEmails = useMemo(() => (email ? [email] : []), [email]);
const moveModel = useMoveMenu(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: moveOwnerAccountId,
emails: moveEmails,
});
const canMoveAnywhere = !moveMenuIsEmpty(moveModel);
const chooseMoveFolder = useChooseMoveFolder();
// Close dropdown menus on click outside
useEffect(() => {
@@ -2164,7 +2162,7 @@ export function EmailViewer({
email?.id,
showToolbarLabels,
isLoading,
moveTree.length,
canMoveAnywhere,
emailKeywords.length,
currentColor,
isInJunkFolder,
@@ -3768,11 +3766,6 @@ export function EmailViewer({
await exportEmail(email, { onArchive, onDelete, archiveEnabled: archiveActionEnabled }, blobSource ?? { client, accountId: undefined });
};
// Import mail into the open folder: the import dialog (lib/mail-import) does the rest.
const handleImportEmail = () => {
openMailImport({ mailboxId: useEmailStore.getState().selectedMailbox || undefined });
};
// What a printout of the message shows, for the Print action's window and
// for the page's print copy (Ctrl+P). Text fields are unescaped.
const messagePrintFields = (): MessagePrintFields | null => {
@@ -4228,6 +4221,8 @@ ${printWatermark ? printWatermark.html : ''}
// the bottom bar has the replies.
const toolbarShownActions = (): ReadonlySet<MessageActionKey> => {
const shown = new Set<MessageActionKey>(['delete', 'edit_draft']);
// The star sits by the subject, or on the sender's line in Mountain View.
if (toolbarPosition === 'top' || mvViewer) shown.add('star');
toolbarRef.current?.querySelectorAll<HTMLElement>('[data-overflow-item]').forEach((item) => {
const key = TOOLBAR_PRIORITY_ACTIONS[item.dataset.overflowPriority ?? ''];
if (key && item.getClientRects().length > 0) shown.add(key);
@@ -4248,9 +4243,10 @@ ${printWatermark ? printWatermark.html : ''}
return hidden;
};
const toggleViewMode = () => setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev);
// The reader's own entries after the message's: the view toggle, Import
// (into the open folder) and, with a keyboard, the shortcuts.
const readerExtras: MessageActionEntry[] = [
// How the reader shows the message, a group of the ⋮ menu of its own. Import
// and the keyboard shortcuts are no message's: Import is in the folder's menu,
// Settings and the command palette, the shortcuts behind ?, the rail and Help.
const readerViews: MessageActionEntry[] = [
...(readAloud.supported && !isBodyLoading
? [{ kind: 'item' as const, key: 'extra:read_aloud' as const, icon: Volume2, label: t('read_aloud'), run: readAloud.start }]
: []),
@@ -4263,15 +4259,11 @@ ${printWatermark ? printWatermark.html : ''}
...(effectiveEmailContent.isHtml
? [{ kind: 'item' as const, key: 'extra:view_mode' as const, icon: isDark ? Sun : Moon, label: isDark ? t('view_in_light_mode') : t('view_in_dark_mode'), run: toggleViewMode }]
: []),
{ kind: 'item', key: 'extra:import', icon: Upload, label: tImport('menu_import'), run: handleImportEmail },
...(onShowShortcuts && !isMobile
? [{ kind: 'item' as const, key: 'extra:shortcuts' as const, icon: Keyboard, label: t('keyboard_shortcuts'), run: onShowShortcuts }]
: []),
];
const readerActionOptions: MessageActionOptions = {
email,
currentMailboxRole,
canMove: moveTree.length > 0 && !!onMoveToMailbox,
canMove: canMoveAnywhere && !!onMoveToMailbox,
canRemoveFromFolder: !!onRemoveFromFolder,
onReply: onReply ? () => onReply() : undefined,
onReplyAll,
@@ -4291,7 +4283,7 @@ ${printWatermark ? printWatermark.html : ''}
onPrint: devices.noPrint ? undefined : () => handlePrint(),
onViewSource: devices.noExport ? undefined : () => setShowSourceModal(true),
omit: readerMenuOmit,
extras: readerExtras.filter((entry) => entry.kind !== 'item' || !readerMenuOmit.has(entry.key)),
views: readerViews,
};
// Shared toolbar items used by both 'top' and 'below-subject' positions
@@ -4467,7 +4459,7 @@ ${printWatermark ? printWatermark.html : ''}
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('delete')}</span>}
</Button>
{/* Move to folder */}
{moveTree.length > 0 && onMoveToMailbox && (
{canMoveAnywhere && onMoveToMailbox && (
<div data-overflow-item data-overflow-priority="5" className={cn("relative", mvViewer && "order-6")}>
<Button
variant="ghost"
@@ -4487,11 +4479,11 @@ ${printWatermark ? printWatermark.html : ''}
{showToolbarLabels && <span className="text-[0.625rem] leading-tight sm:hidden sm:text-sm sm:whitespace-nowrap sm:@[30rem]/reader-toolbar:inline">{t('move')}</span>}
</Button>
<ContextMenu ref={moveMenu.menuRef} isOpen={moveMenuOpen} position={moveMenu.contextMenu.position} onClose={closeMoveMenu} label={t('move_to')}>
<div className="w-56 max-h-72 overflow-y-auto">
<MoveTargetList
tree={moveTree}
targetIds={moveTargetIds}
<div className="w-64 max-w-full" data-reader-move-menu="">
<MoveFolderList
model={moveModel}
onPick={(mailboxId) => { onMoveToMailbox(mailboxId); closeMoveMenu(); }}
onChooseFolder={chooseMoveFolder ? () => { closeMoveMenu(); chooseMoveFolder({ emails: moveEmails, onMove: onMoveToMailbox }); } : undefined}
/>
</div>
</ContextMenu>
@@ -4831,8 +4823,7 @@ ${printWatermark ? printWatermark.html : ''}
openSub={setMoreMenuSub}
close={closeMoreMenu}
entryRefs={moreEntryRefs}
moveTree={moveTree}
moveTargetIds={moveTargetIds}
moveModel={moveModel}
onMoveToMailbox={onMoveToMailbox}
currentTagIds={currentTagIds}
sortedTagIds={sortedTagIds}
@@ -5413,6 +5404,11 @@ ${printWatermark ? printWatermark.html : ''}
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '-';
// A long date wraps over three lines in the narrow column: the short one
// shows, the full one is on hover.
const shownDate = (iso?: string) => iso
? <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;
@@ -5423,7 +5419,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",
@@ -5440,7 +5436,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",
@@ -5450,14 +5446,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>
@@ -5478,17 +5474,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"
/>
@@ -5542,10 +5545,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>
)}
@@ -5590,7 +5593,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",
@@ -5613,7 +5616,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>
@@ -5644,9 +5647,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>
@@ -5678,7 +5681,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>
)}
@@ -5701,9 +5704,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>
)}
@@ -5730,7 +5733,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]);
}
+17 -7
View File
@@ -125,6 +125,8 @@ export interface MessageActionOptions {
onViewSource?: () => 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[];
}
@@ -230,7 +232,6 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
void copyLink(buildMailPath({ mailboxId: null, emailId: null, threadId: email.threadId!, ...emailLinkSource(email) }));
})]
: []),
SEPARATOR,
];
const archive = archiveEnabled ? [item("archive", Archive, t("archive"), o.onArchive)] : [];
const del = item("delete", Trash2, t("delete"), o.onDelete, { destructive: !mountainView, testId: "ctx-delete" });
@@ -267,25 +268,34 @@ export function useMessageActionList(o: MessageActionOptions): MessageActionEntr
const find = o.onFindFromSender && sender?.email
? [SEPARATOR, item("find_from_sender", Search, t("find_emails_from", { name: sender.name || sender.email }), o.onFindFromSender, { testId: "ctx-find-from" })]
: [];
// Download/Export, Print, View source (R6: after Copy, before the end).
const openWindow = o.onOpenInWindow
? [item("open_window", ExternalLink, tWindows("open_in_window"), o.onOpenInWindow, { testId: "ctx-open-window" })]
: [];
// How the host shows it, then Download/Export, Print, View source, at the end.
const views = o.views ?? [];
const file = [
...(o.onOpenInWindow ? [item("open_window", ExternalLink, tWindows("open_in_window"), o.onOpenInWindow, { testId: "ctx-open-window" })] : []),
...(o.onExport ? [item("export", Download, tViewer("export_email"), o.onExport, { disabled: !email.blobId, testId: "ctx-export" })] : []),
...(o.onPrint ? [item("print", Printer, tViewer("print"), o.onPrint, { testId: "ctx-print" })] : []),
...(o.onViewSource ? [item("view_source", Code, tViewer("view_source"), o.onViewSource, { disabled: !email.blobId, testId: "ctx-view-source" })] : []),
];
// In groups, as mail clients have them: answer; file away; mark; spam;
// open and link; how it shows; save, print, source.
list = mountainView
? [
...draft, ...replies, ...archive, del, read, SEPARATOR,
...move, tag, ...rules, ...copy, SEPARATOR,
star, ...pin, ...spam, ...reportPhishing, ...find, SEPARATOR,
...links, ...file,
...links, SEPARATOR, ...views, SEPARATOR, ...openWindow, ...file,
]
: [
...draft, ...replies, ...links, ...archive, del, SEPARATOR,
...move, ...copy, ...rules, star, ...pin, tag, ...spam, ...reportPhishing, SEPARATOR,
read, SEPARATOR, ...file,
...draft, ...replies, ...archive, del, SEPARATOR,
...move, ...copy, ...rules, SEPARATOR,
read, star, ...pin, tag, SEPARATOR,
...spam, ...reportPhishing, SEPARATOR,
...openWindow, ...links, SEPARATOR,
...views, SEPARATOR,
...file,
];
}
+256 -44
View File
@@ -1,9 +1,21 @@
"use client";
import { useTranslations } from "next-intl";
import { Archive, Ban, File, Folder, Inbox, Send, Trash2, type AppIcon } from "@/components/icons";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import { Archive, Ban, Clock, File, Folder, FolderSearch, Inbox, Search, Send, Trash2, Users, type AppIcon } from "@/components/icons";
import { ContextMenuHeader, ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import { localizeMailboxName } from "@/lib/mailbox-label";
import type { MailboxNode } from "@/lib/utils";
import type { Email, Mailbox } from "@/lib/jmap/types";
import {
buildMoveMenu,
countTargets,
filterMoveNodes,
firstEnabled,
MOVE_FILTER_THRESHOLD,
type MoveMenuModel,
type MoveMenuNode,
} from "@/lib/move-menu";
import { noteMoveTarget, readRecentMoveTargets } from "@/lib/move-recent";
import { cn } from "@/lib/utils";
/** A folder's icon in a Move list, the sidebar's for the folders with a role. */
@@ -19,58 +31,258 @@ export function moveMailboxIcon(role?: string | null): AppIcon {
}
}
interface MoveTargetListProps {
/** From buildMoveTargets. */
tree: MailboxNode[];
targetIds: Set<string>;
type MoveMenuEmails = Array<Pick<Email, "mailboxIds" | "sourceAccountId">>;
/** The Move list for these messages, with this device's recent folders. */
export function useMoveMenu(
mailboxes: Mailbox[],
opts: { currentMailboxId?: string | null; ownerAccountId?: string; emails?: MoveMenuEmails },
): MoveMenuModel {
const locale = useLocale();
const { currentMailboxId, ownerAccountId, emails } = opts;
return useMemo(
() => buildMoveMenu(mailboxes, { currentMailboxId, ownerAccountId, emails, locale, recentIds: readRecentMoveTargets }),
[mailboxes, currentMailboxId, ownerAccountId, emails, locale],
);
}
/** Whether the list is long enough for a filter field and "Choose folder…". */
export function moveMenuIsLong(model: MoveMenuModel): boolean {
return model.targetCount > MOVE_FILTER_THRESHOLD;
}
export function moveMenuIsEmpty(model: MoveMenuModel): boolean {
return model.targetCount === 0 && model.others.length === 0;
}
interface MoveFolderListProps {
model: MoveMenuModel;
onPick: (mailboxId: string) => void;
/** `menu` for a dropdown, `panel` for the phone's full-width panel (44px rows). */
variant?: "menu" | "panel";
/**
* `menu`: inside a ContextMenu; other accounts are submenus.
* `dialog`: a list in a dialog; other accounts follow under their names.
* `panel`: the phone's full-width panel (44px rows).
*/
variant?: "menu" | "dialog" | "panel";
/** Opens the Move dialog. Offered at the end of a long list, since a native menu has no filter field. */
onChooseFolder?: () => void;
/** Always show the filter field (the dialog). */
alwaysFilter?: boolean;
/** Stable hook for integration tests: each row is `<prefix><mailbox id>`. */
testIdPrefix?: string;
/** Don't keep the pick as a recent folder (a copy, not a move). */
noRecent?: boolean;
}
/**
* The folders a message can be moved to, as the reader's Move dropdown, its ⋮
* and the phone panel show them: the names the sidebar shows (localized
* Sent, Spam, Trash), subfolders indented on the reading side.
* The folders messages can be moved to (lib/move-menu.ts): recent ones, the
* account's own folders nested and in mail-client order, the open folder
* greyed, other reachable accounts under their names, and a filter field
* once the list is long.
*
* Each folder with subfolders is wrapped in `data-native-submenu`: the
* desktop app's native copy of the menu makes it a submenu there, the folder
* itself first (lib/desktop/native-menu.ts).
*/
export function MoveTargetList({ tree, targetIds, onPick, variant = "menu" }: MoveTargetListProps) {
export function MoveFolderList({
model,
onPick,
variant = "menu",
onChooseFolder,
alwaysFilter = false,
testIdPrefix = "move-to:",
noRecent = false,
}: MoveFolderListProps) {
const t = useTranslations("menuMove");
const tSidebar = useTranslations("sidebar");
const locale = useLocale();
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const panel = variant === "panel";
const indent = (depth: number) => `${(panel ? 1 : 0.75) + depth}rem`;
const showFilter = alwaysFilter || moveMenuIsLong(model);
const render = (nodes: MailboxNode[], depth: number): React.ReactNode =>
nodes.map((node) => {
const Icon = moveMailboxIcon(node.role);
const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`));
const rowClass = panel
? "w-full pe-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3"
: "w-full pe-3 py-1.5 text-sm text-start flex items-center gap-2 pointer-coarse:min-h-11";
const icon = <Icon className={cn(panel ? "w-5 h-5" : "w-4 h-4", "flex-shrink-0")} />;
const labelOf = (node: Pick<MoveMenuNode, "role" | "name">) =>
localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`));
const nodes = filterMoveNodes(model.nodes, query, labelOf, locale);
const others = model.others
.map((account) => ({ ...account, nodes: filterMoveNodes(account.nodes, query, labelOf, locale) }))
.filter((account) => countTargets(account.nodes) > 0);
const filtering = query.trim().length > 0;
const pick = (id: string) => {
if (!noRecent) noteMoveTarget(model.accountId, id);
onPick(id);
};
// The filter takes the focus as the list shows, so typing narrows it at
// once. It can be hidden for a frame while its panel is placed.
useEffect(() => {
if (!showFilter || panel) return;
let tries = 0;
let frame = 0;
const attempt = () => {
const input = inputRef.current;
if (!input) return;
const visible = input.getClientRects().length > 0 && getComputedStyle(input).visibility !== "hidden";
if (visible) {
input.focus({ preventScroll: true });
return;
}
if (++tries < 20) frame = requestAnimationFrame(attempt);
};
frame = requestAnimationFrame(attempt);
return () => cancelAnimationFrame(frame);
}, [showFilter, panel]);
const row = (node: MoveMenuNode, label: string, key: string, title?: string) => {
const Icon = moveMailboxIcon(node.role);
const rowTitle = title ?? (node.current ? t("current_folder", { folder: label }) : label);
if (panel) {
return (
<div key={node.id}>
{targetIds.has(node.id) ? (
<button
type="button"
role="menuitem"
data-mailbox-id={node.id}
onClick={() => onPick(node.id)}
className={cn(rowClass, "hover:bg-muted focus-visible:bg-muted focus-visible:outline-none")}
style={{ paddingInlineStart: indent(depth) }}
title={label}
>
{icon}
<span className="truncate" dir="auto">{label}</span>
</button>
) : (
<div className={cn(rowClass, "text-muted-foreground")} style={{ paddingInlineStart: indent(depth) }}>
{icon}
<span className="truncate" dir="auto" title={label}>{label}</span>
</div>
)}
{node.children.length > 0 && render(node.children, depth + 1)}
<button
key={key}
type="button"
role="menuitem"
data-mailbox-id={node.id}
data-testid={`${testIdPrefix}${node.id}`}
disabled={!node.enabled}
onClick={() => pick(node.id)}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none disabled:text-muted-foreground disabled:hover:bg-transparent"
title={rowTitle}
>
<Icon className="w-5 h-5 flex-shrink-0" />
<span className="truncate" dir="auto" data-menu-label="">{label}</span>
</button>
);
}
return (
<ContextMenuItem
key={key}
icon={Icon}
label={label}
title={rowTitle}
disabled={!node.enabled}
testId={`${testIdPrefix}${node.id}`}
onClick={() => pick(node.id)}
/>
);
};
const tree = (list: MoveMenuNode[]): React.ReactNode =>
list.map((node) => {
const label = labelOf(node);
if (node.children.length === 0) return row(node, label, node.id);
return (
<div key={node.id} data-native-submenu="">
{row(node, label, node.id)}
<div className={panel ? "ps-6" : "ps-4"}>{tree(node.children)}</div>
</div>
);
});
return <>{render(tree, 0)}</>;
const recent = !filtering && !noRecent ? model.recent : [];
const nothing = nodes.length === 0 && others.length === 0;
const separator = panel ? <div className="h-px bg-border my-1" role="separator" /> : <ContextMenuSeparator />;
return (
<div data-move-folder-list="">
{showFilter && (
<div className={cn("sticky top-0 z-10 bg-background", panel ? "px-4 py-2" : "px-2 pt-1 pb-1")}>
{/* The field is the whole box, so the app's focus ring goes round all of it. */}
<div className="relative">
<Search className="pointer-events-none absolute start-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
e.preventDefault();
e.stopPropagation();
const first = firstEnabled(nodes) ?? others.map((a) => firstEnabled(a.nodes)).find(Boolean);
if (first) pick(first.id);
}}
placeholder={t("filter_placeholder")}
aria-label={t("filter_placeholder")}
className={cn("focus-inset w-full min-w-0 rounded-md border border-border bg-transparent ps-8 pe-2 text-sm", panel ? "h-10" : "h-8")}
data-testid="move-filter"
/>
</div>
</div>
)}
{recent.length > 0 && (
<div data-move-recent="">
{panel ? (
<div className="px-4 pt-3 pb-1 text-xs font-medium text-muted-foreground flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" />
{t("recent")}
</div>
) : (
<ContextMenuHeader>{t("recent")}</ContextMenuHeader>
)}
{recent.map((node) => {
const label = [...node.path, labelOf(node)].join(" / ");
return row({ ...node, children: [] }, label, `recent-${node.id}`, label);
})}
{separator}
</div>
)}
{tree(nodes)}
{nothing && (
<div className={cn("text-sm text-muted-foreground", panel ? "px-4 py-3" : "px-3 py-2")}>{t("no_match")}</div>
)}
{others.length > 0 && (variant === "menu" ? (
<>
{nodes.length > 0 && <ContextMenuSeparator />}
{others.map((account) => (
<ContextMenuSubMenu
key={account.accountId}
icon={Users}
label={account.name}
testId={`move-account:${account.accountId}`}
>
{tree(account.nodes)}
</ContextMenuSubMenu>
))}
</>
) : (
others.map((account) => (
<div key={account.accountId} data-move-account={account.accountId}>
{panel ? (
<div className="px-4 pt-3 pb-1 text-xs font-medium text-muted-foreground truncate" title={account.name}>{account.name}</div>
) : (
<ContextMenuHeader>{account.name}</ContextMenuHeader>
)}
{tree(account.nodes)}
</div>
))
))}
{onChooseFolder && moveMenuIsLong(model) && !filtering && (
<>
{separator}
{panel ? (
<button
type="button"
role="menuitem"
onClick={onChooseFolder}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start flex items-center gap-3 hover:bg-muted"
data-testid="move-choose-folder"
>
<FolderSearch className="w-5 h-5 flex-shrink-0" />
<span data-menu-label="">{t("choose_folder")}</span>
</button>
) : (
<ContextMenuItem icon={FolderSearch} label={t("choose_folder")} onClick={onChooseFolder} testId="move-choose-folder" />
)}
</>
)}
</div>
);
}
+72 -21
View File
@@ -1,15 +1,47 @@
"use client";
import { useMemo } from "react";
import { useEffect } from "react";
import { useTranslations } from "next-intl";
import { create } from "zustand";
import { Dialog } from "@/components/ui/dialog";
import type { Email, Mailbox } from "@/lib/jmap/types";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { MoveTargetList } from "./move-target-list";
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { MoveFolderList, useMoveMenu } from "./move-target-list";
interface MoveDialogRequest {
/** The messages to move. */
emails: Email[];
onMove: (mailboxId: string) => void;
}
interface MoveDialogState {
request: MoveDialogRequest | null;
/** Mounted hosts: without one, nothing offers the dialog. */
hosts: number;
open: (request: MoveDialogRequest) => void;
close: () => void;
}
export const useMoveDialogStore = create<MoveDialogState>((set) => ({
request: null,
hosts: 0,
open: (request) => set({ request }),
close: () => set({ request: null }),
}));
/**
* "Choose folder…" at the end of a long Move list: the dialog with the filter
* field. Undefined where no dialog host is mounted.
*/
export function useChooseMoveFolder(): ((request: MoveDialogRequest) => void) | undefined {
const hosts = useMoveDialogStore((s) => s.hosts);
const open = useMoveDialogStore((s) => s.open);
return hosts > 0 ? open : undefined;
}
interface MoveToDialogProps {
/** The message to move; the dialog is open while it is set. */
email: Email | null;
/** The messages to move; the dialog is open while there are some. */
emails: Email[];
mailboxes: Mailbox[];
selectedMailbox: string;
onClose: () => void;
@@ -17,25 +49,24 @@ interface MoveToDialogProps {
}
/**
* "Move to…" from the command palette: the same folder list as the reader's
* Move menu (the message's own account first), in a dialog.
* Move to…: the same folders as the Move menus (lib/move-menu.ts), in a
* dialog with a filter field. From the command palette, the Message menu and
* "Choose folder…" in a long Move menu.
*/
export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) {
export function MoveToDialog({ emails, mailboxes, selectedMailbox, onClose, onMove }: MoveToDialogProps) {
const t = useTranslations("context_menu");
const { tree, targetIds } = useMemo(
() => buildMoveTargets(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: resolveMoveOwnerAccountId(email, mailboxes, selectedMailbox),
emails: email ? [email] : [],
}),
[email, mailboxes, selectedMailbox],
);
const model = useMoveMenu(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: resolveMoveOwnerAccountId(emails[0], mailboxes, selectedMailbox),
emails,
});
return (
<Dialog isOpen={!!email} onClose={onClose} title={t("move_to_dialog")} bodyClassName="px-2" size="sm">
<div role="menu" aria-label={t("move_to_dialog")} className="max-h-[60vh] overflow-y-auto">
<MoveTargetList
tree={tree}
targetIds={targetIds}
<Dialog isOpen={emails.length > 0} onClose={onClose} title={t("move_to_dialog")} bodyClassName="px-2" size="sm">
<div role="menu" aria-label={t("move_to_dialog")} className="max-h-[60vh] overflow-y-auto" data-testid="move-dialog">
<MoveFolderList
variant="dialog"
alwaysFilter
model={model}
onPick={(mailboxId) => {
onClose();
onMove(mailboxId);
@@ -45,3 +76,23 @@ export function MoveToDialog({ email, mailboxes, selectedMailbox, onClose, onMov
</Dialog>
);
}
/** Shows the dialog a Move menu asked for. Mounted once, where the mail is. */
export function MoveToDialogHost({ mailboxes, selectedMailbox }: { mailboxes: Mailbox[]; selectedMailbox: string }) {
const request = useMoveDialogStore((s) => s.request);
const close = useMoveDialogStore((s) => s.close);
useEffect(() => {
useMoveDialogStore.setState((s) => ({ hosts: s.hosts + 1 }));
return () => useMoveDialogStore.setState((s) => ({ hosts: s.hosts - 1, request: s.hosts <= 1 ? null : s.request }));
}, []);
if (!request) return null;
return (
<MoveToDialog
emails={request.emails}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onClose={close}
onMove={request.onMove}
/>
);
}
+17 -70
View File
@@ -1,17 +1,15 @@
"use client";
import { useEffect, useState } from "react";
import { useState } from "react";
import {
Archive,
CheckSquare,
Folder,
FolderInput,
FolderX,
Loader2,
Mail,
MailOpen,
MoreVertical,
Search,
ShieldAlert,
ShieldCheck,
Square,
@@ -31,14 +29,15 @@ import { markSelectionReadWithUndo, runBatchEmailAction, setSelectionTagWithUndo
import { toast } from "@/stores/toast-store";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useContextMenu } from "@/hooks/use-context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuHeader, CONTEXT_MENU_ITEM_CLASS } from "@/components/ui/context-menu";
import { ContextMenu, ContextMenuItem, ContextMenuSeparator, ContextMenuHeader } from "@/components/ui/context-menu";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { TagPicker } from "@/components/email/tag-picker";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { MoveFolderList, moveMenuIsEmpty, useMoveMenu } from "@/components/email/move-target-list";
import { useChooseMoveFolder } from "@/components/email/move-to-dialog";
import { getEmailTagIds } from "@/lib/thread-utils";
import { cn, type MailboxNode } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { PluginActions } from "@/components/plugins/plugin-actions";
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
import { useTimeoutWhileMounted } from "@/hooks/use-timeout-while-mounted";
@@ -170,8 +169,6 @@ export function MvListToolbar({
const moveOpen = moveMenu.contextMenu.isOpen;
const closeMove = moveMenu.closeContextMenu;
// Gmail's "Move to" menu opens on a search field that narrows the folders.
const [moveQuery, setMoveQuery] = useState("");
useEffect(() => { if (!moveOpen) setMoveQuery(""); }, [moveOpen]);
const tagMenu = useContextMenu<null>();
const tagOpen = tagMenu.contextMenu.isOpen;
const closeTag = tagMenu.closeContextMenu;
@@ -288,58 +285,12 @@ export function MvListToolbar({
// The same targets the row's own context menu offers, with the account of
// the selection leading (#1149).
const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, {
const moveModel = useMoveMenu(mailboxes, {
currentMailboxId: selectedMailbox,
ownerAccountId: resolveMoveOwnerAccountId(selectedEmails[0], mailboxes, selectedMailbox),
emails: selectedEmails,
});
const renderMoveNodes = (nodes: MailboxNode[], depth = 0): React.ReactNode =>
nodes.map((node) => {
const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`));
const isTarget = moveTargetIds.has(node.id);
return (
<div key={node.id}>
{isTarget ? (
<button
type="button"
role="menuitem"
onClick={() => handleMove(node.id)}
style={{ paddingInlineStart: `${12 + depth * 16}px` }}
className={CONTEXT_MENU_ITEM_CLASS}
>
<Folder className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{label}</span>
</button>
) : (
<div
style={{ paddingInlineStart: `${12 + depth * 16}px` }}
className="flex items-center gap-2 pe-3 py-1.5 text-sm text-muted-foreground"
>
<Folder className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{label}</span>
</div>
)}
{node.children.length > 0 && renderMoveNodes(node.children, depth + 1)}
</div>
);
});
// While a search narrows the move menu it is a flat list of the matches.
const moveMatches = (() => {
const q = moveQuery.trim().toLowerCase();
if (!q) return null;
const out: MailboxNode[] = [];
const walk = (nodes: MailboxNode[]) => {
for (const node of nodes) {
const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`));
if (moveTargetIds.has(node.id) && label.toLowerCase().includes(q)) out.push({ ...node, children: [] });
walk(node.children);
}
};
walk(moveTree);
return out;
})();
const chooseMoveFolder = useChooseMoveFolder();
// What the tag picker shows: a tag every selected message carries is
// checked, one only some of them carry is drawn as partial.
@@ -560,7 +511,7 @@ export function MvListToolbar({
disabled={isProcessing}
/>
<div className="w-px h-6 bg-border mx-1 shrink-0" />
{moveTree.length > 0 && (
{!moveMenuIsEmpty(moveModel) && (
<div className="relative shrink-0">
<button
type="button"
@@ -581,18 +532,14 @@ export function MvListToolbar({
<ContextMenu ref={moveMenu.menuRef} isOpen={moveOpen} position={moveMenu.contextMenu.position} onClose={closeMove}>
<div className="min-w-64" data-mv-move-menu="">
<ContextMenuHeader>{tMenu("move_to")}</ContextMenuHeader>
<label className="mx-3 my-1 flex items-center gap-2 border-b border-border">
<input
value={moveQuery}
onChange={(e) => setMoveQuery(e.target.value)}
className="h-8 min-w-0 flex-1 bg-transparent text-sm focus:outline-none"
aria-label={tMenu("move_to")}
/>
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
</label>
<div className="max-h-72 overflow-y-auto">
{moveMatches ? renderMoveNodes(moveMatches) : renderMoveNodes(moveTree)}
</div>
<MoveFolderList
model={moveModel}
alwaysFilter
onPick={handleMove}
onChooseFolder={chooseMoveFolder
? () => { closeMove(); chooseMoveFolder({ emails: selectedEmails, onMove: handleMove }); }
: undefined}
/>
{onCreateFolder && (
<>
<ContextMenuSeparator />
+4 -2
View File
@@ -251,12 +251,14 @@ interface RecipientPopoverProps {
email: string;
/** Display label override (e.g. "me") */
displayLabel?: string;
/** Rich content in place of the text label, such as a name over its address. */
label?: React.ReactNode;
/** Called when user clicks "View contact" - receives the contact and email */
onViewContact?: (contact: ContactCard | null, email: string) => void;
className?: string;
}
export function RecipientPopover({ name, email, displayLabel, onViewContact, className }: RecipientPopoverProps) {
export function RecipientPopover({ name, email, displayLabel, label, onViewContact, className }: RecipientPopoverProps) {
const [anchor, setAnchor] = useState<CardAnchor | null>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const shownEmail = useOwnDomainAddress()(email);
@@ -280,7 +282,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
className
)}
>
<bdi>{displayLabel || name || shownEmail}</bdi>
{label ?? <bdi>{displayLabel || name || shownEmail}</bdi>}
</button>
{anchor && (
+25 -19
View File
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import type { Email, Mailbox } from "@/lib/jmap/types";
import { ContextMenuItem, ContextMenuSeparator, ContextMenuSubMenu } from "@/components/ui/context-menu";
import {
@@ -27,6 +27,7 @@ import {
import { useRouter } from "@/i18n/navigation";
import { buildMailboxTree, cn, generateUUID, type MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { sortFolderTree } from "@/lib/move-menu";
import { useAuthStore } from "@/stores/auth-store";
import { useFilterStore } from "@/stores/filter-store";
import { useQuickRuleStore, type FiltersStatus } from "@/stores/quick-rule-store";
@@ -173,7 +174,12 @@ function useRulesModel(anchor: Email, senderEmails: Email[], target: QuickRuleTa
}), [senders, listIds]);
const targetIds = useMemo(() => ruleTargetMailboxIds(target.mailboxes), [target]);
const tree = useMemo(() => filterTree(buildMailboxTree(target.mailboxes), targetIds), [target, targetIds]);
// In the order every Move list has (lib/move-menu.ts).
const locale = useLocale();
const tree = useMemo(
() => sortFolderTree(filterTree(buildMailboxTree(target.mailboxes), targetIds), locale),
[target, targetIds, locale],
);
const junk = useMemo(() => findJunkMailbox(target.mailboxes), [target]);
const sourceMailboxId = useMemo(() => sourceMailboxOf(anchor, target)?.id ?? null, [anchor, target]);
@@ -248,21 +254,18 @@ function ContextFolderItems({
const render = (nodes: MailboxNode[]): React.ReactNode => nodes.map((node) => {
const Icon = mailboxIcon(node.role);
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
// Greyed, not plain text, so the desktop app's native copy keeps it as
// the head of its subfolders' submenu (data-native-submenu).
const enabled = model.targetIds.has(node.id);
return (
<div key={node.id}>
{model.targetIds.has(node.id) ? (
<ContextMenuItem
icon={Icon}
label={label}
testId={`rules:${kind}:${node.id}`}
onClick={() => { model.moveTo(kind, node); onDone(); }}
/>
) : (
<div className="px-3 py-1.5 text-sm flex items-center gap-2 text-muted-foreground">
<Icon className="w-4 h-4 flex-shrink-0" />
<span>{label}</span>
</div>
)}
<div key={node.id} data-native-submenu={node.children.length > 0 ? "" : undefined}>
<ContextMenuItem
icon={Icon}
label={label}
disabled={!enabled}
testId={`rules:${kind}:${node.id}`}
onClick={() => { model.moveTo(kind, node); onDone(); }}
/>
{node.children.length > 0 && <div className="ps-4">{render(node.children)}</div>}
</div>
);
@@ -407,10 +410,13 @@ export function RulesContextSubMenu({
anchor,
senderEmails,
onClose,
disabled = false,
}: {
anchor: Email;
senderEmails: Email[];
onClose: () => void;
/** Rules are saved on the server: greyed out offline. */
disabled?: boolean;
}) {
const t = useTranslations("context_menu");
const accountEmails = useMemo(
@@ -424,7 +430,7 @@ export function RulesContextSubMenu({
icon={Filter}
label={t("rules.title")}
testId="ctx-rules"
disabled={availability === "cross_account"}
disabled={disabled || availability === "cross_account"}
hint={availability === "cross_account" ? t("rules.cross_account") : undefined}
>
{target && <ContextRulesContent anchor={anchor} senderEmails={senderEmails} target={target} onDone={onClose} />}
@@ -801,7 +807,7 @@ function FolderRulesContent({
* saved rules over the mail already in the folder (#1166). Hidden for shared
* folders and accounts without Sieve.
*/
export function FolderRulesSubMenu({ mailbox, onClose }: { mailbox: Mailbox; onClose: () => void }) {
export function FolderRulesSubMenu({ mailbox, onClose, disabled = false }: { mailbox: Mailbox; onClose: () => void; disabled?: boolean }) {
const t = useTranslations("context_menu");
const client = useAuthStore((s) => s.client);
const target = useMemo(
@@ -810,7 +816,7 @@ export function FolderRulesSubMenu({ mailbox, onClose }: { mailbox: Mailbox; onC
);
if (!target || !target.supportsSieve) return null;
return (
<ContextMenuSubMenu icon={Filter} label={t("rules.title")} testId="mailbox-rules">
<ContextMenuSubMenu icon={Filter} label={t("rules.title")} testId="mailbox-rules" disabled={disabled}>
<FolderRulesContent mailbox={mailbox} target={target} onDone={onClose} />
</ContextMenuSubMenu>
);
@@ -221,7 +221,7 @@ describe('FileBrowser', () => {
renderBrowser({ resources: [resource(OWN[1])], allFolders: [shared] });
fireEvent.contextMenu(within(tree()).getByText('Documents'));
fireEvent.click(await screen.findByRole('menuitem', { name: 'share' }));
fireEvent.click(await screen.findByRole('menuitem', { name: 'files_share' }));
expect(await screen.findByTestId('share-dialog')).toHaveTextContent('Documents:p1');
});
@@ -230,7 +230,7 @@ describe('FileBrowser', () => {
renderBrowser();
await screen.findAllByText('Team');
fireEvent.contextMenu(within(tree()).getByText('Team'));
expect(screen.queryByRole('menuitem', { name: 'share' })).toBeNull();
expect(screen.queryByRole('menuitem', { name: 'files_share' })).toBeNull();
});
it('hides dot files unless the setting shows them', () => {
@@ -284,7 +284,7 @@ describe('FileBrowser', () => {
it('offers new and upload entries on My files in the sidebar', () => {
renderBrowser();
fireEvent.contextMenu(within(tree()).getByText('my_files'));
for (const name of ['open', 'new_folder', 'new_text_file', 'upload_files', 'upload_folder']) {
for (const name of ['open', 'files_new_folder', 'files_new_text_file', 'files_upload_files', 'files_upload_folder']) {
expect(screen.getByRole('menuitem', { name })).toBeInTheDocument();
}
});
@@ -457,7 +457,7 @@ describe('FileBrowser', () => {
}
render(<Probe />);
const files = groups.find((g) => g.label === 'files');
expect(files?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['upload_files', 'new_folder', 'trash']));
expect(files?.commands.map((c) => c.label)).toEqual(expect.arrayContaining(['files_upload_files', 'files_new_folder', 'trash']));
fireEvent.keyDown(document.body, { key: '/' });
expect(document.activeElement).toHaveAttribute('role', 'searchbox');
+10 -8
View File
@@ -222,7 +222,9 @@ export function FileBrowser({
onShare,
}: FileBrowserProps) {
const t = useTranslations("files");
const newFileLabel = newFileFormats.length > 0 ? t("new_file") : t("new_text_file");
const tMenu = useTranslations("menuAudit");
// Menu entries that open a dialog end in "…".
const newFileLabel = newFileFormats.length > 0 ? tMenu("files_new_file") : tMenu("files_new_text_file");
const fileNameRules = useMemo(() => client?.getFileNameRules?.() ?? null, [client]);
const validateFileName = useCallback((name: string) => {
const problem = fileNameProblem(name, fileNameRules);
@@ -951,7 +953,7 @@ export function FileBrowser({
)}
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
{canShare(resource) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
<ContextMenuItem icon={Share2} label={tMenu("files_share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
)}
{resource.isDirectory && !resource.isShared && (
<ContextMenuItem
@@ -992,10 +994,10 @@ export function FileBrowser({
const newItems = (
<>
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => { closeMenu(); fileInputRef.current?.click(); }} />
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => { closeMenu(); folderInputRef.current?.click(); }} />
<ContextMenuItem icon={Upload} label={tMenu("files_upload_files")} onClick={() => { closeMenu(); fileInputRef.current?.click(); }} />
<ContextMenuItem icon={FolderUp} label={tMenu("files_upload_folder")} onClick={() => { closeMenu(); folderInputRef.current?.click(); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => { closeMenu(); setShowNewFolder(true); }} />
<ContextMenuItem icon={FolderPlus} label={tMenu("files_new_folder")} onClick={() => { closeMenu(); setShowNewFolder(true); }} />
<ContextMenuItem icon={FilePlus} label={newFileLabel} onClick={() => { closeMenu(); setShowNewTextFile(true); }} />
</>
);
@@ -1035,9 +1037,9 @@ export function FileBrowser({
const tShell = useTranslations("shell");
const isEmbedded = useIsEmbedded();
useRegisterCommands(tSidebar("files"), [
{ id: "upload-files", label: t("upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: t("upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
{ id: "new-folder", label: t("new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
{ id: "upload-files", label: tMenu("files_upload_files"), icon: Upload, run: () => fileInputRef.current?.click(), when: () => canCreate },
{ id: "upload-folder", label: tMenu("files_upload_folder"), icon: FolderUp, run: () => folderInputRef.current?.click(), when: () => canCreate },
{ id: "new-folder", label: tMenu("files_new_folder"), icon: FolderPlus, run: () => setShowNewFolder(true), when: () => canCreate },
{ id: "new-text-file", label: newFileLabel, icon: FilePlus, run: () => setShowNewTextFile(true), when: () => canCreate },
{ id: "trash", label: t("trash"), icon: Trash2, run: () => onNavigate(TRASH_PATH, TRASH_LOCATION), when: () => !inTrash && !accountPickerMode },
{ id: "empty-trash", label: t("empty_trash"), icon: Trash2, run: () => onEmptyTrash?.(), when: () => inTrash && trashCount > 0 && !!onEmptyTrash },
+2 -1
View File
@@ -56,6 +56,7 @@ export function FileDetailsPanel({
iconOptions, getImageUrl, onClose, onOpen, onEdit, onDownload, onShare, onToggleStar, className, pluginFile,
}: FileDetailsPanelProps) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const pluginProps = useMemo(() => (pluginFile ? { file: pluginFile } : null), [pluginFile]);
const header = (
@@ -147,7 +148,7 @@ export function FileDetailsPanel({
)}
{canShare && (
<Button size="sm" variant="outline" className="h-8 gap-1.5" onClick={onShare}>
<Share2 className="w-4 h-4" />{t("share")}
<Share2 className="w-4 h-4" />{tMenu("files_share_button")}
</Button>
)}
{resource.isDirectory && !resource.isShared && (
+2 -1
View File
@@ -223,6 +223,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
note?: string;
}) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const touchProps = useTouchPress(touch, selecting, resource, handlers);
const icon = <ResourceIcon resource={resource} size="sm" options={iconOptions} />;
return (
@@ -299,7 +300,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
<RowAction icon={Download} label={t("download")} onClick={() => handlers.onDownload?.(resource)} />
)}
{!touch && handlers.canShare(resource) && (
<RowAction icon={Share2} label={t("share")} onClick={() => handlers.onShare(resource)} />
<RowAction icon={Share2} label={tMenu("files_share_button")} onClick={() => handlers.onShare(resource)} />
)}
<IconButton
data-item-action
+2 -1
View File
@@ -120,6 +120,7 @@ export function FilesHeader({
panelToggle,
}: FilesHeaderProps) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const sortMenu = useContextMenu<null>();
const overflowMenu = useContextMenu<Crumb[]>();
const [searchOpen, setSearchOpen] = useState(false);
@@ -159,7 +160,7 @@ export function FilesHeader({
<ToolButton icon={Download} label={t("download")} onClick={selection.onDownload} compact />
)}
{selection.canShare && (
<ToolButton icon={Share2} label={t("share")} onClick={selection.onShare} compact />
<ToolButton icon={Share2} label={tMenu("files_share_button")} onClick={selection.onShare} compact />
)}
{selection.canRename && (
<ToolButton icon={Pencil} label={t("rename")} onClick={selection.onRename} compact />
+10 -9
View File
@@ -125,6 +125,7 @@ export function FilesSidebar({
onDropToTrash,
}: FilesSidebarProps) {
const t = useTranslations("files");
const tMenu = useTranslations("menuAudit");
const newMenu = useContextMenu<null>();
const itemMenu = useContextMenu<MenuTarget>();
const newButtonRef = useRef<HTMLButtonElement>(null);
@@ -274,10 +275,10 @@ export function FilesSidebar({
const createItems = (path: string, id: string | null) => (
<>
<ContextMenuSeparator />
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => run(() => onCreateIn(path, id, "folder"))} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? t("new_text_file")} onClick={() => run(() => onCreateIn(path, id, "text"))} />
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => run(() => onCreateIn(path, id, "files"))} />
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => run(() => onCreateIn(path, id, "dir"))} />
<ContextMenuItem icon={FolderPlus} label={tMenu("files_new_folder")} onClick={() => run(() => onCreateIn(path, id, "folder"))} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? tMenu("files_new_text_file")} onClick={() => run(() => onCreateIn(path, id, "text"))} />
<ContextMenuItem icon={Upload} label={tMenu("files_upload_files")} onClick={() => run(() => onCreateIn(path, id, "files"))} />
<ContextMenuItem icon={FolderUp} label={tMenu("files_upload_folder")} onClick={() => run(() => onCreateIn(path, id, "dir"))} />
</>
);
@@ -455,11 +456,11 @@ export function FilesSidebar({
</nav>
<ContextMenu ref={newMenu.menuRef} isOpen={newMenu.contextMenu.isOpen} position={newMenu.contextMenu.position} onClose={newMenu.closeContextMenu}>
<ContextMenuItem icon={Upload} label={t("upload_files")} onClick={() => { newMenu.closeContextMenu(); onUploadFiles(); }} />
<ContextMenuItem icon={FolderUp} label={t("upload_folder")} onClick={() => { newMenu.closeContextMenu(); onUploadFolder(); }} />
<ContextMenuItem icon={Upload} label={tMenu("files_upload_files")} onClick={() => { newMenu.closeContextMenu(); onUploadFiles(); }} />
<ContextMenuItem icon={FolderUp} label={tMenu("files_upload_folder")} onClick={() => { newMenu.closeContextMenu(); onUploadFolder(); }} />
<ContextMenuSeparator />
<ContextMenuItem icon={FolderPlus} label={t("new_folder")} onClick={() => { newMenu.closeContextMenu(); onNewFolder(); }} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? t("new_text_file")} onClick={() => { newMenu.closeContextMenu(); onNewTextFile(); }} />
<ContextMenuItem icon={FolderPlus} label={tMenu("files_new_folder")} onClick={() => { newMenu.closeContextMenu(); onNewFolder(); }} />
<ContextMenuItem icon={FilePlus} label={newFileLabel ?? tMenu("files_new_text_file")} onClick={() => { newMenu.closeContextMenu(); onNewTextFile(); }} />
</ContextMenu>
<ContextMenu ref={itemMenu.menuRef} isOpen={itemMenu.contextMenu.isOpen} position={itemMenu.contextMenu.position} onClose={itemMenu.closeContextMenu}>
@@ -484,7 +485,7 @@ export function FilesSidebar({
Details act on the list and are left out here (R6). */}
{(!folder.isShared || canShare(folder)) && <ContextMenuSeparator />}
{canShare(folder) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
<ContextMenuItem icon={Share2} label={tMenu("files_share")} onClick={() => run(() => onShare(folder))} />
)}
{!folder.isShared && (
<ContextMenuItem
+5 -4
View File
@@ -87,7 +87,7 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
onClick={(e) => e.stopPropagation()}
>
<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 +104,11 @@ export function PreviewShell({ name, onClose, onDownload, actions, neighbours, c
<Download />
</IconButton>
)}
<IconButton label={t("close_preview")} onClick={onClose}>
<X />
</IconButton>
</div>
{/* Its own child of the bar, so it can move to the bar's other end beside the window's controls. */}
<IconButton label={t("close_preview")} onClick={onClose} className="shrink-0 -ms-1" data-pane-close="">
<X />
</IconButton>
</div>
{children}
</div>
+6 -3
View File
@@ -1,7 +1,8 @@
"use client";
import { useState, useCallback, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { useLocale, useTranslations } from "next-intl";
import { sortFolderTree } from "@/lib/move-menu";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Plus, X } from "@/components/icons";
@@ -221,8 +222,10 @@ export function FilterRuleModal({
}, [confirmDiscard, dirty, onClose]);
const nameRef = useRef<HTMLInputElement>(null);
// The folder list in the order every Move list has (lib/move-menu.ts).
const locale = useLocale();
const { hierarchicalMailboxes, mailboxPathMap } = useMemo(() => {
const tree = buildMailboxTree(mailboxes.filter((mb) => !mb.isShared));
const tree = sortFolderTree(buildMailboxTree(mailboxes.filter((mb) => !mb.isShared)), locale);
const pathMap = new Map<string, string>();
const buildPaths = (nodes: MailboxNode[], parentPath = "") => {
for (const node of nodes) {
@@ -236,7 +239,7 @@ export function FilterRuleModal({
};
buildPaths(tree);
return { hierarchicalMailboxes: flattenMailboxTree(tree), mailboxPathMap: pathMap };
}, [mailboxes]);
}, [mailboxes, locale]);
// Rules saved before folder ids were stored only carry the path; resolve it
// so the select shows the folder and the next save adds the id.
@@ -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 -1
View File
@@ -22,7 +22,7 @@ export function InlineAppView({ apps, activeAppId, onClose, className }: InlineA
{/* 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">
<IconButton label={tCommon("close")} onClick={onClose} className="text-muted-foreground" data-pane-close="">
<X />
</IconButton>
</div>
+38 -14
View File
@@ -31,6 +31,7 @@ import {
import { FolderRulesSubMenu } from "@/components/email/rules-menu";
import { openMailExport } from "@/stores/mail-transfer-store";
import { useDevicePolicy } from "@/hooks/use-device-policy";
import { useConnectionState } from "@/hooks/use-connection-state";
interface Position {
x: number;
@@ -45,6 +46,20 @@ export type MailboxContextTarget =
| { kind: "folders-section"; accountId?: string };
const PATH_SEPARATOR = " › ";
/** Unread messages in a folder and the folders under it; undefined where a count isn't known. */
export function unreadInTree(mailbox: Mailbox, mailboxes: Mailbox[]): number | undefined {
let total = 0;
const seen = new Set<string>();
const visit = (box: Mailbox): boolean => {
if (seen.has(box.id)) return true;
seen.add(box.id);
if (typeof box.unreadEmails !== "number") return false;
total += box.unreadEmails;
return mailboxes.filter((child) => child.parentId === box.id).every(visit);
};
return visit(mailbox) ? total : undefined;
}
const MAX_PATH_LENGTH = 40;
const MAX_SEGMENT_LENGTH = 16;
@@ -147,6 +162,11 @@ export function MailboxContextMenu({
const tExport = useTranslations("mail_export");
// Organisation > Devices > No export hides it; import stays.
const exportOff = useDevicePolicy().noExport;
// Folders, rules and sharing need the server. Marking read waits in the outbox
// where the account keeps its mail on this device.
const { offline, canChangeMail } = useConnectionState();
const tMenu = useTranslations("menuAudit");
const offlineHeader = offline ? <ContextMenuHeader>{tMenu("offline_header")}</ContextMenuHeader> : null;
const handleAction = (action: () => void) => {
action();
@@ -198,13 +218,14 @@ export function MailboxContextMenu({
if (target.kind === "folders-section") {
return (
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
{offlineHeader}
{!target.accountId && (
<>
<ContextMenuItem
icon={CheckCheck}
label={t("mark_all_folders_read")}
onClick={() => handleAction(onMarkAllFoldersRead!)}
disabled={!onMarkAllFoldersRead}
disabled={!onMarkAllFoldersRead || !canChangeMail}
/>
<ContextMenuSeparator />
</>
@@ -213,14 +234,14 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(() => onCreateFolder?.(target.accountId))}
disabled={!onCreateFolder}
disabled={!onCreateFolder || offline}
testId="mailbox-new-folder"
/>
<ContextMenuItem
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
disabled={!onRefresh || offline}
/>
{accountItems(target.accountId)}
</ContextMenu>
@@ -238,6 +259,8 @@ export function MailboxContextMenu({
const canSetSeen = mailbox.myRights?.maySetSeen !== false;
const canRemoveItems = mailbox.myRights?.mayRemoveItems !== false;
const canAddItems = mailbox.myRights?.mayAddItems !== false;
const nothingUnread = mailbox.unreadEmails === 0;
const nothingUnreadBelow = unreadInTree(mailbox, mailboxes) === 0;
// Sharing needs the mail:share extension (handler present) and, on a folder
// someone else shared with us, their permission to re-share it.
const canShare = !!onShareFolder && (!mailbox.isShared || mailbox.myRights?.mayShare === true);
@@ -252,19 +275,20 @@ export function MailboxContextMenu({
<ContextMenuHeader>
<span title={fullPath}>{renderShortenedPath(fullPath)}</span>
</ContextMenuHeader>
{offlineHeader}
<ContextMenuItem
icon={MailOpen}
label={t("mark_folder_read")}
onClick={() => handleAction(() => onMarkFolderRead?.(mailbox.id))}
disabled={!onMarkFolderRead || !canSetSeen}
disabled={!onMarkFolderRead || !canSetSeen || !canChangeMail || nothingUnread}
/>
{target.hasChildren && (
<ContextMenuItem
icon={Mails}
label={t("mark_folder_tree_read")}
onClick={() => handleAction(() => onMarkFolderTreeRead?.(mailbox.id))}
disabled={!onMarkFolderTreeRead || !canSetSeen}
disabled={!onMarkFolderTreeRead || !canSetSeen || !canChangeMail || nothingUnreadBelow}
/>
)}
@@ -274,14 +298,14 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_subfolder")}
onClick={() => handleAction(() => onCreateSubfolder?.(mailbox.id))}
disabled={!onCreateSubfolder || !canCreateChild}
disabled={!onCreateSubfolder || !canCreateChild || offline}
testId="mailbox-new-subfolder"
/>
<ContextMenuItem
icon={Pencil}
label={t("rename")}
onClick={() => handleAction(() => onRenameFolder?.(mailbox.id))}
disabled={!onRenameFolder || !canRename}
disabled={!onRenameFolder || !canRename || offline}
testId="mailbox-rename"
/>
{onShareFolder && (
@@ -289,11 +313,11 @@ export function MailboxContextMenu({
icon={Users}
label={t("share")}
onClick={() => handleAction(() => onShareFolder(mailbox.id))}
disabled={!canShare}
disabled={!canShare || offline}
testId="mailbox-share"
/>
)}
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} />
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} disabled={offline} />
{notifications && (() => {
const muted = notifications.isMailboxMuted(mailbox);
return (
@@ -313,7 +337,7 @@ export function MailboxContextMenu({
icon={FolderPlus}
label={t("new_folder")}
onClick={() => handleAction(() => onCreateFolder?.(target.sharedAccountId))}
disabled={!onCreateFolder}
disabled={!onCreateFolder || offline}
testId="mailbox-new-folder"
/>
{accountItems(target.sharedAccountId)}
@@ -326,7 +350,7 @@ export function MailboxContextMenu({
icon={Upload}
label={tImport("menu_import")}
onClick={() => handleAction(() => onImportEmail?.(mailbox.id))}
disabled={!onImportEmail || !canAddItems}
disabled={!onImportEmail || !canAddItems || offline}
/>
{!exportOff && (
<ContextMenuItem
@@ -341,7 +365,7 @@ export function MailboxContextMenu({
icon={RefreshCw}
label={t("refresh")}
onClick={() => handleAction(onRefresh!)}
disabled={!onRefresh}
disabled={!onRefresh || offline}
/>
{/* Destructive entries last (R6). */}
@@ -351,14 +375,14 @@ export function MailboxContextMenu({
icon={FolderX}
label={isTrashOrJunk ? t("empty_folder") : t("empty_folder_generic")}
onClick={() => handleAction(() => onEmptyFolder?.(mailbox.id))}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems}
disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline}
destructive
/>
<ContextMenuItem
icon={Trash2}
label={t("delete_folder")}
onClick={() => handleAction(() => onDeleteFolder?.(mailbox.id))}
disabled={!onDeleteFolder || !canDelete}
disabled={!onDeleteFolder || !canDelete || offline}
destructive
testId="mailbox-delete"
/>
+1 -1
View File
@@ -92,7 +92,7 @@ export function PhoneHeaderMenu({ menuItems }: PhoneHeaderMenuProps) {
<ContextMenuItem
icon={Bell}
label={tNotifications("title")}
shortcut={unread > 0 ? formatBadgeCount(unread) : undefined}
badge={unread > 0 ? formatBadgeCount(unread) : undefined}
onClick={() => { close(); setShowNotifications(true); }}
/>
<ContextMenuItem
+4 -6
View File
@@ -371,7 +371,6 @@ interface SidebarRowProps {
isCollapsed: boolean;
dropHandlers?: Record<string, unknown>;
isValidDropTarget?: boolean;
isInvalidDropTarget?: boolean;
onContextMenu?: (e: React.MouseEvent) => void;
/** New mail here does not notify (#1169). */
muted?: boolean;
@@ -399,7 +398,6 @@ function SidebarRow({
isCollapsed,
dropHandlers,
isValidDropTarget,
isInvalidDropTarget,
onContextMenu,
muted = false,
testRole,
@@ -469,8 +467,9 @@ function SidebarRow({
: isSelected
? "bg-accent text-accent-foreground font-semibold border-s-2 border-primary"
: "hover:bg-muted/50 text-foreground border-s-2 border-transparent",
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset",
isInvalidDropTarget && "bg-destructive/10 ring-2 ring-destructive/30 ring-inset opacity-50"
// Only a folder that takes the drop lights up. Elsewhere the pointer
// says no (dropEffect none), as in a native mail client.
isValidDropTarget && "bg-primary/20 ring-2 ring-primary ring-inset"
)}
>
{!isCollapsed && (
@@ -756,7 +755,7 @@ function MailboxTreeItem({
: undefined;
const { isDragging: globalDragging } = useDragDropContext();
const { dropHandlers, isValidDropTarget, isInvalidDropTarget } = useMailboxDrop({
const { dropHandlers, isValidDropTarget } = useMailboxDrop({
mailbox: node,
accountId: dragAccountId ?? undefined,
onSuccess: (count, mailboxName, restore, receipts) => {
@@ -800,7 +799,6 @@ function MailboxTreeItem({
: undefined
}
isValidDropTarget={isValidDropTarget && !pickOnly}
isInvalidDropTarget={isInvalidDropTarget && !pickOnly}
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
muted={muted}
/>
+441 -62
View File
@@ -1,99 +1,478 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Check, ChevronRight } from "@/components/icons";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import type { DesktopMenuBarEntry } from "@/lib/desktop/types";
import { MENU_BAR_ATTRIBUTE, cachedMenuBarPinned, menuKeyText, rememberMenuBarPinned } from "@/lib/desktop/menu-bar";
import type { DesktopMenuBarEntry, DesktopMenuBarItem, DesktopMenuBarMenu } from "@/lib/desktop/types";
import { watchTitlebarArea } from "@/lib/window-controls-overlay";
import { cn } from "@/lib/utils";
/**
* Alt floats the menu's names over the top left for a moment, since a window with no
* frame has no native bar. Each name opens the app's menu as a native one
* (src/main/menu.ts in the desktop repo). Dismissed, nothing is left of it.
* The desktop app's menu bar where the window's controls sit over the page (Windows and
* Linux), as Thunderbird has it. Alt shows it as a row at the top that moves the app down
* under it, and the window's controls shrink into that row; "Always show the menu bar"
* keeps it. Alt, Escape or a click elsewhere hides it again. Alt and a letter opens that
* menu, with the letters underlined while the keys are on the bar, and the arrows go
* between menus and lines. The menus are the shell's own (src/main/menu.ts in the desktop
* repo), with what's enabled and ticked now, and a pick runs the very item the native
* menu has. macOS keeps its system bar.
*
* A shell from before this gives only the names, and opens each menu natively.
*/
const LINE = "[data-menu-line]";
const ENABLED_LINE = `${LINE}:not([aria-disabled="true"])`;
type Focus = { kind: "name"; index: number } | { kind: "line" | "panel"; sub?: boolean };
/** The label with its letter underlined, while the keys are on the bar. */
function Label({ text, at, cue }: { text: string; at: number | undefined; cue: boolean }) {
if (!cue || at === undefined || at < 0 || at >= text.length) return <>{text}</>;
return (
<>
{text.slice(0, at)}
<span className="underline underline-offset-2">{text[at]}</span>
{text.slice(at + 1)}
</>
);
}
function nameLetterAt(entry: DesktopMenuBarEntry): number | undefined {
if (!entry.mnemonic) return undefined;
const at = entry.label.toLowerCase().indexOf(entry.mnemonic);
return at >= 0 ? at : undefined;
}
function letterOf(item: DesktopMenuBarItem): string | undefined {
return item.mnemonic !== undefined ? item.label[item.mnemonic]?.toLowerCase() : undefined;
}
function usable(item: DesktopMenuBarItem | undefined): item is DesktopMenuBarItem {
return !!item && item.type !== "separator" && item.enabled;
}
const EDGE = 4;
/**
* A menu that would leave the window moves back in. A submenu sits beside its line, on the
* reading side, or the other side where there's no room, and never below the window.
*/
function keepInside(root: HTMLElement | null): void {
if (!root) return;
const width = document.documentElement.clientWidth;
const height = document.documentElement.clientHeight;
const panel = root.querySelector<HTMLElement>("[data-menu-panel]");
if (panel) {
panel.style.translate = "";
const rect = panel.getBoundingClientRect();
if (rect.right > width - EDGE) panel.style.translate = `${Math.round(width - EDGE - rect.right)}px 0`;
else if (rect.left < EDGE) panel.style.translate = `${Math.round(EDGE - rect.left)}px 0`;
}
const subpanel = root.querySelector<HTMLElement>("[data-menu-subpanel]");
const line = panel?.querySelector<HTMLElement>(`${LINE}[aria-expanded="true"]`);
if (!panel || !subpanel || !line) return;
const outer = panel.getBoundingClientRect();
const at = line.getBoundingClientRect();
const own = subpanel.getBoundingClientRect();
const rtl = getComputedStyle(root).direction === "rtl";
const after = outer.right - EDGE;
const before = outer.left + EDGE - own.width;
const fitsAfter = after + own.width <= width - EDGE;
const fitsBefore = before >= EDGE;
const left = rtl ? (fitsBefore ? before : after) : fitsAfter || !fitsBefore ? Math.min(after, width - EDGE - own.width) : before;
subpanel.style.left = `${Math.round(Math.max(EDGE, left))}px`;
subpanel.style.top = `${Math.round(Math.max(EDGE, Math.min(at.top - EDGE, height - EDGE - own.height)))}px`;
}
export function WindowMenuBar() {
const t = useTranslations("desktopUi.menu");
const [entries, setEntries] = useState<DesktopMenuBarEntry[] | null>(null);
const stripRef = useRef<HTMLDivElement>(null);
const [desktop, setDesktop] = useState(false);
const [hasOverlay, setHasOverlay] = useState(false);
const [pinned, setPinned] = useState(false);
/** The keys are on the bar: Alt was pressed, or a menu was opened. */
const [active, setActive] = useState(false);
const [menus, setMenus] = useState<DesktopMenuBarMenu[] | null>(null);
const [open, setOpen] = useState<number | null>(null);
/** The line of the open menu whose submenu is open. */
const [sub, setSub] = useState<number | null>(null);
/** Underlined letters: the keys brought the bar, not the mouse. */
const [cue, setCue] = useState(false);
const barRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
// What to do once the names are on screen: open that menu, or null to focus the first name.
const afterShow = useRef<string | null | undefined>(undefined);
const pendingFocus = useRef<Focus | null>(null);
const openMenu = useCallback((id: string) => {
const button = stripRef.current?.querySelector<HTMLElement>(`[data-menu-id="${CSS.escape(id)}"]`);
if (!button) return;
const rect = button.getBoundingClientRect();
getDesktopBridge()?.menu?.bar?.open(id, { x: rect.left, y: rect.bottom });
}, []);
const dismiss = useCallback(() => {
setEntries(null);
previousFocus.current?.focus?.();
previousFocus.current = null;
}, []);
const shown = desktop && hasOverlay && (pinned || active);
const drawsMenus = desktop && !!getDesktopBridge()?.menu?.bar?.menus;
useEffect(() => {
const bridge = getDesktopBridge();
if (!bridge?.menu?.bar || bridge.platform === "darwin") return;
setDesktop(true);
setPinned(cachedMenuBarPinned());
return watchTitlebarArea((area) => setHasOverlay(area !== null));
}, []);
const refresh = useCallback(async (): Promise<DesktopMenuBarMenu[] | null> => {
const bar = getDesktopBridge()?.menu?.bar;
if (!bar) return null;
const list = bar.menus
? await bar.menus().catch(() => null)
: await bar.entries().then((entries) => entries.map((entry) => ({ ...entry, items: [] }))).catch(() => null);
if (list) setMenus(list);
return list;
}, []);
const rememberFocus = useCallback(() => {
const now = document.activeElement;
if (now instanceof HTMLElement && now !== document.body && !barRef.current?.contains(now)) previousFocus.current = now;
}, []);
/** Back to how it was before Alt: the menus shut, the keys back where they were. */
const deactivate = useCallback(() => {
setOpen(null);
setSub(null);
setActive(false);
setCue(false);
const back = previousFocus.current;
previousFocus.current = null;
if (barRef.current?.contains(document.activeElement)) {
if (back?.isConnected) back.focus({ preventScroll: true });
else (document.activeElement as HTMLElement | null)?.blur();
}
}, []);
// The shell: Alt, Alt and a letter, the window losing the focus, the setting.
useEffect(() => {
if (!desktop) return;
const bar = getDesktopBridge()?.menu?.bar;
if (!bar) return;
return bar.onEvent((event) => {
const stop = bar.onEvent((event) => {
if (typeof event.pinned === "boolean") {
setPinned(event.pinned);
rememberMenuBarPinned(event.pinned);
}
if (!event.visible) {
dismiss();
deactivate();
return;
}
void bar.entries().then((list) => {
previousFocus.current ??= document.activeElement instanceof HTMLElement ? document.activeElement : null;
afterShow.current = event.open ?? null;
setEntries(list);
rememberFocus();
setActive(true);
setCue(true);
setSub(null);
// Set now: the names may be drawn before the menus arrive, and take it then.
pendingFocus.current = event.open && bar.menus ? { kind: "line" } : { kind: "name", index: 0 };
void refresh().then((list) => {
const index = event.open ? (list ?? []).findIndex((menu) => menu.id === event.open) : -1;
if (index >= 0 && bar.menus) setOpen(index);
else setOpen(null);
});
});
}, [dismiss]);
// Listening now: the shell answers with whether the bar is always there.
bar.report?.({ height: 0, active: false });
return stop;
}, [desktop, refresh, deactivate, rememberFocus]);
// An always-shown bar has its names from the start.
useEffect(() => {
const next = afterShow.current;
if (!entries || next === undefined) return;
afterShow.current = undefined;
if (next) openMenu(next);
else stripRef.current?.querySelector<HTMLElement>("button")?.focus();
}, [entries, openMenu]);
if (shown && !menus) void refresh();
}, [shown, menus, refresh]);
if (!entries) return null;
// The row it takes: everything else moves down under it, and the controls fit into it.
useLayoutEffect(() => {
if (!desktop) return;
document.documentElement.toggleAttribute(MENU_BAR_ATTRIBUTE, shown);
const height = shown ? Math.round(barRef.current?.getBoundingClientRect().height ?? 0) : 0;
getDesktopBridge()?.menu?.bar?.report?.({ height, active });
}, [desktop, shown, active]);
const onStripKey = (event: React.KeyboardEvent<HTMLDivElement>) => {
const buttons = Array.from(stripRef.current?.querySelectorAll<HTMLElement>("button") ?? []);
const at = buttons.indexOf(document.activeElement as HTMLElement);
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
useEffect(() => () => document.documentElement.removeAttribute(MENU_BAR_ATTRIBUTE), []);
// Menus stay in the window; the focus goes where the keys asked, once it's drawn.
useLayoutEffect(() => {
const root = barRef.current;
keepInside(root);
const want = pendingFocus.current;
if (!want || !root) return;
let target: HTMLElement | null = null;
if (want.kind === "name") {
target = root.querySelectorAll<HTMLElement>("[data-menu-name]")[want.index] ?? null;
} else {
const panel = root.querySelector<HTMLElement>(want.sub ? "[data-menu-subpanel]" : "[data-menu-panel]");
target = want.kind === "panel" ? panel : panel?.querySelector<HTMLElement>(ENABLED_LINE) ?? panel ?? null;
}
if (!target) return;
pendingFocus.current = null;
target.focus({ preventScroll: true });
});
// A click anywhere else puts it all away.
useEffect(() => {
if (!active && open === null) return;
const onDown = (event: PointerEvent) => {
if (barRef.current?.contains(event.target as Node)) return;
deactivate();
};
document.addEventListener("pointerdown", onDown, true);
return () => document.removeEventListener("pointerdown", onDown, true);
}, [active, open, deactivate]);
const openMenu = useCallback((index: number, focus: "line" | "panel") => {
if (!drawsMenus) {
const entry = menus?.[index];
const button = barRef.current?.querySelectorAll<HTMLElement>("[data-menu-name]")[index];
if (!entry || !button) return;
const rect = button.getBoundingClientRect();
getDesktopBridge()?.menu?.bar?.open(entry.id, { x: rect.left, y: rect.bottom });
return;
}
setActive(true);
setOpen(index);
setSub(null);
pendingFocus.current = { kind: focus };
// What's enabled may have changed since the bar showed.
void refresh();
}, [drawsMenus, menus, refresh]);
const run = useCallback((item: DesktopMenuBarItem) => {
if (!usable(item)) return;
// Cut, Copy and Paste work on what had the focus, so it has it back first.
deactivate();
getDesktopBridge()?.menu?.bar?.run?.(item.key, item.label);
}, [deactivate]);
if (!shown) return null;
const list = menus ?? [];
const openItems = open !== null ? list[open]?.items ?? [] : [];
const subItems = sub !== null ? openItems[sub]?.items ?? [] : [];
const names = () => Array.from(barRef.current?.querySelectorAll<HTMLElement>("[data-menu-name]") ?? []);
const linesIn = (selector: string) => Array.from(barRef.current?.querySelector(selector)?.querySelectorAll<HTMLElement>(ENABLED_LINE) ?? []);
const step = (lines: HTMLElement[], delta: number) => {
if (lines.length === 0) return;
const at = lines.indexOf(document.activeElement as HTMLElement);
const next = at < 0 ? (delta > 0 ? 0 : lines.length - 1) : (at + delta + lines.length) % lines.length;
lines[next]?.focus({ preventScroll: true });
};
const openSub = (index: number) => {
setSub(index);
pendingFocus.current = { kind: "line", sub: true };
};
const closeSub = () => {
const parent = sub;
setSub(null);
barRef.current?.querySelector<HTMLElement>(`[data-menu-panel] [data-menu-line="${parent}"]`)?.focus({ preventScroll: true });
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const target = event.target as HTMLElement;
const rtl = getComputedStyle(barRef.current ?? document.documentElement).direction === "rtl";
const forward = rtl ? "ArrowLeft" : "ArrowRight";
const backward = rtl ? "ArrowRight" : "ArrowLeft";
const inSub = !!target.closest("[data-menu-subpanel]");
const inMenu = inSub || !!target.closest("[data-menu-panel]");
const key = event.key;
const plainLetter = key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey;
const take = () => {
event.preventDefault();
const delta = event.key === "ArrowRight" ? 1 : -1;
buttons[(at + delta + buttons.length) % buttons.length]?.focus();
} else if (event.key === "ArrowDown") {
event.preventDefault();
buttons[at]?.click();
} else if (event.key === "Escape") {
event.preventDefault();
dismiss();
event.stopPropagation();
};
setCue(true);
if (key === "Tab") {
take();
deactivate();
return;
}
if (!inMenu) {
const at = names().indexOf(target);
if (key === forward || key === backward) {
take();
step(names(), key === forward ? 1 : -1);
} else if (key === "ArrowDown" || key === "Enter" || key === " ") {
take();
if (at >= 0) openMenu(at, "line");
} else if (key === "Escape") {
take();
deactivate();
} else if (plainLetter) {
const index = list.findIndex((menu) => menu.mnemonic === key.toLowerCase());
if (index >= 0) {
take();
openMenu(index, "line");
}
}
return;
}
const items = inSub ? subItems : openItems;
const lineIndex = Number(target.closest<HTMLElement>(LINE)?.dataset.menuLine);
const current = Number.isInteger(lineIndex) ? items[lineIndex] : undefined;
const neighbour = (delta: number) => {
if (open !== null && list.length) openMenu((open + delta + list.length) % list.length, "line");
};
if (key === "ArrowDown" || key === "ArrowUp") {
take();
step(linesIn(inSub ? "[data-menu-subpanel]" : "[data-menu-panel]"), key === "ArrowDown" ? 1 : -1);
} else if (key === "Home" || key === "End") {
take();
const lines = linesIn(inSub ? "[data-menu-subpanel]" : "[data-menu-panel]");
lines[key === "Home" ? 0 : lines.length - 1]?.focus({ preventScroll: true });
} else if (key === forward) {
take();
if (!inSub && usable(current) && current.type === "submenu") openSub(lineIndex);
else neighbour(1);
} else if (key === backward) {
take();
if (inSub) closeSub();
else neighbour(-1);
} else if (key === "Enter" || key === " ") {
take();
if (!usable(current)) return;
if (current.type === "submenu") openSub(lineIndex);
else run(current);
} else if (key === "Escape") {
take();
if (inSub) {
closeSub();
} else {
const index = open ?? 0;
setOpen(null);
names()[index]?.focus({ preventScroll: true });
}
} else if (plainLetter) {
const index = items.findIndex((item) => usable(item) && letterOf(item) === key.toLowerCase());
if (index < 0) return;
take();
const item = items[index];
if (item.type === "submenu" && !inSub) openSub(index);
else run(item);
}
};
const renderLines = (items: DesktopMenuBarItem[], level: "panel" | "subpanel") =>
items.map((item, index) => {
if (item.type === "separator") return <div key={item.key} role="separator" className="my-1 h-px bg-border" />;
const isSub = item.type === "submenu";
const checkable = item.type === "checkbox" || item.type === "radio";
const opened = level === "panel" && isSub && sub === index;
return (
<div key={item.key} className="relative">
<button
type="button"
role={item.type === "checkbox" ? "menuitemcheckbox" : item.type === "radio" ? "menuitemradio" : "menuitem"}
aria-checked={checkable ? !!item.checked : undefined}
aria-haspopup={isSub ? "menu" : undefined}
aria-expanded={isSub ? opened : undefined}
aria-disabled={item.enabled ? undefined : true}
aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform) : undefined}
data-menu-line={index}
tabIndex={-1}
onMouseEnter={(event) => {
if (level === "panel") setSub(isSub && item.enabled ? index : null);
if (item.enabled) event.currentTarget.focus({ preventScroll: true });
}}
onClick={() => {
if (!item.enabled) return;
if (isSub) openSub(index);
else run(item);
}}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-start text-sm outline-none",
item.enabled ? "focus:bg-muted" : "cursor-default text-muted-foreground/60",
opened && "bg-muted",
)}
>
<span className="flex w-4 shrink-0 justify-center" aria-hidden="true">
{checkable && item.checked && (item.type === "radio" ? <span className="h-1.5 w-1.5 rounded-full bg-current" /> : <Check className="h-3.5 w-3.5" />)}
</span>
<span className="min-w-0 flex-1 truncate" data-menu-label="">
<Label text={item.label} at={item.mnemonic} cue={cue} />
</span>
{item.accelerator && (
<span className="shrink-0 ps-6 text-xs text-muted-foreground" data-menu-keys="" aria-hidden="true">
{menuKeyText(item.accelerator, getDesktopBridge()?.platform)}
</span>
)}
{isSub && <ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground rtl:rotate-180" aria-hidden="true" />}
</button>
</div>
);
});
return (
<div
ref={stripRef}
ref={barRef}
role="menubar"
aria-label={t("bar_label")}
data-window-menu-bar=""
onKeyDown={onStripKey}
className="fixed start-2 top-2 z-[100002] flex items-center gap-0.5 rounded-lg border border-border bg-popover p-1 text-sm text-popover-foreground shadow-lg select-none"
data-window-top-row=""
onKeyDown={onKeyDown}
className="fixed inset-x-0 top-0 z-[100002] flex h-8 items-center gap-0.5 border-b border-border bg-background px-1 text-sm text-foreground select-none"
>
{entries.map((entry) => (
<button
key={entry.id}
type="button"
role="menuitem"
aria-haspopup="menu"
data-menu-id={entry.id}
onClick={() => openMenu(entry.id)}
className="rounded px-2.5 py-1 hover:bg-muted focus-visible:bg-muted focus-visible:outline-none"
>
{entry.label}
</button>
{list.map((menu, index) => (
<div key={menu.id} className="relative">
<button
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={open === index}
data-menu-name=""
data-menu-id={menu.id}
tabIndex={-1}
onPointerDown={(event) => {
if (event.button !== 0) return;
// The focus stays put until a menu takes it: Cut and Paste need to know where it was.
event.preventDefault();
rememberFocus();
setCue(false);
if (open === index) deactivate();
else openMenu(index, "panel");
}}
onClick={(event) => {
// Enter and Space; the pointer was handled on the way down.
if (event.detail === 0) openMenu(index, "line");
}}
onMouseEnter={() => {
if (open !== null && open !== index) openMenu(index, "panel");
}}
className={cn(
"flex h-7 items-center rounded px-2.5 outline-none hover:bg-muted focus-visible:bg-muted",
open === index && "bg-muted",
)}
>
<Label text={menu.label} at={nameLetterAt(menu)} cue={cue} />
</button>
{open === index && drawsMenus && (
<div
role="menu"
aria-label={menu.label}
data-menu-panel=""
tabIndex={-1}
className="absolute start-0 top-full mt-0.5 min-w-56 max-w-[min(24rem,90vw)] max-h-[calc(100dvh-2.75rem)] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg outline-none"
>
{renderLines(openItems, "panel")}
</div>
)}
{open === index && drawsMenus && sub !== null && (
<div
role="menu"
aria-label={openItems[sub]?.label}
data-menu-subpanel=""
className="fixed z-10 min-w-44 max-w-[min(24rem,90vw)] max-h-[calc(100dvh-0.5rem)] overflow-y-auto rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
style={{ top: -9999, left: -9999 }}
>
{renderLines(subItems, "subpanel")}
</div>
)}
</div>
))}
</div>
);
@@ -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');
+18 -7
View File
@@ -136,9 +136,9 @@ import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { openMailImport } from "@/stores/mail-transfer-store";
import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-identity";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder } from "@/components/icons";
import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons";
import { useRegisterCommands } from "@/lib/command-registry";
import { MoveToDialog } from "@/components/email/move-to-dialog";
import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { ToolbarButton, ToolbarSearchAction } from "@/components/ui/toolbar";
@@ -2335,6 +2335,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
empty: () => { if (selectedMailbox) void handleEmptyFolderFromContextMenu(selectedMailbox); },
};
const folderMenuMarkRead = useCallback(() => folderMenuLatest.current.markRead(), []);
// "Mark folder as read" is greyed out with nothing unread in the open folder.
const selectedFolderHasUnread = mailboxes.find((m) => m.id === selectedMailbox)?.unreadEmails !== 0;
const folderMenuMarkAllRead = useCallback(() => folderMenuLatest.current.markAllRead(), []);
const folderMenuEmpty = useCallback(() => folderMenuLatest.current.empty(), []);
useEffect(() => {
@@ -4813,12 +4815,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
when: () => !!paletteEmail && !paletteEmail.keywords?.$seen,
run: () => { if (client && paletteEmail) void markReadWithUndo(paletteEmail, true).catch(toastUpdateFailure); },
},
{
id: 'star', label: paletteEmail?.keywords?.$flagged ? t('context_menu.unstar') : t('context_menu.star'), icon: Star, shortcut: 's',
when: hasPaletteEmail, run: () => void handleToggleStar(),
},
{
id: 'spam', label: conversationInJunk ? t('context_menu.not_spam') : t('context_menu.mark_as_spam'),
icon: conversationInJunk ? ShieldCheck : ShieldAlert, shortcut: '!',
when: () => !!paletteEmail && conversationSpamApplicable,
run: () => void (conversationInJunk ? handleUndoSpam(paletteEmail) : handleMarkAsSpam(paletteEmail)),
},
// Into the open folder, as the folder's own menu does it.
{
id: 'import', label: t('mail_import.menu_import'), icon: Upload,
run: () => openMailImport({ mailboxId: selectedMailbox || undefined }),
},
...mailboxes
.filter((mb) => !mb.id.startsWith('shared-') && mb.id !== selectedMailbox)
.map((mb) => {
@@ -5537,7 +5548,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onClick={() => { close(); void handleManualRefresh(); }}
/>
<MailFolderMenuItems
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
onPicked={close}
@@ -5577,7 +5588,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
totalCount={searchQuery || !isFilterEmpty(searchFilters) ? undefined : mailboxes.find((mb) => mb.id === selectedMailbox)?.totalEmails}
onRefresh={handleManualRefresh}
isRefreshing={isManualRefreshing}
onMarkFolderRead={selectedMailbox ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
onCreateFolder={() => handleCreateFolderFromContextMenu()}
@@ -5647,7 +5658,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
folderRole={conversationRole}
onRefresh={handleManualRefresh}
refreshDisabled={!client || isManualRefreshing}
onMarkFolderRead={selectedMailbox ? folderMenuMarkRead : undefined}
onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? folderMenuMarkRead : undefined}
onMarkAllFoldersRead={folderMenuMarkAllRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? folderMenuEmpty : undefined}
/>
@@ -6113,7 +6124,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined}
isFullscreen={fullscreenReading}
onShowShortcuts={() => setShowShortcutsModal(true)}
onEditDraft={handleEditDraft}
onCancelScheduledForEdit={async () => {
if (!client || !selectedEmail) return;
@@ -6224,8 +6234,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
/>
)}
<ConfirmDialog {...confirmDialogProps} />
<MoveToDialogHost mailboxes={mailboxes} selectedMailbox={selectedMailbox} />
{moveDialogEmail && <MoveToDialog
email={moveDialogEmail}
emails={[moveDialogEmail]}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onClose={() => setMoveDialogEmail(null)}
+1 -1
View File
@@ -160,7 +160,7 @@ export function PluginActions({ surface, context, variant = 'buttons', mailboxRo
label={action.label}
title={action.pluginName}
destructive={action.danger}
shortcut={action.badge ? badgeText(action.badge) : undefined}
badge={action.badge ? badgeText(action.badge) : undefined}
onClick={() => { void run(action); }}
/>
))}
+1 -1
View File
@@ -178,7 +178,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email
return (
<>
{moved && (
<div role="status" data-popout-moved="" className="shrink-0 border-b border-border bg-muted px-4 py-2 text-sm text-foreground">
<div role="status" data-popout-moved="" data-window-top-row="" className="flex shrink-0 items-center border-b border-border bg-muted px-4 py-2 text-sm text-foreground">
{movedTo
? t("message_moved_to", { folder: localizeMailboxName(movedTo.role, movedTo.name, (key) => tSidebar(`mailboxes.${key}`)) })
: t("message_moved")}
@@ -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 { email: unknown } | null }));
vi.mock('@/components/email/move-to-dialog', () => ({
MoveToDialog: (props: { email: unknown }) => {
moveDialog.current = props;
return null;
},
}));
vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
const multiAccount = vi.hoisted(() => ({
value: { enabled: false, groups: [] as unknown[], allIdentities: [] as Array<{ id: string; email: string; name: string }> },
@@ -79,6 +93,7 @@ vi.mock('@/stores/pro-tab-store', () => ({
}));
import { ProEmailView } from '../pro-email-tab-body';
import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands';
const DRAFT = {
id: 'eaaaaab',
@@ -307,3 +322,24 @@ describe('Pro email view - keyboard', () => {
expect(openComposeTab).not.toHaveBeenCalled();
});
});
describe('Message > Move to… in a message window', () => {
it('opens the folder list for the message on screen', async () => {
popout.value = true;
window.bulwarkDesktop = { version: 1, platform: 'win32', menu: { setState: vi.fn() } } as never;
try {
const client = { getEmail: vi.fn().mockResolvedValue(DRAFT), getUsername: () => 'userb@example.org', getAccountId: () => 'e' };
render(<ProEmailView emailId="eaaaaab" client={client as never} onClose={vi.fn()} />);
await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
expect(moveDialog.current?.email ?? null).toBeNull();
act(() => {
expect(runDesktopCommand('message.move')).toBe(true);
});
await waitFor(() => expect(moveDialog.current?.email).toMatchObject({ id: 'eaaaaab' }));
} finally {
popout.value = false;
window.bulwarkDesktop = undefined;
resetDesktopCommands();
}
});
});
+14
View File
@@ -3,6 +3,7 @@
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
import { useTranslations } from "next-intl";
import { EmailViewer } from "@/components/email/email-viewer";
import { MoveToDialog } from "@/components/email/move-to-dialog";
import { ErrorBoundary, EmailViewerErrorFallback } from "@/components/error";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore, resolveEmailBlobContext, type EmailBlobContext, type EmailWriteSource } from "@/stores/email-store";
@@ -472,6 +473,8 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
// In a pop-out this message is all there is, so the menu bar's Message menu means it.
const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow();
const seen = !!email?.keywords?.$seen;
// Message > Move to… in a pop-out: the same folder list the command palette opens.
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
useDesktopCommands({
'message.reply': () => handleReply(),
'message.replyAll': handleReplyAll,
@@ -481,6 +484,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
'message.star': () => void handleToggleStar(),
'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined,
'message.delete': () => void handleDelete(),
'message.move': () => { if (email) setMoveDialogOpen(true); },
}, {
'message.reply': !!email,
'message.replyAll': !!email,
@@ -490,6 +494,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
'message.star': !!email,
'message.archive': !!email && archiveActionEnabled,
'message.delete': !!email,
'message.move': !!email,
}, inOwnWindow);
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
@@ -642,6 +647,15 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
/>
</ErrorBoundary>
<ConfirmDialog {...confirmDialogProps} />
{inOwnWindow && (
<MoveToDialog
email={moveDialogOpen ? email : null}
mailboxes={mailboxes}
selectedMailbox=""
onClose={() => setMoveDialogOpen(false)}
onMove={(mailboxId) => void handleMoveToMailbox(mailboxId)}
/>
)}
{preview && (
<FilePreviewModal
name={preview.name}
@@ -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>
);
}
@@ -0,0 +1,181 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ContextMenu, ContextMenuItem, ContextMenuSubMenu, type ContextMenuPosition } from '../context-menu';
import { useContextMenu } from '@/hooks/use-context-menu';
import { placeMenu } from '@/lib/menu-placement';
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
// Every menu opens next to what opened it: under a button, lined up with its
// edge, or at the pointer, and towards the other side only where there's no
// room. Never clamped somewhere away from the button.
function rect(left: number, top: number, width: number, height: number): DOMRect {
return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
}
const MENU = { width: 220, height: 300 };
beforeEach(() => {
Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
Object.defineProperty(window, 'innerHeight', { value: 720, configurable: true });
Object.defineProperty(window, 'outerWidth', { value: 1280, configurable: true });
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
return this.getAttribute('role') === 'menu' ? rect(0, 0, MENU.width, MENU.height) : rect(0, 0, 0, 0);
});
});
afterEach(() => {
vi.restoreAllMocks();
window.bulwarkDesktop = undefined;
document.documentElement.dir = '';
});
function openAt(position: ContextMenuPosition) {
render(
<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;
}
});
});
+17
View File
@@ -0,0 +1,17 @@
import { Fragment } from "react";
import { addressBreakPieces } from "@/lib/text-breaks";
/** An address that wraps at its dots and after its @, not anywhere (lib/text-breaks.ts). */
export function BreakableAddress({ text }: { text: string }) {
const pieces = addressBreakPieces(text);
return (
<>
{pieces.map((piece, index) => (
<Fragment key={index}>
{index > 0 && <wbr />}
{piece}
</Fragment>
))}
</>
);
}
+70 -36
View File
@@ -18,7 +18,8 @@ import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { ChevronRight } from "@/components/icons";
import { useTranslations } from "next-intl";
import { nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu";
import { estimateNativeMenuWidth, nativeMenuPoint, nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu";
import { placeMenu } from "@/lib/menu-placement";
import { toast } from "@/stores/toast-store";
interface Position {
@@ -241,7 +242,7 @@ function useMenuLevel(recentTouch: () => boolean): MenuLevel {
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
({ isOpen, position, onClose, children, label }, ref) => {
const [mounted, setMounted] = useState(false);
const [adjustedPosition, setAdjustedPosition] = useState<Position | null>(null);
const [adjustedPosition, setAdjustedPosition] = useState<(Position & { maxHeight?: number }) | null>(null);
const localRef = useRef<HTMLDivElement | null>(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
@@ -282,8 +283,11 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
}
const { anchor: at } = positionRef.current;
// From a right-click it opens at the pointer, which the OS knows best.
// From a button: under it, lined up with the edge the HTML menu would
// use. The hidden copy is as wide as its widest submenu row, not as the
// native menu, so the native menu's own width is estimated.
const place = at
? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP }
? nativeMenuPoint(at, estimateNativeMenuWidth(read.items), getComputedStyle(root).direction === "rtl", ANCHOR_GAP)
: undefined;
const outcome = await showNativeMenu(read, root, place, tDesktopRef.current("from_plugins"));
if (cancelled) return;
@@ -322,34 +326,25 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
const clamp = () => {
const rect = node.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// The height it would have uncapped: a cap set last time mustn't decide the next.
const list = node.firstElementChild as HTMLElement | null;
const height = list && list.scrollHeight > list.clientHeight
? rect.height + list.scrollHeight - list.clientHeight
: rect.height;
// Next to the button it opened from, or at the pointer (lib/menu-placement.ts).
const { x, y, maxHeight } = placeMenu({
point: { x: position.x, y: position.y },
anchor,
size: { width: rect.width, height },
viewport: { width: window.innerWidth, height: window.innerHeight },
rtl: getComputedStyle(node).direction === "rtl",
margin: VIEWPORT_MARGIN,
gap: ANCHOR_GAP,
});
let x = position.x;
let y = position.y;
if (anchor) {
// Under the anchor, lined up with its start edge (end edge for
// align="end"); above it when there is no room below.
const rtl = getComputedStyle(node).direction === "rtl";
const alignEnd = (anchor.align === "end") !== rtl;
x = alignEnd ? anchor.right - rect.width : anchor.left;
y = anchor.bottom + ANCHOR_GAP;
if (y + rect.height > vh - VIEWPORT_MARGIN && anchor.top - ANCHOR_GAP - rect.height >= VIEWPORT_MARGIN) {
y = anchor.top - ANCHOR_GAP - rect.height;
}
}
if (x + rect.width > vw - VIEWPORT_MARGIN) {
x = vw - rect.width - VIEWPORT_MARGIN;
}
if (y + rect.height > vh - VIEWPORT_MARGIN) {
y = vh - rect.height - VIEWPORT_MARGIN;
}
x = Math.max(VIEWPORT_MARGIN, x);
y = Math.max(VIEWPORT_MARGIN, y);
setAdjustedPosition((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y }));
setAdjustedPosition((prev) =>
prev && prev.x === x && prev.y === y && prev.maxHeight === maxHeight ? prev : { x, y, maxHeight }
);
};
clamp();
if (typeof ResizeObserver === "undefined") return;
@@ -540,7 +535,14 @@ export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(
}}
>
{/* Taller than the window: scroll inside the menu. */}
<div className="py-1 overflow-y-auto" style={{ maxHeight: `calc(100vh - ${2 * VIEWPORT_MARGIN + 2}px)` }}>
<div
className="py-1 overflow-y-auto"
style={{
maxHeight: adjustedPosition?.maxHeight !== undefined
? `${Math.max(0, adjustedPosition.maxHeight - 2)}px`
: `calc(100vh - ${2 * VIEWPORT_MARGIN + 2}px)`,
}}
>
{children}
</div>
</div>
@@ -574,7 +576,10 @@ interface ContextMenuItemProps {
onClick: () => void;
disabled?: boolean;
destructive?: boolean;
/** The key that does the same, shown apart from the label. */
shortcut?: string;
/** A count at the end of the row, e.g. unread notifications. Not a key. */
badge?: string;
/** Stable hook for integration tests (not user-visible). */
testId?: string;
/** Tooltip, e.g. the address behind a display name. */
@@ -590,6 +595,7 @@ export function ContextMenuItem({
disabled = false,
destructive = false,
shortcut,
badge,
testId,
title,
hint,
@@ -636,14 +642,41 @@ export function ContextMenuItem({
) : (
<span className="flex-1 min-w-0 [overflow-wrap:anywhere]" data-menu-label="">{label}</span>
)}
{/* Key hints mean nothing without a keyboard: hidden on touch (R8). */}
{shortcut && (
<span className="text-xs text-muted-foreground ms-auto pointer-coarse:hidden">{shortcut}</span>
)}
{shortcut && <MenuShortcut keys={shortcut} />}
{badge && <MenuBadge text={badge} />}
</button>
);
}
/**
* The key that does what a menu row does, at the row's end, muted, as a keycap. The
* desktop app's native menu shows it as the item's accelerator, apart from the label
* (lib/desktop/native-menu.ts reads `data-menu-shortcut`). Key hints mean nothing
* without a keyboard: hidden on touch (R8).
*/
export function MenuShortcut({ keys, className }: { keys: string; className?: string }) {
return (
<kbd
data-menu-shortcut=""
className={cn(
"ms-auto shrink-0 rounded border border-border px-1.5 font-sans text-[0.6875rem] leading-4 text-muted-foreground pointer-coarse:hidden",
className
)}
>
{keys}
</kbd>
);
}
/** A count at a row's end ("3" unread). Not a key: a native menu puts it after the label. */
export function MenuBadge({ text }: { text: string }) {
return (
<span data-menu-badge="" className="ms-auto shrink-0 text-xs tabular-nums text-muted-foreground">
{text}
</span>
);
}
export function ContextMenuSeparator() {
return <div className="h-px bg-border my-1" role="separator" />;
}
@@ -781,7 +814,8 @@ export function ContextMenuSubMenu({
className="min-w-[180px] max-w-[min(20rem,calc(100vw-20px))] bg-background rounded-md shadow-lg border border-border"
role="menu"
>
<div className="py-1 max-h-[min(300px,calc(100vh-40px))] overflow-y-auto">
{/* As tall as the window allows (it's moved up to fit), then it scrolls. */}
<div className="py-1 max-h-[calc(100vh-22px)] overflow-y-auto">
{children}
</div>
</SubMenuPanel>
@@ -0,0 +1,37 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import type { DragEvent } from 'react';
import { DragDropProvider, useDragDropContext } from '@/contexts/drag-drop-context';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useMailboxDrop } from '../use-mailbox-drop';
// A folder in the sidebar takes dragged mail where a Move list would offer
// it, and nowhere else: not the open folder, not one without write rights,
// not Drafts.
const box = (id: string, extra: Partial<Mailbox> = {}) =>
({ id, name: id, myRights: { mayAddItems: true }, ...extra }) as Mailbox;
function dragOver(mailbox: Mailbox) {
const { result } = renderHook(
() => ({ drag: useDragDropContext(), drop: useMailboxDrop({ mailbox }) }),
{ wrapper: DragDropProvider },
);
act(() => result.current.drag.startDrag([{ id: 'e1' } as Email], 'inbox'));
const event = { preventDefault() {}, stopPropagation() {}, dataTransfer: { dropEffect: 'move' } } as unknown as DragEvent<HTMLDivElement>;
act(() => result.current.drop.dropHandlers.onDragEnter(event));
act(() => result.current.drop.dropHandlers.onDragOver(event));
return { effect: event.dataTransfer.dropEffect, valid: result.current.drop.isValidDropTarget };
}
describe('dropping mail on a folder', () => {
it('is allowed on another writable folder', () => {
expect(dragOver(box('work'))).toEqual({ effect: 'move', valid: true });
});
it('is refused on the open folder, a read-only one and Drafts', () => {
expect(dragOver(box('inbox'))).toEqual({ effect: 'none', valid: false });
expect(dragOver(box('ro', { myRights: { mayAddItems: false } as Mailbox['myRights'] }))).toEqual({ effect: 'none', valid: false });
expect(dragOver(box('drafts', { role: 'drafts' }))).toEqual({ effect: 'none', valid: false });
});
});
+41
View File
@@ -0,0 +1,41 @@
"use client";
import { useSyncExternalStore } from "react";
import { serverKey, useOfflineStore } from "@/lib/offline/state";
import { offlineAllowed } from "@/lib/offline/offline-mode";
import { useAuthStore } from "@/stores/auth-store";
function subscribeOnline(callback: () => void): () => void {
window.addEventListener("online", callback);
window.addEventListener("offline", callback);
return () => {
window.removeEventListener("online", callback);
window.removeEventListener("offline", callback);
};
}
export interface ConnectionState {
/** No network, or the open account's mail server can't be reached. */
offline: boolean;
/**
* Changes to messages still work: online, or the account keeps its mail on this
* device and the change waits for the connection. Folders, rules and sharing
* always need the server.
*/
canChangeMail: boolean;
}
/** What the menus go by to grey out what can't work without a connection. */
export function useConnectionState(): ConnectionState {
const account = useAuthStore((s) => s.activeAccountId);
const server = useAuthStore((s) => s.serverUrl);
const navigatorOffline = useSyncExternalStore(
subscribeOnline,
() => navigator.onLine === false,
() => false,
);
const storeOffline = useOfflineStore((s) => s.browserOffline || (!!server && s.downServers.includes(serverKey(server))));
const keepsMail = useOfflineStore((s) => !!account && s.enabled.includes(account));
const offline = navigatorOffline || storeOffline;
return { offline, canChangeMail: !offline || (keepsMail && offlineAllowed(account)) };
}
+4 -30
View File
@@ -24,10 +24,6 @@ export interface UseContextMenuReturn<T> {
menuRef: React.RefObject<HTMLDivElement | null>;
}
const MENU_WIDTH = 200;
const MENU_HEIGHT = 320; // Approximate max height
const VIEWPORT_MARGIN = 10;
/** Shift+F10 or the Menu key: open the focused element's context menu. */
export function isContextMenuKey(e: { key: string; shiftKey: boolean }): boolean {
return e.key === "ContextMenu" || (e.shiftKey && e.key === "F10");
@@ -71,30 +67,6 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
const menuRef = useRef<HTMLDivElement | null>(null);
const calculatePosition = useCallback((clientX: number, clientY: number): ContextMenuPosition => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = clientX;
let y = clientY;
// Adjust for right edge
if (x + MENU_WIDTH > viewportWidth - VIEWPORT_MARGIN) {
x = viewportWidth - MENU_WIDTH - VIEWPORT_MARGIN;
}
// Adjust for bottom edge
if (y + MENU_HEIGHT > viewportHeight - VIEWPORT_MARGIN) {
y = viewportHeight - MENU_HEIGHT - VIEWPORT_MARGIN;
}
// Ensure minimum position
x = Math.max(VIEWPORT_MARGIN, x);
y = Math.max(VIEWPORT_MARGIN, y);
return { x, y };
}, []);
const openContextMenu = useCallback((e: React.MouseEvent, data: T) => {
e.preventDefault();
e.stopPropagation();
@@ -103,14 +75,16 @@ export function useContextMenu<T>(): UseContextMenuReturn<T> {
const trigger = target ? focusReturnTarget(target) : null;
const position = target && openedByKeyboard(e)
? { ...anchorPosition(target), trigger }
: { ...calculatePosition(e.clientX, e.clientY), trigger };
// At the pointer as it is: the menu places itself once it knows its size,
// opening up or to the side where there's no room (lib/menu-placement.ts).
: { x: e.clientX, y: e.clientY, trigger };
setContextMenu({
isOpen: true,
position,
data,
});
}, [calculatePosition]);
}, []);
const openContextMenuFrom = useCallback((element: HTMLElement, data: T, align: "start" | "end" = "start") => {
setContextMenu({ isOpen: true, position: anchorPosition(element, align), data });
+3
View File
@@ -83,6 +83,9 @@ export function useMailboxDrop({ mailbox, accountId, onDropComplete, onSuccess,
// Virtual nodes (shared folder headers) cannot be drop targets
if (mailbox.id.startsWith("shared-")) return false;
// Drafts holds what's being written: no Move list offers it either.
if (mailbox.role === "drafts") return false;
return true;
}, [isDragging, mailbox, sourceMailboxId]);
+150
View File
@@ -0,0 +1,150 @@
/**
* Nothing cut off at common window sizes: the main screens at 1280×720 and 1366×768,
* and at 125 % and 150 % zoom (the same windows in CSS pixels), in English, German
* (long words) and Arabic (right to left). Each screen fails on a line of text that
* runs past the edge of the box it sits in or hides behind a scroll bar
* (helpers/layout-fit.ts). The message details panel also has to give the sender's
* address room and show its dates on one line.
*
* Runs as dso-menus@example.org on Stalwart 1.0: check or create the account on the
* VM first (~/desktop-offline/dso_menus_provision.py stw-v1, then
* dso_menus_authmail.py stw-v1 for the message with long sign-in results), then
* desktop-offline-rig/run-spec.sh with IT_DSO_PASSWORD from the vault item
* "Stalwart test – dso-* throwaway accounts". Reads only; changes nothing.
* IT_LAYOUT_SHOTS: a folder for a screenshot of each screen. IT_LAYOUT_SCREENS=mail:
* the mail screens only.
*/
import { test, expect, type Browser, type Page } from '@playwright/test';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { requireEntEnv } from './helpers/ent';
import { accountSwitcher, neutralizeDevOverlay } from './helpers/app';
import { WEBMAIL_URL } from './helpers/config';
import { findCutOffText, type CutOff } from './helpers/layout-fit';
requireEntEnv();
test.setTimeout(600_000);
const USER = 'dso-menus@example.org';
const PASSWORD = process.env.IT_DSO_PASSWORD ?? '';
if (!PASSWORD) throw new Error('Set IT_DSO_PASSWORD, and check the account on the VM first: see the top of this file');
const AUTH_SUBJECT = 'Your weekly account summary is ready for review';
const SHOTS = process.env.IT_LAYOUT_SHOTS ?? '';
const MAIL_ONLY = process.env.IT_LAYOUT_SCREENS === 'mail';
const SETTINGS_TABS = ['account', 'security', 'notifications', 'general', 'appearance', 'accessibility', 'reading',
'composing', 'organize', 'filters', 'vacation', 'calendar', 'contacts', 'files', 'backup'];
interface Scenario { name: string; locale: string; language: string; width: number; height: number }
const SCENARIOS: Scenario[] = [
{ name: '1280x720-en', locale: 'en-US', language: 'en', width: 1280, height: 720 },
{ name: '1366x768-de', locale: 'de-DE', language: 'de', width: 1366, height: 768 },
// 1280×720 at 125 % and 1366×768 at 150 %: the same window in CSS pixels.
{ name: '1280x720-125-de', locale: 'de-DE', language: 'de', width: 1024, height: 576 },
{ name: '1366x768-150-de', locale: 'de-DE', language: 'de', width: 911, height: 512 },
{ name: '1280x720-ar', locale: 'ar', language: 'ar', width: 1280, height: 720 },
];
function viewerText(language: string): Record<string, string> {
const file = path.join(__dirname, '..', '..', 'locales', language, 'common.json');
const viewer = JSON.parse(readFileSync(file, 'utf-8')).email_viewer;
return { showDetails: viewer.show_details, routing: viewer.details.recipients_routing };
}
async function signIn(browser: Browser, scenario: Scenario): Promise<Page> {
const context = await browser.newContext({
baseURL: WEBMAIL_URL,
locale: scenario.locale,
viewport: { width: scenario.width, height: scenario.height },
});
const page = await context.newPage();
await neutralizeDevOverlay(page);
await page.goto('/');
await page.fill('#username', USER);
await page.fill('#password', PASSWORD);
await page.click('button[type="submit"]');
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
return page;
}
async function settle(page: Page): Promise<void> {
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {});
await page.waitForTimeout(800);
}
async function check(page: Page, scenario: Scenario, screen: string, found: Map<string, CutOff[]>): Promise<void> {
await settle(page);
if (SHOTS) {
if (!existsSync(SHOTS)) mkdirSync(SHOTS, { recursive: true });
await page.screenshot({ path: path.join(SHOTS, `${screen}-${scenario.name}.png`) });
}
const cut = await findCutOffText(page);
if (cut.length) found.set(screen, cut);
}
function report(found: Map<string, CutOff[]>): string {
return [...found].map(([screen, list]) => `${screen}:\n${list.map((c) => ` ${c.px}px ${c.where} "${c.text}"`).join('\n')}`).join('\n');
}
for (const scenario of SCENARIOS) {
test(`nothing is cut off at ${scenario.name}`, async ({ browser }) => {
const page = await signIn(browser, scenario);
const text = viewerText(scenario.language);
const found = new Map<string, CutOff[]>();
// Mail: the list, a message with long sign-in results and its details open.
const item = page.locator('[data-testid="email-list-item"]', { hasText: AUTH_SUBJECT }).first();
await item.waitFor({ state: 'visible', timeout: 60000 });
await check(page, scenario, 'mail-list', found);
await item.click();
await page.getByRole('button', { name: text.showDetails }).first().click();
// Sections nest: the last match is the innermost, the routing section itself.
const routing = page.locator('section', { has: page.getByText(text.routing, { exact: true }) }).last();
await expect(routing).toBeVisible();
await check(page, scenario, 'mail-details', found);
// The sender's address gets a column of its own width, not a sliver, and a date
// takes one line. Rows: From, To, Sent, Received.
const fit = await routing.locator('dl').first().evaluate((list) => {
const values = Array.from(list.querySelectorAll(':scope > dd'));
const from = values[0].getBoundingClientRect();
const sent = values[values.length - 2];
const lineHeight = parseFloat(getComputedStyle(sent).lineHeight) || 20;
return { fromWidth: Math.round(from.width), sentLines: Math.round(sent.getBoundingClientRect().height / lineHeight) };
});
expect.soft(fit.fromWidth, 'width of the From value in the details panel').toBeGreaterThanOrEqual(220);
expect.soft(fit.sentLines, 'lines the Sent date takes in the details panel').toBeLessThanOrEqual(1);
if (scenario.language === 'ar' || MAIL_ONLY) {
expect(found, report(found)).toEqual(new Map());
return;
}
// Composer.
await page.keyboard.press('Escape');
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
await check(page, scenario, 'composer', found);
await page.keyboard.press('Escape');
// Calendar, contacts, files.
await page.goto('/calendar');
await page.waitForTimeout(1500);
await page.keyboard.press('w');
await check(page, scenario, 'calendar-week', found);
await page.keyboard.press('m');
await check(page, scenario, 'calendar-month', found);
await page.goto('/contacts');
await check(page, scenario, 'contacts', found);
await page.goto('/files');
await check(page, scenario, 'files', found);
for (const tab of SETTINGS_TABS) {
await page.goto(`/settings/${tab}`);
await check(page, scenario, `settings-${tab}`, found);
}
expect(found, report(found)).toEqual(new Map());
});
}
+98
View File
@@ -0,0 +1,98 @@
/**
* Finds text that's cut off at the side: a line of text that runs past the edge of
* the nearest box that clips it (overflow other than visible), or past the window
* when nothing clips it. A box's vertical scroll bar counts as its edge, so text
* hidden behind a scroll bar is found too.
*
* Not counted: text that's meant to be cut (text-overflow: ellipsis, line clamp),
* text inside a box that scrolls only sideways, text that's wholly outside its box (a
* closed drawer, a slide), text in a box 1-2px wide (visually hidden), screen-reader-only text, and anything marked
* data-layout-fit-ignore. Frames (a message body) aren't looked into. A field's
* placeholder counts too, unless it ends in an ellipsis.
*/
import type { Page } from '@playwright/test';
export interface CutOff {
/** Tag, test id and a few classes of the element holding the text. */
where: string;
text: string;
/** How many CSS pixels are cut off. */
px: number;
}
export async function findCutOffText(page: Page): Promise<CutOff[]> {
return page.evaluate(() => {
const out: { where: string; text: string; px: number }[] = [];
const seen = new Set<Element>();
const viewport = { left: 0, right: document.documentElement.clientWidth };
const clipsX = (s: CSSStyleDeclaration) => s.overflowX !== 'visible';
// A sideways scroller on purpose: one that scrolls sideways only. A box that
// scrolls up and down computes overflow-x: auto as well, and text running off
// its side is still cut off for anyone who doesn't think to scroll sideways.
const scrollsX = (s: CSSStyleDeclaration) =>
(s.overflowX === 'auto' || s.overflowX === 'scroll') && s.overflowY !== 'auto' && s.overflowY !== 'scroll';
const describe = (el: Element) => {
const id = el.getAttribute('data-testid');
const cls = (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean).slice(0, 4).join('.');
return `${el.tagName.toLowerCase()}${id ? `[data-testid=${id}]` : ''}${cls ? `.${cls}` : ''}`;
};
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim();
const el = node.parentElement;
if (!text || !el || seen.has(el)) continue;
if (el.closest('script, style, noscript, [aria-hidden="true"], .sr-only, [data-layout-fit-ignore]')) continue;
const own = getComputedStyle(el);
if (own.visibility === 'hidden' || own.display === 'none' || Number(own.opacity) === 0) continue;
const range = document.createRange();
range.selectNodeContents(node);
const rects = Array.from(range.getClientRects()).filter((r) => r.width > 0.5 && r.height > 0.5);
if (rects.length === 0) continue;
const textLeft = Math.min(...rects.map((r) => r.left));
const textRight = Math.max(...rects.map((r) => r.right));
let meant = false;
let box: { left: number; right: number } | null = null;
for (let at: Element | null = el; at && at !== document.documentElement; at = at.parentElement) {
const s = getComputedStyle(at);
if (s.textOverflow === 'ellipsis' || (s.webkitLineClamp && s.webkitLineClamp !== 'none')) meant = true;
if (Number(s.opacity) === 0 || s.visibility === 'hidden') { meant = true; break; }
if (!clipsX(s)) continue;
if (scrollsX(s)) { meant = true; break; }
// A strip of pages that scrolls both ways (the calendar's weeks side by side):
// the next page peeking in is meant. Text spilling over by accident is a few
// pixels to a column, never half the box again.
if (s.overflowX !== 'hidden' && s.overflowX !== 'clip' && at.scrollWidth > at.clientWidth * 1.5) { meant = true; break; }
// A 1px box is the visually-hidden pattern: read out, not shown.
if (at.clientWidth <= 2) { meant = true; break; }
const r = at.getBoundingClientRect();
const left = r.left + at.clientLeft;
box = { left, right: left + at.clientWidth };
break;
}
if (meant) continue;
const edge = box ?? viewport;
// Wholly outside: not cut, put away.
if (textRight <= edge.left || textLeft >= edge.right) continue;
const px = Math.max(textRight - edge.right, edge.left - textLeft);
if (px > 1.5) {
seen.add(el);
out.push({ where: describe(el), text: text.slice(0, 60), px: Math.round(px) });
}
}
// A placeholder cut off mid-word: "Termine durchs". With text-overflow: ellipsis
// it ends in "…" and says there's more.
const canvas = document.createElement('canvas').getContext('2d');
for (const input of Array.from(document.querySelectorAll<HTMLInputElement>('input[placeholder]'))) {
if (input.value || !input.placeholder || input.closest('[aria-hidden="true"], [data-layout-fit-ignore]')) continue;
const s = getComputedStyle(input);
if (s.display === 'none' || s.visibility === 'hidden' || input.offsetParent === null) continue;
if (s.textOverflow === 'ellipsis' || !canvas) continue;
canvas.font = `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`;
const room = input.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight);
const px = canvas.measureText(input.placeholder).width - room;
if (px > 1.5) out.push({ where: describe(input), text: input.placeholder.slice(0, 60), px: Math.round(px) });
}
return out;
});
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { buildMoveMenu, filterMoveNodes, type MoveMenuNode } from '../move-menu';
const writable = { mayAddItems: true } as Mailbox['myRights'];
const readOnly = { mayAddItems: false } as Mailbox['myRights'];
function own(id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox {
return {
id, originalId: id, name, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0,
myRights: writable, isSubscribed: true, accountId: 'A', accountName: 'me@example.org', isShared: false, ...extra,
} as Mailbox;
}
function shared(account: string, id: string, name: string, extra: Partial<Mailbox> = {}): Mailbox {
return own(`${account}:${id}`, name, { originalId: id, accountId: account, accountName: `${account.toLowerCase()}@example.org`, isShared: true, ...extra });
}
// What a real server hands over: system folders with a high sortOrder, the
// user's folders in creation order, nesting three deep.
const mailboxes = [
own('zzz', 'Zettelkasten'),
own('trash', 'Trash', { role: 'trash', sortOrder: 50 }),
own('work', 'Work'),
own('inbox', 'Inbox', { role: 'inbox', sortOrder: 10 }),
own('y10', '10', { parentId: 'receipts' }),
own('y9', '9', { parentId: 'receipts' }),
own('junk', 'Junk Mail', { role: 'junk', sortOrder: 40 }),
own('receipts', 'Receipts'),
own('sent', 'Sent Items', { role: 'sent', sortOrder: 30 }),
own('drafts', 'Drafts', { role: 'drafts', sortOrder: 20 }),
own('archive', 'Archive', { role: 'archive', sortOrder: 35 }),
own('clients', 'Clients', { parentId: 'work' }),
own('acme', 'Acme', { parentId: 'clients' }),
own('apple', 'apple'),
own('ro', 'Read only', { myRights: readOnly }),
own('ro-child', 'Below read only', { parentId: 'ro' }),
shared('S', 'inbox', 'Inbox', { role: 'inbox' }),
shared('S', 'projects', 'Projects'),
shared('T', 'x', 'Nothing writable', { myRights: readOnly }),
];
const names = (nodes: MoveMenuNode[]) => nodes.map((n) => n.name);
const ids = (nodes: MoveMenuNode[]): string[] => nodes.flatMap((n) => [n.id, ...ids(n.children)]);
describe('buildMoveMenu', () => {
it('lists system folders first in mail-client order, then the rest by name', () => {
const { nodes } = buildMoveMenu(mailboxes, { currentMailboxId: 'work', ownerAccountId: 'A', locale: 'en' });
// Drafts can't take a moved message; the server's sortOrder doesn't count.
expect(names(nodes)).toEqual(['Inbox', 'Sent Items', 'Archive', 'Junk Mail', 'Trash', 'apple', 'Read only', 'Receipts', 'Work', 'Zettelkasten']);
});
it('keeps the nesting, sorts numbers as numbers, and keeps a read-only parent as a step', () => {
const { nodes } = buildMoveMenu(mailboxes, { ownerAccountId: 'A', locale: 'en' });
const receipts = nodes.find((n) => n.id === 'receipts')!;
expect(names(receipts.children)).toEqual(['9', '10']);
const work = nodes.find((n) => n.id === 'work')!;
expect(work.children[0].children[0]).toMatchObject({ id: 'acme', path: ['Work', 'Clients'] });
const ro = nodes.find((n) => n.id === 'ro')!;
expect(ro.enabled).toBe(false);
expect(ro.children.map((n) => n.id)).toEqual(['ro-child']);
});
it('shows the open folder greyed when the messages are all in it', () => {
const inInbox = { mailboxIds: { inbox: true } } as unknown as Email;
const { nodes, targetCount } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [inInbox] });
expect(nodes[0]).toMatchObject({ id: 'inbox', enabled: false, current: true });
expect(targetCount).toBe(13);
});
it('lets a search hit from another folder move into the open one (#1452)', () => {
const elsewhere = { mailboxIds: { work: true } } as unknown as Email;
const { nodes } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [elsewhere] });
expect(nodes[0]).toMatchObject({ id: 'inbox', enabled: true, current: true });
});
it('keeps only the message\'s own account at the top, the others under their names', () => {
const own = buildMoveMenu(mailboxes, { ownerAccountId: 'A' });
expect(ids(own.nodes).some((id) => id.startsWith('S:'))).toBe(false);
// An account with nothing writable isn't offered.
expect(own.others.map((a) => [a.accountId, a.name])).toEqual([['S', 's@example.org']]);
const inShared = buildMoveMenu(mailboxes, { ownerAccountId: 'S', currentMailboxId: 'S:inbox' });
expect(inShared.accountId).toBe('S');
expect(names(inShared.nodes)).toEqual(['Inbox', 'Projects']);
expect(inShared.others.map((a) => [a.accountId, a.name])).toEqual([['A', 'me@example.org']]);
});
it('offers the last folders moved to that are still there and can still be picked', () => {
const inInbox = { mailboxIds: { inbox: true } } as unknown as Email;
const recentIds = (account: string | undefined) => (account === 'A' ? ['gone', 'inbox', 'acme', 'S:projects', 'sent', 'trash'] : []);
const { recent } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [inInbox], recentIds });
// A deleted folder and the open one drop out; three show.
expect(recent.map((n) => n.id)).toEqual(['acme', 'S:projects', 'sent']);
});
});
describe('filterMoveNodes', () => {
it('keeps the matches with the folders above them, ignoring case and accents', () => {
const { nodes } = buildMoveMenu([...mailboxes, own('kueche', 'Küche')], { ownerAccountId: 'A', locale: 'de' });
const label = (n: MoveMenuNode) => n.name;
expect(ids(filterMoveNodes(nodes, 'ACM', label))).toEqual(['work', 'clients', 'acme']);
expect(ids(filterMoveNodes(nodes, 'kuche', label))).toEqual(['kueche']);
expect(filterMoveNodes(nodes, ' ', label)).toBe(nodes);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { addressBreakPieces } from '../text-breaks';
describe('addressBreakPieces', () => {
it('breaks after the @ and before dots', () => {
expect(addressBreakPieces('dana.with.long@subdomain.example.net')).toEqual(['dana', '.with', '.long@', 'subdomain', '.example.net']);
});
it('breaks after + - and _', () => {
expect(addressBreakPieces('bounces+318-0dcc_x@em.example.org')).toEqual(['bounces+', '318-', '0dcc_', 'x@em', '.example.org']);
});
it('keeps short runs whole and loses no character', () => {
const text = 'a.b.c@d.io';
const pieces = addressBreakPieces(text);
expect(pieces.join('')).toBe(text);
expect(pieces.every((piece) => piece.length >= 3)).toBe(true);
});
it('never leaves the top-level domain alone on a line', () => {
expect(addressBreakPieces('someone@notifications.mail.example-service.example.net').at(-1)).toBe('.example.net');
});
it('leaves a name without break points as one piece', () => {
expect(addressBreakPieces('Anthropic')).toEqual(['Anthropic']);
});
});
+179 -24
View File
@@ -1,9 +1,11 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { WindowControlsHost } from '@/components/layout/window-controls-host';
import { WindowMenuBar } from '@/components/layout/window-menu-bar';
import { menuKeyText } from '@/lib/desktop/menu-bar';
import { desktopBootScript } from '@/lib/desktop/boot';
import { CONTROLS_ATTRIBUTE, CONTROLS_LEFT_ATTRIBUTE, ROW_ATTRIBUTE, ROW_LEFT_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, TITLEBAR_RIGHT_VAR, TITLEBAR_ROW_VAR, markWindowRows, readTitlebarArea, watchWindowRows } from '@/lib/window-controls-overlay';
class FakeOverlay extends EventTarget {
@@ -34,32 +36,56 @@ afterEach(() => {
root.removeAttribute(CONTROLS_LEFT_ATTRIBUTE);
});
describe('the menu strip in the desktop app', () => {
type BarEvent = { visible: boolean; open?: string };
describe('the menu bar in the desktop app', () => {
type BarEvent = { visible: boolean; open?: string; pinned?: boolean };
type Line = { key: string; label: string; type: string; enabled: boolean; checked?: boolean; accelerator?: string; mnemonic?: number; items?: Line[] };
function withBar() {
const line = (key: string, label: string, extra: Partial<Line> = {}): Line => ({ key, label, type: 'normal', enabled: true, mnemonic: 0, ...extra });
const MENUS = [
{ id: 'file', label: 'File', mnemonic: 'f', items: [
line('0/0', 'New message', { accelerator: 'CmdOrCtrl+N' }),
line('0/1', 'Print…', { enabled: false, mnemonic: 0 }),
{ key: '0/2', label: '', type: 'separator', enabled: false },
line('0/3', 'Exit', { mnemonic: 1, accelerator: 'Ctrl+Q' }),
] },
{ id: 'view', label: 'View', mnemonic: 'v', items: [
line('1/0', 'Reading pane', { type: 'submenu', items: [line('1/0/0', 'On the right', { type: 'radio', checked: true }), line('1/0/1', 'Off', { type: 'radio', mnemonic: 1 })] }),
line('1/1', 'Always show the menu bar', { type: 'checkbox', checked: false }),
line('1/2', 'Zoom in', { mnemonic: 0, accelerator: 'CmdOrCtrl+Plus' }),
] },
{ id: 'help', label: 'Help', mnemonic: 'h', items: [line('2/0', 'Keyboard shortcuts', { mnemonic: 0, accelerator: '?' })] },
];
function withBar(options: { legacy?: boolean; platform?: string } = {}) {
let send: (event: BarEvent) => void = () => {};
const open = vi.fn();
const run = vi.fn();
const report = vi.fn();
const setPinned = vi.fn();
window.bulwarkDesktop = {
version: 1,
platform: 'win32',
platform: options.platform ?? 'win32',
menu: {
setState: () => {},
bar: {
entries: async () => [{ id: 'file', label: 'File', mnemonic: 'f' }, { id: 'edit', label: 'Edit', mnemonic: 'e' }],
entries: async () => MENUS.map(({ id, label, mnemonic }) => ({ id, label, mnemonic })),
open,
onEvent: (listener: (event: BarEvent) => void) => {
send = listener;
return () => {};
},
...(options.legacy ? {} : { menus: async () => MENUS, run, report, setPinned }),
},
},
} as never;
return { open, send: (event: BarEvent) => send(event) };
return { open, run, report, setPinned, send: (event: BarEvent) => act(async () => send(event)) };
}
afterEach(() => {
window.bulwarkDesktop = undefined;
document.documentElement.removeAttribute('data-menu-bar');
document.documentElement.removeAttribute('data-desktop');
localStorage.removeItem('bulwark-menu-bar');
});
const overlayOn = () => {
@@ -73,55 +99,184 @@ describe('the menu strip in the desktop app', () => {
delete (navigator as unknown as { windowControlsOverlay?: unknown }).windowControlsOverlay;
});
it('Alt shows the names with the focus on the first, and Escape gives it back', async () => {
overlayOn();
const bar = withBar();
const before = document.createElement('button');
const typed = () => {
const before = document.createElement('input');
document.body.appendChild(before);
before.focus();
return before;
};
it('Alt shows a row that moves the app down, with the keys on the first name, and Escape gives them back', async () => {
overlayOn();
const bar = withBar();
const before = typed();
render(<WindowMenuBar />);
await act(async () => bar.send({ visible: true }));
await bar.send({ visible: true });
const file = await screen.findByRole('menuitem', { name: 'File' });
await waitFor(() => expect(document.activeElement).toBe(file));
expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true);
expect(screen.getByRole('menubar').hasAttribute('data-window-top-row')).toBe(true);
expect(bar.report).toHaveBeenLastCalledWith(expect.objectContaining({ active: true }));
// The letter that opens it is underlined while the keys are on the bar.
expect(file.querySelector('.underline')?.textContent).toBe('F');
fireEvent.keyDown(file, { key: 'ArrowRight' });
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'Edit' }));
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'View' }));
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
expect(screen.queryByRole('menubar')).toBeNull();
expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(false);
expect(bar.report).toHaveBeenLastCalledWith({ height: 0, active: false });
expect(document.activeElement).toBe(before);
before.remove();
});
it('draws the menu with its keys apart from the names, skips what is off, and runs a line where the keys were', async () => {
overlayOn();
const bar = withBar();
const before = typed();
render(<WindowMenuBar />);
await bar.send({ visible: true });
const file = await screen.findByRole('menuitem', { name: 'File' });
await waitFor(() => expect(document.activeElement).toBe(file));
fireEvent.keyDown(file, { key: 'ArrowDown' });
const menu = await screen.findByRole('menu', { name: 'File' });
const newMessage = within(menu).getByRole('menuitem', { name: /New message/ });
await waitFor(() => expect(document.activeElement).toBe(newMessage));
expect(newMessage.querySelector('[data-menu-label]')?.textContent).toBe('New message');
expect(newMessage.querySelector('[data-menu-keys]')?.textContent).toBe('Ctrl+N');
expect(within(menu).getByRole('menuitem', { name: /Print/ }).getAttribute('aria-disabled')).toBe('true');
// Down skips the line that's off.
fireEvent.keyDown(newMessage, { key: 'ArrowDown' });
expect(document.activeElement?.textContent).toContain('Exit');
fireEvent.keyDown(document.activeElement!, { key: 'Enter' });
expect(bar.run).toHaveBeenCalledWith('0/3', 'Exit');
expect(screen.queryByRole('menubar')).toBeNull();
expect(document.activeElement).toBe(before);
before.remove();
});
it('a name opens that menu natively', async () => {
it('the arrows go across to the next menu and into a submenu', async () => {
overlayOn();
const bar = withBar();
render(<WindowMenuBar />);
await act(async () => bar.send({ visible: true }));
fireEvent.click(await screen.findByRole('menuitem', { name: 'Edit' }));
expect(bar.open).toHaveBeenCalledWith('edit', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }));
await bar.send({ visible: true, open: 'file' });
const menu = await screen.findByRole('menu', { name: 'File' });
await waitFor(() => expect(menu.contains(document.activeElement)).toBe(true));
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
const view = await screen.findByRole('menu', { name: 'View' });
await waitFor(() => expect(document.activeElement?.textContent).toContain('Reading pane'));
expect(screen.queryByRole('menu', { name: 'File' })).toBeNull();
fireEvent.keyDown(document.activeElement!, { key: 'ArrowRight' });
const sub = await screen.findByRole('menu', { name: 'Reading pane' });
await waitFor(() => expect(sub.contains(document.activeElement)).toBe(true));
expect(within(sub).getByRole('menuitemradio', { name: /On the right/ }).getAttribute('aria-checked')).toBe('true');
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
expect(screen.queryByRole('menu', { name: 'Reading pane' })).toBeNull();
expect(document.activeElement?.textContent).toContain('Reading pane');
fireEvent.keyDown(document.activeElement!, { key: 'ArrowLeft' });
await screen.findByRole('menu', { name: 'File' });
expect(view.isConnected).toBe(false);
// Escape closes the menu and leaves the keys on its name.
await waitFor(() => expect(screen.getByRole('menu', { name: 'File' }).contains(document.activeElement)).toBe(true));
fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
expect(screen.queryByRole('menu')).toBeNull();
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: 'File' }));
});
it('Alt and a letter opens that one right away', async () => {
it('Alt and a letter opens that menu, and a line\'s letter runs it', async () => {
overlayOn();
const bar = withBar();
render(<WindowMenuBar />);
await act(async () => bar.send({ visible: true, open: 'file' }));
await waitFor(() => expect(bar.open).toHaveBeenCalledWith('file', expect.anything()));
await bar.send({ visible: true, open: 'view' });
const view = await screen.findByRole('menu', { name: 'View' });
await waitFor(() => expect(view.contains(document.activeElement)).toBe(true));
fireEvent.keyDown(document.activeElement!, { key: 'z' });
expect(bar.run).toHaveBeenCalledWith('1/2', 'Zoom in');
});
it('a click elsewhere puts it away', async () => {
overlayOn();
const bar = withBar();
render(<WindowMenuBar />);
await bar.send({ visible: true });
fireEvent.pointerDown(await screen.findByRole('menuitem', { name: 'Help' }), { button: 0 });
await screen.findByRole('menu', { name: 'Help' });
fireEvent.pointerDown(document.body);
expect(screen.queryByRole('menubar')).toBeNull();
});
it('always shown, it is there without Alt and takes no keys, and a click on a name opens that menu', async () => {
overlayOn();
const bar = withBar();
const before = typed();
render(<WindowMenuBar />);
await bar.send({ visible: false, pinned: true });
const help = await screen.findByRole('menuitem', { name: 'Help' });
expect(document.activeElement).toBe(before);
expect(help.querySelector('.underline')).toBeNull();
expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true);
expect(localStorage.getItem('bulwark-menu-bar')).toBe('pinned');
fireEvent.pointerDown(help, { button: 0 });
const menu = await screen.findByRole('menu', { name: 'Help' });
fireEvent.click(within(menu).getByRole('menuitem', { name: /Keyboard shortcuts/ }));
expect(bar.run).toHaveBeenCalledWith('2/0', 'Keyboard shortcuts');
// Still there: it's always shown.
expect(screen.getByRole('menubar')).toBeTruthy();
expect(document.activeElement).toBe(before);
await bar.send({ visible: false, pinned: false });
expect(screen.queryByRole('menubar')).toBeNull();
expect(localStorage.getItem('bulwark-menu-bar')).toBeNull();
before.remove();
});
it('an older shell opens the menu natively', async () => {
overlayOn();
const bar = withBar({ legacy: true });
render(<WindowMenuBar />);
await bar.send({ visible: true });
fireEvent.pointerDown(await screen.findByRole('menuitem', { name: 'View' }), { button: 0 });
expect(bar.open).toHaveBeenCalledWith('view', expect.objectContaining({ x: expect.any(Number), y: expect.any(Number) }));
});
it('the shell can take it away again', async () => {
overlayOn();
const bar = withBar();
render(<WindowMenuBar />);
await act(async () => bar.send({ visible: true }));
await bar.send({ visible: true });
await screen.findByRole('menubar');
await act(async () => bar.send({ visible: false }));
await bar.send({ visible: false });
expect(screen.queryByRole('menubar')).toBeNull();
});
it('with no bridge there is none, and the title stays', async () => {
it('with no bridge there is none, and none on a Mac, which has the system bar', async () => {
overlayOn();
render(<WindowMenuBar />);
const { unmount } = render(<WindowMenuBar />);
expect(screen.queryByRole('menubar')).toBeNull();
unmount();
const bar = withBar({ platform: 'darwin' });
render(<WindowMenuBar />);
await bar.send({ visible: true });
expect(screen.queryByRole('menubar')).toBeNull();
});
it('an always-shown bar has its row from the first frame, before any script of the app', () => {
overlayOn();
localStorage.setItem('bulwark-menu-bar', 'pinned');
window.bulwarkDesktop = { version: 1, platform: 'darwin' } as never;
new Function(desktopBootScript())();
expect(document.documentElement.hasAttribute('data-menu-bar'), 'a Mac has the system bar').toBe(false);
window.bulwarkDesktop = { version: 1, platform: 'win32' } as never;
new Function(desktopBootScript())();
expect(document.documentElement.hasAttribute('data-menu-bar')).toBe(true);
});
it('menu keys read the way Windows writes them', () => {
expect(menuKeyText('CmdOrCtrl+N', 'win32')).toBe('Ctrl+N');
expect(menuKeyText('CmdOrCtrl+Plus', 'win32')).toBe('Ctrl++');
expect(menuKeyText('Alt+Left', 'win32')).toBe('Alt+←');
expect(menuKeyText('Shift+R', 'linux')).toBe('Shift+R');
expect(menuKeyText('Delete', 'win32')).toBe('Del');
expect(menuKeyText('?', 'win32')).toBe('?');
expect(menuKeyText('CommandOrControl+Shift+Z', 'win32')).toBe('Ctrl+Shift+Z');
});
});
@@ -0,0 +1,93 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { ROW_ATTRIBUTE, ROW_RIGHT_ATTRIBUTE, markWindowRows } from '@/lib/window-controls-overlay';
/** The controls take the right 138px of the window, 56px high. */
const AREA = { x: 0, y: 0, width: 1024 - 138, height: 56 };
function box(el: HTMLElement, top: number, height: number, left = 0, right = 1024) {
el.getBoundingClientRect = () => ({ top, bottom: top + height, left, right, width: right - left, height, x: left, y: top, toJSON: () => ({}) }) as DOMRect;
}
function row(parent: HTMLElement = document.body): HTMLElement {
const el = document.createElement('div');
el.className = 'h-14';
parent.appendChild(el);
return el;
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('rows under the window\'s controls', () => {
it('a toolbar pushed down by a line above it still makes room for the controls', () => {
// "This message was moved", an offline note, an update banner: the toolbar starts at 37px.
const toolbar = row();
box(toolbar, 37, 56);
markWindowRows(AREA);
expect(toolbar.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
// It isn't the window's top, so it isn't what the window is dragged by.
expect(toolbar.hasAttribute(ROW_ATTRIBUTE)).toBe(false);
});
it('a row at the top is still a row to drag by, with room at its end', () => {
const toolbar = row();
box(toolbar, 0, 56);
markWindowRows(AREA);
expect(toolbar.hasAttribute(ROW_ATTRIBUTE)).toBe(true);
expect(toolbar.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(true);
});
it('a row that starts below the controls is left alone', () => {
const below = row();
box(below, 56, 56);
markWindowRows(AREA);
expect(below.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
});
it('a row of a list scrolled up under the header is left alone', () => {
const list = document.createElement('div');
document.body.appendChild(list);
Object.defineProperty(list, 'scrollTop', { value: 120, configurable: true });
const item = row(list);
box(item, 20, 56);
markWindowRows(AREA);
expect(item.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
});
it('a row that ends before the controls gets no room for them', () => {
const left = row();
box(left, 30, 56, 0, 500);
markWindowRows(AREA);
expect(left.hasAttribute(ROW_RIGHT_ATTRIBUTE)).toBe(false);
});
});
const read = (file: string) => readFileSync(join(process.cwd(), file), 'utf8');
describe('a pane\'s close next to the window\'s', () => {
it('moves to the other end of the row, on the side away from the controls', () => {
const css = read('app/globals.css');
expect(css).toMatch(/html\[data-window-controls\]:not\(\[dir="rtl"\]\) \[data-window-row-right\] > \[data-pane-close\]/);
expect(css).toMatch(/html\[data-window-controls\]\[dir="rtl"\] \[data-window-row-left\] > \[data-pane-close\]/);
});
it('is marked on every pane that can end up at the window\'s top right', () => {
for (const file of [
'components/email/email-composer.tsx',
'components/layout/inline-app-view.tsx',
'components/calendar/event-modal.tsx',
'components/files/preview-shell.tsx',
'components/calendar/task-modal.tsx',
'components/contacts/contact-form.tsx',
]) {
expect(read(file), file).toContain('data-pane-close');
}
});
it('toasts that step out of the way stay below the controls', () => {
expect(read('app/globals.css')).toMatch(/html\[data-window-controls\] \[data-toast-container\]\[data-dodging\]\s*\{[^}]*--titlebar-row-height/);
});
});
@@ -0,0 +1,98 @@
import { render, screen } from '@testing-library/react';
import { readdirSync, readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { ContextMenuItem } from '@/components/ui/context-menu';
import { readMenu, toAccelerator } from '../native-menu';
// A native menu built from an HTML one shows a row's key apart from its label,
// as the native menus' own accelerators do. The calendar's view menu came out
// as "DayD", "WeekW": the key was read as part of the label.
function menuOf(children: React.ReactNode) {
render(<div role="menu" data-testid="root">{children}</div>);
return readMenu(screen.getByTestId('root')).items;
}
describe('a row\'s key in a native menu', () => {
it('is not part of the label, and becomes the accelerator', () => {
const items = menuOf(
<button type="button" role="menuitemradio" aria-checked="false">
<span>Day</span>
<kbd>D</kbd>
</button>,
);
expect(items[0].label).toBe('Day');
expect(items[0].accelerator).toBe('D');
});
it('a menu item\'s shortcut too', () => {
const items = menuOf(<ContextMenuItem label="Cut" shortcut="Ctrl+X" onClick={() => {}} />);
expect(items[0]).toMatchObject({ label: 'Cut', accelerator: 'Ctrl+X' });
});
it('a count is no key: it goes after the label', () => {
const items = menuOf(<ContextMenuItem label="Notifications" badge="3" onClick={() => {}} />);
expect(items[0].label).toBe('Notifications (3)');
expect(items[0].accelerator).toBeUndefined();
});
it('words in separate elements stay separate words', () => {
const items = menuOf(
<button type="button" role="menuitem">
<span>Alice Example</span>
<span>alice@example.net</span>
</button>,
);
expect(items[0].label).toBe('Alice Example alice@example.net');
});
});
describe('toAccelerator', () => {
it.each([
['D', 'D'],
['r', 'R'],
['Shift + I', 'Shift+I'],
['Ctrl+X', 'Ctrl+X'],
['⌘X', 'Cmd+X'],
['⌘⇧K', 'Cmd+Shift+K'],
['Mod + Enter', 'CmdOrCtrl+Enter'],
['Del', 'Delete'],
['F2', 'F2'],
['?', '?'],
['#', '#'],
['Ctrl++', 'Ctrl+Plus'],
])('%s is %s', (hint, accelerator) => {
expect(toAccelerator(hint)).toBe(accelerator);
});
it.each(['j / ↓', '', 'Hyper+Q', 'twelve'])('%s is no single key', (hint) => {
expect(toAccelerator(hint)).toBeUndefined();
});
});
// Menu rows show their keys through MenuShortcut (or ContextMenuItem's
// shortcut), which the native menu knows. A raw <kbd> in a file with menu
// rows is how "DayD" happened.
describe('menu rows show keys through MenuShortcut', () => {
const root = path.resolve(__dirname, '../../..');
it('no raw <kbd> next to role="menuitem…" rows', () => {
const files: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === '__tests__' || entry.name.startsWith('.')) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.name.endsWith('.tsx')) files.push(full);
}
};
walk(path.join(root, 'components'));
walk(path.join(root, 'app'));
const offenders = files.filter((file) => {
if (file.endsWith(path.join('ui', 'context-menu.tsx'))) return false;
const source = readFileSync(file, 'utf8');
return /role="menuitem/.test(source) && /<kbd[\s>]/.test(source);
});
expect(offenders.map((file) => path.relative(root, file))).toEqual([]);
}, 60_000);
});
+9 -2
View File
@@ -1,9 +1,16 @@
/** What the boot script reads for "Always show the menu bar" (lib/desktop/menu-bar.ts). */
export const MENU_BAR_STORAGE_KEY = 'bulwark-menu-bar';
/** On <html> while the menu bar takes its row: app/globals.css moves everything down under it. */
export const MENU_BAR_ATTRIBUTE = 'data-menu-bar';
/**
* Runs in the inline boot script, before the first paint. The preload bridge
* exists before any page script, so app/desktop.css holds from the first
* frame. Nothing happens in a browser, which has no bridge.
* frame. Nothing happens in a browser, which has no bridge. An always-shown
* menu bar has its row from the first frame too, so nothing jumps when it draws.
*/
export function desktopBootScript(): string {
return `if (window.bulwarkDesktop) document.documentElement.setAttribute('data-desktop', '');
if (navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible) document.documentElement.setAttribute('data-window-controls', '');`;
if (navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible) document.documentElement.setAttribute('data-window-controls', '');
try { if (window.bulwarkDesktop && window.bulwarkDesktop.platform !== 'darwin' && navigator.windowControlsOverlay && navigator.windowControlsOverlay.visible && localStorage.getItem('${MENU_BAR_STORAGE_KEY}') === 'pinned') document.documentElement.setAttribute('${MENU_BAR_ATTRIBUTE}', ''); } catch (e) {}`;
}
+13
View File
@@ -129,6 +129,19 @@ export function shellLabels(t: (key: string, values?: Record<string, string>) =>
menuDocs: t('desktopUi.menu.docs'),
menuSettings: t('desktopUi.menu.settings'),
menuAbout: t('desktopUi.menu.about'),
menuExit: t('desktopMenuBar.exit'),
menuShowMenuBar: t('desktopMenuBar.always_show_label'),
trayNewMessage: t('email_composer.new_message'),
trayLockNow: t('appLock.lock_now'),
menuLockNow: t('appLock.lock_now'),
trayUnread: t('email_list.sections.unread'),
trayPause: t('desktopMenuBar.tray_pause'),
trayPauseHour: t('desktopMenuBar.tray_pause_hour'),
trayPauseTomorrow: t('desktopMenuBar.tray_pause_tomorrow'),
trayResume: t('desktopMenuBar.tray_resume'),
trayOpen: t('desktopMenuBar.tray_open'),
trayQuit: t('desktopMenuBar.tray_quit'),
trayExit: t('desktopMenuBar.tray_exit'),
contextCut: t('desktopUi.menu.cut'),
contextCopy: t('desktopUi.menu.copy'),
contextPaste: t('desktopUi.menu.paste'),
+79
View File
@@ -0,0 +1,79 @@
/**
* The desktop app's menu bar on Windows and Linux, where a window with its controls over
* the page has no native bar: the page draws the app menu as a row at the top
* (components/layout/window-menu-bar.tsx). Alt shows it, or "Always show the menu bar"
* keeps it. The shell owns that setting; this keeps a copy for the first frame.
*/
import { useEffect, useState } from 'react';
import { getDesktopBridge } from './bridge';
import { describeAccelerator } from './shortcut-keys';
import { MENU_BAR_STORAGE_KEY } from './boot';
export { MENU_BAR_ATTRIBUTE, MENU_BAR_STORAGE_KEY } from './boot';
/** A shell that lets the page draw the bar and keep it shown. Never on macOS: it has the system's. */
export function menuBarAvailable(): boolean {
const bridge = getDesktopBridge();
return !!bridge?.menu?.bar?.setPinned && bridge.platform !== 'darwin';
}
export function cachedMenuBarPinned(): boolean {
try {
return localStorage.getItem(MENU_BAR_STORAGE_KEY) === 'pinned';
} catch {
return false;
}
}
export function rememberMenuBarPinned(pinned: boolean): void {
try {
if (pinned) localStorage.setItem(MENU_BAR_STORAGE_KEY, 'pinned');
else localStorage.removeItem(MENU_BAR_STORAGE_KEY);
} catch {
// Private storage off: the bar still follows the shell, a frame late.
}
}
/** "Always show the menu bar" as the shell has it, and a way to change it in every window. */
export function useMenuBarPinned(): [boolean, (pinned: boolean) => void] {
const [pinned, setPinned] = useState(false);
useEffect(() => {
setPinned(cachedMenuBarPinned());
const bar = getDesktopBridge()?.menu?.bar;
if (!bar) return;
return bar.onEvent((event) => {
if (typeof event.pinned === 'boolean') setPinned(event.pinned);
});
}, []);
const change = (next: boolean) => {
setPinned(next);
rememberMenuBarPinned(next);
getDesktopBridge()?.menu?.bar?.setPinned?.(next);
};
return [pinned, change];
}
const KEY_NAMES: Record<string, string> = {
plus: '+',
delete: 'Del',
escape: 'Esc',
left: '←',
right: '→',
up: '↑',
down: '↓',
space: 'Space',
};
/** An Electron accelerator the way a Windows or Linux menu shows it: `Ctrl+Shift+N`, `Alt+←`, `?`. */
export function menuKeyText(accelerator: string, platform: string | undefined): string {
// A key that is itself "+", as in Ctrl++, ends in an empty part.
const parts = accelerator.split('+');
const last = parts[parts.length - 1] === '' ? '+' : parts[parts.length - 1];
const key = KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last);
const modifiers = parts.slice(0, parts[parts.length - 1] === '' ? -2 : -1);
if (modifiers.length === 0) return key;
const named = describeAccelerator(`${modifiers.join('+')}+X`, platform);
return `${named.slice(0, -1)}${key}`;
}
+144 -2
View File
@@ -35,9 +35,68 @@ function isDisabled(el: HTMLElement): boolean {
return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true';
}
/** What a row shows apart from its label: the key, a count. Never part of the label. */
const ASIDE = 'kbd, [data-menu-shortcut], [data-menu-badge]';
/**
* The words of an element, each element's apart: "Day" and "D" make "Day D", not
* "DayD", and a name over an address doesn't run into it. Keys and counts are left out.
*/
function wordsOf(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) return node.textContent ?? '';
if (!(node instanceof Element) || node.matches(ASIDE)) return '';
return Array.from(node.childNodes).map(wordsOf).join(' ');
}
function labelOf(el: HTMLElement): string {
const named = el.getAttribute('aria-label') ?? el.querySelector<HTMLElement>('[data-menu-label]')?.textContent ?? el.textContent ?? '';
return named.replace(/\s+/g, ' ').trim();
const named = el.getAttribute('aria-label') ?? el.querySelector<HTMLElement>('[data-menu-label]')?.textContent ?? wordsOf(el);
const badge = el.querySelector<HTMLElement>('[data-menu-badge]')?.textContent?.trim();
const label = named.replace(/\s+/g, ' ').trim();
return badge && label ? `${label} (${badge})` : label;
}
const KEY_NAMES: Record<string, string> = {
del: 'Delete', delete: 'Delete', esc: 'Escape', escape: 'Escape', enter: 'Enter', return: 'Enter',
space: 'Space', tab: 'Tab', backspace: 'Backspace', insert: 'Insert', home: 'Home', end: 'End',
pageup: 'PageUp', pagedown: 'PageDown', up: 'Up', down: 'Down', left: 'Left', right: 'Right',
'↑': 'Up', '↓': 'Down', '←': 'Left', '→': 'Right', '+': 'Plus', '⌫': 'Backspace', '⏎': 'Enter', '↵': 'Enter',
};
const MODIFIERS: Record<string, string> = {
ctrl: 'Ctrl', control: 'Ctrl', mod: 'CmdOrCtrl', cmd: 'Cmd', command: 'Cmd', '⌘': 'Cmd', alt: 'Alt', option: 'Alt',
'⌥': 'Alt', shift: 'Shift', '⇧': 'Shift', '⌃': 'Ctrl', meta: 'Super', win: 'Super',
};
const KEY = /^([A-Z0-9]|F([1-9]|1[0-9]|2[0-4])|[)!@#$%^&*(:;=<,_\-.>?/~`{\][|\\}"']|Plus|Space|Tab|Backspace|Delete|Insert|Enter|Up|Down|Left|Right|Home|End|PageUp|PageDown|Escape)$/;
/**
* A key hint as the app shows it ("Shift + I", "⌘X", "Del") in Electron's accelerator
* syntax, or undefined when it isn't one key (two alternatives, a word).
*/
export function toAccelerator(hint: string): string | undefined {
let text = hint.trim();
if (!text || text.includes(' / ')) return undefined;
const parts: string[] = [];
// macOS writes its modifiers as symbols with nothing between: ⌘⇧K.
while (text.length > 1 && /^[⌘⌥⇧⌃]/.test(text)) {
parts.push(MODIFIERS[text[0]]);
text = text.slice(1).replace(/^\s*\+?\s*/, '');
}
const pieces = text === '+' ? ['+'] : text.split(/\s*\+\s*/).filter(Boolean);
if (text.endsWith('++')) pieces.push('+');
for (let i = 0; i < pieces.length - 1; i++) {
const modifier = MODIFIERS[pieces[i].toLowerCase()];
if (!modifier) return undefined;
parts.push(modifier);
}
const last = pieces[pieces.length - 1];
if (!last) return undefined;
const key = KEY_NAMES[last.toLowerCase()] ?? (last.length === 1 ? last.toUpperCase() : last);
if (!KEY.test(key)) return undefined;
return [...new Set(parts), key].join('+');
}
function acceleratorOf(el: HTMLElement): string | undefined {
const hint = el.querySelector<HTMLElement>('[data-menu-shortcut], kbd')?.textContent;
return hint ? toAccelerator(hint) : undefined;
}
function isHidden(el: Element): boolean {
@@ -64,6 +123,19 @@ export function readMenu(root: HTMLElement): ReadMenu {
continue;
}
if (child.dataset.submenuPanel !== undefined) continue;
if (child.dataset.nativeSubmenu !== undefined) {
// A folder with subfolders (a Move list): a native menu can't indent,
// so it's a submenu with the folder itself first, as Thunderbird has it.
const group: DesktopNativeMenuItem[] = [];
walk(child, group);
const [head, ...rest] = group;
if (head && rest.length && head.type !== 'separator' && head.label) {
into.push({ label: head.label, submenu: [head, { type: 'separator' }, ...rest] });
} else {
into.push(...group);
}
continue;
}
if (child.dataset.contextMenuHeader !== undefined) {
const label = labelOf(child);
if (label) into.push({ id: `h${next++}`, label, enabled: false });
@@ -100,10 +172,12 @@ export function readMenu(root: HTMLElement): ReadMenu {
const id = `i${next++}`;
elements.set(id, child);
const role = child.getAttribute('role');
const accelerator = acceleratorOf(child);
into.push({
id,
label,
enabled: !isDisabled(child),
...(accelerator ? { accelerator } : {}),
...(role === 'menuitemcheckbox' || role === 'menuitemradio'
? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' }
: {}),
@@ -182,6 +256,74 @@ export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x
return 'done';
}
/**
* The system's menu font and the room a native menu adds around its words, in screen
* pixels: Chromium's menus on Windows and Linux, AppKit's on macOS. Measured off the
* real menus at 100 %; an estimate within a few pixels is all a placement needs.
*/
const NATIVE_METRICS = {
win32: { font: '12px "Segoe UI", system-ui, sans-serif', padding: 74, keyGap: 28, charWidth: 6.2 },
darwin: { font: '14px -apple-system, system-ui, sans-serif', padding: 48, keyGap: 24, charWidth: 7.2 },
linux: { font: '13px system-ui, sans-serif', padding: 70, keyGap: 28, charWidth: 6.8 },
} as const;
let measureContext: CanvasRenderingContext2D | null | undefined;
function textWidth(text: string, font: string, charWidth: number): number {
if (measureContext === undefined) {
try {
// jsdom has no canvas, and says so on the console.
measureContext = typeof document === 'undefined' || /jsdom/i.test(navigator.userAgent)
? null
: document.createElement('canvas').getContext('2d');
} catch {
measureContext = null;
}
}
if (measureContext) {
measureContext.font = font;
const width = measureContext.measureText(text).width;
if (Number.isFinite(width) && width > 0) return width;
}
return text.length * charWidth;
}
/**
* How wide the native menu for these items will be, in the page's CSS pixels. A native
* menu is drawn at the screen's scale, not the page's zoom: at 150 % zoom it takes up
* less of the page. outerWidth is in screen pixels and innerWidth in CSS pixels, which
* gives the zoom in a window with no frame.
*/
export function estimateNativeMenuWidth(items: DesktopNativeMenuItem[], platform = getDesktopBridge()?.platform ?? 'win32'): number {
const metrics = NATIVE_METRICS[platform] ?? NATIVE_METRICS.win32;
let widest = 0;
for (const item of items) {
if (item.type === 'separator' || !item.label) continue;
const key = item.accelerator ? metrics.keyGap + textWidth(item.accelerator, metrics.font, metrics.charWidth) : 0;
widest = Math.max(widest, textWidth(item.label, metrics.font, metrics.charWidth) + key);
}
const screenWidth = widest + metrics.padding;
const zoom = typeof window !== 'undefined' && window.innerWidth > 0 && window.outerWidth > 0
? Math.min(5, Math.max(0.25, window.outerWidth / window.innerWidth))
: 1;
return Math.round(screenWidth / zoom);
}
/**
* Where a native menu from a button opens, in page coordinates: its top left corner,
* under the button and lined up with the edge the HTML menu would use. Only that: the
* OS keeps a native menu on the screen itself, and it may reach past the window.
*/
export function nativeMenuPoint(
anchor: { left: number; right: number; bottom: number; align?: 'start' | 'end' },
menuWidth: number,
rtl: boolean,
gap: number,
): { x: number; y: number } {
const alignEnd = (anchor.align === 'end') !== rtl;
return { x: Math.max(0, Math.round(alignEnd ? anchor.right - menuWidth : anchor.left)), y: Math.round(anchor.bottom + gap) };
}
/** A native menu is up: the window's blur or a stray event says nothing about it. */
export function nativeMenuIsOpen(): boolean {
return typeof document !== 'undefined' && document.documentElement.dataset.nativeMenuOpen !== undefined;
+35
View File
@@ -84,6 +84,11 @@ export interface DesktopNativeMenuItem {
type?: 'normal' | 'separator' | 'checkbox' | 'radio';
enabled?: boolean;
checked?: boolean;
/**
* The key the page answers for this item, in Electron's accelerator syntax ("D",
* "Shift+I", "Ctrl+X"). Only shown, apart from the label: never registered.
*/
accelerator?: string;
submenu?: DesktopNativeMenuItem[];
}
@@ -95,9 +100,31 @@ export interface DesktopMenuBarEntry {
}
export interface DesktopMenuBarEvent {
/** The bar has the keys: Alt was pressed. With `pinned` it shows either way. */
visible: boolean;
/** Open this menu right away: Alt plus its letter. */
open?: string;
/** "Always show the menu bar". */
pinned?: boolean;
}
/** One line of a menu the page draws as the menu bar's. */
export interface DesktopMenuBarItem {
/** Its place in the app menu: handed back with its text when it's picked. */
key: string;
label: string;
type: 'normal' | 'checkbox' | 'radio' | 'separator' | 'submenu';
enabled: boolean;
checked?: boolean;
/** As Electron writes it: `CmdOrCtrl+N`. */
accelerator?: string;
/** Where in `label` the letter for it is. */
mnemonic?: number;
items?: DesktopMenuBarItem[];
}
export interface DesktopMenuBarMenu extends DesktopMenuBarEntry {
items: DesktopMenuBarItem[];
}
/** What a page answers when asked to save before a window goes. */
@@ -124,6 +151,14 @@ export interface DesktopMenuBridge {
/** Opens that menu natively, at page coordinates. */
open: (id: string, at: { x: number; y: number }) => void;
onEvent: (listener: (event: DesktopMenuBarEvent) => void) => () => void;
/** Every menu, with what's enabled and ticked in this window now. An older shell has none. */
menus?: () => Promise<DesktopMenuBarMenu[]>;
/** Runs a line of `menus`, by its key and text. */
run?: (key: string, label: string) => void;
/** The room the bar takes now (CSS pixels, 0 when gone), and whether it has the keys. */
report?: (report: { height: number; active: boolean }) => void;
/** "Always show the menu bar", in every window. */
setPinned?: (pinned: boolean) => void;
};
}
+92
View File
@@ -0,0 +1,92 @@
/**
* Where a menu goes: next to what opened it, inside the window.
*
* - Opened from a button (an anchor): under it, lined up with its start edge, or its
* end edge for a button at the end of a toolbar. In RTL start and end swap. When it
* doesn't fit on that side it lines up with the other edge, and when there's no room
* below it goes above.
* - Opened by a right-click (a point): its corner at the pointer, opening to the right
* and down (to the left in RTL), and towards the other side where there's no room, as
* the OS does with its own menus.
*
* Whatever is left over is clamped into the window. A menu taller than the window
* scrolls inside (the menu caps its own height).
*/
export interface MenuAnchorRect {
left: number;
top: number;
right: number;
bottom: number;
/** Line up with the anchor's start (default) or end edge. */
align?: "start" | "end";
}
export interface MenuPlacementInput {
/** Where a right-click happened. Ignored when there's an anchor. */
point: { x: number; y: number };
anchor?: MenuAnchorRect;
size: { width: number; height: number };
viewport: { width: number; height: number };
rtl?: boolean;
/** Room kept free along the window's edges. */
margin?: number;
/** Between the anchor and the menu. */
gap?: number;
}
export const MENU_VIEWPORT_MARGIN = 10;
export const MENU_ANCHOR_GAP = 4;
function clamp(value: number, size: number, room: number, margin: number): number {
return Math.max(margin, Math.min(value, room - size - margin));
}
export function placeMenu({
point,
anchor,
size,
viewport,
rtl = false,
margin = MENU_VIEWPORT_MARGIN,
gap = MENU_ANCHOR_GAP,
}: MenuPlacementInput): { x: number; y: number; maxHeight?: number } {
const { width: w } = size;
let h = size.height;
const { width: vw, height: vh } = viewport;
const fitsX = (x: number) => x >= margin && x + w <= vw - margin;
const fitsY = (y: number) => y >= margin && y + h <= vh - margin;
let x: number;
let y: number;
if (anchor) {
const alignEnd = (anchor.align === "end") !== rtl;
const startEdge = anchor.left;
const endEdge = anchor.right - w;
const preferred = alignEnd ? endEdge : startEdge;
const other = alignEnd ? startEdge : endEdge;
x = fitsX(preferred) || !fitsX(other) ? preferred : other;
const below = anchor.bottom + gap;
const above = anchor.top - gap - h;
if (fitsY(below)) y = below;
else if (fitsY(above)) y = above;
else {
// Neither fits whole: the side with more room, as tall as that room, and
// the menu scrolls. Clamped instead, it would cover its own button.
const roomBelow = vh - margin - below;
const roomAbove = anchor.top - gap - margin;
const maxHeight = Math.max(roomBelow, roomAbove);
h = Math.min(h, maxHeight);
y = roomBelow >= roomAbove ? below : anchor.top - gap - h;
return { x: clamp(x, w, vw, margin), y: Math.max(margin, y), maxHeight };
}
} else {
const towardEnd = rtl ? point.x - w : point.x;
const towardStart = rtl ? point.x : point.x - w;
x = fitsX(towardEnd) || !fitsX(towardStart) ? towardEnd : towardStart;
const down = point.y;
const up = point.y - h;
y = fitsY(down) || !fitsY(up) ? down : up;
}
return { x: clamp(x, w, vw, margin), y: clamp(y, h, vh, margin) };
}
+253
View File
@@ -0,0 +1,253 @@
/**
* What every "Move to" list shows: the context menu (and the desktop app's
* native copy of it), the reader's Move button, the phone panel, the
* selection toolbar and the Move dialog. One model, so they can't drift.
*
* - The message's own account at the top. Other accounts a move can reach
* (shared ones) come after it, each under its own name.
* - System folders first, in the order mail clients use (Inbox, Drafts,
* Sent, Archive, Spam, Trash), then the user's folders by name, sorted
* for the interface language. The server's sortOrder doesn't count here:
* the sidebar honours it, a Move list is for finding a folder by name.
* - Nesting kept. The open folder stays in the list, disabled, when the
* messages are all in it (#1452: a search hit from elsewhere can move in).
* - The last few folders moved to, per account, as "Recent".
*/
import type { Email, Mailbox } from "./jmap/types";
import { emailInMailbox } from "./move-targets";
export const SYSTEM_FOLDER_ORDER = ["inbox", "drafts", "sent", "archive", "junk", "trash"] as const;
/** Past this many folders to pick from, the list gets a filter field. */
export const MOVE_FILTER_THRESHOLD = 12;
/** How many recent folders show. */
export const MOVE_RECENT_SHOWN = 3;
export interface MoveMenuNode {
/** The mailbox id, as the store knows it. */
id: string;
name: string;
role?: string | null;
/** Can be picked. */
enabled: boolean;
/** The open folder, which the messages are already in. */
current: boolean;
/** Names of the folders above it, outermost first. */
path: string[];
children: MoveMenuNode[];
}
export interface MoveMenuAccount {
accountId: string;
name: string;
nodes: MoveMenuNode[];
}
export interface MoveMenuModel {
/** The account the messages are in; recents are kept per account. */
accountId?: string;
nodes: MoveMenuNode[];
/** Other accounts the messages can be moved to, each a submenu. */
others: MoveMenuAccount[];
recent: MoveMenuNode[];
/** Pickable folders in the own account. */
targetCount: number;
}
export interface MoveMenuOptions {
currentMailboxId?: string | null;
/** The account the messages are in (resolveMoveOwnerAccountId). */
ownerAccountId?: string;
/** The messages being moved; without them the open folder counts as theirs. */
emails?: Array<Pick<Email, "mailboxIds" | "sourceAccountId">>;
/** The folders last moved to in an account, most recent first (readRecentMoveTargets). */
recentIds?: (accountId: string | undefined) => string[];
/** The interface language, for sorting names. */
locale?: string;
}
function rolePosition(role: string | null | undefined): number {
if (!role) return SYSTEM_FOLDER_ORDER.length;
const normal = role === "spam" ? "junk" : role;
const at = (SYSTEM_FOLDER_ORDER as readonly string[]).indexOf(normal);
return at < 0 ? SYSTEM_FOLDER_ORDER.length : at;
}
export function compareMoveNodes(collator: Intl.Collator) {
return (a: Pick<MoveMenuNode, "role" | "name">, b: Pick<MoveMenuNode, "role" | "name">): number => {
const byRole = rolePosition(a.role) - rolePosition(b.role);
if (byRole !== 0) return byRole;
return collator.compare(a.name, b.name);
};
}
function makeCollator(locale?: string): Intl.Collator {
try {
return new Intl.Collator(locale || undefined, { numeric: true, sensitivity: "base" });
} catch {
return new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
}
}
/** A target: may take mail, isn't Drafts, isn't a virtual header. */
function isTarget(mailbox: Mailbox): boolean {
return mailbox.role !== "drafts" && !mailbox.id.startsWith("shared-") && !!mailbox.myRights?.mayAddItems;
}
/** One account's folders as a sorted tree, with only the branches that lead somewhere. */
function accountTree(
boxes: Mailbox[],
currentId: string | undefined,
hideCurrent: boolean,
collator: Intl.Collator,
): MoveMenuNode[] {
const ids = new Set(boxes.map((m) => m.id));
const childrenOf = new Map<string, Mailbox[]>();
const roots: Mailbox[] = [];
for (const box of boxes) {
if (box.parentId && ids.has(box.parentId) && box.parentId !== box.id) {
const list = childrenOf.get(box.parentId) ?? [];
list.push(box);
childrenOf.set(box.parentId, list);
} else {
roots.push(box);
}
}
const compare = compareMoveNodes(collator);
const seen = new Set<string>();
const build = (list: Mailbox[], path: string[]): MoveMenuNode[] => {
const out: MoveMenuNode[] = [];
for (const box of list) {
if (seen.has(box.id)) continue;
seen.add(box.id);
const current = box.id === currentId;
const children = build(childrenOf.get(box.id) ?? [], [...path, box.name]);
const enabled = isTarget(box) && !(current && hideCurrent);
// A folder that can't take mail stays as a step to the ones below it;
// the open folder stays so the list reads like the sidebar.
if (!enabled && !children.length && !(current && isTarget(box))) continue;
out.push({ id: box.id, name: box.name, role: box.role ?? null, enabled, current, path, children });
}
return out.sort(compare);
};
return build(roots, []);
}
function walk(nodes: MoveMenuNode[], visit: (node: MoveMenuNode) => void): void {
for (const node of nodes) {
visit(node);
walk(node.children, visit);
}
}
export function countTargets(nodes: MoveMenuNode[]): number {
let count = 0;
walk(nodes, (node) => {
if (node.enabled) count++;
});
return count;
}
export function buildMoveMenu(mailboxes: Mailbox[], opts: MoveMenuOptions = {}): MoveMenuModel {
const collator = makeCollator(opts.locale);
const current = mailboxes.find((m) => m.id === opts.currentMailboxId);
const emails = opts.emails ?? [];
const hideCurrent = !!current && (emails.length === 0 || emails.every((e) => emailInMailbox(e, current)));
// Folders by account: the login's own, then each shared account.
const groups = new Map<string, { name: string; shared: boolean; boxes: Mailbox[] }>();
const OWN = "__own_account__";
for (const box of mailboxes) {
if (box.id.startsWith("shared-")) continue;
const key = box.isShared ? box.accountId ?? "unknown" : OWN;
let group = groups.get(key);
if (!group) {
group = { name: box.accountName || box.accountId || "", shared: !!box.isShared, boxes: [] };
groups.set(key, group);
}
if (!group.name && box.accountName) group.name = box.accountName;
group.boxes.push(box);
}
const ownAccountId = groups.get(OWN)?.boxes.find((m) => m.accountId)?.accountId;
const ownerKey = opts.ownerAccountId && groups.get(opts.ownerAccountId)?.shared ? opts.ownerAccountId : OWN;
const treeOf = (key: string) => {
const group = groups.get(key);
return group ? accountTree(group.boxes, current?.id, hideCurrent, collator) : [];
};
const nodes = treeOf(ownerKey);
const others: MoveMenuAccount[] = [];
for (const [key, group] of groups) {
if (key === ownerKey) continue;
const tree = treeOf(key);
if (countTargets(tree) === 0) continue;
others.push({ accountId: key === OWN ? ownAccountId ?? "" : key, name: group.name, nodes: tree });
}
// The login's own account first among the others, then shared ones by name.
others.sort((a, b) => {
const aOwn = a.accountId === ownAccountId ? 0 : 1;
const bOwn = b.accountId === ownAccountId ? 0 : 1;
return aOwn - bOwn || collator.compare(a.name, b.name);
});
const accountId = ownerKey === OWN ? ownAccountId : ownerKey;
const byId = new Map<string, MoveMenuNode>();
walk(nodes, (node) => byId.set(node.id, node));
for (const other of others) walk(other.nodes, (node) => byId.set(node.id, node));
const recent: MoveMenuNode[] = [];
for (const id of opts.recentIds?.(accountId) ?? []) {
const node = byId.get(id);
if (node?.enabled && !recent.includes(node)) recent.push(node);
if (recent.length >= MOVE_RECENT_SHOWN) break;
}
return { accountId, nodes, others, recent, targetCount: countTargets(nodes) };
}
/**
* The nodes whose name has the query in it, with the folders above them kept
* so a match still shows where it lives. Case and accents don't count.
*/
export function filterMoveNodes(nodes: MoveMenuNode[], query: string, labelOf: (node: MoveMenuNode) => string, locale?: string): MoveMenuNode[] {
const q = fold(query.trim(), locale);
if (!q) return nodes;
const keep = (list: MoveMenuNode[]): MoveMenuNode[] =>
list.reduce<MoveMenuNode[]>((acc, node) => {
const children = keep(node.children);
if (fold(labelOf(node), locale).includes(q) || children.length) acc.push({ ...node, children });
return acc;
}, []);
return keep(nodes);
}
function fold(text: string, locale?: string): string {
let lower: string;
try {
lower = text.toLocaleLowerCase(locale || undefined);
} catch {
lower = text.toLowerCase();
}
return lower.normalize("NFD").replace(/\p{M}/gu, "");
}
/** The first folder that can be picked, in list order. */
export function firstEnabled(nodes: MoveMenuNode[]): MoveMenuNode | undefined {
for (const node of nodes) {
if (node.enabled) return node;
const below = firstEnabled(node.children);
if (below) return below;
}
return undefined;
}
/**
* Any folder tree in Move-list order (system folders first, then by name),
* for pickers that build their own tree, such as the Rules menu's.
*/
export function sortFolderTree<T extends { role?: string | null; name: string; children: T[] }>(nodes: T[], locale?: string): T[] {
const compare = compareMoveNodes(makeCollator(locale));
const sort = (list: T[]): T[] => [...list].map((node) => ({ ...node, children: sort(node.children) })).sort(compare);
return sort(nodes);
}
+40
View File
@@ -0,0 +1,40 @@
/**
* The folders messages were last moved to, per account, most recent first.
* Kept on this device only; the Move lists show the few that still exist.
*/
const KEY = "bulwark.moveRecent.v1";
const KEPT = 8;
type Store = Record<string, string[]>;
function read(): Store {
try {
const raw = typeof localStorage === "undefined" ? null : localStorage.getItem(KEY);
const parsed: unknown = raw ? JSON.parse(raw) : null;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const out: Store = {};
for (const [account, ids] of Object.entries(parsed as Record<string, unknown>)) {
if (Array.isArray(ids)) out[account] = ids.filter((id): id is string => typeof id === "string").slice(0, KEPT);
}
return out;
} catch {
return {};
}
}
export function readRecentMoveTargets(accountId: string | undefined): string[] {
if (!accountId) return [];
return read()[accountId] ?? [];
}
export function noteMoveTarget(accountId: string | undefined, mailboxId: string): void {
if (!accountId || !mailboxId) return;
const store = read();
store[accountId] = [mailboxId, ...(store[accountId] ?? []).filter((id) => id !== mailboxId)].slice(0, KEPT);
try {
localStorage.setItem(KEY, JSON.stringify(store));
} catch {
// Storage full or off: the list just has no recents.
}
}
+9
View File
@@ -10,3 +10,12 @@ import { useSettingsStore } from "@/stores/settings-store";
export function isSingleKeyShortcutOff(event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">): boolean {
return isSingleCharacterKey(event) && useSettingsStore.getState().singleKeyShortcuts === false;
}
/**
* A key hint as the help and menus write it ("r", "Shift + I", "#") that's a single-key
* shortcut, so it does nothing while those are off. Arrows aren't characters.
*/
export function isSingleKeyHint(combo: string): boolean {
const key = combo.replace(/^(shift\s*\+\s*)+/i, "").trim();
return [...key].length === 1 && !/[←→↑↓]/.test(key);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Where a long address may wrap: after "@", "+", "-" and "_", and before a dot, as
* printed addresses do. A browser on its own wraps such a string anywhere once it
* doesn't fit, which leaves ragged two-letter lines in a narrow column.
*
* Returns the pieces; a line break opportunity (<wbr>) goes between each two.
* Pieces are never shorter than MIN_PIECE characters: "a.b.c" stays whole. The
* last few characters (".net") stay with the piece before them.
*/
const MIN_PIECE = 3;
const MAX_TAIL = 4;
export function addressBreakPieces(text: string): string[] {
const pieces: string[] = [];
let current = '';
for (const ch of text) {
if (ch === '.' && current.length >= MIN_PIECE) {
pieces.push(current);
current = ch;
continue;
}
current += ch;
if ((ch === '@' || ch === '+' || ch === '-' || ch === '_') && current.length >= MIN_PIECE) {
pieces.push(current);
current = '';
}
}
if (current) {
// A short tail, such as ".net", joins the piece before it: no line holds only that.
if (current.length <= MAX_TAIL && pieces.length) pieces[pieces.length - 1] += current;
else pieces.push(current);
}
return pieces;
}
+18 -4
View File
@@ -114,23 +114,37 @@ export function markWindowRows(area: TitlebarArea, doc: Document = document): HT
for (const el of Array.from(doc.querySelectorAll<HTMLElement>(ROW_CANDIDATES))) {
const rect = el.getBoundingClientRect();
const explicit = el.hasAttribute('data-window-top-row');
const fits = rect.top <= 1 && rect.height > 0 && rect.width > 0 && (explicit || rect.height <= MAX_ROW_HEIGHT);
const shaped = rect.height > 0 && rect.width > 0 && (explicit || rect.height <= MAX_ROW_HEIGHT);
const atTop = shaped && rect.top <= 1;
// A row a line above pushed down (a note that the message moved, that the app is
// offline, an update) still starts beside the controls and needs the same room.
// It isn't what the window is dragged by, though: that's the top. A list's row
// scrolled up under its header isn't one of these.
const underControls = shaped && !atTop && rect.top < area.height && !inScrolledList(el);
// The outermost box of a row: an inner one sits inside its padding already.
const nested = fits && [...seen].some((outer) => outer.contains(el));
if (!fits || nested) {
const nested = (atTop || underControls) && [...seen].some((outer) => outer.contains(el));
if ((!atTop && !underControls) || nested) {
el.removeAttribute(ROW_ATTRIBUTE);
el.removeAttribute(ROW_RIGHT_ATTRIBUTE);
el.removeAttribute(ROW_LEFT_ATTRIBUTE);
continue;
}
seen.add(el);
el.setAttribute(ROW_ATTRIBUTE, '');
el.toggleAttribute(ROW_ATTRIBUTE, atTop);
el.toggleAttribute(ROW_RIGHT_ATTRIBUTE, right > 0 && rect.right > viewportWidth - right);
el.toggleAttribute(ROW_LEFT_ATTRIBUTE, left > 0 && rect.left < left);
}
return [...seen];
}
/** Inside something scrolled away from its top: a row passing under, not a toolbar. */
function inScrolledList(el: HTMLElement): boolean {
for (let at = el.parentElement; at && at !== el.ownerDocument.body; at = at.parentElement) {
if (at.scrollTop > 0) return true;
}
return false;
}
/**
* Keeps those marks right as the page changes, before the next paint. Mutation and
* resize callbacks both run ahead of painting, so a row never shows without its room.
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "البحث عن رسائل من {name}"
},
"menuMove": {
"filter_placeholder": "ابحث عن مجلد",
"recent": "الأخيرة",
"no_match": "لا يوجد مجلد مطابق",
"choose_folder": "اختيار مجلد…",
"current_folder": "موجودة بالفعل في {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "تمييز المجلد كمقروء",
"mark_folder_tree_read": "تمييز المجلد والمجلدات الفرعية كمقروءة",
@@ -6463,6 +6470,19 @@
"group_account": "الحساب",
"switch_to": "التبديل إلى {account}"
},
"menuAudit": {
"offline_header": "أنت غير متصل. بعض الإجراءات تحتاج إلى اتصال.",
"files_new_folder": "مجلد جديد…",
"files_new_file": "ملف جديد…",
"files_new_text_file": "ملف نصي جديد…",
"files_upload_files": "رفع الملفات…",
"files_upload_folder": "رفع مجلد…",
"files_share": "مشاركة…",
"files_share_button": "مشاركة",
"contacts_add_to_group": "إضافة إلى مجموعة…",
"contacts_bulk_add_to_group": "إضافة إلى مجموعة…",
"schedule_send": "جدولة الإرسال…"
},
"notification_center": {
"title": "الإشعارات",
"mark_all_read": "تعليم الكل كمقروء",
@@ -6818,6 +6838,18 @@
"close": "إغلاق"
}
},
"desktopMenuBar": {
"always_show_label": "إظهار شريط القوائم دائمًا",
"always_show_description": "وإلا فسيظهر عند الضغط على Alt.",
"exit": "خروج",
"tray_pause": "إيقاف الإشعارات مؤقتًا",
"tray_pause_hour": "لمدة ساعة واحدة",
"tray_pause_tomorrow": "حتى الغد",
"tray_resume": "إعادة تشغيل الإشعارات",
"tray_open": "فتح Bulwark",
"tray_quit": "إنهاء Bulwark",
"tray_exit": "الخروج من Bulwark"
},
"appLock": {
"section_title": "قفل التطبيق",
"section_desc": "يطلب رمز PIN بعد فترة من عدم الاستخدام، في كل نافذة وعلامة تبويب. أثناء القفل، لا يستطيع التطبيق قراءة البريد المخزن على هذا الجهاز حتى يدخل أحدهم رمز PIN.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "Cerca correus de {name}"
},
"menuMove": {
"filter_placeholder": "Cerca una carpeta",
"recent": "Recents",
"no_match": "Cap carpeta coincideix",
"choose_folder": "Tria una carpeta…",
"current_folder": "Ja és a {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Marca la carpeta com a llegida",
"mark_folder_tree_read": "Marca la carpeta i les subcarpetes com a llegides",
@@ -6463,6 +6470,19 @@
"group_account": "Compte",
"switch_to": "Canvia a {account}"
},
"menuAudit": {
"offline_header": "No teniu connexió. Algunes accions la necessiten.",
"files_new_folder": "Carpeta nova…",
"files_new_file": "Fitxer nou…",
"files_new_text_file": "Fitxer de text nou…",
"files_upload_files": "Puja fitxers…",
"files_upload_folder": "Puja una carpeta…",
"files_share": "Comparteix…",
"files_share_button": "Comparteix",
"contacts_add_to_group": "Afegeix al grup…",
"contacts_bulk_add_to_group": "Afegeix al grup…",
"schedule_send": "Programa l'enviament…"
},
"notification_center": {
"title": "Notificacions",
"mark_all_read": "Marca-ho tot com a llegit",
@@ -6818,6 +6838,18 @@
"close": "Tanca"
}
},
"desktopMenuBar": {
"always_show_label": "Mostra sempre la barra de menús",
"always_show_description": "Si no, apareix en prémer Alt.",
"exit": "Surt",
"tray_pause": "Posa en pausa les notificacions",
"tray_pause_hour": "Durant 1 hora",
"tray_pause_tomorrow": "Fins demà",
"tray_resume": "Torna a activar les notificacions",
"tray_open": "Obre Bulwark",
"tray_quit": "Surt de Bulwark",
"tray_exit": "Surt de Bulwark"
},
"appLock": {
"section_title": "Bloqueig de l'app",
"section_desc": "Demana un PIN després d'un temps sense ús, a cada finestra i pestanya. Mentre està bloquejada, l'aplicació no pot llegir el correu desat en aquest dispositiu fins que algú introdueixi el PIN.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "Najít e-maily od {name}"
},
"menuMove": {
"filter_placeholder": "Najít složku",
"recent": "Nedávné",
"no_match": "Žádná složka neodpovídá",
"choose_folder": "Vybrat složku…",
"current_folder": "Už je ve složce {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Označit složku jako přečtenou",
"mark_folder_tree_read": "Označit složku a podsložky jako přečtené",
@@ -6463,6 +6470,19 @@
"group_account": "Účet",
"switch_to": "Přepnout na {account}"
},
"menuAudit": {
"offline_header": "Jste offline. Některé akce potřebují připojení.",
"files_new_folder": "Nová složka…",
"files_new_file": "Nový soubor…",
"files_new_text_file": "Nový textový soubor…",
"files_upload_files": "Nahrát soubory…",
"files_upload_folder": "Nahrát složku…",
"files_share": "Sdílet…",
"files_share_button": "Sdílet",
"contacts_add_to_group": "Přidat do skupiny…",
"contacts_bulk_add_to_group": "Přidat do skupiny…",
"schedule_send": "Naplánovat odeslání…"
},
"notification_center": {
"title": "Oznámení",
"mark_all_read": "Označit vše jako přečtené",
@@ -6818,6 +6838,18 @@
"close": "Zavřít"
}
},
"desktopMenuBar": {
"always_show_label": "Vždy zobrazovat řádek nabídek",
"always_show_description": "Jinak se zobrazí po stisknutí klávesy Alt.",
"exit": "Konec",
"tray_pause": "Pozastavit oznámení",
"tray_pause_hour": "Na 1 hodinu",
"tray_pause_tomorrow": "Do zítřka",
"tray_resume": "Znovu zapnout oznámení",
"tray_open": "Otevřít Bulwark",
"tray_quit": "Ukončit Bulwark",
"tray_exit": "Ukončit Bulwark"
},
"appLock": {
"section_title": "Zámek aplikace",
"section_desc": "Po době nečinnosti žádá o PIN v každém okně a na každé kartě. Dokud je zamčeno, aplikace nemůže číst poštu uloženou v tomto zařízení, dokud někdo nezadá PIN.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "Find e-mails fra {name}"
},
"menuMove": {
"filter_placeholder": "Find en mappe",
"recent": "Seneste",
"no_match": "Ingen mapper passer",
"choose_folder": "Vælg mappe…",
"current_folder": "Allerede i {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Markér mappe som læst",
"mark_folder_tree_read": "Markér mappe & undermapper som læst",
@@ -6463,6 +6470,19 @@
"group_account": "Konto",
"switch_to": "Skift til {account}"
},
"menuAudit": {
"offline_header": "Du er offline. Nogle handlinger kræver forbindelse.",
"files_new_folder": "Ny mappe…",
"files_new_file": "Ny fil…",
"files_new_text_file": "Ny tekstfil…",
"files_upload_files": "Upload filer…",
"files_upload_folder": "Upload mappe…",
"files_share": "Del…",
"files_share_button": "Del",
"contacts_add_to_group": "Tilføj til gruppe…",
"contacts_bulk_add_to_group": "Tilføj til gruppe…",
"schedule_send": "Planlæg afsendelse…"
},
"notification_center": {
"title": "Notifikationer",
"mark_all_read": "Markér alle som læst",
@@ -6818,6 +6838,18 @@
"close": "Luk"
}
},
"desktopMenuBar": {
"always_show_label": "Vis altid menulinjen",
"always_show_description": "Ellers vises den, når du trykker på Alt.",
"exit": "Afslut",
"tray_pause": "Sæt notifikationer på pause",
"tray_pause_hour": "I 1 time",
"tray_pause_tomorrow": "Indtil i morgen",
"tray_resume": "Slå notifikationer til igen",
"tray_open": "Åbn Bulwark",
"tray_quit": "Afslut Bulwark",
"tray_exit": "Afslut Bulwark"
},
"appLock": {
"section_title": "Applås",
"section_desc": "Beder om en pinkode efter et stykke tid uden brug, i alle vinduer og faner. Mens den er låst, kan appen ikke læse den mail, der er gemt på denne enhed, før nogen indtaster pinkoden.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "E-Mails von {name} finden"
},
"menuMove": {
"filter_placeholder": "Ordner suchen",
"recent": "Zuletzt verwendet",
"no_match": "Kein Ordner gefunden",
"choose_folder": "Ordner auswählen…",
"current_folder": "Bereits in {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Ordner als gelesen markieren",
"mark_folder_tree_read": "Ordner & Unterordner als gelesen markieren",
@@ -6463,6 +6470,19 @@
"group_account": "Konto",
"switch_to": "Zu {account} wechseln"
},
"menuAudit": {
"offline_header": "Sie sind offline. Manche Aktionen brauchen eine Verbindung.",
"files_new_folder": "Neuer Ordner…",
"files_new_file": "Neue Datei…",
"files_new_text_file": "Neue Textdatei…",
"files_upload_files": "Dateien hochladen…",
"files_upload_folder": "Ordner hochladen…",
"files_share": "Teilen…",
"files_share_button": "Teilen",
"contacts_add_to_group": "Zur Gruppe hinzufügen…",
"contacts_bulk_add_to_group": "Zur Gruppe hinzufügen…",
"schedule_send": "Senden planen…"
},
"notification_center": {
"title": "Benachrichtigungen",
"mark_all_read": "Alle als gelesen markieren",
@@ -6818,6 +6838,18 @@
"close": "Schließen"
}
},
"desktopMenuBar": {
"always_show_label": "Menüleiste immer anzeigen",
"always_show_description": "Sonst erscheint sie, wenn Sie Alt drücken.",
"exit": "Beenden",
"tray_pause": "Benachrichtigungen pausieren",
"tray_pause_hour": "Für 1 Stunde",
"tray_pause_tomorrow": "Bis morgen",
"tray_resume": "Benachrichtigungen wieder einschalten",
"tray_open": "Bulwark öffnen",
"tray_quit": "Bulwark beenden",
"tray_exit": "Bulwark beenden"
},
"appLock": {
"section_title": "App-Sperre",
"section_desc": "Fragt nach einer Zeit ohne Nutzung in jedem Fenster und Tab nach einer PIN. Solange gesperrt ist, kann die App die auf diesem Gerät gespeicherte Mail nicht lesen, bis jemand die PIN eingibt.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "Find emails from {name}"
},
"menuMove": {
"filter_placeholder": "Find a folder",
"recent": "Recent",
"no_match": "No folder matches",
"choose_folder": "Choose folder…",
"current_folder": "Already in {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Mark folder as read",
"mark_folder_tree_read": "Mark folder & subfolders as read",
@@ -6463,6 +6470,19 @@
"group_account": "Account",
"switch_to": "Switch to {account}"
},
"menuAudit": {
"offline_header": "You're offline. Some actions need a connection.",
"files_new_folder": "New folder…",
"files_new_file": "New file…",
"files_new_text_file": "New text file…",
"files_upload_files": "Upload files…",
"files_upload_folder": "Upload folder…",
"files_share": "Share…",
"files_share_button": "Share",
"contacts_add_to_group": "Add to group…",
"contacts_bulk_add_to_group": "Add to group…",
"schedule_send": "Schedule send…"
},
"notification_center": {
"title": "Notifications",
"mark_all_read": "Mark all read",
@@ -6818,6 +6838,18 @@
"close": "Close"
}
},
"desktopMenuBar": {
"always_show_label": "Always show the menu bar",
"always_show_description": "Otherwise it shows when you press Alt.",
"exit": "Exit",
"tray_pause": "Pause notifications",
"tray_pause_hour": "For 1 hour",
"tray_pause_tomorrow": "Until tomorrow",
"tray_resume": "Turn notifications back on",
"tray_open": "Open Bulwark",
"tray_quit": "Quit Bulwark",
"tray_exit": "Exit Bulwark"
},
"appLock": {
"section_title": "App lock",
"section_desc": "Asks for a PIN after a time without use, in every window and tab. While it's locked, the app can't read the mail stored on this device until someone enters the PIN.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "Buscar correos de {name}"
},
"menuMove": {
"filter_placeholder": "Buscar una carpeta",
"recent": "Recientes",
"no_match": "Ninguna carpeta coincide",
"choose_folder": "Elegir carpeta…",
"current_folder": "Ya está en {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Marcar carpeta como leída",
"mark_folder_tree_read": "Marcar carpeta y subcarpetas como leídas",
@@ -6463,6 +6470,19 @@
"group_account": "Cuenta",
"switch_to": "Cambiar a {account}"
},
"menuAudit": {
"offline_header": "No tiene conexión. Algunas acciones la necesitan.",
"files_new_folder": "Nueva carpeta…",
"files_new_file": "Nuevo archivo…",
"files_new_text_file": "Nuevo archivo de texto…",
"files_upload_files": "Subir archivos…",
"files_upload_folder": "Subir carpeta…",
"files_share": "Compartir…",
"files_share_button": "Compartir",
"contacts_add_to_group": "Añadir al grupo…",
"contacts_bulk_add_to_group": "Agregar al grupo…",
"schedule_send": "Programar envío…"
},
"notification_center": {
"title": "Notificaciones",
"mark_all_read": "Marcar todo como leído",
@@ -6818,6 +6838,18 @@
"close": "Cerrar"
}
},
"desktopMenuBar": {
"always_show_label": "Mostrar siempre la barra de menús",
"always_show_description": "Si no, aparece al pulsar Alt.",
"exit": "Salir",
"tray_pause": "Pausar notificaciones",
"tray_pause_hour": "Durante 1 hora",
"tray_pause_tomorrow": "Hasta mañana",
"tray_resume": "Volver a activar las notificaciones",
"tray_open": "Abrir Bulwark",
"tray_quit": "Salir de Bulwark",
"tray_exit": "Salir de Bulwark"
},
"appLock": {
"section_title": "Bloqueo de la app",
"section_desc": "Pide un PIN tras un tiempo sin uso, en cada ventana y pestaña. Mientras está bloqueada, la aplicación no puede leer el correo guardado en este dispositivo hasta que alguien introduzca el PIN.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "Leia kirjad saatjalt {name}"
},
"menuMove": {
"filter_placeholder": "Otsi kausta",
"recent": "Viimati kasutatud",
"no_match": "Ükski kaust ei sobi",
"choose_folder": "Vali kaust…",
"current_folder": "Juba kaustas {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Märgi kaust loetuks",
"mark_folder_tree_read": "Märgi kaust ja alamkaustad loetuks",
@@ -6463,6 +6470,19 @@
"group_account": "Konto",
"switch_to": "Lülitu kontole {account}"
},
"menuAudit": {
"offline_header": "Olete võrguühenduseta. Mõni toiming vajab ühendust.",
"files_new_folder": "Uus kaust…",
"files_new_file": "Uus fail…",
"files_new_text_file": "Uus tekstifail…",
"files_upload_files": "Laadi failid üles…",
"files_upload_folder": "Laadi kaust üles…",
"files_share": "Jaga…",
"files_share_button": "Jaga",
"contacts_add_to_group": "Lisa gruppi…",
"contacts_bulk_add_to_group": "Lisa gruppi…",
"schedule_send": "Ajasta saatmine…"
},
"notification_center": {
"title": "Märguanded",
"mark_all_read": "Märgi kõik loetuks",
@@ -6818,6 +6838,18 @@
"close": "Sulge"
}
},
"desktopMenuBar": {
"always_show_label": "Näita menüüriba alati",
"always_show_description": "Muidu ilmub see Alt-klahvi vajutamisel.",
"exit": "Välju",
"tray_pause": "Peata teavitused",
"tray_pause_hour": "1 tunniks",
"tray_pause_tomorrow": "Homseni",
"tray_resume": "Lülita teavitused uuesti sisse",
"tray_open": "Ava Bulwark",
"tray_quit": "Sulge Bulwark",
"tray_exit": "Välju Bulwarkist"
},
"appLock": {
"section_title": "Rakenduse lukk",
"section_desc": "Küsib pärast kasutuseta aega PIN-koodi igas aknas ja vahekaardil. Lukus olles ei saa rakendus selles seadmes hoitavat kirja lugeda enne, kui keegi PIN-koodi sisestab.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "یافتن ایمیل‌های {name}"
},
"menuMove": {
"filter_placeholder": "یافتن پوشه",
"recent": "اخیر",
"no_match": "هیچ پوشه‌ای مطابق نیست",
"choose_folder": "انتخاب پوشه…",
"current_folder": "از قبل در {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "علامت‌گذاری پوشه خوانده شده",
"mark_folder_tree_read": "علامت‌گذاری پوشه و زیرپوشه‌ها",
@@ -6463,6 +6470,19 @@
"group_account": "حساب",
"switch_to": "رفتن به {account}"
},
"menuAudit": {
"offline_header": "آفلاین هستید. برخی کارها به اتصال نیاز دارند.",
"files_new_folder": "پوشه جدید…",
"files_new_file": "فایل جدید…",
"files_new_text_file": "فایل متنی جدید…",
"files_upload_files": "بارگذاری فایل‌ها…",
"files_upload_folder": "بارگذاری پوشه…",
"files_share": "اشتراک‌گذاری…",
"files_share_button": "اشتراک‌گذاری",
"contacts_add_to_group": "افزودن به گروه…",
"contacts_bulk_add_to_group": "افزودن به گروه…",
"schedule_send": "زمان‌بندی ارسال…"
},
"notification_center": {
"title": "اعلان‌ها",
"mark_all_read": "علامت‌گذاری همه به‌عنوان خوانده‌شده",
@@ -6818,6 +6838,18 @@
"close": "بستن"
}
},
"desktopMenuBar": {
"always_show_label": "همیشه نوار منو نمایش داده شود",
"always_show_description": "در غیر این صورت با فشردن Alt نمایش داده می‌شود.",
"exit": "خروج",
"tray_pause": "توقف موقت اعلان‌ها",
"tray_pause_hour": "به مدت ۱ ساعت",
"tray_pause_tomorrow": "تا فردا",
"tray_resume": "روشن کردن دوباره اعلان‌ها",
"tray_open": "باز کردن Bulwark",
"tray_quit": "بستن Bulwark",
"tray_exit": "خروج از Bulwark"
},
"appLock": {
"section_title": "قفل برنامه",
"section_desc": "پس از مدتی استفاده نکردن، در هر پنجره و زبانه پین می‌خواهد. تا وقتی قفل است، برنامه نمی‌تواند نامه‌های ذخیره‌شده در این دستگاه را بخواند تا کسی پین را وارد کند.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "Rechercher les e-mails de {name}"
},
"menuMove": {
"filter_placeholder": "Rechercher un dossier",
"recent": "Récents",
"no_match": "Aucun dossier ne correspond",
"choose_folder": "Choisir un dossier…",
"current_folder": "Déjà dans {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Marquer le dossier comme lu",
"mark_folder_tree_read": "Marquer le dossier et les sous-dossiers comme lus",
@@ -6463,6 +6470,19 @@
"group_account": "Compte",
"switch_to": "Passer à {account}"
},
"menuAudit": {
"offline_header": "Vous êtes hors ligne. Certaines actions ont besoin d’une connexion.",
"files_new_folder": "Nouveau dossier…",
"files_new_file": "Nouveau fichier…",
"files_new_text_file": "Nouveau fichier texte…",
"files_upload_files": "Téléverser des fichiers…",
"files_upload_folder": "Téléverser un dossier…",
"files_share": "Partager…",
"files_share_button": "Partager",
"contacts_add_to_group": "Ajouter au groupe…",
"contacts_bulk_add_to_group": "Ajouter au groupe…",
"schedule_send": "Planifier l’envoi…"
},
"notification_center": {
"title": "Notifications",
"mark_all_read": "Tout marquer comme lu",
@@ -6818,6 +6838,18 @@
"close": "Fermer"
}
},
"desktopMenuBar": {
"always_show_label": "Toujours afficher la barre de menus",
"always_show_description": "Sinon, elle s’affiche quand vous appuyez sur Alt.",
"exit": "Quitter",
"tray_pause": "Suspendre les notifications",
"tray_pause_hour": "Pendant 1 heure",
"tray_pause_tomorrow": "Jusqu’à demain",
"tray_resume": "Réactiver les notifications",
"tray_open": "Ouvrir Bulwark",
"tray_quit": "Quitter Bulwark",
"tray_exit": "Quitter Bulwark"
},
"appLock": {
"section_title": "Verrouillage de l'app",
"section_desc": "Demande un code PIN après un temps sans utilisation, dans chaque fenêtre et onglet. Tant que c'est verrouillé, l'application ne peut pas lire les messages stockés sur cet appareil avant la saisie du code PIN.",
+32
View File
@@ -3592,6 +3592,13 @@
},
"find_emails_from": "חיפוש הודעות מ{name}"
},
"menuMove": {
"filter_placeholder": "חיפוש תיקייה",
"recent": "אחרונות",
"no_match": "אין תיקייה תואמת",
"choose_folder": "בחירת תיקייה…",
"current_folder": "כבר בתיקייה {folder}"
},
"shortcuts": {
"title": "קיצורי מקלדת",
"tip": "לחץ על? בכל עת כדי להראות את העזרה הזו",
@@ -6464,6 +6471,19 @@
"group_account": "חשבון",
"switch_to": "מעבר אל {account}"
},
"menuAudit": {
"offline_header": "אין חיבור. חלק מהפעולות צריכות חיבור.",
"files_new_folder": "תיקיה חדשה…",
"files_new_file": "קובץ חדש…",
"files_new_text_file": "קובץ טקסט חדש…",
"files_upload_files": "העלה קבצים…",
"files_upload_folder": "העלה תיקייה…",
"files_share": "שתף…",
"files_share_button": "שתף",
"contacts_add_to_group": "הוסף לקבוצה…",
"contacts_bulk_add_to_group": "הוסף לקבוצה…",
"schedule_send": "תזמן שליחה…"
},
"notification_center": {
"title": "התראות",
"mark_all_read": "סימון הכול כנקרא",
@@ -6819,6 +6839,18 @@
"close": "סגירה"
}
},
"desktopMenuBar": {
"always_show_label": "תמיד להציג את סרגל התפריטים",
"always_show_description": "אחרת הוא מופיע בלחיצה על Alt.",
"exit": "יציאה",
"tray_pause": "השהיית התראות",
"tray_pause_hour": "לשעה אחת",
"tray_pause_tomorrow": "עד מחר",
"tray_resume": "הפעלת ההתראות מחדש",
"tray_open": "פתיחת Bulwark",
"tray_quit": "סגירת Bulwark",
"tray_exit": "יציאה מ-Bulwark"
},
"appLock": {
"section_title": "נעילת אפליקציה",
"section_desc": "מבקש PIN אחרי זמן ללא שימוש, בכל חלון ולשונית. בזמן הנעילה האפליקציה לא יכולה לקרוא את הדואר השמור במכשיר הזה עד שמישהו מזין את ה-PIN.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "{name} e-mailjeinek keresése"
},
"menuMove": {
"filter_placeholder": "Mappa keresése",
"recent": "Legutóbbiak",
"no_match": "Nincs ilyen mappa",
"choose_folder": "Mappa kiválasztása…",
"current_folder": "Már itt van: {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Mappa olvasottnak jelölése",
"mark_folder_tree_read": "Mappa és almappák olvasottnak jelölése",
@@ -6463,6 +6470,19 @@
"group_account": "Fiók",
"switch_to": "Váltás erre: {account}"
},
"menuAudit": {
"offline_header": "Nincs kapcsolat. Néhány művelethez kapcsolat kell.",
"files_new_folder": "Új mappa…",
"files_new_file": "Új fájl…",
"files_new_text_file": "Új szöveges fájl…",
"files_upload_files": "Fájlok feltöltése…",
"files_upload_folder": "Mappa feltöltése…",
"files_share": "Megosztás…",
"files_share_button": "Megosztás",
"contacts_add_to_group": "Hozzáadás a csoporthoz…",
"contacts_bulk_add_to_group": "Hozzáadás a csoporthoz…",
"schedule_send": "Késleltetett küldés…"
},
"notification_center": {
"title": "Értesítések",
"mark_all_read": "Összes megjelölése olvasottként",
@@ -6818,6 +6838,18 @@
"close": "Bezárás"
}
},
"desktopMenuBar": {
"always_show_label": "Menüsor mindig látható",
"always_show_description": "Különben az Alt billentyű lenyomásakor jelenik meg.",
"exit": "Kilépés",
"tray_pause": "Értesítések szüneteltetése",
"tray_pause_hour": "1 órára",
"tray_pause_tomorrow": "Holnapig",
"tray_resume": "Értesítések visszakapcsolása",
"tray_open": "Bulwark megnyitása",
"tray_quit": "Kilépés a Bulwarkból",
"tray_exit": "Kilépés a Bulwarkból"
},
"appLock": {
"section_title": "Alkalmazászár",
"section_desc": "Egy idő után minden ablakban és lapon PIN-kódot kér. Zárolt állapotban az alkalmazás nem tudja olvasni az eszközön tárolt leveleket, amíg valaki meg nem adja a PIN-kódot.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "Trova email da {name}"
},
"menuMove": {
"filter_placeholder": "Cerca una cartella",
"recent": "Recenti",
"no_match": "Nessuna cartella corrisponde",
"choose_folder": "Scegli cartella…",
"current_folder": "Già in {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Segna cartella come letta",
"mark_folder_tree_read": "Segna cartella e sottocartelle come lette",
@@ -6463,6 +6470,19 @@
"group_account": "Account",
"switch_to": "Passa a {account}"
},
"menuAudit": {
"offline_header": "Sei offline. Alcune azioni richiedono una connessione.",
"files_new_folder": "Nuova cartella…",
"files_new_file": "Nuovo file…",
"files_new_text_file": "Nuovo file di testo…",
"files_upload_files": "Carica file…",
"files_upload_folder": "Carica cartella…",
"files_share": "Condividi…",
"files_share_button": "Condividi",
"contacts_add_to_group": "Aggiungi al gruppo…",
"contacts_bulk_add_to_group": "Aggiungi al gruppo…",
"schedule_send": "Programma invio…"
},
"notification_center": {
"title": "Notifiche",
"mark_all_read": "Segna tutte come lette",
@@ -6818,6 +6838,18 @@
"close": "Chiudi"
}
},
"desktopMenuBar": {
"always_show_label": "Mostra sempre la barra dei menu",
"always_show_description": "Altrimenti compare premendo Alt.",
"exit": "Esci",
"tray_pause": "Sospendi le notifiche",
"tray_pause_hour": "Per 1 ora",
"tray_pause_tomorrow": "Fino a domani",
"tray_resume": "Riattiva le notifiche",
"tray_open": "Apri Bulwark",
"tray_quit": "Esci da Bulwark",
"tray_exit": "Esci da Bulwark"
},
"appLock": {
"section_title": "Blocco dell'app",
"section_desc": "Chiede un PIN dopo un periodo di inattività, in ogni finestra e scheda. Mentre è bloccata, l'app non può leggere la posta salvata su questo dispositivo finché qualcuno non inserisce il PIN.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "{name} からのメールを検索"
},
"menuMove": {
"filter_placeholder": "フォルダーを検索",
"recent": "最近使用",
"no_match": "一致するフォルダーはありません",
"choose_folder": "フォルダーを選択…",
"current_folder": "すでに {folder} にあります"
},
"mailbox_context_menu": {
"mark_folder_read": "フォルダーを既読にする",
"mark_folder_tree_read": "フォルダーとサブフォルダーを既読にする",
@@ -6463,6 +6470,19 @@
"group_account": "アカウント",
"switch_to": "{account} に切り替え"
},
"menuAudit": {
"offline_header": "オフラインです。一部の操作には接続が必要です。",
"files_new_folder": "新しいフォルダー…",
"files_new_file": "新規ファイル…",
"files_new_text_file": "新規テキストファイル…",
"files_upload_files": "ファイルをアップロード…",
"files_upload_folder": "フォルダをアップロード…",
"files_share": "共有…",
"files_share_button": "共有",
"contacts_add_to_group": "グループに追加…",
"contacts_bulk_add_to_group": "グループに追加…",
"schedule_send": "送信を予約…"
},
"notification_center": {
"title": "通知",
"mark_all_read": "すべて既読にする",
@@ -6818,6 +6838,18 @@
"close": "閉じる"
}
},
"desktopMenuBar": {
"always_show_label": "メニューバーを常に表示",
"always_show_description": "オフにすると、Alt キーを押したときに表示されます。",
"exit": "終了",
"tray_pause": "通知を一時停止",
"tray_pause_hour": "1 時間",
"tray_pause_tomorrow": "明日まで",
"tray_resume": "通知を再開",
"tray_open": "Bulwark を開く",
"tray_quit": "Bulwark を終了",
"tray_exit": "Bulwark を終了"
},
"appLock": {
"section_title": "アプリのロック",
"section_desc": "しばらく使わないと、すべてのウィンドウとタブで PIN を求めます。ロック中は、誰かが PIN を入力するまで、アプリはこのデバイスに保存されたメールを読めません。",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "{name}님이 보낸 이메일 찾기"
},
"menuMove": {
"filter_placeholder": "폴더 찾기",
"recent": "최근",
"no_match": "일치하는 폴더 없음",
"choose_folder": "폴더 선택…",
"current_folder": "이미 {folder}에 있음"
},
"mailbox_context_menu": {
"mark_folder_read": "폴더를 읽음으로 표시",
"mark_folder_tree_read": "폴더 및 하위 폴더를 읽음으로 표시",
@@ -6463,6 +6470,19 @@
"group_account": "계정",
"switch_to": "{account}(으)로 전환"
},
"menuAudit": {
"offline_header": "오프라인 상태입니다. 일부 작업에는 연결이 필요합니다.",
"files_new_folder": "새 폴더…",
"files_new_file": "새 파일…",
"files_new_text_file": "새 텍스트 파일…",
"files_upload_files": "파일 업로드…",
"files_upload_folder": "폴더 업로드…",
"files_share": "공유…",
"files_share_button": "공유",
"contacts_add_to_group": "그룹에 추가…",
"contacts_bulk_add_to_group": "그룹에 추가…",
"schedule_send": "보내기 예약…"
},
"notification_center": {
"title": "알림",
"mark_all_read": "모두 읽음으로 표시",
@@ -6818,6 +6838,18 @@
"close": "닫기"
}
},
"desktopMenuBar": {
"always_show_label": "메뉴 막대 항상 표시",
"always_show_description": "끄면 Alt 키를 누를 때 표시됩니다.",
"exit": "끝내기",
"tray_pause": "알림 일시 중지",
"tray_pause_hour": "1시간 동안",
"tray_pause_tomorrow": "내일까지",
"tray_resume": "알림 다시 켜기",
"tray_open": "Bulwark 열기",
"tray_quit": "Bulwark 종료",
"tray_exit": "Bulwark 종료"
},
"appLock": {
"section_title": "앱 잠금",
"section_desc": "일정 시간 사용하지 않으면 모든 창과 탭에서 PIN을 요청합니다. 잠겨 있는 동안 앱은 누군가 PIN을 입력할 때까지 이 기기에 저장된 메일을 읽을 수 없습니다.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "Atrast e-pastus no {name}"
},
"menuMove": {
"filter_placeholder": "Meklēt mapi",
"recent": "Nesen izmantotās",
"no_match": "Neviena mape neatbilst",
"choose_folder": "Izvēlēties mapi…",
"current_folder": "Jau mapē {folder}"
},
"mailbox_context_menu": {
"mark_folder_read": "Atzīmēt mapi kā lasītu",
"mark_folder_tree_read": "Atzīmēt mapi un apakšmapes kā lasītas",
@@ -6463,6 +6470,19 @@
"group_account": "Konts",
"switch_to": "Pārslēgties uz {account}"
},
"menuAudit": {
"offline_header": "Jūs esat bezsaistē. Dažām darbībām vajag savienojumu.",
"files_new_folder": "Jauna mape…",
"files_new_file": "Jauns fails…",
"files_new_text_file": "Jauns teksta fails…",
"files_upload_files": "Augšupielādēt failus…",
"files_upload_folder": "Augšupielādēt mapi…",
"files_share": "Kopīgot…",
"files_share_button": "Kopīgot",
"contacts_add_to_group": "Pievienot grupai…",
"contacts_bulk_add_to_group": "Pievienot grupai…",
"schedule_send": "Ieplānot sūtīšanu…"
},
"notification_center": {
"title": "Paziņojumi",
"mark_all_read": "Atzīmēt visus kā lasītus",
@@ -6818,6 +6838,18 @@
"close": "Aizvērt"
}
},
"desktopMenuBar": {
"always_show_label": "Vienmēr rādīt izvēļņu joslu",
"always_show_description": "Citādi tā parādās, nospiežot Alt.",
"exit": "Iziet",
"tray_pause": "Pauzēt paziņojumus",
"tray_pause_hour": "Uz 1 stundu",
"tray_pause_tomorrow": "Līdz rītdienai",
"tray_resume": "Atkal ieslēgt paziņojumus",
"tray_open": "Atvērt Bulwark",
"tray_quit": "Aizvērt Bulwark",
"tray_exit": "Iziet no Bulwark"
},
"appLock": {
"section_title": "Lietotnes bloķēšana",
"section_desc": "Pēc neizmantošanas laika prasa PIN katrā logā un cilnē. Kamēr ir bloķēts, lietotne nevar lasīt šajā ierīcē glabāto pastu, līdz kāds ievada PIN.",
+32
View File
@@ -3648,6 +3648,13 @@
},
"find_emails_from": "{name}-с ирсэн имэйлүүдийг хайх"
},
"menuMove": {
"filter_placeholder": "Хавтас хайх",
"recent": "Сүүлд ашигласан",
"no_match": "Тохирох хавтас алга",
"choose_folder": "Хавтас сонгох…",
"current_folder": "Аль хэдийн {folder}-д байна"
},
"mailbox_context_menu": {
"mark_folder_read": "Хавтсыг уншсан гэж тэмдэглэх",
"mark_folder_tree_read": "Хавтас болон дэд хавтаснуудыг уншсан гэж тэмдэглэх",
@@ -6463,6 +6470,19 @@
"group_account": "Бүртгэл",
"switch_to": "{account} руу шилжих"
},
"menuAudit": {
"offline_header": "Та офлайн байна. Зарим үйлдэлд холболт хэрэгтэй.",
"files_new_folder": "Шинэ хавтас…",
"files_new_file": "Шинэ файл…",
"files_new_text_file": "Шинэ текст файл…",
"files_upload_files": "Файл байршуулах…",
"files_upload_folder": "Хавтас байршуулах…",
"files_share": "Хуваалцах…",
"files_share_button": "Хуваалцах",
"contacts_add_to_group": "Бүлэгт нэмэх…",
"contacts_bulk_add_to_group": "Бүлэгт нэмэх…",
"schedule_send": "Хуваарь илгээх…"
},
"notification_center": {
"title": "Мэдэгдэл",
"mark_all_read": "Бүгдийг уншсан болгох",
@@ -6818,6 +6838,18 @@
"close": "Хаах"
}
},
"desktopMenuBar": {
"always_show_label": "Цэсний мөрийг үргэлж харуулах",
"always_show_description": "Эс бөгөөс Alt товч дарахад гарч ирнэ.",
"exit": "Гарах",
"tray_pause": "Мэдэгдлийг түр зогсоох",
"tray_pause_hour": "1 цагийн турш",
"tray_pause_tomorrow": "Маргааш хүртэл",
"tray_resume": "Мэдэгдлийг дахин асаах",
"tray_open": "Bulwark-г нээх",
"tray_quit": "Bulwark-г хаах",
"tray_exit": "Bulwark-аас гарах"
},
"appLock": {
"section_title": "Аппын түгжээ",
"section_desc": "Хэсэг хугацаанд ашиглаагүй бол цонх, таб бүрт ПИН асуудаг. Түгжээтэй үед хэн нэгэн ПИН оруулах хүртэл апп энэ төхөөрөмжид хадгалагдсан шууданг уншиж чадахгүй.",

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