{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ios-messages-app",
  "title": "iOS Messages app",
  "description": "The complete iOS 26 Messages app: list, conversation, New Message sheet, and the long-press overlay.",
  "registryDependencies": [
    "https://imessage.swerdlow.dev/r/audio-recorder.json",
    "https://imessage.swerdlow.dev/r/context-menu.json",
    "https://imessage.swerdlow.dev/r/group-avatar.json",
    "https://imessage.swerdlow.dev/r/group-details.json",
    "https://imessage.swerdlow.dev/r/image-viewer.json",
    "https://imessage.swerdlow.dev/r/ios-composer.json",
    "https://imessage.swerdlow.dev/r/ios-conversation-list.json",
    "https://imessage.swerdlow.dev/r/ios-details.json",
    "https://imessage.swerdlow.dev/r/ios-effects-picker.json",
    "https://imessage.swerdlow.dev/r/ios-nav-bar.json",
    "https://imessage.swerdlow.dev/r/ios-new-message-sheet.json",
    "https://imessage.swerdlow.dev/r/ios-plus-menu.json",
    "https://imessage.swerdlow.dev/r/ios-scroll-edge.json",
    "https://imessage.swerdlow.dev/r/ios-search.json",
    "https://imessage.swerdlow.dev/r/ios-select-mode.json",
    "https://imessage.swerdlow.dev/r/ios-status-bar.json",
    "https://imessage.swerdlow.dev/r/link-preview.json",
    "https://imessage.swerdlow.dev/r/message-actions.json",
    "https://imessage.swerdlow.dev/r/message-attachment.json",
    "https://imessage.swerdlow.dev/r/message-audio.json",
    "https://imessage.swerdlow.dev/r/message-bubble.json",
    "https://imessage.swerdlow.dev/r/message-image.json",
    "https://imessage.swerdlow.dev/r/message-list.json",
    "https://imessage.swerdlow.dev/r/message-motion.json",
    "https://imessage.swerdlow.dev/r/message-reply.json",
    "https://imessage.swerdlow.dev/r/palette.json",
    "https://imessage.swerdlow.dev/r/photo-picker.json",
    "https://imessage.swerdlow.dev/r/platform.json",
    "https://imessage.swerdlow.dev/r/sticker-picker.json",
    "https://imessage.swerdlow.dev/r/system-message.json",
    "https://imessage.swerdlow.dev/r/tapback.json",
    "https://imessage.swerdlow.dev/r/tapback-bar.json",
    "https://imessage.swerdlow.dev/r/tapback-details.json",
    "https://imessage.swerdlow.dev/r/tokens.json"
  ],
  "files": [
    {
      "path": "registry/imessage/ios-messages-app.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { PlatformProvider } from \"@/components/message-ui/platform\";\nimport { PaletteStyle } from \"@/components/message-ui/palette\";\nimport { IosStatusBar } from \"@/components/message-ui/ios-status-bar\";\nimport { IosNavBar } from \"@/components/message-ui/ios-nav-bar\";\nimport { IosScrollEdge } from \"@/components/message-ui/ios-scroll-edge\";\nimport { IosComposer } from \"@/components/message-ui/ios-composer\";\nimport { IosConversationList, type IosConversation } from \"@/components/message-ui/ios-conversation-list\";\nimport { IosNewMessageSheet, iosSheetDismissal, iosSheetDismissKeyframes } from \"@/components/message-ui/ios-new-message-sheet\";\nimport { MessageList, messageListMetrics, type Message, type MessageListHandle } from \"@/components/message-ui/message-list\";\nimport { isEmojiOnly, MessageBubble } from \"@/components/message-ui/message-bubble\";\nimport { LinkPreview } from \"@/components/message-ui/link-preview\";\nimport { MessageAttachment } from \"@/components/message-ui/message-attachment\";\nimport { MessageAudio } from \"@/components/message-ui/message-audio\";\nimport { MessageImages } from \"@/components/message-ui/message-image\";\nimport { bubbleMetrics, emojiFontStack, fontStack } from \"@/components/message-ui/tokens\";\nimport { ReplyThread, replyThreadMetrics, replyThreadMotion } from \"@/components/message-ui/message-reply\";\nimport { Tapback, type TapbackType } from \"@/components/message-ui/tapback\";\nimport { MessageActions, messageActionsMetrics, photoLiftScale, type Rect } from \"@/components/message-ui/message-actions\";\nimport { iosPhotoMenu } from \"@/components/message-ui/context-menu\";\nimport { useArrivalAnimation, type ArrivalAnimation } from \"@/components/message-ui/message-motion\";\nimport { IosEffectsPicker, type EffectsPickerSelection } from \"@/components/message-ui/ios-effects-picker\";\nimport type { TapbackSelection } from \"@/components/message-ui/tapback-bar\";\nimport { ImageViewer, type ImageViewerRect } from \"@/components/message-ui/image-viewer\";\nimport { IosPlusMenu, defaultPlusMenuItems, type PlusMenuItem } from \"@/components/message-ui/ios-plus-menu\";\nimport { PhotoPicker, PhotoPickerAttachments, photoPickerChipMetrics, photoPickerSamples, type PhotoPickerDetent, type PhotoPickerPhoto } from \"@/components/message-ui/photo-picker\";\nimport { StickerPicker, stickerPickerMetrics, type Sticker, type StickerPlacement } from \"@/components/message-ui/sticker-picker\";\nimport { AudioRecorder, audioRecorderMetrics, type AudioRecorderState, type AudioTake } from \"@/components/message-ui/audio-recorder\";\nimport { IosDetails, type IosDetailsAction, type IosDetailsItem, type IosDetailsPhoto, type IosDetailsSection } from \"@/components/message-ui/ios-details\";\nimport { GroupDetails } from \"@/components/message-ui/group-details\";\nimport { TapbackDetailsPlatter, tapbackDetailsPlatterMetrics, type TapbackReactor } from \"@/components/message-ui/tapback-details\";\nimport { IosSearch, type IosSearchResult, type IosSearchSection, type IosSearchSectionKind } from \"@/components/message-ui/ios-search\";\nimport { GroupAvatar, groupAvatarMetrics, type GroupParticipant } from \"@/components/message-ui/group-avatar\";\nimport { IosSelectMode, IosSelectionCloseButton, IosSelectionToolbar, SelectionCircle, selectModeMotion, selectionMetrics, useSelectModeTransition } from \"@/components/message-ui/ios-select-mode\";\nimport type { SystemMessageEvent } from \"@/components/message-ui/system-message\";\n\n/**\n * `listTop` puts the first date header's ink at y 172.67 (conv3-light.png). `listBottom` is the composer's\n * 68pt band plus the 28pt it keeps clear: in the one scrolled iOS capture (dateheader-mid-light.png) the\n * last row's ink bottom is at 778 with the composer field starting at 806.\n */\nexport const iosScreen = { width: 402, height: 874, statusBar: 54, navBar: 94, listTop: 169.5, composer: 68, listBottom: 96 } as const;\n\nexport type IosScreen = \"list\" | \"conversation\" | \"new-message\";\n\n/**\n * Moving between screens. A conversation is pushed in from the trailing edge while the list follows\n * it part of the way out and dims under it; going back plays that backwards; the New Message sheet\n * comes up from the bottom while its dim fades in, and leaves on `iosSheetDismissal`'s table.\n *\n * UNVERIFIED, except `dismiss` and `represent`. Nothing in `references/` captures a push, a pop or a\n * sheet arriving, so those three numbers are not measured: they are kept in the family of the motion\n * that is (the send flight that settles by ~520 ms, the 380 ms long-press entrance, the 260 ms\n * effects screen and its 320 ms move). Replace them from a recording before describing any of it as\n * measured.\n *\n * `dismiss` **is** measured — 200 ms, twice, off an iPhone 17 Pro on iOS 26 — and the duration is\n * the lesser half of what was measured: the panel's travel is a table of recorded samples rather\n * than an ease, so the sheet branch below plays `iosSheetDismissKeyframes()` linearly instead of the\n * presentation reversed.\n *\n * `represent` is 0 because the device does not animate one. `newmsg-sheet-open` re-opens this sheet\n * by deep link with the list bare from 216 to 613 ms and the sheet fully in place at 643 ms, with no\n * intermediate frames at all — on a recorder that emits a frame per screen change, where a 300 ms\n * slide would have left ~18 of them. So a sheet the app has already built snaps back into place.\n * NOT MEASURED: the recording re-opens it by **deep link**, and nothing records a second tap of\n * Compose. This treats the two alike, which is a guess about the tap and a reading about the link.\n *\n * Each one is a Web Animations timeline rather than a transition or a rAF loop, so\n * `screenTransition.progress` can seek a frame instead of playing it, and `document.getAnimations()`\n * reaches it (which is how the harness freezes a checkpoint).\n */\nexport const iosScreenTransition = {\n  push: 350,\n  pop: 320,\n  present: 400,\n  dismiss: iosSheetDismissal.duration,\n  represent: 0,\n  /** How far the screen underneath follows the one on top, as a share of the screen width. */\n  parallax: 0.3,\n  /** Black over the screen that slid back, at the end of the push. */\n  dim: 0.14,\n  ease: \"cubic-bezier(0.32, 0.72, 0, 1)\",\n} as const;\n\nexport type IosScreenTransitionKind = \"push\" | \"pop\" | \"present\" | \"dismiss\" | \"represent\";\n\n/**\n * Which transition takes the app from one screen to another. The list and a conversation are a\n * navigation stack; the sheet is presented over whichever of them is showing. Going from the sheet\n * straight into a conversation (a recipient was chosen) pushes from the list underneath it, so the\n * sheet leaves with the screen it was presented over.\n *\n * `built` is whether this app has put the New Message sheet up before. A second presentation of a\n * sheet the app has already built is a **represent**, which the device does not animate — see\n * `iosScreenTransition.represent`.\n */\nexport function screenTransitionKind(from: IosScreen, to: IosScreen, options?: { built?: boolean }): IosScreenTransitionKind | null {\n  if (from === to) return null;\n  if (to === \"new-message\") return options?.built ? \"represent\" : \"present\";\n  if (from === \"new-message\" && to === \"list\") return \"dismiss\";\n  return to === \"conversation\" ? \"push\" : \"pop\";\n}\n\nexport type IosMessagesAppProps = {\n  width?: number;\n  height?: number;\n  /** Status bar clock. */\n  time?: string;\n  screen?: IosScreen;\n  conversations?: IosConversation[];\n  contact: { name: string; initials?: string; photo?: string };\n  group?: boolean;\n  /**\n   * The people in the conversation, when it is a group. Two or more of them turn the nav bar's Ø60\n   * slot into `group-avatar.tsx`'s Snowglobe stack — which is also the shape the details screen's\n   * entrance morphs out of — and mark the conversation as a group without a separate `group` flag.\n   */\n  participants?: readonly GroupParticipant[];\n  messages: Message[];\n  typing?: boolean | { sender?: string };\n  /** Reference time for \"Today\"/\"Yesterday\". */\n  now?: Date | number;\n  composer?: { value?: string; placeholder?: string; disabled?: boolean; onChange?: (value: string) => void; onSend?: (text: string, photos: PhotoPickerPhoto[]) => void | Promise<void>; onAttach?: () => void; onMic?: () => void };\n  /**\n   * Scrub a screen change instead of playing it: the app is on `screen`, arriving from `from`, and\n   * `progress` (0..1) seeks the push, the pop or the sheet. Leave it out and the app runs the\n   * transition itself whenever `screen` changes, which is what an application wants; a harness that\n   * renders one frame at a time has nothing to observe changing and states it instead.\n   *\n   * `represent` states the one thing a single frame cannot show: that this presentation is of a\n   * sheet the app has already built, which is not animated. Playing the transitions, the app knows\n   * that for itself; scrubbing, nothing has happened for it to remember.\n   */\n  screenTransition?: { from: IosScreen; progress: number; represent?: boolean } | null;\n  onBack?: () => void;\n  onSelectConversation?: (conversation: IosConversation) => void;\n  onCompose?: () => void;\n  onCloseNewMessage?: () => void;\n  onDetails?: () => void;\n  /**\n   * The open thread: the message whose replies are showing. The conversation blurs behind it. Its\n   * members are the message itself and every message replying to it, so the caller only names the\n   * root. `progress` (0..1) seeks the entrance rather than playing it.\n   */\n  thread?: { rootId: string; progress?: number } | null;\n  /** The reply count under a message was activated. */\n  onOpenThread?: (id: string) => void;\n  /** The thread was dismissed. The overlay stays up for its exit after `thread` clears. */\n  onCloseThread?: () => void;\n  /**\n   * Omit to animate appended outgoing messages automatically; null disables automatic sends.\n   * That message was just sent: the composer's text row becomes its bubble and flies to its slot.\n   * With `progress` the animation is seeked to `progress * duration` instead of played.\n   */\n  sendAnimation?: ArrivalAnimation | null;\n  /** That message just arrived: it pops in from the typing indicator's position. */\n  receiveAnimation?: ArrivalAnimation | null;\n  /** Called when a played (not seeked) send animation finishes. */\n  onSendAnimationEnd?: () => void;\n  /** Open the long-press overlay on a message; `progress` scrubs the entrance (0–1). */\n  longPress?: { id: string; progress?: number } | null;\n  onLongPress?: (id: string) => void;\n  onLongPressClose?: () => void;\n  onTapback?: (id: string, selection: TapbackSelection) => void;\n  onMenuAction?: (id: string, action: string) => void;\n  /** Play a message's effect again — the Replay control under a message sent with one. */\n  onReplayEffect?: (id: string) => void;\n  /**\n   * Select mode: the checkbox multi-select the long-press menu's \"Select\" row opens, drawn by\n   * `ios-select-mode.tsx` and measured on `select-mode-dark.png` — a circle in the leading gutter of\n   * every row, the nav bar's back button replaced by a ✕, and the composer replaced by the trash and\n   * forward toolbar.\n   *\n   * It follows the same two-mode contract as the presented surfaces below: left out entirely, the\n   * shell owns it and choosing \"Select\" enters it with nothing wired; passed (a value or `null`) the\n   * caller owns it and `progress` (0..1) seeks the entrance instead of playing it. Either way the\n   * caller still hears about the action through `onMenuAction(id, \"select\")`.\n   */\n  selectMode?: { progress?: number } | null;\n  /** \"Select\" was chosen on that message; it is also the message the mode opens with ticked. */\n  onOpenSelectMode?: (id: string) => void;\n  /** The ✕, Escape, or a toolbar action that ends the mode. The selection clears with it. */\n  onCloseSelectMode?: () => void;\n  /**\n   * The messages select mode has ticked — the same surface macOS takes for its click-to-select, so a\n   * caller keeps one selection state for both platforms. Left out, the shell keeps the set itself.\n   *\n   * `context.id` is the message the tap acted on, or `null` when the whole selection was cleared.\n   * There is no `shiftKey`/`metaKey` here as there is on the Mac: a tap on a phone carries no\n   * modifier, so a tap toggles exactly one message and nothing extends a range.\n   */\n  selectedMessageIds?: readonly string[];\n  onSelectMessage?: (ids: string[], context: { id: string | null }) => void;\n  /**\n   * The toolbar's trash and forward, on the ticked messages. Neither one touches `messages`: this\n   * shell still only draws state. Deleting leaves select mode, the way it does natively; forwarding\n   * does not, because the sheet it would open is not in this registry to open.\n   */\n  onDeleteMessages?: (ids: string[]) => void;\n  onForwardMessages?: (ids: string[]) => void;\n  /**\n   * The \"Send with effect\" screen. iOS opens it on a press and hold of the send button, which is what\n   * `onEffectsPickerOpen` reports; pass the state back here to show it.\n   */\n  effectsPicker?: { tab?: \"bubble\" | \"screen\"; selection?: EffectsPickerSelection; draft?: string; progress?: number } | null;\n  onEffectsPickerOpen?: (draft: string) => void;\n  onEffectsTabChange?: (tab: \"bubble\" | \"screen\") => void;\n  onEffectSelect?: (selection: EffectsPickerSelection) => void;\n  onSendWithEffect?: (text: string, selection: EffectsPickerSelection) => void;\n  onEffectsPickerClose?: () => void;\n  /*\n   * ------------------------------------------------------------------------------------------\n   * The presented surfaces: the plus menu, the Photos picker, the sticker sheet, the voice\n   * recorder, the details screen, the Tapback Details platter, the photo viewer and search.\n   *\n   * Every one of them follows the same two-mode contract, and it is the contract that lets one\n   * component be both an application and a harness fixture:\n   *\n   *   - **Left out entirely** (`undefined`), the shell owns the surface. The gesture that opens it\n   *     natively opens it here: the composer's `+`, its mic, the nav bar's name pill, a tap on a\n   *     photo, a tap on a tapback balloon, the list's search field. Nothing has to be wired for the\n   *     app to behave like Messages.\n   *   - **Passed** (a value or `null`), the caller owns it, and `progress` (0..1) seeks the\n   *     surface's own entrance instead of playing it. A scrubbed checkpoint is then a pure function\n   *     of its props, which is what makes two runs render the same frame.\n   *\n   * Each one stays mounted after its prop clears so its exit has frames to run in, and each is\n   * derived during render rather than in an effect — see the long-press overlay above for why.\n   * ------------------------------------------------------------------------------------------\n   */\n  /** The `+` menu over the composer. */\n  plusMenu?: { progress?: number } | null;\n  /** A row of the plus menu was chosen. \"photos\", \"stickers\" and \"audio\" open their own surfaces. */\n  onPlusMenuSelect?: (id: string) => void;\n  onPlusMenuClose?: () => void;\n  /** Replaces the seven rows of the plus menu. Handlers on a row run before the shell's own. */\n  plusMenuItems?: PlusMenuItem[];\n  /**\n   * The full-screen photo viewer. `id` is the message that was TAPPED; the viewer pages over that\n   * photo's whole RUN — see `photoRun`, and `references/image-viewer.md` for the ChatKit reading\n   * behind it — so `index` counts photos across the run, not inside `id`. For the ordinary case of a\n   * message with one photo in a run of one, the two are the same number. `dismiss` holds the\n   * drag-to-dismiss pose without a synthesised drag; `chrome` states the bar visibility.\n   */\n  photoViewer?: { id: string; index: number; progress?: number; chrome?: boolean; dismiss?: number; pageOffset?: number } | null;\n  /** A photo was tapped. `index` is its index inside `id`'s own message, which is what the tile knows. */\n  onOpenPhoto?: (id: string, index: number) => void;\n  /** The viewer paged. `index` counts photos across the run, the same way `photoViewer.index` does. */\n  onPhotoIndexChange?: (index: number) => void;\n  onClosePhoto?: () => void;\n  /** Share the photo ON SCREEN: after a page that is a different message from the one that was tapped. */\n  onSharePhoto?: (id: string, index: number) => void;\n  /** Save the photo ON SCREEN, with its index inside its own message. */\n  onSavePhoto?: (id: string, index: number) => void;\n  /** The Photos picker under the composer, which the plus menu's \"Photos\" row opens. */\n  photoPicker?: { selected?: string[]; detent?: PhotoPickerDetent; progress?: number } | null;\n  /** What the picker shows. Defaults to the registry's own gradient placeholders. */\n  photos?: PhotoPickerPhoto[];\n  onPhotoPickerSelectionChange?: (selected: string[]) => void;\n  onPhotoPickerDetentChange?: (detent: PhotoPickerDetent) => void;\n  onPhotoPickerClose?: () => void;\n  /** The sticker sheet, which the plus menu's \"Stickers\" row opens. */\n  stickerPicker?: { tab?: string; progress?: number; drag?: { id: string; to: { x: number; y: number }; progress: number } } | null;\n  onStickerPickerTab?: (tabId: string) => void;\n  onStickerPickerClose?: () => void;\n  onStickerSelect?: (sticker: Sticker) => void;\n  /** A sticker let go over the transcript. `placement.size` is ChatKit's landed 48. */\n  onStickerPlace?: (sticker: Sticker, placement: StickerPlacement) => void;\n  onStickerEdit?: () => void;\n  /** The voice recorder in place of the composer, which the composer's mic opens. */\n  audioRecorder?: { state: AudioRecorderState; position?: number; progress?: number; transition?: { from: AudioRecorderState; progress: number }; levels?: number[]; duration?: number } | null;\n  onAudioRecorderClose?: () => void;\n  /** A finished take was sent. `audioTakeToPeaks(take.levels)` is what `MessageAudio` draws. */\n  onAudioSend?: (take: AudioTake) => void;\n  /**\n   * The details screen the nav bar's name pill pushes: `IosDetails` for one person, `GroupDetails`\n   * for a group. `progress` seeks the presentation, `scroll` (points) the header collapse.\n   */\n  details?: { progress?: number; scroll?: number } | null;\n  onCloseDetails?: () => void;\n  /** What that screen shows beyond the name and the participants. */\n  detailsContent?: {\n    phoneLabel?: string;\n    phone?: string;\n    tag?: string;\n    hideAlerts?: boolean;\n    onHideAlertsChange?: (next: boolean) => void;\n    photos?: IosDetailsSection<IosDetailsPhoto>;\n    sharedLinks?: IosDetailsSection<IosDetailsItem>;\n    attachments?: IosDetailsSection<IosDetailsItem>;\n    onAddContact?: () => void;\n    onLeave?: () => void;\n    /** Blocking is a toggle; the row reports which way it went. */\n    onBlock?: (blocked: boolean) => void;\n    /**\n     * The three glass circles under the name. Leave it out and the screen still draws all three,\n     * which is what `details-light.png` shows: an action this contact cannot take keeps its circle\n     * and drops the glyph to the tertiary label colour, it does not disappear. FaceTime picks up\n     * Each of the three becomes live when it is given a handler here.\n     */\n    actions?: IosDetailsAction[];\n    onAudioCall?: () => void;\n    onFaceTime?: () => void;\n    onMail?: () => void;\n  };\n  /** The group's name was committed from the details screen. */\n  onGroupNameChange?: (name: string) => void;\n  /**\n   * A group edit the caller should answer by appending a status line. The screen itself never\n   * appends anything: this shell does not own `messages`.\n   */\n  onGroupEvent?: (event: SystemMessageEvent) => void;\n  /** The Tapback Details platter: who reacted to `id`. */\n  tapbackDetails?: { id: string; progress?: number; filter?: string | null } | null;\n  onOpenTapbackDetails?: (id: string) => void;\n  onCloseTapbackDetails?: () => void;\n  onRemoveTapback?: (id: string, reactor: TapbackReactor) => void;\n  /**\n   * Search over the conversation list. `closing` runs the measured close table rather than the open\n   * one played backwards — the two are different animations and the recordings disprove reversing.\n   */\n  search?: { query?: string; sections?: IosSearchSection[]; maxResults?: number; progress?: number; closing?: boolean } | null;\n  onOpenSearch?: () => void;\n  onCloseSearch?: () => void;\n  onSearchQueryChange?: (query: string) => void;\n  onSearchSelect?: (result: IosSearchResult, kind: IosSearchSectionKind) => void;\n  onSearchSeeAll?: (kind: IosSearchSectionKind) => void;\n  /** A status line arriving in the transcript, its entrance seeked by `progress`. */\n  systemArrival?: { id: string; progress?: number } | null;\n  /** Extra overlays (a call card, a screen effect, a selection toolbar) rendered above everything. */\n  overlay?: ReactNode;\n  /** Render reactions for a message; defaults to the message's `reactions` as Tapback balloons. */\n  renderReactions?: (message: Message) => ReactNode;\n  className?: string;\n  style?: CSSProperties;\n  /** Exposes the device frame element (for screen-space measurements and overlays). */\n  frameRef?: RefObject<HTMLDivElement | null>;\n};\n\n/**\n * Several people reacting to one message is **one** balloon, not one per person.\n *\n * ChatKit collapses them into a `CKAggregateAcknowledgmentChatItem`, and that item carries a single\n * `acknowledgmentImageName` and `acknowledgmentImageColor` next to `latestTapback`,\n * `latestAcknowledgmentType`, `latestIsFromMe` and `includesMultiple`. One image name means one\n * glyph: the transcript shows the most recent reaction, tinted by whether that one is yours, and\n * says how many there are. Two glyphs would need two names, and there is no second name.\n *\n * `-[CKUIBehaviorPhone aggregateAcknowledgmentTranscriptBalloonSize]` is `{46, 40}` against\n * `messageAcknowledgmentTranscriptBalloonSize` `{36, 36}` for a single, so the aggregate balloon is\n * 10 wider and 4 taller. `Tapback`'s count pill grows the width and not the height; the extra 4 pt\n * is not applied yet, and no capture of a multi-person reaction exists to settle how the count is\n * set inside it.\n */\nexport function defaultReactions(message: Message): ReactNode {\n  const all = message.reactions;\n  if (!all?.length) return undefined;\n  const outgoing = message.direction === \"outgoing\";\n  const latest = all[all.length - 1];\n  return (\n    <div data-slot=\"reaction-stack\" className=\"flex\">\n      <Tapback reaction={latest.emoji ? undefined : (latest.type as TapbackType)} emoji={latest.emoji}\n        own={latest.byMe ?? true} side={outgoing ? \"left\" : \"right\"} count={all.length > 1 ? all.length : undefined} />\n    </div>\n  );\n}\n\n/**\n * A message's body box, whichever kind it is. Every kind draws its own slot and only a text or audio\n * message has a `bubble`, so measuring that alone left an emoji-only message, a photo, a link card and\n * a file card with no rect at all, and the long-press overlay never opened on any of them.\n * `ios-select-mode.tsx`, `ios-swipe-times.tsx` and `message-motion.tsx` measure the same way.\n */\nconst messageBodySelector = ['[data-slot=\"bubble\"]', '[data-slot=\"emoji\"]', '[data-slot=\"image-grid\"]', '[data-slot=\"link-preview\"]', '[data-slot=\"message-attachment\"]'].join(\", \");\n\n/**\n * What the lifted copy replaces, so the original is not drawn twice. One entry per body above, taking\n * the whole wrapper where the body has one (`bubble-frame` carries the tail and the balloons,\n * `message-images` carries the photo tail), plus the delivery label, which the overlay does not lift.\n */\nconst messageBodyHiddenSlots = [\"bubble-frame\", \"emoji\", \"message-images\", \"link-preview\", \"message-attachment\", \"status\"];\n\n/**\n * A quoted reply stub draws a scaled copy of the quoted message, tail and all, above the message that\n * replies. It is the row's first `[data-slot=\"bubble\"]`, so the overlay used to lift the quotation\n * instead of the message: measured on the harness's `reply` scene, the stub's 157.97×30.39 box at\n * y 570.36 instead of the message's own 133.44×40 at y 605.08.\n */\nconst insideQuotedStub = (element: HTMLElement) => Boolean(element.closest(\"[data-stub]\"));\n\n/**\n * Where the composer sits while the Photos picker is up, measured rather than derived: at this top\n * the composer's painted white ends on device row 1391 in the render and on device row 1391 in\n * `references/ios/captures/photo-picker-light.png`, which is `photoPickerMetrics.composer.fieldBottom`\n * = 463.6667 pt, leaving the measured `photoPickerMetrics.composer.gap` of backdrop above the panel's\n * own 485. The composer keeps its `+`, its placeholder and its mic; the picker is not the plus menu.\n */\nconst photoPickerComposerTop = 424;\n\n/** The value a surface with no state of its own takes while it is open. One object, so it is stable. */\nconst openMarker: { progress?: number; scroll?: number } = {};\n\n/**\n * How far an incoming row steps out of the selection circles' way. **DERIVED from ChatKit, not\n * measured**: `select-mode-dark.png` is one side of an SMS thread, so the only rows it shows are\n * outgoing ones, and those do not move at all in it — which is why nothing here shifts them, and why\n * `MessageSelectionRow`'s own `shift` defaults to 0.\n *\n * `-[CKUIBehaviorPhone editingCheckmarkLeadingPadding]` is 11 and `editingCheckmarkTrailingPadding`\n * is 20, around a `transcriptEditingUnselectedImage` (SF Symbol `circle`, tertiaryLabelColor) whose\n * box is 26×26 with 2 pt of content inset on every edge. That puts the ink of the circle at x 13 at\n * Ø22 — the capture's own 13.0 and Ø21.667, which is 65/3 at 3x, so the two describe one circle —\n * and the content after it at 11 + 26 + 20 = 57. A balloon already begins at `edgeInset`, so an\n * incoming row owes the difference.\n *\n * The log's own content box is deliberately left alone: widening its leading padding to 57 would\n * narrow `maxWidthRatio`'s 280.33 to 251.7 and rewrap the capture's long outgoing bubble, which the\n * capture disproves.\n */\nconst selectModeRowShift = 11 + 26 + 20 - bubbleMetrics.ios.edgeInset;\n\n/**\n * The rules select mode adds to the conversation layer, all of them driven by `--ios-sel-t` so they\n * ride `IosSelectMode`'s one timeline rather than a second one.\n *\n * The composer is dropped by a rule instead of being handed to `IosSelectionToolbar`'s `composer`\n * slot: moving it into that slot would move it in the React tree, and remounting `IosComposer` loses\n * an uncontrolled draft. The travel is the slot's own — a whole height down, the fade spent in the\n * first 40% — so the two still cross the same way.\n */\nfunction selectModeRules(timing: string) {\n  const move = timing ? `transform ${timing}, opacity ${timing}` : \"none\";\n  const fade = timing ? `opacity ${timing}` : \"none\";\n  const scope = '[data-slot=\"ios-select-mode\"] ';\n  return [\n    `${scope}[data-slot=\"ios-composer\"]{transform:translateY(calc(var(--ios-sel-t) * 100%));opacity:max(0, 1 - var(--ios-sel-t) * 2.5);transition:${move}}`,\n    // `select-mode-dark.png` carries no delivery label under its last bubble where every other\n    // capture of the same thread does. Fading rather than hiding it moves nothing either way: the\n    // label is the last thing in a top-anchored log.\n    `${scope}[data-slot=\"message-row\"] [data-slot=\"status\"]{opacity:calc(1 - var(--ios-sel-t));transition:${fade}}`,\n    `${scope}[data-slot=\"message-row\"][data-direction=\"incoming\"]{transform:translateX(calc(var(--ios-sel-t) * ${selectModeRowShift}px));transition:${move}}`,\n  ].join(\"\");\n}\n\n/** `CSS.escape` is browser-only and this file renders on the server too. */\nfunction cssEscape(value: string): string {\n  return typeof CSS !== \"undefined\" && CSS.escape ? CSS.escape(value) : value.replace(/[\"\\\\]/g, \"\\\\$&\");\n}\n\n/**\n * Keeps a presented surface mounted while it plays its exit, and reports when the exit is over.\n *\n * `key` is what identifies the thing on screen; when it clears, the last value stays mounted with\n * `open: false` until the surface calls `exited`. Derived during render, never in an effect: an\n * effect leaves one committed frame with the surface already unmounted and the exit never runs.\n * That is the same rule the long-press overlay, the effects screen and the thread overlay follow,\n * written once so eight more surfaces do not each repeat it.\n */\nfunction useExit(key: string | null) {\n  const [seen, setSeen] = useState<string | null>(key);\n  const [leaving, setLeaving] = useState<string | null>(null);\n  if (seen !== key) {\n    setSeen(key);\n    setLeaving(key === null ? seen : null);\n  }\n  const exited = useCallback(() => setLeaving(null), []);\n  // `mounted` is the key of whatever is on screen, open or leaving; `open` is whether it is staying.\n  // A surface that is leaving draws with its state already gone, which is the same thing the effects\n  // screen and the thread overlay above do, and is why their props are all read with `?.`.\n  return { open: key !== null, mounted: key ?? leaving, exited };\n}\n\n/**\n * The photos the viewer pages over when one of them is tapped. It is NOT the tapped message's\n * photos — it is the whole RUN those photos belong to, and each photo of a multi-photo send is its\n * own message.\n *\n * MEASURED, in ChatKit on this Mac (macOS 26.5.2), by disassembling the loaded iOSSupport image the\n * way `references/image-viewer.md` describes:\n * `-[CKChatController _displayPreviewItemForMediaObject:]` — the method that puts the viewer up —\n * calls `-previewItemsForMediaObject:currentItemIndex:containsRestoring:`, sets the result on\n * `CKQLPreviewControllerDataSource.previewItems` (its only ivar, and what\n * `-numberOfPreviewItemsInPreviewController:` counts), and then sets the mock scene's\n * `currentPreviewItemIndex` to the index that call handed back.\n * `-[CKChatController(QuickLook) previewItemsForMediaObject:currentItemIndex:containsRestoring:]`\n * takes `-_chatItemForMediaObject:`, reads its `layoutGroupIdentifier`, and enumerates\n * `self.collectionViewController.chatItems` — **the whole transcript** — keeping every chat item\n * whose own `layoutGroupIdentifier` is `isEqualToString:` that one, skipping\n * `itemIsReplyContextPreview` items, and appending each kept item's `mediaObject`. The index it\n * reports back is the position of the item whose `transferGUID` matches the one that was tapped.\n * So the viewer pages ACROSS MESSAGES, over the tapped photo's layout group, and opens on the\n * photo that was tapped inside it.\n *\n * (Read there too: when the tapped item's `layoutGroupIdentifier` has zero length the identifier\n * test is skipped and every media chat item in the transcript is taken instead.)\n *\n * WHAT IS NOT MEASURED is how `layoutGroupIdentifier` is derived —\n * `-[IMOrganicAttachmentMessagePartChatItem layoutGroupIdentifier]` is two chained IMCore calls\n * into functions with no symbol. The run below is this kit's analogue of it: the maximal stretch of\n * adjacent photo messages around the tapped one, same direction and same sender, unbroken by any\n * other kind of message. That matches what a send of N photos produces — N adjacent photo messages\n * laid out as one group — but the rule itself is JUDGEMENT, not a reading.\n */\nexport type PhotoRun = { ids: string[]; photos: NonNullable<Message[\"images\"]>; offsets: number[] };\n/** One entry per photo of a run; `null` where that photo's tile is not in the DOM to fly out of. */\ntype TileRects = Array<ImageViewerRect | null>;\n/** How many photos each message of the run owns, in run order. */\nfunction runSizes(run: PhotoRun): number[] {\n  return run.ids.map((_, index) => (run.offsets[index + 1] ?? run.photos.length) - run.offsets[index]);\n}\n\nexport function photoRun(messages: Message[], id: string): PhotoRun {\n  const centre = messages.findIndex(message => message.id === id);\n  const empty = { ids: [] as string[], photos: [] as NonNullable<Message[\"images\"]>, offsets: [] as number[] };\n  if (centre < 0) return empty;\n  // The same test `messageShape` uses for the image row, so the run can only ever contain messages\n  // the log actually drew as photos — a message carrying `images` without `kind: \"image\"` draws as a\n  // bubble, and a viewer that paged into it would be showing something the transcript never did.\n  const isPhoto = (message: Message | undefined) =>\n    Boolean(message && message.kind === \"image\" && message.images?.length);\n  if (!isPhoto(messages[centre])) return empty;\n  const joins = (a: Message, b: Message) =>\n    isPhoto(b) && a.direction === b.direction && (a.sender ?? \"\") === (b.sender ?? \"\");\n  let first = centre;\n  while (first > 0 && joins(messages[first], messages[first - 1])) first--;\n  let last = centre;\n  while (last + 1 < messages.length && joins(messages[last], messages[last + 1])) last++;\n  const ids: string[] = [];\n  const photos: NonNullable<Message[\"images\"]> = [];\n  const offsets: number[] = [];\n  for (let i = first; i <= last; i++) {\n    ids.push(messages[i].id);\n    offsets.push(photos.length);\n    photos.push(...(messages[i].images ?? []));\n  }\n  return { ids, photos, offsets };\n}\n\n/**\n * Where each of the run's photo tiles sits inside the device frame, in the frame's own coordinates,\n * concatenated in the same order as `photoRun`'s photos. Read out of the DOM rather than carried in\n * the open event: only the log knows where a tile ended up after wrapping and scrolling, and a rect\n * captured at open time is stale the moment the log moves under it. `rectForIndex` is what stops the\n * fourth photo flying back into the first photo's thumbnail; a tile that is not in the DOM (past\n * `MAX_TILES`, or scrolled out of the log) has no rect, and the viewer fades for it, which is what\n * native does for an off-screen item.\n */\nfunction useTileRects(frame: RefObject<HTMLDivElement | null>, run: PhotoRun) {\n  const key = run.ids.join(\" \");\n  const counts = runSizes(run).join(\",\");\n  const [measured, setMeasured] = useState<{ id: string; rects: TileRects } | null>(null);\n  useLayoutEffect(() => {\n    if (!key) return;\n    const ids = key.split(\" \");\n    const sizes = counts.split(\",\").map(Number);\n    const measure = () => {\n      const next = readTileRects(frame.current, ids, sizes);\n      if (next) setMeasured(current => (current?.id === key && sameRects(current.rects, next) ? current : { id: key, rects: next }));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    const root = frame.current;\n    if (root) observer.observe(root);\n    return () => observer.disconnect();\n  }, [frame, key, counts]);\n  // The setter is handed back so the tap that opens the viewer can seed it in the same event, before\n  // the state that mounts the viewer commits: without that seed the viewer's first frame has no\n  // source rect and it opens on the fade path instead of growing out of the tile that was tapped.\n  return [measured?.id === key ? measured.rects : [], setMeasured] as const;\n}\n\n/**\n * Every photo tile of a run of messages, in the frame's own coordinates and in run order — one entry\n * per photo of the run, `null` where that tile is not in the DOM.\n *\n * The nulls are the point. A run spans messages and the log only keeps the rows near the viewport, so\n * packing the survivors would slide every later photo's rect onto the wrong photo and the exit would\n * fly the third photo back into the second one's balloon. `sizes` says how many slots each message\n * owns, so a row that is not drawn leaves holes instead of closing the gap, and `rectForIndex`\n * already reads a missing rect as \"fade instead of fly\" — which is what native does for an\n * off-screen item anyway.\n */\n// DOM rectangles include an embedding preview's transform; overlay positions use local CSS pixels.\nfunction rectInFrame(root: HTMLElement, box: Pick<DOMRect, \"left\" | \"top\" | \"width\" | \"height\">): Rect {\n  const host = root.getBoundingClientRect();\n  const scaleX = host.width / root.clientWidth || 1;\n  const scaleY = host.height / root.clientHeight || 1;\n  return { x: (box.left - host.left) / scaleX, y: (box.top - host.top) / scaleY, width: box.width / scaleX, height: box.height / scaleY };\n}\n\nfunction readTileRects(root: HTMLElement | null, ids: readonly string[], sizes: readonly number[]): TileRects | null {\n  if (!root || !ids.length) return null;\n  const rects: TileRects = [];\n  let drewAny = false;\n  ids.forEach((id, index) => {\n    const row = root.querySelector<HTMLElement>(`[data-message-id=\"${cssEscape(id)}\"]`);\n    const tiles = row ? Array.from(row.querySelectorAll<HTMLElement>('[data-slot=\"photo-tile\"]')) : [];\n    if (row) drewAny = true;\n    for (let slot = 0; slot < (sizes[index] ?? 0); slot++) {\n      const tile = tiles[slot];\n      if (!tile) { rects.push(null); continue; }\n      const box = tile.getBoundingClientRect();\n      rects.push(rectInFrame(root, box));\n    }\n  });\n  return drewAny ? rects : null;\n}\n\nfunction sameRects(a: TileRects, b: TileRects): boolean {\n  return a.length === b.length && a.every((rect, index) => {\n    const other = b[index];\n    if (!rect || !other) return rect === other;\n    return Math.abs(rect.x - other.x) < 0.5 && Math.abs(rect.y - other.y) < 0.5 &&\n      Math.abs(rect.width - other.width) < 0.5 && Math.abs(rect.height - other.height) < 0.5;\n  });\n}\n\n/** The top edge of one element inside the frame, in the frame's own coordinates. */\nfunction useFrameTop(frame: RefObject<HTMLDivElement | null>, selector: string | null | false) {\n  const [top, setTop] = useState<number | null>(null);\n  useLayoutEffect(() => {\n    if (!selector) { return; }\n    const measure = () => {\n      const root = frame.current;\n      const target = root?.querySelector<HTMLElement>(selector);\n      if (!root || !target) return;\n      const next = rectInFrame(root, target.getBoundingClientRect()).y;\n      setTop(current => (current !== null && Math.abs(current - next) < 0.5 ? current : next));\n    };\n    measure();\n    const observer = new ResizeObserver(measure);\n    const root = frame.current;\n    if (root) observer.observe(root);\n    return () => observer.disconnect();\n  }, [frame, selector]);\n  return selector ? top : null;\n}\n\n/** Your own reaction on a message, in the shape both the tapback bar and the viewer take. */\nfunction ownReactionOf(message: Message | undefined): TapbackSelection | null {\n  const mine = message?.reactions?.find(reaction => reaction.byMe);\n  if (!mine) return null;\n  return mine.emoji ? { emoji: mine.emoji } : { type: mine.type as TapbackType };\n}\n\n/**\n * Who reacted to a message, as the Tapback Details platter wants them. A reaction that names nobody\n * is yours or the person you are talking to, which is the only pair a one-to-one conversation has;\n * a group names its own people through `MessageReaction.by`.\n */\nfunction reactorsOf(message: Message | undefined, contactName: string): TapbackReactor[] {\n  if (!message?.reactions?.length) return [];\n  return message.reactions.map((reaction, index) => ({\n    id: `${message.id}-r${index}`,\n    name: reaction.by ?? (reaction.byMe ?? true ? \"You\" : contactName),\n    initials: reaction.byInitials,\n    reaction: reaction.emoji ? undefined : (reaction.type as TapbackType),\n    emoji: reaction.emoji,\n    own: reaction.byMe ?? true,\n  }));\n}\n\n/**\n * The whole iOS 26 Messages app in a 402×874 frame: status bar, the conversation list, the\n * conversation screen (nav bar, message log, composer), the New Message sheet, and every surface\n * those reach — the long-press overlay and the select mode its \"Select\" row opens, the effects\n * screen, a reply thread, the `+` menu and the\n * Photos picker, sticker sheet and voice recorder it opens, the details screen (`GroupDetails` for\n * a group, `IosDetails` for one person), the Tapback Details platter, search over the list, and the\n * full-screen photo viewer.\n *\n * Data stays with the caller: this component still only draws state, and it appends nothing to\n * `messages`. What it does own is *presentation*. Every one of those surfaces is either stated by a\n * prop — with `progress` to seek its entrance instead of playing it, which is what a harness needs —\n * or, with that prop left out, held by the shell, so the gesture that opens it natively opens it\n * here with nothing wired. See the block of surface props on `IosMessagesAppProps` for the contract.\n */\nexport function IosMessagesApp({\n  width = iosScreen.width, height = iosScreen.height, time = \"9:41\", screen = \"conversation\", conversations = [], contact, group = false, participants,\n  messages, typing = false, now, composer, screenTransition, onBack, onSelectConversation, onCompose, onCloseNewMessage, onDetails,\n  thread, onOpenThread, onCloseThread,\n  sendAnimation, receiveAnimation, onSendAnimationEnd,\n  longPress, onLongPress, onLongPressClose, onTapback, onMenuAction, onReplayEffect,\n  selectMode, onOpenSelectMode, onCloseSelectMode, selectedMessageIds, onSelectMessage, onDeleteMessages, onForwardMessages,\n  effectsPicker, onEffectsPickerOpen, onEffectsTabChange, onEffectSelect, onSendWithEffect, onEffectsPickerClose,\n  plusMenu, onPlusMenuSelect, onPlusMenuClose, plusMenuItems = defaultPlusMenuItems,\n  photoViewer, onOpenPhoto, onPhotoIndexChange, onClosePhoto, onSharePhoto, onSavePhoto,\n  photoPicker, photos, onPhotoPickerSelectionChange, onPhotoPickerDetentChange, onPhotoPickerClose,\n  stickerPicker, onStickerPickerTab, onStickerPickerClose, onStickerSelect, onStickerPlace, onStickerEdit,\n  audioRecorder, onAudioRecorderClose, onAudioSend,\n  details, onCloseDetails, detailsContent, onGroupNameChange, onGroupEvent,\n  tapbackDetails, onOpenTapbackDetails, onCloseTapbackDetails, onRemoveTapback,\n  search, onOpenSearch, onCloseSearch, onSearchQueryChange, onSearchSelect, onSearchSeeAll,\n  systemArrival,\n  overlay, renderReactions = defaultReactions, className, style, frameRef,\n}: IosMessagesAppProps) {\n  const localFrame = useRef<HTMLDivElement>(null);\n  const frame = frameRef ?? localFrame;\n  const list = useRef<MessageListHandle>(null);\n  const [pressedRect, setPressedRect] = useState<{ id: string; rect: Rect; tail: boolean } | null>(null);\n  const pressed = longPress ? messages.find(message => message.id === longPress.id) : undefined;\n  const pressedId = pressed?.id;\n  // The overlay stays mounted for its exit timeline after `longPress` clears. Deriving this during\n  // render rather than in an effect matters: an effect would leave one committed frame with the\n  // overlay already gone, and the dismissal would never be seen.\n  const [seenPressedId, setSeenPressedId] = useState<string | null>(pressedId ?? null);\n  const [closing, setClosing] = useState<string | null>(null);\n  if (seenPressedId !== (pressedId ?? null)) {\n    setSeenPressedId(pressedId ?? null);\n    setClosing(pressedId ? null : seenPressedId);\n  }\n  const overlayId = pressedId ?? closing;\n  const overlayMessage = overlayId ? messages.find(message => message.id === overlayId) : undefined;\n\n  // Same derive-during-render rule as the long-press overlay: the effects screen has to outlive the\n  // prop that opened it or its exit never gets a committed frame to run in.\n  const pickerOpen = effectsPicker != null;\n  const [seenPickerOpen, setSeenPickerOpen] = useState(pickerOpen);\n  const [pickerClosing, setPickerClosing] = useState(false);\n  const [capturedDraft, setCapturedDraft] = useState(\"\");\n  if (seenPickerOpen !== pickerOpen) {\n    setSeenPickerOpen(pickerOpen);\n    setPickerClosing(!pickerOpen && seenPickerOpen);\n  }\n  const draft = effectsPicker?.draft ?? composer?.value ?? capturedDraft;\n\n  // A screen change is a transition, not a cut, so the screen that is leaving has to outlive the\n  // prop that dismissed it. Same rule as the two overlays above: the previous screen is captured\n  // during render, because an effect would leave one committed frame with it already unmounted and\n  // the push out would never run.\n  const [seenScreen, setSeenScreen] = useState<IosScreen>(screen);\n  // Whether the New Message sheet has ever been up in this app. The device animates the first\n  // presentation and snaps every one after it (`iosScreenTransition.represent`), so the difference\n  // is not which screen is arriving but whether the sheet already exists to arrive.\n  const [sheetBuilt, setSheetBuilt] = useState(false);\n  const [nav, setNav] = useState<{ from: IosScreen; to: IosScreen; run: number; built: boolean } | null>(null);\n  if (seenScreen !== screen) {\n    setSeenScreen(screen);\n    // `built` is frozen into the transition rather than read back on every render: the sheet becomes\n    // built the moment this presentation puts it up, and re-deriving the kind from that would turn\n    // the presentation into a re-present halfway through and cut its own animation off.\n    setNav(screenTransition ? null : current => ({ from: seenScreen, to: screen, run: (current?.run ?? 0) + 1, built: sheetBuilt }));\n  }\n  // A stated transition replaces a derived one. Leaving the derived one behind would replay it the\n  // moment the caller stopped stating transitions.\n  if (screenTransition && nav) setNav(null);\n  const moving = screenTransition\n    ? { from: screenTransition.from, kind: screenTransitionKind(screenTransition.from, screen, { built: screenTransition.represent }), run: -1, progress: screenTransition.progress }\n    : nav\n      ? { from: nav.from, kind: screenTransitionKind(nav.from, nav.to, { built: nav.built }), run: nav.run, progress: undefined }\n      : null;\n  const kind = moving?.kind ?? null;\n  const from = kind ? moving!.from : null;\n  const showList = screen === \"list\" || screen === \"new-message\" || from === \"list\" || from === \"new-message\";\n  const showConversation = screen === \"conversation\" || from === \"conversation\";\n  const showSheet = screen === \"new-message\" || kind === \"dismiss\";\n  // Recorded after `kind`, never before: the presentation that builds the sheet has to see `false`.\n  if (showSheet && !sheetBuilt) setSheetBuilt(true);\n  // A re-present has no timeline, so nothing ever finishes to clear `nav` the way every other kind\n  // does — it is over in the frame it starts in.\n  if (nav && kind === \"represent\") setNav(null);\n  const listLayer = useRef<HTMLDivElement>(null);\n  const conversationLayer = useRef<HTMLDivElement>(null);\n  const screenDim = useRef<HTMLDivElement>(null);\n  const movingRun = moving?.run;\n  const movingProgress = moving?.progress;\n  useLayoutEffect(() => {\n    if (!kind) return;\n    // A re-present is not animated on the device, so there is nothing to run and nothing to seek:\n    // the sheet is simply there, at rest, in the frame it appears in.\n    if (kind === \"represent\") return;\n    const t = iosScreenTransition;\n    const duration = t[kind];\n    const running: Animation[] = [];\n    const play = (element: Element | null | undefined, keyframes: Keyframe[], easing: string = t.ease) => {\n      if (element) running.push(element.animate(keyframes, { duration, easing, fill: \"both\" }));\n    };\n    // A pop is the push played backwards, so that pair is written once, in the forward direction,\n    // and the frames are swapped. The sheet's *panel* is no longer such a pair — the dismissal is\n    // measured and the presentation is not — but its dim still is, so `order` still serves both.\n    const forward = kind === \"push\" || kind === \"present\";\n    const order = (a: Keyframe, b: Keyframe) => (forward ? [a, b] : [b, a]);\n    if (kind === \"push\" || kind === \"pop\") {\n      play(conversationLayer.current, order({ transform: \"translateX(100%)\" }, { transform: \"translateX(0%)\" }));\n      play(listLayer.current, order({ transform: \"translateX(0%)\" }, { transform: `translateX(${-100 * t.parallax}%)` }));\n      play(screenDim.current, order({ opacity: 0 }, { opacity: t.dim }));\n    } else {\n      // The sheet paints its own dim on its root and holds the panel inside it, so the panel slides\n      // and the dim fades separately. Reading the resting colour keeps the fade theme-correct\n      // without this file knowing what the sheet's dim is.\n      const sheet = frame.current?.querySelector<HTMLElement>('[data-slot=\"ios-new-message-sheet\"]');\n      const panel = sheet?.querySelector<HTMLElement>('[data-slot=\"sheet\"]');\n      const dim = sheet ? getComputedStyle(sheet).backgroundColor : \"rgba(0, 0, 0, 0)\";\n      // Leaving, the panel runs the recorded table — linearly, because the curve is in the samples\n      // and an ease over the whole effect would re-time them. Arriving, it runs the shell's guess.\n      if (kind === \"dismiss\") play(panel, iosSheetDismissKeyframes(), \"linear\");\n      else play(panel, [{ transform: \"translateY(100%)\" }, { transform: \"translateY(0%)\" }]);\n      play(sheet, order({ backgroundColor: \"rgba(0, 0, 0, 0)\" }, { backgroundColor: dim }));\n    }\n    if (movingProgress !== undefined) {\n      // Seeked, not played: a scrubbed checkpoint has to land on the same frame every run.\n      const at = Math.max(0, Math.min(1, movingProgress)) * duration;\n      for (const animation of running) { animation.pause(); animation.currentTime = at; }\n      return () => { for (const animation of running) animation.cancel(); };\n    }\n    const last = running[running.length - 1];\n    const done = () => setNav(null);\n    last?.addEventListener(\"finish\", done);\n    // Reduced motion cuts to the destination: the same timelines, jumped to their end, so the screen\n    // that is leaving still unmounts through the same finish.\n    if (typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) {\n      for (const animation of running) animation.finish();\n    }\n    return () => {\n      last?.removeEventListener(\"finish\", done);\n      for (const animation of running) animation.cancel();\n    };\n  }, [kind, from, movingRun, movingProgress, frame]);\n\n  // The thread overlay follows the same derive-during-render rule so its exit gets a frame to run in.\n  const threadRootId = thread?.rootId ?? null;\n  const [seenThread, setSeenThread] = useState<string | null>(threadRootId);\n  const [threadClosing, setThreadClosing] = useState<string | null>(null);\n  if (seenThread !== threadRootId) {\n    setSeenThread(threadRootId);\n    setThreadClosing(threadRootId ? null : seenThread);\n  }\n  const threadId = threadRootId ?? threadClosing;\n  const threadRoot = threadId ? messages.find(message => message.id === threadId) : undefined;\n  const threadReplies = threadId ? messages.filter(message => message.replyTo?.id === threadId) : [];\n  const threadOpen = threadRootId !== null;\n  const threadShown = Boolean(threadRoot);\n  const threadProgress = thread?.progress;\n  // The thread's blur belongs to the conversation, not to the overlay. `ReplyThread` blurs either a\n  // copy of the conversation passed as `backdrop` or, with neither, whatever `backdrop-filter`\n  // samples, and it documents why the second is unreliable in a capture. This is the third way: the\n  // conversation is already its own layer here, so blurring that layer is a real filter with no\n  // second copy of the log in the DOM (a copy would duplicate every `data-message-id`, which the\n  // long-press measurement below looks up by). `blur={0}` on the overlay leaves the job here, and\n  // the ramp copies the overlay's own: the dim's duration, seeked on the entrance's clock.\n  useLayoutEffect(() => {\n    const element = conversationLayer.current;\n    if (!element || !threadShown) return;\n    const blur = replyThreadMetrics.ios.blur;\n    const animation = threadOpen\n      ? element.animate([{ filter: \"blur(0px)\" }, { filter: `blur(${blur}px)` }], { duration: replyThreadMotion.dim, easing: \"ease-out\", fill: \"both\" })\n      : element.animate([{ filter: `blur(${blur}px)` }, { filter: \"blur(0px)\" }], { duration: replyThreadMotion.exit, easing: replyThreadMotion.exitEase, fill: \"both\" });\n    if (threadOpen && threadProgress !== undefined) {\n      animation.pause();\n      animation.currentTime = Math.max(0, Math.min(1, threadProgress)) * replyThreadMotion.enter;\n    } else if (typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches) {\n      // The overlay jumps to its settled frame under reduced motion; the blur goes with it.\n      animation.finish();\n    }\n    return () => animation.cancel();\n  }, [threadShown, threadOpen, threadProgress]);\n\n  useArrivalAnimation({ frame: conversationLayer, messages, send: sendAnimation, receive: receiveAnimation, onSendEnd: onSendAnimationEnd });\n\n  // Measure the pressed bubble's body inside the frame so the overlay can lift a copy of it in place.\n  // The first render after `longPress` opens has no layout yet, so measure on the next frame; the\n  // observer keeps the rect right when the list scrolls, wraps, or fonts settle.\n  useLayoutEffect(() => {\n    if (!pressedId) return;\n    let raf = 0;\n    const measure = () => {\n      const root = frame.current;\n      const row = root?.querySelector<HTMLElement>(`[data-message-id=\"${pressedId}\"]`);\n      if (!root || !row) return;\n      // Every body this message drew, minus anything inside its quoted stub. A file message can carry\n      // several cards, so take the union rather than the first: the lift covers all of them.\n      const bodies = Array.from(row.querySelectorAll<HTMLElement>(messageBodySelector)).filter(element => !insideQuotedStub(element));\n      if (!bodies.length) return;\n      const boxes = bodies.map(element => element.getBoundingClientRect());\n      const left = Math.min(...boxes.map(b => b.left)), top = Math.min(...boxes.map(b => b.top));\n      const rect = rectInFrame(root, { left, top, width: Math.max(...boxes.map(b => b.right)) - left, height: Math.max(...boxes.map(b => b.bottom)) - top });\n      // Read the tail off the message rather than assuming one: a bubble in the middle of a cluster\n      // has none, and an emoji-only message has no bubble to hang one from.\n      const tail = Array.from(row.querySelectorAll<HTMLElement>('[data-slot=\"tail\"]')).some(element => !insideQuotedStub(element));\n      // Height is part of \"the same rect\": it used to be left out, so a body that grew without moving\n      // its top-left corner or changing its width was never picked up — which is exactly what a photo\n      // does when it loads and swaps the 4:3 placeholder for its own aspect.\n      setPressedRect(current => (current?.id === pressedId && current.tail === tail && Math.abs(current.rect.x - rect.x) < 0.5 && Math.abs(current.rect.y - rect.y) < 0.5 && Math.abs(current.rect.width - rect.width) < 0.5 && Math.abs(current.rect.height - rect.height) < 0.5 ? current : { id: pressedId, rect, tail }));\n    };\n    const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(measure); };\n    // Measure synchronously so the overlay is on screen at the first paint after the press. Deferring\n    // it to a frame makes the opened menu appear one frame late, which is invisible to a person but\n    // makes a screenshot of the opening checkpoint depend on timing.\n    measure();\n    const observer = new ResizeObserver(schedule);\n    if (frame.current) observer.observe(frame.current);\n    const row = frame.current?.querySelector(`[data-message-id=\"${pressedId}\"]`);\n    if (row) observer.observe(row);\n    window.addEventListener(\"resize\", schedule);\n    document.fonts?.ready.then(schedule).catch(() => {});\n    return () => { observer.disconnect(); cancelAnimationFrame(raf); window.removeEventListener(\"resize\", schedule); };\n  }, [pressedId, frame, messages, width, height]);\n  const pressedBody = pressedRect && pressedRect.id === overlayId ? pressedRect : null;\n\n  // ---------------------------------------------------------------------------------------------\n  // The presented surfaces. Each resolves to `the prop, if it was passed, otherwise the shell's own\n  // state`, so leaving a prop out does not disable the surface — it hands it to the shell, and the\n  // gesture that opens it natively opens it here. `useExit` keeps each one mounted for its exit.\n  // ---------------------------------------------------------------------------------------------\n  const isGroup = group || (participants?.length ?? 0) > 1;\n  // The Ø60 slot the details screen's entrance morphs out of, so a group grows out of its own faces.\n  const navAvatar = participants && participants.length > 1\n    ? <GroupAvatar participants={participants} size={groupAvatarMetrics.phone.groupAvatar} name={contact.name} />\n    : contact.photo ? (\n      // eslint-disable-next-line @next/next/no-img-element -- framework-neutral registry component\n      <img src={contact.photo} alt=\"\" className=\"size-full object-cover\" draggable={false} />\n    ) : undefined;\n\n  const [ownPlusMenu, setOwnPlusMenu] = useState(false);\n  const plusValue = plusMenu === undefined ? (ownPlusMenu ? openMarker : null) : plusMenu;\n  const plusLatch = useExit(plusValue ? \"open\" : null);\n\n  const [ownViewer, setOwnViewer] = useState<NonNullable<IosMessagesAppProps[\"photoViewer\"]> | null>(null);\n  const viewerValue = photoViewer === undefined ? ownViewer : photoViewer;\n  const viewerLatch = useExit(viewerValue?.id ?? null);\n  const viewerId = viewerLatch.mounted;\n  // The photo that is showing, held by the shell whether or not the caller controls the viewer, so\n  // the exit lands on the tile of the photo the reader paged to rather than the one it opened on.\n  // The number counts photos in the RUN, not in one message — see `photoRun`.\n  const [viewerIndex, setViewerIndex] = useState(0);\n  const viewerRun = useMemo(() => photoRun(messages, viewerId ?? \"\"), [messages, viewerId]);\n  const shownIndex = Math.min(Math.max(viewerValue?.index ?? viewerIndex, 0), Math.max(viewerRun.photos.length - 1, 0));\n  // Which message of the run the photo on screen belongs to: the share, the save, the reply and the\n  // tapback all act on the item that is showing, not on the one the reader happened to tap.\n  const shownRunIndex = viewerRun.offsets.reduce((found, offset, index) => (offset <= shownIndex ? index : found), 0);\n  const shownMessageId = viewerRun.ids[shownRunIndex] ?? viewerId;\n  const shownMessage = shownMessageId ? messages.find(message => message.id === shownMessageId) : undefined;\n  const shownPhotoIndex = shownIndex - (viewerRun.offsets[shownRunIndex] ?? 0);\n  const [viewerRects, seedTileRects] = useTileRects(frame, viewerRun);\n\n  const [ownPhotoPicker, setOwnPhotoPicker] = useState<NonNullable<IosMessagesAppProps[\"photoPicker\"]> | null>(null);\n  const photoPickerValue = photoPicker === undefined ? ownPhotoPicker : photoPicker;\n  const photoPickerLatch = useExit(photoPickerValue ? \"open\" : null);\n  const pickerPhotos = photos ?? photoPickerSamples;\n  const picked = photoPickerValue?.selected ?? [];\n\n  const [ownSticker, setOwnSticker] = useState<NonNullable<IosMessagesAppProps[\"stickerPicker\"]> | null>(null);\n  const stickerValue = stickerPicker === undefined ? ownSticker : stickerPicker;\n  const stickerLatch = useExit(stickerValue ? \"open\" : null);\n  // Where a dropped sticker landed, when nothing above this shell is keeping them. `onPlace` hands\n  // over the settled pose, so drawing it here takes over from the ghost with no jump.\n  const [placed, setPlaced] = useState<Array<{ key: string; sticker: Sticker; placement: StickerPlacement }>>([]);\n\n  const [ownRecorder, setOwnRecorder] = useState<{ state: AudioRecorderState; take?: AudioTake } | null>(null);\n  const recorderValue: NonNullable<IosMessagesAppProps[\"audioRecorder\"]> | null = audioRecorder === undefined\n    ? (ownRecorder ? { state: ownRecorder.state, levels: ownRecorder.take?.levels, duration: ownRecorder.take?.duration } : null)\n    : audioRecorder;\n  const recorderLatch = useExit(recorderValue ? \"open\" : null);\n\n  const [ownDetails, setOwnDetails] = useState(false);\n  const detailsValue = details === undefined ? (ownDetails ? openMarker : null) : details;\n  const detailsLatch = useExit(detailsValue ? \"open\" : null);\n\n  const [ownTapbackDetails, setOwnTapbackDetails] = useState<string | null>(null);\n  const tapbackDetailsValue = tapbackDetails === undefined ? (ownTapbackDetails ? { id: ownTapbackDetails } : null) : tapbackDetails;\n  const tapbackDetailsLatch = useExit(tapbackDetailsValue?.id ?? null);\n  const reactedId = tapbackDetailsLatch.mounted;\n  const reactedMessage = reactedId ? messages.find(message => message.id === reactedId) : undefined;\n  const reactors = useMemo(() => reactorsOf(reactedMessage, contact.name), [reactedMessage, contact.name]);\n  // ChatKit's `votingViewTargetFrame`: y = max(minPadding, the tapback's own frame origin). The\n  // balloon is the thing the platter is about, so its own top is what anchors it.\n  const platterTop = useFrameTop(frame, reactedId && `[data-message-id=\"${cssEscape(reactedId)}\"] [data-slot=\"tapback\"]`);\n\n  const [ownSelectMode, setOwnSelectMode] = useState(false);\n  const selectValue = selectMode === undefined ? (ownSelectMode ? openMarker : null) : selectMode;\n  const selecting = selectValue !== null;\n  const selectLatch = useExit(selecting ? \"open\" : null);\n  // Mounted while the mode is open or playing its exit, and never over another screen.\n  const selectShown = selectLatch.mounted !== null && showConversation;\n  // The same timeline as the `IosSelectMode` below, for the two things that are not select-mode\n  // components: the rules that drop the composer and step the incoming rows aside.\n  const selectTransition = useSelectModeTransition({ active: selecting, progress: selectValue?.progress });\n  const [ownSelected, setOwnSelected] = useState<readonly string[]>([]);\n  const selectedIds = selectedMessageIds ?? ownSelected;\n  const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);\n  /** Selection is the caller's if it owns it and the shell's otherwise; both are told either way. */\n  const changeSelection = (ids: string[], id: string | null) => {\n    setOwnSelected(ids);\n    onSelectMessage?.(ids, { id });\n  };\n  const toggleSelected = (id: string) => changeSelection(selectedSet.has(id) ? selectedIds.filter(entry => entry !== id) : [...selectedIds, id], id);\n  const closeSelectMode = () => {\n    setOwnSelectMode(false);\n    changeSelection([], null);\n    onCloseSelectMode?.();\n  };\n  /**\n   * \"Select\" on a message: the menu folds away and the mode opens with that message already ticked,\n   * which is the one state `select-mode-dark.png` shows — exactly one circle filled.\n   */\n  const openSelectMode = (id: string) => {\n    setOwnSelectMode(true);\n    changeSelection([id], id);\n    onLongPressClose?.();\n    onOpenSelectMode?.(id);\n  };\n\n  const [ownSearch, setOwnSearch] = useState<NonNullable<IosMessagesAppProps[\"search\"]> | null>(null);\n  const searchValue = search === undefined ? ownSearch : search;\n  const searchLatch = useExit(searchValue ? \"open\" : null);\n  const searchShown = searchLatch.mounted !== null && showList;\n  const searchListLayer = useRef<HTMLDivElement>(null);\n\n  const closePlusMenu = () => { setOwnPlusMenu(false); onPlusMenuClose?.(); };\n  /**\n   * `index` arrives as the tile's index inside `id`'s own message, because that is all the tile\n   * knows. The viewer counts in run photos, so it is rebased here — and `onOpenPhoto` keeps\n   * reporting the message-local index it was handed, which is the number its caller can act on.\n   */\n  const openPhoto = (id: string, index: number) => {\n    const run = photoRun(messages, id);\n    const runIndex = (run.offsets[run.ids.indexOf(id)] ?? 0) + index;\n    // Read the tiles in the same event that mounts the viewer, so its very first frame already has\n    // the box it grows out of; both writes batch into one commit.\n    const rects = readTileRects(frame.current, run.ids, runSizes(run));\n    if (rects) seedTileRects({ id: run.ids.join(\" \"), rects });\n    setViewerIndex(runIndex);\n    setOwnViewer({ id, index: runIndex });\n    onOpenPhoto?.(id, index);\n  };\n  const openTapbackDetails = (id: string) => { setOwnTapbackDetails(id); onOpenTapbackDetails?.(id); };\n  const changePhotoSelection = (next: string[]) => {\n    setOwnPhotoPicker(current => (current ? { ...current, selected: next } : current));\n    onPhotoPickerSelectionChange?.(next);\n  };\n  /**\n   * A row of the plus menu. The three that have a surface in this registry open it; the rest only\n   * report, because there is nothing measured to open. The menu folds back into the `+` either way,\n   * which is what it does natively once a row has been chosen.\n   */\n  const selectPlusItem = (id: string) => {\n    closePlusMenu();\n    if (id === \"photos\") setOwnPhotoPicker({ selected: [], detent: \"collapsed\" });\n    if (id === \"stickers\") setOwnSticker({});\n    if (id === \"audio\") setOwnRecorder({ state: \"recording\" });\n    onPlusMenuSelect?.(id);\n  };\n  const menuItems = plusMenuItems.map(item => ({ ...item, onSelect: () => { item.onSelect?.(); selectPlusItem(item.id); } }));\n\n  const recorderUp = recorderLatch.mounted !== null;\n  const pickerUp = photoPickerLatch.mounted !== null;\n  // Keep the measured gap above the bottom-anchored picker in shorter app previews too.\n  const pickerComposerTop = photoPickerComposerTop + height - iosScreen.height;\n  /**\n   * The composer's own band is 68 tall and the recorder's root is\n   * `composer.bottom + rowHeight` = 80, so the log gives up another 12 while a take is being made.\n   *\n   * The picker's number is a derivation, not a reading: the composer's measured top plus the 28 the\n   * band already keeps clear. Nothing in `references/` shows the transcript with the picker up, so\n   * how far the log is really pushed is UNMEASURED.\n   */\n  const listBottom = recorderUp\n    ? iosScreen.listBottom + (audioRecorderMetrics.ios.composer.bottom + audioRecorderMetrics.ios.rowHeight - iosScreen.composer)\n    : pickerUp\n      ? iosScreen.height - photoPickerComposerTop + (iosScreen.listBottom - iosScreen.composer)\n      : iosScreen.listBottom;\n\n  /**\n   * The conversation drawn again for the details screen to blur and push back. A second copy rather\n   * than the layer itself because the screen owns that filter and its scale; it is `inert`, so the\n   * duplicate carries no tab stops, and it comes after the live layer in the document, so every\n   * `[data-message-id]` lookup in this file still finds the real row first.\n   */\n  // Native always draws all three circles; `details-light.png` has Call live and FaceTime and Mail\n  // in the tertiary label colour, circle intact. So the default is the row, not an empty space.\n  const detailsActions: IosDetailsAction[] = detailsContent?.actions ?? [\n    { id: \"call\", label: \"Call\", icon: \"phone\", disabled: !detailsContent?.onAudioCall, onPress: detailsContent?.onAudioCall },\n    { id: \"facetime\", label: \"FaceTime\", icon: \"video\", disabled: !detailsContent?.onFaceTime, onPress: detailsContent?.onFaceTime },\n    { id: \"mail\", label: \"Mail\", icon: \"mail\", disabled: !detailsContent?.onMail, onPress: detailsContent?.onMail },\n  ];\n\n  const detailsBackdrop = () => (\n    <div inert className=\"absolute inset-0\" style={{ background: \"var(--im-bg)\" }}>\n      <MessageList messages={messages} typing={typing} group={isGroup} now={now} anchor=\"top\"\n        insetTop={iosScreen.listTop} insetBottom={iosScreen.listBottom} renderReactions={renderReactions}\n        className=\"absolute inset-0\" />\n      <IosScrollEdge />\n      <IosNavBar name={contact.name} initials={contact.initials} avatar={navAvatar} className=\"absolute left-0\" style={{ top: iosScreen.statusBar }} />\n      {/* `attachExpanded` even here: the backdrop is `inert` and `aria-hidden`, but a `+` that says it\n          has a menu and never says whether the menu is open is half an attribute wherever it sits. */}\n      <IosComposer className=\"absolute bottom-0 left-0\" value={composer?.value} placeholder={composer?.placeholder} attachExpanded={false} disabled />\n    </div>\n  );\n\n  return (\n    <PlatformProvider platform=\"ios\">\n      <PaletteStyle platform=\"ios\" />\n      <div ref={frame} data-slot=\"ios-messages-app\" data-im-platform=\"ios\" data-screen={screen} data-transition={kind ?? undefined}\n        className={cn(\"relative isolate overflow-hidden select-none\", className)}\n        style={{ width, height, background: \"var(--im-bg)\", color: \"var(--im-incoming-text)\", fontFamily: \"-apple-system, BlinkMacSystemFont, sans-serif\", ...style }}>\n        {/* Each screen is its own layer so a transition has something to move. At rest a layer\n            carries no transform: a transform node makes Chrome snap its descendants to whole CSS px,\n            which would move text by a fraction of a point against the captures. */}\n        {showList && (\n          <div ref={listLayer} data-slot=\"screen-list\" className=\"absolute inset-0\" inert={screen !== \"list\" || searchShown || undefined} aria-hidden={screen !== \"list\" || searchShown || undefined} style={{ pointerEvents: screen === \"conversation\" ? \"none\" : undefined }}>\n            {/* The layer search rises and falls: the list itself, without its bottom bar, because\n                `IosSearch` draws that bar. Leaving both would paint the glass fill and its\n                `0 6px 36px spread 4` shadow twice, which measures 1.04% against `list-light.png`\n                with a -2.24 uniform darkening; hidden, the same band diffs 0.02%. */}\n            <div ref={searchListLayer} className=\"absolute inset-0\">\n              <IosConversationList conversations={conversations} onSelect={onSelectConversation} onCompose={onCompose}\n                onSearch={() => { setOwnSearch({ query: \"\" }); onOpenSearch?.(); }}\n                topInset={iosScreen.statusBar} className={cn(\"absolute inset-0\", searchShown && \"[&_[data-slot=bottom-bar]]:hidden\")} />\n            </div>\n            {(kind === \"push\" || kind === \"pop\") && <div ref={screenDim} aria-hidden=\"true\" data-slot=\"screen-dim\" className=\"pointer-events-none absolute inset-0\" style={{ background: \"#000000\", opacity: 0 }} />}\n          </div>\n        )}\n        {/* Search is an overlay on the list, not a fifth screen: the list stays mounted underneath\n            and the two measured tables move it. `closing` picks the close table rather than playing\n            the open one backwards — the recordings show they are different animations. */}\n        {searchShown && (\n          <IosSearch\n            query={searchValue?.query}\n            onQueryChange={query => { setOwnSearch({ query }); onSearchQueryChange?.(query); }}\n            sections={searchValue?.sections ?? []}\n            maxResults={searchValue?.maxResults}\n            onSelect={onSearchSelect}\n            onSeeAll={onSearchSeeAll}\n            onCancel={() => { setOwnSearch(null); onCloseSearch?.(); }}\n            open={searchLatch.open && !search?.closing}\n            onExited={searchLatch.exited}\n            progress={searchValue?.progress}\n            topInset={iosScreen.statusBar}\n            listRef={searchListLayer} />\n        )}\n        {showConversation && (\n          <div ref={conversationLayer} data-slot=\"screen-conversation\" inert={screen !== \"conversation\" || !!viewerLatch.mounted || undefined} aria-hidden={screen !== \"conversation\" || !!viewerLatch.mounted || undefined} className=\"absolute inset-0\" style={{ background: \"var(--im-bg)\", pointerEvents: screen === \"conversation\" ? undefined : \"none\" }}>\n            {/* Select mode wraps the whole screen rather than being mounted beside it: it generates\n                no box (`display: contents`), it publishes the one `--ios-sel-t` every piece below\n                animates off, and it is what cross-fades the nav bar's back button out. It stays\n                wrapped whether or not the mode is on, because moving the log in or out of it would\n                remount `MessageList` and lose the scroll position. */}\n            <IosSelectMode active={selecting} progress={selectValue?.progress} onExited={selectLatch.exited}>\n            {selectShown && <style>{selectModeRules(selectTransition.timing)}</style>}\n            <MessageList ref={list} frameRef={frame} messages={messages} typing={typing} group={isGroup} now={now} anchor=\"top\"\n              insetTop={iosScreen.listTop} insetBottom={listBottom} renderReactions={renderReactions} messageActions={Boolean(onLongPress)} openMenuId={overlayMessage?.id ?? null} onReplayEffect={onReplayEffect}\n              onOpenThread={onOpenThread} onOpenImage={openPhoto} systemArrival={systemArrival} className=\"absolute inset-0\" />\n            {/* Over the log and under the nav bar, so a circle scrolled up behind the bar is covered\n                by it the way the bubbles are. */}\n            {selectShown && (\n              <SelectionCircleLayer frame={frame} messages={messages} selected={selectedSet} onToggle={toggleSelected}\n                active={selecting} progress={selectValue?.progress} />\n            )}\n            {/* Stickers that were let go over the transcript, at the pose `onPlace` settled on: root\n                coordinates, ChatKit's landed Ø48 and the angle the drop finished at, so the ghost\n                unmounting is invisible. They sit over the log rather than inside a bubble, because\n                nothing in `references/` measures where a landed sticker attaches. */}\n            {placed.length > 0 && (\n              <div aria-hidden=\"true\" data-slot=\"placed-stickers\" className=\"pointer-events-none absolute inset-0\">\n                {placed.map(({ key, sticker, placement }) => (\n                  // `placement.x/y` is the sticker's centre, and the two font ratios are the ones\n                  // `StickerArt` draws a cell with, so the landed sticker is the same artwork at the\n                  // same proportions the sheet showed.\n                  <span key={key} data-slot=\"placed-sticker\" className=\"absolute flex items-center justify-center\"\n                    style={{ left: placement.x - placement.size / 2, top: placement.y - placement.size / 2, width: placement.size, height: placement.size,\n                      transform: `rotate(${placement.rotation}deg)`, fontFamily: emojiFontStack,\n                      fontSize: placement.size * (sticker.kind === \"emoji\" ? 0.78 : 0.68), lineHeight: 1,\n                      background: sticker.fill, borderRadius: sticker.fill ? stickerPickerMetrics.grid.tileRadius : undefined }}>\n                    {sticker.glyph}\n                  </span>\n                ))}\n              </div>\n            )}\n            {/* Between the transcript and the bar: a message scrolling up under the floating glass is\n                washed toward the page rather than arriving at full contrast. See `ios-scroll-edge`. */}\n            <IosScrollEdge />\n            <IosNavBar name={contact.name} initials={contact.initials} avatar={navAvatar} onBack={onBack}\n              onDetails={() => { setOwnDetails(true); onDetails?.(); }} className=\"absolute left-0\" style={{ top: iosScreen.statusBar }} />\n            {/* The recorder replaces the field rather than sitting beside it: the two boxes overlap,\n                and native swaps them. While the Photos picker is up the composer moves to its\n                measured top instead, keeping its `+`, its placeholder and its mic. */}\n            {recorderUp ? (\n              <AudioRecorder platform=\"ios\"\n                state={recorderValue?.state ?? \"recording\"}\n                levels={recorderValue?.levels}\n                duration={recorderValue?.duration}\n                position={recorderValue?.position}\n                progress={recorderValue?.progress}\n                transition={recorderValue?.transition}\n                open={recorderLatch.open}\n                onExited={recorderLatch.exited}\n                onCancel={() => { setOwnRecorder(null); onAudioRecorderClose?.(); }}\n                onStop={take => setOwnRecorder({ state: \"stopped\", take })}\n                onAppend={() => setOwnRecorder(current => ({ state: \"recording\", take: current?.take }))}\n                onPlayChange={playing => setOwnRecorder(current => (current ? { ...current, state: playing ? \"playing\" : \"stopped\" } : current))}\n                onSend={take => { onAudioSend?.(take); setOwnRecorder(null); onAudioRecorderClose?.(); }} />\n            ) : (\n              <IosComposer className={cn(\"absolute left-0\", !pickerUp && \"bottom-0\")} style={pickerUp ? { top: pickerComposerTop } : undefined}\n                // Dropped by `selectModeRules`, not unmounted: it has to be on screen to be seen\n                // leaving. `inert` once it has, so the toolbar over it is the only thing reachable.\n                inert={(selecting && selectTransition.t > 0.5) || undefined}\n                value={composer?.value} placeholder={composer?.placeholder} disabled={composer?.disabled}\n                onChange={composer?.onChange} hasAttachments={picked.length > 0}\n                onSend={async text => {\n                  if (!composer?.onSend) return;\n                  const attachments = pickerPhotos.filter((photo, index) => picked.includes(photo.id ?? String(index)));\n                  await composer.onSend(text, attachments);\n                  if (attachments.length) {\n                    changePhotoSelection([]);\n                    setOwnPhotoPicker(null);\n                    if (photoPicker !== undefined) onPhotoPickerClose?.();\n                  }\n                }}\n                attachExpanded={plusLatch.open}\n                // The picker and the sticker sheet have no dismissal of their own in the capture, so\n                // the control that opened them is the way back out; otherwise the `+` opens the menu.\n                onAttach={() => {\n                  composer?.onAttach?.();\n                  if (pickerUp) { setOwnPhotoPicker(null); return; }\n                  if (stickerLatch.open) { setOwnSticker(null); onStickerPickerClose?.(); return; }\n                  setOwnPlusMenu(true);\n                }}\n                onMic={() => { composer?.onMic?.(); setOwnRecorder({ state: \"recording\" }); }} />\n            )}\n            {/* The picker's own chips over the composer. `IosComposer` has no attachment slot, so the\n                shell places them; their geometry is `photoPickerChipMetrics`, which is invented. */}\n            {pickerUp && picked.length > 0 && (\n              // UNMEASURED: the capture that pins the panel and the composer has no chips in it, so\n              // the row's own 8 pt of air above the composer is invented, as is every number in\n              // `photoPickerChipMetrics` it sits on.\n              <div className=\"absolute\" style={{ left: photoPickerChipMetrics.rowInset, top: pickerComposerTop - photoPickerChipMetrics.size - 8 }}>\n                <PhotoPickerAttachments photos={pickerPhotos.filter((photo, index) => picked.includes(photo.id ?? String(index)))}\n                  onRemove={id => changePhotoSelection(picked.filter(entry => entry !== id))} />\n              </div>\n            )}\n            {pickerUp && (\n              <PhotoPicker\n                photos={pickerPhotos}\n                selected={picked}\n                onSelectionChange={next => changePhotoSelection(next)}\n                detent={photoPickerValue?.detent}\n                onDetentChange={detent => { setOwnPhotoPicker(current => (current ? { ...current, detent } : current)); onPhotoPickerDetentChange?.(detent); }}\n                open={photoPickerLatch.open}\n                progress={photoPickerValue?.progress}\n                onExited={() => { photoPickerLatch.exited(); onPhotoPickerClose?.(); }} />\n            )}\n            {/* No holding a message while selecting: in select mode every gesture on the log is the\n                tick, which is what `SelectionCircleLayer`'s own capture-phase click does. */}\n            {onLongPress && !selecting && <LongPressLayer frame={frame} onLongPress={onLongPress} />}\n            {/* `-[CKUIBehavior canTapAssociatedAcknowledgment]` is 1: the balloon is a tap target, and\n                the tap opens the platter. Delegated on the log, in the bubble phase, so it lands\n                before React's own root listener and the same click cannot also open a thread. */}\n            <TapbackTapLayer frame={frame} onOpen={openTapbackDetails} />\n            {onEffectsPickerOpen && (\n              <SendHoldLayer\n                frame={frame}\n                onHold={value => {\n                  setCapturedDraft(value);\n                  onEffectsPickerOpen(value);\n                }}\n              />\n            )}\n            {/* Last in the layer, so the ✕ sits over the nav bar it replaces the back button in and\n                the toolbar over the composer it drops. Escape is the close button's own. */}\n            {selectShown && (\n              <>\n                <IosSelectionCloseButton onClose={closeSelectMode} />\n                <IosSelectionToolbar count={selectedIds.length}\n                  onDelete={() => { onDeleteMessages?.([...selectedIds]); closeSelectMode(); }}\n                  onForward={() => onForwardMessages?.([...selectedIds])} />\n              </>\n            )}\n            </IosSelectMode>\n          </div>\n        )}\n        {/* The attachments sheet, over the composer it grows out of. It draws no composer of its own\n            here: the shell already has one, and the menu's own rule fades that `+` out. */}\n        {plusLatch.mounted && (\n          <IosPlusMenu items={menuItems} open={plusLatch.open} progress={plusValue?.progress}\n            onExited={plusLatch.exited} onDismiss={closePlusMenu} />\n        )}\n        {/* The sticker sheet sits in the frame's own coordinate space rather than inside `overlay`,\n            because the drag ghost has to travel over the transcript in the space the transcript is in. */}\n        {stickerLatch.mounted && (\n          <StickerPicker\n            open={stickerLatch.open}\n            progress={stickerValue?.progress}\n            tab={stickerValue?.tab}\n            dragPreview={stickerValue?.drag ?? null}\n            onTabChange={tabId => { setOwnSticker(current => (current ? { ...current, tab: tabId } : current)); onStickerPickerTab?.(tabId); }}\n            onDismiss={() => { setOwnSticker(null); onStickerPickerClose?.(); }}\n            onExited={stickerLatch.exited}\n            onSelect={onStickerSelect}\n            onPlace={(sticker, placement) => {\n              onStickerPlace?.(sticker, placement);\n              if (!onStickerPlace) setPlaced(current => [...current, { key: `${sticker.id}-${current.length}`, sticker, placement }]);\n            }}\n            onEdit={onStickerEdit} />\n        )}\n        {(pickerOpen || pickerClosing) && (\n          <IosEffectsPicker\n            tab={effectsPicker?.tab}\n            selection={effectsPicker?.selection ?? null}\n            open={pickerOpen}\n            progress={effectsPicker?.progress}\n            onExited={() => setPickerClosing(false)}\n            onTabChange={onEffectsTabChange}\n            onSelect={onEffectSelect}\n            onSend={selection => onSendWithEffect?.(draft, selection)}\n            onClose={onEffectsPickerClose}\n            preview={<MessageBubble direction=\"outgoing\" tail>{draft}</MessageBubble>}\n          />\n        )}\n        {/* The thread the reply count opened: the message it hangs off, then every message replying\n            to it, over the conversation layer the effect above blurs. Its bubbles are laid out by\n            the overlay rather than by the log, so they carry no screen-space fill position and take\n            the mid-screen colour; no capture shows a thread, so that is provisional with the rest. */}\n        {threadRoot && (\n          <ReplyThread open={threadOpen} progress={threadProgress} onExited={() => setThreadClosing(null)} onClose={onCloseThread} blur={0}\n            root={<ThreadRow message={threadRoot} tail />}>\n            {threadReplies.map((message, index) => (\n              // The measured cluster rule: only the last bubble of a run by one sender is tailed.\n              <ThreadRow key={message.id} message={message} tail={index === threadReplies.length - 1 || threadReplies[index + 1].direction !== message.direction} />\n            ))}\n          </ReplyThread>\n        )}\n        {/* The details screen the name pill pushes. A group gets `GroupDetails`, one person gets\n            `IosDetails`; the two take the same `progress` / `scroll` / `open` / `onExited` contract,\n            so the branch is the only difference. `onBack` covers all three ways out — the Ø44 back\n            circle, Escape and a committed drag-down — and the screen stays mounted until `onExited`,\n            because unmounting on the back would leave its dismissal no frames to run in. */}\n        {detailsLatch.mounted && (isGroup ? (\n          <GroupDetails className=\"absolute inset-0 z-30\"\n            name={contact.name}\n            onNameChange={onGroupNameChange && (next => { onGroupNameChange(next); onGroupEvent?.({ type: \"conversationNamed\", name: next }); })}\n            participants={(participants ?? []).map((person, index) => ({\n              id: person.name ?? person.initials ?? String(index),\n              name: person.name ?? person.initials ?? \"\",\n              initials: person.initials,\n              src: person.src,\n            }))}\n            onAddContact={detailsContent?.onAddContact}\n            hideAlerts={detailsContent?.hideAlerts} onHideAlertsChange={detailsContent?.onHideAlertsChange}\n            photos={detailsContent?.photos} sharedLinks={detailsContent?.sharedLinks} attachments={detailsContent?.attachments}\n            onLeave={detailsContent?.onLeave && (() => { detailsContent.onLeave!(); onGroupEvent?.({ type: \"participantLeft\" }); })}\n            onBack={() => { setOwnDetails(false); onCloseDetails?.(); }}\n            open={detailsLatch.open} progress={detailsValue?.progress} scroll={detailsValue?.scroll}\n            onExited={detailsLatch.exited}\n            backdrop={detailsBackdrop()} />\n        ) : (\n          <IosDetails className=\"absolute inset-0 z-30\"\n            name={contact.name} initials={contact.initials} avatar={navAvatar} actions={detailsActions}\n            phone={detailsContent?.phone} phoneLabel={detailsContent?.phoneLabel} tag={detailsContent?.tag}\n            hideAlerts={detailsContent?.hideAlerts} onHideAlertsChange={detailsContent?.onHideAlertsChange}\n            photos={detailsContent?.photos} sharedLinks={detailsContent?.sharedLinks} attachments={detailsContent?.attachments}\n            onBlock={detailsContent?.onBlock}\n            onBack={() => { setOwnDetails(false); onCloseDetails?.(); }}\n            open={detailsLatch.open} progress={detailsValue?.progress} scroll={detailsValue?.scroll}\n            onExited={detailsLatch.exited}\n            backdrop={detailsBackdrop()} />\n        ))}\n        {/* The status bar stays crisp above the effects screen, the way it does natively. */}\n        <IosStatusBar time={time} className=\"absolute left-0 top-0\" />\n        {showSheet && (\n          <IosNewMessageSheet onClose={onCloseNewMessage} caret style={{ pointerEvents: screen === \"new-message\" ? undefined : \"none\" }}>\n            <IosComposer placeholder=\"\" value={composer?.value} onChange={composer?.onChange} onSend={text => composer?.onSend?.(text, [])} />\n          </IosNewMessageSheet>\n        )}\n        {/* The overlay lifts a copy of the pressed message at the same spot, so hide the original. */}\n        {overlayMessage && pressedBody && <style>{messageBodyHiddenSlots.map(slot => `[data-message-id=\"${overlayMessage.id}\"] [data-slot=\"${slot}\"]`).join(\",\")}{\"{visibility:hidden}\"}</style>}\n        {overlayMessage && pressedBody && (\n          <MessageActions rect={pressedBody.rect} frame={{ width, height }} direction={overlayMessage.direction} service={overlayMessage.service ?? \"imessage\"}\n            progress={longPress?.progress} autoFocus={longPress?.progress === undefined && !closing}\n            open={!closing} onExited={() => setClosing(null)}\n            // A photo's menu is Save / Copy / More…, not the text bubble's Copy / Translate / Select /\n            // More… — measured on the device, see `iosPhotoMenu`.\n            items={messageShape(overlayMessage) === \"image\" ? iosPhotoMenu : undefined}\n            // Measured on the device, and both photo-only: a photo's preview lifts to a fixed 326 pt\n            // width rather than by the bubble rule, its tapback pill starts 6 from the screen edge\n            // rather than 10.83, and the preview carries NO TAIL even when the balloon it came from\n            // has one (so the menu's gap is taken from the preview's own bottom edge — passing\n            // `tail={false}` is what stops the layout adding the scaled 6.8 pt hang below it).\n            {...(messageShape(overlayMessage) === \"image\"\n              ? { scale: photoLiftScale(pressedBody.rect), barEdgeInset: messageActionsMetrics.photoBarEdgeInset, tail: false }\n              : { tail: pressedBody.tail })}\n            // The menu's glass picks up the bubble behind it, and a message with no bubble has none to\n            // give: an emoji-only message, a photo, a link card and a file card all take the plain glass.\n            wash={liftsABubble(overlayMessage) ? undefined : null}\n            selected={overlayMessage.reactions?.find(r => r.byMe) ? (overlayMessage.reactions.find(r => r.byMe)!.emoji ? { emoji: overlayMessage.reactions.find(r => r.byMe)!.emoji! } : { type: overlayMessage.reactions.find(r => r.byMe)!.type as TapbackType }) : undefined}\n            onSelect={selection => onTapback?.(overlayMessage.id, selection)}\n            // \"Select\" is the one row the shell answers itself, so the mode is reachable with\n            // nothing wired; the caller still hears the action, as it does for every other row.\n            onAction={action => { if (action === \"select\") openSelectMode(overlayMessage.id); onMenuAction?.(overlayMessage.id, action); }}\n            onClose={onLongPressClose}>\n            {/* A photo's preview drops its tail: the last balloon of a run carries one at rest and the\n                capture shows a plain rounded corner in its place once it is lifted. */}\n            <LiftedMessage message={overlayMessage} tail={pressedBody.tail && messageShape(overlayMessage) !== \"image\"} screenBottom={pressedBody.rect.y + pressedBody.rect.height} />\n          </MessageActions>\n        )}\n        {/* Who reacted. It paints its own `--im-dim` scrim over the whole frame and traps Tab, so it\n            goes after the log and after the long-press overlay. `top` is the balloon's own top, which\n            is `votingViewTargetFrame`'s rule; with nothing measured yet it falls back to `minPadding`. */}\n        {reactedId && reactors.length > 0 && (\n          <TapbackDetailsPlatter\n            reactors={reactors}\n            platform=\"ios\"\n            top={platterTop === null ? undefined : Math.max(tapbackDetailsPlatterMetrics.ios.minPadding, platterTop)}\n            filter={tapbackDetailsValue?.filter}\n            open={tapbackDetailsLatch.open}\n            progress={tapbackDetailsValue?.progress}\n            autoFocus={tapbackDetailsValue?.progress === undefined}\n            onRemove={reactor => { onRemoveTapback?.(reactedId, reactor); setOwnTapbackDetails(null); onCloseTapbackDetails?.(); }}\n            onClose={() => { setOwnTapbackDetails(null); onCloseTapbackDetails?.(); }}\n            onExited={tapbackDetailsLatch.exited} />\n        )}\n        {/* The photo viewer is last: it fills the frame, draws its own status bar (`allowStatusBar`\n            is 1) and covers everything, including the shell's. `rectForIndex` is what lands the exit\n            on the photo that is showing rather than on the tile the entrance grew out of.\n            `photos` is the whole run, not the tapped message's: measured in ChatKit, the viewer\n            pages across the tapped photo's layout group. See `photoRun`. Every callback below\n            reports the message the photo ON SCREEN belongs to, which after a page is not the one\n            that was tapped. */}\n        {viewerId && viewerRun.photos.length ? (\n          <ImageViewer\n            photos={viewerRun.photos}\n            index={shownIndex}\n            onIndexChange={index => { setViewerIndex(index); setOwnViewer(current => (current ? { ...current, index } : current)); onPhotoIndexChange?.(index); }}\n            open={viewerLatch.open}\n            progress={viewerValue?.progress}\n            chrome={viewerValue?.chrome}\n            dismissProgress={viewerValue?.dismiss}\n            pageOffset={viewerValue?.pageOffset}\n            sourceRect={viewerRects[shownIndex] ?? null}\n            rectForIndex={index => viewerRects[index] ?? null}\n            time={time}\n            onClose={() => { setOwnViewer(null); onClosePhoto?.(); }}\n            onExited={viewerLatch.exited}\n            onShare={onSharePhoto && (() => onSharePhoto(shownMessageId, shownPhotoIndex))}\n            onSave={onSavePhoto && (() => onSavePhoto(shownMessageId, shownPhotoIndex))}\n            onReply={onOpenThread && (() => onOpenThread(shownMessageId))}\n            reaction={ownReactionOf(shownMessage)}\n            onReact={onTapback && (selection => onTapback(shownMessageId, selection))} />\n        ) : null}\n        {overlay}\n      </div>\n    </PlatformProvider>\n  );\n}\n\n/**\n * Which of the log's row shapes a message takes. `message-list.tsx` picks in exactly this order, so a\n * photo whose caption happens to be one emoji is still a photo, and this has to agree with it or the\n * overlay would lift something the log never drew.\n */\ntype MessageShape = \"link\" | \"image\" | \"audio\" | \"attachment\" | \"emoji\" | \"bubble\";\n\nfunction messageShape(message: Message): MessageShape {\n  if (message.kind === \"link\" && message.link) return \"link\";\n  if (message.kind === \"image\" && message.images?.length) return \"image\";\n  if (message.kind === \"audio\" && message.audio) return \"audio\";\n  if (message.kind === \"attachment\" && message.attachments?.length) return \"attachment\";\n  return message.kind !== \"link\" && isEmojiOnly(message.text) ? \"emoji\" : \"bubble\";\n}\n\n/** Only these two shapes draw a coloured bubble; the rest carry their own surface, or none at all. */\nfunction liftsABubble(message: Message): boolean {\n  const shape = messageShape(message);\n  return shape === \"bubble\" || shape === \"audio\";\n}\n\n/**\n * The copy the long-press overlay lifts. It has to be the same thing the log drew, so it is built the\n * same way `message-list.tsx` builds a row: a photo message lifts its photos, a file message its cards,\n * an emoji-only message a bare glyph at the log's emoji metrics, and only text and audio lift a bubble.\n * Lifting `message.text` as a bubble for every kind put a balloon reading \"Photos\" where the picture is.\n *\n * The glyph drops the log's `emojiShift`: the shift moves the whole element box, the overlay measured\n * the box after it moved, and the copy is placed at that measured box, so re-applying it would double it.\n */\nfunction LiftedMessage({ message, tail, screenBottom }: { message: Message; tail: boolean; screenBottom: number }) {\n  const outgoing = message.direction === \"outgoing\";\n  switch (messageShape(message)) {\n    case \"link\":\n      return <LinkPreview href={message.link!.url} title={message.link!.title} host={message.link!.host} image={message.link!.image} />;\n    case \"image\":\n      return <MessageImages images={message.images!} direction={message.direction} tail={tail} />;\n    case \"audio\":\n      return <MessageAudio duration={message.audio!.duration} peaks={message.audio!.peaks} direction={message.direction} tail={tail} />;\n    case \"attachment\":\n      return (\n        <>\n          {message.attachments!.map((file, index) => (\n            <MessageAttachment key={file.name + index} name={file.name} size={file.size} href={file.href}\n              direction={message.direction} tail={tail && index === message.attachments!.length - 1}\n              style={index ? { marginTop: bubbleMetrics.ios.gapInGroup } : undefined} />\n          ))}\n        </>\n      );\n    case \"emoji\":\n      return (\n        <div data-slot=\"message-bubble\" data-direction={message.direction} data-platform=\"ios\" data-emoji-only=\"true\"\n          className={cn(\"flex min-w-0 flex-col\", outgoing ? \"items-end\" : \"items-start\")} style={{ width: \"100%\", fontFamily: fontStack }}>\n          <div data-slot=\"emoji\" style={{ fontSize: messageListMetrics.ios.emojiSize, lineHeight: `${messageListMetrics.ios.emojiLineHeight}px`, fontFamily: emojiFontStack, padding: `0 ${messageListMetrics.ios.emojiPadX}px`, whiteSpace: \"nowrap\" }}>\n            <span className=\"sr-only\">{outgoing ? \"You: \" : `${message.sender ?? \"Contact\"}: `}</span>{message.text.trim()}\n          </div>\n        </div>\n      );\n    default:\n      return <MessageBubble direction={message.direction} service={message.service ?? \"imessage\"} tail={tail} screenBottom={screenBottom}>{message.text}</MessageBubble>;\n  }\n}\n\n/** One message inside the thread overlay, on its own edge. */\nfunction ThreadRow({ message, tail }: { message: Message; tail: boolean }) {\n  return (\n    <div data-slot=\"thread-row\" data-direction={message.direction} className={cn(\"flex w-full\", message.direction === \"outgoing\" ? \"justify-end\" : \"justify-start\")}>\n      <MessageBubble direction={message.direction} service={message.service ?? \"imessage\"} tail={tail}>{message.text}</MessageBubble>\n    </div>\n  );\n}\n\n/**\n * Turns a press and hold on the composer's send button into `onHold(draft)`, and swallows the click\n * that a pointer release would otherwise fire so the message is not also sent.\n */\nfunction SendHoldLayer({ frame, onHold }: { frame: RefObject<HTMLDivElement | null>; onHold: (draft: string) => void }) {\n  const latest = useRef(onHold);\n  useEffect(() => { latest.current = onHold; }, [onHold]);\n  useEffect(() => {\n    const composer = frame.current?.querySelector<HTMLElement>('[data-slot=\"ios-composer\"]');\n    if (!composer) return;\n    // Delegate from the form, not the button: the send button only exists while the field has text,\n    // so a listener bound to it at mount would never see the composer's first message.\n    const sendAt = (target: EventTarget | null) => (target as HTMLElement | null)?.closest?.('[data-slot=\"send\"]') ?? null;\n    const draft = () => composer.querySelector<HTMLTextAreaElement>(\"textarea\")?.value ?? \"\";\n    let timer: ReturnType<typeof setTimeout> | null = null;\n    let held = false;\n    const cancel = () => { if (timer) clearTimeout(timer); timer = null; };\n    const onPointerDown = (event: PointerEvent) => {\n      if (event.button !== 0 || !sendAt(event.target)) return;\n      held = false;\n      cancel();\n      timer = setTimeout(() => { cancel(); held = true; latest.current(draft()); }, 500);\n    };\n    const onClick = (event: MouseEvent) => {\n      if (!held || !sendAt(event.target)) return;\n      // The hold already opened the effects screen; do not also send the message.\n      event.preventDefault();\n      event.stopPropagation();\n      held = false;\n    };\n    const onContextMenu = (event: MouseEvent) => {\n      if (!sendAt(event.target)) return;\n      event.preventDefault();\n      cancel();\n      latest.current(draft());\n    };\n    composer.addEventListener(\"pointerdown\", onPointerDown);\n    composer.addEventListener(\"pointerup\", cancel);\n    composer.addEventListener(\"pointercancel\", cancel);\n    composer.addEventListener(\"pointerleave\", cancel);\n    composer.addEventListener(\"click\", onClick, true);\n    composer.addEventListener(\"contextmenu\", onContextMenu);\n    return () => {\n      cancel();\n      composer.removeEventListener(\"pointerdown\", onPointerDown);\n      composer.removeEventListener(\"pointerup\", cancel);\n      composer.removeEventListener(\"pointercancel\", cancel);\n      composer.removeEventListener(\"pointerleave\", cancel);\n      composer.removeEventListener(\"click\", onClick, true);\n      composer.removeEventListener(\"contextmenu\", onContextMenu);\n    };\n  }, [frame]);\n  return null;\n}\n\n/**\n * Turns a tap on a tapback balloon into `onOpen(messageId)`, which is what\n * `-[CKUIBehavior canTapAssociatedAcknowledgment]` = 1 makes the balloon: a control.\n *\n * Delegated on the log rather than handed to `renderReactions`, so it works for whatever balloons a\n * caller's own renderer draws, and so it can stop the click. React attaches its handlers at the app\n * root, above this element, so a native listener here runs first: stopping the click is what keeps\n * the same tap from also opening the message's thread or running a click-to-select.\n */\nfunction TapbackTapLayer({ frame, onOpen }: { frame: RefObject<HTMLDivElement | null>; onOpen: (id: string) => void }) {\n  const latest = useRef(onOpen);\n  useEffect(() => { latest.current = onOpen; }, [onOpen]);\n  useEffect(() => {\n    const log = frame.current?.querySelector<HTMLElement>('[data-slot=\"message-list\"]');\n    if (!log) return;\n    const onClick = (event: MouseEvent) => {\n      const target = event.target as HTMLElement | null;\n      if (!target?.closest?.('[data-slot=\"tapback\"]')) return;\n      const id = target.closest(\"[data-message-id]\")?.getAttribute(\"data-message-id\");\n      if (!id) return;\n      event.preventDefault();\n      event.stopPropagation();\n      latest.current(id);\n    };\n    log.addEventListener(\"click\", onClick);\n    return () => log.removeEventListener(\"click\", onClick);\n  }, [frame]);\n  return null;\n}\n\n/**\n * The leading gutter of selection circles, over the log, plus the tap that ticks a row.\n *\n * `MessageSelectionRow` is one of these rows written the other way round, and every number here is\n * its number: `MessageList` builds its own rows and takes no per-row wrapper, so the shell cannot\n * wrap one, and a second log to wrap would be a second log. The geometry all comes from\n * `selectionMetrics` and `selectModeMotion`, so the overlay and the row component stay one\n * measurement rather than two.\n *\n * A circle is centred on the message **body**, not on the row — `MessageSelectionRow` explains why: a\n * reaction balloon adds headroom above the body and a status line a row of type below it, and the\n * capture moves the circle for neither. The reacted \"Ok\" bubble is where that shows: its body runs\n * y 552.67–592.33 and the capture's circle is centred on 572.33, the body's centre, 13.83 below the\n * row's.\n *\n * Positions are read in the log's own **content** coordinates (the body's box plus `scrollTop`), so\n * scrolling only writes this layer's transform and never re-measures.\n */\nfunction SelectionCircleLayer({ frame, messages, selected, onToggle, active, progress }: {\n  frame: RefObject<HTMLDivElement | null>;\n  messages: Message[];\n  selected: ReadonlySet<string>;\n  onToggle: (id: string) => void;\n  active: boolean;\n  progress?: number;\n}) {\n  // The same timeline `IosSelectMode` is running around this layer. Both are pure functions of\n  // `active` and `progress`, so the two calls cannot disagree; the context that would have carried\n  // it is private to `ios-select-mode.tsx`.\n  const transition = useSelectModeTransition({ active, progress });\n  const arrived = transition.t > 0.5;\n  const [spots, setSpots] = useState<{ id: string; y: number }[]>([]);\n  const track = useRef<HTMLDivElement>(null);\n  const latest = useRef(onToggle);\n  useEffect(() => { latest.current = onToggle; }, [onToggle]);\n\n  // A passive effect, not a layout one, for `LongPressLayer`'s reason: React attaches a child's ref\n  // before its ancestors', so `frame.current` is still null while this layer's own layout effect\n  // runs and the log would never be found. The circles start at opacity 0 either way.\n  useEffect(() => {\n    const log = frame.current?.querySelector<HTMLElement>('[data-slot=\"message-list\"]');\n    if (!log) return;\n    let raf = 0;\n    const follow = () => { if (track.current) track.current.style.transform = `translateY(${-log.scrollTop}px)`; };\n    const measure = () => {\n      // The content's own origin: where row 0 would sit however far the log is scrolled.\n      const bounds = log.getBoundingClientRect();\n      const scaleY = bounds.height / log.clientHeight || 1;\n      const next = Array.from(log.querySelectorAll<HTMLElement>('[data-slot=\"message-row\"][data-message-id]')).map(row => {\n        // Every body this message drew, minus anything inside its quoted stub — a file message can\n        // carry several cards, and the circle belongs on the middle of all of them.\n        const boxes = Array.from(row.querySelectorAll<HTMLElement>(messageBodySelector))\n          .filter(element => !insideQuotedStub(element)).map(element => element.getBoundingClientRect());\n        const top = boxes.length ? Math.min(...boxes.map(box => box.top)) : row.getBoundingClientRect().top;\n        const bottom = boxes.length ? Math.max(...boxes.map(box => box.bottom)) : row.getBoundingClientRect().bottom;\n        return { id: row.getAttribute(\"data-message-id\")!, y: ((top + bottom) / 2 - bounds.top) / scaleY + log.scrollTop };\n      });\n      setSpots(current => (current.length === next.length && current.every((spot, index) => spot.id === next[index].id && Math.abs(spot.y - next[index].y) < 0.5) ? current : next));\n      follow();\n    };\n    const schedule = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(measure); };\n    measure();\n    const observer = new ResizeObserver(schedule);\n    observer.observe(log);\n    const content = log.querySelector('[data-slot=\"message-list-content\"]');\n    if (content) observer.observe(content);\n    log.addEventListener(\"scroll\", follow, { passive: true });\n    document.fonts?.ready.then(schedule).catch(() => {});\n    return () => { observer.disconnect(); cancelAnimationFrame(raf); log.removeEventListener(\"scroll\", follow); };\n  }, [frame, messages]);\n\n  // A tap anywhere on a row ticks it, which is what select mode does to every other gesture on the\n  // log. Bound in the capture phase so it lands before the tapback balloon's own delegated click and\n  // before the row's tap-to-open-a-thread, and stopped there so neither of them also runs.\n  useEffect(() => {\n    if (!active) return;\n    const log = frame.current?.querySelector<HTMLElement>('[data-slot=\"message-list\"]');\n    if (!log) return;\n    const onClick = (event: MouseEvent) => {\n      const id = (event.target as HTMLElement | null)?.closest?.(\"[data-message-id]\")?.getAttribute(\"data-message-id\");\n      if (!id) return;\n      event.preventDefault();\n      event.stopPropagation();\n      latest.current(id);\n    };\n    log.addEventListener(\"click\", onClick, true);\n    return () => log.removeEventListener(\"click\", onClick, true);\n  }, [frame, active]);\n\n  const size = selectionMetrics.circleSize;\n  const labels = useMemo(() => new Map(messages.map(message => [message.id, message.text])), [messages]);\n  return (\n    // Clipped to the log's own box so a circle scrolled past the top edge is not painted over the\n    // date header's inset; the nav bar comes later in the document and covers the rest.\n    <div data-slot=\"ios-selection-circles\" className=\"pointer-events-none absolute inset-0 overflow-hidden\">\n      <div ref={track} className=\"absolute left-0 top-0 w-full\" inert={!arrived}>\n        {spots.map(spot => (\n          // `flex` keeps the wrapper exactly as tall as the circle, the same reason\n          // `MessageSelectionRow` gives: an inline box would add leading and pull the centre up.\n          <span key={spot.id} className=\"absolute flex\"\n            style={{\n              left: selectionMetrics.circleCenterX - size / 2,\n              top: spot.y - size / 2,\n              transform: `translateX(calc((var(--ios-sel-t) - 1) * ${selectModeMotion.circleSlide}px))`,\n              opacity: \"var(--ios-sel-t)\",\n              transition: transition.timing ? `transform ${transition.timing}, opacity ${transition.timing}` : undefined,\n              pointerEvents: arrived ? \"auto\" : \"none\",\n            }}>\n            <SelectionCircle selected={selected.has(spot.id)} onChange={() => latest.current(spot.id)} label={labels.get(spot.id)} />\n          </span>\n        ))}\n      </div>\n    </div>\n  );\n}\n\n/** Turns a long press (or right-click / double-click) on any bubble in the frame into `onLongPress(id)`. */\nfunction LongPressLayer({ frame, onLongPress }: { frame: RefObject<HTMLDivElement | null>; onLongPress: (id: string) => void }) {\n  const latest = useRef(onLongPress);\n  useEffect(() => { latest.current = onLongPress; }, [onLongPress]);\n  // Bind from an effect, not from a ref callback: React attaches a child's ref before its ancestors',\n  // so `frame.current` is still null while this layer's ref runs and the listeners would never land.\n  // The effect also removes them again, so a remounted log is not left with a stale binding.\n  useEffect(() => {\n    const log = frame.current?.querySelector<HTMLElement>('[data-slot=\"message-list\"]');\n    if (!log) return;\n    let timer: ReturnType<typeof setTimeout> | null = null;\n    let target: string | null = null;\n    let origin = { x: 0, y: 0 };\n    const cancel = () => { if (timer) clearTimeout(timer); timer = null; target = null; };\n    const idAt = (element: EventTarget | null) => (element as HTMLElement | null)?.closest?.(\"[data-message-id]\")?.getAttribute(\"data-message-id\") ?? null;\n    const onPointerDown = (event: PointerEvent) => {\n      if (event.button !== 0) return;\n      const id = idAt(event.target);\n      if (!id) return;\n      cancel(); target = id; origin = { x: event.clientX, y: event.clientY };\n      timer = setTimeout(() => { const pressedId = target; cancel(); if (pressedId) latest.current(pressedId); }, 500);\n    };\n    const onPointerMove = (event: PointerEvent) => { if (timer && Math.hypot(event.clientX - origin.x, event.clientY - origin.y) > 8) cancel(); };\n    const onContextMenu = (event: MouseEvent) => { const id = idAt(event.target); if (id) { event.preventDefault(); cancel(); latest.current(id); } };\n    const onDoubleClick = (event: MouseEvent) => { const id = idAt(event.target); if (id) { event.preventDefault(); cancel(); latest.current(id); } };\n    // The keyboard equivalent of the hold: the log's arrow keys focus a message, and these keys open\n    // its actions. Without this the overlay would be reachable by pointer only.\n    const onKeyDown = (event: KeyboardEvent) => {\n      const row = event.target as HTMLElement | null;\n      if (!row?.matches?.('[data-slot=\"message-row\"][data-message-id]')) return;\n      if (event.key !== \"Enter\" && event.key !== \" \" && event.key !== \"ContextMenu\" && !(event.key === \"F10\" && event.shiftKey)) return;\n      const id = idAt(row);\n      if (!id) return;\n      event.preventDefault();\n      cancel();\n      latest.current(id);\n    };\n    log.addEventListener(\"pointerdown\", onPointerDown);\n    log.addEventListener(\"pointermove\", onPointerMove);\n    log.addEventListener(\"pointerup\", cancel);\n    log.addEventListener(\"pointercancel\", cancel);\n    log.addEventListener(\"pointerleave\", cancel);\n    log.addEventListener(\"scroll\", cancel, { passive: true });\n    window.addEventListener(\"pointerup\", cancel);\n    window.addEventListener(\"blur\", cancel);\n    log.addEventListener(\"contextmenu\", onContextMenu);\n    log.addEventListener(\"dblclick\", onDoubleClick);\n    log.addEventListener(\"keydown\", onKeyDown);\n    return () => {\n      cancel();\n      log.removeEventListener(\"pointerdown\", onPointerDown);\n      log.removeEventListener(\"pointermove\", onPointerMove);\n      log.removeEventListener(\"pointerup\", cancel);\n      log.removeEventListener(\"pointercancel\", cancel);\n      log.removeEventListener(\"pointerleave\", cancel);\n      log.removeEventListener(\"scroll\", cancel);\n      window.removeEventListener(\"pointerup\", cancel);\n      window.removeEventListener(\"blur\", cancel);\n      log.removeEventListener(\"contextmenu\", onContextMenu);\n      log.removeEventListener(\"dblclick\", onDoubleClick);\n      log.removeEventListener(\"keydown\", onKeyDown);\n    };\n  }, [frame]);\n  return <div data-slot=\"long-press-layer\" className=\"absolute inset-0\" style={{ pointerEvents: \"none\" }} />;\n}\n",
      "type": "registry:component",
      "target": "components/message-ui/ios-messages-app.tsx"
    }
  ],
  "type": "registry:block"
}
