AI

Share Conversation

A publish-a-link share dialog for a chat thread — server-issued URL, optimistic visibility and attachment writes with rollback, copy feedback and a destructive revoke confirm.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  Copy,
  Globe,
  Link2,
  LoaderCircle,
  MessagesSquare,
  Paperclip,
  TriangleAlert,
  Users,
  X,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/share-conversation.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ShareConversation" component: the dialog
that turns a chat thread into a revocable public link. Ship it as two exports —
a headless-of-chrome <ShareConversationPanel> (the whole flow) and a
<ShareConversation> dialog wrapper built on the shadcn/Radix dialog primitive.
Icons from lucide-react. No data fetching inside: every server call is an
injected async callback.

Contract
- type ShareVisibility = "anyone" | "workspace"
- interface ShareSettings { visibility: ShareVisibility; includeAttachments: boolean }
- interface ShareLink extends ShareSettings { url: string; createdAtLabel?: string }
  createdAtLabel is ALREADY formatted — the component never formats dates.
- interface ShareActivity { busy: boolean; confirming: boolean }
- ShareConversationPanel: forwardRef<HTMLDivElement>, extends
  React.HTMLAttributes<HTMLDivElement> minus children and onCopy.
  - conversationTitle: string (required), messageCount?: number,
    attachmentCount?: number
  - link?: ShareLink | null (controlled; null = nothing published),
    defaultLink?: ShareLink | null (uncontrolled seed),
    onLinkChange?: (link: ShareLink | null) => void
  - onCreateLink: (settings: ShareSettings) => ShareLink | Promise<ShareLink>
  - onUpdateSettings?: (settings, previous) => void | Promise<void>
  - onRevokeLink?: (link: ShareLink) => void | Promise<void>
  - onCopy?: (url: string) => void
  - onActivityChange?: (activity: ShareActivity) => void
  - defaultVisibility = "anyone", defaultIncludeAttachments = true,
    createLabel = "Create public link", disabled = false
- ShareConversation: every panel prop except onActivityChange, plus
  open / defaultOpen / onOpenChange, trigger?: ReactNode, title, description,
  contentClassName.
- Phases are derived, never stored twice: "idle" | "creating" | "created" |
  "revoking", mirrored onto the root as data-phase (and data-visibility) so a
  host page can style around it.

Behavior — creating the link (the one action that cannot be optimistic)
- A URL can only be minted by the server, so onCreateLink RESOLVES with the
  link and the UI shows a real pending state instead of inventing an address.
- One-shot lock: a ref flag is raised before the call and lowered in finally,
  so a double click, an Enter repeat or a second Space can never start a second
  request — disabled alone is not enough, the click can land in the same tick.
- On resolve, adopt the link the server returned INCLUDING its settings: a
  workspace policy may downgrade "anyone" on the way in, and the radio must
  show what was actually published, not what was asked for.
- On reject, stay private, print the reason inline (role="alert") and re-arm
  the button. Never clear the user's draft settings.
- Guard every callback with a mounted ref that is SET in the effect body (not
  only cleared in cleanup): StrictMode mounts → cleans up → mounts, and a flag
  that is only ever set to false would freeze the live instance forever.

Behavior — settings (optimistic write + rollback)
- Visibility and include-attachments flip IMMEDIATELY, then call
  onUpdateSettings(next, previous). The stored link is updated in the same
  breath so a controlled parent stays in sync.
- A rejection rolls back BOTH the control and the stored link to `previous` and
  states the reason. Silent rollback is worse than no rollback.
- Last-write-wins: stamp each write with an incrementing sequence number and
  ignore any settle whose stamp is not the newest — a stale rejection must
  never undo a newer choice.
- Before anything is published these controls are a local draft: no server
  call, and the draft ships as the argument to onCreateLink.
- attachmentCount === 0 disables the switch, pins includeAttachments to false
  in every callback, and swaps the hint for "no attachments".

Behavior — copy
- Read-only <input> holding the URL, selected on focus; copy writes with
  navigator.clipboard and shows "Copied" for ~2s (one timer, cleared on
  re-entry and on unmount).
- No Clipboard API (insecure context) or a rejected write: select the field and
  say "Copy failed — the link is selected, press Ctrl/Cmd+C". Never fail
  silently; never leave the user with a masked or truncated string to copy.
- A new URL resets the copy state — "Copied" must never describe a dead link.

Behavior — revoke (destructive, optimistic, reversible on failure)
- Two steps: "Revoke link" opens an inline confirm strip that spells out the
  consequence ("breaks the link for everyone who has it; a new link gets a new
  address"), with Keep / Revoke. Focus moves to the destructive button.
- Escape belongs to the confirm while it is open: preventDefault +
  stopPropagation cancel the confirmation and must NOT close the host dialog.
- On confirm the row disappears immediately — a destructive action left looking
  pending reads as "maybe it worked" — and a rejection restores the exact same
  link object with "it is still live".
- Every transition destroys the control the user is standing on, so park the
  next focus target in a ref and honour it in a post-commit effect: create →
  Copy, create failed → Create, revoke started → the pending line (tabIndex=-1),
  revoke settled → Create or back to Revoke.

Behavior — the dialog wrapper
- The link state lives in the WRAPPER, above the dialog content: Radix unmounts
  content on close, so a panel that owned the link would forget one it had just
  minted and let the user mint a second one on the next open.
- The panel reports { busy, confirming } upward; while either is true the
  dialog refuses to close — Esc and outside clicks are preventDefault'd and the
  close button is disabled. Store the report only when the values actually
  changed, otherwise the callback loops.
- onOpenAutoFocus is redirected to the content element instead of the first
  radio, so the first thing announced is what is being shared.

Rendering & styling
- Semantic tokens only: bg-card, bg-muted/40, bg-primary, bg-primary/5,
  border-primary/40, text-primary, text-muted-foreground, bg-destructive/10,
  border-destructive/30, text-destructive, border-input, ring-ring/50. No hex,
  no rgb(), no oklch().
- Merge every className through cn(); spread the remaining native props on the
  panel root.
- Visibility is a hand-rolled role="radiogroup" with roving tabindex — one tab
  stop, arrows and Home/End both move and select, each option is a
  role="radio" button carrying label + hint + a token-drawn dot.
- Attachments is a role="switch" button (track h-5 w-9 p-0.5, knob size-4 with
  translate-x-4) — not a checkbox: the label is a state, not a value.
- Motion is decoration: the published row enters with
  motion-safe:animate-in fade-in/slide-in and the spinners are
  animate-spin motion-reduce:animate-none — under reduced motion everything
  still works, it just stops moving.
- One sr-only aria-live="polite" region for transitions with no visible text
  (created / copied / revoked); failures announce themselves through their own
  role="alert" so nothing is spoken twice.

Customization levers
- Visibility options: the option list is one array of { value, label, hint,
  icon } — add "Anyone in the org", "Password required", or cut it to one
  option and the radiogroup collapses gracefully.
- Extra controls: an expiry select, a "hide my name" switch or a domain
  allow-list all belong next to the attachments row and route through the same
  applySettings(next) optimistic path — no new state machine needed.
- Copy feedback window (2000ms) and the create/revoke pending copy are single
  constants; the whole copy row can be swapped for your own CopyButton.
- Destructive confirm: keep it inline (it stays inside the dialog) or lift it
  into an AlertDialog / type-to-confirm — only the two-step gate is load
  bearing.
- Chrome: render the panel alone inside a sheet, a settings page or a popover;
  the dialog wrapper is 60 lines and nothing in the panel depends on it.
- Density: the root's gap-4 / text-sm and the rows' px-3 py-2.5 are the only
  spacing knobs; pass className to tighten them.
- Close policy: relax the "held" rule to busy-only if your product prefers a
  dialog that can always be dismissed — the mounted-ref guards already make an
  in-flight promise safe to abandon.

Concepts

  • Server-issued value — visibility and attachments can be flipped optimistically because the client already knows the answer; a URL cannot, so onCreateLink resolves with the link and the button shows a real pending state instead of inventing an address the server might not agree to.
  • Optimistic write with rollback — the control moves first and the request follows; a rejection restores both the control and the stored link to the exact previous values and prints why, because a switch that silently springs back reads as a broken widget.
  • Newest-stamp-wins — every settings write carries an incrementing stamp and only the newest one may act on its result, so a slow rejection from two clicks ago can never undo the choice the user just made.
  • One-shot lock — a ref raised before the call and lowered in finally is what actually prevents a duplicate link; disabled alone loses to a double click that lands in the same tick, and the cost of losing that race is a second live URL nobody can see.
  • Destructive confirm owns Escape — while the revoke confirmation is open, Escape cancels the confirmation instead of closing the surface underneath it: one key, one job, and the user never dismisses a dialog they were only trying to back out of.
  • Held surface — a request in flight (or an open confirmation) blocks Esc, outside clicks and the close button, because unmounting the panel mid-promise leaves the user unable to tell whether a public link now exists.

On This Page