Files
kandev/apps/web/components/settings/canvas-host-route.tsx
T

490 lines
15 KiB
TypeScript

"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<ReturnType<typeof setTimeout> | 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<T> = { current: T };
type CanvasHostSetter<T> = (value: T | ((current: T) => T)) => void;
type CanvasHostSnapshotOptions = {
canvasId: string;
requestId: number;
requestRef: CanvasHostRef<number>;
canvasRef: CanvasHostRef<Canvas | null>;
nextCanvas: Canvas;
forceRuntimeReset: boolean;
setCanvas: CanvasHostSetter<Canvas | null>;
setRuntimeUrl: CanvasHostSetter<string | null>;
setState: CanvasHostSetter<CanvasHostState>;
setError: CanvasHostSetter<string | null>;
clearRuntimeRenewal: () => void;
applyRuntime: (runtime: CanvasRuntimeResponse) => void;
};
function applyCanvasRuntime(
runtime: CanvasRuntimeResponse,
setRuntimeUrl: CanvasHostSetter<string | null>,
setState: CanvasHostSetter<CanvasHostState>,
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<number>;
requestId: number;
reason: unknown;
message: string;
clearRuntimeRenewal: () => void;
setState: CanvasHostSetter<CanvasHostState>;
setError: CanvasHostSetter<string | null>;
};
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<void> | 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<number>;
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<Canvas | null>;
setRuntimeUrl: CanvasHostSetter<string | null>;
setState: CanvasHostSetter<CanvasHostState>;
setError: CanvasHostSetter<string | null>;
requestRef: CanvasHostRef<number>;
canvasRef: CanvasHostRef<Canvas | null>;
renewingRef: CanvasHostRef<boolean>;
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<Canvas | null>(null);
const [runtimeUrl, setRuntimeUrl] = useState<string | null>(null);
const [state, setState] = useState<CanvasHostState>("loading_metadata");
const [error, setError] = useState<string | null>(null);
const requestRef = useRef(0);
const canvasRef = useRef<Canvas | null>(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<typeof useRouter>;
setEditing: (editing: boolean) => void;
setMenuOpen: (open: boolean) => void;
onError: (reason: unknown) => void;
};
async function editCanvasFromHost(options: CanvasHostEditOptions): Promise<void> {
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);
}
}
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 [releasesOpen, setReleasesOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [renameOpen, setRenameOpen] = useState(false);
const [editing, setEditing] = useState(false);
useEffect(() => {
if (!canvas || canvas.scope_kind !== "task" || !canvas.task_id || !presentationUserId) return;
recordCanvasPresentation(
{
userId: presentationUserId,
workspaceId: canvas.workspace_id,
taskId: canvas.task_id,
canvasId: canvas.id,
},
"manual",
);
}, [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 (
<CanvasHostRouteView
canvasId={canvasId}
embedded={embedded}
isMobile={isMobile}
canvas={canvas}
hostCanvases={hostCanvases}
runtimeUrl={runtimeUrl}
state={state}
error={error}
menuOpen={menuOpen}
promotionOpen={promotionOpen}
releasesOpen={releasesOpen}
shareOpen={shareOpen}
renameOpen={renameOpen}
editing={editing}
setMenuOpen={setMenuOpen}
setPromotionOpen={setPromotionOpen}
setReleasesOpen={setReleasesOpen}
setShareOpen={setShareOpen}
setRenameOpen={setRenameOpen}
onEdit={() => void edit()}
onPromote={() => setPromotionOpen(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}
/>
);
}