TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import Link from "next/link";4import useSWR from "swr";5import { AlertTriangle, Check, Copy, ExternalLink, Eye, Link2, Link2Off, MessageSquare, Share2, Sparkles, User } from "lucide-react";6import type { ConversationDetail, PublicMessage, ShareLinkItem } from "@/lib/client/types";7import { api, useApi } from "@/lib/client/api";8import { useIsMobile, useCopy } from "@/lib/client/hooks";9import { ResponsiveDialog } from "@/components/ui/sheet";10import { Segmented } from "@/components/ui/segmented";11import { Button } from "@/components/ui/button";12import { Badge } from "@/components/ui/badge";13import { EmptyState, Skeleton, Spinner } from "@/components/ui/misc";14import { ConfirmDialog } from "@/components/common/confirm-dialog";15import { ProviderIcon } from "@/components/brand/provider-icon";16import { toast } from "@/components/ui/toast";17import { errorMessage } from "@/lib/client/humanize";18import { stripMarkdown } from "@/lib/search/query";19import { cn, formatRelative, truncate } from "@/lib/utils";2021/* ------------------------------------------------------------------------------------------------22 * Global hook — open the sheet from anywhere (chat header, palette, sidebar) without prop drilling.23 *24 * const share = useShareSheet();25 * share.open({ conversationId, title, messages }) // messages optional (fetched when absent)26 * share.open({ conversationId, messageIds: [id1, id2] }) // preselect "Selected messages"27 *28 * `<ShareSheetHost />` must be mounted once (done in components/app/command-palette.tsx).29 * ---------------------------------------------------------------------------------------------- */30export interface ShareRequest {31 conversationId: string;32 title?: string;33 messages?: PublicMessage[];34 /** Preselect these messages and start in "Selected messages" mode. */35 messageIds?: string[];36}3738interface HostState {39 req: ShareRequest | null;40 open: boolean;41}42let hostState: HostState = { req: null, open: false };43const hostListeners = new Set<() => void>();44function setHost(next: HostState) {45 hostState = next;46 for (const l of hostListeners) l();47}48export function openShareSheet(req: ShareRequest) {49 setHost({ req, open: true });50}51export function closeShareSheet() {52 setHost({ ...hostState, open: false });53 setTimeout(() => {54 if (!hostState.open) setHost({ req: null, open: false });55 }, 320);56}57export function useShareSheet() {58 return React.useMemo(() => ({ open: openShareSheet, close: closeShareSheet }), []);59}60export function ShareSheetHost() {61 const state = React.useSyncExternalStore(62 (cb) => {63 hostListeners.add(cb);64 return () => hostListeners.delete(cb);65 },66 () => hostState,67 () => hostState,68 );69 if (!state.req) return null;70 return <ShareSheet open={state.open} onOpenChange={(o) => (o ? setHost({ ...hostState, open: true }) : closeShareSheet())} {...state.req} />;71}7273/* ------------------------------------------------------------------------------------------------74 * Sheet75 * ---------------------------------------------------------------------------------------------- */76type Mode = "all" | "selected";7778interface ShareResult {79 id: string;80 path: string;81 partial: boolean;82 messageCount: number;83 created: boolean;84}8586export interface ShareSheetProps extends ShareRequest {87 open: boolean;88 onOpenChange: (open: boolean) => void;89}9091export function ShareSheet({ open, onOpenChange, conversationId, title, messages: providedMessages, messageIds }: ShareSheetProps) {92 const isMobile = useIsMobile();93 const [mode, setMode] = React.useState<Mode>(messageIds?.length ? "selected" : "all");94 const [selected, setSelected] = React.useState<Set<string>>(() => new Set(messageIds ?? []));95 const [busy, setBusy] = React.useState(false);96 const [result, setResult] = React.useState<ShareResult | null>(null);97 const [revoking, setRevoking] = React.useState<ShareLinkItem | null>(null);9899 const detail = useApi<ConversationDetail>(open && !providedMessages ? `/api/conversations/${conversationId}` : null);100 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 });101102 const allMessages = React.useMemo(() => (providedMessages ?? detail.data?.messages ?? []).filter((m) => m.active !== false && m.status !== "streaming"), [providedMessages, detail.data]);103 const convTitle = title ?? detail.data?.conversation.title ?? "Conversation";104 const links = status.data?.shares ?? [];105 const fullLink = links.find((l) => !l.partial) ?? null;106 const origin = typeof window !== "undefined" ? window.location.origin : "";107108 const toggle = (id: string) =>109 setSelected((prev) => {110 const next = new Set(prev);111 if (next.has(id)) next.delete(id);112 else next.add(id);113 return next;114 });115 const selectAll = () => setSelected(new Set(allMessages.map((m) => m.id)));116 const selectNone = () => setSelected(new Set());117118 const create = async () => {119 setBusy(true);120 try {121 const body = mode === "selected" ? { action: "share", messageIds: allMessages.filter((m) => selected.has(m.id)).map((m) => m.id) } : { action: "share" };122 const res = await api<ShareResult>(`/api/conversations/${conversationId}/actions`, { method: "POST", json: body });123 setResult(res);124 await status.mutate();125 const url = `${origin}${res.path}`;126 try {127 await navigator.clipboard.writeText(url);128 toast.success(res.created ? "Share link created and copied" : "Share link updated and copied");129 } catch {130 toast.success(res.created ? "Share link created" : "Share link updated");131 }132 } catch (e) {133 toast.error("Could not create the link", errorMessage(e));134 } finally {135 setBusy(false);136 }137 };138139 const revoke = async (link: ShareLinkItem) => {140 await api(`/api/shares?id=${encodeURIComponent(link.id)}`, { method: "DELETE" });141 if (result?.id === link.id) setResult(null);142 await status.mutate();143 toast.success("Link revoked", "Anyone opening it now sees “unavailable”.");144 };145146 const canCreate = mode === "all" ? allMessages.length > 0 || Boolean(providedMessages) : selected.size > 0;147 const primaryLabel = mode === "all" ? (fullLink ? "Update link" : "Create link") : `Share ${selected.size} message${selected.size === 1 ? "" : "s"}`;148149 return (150 <>151 <ResponsiveDialog152 open={open}153 onOpenChange={onOpenChange}154 title="Share conversation"155 description={truncate(convTitle, 80)}156 size="md"157 snap="full"158 footer={159 <div className="flex items-center gap-2">160 <p className="hidden flex-1 text-[12px] text-fg-subtle sm:block">Links stay active until you revoke them.</p>161 <Button variant="ghost" size={isMobile ? "lg" : "md"} className={cn(isMobile && "flex-1")} onClick={() => onOpenChange(false)}>162 Done163 </Button>164 <Button variant="primary" size={isMobile ? "lg" : "md"} className={cn(isMobile && "flex-[2]")} onClick={create} loading={busy} disabled={!canCreate}>165 <Share2 /> {primaryLabel}166 </Button>167 </div>168 }169 >170 <div className="space-y-4 pt-1">171 <div className="flex items-start gap-2.5 rounded-xl bg-warning-soft/70 px-3 py-2.5 text-[13px] leading-5 text-warning">172 <AlertTriangle className="mt-0.5 size-4 shrink-0" aria-hidden />173 <p>174 <span className="font-medium">Anyone with the link can read this.</span> Attachments are not included. The link is a frozen snapshot — later messages are not added until you update it.175 </p>176 </div>177178 <Segmented<Mode> value={mode} onChange={setMode} fill size={isMobile ? "lg" : "md"} ariaLabel="What to share" options={[{ value: "all", label: "Entire conversation", icon: <MessageSquare /> }, { value: "selected", label: "Selected messages", icon: <Check />, count: mode === "selected" ? selected.size : undefined }]} />179180 {mode === "selected" ? (181 <div className="space-y-2">182 <div className="flex items-center justify-between text-[12.5px] text-fg-muted">183 <span>184 {selected.size} of {allMessages.length} selected185 </span>186 <span className="flex gap-1">187 <Button variant="ghost" size="xs" onClick={selectAll} disabled={!allMessages.length}>188 All189 </Button>190 <Button variant="ghost" size="xs" onClick={selectNone} disabled={!selected.size}>191 None192 </Button>193 </span>194 </div>195 {!providedMessages && detail.isLoading ? (196 <div className="space-y-1.5">197 {Array.from({ length: 4 }).map((_, i) => (198 <Skeleton key={i} className="h-14" />199 ))}200 </div>201 ) : allMessages.length === 0 ? (202 <EmptyState title="Nothing to share yet" description="Send a message first." className="py-8" />203 ) : (204 <ul className="max-h-[42dvh] space-y-1 overflow-y-auto rounded-xl bg-bg-subtle p-1.5 scrollbar-thin md:max-h-72" aria-label="Messages">205 {allMessages.map((m) => {206 const on = selected.has(m.id);207 return (208 <li key={m.id}>209 <label className={cn("flex min-h-[48px] cursor-pointer items-start gap-3 rounded-lg px-2.5 py-2 transition-colors", on ? "bg-bg-elevated shadow-xs" : "hover:bg-bg-elevated/60")}>210 <input type="checkbox" checked={on} onChange={() => toggle(m.id)} className="mt-1 size-4 shrink-0 accent-[var(--accent)]" aria-label={`Include ${m.role} message`} />211 <span className="min-w-0 flex-1">212 <span className="flex items-center gap-1.5 text-[11.5px] font-medium text-fg-muted">213 {m.role === "user" ? <User className="size-3" /> : <ProviderIcon provider={m.provider} size={12} />}214 {m.role === "user" ? "You" : m.modelKey?.split("/").slice(1).join("/") || "Assistant"}215 </span>216 <span className="mt-0.5 line-clamp-2 block text-[13.5px] leading-5 text-fg">{stripMarkdown(m.content).slice(0, 220) || <em className="text-fg-subtle">No text</em>}</span>217 </span>218 </label>219 </li>220 );221 })}222 </ul>223 )}224 </div>225 ) : (226 <p className="text-[13px] text-fg-muted">227 {allMessages.length ? `${allMessages.length} message${allMessages.length === 1 ? "" : "s"} will be published.` : detail.isLoading ? "Loading messages…" : "The conversation snapshot will be published."}228 {fullLink ? " A public link already exists — updating refreshes its content and keeps the same URL." : ""}229 </p>230 )}231232 {result ? <LinkBox url={`${origin}${result.path}`} label={result.partial ? `Excerpt · ${result.messageCount} message${result.messageCount === 1 ? "" : "s"}` : "Entire conversation"} highlight /> : null}233234 <section aria-label="Active links for this conversation" className="space-y-1.5">235 <h3 className="text-[11px] font-medium uppercase tracking-wide text-fg-subtle">Active links</h3>236 {status.isLoading && !status.data ? (237 <Skeleton className="h-12" />238 ) : links.length === 0 ? (239 <p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-[12.5px] text-fg-subtle">No public link yet.</p>240 ) : (241 <ul className="divide-y divide-hairline overflow-hidden rounded-xl bg-bg-subtle">242 {links.map((l) => (243 <ShareLinkRow key={l.id} link={l} origin={origin} onRevoke={() => setRevoking(l)} compact />244 ))}245 </ul>246 )}247 </section>248 </div>249 </ResponsiveDialog>250251 <ConfirmDialog open={Boolean(revoking)} onOpenChange={(o) => !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); }} />252 </>253 );254}255256/* ------------------------------------------------------------------------------------------------257 * Pieces258 * ---------------------------------------------------------------------------------------------- */259function LinkBox({ url, label, highlight }: { url: string; label?: string; highlight?: boolean }) {260 const [copied, copy] = useCopy();261 const canNativeShare = typeof navigator !== "undefined" && typeof navigator.share === "function";262 return (263 <div className={cn("rounded-xl border px-3 py-2.5", highlight ? "border-accent/40 bg-accent-soft/40" : "border-border bg-bg-subtle")}>264 {label ? (265 <p className="mb-1 flex items-center gap-1.5 text-[11.5px] font-medium text-fg-muted">266 <Link2 className="size-3.5" /> {label}267 </p>268 ) : null}269 <div className="flex items-center gap-2">270 <code className="min-w-0 flex-1 truncate font-mono text-[12.5px] text-fg">{url}</code>271 <Button variant="outline" size="sm" onClick={() => copy(url)} aria-label="Copy link">272 {copied ? <Check className="text-success" /> : <Copy />} {copied ? "Copied" : "Copy"}273 </Button>274 </div>275 <div className="mt-2 flex flex-wrap gap-1.5">276 <Button asChild variant="ghost" size="xs">277 <a href={url} target="_blank" rel="noopener noreferrer">278 <ExternalLink /> Open279 </a>280 </Button>281 {canNativeShare ? (282 <Button variant="ghost" size="xs" onClick={() => navigator.share({ url, title: "Shared from PolyLLM" }).catch(() => {})}>283 <Share2 /> Share…284 </Button>285 ) : null}286 </div>287 </div>288 );289}290291function ShareLinkRow({ link, origin, onRevoke, compact }: { link: ShareLinkItem; origin: string; onRevoke: () => void; compact?: boolean }) {292 const [copied, copy] = useCopy();293 const url = `${origin}${link.path}`;294 return (295 <li className={cn("flex items-center gap-3 px-3", compact ? "min-h-[52px] py-2" : "min-h-[60px] py-2.5")}>296 <span className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-bg-elevated text-fg-muted">297 <Link2 className="size-4" />298 </span>299 <span className="min-w-0 flex-1">300 <span className="flex items-center gap-1.5">301 <Link href={link.path} target="_blank" className="truncate text-[13.5px] font-medium text-fg hover:underline">302 {compact ? link.partial ? "Excerpt" : "Entire conversation" : link.title}303 </Link>304 {!compact && link.partial ? <Badge variant="accent">Excerpt</Badge> : null}305 </span>306 <span className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11.5px] tabular-nums text-fg-subtle">307 <span>{formatRelative(link.createdAt)}</span>308 <span aria-hidden>·</span>309 <span className="inline-flex items-center gap-1">310 <Eye className="size-3" /> {link.viewCount} view{link.viewCount === 1 ? "" : "s"}311 </span>312 <span aria-hidden>·</span>313 <span>{link.messageCount} msg</span>314 </span>315 </span>316 <div className="flex shrink-0 items-center gap-0.5">317 <Button variant="ghost" size="icon-sm" onClick={() => copy(url)} aria-label="Copy link" className="tap">318 {copied ? <Check className="text-success" /> : <Copy />}319 </Button>320 <Button variant="ghost" size="icon-sm" onClick={onRevoke} aria-label="Revoke link" className="tap text-fg-muted hover:text-danger">321 <Link2Off />322 </Button>323 </div>324 </li>325 );326}327328/* ------------------------------------------------------------------------------------------------329 * Settings → Data: every active link of the user (mounted by workstream E).330 *331 * <ShareLinksList />332 * ---------------------------------------------------------------------------------------------- */333export function ShareLinksList({ className }: { className?: string }) {334 const { data, isLoading, mutate } = useApi<{ shares: ShareLinkItem[] }>("/api/shares");335 const [revoking, setRevoking] = React.useState<ShareLinkItem | null>(null);336 const origin = typeof window !== "undefined" ? window.location.origin : "";337 const list = data?.shares ?? [];338339 const revoke = async (link: ShareLinkItem) => {340 try {341 await api(`/api/shares?id=${encodeURIComponent(link.id)}`, { method: "DELETE" });342 await mutate();343 toast.success("Link revoked");344 } catch (e) {345 toast.error("Could not revoke", errorMessage(e));346 }347 };348349 return (350 <div className={className}>351 {isLoading && !data ? (352 <div className="flex items-center gap-2 px-1 py-4 text-[13px] text-fg-muted">353 <Spinner /> Loading links…354 </div>355 ) : list.length === 0 ? (356 <EmptyState icon={<Sparkles />} title="No active share links" description="Share a conversation from the chat header or the command palette (⌘K → Share conversation)." />357 ) : (358 <ul className="divide-y divide-hairline overflow-hidden rounded-xl border border-border bg-bg-elevated">359 {list.map((l) => (360 <ShareLinkRow key={l.id} link={l} origin={origin} onRevoke={() => setRevoking(l)} />361 ))}362 </ul>363 )}364 {list.length ? (365 <p className="mt-2 text-[12px] text-fg-subtle">366 {list.length} active link{list.length === 1 ? "" : "s"}. Revoking a link is immediate.367 </p>368 ) : null}369 <ConfirmDialog open={Boolean(revoking)} onOpenChange={(o) => !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); }} />370 </div>371 );372}373