"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useRouter } from "@/lib/routing/client-router"; import { linkToTask } from "@/lib/links"; import { canvasHref, getCanvas, getCanvasRuntime, startCanvasEdit, type Canvas, type CanvasRuntimeResponse, } from "@/lib/api/domains/canvas-api"; import { canvasErrorMessage } from "@/lib/api/domains/canvas-error-copy"; import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle"; import { useAppStore } from "@/components/state-provider"; import { canvasPresentationUserId, recordCanvasPresentation, } from "@/lib/canvas-presentation-storage"; import { useCanvasHostCanvases } from "./canvas-host-picker"; import { type CanvasHostState } from "./canvas-host-components"; import { CanvasHostRouteView } from "./canvas-host-route-view"; function stateForCanvas(canvas: Canvas): CanvasHostState { if (canvas.status === "archived") return "archived"; if ( !canvas.active_release_id && canvas.pending_release?.validation_status === "pending_permission" ) { return "pending_permission"; } if (!canvas.active_release_id) return "pending_first_release"; if (canvas.active_release_status === "pending_permission") return "pending_permission"; if (canvas.active_release_status === "invalid") return "invalid_release"; if (canvas.active_release_status === "unavailable") return "unavailable"; return "loading_runtime"; } function canvasAuthorityChanged(previous: Canvas, next: Canvas): boolean { return ( previous.status !== next.status || previous.plugin_id !== next.plugin_id || previous.plugin_instance_id !== next.plugin_instance_id || previous.scope_kind !== next.scope_kind || previous.workspace_id !== next.workspace_id || previous.task_id !== next.task_id || previous.active_release_id !== next.active_release_id || previous.active_release_status !== next.active_release_status || previous.pending_release?.id !== next.pending_release?.id || previous.pending_release?.validation_status !== next.pending_release?.validation_status || previous.grant_generation !== next.grant_generation ); } function useRuntimeRenewal() { const renewalTimerRef = useRef | null>(null); const renewRuntimeRef = useRef<() => void>(() => undefined); const clearRuntimeRenewal = useCallback(() => { if (renewalTimerRef.current !== null) { clearTimeout(renewalTimerRef.current); renewalTimerRef.current = null; } }, []); const scheduleRuntimeRenewal = useCallback( (expiresInSeconds: number | undefined) => { clearRuntimeRenewal(); if (!Number.isFinite(expiresInSeconds) || !expiresInSeconds || expiresInSeconds <= 0) { return; } const delay = Math.max(100, (expiresInSeconds - 30) * 1000); renewalTimerRef.current = setTimeout(() => { renewalTimerRef.current = null; renewRuntimeRef.current(); }, delay); }, [clearRuntimeRenewal], ); return { clearRuntimeRenewal, scheduleRuntimeRenewal, renewRuntimeRef }; } type CanvasHostRef = { current: T }; type CanvasHostSetter = (value: T | ((current: T) => T)) => void; type CanvasHostSnapshotOptions = { canvasId: string; requestId: number; requestRef: CanvasHostRef; canvasRef: CanvasHostRef; nextCanvas: Canvas; forceRuntimeReset: boolean; setCanvas: CanvasHostSetter; setRuntimeUrl: CanvasHostSetter; setState: CanvasHostSetter; setError: CanvasHostSetter; clearRuntimeRenewal: () => void; applyRuntime: (runtime: CanvasRuntimeResponse) => void; }; function applyCanvasRuntime( runtime: CanvasRuntimeResponse, setRuntimeUrl: CanvasHostSetter, setState: CanvasHostSetter, clearRuntimeRenewal: () => void, scheduleRuntimeRenewal: (expiresInSeconds: number | undefined) => void, ) { if (runtime.runtime_url) { setRuntimeUrl(runtime.runtime_url); setState("loading_runtime"); scheduleRuntimeRenewal(runtime.expires_in_seconds); } else { clearRuntimeRenewal(); setRuntimeUrl(null); setState("unavailable"); } } type CanvasHostErrorOptions = { requestRef: CanvasHostRef; requestId: number; reason: unknown; message: string; clearRuntimeRenewal: () => void; setState: CanvasHostSetter; setError: CanvasHostSetter; }; function applyCanvasHostError(options: CanvasHostErrorOptions) { if (options.requestRef.current !== options.requestId) return; options.clearRuntimeRenewal(); options.setState( typeof navigator !== "undefined" && !navigator.onLine ? "offline" : "unavailable", ); options.setError(options.message || String(options.reason)); } function applyCanvasSnapshot(options: CanvasHostSnapshotOptions): Promise | undefined { if (options.requestRef.current !== options.requestId) return; const previousCanvas = options.canvasRef.current; options.canvasRef.current = options.nextCanvas; options.setCanvas(options.nextCanvas); if ( !options.forceRuntimeReset && previousCanvas && !canvasAuthorityChanged(previousCanvas, options.nextCanvas) ) { return; } options.clearRuntimeRenewal(); options.setRuntimeUrl(null); options.setError(null); const nextState = stateForCanvas(options.nextCanvas); options.setState(nextState); if (nextState !== "loading_runtime") return; return getCanvasRuntime(options.canvasId).then((runtime) => { if (options.requestRef.current !== options.requestId) return; options.applyRuntime(runtime); }); } type CanvasHostRuntimeRequestOptions = { canvasId: string; requestId: number; requestRef: CanvasHostRef; applyRuntime: (runtime: CanvasRuntimeResponse) => void; onError: (requestId: number, reason: unknown) => void; onComplete: () => void; }; function requestCanvasHostRuntime(options: CanvasHostRuntimeRequestOptions) { getCanvasRuntime(options.canvasId) .then((runtime) => { if (options.requestRef.current !== options.requestId) return; options.applyRuntime(runtime); }) .catch((reason: unknown) => options.onError(options.requestId, reason)) .finally(options.onComplete); } type CanvasHostRequestOptions = { setCanvas: CanvasHostSetter; setRuntimeUrl: CanvasHostSetter; setState: CanvasHostSetter; setError: CanvasHostSetter; requestRef: CanvasHostRef; canvasRef: CanvasHostRef; renewingRef: CanvasHostRef; clearRuntimeRenewal: () => void; scheduleRuntimeRenewal: (expiresInSeconds: number | undefined) => void; }; function useCanvasHostRequests(canvasId: string, options: CanvasHostRequestOptions) { const { t } = useTranslation(); const { setCanvas, setRuntimeUrl, setState, setError, requestRef, canvasRef, renewingRef, clearRuntimeRenewal, scheduleRuntimeRenewal, } = options; const applyRuntime = useCallback( (runtime: CanvasRuntimeResponse) => applyCanvasRuntime( runtime, setRuntimeUrl, setState, clearRuntimeRenewal, scheduleRuntimeRenewal, ), [clearRuntimeRenewal, scheduleRuntimeRenewal], ); const handleError = useCallback( (requestId: number, reason: unknown) => applyCanvasHostError({ requestRef, requestId, reason, message: canvasErrorMessage(reason, t, "canvases:loadFailed"), clearRuntimeRenewal, setState, setError, }), [clearRuntimeRenewal, t], ); const applySnapshot = useCallback( (requestId: number, nextCanvas: Canvas, forceRuntimeReset: boolean) => applyCanvasSnapshot({ canvasId, requestId, requestRef, canvasRef, nextCanvas, forceRuntimeReset, setCanvas, setRuntimeUrl, setState, setError, clearRuntimeRenewal, applyRuntime, }), [applyRuntime, canvasId, clearRuntimeRenewal], ); const renewRuntime = useCallback(() => { if (renewingRef.current) return; const requestId = ++requestRef.current; renewingRef.current = true; clearRuntimeRenewal(); setRuntimeUrl(null); setState("loading_runtime"); setError(null); requestCanvasHostRuntime({ canvasId, requestId, requestRef, applyRuntime, onError: handleError, onComplete: () => { renewingRef.current = false; }, }); }, [applyRuntime, canvasId, clearRuntimeRenewal, handleError]); const load = useCallback(() => { const requestId = ++requestRef.current; clearRuntimeRenewal(); canvasRef.current = null; setCanvas(null); setRuntimeUrl(null); setState("loading_metadata"); setError(null); getCanvas(canvasId) .then((nextCanvas) => applySnapshot(requestId, nextCanvas, true)) .catch((reason: unknown) => handleError(requestId, reason)); }, [applySnapshot, canvasId, clearRuntimeRenewal, handleError]); const refresh = useCallback(() => { const requestId = ++requestRef.current; getCanvas(canvasId) .then((nextCanvas) => applySnapshot(requestId, nextCanvas, false)) .catch((reason: unknown) => handleError(requestId, reason)); }, [applySnapshot, canvasId, handleError]); return { load, refresh, renewRuntime }; } function useCanvasHost(canvasId: string) { const [canvas, setCanvas] = useState(null); const [runtimeUrl, setRuntimeUrl] = useState(null); const [state, setState] = useState("loading_metadata"); const [error, setError] = useState(null); const requestRef = useRef(0); const canvasRef = useRef(null); const renewingRef = useRef(false); const { clearRuntimeRenewal, scheduleRuntimeRenewal, renewRuntimeRef } = useRuntimeRenewal(); const lifecycleRevision = useCanvasLifecycleRevision(); const { load, refresh, renewRuntime } = useCanvasHostRequests(canvasId, { setCanvas, setRuntimeUrl, setState, setError, requestRef, canvasRef, renewingRef, clearRuntimeRenewal, scheduleRuntimeRenewal, }); const markRuntimeReady = useCallback(() => { setState((current) => (current === "loading_runtime" ? "ready" : current)); }, []); const markRuntimeUnavailable = useCallback(() => { clearRuntimeRenewal(); setRuntimeUrl(null); setState("unavailable"); }, [clearRuntimeRenewal]); useEffect(() => { renewRuntimeRef.current = renewRuntime; return () => { renewRuntimeRef.current = () => undefined; }; }, [renewRuntime]); useEffect(() => { load(); return () => { requestRef.current += 1; clearRuntimeRenewal(); }; }, [clearRuntimeRenewal, load]); useEffect(() => { if (!canvasRef.current) return; refresh(); }, [lifecycleRevision, refresh]); return { canvas, runtimeUrl, state, error, lifecycleRevision, load, refresh, renewRuntime, markRuntimeReady, markRuntimeUnavailable, setHostError: setError, }; } type CanvasHostEditOptions = { canvas: Canvas | null; router: ReturnType; setEditing: (editing: boolean) => void; setMenuOpen: (open: boolean) => void; onError: (reason: unknown) => void; }; async function editCanvasFromHost(options: CanvasHostEditOptions): Promise { const { canvas, router, setEditing, setMenuOpen, onError } = options; if (!canvas) return; setEditing(true); try { const response = await startCanvasEdit(canvas.id); if (response.task_id) { router.push(linkToTask(response.task_id, { sessionId: response.session_id ?? undefined })); } } catch (reason: unknown) { onError(reason); } finally { setEditing(false); setMenuOpen(false); } } function useRecordCanvasPresentation(canvas: Canvas | null, userId: string | null) { useEffect(() => { if (!canvas || canvas.scope_kind !== "task" || !canvas.task_id || !userId) return; recordCanvasPresentation( { userId, workspaceId: canvas.workspace_id, taskId: canvas.task_id, canvasId: canvas.id, }, "manual", ); }, [canvas, userId]); } export function CanvasHostRoute({ canvasId, embedded = false, }: { canvasId: string; embedded?: boolean; }) { const { t } = useTranslation(); const router = useRouter(); const { isMobile } = useResponsiveBreakpoint(); const presentationUserId = useAppStore((state) => canvasPresentationUserId(state.auth)); const { canvas, runtimeUrl, state, error, load, refresh, markRuntimeReady, markRuntimeUnavailable, setHostError, } = useCanvasHost(canvasId); const hostCanvases = useCanvasHostCanvases(canvas); const [menuOpen, setMenuOpen] = useState(false); const [promotionOpen, setPromotionOpen] = useState(false); const [workspaceDataOpen, setWorkspaceDataOpen] = useState(false); const [releasesOpen, setReleasesOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false); const [renameOpen, setRenameOpen] = useState(false); const [editing, setEditing] = useState(false); useRecordCanvasPresentation(canvas, presentationUserId); const edit = () => editCanvasFromHost({ canvas, router, setEditing, setMenuOpen, onError: (reason) => setHostError(canvasErrorMessage(reason, t, "canvases:actionFailed")), }); const selectCanvas = useCallback( (nextCanvas: Canvas) => { setMenuOpen(false); if (nextCanvas.id !== canvasId) router.push(canvasHref(nextCanvas.id)); }, [canvasId, router], ); return ( void edit()} onPromote={() => setPromotionOpen(true)} onEnableWorkspaceData={() => { setMenuOpen(false); setWorkspaceDataOpen(true); }} onReleases={() => setReleasesOpen(true)} onShare={() => setShareOpen(true)} onRename={() => { setMenuOpen(false); setRenameOpen(true); }} onSelectCanvas={selectCanvas} onRuntimeReady={markRuntimeReady} onRuntimeError={markRuntimeUnavailable} onRetry={load} onPromotionCompleted={() => router.push(canvas ? canvasHref(canvas.id) : "/")} onChanged={refresh} /> ); }