feat: drag a mail to a new browser tab to open it fullscreen

This commit is contained in:
Linus Rath
2026-08-20 15:55:44 +02:00
parent 65e70a8088
commit fc88fc6d3b
4 changed files with 65 additions and 9 deletions
+32 -7
View File
@@ -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 = {}) {
</div>
{/* Sidebar resize handle (desktop only, hidden when collapsed) */}
{!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && (
{!isMobile && !isTablet && !sidebarCollapsed && !inlineApp && !fullscreenReading && (
<ResizeHandle
onResizeStart={() => { 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 = {}) {
</div>
{/* Email list resize handle (desktop only) */}
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && (
{!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && !fullscreenReading && (
<ResizeHandle
onResizeStart={() => { 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 && (
<ResizeHandle
orientation="horizontal"
onResizeStart={() => { dragStartWidth.current = emailListHeight; setIsResizing(true); }}
+13
View File
@@ -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) {
+14
View File
@@ -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();
+6 -2
View File
@@ -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 };
}