import { useCallback, useEffect, useRef, useState } from 'react' import { ProgressBar } from 'react-aria-components' import { api } from '../api/client' interface WopiTokenResponse { access_token: string access_token_ttl: number editor_url: string | null } interface CollaboraEditorProps { fileId: string fileName: string mimetype: string onClose?: () => void onSaveStatus?: (saving: boolean) => void } const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000 // refresh 5 min before expiry export default function CollaboraEditor({ fileId, fileName: _fileName, mimetype: _mimetype, onClose, onSaveStatus, }: CollaboraEditorProps) { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [wopiData, setWopiData] = useState(null) const [saveStatus, setSaveStatus] = useState(null) const formRef = useRef(null) const iframeRef = useRef(null) const refreshTimerRef = useRef | null>(null) const collaboraOriginRef = useRef('*') // Fetch WOPI token const fetchToken = useCallback(async () => { try { const data = await api.post('/wopi/token', { file_id: fileId }) if (data.editor_url) { try { collaboraOriginRef.current = new URL(data.editor_url).origin } catch { /* keep wildcard */ } } setWopiData(data) return data } catch (err) { setError(err instanceof Error ? err.message : 'Failed to get editor token') return null } }, [fileId]) // Schedule token refresh const scheduleTokenRefresh = useCallback( (tokenTtl: number) => { if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current) } const ttlMs = tokenTtl - Date.now() const refreshInMs = Math.max(ttlMs - TOKEN_REFRESH_MARGIN_MS, 0) refreshTimerRef.current = setTimeout(async () => { const data = await fetchToken() if (data && iframeRef.current?.contentWindow) { // Send new token to Collabora iframe via postMessage iframeRef.current.contentWindow.postMessage( JSON.stringify({ MessageId: 'Action_ResetAccessToken', Values: { token: data.access_token, token_ttl: String(data.access_token_ttl), }, }), collaboraOriginRef.current, ) scheduleTokenRefresh(data.access_token_ttl) } }, refreshInMs) }, [fetchToken], ) // Fetch token on mount useEffect(() => { let cancelled = false fetchToken().then((data) => { if (!cancelled && data) { scheduleTokenRefresh(data.access_token_ttl) } }) return () => { cancelled = true if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current) } } }, [fetchToken, scheduleTokenRefresh]) // Submit form to iframe AFTER React has committed both the form and iframe to the DOM. // This useEffect fires when wopiData changes — by that point refs are assigned. // If we submit before the iframe with name="collabora_frame" is in the DOM, // the browser opens the POST in the main window and navigates away from the SPA. useEffect(() => { if (wopiData?.editor_url && formRef.current && iframeRef.current) { formRef.current.submit() } }, [wopiData]) // PostMessage listener for Collabora communication useEffect(() => { const handleMessage = (event: MessageEvent) => { // Validate origin — only accept messages from Collabora if (collaboraOriginRef.current !== '*' && event.origin !== collaboraOriginRef.current) return let data: { MessageId?: string; Values?: Record } try { data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data } catch { return } if (!data || !data.MessageId) return switch (data.MessageId) { case 'App_LoadingStatus': if (data.Values?.Status === 'Document_Loaded') { setLoading(false) // Focus the iframe once the document is loaded iframeRef.current?.focus() } break case 'UI_Close': onClose?.() break case 'Action_Save_Resp': onSaveStatus?.(false) setSaveStatus('All changes saved') break case 'Action_Save': onSaveStatus?.(true) setSaveStatus('Saving...') break } } window.addEventListener('message', handleMessage) return () => window.removeEventListener('message', handleMessage) }, [onClose, onSaveStatus]) if (error) { return (

Failed to load editor

{error}

) } return (
{loading && (
Loading editor...
)} {/* Save status live region */}
{saveStatus}
{wopiData && wopiData.editor_url && (
)}