App.tsx
import { useEffect, useRef, useState } from 'react';
import { Platform, StatusBar, useWindowDimensions, type ScrollView } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import {
KeyboardAvoidingArea,
SafeArea,
ScrollArea,
Block,
Avatar,
Button,
Column,
Flex,
Icon,
IconButton,
Input,
PlocksProvider,
Row,
SegmentedControl,
Tabs,
Text,
Title,
} from '@plocks/ui';
import { RECENT_CALLS, SEED_CHATS, STATUSES, timeNow, type Chat, type Message } from './data';
const COLORS = {
green: '#00A884',
darkGreen: '#075E54',
brightGreen: '#25D366',
ink: '#111B21',
muted: '#667781',
faint: '#8696A0',
line: '#E9EDEF',
chrome: '#F0F2F5',
white: '#FFFFFF',
chat: '#F1EEE8',
outgoing: '#D9FDD3',
};
const STORAGE_KEY = 'plocks-chatline-app:chats';
const THEME = { colorScheme: 'light' as const };
type Tab = 'chats' | 'updates' | 'calls';
type Filter = 'all' | 'unread' | 'groups';
type Detail = { kind: 'chat' | 'status' | 'call'; id: string } | null;
function isSavedChats(value: unknown): value is Chat[] {
return (
Array.isArray(value) &&
value.length === SEED_CHATS.length &&
value.every(
(chat) =>
chat &&
typeof chat === 'object' &&
typeof chat.id === 'string' &&
typeof chat.name === 'string' &&
typeof chat.initials === 'string' &&
typeof chat.color === 'string' &&
typeof chat.unread === 'number' &&
Array.isArray(chat.messages) &&
chat.messages.every(
(message: Message) =>
message &&
typeof message.id === 'string' &&
typeof message.text === 'string' &&
typeof message.time === 'string' &&
typeof message.outgoing === 'boolean',
),
)
);
}
function ChatAvatar({ chat, size = 'lg' }: { chat: Chat; size?: 'md' | 'lg' | 'xl' | '2xl' | '3xl' }) {
return (
<Avatar
size={size}
fallback={chat.initials}
bg={chat.color}
textColor={COLORS.white}
accessibilityLabel={`${chat.name} avatar`}
/>
);
}
function ChatRow({ chat, active, onPress }: { chat: Chat; active: boolean; onPress: () => void }) {
const last = chat.messages[chat.messages.length - 1];
return (
<Block
gap={0}
onPress={onPress}
accessibilityRole="button"
accessibilityLabel={`Open chat with ${chat.name}`}
bg={active ? '#F0F4F2' : COLORS.white}
px={18}
py={13}
>
<Row align="center" gap="md" fullWidth>
<ChatAvatar chat={chat} size="lg" />
<Block gap={4} direction="column" flex={1} miw={0}>
<Row align="center" justify="space-between" fullWidth gap="sm">
<Text c={COLORS.ink} fw="semibold" size={16} numberOfLines={1} flex={1}>
{chat.name}
</Text>
<Text c={chat.unread ? COLORS.green : COLORS.faint} size={11}>
{last?.time ?? ''}
</Text>
</Row>
<Row align="center" justify="space-between" fullWidth gap="sm">
<Text c={COLORS.muted} size={13} numberOfLines={1} flex={1}>
{last?.outgoing ? 'You: ' : ''}
{last?.text ?? 'Start a conversation'}
</Text>
{chat.unread > 0 && (
<Block
align="center"
justify="center"
direction="row"
miw={21}
h={21}
px={5}
radius={11}
bg={COLORS.brightGreen}
>
<Text c={COLORS.white} fw="bold" size={11}>
{chat.unread}
</Text>
</Block>
)}
</Row>
</Block>
</Row>
</Block>
);
}
function MessageBubble({ message }: { message: Message }) {
return (
<Flex fullWidth justify={message.outgoing ? 'flex-end' : 'flex-start'}>
<Block
gap={3}
direction="column"
maw="78%"
miw={96}
px={12}
py={8}
radius={12}
bg={message.outgoing ? COLORS.outgoing : COLORS.white}
borderTopRightRadius={message.outgoing ? 3 : 12}
borderTopLeftRadius={message.outgoing ? 12 : 3}
>
<Text c={COLORS.ink} size={14} lh={21}>
{message.text}
</Text>
<Row align="center" justify="flex-end" gap={4} fullWidth>
<Text c={COLORS.muted} size={10}>
{message.time}
</Text>
{message.outgoing && (
<Text c="#53A8C1" size={11}>
✓✓
</Text>
)}
</Row>
</Block>
</Flex>
);
}
function ChatlineScreen() {
const { width } = useWindowDimensions();
const desktop = width >= 820;
const messageScroll = useRef<ScrollView>(null);
const [chats, setChats] = useState<Chat[]>(SEED_CHATS);
const [ready, setReady] = useState(false);
const [tab, setTab] = useState<Tab>('chats');
const [filter, setFilter] = useState<Filter>('all');
const [query, setQuery] = useState('');
const [draft, setDraft] = useState('');
const [detail, setDetail] = useState<Detail>(null);
const [newChat, setNewChat] = useState(false);
useEffect(() => {
AsyncStorage.getItem(STORAGE_KEY)
.then((saved) => {
if (!saved) return;
const parsed: unknown = JSON.parse(saved);
if (isSavedChats(parsed)) setChats(parsed);
})
.catch(() => {})
.finally(() => setReady(true));
}, []);
useEffect(() => {
if (ready) AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(chats)).catch(() => {});
}, [chats, ready]);
const selected =
detail ?? (desktop && tab === 'chats' && !newChat ? { kind: 'chat' as const, id: chats[0].id } : null);
const activeChat = selected?.kind === 'chat' ? chats.find((chat) => chat.id === selected.id) : null;
const unreadTotal = chats.reduce((total, chat) => total + chat.unread, 0);
const shownChats = chats.filter((chat) => {
const matches = `${chat.name} ${chat.messages[chat.messages.length - 1]?.text ?? ''}`
.toLowerCase()
.includes(query.trim().toLowerCase());
return (
matches && (filter === 'all' || (filter === 'unread' && chat.unread > 0) || (filter === 'groups' && chat.isGroup))
);
});
function openChat(id: string) {
setChats((current) => current.map((chat) => (chat.id === id ? { ...chat, unread: 0 } : chat)));
setDetail({ kind: 'chat', id });
setNewChat(false);
setDraft('');
}
function sendMessage() {
const text = draft.trim();
if (!text || !activeChat) return;
const message: Message = { id: `local-${Date.now()}`, text, time: timeNow(), outgoing: true };
setChats((current) => {
const updated = current.find((chat) => chat.id === activeChat.id);
if (!updated) return current;
return [
{ ...updated, unread: 0, messages: [...updated.messages, message] },
...current.filter((chat) => chat.id !== updated.id),
];
});
setDetail({ kind: 'chat', id: activeChat.id });
setDraft('');
}
function selectTab(next: Tab) {
setTab(next);
setDetail(null);
setNewChat(false);
setQuery('');
}
function renderSidebarContent() {
if (newChat) {
return (
<>
<Block align="center" gap="sm" direction="row" px={18} py={18}>
<IconButton
icon="arrow-left"
variant="ghost"
iconColor={COLORS.ink}
accessibilityLabel="Back to chats"
onPress={() => setNewChat(false)}
/>
<Title order={2} c={COLORS.ink} size={22}>
New chat
</Title>
</Block>
<Text c={COLORS.muted} size={12} fw="bold" px={20} pb={10}>
CONTACTS
</Text>
{chats
.filter((chat) => !chat.isGroup)
.map((chat) => (
<ChatRow key={chat.id} chat={chat} active={false} onPress={() => openChat(chat.id)} />
))}
</>
);
}
if (tab === 'updates') {
return (
<Block gap="md" direction="column" p={18}>
<Text c={COLORS.ink} fw="bold" size={18}>
Status
</Text>
<Block align="center" gap="md" direction="row" py={8}>
<Block align="center" justify="center" direction="row" w={48} h={48} radius={24} bg="#DDECE7">
<Icon name="plus" color={COLORS.green} size={23} />
</Block>
<Column gap={2}>
<Text c={COLORS.ink} fw="semibold">
My status
</Text>
<Text c={COLORS.muted} size={12}>
Share a moment with friends
</Text>
</Column>
</Block>
<Text c={COLORS.muted} fw="bold" size={12}>
RECENT UPDATES
</Text>
{STATUSES.map((status) => {
const chat = chats.find((item) => item.id === status.chatId)!;
return (
<Block
gap={0}
key={status.id}
onPress={() => setDetail({ kind: 'status', id: status.id })}
accessibilityRole="button"
accessibilityLabel={`View ${chat.name}'s status`}
>
<Block align="center" gap="md" direction="row" py={9}>
<Block direction="row" p={3} borderWidth={2} radius={32} borderColor={COLORS.green}>
<ChatAvatar chat={chat} size="lg" />
</Block>
<Column gap={2}>
<Text c={COLORS.ink} fw="semibold">
{chat.name}
</Text>
<Text c={COLORS.muted} size={12}>
{status.time}
</Text>
</Column>
</Block>
</Block>
);
})}
</Block>
);
}
if (tab === 'calls') {
return (
<Block gap="sm" direction="column" p={18}>
<Text c={COLORS.ink} fw="bold" size={18}>
Recent calls
</Text>
<Text c={COLORS.muted} size={12} mb={8}>
Keep the conversation going.
</Text>
{RECENT_CALLS.map((call) => {
const chat = chats.find((item) => item.id === call.chatId)!;
return (
<Block
gap={0}
key={call.id}
onPress={() => setDetail({ kind: 'call', id: call.id })}
accessibilityRole="button"
accessibilityLabel={`View ${chat.name} call`}
>
<Block align="center" gap="md" direction="row" py={11}>
<ChatAvatar chat={chat} size="lg" />
<Block gap={2} direction="column" flex={1}>
<Text c={call.missed ? '#C74747' : COLORS.ink} fw="semibold">
{chat.name}
</Text>
<Text c={COLORS.muted} size={12}>
{call.missed ? 'Missed' : 'Outgoing'} · {call.time}
</Text>
</Block>
<Icon name={call.type === 'video' ? 'camera' : 'phone'} size={19} color={COLORS.green} />
</Block>
</Block>
);
})}
</Block>
);
}
return (
<>
<Block direction="row" px={18} pt={15} pb={10}>
<Input
value={query}
onChangeText={setQuery}
placeholder="Search or start a new chat"
startSection={<Icon name="search" size={18} color={COLORS.muted} />}
variant="filled"
accessibilityLabel="Search chats"
/>
</Block>
<SegmentedControl
data={['all', 'unread', 'groups'].map((item) => ({
value: item,
label: item[0].toUpperCase() + item.slice(1),
}))}
value={filter}
onChange={(item) => setFilter(item as Filter)}
color={COLORS.green}
size="sm"
mx={18}
mb={10}
accessibilityLabel="Filter chats"
/>
{shownChats.length ? (
shownChats.map((chat) => (
<ChatRow
key={chat.id}
chat={chat}
active={desktop && selected?.kind === 'chat' && selected.id === chat.id}
onPress={() => openChat(chat.id)}
/>
))
) : (
<Block align="center" gap="xs" direction="column" p={30}>
<Icon name="search" size={26} color={COLORS.faint} />
<Text c={COLORS.muted} ta="center">
No chats found. Try another search or filter.
</Text>
</Block>
)}
</>
);
}
function renderDetail() {
if (selected?.kind === 'status') {
const status = STATUSES.find((item) => item.id === selected.id)!;
const chat = chats.find((item) => item.id === status.chatId)!;
return (
<Block direction="column" flex={1} bg={status.color}>
<Block align="center" gap="sm" direction="row" p={16}>
<IconButton
icon="arrow-left"
variant="ghost"
iconColor={COLORS.ink}
accessibilityLabel="Close status"
onPress={() => setDetail(null)}
/>
<ChatAvatar chat={chat} size="md" />
<Column gap={0}>
<Text c={COLORS.ink} fw="bold">
{chat.name}
</Text>
<Text c={COLORS.ink} size={11}>
{status.time}
</Text>
</Column>
</Block>
<Block align="center" justify="center" direction="row" flex={1} p={32}>
<Text c={COLORS.ink} fw="bold" size={30} ta="center" lh={42}>
{status.caption}
</Text>
</Block>
<Button
title={`Message ${chat.name}`}
variant="filled"
color={COLORS.darkGreen}
m={24}
onPress={() => {
setTab('chats');
openChat(chat.id);
}}
/>
</Block>
);
}
if (selected?.kind === 'call') {
const call = RECENT_CALLS.find((item) => item.id === selected.id);
const chat = chats.find((item) => item.id === (call?.chatId ?? selected.id.replace('new:', '')))!;
return (
<Block align="center" justify="center" gap="lg" direction="column" flex={1} bg={COLORS.chrome} p={24}>
<ChatAvatar chat={chat} size="3xl" />
<Title order={2} c={COLORS.ink}>
{chat.name}
</Title>
<Text c={COLORS.muted}>
{call?.type === 'video' ? 'Video' : 'Voice'} call · {call?.time ?? 'Now'}
</Text>
<Text c={COLORS.muted} size={13} ta="center">
Calls are a preview in this example app.
</Text>
<Row gap="sm">
<Button title="Back to calls" variant="outline" color={COLORS.darkGreen} onPress={() => setDetail(null)} />
<Button
title="Open chat"
variant="filled"
color={COLORS.darkGreen}
onPress={() => {
setTab('chats');
openChat(chat.id);
}}
/>
</Row>
</Block>
);
}
if (!activeChat) {
return (
<Block align="center" justify="center" gap="md" direction="column" flex={1} bg={COLORS.chrome} p={36}>
<Block align="center" justify="center" direction="row" w={86} h={86} radius={43} bg="#DDECE7">
<Icon name="chat" size={40} color={COLORS.green} />
</Block>
<Title order={2} c={COLORS.ink}>
A good conversation starts here.
</Title>
<Text c={COLORS.muted} ta="center">
Pick a chat to catch up with someone.
</Text>
</Block>
);
}
return (
<KeyboardAvoidingArea behavior={Platform.OS === 'ios' ? 'padding' : undefined} flex={1} bg={COLORS.chat} gap={0}>
<Block
align="center"
gap="sm"
direction="row"
px={16}
py={12}
bg={COLORS.chrome}
borderBottomWidth={1}
borderBottomColor={COLORS.line}
>
{!desktop && (
<IconButton
icon="arrow-left"
variant="ghost"
iconColor={COLORS.ink}
accessibilityLabel="Back to chats"
onPress={() => setDetail(null)}
/>
)}
<ChatAvatar chat={activeChat} size="md" />
<Block gap={1} direction="column" flex={1} miw={0}>
<Text c={COLORS.ink} fw="bold" size={16} numberOfLines={1}>
{activeChat.name}
</Text>
<Text c={COLORS.muted} size={11}>
{activeChat.isGroup ? 'Group chat' : activeChat.online ? 'online' : 'tap here for contact info'}
</Text>
</Block>
<IconButton
icon="phone"
variant="ghost"
iconColor={COLORS.darkGreen}
accessibilityLabel={`Voice call ${activeChat.name}`}
onPress={() => setDetail({ kind: 'call', id: `new:${activeChat.id}` })}
/>
</Block>
<ScrollArea
ref={messageScroll}
flex={1}
contentProps={{ px: 22, py: 24, gap: 11, grow: 1, justify: 'flex-end' }}
onContentSizeChange={() => messageScroll.current?.scrollToEnd({ animated: false })}
>
<Block
align="center"
justify="center"
direction="row"
alignSelf="center"
bg="#E2F3FA"
px={12}
py={5}
radius={8}
mb={8}
>
<Text c={COLORS.muted} size={11}>
TODAY
</Text>
</Block>
{activeChat.messages.map((message) => (
<MessageBubble key={message.id} message={message} />
))}
</ScrollArea>
<Block
align="center"
gap="sm"
direction="row"
px={14}
py={12}
bg={COLORS.chrome}
borderTopWidth={1}
borderTopColor={COLORS.line}
>
<Icon name="emoji" color={COLORS.muted} size={24} />
<Block direction="row" flex={1}>
<Input
value={draft}
onChangeText={setDraft}
onEnter={sendMessage}
placeholder="Type a message"
variant="filled"
radius="full"
accessibilityLabel="Type a message"
/>
</Block>
<IconButton
icon="arrow-right"
variant="filled"
color={COLORS.green}
radius="full"
accessibilityLabel="Send message"
disabled={!draft.trim()}
onPress={sendMessage}
/>
</Block>
</KeyboardAvoidingArea>
);
}
return (
<SafeArea gap={0} flex={1} bg={COLORS.chrome}>
<StatusBar barStyle="dark-content" />
<Block
align="stretch"
direction="row"
flex={1}
w="100%"
maw={1440}
alignSelf="center"
bg={COLORS.white}
borderLeftWidth={desktop ? 1 : 0}
borderRightWidth={desktop ? 1 : 0}
borderColor={COLORS.line}
>
{(desktop || !detail) && (
<Block
align="stretch"
gap={0}
direction="column"
w={desktop ? 390 : '100%'}
shrink={0}
bg={COLORS.white}
borderRightWidth={desktop ? 1 : 0}
borderRightColor={COLORS.line}
>
<Block align="center" justify="space-between" direction="row" px={18} py={17} bg={COLORS.chrome}>
<Row align="center" gap="sm">
<Block align="center" justify="center" direction="row" w={31} h={31} radius={11} bg={COLORS.green}>
<Icon name="chat" variant="filled" size={19} color={COLORS.white} />
</Block>
<Text c={COLORS.darkGreen} fw="bold" size={22}>
chatline
</Text>
</Row>
<IconButton
icon="edit"
variant="ghost"
iconColor={COLORS.ink}
accessibilityLabel="New chat"
onPress={() => {
setTab('chats');
setNewChat(true);
setDetail(null);
}}
/>
</Block>
{!newChat && (
<Block align="center" justify="space-between" direction="row" px={18} pt={16} pb={8}>
<Text c={COLORS.ink} fw="bold" size={29} shrink={1}>
{tab === 'chats' ? 'Chats' : tab === 'updates' ? 'Updates' : 'Calls'}
</Text>
{tab === 'chats' && (
<Text c={COLORS.green} fw="bold" size={12}>
{unreadTotal ? `${unreadTotal} unread` : 'All caught up'}
</Text>
)}
</Block>
)}
<ScrollArea flex={1} keyboardShouldPersistTaps="handled">
{renderSidebarContent()}
</ScrollArea>
<Tabs
navigationOnly
value={tab}
onChange={(item) => selectTab(item as Tab)}
color={COLORS.darkGreen}
tabStyle={{ flex: 1 }}
items={([
['chats', 'Chats', 'chat'],
['updates', 'Updates', 'circle'],
['calls', 'Calls', 'phone'],
] as const).map(([key, label, icon]) => ({
key,
label,
subLabel: key === 'chats' && unreadTotal ? String(unreadTotal) : undefined,
icon: <Icon name={icon} size={20} color={tab === key ? COLORS.darkGreen : COLORS.muted} />,
content: null,
}))}
/>
</Block>
)}
{(desktop || detail) && (
<Block align="stretch" direction="row" flex={1} miw={0}>
{renderDetail()}
</Block>
)}
</Block>
</SafeArea>
);
}
export default function App() {
return (
<SafeAreaProvider>
<PlocksProvider theme={THEME}>
<ChatlineScreen />
</PlocksProvider>
</SafeAreaProvider>
);
}
data.ts
export type Message = {
id: string;
text: string;
time: string;
outgoing: boolean;
};
export type Chat = {
id: string;
name: string;
initials: string;
color: string;
isGroup?: boolean;
online?: boolean;
unread: number;
messages: Message[];
};
export const SEED_CHATS: Chat[] = [
{
id: 'maria', name: 'Maria Chen', initials: 'MC', color: '#D7936C', online: true, unread: 2,
messages: [
{ id: 'm1', text: 'Hey! Are we still on for coffee this afternoon?', time: '10:31 AM', outgoing: false },
{ id: 'm2', text: 'Absolutely. I found a little place near the park ☕', time: '10:34 AM', outgoing: true },
{ id: 'm3', text: 'Perfect, I can be there around 3.', time: '10:42 AM', outgoing: false },
{ id: 'm4', text: 'I will send you the address in a minute!', time: '10:42 AM', outgoing: false },
],
},
{
id: 'design', name: 'Design Team', initials: 'DT', color: '#806DB9', isGroup: true, unread: 3,
messages: [
{ id: 'd1', text: 'Morning team! The new concepts are ready to review.', time: '9:12 AM', outgoing: false },
{ id: 'd2', text: 'These look great. The colors are really coming together.', time: '9:18 AM', outgoing: true },
{ id: 'd3', text: 'I added my notes to the board.', time: '9:24 AM', outgoing: false },
],
},
{
id: 'dad', name: 'Dad', initials: 'D', color: '#729A87', unread: 0,
messages: [
{ id: 'f1', text: 'Just checked in. Everything is going well here!', time: 'Yesterday', outgoing: false },
{ id: 'f2', text: 'Glad to hear it. Talk this weekend?', time: 'Yesterday', outgoing: true },
{ id: 'f3', text: 'Sounds good ❤️', time: 'Yesterday', outgoing: false },
],
},
{
id: 'alex', name: 'Alex Rivera', initials: 'AR', color: '#6D9AB2', online: true, unread: 0,
messages: [
{ id: 'a1', text: 'I finished that playlist I told you about.', time: 'Yesterday', outgoing: false },
{ id: 'a2', text: 'Send it over! I need something new to listen to.', time: 'Yesterday', outgoing: true },
{ id: 'a3', text: 'You are going to love the last track 🎵', time: 'Yesterday', outgoing: false },
],
},
{
id: 'weekend', name: 'Weekend Plans', initials: 'WP', color: '#C38A80', isGroup: true, unread: 0,
messages: [
{ id: 'w1', text: 'Should we do a picnic on Saturday?', time: 'Monday', outgoing: false },
{ id: 'w2', text: 'I can bring snacks and a blanket.', time: 'Monday', outgoing: true },
{ id: 'w3', text: 'Saturday works for everyone! 🌿', time: 'Monday', outgoing: false },
],
},
{
id: 'priya', name: 'Priya Shah', initials: 'PS', color: '#B38EAB', unread: 0,
messages: [
{ id: 'p1', text: 'Thank you for the recommendation!', time: 'Monday', outgoing: false },
{ id: 'p2', text: 'Anytime. Let me know what you think.', time: 'Monday', outgoing: true },
],
},
];
export type Status = { id: string; chatId: string; caption: string; color: string; time: string };
export const STATUSES: Status[] = [
{ id: 's1', chatId: 'maria', caption: 'A little sunshine goes a long way ☀️', color: '#A6C7A6', time: 'Today, 9:30 AM' },
{ id: 's2', chatId: 'alex', caption: 'Found a new favorite corner of the city.', color: '#9FBBD0', time: 'Today, 8:14 AM' },
{ id: 's3', chatId: 'priya', caption: 'Taking the scenic route home 🌸', color: '#D7AEC7', time: 'Yesterday, 6:20 PM' },
];
export type RecentCall = { id: string; chatId: string; time: string; type: 'voice' | 'video'; missed?: boolean };
export const RECENT_CALLS: RecentCall[] = [
{ id: 'c1', chatId: 'maria', time: 'Today, 11:02 AM', type: 'voice' },
{ id: 'c2', chatId: 'dad', time: 'Yesterday, 7:45 PM', type: 'video', missed: true },
{ id: 'c3', chatId: 'alex', time: 'Monday, 4:18 PM', type: 'voice' },
];
export function timeNow(): string {
return new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
}