{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"typing-indicator","type":"registry:ui","title":"Typing Indicator","description":"Someone is writing.","dependencies":["motion"],"categories":["notification"],"docs":"https://www.interior.dev/docs/typing-indicator","files":[{"path":"registry/interior/typing-indicator.tsx","type":"registry:ui","target":"components/interior/typing-indicator.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useTransform,\n  type MotionValue,\n} from \"motion/react\";\n\nconst WAVE_MS = 1.25;\n\nconst SURFACE = { type: \"spring\", stiffness: 380, damping: 30, mass: 0.8 } as const;\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst EASE = [0.23, 1, 0.32, 1] as const;\nconst LEAVE = [0.4, 0, 1, 1] as const;\nconst INSTANT = { duration: 0 } as const;\n\nconst SEND_MS = 340;\n\nexport type UseTypingPresenceOptions = {\n  timeout?: number;\n  minVisible?: number;\n};\n\nexport type TypingPresence = {\n  typists: string[];\n  beat: number;\n  sending: boolean;\n  ping: (name: string) => void;\n  send: (name: string) => void;\n  clear: (name: string) => void;\n  reset: () => void;\n};\n\nexport function useTypingPresence({\n  timeout = 3000,\n  minVisible = 900,\n}: UseTypingPresenceOptions = {}): TypingPresence {\n  const [presence, setPresence] = useState<{ typists: string[]; beat: number }>({\n    typists: [],\n    beat: 0,\n  });\n  const [sending, setSending] = useState(false);\n\n  const seen = useRef(new Map<string, number>());\n  const shown = useRef<string[]>([]);\n  const shownAt = useRef(0);\n  const sweep = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const release = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const settle = useRef<(bump: boolean) => void>(() => {});\n\n  const commit = useCallback(\n    (bump: boolean) => {\n      const now = Date.now();\n\n      for (const [name, at] of seen.current) {\n        if (at + timeout <= now) seen.current.delete(name);\n      }\n\n      let next = Infinity;\n      for (const at of seen.current.values()) next = Math.min(next, at + timeout);\n\n      let roster = Array.from(seen.current.keys());\n      if (roster.length === 0 && shown.current.length > 0) {\n        const until = shownAt.current + minVisible;\n        if (until > now) {\n          roster = shown.current;\n          next = Math.min(next, until);\n        }\n      }\n\n      const changed =\n        roster.length !== shown.current.length ||\n        roster.some((name, i) => name !== shown.current[i]);\n\n      if (changed) {\n        if (shown.current.length === 0) shownAt.current = now;\n        shown.current = roster;\n      }\n\n      if (changed || bump) {\n        setPresence((prev) => ({\n          typists: changed ? roster : prev.typists,\n\n          beat: changed && roster.length === 0 ? 0 : bump ? prev.beat + 1 : prev.beat,\n        }));\n      }\n\n      if (sweep.current) clearTimeout(sweep.current);\n      sweep.current =\n        next === Infinity\n          ? null\n          : setTimeout(() => settle.current(false), Math.max(24, next - now));\n    },\n    [timeout, minVisible],\n  );\n\n  settle.current = commit;\n\n  const ping = useCallback(\n    (name: string) => {\n      if (release.current) {\n        clearTimeout(release.current);\n        release.current = null;\n        setSending(false);\n        shown.current = [];\n        shownAt.current = 0;\n      }\n      seen.current.set(name, Date.now());\n      commit(true);\n    },\n    [commit],\n  );\n\n  const clear = useCallback(\n    (name: string) => {\n      if (!seen.current.delete(name)) return;\n      commit(false);\n    },\n    [commit],\n  );\n\n  const send = useCallback((name: string) => {\n    if (!seen.current.has(name) && !shown.current.includes(name)) return;\n    seen.current.delete(name);\n    if (sweep.current) clearTimeout(sweep.current);\n    sweep.current = null;\n    setSending(true);\n\n    if (release.current) clearTimeout(release.current);\n    release.current = setTimeout(() => {\n      release.current = null;\n      setSending(false);\n      shown.current = [];\n      shownAt.current = 0;\n      setPresence({ typists: [], beat: 0 });\n\n      if (seen.current.size > 0) settle.current(false);\n    }, SEND_MS);\n  }, []);\n\n  const reset = useCallback(() => {\n    if (sweep.current) clearTimeout(sweep.current);\n    if (release.current) clearTimeout(release.current);\n    sweep.current = null;\n    release.current = null;\n    seen.current.clear();\n    shown.current = [];\n    shownAt.current = 0;\n    setSending(false);\n    setPresence({ typists: [], beat: 0 });\n  }, []);\n\n  useEffect(() => {\n    return () => {\n      if (sweep.current) clearTimeout(sweep.current);\n      if (release.current) clearTimeout(release.current);\n      sweep.current = null;\n      release.current = null;\n    };\n  }, []);\n\n  return {\n    typists: presence.typists,\n    beat: presence.beat,\n    sending,\n    ping,\n    send,\n    clear,\n    reset,\n  };\n}\n\nfunction describe(names: string[], max: number): string {\n  if (names.length === 0) return \"\";\n\n  const head = names.slice(0, Math.max(1, max));\n  const rest = names.length - head.length;\n\n  if (rest > 0) {\n    return `${head.join(\", \")} and ${rest} ${rest === 1 ? \"other\" : \"others\"} are typing`;\n  }\n  if (head.length === 1) return `${head[0]} is typing`;\n\n  return `${head.slice(0, -1).join(\", \")} and ${head[head.length - 1]} are typing`;\n}\n\nfunction Dot({\n  index,\n  wave,\n  size,\n}: {\n  index: number;\n  wave: MotionValue<number>;\n  size: number;\n}) {\n  const lift = useTransform(wave, (w) => {\n    let distance = (w - index) % 3;\n    if (distance < 0) distance += 3;\n    if (distance > 1.5) distance -= 3;\n    return Math.max(0, 1 - Math.abs(distance));\n  });\n\n  const scale = useTransform(lift, [0, 1], [0.74, 1]);\n  const opacity = useTransform(lift, [0, 1], [0.32, 1]);\n\n  return (\n    <motion.span\n      className=\"block rounded-full bg-stone-500 dark:bg-stone-300\"\n      style={{ width: size, height: size, scale, opacity }}\n    />\n  );\n}\n\nexport type TypingIndicatorProps = {\n  typists: string[];\n  sending?: boolean;\n  max?: number;\n\n  size?: number;\n  showLabel?: boolean;\n  announceAfter?: number;\n  className?: string;\n};\n\nexport function TypingIndicator({\n  typists,\n  sending = false,\n  max = 2,\n  size = 34,\n  showLabel = true,\n  announceAfter = 700,\n  className = \"\",\n}: TypingIndicatorProps) {\n  const reduced = useReducedMotion();\n\n  const label = useMemo(() => describe(typists, max), [typists, max]);\n  const active = typists.length > 0;\n\n  const wave = useMotionValue(0);\n  useEffect(() => {\n    if (!active || reduced) {\n      wave.jump(0);\n      return;\n    }\n    const controls = animate(wave, 3, {\n      duration: WAVE_MS,\n      ease: \"linear\",\n      repeat: Infinity,\n      repeatType: \"loop\",\n    });\n    return () => controls.stop();\n  }, [active, reduced, wave]);\n\n  const [announced, setAnnounced] = useState(label);\n  useEffect(() => {\n    const timer = setTimeout(() => setAnnounced(label), announceAfter);\n    return () => clearTimeout(timer);\n  }, [label, announceAfter]);\n\n  const width = Math.round(size * 2);\n  const dot = Math.round(size * 0.23);\n  const gap = Math.round(size * 0.15);\n  const radius = Math.round(size * 0.47);\n\n  return (\n    <div\n      className={`inline-flex max-w-full items-end gap-3 ${className}`}\n      style={{ height: size }}\n    >\n      <div className=\"relative shrink-0\" style={{ width, height: size }}>\n        <AnimatePresence initial={false}>\n          {active ? (\n            <motion.div\n              key=\"bubble\"\n              aria-hidden\n              className=\"absolute inset-0 flex items-center justify-center bg-stone-200 dark:bg-white/[0.09]\"\n              style={{ borderRadius: radius, transformOrigin: \"0% 100%\", gap }}\n              initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.74 }}\n              animate={\n                sending && !reduced\n                  ? { opacity: 0, scale: 0.45, y: 0 }\n                  : { opacity: 1, scale: 1, y: 0 }\n              }\n              exit={\n                reduced\n                  ? { opacity: 0, transition: INSTANT }\n                  : {\n                      opacity: 0,\n                      scale: 0.4,\n                      transition: { duration: 0.26, ease: EASE },\n                    }\n              }\n              transition={\n                reduced\n                  ? INSTANT\n                  : sending\n                    ? { duration: SEND_MS / 1000, ease: LEAVE }\n                    : { ...SURFACE, opacity: { duration: 0.18, ease: EASE } }\n              }\n            >\n              {[0, 1, 2].map((i) =>\n                reduced ? (\n                  <span\n                    key={i}\n                    className=\"block rounded-full bg-stone-500 opacity-80 dark:bg-stone-300\"\n                    style={{ width: dot, height: dot }}\n                  />\n                ) : (\n                  <Dot key={i} index={i} wave={wave} size={dot} />\n                ),\n              )}\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n\n      {showLabel ? (\n        <span className=\"grid min-w-0 flex-1\" style={{ height: size * 0.6 }}>\n          <AnimatePresence initial={false}>\n            {label && !sending ? (\n              <motion.span\n                key={label}\n                aria-hidden\n                className=\"col-start-1 row-start-1 self-center truncate text-[13px] text-stone-500 dark:text-stone-400\"\n                initial={reduced ? { opacity: 0 } : { opacity: 0, y: 7 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={reduced ? { opacity: 0 } : { opacity: 0, y: -7 }}\n                transition={reduced ? INSTANT : CROSSFADE}\n              >\n                {label}\n              </motion.span>\n            ) : null}\n          </AnimatePresence>\n        </span>\n      ) : null}\n\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {announced}\n      </span>\n    </div>\n  );\n}\n"}]}