0 && !(mvShell && !cursorOnly && selectedEmail);
const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts);
useKeyboardShortcuts({
@@ -1258,6 +1263,59 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
}, isAuthenticated && !!client);
+ // The desktop app's menu bar: the same handlers as the keys, and what's enabled
+ // from what's on screen (lib/desktop/commands.ts).
+ const desktopTarget = !showComposer && (!!selectedEmail || selectedEmailKeys.size > 0);
+ const desktopSingle = !showComposer && !!selectedEmail;
+ const mailLayoutSetting = useSettingsStore((s) => s.mailLayout);
+ const layoutFixed = interfaceLayout === 'mountain-view';
+ useDesktopCommands({
+ 'file.newMessage': keyboardHandlers.onCompose,
+ 'edit.search': keyboardHandlers.onFocusSearch,
+ 'view.refresh': () => void handleManualRefresh(),
+ 'view.layout.split': () => useSettingsStore.getState().updateSetting('mailLayout', 'split'),
+ 'view.layout.horizontal': () => useSettingsStore.getState().updateSetting('mailLayout', 'horizontal'),
+ 'view.layout.focus': () => useSettingsStore.getState().updateSetting('mailLayout', 'focus'),
+ 'message.reply': keyboardHandlers.onReply,
+ 'message.replyAll': keyboardHandlers.onReplyAll,
+ 'message.forward': keyboardHandlers.onForward,
+ 'message.markRead': () => void keyboardHandlers.onMarkAsRead?.(),
+ 'message.markUnread': () => void keyboardHandlers.onMarkAsUnread?.(),
+ 'message.star': () => void keyboardHandlers.onToggleStar?.(),
+ 'message.move': () => { if (selectedEmail) setMoveDialogEmail(selectedEmail); },
+ 'message.archive': keyboardHandlers.onArchive ? () => void keyboardHandlers.onArchive?.() : undefined,
+ 'message.spam': () => void keyboardHandlers.onToggleSpam?.(),
+ 'message.delete': () => void keyboardHandlers.onDelete?.(),
+ 'message.openWindow': () => { if (selectedEmail) openEmailInWindow?.(selectedEmail); },
+ 'go.inbox': () => keyboardHandlers.onGoToMailbox('inbox'),
+ 'go.sent': () => keyboardHandlers.onGoToMailbox('sent'),
+ 'go.drafts': () => keyboardHandlers.onGoToMailbox('drafts'),
+ 'go.nextMessage': keyboardHandlers.onNextEmail,
+ 'go.previousMessage': keyboardHandlers.onPreviousEmail,
+ 'go.nextUnread': keyboardHandlers.onNextUnread,
+ }, {
+ 'view.refresh': true,
+ 'view.layout.split': { enabled: !layoutFixed, checked: mailLayoutSetting === 'split' },
+ 'view.layout.horizontal': { enabled: !layoutFixed, checked: mailLayoutSetting === 'horizontal' },
+ 'view.layout.focus': { enabled: !layoutFixed, checked: mailLayoutSetting === 'focus' },
+ 'message.reply': desktopSingle,
+ 'message.replyAll': desktopSingle,
+ 'message.forward': desktopSingle,
+ 'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen),
+ 'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen),
+ 'message.star': desktopSingle,
+ 'message.move': desktopSingle,
+ 'message.archive': desktopTarget && archiveActionEnabled,
+ 'message.spam': desktopTarget,
+ 'message.delete': desktopTarget,
+ 'message.openWindow': desktopSingle && !!openEmailInWindow,
+ 'go.sent': true,
+ 'go.drafts': true,
+ 'go.nextMessage': !showComposer,
+ 'go.previousMessage': !showComposer,
+ 'go.nextUnread': !showComposer,
+ }, isAuthenticated && !isEmbedded);
+
// Shared mail refresh: re-fetch mailboxes (unread counts) + the active list
// via JMAP. Used by both the browser refresh gestures and the toolbar button.
const [isManualRefreshing, setIsManualRefreshing] = useState(false);
@@ -2265,7 +2323,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const composerSource = usePinnedPerSession(composerSessionId, selectedEmail ? emailWriteSource(selectedEmail, client) : undefined);
const popoutsOn = usePopoutsAvailable();
- const openEmailInWindow = useOpenEmailInWindow();
+ const desktopDoubleClick = useDesktopPrefs().openOnDoubleClick && hasDesktopWindows() && !isMobile && !!openEmailInWindow;
// Text from a closing pop-out that couldn't be saved: the "Continue draft"
// slot, once it is free.
useEffect(() => {
@@ -2603,17 +2661,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
draft = await emailHooks.onBeforeEditDraft.transform(draft);
- const { body: draftBody, bodyText, plainTextMode: draftPlainTextMode } = getDraftComposerBody(draft);
+ const { bodyText } = getDraftComposerBody(draft);
- // Restore the identity the draft was composed with. Match the saved From
- // (name + address) against the same list the composer renders — the flat
- // cross-account list when multi-account is on — so a draft from a non-active
- // account, or one of two identities sharing an address, is restored rather
- // than reset to the default.
+ // The list the composer renders: the flat cross-account one when multi-account is on.
const composerIdentities = multiAccountIdentities.enabled
? multiAccountIdentities.allIdentities
: identities;
- const matchedIdentityId = findDraftIdentityIdForLogin(composerIdentities, draft.from?.[0], localAccountId);
const draftRecipients = (list: typeof draft.to) => (list ?? []).map(a => a.email).filter(Boolean);
if (!(await withComposeGate('compose', {
@@ -2626,46 +2679,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
setComposerSessionId(id => id + 1);
- setPendingDraft({
- to: draft.to?.map(a => a.email).filter(Boolean).join(', ') || '',
- cc: draft.cc?.map(a => a.email).filter(Boolean).join(', ') || '',
- bcc: draft.bcc?.map(a => a.email).filter(Boolean).join(', ') || '',
- // Drafts saved before #1189 stored an empty subject as the composer's
- // "(No Subject)" placeholder; reopen those with an empty field.
- subject: draft.subject && draft.subject !== t('email_composer.no_subject') ? draft.subject : '',
- body: draftBody,
- // Re-open in the format the draft was written in (#1022).
- plainTextMode: draftPlainTextMode,
- showCc: (draft.cc?.length || 0) > 0,
- showBcc: (draft.bcc?.length || 0) > 0,
- selectedIdentityId: matchedIdentityId,
- subAddressTag: '',
- mode: 'compose',
- draftId: draft.id,
- draftAccountId: accountId,
- draftLocalAccountId: localAccountId,
- // A reply draft re-opens in compose mode; its threading headers must
- // come along or the reply leaves its thread.
- inReplyTo: draft.inReplyTo ?? undefined,
- references: draft.references ?? undefined,
- priority: messagePriorityFromHeaders(draft.headers),
- // A sent message brought back (Undo, Cancel and edit) has no draft
- // marker: its SIO-Label says which label it went out with.
- classification: reopenedLabelChoice(draft.headers, localAccountId),
- // Existing server-side attachments must ride along, or the composer
- // starts empty and the next save/send silently rebuilds the draft
- // without them (#849).
- attachments: (draft.attachments ?? [])
- .filter(a => !!a.blobId)
- .map(a => ({
- blobId: a.blobId,
- name: a.name,
- type: a.type,
- size: a.size,
- cid: a.cid,
- disposition: a.disposition,
- })),
- });
+ setPendingDraft(composerDataFromDraft(draft, {
+ accountId,
+ localAccountId,
+ identities: composerIdentities,
+ noSubject: t('email_composer.no_subject'),
+ }));
setComposerMode('compose');
setShowComposer(true);
if (isMobile) setActiveView('viewer');
@@ -5525,7 +5544,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onEmailSelect={handleEmailSelect}
onOpenSearchResult={handleEmailSelect}
onEmailPrefetch={handleEmailPrefetch}
- onEmailDoubleClick={isEmbedded ? ((email) => {
+ onEmailDoubleClick={!isEmbedded ? (desktopDoubleClick ? (email) => {
+ // A draft goes back in the composer, as it does from a single click.
+ if (email.keywords?.$draft) void handleEditDraft(email);
+ else openEmailInWindow?.(email);
+ } : undefined) : ((email) => {
const store = useProTabStore.getState();
const hasSplit = store.tabs.some((tab) => tab.paneId === 'split');
// In a split, open on the *other* side so this list stays
@@ -5556,7 +5579,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
mailboxId: selectedMailbox,
title: email.subject?.trim() || t('email_composer.new_message'),
}, { pane: targetPane, reuseReader: hasSplit });
- }) : undefined}
+ })}
onOpenConversation={handleOpenConversation}
// Context menu handlers
onReply={(email) => {
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index 97a8c6b67..aeaf0f204 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -34,6 +34,8 @@ import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
import { canDownload } from '@/lib/device-policy';
import { useOpenEmailInWindow } from '@/hooks/use-popouts-available';
import { isFilePreviewable } from '@/lib/file-preview';
+import { useDesktopCommands } from '@/lib/desktop/commands';
+import { isPopoutWindow } from '@/lib/popout/window-role';
import dynamic from 'next/dynamic';
// Only needed when the device policy turns downloads off.
@@ -450,6 +452,29 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
setEmail({ ...email, keywords });
}, [email, setEmailKeywordsLocal]);
+ // In a pop-out this message is all there is, so the menu bar's Message menu means it.
+ const inOwnWindow = typeof window !== 'undefined' && isPopoutWindow();
+ const seen = !!email?.keywords?.$seen;
+ useDesktopCommands({
+ 'message.reply': () => handleReply(),
+ 'message.replyAll': handleReplyAll,
+ 'message.forward': handleForward,
+ 'message.markRead': () => { if (email) void handleMarkAsRead(email.id, true); },
+ 'message.markUnread': () => { if (email) void handleMarkAsRead(email.id, false); },
+ 'message.star': () => void handleToggleStar(),
+ 'message.archive': archiveActionEnabled ? () => void handleArchive() : undefined,
+ 'message.delete': () => void handleDelete(),
+ }, {
+ 'message.reply': !!email,
+ 'message.replyAll': !!email,
+ 'message.forward': !!email,
+ 'message.markRead': !!email && !seen,
+ 'message.markUnread': !!email && seen,
+ 'message.star': !!email,
+ 'message.archive': !!email && archiveActionEnabled,
+ 'message.delete': !!email,
+ }, inOwnWindow);
+
const handleMoveToMailbox = useCallback(async (mailboxId: string) => {
if (!client || !email) return;
try {
diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx
index c22f3dfc9..68922cc82 100644
--- a/components/ui/context-menu.tsx
+++ b/components/ui/context-menu.tsx
@@ -17,6 +17,7 @@ import {
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { ChevronRight } from "@/components/icons";
+import { nativeMenusAvailable, readSettledMenu, showNativeMenu } from "@/lib/desktop/native-menu";
interface Position {
x: number;
@@ -199,6 +200,9 @@ interface MenuLevel {
const MenuLevelContext = createContext
(null);
const neverTouched = () => false;
+/** The desktop app shows this menu natively: it renders hidden, submenus and all, to be read. */
+const NativeMenuContext = createContext(false);
+
function useMenuLevel(recentTouch: () => boolean): MenuLevel {
const [openId, setOpenId] = useState(null);
const timer = useRef | null>(null);
@@ -245,11 +249,45 @@ export const ContextMenu = forwardRef(
const recentTouch = useCallback(() => Date.now() - lastTouchAt.current < 800, []);
const level = useMenuLevel(recentTouch);
const { request: requestLevel } = level;
+ const [nativeRefused, setNativeRefused] = useState(false);
+ const nativeMode = isOpen && mounted && !nativeRefused && nativeMenusAvailable();
+ const positionRef = useRef(position);
+ positionRef.current = position;
useEffect(() => {
setMounted(true);
}, []);
+ useEffect(() => {
+ if (!isOpen) setNativeRefused(false);
+ }, [isOpen]);
+
+ // In the desktop app: read what rendered and show that natively. See lib/desktop/native-menu.ts.
+ useEffect(() => {
+ if (!nativeMode) return;
+ const root = localRef.current;
+ if (!root) return;
+ let cancelled = false;
+ void (async () => {
+ const read = await readSettledMenu(root, () => cancelled);
+ if (cancelled) return;
+ if (read.unsupported) {
+ setNativeRefused(true);
+ return;
+ }
+ const { anchor: at } = positionRef.current;
+ // From a right-click it opens at the pointer, which the OS knows best.
+ const place = at
+ ? { x: at.align === "end" ? Math.max(0, at.right - root.getBoundingClientRect().width) : at.left, y: at.bottom + ANCHOR_GAP }
+ : undefined;
+ await showNativeMenu(read, place);
+ if (!cancelled) onCloseRef.current();
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [nativeMode]);
+
const anchor = position.anchor;
const anchorKey = anchor ? `${anchor.left},${anchor.top},${anchor.right},${anchor.bottom},${anchor.align ?? ""}` : "";
@@ -443,11 +481,11 @@ export const ContextMenu = forwardRef(
// Once placed (it is invisible, and can't take the focus, before), the
// first item gets the focus.
useEffect(() => {
- if (!isOpen || !isPositioned) return;
+ if (!isOpen || !isPositioned || nativeMode) return;
const root = localRef.current;
if (!root || root.contains(document.activeElement)) return;
return focusFirstItem(root);
- }, [isOpen, isPositioned]);
+ }, [isOpen, isPositioned, nativeMode]);
const setRefs = (node: HTMLDivElement | null) => {
localRef.current = node;
@@ -463,6 +501,7 @@ export const ContextMenu = forwardRef(
const renderPosition = adjustedPosition ?? position;
return createPortal(
+
(
left: renderPosition.x,
top: renderPosition.y,
maxWidth: `calc(100vw - ${2 * VIEWPORT_MARGIN}px)`,
- visibility: isPositioned ? "visible" : "hidden",
+ visibility: isPositioned && !nativeMode ? "visible" : "hidden",
}}
role="menu"
aria-label={label}
@@ -493,7 +532,8 @@ export const ContextMenu = forwardRef(
{children}
- ,
+
+ ,
document.body
);
}
@@ -578,11 +618,11 @@ export function ContextMenuItem({
{Icon &&