{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "imessage-chat-flow",
  "title": "UI iMessage Chat Flow",
  "description": "An iMessage-styled messaging composition: gray incoming and blue outgoing bubbles with tails, an in-thread typing bubble with dots, a Delivered label, and tapback-style reaction badges. The composer types the outgoing message before sending. Chrome icons come from lucide-react; self-contained otherwise, composing the caret and typing-indicator primitives.",
  "dependencies": ["remotion", "lucide-react"],
  "registryDependencies": [
    "@remocn/remocn-ui",
    "@remocn/caret",
    "@remocn/typing-indicator"
  ],
  "files": [
    {
      "path": "registry/remocn-ui/imessage-chat-flow/index.tsx",
      "content": "\"use client\";\n\nimport { ArrowUp, ChevronLeft, Plus, Video } from \"lucide-react\";\nimport { interpolate, spring, useCurrentFrame, useVideoConfig } from \"remotion\";\nimport { Caret } from \"@/components/remocn/caret\";\nimport { TypingIndicator } from \"@/components/remocn/typing-indicator\";\nimport { type RemocnTheme, revealedText } from \"@/lib/remocn-ui\";\n\nexport interface ImessageMessage {\n  from: \"me\" | \"them\";\n  text: string;\n  reaction?: string;\n}\n\nexport interface ImessageContact {\n  name: string;\n  avatar?: string;\n}\n\nexport interface ImessageChatFlowProps {\n  messages?: ImessageMessage[];\n  contact?: ImessageContact;\n  accentColor?: string;\n  speed?: number;\n  theme?: Partial<RemocnTheme>;\n}\n\nconst MOBILE_WIDTH = 460;\nconst LEAD_IN = 12;\nconst FRAMES_PER_CHAR = 2.2;\nconst MIN_TYPE = 18;\nconst MAX_TYPE = 86;\nconst SEND_GAP = 10;\nconst REVEAL = 14;\nconst REACT_DELAY = 8;\nconst REACT_DUR = 14;\nconst MSG_GAP = 18;\nconst TYPING_MIN = 34;\nconst TYPING_MAX = 70;\nconst TAIL = 28;\nconst PRESS_WINDOW = 7;\n\nconst IMESSAGE_BLUE = \"#0a7cff\";\nconst SYSTEM_BLUE = \"#007aff\";\nconst INCOMING_BG = \"#e9e9eb\";\nconst INCOMING_FG = \"#000000\";\nconst OUTGOING_FG = \"#ffffff\";\nconst META_GRAY = \"#8e8e93\";\n\nconst DEFAULT_MESSAGES: ImessageMessage[] = [\n  { from: \"me\", text: \"Hey — ready for the demo?\" },\n  { from: \"them\", text: \"Yep, pushing it live now\", reaction: \"❤️\" },\n  { from: \"me\", text: \"Perfect, sending the link over\", reaction: \"👍\" },\n];\n\nfunction clamp(value: number, lo: number, hi: number): number {\n  return Math.max(lo, Math.min(hi, value));\n}\n\nexport interface ScheduledMessage {\n  index: number;\n  from: \"me\" | \"them\";\n  text: string;\n  reaction?: string;\n  presenceStart: number;\n  typeStart?: number;\n  sendAt?: number;\n  typingStart?: number;\n  revealAt: number;\n  reactAt?: number;\n}\n\nexport interface ImessageChatFlowSchedule {\n  items: ScheduledMessage[];\n  duration: number;\n}\n\nexport function imessageChatFlowSchedule(\n  messages: ImessageMessage[],\n): ImessageChatFlowSchedule {\n  const items: ScheduledMessage[] = [];\n  let cursor = LEAD_IN;\n\n  messages.forEach((message, index) => {\n    const hasReaction =\n      message.reaction !== undefined && message.reaction !== \"\";\n    if (message.from === \"me\") {\n      const typeStart = cursor;\n      const typeDur = clamp(\n        Math.round(message.text.length * FRAMES_PER_CHAR),\n        MIN_TYPE,\n        MAX_TYPE,\n      );\n      const sendAt = typeStart + typeDur + SEND_GAP;\n      const revealAt = sendAt;\n      const reactAt = hasReaction ? revealAt + REVEAL + REACT_DELAY : undefined;\n      items.push({\n        index,\n        from: \"me\",\n        text: message.text,\n        reaction: hasReaction ? message.reaction : undefined,\n        presenceStart: sendAt - 2,\n        typeStart,\n        sendAt,\n        revealAt,\n        reactAt,\n      });\n      cursor =\n        revealAt +\n        REVEAL +\n        (hasReaction ? REACT_DELAY + REACT_DUR : 0) +\n        MSG_GAP;\n    } else {\n      const typingStart = cursor;\n      const typingDur = clamp(\n        Math.round(message.text.length * FRAMES_PER_CHAR),\n        TYPING_MIN,\n        TYPING_MAX,\n      );\n      const revealAt = typingStart + typingDur;\n      const reactAt = hasReaction ? revealAt + REVEAL + REACT_DELAY : undefined;\n      items.push({\n        index,\n        from: \"them\",\n        text: message.text,\n        reaction: hasReaction ? message.reaction : undefined,\n        presenceStart: typingStart,\n        typingStart,\n        revealAt,\n        reactAt,\n      });\n      cursor =\n        revealAt +\n        REVEAL +\n        (hasReaction ? REACT_DELAY + REACT_DUR : 0) +\n        MSG_GAP;\n    }\n  });\n\n  const duration = Math.max(cursor - MSG_GAP + TAIL, LEAD_IN + TAIL);\n  return { items, duration };\n}\n\nexport function imessageChatFlowDuration(\n  messages: ImessageMessage[] = DEFAULT_MESSAGES,\n  speed = 1,\n): number {\n  const raw = imessageChatFlowSchedule(messages).duration;\n  return Math.ceil(raw / (speed <= 0 ? 1 : speed));\n}\n\nexport function sendPulse(items: ScheduledMessage[], eff: number): number {\n  let best = 0;\n  for (const item of items) {\n    if (item.sendAt === undefined) continue;\n    const distance = Math.abs(eff - item.sendAt);\n    if (distance <= PRESS_WINDOW) {\n      best = Math.max(best, 1 - distance / PRESS_WINDOW);\n    }\n  }\n  return best;\n}\n\nfunction Avatar({ contact, size }: { contact: ImessageContact; size: number }) {\n  const initial = contact.name.trim().charAt(0).toUpperCase();\n  return (\n    <div\n      style={{\n        flexShrink: 0,\n        width: size,\n        height: size,\n        minWidth: size,\n        borderRadius: \"50%\",\n        overflow: \"hidden\",\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        background: \"linear-gradient(180deg, #b8c0c9 0%, #8e99a4 100%)\",\n        color: \"#ffffff\",\n        fontSize: size * 0.42,\n        fontWeight: 600,\n        fontFamily:\n          \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\",\n      }}\n    >\n      {contact.avatar !== undefined ? (\n        // biome-ignore lint/performance/noImgElement: Remotion output, not a Next.js app — next/image isn't available where this component ships\n        <img\n          src={contact.avatar}\n          alt={contact.name}\n          style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\" }}\n        />\n      ) : (\n        initial\n      )}\n    </div>\n  );\n}\n\nfunction BubbleTail({\n  side,\n  color,\n}: {\n  side: \"left\" | \"right\";\n  color: string;\n}) {\n  const path =\n    side === \"right\"\n      ? \"M0 0 C2 8 6 12 12 13 C7 14 1 13 0 8 Z\"\n      : \"M13 0 C11 8 7 12 1 13 C6 14 12 13 13 8 Z\";\n  return (\n    <svg\n      width={13}\n      height={14}\n      viewBox=\"0 0 13 14\"\n      style={{\n        position: \"absolute\",\n        bottom: 0,\n        left: side === \"left\" ? -5 : undefined,\n        right: side === \"right\" ? -5 : undefined,\n      }}\n    >\n      <path d={path} fill={color} />\n    </svg>\n  );\n}\n\nexport function ImessageChatFlow({\n  messages = DEFAULT_MESSAGES,\n  contact,\n  accentColor,\n  speed = 1,\n}: ImessageChatFlowProps) {\n  const frame = useCurrentFrame();\n  const eff = frame * speed;\n  const accent = accentColor ?? IMESSAGE_BLUE;\n\n  const { items } = imessageChatFlowSchedule(messages);\n\n  const activeMe = items.find(\n    (item) =>\n      item.from === \"me\" &&\n      item.typeStart !== undefined &&\n      item.sendAt !== undefined &&\n      eff >= item.typeStart &&\n      eff < item.sendAt,\n  );\n  let composerText = \"\";\n  let typing = false;\n  if (\n    activeMe &&\n    activeMe.typeStart !== undefined &&\n    activeMe.sendAt !== undefined\n  ) {\n    const typeDur = Math.max(\n      activeMe.sendAt - SEND_GAP - activeMe.typeStart,\n      1,\n    );\n    const progress = clamp((eff - activeMe.typeStart) / typeDur, 0, 1);\n    composerText = revealedText(\n      activeMe.text,\n      Math.floor(progress * activeMe.text.length),\n    );\n    typing = true;\n  }\n\n  const sendActive = composerText.length > 0;\n  const sendScale = 1 - 0.16 * sendPulse(items, eff);\n  const present = items.filter((item) => eff >= item.presenceStart);\n\n  let lastMeIndex = -1;\n  items.forEach((item) => {\n    if (item.from === \"me\") lastMeIndex = item.index;\n  });\n  const deliveredIndex = lastMeIndex === messages.length - 1 ? lastMeIndex : -1;\n\n  return (\n    <div\n      style={{\n        position: \"relative\",\n        width: \"100%\",\n        height: \"100%\",\n        display: \"flex\",\n        justifyContent: \"center\",\n        background: \"transparent\",\n        fontFamily:\n          \"var(--font-geist-sans), -apple-system, BlinkMacSystemFont, sans-serif\",\n      }}\n    >\n      <div\n        style={{\n          display: \"flex\",\n          flexDirection: \"column\",\n          width: \"100%\",\n          maxWidth: MOBILE_WIDTH,\n          height: \"100%\",\n          boxSizing: \"border-box\",\n        }}\n      >\n        <div\n          style={{\n            display: \"flex\",\n            alignItems: \"center\",\n            gap: 6,\n            padding: \"10px 12px\",\n            background: \"rgba(255,255,255,0.82)\",\n            backdropFilter: \"blur(12px)\",\n            borderBottom: \"1px solid rgba(0,0,0,0.08)\",\n          }}\n        >\n          <ChevronLeft size={28} color={SYSTEM_BLUE} strokeWidth={2.25} />\n          <div\n            style={{\n              display: \"flex\",\n              flexDirection: \"column\",\n              alignItems: \"center\",\n              gap: 2,\n              flex: 1,\n            }}\n          >\n            {contact !== undefined && <Avatar contact={contact} size={30} />}\n            <span\n              style={{\n                fontSize: 12,\n                fontWeight: 600,\n                color: \"#000000\",\n              }}\n            >\n              {contact?.name ?? \"Chat\"}\n            </span>\n          </div>\n          <Video size={24} color={SYSTEM_BLUE} strokeWidth={2} />\n        </div>\n\n        <div\n          style={{\n            position: \"relative\",\n            flex: 1,\n            minHeight: 0,\n            overflow: \"hidden\",\n          }}\n        >\n          <div\n            style={{\n              display: \"flex\",\n              flexDirection: \"column\",\n              justifyContent: \"flex-end\",\n              gap: 18,\n              minHeight: \"100%\",\n              padding: \"16px 14px 12px\",\n            }}\n          >\n            {present.map((item) => (\n              <ImessageRow\n                key={item.index}\n                item={item}\n                eff={eff}\n                contact={contact}\n                accent={accent}\n                showDelivered={item.index === deliveredIndex}\n              />\n            ))}\n          </div>\n        </div>\n\n        <div\n          style={{\n            display: \"flex\",\n            alignItems: \"flex-end\",\n            gap: 8,\n            padding: \"8px 12px 12px\",\n          }}\n        >\n          <div\n            style={{\n              width: 34,\n              height: 34,\n              borderRadius: \"50%\",\n              display: \"flex\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              background: \"#e9e9eb\",\n            }}\n          >\n            <Plus size={22} color=\"#3c3c43\" strokeWidth={2} />\n          </div>\n          <div\n            style={{\n              flex: 1,\n              display: \"flex\",\n              alignItems: \"center\",\n              gap: 6,\n              minHeight: 36,\n              padding: \"0 6px 0 14px\",\n              borderRadius: 18,\n              border: \"1px solid #d1d1d6\",\n              background: \"#ffffff\",\n            }}\n          >\n            <div\n              style={{\n                flex: 1,\n                display: \"flex\",\n                alignItems: \"center\",\n                fontSize: 16,\n                color: sendActive ? \"#000000\" : \"#9b9ba1\",\n              }}\n            >\n              <span style={{ whiteSpace: \"pre-wrap\", wordBreak: \"break-word\" }}>\n                {sendActive ? composerText : \"iMessage\"}\n              </span>\n              {typing && (\n                <Caret\n                  color={accent}\n                  height={18}\n                  radius={1}\n                  blink\n                  marginLeft={composerText.length > 0 ? 2 : 0}\n                />\n              )}\n            </div>\n            <div\n              style={{\n                flexShrink: 0,\n                width: 28,\n                height: 28,\n                borderRadius: \"50%\",\n                display: \"flex\",\n                alignItems: \"center\",\n                justifyContent: \"center\",\n                background: sendActive ? accent : \"#c6c6cc\",\n                transform: `scale(${sendScale})`,\n              }}\n            >\n              <ArrowUp size={20} color=\"#ffffff\" strokeWidth={2.75} />\n            </div>\n          </div>\n        </div>\n      </div>\n    </div>\n  );\n}\n\nfunction ImessageRow({\n  item,\n  eff,\n  contact,\n  accent,\n  showDelivered,\n}: {\n  item: ScheduledMessage;\n  eff: number;\n  contact?: ImessageContact;\n  accent: string;\n  showDelivered: boolean;\n}) {\n  const { fps } = useVideoConfig();\n  const outgoing = item.from === \"me\";\n  const showTyping =\n    item.from === \"them\" &&\n    item.typingStart !== undefined &&\n    eff < item.revealAt;\n\n  const enter = spring({\n    fps,\n    frame: eff - (showTyping ? (item.typingStart ?? 0) : item.revealAt),\n    config: { damping: 16, stiffness: 200, mass: 0.7 },\n  });\n  const opacity = interpolate(enter, [0, 1], [0, 1]);\n  const translateY = interpolate(enter, [0, 1], [10, 0]);\n  const scale = interpolate(enter, [0, 1], [0.92, 1]);\n\n  let reactionScale = 0;\n  let reactionOpacity = 0;\n  if (item.reactAt !== undefined) {\n    reactionScale = spring({\n      fps,\n      frame: eff - item.reactAt,\n      config: { damping: 11, stiffness: 220, mass: 0.6 },\n    });\n    reactionOpacity = interpolate(\n      eff,\n      [item.reactAt, item.reactAt + 5],\n      [0, 1],\n      {\n        extrapolateLeft: \"clamp\",\n        extrapolateRight: \"clamp\",\n      },\n    );\n  }\n\n  const bg = outgoing ? accent : INCOMING_BG;\n  const fg = outgoing ? OUTGOING_FG : INCOMING_FG;\n  const delivered =\n    showDelivered && !showTyping && eff > item.revealAt + REVEAL;\n\n  const bubble = (\n    <div\n      style={{\n        position: \"relative\",\n        maxWidth: \"72%\",\n        padding: showTyping ? \"11px 14px\" : \"7px 13px 8px\",\n        background: bg,\n        color: fg,\n        borderRadius: 19,\n        borderBottomRightRadius: outgoing ? 7 : 19,\n        borderBottomLeftRadius: outgoing ? 19 : 7,\n        fontSize: 16,\n        lineHeight: 1.32,\n      }}\n    >\n      {showTyping ? (\n        <TypingIndicator color={META_GRAY} />\n      ) : (\n        <span style={{ wordBreak: \"break-word\", overflowWrap: \"break-word\" }}>\n          {item.text}\n        </span>\n      )}\n      {!showTyping && (\n        <BubbleTail side={outgoing ? \"right\" : \"left\"} color={bg} />\n      )}\n      {item.reaction !== undefined && (\n        <div\n          style={{\n            position: \"absolute\",\n            top: -16,\n            left: outgoing ? -14 : undefined,\n            right: outgoing ? undefined : -14,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            width: 30,\n            height: 30,\n            borderRadius: \"50%\",\n            background: INCOMING_BG,\n            fontSize: 15,\n            boxShadow: \"0 1px 2px rgba(0,0,0,0.12)\",\n            opacity: reactionOpacity,\n            transform: `scale(${reactionScale})`,\n            transformOrigin: outgoing ? \"bottom left\" : \"bottom right\",\n          }}\n        >\n          {item.reaction}\n        </div>\n      )}\n    </div>\n  );\n\n  return (\n    <div\n      style={{\n        display: \"flex\",\n        flexDirection: \"column\",\n        alignItems: outgoing ? \"flex-end\" : \"flex-start\",\n        gap: 2,\n        width: \"100%\",\n        opacity,\n        transform: `translateY(${translateY}px) scale(${scale})`,\n        transformOrigin: outgoing ? \"bottom right\" : \"bottom left\",\n      }}\n    >\n      <div\n        style={{\n          display: \"flex\",\n          alignItems: \"flex-end\",\n          justifyContent: outgoing ? \"flex-end\" : \"flex-start\",\n          gap: 7,\n          width: \"100%\",\n        }}\n      >\n        {!outgoing && contact !== undefined && (\n          <Avatar contact={contact} size={28} />\n        )}\n        {bubble}\n      </div>\n      {delivered && (\n        <span\n          style={{\n            fontSize: 11,\n            color: META_GRAY,\n            paddingRight: 4,\n          }}\n        >\n          Delivered\n        </span>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/remocn/imessage-chat-flow.tsx"
    }
  ],
  "type": "registry:component"
}
