{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ios-conversation-list",
  "title": "iOS conversation list",
  "description": "The Messages list screen: large title, rows with avatars and previews, search pill, and compose button.",
  "registryDependencies": [
    "https://imessage.swerdlow.dev/r/group-avatar.json",
    "https://imessage.swerdlow.dev/r/ios-composer.json",
    "https://imessage.swerdlow.dev/r/ios-nav-bar.json"
  ],
  "files": [
    {
      "path": "registry/imessage/ios-conversation-list.tsx",
      "content": "\"use client\";\n\nimport { useState, type ComponentProps, type CSSProperties, type PointerEvent as ReactPointerEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { IosLargeTitle } from \"@/components/message-ui/ios-nav-bar\";\nimport { IosMicIcon } from \"@/components/message-ui/ios-composer\";\nimport { GroupAvatar, groupAvatarMetrics, type GroupParticipant } from \"@/components/message-ui/group-avatar\";\n\n/**\n * iOS 26 Messages list screen, measured from `references/ios/captures/list-light.png` and\n * `list-dark.png` (402×874). Large title 34pt bold at x 16 (baseline 152); rows 86.67 tall from\n * y 168: avatar Ø45 at x 26 (top +20) with 21pt semibold initials, name 17pt semibold at x 83 with\n * its cap height centered 22.33 below the row top, time 15pt secondary whose ink right edge sits at\n * x 364.0 (native formats it with a narrow no-break space, \"1:48 AM\"), chevron 7×12 at x 378.33,\n * preview 15pt secondary on a 20pt line (up to two lines), 1pt separator from x 83 to 386 at the\n * row bottom. Rows are placed with transforms so their third-point pitch is not snapped to whole pixels. Floating bottom bar: glass search pill x 28–314, y 798–846 (magnifier, \"Search\"\n * 17pt medium, mic) and a Ø48 glass compose button centered (350, 822). Glass shadows are painted\n * on a layer beneath both surfaces.\n *\n * Both bottom-bar surfaces are plain circular capsules: fitting the pill's left cap to\n * |u|^n + |v|^n = 1 over r 24 gives n = 2.045 (rmse 0.058 pt) and the compose circle n = 2.015, so\n * neither uses a continuous corner. Magnifier: ring outer diameter 13.49 centered (54.75, 820.22),\n * stroke 1.73; handle a 45deg stroke 2.47 wide ending at (63.17, 828.77). Its SVG box is placed on\n * a whole pixel (top 14) because Chrome snaps an SVG layer's origin down to the nearest CSS pixel,\n * so the ring and handle carry the sub-pixel offsets instead. Compose glyph: a rounded square\n * whose outer box is exactly 19x19 at (340, 813.33), stroke 1.83, outer corner radius 4.05;\n * pencil a 45deg stroke 2.10 wide. Avatar gradient endpoints are a least-squares fit down the\n * avatar's center column.\n *\n * Neither capture shows these, so they are not measured against pixels: a preview long enough to\n * wrap to two lines, a name or preview long enough to truncate, an unread row, and a row with a\n * finger on it. The last two are read out of ChatKit instead; see `UNREAD` and the note above\n * `--ios-list-highlight`.\n *\n * Nor the glass fill's alpha. Both captures have the last row ending 400pt above the bottom bar, so\n * every point of both surfaces sits over the flat page: sampling their glass-only interiors (the pill\n * left of the magnifier, its mid span, right of the mic, and the compose circle either side of its\n * glyph) gives 255 in light and 25 in dark in the capture and in our render alike, mean signed error\n * 0.00 per channel on all five. That pins the light fill to white at any alpha and the dark fill only\n * to alpha × colour = 25. The 90% is carried over from the nav bar, where the same is true; see the\n * note there. It decides how much of a row scrolled under the bar shows through, which nothing here\n * measures.\n */\nexport type IosConversation = {\n  id: string;\n  name: string;\n  initials?: string;\n  /** Contact photo, cropped to the existing circular avatar. */\n  photo?: string;\n  preview: string;\n  time: string;\n  unread?: boolean;\n  /**\n   * The people in a group conversation. Two or more draws `group-avatar.tsx`'s Snowglobe stack on\n   * the framework's `conversationListContactImageDiameter` (45 on the phone) instead of the monogram\n   * circle; one, or none, keeps the monogram, because native skips `SnowglobeUIView` entirely for a\n   * single contact and a lone face must not get a plate.\n   */\n  members?: readonly GroupParticipant[];\n};\n\nexport type IosConversationListProps = Omit<ComponentProps<\"div\">, \"onSelect\"> & {\n  conversations: IosConversation[];\n  onSelect?: (conversation: IosConversation) => void;\n  onCompose?: () => void;\n  onSearch?: () => void;\n  title?: string;\n  /** Space reserved above the title for the status bar. */\n  topInset?: number;\n  /**\n   * The row drawn as though a finger were on it. Controlled; leave it out and the list follows the\n   * real pointer, which is what a product wants. It exists so a scenario can hold the highlight\n   * still for a screenshot — the state itself is a live pointer's, and a seeked frame has no pointer\n   * in it. `null` forces no row, the same as a lifted finger.\n   */\n  pressedId?: string | null;\n};\n\nconst font = \"-apple-system, BlinkMacSystemFont, sans-serif\";\nconst ROW = 86.6667;\n/**\n * The name is laid out on its ink, not on a line box: `top: 14` with `lineHeight: 1` puts its cap\n * line where the capture's is (ours 183.33 device-pt against the capture's 183.67, one device pixel\n * at 3x). A 17 px line box around a 17 px font is smaller than the font's own ascent + descent\n * though - SF Pro Text asks for about 20.3 - so `truncate`'s `overflow: hidden` was cutting the flat\n * bottom off every descender, which is what the row reads as when the name is crowded by the\n * preview under it. The clip box has to grow without the text moving, so the bleed is added as\n * padding and taken back off `top`: `overflow: hidden` clips at the padding box, padding-block does\n * not touch a left/right-anchored element's width, and the content box stays at 14. 3 covers SF's\n * descender (0.156 em = 2.65 at 17) and its accent room with a little to spare; the row is 86.67\n * tall so nothing else is near enough to collide.\n */\nconst NAME_BLEED = 3;\n/**\n * The unread dot, read out of ChatKit 26.5 rather than a capture: neither list capture contains an\n * unread row, so nothing here is measured against pixels. `-[CKUIBehaviorPhone unreadIndicatorImageViewSize]`\n * is {11, 11}, and `_calculateIndicatorFrameForSize:trailing:displayScale:insets:` puts it at\n * (`conversationListCellLeftMargin` 26 - 11) / 2 horizontally and centred on the row vertically.\n * `shouldUnreadIndicatorChangeOnSelection` is NO on iOS, so unlike macOS the dot never turns white.\n */\nconst UNREAD = 11;\nconst UNREAD_LEFT = 7.5;\n\n/**\n * The touch highlight: what a row fills with while a finger is on it.\n *\n * **No capture holds one** — scanning the row gutter (device column 30) down the whole list of both\n * `list-light.png` and `list-dark.png` returns one flat run, #ffffff for 1900 px and #000000 for\n * 1920 px, so neither capture has a pressed row in it. The colour is read out of ChatKit 26.5\n * instead, and two independent readings agree on it:\n *\n * - `-[CKUITheme conversationListSelectedCellColor]` (the phone theme; there is no `CKUIThemePhone`,\n *   the base class *is* it) resolves to **#dcdcdc opaque light / #464646 opaque dark**. The same\n *   selector is `#0088ff / #0091ff` on `CKUIThemePad` — iPadOS's persistent blue list selection —\n *   and **null** on `CKUIThemeMac`, which is why the Mac's own row fill comes from a capture and not\n *   from here. `-[CKUITheme detailsSelectedCellColor]` is the same #dcdcdc / #464646 pair, and a\n *   details row has no persistent selection at all, so on the phone this colour is a *tap*\n *   highlight, not a selection.\n * - `+[UIBackgroundConfiguration listPlainCellConfiguration]` resolved for a `selected`\n *   `UICellConfigurationState` returns the same #dcdcdc / #464646 at both idiom 0 and idiom 5. So\n *   the value is UIKit's own plain-list fill and ChatKit is restating it, not overriding it.\n *\n * `CKConversationListCell` is a **`UITableViewCell`** (not a collection-view cell), and its\n * `selectionStyle` is `Default`. A `UITableViewCell` shows its `selectedBackgroundView` for\n * `highlighted` as well as `selected`, which on a phone — where a row is never left selected — is\n * only ever the moment a finger is down. Hence: on at touch-down, off at lift, no fade.\n *\n * The labels do **not** change. `-[CKUIBehaviorPhone useSelectedAppearanceForConversationCellState:\n * traitCollection:]` returns YES for `selected` and **NO for `highlighted` alone** (probed over all\n * four state combinations in both appearances), so nothing in the row goes white under the finger —\n * and the unread dot stays blue for the separate reason already noted above\n * (`shouldUnreadIndicatorChangeOnSelection` is NO on the phone).\n *\n * **UNMEASURED: the separators.** `UITableView` hides the separator at a highlighted row's own\n * bottom edge and the one above it, so the fill reads as one unbroken band; that is the same rule\n * `macos-sidebar.tsx` already records for its selected row. No capture holds it here, because no\n * capture holds a pressed row at all.\n *\n * The two values live in `--ios-list-highlight` in `vars` below.\n */\nconst vars =\n  \"[--ios-list-bg:#ffffff] [--ios-list-label:#000000] [--ios-list-secondary:#8a8a8e] [--ios-list-chevron:#c5c5c7] [--ios-list-separator:#e8e8e8] \" +\n  \"[--ios-list-glass:rgba(255,255,255,0.9)] [--ios-list-rim:none] [--ios-list-shadow:0_6px_36px_4px_rgba(0,0,0,0.065)] [--ios-list-shadow-round:0_5px_20px_6px_rgba(0,0,0,0.055)] [--ios-list-glyph:#1a1919] [--ios-list-field:#8a8a8e] \" +\n  // `--ios-list-highlight` is `conversationListSelectedCellColor` above; the literals stay spelled out\n  // here because Tailwind reads these class names out of the source text and cannot see through a\n  // template hole.\n  \"[--ios-list-avatar-top:#a9c2e1] [--ios-list-avatar-bottom:#747fb9] [--ios-list-unread:#0088ff] [--ios-list-highlight:#dcdcdc] \" +\n  \"dark:[--ios-list-bg:#000000] dark:[--ios-list-label:#ffffff] dark:[--ios-list-secondary:#8d8d93] dark:[--ios-list-chevron:#464649] dark:[--ios-list-separator:#2a2a2c] dark:[--ios-list-unread:#0091ff] \" +\n  \"dark:[--ios-list-glass:rgba(28,28,28,0.9)] dark:[--ios-list-rim:inset_0_0_0_1px_rgba(255,255,255,0.09)] dark:[--ios-list-shadow:none] dark:[--ios-list-shadow-round:none] dark:[--ios-list-glyph:#f4f3f4] dark:[--ios-list-field:#97979d] \" +\n  \"dark:[--ios-list-avatar-top:#575368] dark:[--ios-list-avatar-bottom:#302649] dark:[--ios-list-highlight:#464646]\";\n\n/** `clip` stops the shadow at the midpoint of the 12pt gap toward a neighboring glass element, so the two shadows read as one (they never add up on the device). */\nfunction GlassLayers({ round = false, clip }: { round?: boolean; clip?: \"left\" | \"right\" }) {\n  return (\n    <>\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 -z-10 rounded-[inherit] [corner-shape:inherit]\" style={{ boxShadow: round ? \"var(--ios-list-shadow-round)\" : \"var(--ios-list-shadow)\", clipPath: clip ? `inset(-60px ${clip === \"right\" ? \"-6px\" : \"-60px\"} -60px ${clip === \"left\" ? \"-6px\" : \"-60px\"})` : undefined }} />\n      <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 rounded-[inherit] [corner-shape:inherit]\" style={{ background: \"var(--ios-list-glass)\", boxShadow: \"var(--ios-list-rim)\", backdropFilter: \"blur(24px)\", WebkitBackdropFilter: \"blur(24px)\" }} />\n    </>\n  );\n}\n\nfunction initialsOf(name: string): string {\n  return name.trim().split(/\\s+/).slice(0, 2).map(part => part[0] ?? \"\").join(\"\").toUpperCase();\n}\n\nexport type IosConversationRowProps = Omit<ComponentProps<\"button\">, \"onSelect\"> & {\n  conversation: IosConversation;\n  onSelect?: (conversation: IosConversation) => void;\n  pressed?: boolean;\n  separator?: boolean;\n};\n\n/** One native-sized row, also usable in a custom list. Inherits the surrounding light/dark theme. */\nexport function IosConversationRow({ conversation, onSelect, pressed, separator = true, className, style,\n  onClick, onPointerDown, onPointerUp, onPointerCancel, onPointerLeave, ...props }: IosConversationRowProps) {\n  const [held, setHeld] = useState(false);\n  const highlighted = pressed ?? held;\n  const letters = conversation.initials ?? initialsOf(conversation.name);\n  return (\n    <button type=\"button\" data-slot=\"ios-conversation-row\" data-pressed={highlighted || undefined} onClick={event => { onClick?.(event); if (!event.defaultPrevented) onSelect?.(conversation); }} aria-label={`${conversation.unread ? \"Unread. \" : \"\"}${conversation.name}, ${conversation.time}, ${conversation.preview}`}\n      onPointerDown={event => { if (event.button === 0) setHeld(true); onPointerDown?.(event); }}\n      onPointerUp={event => { setHeld(false); onPointerUp?.(event); }}\n      onPointerCancel={event => { setHeld(false); onPointerCancel?.(event); }}\n      onPointerLeave={event => { setHeld(false); onPointerLeave?.(event); }}\n      className={cn(\"relative block w-full text-left select-none focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-blue-500\", vars, className)}\n      style={{ height: ROW, fontFamily: font, background: highlighted ? \"var(--ios-list-highlight)\" : \"transparent\", ...style }} {...props}>\n      {conversation.unread && <span aria-hidden=\"true\" data-slot=\"unread\" className=\"absolute rounded-full bg-[var(--ios-list-unread)]\" style={{ left: UNREAD_LEFT, top: (ROW - UNREAD) / 2, width: UNREAD, height: UNREAD }} />}\n      {conversation.members && conversation.members.length > 1 ? (\n        <GroupAvatar aria-hidden=\"true\" data-slot=\"avatar\" participants={conversation.members} role={undefined}\n          size={groupAvatarMetrics.phone.conversationList} className=\"absolute\" style={{ left: 26, top: 20 }} />\n      ) : (\n        <span aria-hidden=\"true\" data-slot=\"avatar\" className=\"absolute flex items-center justify-center overflow-hidden rounded-full text-white\"\n          style={{ left: 26, top: 20, width: 45, height: 45, fontSize: 21, lineHeight: 1, fontWeight: 600, background: \"linear-gradient(var(--ios-list-avatar-top), var(--ios-list-avatar-bottom))\" }}>\n          {conversation.photo ? (\n            // eslint-disable-next-line @next/next/no-img-element -- framework-neutral registry component\n            <img src={conversation.photo} alt=\"\" className=\"size-full object-cover\" draggable={false} />\n          ) : letters}\n        </span>\n      )}\n      <span aria-hidden=\"true\" data-slot=\"name\" className=\"absolute truncate\" style={{ left: 83, right: 96, top: 14 - NAME_BLEED, paddingBlock: NAME_BLEED, transform: \"translateY(0.3333px)\", fontSize: 17, lineHeight: 1, fontWeight: 600, letterSpacing: 0, color: \"var(--ios-list-label)\" }}>\n        {conversation.name}\n      </span>\n      <span aria-hidden=\"true\" data-slot=\"time\" className=\"absolute whitespace-nowrap\" style={{ right: 37.1167, top: 15, transform: \"translateY(0.3333px)\", fontSize: 15, lineHeight: 1, letterSpacing: 0, color: \"var(--ios-list-secondary)\" }}>\n        {conversation.time}\n      </span>\n      <svg aria-hidden=\"true\" data-slot=\"chevron\" className=\"absolute\" style={{ right: 15, top: 15, transform: \"translateX(0.3333px)\" }} width=\"11\" height=\"16\" viewBox=\"-2 -2 11 16\" fill=\"none\" stroke=\"var(--ios-list-chevron)\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <path d=\"M1 1 6 6 1 11\" />\n      </svg>\n      <span aria-hidden=\"true\" data-slot=\"preview\" className=\"absolute overflow-hidden\" style={{ left: 83, right: 34, top: 33, transform: \"translateY(-0.3333px)\", fontSize: 15, lineHeight: \"20px\", letterSpacing: 0, color: \"var(--ios-list-secondary)\", display: \"-webkit-box\", WebkitLineClamp: 2, WebkitBoxOrient: \"vertical\" } as CSSProperties}>\n        {conversation.preview}\n      </span>\n      {separator && <span aria-hidden=\"true\" data-slot=\"separator\" className=\"absolute\" style={{ left: 83, right: 16, bottom: 0, height: 1, transform: \"translateY(-0.3333px)\", background: \"var(--ios-list-separator)\" }} />}\n    </button>\n  );\n}\n\nexport function IosConversationList({ conversations, onSelect, onCompose, onSearch, title = \"Messages\", topInset = 54, pressedId, className, style, ...props }: IosConversationListProps) {\n  /**\n   * The row a finger is on. One id rather than a boolean per row so a second pointer cannot leave a\n   * stale highlight behind, and so the row above the pressed one can drop its separator.\n   */\n  const [pressed, setPressed] = useState<string | null>(null);\n  const holding = pressedId !== undefined ? pressedId : pressed;\n  const pressedIndex = holding === null ? -1 : conversations.findIndex(item => item.id === holding);\n  const release = () => setPressed(current => (current === null ? current : null));\n  /**\n   * Touch-down lights the row; lift, a cancel and leaving the row all put it out. A drag that turns\n   * into a scroll arrives as `pointercancel` — the browser fires it the moment the gesture is taken\n   * over for panning — which is `UITableView`'s own rule: a touch that becomes a scroll never\n   * highlights. `onScroll` on the scroller is the belt to that brace, for a scroll started by the\n   * wheel or the keyboard while a pointer happens to be down.\n   *\n   * `event.button === 0` keeps a right click from lighting the row. Touch reports button 0.\n   *\n   * Nothing here focuses anything: a programmatic `focus()` while a touch is still held matches\n   * `:focus-visible` in both engines and would paint a ring iOS does not have (see `tapback-bar.tsx`\n   * and `audio-recorder.tsx`, which have to work around exactly that). The button keeps its\n   * `focus-visible` ring for the keyboard, where it belongs.\n   */\n  const press = (event: ReactPointerEvent<HTMLButtonElement>, id: string) => {\n    if (event.button === 0) setPressed(id);\n  };\n  return (\n    <div data-slot=\"ios-conversation-list\" className={cn(\"relative isolate h-full w-full overflow-hidden select-none\", vars, className)}\n      style={{ fontFamily: font, background: \"var(--ios-list-bg)\", ...style }} {...props}>\n      <div data-slot=\"scroll\" className=\"absolute inset-0 overflow-y-auto\" style={{ paddingTop: topInset, paddingBottom: 90 }} onScroll={release}>\n        <IosLargeTitle>{title}</IosLargeTitle>\n        <ul data-slot=\"rows\" aria-label={title} className=\"relative m-0 list-none p-0\" style={{ height: conversations.length * ROW }}>\n          {conversations.map((conversation, index) => {\n            const held = index === pressedIndex;\n            // The pressed row's own separator and the one above it go with the fill, so the highlight\n            // is one unbroken band. See the note on `--ios-list-highlight`.\n            const separated = !held && index !== pressedIndex - 1;\n            return (\n              <li key={conversation.id} data-slot=\"row\" data-pressed={held ? \"true\" : undefined} className=\"absolute left-0 right-0 top-0\" style={{ height: ROW, transform: `translateY(${index * ROW}px)` }}>\n                <IosConversationRow conversation={conversation} onSelect={onSelect} pressed={held} separator={separated}\n                  onPointerDown={event => press(event, conversation.id)} onPointerUp={release} onPointerCancel={release} onPointerLeave={release} />\n              </li>\n            );\n          })}\n        </ul>\n      </div>\n      <div data-slot=\"bottom-bar\" className=\"absolute flex items-end\" style={{ left: 28, right: 28, bottom: 28, height: 48 }}>\n        <button type=\"button\" data-slot=\"search\" aria-label=\"Search\" onClick={onSearch}\n          className=\"relative h-full min-w-0 flex-1 rounded-full text-left focus-visible:outline-2 focus-visible:outline-blue-500\">\n          <GlassLayers clip=\"right\" />\n          <svg aria-hidden=\"true\" className=\"absolute\" style={{ left: 19, top: 14 }} width=\"18.3333\" height=\"18.6667\" viewBox=\"-1 -1 18.3333 18.6667\" fill=\"none\" stroke=\"var(--ios-list-field)\" strokeLinecap=\"round\">\n            <circle cx=\"6.745\" cy=\"7.219\" r=\"5.883\" strokeWidth=\"1.725\" />\n            <path d=\"M11.4 12.01 15.17 15.78\" strokeWidth=\"2.467\" />\n          </svg>\n          <span className=\"absolute\" style={{ left: 46.8, top: 15.5, transform: \"translateY(0.3333px)\", fontSize: 17, lineHeight: 1, fontWeight: 500, letterSpacing: 0, color: \"var(--ios-list-field)\" }}>Search</span>\n          <span aria-hidden=\"true\" className=\"absolute\" style={{ right: 23.3333, top: 14.6667, color: \"var(--ios-list-field)\", display: \"flex\" }}>\n            <IosMicIcon width={12.6667} height={18.3333} />\n          </span>\n        </button>\n        <button type=\"button\" data-slot=\"compose\" aria-label=\"New message\" onClick={onCompose}\n          className=\"relative shrink-0 rounded-full focus-visible:outline-2 focus-visible:outline-blue-500\" style={{ marginLeft: 12, width: 48, height: 48 }}>\n          <GlassLayers round clip=\"left\" />\n          <svg aria-hidden=\"true\" className=\"absolute\" style={{ left: 14, top: 13 }} width=\"21.3333\" height=\"24\" viewBox=\"0 -2.3333 21.3333 24\" fill=\"none\" stroke=\"var(--ios-list-glyph)\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n            <path d=\"M15 0.9167H4.1167A3.2 3.2 0 0 0 0.9167 4.1167V14.8833A3.2 3.2 0 0 0 4.1167 18.0833H14.8833A3.2 3.2 0 0 0 18.0833 14.8833V4\" strokeWidth=\"1.8333\" />\n            <path d=\"M8.427 10.623 17.45 1.6\" strokeWidth=\"2.1\" />\n            <circle cx=\"20\" cy=\"-1\" r=\"0.6\" strokeWidth=\"1.4\" />\n          </svg>\n        </button>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/message-ui/ios-conversation-list.tsx"
    }
  ],
  "type": "registry:ui"
}
