{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reply-preview",
  "type": "registry:ui",
  "title": "Reply Preview",
  "description": "The quoted message slides up above the composer and jumps back to its original.",
  "categories": [
    "messaging"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/reply-preview.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { File as FileIcon, Image as ImageIcon, Mic, X } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\nexport type ReplyTarget = {\n  /** The original message's id. It should match data-message-id on the element that renders it. */\n  id: string;\n  author: string;\n  /** The original text. Long text is truncated in place, never cut in the data. */\n  text?: string;\n  /** What the original was, when it wasn't just text. */\n  kind?: \"text\" | \"voice\" | \"image\" | \"file\";\n  /** Voice length, or a file name, shown after the kind: \"Voice message · 0:14\". */\n  detail?: string;\n  /** A small preview for images. */\n  thumbnail?: string;\n  /** The original is gone. The quote stays, says so, and stops being a link. */\n  deleted?: boolean;\n};\n\nconst kindLabel = { voice: \"Voice message\", image: \"Photo\", file: \"File\", text: \"\" } as const;\n\nfunction Summary({ target }: { target: ReplyTarget }) {\n  if (target.deleted) return <span className=\"italic\">Original message was deleted</span>;\n  const kind = target.kind ?? \"text\";\n  if (kind === \"text\") return <>{target.text}</>;\n  const Icon = kind === \"voice\" ? Mic : kind === \"image\" ? ImageIcon : FileIcon;\n  return (\n    <span className=\"inline-flex min-w-0 max-w-full items-center gap-1 align-bottom\">\n      <Icon size={12} className=\"shrink-0 opacity-80\" />\n      <span className=\"truncate\">{target.text || [kindLabel[kind], target.detail].filter(Boolean).join(\" · \")}</span>\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * jumpToMessage: scroll to the original, flash it, and move focus there\n * -----------------------------------------------------------------------------------------------*/\n\nexport type JumpOptions = {\n  /** Where to look for [data-message-id]. Defaults to the document. */\n  root?: ParentNode | null;\n  /** Skip the smooth scroll and the pulse. Defaults to the user's reduced-motion setting. */\n  reduce?: boolean;\n  /** Move focus to the original so keyboard and screen reader users land on it. Default true. */\n  focus?: boolean;\n};\n\nfunction scrollParent(el: HTMLElement) {\n  for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {\n    const { overflowY } = getComputedStyle(node);\n    if ((overflowY === \"auto\" || overflowY === \"scroll\") && node.scrollHeight > node.clientHeight) return node;\n  }\n  return null;\n}\n\nconst reducedNow = () => typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n/** Finds the original by id, brings it to the middle of its scroller, and flashes it. Returns false if it isn't rendered. */\nexport function jumpToMessage(id: string, { root, reduce = reducedNow(), focus = true }: JumpOptions = {}) {\n  const scope = root ?? (typeof document !== \"undefined\" ? document : null);\n  const el = scope?.querySelector<HTMLElement>(`[data-message-id=\"${CSS.escape(id)}\"]`);\n  if (!el) return false;\n\n  // Scroll only the conversation, not the page around it: center the original in its own scroller.\n  const scroller = scrollParent(el);\n  let visible: boolean;\n  if (scroller) {\n    const box = scroller.getBoundingClientRect();\n    const rect = el.getBoundingClientRect();\n    visible = rect.top >= box.top && rect.bottom <= box.bottom;\n    const top = scroller.scrollTop + (rect.top - box.top) - (box.height - rect.height) / 2;\n    scroller.scrollTo({ top, behavior: reduce ? \"auto\" : \"smooth\" });\n  } else {\n    const rect = el.getBoundingClientRect();\n    visible = rect.top >= 0 && rect.bottom <= window.innerHeight;\n    el.scrollIntoView({ block: \"center\", behavior: reduce ? \"auto\" : \"smooth\" });\n  }\n  if (focus) {\n    if (!el.hasAttribute(\"tabindex\")) el.setAttribute(\"tabindex\", \"-1\");\n    el.focus({ preventScroll: true });\n  }\n\n  // Flash once the scroll has (nearly) settled, so the eye arrives before the highlight fades.\n  const flash = () => {\n    el.animate(\n      [{ backgroundColor: \"color-mix(in oklab, var(--fg) 11%, transparent)\" }, { backgroundColor: \"color-mix(in oklab, var(--fg) 11%, transparent)\", offset: 0.25 }, { backgroundColor: \"transparent\" }],\n      { duration: 1400, easing: \"cubic-bezier(0.25, 1, 0.5, 1)\" },\n    );\n    const bubble = el.querySelector<HTMLElement>(\"[data-bubble]\");\n    if (bubble && !reduce) bubble.animate([{ scale: \"1\" }, { scale: \"1.035\" }, { scale: \"1\" }], { duration: 380, easing: \"cubic-bezier(0.16, 1, 0.3, 1)\" });\n  };\n  if (reduce || visible) flash();\n  else window.setTimeout(flash, 320);\n  return true;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * ReplyPreview: the bar above the composer\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ReplyPreviewProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The message being replied to, or null for none. Changing it swaps the contents in place. */\n  target: ReplyTarget | null;\n  onCancel: () => void;\n  /** Pressing the quote. Defaults to jumping to the original in the page. */\n  onJump?: (id: string) => void;\n  /** Words before the author. */\n  label?: string;\n  /** Escape anywhere in the surrounding form cancels the reply. On by default. */\n  escapeToCancel?: boolean;\n};\n\n/**\n * The message you're answering, slid up above the text you're writing. Pressing it takes you\n * to the original; the cross or Escape drops it and hands focus back to the field.\n */\nexport function ReplyPreview({ target, onCancel, onJump, label = \"Replying to\", escapeToCancel = true, className, ...rest }: ReplyPreviewProps) {\n  const reduce = !!useReducedMotion();\n  const ref = useRef<HTMLDivElement>(null);\n  const onCancelRef = useRef(onCancel);\n  useEffect(() => {\n    onCancelRef.current = onCancel;\n  }, [onCancel]);\n\n  // Cancelling removes the button that had focus; the field it belongs to takes it instead.\n  const cancel = () => {\n    const field = ref.current?.closest(\"form\")?.querySelector<HTMLElement>(\"textarea, input:not([type=hidden]):not([type=file])\");\n    field?.focus({ preventScroll: true });\n    onCancelRef.current();\n  };\n\n  const open = !!target;\n  useEffect(() => {\n    if (!open || !escapeToCancel) return;\n    const scope = ref.current?.closest(\"form\") ?? ref.current?.parentElement;\n    if (!scope) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key !== \"Escape\" || e.defaultPrevented) return;\n      e.preventDefault();\n      onCancelRef.current();\n    };\n    scope.addEventListener(\"keydown\", onKey);\n    return () => scope.removeEventListener(\"keydown\", onKey);\n  }, [open, escapeToCancel]);\n\n  return (\n    <div ref={ref} className={cn(\"min-w-0\", className)} {...rest}>\n      <AnimatePresence initial={false}>\n        {target && (\n          <motion.div\n            key=\"reply\"\n            initial={{ height: 0, opacity: 0 }}\n            animate={{ height: \"auto\", opacity: 1 }}\n            exit={{ height: 0, opacity: 0, transition: { height: { duration: reduce ? 0 : 0.18, ease: ease.inOut }, opacity: { duration: 0.1 } } }}\n            transition={reduce ? { duration: 0.15, height: { duration: 0 } } : { height: { duration: 0.24, ease: ease.out }, opacity: { duration: 0.18 } }}\n            className=\"overflow-hidden\"\n          >\n            <motion.div\n              initial={reduce ? false : { y: 10, filter: \"blur(2px)\" }}\n              animate={{ y: 0, filter: \"blur(0px)\" }}\n              transition={{ duration: 0.28, ease: ease.out }}\n              className=\"flex items-stretch gap-1 pb-0.5 pl-3 pr-1.5 pt-1.5\"\n            >\n              <AnimatePresence initial={false} mode=\"popLayout\">\n                <motion.button\n                  key={target.id}\n                  type=\"button\"\n                  disabled={target.deleted}\n                  onClick={() => (onJump ? onJump(target.id) : jumpToMessage(target.id))}\n                  aria-label={`${label} ${target.author}: ${target.deleted ? \"original message was deleted\" : target.text || kindLabel[target.kind ?? \"text\"]}. Show original`}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n                  transition={{ duration: 0.2, ease: ease.out }}\n                  className={cn(\n                    \"group/quote relative flex min-w-0 flex-1 items-center gap-2.5 rounded-lg py-1 pl-2.5 pr-2 text-left outline-none\",\n                    \"transition-[background-color] duration-150 hover:bg-hover disabled:hover:bg-transparent\",\n                    \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-4\",\n                  )}\n                >\n                  <span aria-hidden className=\"absolute bottom-1.5 left-0 top-1.5 w-[2px] rounded-full bg-fg-3 transition-colors duration-150 group-hover/quote:bg-fg-2\" />\n                  <span className=\"flex min-w-0 flex-1 flex-col\">\n                    <span className=\"truncate text-[11.5px] leading-4 text-fg-3\">\n                      {label} <span className=\"font-medium text-fg\">{target.author}</span>\n                    </span>\n                    <span className=\"truncate text-[12.5px] leading-[18px] text-fg-2\">\n                      <Summary target={target} />\n                    </span>\n                  </span>\n                  {target.thumbnail && !target.deleted && (\n                    // eslint-disable-next-line @next/next/no-img-element -- a 32px preview of the quoted image\n                    <img src={target.thumbnail} alt=\"\" className=\"size-8 shrink-0 rounded-md border border-line object-cover\" />\n                  )}\n                </motion.button>\n              </AnimatePresence>\n              <button\n                type=\"button\"\n                aria-label=\"Cancel reply\"\n                aria-keyshortcuts={escapeToCancel ? \"Escape\" : undefined}\n                onClick={cancel}\n                className={cn(\n                  \"relative grid size-7 shrink-0 self-center place-items-center rounded-full text-fg-3 outline-none\",\n                  \"transition-[background-color,color,scale] duration-150 ease-out hover:bg-hover hover:text-fg active:scale-[0.9] active:duration-75\",\n                  \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                  \"before:absolute before:-inset-2 before:rounded-full before:content-[''] pointer-fine:before:hidden\",\n                )}\n              >\n                <X size={14} />\n              </button>\n            </motion.div>\n          </motion.div>\n        )}\n      </AnimatePresence>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {target ? `${label} ${target.author}` : \"\"}\n      </span>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * ReplyQuote: the quote inside a reply's bubble\n * -----------------------------------------------------------------------------------------------*/\n\nexport type ReplyQuoteProps = Omit<React.ComponentProps<\"button\">, \"children\" | \"onClick\"> & {\n  target: ReplyTarget;\n  /** Pressing the quote. Defaults to jumping to the original in the page. */\n  onJump?: (id: string) => void;\n  /** Lines of text before it's cut. */\n  lines?: 1 | 2;\n};\n\n/**\n * A quote that sits inside any bubble. It takes its color from the text around it, so it reads\n * on a filled bubble and a quiet one alike, and it takes you to the original when pressed.\n */\nexport function ReplyQuote({ target, onJump, lines = 2, className, ...rest }: ReplyQuoteProps) {\n  return (\n    <button\n      type=\"button\"\n      disabled={target.deleted}\n      onClick={() => (onJump ? onJump(target.id) : jumpToMessage(target.id))}\n      aria-label={`Reply to ${target.author}: ${target.deleted ? \"original message was deleted\" : target.text || kindLabel[target.kind ?? \"text\"]}. Show original`}\n      data-deleted={target.deleted || undefined}\n      className={cn(\n        \"group/quote relative flex w-full min-w-0 items-center gap-2 overflow-hidden rounded-lg bg-current/[0.07] py-1.5 pl-3 pr-2 text-left outline-none\",\n        \"transition-[background-color,scale] duration-150 ease-out hover:bg-current/[0.12] active:scale-[0.985] active:duration-75\",\n        \"disabled:cursor-default disabled:hover:bg-current/[0.07] disabled:active:scale-100\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-current/50\",\n        className,\n      )}\n      {...rest}\n    >\n      <span aria-hidden className=\"absolute inset-y-0 left-0 w-[3px] bg-current opacity-35 transition-opacity duration-150 group-hover/quote:opacity-60\" />\n      <span className=\"flex min-w-0 flex-1 flex-col\">\n        <span className=\"truncate text-[11.5px] font-medium leading-4\">{target.author}</span>\n        <span className={cn(\"text-[12px] leading-4 opacity-70\", lines === 1 ? \"truncate\" : \"line-clamp-2 break-words\")}>\n          <Summary target={target} />\n        </span>\n      </span>\n      {target.thumbnail && !target.deleted && (\n        // eslint-disable-next-line @next/next/no-img-element -- a small preview of the quoted image\n        <img src={target.thumbnail} alt=\"\" className=\"size-8 shrink-0 rounded-md object-cover\" />\n      )}\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/reply-preview"
}
