{movedTo
? t("message_moved_to", { folder: localizeMailboxName(movedTo.role, movedTo.name, (key) => tSidebar(`mailboxes.${key}`)) })
: t("message_moved")}
diff --git a/lib/__tests__/window-controls-rows.test.ts b/lib/__tests__/window-controls-rows.test.ts
new file mode 100644
index 000000000..b41cec67f
--- /dev/null
+++ b/lib/__tests__/window-controls-rows.test.ts
@@ -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/);
+ });
+});
diff --git a/lib/window-controls-overlay.ts b/lib/window-controls-overlay.ts
index f69cdca13..0e3989135 100644
--- a/lib/window-controls-overlay.ts
+++ b/lib/window-controls-overlay.ts
@@ -114,23 +114,37 @@ export function markWindowRows(area: TitlebarArea, doc: Document = document): HT
for (const el of Array.from(doc.querySelectorAll
(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.
From f92b7d942d62975cf40e9dcbbe33fdd042daeb23 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 05:32:23 +0100
Subject: [PATCH 12/20] fix: grey out folder and message actions that can't
work offline or with nothing unread
---
.../email-context-menu-shortcuts.test.tsx | 33 +++++++-
components/email/email-context-menu.tsx | 39 ++++++---
components/email/message-action-shortcuts.ts | 4 +-
components/email/rules-menu.tsx | 9 ++-
.../mailbox-context-menu-states.test.tsx | 81 +++++++++++++++++++
components/layout/mailbox-context-menu.tsx | 52 ++++++++----
components/mail/mail-app.tsx | 8 +-
hooks/use-connection-state.ts | 41 ++++++++++
locales/ar/common.json | 13 +++
locales/ca/common.json | 13 +++
locales/cs/common.json | 13 +++
locales/da/common.json | 13 +++
locales/de/common.json | 13 +++
locales/en/common.json | 13 +++
locales/es/common.json | 13 +++
locales/et/common.json | 13 +++
locales/fa/common.json | 13 +++
locales/fr/common.json | 13 +++
locales/he/common.json | 13 +++
locales/hu/common.json | 13 +++
locales/it/common.json | 13 +++
locales/ja/common.json | 13 +++
locales/ko/common.json | 13 +++
locales/lv/common.json | 13 +++
locales/mn/common.json | 13 +++
locales/nb/common.json | 13 +++
locales/nl/common.json | 13 +++
locales/pl/common.json | 13 +++
locales/pt/common.json | 13 +++
locales/ro/common.json | 13 +++
locales/ru/common.json | 13 +++
locales/sk/common.json | 13 +++
locales/th/common.json | 13 +++
locales/tr/common.json | 13 +++
locales/uk/common.json | 13 +++
locales/vi/common.json | 13 +++
locales/zh-TW/common.json | 13 +++
locales/zh/common.json | 13 +++
38 files changed, 619 insertions(+), 38 deletions(-)
create mode 100644 components/layout/__tests__/mailbox-context-menu-states.test.tsx
create mode 100644 hooks/use-connection-state.ts
diff --git a/components/email/__tests__/email-context-menu-shortcuts.test.tsx b/components/email/__tests__/email-context-menu-shortcuts.test.tsx
index 062e60278..cc3c8f8f5 100644
--- a/components/email/__tests__/email-context-menu-shortcuts.test.tsx
+++ b/components/email/__tests__/email-context-menu-shortcuts.test.tsx
@@ -58,10 +58,10 @@ describe('message menu key hints', () => {
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('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_read')).getByText('Shift + I')).toBeInTheDocument();
expect(within(row('mark_as_spam')).getByText('!')).toBeInTheDocument();
expect(within(screen.getByTestId('ctx-delete')).getByText('Del')).toBeInTheDocument();
});
@@ -77,13 +77,38 @@ describe('message menu key hints', () => {
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-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('read', { unread: true, singleKeys: true })).toBe('Shift + I');
expect(messageActionShortcut('print', { singleKeys: true })).toBeUndefined();
});
});
+
+describe('message menu offline', () => {
+ afterEach(async () => {
+ const { useOfflineStore } = await import('@/lib/offline/state');
+ useOfflineStore.setState({ browserOffline: false, enabled: [] });
+ });
+
+ it('greys out changes that would fail without a connection, and says why', async () => {
+ const { useOfflineStore } = await import('@/lib/offline/state');
+ useOfflineStore.setState({ browserOffline: true, enabled: [] });
+ renderMenu();
+ expect(screen.getByText('offline_header')).toBeInTheDocument();
+ expect((screen.getByTestId('ctx-delete') as HTMLButtonElement).disabled).toBe(true);
+ expect((screen.getByTestId('ctx-star') as HTMLButtonElement).disabled).toBe(true);
+ expect((screen.getByTestId('ctx-mark-read') as HTMLButtonElement).disabled).toBe(true);
+ // Writing a reply still works: the composer keeps the draft.
+ expect(row('reply')).not.toBeDisabled();
+ });
+
+ it('leaves them on while online', () => {
+ renderMenu();
+ expect(screen.queryByText('offline_header')).toBeNull();
+ expect((screen.getByTestId('ctx-delete') as HTMLButtonElement).disabled).toBe(false);
+ });
+});
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx
index deaf6e91e..e264dafca 100644
--- a/components/email/email-context-menu.tsx
+++ b/components/email/email-context-menu.tsx
@@ -31,6 +31,7 @@ import {
} from "@/components/icons";
import { useMessageActionList, type MessageActionEntry, type MessageActionKey } from "./message-actions";
import { useMessageActionShortcut } from "./message-action-shortcuts";
+import { useConnectionState } from "@/hooks/use-connection-state";
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { localizeMailboxName } from "@/lib/mailbox-label";
@@ -142,6 +143,12 @@ interface EmailContextMenuProps {
label?: string;
}
+/** Entries that change the message on the server, or queue the change offline. */
+const CHANGES_MAIL = new Set([
+ "archive", "delete", "read", "remove_from_folder", "star", "pin", "spam",
+ "reschedule", "cancel_send", "cancel_and_edit",
+]);
+
export function EmailContextMenu({
email,
position,
@@ -200,6 +207,11 @@ export function EmailContextMenu({
}: 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");
@@ -360,7 +372,7 @@ export function EmailContextMenu({
label={t("archive")}
shortcut={shortcutOf("archive")}
onClick={() => handleAction(onBatchArchive!)}
- disabled={!onBatchArchive}
+ disabled={!onBatchArchive || !canChangeMail}
/>
>
);
@@ -373,7 +385,7 @@ export function EmailContextMenu({
testId="ctx-delete"
shortcut={shortcutOf("delete")}
onClick={() => handleAction(onBatchDelete!)}
- disabled={!onBatchDelete}
+ disabled={!onBatchDelete || !canChangeMail}
destructive={!mountainView}
/>
>
@@ -386,6 +398,7 @@ export function EmailContextMenu({
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))}
/>
>
@@ -394,7 +407,7 @@ export function EmailContextMenu({
<>
{/* Move to submenu */}
{moveTree.length > 0 && (
-
+
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onBatchMoveToMailbox?.(mailboxId))}
)}
@@ -403,6 +416,7 @@ export function EmailContextMenu({
icon={FolderX}
label={t("remove_from_folder")}
testId="ctx-remove-from-folder"
+ disabled={!canChangeMail}
onClick={() => handleAction(onBatchRemoveFromFolder!)}
/>
)}
@@ -422,7 +436,7 @@ export function EmailContextMenu({
<>
{/* Copy to another connected account, keeping the originals */}
{onCopyToAccount && copyTrees.length > 0 && (
-
+
{copyTargetsContent}
)}
@@ -431,14 +445,14 @@ export function EmailContextMenu({
const rulesItem = (
<>
{/* Rules: filter rules made from the sender(s), saved as Sieve */}
-
+
>
);
const tagItem = (
<>
{/* Set tag submenu - the whole selection's tags (#1077) */}
{onBatchToggleTag && (
-
+
handleAction(isInJunkFolder ? onBatchUndoSpam! : onBatchMarkAsSpam!)}
- disabled={isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam}
+ disabled={(isInJunkFolder ? !onBatchUndoSpam : !onBatchMarkAsSpam) || !canChangeMail}
destructive={!isInJunkFolder && !mountainView}
/>
{batchPhishing.available && (
@@ -483,26 +497,26 @@ export function EmailContextMenu({
switch (entry.key) {
case "move":
return (
-
+
{renderMailboxNodes(moveTree, moveTargetIds, "move-to:", (mailboxId) => onMoveToMailbox?.(mailboxId))}
);
case "copy_to_account":
return (
-
+
{copyTargetsContent}
);
case "tag":
return (
-
+
onSetTag?.(tagId)} scope={tagScope} />
);
case "rules":
- return ;
+ return ;
}
};
@@ -515,7 +529,7 @@ 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)}
@@ -531,6 +545,7 @@ export function EmailContextMenu({
onClose={onClose}
label={label}
>
+ {offline && {tMenu("offline_header")}}
{showBatchActions ? (
<>
diff --git a/components/email/message-action-shortcuts.ts b/components/email/message-action-shortcuts.ts
index ce9efe28c..944488657 100644
--- a/components/email/message-action-shortcuts.ts
+++ b/components/email/message-action-shortcuts.ts
@@ -12,7 +12,7 @@ import type { MessageActionKey } from "./message-actions";
*/
const KEYS: Partial> = {
reply: { key: "R", single: true },
- reply_all: { key: "Shift+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 },
@@ -25,7 +25,7 @@ export function messageActionShortcut(
options: { unread?: boolean; singleKeys: boolean },
): string | undefined {
const entry = key === "read"
- ? { key: options.unread ? "Shift+I" : "U", single: true }
+ ? { key: options.unread ? "Shift + I" : "U", single: true }
: KEYS[key];
if (!entry) return undefined;
if (entry.single && !options.singleKeys) return undefined;
diff --git a/components/email/rules-menu.tsx b/components/email/rules-menu.tsx
index 643d11376..e8ffbe6af 100644
--- a/components/email/rules-menu.tsx
+++ b/components/email/rules-menu.tsx
@@ -407,10 +407,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 +427,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 && }
@@ -801,7 +804,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 +813,7 @@ export function FolderRulesSubMenu({ mailbox, onClose }: { mailbox: Mailbox; onC
);
if (!target || !target.supportsSieve) return null;
return (
-
+
);
diff --git a/components/layout/__tests__/mailbox-context-menu-states.test.tsx b/components/layout/__tests__/mailbox-context-menu-states.test.tsx
new file mode 100644
index 000000000..5f6447986
--- /dev/null
+++ b/components/layout/__tests__/mailbox-context-menu-states.test.tsx
@@ -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(
+ ,
+ );
+}
+
+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);
+ });
+});
diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx
index 827d1d592..0e2f7d40d 100644
--- a/components/layout/mailbox-context-menu.tsx
+++ b/components/layout/mailbox-context-menu.tsx
@@ -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();
+ 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 ? {tMenu("offline_header")} : null;
const handleAction = (action: () => void) => {
action();
@@ -198,13 +218,14 @@ export function MailboxContextMenu({
if (target.kind === "folders-section") {
return (
+ {offlineHeader}
{!target.accountId && (
<>
handleAction(onMarkAllFoldersRead!)}
- disabled={!onMarkAllFoldersRead}
+ disabled={!onMarkAllFoldersRead || !canChangeMail}
/>
>
@@ -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"
/>
handleAction(onRefresh!)}
- disabled={!onRefresh}
+ disabled={!onRefresh || offline}
/>
{accountItems(target.accountId)}
@@ -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({
{renderShortenedPath(fullPath)}
+ {offlineHeader}
handleAction(() => onMarkFolderRead?.(mailbox.id))}
- disabled={!onMarkFolderRead || !canSetSeen}
+ disabled={!onMarkFolderRead || !canSetSeen || !canChangeMail || nothingUnread}
/>
{target.hasChildren && (
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"
/>
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"
/>
)}
-
+
{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 && (
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
/>
handleAction(() => onDeleteFolder?.(mailbox.id))}
- disabled={!onDeleteFolder || !canDelete}
+ disabled={!onDeleteFolder || !canDelete || offline}
destructive
testId="mailbox-delete"
/>
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index e22795c00..ccace0962 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -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(() => {
@@ -5541,7 +5543,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onClick={() => { close(); void handleManualRefresh(); }}
/>
handleMarkFolderRead(selectedMailbox) : undefined}
+ onMarkFolderRead={selectedMailbox && selectedFolderHasUnread ? () => handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
onPicked={close}
@@ -5581,7 +5583,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()}
@@ -5651,7 +5653,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}
/>
diff --git a/hooks/use-connection-state.ts b/hooks/use-connection-state.ts
new file mode 100644
index 000000000..e870307e5
--- /dev/null
+++ b/hooks/use-connection-state.ts
@@ -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)) };
+}
diff --git a/locales/ar/common.json b/locales/ar/common.json
index ed9cbecb0..76899d3a2 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -6455,6 +6455,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": "تعليم الكل كمقروء",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 5e3d4916d..1522a3a4c 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -6455,6 +6455,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",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index ace3e4105..96f28a71a 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -6455,6 +6455,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é",
diff --git a/locales/da/common.json b/locales/da/common.json
index b473c8379..21b96cc43 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -6455,6 +6455,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",
diff --git a/locales/de/common.json b/locales/de/common.json
index 9cb2097f7..a7a30ba85 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -6455,6 +6455,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",
diff --git a/locales/en/common.json b/locales/en/common.json
index 84f9389a7..5db5a5d24 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -6455,6 +6455,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",
diff --git a/locales/es/common.json b/locales/es/common.json
index a44fce868..60d8f6b6c 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -6455,6 +6455,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",
diff --git a/locales/et/common.json b/locales/et/common.json
index ace51f3b5..32331fd35 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -6455,6 +6455,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",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 9961aab01..84e115b67 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -6455,6 +6455,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": "علامتگذاری همه بهعنوان خواندهشده",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 750e84417..912d19a66 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -6455,6 +6455,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",
diff --git a/locales/he/common.json b/locales/he/common.json
index 23cb11fa8..63e69fec9 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -6456,6 +6456,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": "סימון הכול כנקרא",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index 172fa6df6..8b6bf498e 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -6455,6 +6455,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",
diff --git a/locales/it/common.json b/locales/it/common.json
index 71f13cca0..27ad83884 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -6455,6 +6455,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",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 7d382e843..117732b7c 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -6455,6 +6455,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": "すべて既読にする",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 9f9643ad0..1cf33acbd 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -6455,6 +6455,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": "모두 읽음으로 표시",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index a4bc8ef83..47903d6f7 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -6455,6 +6455,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",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index 152574c4e..4bf1e40a8 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -6455,6 +6455,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": "Бүгдийг уншсан болгох",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 9a6c14f76..1cadf8136 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -6455,6 +6455,19 @@
"group_account": "Konto",
"switch_to": "Bytt til {account}"
},
+ "menuAudit": {
+ "offline_header": "Du er frakoblet. Noen handlinger trenger tilkobling.",
+ "files_new_folder": "Ny mappe…",
+ "files_new_file": "Ny fil…",
+ "files_new_text_file": "Ny tekstfil…",
+ "files_upload_files": "Last opp filer…",
+ "files_upload_folder": "Last opp mappe…",
+ "files_share": "Del…",
+ "files_share_button": "Del",
+ "contacts_add_to_group": "Legg til i gruppe…",
+ "contacts_bulk_add_to_group": "Legg til i gruppe…",
+ "schedule_send": "Planlegg sending…"
+ },
"notification_center": {
"title": "Varsler",
"mark_all_read": "Merk alle som lest",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index 3c57a4007..04129a79b 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -6455,6 +6455,19 @@
"group_account": "Account",
"switch_to": "Overschakelen naar {account}"
},
+ "menuAudit": {
+ "offline_header": "U bent offline. Sommige acties hebben een verbinding nodig.",
+ "files_new_folder": "Nieuwe map…",
+ "files_new_file": "Nieuw bestand…",
+ "files_new_text_file": "Nieuw tekstbestand…",
+ "files_upload_files": "Bestanden uploaden…",
+ "files_upload_folder": "Map uploaden…",
+ "files_share": "Delen…",
+ "files_share_button": "Delen",
+ "contacts_add_to_group": "Aan groep toevoegen…",
+ "contacts_bulk_add_to_group": "Aan groep toevoegen…",
+ "schedule_send": "Verzenden plannen…"
+ },
"notification_center": {
"title": "Meldingen",
"mark_all_read": "Alles als gelezen markeren",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 20bd079ae..50277339d 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -6455,6 +6455,19 @@
"group_account": "Konto",
"switch_to": "Przełącz na {account}"
},
+ "menuAudit": {
+ "offline_header": "Jesteś offline. Niektóre działania wymagają połączenia.",
+ "files_new_folder": "Nowy folder…",
+ "files_new_file": "Nowy plik…",
+ "files_new_text_file": "Nowy plik tekstowy…",
+ "files_upload_files": "Prześlij pliki…",
+ "files_upload_folder": "Prześlij folder…",
+ "files_share": "Udostępnij…",
+ "files_share_button": "Udostępnij",
+ "contacts_add_to_group": "Dodaj do grupy…",
+ "contacts_bulk_add_to_group": "Dodaj do grupy…",
+ "schedule_send": "Zaplanuj wysyłkę…"
+ },
"notification_center": {
"title": "Powiadomienia",
"mark_all_read": "Oznacz wszystkie jako przeczytane",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 2452edf4a..d53551a93 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -6455,6 +6455,19 @@
"group_account": "Conta",
"switch_to": "Mudar para {account}"
},
+ "menuAudit": {
+ "offline_header": "Está offline. Algumas ações precisam de ligação.",
+ "files_new_folder": "Nova pasta…",
+ "files_new_file": "Novo arquivo…",
+ "files_new_text_file": "Novo arquivo de texto…",
+ "files_upload_files": "Carregar arquivos…",
+ "files_upload_folder": "Carregar pasta…",
+ "files_share": "Compartilhar…",
+ "files_share_button": "Compartilhar",
+ "contacts_add_to_group": "Adicionar ao grupo…",
+ "contacts_bulk_add_to_group": "Adicionar ao grupo…",
+ "schedule_send": "Agendar envio…"
+ },
"notification_center": {
"title": "Notificações",
"mark_all_read": "Marcar todas como lidas",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index 03bfef66a..f85219224 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -6455,6 +6455,19 @@
"group_account": "Cont",
"switch_to": "Comută la {account}"
},
+ "menuAudit": {
+ "offline_header": "Sunteți offline. Unele acțiuni au nevoie de conexiune.",
+ "files_new_folder": "Dosar nou…",
+ "files_new_file": "Fișier nou…",
+ "files_new_text_file": "Fișier text nou…",
+ "files_upload_files": "Încărcați fișiere…",
+ "files_upload_folder": "Încărcare folder…",
+ "files_share": "Distribuie…",
+ "files_share_button": "Distribuie",
+ "contacts_add_to_group": "Adaugă la grup…",
+ "contacts_bulk_add_to_group": "Adaugă la grup…",
+ "schedule_send": "Programează trimiterea…"
+ },
"notification_center": {
"title": "Notificări",
"mark_all_read": "Marchează toate ca citite",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index ebb25a96f..466ad5068 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -6455,6 +6455,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": "Отметить все как прочитанные",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 43343c99e..bccc84b78 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -6455,6 +6455,19 @@
"group_account": "Účet",
"switch_to": "Prepnúť na {account}"
},
+ "menuAudit": {
+ "offline_header": "Ste offline. Niektoré akcie potrebujú pripojenie.",
+ "files_new_folder": "Nový priečinok…",
+ "files_new_file": "Nový súbor…",
+ "files_new_text_file": "Nový textový súbor…",
+ "files_upload_files": "Nahrať súbory…",
+ "files_upload_folder": "Nahrať priečinok…",
+ "files_share": "Zdieľať…",
+ "files_share_button": "Zdieľať",
+ "contacts_add_to_group": "Pridať do skupiny…",
+ "contacts_bulk_add_to_group": "Pridať do skupiny…",
+ "schedule_send": "Naplánovať odoslanie…"
+ },
"notification_center": {
"title": "Oznámenia",
"mark_all_read": "Označiť všetko ako prečítané",
diff --git a/locales/th/common.json b/locales/th/common.json
index d4aef2af2..2032b6105 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -98,6 +98,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": "ทำเครื่องหมายว่าอ่านแล้วทั้งหมด",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index 03393f7f0..c7ef9d9b8 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -6455,6 +6455,19 @@
"group_account": "Hesap",
"switch_to": "{account} hesabına geç"
},
+ "menuAudit": {
+ "offline_header": "Çevrimdışısınız. Bazı işlemler bağlantı gerektirir.",
+ "files_new_folder": "Yeni Klasör…",
+ "files_new_file": "Yeni Dosya…",
+ "files_new_text_file": "Yeni Metin Dosyası…",
+ "files_upload_files": "Dosya Yükle…",
+ "files_upload_folder": "Klasör Yükle…",
+ "files_share": "Paylaş…",
+ "files_share_button": "Paylaş",
+ "contacts_add_to_group": "Gruba ekle…",
+ "contacts_bulk_add_to_group": "Gruba ekle…",
+ "schedule_send": "Göndermeyi zamanla…"
+ },
"notification_center": {
"title": "Bildirimler",
"mark_all_read": "Tümünü okundu işaretle",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index f033cd1ee..da741e92d 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -6455,6 +6455,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": "Позначити всі як прочитані",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 9faa09bde..6c7be2f27 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -6467,6 +6467,19 @@
"group_account": "Tài khoản",
"switch_to": "Chuyển sang {account}"
},
+ "menuAudit": {
+ "offline_header": "Bạn đang ngoại tuyến. Một số thao tác cần kết nối.",
+ "files_new_folder": "Thư mục mới…",
+ "files_new_file": "Tệp mới…",
+ "files_new_text_file": "Tệp văn bản mới…",
+ "files_upload_files": "Tải tệp lên…",
+ "files_upload_folder": "Tải lên thư mục…",
+ "files_share": "Chia sẻ…",
+ "files_share_button": "Chia sẻ",
+ "contacts_add_to_group": "Thêm vào nhóm…",
+ "contacts_bulk_add_to_group": "Thêm vào nhóm…",
+ "schedule_send": "Lên lịch gửi…"
+ },
"notification_center": {
"title": "Thông báo",
"mark_all_read": "Đánh dấu tất cả đã đọc",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index eed67bf21..74371a3e6 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -6455,6 +6455,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": "全部標示為已讀",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 0edeb8c81..412f76ce8 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -6455,6 +6455,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": "全部标为已读",
From fc7b607ff758bdd32c1ea57e16294db278451589 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 05:32:23 +0100
Subject: [PATCH 13/20] =?UTF-8?q?fix:=20end=20Files=20and=20composer=20men?=
=?UTF-8?q?u=20entries=20that=20open=20a=20dialog=20with=20=E2=80=A6,=20an?=
=?UTF-8?q?d=20translate=20Share?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Files tests now look for the new menuAudit keys: the menu entries
moved to them so they can end in "…" while toolbar buttons keep the
plain label.
---
.../composer-plugin-surfaces.test.tsx | 2 ++
components/email/email-composer.tsx | 4 +++-
.../file-browser-shared-folders.test.tsx | 8 ++++----
components/files/file-browser.tsx | 18 ++++++++++--------
components/files/file-details-panel.tsx | 3 ++-
components/files/file-views.tsx | 3 ++-
components/files/files-header.tsx | 3 ++-
components/files/files-sidebar.tsx | 19 ++++++++++---------
8 files changed, 35 insertions(+), 25 deletions(-)
diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx
index 787389c18..082e40541 100644
--- a/components/email/__tests__/composer-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx
@@ -327,6 +327,8 @@ describe('composer-send-menu', () => {
const schedule = screen.getAllByRole('menuitem', { name: /schedule_send/ })[0];
const plugin = screen.getAllByRole('menuitem', { name: 'Send with Later' })[0];
expect(follows(schedule, plugin)).toBe(true);
+ // It opens a dialog, and shows the key that opens it from the composer.
+ expect(schedule).toHaveTextContent('Ctrl + Shift + Enter');
});
it('keeps a plain Send button without plugin entries or scheduled send', () => {
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 3409f77db..bf3a3528c 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -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');
@@ -5813,7 +5815,7 @@ export function EmailComposer({
{composerClient?.hasDelayedSend() && (
-
+
)}
{
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();
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');
diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx
index 5338bf98b..8d7c6ca00 100644
--- a/components/files/file-browser.tsx
+++ b/components/files/file-browser.tsx
@@ -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)) && }
{canShare(resource) && (
- { closeMenu(); setShareTargetId(resource.id); }} />
+ { closeMenu(); setShareTargetId(resource.id); }} />
)}
{resource.isDirectory && !resource.isShared && (
- { closeMenu(); fileInputRef.current?.click(); }} />
- { closeMenu(); folderInputRef.current?.click(); }} />
+ { closeMenu(); fileInputRef.current?.click(); }} />
+ { closeMenu(); folderInputRef.current?.click(); }} />
- { closeMenu(); setShowNewFolder(true); }} />
+ { closeMenu(); setShowNewFolder(true); }} />
{ 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 },
diff --git a/components/files/file-details-panel.tsx b/components/files/file-details-panel.tsx
index 34facf37d..57c2e135d 100644
--- a/components/files/file-details-panel.tsx
+++ b/components/files/file-details-panel.tsx
@@ -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 && (
)}
{resource.isDirectory && !resource.isShared && (
diff --git a/components/files/file-views.tsx b/components/files/file-views.tsx
index 11a26656c..972e8cfad 100644
--- a/components/files/file-views.tsx
+++ b/components/files/file-views.tsx
@@ -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 = ;
return (
@@ -299,7 +300,7 @@ function FileListRow({ resource, isSelected, isFocused, isTabStop, isDropTarget,
handlers.onDownload?.(resource)} />
)}
{!touch && handlers.canShare(resource) && (
- handlers.onShare(resource)} />
+ handlers.onShare(resource)} />
)}
();
const overflowMenu = useContextMenu();
const [searchOpen, setSearchOpen] = useState(false);
@@ -159,7 +160,7 @@ export function FilesHeader({
)}
{selection.canShare && (
-
+
)}
{selection.canRename && (
diff --git a/components/files/files-sidebar.tsx b/components/files/files-sidebar.tsx
index 58dc0b629..d47ea17a5 100644
--- a/components/files/files-sidebar.tsx
+++ b/components/files/files-sidebar.tsx
@@ -125,6 +125,7 @@ export function FilesSidebar({
onDropToTrash,
}: FilesSidebarProps) {
const t = useTranslations("files");
+ const tMenu = useTranslations("menuAudit");
const newMenu = useContextMenu();
const itemMenu = useContextMenu();
const newButtonRef = useRef(null);
@@ -274,10 +275,10 @@ export function FilesSidebar({
const createItems = (path: string, id: string | null) => (
<>
- run(() => onCreateIn(path, id, "folder"))} />
- run(() => onCreateIn(path, id, "text"))} />
- run(() => onCreateIn(path, id, "files"))} />
- run(() => onCreateIn(path, id, "dir"))} />
+ run(() => onCreateIn(path, id, "folder"))} />
+ run(() => onCreateIn(path, id, "text"))} />
+ run(() => onCreateIn(path, id, "files"))} />
+ run(() => onCreateIn(path, id, "dir"))} />
>
);
@@ -455,11 +456,11 @@ export function FilesSidebar({
- { newMenu.closeContextMenu(); onUploadFiles(); }} />
- { newMenu.closeContextMenu(); onUploadFolder(); }} />
+ { newMenu.closeContextMenu(); onUploadFiles(); }} />
+ { newMenu.closeContextMenu(); onUploadFolder(); }} />
- { newMenu.closeContextMenu(); onNewFolder(); }} />
- { newMenu.closeContextMenu(); onNewTextFile(); }} />
+ { newMenu.closeContextMenu(); onNewFolder(); }} />
+ { newMenu.closeContextMenu(); onNewTextFile(); }} />
@@ -484,7 +485,7 @@ export function FilesSidebar({
Details act on the list and are left out here (R6). */}
{(!folder.isShared || canShare(folder)) && }
{canShare(folder) && (
- run(() => onShare(folder))} />
+ run(() => onShare(folder))} />
)}
{!folder.isShared && (
Date: Wed, 4 Nov 2026 05:32:24 +0100
Subject: [PATCH 14/20] fix: leave Edit, Move and Delete out for contacts in a
read-only address book
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The two "Add to group" tests click the entry by its new key, which ends
in "…" because it opens a group picker.
---
.../contacts/__tests__/contacts-app.test.tsx | 20 ++++++++++--
components/contacts/contact-context-menu.tsx | 31 ++++++++++++-------
components/contacts/contact-detail.tsx | 7 +++--
components/contacts/contact-list.tsx | 4 +++
components/contacts/contacts-app.tsx | 5 +++
5 files changed, 52 insertions(+), 15 deletions(-)
diff --git a/components/contacts/__tests__/contacts-app.test.tsx b/components/contacts/__tests__/contacts-app.test.tsx
index cd153f9e8..802c8f994 100644
--- a/components/contacts/__tests__/contacts-app.test.tsx
+++ b/components/contacts/__tests__/contacts-app.test.tsx
@@ -182,7 +182,7 @@ describe('ContactsApp', () => {
it('starts a new group with the contact when "Add to group" finds no group to add to', () => {
renderContacts();
fireEvent.contextMenu(screen.getByText('Alice Smith'));
- fireEvent.click(screen.getByText('context_menu.add_to_group'));
+ fireEvent.click(screen.getByText('contacts_add_to_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
@@ -190,11 +190,27 @@ describe('ContactsApp', () => {
const team: ContactCard = { id: 'g-team', kind: 'group', addressBookIds: {}, name: { full: 'Team' }, members: {} };
renderContacts([alice, bob, team]);
fireEvent.contextMenu(screen.getByText('Bob Jones'));
- fireEvent.click(screen.getByText('context_menu.add_to_group'));
+ fireEvent.click(screen.getByText('contacts_add_to_group'));
fireEvent.click(screen.getByText('bulk.new_group'));
expect(screen.getByText('groups.members_label (1)')).toBeInTheDocument();
});
+ it('offers no Edit, Move or Delete for a contact in an address book shared read-only', () => {
+ const shared = { ...alice, addressBookIds: { 'ab-shared': true } };
+ renderContacts([shared, bob]);
+ act(() => {
+ useContactStore.setState({ addressBooks: [{ id: 'ab-shared', name: 'Team', myRights: { mayRead: true, mayWrite: false } }] as never });
+ });
+ fireEvent.contextMenu(screen.getByText('Alice Smith'));
+ expect(screen.queryByRole('menuitem', { name: 'context_menu.edit' })).toBeNull();
+ expect(screen.queryByRole('menuitem', { name: /context_menu.delete/ })).toBeNull();
+ expect(screen.getByRole('menuitem', { name: 'context_menu.export_vcard' })).toBeInTheDocument();
+ fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
+
+ fireEvent.contextMenu(screen.getByText('Bob Jones'));
+ expect(screen.getByRole('menuitem', { name: 'context_menu.edit' })).toBeInTheDocument();
+ });
+
it('imports in a real dialog that Escape closes', () => {
renderContacts();
fireEvent.click(screen.getAllByRole('button', { name: 'open_categories' })[0]);
diff --git a/components/contacts/contact-context-menu.tsx b/components/contacts/contact-context-menu.tsx
index 9b6eea288..6281846ca 100644
--- a/components/contacts/contact-context-menu.tsx
+++ b/components/contacts/contact-context-menu.tsx
@@ -65,6 +65,8 @@ interface ContactContextMenuProps {
onBatchExport?: () => void;
onBatchAddToGroup?: () => void;
onBatchDelete?: () => void;
+ /** The contact is in an address book shared read-only: no Edit, Move or Delete. */
+ readOnly?: boolean;
}
export function ContactContextMenu({
@@ -88,8 +90,10 @@ export function ContactContextMenu({
onBatchExport,
onBatchAddToGroup,
onBatchDelete,
+ readOnly = false,
}: ContactContextMenuProps) {
const t = useTranslations("contacts");
+ const tMenu = useTranslations("menuAudit");
const printOff = useStrictestDevices().noPrint;
const email = getContactPrimaryEmail(contact);
const phone = getContactPrimaryPhone(contact);
@@ -134,7 +138,7 @@ export function ContactContextMenu({
onBatchAddToGroup?.())}
disabled={!onBatchAddToGroup}
/>
@@ -179,12 +183,12 @@ export function ContactContextMenu({
onClick={handle(handleCall)}
/>
)}
- {onEdit && }
+ {onEdit && !readOnly && }
{!inDetail && }
{onAddToGroup && (
)}
@@ -195,7 +199,7 @@ export function ContactContextMenu({
onClick={handle(onDuplicate)}
/>
)}
- {onMoveTo && (
+ {onMoveTo && !readOnly && (
)}
{email && (
@@ -225,13 +229,18 @@ export function ContactContextMenu({
onClick={handle(handlePrint)}
/>
)}
-
-
+ {!readOnly && (
+ <>
+
+
+ >
+ )}
);
}
diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx
index 77edb2cdc..66a3969e2 100644
--- a/components/contacts/contact-detail.tsx
+++ b/components/contacts/contact-detail.tsx
@@ -35,6 +35,8 @@ interface ContactDetailProps {
onPrint?: () => void;
isMobile?: boolean;
className?: string;
+ /** In an address book shared read-only: no Edit, Move or Delete. */
+ readOnly?: boolean;
}
// Contexts and phone features the contact form offers, shown with the same
@@ -74,7 +76,7 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
}
-export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className }: ContactDetailProps) {
+export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, onPrint, isMobile, className, readOnly = false }: ContactDetailProps) {
const t = useTranslations("contacts");
const tCommon = useTranslations("common");
const locale = useLocale();
@@ -173,7 +175,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
)}
{/* A phone's row has no room for this label: the ⋮ fell onto a line of its own. */}
- {isMobile ? (
+ {readOnly ? null : isMobile ? (
@@ -207,6 +209,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
onClose={moreMenu.closeContextMenu}
menuRef={moreMenu.menuRef}
inDetail
+ readOnly={readOnly}
onCompose={onCompose}
onDelete={onDelete}
onAddToGroup={onAddToGroup}
diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx
index 6a11b7031..c3eef9c7a 100644
--- a/components/contacts/contact-list.tsx
+++ b/components/contacts/contact-list.tsx
@@ -105,6 +105,8 @@ function ToggleChip({ icon, label, value, onClick }: { icon: React.ReactNode; la
interface ContactListProps {
contacts: ContactCard[];
+ /** A contact in an address book shared read-only: its menu has no Edit, Move or Delete. */
+ isContactReadOnly?: (contact: ContactCard) => boolean;
selectedContactId: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
@@ -144,6 +146,7 @@ interface ContactListProps {
export function ContactList({
contacts,
+ isContactReadOnly,
selectedContactId,
searchQuery,
onSearchChange,
@@ -788,6 +791,7 @@ export function ContactList({
onBatchExport={onBulkExport}
onBatchAddToGroup={onBulkAddToGroup}
onBatchDelete={onBulkDelete}
+ readOnly={isContactReadOnly?.(contextMenu.data) ?? false}
/>
)}
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx
index a28c84ea1..83e2619d7 100644
--- a/components/contacts/contacts-app.tsx
+++ b/components/contacts/contacts-app.tsx
@@ -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 =
) : (
Date: Wed, 4 Nov 2026 05:35:44 +0100
Subject: [PATCH 15/20] fix: let a long submenu use the window's height, and
draw one focus ring round the folder filter
---
components/email/move-target-list.tsx | 9 +++++----
components/ui/context-menu.tsx | 3 ++-
2 files changed, 7 insertions(+), 5 deletions(-)
diff --git a/components/email/move-target-list.tsx b/components/email/move-target-list.tsx
index 51395ccb0..34632ca49 100644
--- a/components/email/move-target-list.tsx
+++ b/components/email/move-target-list.tsx
@@ -189,8 +189,9 @@ export function MoveFolderList({
{showFilter && (
)}
diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx
index 8241bd4d0..e0468679b 100644
--- a/components/ui/context-menu.tsx
+++ b/components/ui/context-menu.tsx
@@ -781,7 +781,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"
>
-
+ {/* As tall as the window allows (it's moved up to fit), then it scrolls. */}
+
{children}
From 4508eddccb89c50213fbc7564f29f1072cbd6976 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 05:35:57 +0100
Subject: [PATCH 16/20] fix: keep a tall menu under its button and scroll it,
instead of covering the button
---
.../__tests__/context-menu-placement.test.tsx | 19 +++++++++++++++
components/ui/context-menu.tsx | 24 +++++++++++++++----
lib/menu-placement.ts | 17 +++++++++----
3 files changed, 51 insertions(+), 9 deletions(-)
diff --git a/components/ui/__tests__/context-menu-placement.test.tsx b/components/ui/__tests__/context-menu-placement.test.tsx
index a856a4268..d1345b377 100644
--- a/components/ui/__tests__/context-menu-placement.test.tsx
+++ b/components/ui/__tests__/context-menu-placement.test.tsx
@@ -98,6 +98,25 @@ describe('a menu from a button', () => {
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(
+
{}}>
+ {}} />
+ ,
+ );
+ 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 } });
diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx
index 84e0c359c..0498a3dc3 100644
--- a/components/ui/context-menu.tsx
+++ b/components/ui/context-menu.tsx
@@ -242,7 +242,7 @@ function useMenuLevel(recentTouch: () => boolean): MenuLevel {
export const ContextMenu = forwardRef
(
({ isOpen, position, onClose, children, label }, ref) => {
const [mounted, setMounted] = useState(false);
- const [adjustedPosition, setAdjustedPosition] = useState(null);
+ const [adjustedPosition, setAdjustedPosition] = useState<(Position & { maxHeight?: number }) | null>(null);
const localRef = useRef(null);
const onCloseRef = useRef(onClose);
useEffect(() => {
@@ -326,18 +326,25 @@ export const ContextMenu = forwardRef(
const clamp = () => {
const rect = node.getBoundingClientRect();
+ // 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 } = placeMenu({
+ const { x, y, maxHeight } = placeMenu({
point: { x: position.x, y: position.y },
anchor,
- size: { width: rect.width, height: rect.height },
+ size: { width: rect.width, height },
viewport: { width: window.innerWidth, height: window.innerHeight },
rtl: getComputedStyle(node).direction === "rtl",
margin: VIEWPORT_MARGIN,
gap: ANCHOR_GAP,
});
- 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;
@@ -528,7 +535,14 @@ export const ContextMenu = forwardRef(
}}
>
{/* Taller than the window: scroll inside the menu. */}
-
diff --git a/lib/menu-placement.ts b/lib/menu-placement.ts
index a8397d1ee..146c6b391 100644
--- a/lib/menu-placement.ts
+++ b/lib/menu-placement.ts
@@ -50,8 +50,9 @@ export function placeMenu({
rtl = false,
margin = MENU_VIEWPORT_MARGIN,
gap = MENU_ANCHOR_GAP,
-}: MenuPlacementInput): { x: number; y: number } {
- const { width: w, height: h } = size;
+}: 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;
@@ -69,8 +70,16 @@ export function placeMenu({
const above = anchor.top - gap - h;
if (fitsY(below)) y = below;
else if (fitsY(above)) y = above;
- // Neither fits whole: the side with more room, and the menu scrolls.
- else y = vh - anchor.bottom >= anchor.top ? below : 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;
From 53eb91ab0ef35b2c0f0ddd90d4e47f938619df29 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 05:58:47 +0100
Subject: [PATCH 17/20] fix: keep the message details panel readable in a
narrow reader, with addresses, dates and sign-in results that fit
---
.../__tests__/viewer-details-fit.test.tsx | 84 +++++++++++++++++++
components/email/email-viewer.tsx | 51 ++++++-----
components/email/recipient-popover.tsx | 6 +-
components/ui/breakable-address.tsx | 17 ++++
lib/__tests__/text-breaks.test.ts | 27 ++++++
lib/text-breaks.ts | 34 ++++++++
6 files changed, 198 insertions(+), 21 deletions(-)
create mode 100644 components/email/__tests__/viewer-details-fit.test.tsx
create mode 100644 components/ui/breakable-address.tsx
create mode 100644 lib/__tests__/text-breaks.test.ts
create mode 100644 lib/text-breaks.ts
diff --git a/components/email/__tests__/viewer-details-fit.test.tsx b/components/email/__tests__/viewer-details-fit.test.tsx
new file mode 100644
index 000000000..580262959
--- /dev/null
+++ b/components/email/__tests__/viewer-details-fit.test.tsx
@@ -0,0 +1,84 @@
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+// The message details panel in a narrow reader: the two columns followed the
+// window's width, not the panel's, so they stayed side by side in a narrow pane;
+// the sender's address went letter by letter down a sliver of a column, dates took
+// three lines, and long sign-in results ran off the side.
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+}));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+const ADDRESS = 'no-reply-x8@notifications.mail.example-service.example.net';
+const SPF_DOMAIN = 'bounces+31859940-0dcc@em9672.notifications.example-service.example.net';
+
+const email = {
+ id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Example Service', email: ADDRESS }], to: [{ name: 'Bob', email: 'bob@example.com' }],
+ subject: 'Hello', sentAt: '2026-11-04T04:33:50Z', receivedAt: '2026-11-04T04:33:53Z', preview: 'Hello',
+ hasAttachment: false, size: 100, textBody: [], htmlBody: [], attachments: [], bodyValues: {},
+ authenticationResults: { spf: { result: 'pass', domain: SPF_DOMAIN } },
+} as unknown as Email;
+
+function openDetails() {
+ render();
+ act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); });
+ const heading = screen.getByText('details.recipients_routing');
+ return heading.closest('section')!;
+}
+
+describe('message details panel', () => {
+ it('lays out by its own width, not the window', () => {
+ const routing = openDetails();
+ const grid = routing.parentElement!;
+ expect(grid.className).toMatch(/@3xl:grid-cols-2/);
+ expect(grid.className).not.toMatch(/\blg:grid-cols-2/);
+ expect(grid.parentElement!.className).toMatch(/@container/);
+ // The value column may shrink below its content's width.
+ expect(routing.querySelector('dl')!.className).toContain('minmax(0,1fr)');
+ });
+
+ it('shows the sender name and address apart, the address breakable at its dots', () => {
+ const routing = openDetails();
+ expect(routing.textContent).not.toContain(`<${ADDRESS}>`);
+ const address = routing.querySelector(`[title="${ADDRESS}"]`)!;
+ expect(address.textContent).toBe(ADDRESS);
+ expect(address.querySelectorAll('wbr').length).toBeGreaterThan(2);
+ });
+
+ it('shows dates short, with the full date on hover', () => {
+ const routing = openDetails();
+ const times = routing.querySelectorAll('time');
+ expect(times.length).toBe(2);
+ for (const time of Array.from(times)) {
+ expect(time.getAttribute('title')!.length).toBeGreaterThan(time.textContent!.length);
+ }
+ });
+
+ it('keeps sign-in result chips inside the panel, the long part cut with the whole on hover', () => {
+ openDetails();
+ const domain = screen.getByText(SPF_DOMAIN);
+ expect(domain.className).toContain('truncate');
+ expect(domain.getAttribute('title')).toBe(SPF_DOMAIN);
+ expect(domain.parentElement!.className).toContain('max-w-full');
+ });
+});
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 0b1b5fac7..eb050d200 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -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";
@@ -5413,6 +5414,11 @@ ${printWatermark ? printWatermark.html : ''}
const fullDate = (iso?: string) => iso
? formatDateTime(iso, timeFormat, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', second: '2-digit', timeZoneName: 'short' })
: '-';
+ // A long date wraps over three lines in the narrow column: the short one
+ // shows, the full one is on hover.
+ const shownDate = (iso?: string) => iso
+ ?
+ : '-';
const auth = email.authenticationResults;
const totalAttachmentSize = effectiveAttachments.reduce((s, a) => s + (a.size || 0), 0);
const topMimeType = email.bodyStructure?.type;
@@ -5423,7 +5429,7 @@ ${printWatermark ? printWatermark.html : ''}
);
const Row = ({ label, children, mono }: { label: string; children: React.ReactNode; mono?: boolean }) => (
<>
- {label}
+ {label}
- {name}
-
+ {name}
+
{translateAuthResult(result)}
{extra && (
<>
·
- {extra}
+ {extra}
>
)}
@@ -5478,17 +5484,24 @@ ${printWatermark ? printWatermark.html : ''}
);
return (
-
-
+
+
{t('details.recipients_routing')}
-
+
` : undefined}
+ label={sender?.name && sender?.email ? (
+ <>
+ {sender.name}
+
+
+
+ >
+ ) : undefined}
onViewContact={handleViewContactSidebar}
className="text-sm text-start"
/>
@@ -5542,10 +5555,10 @@ ${printWatermark ? printWatermark.html : ''}
)}
{email.sentAt && (
- {fullDate(email.sentAt)}
+ {shownDate(email.sentAt)}
)}
- {fullDate(email.receivedAt)}
+ {shownDate(email.receivedAt)}
{deliveryDeltaMs > 60000 && (
· {formatDelta(deliveryDeltaMs)} {t('details.delivery_time').toLowerCase()}
)}
@@ -5590,7 +5603,7 @@ ${printWatermark ? printWatermark.html : ''}
)}
{email.spamScore !== undefined && (
5 ? "bg-red-500/[0.07] border-red-500/30" :
email.spamScore > 2 ? "bg-amber-500/[0.07] border-amber-500/30" :
"bg-green-500/[0.07] border-green-500/30",
@@ -5613,7 +5626,7 @@ ${printWatermark ? printWatermark.html : ''}
{email.spamStatus && (
<>
·
- {email.spamStatus}
+ {email.spamStatus}
>
)}
@@ -5644,9 +5657,9 @@ ${printWatermark ? printWatermark.html : ''}
{hasIdentifiers && (
{t('details.identifiers_threading')}
-
+
{email.messageId && (
- {email.messageId}
+
)}
{email.inReplyTo && email.inReplyTo.length > 0 && (
@@ -5678,7 +5691,7 @@ ${printWatermark ? printWatermark.html : ''}
{t('details.message_properties')}
-
+
{email.subject !== undefined && (
{email.subject || {t('details.no_subject')}}
)}
@@ -5701,9 +5714,9 @@ ${printWatermark ? printWatermark.html : ''}
{hasListInfo && (
-
+
{t('details.mailing_list')}
-
+
{listHeaders?.listId && (
{listHeaders.listId}
)}
@@ -5730,7 +5743,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Plugin-supplied category. Plugins whose shouldShow accepts
`category === null` render their own titled section here. */}
{hasDetailsSlotOffers && (
-
+
)}
diff --git a/components/email/recipient-popover.tsx b/components/email/recipient-popover.tsx
index caaf8bc78..1ea559311 100644
--- a/components/email/recipient-popover.tsx
+++ b/components/email/recipient-popover.tsx
@@ -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(null);
const triggerRef = useRef(null);
const shownEmail = useOwnDomainAddress()(email);
@@ -280,7 +282,7 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
className
)}
>
- {displayLabel || name || shownEmail}
+ {label ?? {displayLabel || name || shownEmail}}
{anchor && (
diff --git a/components/ui/breakable-address.tsx b/components/ui/breakable-address.tsx
new file mode 100644
index 000000000..c5e703132
--- /dev/null
+++ b/components/ui/breakable-address.tsx
@@ -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) => (
+
+ {index > 0 && }
+ {piece}
+
+ ))}
+ >
+ );
+}
diff --git a/lib/__tests__/text-breaks.test.ts b/lib/__tests__/text-breaks.test.ts
new file mode 100644
index 000000000..4224f665d
--- /dev/null
+++ b/lib/__tests__/text-breaks.test.ts
@@ -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']);
+ });
+});
diff --git a/lib/text-breaks.ts b/lib/text-breaks.ts
new file mode 100644
index 000000000..2bb86de85
--- /dev/null
+++ b/lib/text-breaks.ts
@@ -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 () 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;
+}
From ea274b02e995335a730f03fe3f0bec3186a37c44 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 05:58:48 +0100
Subject: [PATCH 18/20] fix: end a field's text in an ellipsis when it doesn't
fit, instead of cutting it mid-word
---
app/globals.css | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/app/globals.css b/app/globals.css
index b9aee76aa..46339ee6e 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -191,6 +191,12 @@
* {
border-color: var(--color-border);
}
+
+ /* A placeholder or value too long for a narrow field ends in "…" instead of
+ stopping mid-word ("Termine durchs"), so it's clear there's more. */
+ input {
+ text-overflow: ellipsis;
+ }
}
body {
From eda317721c07795873889b19b026d16fc29fa055 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 05:58:50 +0100
Subject: [PATCH 19/20] test: find cut-off placeholders, and let the calendar's
sideways strip of weeks scroll
---
integration/tests/25-do-layout-fit.spec.ts | 7 ++++---
integration/tests/helpers/layout-fit.ts | 20 +++++++++++++++++++-
2 files changed, 23 insertions(+), 4 deletions(-)
diff --git a/integration/tests/25-do-layout-fit.spec.ts b/integration/tests/25-do-layout-fit.spec.ts
index 77ec19577..9fc15ecba 100644
--- a/integration/tests/25-do-layout-fit.spec.ts
+++ b/integration/tests/25-do-layout-fit.spec.ts
@@ -99,14 +99,15 @@ for (const scenario of SCENARIOS) {
await check(page, scenario, 'mail-list', found);
await item.click();
await page.getByRole('button', { name: text.showDetails }).first().click();
- const routing = page.locator('section', { has: page.getByText(text.routing, { exact: true }) }).first();
+ // 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.evaluate((section) => {
- const values = Array.from(section.querySelectorAll('dl > dd'));
+ 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;
diff --git a/integration/tests/helpers/layout-fit.ts b/integration/tests/helpers/layout-fit.ts
index 127b2d785..067a7b8cc 100644
--- a/integration/tests/helpers/layout-fit.ts
+++ b/integration/tests/helpers/layout-fit.ts
@@ -7,7 +7,8 @@
* 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.
+ * 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';
@@ -58,6 +59,10 @@ export async function findCutOffText(page: Page): Promise {
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();
@@ -75,6 +80,19 @@ export async function findCutOffText(page: Page): Promise {
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('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;
});
}
From 8bcf5248a4abb5c57934c91a63be503fe5395141 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:17:12 +0100
Subject: [PATCH 20/20] fix: translate Lock now in the desktop File menu
---
lib/desktop/languages.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/lib/desktop/languages.ts b/lib/desktop/languages.ts
index a877ef3dd..6f6840339 100644
--- a/lib/desktop/languages.ts
+++ b/lib/desktop/languages.ts
@@ -132,6 +132,7 @@ export function shellLabels(t: (key: string, values?: Record) =>
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'),