# Chat Conversations

A messaging app layout with a conversation list beside the selected thread and a message composer.

Chat Conversations is an open-source block from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/blocks/chat-conversations

**React**

**Example**

```tsx title="components/blocks/chat-conversations.tsx"
"use client"

import {
  ArrowUpIcon,
  InfoIcon,
  NotePencilIcon,
  PlusIcon,
  VideoCameraIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
} from "@/components/patterns/split-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"

type Line = { id: string; from: "me" | "them"; text: string }

const threads = [
  {
    name: "Riley Chen",
    hue: 150,
    time: "9:41 AM",
    lines: [
      "them:Is the weekend trip to the coast still on?",
      "me:Yes! We leave Friday evening",
      "me:I booked the boat tour, it leaves at 5 AM",
      "them:Great, I'll bring the camera",
    ],
  },
  {
    name: "Morgan Lee",
    hue: 30,
    time: "8:12 AM",
    lines: [
      "them:I sent the deck, can you take a look?",
      "me:On it, back to you in 10 minutes",
    ],
  },
  {
    name: "Family",
    hue: 280,
    time: "Yesterday",
    lines: [
      "them:Is everyone coming for Sunday brunch?",
      "me:We'll be there",
      "them:I'll bring pastries",
    ],
  },
  {
    name: "Taylor Kim",
    hue: 200,
    time: "Monday",
    lines: ["them:What time does the game start?"],
  },
]

const replies: [string, ...string[]] = [
  "Sounds good",
  "Great, see you then!",
  "Ha, absolutely",
  "Let me check and get back to you",
]

const toLines = (lines: string[], thread: number): Line[] =>
  lines.map((line, index) => {
    const [from, ...text] = line.split(":")
    return {
      id: `${thread}-${index}`,
      from: from as Line["from"],
      text: text.join(":"),
    }
  })

function Initials({
  name,
  hue,
  className,
}: {
  name: string
  hue: number
  className?: string
}) {
  return (
    <Avatar className={className}>
      <AvatarFallback
        style={{
          background: `linear-gradient(160deg, oklch(0.78 0.1 ${hue}), oklch(0.6 0.14 ${hue + 30}))`,
        }}
        className="text-white"
      >
        {name
          .split(" ")
          .map((part) => part[0])
          .join("")
          .slice(0, 2)}
      </AvatarFallback>
    </Avatar>
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" aria-label="Typing">
          <BubbleContent className="flex gap-1 px-3.5 py-3">
            {[0, 1, 2].map((dot) => (
              <span
                key={dot}
                style={{ animationDelay: `${dot * 180}ms` }}
                className="size-2 animate-[pulse_1.1s_ease-in-out_infinite] rounded-full bg-muted-foreground"
              />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function Composer({
  placeholder,
  onSend,
}: {
  placeholder: string
  onSend: (text: string) => void
}) {
  const [value, setValue] = React.useState("")
  const ready = value.trim().length > 0

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        if (!ready) return
        onSend(value.trim())
        setValue("")
      }}
      className="flex shrink-0 items-center gap-2 px-4 pt-2.5 pb-3.5"
    >
      <Button
        type="button"
        variant="secondary"
        size="icon-sm"
        aria-label="Add attachment"
        className="text-muted-foreground"
      >
        <PlusIcon className="size-4.5" />
      </Button>
      <div className="flex min-h-8.5 flex-1 items-center rounded-full border border-input ps-3.5 pe-0.75 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50">
        <Input
          value={value}
          onChange={(event) => setValue(event.target.value)}
          placeholder={placeholder}
          aria-label={placeholder}
          className="h-8 flex-1 rounded-none bg-transparent px-0 focus-visible:border-transparent focus-visible:ring-0 aria-invalid:ring-0"
        />
        <Button
          type="submit"
          size="icon-xs"
          aria-label="Send"
          disabled={!ready}
          data-ready={ready ? "" : undefined}
          className="size-6.75 scale-0 transition-[scale] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] data-ready:scale-100 motion-reduce:transition-none"
        >
          <ArrowUpIcon className="size-4" />
        </Button>
      </div>
    </form>
  )
}

export function ChatConversations() {
  const [conversations, setConversations] = React.useState(() =>
    threads.map((thread, index) => toLines(thread.lines, index))
  )
  const [selected, setSelected] = React.useState(0)
  const [typingIn, setTypingIn] = React.useState<Record<number, number>>({})
  const replyCount = React.useRef(0)
  const thread = threads[selected]
  const lines = conversations[selected]
  if (!thread || !lines) return null
  const typing = (typingIn[selected] ?? 0) > 0

  const send = (text: string) => {
    const target = selected
    setConversations((current) =>
      current.map((items, index) =>
        index === target
          ? [...items, { id: `${target}-${items.length}`, from: "me", text }]
          : items
      )
    )
    const typeIn = (step: number) =>
      setTypingIn((current) => ({
        ...current,
        [target]: (current[target] ?? 0) + step,
      }))
    window.setTimeout(() => typeIn(1), 500)
    window.setTimeout(() => {
      const reply = replies[replyCount.current++ % replies.length] ?? replies[0]
      typeIn(-1)
      setConversations((current) =>
        current.map((items, index) =>
          index === target
            ? [
                ...items,
                {
                  id: `${target}-${items.length}`,
                  from: "them",
                  text: reply,
                },
              ]
            : items
        )
      )
    }, 1800)
  }

  return (
    <SplitView
      defaultColumn="list"
      className="h-150 rounded-none border-0 max-md:h-auto max-md:min-h-150 max-md:p-4"
    >
      <SplitViewList title="Messages" className="gap-1 md:px-2 md:pt-3">
        <div className="flex items-center px-1.5 pb-1 max-md:-mt-12 max-md:justify-end">
          <span className="flex-1 text-lg font-semibold max-md:hidden">
            Messages
          </span>
          <Button
            variant="ghost"
            size="icon"
            aria-label="New message"
            className="text-muted-foreground"
          >
            <NotePencilIcon className="size-4.5" />
          </Button>
        </div>
        {threads.map((item, index) => {
          const last = conversations[index]?.at(-1)
          return (
            <SplitViewItem
              key={item.name}
              isActive={index === selected}
              onClick={() => setSelected(index)}
              className="group/thread flex-row p-2.5 md:data-active:bg-primary md:data-active:text-primary-foreground"
            >
              <span className="flex items-center gap-2.5">
                <Initials
                  name={item.name}
                  hue={item.hue}
                  className="size-10.5"
                />
                <span className="flex min-w-0 flex-1 flex-col gap-0.5">
                  <span className="flex items-center gap-1.5">
                    <span className="flex-1 truncate text-sm font-semibold">
                      {item.name}
                    </span>
                    <span className="text-xs text-muted-foreground md:group-data-active/thread:text-primary-foreground">
                      {item.time}
                    </span>
                  </span>
                  <span className="truncate text-sm text-muted-foreground md:group-data-active/thread:text-primary-foreground">
                    {last?.text}
                  </span>
                </span>
              </span>
            </SplitViewItem>
          )
        })}
      </SplitViewList>
      <SplitViewDetail
        title={thread.name}
        className="h-full p-0 md:p-0 [&>h2]:sr-only"
      >
        <div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-4">
          <Initials name={thread.name} hue={thread.hue} className="size-7.5" />
          <div className="flex flex-1 flex-col">
            <span className="text-sm font-semibold">{thread.name}</span>
            <span className="text-xs text-muted-foreground" aria-live="polite">
              {typing ? "typing…" : "Message"}
            </span>
          </div>
          <Button
            variant="ghost"
            size="icon"
            aria-label="Video call"
            className="text-primary"
          >
            <VideoCameraIcon className="size-5" />
          </Button>
          <Button
            variant="ghost"
            size="icon"
            aria-label="Details"
            className="text-primary"
          >
            <InfoIcon className="size-5" />
          </Button>
        </div>
        <MessageScrollerProvider key={selected}>
          <MessageScroller className="min-h-80 flex-1">
            <MessageScrollerViewport
              aria-label={`Conversation with ${thread.name}`}
            >
              <MessageScrollerContent className="gap-1 px-5 py-4">
                <p className="mt-1 mb-2.5 text-center text-xs text-muted-foreground">
                  Today 9:30 AM
                </p>
                {lines.map((line) => (
                  <MessageScrollerItem key={line.id} messageId={line.id}>
                    <Message
                      align={line.from === "me" ? "end" : "start"}
                      className="transition-[opacity,translate] duration-700 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
                    >
                      <MessageContent>
                        <Bubble
                          variant={line.from === "me" ? "default" : "muted"}
                          align={line.from === "me" ? "end" : "start"}
                          className="max-w-90"
                        >
                          <BubbleContent>{line.text}</BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                ))}
                {typing && (
                  <MessageScrollerItem messageId="typing">
                    <TypingDots />
                  </MessageScrollerItem>
                )}
              </MessageScrollerContent>
            </MessageScrollerViewport>
          </MessageScroller>
        </MessageScrollerProvider>
        <Composer placeholder="Message" onSend={send} />
      </SplitViewDetail>
    </SplitView>
  )
}
```

A messaging app with conversations on the left, the open thread on the right and a composer whose send button appears once you type. On mobile only one pane shows at a time, starting with the conversation list, and a back button returns to it. Built on `SplitView` and `MessageScroller`, it is a starting point for direct messages, team chat or customer conversations.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/chat-conversations
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks chat-conversations
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Input](https://ui.suiss.app/docs/components/input), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Split View](https://ui.suiss.app/docs/patterns/split-view).

3. Copy the source into `components/blocks/chat-conversations.tsx`:

```tsx title="components/blocks/chat-conversations.tsx"
"use client"

import {
  ArrowUpIcon,
  InfoIcon,
  NotePencilIcon,
  PlusIcon,
  VideoCameraIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import {
  SplitView,
  SplitViewDetail,
  SplitViewItem,
  SplitViewList,
} from "@/components/patterns/split-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerContent,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
} from "@/components/ui/message-scroller"

type Line = { id: string; from: "me" | "them"; text: string }

const threads = [
  {
    name: "Riley Chen",
    hue: 150,
    time: "9:41 AM",
    lines: [
      "them:Is the weekend trip to the coast still on?",
      "me:Yes! We leave Friday evening",
      "me:I booked the boat tour, it leaves at 5 AM",
      "them:Great, I'll bring the camera",
    ],
  },
  {
    name: "Morgan Lee",
    hue: 30,
    time: "8:12 AM",
    lines: [
      "them:I sent the deck, can you take a look?",
      "me:On it, back to you in 10 minutes",
    ],
  },
  {
    name: "Family",
    hue: 280,
    time: "Yesterday",
    lines: [
      "them:Is everyone coming for Sunday brunch?",
      "me:We'll be there",
      "them:I'll bring pastries",
    ],
  },
  {
    name: "Taylor Kim",
    hue: 200,
    time: "Monday",
    lines: ["them:What time does the game start?"],
  },
]

const replies: [string, ...string[]] = [
  "Sounds good",
  "Great, see you then!",
  "Ha, absolutely",
  "Let me check and get back to you",
]

const toLines = (lines: string[], thread: number): Line[] =>
  lines.map((line, index) => {
    const [from, ...text] = line.split(":")
    return {
      id: `${thread}-${index}`,
      from: from as Line["from"],
      text: text.join(":"),
    }
  })

function Initials({
  name,
  hue,
  className,
}: {
  name: string
  hue: number
  className?: string
}) {
  return (
    <Avatar className={className}>
      <AvatarFallback
        style={{
          background: `linear-gradient(160deg, oklch(0.78 0.1 ${hue}), oklch(0.6 0.14 ${hue + 30}))`,
        }}
        className="text-white"
      >
        {name
          .split(" ")
          .map((part) => part[0])
          .join("")
          .slice(0, 2)}
      </AvatarFallback>
    </Avatar>
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" aria-label="Typing">
          <BubbleContent className="flex gap-1 px-3.5 py-3">
            {[0, 1, 2].map((dot) => (
              <span
                key={dot}
                style={{ animationDelay: `${dot * 180}ms` }}
                className="size-2 animate-[pulse_1.1s_ease-in-out_infinite] rounded-full bg-muted-foreground"
              />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function Composer({
  placeholder,
  onSend,
}: {
  placeholder: string
  onSend: (text: string) => void
}) {
  const [value, setValue] = React.useState("")
  const ready = value.trim().length > 0

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        if (!ready) return
        onSend(value.trim())
        setValue("")
      }}
      className="flex shrink-0 items-center gap-2 px-4 pt-2.5 pb-3.5"
    >
      <Button
        type="button"
        variant="secondary"
        size="icon-sm"
        aria-label="Add attachment"
        className="text-muted-foreground"
      >
        <PlusIcon className="size-4.5" />
      </Button>
      <div className="flex min-h-8.5 flex-1 items-center rounded-full border border-input ps-3.5 pe-0.75 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50">
        <Input
          value={value}
          onChange={(event) => setValue(event.target.value)}
          placeholder={placeholder}
          aria-label={placeholder}
          className="h-8 flex-1 rounded-none bg-transparent px-0 focus-visible:border-transparent focus-visible:ring-0 aria-invalid:ring-0"
        />
        <Button
          type="submit"
          size="icon-xs"
          aria-label="Send"
          disabled={!ready}
          data-ready={ready ? "" : undefined}
          className="size-6.75 scale-0 transition-[scale] duration-300 ease-[cubic-bezier(0.3,1.25,0.5,1)] data-ready:scale-100 motion-reduce:transition-none"
        >
          <ArrowUpIcon className="size-4" />
        </Button>
      </div>
    </form>
  )
}

export function ChatConversations() {
  const [conversations, setConversations] = React.useState(() =>
    threads.map((thread, index) => toLines(thread.lines, index))
  )
  const [selected, setSelected] = React.useState(0)
  const [typingIn, setTypingIn] = React.useState<Record<number, number>>({})
  const replyCount = React.useRef(0)
  const thread = threads[selected]
  const lines = conversations[selected]
  if (!thread || !lines) return null
  const typing = (typingIn[selected] ?? 0) > 0

  const send = (text: string) => {
    const target = selected
    setConversations((current) =>
      current.map((items, index) =>
        index === target
          ? [...items, { id: `${target}-${items.length}`, from: "me", text }]
          : items
      )
    )
    const typeIn = (step: number) =>
      setTypingIn((current) => ({
        ...current,
        [target]: (current[target] ?? 0) + step,
      }))
    window.setTimeout(() => typeIn(1), 500)
    window.setTimeout(() => {
      const reply = replies[replyCount.current++ % replies.length] ?? replies[0]
      typeIn(-1)
      setConversations((current) =>
        current.map((items, index) =>
          index === target
            ? [
                ...items,
                {
                  id: `${target}-${items.length}`,
                  from: "them",
                  text: reply,
                },
              ]
            : items
        )
      )
    }, 1800)
  }

  return (
    <SplitView
      defaultColumn="list"
      className="h-150 rounded-none border-0 max-md:h-auto max-md:min-h-150 max-md:p-4"
    >
      <SplitViewList title="Messages" className="gap-1 md:px-2 md:pt-3">
        <div className="flex items-center px-1.5 pb-1 max-md:-mt-12 max-md:justify-end">
          <span className="flex-1 text-lg font-semibold max-md:hidden">
            Messages
          </span>
          <Button
            variant="ghost"
            size="icon"
            aria-label="New message"
            className="text-muted-foreground"
          >
            <NotePencilIcon className="size-4.5" />
          </Button>
        </div>
        {threads.map((item, index) => {
          const last = conversations[index]?.at(-1)
          return (
            <SplitViewItem
              key={item.name}
              isActive={index === selected}
              onClick={() => setSelected(index)}
              className="group/thread flex-row p-2.5 md:data-active:bg-primary md:data-active:text-primary-foreground"
            >
              <span className="flex items-center gap-2.5">
                <Initials
                  name={item.name}
                  hue={item.hue}
                  className="size-10.5"
                />
                <span className="flex min-w-0 flex-1 flex-col gap-0.5">
                  <span className="flex items-center gap-1.5">
                    <span className="flex-1 truncate text-sm font-semibold">
                      {item.name}
                    </span>
                    <span className="text-xs text-muted-foreground md:group-data-active/thread:text-primary-foreground">
                      {item.time}
                    </span>
                  </span>
                  <span className="truncate text-sm text-muted-foreground md:group-data-active/thread:text-primary-foreground">
                    {last?.text}
                  </span>
                </span>
              </span>
            </SplitViewItem>
          )
        })}
      </SplitViewList>
      <SplitViewDetail
        title={thread.name}
        className="h-full p-0 md:p-0 [&>h2]:sr-only"
      >
        <div className="flex h-14 shrink-0 items-center gap-2.5 border-b border-border px-4">
          <Initials name={thread.name} hue={thread.hue} className="size-7.5" />
          <div className="flex flex-1 flex-col">
            <span className="text-sm font-semibold">{thread.name}</span>
            <span className="text-xs text-muted-foreground" aria-live="polite">
              {typing ? "typing…" : "Message"}
            </span>
          </div>
          <Button
            variant="ghost"
            size="icon"
            aria-label="Video call"
            className="text-primary"
          >
            <VideoCameraIcon className="size-5" />
          </Button>
          <Button
            variant="ghost"
            size="icon"
            aria-label="Details"
            className="text-primary"
          >
            <InfoIcon className="size-5" />
          </Button>
        </div>
        <MessageScrollerProvider key={selected}>
          <MessageScroller className="min-h-80 flex-1">
            <MessageScrollerViewport
              aria-label={`Conversation with ${thread.name}`}
            >
              <MessageScrollerContent className="gap-1 px-5 py-4">
                <p className="mt-1 mb-2.5 text-center text-xs text-muted-foreground">
                  Today 9:30 AM
                </p>
                {lines.map((line) => (
                  <MessageScrollerItem key={line.id} messageId={line.id}>
                    <Message
                      align={line.from === "me" ? "end" : "start"}
                      className="transition-[opacity,translate] duration-700 ease-[cubic-bezier(0.32,0.72,0,1)] starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
                    >
                      <MessageContent>
                        <Bubble
                          variant={line.from === "me" ? "default" : "muted"}
                          align={line.from === "me" ? "end" : "start"}
                          className="max-w-90"
                        >
                          <BubbleContent>{line.text}</BubbleContent>
                        </Bubble>
                      </MessageContent>
                    </Message>
                  </MessageScrollerItem>
                ))}
                {typing && (
                  <MessageScrollerItem messageId="typing">
                    <TypingDots />
                  </MessageScrollerItem>
                )}
              </MessageScrollerContent>
            </MessageScrollerViewport>
          </MessageScroller>
        </MessageScrollerProvider>
        <Composer placeholder="Message" onSend={send} />
      </SplitViewDetail>
    </SplitView>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { ChatConversations } from "@/components/blocks/chat-conversations"
```

```tsx
<ChatConversations />
```

Replace the `threads` array; each thread has a name, avatar hue, time and lines written as strings prefixed with `me:` or `them:`. The `send` function fakes a typing indicator and a canned reply from `replies`, so replace it with your messaging backend. The New message, attachment, video call and details buttons have no handlers.

## Notes

Typing status is announced, and icon-only buttons are labeled. The composer input uses its placeholder as its accessible name, so keep the placeholder descriptive.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-chat-conversations--default.light.webp)

```tsx title="uniwind/components/blocks/chat-conversations.tsx"
import {
  ArrowUpIcon,
  InfoIcon,
  NotePencilIcon,
  PlusIcon,
  TrashIcon,
  VideoCameraIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import {
  SwipeAction,
  SwipeActions,
  SwipeActionsActions,
  SwipeActionsContent,
} from "@/components/interactions/swipe-actions"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
  Header,
  HeaderActions,
  HeaderBack,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/ui/message-scroller"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

type Line = { id: string; from: "me" | "them"; text: string }

type Thread = { name: string; tint: string; time: string; lines: Line[] }

type Row =
  | { id: string; kind: "stamp" }
  | { id: string; kind: "typing" }
  | ({ kind: "line" } & Line)

const THREADS: { name: string; tint: string; time: string; lines: string[] }[] =
  [
    {
      name: "Riley Chen",
      tint: "bg-green-500",
      time: "9:41 AM",
      lines: [
        "them:Is the weekend trip to the coast still on?",
        "me:Yes! We leave Friday evening",
        "me:I booked the boat tour, it leaves at 5 AM",
        "them:Great, I'll bring the camera",
      ],
    },
    {
      name: "Morgan Lee",
      tint: "bg-orange-500",
      time: "8:12 AM",
      lines: [
        "them:I sent the deck, can you take a look?",
        "me:On it, back to you in 10 minutes",
      ],
    },
    {
      name: "Family",
      tint: "bg-purple-500",
      time: "Yesterday",
      lines: [
        "them:Is everyone coming for Sunday brunch?",
        "me:We'll be there",
        "them:I'll bring pastries",
      ],
    },
    {
      name: "Taylor Kim",
      tint: "bg-blue-500",
      time: "Monday",
      lines: ["them:What time does the game start?"],
    },
  ]

const REPLIES = [
  "Sounds good",
  "Great, see you then!",
  "Ha, absolutely",
  "Let me check and get back to you",
]

const SEND_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

const initialThreads = (): Thread[] =>
  THREADS.map((thread, index) => ({
    name: thread.name,
    tint: thread.tint,
    time: thread.time,
    lines: thread.lines.map((line, position) => {
      const [from, ...text] = line.split(":")
      return {
        id: `${index}-${position}`,
        from: from as Line["from"],
        text: text.join(":"),
      }
    }),
  }))

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")
    .slice(0, 2)

function TypingDot({ index }: { index: number }) {
  const opacity = useSharedValue(0.35)

  React.useEffect(() => {
    opacity.value = withDelay(
      index * 180,
      withRepeat(
        withSequence(
          withTiming(1, { duration: 550 }),
          withTiming(0.35, { duration: 550 })
        ),
        -1,
        false,
        undefined,
        ReduceMotion.System
      )
    )
    return () => cancelAnimation(opacity)
  }, [index, opacity])

  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))

  return (
    <AnimatedView
      className="size-2 rounded-full bg-muted-foreground"
      style={animatedStyle}
    />
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" accessible accessibilityLabel="Typing">
          <BubbleContent className="flex-row gap-1 px-3.5 py-3">
            {[0, 1, 2].map((dot) => (
              <TypingDot key={dot} index={dot} />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function Composer({
  placeholder,
  onSend,
  onAttach,
}: {
  placeholder: string
  onSend: (text: string) => void
  onAttach?: () => void
}) {
  const [value, setValue] = React.useState("")
  const { scrollToEnd } = useMessageScroller()
  const ready = value.trim().length > 0
  const scale = useSharedValue(ready ? 1 : 0)

  React.useEffect(() => {
    scale.value = withTiming(ready ? 1 : 0, {
      duration: 300,
      easing: SEND_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [ready, scale])

  const sendStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  const send = () => {
    if (!ready) return
    onSend(value.trim())
    setValue("")
    requestAnimationFrame(() => scrollToEnd())
  }

  return (
    <BottomBar separator>
      <BottomBarContent className="flex-row items-center gap-2 px-4 pt-2.5 pb-3.5">
        <Button
          variant="secondary"
          size="icon-sm"
          accessibilityLabel="Add attachment"
          onPress={onAttach}
        >
          <Icon
            as={PlusIcon}
            weight="bold"
            className="size-4.5 text-muted-foreground"
          />
        </Button>
        <InputGroup className="h-9 flex-1 rounded-full">
          <InputGroupInput
            value={value}
            onChangeText={setValue}
            placeholder={placeholder}
            accessibilityLabel={placeholder}
            returnKeyType="send"
            submitBehavior="submit"
            onSubmitEditing={send}
            className="ps-3.5"
          />
          <InputGroupAddon align="inline-end" className="pe-1">
            <AnimatedView style={sendStyle}>
              <InputGroupButton
                variant="default"
                size="icon-xs"
                accessibilityLabel="Send"
                disabled={!ready}
                onPress={send}
                className="size-6.75"
              >
                <Icon as={ArrowUpIcon} weight="bold" className="size-4" />
              </InputGroupButton>
            </AnimatedView>
          </InputGroupAddon>
        </InputGroup>
      </BottomBarContent>
    </BottomBar>
  )
}

function LineRow({ line }: { line: Line }) {
  const mine = line.from === "me"
  return (
    <AnimatedView
      entering={FadeInDown.duration(700)
        .easing(Easing.bezier(0.32, 0.72, 0, 1))
        .reduceMotion(ReduceMotion.System)}
    >
      <Message align={mine ? "end" : "start"}>
        <MessageContent>
          <Bubble
            variant={mine ? "default" : "muted"}
            align={mine ? "end" : "start"}
          >
            <BubbleContent>{line.text}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </AnimatedView>
  )
}

function ThreadAvatar({
  thread,
  className,
}: {
  thread: Thread
  className?: string
}) {
  return (
    <Avatar size="lg" className={className}>
      <AvatarFallback className={thread.tint}>
        {initials(thread.name)}
      </AvatarFallback>
    </Avatar>
  )
}

function ChatConversations({
  onSend,
  onNewMessage,
  onAttach,
  onVideoCall,
  onDetails,
  onDelete,
}: {
  onSend?: (text: string, conversation: string) => void
  onNewMessage?: () => void
  onAttach?: () => void
  onVideoCall?: (conversation: string) => void
  onDetails?: (conversation: string) => void
  onDelete?: (conversation: string) => void
}) {
  const [threads, setThreads] = React.useState(initialThreads)
  const [selected, setSelected] = React.useState<string | null>(null)
  const [typing, setTyping] = React.useState<string | null>(null)
  const replyCount = React.useRef(0)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  const scroll = useHeaderScroll()
  const thread = threads.find((item) => item.name === selected)

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

  const append = (name: string, from: Line["from"], text: string) =>
    setThreads((current) =>
      current.map((item) =>
        item.name === name
          ? {
              ...item,
              lines: [
                ...item.lines,
                { id: `${name}-${item.lines.length}`, from, text },
              ],
            }
          : item
      )
    )

  const send = (text: string) => {
    if (!thread) return
    const target = thread.name
    onSend?.(text, target)
    append(target, "me", text)
    timers.current.push(setTimeout(() => setTyping(target), 500))
    timers.current.push(
      setTimeout(() => {
        const reply =
          REPLIES[replyCount.current++ % REPLIES.length] ?? REPLIES[0]
        setTyping(null)
        append(target, "them", reply)
      }, 1800)
    )
  }

  if (thread) {
    const rows: Row[] = [
      { id: "stamp", kind: "stamp" },
      ...thread.lines.map((line) => ({ kind: "line" as const, ...line })),
      ...(typing === thread.name
        ? [{ id: "typing", kind: "typing" as const }]
        : []),
    ]
    return (
      <Screen edges={["top", "right", "left"]}>
        <MessageScrollerProvider key={thread.name}>
          <Header className="h-14 border-b border-border">
            <HeaderBack onPress={() => setSelected(null)}>Messages</HeaderBack>
            <HeaderTitle className="flex-row items-center gap-2.5">
              <ThreadAvatar thread={thread} className="size-7.5" />
              <View className="shrink">
                <Text numberOfLines={1} className="text-sm font-semibold">
                  {thread.name}
                </Text>
                <Text
                  accessibilityLiveRegion="polite"
                  className="text-xs text-muted-foreground"
                >
                  {typing === thread.name ? "typing…" : "Message"}
                </Text>
              </View>
            </HeaderTitle>
            <HeaderActions>
              <Button
                variant="ghost"
                size="icon"
                accessibilityLabel="Video call"
                onPress={() => onVideoCall?.(thread.name)}
              >
                <Icon as={VideoCameraIcon} className="size-5 text-primary" />
              </Button>
              <Button
                variant="ghost"
                size="icon"
                accessibilityLabel="Details"
                onPress={() => onDetails?.(thread.name)}
              >
                <Icon as={InfoIcon} className="size-5 text-primary" />
              </Button>
            </HeaderActions>
          </Header>
          <MessageScroller>
            <MessageScrollerViewport
              data={rows}
              keyExtractor={(row) => row.id}
              accessibilityLabel={`Conversation with ${thread.name}`}
              contentContainerClassName="gap-1 px-2.5 py-4"
              renderItem={({ item }) => {
                if (item.kind === "stamp") {
                  return (
                    <MessageScrollerItem>
                      <Text className="mt-1 mb-2.5 text-center text-xs text-muted-foreground">
                        Today 9:30 AM
                      </Text>
                    </MessageScrollerItem>
                  )
                }
                return (
                  <MessageScrollerItem>
                    {item.kind === "typing" ? (
                      <TypingDots />
                    ) : (
                      <LineRow line={item} />
                    )}
                  </MessageScrollerItem>
                )
              }}
            />
          </MessageScroller>
          <Composer placeholder="Message" onSend={send} onAttach={onAttach} />
        </MessageScrollerProvider>
      </Screen>
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <Header scroll={scroll}>
        <HeaderTitle>Messages</HeaderTitle>
        <HeaderActions>
          <Button
            variant="ghost"
            size="icon"
            accessibilityLabel="New message"
            onPress={onNewMessage}
          >
            <Icon as={NotePencilIcon} className="size-5 text-primary" />
          </Button>
        </HeaderActions>
      </Header>
      <HeaderScrollView scroll={scroll}>
        <HeaderLargeTitle>Messages</HeaderLargeTitle>
        {threads.length === 0 ? (
          <Text className="px-4 pt-10 text-center text-base text-muted-foreground">
            No Messages
          </Text>
        ) : null}
        {threads.map((item, index) => {
          const last = item.lines.at(-1)
          return (
            <React.Fragment key={item.name}>
              {index > 0 ? <View className="ms-18 h-px bg-border" /> : null}
              <SwipeActions>
                <SwipeActionsContent>
                  <Pressable
                    role="button"
                    onPress={() => setSelected(item.name)}
                    className="flex-row items-center gap-3 px-4 py-2.5 active:opacity-80"
                  >
                    <ThreadAvatar thread={item} className="size-11" />
                    <View className="flex-1 gap-0.5">
                      <View className="flex-row items-center gap-1.5">
                        <Text
                          numberOfLines={1}
                          className="flex-1 text-base font-semibold"
                        >
                          {item.name}
                        </Text>
                        <Text className="text-sm text-muted-foreground">
                          {item.time}
                        </Text>
                      </View>
                      <Text
                        numberOfLines={1}
                        className="text-sm text-muted-foreground"
                      >
                        {last?.text}
                      </Text>
                    </View>
                  </Pressable>
                </SwipeActionsContent>
                <SwipeActionsActions side="trailing">
                  <SwipeAction
                    variant="destructive"
                    fullSwipe
                    onPress={() => {
                      onDelete?.(item.name)
                      setThreads((current) =>
                        current.filter((entry) => entry.name !== item.name)
                      )
                    }}
                  >
                    <Icon as={TrashIcon} />
                    <Text>Delete</Text>
                  </SwipeAction>
                </SwipeActionsActions>
              </SwipeActions>
            </React.Fragment>
          )
        })}
        <View className="h-8" />
      </HeaderScrollView>
    </Screen>
  )
}

export { ChatConversations }
```

```swift title="swift/Blocks/ChatConversations.swift"
import PhosphorSwift
import SwiftUI

public struct ChatConversations: View {
  struct Line: Identifiable, Equatable {
    let id: String
    let fromMe: Bool
    let text: String
  }

  struct Thread {
    let name: String
    let tint: Color
    let time: String
    let lines: [String]
  }

  enum Entry: Identifiable {
    case day
    case line(Line)
    case typing

    var id: String {
      switch self {
      case .day: "day"
      case .line(let line): line.id
      case .typing: "typing"
      }
    }
  }

  static var threads: [Thread] {
    [
      Thread(name: "Riley Chen", tint: Palette.green500, time: "9:41 AM", lines: [
        "them:Is the weekend trip to the coast still on?",
        "me:Yes! We leave Friday evening",
        "me:I booked the boat tour, it leaves at 5 AM",
        "them:Great, I'll bring the camera",
      ]),
      Thread(name: "Morgan Lee", tint: Palette.orange500, time: "8:12 AM", lines: [
        "them:I sent the deck, can you take a look?",
        "me:On it, back to you in 10 minutes",
      ]),
      Thread(name: "Family", tint: Palette.purple500, time: "Yesterday", lines: [
        "them:Is everyone coming for Sunday brunch?",
        "me:We'll be there",
        "them:I'll bring pastries",
      ]),
      Thread(name: "Taylor Kim", tint: Palette.teal500, time: "Monday", lines: [
        "them:What time does the game start?",
      ]),
    ]
  }

  static let replies = [
    "Sounds good",
    "Great, see you then!",
    "Ha, absolutely",
    "Let me check and get back to you",
  ]

  static func lines(_ thread: Thread, index: Int) -> [Line] {
    thread.lines.enumerated().map { offset, raw in
      let parts = raw.split(separator: ":", maxSplits: 1).map(String.init)
      return Line(id: "\(index)-\(offset)", fromMe: parts.first == "me", text: parts.last ?? "")
    }
  }

  private var onSend: ((String, String) -> Void)?
  private var onNewMessage: (() -> Void)?
  private var onDelete: ((String) -> Void)?
  private var onAttach: (() -> Void)?
  private var onVideoCall: ((String) -> Void)?
  private var onDetails: ((String) -> Void)?
  private var swiped: Int?
  @State private var conversations: [[Line]]
  @State private var visible: [Int]
  @State private var selected: Int?
  @State private var typing: Bool
  @State private var draft: String
  @State private var replyCount = 0
  @State private var replyTasks: [Task<Void, Never>] = []
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onSend: ((_ text: String, _ conversation: String) -> Void)? = nil,
    onNewMessage: (() -> Void)? = nil,
    onDelete: ((_ conversation: String) -> Void)? = nil,
    onAttach: (() -> Void)? = nil,
    onVideoCall: ((_ conversation: String) -> Void)? = nil,
    onDetails: ((_ conversation: String) -> Void)? = nil
  ) {
    self.init(selected: nil)
    self.onSend = onSend
    self.onNewMessage = onNewMessage
    self.onDelete = onDelete
    self.onAttach = onAttach
    self.onVideoCall = onVideoCall
    self.onDetails = onDetails
  }

  init(selected: Int?, typing: Bool = false, draft: String = "", swiped: Int? = nil) {
    _conversations = State(initialValue: Self.threads.enumerated().map { Self.lines($1, index: $0) })
    _visible = State(initialValue: Array(Self.threads.indices))
    _selected = State(initialValue: selected)
    _typing = State(initialValue: typing)
    _draft = State(initialValue: draft)
    self.swiped = swiped
  }

  public var body: some View {
    ZStack {
      if let selected {
        detail(selected)
          .transition(.move(edge: .trailing))
          .zIndex(1)
      } else {
        list
          .transition(.move(edge: .leading))
      }
    }
    .animation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.35), value: selected)
    .onDisappear {
      replyTasks.forEach { $0.cancel() }
      replyTasks.removeAll()
    }
  }

  private var list: some View {
    Screen {
      HeaderScrollView {
        HeaderLargeTitle("Messages")
        LazyVStack(spacing: 0) {
          ForEach(visible, id: \.self) { index in
            let thread = Self.threads[index]
            SwipeActions(revealed: swiped == index ? -88 : 0) {
              ChatConversationsRow(
                thread: thread,
                last: conversations[index].last?.text ?? "",
                separator: index != visible.last
              ) {
                typing = false
                draft = ""
                selected = index
              }
            } leading: {
              EmptyView()
            } trailing: {
              SwipeAction("Delete", icon: Ph.trash.fill, variant: .destructive, fullSwipe: true) {
                onDelete?(thread.name)
                withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.3)) {
                  visible.removeAll { $0 == index }
                }
              }
            }
          }
        }
        .padding(.bottom, 24)
      } header: {
        Header {
          HeaderTitle("Messages")
        } trailing: {
          HeaderActions {
            Button {
              onNewMessage?()
            } label: {
              Ph.notePencil.regular
                .frame(width: 22, height: 22)
                .foregroundStyle(EsuissColor.primary)
            }
            .accessibilityLabel(Text("New message"))
          }
        }
      }
    }
  }

  private func detail(_ index: Int) -> some View {
    let thread = Self.threads[index]
    var entries: [Entry] = [.day] + conversations[index].map(Entry.line)
    if typing { entries.append(.typing) }
    let ready = !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    return Screen {
      Header {
        HStack(spacing: 8) {
          ChatConversationsAvatar(name: thread.name, tint: thread.tint, size: 30)
          VStack(alignment: .leading, spacing: 0) {
            Text(verbatim: thread.name)
              .scaledFont(.sm, weight: .semibold)
              .foregroundStyle(EsuissColor.foreground)
            Text(typing ? "typing…" : "Message")
              .scaledFont(.xs)
              .foregroundStyle(EsuissColor.mutedForeground)
              .contentTransition(.opacity)
          }
          .lineLimit(1)
        }
        .accessibilityElement(children: .combine)
      } leading: {
        HeaderBack("Messages") {
          selected = nil
        }
      } trailing: {
        HeaderActions {
          Button {
            onVideoCall?(thread.name)
          } label: {
            Ph.videoCamera.regular.frame(width: 22, height: 22)
          }
          .accessibilityLabel(Text("Video call"))
          Button {
            onDetails?(thread.name)
          } label: {
            Ph.info.regular.frame(width: 22, height: 22)
          }
          .accessibilityLabel(Text("Details"))
        }
        .foregroundStyle(EsuissColor.primary)
      }
      .overlay(alignment: .bottom) {
        EsuissColor.border.frame(height: 1)
      }
      MessageScroller(entries, spacing: 4) { entry in
        switch entry {
        case .day:
          Text("Today 9:30 AM")
            .scaledFont(.xs)
            .foregroundStyle(EsuissColor.mutedForeground)
            .frame(maxWidth: .infinity)
            .padding(.top, 4)
            .padding(.bottom, 10)
        case .line(let line):
          Message(align: line.fromMe ? .end : .start) {
            MessageContent {
              Bubble(variant: line.fromMe ? .default : .muted, align: line.fromMe ? .end : .start) {
                BubbleContent(verbatim: line.text)
              }
            }
          }
          .transition(.opacity.combined(with: .offset(y: 8)))
        case .typing:
          ChatConversationsTyping()
        }
      }
      .accessibilityLabel(Text("Conversation with \(thread.name)"))
      .frame(maxHeight: .infinity)
      BottomBar {
        BottomBarContent {
          HStack(spacing: 8) {
            Button {
              onAttach?()
            } label: {
              Ph.plus.bold.frame(width: 18, height: 18)
            }
            .buttonStyle(.variant(.secondary, size: .iconSm))
            .accessibilityLabel(Text("Add attachment"))
            InputGroup {
              InputGroupInput("Message", text: $draft)
                .submitLabel(.send)
                .onSubmit { send(index) }
              InputGroupAddon(align: .inlineEnd) {
                InputGroupButton(variant: .default, size: .iconXs) {
                  send(index)
                } label: {
                  Ph.arrowUp.bold.frame(width: 14, height: 14)
                }
                .scaleEffect(ready ? 1 : 0)
                .disabled(!ready)
                .accessibilityLabel(Text("Send"))
                .animation(reduceMotion ? nil : .spring(response: 0.3, dampingFraction: 0.6), value: ready)
              }
            }
          }
        }
      }
    }
  }

  private func send(_ index: Int) {
    let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !text.isEmpty else { return }
    draft = ""
    onSend?(text, Self.threads[index].name)
    withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.7)) {
      conversations[index].append(Line(id: "\(index)-\(conversations[index].count)", fromMe: true, text: text))
    }
    let reply = Self.replies[replyCount % Self.replies.count]
    replyCount += 1
    replyTasks.append(Task {
      guard (try? await Task.sleep(for: .milliseconds(500))) != nil else { return }
      typing = true
      guard (try? await Task.sleep(for: .milliseconds(1300))) != nil else { return }
      typing = false
      withAnimation(reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.7)) {
        conversations[index].append(Line(id: "\(index)-\(conversations[index].count)", fromMe: false, text: reply))
      }
    })
  }
}

private struct ChatConversationsAvatar: View {
  let name: String
  let tint: Color
  let size: CGFloat

  var body: some View {
    Text(verbatim: name.split(separator: " ").compactMap(\.first).prefix(2).map(String.init).joined())
      .font(.system(size: size * 0.38, weight: .semibold))
      .foregroundStyle(EsuissColor.primaryForeground)
      .frame(width: size, height: size)
      .background(tint, in: Circle())
      .accessibilityHidden(true)
  }
}

private struct ChatConversationsRow: View {
  let thread: ChatConversations.Thread
  let last: String
  let separator: Bool
  let action: () -> Void

  var body: some View {
    Button(action: action) {
      HStack(alignment: .top, spacing: 12) {
        ChatConversationsAvatar(name: thread.name, tint: thread.tint, size: 44)
        VStack(alignment: .leading, spacing: 2) {
          HStack(spacing: 6) {
            Text(verbatim: thread.name)
              .scaledFont(.base, weight: .semibold)
              .foregroundStyle(EsuissColor.foreground)
              .lineLimit(1)
              .frame(maxWidth: .infinity, alignment: .leading)
            Text(LocalizedStringKey(thread.time))
              .scaledFont(.sm)
              .foregroundStyle(EsuissColor.mutedForeground)
            Ph.caretRight.bold
              .frame(width: 12, height: 12)
              .foregroundStyle(EsuissColor.mutedForeground)
              .flipsForRightToLeftLayoutDirection(true)
          }
          Text(verbatim: last)
            .scaledFont(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
            .lineLimit(2, reservesSpace: true)
            .multilineTextAlignment(.leading)
        }
        .padding(.bottom, 12)
        .overlay(alignment: .bottom) {
          if separator {
            EsuissColor.border.frame(height: 1)
          }
        }
      }
      .padding(.top, 12)
      .padding(.horizontal, 16)
      .contentShape(Rectangle())
    }
    .buttonStyle(ChatConversationsRowStyle())
    .accessibilityElement(children: .combine)
  }
}

private struct ChatConversationsRowStyle: ButtonStyle {
  func makeBody(configuration: Configuration) -> some View {
    configuration.label
      .opacity(configuration.isPressed ? 0.8 : 1)
  }
}

private struct ChatConversationsTyping: View {
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  var body: some View {
    Message {
      MessageContent {
        Bubble(variant: .muted) {
          BubbleContent {
            TimelineView(.animation(paused: reduceMotion)) { context in
              let time = context.date.timeIntervalSinceReferenceDate
              HStack(spacing: 4) {
                ForEach(0..<3, id: \.self) { dot in
                  Circle()
                    .fill(EsuissColor.mutedForeground)
                    .frame(width: 8, height: 8)
                    .opacity(reduceMotion ? 1 : 0.55 + 0.45 * abs(sin((time - Double(dot) * 0.18) * .pi / 1.1)))
                }
              }
              .padding(.vertical, 6)
            }
          }
        }
      }
    }
    .accessibilityElement(children: .ignore)
    .accessibilityLabel(Text("Typing"))
  }
}
```

```kt title="kotlin/blocks/ChatConversations.kt"
package com.esuiss.ui.blocks

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.ArrowUp
import com.adamglin.phosphoricons.bold.Plus
import com.adamglin.phosphoricons.bold.Trash
import com.adamglin.phosphoricons.regular.Info
import com.adamglin.phosphoricons.regular.NotePencil
import com.adamglin.phosphoricons.regular.VideoCamera
import com.esuiss.ui.components.BottomBar
import com.esuiss.ui.components.Bubble
import com.esuiss.ui.components.BubbleAlign
import com.esuiss.ui.components.BubbleContent
import com.esuiss.ui.components.BubbleVariant
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderActions
import com.esuiss.ui.components.HeaderBack
import com.esuiss.ui.components.HeaderLargeTitle
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.InputGroup
import com.esuiss.ui.components.InputGroupAddon
import com.esuiss.ui.components.InputGroupAddonAlign
import com.esuiss.ui.components.InputGroupButton
import com.esuiss.ui.components.InputGroupButtonSize
import com.esuiss.ui.components.InputGroupInput
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.Message
import com.esuiss.ui.components.MessageAlign
import com.esuiss.ui.components.MessageContent
import com.esuiss.ui.components.MessageScroller
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.rememberHeaderState
import com.esuiss.ui.interactions.SwipeAction
import com.esuiss.ui.interactions.SwipeActionVariant
import com.esuiss.ui.interactions.SwipeActions
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

private val ConversationPopEasing = CubicBezierEasing(0.3f, 1.25f, 0.5f, 1f)

private val ConversationEnterEasing = CubicBezierEasing(0.32f, 0.72f, 0f, 1f)

private class ConversationLine(val id: String, val mine: Boolean, val text: String)

private class Conversation(
    val name: String,
    val tint: (Colors) -> Color,
    val time: String,
    val lines: SnapshotStateList<ConversationLine>,
)

private fun conversation(name: String, tint: (Colors) -> Color, time: String, vararg lines: String) = Conversation(
    name,
    tint,
    time,
    mutableStateListOf<ConversationLine>().apply {
        lines.forEachIndexed { index, line ->
            val from = line.substringBefore(":")
            add(ConversationLine("$name-$index", from == "me", line.substringAfter(":")))
        }
    },
)

private fun conversations() = listOf(
    conversation(
        "Riley Chen",
        { Palette.green500 },
        "9:41 AM",
        "them:Is the weekend trip to the coast still on?",
        "me:Yes! We leave Friday evening",
        "me:I booked the boat tour, it leaves at 5 AM",
        "them:Great, I'll bring the camera",
    ),
    conversation(
        "Morgan Lee",
        { Palette.orange500 },
        "8:12 AM",
        "them:I sent the deck, can you take a look?",
        "me:On it, back to you in 10 minutes",
    ),
    conversation(
        "Family",
        { Palette.purple500 },
        "Yesterday",
        "them:Is everyone coming for Sunday brunch?",
        "me:We'll be there",
        "them:I'll bring pastries",
    ),
    conversation("Taylor Kim", { Palette.teal500 }, "Monday", "them:What time does the game start?"),
)

private val ConversationReplies = listOf(
    "Sounds good",
    "Great, see you then!",
    "Ha, absolutely",
    "Let me check and get back to you",
)

private const val ConversationStamp = "stamp"

private const val ConversationTyping = "typing"

@Composable
fun ChatConversations(
    modifier: Modifier = Modifier,
    onSend: (text: String, conversation: String) -> Unit = { _, _ -> },
    onNewMessage: () -> Unit = {},
    onAttach: () -> Unit = {},
    onVideoCall: (conversation: String) -> Unit = {},
    onDetails: (conversation: String) -> Unit = {},
    onDelete: (conversation: String) -> Unit = {},
) {
    val threads = remember { mutableStateListOf<Conversation>().apply { addAll(conversations()) } }
    var selected by rememberSaveable { mutableStateOf<String?>(null) }
    var typing by remember { mutableStateOf<String?>(null) }
    var replyCount by remember { mutableIntStateOf(0) }
    val scope = rememberCoroutineScope()

    fun send(thread: Conversation, text: String) {
        onSend(text, thread.name)
        thread.lines += ConversationLine("${thread.name}-${thread.lines.size}", true, text)
        scope.launch {
            delay(500)
            typing = thread.name
            delay(1300)
            typing = null
            val reply = ConversationReplies[replyCount++ % ConversationReplies.size]
            thread.lines += ConversationLine("${thread.name}-${thread.lines.size}", false, reply)
        }
    }

    Screen(modifier) {
        AnimatedContent(
            targetState = threads.firstOrNull { it.name == selected },
            transitionSpec = {
                val forward = targetState != null
                (slideInHorizontally(tween(300)) { if (forward) it / 3 else -it / 3 } + fadeIn(tween(300))) togetherWith
                    (slideOutHorizontally(tween(300)) { if (forward) -it / 3 else it / 3 } + fadeOut(tween(200)))
            },
            label = "conversations",
        ) { thread ->
            if (thread == null) {
                ConversationList(
                    threads = threads,
                    onOpen = { selected = it },
                    onNewMessage = onNewMessage,
                    onDelete = { item ->
                        onDelete(item.name)
                        threads.remove(item)
                    },
                )
            } else {
                ConversationDetail(
                    thread = thread,
                    typing = typing == thread.name,
                    onBack = { selected = null },
                    onSend = { send(thread, it) },
                    onAttach = onAttach,
                    onVideoCall = { onVideoCall(thread.name) },
                    onDetails = { onDetails(thread.name) },
                )
            }
        }
    }
}

@Composable
private fun ConversationList(
    threads: List<Conversation>,
    onOpen: (String) -> Unit,
    onNewMessage: () -> Unit,
    onDelete: (Conversation) -> Unit,
) {
    val colors = EsuissTheme.colors
    val header = rememberHeaderState()
    Column(
        Modifier
            .fillMaxSize()
            .nestedScroll(header.nestedScrollConnection),
    ) {
        Header(state = header) {
            HeaderTitle("Messages")
            HeaderLargeTitle("Messages")
            HeaderActions {
                Button(onClick = onNewMessage, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
                    Icon(PhosphorIcons.Regular.NotePencil, contentDescription = "New message", size = 20.dp, tint = colors.primary)
                }
            }
        }
        Column(
            Modifier
                .weight(1f)
                .fillMaxWidth()
                .verticalScroll(rememberScrollState()),
        ) {
            threads.forEachIndexed { index, thread ->
                key(thread.name) {
                    SwipeActions(
                        trailing = listOf(
                            SwipeAction(
                                label = "Delete",
                                onClick = { onDelete(thread) },
                                icon = PhosphorIcons.Bold.Trash,
                                variant = SwipeActionVariant.Destructive,
                                fullSwipe = true,
                            ),
                        ),
                    ) {
                        ConversationRow(thread, divided = index > 0) { onOpen(thread.name) }
                    }
                }
            }
        }
    }
}

@Composable
private fun ConversationRow(thread: Conversation, divided: Boolean, onClick: () -> Unit) {
    val colors = EsuissTheme.colors
    val interaction = remember { MutableInteractionSource() }
    val pressed by interaction.collectIsPressedAsState()
    val last = thread.lines.lastOrNull()?.text.orEmpty()
    Box(
        Modifier
            .fillMaxWidth()
            .alpha(if (pressed) 0.8f else 1f)
            .clickable(interactionSource = interaction, indication = null, role = Role.Button, onClick = onClick),
    ) {
        if (divided) {
            Box(
                Modifier
                    .padding(start = 70.dp)
                    .fillMaxWidth()
                    .height(1.dp)
                    .background(colors.border),
            )
        }
        Row(
            modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            ConversationInitials(thread, 42.dp)
            Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
                Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) {
                    BasicText(
                        thread.name,
                        modifier = Modifier.weight(1f),
                        style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                        maxLines = 1,
                        overflow = TextOverflow.Ellipsis,
                    )
                    BasicText(thread.time, style = Typography.xs.copy(color = colors.mutedForeground))
                }
                BasicText(
                    last,
                    style = Typography.sm.copy(color = colors.mutedForeground),
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
        }
    }
}

@Composable
private fun ConversationInitials(thread: Conversation, size: Dp) {
    val colors = EsuissTheme.colors
    Box(
        Modifier
            .size(size)
            .background(thread.tint(colors), CircleShape),
        contentAlignment = Alignment.Center,
    ) {
        BasicText(
            thread.name.split(" ").mapNotNull { it.firstOrNull() }.joinToString("").take(2),
            style = (if (size > 36.dp) Typography.base else Typography.xs).copy(color = Palette.white, fontWeight = FontWeight.Medium),
        )
    }
}

@Composable
private fun ConversationDetail(
    thread: Conversation,
    typing: Boolean,
    onBack: () -> Unit,
    onSend: (String) -> Unit,
    onAttach: () -> Unit,
    onVideoCall: () -> Unit,
    onDetails: () -> Unit,
) {
    val colors = EsuissTheme.colors
    val keys = buildList {
        add(ConversationStamp)
        thread.lines.forEach { add(it.id) }
        if (typing) add(ConversationTyping)
    }
    Column(Modifier.fillMaxSize()) {
        Header {
            HeaderBack(onClick = onBack, text = "Messages")
            HeaderTitle(
                thread.name,
                modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
                subtitle = if (typing) "typing…" else "Message",
            )
            HeaderActions {
                Button(onClick = onVideoCall, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
                    Icon(PhosphorIcons.Regular.VideoCamera, contentDescription = "Video call", size = 20.dp, tint = colors.primary)
                }
                Button(onClick = onDetails, variant = ButtonVariant.Ghost, size = ButtonSize.Icon) {
                    Icon(PhosphorIcons.Regular.Info, contentDescription = "Details", size = 20.dp, tint = colors.primary)
                }
            }
        }
        MessageScroller(
            items = keys,
            key = { it },
            modifier = Modifier
                .weight(1f)
                .fillMaxWidth()
                .semantics { contentDescription = "Conversation with ${thread.name}" },
            contentPadding = PaddingValues(horizontal = 16.dp, vertical = 16.dp),
            verticalArrangement = Arrangement.spacedBy(4.dp, Alignment.Bottom),
        ) { id ->
            when (id) {
                ConversationStamp -> BasicText(
                    "Today 9:30 AM",
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(top = 4.dp, bottom = 10.dp),
                    style = Typography.xs.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                )
                ConversationTyping -> ConversationTypingBubble()
                else -> thread.lines.firstOrNull { it.id == id }?.let { ConversationBubble(it) }
            }
        }
        ConversationComposer(onSend, onAttach)
    }
}

@Composable
private fun ConversationBubble(line: ConversationLine) {
    val appear = remember { Animatable(0f) }
    LaunchedEffect(Unit) { appear.animateTo(1f, tween(700, easing = ConversationEnterEasing)) }
    Message(
        modifier = Modifier.graphicsLayer {
            alpha = appear.value
            translationY = (1f - appear.value) * 8.dp.toPx()
        },
        align = if (line.mine) MessageAlign.End else MessageAlign.Start,
    ) {
        MessageContent {
            Bubble(
                modifier = Modifier.widthIn(max = 280.dp),
                variant = if (line.mine) BubbleVariant.Default else BubbleVariant.Muted,
                align = if (line.mine) BubbleAlign.End else BubbleAlign.Start,
            ) {
                BubbleContent {
                    BasicText(line.text, style = LocalContentStyle.current)
                }
            }
        }
    }
}

@Composable
private fun ConversationTypingBubble() {
    val colors = EsuissTheme.colors
    val transition = rememberInfiniteTransition(label = "typing")
    Message {
        MessageContent {
            Bubble(variant = BubbleVariant.Muted) {
                BubbleContent(Modifier.semantics { contentDescription = "Typing" }) {
                    Row(
                        modifier = Modifier.padding(vertical = 5.dp),
                        horizontalArrangement = Arrangement.spacedBy(4.dp),
                    ) {
                        repeat(3) { dot ->
                            val alpha by transition.animateFloat(
                                initialValue = 0.35f,
                                targetValue = 1f,
                                animationSpec = infiniteRepeatable(
                                    animation = tween(550, delayMillis = dot * 180),
                                    repeatMode = RepeatMode.Reverse,
                                ),
                                label = "dot",
                            )
                            Box(
                                Modifier
                                    .size(8.dp)
                                    .graphicsLayer { this.alpha = alpha }
                                    .background(colors.mutedForeground, CircleShape),
                            )
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun ConversationComposer(onSend: (String) -> Unit, onAttach: () -> Unit) {
    val colors = EsuissTheme.colors
    var draft by rememberSaveable { mutableStateOf("") }
    val ready = draft.isNotBlank()
    val scale by animateFloatAsState(if (ready) 1f else 0f, tween(300, easing = ConversationPopEasing), label = "send")

    fun submit() {
        if (!ready) return
        onSend(draft.trim())
        draft = ""
    }

    BottomBar {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 12.dp),
            horizontalArrangement = Arrangement.spacedBy(8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Button(onClick = onAttach, variant = ButtonVariant.Secondary, size = ButtonSize.IconSm) {
                Icon(PhosphorIcons.Bold.Plus, contentDescription = "Add attachment", size = 18.dp, tint = colors.mutedForeground)
            }
            InputGroup(Modifier.weight(1f)) {
                InputGroupInput(
                    value = draft,
                    onValueChange = { draft = it },
                    placeholder = "Message",
                    keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Sentences, imeAction = ImeAction.Send),
                    keyboardActions = KeyboardActions(onSend = { submit() }),
                )
                InputGroupAddon(align = InputGroupAddonAlign.InlineEnd) {
                    InputGroupButton(
                        onClick = ::submit,
                        modifier = Modifier.graphicsLayer {
                            scaleX = scale
                            scaleY = scale
                        },
                        size = InputGroupButtonSize.IconXs,
                        variant = ButtonVariant.Default,
                        enabled = ready,
                    ) {
                        Icon(PhosphorIcons.Bold.ArrowUp, contentDescription = "Send", size = 14.dp)
                    }
                }
            }
        }
    }
}
```

Composes `Screen`, `Header`, `SwipeActions` from the interactions, `Avatar`, `Message`, `Bubble`, `MessageScroller`, `InputGroup`, `Button` and `BottomBar`. Tapping a row opens the thread with a back button, video call and details actions, and a composer. Sending a message shows a typing indicator followed by a sample reply. Swiping a row reveals a Delete action.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/chat-conversations
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add chat-conversations
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add chat-conversations --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add chat-conversations --platform kotlin
```

The CLI also adds what it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Text](https://ui.suiss.app/docs/components/text). On React Native it also adds the animated view `components/ui/animated-view.tsx`.

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `uniwind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/uniwind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/chat-conversations.tsx`:

- `uniwind/components/blocks/chat-conversations.tsx`: shown above

5. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `nativewind/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/nativewind/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/chat-conversations.tsx`:

```tsx title="nativewind/components/blocks/chat-conversations.tsx"
import {
  ArrowUpIcon,
  InfoIcon,
  NotePencilIcon,
  PlusIcon,
  TrashIcon,
  VideoCameraIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import {
  SwipeAction,
  SwipeActions,
  SwipeActionsActions,
  SwipeActionsContent,
} from "@/components/interactions/swipe-actions"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
  Header,
  HeaderActions,
  HeaderBack,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/ui/message-scroller"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"

type Line = { id: string; from: "me" | "them"; text: string }

type Thread = { name: string; tint: string; time: string; lines: Line[] }

type Row =
  | { id: string; kind: "stamp" }
  | { id: string; kind: "typing" }
  | ({ kind: "line" } & Line)

const THREADS: { name: string; tint: string; time: string; lines: string[] }[] =
  [
    {
      name: "Riley Chen",
      tint: "bg-green-500",
      time: "9:41 AM",
      lines: [
        "them:Is the weekend trip to the coast still on?",
        "me:Yes! We leave Friday evening",
        "me:I booked the boat tour, it leaves at 5 AM",
        "them:Great, I'll bring the camera",
      ],
    },
    {
      name: "Morgan Lee",
      tint: "bg-orange-500",
      time: "8:12 AM",
      lines: [
        "them:I sent the deck, can you take a look?",
        "me:On it, back to you in 10 minutes",
      ],
    },
    {
      name: "Family",
      tint: "bg-purple-500",
      time: "Yesterday",
      lines: [
        "them:Is everyone coming for Sunday brunch?",
        "me:We'll be there",
        "them:I'll bring pastries",
      ],
    },
    {
      name: "Taylor Kim",
      tint: "bg-blue-500",
      time: "Monday",
      lines: ["them:What time does the game start?"],
    },
  ]

const REPLIES = [
  "Sounds good",
  "Great, see you then!",
  "Ha, absolutely",
  "Let me check and get back to you",
]

const SEND_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

const initialThreads = (): Thread[] =>
  THREADS.map((thread, index) => ({
    name: thread.name,
    tint: thread.tint,
    time: thread.time,
    lines: thread.lines.map((line, position) => {
      const [from, ...text] = line.split(":")
      return {
        id: `${index}-${position}`,
        from: from as Line["from"],
        text: text.join(":"),
      }
    }),
  }))

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")
    .slice(0, 2)

function TypingDot({ index }: { index: number }) {
  const opacity = useSharedValue(0.35)

  React.useEffect(() => {
    opacity.value = withDelay(
      index * 180,
      withRepeat(
        withSequence(
          withTiming(1, { duration: 550 }),
          withTiming(0.35, { duration: 550 })
        ),
        -1,
        false,
        undefined,
        ReduceMotion.System
      )
    )
    return () => cancelAnimation(opacity)
  }, [index, opacity])

  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))

  return (
    <AnimatedView
      className="size-2 rounded-full bg-muted-foreground"
      style={animatedStyle}
    />
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" accessible accessibilityLabel="Typing">
          <BubbleContent className="flex-row gap-1 px-3.5 py-3">
            {[0, 1, 2].map((dot) => (
              <TypingDot key={dot} index={dot} />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function Composer({
  placeholder,
  onSend,
  onAttach,
}: {
  placeholder: string
  onSend: (text: string) => void
  onAttach?: () => void
}) {
  const [value, setValue] = React.useState("")
  const { scrollToEnd } = useMessageScroller()
  const ready = value.trim().length > 0
  const scale = useSharedValue(ready ? 1 : 0)

  React.useEffect(() => {
    scale.value = withTiming(ready ? 1 : 0, {
      duration: 300,
      easing: SEND_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [ready, scale])

  const sendStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  const send = () => {
    if (!ready) return
    onSend(value.trim())
    setValue("")
    requestAnimationFrame(() => scrollToEnd())
  }

  return (
    <BottomBar separator>
      <BottomBarContent className="flex-row items-center gap-2 px-4 pt-2.5 pb-3.5">
        <Button
          variant="secondary"
          size="icon-sm"
          accessibilityLabel="Add attachment"
          onPress={onAttach}
        >
          <Icon
            as={PlusIcon}
            weight="bold"
            className="size-[1.125rem] text-muted-foreground"
          />
        </Button>
        <InputGroup className="h-9 flex-1 rounded-full">
          <InputGroupInput
            value={value}
            onChangeText={setValue}
            placeholder={placeholder}
            accessibilityLabel={placeholder}
            returnKeyType="send"
            submitBehavior="submit"
            onSubmitEditing={send}
            className="ps-3.5"
          />
          <InputGroupAddon align="inline-end" className="pe-1">
            <AnimatedView style={sendStyle}>
              <InputGroupButton
                variant="default"
                size="icon-xs"
                accessibilityLabel="Send"
                disabled={!ready}
                onPress={send}
                className="size-[1.6875rem]"
              >
                <Icon as={ArrowUpIcon} weight="bold" className="size-4" />
              </InputGroupButton>
            </AnimatedView>
          </InputGroupAddon>
        </InputGroup>
      </BottomBarContent>
    </BottomBar>
  )
}

function LineRow({ line }: { line: Line }) {
  const mine = line.from === "me"
  return (
    <AnimatedView
      entering={FadeInDown.duration(700)
        .easing(Easing.bezier(0.32, 0.72, 0, 1))
        .reduceMotion(ReduceMotion.System)}
    >
      <Message align={mine ? "end" : "start"}>
        <MessageContent>
          <Bubble
            variant={mine ? "default" : "muted"}
            align={mine ? "end" : "start"}
          >
            <BubbleContent>{line.text}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </AnimatedView>
  )
}

function ThreadAvatar({
  thread,
  className,
}: {
  thread: Thread
  className?: string
}) {
  return (
    <Avatar size="lg" className={className}>
      <AvatarFallback className={thread.tint}>
        {initials(thread.name)}
      </AvatarFallback>
    </Avatar>
  )
}

function ChatConversations({
  onSend,
  onNewMessage,
  onAttach,
  onVideoCall,
  onDetails,
  onDelete,
}: {
  onSend?: (text: string, conversation: string) => void
  onNewMessage?: () => void
  onAttach?: () => void
  onVideoCall?: (conversation: string) => void
  onDetails?: (conversation: string) => void
  onDelete?: (conversation: string) => void
}) {
  const [threads, setThreads] = React.useState(initialThreads)
  const [selected, setSelected] = React.useState<string | null>(null)
  const [typing, setTyping] = React.useState<string | null>(null)
  const replyCount = React.useRef(0)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  const scroll = useHeaderScroll()
  const thread = threads.find((item) => item.name === selected)

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

  const append = (name: string, from: Line["from"], text: string) =>
    setThreads((current) =>
      current.map((item) =>
        item.name === name
          ? {
              ...item,
              lines: [
                ...item.lines,
                { id: `${name}-${item.lines.length}`, from, text },
              ],
            }
          : item
      )
    )

  const send = (text: string) => {
    if (!thread) return
    const target = thread.name
    onSend?.(text, target)
    append(target, "me", text)
    timers.current.push(setTimeout(() => setTyping(target), 500))
    timers.current.push(
      setTimeout(() => {
        const reply =
          REPLIES[replyCount.current++ % REPLIES.length] ?? REPLIES[0]
        setTyping(null)
        append(target, "them", reply)
      }, 1800)
    )
  }

  if (thread) {
    const rows: Row[] = [
      { id: "stamp", kind: "stamp" },
      ...thread.lines.map((line) => ({ kind: "line" as const, ...line })),
      ...(typing === thread.name
        ? [{ id: "typing", kind: "typing" as const }]
        : []),
    ]
    return (
      <Screen edges={["top", "right", "left"]}>
        <MessageScrollerProvider key={thread.name}>
          <Header className="h-14 border-b border-border">
            <HeaderBack onPress={() => setSelected(null)}>Messages</HeaderBack>
            <HeaderTitle className="flex-row items-center gap-2.5">
              <ThreadAvatar thread={thread} className="size-[1.875rem]" />
              <View className="shrink">
                <Text numberOfLines={1} className="text-sm font-semibold">
                  {thread.name}
                </Text>
                <Text
                  accessibilityLiveRegion="polite"
                  className="text-xs text-muted-foreground"
                >
                  {typing === thread.name ? "typing…" : "Message"}
                </Text>
              </View>
            </HeaderTitle>
            <HeaderActions>
              <Button
                variant="ghost"
                size="icon"
                accessibilityLabel="Video call"
                onPress={() => onVideoCall?.(thread.name)}
              >
                <Icon as={VideoCameraIcon} className="size-5 text-primary" />
              </Button>
              <Button
                variant="ghost"
                size="icon"
                accessibilityLabel="Details"
                onPress={() => onDetails?.(thread.name)}
              >
                <Icon as={InfoIcon} className="size-5 text-primary" />
              </Button>
            </HeaderActions>
          </Header>
          <MessageScroller>
            <MessageScrollerViewport
              data={rows}
              keyExtractor={(row) => row.id}
              accessibilityLabel={`Conversation with ${thread.name}`}
              contentContainerClassName="gap-1 px-2.5 py-4"
              renderItem={({ item }) => {
                if (item.kind === "stamp") {
                  return (
                    <MessageScrollerItem>
                      <Text className="mt-1 mb-2.5 text-center text-xs text-muted-foreground">
                        Today 9:30 AM
                      </Text>
                    </MessageScrollerItem>
                  )
                }
                return (
                  <MessageScrollerItem>
                    {item.kind === "typing" ? (
                      <TypingDots />
                    ) : (
                      <LineRow line={item} />
                    )}
                  </MessageScrollerItem>
                )
              }}
            />
          </MessageScroller>
          <Composer placeholder="Message" onSend={send} onAttach={onAttach} />
        </MessageScrollerProvider>
      </Screen>
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <Header scroll={scroll}>
        <HeaderTitle>Messages</HeaderTitle>
        <HeaderActions>
          <Button
            variant="ghost"
            size="icon"
            accessibilityLabel="New message"
            onPress={onNewMessage}
          >
            <Icon as={NotePencilIcon} className="size-5 text-primary" />
          </Button>
        </HeaderActions>
      </Header>
      <HeaderScrollView scroll={scroll}>
        <HeaderLargeTitle>Messages</HeaderLargeTitle>
        {threads.length === 0 ? (
          <Text className="px-4 pt-10 text-center text-base text-muted-foreground">
            No Messages
          </Text>
        ) : null}
        {threads.map((item, index) => {
          const last = item.lines.at(-1)
          return (
            <React.Fragment key={item.name}>
              {index > 0 ? (
                <View className="ms-[4.5rem] h-px bg-border" />
              ) : null}
              <SwipeActions>
                <SwipeActionsContent>
                  <Pressable
                    role="button"
                    onPress={() => setSelected(item.name)}
                    className="flex-row items-center gap-3 px-4 py-2.5 active:opacity-80"
                  >
                    <ThreadAvatar thread={item} className="size-11" />
                    <View className="flex-1 gap-0.5">
                      <View className="flex-row items-center gap-1.5">
                        <Text
                          numberOfLines={1}
                          className="flex-1 text-base font-semibold"
                        >
                          {item.name}
                        </Text>
                        <Text className="text-sm text-muted-foreground">
                          {item.time}
                        </Text>
                      </View>
                      <Text
                        numberOfLines={1}
                        className="text-sm text-muted-foreground"
                      >
                        {last?.text}
                      </Text>
                    </View>
                  </Pressable>
                </SwipeActionsContent>
                <SwipeActionsActions side="trailing">
                  <SwipeAction
                    variant="destructive"
                    fullSwipe
                    onPress={() => {
                      onDelete?.(item.name)
                      setThreads((current) =>
                        current.filter((entry) => entry.name !== item.name)
                      )
                    }}
                  >
                    <Icon as={TrashIcon} />
                    <Text>Delete</Text>
                  </SwipeAction>
                </SwipeActionsActions>
              </SwipeActions>
            </React.Fragment>
          )
        })}
        <View className="h-8" />
      </HeaderScrollView>
    </Screen>
  )
}

export { ChatConversations }
```

5. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-reanimated react-native-svg react-native-worklets
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar), [Bubble](https://ui.suiss.app/docs/components/bubble), [Button](https://ui.suiss.app/docs/components/button), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Message](https://ui.suiss.app/docs/components/message), [Message Scroller](https://ui.suiss.app/docs/components/message-scroller), [Screen](https://ui.suiss.app/docs/components/screen), [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions), [Text](https://ui.suiss.app/docs/components/text), and the animated view `components/ui/animated-view.tsx` from the next step.

3. Copy the animated view into `components/ui/animated-view.tsx`:

- `stylesheet/components/ui/animated-view.tsx`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/stylesheet/components/ui/animated-view.tsx)

4. Copy the source into `components/blocks/chat-conversations.tsx`:

```tsx title="stylesheet/components/blocks/chat-conversations.tsx"
import {
  ArrowUpIcon,
  InfoIcon,
  NotePencilIcon,
  PlusIcon,
  TrashIcon,
  VideoCameraIcon,
} from "phosphor-react-native"
import * as React from "react"
import { Pressable, StyleSheet, View } from "react-native"
import {
  cancelAnimation,
  Easing,
  FadeInDown,
  ReduceMotion,
  useAnimatedStyle,
  useSharedValue,
  withDelay,
  withRepeat,
  withSequence,
  withTiming,
} from "react-native-reanimated"
import {
  SwipeAction,
  SwipeActions,
  SwipeActionsActions,
  SwipeActionsContent,
} from "@/components/interactions/swipe-actions"
import { AnimatedView } from "@/components/ui/animated-view"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { BottomBar, BottomBarContent } from "@/components/ui/bottom-bar"
import { Bubble, BubbleContent } from "@/components/ui/bubble"
import { Button } from "@/components/ui/button"
import {
  Header,
  HeaderActions,
  HeaderBack,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Message, MessageContent } from "@/components/ui/message"
import {
  MessageScroller,
  MessageScrollerItem,
  MessageScrollerProvider,
  MessageScrollerViewport,
  useMessageScroller,
} from "@/components/ui/message-scroller"
import { Screen } from "@/components/ui/screen"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type Tone = keyof ThemeColors | keyof typeof palette

function tone(colors: ThemeColors, key: Tone) {
  return { ...colors, ...palette }[key]
}

type Line = { id: string; from: "me" | "them"; text: string }

type Tint = Tone

type Thread = { name: string; tint: Tint; time: string; lines: Line[] }

type Row =
  | { id: string; kind: "stamp" }
  | { id: string; kind: "typing" }
  | ({ kind: "line" } & Line)

const THREADS: { name: string; tint: Tint; time: string; lines: string[] }[] = [
  {
    name: "Riley Chen",
    tint: "green500",
    time: "9:41 AM",
    lines: [
      "them:Is the weekend trip to the coast still on?",
      "me:Yes! We leave Friday evening",
      "me:I booked the boat tour, it leaves at 5 AM",
      "them:Great, I'll bring the camera",
    ],
  },
  {
    name: "Morgan Lee",
    tint: "orange500",
    time: "8:12 AM",
    lines: [
      "them:I sent the deck, can you take a look?",
      "me:On it, back to you in 10 minutes",
    ],
  },
  {
    name: "Family",
    tint: "purple500",
    time: "Yesterday",
    lines: [
      "them:Is everyone coming for Sunday brunch?",
      "me:We'll be there",
      "them:I'll bring pastries",
    ],
  },
  {
    name: "Taylor Kim",
    tint: "blue500",
    time: "Monday",
    lines: ["them:What time does the game start?"],
  },
]

const REPLIES = [
  "Sounds good",
  "Great, see you then!",
  "Ha, absolutely",
  "Let me check and get back to you",
]

const SEND_EASING = Easing.bezier(0.3, 1.25, 0.5, 1)

const styles = StyleSheet.create({
  dot: { width: 8, height: 8, borderRadius: radius.full },
  dots: {
    flexDirection: "row",
    gap: 4,
    paddingHorizontal: 14,
    paddingVertical: 12,
  },
  composer: {
    flexDirection: "row",
    alignItems: "center",
    gap: 8,
    paddingHorizontal: 16,
    paddingTop: 10,
    paddingBottom: 14,
  },
  group: { flex: 1, height: 36, borderRadius: radius.full },
  input: { paddingStart: 14 },
  addon: { paddingEnd: 4 },
  send: { width: 27, height: 27 },
  header: { height: 56, borderBottomWidth: 1 },
  title: { flexDirection: "row", alignItems: "center", gap: 10 },
  titleText: { flexShrink: 1 },
  list: { gap: 4, paddingHorizontal: 10, paddingVertical: 16 },
  stamp: {
    ...fontSize.xs,
    marginTop: 4,
    marginBottom: 10,
    textAlign: "center",
  },
  row: {
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    paddingHorizontal: 16,
    paddingVertical: 10,
  },
  rowText: { flex: 1, gap: 2 },
  rowTop: { flexDirection: "row", alignItems: "center", gap: 6 },
  name: { ...fontSize.base, flex: 1, fontWeight: "600" },
  separator: { height: 1, marginStart: 72 },
  empty: {
    ...fontSize.base,
    paddingHorizontal: 16,
    paddingTop: 40,
    textAlign: "center",
  },
})

const initialThreads = (): Thread[] =>
  THREADS.map((thread, index) => ({
    name: thread.name,
    tint: thread.tint,
    time: thread.time,
    lines: thread.lines.map((line, position) => {
      const [from, ...text] = line.split(":")
      return {
        id: `${index}-${position}`,
        from: from as Line["from"],
        text: text.join(":"),
      }
    }),
  }))

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")
    .slice(0, 2)

function TypingDot({ index }: { index: number }) {
  const { colors } = useTheme()
  const opacity = useSharedValue(0.35)

  React.useEffect(() => {
    opacity.value = withDelay(
      index * 180,
      withRepeat(
        withSequence(
          withTiming(1, { duration: 550 }),
          withTiming(0.35, { duration: 550 })
        ),
        -1,
        false,
        undefined,
        ReduceMotion.System
      )
    )
    return () => cancelAnimation(opacity)
  }, [index, opacity])

  const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))

  return (
    <AnimatedView
      style={[
        styles.dot,
        { backgroundColor: colors.mutedForeground },
        animatedStyle,
      ]}
    />
  )
}

function TypingDots() {
  return (
    <Message>
      <MessageContent>
        <Bubble variant="muted" accessible accessibilityLabel="Typing">
          <BubbleContent style={styles.dots}>
            {[0, 1, 2].map((dot) => (
              <TypingDot key={dot} index={dot} />
            ))}
          </BubbleContent>
        </Bubble>
      </MessageContent>
    </Message>
  )
}

function Composer({
  placeholder,
  onSend,
  onAttach,
}: {
  placeholder: string
  onSend: (text: string) => void
  onAttach?: () => void
}) {
  const { colors } = useTheme()
  const [value, setValue] = React.useState("")
  const { scrollToEnd } = useMessageScroller()
  const ready = value.trim().length > 0
  const scale = useSharedValue(ready ? 1 : 0)

  React.useEffect(() => {
    scale.value = withTiming(ready ? 1 : 0, {
      duration: 300,
      easing: SEND_EASING,
      reduceMotion: ReduceMotion.System,
    })
  }, [ready, scale])

  const sendStyle = useAnimatedStyle(() => ({
    transform: [{ scale: scale.value }],
  }))

  const send = () => {
    if (!ready) return
    onSend(value.trim())
    setValue("")
    requestAnimationFrame(() => scrollToEnd())
  }

  return (
    <BottomBar separator>
      <BottomBarContent style={styles.composer}>
        <Button
          variant="secondary"
          size="icon-sm"
          accessibilityLabel="Add attachment"
          onPress={onAttach}
        >
          <Icon
            as={PlusIcon}
            weight="bold"
            size={18}
            color={colors.mutedForeground}
          />
        </Button>
        <InputGroup style={styles.group}>
          <InputGroupInput
            value={value}
            onChangeText={setValue}
            placeholder={placeholder}
            accessibilityLabel={placeholder}
            returnKeyType="send"
            submitBehavior="submit"
            onSubmitEditing={send}
            style={styles.input}
          />
          <InputGroupAddon align="inline-end" style={styles.addon}>
            <AnimatedView style={sendStyle}>
              <InputGroupButton
                variant="default"
                size="icon-xs"
                accessibilityLabel="Send"
                disabled={!ready}
                onPress={send}
                style={styles.send}
              >
                <Icon as={ArrowUpIcon} weight="bold" size={16} />
              </InputGroupButton>
            </AnimatedView>
          </InputGroupAddon>
        </InputGroup>
      </BottomBarContent>
    </BottomBar>
  )
}

function LineRow({ line }: { line: Line }) {
  const mine = line.from === "me"
  return (
    <AnimatedView
      entering={FadeInDown.duration(700)
        .easing(Easing.bezier(0.32, 0.72, 0, 1))
        .reduceMotion(ReduceMotion.System)}
    >
      <Message align={mine ? "end" : "start"}>
        <MessageContent>
          <Bubble
            variant={mine ? "default" : "muted"}
            align={mine ? "end" : "start"}
          >
            <BubbleContent>{line.text}</BubbleContent>
          </Bubble>
        </MessageContent>
      </Message>
    </AnimatedView>
  )
}

function ThreadAvatar({ thread, size }: { thread: Thread; size: number }) {
  const { colors } = useTheme()
  return (
    <Avatar size="lg" style={{ width: size, height: size }}>
      <AvatarFallback style={{ backgroundColor: tone(colors, thread.tint) }}>
        {initials(thread.name)}
      </AvatarFallback>
    </Avatar>
  )
}

function ChatConversations({
  onSend,
  onNewMessage,
  onAttach,
  onVideoCall,
  onDetails,
  onDelete,
}: {
  onSend?: (text: string, conversation: string) => void
  onNewMessage?: () => void
  onAttach?: () => void
  onVideoCall?: (conversation: string) => void
  onDetails?: (conversation: string) => void
  onDelete?: (conversation: string) => void
}) {
  const { colors } = useTheme()
  const [threads, setThreads] = React.useState(initialThreads)
  const [selected, setSelected] = React.useState<string | null>(null)
  const [typing, setTyping] = React.useState<string | null>(null)
  const replyCount = React.useRef(0)
  const timers = React.useRef<ReturnType<typeof setTimeout>[]>([])
  const scroll = useHeaderScroll()
  const thread = threads.find((item) => item.name === selected)

  React.useEffect(
    () => () => {
      for (const timer of timers.current) clearTimeout(timer)
    },
    []
  )

  const append = (name: string, from: Line["from"], text: string) =>
    setThreads((current) =>
      current.map((item) =>
        item.name === name
          ? {
              ...item,
              lines: [
                ...item.lines,
                { id: `${name}-${item.lines.length}`, from, text },
              ],
            }
          : item
      )
    )

  const send = (text: string) => {
    if (!thread) return
    const target = thread.name
    onSend?.(text, target)
    append(target, "me", text)
    timers.current.push(setTimeout(() => setTyping(target), 500))
    timers.current.push(
      setTimeout(() => {
        const reply =
          REPLIES[replyCount.current++ % REPLIES.length] ?? REPLIES[0]
        setTyping(null)
        append(target, "them", reply)
      }, 1800)
    )
  }

  if (thread) {
    const rows: Row[] = [
      { id: "stamp", kind: "stamp" },
      ...thread.lines.map((line) => ({ kind: "line" as const, ...line })),
      ...(typing === thread.name
        ? [{ id: "typing", kind: "typing" as const }]
        : []),
    ]
    return (
      <Screen edges={["top", "right", "left"]}>
        <MessageScrollerProvider key={thread.name}>
          <Header style={[styles.header, { borderBottomColor: colors.border }]}>
            <HeaderBack onPress={() => setSelected(null)}>Messages</HeaderBack>
            <HeaderTitle style={styles.title}>
              <ThreadAvatar thread={thread} size={30} />
              <View style={styles.titleText}>
                <Text
                  numberOfLines={1}
                  style={[fontSize.sm, { fontWeight: "600" }]}
                >
                  {thread.name}
                </Text>
                <Text
                  accessibilityLiveRegion="polite"
                  style={[fontSize.xs, { color: colors.mutedForeground }]}
                >
                  {typing === thread.name ? "typing…" : "Message"}
                </Text>
              </View>
            </HeaderTitle>
            <HeaderActions>
              <Button
                variant="ghost"
                size="icon"
                accessibilityLabel="Video call"
                onPress={() => onVideoCall?.(thread.name)}
              >
                <Icon as={VideoCameraIcon} size={20} color={colors.primary} />
              </Button>
              <Button
                variant="ghost"
                size="icon"
                accessibilityLabel="Details"
                onPress={() => onDetails?.(thread.name)}
              >
                <Icon as={InfoIcon} size={20} color={colors.primary} />
              </Button>
            </HeaderActions>
          </Header>
          <MessageScroller>
            <MessageScrollerViewport
              data={rows}
              keyExtractor={(row) => row.id}
              accessibilityLabel={`Conversation with ${thread.name}`}
              contentContainerStyle={styles.list}
              renderItem={({ item }) => (
                <MessageScrollerItem>
                  {item.kind === "stamp" ? (
                    <Text
                      style={[styles.stamp, { color: colors.mutedForeground }]}
                    >
                      Today 9:30 AM
                    </Text>
                  ) : null}
                  {item.kind === "typing" ? <TypingDots /> : null}
                  {item.kind === "line" ? <LineRow line={item} /> : null}
                </MessageScrollerItem>
              )}
            />
          </MessageScroller>
          <Composer placeholder="Message" onSend={send} onAttach={onAttach} />
        </MessageScrollerProvider>
      </Screen>
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <Header scroll={scroll}>
        <HeaderTitle>Messages</HeaderTitle>
        <HeaderActions>
          <Button
            variant="ghost"
            size="icon"
            accessibilityLabel="New message"
            onPress={onNewMessage}
          >
            <Icon as={NotePencilIcon} size={20} color={colors.primary} />
          </Button>
        </HeaderActions>
      </Header>
      <HeaderScrollView scroll={scroll}>
        <HeaderLargeTitle>Messages</HeaderLargeTitle>
        {threads.length === 0 ? (
          <Text style={[styles.empty, { color: colors.mutedForeground }]}>
            No Messages
          </Text>
        ) : null}
        {threads.map((item, index) => {
          const last = item.lines.at(-1)
          return (
            <React.Fragment key={item.name}>
              {index > 0 ? (
                <View
                  style={[styles.separator, { backgroundColor: colors.border }]}
                />
              ) : null}
              <SwipeActions>
                <SwipeActionsContent>
                  <Pressable
                    role="button"
                    onPress={() => setSelected(item.name)}
                    style={({ pressed }) => [
                      styles.row,
                      pressed && { opacity: 0.8 },
                    ]}
                  >
                    <ThreadAvatar thread={item} size={44} />
                    <View style={styles.rowText}>
                      <View style={styles.rowTop}>
                        <Text numberOfLines={1} style={styles.name}>
                          {item.name}
                        </Text>
                        <Text
                          style={[
                            fontSize.sm,
                            { color: colors.mutedForeground },
                          ]}
                        >
                          {item.time}
                        </Text>
                      </View>
                      <Text
                        numberOfLines={1}
                        style={[fontSize.sm, { color: colors.mutedForeground }]}
                      >
                        {last?.text}
                      </Text>
                    </View>
                  </Pressable>
                </SwipeActionsContent>
                <SwipeActionsActions side="trailing">
                  <SwipeAction
                    variant="destructive"
                    fullSwipe
                    onPress={() => {
                      onDelete?.(item.name)
                      setThreads((current) =>
                        current.filter((entry) => entry.name !== item.name)
                      )
                    }}
                  >
                    <Icon as={TrashIcon} />
                    <Text>Delete</Text>
                  </SwipeAction>
                </SwipeActionsActions>
              </SwipeActions>
            </React.Fragment>
          )
        })}
        <View style={{ height: 32 }} />
      </HeaderScrollView>
    </Screen>
  )
}

export { ChatConversations }
```

5. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/ChatConversations.swift`: shown above

- `swift/Components/BottomBar.swift`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `swift/Components/Bubble.swift`: see [Bubble](https://ui.suiss.app/docs/components/bubble.md)

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Header.swift`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `swift/Components/InputGroup.swift`: see [Input Group](https://ui.suiss.app/docs/components/input-group.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Message.swift`: see [Message](https://ui.suiss.app/docs/components/message.md)

- `swift/Components/MessageScroller.swift`: see [Message Scroller](https://ui.suiss.app/docs/components/message-scroller.md)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Interactions/SwipeActions.swift`: see [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions.md)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/ChatConversations.kt`: shown above

- `kotlin/components/Avatar.kt`: see [Avatar](https://ui.suiss.app/docs/components/avatar.md)

- `kotlin/components/BottomBar.kt`: see [Bottom Bar](https://ui.suiss.app/docs/components/bottom-bar.md)

- `kotlin/components/Bubble.kt`: see [Bubble](https://ui.suiss.app/docs/components/bubble.md)

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Header.kt`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/InputGroup.kt`: see [Input Group](https://ui.suiss.app/docs/components/input-group.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Message.kt`: see [Message](https://ui.suiss.app/docs/components/message.md)

- `kotlin/components/MessageScroller.kt`: see [Message Scroller](https://ui.suiss.app/docs/components/message-scroller.md)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/SignaturePad.kt`: see [Signature Pad](https://ui.suiss.app/docs/components/signature-pad.md)

- `kotlin/components/Spinner.kt`: see [Spinner](https://ui.suiss.app/docs/components/spinner.md)

- `kotlin/interactions/SwipeActions.kt`: see [Swipe Actions](https://ui.suiss.app/docs/interactions/swipe-actions.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { ChatConversations } from "@/components/blocks/chat-conversations"
```

```tsx
<ChatConversations />
```

Replace `THREADS` and `REPLIES` with your conversations and drop the canned replies. Wire `onSend`, `onNewMessage`, `onAttach`, `onVideoCall`, `onDetails` and `onDelete` to your messaging backend.

