"use client"; import * as React from "react"; import Link from "next/link"; import useSWR from "swr"; import { AlertTriangle, Check, Copy, ExternalLink, Eye, Link2, Link2Off, MessageSquare, Share2, Sparkles, User } from "lucide-react"; import type { ConversationDetail, PublicMessage, ShareLinkItem } from "@/lib/client/types"; import { api, useApi } from "@/lib/client/api"; import { useIsMobile, useCopy } from "@/lib/client/hooks"; import { ResponsiveDialog } from "@/components/ui/sheet"; import { Segmented } from "@/components/ui/segmented"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { EmptyState, Skeleton, Spinner } from "@/components/ui/misc"; import { ConfirmDialog } from "@/components/common/confirm-dialog"; import { ProviderIcon } from "@/components/brand/provider-icon"; import { toast } from "@/components/ui/toast"; import { errorMessage } from "@/lib/client/humanize"; import { stripMarkdown } from "@/lib/search/query"; import { cn, formatRelative, truncate } from "@/lib/utils"; /* ------------------------------------------------------------------------------------------------ * Global hook — open the sheet from anywhere (chat header, palette, sidebar) without prop drilling. * * const share = useShareSheet(); * share.open({ conversationId, title, messages }) // messages optional (fetched when absent) * share.open({ conversationId, messageIds: [id1, id2] }) // preselect "Selected messages" * * `` must be mounted once (done in components/app/command-palette.tsx). * ---------------------------------------------------------------------------------------------- */ export interface ShareRequest { conversationId: string; title?: string; messages?: PublicMessage[]; /** Preselect these messages and start in "Selected messages" mode. */ messageIds?: string[]; } interface HostState { req: ShareRequest | null; open: boolean; } let hostState: HostState = { req: null, open: false }; const hostListeners = new Set<() => void>(); function setHost(next: HostState) { hostState = next; for (const l of hostListeners) l(); } export function openShareSheet(req: ShareRequest) { setHost({ req, open: true }); } export function closeShareSheet() { setHost({ ...hostState, open: false }); setTimeout(() => { if (!hostState.open) setHost({ req: null, open: false }); }, 320); } export function useShareSheet() { return React.useMemo(() => ({ open: openShareSheet, close: closeShareSheet }), []); } export function ShareSheetHost() { const state = React.useSyncExternalStore( (cb) => { hostListeners.add(cb); return () => hostListeners.delete(cb); }, () => hostState, () => hostState, ); if (!state.req) return null; return (o ? setHost({ ...hostState, open: true }) : closeShareSheet())} {...state.req} />; } /* ------------------------------------------------------------------------------------------------ * Sheet * ---------------------------------------------------------------------------------------------- */ type Mode = "all" | "selected"; interface ShareResult { id: string; path: string; partial: boolean; messageCount: number; created: boolean; } export interface ShareSheetProps extends ShareRequest { open: boolean; onOpenChange: (open: boolean) => void; } export function ShareSheet({ open, onOpenChange, conversationId, title, messages: providedMessages, messageIds }: ShareSheetProps) { const isMobile = useIsMobile(); const [mode, setMode] = React.useState(messageIds?.length ? "selected" : "all"); const [selected, setSelected] = React.useState>(() => new Set(messageIds ?? [])); const [busy, setBusy] = React.useState(false); const [result, setResult] = React.useState(null); const [revoking, setRevoking] = React.useState(null); const detail = useApi(open && !providedMessages ? `/api/conversations/${conversationId}` : null); const status = useSWR<{ share: { id: string; createdAt: string; viewCount: number } | null; shares: ShareLinkItem[] }>(open ? ["share-status", conversationId] : null, () => api(`/api/conversations/${conversationId}/actions`, { method: "POST", json: { action: "share-status" } }), { revalidateOnFocus: false }); const allMessages = React.useMemo(() => (providedMessages ?? detail.data?.messages ?? []).filter((m) => m.active !== false && m.status !== "streaming"), [providedMessages, detail.data]); const convTitle = title ?? detail.data?.conversation.title ?? "Conversation"; const links = status.data?.shares ?? []; const fullLink = links.find((l) => !l.partial) ?? null; const origin = typeof window !== "undefined" ? window.location.origin : ""; const toggle = (id: string) => setSelected((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); const selectAll = () => setSelected(new Set(allMessages.map((m) => m.id))); const selectNone = () => setSelected(new Set()); const create = async () => { setBusy(true); try { const body = mode === "selected" ? { action: "share", messageIds: allMessages.filter((m) => selected.has(m.id)).map((m) => m.id) } : { action: "share" }; const res = await api(`/api/conversations/${conversationId}/actions`, { method: "POST", json: body }); setResult(res); await status.mutate(); const url = `${origin}${res.path}`; try { await navigator.clipboard.writeText(url); toast.success(res.created ? "Share link created and copied" : "Share link updated and copied"); } catch { toast.success(res.created ? "Share link created" : "Share link updated"); } } catch (e) { toast.error("Could not create the link", errorMessage(e)); } finally { setBusy(false); } }; const revoke = async (link: ShareLinkItem) => { await api(`/api/shares?id=${encodeURIComponent(link.id)}`, { method: "DELETE" }); if (result?.id === link.id) setResult(null); await status.mutate(); toast.success("Link revoked", "Anyone opening it now sees “unavailable”."); }; const canCreate = mode === "all" ? allMessages.length > 0 || Boolean(providedMessages) : selected.size > 0; const primaryLabel = mode === "all" ? (fullLink ? "Update link" : "Create link") : `Share ${selected.size} message${selected.size === 1 ? "" : "s"}`; return ( <>

Links stay active until you revoke them.

} >

Anyone with the link can read this. Attachments are not included. The link is a frozen snapshot — later messages are not added until you update it.

value={mode} onChange={setMode} fill size={isMobile ? "lg" : "md"} ariaLabel="What to share" options={[{ value: "all", label: "Entire conversation", icon: }, { value: "selected", label: "Selected messages", icon: , count: mode === "selected" ? selected.size : undefined }]} /> {mode === "selected" ? (
{selected.size} of {allMessages.length} selected
{!providedMessages && detail.isLoading ? (
{Array.from({ length: 4 }).map((_, i) => ( ))}
) : allMessages.length === 0 ? ( ) : (
    {allMessages.map((m) => { const on = selected.has(m.id); return (
  • ); })}
)}
) : (

{allMessages.length ? `${allMessages.length} message${allMessages.length === 1 ? "" : "s"} will be published.` : detail.isLoading ? "Loading messages…" : "The conversation snapshot will be published."} {fullLink ? " A public link already exists — updating refreshes its content and keeps the same URL." : ""}

)} {result ? : null}

Active links

{status.isLoading && !status.data ? ( ) : links.length === 0 ? (

No public link yet.

) : (
    {links.map((l) => ( setRevoking(l)} compact /> ))}
)}
!o && setRevoking(null)} title="Revoke this link?" description="The page will show “unavailable” to anyone who opens it. You can create a new link at any time." confirmLabel="Revoke" destructive onConfirm={async () => { if (revoking) await revoke(revoking); }} /> ); } /* ------------------------------------------------------------------------------------------------ * Pieces * ---------------------------------------------------------------------------------------------- */ function LinkBox({ url, label, highlight }: { url: string; label?: string; highlight?: boolean }) { const [copied, copy] = useCopy(); const canNativeShare = typeof navigator !== "undefined" && typeof navigator.share === "function"; return (
{label ? (

{label}

) : null}
{url}
{canNativeShare ? ( ) : null}
); } function ShareLinkRow({ link, origin, onRevoke, compact }: { link: ShareLinkItem; origin: string; onRevoke: () => void; compact?: boolean }) { const [copied, copy] = useCopy(); const url = `${origin}${link.path}`; return (
  • {compact ? link.partial ? "Excerpt" : "Entire conversation" : link.title} {!compact && link.partial ? Excerpt : null} {formatRelative(link.createdAt)} · {link.viewCount} view{link.viewCount === 1 ? "" : "s"} · {link.messageCount} msg
  • ); } /* ------------------------------------------------------------------------------------------------ * Settings → Data: every active link of the user (mounted by workstream E). * * * ---------------------------------------------------------------------------------------------- */ export function ShareLinksList({ className }: { className?: string }) { const { data, isLoading, mutate } = useApi<{ shares: ShareLinkItem[] }>("/api/shares"); const [revoking, setRevoking] = React.useState(null); const origin = typeof window !== "undefined" ? window.location.origin : ""; const list = data?.shares ?? []; const revoke = async (link: ShareLinkItem) => { try { await api(`/api/shares?id=${encodeURIComponent(link.id)}`, { method: "DELETE" }); await mutate(); toast.success("Link revoked"); } catch (e) { toast.error("Could not revoke", errorMessage(e)); } }; return (
    {isLoading && !data ? (
    Loading links…
    ) : list.length === 0 ? ( } title="No active share links" description="Share a conversation from the chat header or the command palette (⌘K → Share conversation)." /> ) : (
      {list.map((l) => ( setRevoking(l)} /> ))}
    )} {list.length ? (

    {list.length} active link{list.length === 1 ? "" : "s"}. Revoking a link is immediate.

    ) : null} !o && setRevoking(null)} title="Revoke this link?" description={revoking ? `“${revoking.title}” will no longer be publicly readable.` : undefined} confirmLabel="Revoke" destructive onConfirm={async () => { if (revoking) await revoke(revoking); }} />
    ); }