From b646ff0dc1fd8c4957769383648250627754ff93 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:48:05 +0200 Subject: [PATCH] fix: preview attachments in the installed app instead of opening a blank window #865 --- components/email/email-viewer.tsx | 51 +++++++++++-- .../file-preview-modal-standalone.test.tsx | 58 +++++++++++++++ components/files/file-preview-modal.tsx | 5 +- components/files/pdf-mobile-viewer.tsx | 23 +++--- lib/__tests__/standalone-display.test.ts | 72 +++++++++++++++++++ lib/standalone-display.ts | 39 ++++++++++ 6 files changed, 232 insertions(+), 16 deletions(-) create mode 100644 components/files/__tests__/file-preview-modal-standalone.test.tsx create mode 100644 lib/__tests__/standalone-display.test.ts create mode 100644 lib/standalone-display.ts diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 2d018bf88..251c27709 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -116,6 +116,8 @@ import { RecipientPopover } from "./recipient-popover"; import { MailtoLink } from "@/components/ui/mailto-link"; import { requestInternalMailto } from "@/lib/protocol-handlers/mailto"; import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview"; +import { openPreviewInNewWindow } from "@/lib/standalone-display"; +import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { parseTnef, isTnefAttachment } from "@/lib/tnef"; import { debug } from "@/lib/debug"; @@ -1274,7 +1276,26 @@ export function EmailViewer({ // at a part can be routed through the attachment preview/download gate // instead of window.open() (GHSA-xvjh-v9c6-qcvc). A ref because the iframe // click handler is bound once per document. - const cidBlobPartsRef = useRef>(new Map()); + const cidBlobPartsRef = useRef>(new Map()); + // A locally held attachment (TNEF, decrypted, cid: part) shown in the + // in-app preview because an installed app cannot open its blob: URL in a + // new window (#865). + const [inAppPreview, setInAppPreview] = useState<{ name: string; downloadName: string; blob: Blob; contentType: string } | null>(null); + const getInAppPreviewContent = useCallback(async () => { + if (!inAppPreview) throw new Error('No attachment selected'); + return { blob: inAppPreview.blob, contentType: inAppPreview.contentType }; + }, [inAppPreview]); + const downloadInAppPreview = useCallback(() => { + if (!inAppPreview) return; + const url = URL.createObjectURL(inAppPreview.blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = inAppPreview.downloadName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setTimeout(() => URL.revokeObjectURL(url), 60_000); + }, [inAppPreview]); const [quickReplyText, setQuickReplyText] = useState(""); const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false); const [isSendingQuickReply, setIsSendingQuickReply] = useState(false); @@ -2089,7 +2110,7 @@ export function EmailViewer({ const objectUrl = URL.createObjectURL(blob); urls[cidValue] = objectUrl; objectUrls.push(objectUrl); - cidBlobParts.set(objectUrl, { name: att.filename || cidValue, type: att.mimeType }); + cidBlobParts.set(objectUrl, { name: att.filename || cidValue, type: att.mimeType, blob }); }); setCidBlobUrls(urls); @@ -2123,11 +2144,12 @@ export function EmailViewer({ // the Blob type; re-type anything that could execute as our origin // before it becomes a blob: URL (GHSA-xvjh-v9c6-qcvc). const blob = await blobClient!.fetchBlob(att.blobId, att.name || 'inline', att.type, blobAccountId); - const objectUrl = URL.createObjectURL(toInertBlob(blob)); + const inertBlob = toInertBlob(blob); + const objectUrl = URL.createObjectURL(inertBlob); if (!cancelled) { urls[cidValue] = objectUrl; objectUrls.push(objectUrl); - cidBlobParts.set(objectUrl, { name: att.name || cidValue, type: att.type }); + cidBlobParts.set(objectUrl, { name: att.name || cidValue, type: att.type, blob: inertBlob }); } else { URL.revokeObjectURL(objectUrl); } @@ -2494,7 +2516,9 @@ export function EmailViewer({ if (opensPreview) { const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info); - window.open(transformed.previewUrl || objectUrl, '_blank', 'noopener,noreferrer'); + if (!openPreviewInNewWindow(transformed.previewUrl || objectUrl)) { + setInAppPreview({ name: attachment.name || downloadName, downloadName, blob, contentType: blob.type }); + } } else { emailHooks.onAttachmentDownload.emit(info); const anchor = document.createElement('a'); @@ -2524,7 +2548,9 @@ export function EmailViewer({ if (opensPreview) { const transformed = await emailHooks.onAttachmentPreview.transform({ previewUrl: objectUrl } as AttachmentPreview, info); - window.open(transformed.previewUrl || objectUrl, '_blank', 'noopener,noreferrer'); + if (!openPreviewInNewWindow(transformed.previewUrl || objectUrl)) { + setInAppPreview({ name: attachment.name || downloadName, downloadName, blob, contentType: blob.type }); + } } else { emailHooks.onAttachmentDownload.emit(info); const anchor = document.createElement('a'); @@ -2940,7 +2966,9 @@ export function EmailViewer({ && isFilePreviewable(part.name, part.type) && isMimeTypeSafeForInlinePreview(part.type); if (opensPreview) { - window.open(href, '_blank', 'noopener,noreferrer'); + if (!openPreviewInNewWindow(href)) { + setInAppPreview({ name: part.name, downloadName: part.name, blob: part.blob, contentType: part.type || part.blob.type }); + } return; } const anchor = document.createElement('a'); @@ -6332,6 +6360,15 @@ export function EmailViewer({ /> )} + {inAppPreview && ( + setInAppPreview(null)} + onDownload={downloadInAppPreview} + getFileContent={getInAppPreviewContent} + /> + )} + ); } diff --git a/components/files/__tests__/file-preview-modal-standalone.test.tsx b/components/files/__tests__/file-preview-modal-standalone.test.tsx new file mode 100644 index 000000000..72147fcad --- /dev/null +++ b/components/files/__tests__/file-preview-modal-standalone.test.tsx @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; + +vi.mock('next-intl', () => ({ + useTranslations: () => (key: string) => key, +})); + +import { FilePreviewModal } from '../file-preview-modal'; + +// In an installed app (PWA) "open in new tab" hands the blob: URL to the +// system browser, which cannot load it: the button opened a blank window +// (#865). It must not be offered there; the in-app preview and download stay. + +function mockDisplayMode(mode: string) { + vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ + matches: query === `(display-mode: ${mode})`, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }))); +} + +beforeEach(() => { + vi.spyOn(URL, 'createObjectURL').mockImplementation(() => 'blob:test'); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +function renderPng() { + render( + {}} + onDownload={() => {}} + getFileContent={async () => ({ blob: new Blob([new Uint8Array([137, 80, 78, 71])], { type: 'image/png' }), contentType: 'image/png' })} + />, + ); +} + +describe('FilePreviewModal open in new tab', () => { + it('is offered in a browser tab', async () => { + mockDisplayMode('browser'); + renderPng(); + await screen.findByAltText('photo.png'); + expect(screen.getByLabelText('open_in_new_tab')).toBeTruthy(); + }); + + it('is not offered in an installed app, where the image still previews in-app', async () => { + mockDisplayMode('standalone'); + renderPng(); + expect((await screen.findByAltText('photo.png')).getAttribute('src')).toBe('blob:test'); + expect(screen.queryByLabelText('open_in_new_tab')).toBeNull(); + }); +}); diff --git a/components/files/file-preview-modal.tsx b/components/files/file-preview-modal.tsx index c83e2a471..7e8156a9d 100644 --- a/components/files/file-preview-modal.tsx +++ b/components/files/file-preview-modal.tsx @@ -9,6 +9,7 @@ import { getFilePreviewKind, imageBlobUrl, isMimeTypeSafeForInlinePreview, previ import dynamic from "next/dynamic"; import { EmlPreview, type ParsedEml } from "@/components/files/eml-preview"; import { FilePreviewPluginSection } from "@/components/files/file-preview-plugin-section"; +import { canOpenUrlInNewWindow } from "@/lib/standalone-display"; import type { FileResourceView } from "@/lib/plugin-types"; // pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded @@ -274,7 +275,9 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
e.stopPropagation()}>

{name}

- {objectUrl && canOpenInNewTab && ( + {/* An installed app hands the new window to the system browser, + which cannot load our blob: URL (#865): no dead button there. */} + {objectUrl && canOpenInNewTab && canOpenUrlInNewWindow(objectUrl) && ( + {canOpenUrlInNewWindow(url) ? ( + + ) : ( +

{t("preview_error")}

+ )}
)}
diff --git a/lib/__tests__/standalone-display.test.ts b/lib/__tests__/standalone-display.test.ts new file mode 100644 index 000000000..3869bf457 --- /dev/null +++ b/lib/__tests__/standalone-display.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { canOpenUrlInNewWindow, isStandaloneDisplayMode, openPreviewInNewWindow } from '../standalone-display'; + +// An installed app (PWA) hands window.open(url, "_blank") to the system +// browser, which cannot resolve the app's blob: URLs, so attachment previews +// opened that way came up blank (#865). + +function mockDisplayMode(mode: string | null) { + vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ + matches: mode !== null && query === `(display-mode: ${mode})`, + media: query, + addEventListener: () => {}, + removeEventListener: () => {}, + }))); +} + +let openSpy: ReturnType; + +beforeEach(() => { + openSpy = vi.fn(() => null); + vi.stubGlobal('open', openSpy); + mockDisplayMode('browser'); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete (navigator as Navigator & { standalone?: boolean }).standalone; +}); + +describe('isStandaloneDisplayMode', () => { + it('is false in a browser tab', () => { + expect(isStandaloneDisplayMode()).toBe(false); + }); + + it.each(['standalone', 'fullscreen', 'window-controls-overlay'])('is true in %s display mode', (mode) => { + mockDisplayMode(mode); + expect(isStandaloneDisplayMode()).toBe(true); + }); + + it('is true for an iOS home-screen app that only reports navigator.standalone', () => { + mockDisplayMode(null); + (navigator as Navigator & { standalone?: boolean }).standalone = true; + expect(isStandaloneDisplayMode()).toBe(true); + }); +}); + +describe('openPreviewInNewWindow', () => { + it('opens a blob: URL in a new window from a browser tab', () => { + expect(openPreviewInNewWindow('blob:https://mail.example/abc')).toBe(true); + expect(openSpy).toHaveBeenCalledWith('blob:https://mail.example/abc', '_blank', 'noopener,noreferrer'); + }); + + it('does not open a blob: URL in an installed app and tells the caller to preview in-app', () => { + mockDisplayMode('standalone'); + expect(openPreviewInNewWindow('blob:https://mail.example/abc')).toBe(false); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('does not open a blob: URL in an iOS home-screen app', () => { + mockDisplayMode(null); + (navigator as Navigator & { standalone?: boolean }).standalone = true; + expect(openPreviewInNewWindow('blob:https://mail.example/abc')).toBe(false); + expect(openSpy).not.toHaveBeenCalled(); + }); + + it('still opens a web URL (e.g. a plugin viewer) in an installed app', () => { + mockDisplayMode('standalone'); + expect(canOpenUrlInNewWindow('https://viewer.example/doc')).toBe(true); + expect(openPreviewInNewWindow('https://viewer.example/doc')).toBe(true); + expect(openSpy).toHaveBeenCalledWith('https://viewer.example/doc', '_blank', 'noopener,noreferrer'); + }); +}); diff --git a/lib/standalone-display.ts b/lib/standalone-display.ts new file mode 100644 index 000000000..05fbe8961 --- /dev/null +++ b/lib/standalone-display.ts @@ -0,0 +1,39 @@ +/** + * Installed-app (PWA) display modes and what they break. + * + * In an installed web app, window.open(url, "_blank") is handed to the system + * browser: a separate browsing context that cannot resolve the app's blob: + * URLs, so the new window opens blank or errors (#865). Callers that would + * open a blob: URL in a new window ask here first and show the file in-app + * instead. + */ + +type StandaloneNavigator = Navigator & { standalone?: boolean }; + +const APP_DISPLAY_MODES = ["standalone", "fullscreen", "window-controls-overlay"] as const; + +/** Whether the page runs as an installed app rather than in a browser tab. */ +export function isStandaloneDisplayMode(): boolean { + if (typeof window === "undefined") return false; + // iOS Safari home-screen apps report navigator.standalone and (on older + // versions) no display-mode media feature at all. + if ((window.navigator as StandaloneNavigator).standalone === true) return true; + if (typeof window.matchMedia !== "function") return false; + return APP_DISPLAY_MODES.some((mode) => window.matchMedia(`(display-mode: ${mode})`).matches); +} + +/** Whether a new window could load this URL. Only blob: URLs in an installed app cannot. */ +export function canOpenUrlInNewWindow(url: string): boolean { + return !url.startsWith("blob:") || !isStandaloneDisplayMode(); +} + +/** + * Opens a preview URL in a new window when that window can load it. Returns + * false without opening anything when it cannot, so the caller shows the + * file in-app (or downloads it) instead of leaving the user a dead window. + */ +export function openPreviewInNewWindow(url: string): boolean { + if (!canOpenUrlInNewWindow(url)) return false; + window.open(url, "_blank", "noopener,noreferrer"); + return true; +}