mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
fix: preview attachments in the installed app instead of opening a blank window #865
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user