v0.1 public beta early access on GitHub
Home / Screens / chat-chat-list
Screen template

Chat List Screen

Searchable conversation rows with an active-now rail, presence dots, pinned/muted affordances, typing + draft states, unread badges, and a compose action

pro Expo Go ✓ iosandroid gestures: tap · scroll haptics: light · selection
$ npx lumioui add chat-chat-list
import { ChatListScreen } from "@/screens/chat/chat-list";
Native packages installed4
@lumioui/ui@latest@lumioui/core@latestreact-native-safe-area-context@*react-native-reanimated@*
chat/chat-list.tsxsource · yours to edit
import * as React from "react";
import { useState } from "react";
import { View, FlatList, ScrollView } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
  Easing,
  useSharedValue,
  useAnimatedStyle,
  withDelay,
  withRepeat,
  withTiming,
} from "react-native-reanimated";
import { Avatar, Button, EmptyState, Icon, SearchInput, Text } from "@lumioui/ui";
import { AnimatedPressable, cn, ease, timing, useHaptic, usePressAnimation, useReducedMotion, } from "@lumioui/core";

export interface Conversation {
  id: string;
  name: string;
  lastMessage: string;
  time: string;
  unreadCount?: number;
  online?: boolean;
  /** Pinned conversations float to the top (iMessage / WhatsApp idiom). */
  pinned?: boolean;
  /** Muted conversations keep their count but lose the accent badge. */
  muted?: boolean;
  /** Peer is typing right now — replaces the preview line. */
  typing?: boolean;
  /** Unsent draft — the preview line switches to a "Draft:" prefix. */
  draft?: string;
  avatarUrl?: string;
}

export interface ChatListScreenProps {
  conversations?: Conversation[];
  onConversationPress?: (conversation: Conversation) => void;
  onSearch?: (query: string) => void;
  /** Compose affordance in the header (iMessage's new-message button). */
  onCompose?: () => void;
}

const DEFAULT_CONVERSATIONS: Conversation[] = [
  { id: "1", name: "Maya Chen", lastMessage: "Sending the mockups now", time: "2m", unreadCount: 2, online: true, pinned: true, typing: true, avatarUrl: "https://i.pravatar.cc/150?img=47" },
  { id: "2", name: "Design Team", lastMessage: "Priya: New icons are in Figma", time: "26m", unreadCount: 5, pinned: true },
  { id: "3", name: "Sakura Ramen", lastMessage: "Order #2418 is out for delivery", time: "41m", unreadCount: 1, avatarUrl: "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=150&h=150&fit=crop&q=70&auto=format" },
  { id: "4", name: "Tom Reed", lastMessage: "LGTM — ship it!", time: "1h", online: true, avatarUrl: "https://i.pravatar.cc/150?img=12" },
  { id: "5", name: "Diego Ruiz", lastMessage: "", draft: "See you at the trailhead at 7?", time: "3h", online: true, avatarUrl: "https://i.pravatar.cc/150?img=53" },
  { id: "6", name: "Priya Nair", lastMessage: "Thanks for the review!", time: "1d", online: true, avatarUrl: "https://i.pravatar.cc/150?img=32" },
  { id: "7", name: "Amara Chen", lastMessage: "The investor deck is ready when you are", time: "1d", avatarUrl: "https://i.pravatar.cc/150?img=9" },
  { id: "8", name: "Run Club", lastMessage: "Amara: Who's pacing the Saturday loop?", time: "2d", muted: true, unreadCount: 8 },
  { id: "9", name: "Jonah Reyes", lastMessage: "You: Sounds good, see you then", time: "4d", avatarUrl: "https://i.pravatar.cc/150?img=11" },
];

/** Staggered mount — rows rise and fade in sequence. Ambient stagger step is
 *  choreography data (like EmptyState's 90ms cadence), capped so rows that
 *  mount lazily on scroll never wait on a deep index. */
const MOUNT_STAGGER = 45;
const MOUNT_MAX_DELAY = 320;
const MOUNT_TIMING = { duration: timing.normal.duration, easing: Easing.bezier(...ease.out) };

/** Fade + small rise on mount, delayed by index. Reduced motion: fade only. */
function MountIn({
  index,
  className,
  children,
}: {
  index: number;
  className?: string;
  children: React.ReactNode;
}) {
  const reduce = useReducedMotion();
  const t = useSharedValue(reduce ? 1 : 0);
  React.useEffect(() => {
    if (reduce) {
      t.value = 1;
      return;
    }
    t.value = withDelay(
      Math.min(index * MOUNT_STAGGER, MOUNT_MAX_DELAY),
      withTiming(1, MOUNT_TIMING)
    );
  }, [index, reduce, t]);
  const style = useAnimatedStyle(() => ({
    opacity: t.value,
    transform: [{ translateY: (1 - t.value) * 10 }],
  }));
  return (
    <Animated.View style={style} className={className}>
      {children}
    </Animated.View>
  );
}

/** Ambient pulse on the "typing…" preview — a slow breathing loop (fixed
 *  period, like the typing dots in the thread) so the row reads alive. */
const TYPING_PULSE = { duration: 900 };

function TypingPreview() {
  const reduce = useReducedMotion();
  const v = useSharedValue(1);
  React.useEffect(() => {
    if (reduce) return;
    v.value = withRepeat(withTiming(0.45, TYPING_PULSE), -1, true);
  }, [v, reduce]);
  const style = useAnimatedStyle(() => ({ opacity: v.value }));
  return (
    <Animated.Text
      numberOfLines={1}
      style={style}
      className="flex-1 text-sm font-medium text-[#3E9AA3]"
    >
      typing…
    </Animated.Text>
  );
}

function ConversationRow({
  item,
  index,
  onPress,
}: {
  item: Conversation;
  index: number;
  onPress?: (c: Conversation) => void;
}) {
  const haptics = useHaptic();
  // Rows dip like iOS cells — fast timing keeps the feedback under a scroll.
  const press = usePressAnimation({ pressedScale: 0.98, pressedOpacity: 0.75, fast: true });
  const unread = !!item.unreadCount && item.unreadCount > 0;

  const stateBits = [
    item.pinned ? "pinned" : null,
    item.muted ? "muted" : null,
    item.online ? "online" : null,
    item.typing ? "typing" : null,
    unread ? `${item.unreadCount} unread` : null,
  ].filter(Boolean);

  return (
    <MountIn index={index}>
      <AnimatedPressable
        onPress={() => {
          haptics.light();
          onPress?.(item);
        }}
        onPressIn={press.onPressIn}
        onPressOut={press.onPressOut}
        style={press.animatedStyle}
        accessibilityRole="button"
        accessibilityLabel={`${item.name}, ${item.typing ? "typing" : item.draft ? `draft: ${item.draft}` : item.lastMessage}${stateBits.length ? `, ${stateBits.join(", ")}` : ""}`}
        className="flex-row items-center gap-3 px-6 py-3.5"
      >
        <Avatar
          size="lg"
          source={item.avatarUrl ? { uri: item.avatarUrl } : undefined}
          fallback={item.name}
          status={item.online ? "online" : undefined}
          alt={`${item.name}'s avatar`}
        />
        <View className="flex-1 gap-0.5">
          <View className="flex-row items-center justify-between">
            <Text
              numberOfLines={1}
              className={cn(
                "flex-1 pr-2 text-sm",
                unread ? "font-semibold" : "font-medium"
              )}
            >
              {item.name}
            </Text>
            <Text
              numberOfLines={1}
              className={cn(
                "shrink-0 text-xs tabular-nums",
                unread && !item.muted
                  ? "font-semibold text-foreground"
                  : "text-muted-foreground"
              )}
            >
              {item.time}
            </Text>
          </View>
          <View className="flex-row items-center gap-1.5">
            {item.pinned ? (
              // No pin glyph exists in the icon set — a filled star is the
              // closest "kept at the top" affordance (starred conversations).
              <Icon name="star" size="sm" color="mutedForeground" filled />
            ) : null}
            {item.typing ? (
              <TypingPreview />
            ) : item.draft ? (
              // iMessage idiom — an unsent draft flags itself in red ahead
              // of the grey preview text.
              <Text variant="muted" numberOfLines={1} className="flex-1">
                <Text className="text-sm text-destructive">Draft: </Text>
                {item.draft}
              </Text>
            ) : (
              <Text
                variant="muted"
                numberOfLines={1}
                className={cn("flex-1", unread && "font-medium text-foreground")}
              >
                {item.lastMessage}
              </Text>
            )}
            {item.muted ? (
              // Crescent moon = iOS "Hide Alerts" idiom for a muted thread.
              <Icon name="moon" size="sm" color="mutedForeground" />
            ) : null}
            {unread ? (
              // Muted threads keep their count but drop the accent —
              // WhatsApp shows the muted badge in grey.
              <View
                className={cn(
                  "min-w-5 items-center rounded-full px-1.5 py-0.5",
                  item.muted ? "bg-[#E4E4EA]" : "bg-[#1B1B26]"
                )}
                accessibilityLabel={`${item.unreadCount} unread messages`}
              >
                <Text
                  className={cn(
                    "text-[10px] font-semibold",
                    item.muted ? "text-[#6B6B78]" : "text-white"
                  )}
                >
                  {item.unreadCount}
                </Text>
              </View>
            ) : null}
          </View>
        </View>
      </AnimatedPressable>
    </MountIn>
  );
}

/** First token for the rail label — falls back to the whole name when the
 *  split yields nothing (single names, group names, stray whitespace). */
const firstName = (name: string) => name.trim().split(/\s+/)[0] || name;

/** "Active now" rail — Messenger-style horizontal strip of online contacts.
 *  Unread conversations get an accent ring (stories-rail idiom: a ring means
 *  there's something unviewed inside). */
function OnlineRail({
  contacts,
  onPress,
}: {
  contacts: Conversation[];
  onPress?: (c: Conversation) => void;
}) {
  const haptics = useHaptic();
  return (
    <View className="gap-2 pb-1">
      <Text
        variant="muted"
        className="px-6 text-xs font-semibold uppercase tracking-wider"
        accessibilityRole="header"
      >
        Active now
      </Text>
      <ScrollView
        horizontal
        className="grow-0"
        showsHorizontalScrollIndicator={false}
        contentContainerClassName="gap-4 px-6 pb-1"
      >
        {contacts.map((c, i) => (
          <MountIn key={c.id} index={i}>
            <AnimatedPressable
              onPress={() => {
                haptics.light();
                onPress?.(c);
              }}
              accessibilityRole="button"
              accessibilityLabel={`Open conversation with ${c.name}${c.unreadCount ? `, ${c.unreadCount} unread` : ""}`}
              className="w-14 items-center gap-1.5"
            >
              <View
                className={cn(
                  "rounded-full border-2 p-0.5",
                  c.unreadCount ? "border-[#1B1B26]" : "border-transparent"
                )}
              >
                <Avatar
                  size="lg"
                  source={c.avatarUrl ? { uri: c.avatarUrl } : undefined}
                  fallback={c.name}
                  status="online"
                  alt=""
                />
              </View>
              <Text variant="muted" numberOfLines={1} className="w-full text-center text-xs">
                {firstName(c.name)}
              </Text>
            </AnimatedPressable>
          </MountIn>
        ))}
      </ScrollView>
    </View>
  );
}

/** Chat list — searchable conversation rows with an online rail, pinned/muted
 *  affordances, live typing previews, unread badges, and a compose action. */
export function ChatListScreen({
  conversations = DEFAULT_CONVERSATIONS,
  onConversationPress,
  onSearch,
  onCompose,
}: ChatListScreenProps) {
  const insets = useSafeAreaInsets();
  const haptics = useHaptic();
  const [query, setQuery] = useState("");

  const clearSearch = () => {
    setQuery("");
    onSearch?.("");
  };

  const q = query.trim().toLowerCase();
  const searching = q.length > 0;
  const visible = searching
    ? conversations.filter(
        (c) =>
          c.name.toLowerCase().includes(q) || c.lastMessage.toLowerCase().includes(q)
      )
    : // Pinned first, stable — matches iMessage/WhatsApp ordering. Don't
      // re-sort while searching; match order is the ranking.
      [...conversations].sort((a, b) => Number(b.pinned ?? false) - Number(a.pinned ?? false));

  const onlineContacts = searching ? [] : conversations.filter((c) => c.online);

  return (
    <SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
      <View className="gap-3 px-6 pb-2 pt-4">
        <View className="flex-row items-center justify-between">
          <Text variant="h2" accessibilityRole="header">
          Messages
        </Text>
          <Button
            variant="ghost"
            size="icon"
            haptic="light"
            onPress={onCompose}
            accessibilityLabel="New message"
          >
            <Icon name="edit" size="md" color="foreground" />
          </Button>
        </View>
        <SearchInput
          placeholder="Search conversations…"
          value={query}
          onChangeText={(text) => {
            setQuery(text);
            onSearch?.(text);
          }}
          onClear={clearSearch}
          returnKeyType="search"
          accessibilityLabel="Search conversations"
          glass
        />
      </View>

      {/* Archived row — WhatsApp keeps the muted archive one tap away,
          above the conversation list. */}
      {!searching ? (
        <AnimatedPressable
          onPress={() => haptics.light()}
          accessibilityRole="button"
          accessibilityLabel="Archived chats, 12 conversations"
          className="flex-row items-center gap-3 px-6 py-2.5"
        >
          <Icon name="package" size="sm" color="mutedForeground" />
          <Text variant="small" className="flex-1 text-muted-foreground">
            Archived
          </Text>
          <Text variant="small" className="text-xs tabular-nums text-muted-foreground">
            12
          </Text>
        </AnimatedPressable>
      ) : null}

      <FlatList
        data={visible}
        keyExtractor={(item) => item.id}
        contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
        showsVerticalScrollIndicator={false}
        keyboardShouldPersistTaps="handled"
        ListHeaderComponent={
          // The rail scrolls away with content — it's a discovery surface,
          // not chrome that should eat the viewport while you scroll.
          onlineContacts.length > 0 ? (
            <View className="pb-2 pt-1">
              <OnlineRail contacts={onlineContacts} onPress={onConversationPress} />
            </View>
          ) : null
        }
        ListEmptyComponent={
          <EmptyState
            // "search" only when a query produced zero results — the
            // plain empty inbox is neutral, not a failed search.
            variant={searching ? "search" : "default"}
            title={searching ? "No results" : "No conversations"}
            description={
              searching
                ? `Nothing matches "${query.trim()}".`
                : "Your conversations will appear here."
            }
            action={
              searching ? (
                <Button variant="secondary" onPress={clearSearch}>
                  Clear search
                </Button>
              ) : undefined
            }
          />
        }
        ItemSeparatorComponent={() => (
          // Align with the text column: px-6 (1.5rem) + avatar w-14
          // (3.5rem) + gap-3 (0.75rem) = 5.75rem.
          <View className="ml-[5.75rem] h-px bg-border" accessible={false} />
        )}
        renderItem={({ item, index }) => (
          <ConversationRow item={item} index={index} onPress={onConversationPress} />
        )}
      />
    </SafeAreaView>
  );
}
agent prompt — paste into Claude Code, Cursor, Devin…
Recreate the "Chat List Screen" screen in React Native using LumioUI.

Goal: Searchable conversation rows with an active-now rail, presence dots, pinned/muted affordances, typing + draft states, unread badges, and a compose action Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.

## Setup

Fastest path — install the screen plus its component deps:

```bash
npx lumioui add chat-chat-list
```

Or pull just the components: `npx lumioui add avatar badge button empty-state icon search-input text`

Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*

Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.

## Components — how, where, when

### `avatar` — Circular user image with fallback initials and status dot.
- `<Avatar>` ×2

### `badge` — Compact status pill with variants, icons and pulse option.
- imported but not rendered directly — likely used through a sibling or as a utility

### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×2

### `empty-state` — Illustrated placeholder with title, copy and action slot.
- `<EmptyState>` ×1

### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×4 — No pin glyph exists in the icon set — a filled star is the closest "kept at the top" affordance (starred conversations).

### `search-input` — Search field with clear button, debounce, focus halo — plus a glass capsule mode that floats over content like iOS 26
- `<SearchInput>` ×1

### `text` — Themed type scale from caption to display.
- `<Text>` ×11

## Conventions (non-negotiable)

- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.

## Deliverable

One file: `screens/chat/chat-list.tsx` exporting ChatListScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.

## Reference implementation

The LumioUI source for this screen — reproduce it faithfully:

```tsx
import * as React from "react";
import { useState } from "react";
import { View, FlatList, ScrollView } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import Animated, {
  Easing,
  useSharedValue,
  useAnimatedStyle,
  withDelay,
  withRepeat,
  withTiming,
} from "react-native-reanimated";
import { Avatar, Button, EmptyState, Icon, SearchInput, Text } from "@lumioui/ui";
import { AnimatedPressable, cn, ease, timing, useHaptic, usePressAnimation, useReducedMotion, } from "@lumioui/core";

export interface Conversation {
  id: string;
  name: string;
  lastMessage: string;
  time: string;
  unreadCount?: number;
  online?: boolean;
  /** Pinned conversations float to the top (iMessage / WhatsApp idiom). */
  pinned?: boolean;
  /** Muted conversations keep their count but lose the accent badge. */
  muted?: boolean;
  /** Peer is typing right now — replaces the preview line. */
  typing?: boolean;
  /** Unsent draft — the preview line switches to a "Draft:" prefix. */
  draft?: string;
  avatarUrl?: string;
}

export interface ChatListScreenProps {
  conversations?: Conversation[];
  onConversationPress?: (conversation: Conversation) => void;
  onSearch?: (query: string) => void;
  /** Compose affordance in the header (iMessage's new-message button). */
  onCompose?: () => void;
}

const DEFAULT_CONVERSATIONS: Conversation[] = [
  { id: "1", name: "Maya Chen", lastMessage: "Sending the mockups now", time: "2m", unreadCount: 2, online: true, pinned: true, typing: true, avatarUrl: "https://i.pravatar.cc/150?img=47" },
  { id: "2", name: "Design Team", lastMessage: "Priya: New icons are in Figma", time: "26m", unreadCount: 5, pinned: true },
  { id: "3", name: "Sakura Ramen", lastMessage: "Order #2418 is out for delivery", time: "41m", unreadCount: 1, avatarUrl: "https://images.unsplash.com/photo-1569718212165-3a8278d5f624?w=150&h=150&fit=crop&q=70&auto=format" },
  { id: "4", name: "Tom Reed", lastMessage: "LGTM — ship it!", time: "1h", online: true, avatarUrl: "https://i.pravatar.cc/150?img=12" },
  { id: "5", name: "Diego Ruiz", lastMessage: "", draft: "See you at the trailhead at 7?", time: "3h", online: true, avatarUrl: "https://i.pravatar.cc/150?img=53" },
  { id: "6", name: "Priya Nair", lastMessage: "Thanks for the review!", time: "1d", online: true, avatarUrl: "https://i.pravatar.cc/150?img=32" },
  { id: "7", name: "Amara Chen", lastMessage: "The investor deck is ready when you are", time: "1d", avatarUrl: "https://i.pravatar.cc/150?img=9" },
  { id: "8", name: "Run Club", lastMessage: "Amara: Who's pacing the Saturday loop?", time: "2d", muted: true, unreadCount: 8 },
  { id: "9", name: "Jonah Reyes", lastMessage: "You: Sounds good, see you then", time: "4d", avatarUrl: "https://i.pravatar.cc/150?img=11" },
];

/** Staggered mount — rows rise and fade in sequence. Ambient stagger step is
 *  choreography data (like EmptyState's 90ms cadence), capped so rows that
 *  mount lazily on scroll never wait on a deep index. */
const MOUNT_STAGGER = 45;
const MOUNT_MAX_DELAY = 320;
const MOUNT_TIMING = { duration: timing.normal.duration, easing: Easing.bezier(...ease.out) };

/** Fade + small rise on mount, delayed by index. Reduced motion: fade only. */
function MountIn({
  index,
  className,
  children,
}: {
  index: number;
  className?: string;
  children: React.ReactNode;
}) {
  const reduce = useReducedMotion();
  const t = useSharedValue(reduce ? 1 : 0);
  React.useEffect(() => {
    if (reduce) {
      t.value = 1;
      return;
    }
    t.value = withDelay(
      Math.min(index * MOUNT_STAGGER, MOUNT_MAX_DELAY),
      withTiming(1, MOUNT_TIMING)
    );
  }, [index, reduce, t]);
  const style = useAnimatedStyle(() => ({
    opacity: t.value,
    transform: [{ translateY: (1 - t.value) * 10 }],
  }));
  return (
    <Animated.View style={style} className={className}>
      {children}
    </Animated.View>
  );
}

/** Ambient pulse on the "typing…" preview — a slow breathing loop (fixed
 *  period, like the typing dots in the thread) so the row reads alive. */
const TYPING_PULSE = { duration: 900 };

function TypingPreview() {
  const reduce = useReducedMotion();
  const v = useSharedValue(1);
  React.useEffect(() => {
    if (reduce) return;
    v.value = withRepeat(withTiming(0.45, TYPING_PULSE), -1, true);
  }, [v, reduce]);
  const style = useAnimatedStyle(() => ({ opacity: v.value }));
  return (
    <Animated.Text
      numberOfLines={1}
      style={style}
      className="flex-1 text-sm font-medium text-[#3E9AA3]"
    >
      typing…
    </Animated.Text>
  );
}

function ConversationRow({
  item,
  index,
  onPress,
}: {
  item: Conversation;
  index: number;
  onPress?: (c: Conversation) => void;
}) {
  const haptics = useHaptic();
  // Rows dip like iOS cells — fast timing keeps the feedback under a scroll.
  const press = usePressAnimation({ pressedScale: 0.98, pressedOpacity: 0.75, fast: true });
  const unread = !!item.unreadCount && item.unreadCount > 0;

  const stateBits = [
    item.pinned ? "pinned" : null,
    item.muted ? "muted" : null,
    item.online ? "online" : null,
    item.typing ? "typing" : null,
    unread ? `${item.unreadCount} unread` : null,
  ].filter(Boolean);

  return (
    <MountIn index={index}>
      <AnimatedPressable
        onPress={() => {
          haptics.light();
          onPress?.(item);
        }}
        onPressIn={press.onPressIn}
        onPressOut={press.onPressOut}
        style={press.animatedStyle}
        accessibilityRole="button"
        accessibilityLabel={`${item.name}, ${item.typing ? "typing" : item.draft ? `draft: ${item.draft}` : item.lastMessage}${stateBits.length ? `, ${stateBits.join(", ")}` : ""}`}
        className="flex-row items-center gap-3 px-6 py-3.5"
      >
        <Avatar
          size="lg"
          source={item.avatarUrl ? { uri: item.avatarUrl } : undefined}
          fallback={item.name}
          status={item.online ? "online" : undefined}
          alt={`${item.name}'s avatar`}
        />
        <View className="flex-1 gap-0.5">
          <View className="flex-row items-center justify-between">
            <Text
              numberOfLines={1}
              className={cn(
                "flex-1 pr-2 text-sm",
                unread ? "font-semibold" : "font-medium"
              )}
            >
              {item.name}
            </Text>
            <Text
              numberOfLines={1}
              className={cn(
                "shrink-0 text-xs tabular-nums",
                unread && !item.muted
                  ? "font-semibold text-foreground"
                  : "text-muted-foreground"
              )}
            >
              {item.time}
            </Text>
          </View>
          <View className="flex-row items-center gap-1.5">
            {item.pinned ? (
              // No pin glyph exists in the icon set — a filled star is the
              // closest "kept at the top" affordance (starred conversations).
              <Icon name="star" size="sm" color="mutedForeground" filled />
            ) : null}
            {item.typing ? (
              <TypingPreview />
            ) : item.draft ? (
              // iMessage idiom — an unsent draft flags itself in red ahead
              // of the grey preview text.
              <Text variant="muted" numberOfLines={1} className="flex-1">
                <Text className="text-sm text-destructive">Draft: </Text>
                {item.draft}
              </Text>
            ) : (
              <Text
                variant="muted"
                numberOfLines={1}
                className={cn("flex-1", unread && "font-medium text-foreground")}
              >
                {item.lastMessage}
              </Text>
            )}
            {item.muted ? (
              // Crescent moon = iOS "Hide Alerts" idiom for a muted thread.
              <Icon name="moon" size="sm" color="mutedForeground" />
            ) : null}
            {unread ? (
              // Muted threads keep their count but drop the accent —
              // WhatsApp shows the muted badge in grey.
              <View
                className={cn(
                  "min-w-5 items-center rounded-full px-1.5 py-0.5",
                  item.muted ? "bg-[#E4E4EA]" : "bg-[#1B1B26]"
                )}
                accessibilityLabel={`${item.unreadCount} unread messages`}
              >
                <Text
                  className={cn(
                    "text-[10px] font-semibold",
                    item.muted ? "text-[#6B6B78]" : "text-white"
                  )}
                >
                  {item.unreadCount}
                </Text>
              </View>
            ) : null}
          </View>
        </View>
      </AnimatedPressable>
    </MountIn>
  );
}

/** First token for the rail label — falls back to the whole name when the
 *  split yields nothing (single names, group names, stray whitespace). */
const firstName = (name: string) => name.trim().split(/\s+/)[0] || name;

/** "Active now" rail — Messenger-style horizontal strip of online contacts.
 *  Unread conversations get an accent ring (stories-rail idiom: a ring means
 *  there's something unviewed inside). */
function OnlineRail({
  contacts,
  onPress,
}: {
  contacts: Conversation[];
  onPress?: (c: Conversation) => void;
}) {
  const haptics = useHaptic();
  return (
    <View className="gap-2 pb-1">
      <Text
        variant="muted"
        className="px-6 text-xs font-semibold uppercase tracking-wider"
        accessibilityRole="header"
      >
        Active now
      </Text>
      <ScrollView
        horizontal
        className="grow-0"
        showsHorizontalScrollIndicator={false}
        contentContainerClassName="gap-4 px-6 pb-1"
      >
        {contacts.map((c, i) => (
          <MountIn key={c.id} index={i}>
            <AnimatedPressable
              onPress={() => {
                haptics.light();
                onPress?.(c);
              }}
              accessibilityRole="button"
              accessibilityLabel={`Open conversation with ${c.name}${c.unreadCount ? `, ${c.unreadCount} unread` : ""}`}
              className="w-14 items-center gap-1.5"
            >
              <View
                className={cn(
                  "rounded-full border-2 p-0.5",
                  c.unreadCount ? "border-[#1B1B26]" : "border-transparent"
                )}
              >
                <Avatar
                  size="lg"
                  source={c.avatarUrl ? { uri: c.avatarUrl } : undefined}
                  fallback={c.name}
                  status="online"
                  alt=""
                />
              </View>
              <Text variant="muted" numberOfLines={1} className="w-full text-center text-xs">
                {firstName(c.name)}
              </Text>
            </AnimatedPressable>
          </MountIn>
        ))}
      </ScrollView>
    </View>
  );
}

/** Chat list — searchable conversation rows with an online rail, pinned/muted
 *  affordances, live typing previews, unread badges, and a compose action. */
export function ChatListScreen({
  conversations = DEFAULT_CONVERSATIONS,
  onConversationPress,
  onSearch,
  onCompose,
}: ChatListScreenProps) {
  const insets = useSafeAreaInsets();
  const haptics = useHaptic();
  const [query, setQuery] = useState("");

  const clearSearch = () => {
    setQuery("");
    onSearch?.("");
  };

  const q = query.trim().toLowerCase();
  const searching = q.length > 0;
  const visible = searching
    ? conversations.filter(
        (c) =>
          c.name.toLowerCase().includes(q) || c.lastMessage.toLowerCase().includes(q)
      )
    : // Pinned first, stable — matches iMessage/WhatsApp ordering. Don't
      // re-sort while searching; match order is the ranking.
      [...conversations].sort((a, b) => Number(b.pinned ?? false) - Number(a.pinned ?? false));

  const onlineContacts = searching ? [] : conversations.filter((c) => c.online);

  return (
    <SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
      <View className="gap-3 px-6 pb-2 pt-4">
        <View className="flex-row items-center justify-between">
          <Text variant="h2" accessibilityRole="header">
          Messages
        </Text>
          <Button
            variant="ghost"
            size="icon"
            haptic="light"
            onPress={onCompose}
            accessibilityLabel="New message"
          >
            <Icon name="edit" size="md" color="foreground" />
          </Button>
        </View>
        <SearchInput
          placeholder="Search conversations…"
          value={query}
          onChangeText={(text) => {
            setQuery(text);
            onSearch?.(text);
          }}
          onClear={clearSearch}
          returnKeyType="search"
          accessibilityLabel="Search conversations"
          glass
        />
      </View>

      {/* Archived row — WhatsApp keeps the muted archive one tap away,
          above the conversation list. */}
      {!searching ? (
        <AnimatedPressable
          onPress={() => haptics.light()}
          accessibilityRole="button"
          accessibilityLabel="Archived chats, 12 conversations"
          className="flex-row items-center gap-3 px-6 py-2.5"
        >
          <Icon name="package" size="sm" color="mutedForeground" />
          <Text variant="small" className="flex-1 text-muted-foreground">
            Archived
          </Text>
          <Text variant="small" className="text-xs tabular-nums text-muted-foreground">
            12
          </Text>
        </AnimatedPressable>
      ) : null}

      <FlatList
        data={visible}
        keyExtractor={(item) => item.id}
        contentContainerStyle={{ paddingBottom: insets.bottom + 16 }}
        showsVerticalScrollIndicator={false}
        keyboardShouldPersistTaps="handled"
        ListHeaderComponent={
          // The rail scrolls away with content — it's a discovery surface,
          // not chrome that should eat the viewport while you scroll.
          onlineContacts.length > 0 ? (
            <View className="pb-2 pt-1">
              <OnlineRail contacts={onlineContacts} onPress={onConversationPress} />
            </View>
          ) : null
        }
        ListEmptyComponent={
          <EmptyState
            // "search" only when a query produced zero results — the
            // plain empty inbox is neutral, not a failed search.
            variant={searching ? "search" : "default"}
            title={searching ? "No results" : "No conversations"}
            description={
              searching
                ? `Nothing matches "${query.trim()}".`
                : "Your conversations will appear here."
            }
            action={
              searching ? (
                <Button variant="secondary" onPress={clearSearch}>
                  Clear search
                </Button>
              ) : undefined
            }
          />
        }
        ItemSeparatorComponent={() => (
          // Align with the text column: px-6 (1.5rem) + avatar w-14
          // (3.5rem) + gap-3 (0.75rem) = 5.75rem.
          <View className="ml-[5.75rem] h-px bg-border" accessible={false} />
        )}
        renderItem={({ item, index }) => (
          <ConversationRow item={item} index={index} onPress={onConversationPress} />
        )}
      />
    </SafeAreaView>
  );
}
```

Screen: https://lumioui.dev/screens/chat-chat-list
Pixel 10 · Android
9:41
Live preview — the real screen via Expo Web · open full ↗