{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "quote-reply",
  "type": "registry:component",
  "title": "Quote Reply",
  "description": "Highlight any part of a message to get a small Quote action; the snippet rides along in the composer until you send or dismiss it, and tapping it later jumps back to the source.",
  "categories": [
    "component",
    "Together"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/quote-reply.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowUpIcon, CheckIcon, CopyIcon, TextQuoteIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type FormEvent, Fragment, useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction useSafeReducedMotion() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nexport interface QuoteAuthor {\n  id: string;\n  name: string;\n  initials: string;\n  avatarClassName?: string;\n}\n\nexport interface QuotedSnippet {\n  messageId: string;\n  authorName: string;\n  text: string;\n}\n\nexport interface QuoteMessage {\n  id: string;\n  author: QuoteAuthor;\n  time: string;\n  text: string;\n  quote?: QuotedSnippet;\n}\n\nexport interface QuoteReplyProps {\n  title?: string;\n  messages: QuoteMessage[];\n  currentUser: QuoteAuthor;\n  onSend?: (text: string, quote: QuotedSnippet | null) => void;\n  className?: string;\n}\n\ninterface Floating {\n  messageId: string;\n  text: string;\n  x: number;\n  y: number;\n  below: boolean;\n}\n\nconst spring = { type: \"spring\", stiffness: 480, damping: 38 } as const;\n\nexport function QuoteReply({ title = \"Onboarding review\", messages: initial, currentUser, onSend, className }: QuoteReplyProps) {\n  const reduce = useSafeReducedMotion();\n  const inputId = useId();\n  const cardRef = useRef<HTMLElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLTextAreaElement>(null);\n  const [messages, setMessages] = useState(initial);\n  const [floating, setFloating] = useState<Floating | null>(null);\n  const [quote, setQuote] = useState<QuotedSnippet | null>(null);\n  const [draft, setDraft] = useState(\"\");\n  const [copied, setCopied] = useState(false);\n  const [flash, setFlash] = useState<{ id: string; text: string; on: boolean } | null>(null);\n  const flashTimer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  useEffect(() => () => clearTimeout(flashTimer.current), []);\n\n  useEffect(() => {\n    if (!floating) return;\n    const onChange = () => {\n      const sel = window.getSelection();\n      if (!sel || sel.isCollapsed) setFloating(null);\n    };\n    document.addEventListener(\"selectionchange\", onChange);\n    return () => document.removeEventListener(\"selectionchange\", onChange);\n  }, [floating]);\n\n  const readSelection = () => {\n    const sel = window.getSelection();\n    const card = cardRef.current;\n    if (!sel || sel.isCollapsed || !sel.rangeCount || !card) return setFloating(null);\n    const anchor = sel.anchorNode?.parentElement?.closest<HTMLElement>(\"[data-quote-source]\");\n    const focus = sel.focusNode?.parentElement?.closest<HTMLElement>(\"[data-quote-source]\");\n    const text = sel.toString().replace(/\\s+/g, \" \").trim();\n    if (!anchor || anchor !== focus || text.length < 2) return setFloating(null);\n    const rect = sel.getRangeAt(0).getBoundingClientRect();\n    const box = card.getBoundingClientRect();\n    const below = rect.top - box.top < 56;\n    setCopied(false);\n    setFloating({\n      messageId: anchor.dataset.quoteSource ?? \"\",\n      text: text.length > 280 ? `${text.slice(0, 277)}…` : text,\n      x: Math.min(Math.max(rect.left + rect.width / 2 - box.left, 80), box.width - 80),\n      y: below ? rect.bottom - box.top + 8 : rect.top - box.top - 8,\n      below,\n    });\n  };\n\n  const startQuote = (snippet: QuotedSnippet) => {\n    setQuote(snippet);\n    setFloating(null);\n    window.getSelection()?.removeAllRanges();\n    requestAnimationFrame(() => inputRef.current?.focus());\n  };\n\n  const quoteFromFloating = () => {\n    if (!floating) return;\n    const msg = messages.find((m) => m.id === floating.messageId);\n    if (msg) startQuote({ messageId: msg.id, authorName: msg.author.name, text: floating.text });\n  };\n\n  const copy = async () => {\n    if (!floating) return;\n    try {\n      await navigator.clipboard.writeText(floating.text);\n      setCopied(true);\n    } catch {\n      setCopied(false);\n    }\n  };\n\n  const jumpTo = (q: QuotedSnippet) => {\n    const list = listRef.current;\n    const el = list?.querySelector<HTMLElement>(`[data-message=\"${q.messageId}\"]`);\n    if (list && el) {\n      const offset = el.getBoundingClientRect().top - list.getBoundingClientRect().top;\n      list.scrollTo({ top: list.scrollTop + offset - (list.clientHeight - el.offsetHeight) / 2, behavior: reduce ? \"auto\" : \"smooth\" });\n    }\n    setFlash({ id: q.messageId, text: q.text, on: true });\n    clearTimeout(flashTimer.current);\n    flashTimer.current = setTimeout(() => {\n      setFlash((f) => (f ? { ...f, on: false } : f));\n      flashTimer.current = setTimeout(() => setFlash(null), 900);\n    }, 700);\n  };\n\n  const send = (e?: FormEvent) => {\n    e?.preventDefault();\n    const text = draft.trim();\n    if (!text) return;\n    onSend?.(text, quote);\n    setMessages((m) => [...m, { id: `m-${Date.now()}`, author: currentUser, time: \"now\", text, quote: quote ?? undefined }]);\n    setDraft(\"\");\n    setQuote(null);\n    requestAnimationFrame(() => {\n      const list = listRef.current;\n      list?.scrollTo({ top: list.scrollHeight, behavior: reduce ? \"auto\" : \"smooth\" });\n    });\n  };\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\"relative flex h-[520px] w-full max-w-[420px] flex-col rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}\n      ref={cardRef}\n    >\n      <header className=\"flex items-center justify-between gap-3 border-b px-4 py-3\">\n        <h3 className=\"truncate font-medium text-sm\">{title}</h3>\n        <span className=\"shrink-0 text-muted-foreground text-xs\">Select text to quote</span>\n      </header>\n\n      <div\n        className=\"flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4\"\n        onKeyUp={(e) => e.shiftKey && readSelection()}\n        onMouseUp={() => requestAnimationFrame(readSelection)}\n        onScroll={() => floating && setFloating(null)}\n        ref={listRef}\n      >\n        <AnimatePresence initial={false}>\n          {messages.map((m) => {\n            const mine = m.author.id === currentUser.id;\n            return (\n              <motion.article\n                animate={{ opacity: 1, y: 0 }}\n                className=\"group/msg relative flex gap-2.5\"\n                data-message={m.id}\n                initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}\n                key={m.id}\n                transition={{ ...spring, opacity: { duration: 0.16 } }}\n              >\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"flex size-8 shrink-0 select-none items-center justify-center rounded-full font-medium text-xs\",\n                    m.author.avatarClassName ?? \"bg-muted\",\n                  )}\n                >\n                  {m.author.initials}\n                </span>\n                <div className=\"min-w-0 flex-1\">\n                  <div className=\"flex items-baseline gap-2\">\n                    <span className=\"font-medium text-sm\">{mine ? \"You\" : m.author.name}</span>\n                    <span className=\"text-muted-foreground text-xs\">{m.time}</span>\n                  </div>\n                  {m.quote && (\n                    <button\n                      className=\"mt-1 mb-1.5 block w-full cursor-pointer rounded-lg border-foreground/20 border-s-2 bg-muted/60 py-1.5 ps-2.5 pe-2 text-left outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring\"\n                      onClick={() => jumpTo(m.quote as QuotedSnippet)}\n                      type=\"button\"\n                    >\n                      <span className=\"block font-medium text-muted-foreground text-xs\">{m.quote.authorName}</span>\n                      <span className=\"line-clamp-2 text-pretty text-foreground/80 text-xs leading-relaxed\">{m.quote.text}</span>\n                    </button>\n                  )}\n                  <p\n                    className=\"mt-0.5 text-pretty text-sm leading-relaxed selection:bg-info/25\"\n                    data-quote-source={m.id}\n                  >\n                    <MessageText flash={flash?.id === m.id ? flash : null} text={m.text} />\n                  </p>\n                </div>\n                <button\n                  aria-label={`Quote ${m.author.name}'s message`}\n                  className=\"absolute -top-1 right-0 flex size-7 cursor-pointer items-center justify-center rounded-lg border bg-popover text-muted-foreground opacity-0 shadow-xs/5 outline-none transition-[opacity,color] duration-150 hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover/msg:opacity-100 [@media(hover:none)]:hidden\"\n                  onClick={() => startQuote({ messageId: m.id, authorName: m.author.name, text: m.text })}\n                  type=\"button\"\n                >\n                  <TextQuoteIcon className=\"size-3.5\" />\n                </button>\n              </motion.article>\n            );\n          })}\n        </AnimatePresence>\n      </div>\n\n      <AnimatePresence>\n        {floating && (\n          <motion.div\n            animate={{ opacity: 1, scale: 1, y: 0 }}\n            aria-label=\"Selection\"\n            className=\"absolute z-30 flex items-center gap-0.5 rounded-lg border bg-popover p-0.5 text-popover-foreground shadow-lg/5\"\n            exit={{ opacity: 0, transition: { duration: 0.08 } }}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, y: floating.below ? -4 : 4 }}\n            key=\"floating\"\n            onMouseDown={(e) => e.preventDefault()}\n            role=\"toolbar\"\n            style={{\n              left: floating.x,\n              top: floating.y,\n              translate: floating.below ? \"-50% 0\" : \"-50% -100%\",\n              transformOrigin: floating.below ? \"top center\" : \"bottom center\",\n            }}\n            transition={{ duration: 0.14, ease: [0.23, 1, 0.32, 1] }}\n          >\n            <button\n              className=\"flex h-7 cursor-pointer items-center gap-1.5 rounded-md px-2 font-medium text-xs outline-none transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\"\n              onClick={quoteFromFloating}\n              type=\"button\"\n            >\n              <TextQuoteIcon className=\"size-3.5\" />\n              Quote\n            </button>\n            <span aria-hidden className=\"h-4 w-px bg-border\" />\n            <button\n              aria-label={copied ? \"Copied\" : \"Copy selection\"}\n              className=\"flex size-7 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97]\"\n              onClick={copy}\n              type=\"button\"\n            >\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.span\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={{ opacity: 0, scale: 0.8 }}\n                  initial={{ opacity: 0, scale: 0.8 }}\n                  key={copied ? \"check\" : \"copy\"}\n                  transition={{ duration: 0.12 }}\n                >\n                  {copied ? <CheckIcon className=\"size-3.5 text-success-foreground\" /> : <CopyIcon className=\"size-3.5\" />}\n                </motion.span>\n              </AnimatePresence>\n            </button>\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <form className=\"border-t p-3\" onSubmit={send}>\n        <div className=\"rounded-xl border bg-background transition-shadow focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/24 dark:bg-input/32\">\n          <AnimatePresence initial={false}>\n            {quote && (\n              <motion.div\n                animate={{ height: \"auto\", opacity: 1 }}\n                className=\"overflow-hidden\"\n                exit={{ height: 0, opacity: 0 }}\n                initial={{ height: 0, opacity: 0 }}\n                transition={reduce ? { duration: 0 } : { ...spring, opacity: { duration: 0.14 } }}\n              >\n                <div className=\"flex items-start gap-2 px-2 pt-2\">\n                  <div className=\"min-w-0 flex-1 rounded-lg border-info border-s-2 bg-info/8 py-1.5 ps-2.5 pe-2\">\n                    <span className=\"block font-medium text-info-foreground text-xs\">Replying to {quote.authorName.split(\" \")[0]}</span>\n                    <span className=\"line-clamp-2 text-pretty text-foreground/80 text-xs leading-relaxed\">{quote.text}</span>\n                  </div>\n                  <button\n                    aria-label=\"Remove quote\"\n                    className=\"flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\"\n                    onClick={() => {\n                      setQuote(null);\n                      inputRef.current?.focus();\n                    }}\n                    type=\"button\"\n                  >\n                    <XIcon className=\"size-3.5\" />\n                  </button>\n                </div>\n              </motion.div>\n            )}\n          </AnimatePresence>\n          <div className=\"flex items-end gap-2 ps-3 pe-1 pb-1\">\n            <label className=\"sr-only\" htmlFor={inputId}>\n              Reply\n            </label>\n            <textarea\n              className=\"field-sizing-content max-h-28 min-h-9 min-w-0 flex-1 resize-none bg-transparent py-2 text-sm outline-none placeholder:text-muted-foreground/72\"\n              id={inputId}\n              onChange={(e) => setDraft(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\" && !e.shiftKey) send(e);\n                if (e.key === \"Escape\" && quote) setQuote(null);\n              }}\n              placeholder={quote ? \"Add your reply…\" : \"Reply to the thread\"}\n              ref={inputRef}\n              rows={1}\n              value={draft}\n            />\n            <button\n              aria-label=\"Send\"\n              className=\"mb-1 flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-primary text-primary-foreground outline-none transition-[opacity,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] disabled:cursor-default disabled:opacity-40\"\n              disabled={!draft.trim()}\n              type=\"submit\"\n            >\n              <ArrowUpIcon className=\"size-4\" />\n            </button>\n          </div>\n        </div>\n      </form>\n    </section>\n  );\n}\n\nfunction MessageText({ text, flash }: { text: string; flash: { text: string; on: boolean } | null }) {\n  const needle = flash?.text.replace(/…$/, \"\") ?? \"\";\n  const flat = text.replace(/\\s+/g, \" \");\n  const at = needle ? flat.indexOf(needle) : -1;\n  if (!flash || at < 0) return <>{text}</>;\n  return (\n    <Fragment>\n      {flat.slice(0, at)}\n      <span\n        className={cn(\n          \"rounded-sm [box-decoration-break:clone]\",\n          flash.on ? \"bg-warning/30\" : \"bg-transparent transition-[background-color] duration-700 ease-out\",\n        )}\n      >\n        {flat.slice(at, at + needle.length)}\n      </span>\n      {flat.slice(at + needle.length)}\n    </Fragment>\n  );\n}\n"
    }
  ]
}