{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "read-receipts",
  "type": "registry:component",
  "title": "Read Receipts",
  "description": "Tiny faces that glide down a conversation as people catch up, stacking under the last message they saw and folding into +N when it gets crowded.",
  "categories": [
    "component",
    "Together"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/devl/read-receipts.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowUpIcon, CheckCheckIcon } from \"lucide-react\";\nimport { AnimatePresence, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { type FormEvent, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from \"@/components/ui/tooltip\";\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 ReceiptPerson {\n  id: string;\n  name: string;\n  initials: string;\n  avatarClassName?: string;\n}\n\nexport interface ReceiptMessage {\n  id: string;\n  from: string;\n  text: string;\n}\n\nexport interface ReadReceiptsProps {\n  title?: string;\n  people: ReceiptPerson[];\n  messages: ReceiptMessage[];\n  readUpTo: Record<string, string>;\n  onSend?: (text: string) => void;\n  max?: number;\n  className?: string;\n}\n\nconst spring = { type: \"spring\", stiffness: 420, damping: 41 } as const;\n\nfunction names(list: ReceiptPerson[]) {\n  const first = list.map((p) => p.name.split(\" \")[0]);\n  if (first.length <= 2) return first.join(\" and \");\n  if (first.length === 3) return `${first[0]}, ${first[1]} and ${first[2]}`;\n  return `${first.slice(0, 2).join(\", \")} and ${first.length - 2} others`;\n}\n\nexport function ReadReceipts({\n  title = \"design-crit\",\n  people,\n  messages,\n  readUpTo,\n  onSend,\n  max = 3,\n  className,\n}: ReadReceiptsProps) {\n  const reduce = useSafeReducedMotion();\n  const groupId = useId();\n  const inputId = useId();\n  const [draft, setDraft] = useState(\"\");\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const moved = useRef(new Map<string, number>());\n  const prev = useRef<Record<string, string>>(readUpTo);\n\n  for (const p of people) {\n    if (prev.current[p.id] !== readUpTo[p.id]) moved.current.set(p.id, performance.now());\n  }\n  useEffect(() => {\n    prev.current = readUpTo;\n  });\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (el) el.scrollTo({ top: el.scrollHeight, behavior: reduce ? \"auto\" : \"smooth\" });\n  }, [messages.length, reduce]);\n\n  const byMessage = useMemo(() => {\n    const map = new Map<string, ReceiptPerson[]>();\n    for (const p of people) {\n      const at = readUpTo[p.id];\n      if (!at) continue;\n      const list = map.get(at) ?? [];\n      list.push(p);\n      map.set(at, list);\n    }\n    for (const list of map.values()) {\n      list.sort((a, b) => (moved.current.get(b.id) ?? 0) - (moved.current.get(a.id) ?? 0));\n    }\n    return map;\n  }, [people, readUpTo]);\n\n  const lastMine = [...messages].reverse().find((m) => m.from === \"me\");\n  const indexOf = (id: string | undefined) => (id ? messages.findIndex((m) => m.id === id) : -1);\n  const lastMineIndex = indexOf(lastMine?.id);\n  const seenLastMine = lastMine ? people.filter((p) => indexOf(readUpTo[p.id]) >= lastMineIndex).length : 0;\n\n  const submit = (e: FormEvent) => {\n    e.preventDefault();\n    const text = draft.trim();\n    if (!text) return;\n    onSend?.(text);\n    setDraft(\"\");\n  };\n\n  return (\n    <section\n      aria-label={`#${title}`}\n      className={cn(\"flex w-full max-w-[400px] flex-col rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex items-center justify-between gap-3 border-b px-4 py-3\">\n        <h3 className=\"font-medium text-sm\">\n          <span className=\"text-muted-foreground\">#</span>\n          {title}\n        </h3>\n        <span className=\"text-muted-foreground text-xs tabular-nums\">{people.length + 1} people</span>\n      </header>\n\n      <TooltipProvider delay={300}>\n        <LayoutGroup id={groupId}>\n          <div className=\"h-[340px] overflow-y-auto px-4 pt-4 [mask-image:linear-gradient(to_bottom,transparent,black_16px)]\" ref={scrollRef}>\n            <ol className=\"flex flex-col\">\n              {messages.map((m, i) => {\n                const mine = m.from === \"me\";\n                const author = people.find((p) => p.id === m.from);\n                const prevFrom = messages[i - 1]?.from;\n                const readers = byMessage.get(m.id) ?? [];\n                const shown = readers.length > max ? readers.slice(0, max - 1) : readers;\n                const extra = readers.length - shown.length;\n                return (\n                  <motion.li\n                    animate={{ opacity: 1, y: 0 }}\n                    className={cn(\"relative flex flex-col pb-6\", mine ? \"items-end\" : \"items-start\")}\n                    initial={reduce ? false : { opacity: 0, y: 8 }}\n                    key={m.id}\n                    transition={{ ...spring, opacity: { duration: 0.16 } }}\n                  >\n                    {!mine && author && prevFrom !== m.from && (\n                      <span className=\"mb-1 ps-9 text-muted-foreground text-xs\">{author.name.split(\" \")[0]}</span>\n                    )}\n                    <div className={cn(\"flex max-w-[85%] items-end gap-2\", mine && \"flex-row-reverse\")}>\n                      {!mine &&\n                        (author && messages[i + 1]?.from !== m.from ? (\n                          <span\n                            aria-hidden\n                            className={cn(\n                              \"flex size-7 shrink-0 select-none items-center justify-center rounded-full font-medium text-[10px]\",\n                              author.avatarClassName ?? \"bg-muted\",\n                            )}\n                          >\n                            {author.initials}\n                          </span>\n                        ) : (\n                          <span className=\"w-7 shrink-0\" />\n                        ))}\n                      <p\n                        className={cn(\n                          \"text-pretty rounded-2xl px-3 py-2 text-sm\",\n                          mine ? \"rounded-br-md bg-primary text-primary-foreground\" : \"rounded-bl-md bg-muted\",\n                        )}\n                      >\n                        {m.text}\n                      </p>\n                    </div>\n\n                    <div className=\"absolute right-0 bottom-0.5 flex h-5 items-center gap-1.5\">\n                      {mine && m.id === lastMine?.id && seenLastMine === 0 && (\n                        <span className=\"flex items-center gap-1 text-muted-foreground text-xs\">\n                          <CheckCheckIcon className=\"size-3\" />\n                          Delivered\n                        </span>\n                      )}\n                      {readers.length > 0 && (\n                        <Tooltip>\n                          <TooltipTrigger\n                            render={\n                              <button\n                                aria-label={`Seen by ${names(readers)}`}\n                                className=\"flex cursor-default items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n                                type=\"button\"\n                              />\n                            }\n                          >\n                            <AnimatePresence initial={false} mode=\"popLayout\">\n                              {shown.map((p, k) => (\n                                <motion.span\n                                  animate={{ opacity: 1, scale: 1 }}\n                                  className={cn(\n                                    \"relative flex size-5 select-none items-center justify-center rounded-full font-semibold text-[9px] ring-2 ring-card\",\n                                    k > 0 && \"-ms-1\",\n                                    p.avatarClassName ?? \"bg-muted\",\n                                  )}\n                                  exit={{ opacity: 0, scale: 0.6 }}\n                                  initial={{ opacity: 0, scale: 0.6 }}\n                                  key={p.id}\n                                  layoutId={p.id}\n                                  style={{ zIndex: max - k }}\n                                  transition={reduce ? { duration: 0 } : spring}\n                                >\n                                  {p.initials.charAt(0)}\n                                </motion.span>\n                              ))}\n                              {extra > 0 && (\n                                <motion.span\n                                  animate={{ opacity: 1, scale: 1 }}\n                                  className=\"relative -ms-1 flex h-5 min-w-6 items-center justify-center rounded-full bg-card px-1 font-medium text-[9px] text-muted-foreground tabular-nums ring-2 ring-card\"\n                                  exit={{ opacity: 0, scale: 0.6 }}\n                                  initial={{ opacity: 0, scale: 0.6 }}\n                                  key=\"extra\"\n                                  layout\n                                  transition={reduce ? { duration: 0 } : spring}\n                                >\n                                  <span className=\"absolute inset-0 rounded-full bg-muted\" />\n                                  <span className=\"relative\">+{extra}</span>\n                                </motion.span>\n                              )}\n                            </AnimatePresence>\n                          </TooltipTrigger>\n                          <TooltipPopup>Seen by {names(readers)}</TooltipPopup>\n                        </Tooltip>\n                      )}\n                    </div>\n                  </motion.li>\n                );\n              })}\n            </ol>\n          </div>\n        </LayoutGroup>\n      </TooltipProvider>\n\n      <form className=\"border-t p-3\" onSubmit={submit}>\n        <label className=\"sr-only\" htmlFor={inputId}>\n          Message #{title}\n        </label>\n        <div className=\"flex items-center gap-2 rounded-xl border bg-background ps-3 pe-1 transition-shadow focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/24 dark:bg-input/32\">\n          <input\n            autoComplete=\"off\"\n            className=\"h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/72\"\n            id={inputId}\n            onChange={(e) => setDraft(e.target.value)}\n            placeholder={`Message #${title}`}\n            value={draft}\n          />\n          <button\n            aria-label=\"Send\"\n            className=\"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      </form>\n    </section>\n  );\n}\n"
    }
  ]
}