mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
feat: drag a mail to a new browser tab to open it fullscreen
This commit is contained in:
@@ -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); }}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user