mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useLocale, useTranslations } from "next-intl";
|
|
import { Loader2, X } from "@/components/icons";
|
|
import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
|
|
import { apiFetch } from "@/lib/browser-navigation";
|
|
import { useCloseOnBack } from "@/hooks/use-close-on-back";
|
|
|
|
/** What the overlay opens: a Files node, or a mail attachment (read-only, #1047). */
|
|
export type WopiDocument =
|
|
| { kind: "file"; id: string; name: string }
|
|
| { kind: "attachment"; blobId: string; name: string; type?: string; size?: number };
|
|
|
|
interface WopiEditorProps {
|
|
target: WopiDocument;
|
|
/** JMAP account holding the node/blob (multi-account and shared contexts). */
|
|
accountId?: string | null;
|
|
/**
|
|
* Cookie slot of the login that owns the document. Defaults to the active
|
|
* account; the unified inbox passes the slot of the message's own login.
|
|
*/
|
|
slot?: number | null;
|
|
onClose: () => void;
|
|
/** Back closes the editor (Files); Mail keeps its own history. */
|
|
closeOnBack?: boolean;
|
|
}
|
|
|
|
interface LaunchData {
|
|
url: string;
|
|
accessToken: string;
|
|
accessTokenTtl: number;
|
|
readOnly: boolean;
|
|
}
|
|
|
|
/**
|
|
* Full-screen WOPI editor overlay (#425). The editor (Collabora Online,
|
|
* OnlyOffice/EuroOffice, ...) is launched the way the WOPI spec prescribes:
|
|
* a form POST of the access token to the editor URL, targeted at an iframe.
|
|
* Mail attachments open in the same overlay as a read-only viewer (#1047).
|
|
*/
|
|
export function WopiEditor({ target, accountId, slot, onClose, closeOnBack = false }: WopiEditorProps) {
|
|
const t = useTranslations("files");
|
|
useCloseOnBack(onClose, closeOnBack);
|
|
const locale = useLocale();
|
|
const [launch, setLaunch] = useState<LaunchData | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [frameLoaded, setFrameLoaded] = useState(false);
|
|
const formRef = useRef<HTMLFormElement>(null);
|
|
const submittedRef = useRef(false);
|
|
// Launch once per document; the object itself is rebuilt on every render.
|
|
const launchBody = JSON.stringify(
|
|
target.kind === "attachment"
|
|
? { blobId: target.blobId, name: target.name, type: target.type, size: target.size, accountId: accountId || undefined, lang: locale }
|
|
: { fileId: target.id, accountId: accountId || undefined, lang: locale },
|
|
);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
try {
|
|
const slotHeaders: Record<string, string> =
|
|
typeof slot === "number" ? { "X-JMAP-Cookie-Slot": String(slot) } : getActiveAccountSlotHeaders();
|
|
const res = await apiFetch("/api/wopi/launch", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...slotHeaders },
|
|
body: launchBody,
|
|
});
|
|
if (!res.ok) throw new Error(`launch failed (${res.status})`);
|
|
const data = (await res.json()) as LaunchData;
|
|
if (!cancelled) setLaunch(data);
|
|
} catch {
|
|
if (!cancelled) setError(t("office_error"));
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [launchBody, slot, t]);
|
|
|
|
// Submit the launch form exactly once, after it is in the DOM.
|
|
useEffect(() => {
|
|
if (launch && formRef.current && !submittedRef.current) {
|
|
submittedRef.current = true;
|
|
formRef.current.submit();
|
|
}
|
|
}, [launch]);
|
|
|
|
// Close on Escape and when the editor posts a close message
|
|
// (Collabora sends {"MessageId":"close"} / UI_Close via postMessage).
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onClose();
|
|
};
|
|
const onMessage = (e: MessageEvent) => {
|
|
if (typeof e.data !== "string") return;
|
|
try {
|
|
const msg = JSON.parse(e.data) as { MessageId?: string };
|
|
if (msg.MessageId === "close" || msg.MessageId === "UI_Close") onClose();
|
|
} catch {
|
|
// Not a WOPI post message - ignore.
|
|
}
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
window.addEventListener("message", onMessage);
|
|
return () => {
|
|
window.removeEventListener("keydown", onKey);
|
|
window.removeEventListener("message", onMessage);
|
|
};
|
|
}, [onClose]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[100] bg-background flex flex-col">
|
|
<div className="flex items-center justify-between gap-3 px-4 h-12 border-b border-border shrink-0">
|
|
<span className="text-sm font-medium text-foreground truncate">{target.name}</span>
|
|
<button
|
|
onClick={onClose}
|
|
aria-label={t("office_close")}
|
|
className="p-1.5 rounded-md hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="relative flex-1 min-h-0">
|
|
{error ? (
|
|
<div className="absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-muted-foreground">
|
|
{error}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{!frameLoaded && (
|
|
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
{t("office_loading")}
|
|
</div>
|
|
)}
|
|
<iframe
|
|
name="wopi-editor-frame"
|
|
title={target.name}
|
|
className="w-full h-full border-0"
|
|
onLoad={() => setFrameLoaded(true)}
|
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads allow-modals"
|
|
referrerPolicy="no-referrer"
|
|
allow="clipboard-read; clipboard-write"
|
|
/>
|
|
{launch && (
|
|
<form
|
|
ref={formRef}
|
|
action={launch.url}
|
|
method="POST"
|
|
target="wopi-editor-frame"
|
|
hidden
|
|
>
|
|
<input name="access_token" value={launch.accessToken} type="hidden" readOnly />
|
|
<input name="access_token_ttl" value={String(launch.accessTokenTtl)} type="hidden" readOnly />
|
|
</form>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|