diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 882fe3586..4ab35fe44 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -593,15 +593,24 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { // what the user is looking at. Suppressed inside the Pro shell: there the // route is /pro and the tab strip owns the URL. const proInterfaceActive = useProInterfaceActive(); + // Fullscreen reading (#733): a `?view=fullscreen` message permalink - what a + // mail dragged out into a new browser tab opens as - shows the message + // alone, with the folder sidebar and list panes hidden. Cleared when the + // message is closed, which reveals the normal layout. + const [fullscreenReading, setFullscreenReading] = useState(false); + const buildMailUrl = useCallback((state: NavSnapshot) => { if (isEmbedded || proInterfaceActive) return null; - return appPath( + const path = appPath( buildMailPath( { mailboxId: state.mailboxId, emailId: state.emailId, threadId: state.threadId }, useEmailStore.getState().mailboxes, ), ); - }, [isEmbedded, proInterfaceActive]); + // Keep the marker while fullscreen is active, so reloading the dragged-out + // tab reopens the message fullscreen instead of in the normal layout. + return fullscreenReading && state.emailId ? `${path}?view=fullscreen` : path; + }, [isEmbedded, proInterfaceActive, fullscreenReading]); useBrowserNavigation({ mailboxId: selectedMailbox, @@ -1309,6 +1318,12 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { selectEmail(email); } } + if (link.fullscreen && !isMobile) { + // The owning-folder branch above may have skipped selecting (the + // folder was already current) - fullscreen needs the message open. + selectEmail(email); + setFullscreenReading(true); + } if (isMobile) setActiveView('viewer'); if (isTablet) setTabletListVisible(false); } finally { @@ -1361,6 +1376,14 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { }); }, [isEmbedded]); + // Leaving fullscreen reading: once nothing is open in the viewer any more + // (message closed, composer closed), fall back to the normal layout. + useEffect(() => { + if (fullscreenReading && !selectedEmail && !showComposer && !conversationThread) { + setFullscreenReading(false); + } + }, [fullscreenReading, selectedEmail, showComposer, conversationThread]); + useEffect(() => { if (deepLinkHandledRef.current) return; if (!isAuthenticated || !client) return; @@ -3288,7 +3311,8 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { // Desktop: normal flow "lg:relative lg:translate-x-0" ), - inlineApp && "hidden" + inlineApp && "hidden", + fullscreenReading && "hidden" )} style={!isMobile && !isTablet ? { width: sidebarCollapsed ? 48 : sidebarWidth } : undefined} > @@ -3336,7 +3360,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { {/* Sidebar resize handle (desktop only, hidden when collapsed) */} - {!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && ( + {!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && !fullscreenReading && ( { dragStartWidth.current = sidebarWidth; setIsResizing(true); }} onResize={(delta) => setSidebarWidth(dragStartWidth.current + delta)} @@ -3369,7 +3393,8 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { isHorizontalMailLayout && !shouldHideHorizontalViewerPane && "md:shadow-[0_8px_12px_-6px_rgba(0,0,0,0.18)] dark:md:shadow-[0_8px_14px_-6px_rgba(0,0,0,0.55)]", !isHorizontalMailLayout && "md:shadow-sm", !isResizing && "transition-all duration-200 ease-out", - shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-e-0" + shouldCollapseListPane && "md:w-0 md:opacity-0 md:overflow-hidden md:border-e-0", + fullscreenReading && "hidden" )} style={ isMobile @@ -3770,7 +3795,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { {/* Email list resize handle (desktop only) */} - {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && ( + {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && !fullscreenReading && ( { dragStartWidth.current = emailListWidth; setIsResizing(true); }} onResize={(delta) => setEmailListWidth(dragStartWidth.current + delta)} @@ -3778,7 +3803,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) { onDoubleClick={resetEmailListWidth} /> )} - {!isMobile && !isTablet && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && ( + {!isMobile && !isTablet && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && !fullscreenReading && ( { dragStartWidth.current = emailListHeight; setIsResizing(true); }} diff --git a/hooks/use-email-drag.ts b/hooks/use-email-drag.ts index 122e9a778..764e962f6 100644 --- a/hooks/use-email-drag.ts +++ b/hooks/use-email-drag.ts @@ -8,6 +8,7 @@ import { useAuthStore } from "@/stores/auth-store"; import { useDragDropContext } from "@/contexts/drag-drop-context"; import { useUIStore } from "@/stores/ui-store"; import { isDragOutSupported } from "@/hooks/use-attachment-drag"; +import { appUrl, buildMailPath } from "@/lib/deep-links"; import { bundleExportFilename, DEFAULT_BUNDLE_TEMPLATE, @@ -223,6 +224,18 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD "text/plain", emailsToDrag.map(em => em.subject || "(no subject)").join(", ") ); + // Dropped on the browser's tab strip or address bar, text/uri-list wins + // over text/plain - the message permalink opens instead of a search for + // the subject text. One URL per message (RFC 2483 CRLF-separated); + // browsers open the first on a tab-strip drop. In-app drop targets key on + // application/x-email-ids and ignore this entry. `view=fullscreen` opens + // the message alone, without the folder sidebar and list. + e.dataTransfer.setData( + "text/uri-list", + emailsToDrag + .map(em => `${appUrl(buildMailPath({ mailboxId: null, emailId: em.id, threadId: null }))}?view=fullscreen`) + .join("\r\n") + ); // Drag-out to file explorer. if (dragOutEnabled && client) { diff --git a/lib/__tests__/deep-links.test.ts b/lib/__tests__/deep-links.test.ts index 48e2442bd..6c0bb17ad 100644 --- a/lib/__tests__/deep-links.test.ts +++ b/lib/__tests__/deep-links.test.ts @@ -136,6 +136,20 @@ describe('mail paths', () => { expect(parseMailPath(['thread', 't1'])).toEqual({ kind: 'thread', id: 't1', accountId: undefined }); }); + it('reads the fullscreen marker on message links', () => { + expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=fullscreen'))).toEqual({ + kind: 'message', + id: 'm1', + accountId: undefined, + fullscreen: true, + }); + expect(parseMailPath(['message', 'm1'], new URLSearchParams('view=other'))).toEqual({ + kind: 'message', + id: 'm1', + accountId: undefined, + }); + }); + it('falls back to the bare mail path with nothing selected', () => { expect(buildMailPath({ mailboxId: null, emailId: null, threadId: null })).toBe('/mail'); expect(parseMailPath([])).toBeNull(); diff --git a/lib/deep-links.ts b/lib/deep-links.ts index acf136f1b..72a4f1b23 100644 --- a/lib/deep-links.ts +++ b/lib/deep-links.ts @@ -116,7 +116,7 @@ const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'j export type MailDeepLink = | { kind: 'folder'; ref: string; accountId?: string } - | { kind: 'message'; id: string; accountId?: string } + | { kind: 'message'; id: string; accountId?: string; fullscreen?: boolean } | { kind: 'thread'; id: string; accountId?: string }; export interface MailLinkState { @@ -193,12 +193,16 @@ export function parseMailPath( search?: URLSearchParams, ): MailDeepLink | null { const accountId = search?.get('account') ?? undefined; + // `?view=fullscreen` asks for the message alone, no sidebar or list - what + // a mail dragged out into a new browser tab opens as. The Pro shell always + // opens message links as fullscreen email tabs and ignores the flag. + const fullscreen = search?.get('view') === 'fullscreen' || undefined; const [kind, value] = segments; if (kind && value) { const id = decodeSegment(value); if (id) { - if (kind === 'message') return { kind: 'message', id, accountId }; + if (kind === 'message') return { kind: 'message', id, accountId, fullscreen }; if (kind === 'thread') return { kind: 'thread', id, accountId }; if (kind === 'folder') return { kind: 'folder', ref: id, accountId }; }