diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index 1263433fb..11a50836e 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -115,6 +115,7 @@ import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { IS_LITE } from "@/lib/lite"; import { findMeetingLink } from "@/lib/event-links"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; @@ -161,6 +162,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const tSidebar = useTranslations("sidebar"); const tOffline = useTranslations("calendar_offline"); useDocumentTitle(tSidebar("calendar")); + useDesktopWindowName(tSidebar("calendar"), { withAccount: true }); const skeletonLabels = useSkeletonLabels(); const tWebcalAction = useTranslations("calendar.webcal_action"); const { demoMode } = useConfig(); diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx index 00e76d684..f59092a32 100644 --- a/components/contacts/contacts-app.tsx +++ b/components/contacts/contacts-app.tsx @@ -60,6 +60,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { ContactsSkeleton, ContactsSkeletonBody } from "@/components/boot-skeleton/contacts-skeleton"; import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels"; import { useRegisterCommands } from "@/lib/command-registry"; @@ -110,6 +111,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps = const tShell = useTranslations("shell"); const tMerge = useTranslations("ux_pim_cimport"); useDocumentTitle(tSidebar("contacts")); + useDesktopWindowName(tSidebar("contacts"), { withAccount: true }); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); diff --git a/components/email/__tests__/composer-own-window.test.tsx b/components/email/__tests__/composer-own-window.test.tsx index 2731b6a23..d6d312db5 100644 --- a/components/email/__tests__/composer-own-window.test.tsx +++ b/components/email/__tests__/composer-own-window.test.tsx @@ -112,6 +112,15 @@ function target(shown = true): ComposerPopOut & { handed: ComposerDraftData[]; c const DRAFT: ComposerDraftData = { to: '', cc: '', bcc: '', subject: 'Old', body: '', showCc: false, showBcc: false, selectedIdentityId: null, subAddressTag: '', mode: 'compose', draftId: null }; +describe('"Move to the main window" in quick compose', () => { + it('shows an arrow going in, not the arrows of leaving full screen', () => { + mount({ popOut: vi.fn(() => target()), popOutToMainLabel: 'Move to the main window' }); + const svg = screen.getByTestId('composer-back-to-main').querySelector('svg'); + expect(svg?.getAttribute('class')).toContain('tabler-icon-login'); + expect(svg?.getAttribute('class')).not.toContain('minimize'); + }); +}); + describe('"Open in new window" on a composer', () => { it('is not there without the host offering it: browsers stay as they are', () => { mount({}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index fc292094a..bbcff311d 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -38,6 +38,7 @@ import { Minimize2, Trash2, ExternalLink, + LogIn, ReplyAll, Reply, Forward, @@ -4682,7 +4683,8 @@ export function EmailComposer({ title={popOutToMainLabel ?? tWindows('open_in_window')} data-testid={popOutToMainLabel ? "composer-back-to-main" : "composer-open-in-window"} > - {popOutToMainLabel ? : } + {/* Into the main window: an arrow going in, not the inward arrows of leaving full screen. */} + {popOutToMainLabel ? : } ); diff --git a/components/files/files-app.tsx b/components/files/files-app.tsx index 8b8786e1a..344d33605 100644 --- a/components/files/files-app.tsx +++ b/components/files/files-app.tsx @@ -51,6 +51,7 @@ import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; import { FilesSkeleton, FilesSkeletonBody } from "@/components/boot-skeleton/files-skeleton"; @@ -69,6 +70,7 @@ export function FilesApp({ linkSegments: routeSegments }: FilesAppProps = {}) { const tSidebar = useTranslations("sidebar"); const tShell = useTranslations("shell"); useDocumentTitle(tSidebar("files")); + useDesktopWindowName(tSidebar("files"), { withAccount: true }); const tDeepLink = useTranslations("deep_link"); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore(); diff --git a/components/layout/window-menu-bar.tsx b/components/layout/window-menu-bar.tsx index 1c39ff3b5..33e8f517e 100644 --- a/components/layout/window-menu-bar.tsx +++ b/components/layout/window-menu-bar.tsx @@ -372,7 +372,7 @@ export function WindowMenuBar() { aria-haspopup={isSub ? "menu" : undefined} aria-expanded={isSub ? opened : undefined} aria-disabled={item.enabled ? undefined : true} - aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform) : undefined} + aria-keyshortcuts={item.accelerator ? menuKeyText(item.accelerator, getDesktopBridge()?.platform, 'en') : undefined} data-menu-line={index} tabIndex={-1} onMouseEnter={(event) => { diff --git a/components/mail/__tests__/compose-plugin-hooks.test.tsx b/components/mail/__tests__/compose-plugin-hooks.test.tsx index c7cb0c74f..8645e3d3e 100644 --- a/components/mail/__tests__/compose-plugin-hooks.test.tsx +++ b/components/mail/__tests__/compose-plugin-hooks.test.tsx @@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { Mailbox } from '@/lib/jmap/types'; import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; import { useEmailStore } from '@/stores/email-store'; import { useSettingsStore } from '@/stores/settings-store'; import { usePluginStore } from '@/stores/plugin-store'; @@ -211,6 +212,40 @@ describe('a mail link from another program in the desktop app', () => { expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); }); + it('files from Send to or Open with go to a compose window too, attached, and the main window shows no composer', async () => { + const { handOffDraft } = await import('@/lib/launch-handoff'); + render(); + const file = new File(['pdf'], 'report.pdf', { type: 'application/pdf' }); + act(() => { handOffDraft({ files: [file], fromOs: true }); }); + await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1)); + expect(desktopWindows.handOver).toHaveBeenCalledWith(expect.objectContaining({ mode: 'compose', initialFiles: [file] })); + await act(() => new Promise((r) => setTimeout(r, 50))); + expect(screen.queryByText('stub composer')).not.toBeInTheDocument(); + }); + + it('with two accounts it opens from the default one, in its own window, with no question', async () => { + const client = useAuthStore.getState().client; + useAuthStore.setState({ getAllConnectedClients: () => new Map([['login', client], ['second', client]]) as never }); + useAccountStore.setState({ + accounts: [ + { id: 'login', email: 'me@example.com', isConnected: true, isDefault: false }, + { id: 'second', email: 'work@example.com', isConnected: true, isDefault: true }, + ], + defaultAccountId: 'second', + } as never); + try { + render(); + await fromOs(); + await waitFor(() => expect(desktopWindows.handOver).toHaveBeenCalledTimes(1)); + expect(desktopWindows.open).toHaveBeenCalledWith('second'); + expect(screen.queryByText('select_account_title')).not.toBeInTheDocument(); + // The main window keeps the account it shows. + expect(useAuthStore.getState().activeAccountId).toBe('login'); + } finally { + useAccountStore.setState({ accounts: [], defaultAccountId: null } as never); + } + }); + it('a link inside the app still opens here', async () => { render(); act(() => { requestInternalMailto('mailto:bob@example.com?subject=Hi'); }); diff --git a/components/mail/__tests__/mail-app-menu-selection.test.tsx b/components/mail/__tests__/mail-app-menu-selection.test.tsx new file mode 100644 index 000000000..9234ef35e --- /dev/null +++ b/components/mail/__tests__/mail-app-menu-selection.test.tsx @@ -0,0 +1,183 @@ +import React from 'react'; +import { act, cleanup, render, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { useAuthStore } from '@/stores/auth-store'; +import { useEmailStore } from '@/stores/email-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { usePluginStore } from '@/stores/plugin-store'; +import { useThemeStore } from '@/stores/theme-store'; +import { useToastStore } from '@/stores/toast-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; +import { resetDesktopCommands, runDesktopCommand } from '@/lib/desktop/commands'; +import { useMoveDialogStore } from '@/components/email/move-to-dialog'; + +// The desktop app's Message menu with several messages selected: each line acts on all +// of them, or isn't offered. Reply to one of seven, or Move for the open one only, acts on +// mail nobody picked (usability pass 2, desktop-p2-3). + +const seen = vi.hoisted(() => ({ sourceEmailId: undefined as string | undefined })); + + +vi.mock('@/components/email/email-composer', async () => { + const { useEffect, useRef, useState } = await import('react'); + const { registerComposeFlush } = await import('@/lib/compose-flush-registry'); + return { + EmailComposer: ({ initialData, onSaveState, onSend, sourceEmailId }: { + initialData?: { subject?: string; mode?: string } | null; + onSaveState: (data: { subject: string; mode?: string }) => void; + onSend: (data: { to: string[]; cc: string[]; bcc: string[]; subject: string; body: string }) => Promise; + sourceEmailId?: string; + }) => { + seen.sourceEmailId = sourceEmailId; + useEffect(() => registerComposeFlush(async () => {}), []); + const [subject, setSubject] = useState(initialData?.subject ?? ''); + const initial = useRef(subject); + const latest = useRef(subject); + latest.current = subject; + useEffect(() => () => { + if (latest.current !== initial.current) onSaveState({ subject: latest.current, mode: initialData?.mode }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ( + <> + setSubject(e.target.value)} /> + + + ); + }, + }; +}); +vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null })); +vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null })); +vi.mock('@/components/search/mv-search-form', () => ({ MvSearchForm: () => null })); +vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () =>
message viewer
})); +vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); +vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); +vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); +vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null })); +vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null })); +vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null })); +vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null })); +vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) })); +vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); +vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] })); +vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) })); +vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} })); +vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] })); +vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} })); +vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false })); +vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} })); +// A compact desktop: rail and folders beside the list, the composer in the pane. +const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: true, isDesktop: true } })); +vi.mock('@/hooks/use-media-query', () => ({ + useDeviceDetection: () => device.current, + useMediaQuery: () => false, + useIsDesktop: () => true, + atLeast: (px: number) => `(width >= ${px}px)`, + PHONE_BELOW: 600, +})); +vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} })); +vi.mock('@/components/email/email-list', () => ({ EmailList: () =>
message list
})); + +const { MailApp } = await import('../mail-app'); + +const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 3, unreadEmails: 0, myRights: { mayRemoveItems: true, mayAddItems: true } } as unknown as Mailbox; +const later = { id: 'later', name: 'Read later', role: null, totalEmails: 0, unreadEmails: 0, myRights: { mayRemoveItems: true, mayAddItems: true } } as unknown as Mailbox; +const message = (id: string) => ({ id, threadId: `t-${id}`, subject: `Subject ${id}`, receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } }) as unknown as Email; +const [a, b, c] = ['a', 'b', 'c'].map(message); + +let reported: Record = {}; +let named: string[] = []; +let batchMoveToMailbox: ReturnType; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request'))); + vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} }); + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} }))); + reported = {}; + named = []; + (window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = { + version: 1, + platform: 'win32', + menu: { setState: (state: Record) => { reported = state; }, onCommand: () => () => {} }, + windows: { setMenuName: (name: string) => { named.push(name); } }, + }; + usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) }); + useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) }); + const client = { + getAccountId: () => 'acc', getUsername: () => 'a@example.org', + getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null, + }; + useAuthStore.setState({ + isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [], + getClientForAccount: () => client as never, + getAllConnectedClients: () => new Map([['login-a', client]]) as never, + }); + useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', proInterface: false } as never); + batchMoveToMailbox = vi.fn().mockResolvedValue(true); + useEmailStore.setState({ + emails: [a, b, c], selectedEmail: a, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox, later], + viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null, + isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '', + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false, + selectedEmailKeys: new Set(['a', 'b', 'c']), unreadSection: null, threadEmailsCache: new Map(), error: null, + fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined), + fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined), + refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined), + fetchEmailContent: vi.fn().mockResolvedValue(undefined), + refreshCurrentMailbox: vi.fn().mockResolvedValue(undefined), + batchMoveToMailbox, + } as never); +}); +afterEach(() => { + cleanup(); + resetDesktopCommands(); + useMoveDialogStore.setState({ request: null }); + delete (window as unknown as { bulwarkDesktop?: unknown }).bulwarkDesktop; + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('the Message menu with three messages selected', () => { + it('offers no Reply, Reply all, Forward or Star, which would act on one of them', async () => { + render(); + await waitFor(() => expect(reported['message.archive']).toBe(true)); + expect(reported['message.reply']).toBe(false); + expect(reported['message.replyAll']).toBe(false); + expect(reported['message.forward']).toBe(false); + expect(reported['message.star']).toBe(false); + expect(reported['message.openWindow']).toBe(false); + expect(reported['message.move']).toBe(true); + }); + + it('Move to… moves all three, and the toast says how many', async () => { + render(); + await waitFor(() => expect(reported['message.move']).toBe(true)); + act(() => { runDesktopCommand('message.move'); }); + const request = useMoveDialogStore.getState().request; + expect(request?.emails.map((email) => email.id)).toEqual(['a', 'b', 'c']); + await act(async () => { request!.onMove('later'); }); + await waitFor(() => expect(batchMoveToMailbox).toHaveBeenCalledWith(expect.anything(), 'later', undefined, expect.any(Array))); + await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('moved_count_to')); + }); + + it('with one message open and none selected, Reply and Move are for that one', async () => { + useEmailStore.setState({ selectedEmailKeys: new Set() } as never); + render(); + await waitFor(() => expect(reported['message.reply']).toBe(true)); + expect(reported['message.move']).toBe(true); + act(() => { runDesktopCommand('message.move'); }); + expect(useMoveDialogStore.getState().request).toBeNull(); + expect(batchMoveToMailbox).not.toHaveBeenCalled(); + }); +}); + +describe('the Window menu name of the main window', () => { + it('is the folder, not the message open in it, so it never reads like the window of that message', async () => { + useEmailStore.setState({ selectedEmailKeys: new Set() } as never); + render(); + await waitFor(() => expect(named.at(-1)).toBe('sidebar.mailboxes.inbox')); + expect(named).not.toContain('Subject a'); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bdc888cfc..ca0f59b53 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -145,7 +145,8 @@ import { deliveryAddresses, resolveComposeAccountEmail } from "@/lib/reply-ident import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities"; import { Filter, X, Mail, MailOpen, PenSquare, CheckSquare, Square, AlertTriangle, ArrowLeft, RefreshCw, Reply, ReplyAll, Forward, Archive, Trash2, FolderInput, ShieldAlert, ShieldCheck, Folder, Upload, Star } from "@/components/icons"; import { useRegisterCommands } from "@/lib/command-registry"; -import { MoveToDialog, MoveToDialogHost } from "@/components/email/move-to-dialog"; +import { MoveToDialog, MoveToDialogHost, useMoveDialogStore } from "@/components/email/move-to-dialog"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { SearchResultCount } from "@/components/email/search-result-count"; import { ResizeHandle } from "@/components/layout/resize-handle"; import { Button } from "@/components/ui/button"; @@ -155,7 +156,7 @@ import { mailTitleContext } from "@/lib/tab-title"; import { usePluginStore } from "@/stores/plugin-store"; import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot"; import { canDownload } from "@/lib/device-policy"; -import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; +import { consumePendingMailto, mailLinkAccountId, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; import { setNotificationReplyOpener } from "@/lib/pwa-notify/page-actions"; import { setMailLinkOpener } from "@/lib/desktop/mail-links"; import { requestTabReply } from "@/lib/pwa-notify/tab-reply"; @@ -1366,7 +1367,26 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // 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; + // With several selected, a menu line acts on all of them or isn't offered: Reply to one + // of seven, or Move for the open one only, would act on mail nobody picked. + const desktopMulti = selectedEmailKeys.size > 1; + const desktopSingle = !showComposer && !!selectedEmail && !desktopMulti; + const tListExtra = useTranslations('mail_list_extra'); + /** Message > Move to… on the selection, as the selection bar's Move does: one toast with the count. */ + const moveSelectionTo = async (mailboxId: string) => { + if (!client) return; + const count = selectedEmailKeys.size; + const restore = restoreBatchesForSelection(client); + const receipts: CrossAccountMoveReceipt[] = []; + const destination = mailboxes.find((m) => m.id === mailboxId); + await runBatchEmailAction(() => useEmailStore.getState().batchMoveToMailbox(client, mailboxId, undefined, receipts), { + success: destination + ? tListExtra('moved_count_to', { count, mailbox: getMailboxPath(destination, mailboxes) }) + : tNotifications('emails_moved', { count }), + error: tNotifications('move_failed'), + undo: undoMoveAction(client, restore, undo, receipts), + }); + }; const mailLayoutSetting = useSettingsStore((s) => s.mailLayout); const layoutFixed = interfaceLayout === 'mountain-view'; // A shared folder you may only read from: nothing can leave it. @@ -1384,7 +1404,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { 'message.markRead': () => void keyboardHandlers.onMarkAsRead?.(), 'message.markUnread': () => void keyboardHandlers.onMarkAsUnread?.(), 'message.star': () => void keyboardHandlers.onToggleStar?.(), - 'message.move': () => { if (selectedEmail) setMoveDialogEmail(selectedEmail); }, + 'message.move': () => { + if (shortcutsTargetSelection) { + const state = useEmailStore.getState(); + const picked = resolveSelectedEmails({ emails: state.emails, threadEmailsCache: state.threadEmailsCache, selectedEmailKeys }); + if (picked.length > 0) useMoveDialogStore.getState().open({ emails: picked, onMove: (mailboxId) => void moveSelectionTo(mailboxId) }); + } else if (selectedEmail) { + setMoveDialogEmail(selectedEmail); + } + }, 'message.archive': keyboardHandlers.onArchive ? () => void keyboardHandlers.onArchive?.() : undefined, 'message.spam': () => void keyboardHandlers.onToggleSpam?.(), 'message.delete': () => void keyboardHandlers.onDelete?.(), @@ -1408,7 +1436,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { 'message.markRead': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && selectedEmail.keywords?.$seen), 'message.markUnread': desktopTarget && !(selectedEmail && selectedEmailKeys.size === 0 && !selectedEmail.keywords?.$seen), 'message.star': desktopSingle, - 'message.move': desktopSingle && desktopMayRemove, + 'message.move': desktopTarget && desktopMayRemove, 'message.archive': desktopTarget && archiveActionEnabled && desktopMayRemove, 'message.spam': desktopTarget && desktopMayRemove, 'message.delete': desktopTarget && desktopMayRemove, @@ -1497,6 +1525,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle }); }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]); useDocumentTitle(titleContext); + // The Window menu names this window by its folder, never by the open message (desktop-p2-5). + const windowView = useMemo(() => { + const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined; + return mailbox ? localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`)) : t('sidebar.mail'); + }, [selectedMailbox, mailboxes, t]); + useDesktopWindowName(windowView, { withAccount: true }); // When this page is rendered inside the Pro shell as the Mail tab body, // we hoist every "show composer" intent into its own Pro tab and reset @@ -1682,8 +1716,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // A mail link from another program, in the desktop app: a compose window of its own, // as Outlook and Thunderbird do, and the main window stays where it was. Plugins get // their say first. False when it has to open here instead. - const openMailtoInWindow = useCallback(async (pending: DraftRequest): Promise => { - if (!pending.fromOs || pending.initialFiles?.length || !hasDesktopWindows() || !popoutsAvailable()) return false; + const openMailtoInWindow = useCallback(async (pending: DraftRequest, accountId?: string): Promise => { + if (!pending.fromOs || !hasDesktopWindows() || !popoutsAvailable()) return false; let prefill: ComposePrefill | null = null; if (emailHooks.onBeforeCompose.size > 0) { prefill = await runBeforeCompose(composeOptionsFor('compose', { @@ -1695,12 +1729,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // A plugin said no: nothing opens, here or there. if (!prefill) return true; } - const opened = openComposerWindow(useAuthStore.getState().activeAccountId); + // The account the link goes out from, in its own window: the main window keeps the one it shows. + const opened = openComposerWindow(accountId ?? useAuthStore.getState().activeAccountId); if (!opened) return false; void opened.handOver({ mode: 'compose', initialData: mailtoComposerDraft(pending, useSettingsStore.getState().plainTextMode), prefill: prefill && Object.keys(prefill).length > 0 ? prefill : null, + // Send to and Open with: the files go along (desktop-p2-6). + ...(pending.initialFiles?.length ? { initialFiles: pending.initialFiles } : {}), }); return true; }, [identities, t]); @@ -1732,6 +1769,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }, [openMailtoInWindow, openMailtoHere]); const openMailtoForAccount = useCallback(async (pending: DraftRequest, accountId: string) => { + if (await openMailtoInWindow(pending, accountId)) { + setPendingMailtoAccountChoice(null); + return; + } setIsProtocolAccountSwitching(true); try { if (useAuthStore.getState().activeAccountId !== accountId) { @@ -1742,7 +1783,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { } finally { setIsProtocolAccountSwitching(false); } - }, [openMailtoDraft, switchAccount]); + }, [openMailtoDraft, openMailtoInWindow, switchAccount]); // onMailtoIntercept: a plugin may stop a mailto: from opening the composer. const mailtoAllowed = useCallback(async (pending: ParsedMailto & { href?: string }): Promise => { @@ -1755,17 +1796,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { }); }, []); - // A draft that comes from outside: which account it goes out from. + // A draft that comes from outside goes out from the default account, as in Outlook and + // Thunderbird, with no question: the composer's From changes it (desktop-p2-8). const routeOutsideDraft = useCallback(async (pending: DraftRequest): Promise => { - const protocolAccounts = getMailtoProtocolAccounts(); - if (protocolAccounts.length > 1) { - // The question is asked in this window: bring it up. - if (pending.fromOs) getDesktopBridge()?.windows?.focus?.(); - setPendingMailtoAccountChoice(pending); - return; - } - - const accountId = protocolAccounts[0]?.id ?? activeAccountId; + const accountId = mailLinkAccountId(getMailtoProtocolAccounts(), useAccountStore.getState().defaultAccountId, activeAccountId); if (accountId) { await openMailtoForAccount(pending, accountId); return; @@ -1823,6 +1857,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { body: handoff.body ?? '', initialFiles: handoff.files, unedited: true, + ...(handoff.fromOs ? { fromOs: true } : {}), }); }; diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx index 5f8f5433b..3670f4c83 100644 --- a/components/popout/popout-compose-app.tsx +++ b/components/popout/popout-compose-app.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer"; import { ErrorBoundary, ComposerErrorFallback } from "@/components/error"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; @@ -313,6 +314,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: { const [sessionKey] = useState(() => `popout-${handoffId ?? Date.now().toString(36)}`); const mode = payload.initialData?.mode ?? payload.mode; + // The Window menu names a composer by its subject, or "New message" before there is one. + const [subject, setSubject] = useState(payload.initialData?.subject ?? ""); + useDesktopWindowName(subject.trim() || t("email_composer.new_message")); useDocumentTitle( mode === "reply" ? t("email_composer.reply") : mode === "replyAll" ? t("email_composer.reply_all") @@ -512,6 +516,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: { replyTo={payload.replyTo} initialDraftText={payload.initialDraftText} initialData={payload.initialData} + initialFiles={payload.initialFiles ?? null} prefill={payload.prefill} sessionKey={sessionKey} sourceEmailId={payload.sourceEmailId ?? undefined} @@ -523,6 +528,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: { ownWindow={ownWindow} popOut={mainHoldsLogin ? backToMain : undefined} popOutToMainLabel={mainHoldsLogin ? tQuick("back_to_main") : undefined} + onSubjectChange={setSubject} className="min-h-0 flex-1" /> diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx index 0c68ce71a..80d81fb16 100644 --- a/components/popout/popout-message-app.tsx +++ b/components/popout/popout-message-app.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { ProEmailView } from "@/components/pro/pro-email-tab-body"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { useDesktopWindowName } from "@/lib/desktop/window-name"; import { useAuthStore } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -105,6 +106,8 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email const [subject, setSubject] = useState(null); const subjectInTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled("tabTitleSubjectEnabled")); useDocumentTitle(subjectInTitle ? subject : null); + // Kept out where the organisation keeps subjects out of titles: the shell then says "Message". + useDesktopWindowName(subjectInTitle ? subject : null); // Folders first: without them Delete has no Trash to move to, Archive has no // button yet, and the toolbar moves under the pointer once they arrive. diff --git a/components/protocol/protocol-account-picker.tsx b/components/protocol/protocol-account-picker.tsx index 9bc672a4a..bb594ce9e 100644 --- a/components/protocol/protocol-account-picker.tsx +++ b/components/protocol/protocol-account-picker.tsx @@ -167,7 +167,7 @@ export function ProtocolAccountPicker({ {t("switching_account")} ) : ( - {t("select_account_note")} + )}