dca3ee3484
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
513 lines
15 KiB
TypeScript
513 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 type { WebAppStartupFailureReason } from "@/components/plugins/web-app-startup";
|
|
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 [runtimeFailureReason, setRuntimeFailureReason] =
|
|
useState<WebAppStartupFailureReason | null>(null);
|
|
|
|
const markRuntimeUnavailable = useCallback(
|
|
(reason: WebAppStartupFailureReason) => {
|
|
clearRuntimeRenewal();
|
|
setRuntimeUrl(null);
|
|
setError(null);
|
|
setRuntimeFailureReason(reason);
|
|
setState("runtime_failed");
|
|
},
|
|
[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,
|
|
runtimeFailureReason,
|
|
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);
|
|
}
|
|
}
|
|
|
|
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,
|
|
runtimeFailureReason,
|
|
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 (
|
|
<CanvasHostRouteView
|
|
canvasId={canvasId}
|
|
embedded={embedded}
|
|
isMobile={isMobile}
|
|
canvas={canvas}
|
|
hostCanvases={hostCanvases}
|
|
runtimeUrl={runtimeUrl}
|
|
state={state}
|
|
error={error}
|
|
runtimeFailureReason={runtimeFailureReason}
|
|
menuOpen={menuOpen}
|
|
promotionOpen={promotionOpen}
|
|
workspaceDataOpen={workspaceDataOpen}
|
|
releasesOpen={releasesOpen}
|
|
shareOpen={shareOpen}
|
|
renameOpen={renameOpen}
|
|
editing={editing}
|
|
setMenuOpen={setMenuOpen}
|
|
setPromotionOpen={setPromotionOpen}
|
|
setWorkspaceDataOpen={setWorkspaceDataOpen}
|
|
setReleasesOpen={setReleasesOpen}
|
|
setShareOpen={setShareOpen}
|
|
setRenameOpen={setRenameOpen}
|
|
onEdit={() => 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}
|
|
/>
|
|
);
|
|
}
|