Searchable conversation rows with an active-now rail, presence dots, pinned/muted affordances, typing + draft states, unread badges, and a compose action
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>
);
}
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