import { AgentPanel } from "@agent-native/core/client/agent-chat"; import { track } from "@agent-native/core/client/analytics"; import { agentNativePath, appBasePath, appPath, } from "@agent-native/core/client/api-path"; import { useSession, getBrowserTabId } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { buildSignInReturnHref } from "@agent-native/core/client/ui"; import { IconAlertTriangle, IconArrowLeft, IconDeviceDesktop, IconDownload, IconDots, IconExternalLink, IconLogin2, IconShare3, } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { eq } from "drizzle-orm"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { type HeadersArgs, type LoaderFunctionArgs, type MetaFunction, } from "react-router"; import { Link, useLoaderData, useNavigate, useParams, useSearchParams, } from "react-router"; import { toast } from "sonner"; import { CaptureInstallButton } from "@/components/capture-install-options"; import { AccessPasswordPrompt } from "@/components/player/access-password-prompt"; import { CommentsPanel } from "@/components/player/comments-panel"; import { RecordingOptionsMenu } from "@/components/player/delete-recording-menu"; import { InsightsPanel } from "@/components/player/insights-panel"; import { ReactionsTray } from "@/components/player/reactions-tray"; import { RecordingViewsBadge } from "@/components/player/recording-views-badge"; import { ShareRecordingPopover } from "@/components/player/share-dialog"; import { SignInPromptDialog } from "@/components/player/sign-in-prompt-dialog"; import { TranscriptPanel } from "@/components/player/transcript-panel"; import { VideoPlayer, type VideoPlayerHandle, } from "@/components/player/video-player"; import { StorageSetupCard } from "@/components/recorder/storage-setup-card"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { isDefaultTitle } from "@/hooks/use-auto-title"; import { usePlayerShortcuts } from "@/hooks/use-player-shortcuts"; import { useViewTracking } from "@/hooks/use-view-tracking"; import { parsePlaybackSpeed } from "@/lib/playback-speed"; import { isStorageSetupFailureReason } from "@/lib/storage-failures"; import { getDb, schema } from "../../server/db"; import { resolvePlayerThumbnailUrl } from "../../server/lib/player-thumbnail-url"; import { buildAgentApiUrls, buildAgentDiscoveryPayload, CLIPS_AGENT_ACCESS_PARAM, CLIP_AGENT_ACCESS_TOKEN_PREFIX, safeJsonForHtml, } from "../../shared/agent-context"; import { isLoomEmbedBackedRecording, isLoomRecordingSource, } from "../../shared/loom"; import { buildSignupAttributionQuery, readShareAttribution, } from "../../shared/share-attribution"; import { resolveDashboardRedirect } from "../../shared/share-dashboard-redirect"; import { privateShareLoaderData } from "../../shared/share-loader-response"; import { buildClipsShareMeta, clipsSharePageTitle, displayRecordingTitle, } from "../../shared/share-meta"; type SharePageMetaRecording = { id: string; title: string; description: string; thumbnailUrl: string | null; animatedThumbnailUrl: string | null; visibility: "private" | "org" | "public"; status: "uploading" | "processing" | "ready" | "failed"; archivedAt: string | null; trashedAt: string | null; }; type SharePageLoaderData = { recording: SharePageMetaRecording | null; agentContextUrl: string | null; origin: string | null; shareUrl: string | null; }; const CLIPS_AGENT_ACCESS_TTL_SECONDS = 2 * 60 * 60; function emptyLoaderData(url: URL): SharePageLoaderData { return { recording: null, agentContextUrl: null, origin: url.origin, shareUrl: null, }; } function shareLoaderData( payload: SharePageLoaderData, privateAgentAccess = false, ) { if (!privateAgentAccess) return payload; return privateShareLoaderData(payload); } export function headers({ loaderHeaders }: HeadersArgs) { return loaderHeaders; } function failureDetail(reason: string | null | undefined): string | null { const trimmed = reason?.trim(); if (!trimmed) return null; return trimmed.length > 800 ? `${trimmed.slice(0, 800)}...` : trimmed; } function shouldShowGeneratedTitleSkeleton( recording: { title: string | null | undefined; createdAt?: string | null }, transcriptStatus?: string, ): boolean { if (!isDefaultTitle(recording.title)) return false; if (transcriptStatus === "failed") return false; const createdAtMs = Date.parse(recording.createdAt ?? ""); if ( Number.isFinite(createdAtMs) && Date.now() - createdAtMs > 2 * 60 * 1000 && transcriptStatus !== "pending" ) { return false; } return true; } export async function loader({ params, url }: LoaderFunctionArgs) { const id = params.shareId; if (!id) return emptyLoaderData(url); const [ { getRequestUserEmail, signScopedAgentAccessToken, verifyScopedAgentAccessToken, }, { resolveAccess }, ] = await Promise.all([ import("@agent-native/core/server"), import("@agent-native/core/sharing"), ]); const [rec] = await getDb() .select({ id: schema.recordings.id, title: schema.recordings.title, description: schema.recordings.description, thumbnailUrl: schema.recordings.thumbnailUrl, animatedThumbnailUrl: schema.recordings.animatedThumbnailUrl, visibility: schema.recordings.visibility, status: schema.recordings.status, ownerEmail: schema.recordings.ownerEmail, password: schema.recordings.password, expiresAt: schema.recordings.expiresAt, archivedAt: schema.recordings.archivedAt, trashedAt: schema.recordings.trashedAt, }) .from(schema.recordings) .where(eq(schema.recordings.id, id)) .limit(1); const agentAccessToken = url.searchParams.get(CLIPS_AGENT_ACCESS_PARAM) ?? url.searchParams.get("t") ?? ""; const hasAgentAccessToken = Boolean(agentAccessToken); const tokenGrantsAgentAccess = agentAccessToken ? verifyScopedAgentAccessToken(agentAccessToken, { resourceKind: CLIP_AGENT_ACCESS_TOKEN_PREFIX, resourceId: id, }).ok : false; if (!rec) return shareLoaderData(emptyLoaderData(url), hasAgentAccessToken); if (rec.expiresAt) { const expires = new Date(rec.expiresAt).getTime(); if (Number.isFinite(expires) && expires < Date.now()) { return shareLoaderData(emptyLoaderData(url), hasAgentAccessToken); } } if (rec.visibility !== "public" && !tokenGrantsAgentAccess) { const userEmail = getRequestUserEmail(); const access = userEmail ? await resolveAccess("recording", id) : null; if (!access) return privateShareLoaderData(emptyLoaderData(url)); } const recording: SharePageMetaRecording = { id: rec.id, title: rec.title, description: rec.description, thumbnailUrl: rec.password ? null : resolvePlayerThumbnailUrl(rec, { appPath }), animatedThumbnailUrl: null, visibility: rec.visibility, status: rec.status, archivedAt: rec.archivedAt, trashedAt: rec.trashedAt, }; const canExposeAgentContext = (rec.visibility === "public" || tokenGrantsAgentAccess) && !rec.archivedAt && !rec.trashedAt; const token = tokenGrantsAgentAccess ? agentAccessToken : canExposeAgentContext && rec.password && getRequestUserEmail() === rec.ownerEmail ? signScopedAgentAccessToken({ resourceKind: CLIP_AGENT_ACCESS_TOKEN_PREFIX, resourceId: id, ttlSeconds: CLIPS_AGENT_ACCESS_TTL_SECONDS, }) : undefined; const canExposeAnonymousAgentContext = canExposeAgentContext && !rec.password; const canExposeOwnerAgentContext = canExposeAgentContext && Boolean(token); return shareLoaderData( { recording, origin: url.origin, shareUrl: `${url.origin}${url.pathname}`, agentContextUrl: canExposeAnonymousAgentContext || canExposeOwnerAgentContext ? buildAgentApiUrls(id, { origin: url.origin, basePath: process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH || "", token, }).contextUrl : null, }, hasAgentAccessToken || canExposeOwnerAgentContext, ); } export const meta: MetaFunction = ({ loaderData }) => { return buildClipsShareMeta({ recording: loaderData?.recording ?? null, origin: loaderData?.origin ?? null, shareUrl: loaderData?.shareUrl ?? null, }); }; const STORAGE_KEY_PREFIX = "clips-share-pw-"; const CLIPS_SOURCE_URL = "https://github.com/BuilderIO/agent-native/tree/main/templates/clips"; const CLIPS_TEMPLATE_URL = "https://www.agent-native.com/templates/clips"; const CLIPS_AGENT_DOCS_URL = "https://www.agent-native.com/docs/template-clips#agent-readable-clips"; const UPLOAD_STUCK_TIMEOUT_MS = 5 * 60 * 1000; const PROCESSING_STUCK_TIMEOUT_MS = 12 * 60 * 1000; type ViewerPlatform = "mac" | "windows" | "linux"; function detectViewerPlatform(): ViewerPlatform | null { if (typeof navigator === "undefined") return null; const ua = navigator.userAgent; if (/Windows/i.test(ua)) return "windows"; if (/Mac/i.test(ua)) return "mac"; if (/Linux|X11/i.test(ua) && !/Android/i.test(ua)) return "linux"; return null; } function AgentDiscovery({ recording, agentContextUrl, }: { recording: Pick | null; agentContextUrl: string | null; }) { const t = useT(); if (!recording || !agentContextUrl) return null; const payload = buildAgentDiscoveryPayload({ recordingId: recording.id, title: recording.title, status: recording.status, agentContextUrl, }); return ( <> {t("sharePage.agentReadableContext")}