fix: preview attachments in the installed app instead of opening a blank window #865

This commit is contained in:
Linus Rath
2026-10-17 19:17:55 +02:00
parent 5515297857
commit b646ff0dc1
6 changed files with 232 additions and 16 deletions
+44 -7
View File
@@ -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<Map<string, { name: string; type?: string }>>(new Map());
const cidBlobPartsRef = useRef<Map<string, { name: string; type?: string; blob: Blob }>>(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 && (
<FilePreviewModal
name={inAppPreview.name}
onClose={() => setInAppPreview(null)}
onDownload={downloadInAppPreview}
getFileContent={getInAppPreviewContent}
/>
)}
</div>
);
}
@@ -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(
<FilePreviewModal
name="photo.png"
onClose={() => {}}
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();
});
});
+4 -1
View File
@@ -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
<div className="flex items-center justify-between gap-2 pl-[calc(1rem+env(safe-area-inset-left))] pr-[calc(1rem+env(safe-area-inset-right))] pt-[calc(0.75rem+env(safe-area-inset-top))] pb-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-medium truncate">{name}</h3>
<div className="flex items-center gap-2">
{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) && (
<Button
variant="ghost"
size="icon"
+15 -8
View File
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { Loader2, ExternalLink } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { liteMountedAssetUrl } from "@/lib/lite";
import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
// Real inline PDF preview for mobile browsers. Android Chrome / iOS WebKit have
// no native inline PDF viewer, so the desktop <iframe src=blob:> approach
@@ -216,15 +217,21 @@ export function PdfMobileViewer({ url, blob }: { url: string; blob: Blob }) {
{status === "error" && (
// pdf.js couldn't render this document (corrupt/encrypted, worker load
// failure, ...). Offer the OS/native viewer instead of a stuck frame.
// An installed app cannot open the blob: URL in a new window (#865),
// so it only says so; the header's download button still works.
<div className="flex justify-center py-10">
<Button
variant="outline"
size="sm"
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
>
<ExternalLink className="w-4 h-4 me-2" />
{t("open_in_new_tab")}
</Button>
{canOpenUrlInNewWindow(url) ? (
<Button
variant="outline"
size="sm"
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
>
<ExternalLink className="w-4 h-4 me-2" />
{t("open_in_new_tab")}
</Button>
) : (
<p className="text-sm text-destructive">{t("preview_error")}</p>
)}
</div>
)}
<div ref={pagesRef} className="w-full" />
+72
View File
@@ -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<typeof vi.fn>;
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');
});
});
+39
View File
@@ -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;
}