import { useEffect, useMemo, useState } from "react"; import type { RetainedArtifactMetadata, RetainedArtifactReference } from "@opengeni/sdk"; import { isRetainedImageContentType, useRetainedImageObjectUrl } from "../../../artifacts"; import { createWorkspaceRetainedArtifactLoader, useLightboxOptional } from "../../../timeline"; import { useAppContext } from "../../app-context"; import { Button } from "../../../ui/button"; import { Link } from "../../lib/navigation"; import { DeferredChatMedia } from "../../../artifacts"; import { cachedRetainedArtifactLoader, cachedRetainedMetadata, retainedImageCache, retainedImageScope, } from "./retained-image-cache"; const CHAT_IMAGE_HEIGHT = 360; type InlineChatImageProps = { workspaceId: string; artifactId: string; alt: string; /** Non-interactive image for a surrounding catalog link; no nested controls. */ thumbnail?: boolean | undefined; /** Full artifact viewer uses the page width instead of the fixed chat slot. */ viewer?: boolean | undefined; showArtifactLink?: boolean | undefined; fromSession?: string | undefined; }; export function InlineChatImage(props: InlineChatImageProps) { if (props.thumbnail || props.viewer) return ; return ( {/* Metadata must not change the height reserved before viewport entry. */}
); } function InlineChatImageBody({ workspaceId, artifactId, alt, thumbnail = false, viewer = false, showArtifactLink = false, fromSession, }: InlineChatImageProps) { const { client, accessKeyVersion } = useAppContext(); const cache = retainedImageCache(client); const scope = retainedImageScope(accessKeyVersion, workspaceId, artifactId); // A remount (tab switch, search, scroll) starts from what this page already has. const [loaded, setLoaded] = useState<{ client: typeof client; workspaceId: string; accessKeyVersion: number; artifact: RetainedArtifactReference; } | null>(() => { const artifact = cache.peek(`${scope}meta`); return artifact && usableImage(artifact, artifactId) ? { client, workspaceId, accessKeyVersion, artifact } : null; }); const [error, setError] = useState(false); const [retry, setRetry] = useState(0); useEffect(() => { let active = true; setError(false); void cachedRetainedMetadata(retainedImageCache(client), scope, () => client.getRetainedArtifact(workspaceId, artifactId), ) .then((artifact) => { if (!active) return; if (!usableImage(artifact, artifactId)) { setError(true); return; } setLoaded((current) => current?.artifact === artifact && current.client === client && current.workspaceId === workspaceId && current.accessKeyVersion === accessKeyVersion ? current : { client, workspaceId, accessKeyVersion, artifact }, ); }) .catch(() => { if (active) setError(true); }); return () => { active = false; }; }, [client, workspaceId, accessKeyVersion, artifactId, scope, retry]); const load = useMemo( () => cachedRetainedArtifactLoader( retainedImageCache(client), accessKeyVersion, workspaceId, createWorkspaceRetainedArtifactLoader(client, workspaceId), ), [client, workspaceId, accessKeyVersion], ); // Retry means "fetch again": never answer it from the cache. const retryFresh = () => { cache.forget(scope); setLoaded(null); setRetry((v) => v + 1); }; if (error) return ( {alt || "Image"} unavailable.{" "} {!thumbnail ? ( ) : null} ); if ( !loaded || loaded.client !== client || loaded.workspaceId !== workspaceId || loaded.accessKeyVersion !== accessKeyVersion || loaded.artifact.artifactId !== artifactId ) return ( Loading image… ); const image = ( ); return ( <> {thumbnail ? ( image ) : (
{image}
)} {showArtifactLink && !thumbnail ? ( Open in Artifacts ) : null} ); } function usableImage( artifact: RetainedArtifactMetadata, artifactId: string, ): artifact is RetainedArtifactReference { return ( artifact.available && artifact.artifactId === artifactId && isRetainedImageContentType(artifact.contentType) ); } function LoadedImage({ artifact, load, alt, onRetry, thumbnail, viewer, }: { artifact: RetainedArtifactReference; load: ReturnType; alt: string; onRetry: () => void; thumbnail: boolean; viewer: boolean; }) { const state = useRetainedImageObjectUrl(artifact, load); const lightbox = useLightboxOptional(); const [failed, setFailed] = useState(false); if (state.kind === "loading") return ( Loading image… ); if (state.kind !== "ready" || failed) return ( {alt || "Image"} unavailable.{" "} {!thumbnail ? ( ) : null} ); const image = ( {alt} setFailed(true)} /> ); return lightbox && !thumbnail ? ( ) : ( image ); }