import { appPath } from "@agent-native/core/client/api-path"; import { useActionMutation, useActionQuery, useSession, } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconCode, IconExternalLink, IconLink, IconMail, } from "@tabler/icons-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { CopyField, GeneralAccessSelect, MakePublicCard, ShareCardHeader, SharePeopleTab, copyToClipboard, useResourceVisibilityMutation, type SharesQuery, type SharesResponse, type Visibility, } from "@/components/sharing/share-ui"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverTrigger, PopoverContent, } from "@/components/ui/popover"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { isLoomEmbedUrl } from "../../../shared/loom"; import { withShareAttribution } from "../../../shared/share-attribution"; function absoluteAppUrl(path: string): string { if (typeof window === "undefined") return ""; return new URL(appPath(path), window.location.origin).toString(); } export interface ShareRecordingPopoverProps { recordingId: string; recordingTitle?: string; initialVisibility?: Visibility | null; initialRole?: "owner" | "admin" | "editor" | "viewer"; videoUrl?: string | null; animatedThumbnailUrl?: string | null; isLoomRecording?: boolean; hasPassword?: boolean; /** Trigger element rendered as the popover anchor (usually the Share button). */ children: ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; } type ShareRecordingDialogProps = Omit< ShareRecordingPopoverProps, "children" > & { open: boolean; onOpenChange: (open: boolean) => void; }; /** * Clips share popover — anchored to a trigger button, contains Link / * Invite / Embed tabs with the same functionality as the framework share * dialog, plus Clips-specific extras (GIF preview + MP4 download) and a * recording-aware embed configurator (autoplay, start time, responsive / * fixed size). */ export function ShareRecordingPopover({ recordingId, recordingTitle, initialVisibility, initialRole, videoUrl, animatedThumbnailUrl, isLoomRecording = false, children, open, onOpenChange, }: ShareRecordingPopoverProps) { return ( {children} {/* Keep the layer class in app source so Tailwind emits it for Clips. */} ); } /** * Dialog shell for menu-driven Share actions. Radix popovers need a real * anchor; opening one from a dropdown item with an invisible trigger can * be dismissed by the same click/focus cycle that closes the menu. */ export function ShareRecordingDialog({ recordingId, recordingTitle, initialVisibility, initialRole, videoUrl, animatedThumbnailUrl, isLoomRecording = false, open, onOpenChange, }: ShareRecordingDialogProps) { const t = useT(); return ( {recordingTitle ? t("shareDialog.sharePlainTitle", { title: recordingTitle }) : t("shareDialog.shareRecording")} ); } function ShareRecordingContent({ recordingId, recordingTitle, initialVisibility, initialRole, videoUrl, animatedThumbnailUrl, isLoomRecording = false, reserveCloseButton = false, }: { recordingId: string; recordingTitle?: string; initialVisibility?: Visibility | null; initialRole?: "owner" | "admin" | "editor" | "viewer"; videoUrl?: string | null; animatedThumbnailUrl?: string | null; isLoomRecording?: boolean; reserveCloseButton?: boolean; }) { const t = useT(); const sharesQuery = useActionQuery("list-resource-shares", { resourceType: "recording", resourceId: recordingId, }); const data = sharesQuery.data; const role = data?.role ?? initialRole; const canManage = role === "owner" || role === "admin"; const visibility = (data?.visibility as Visibility | null | undefined) ?? initialVisibility ?? null; // Attribution `via` must be a stable non-PII id, never an email. The only // owner id available client-side is the *current* session's userId, which is // the clip owner only when the viewer is the owner. Anyone else (e.g. a // share-admin) gets an untagged `via` so we never attribute the link to the // wrong person or leak the owner's email. const { session } = useSession(); const ownerViaId = data?.role === "owner" ? (session?.userId ?? undefined) : undefined; const shareUrl = typeof window === "undefined" ? "" : withShareAttribution( absoluteAppUrl(`/share/${recordingId}`), ownerViaId, ); const titleText = recordingTitle ? t("shareDialog.shareTitle", { title: recordingTitle }) : t("shareDialog.shareRecording"); return ( <> {t("shareDialog.link")} {t("shareDialog.invite")} {t("shareDialog.embed")} ); } // --------------------------------------------------------------------------- // Link tab — visibility + copy link + Clips extras (GIF / MP4) // --------------------------------------------------------------------------- function LinkTab({ recordingId, shareUrl, sharesQuery, visibility, canManage, videoUrl, animatedThumbnailUrl, isLoomRecording: isLoomRecordingProp, }: { recordingId: string; shareUrl: string; sharesQuery: SharesQuery; visibility: Visibility | null; canManage: boolean; videoUrl?: string | null; animatedThumbnailUrl?: string | null; isLoomRecording?: boolean; }) { const t = useT(); const { setResourceVisibility, isPending } = useResourceVisibilityMutation( "recording", recordingId, sharesQuery, ); const isPublic = visibility === "public"; const sharesLoaded = visibility !== null; const visibilityPending = isPending || sharesQuery.isLoading; const isLoomRecording = isLoomRecordingProp || isLoomEmbedUrl(videoUrl); const createAgentLink = useActionMutation( "create-recording-agent-link" as any, ); const createAgentLinkAsyncRef = useRef(createAgentLink.mutateAsync); const agentLinkRequestIdRef = useRef(0); const [agentContextUrl, setAgentContextUrl] = useState(""); const [agentLinkError, setAgentLinkError] = useState(false); useEffect(() => { createAgentLinkAsyncRef.current = createAgentLink.mutateAsync; }); const loadAgentContextUrl = useCallback(async () => { const requestId = agentLinkRequestIdRef.current + 1; agentLinkRequestIdRef.current = requestId; setAgentContextUrl(""); setAgentLinkError(false); try { const result = (await createAgentLinkAsyncRef.current({ recordingId, })) as { url?: string }; if (agentLinkRequestIdRef.current !== requestId) return; if (result?.url) { setAgentContextUrl(result.url); } else { setAgentLinkError(true); } } catch { if (agentLinkRequestIdRef.current === requestId) { setAgentLinkError(true); } } }, [recordingId]); useEffect(() => { setAgentContextUrl(""); setAgentLinkError(false); if (!sharesLoaded) return; if (!isPublic) { void loadAgentContextUrl(); } return () => { agentLinkRequestIdRef.current += 1; }; }, [isPublic, loadAgentContextUrl, recordingId, sharesLoaded, visibility]); const agentLink = isPublic ? shareUrl : agentContextUrl; const agentShareDisabled = visibilityPending || !sharesLoaded || (!isPublic && (isPending || createAgentLink.isPending || !agentContextUrl)); const agentPrompt = agentLink ? t("shareDialog.agentPrompt", { agentContextUrl: agentLink }) : ""; return (
{visibility ? ( setResourceVisibility(next)} publicDescription={t("shareDialog.publicDescription")} /> ) : (
)} {!isPublic ? (
{sharesLoaded ? ( <>

{t("shareDialog.agentTokenDescription")}

{agentLinkError ? (

{t("shareDialog.agentLinkUnavailable")}

) : null} ) : null}
) : null} {sharesLoaded && !isPublic && canManage ? ( setResourceVisibility("public", { onSuccess: () => copyToClipboard(shareUrl), }) } /> ) : null} {videoUrl || animatedThumbnailUrl ? (
{animatedThumbnailUrl ? ( ) : null} {videoUrl ? ( ) : null}
) : null}
); } // --------------------------------------------------------------------------- // Embed tab — Clips-specific configurator // --------------------------------------------------------------------------- function ClipsEmbedConfigurator({ recordingId, sharesQuery, visibility, canManage, ownerViaId, }: { recordingId: string; sharesQuery: SharesQuery; visibility: Visibility | null; canManage: boolean; ownerViaId?: string; }) { const t = useT(); const [autoplay, setAutoplay] = useState(false); const [startMs, setStartMs] = useState(0); const [mode, setMode] = useState<"responsive" | "fixed">("responsive"); const [width, setWidth] = useState(640); const [height, setHeight] = useState(360); const isPublic = visibility === "public"; const visibilityLabel = visibility ? t(`shareUi.visibility.${visibility}.label`) : ""; const { setResourceVisibility, isPending } = useResourceVisibilityMutation( "recording", recordingId, sharesQuery, ); const makePublic = () => setResourceVisibility("public"); const src = useMemo(() => { const params: string[] = []; if (autoplay) params.push("autoplay=1"); if (startMs > 0) params.push(`t=${Math.round(startMs / 1000)}`); const qs = params.length ? `?${params.join("&")}` : ""; // Keep autoplay/t intact and also self-attribute the embed. return withShareAttribution( absoluteAppUrl(`/embed/${recordingId}${qs}`), ownerViaId, ); }, [recordingId, autoplay, startMs, ownerViaId]); const code = mode === "responsive" ? `
` : ``; if (!visibility) { return (
); } return (
{!isPublic ? (
{t("shareDialog.embedsNeedPublic")}

{t("shareDialog.embedPublicDescription", { visibility: visibilityLabel, })}

{canManage ? ( ) : (

{t("shareDialog.askOwnerPublic")}

)}
) : null}
{mode === "fixed" ? (
setWidth(parseInt(e.target.value) || 640)} />
setHeight(parseInt(e.target.value) || 360)} />
) : null}
setStartMs((parseInt(e.target.value) || 0) * 1000)} />