App.tsx
import { useEffect, useState, type ReactNode } from 'react';
import { StatusBar, useWindowDimensions } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import {
TextArea,
AppShell,
Input,
ScrollArea,
SafeArea,
Block,
Avatar,
Button,
Dialog,
Icon,
IconButton,
PlocksProvider,
Text,
useAppShellApi,
} from '@plocks/ui';
function NavbarControls({ children }: { children: (actions: ReturnType<typeof useAppShellApi>) => ReactNode }) {
return <>{children(useAppShellApi())}</>;
}
const C = {
bg: '#F7F8FC',
white: '#FFF',
ink: '#202124',
muted: '#697279',
line: '#E6E9EF',
blue: '#0B57D0',
pale: '#DDE9FF',
red: '#EA4335',
};
type Folder = 'inbox' | 'starred' | 'sent' | 'drafts' | 'archive' | 'trash';
type Mail = {
id: string;
from: string;
to: string;
subject: string;
body: string;
time: string;
folder: Folder;
starred: boolean;
read: boolean;
};
const INITIAL: Mail[] = [
{
id: 'm1',
from: 'Maya Chen',
to: 'you@example.com',
subject: 'Coffee this weekend?',
body: 'Hey! I found a new little café near the park. Want to catch up on Saturday morning? I can bring that book I was telling you about.',
time: '10:42 AM',
folder: 'inbox',
starred: true,
read: false,
},
{
id: 'm2',
from: 'The Weekly Edit',
to: 'you@example.com',
subject: 'Five things worth your time this week',
body: 'Hello! This week we are sharing a thoughtful article, a beautiful photo essay, and a recipe for a very slow Sunday.',
time: '9:18 AM',
folder: 'inbox',
starred: false,
read: false,
},
{
id: 'm3',
from: 'Alex Rivera',
to: 'you@example.com',
subject: 'Re: project notes',
body: 'These notes look great. I added a couple of comments to the layout section. Let us review them together tomorrow.',
time: 'Yesterday',
folder: 'inbox',
starred: false,
read: true,
},
{
id: 'm4',
from: 'Studio North',
to: 'you@example.com',
subject: 'Your workshop is confirmed',
body: 'Thanks for signing up! We look forward to seeing you at the illustration workshop next Tuesday at 6 PM.',
time: 'Yesterday',
folder: 'inbox',
starred: true,
read: true,
},
{
id: 'm5',
from: 'Jordan Lee',
to: 'you@example.com',
subject: 'Photos from the coast',
body: 'Finally sorted the pictures from our weekend trip. The sunset ones came out especially well. I will send over a folder soon!',
time: 'Mon',
folder: 'inbox',
starred: false,
read: true,
},
{
id: 'm6',
from: 'you@example.com',
to: 'sam@example.com',
subject: 'Thanks for your help',
body: 'Hi Sam, thank you for walking me through the setup. Everything is working smoothly now.',
time: 'Sun',
folder: 'sent',
starred: false,
read: true,
},
{
id: 'm7',
from: 'Local Library',
to: 'you@example.com',
subject: 'A book is ready for pickup',
body: 'The title you requested is now available at the front desk. We will hold it for one week.',
time: 'Sat',
folder: 'archive',
starred: false,
read: true,
},
];
const STORAGE = 'plocks-postbox-example:v1';
const FOLDERS: { id: Folder; label: string; icon: string }[] = [
{ id: 'inbox', label: 'Inbox', icon: 'mail' },
{ id: 'starred', label: 'Starred', icon: 'star' },
{ id: 'sent', label: 'Sent', icon: 'arrowRight' },
{ id: 'drafts', label: 'Drafts', icon: 'edit' },
{ id: 'archive', label: 'Archive', icon: 'folder' },
{ id: 'trash', label: 'Trash', icon: 'trash' },
];
const avatarColor = (name: string) => ['#9874BD', '#6C9CA5', '#CA8C70', '#7DA371'][name.length % 4];
function PostboxScreen() {
const { width } = useWindowDimensions();
const wide = width >= 768;
const [folder, setFolder] = useState<Folder>('inbox');
const [messages, setMessages] = useState<Mail[]>(INITIAL);
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<string | null>(null);
const [compose, setCompose] = useState(false);
const [to, setTo] = useState('');
const [subject, setSubject] = useState('');
const [body, setBody] = useState('');
const [ready, setReady] = useState(false);
const active = messages.find((item) => item.id === selected);
const unread = messages.filter((item) => item.folder === 'inbox' && !item.read).length;
const shown = messages.filter(
(item) =>
(query.trim()
? item.folder !== 'trash'
: folder === 'starred'
? item.starred && item.folder !== 'trash'
: item.folder === folder) &&
`${item.from} ${item.to} ${item.subject} ${item.body}`.toLowerCase().includes(query.toLowerCase()),
);
useEffect(() => {
AsyncStorage.getItem(STORAGE)
.then((raw) => {
if (!raw) return;
const data = JSON.parse(raw) as Mail[];
if (Array.isArray(data)) setMessages(data);
})
.catch(() => {})
.finally(() => setReady(true));
}, []);
useEffect(() => {
if (ready) AsyncStorage.setItem(STORAGE, JSON.stringify(messages)).catch(() => {});
}, [messages, ready]);
const changeFolder = (next: Folder) => {
setFolder(next);
setQuery('');
setSelected(null);
};
const openMessage = (id: string) => {
setSelected(id);
setMessages((old) => old.map((item) => (item.id === id ? { ...item, read: true } : item)));
};
const toggleStar = (id: string) =>
setMessages((old) => old.map((item) => (item.id === id ? { ...item, starred: !item.starred } : item)));
const moveMessage = (id: string, next: Folder) => {
setMessages((old) => old.map((item) => (item.id === id ? { ...item, folder: next } : item)));
setSelected(null);
};
function startCompose(reply?: Mail) {
setTo(reply?.from ?? '');
setSubject(reply ? `Re: ${reply.subject.replace(/^Re: /, '')}` : '');
setBody('');
setCompose(true);
}
function saveDraft() {
if (to.trim() || subject.trim() || body.trim())
setMessages((old) => [
{
id: `draft-${Date.now()}`,
from: 'you@example.com',
to: to.trim(),
subject: subject.trim() || '(no subject)',
body: body.trim(),
time: 'Draft',
folder: 'drafts',
starred: false,
read: true,
},
...old,
]);
setCompose(false);
}
function send() {
if (!to.trim() || !body.trim()) return;
setMessages((old) => [
{
id: `sent-${Date.now()}`,
from: 'you@example.com',
to: to.trim(),
subject: subject.trim() || '(no subject)',
body: body.trim(),
time: 'Just now',
folder: 'sent',
starred: false,
read: true,
},
...old,
]);
setCompose(false);
setTo('');
setSubject('');
setBody('');
setFolder('sent');
setSelected(null);
}
const sidebar = (closeNavbar: () => void) => (
<Block flex={1} bg={C.bg} p={12} gap={8}>
<Button
title="✎ Compose"
color={C.pale}
textColor={C.ink}
radius="lg"
onPress={() => {
closeNavbar();
startCompose();
}}
/>
<Block gap={0} h={8} />
{FOLDERS.map((item) => (
<Block
key={item.id}
onPress={() => {
changeFolder(item.id);
closeNavbar();
}}
accessibilityRole="button"
accessibilityLabel={`Open ${item.label}`}
direction="row"
align="center"
gap={15}
px={14}
py={10}
radius={19}
bg={folder === item.id ? C.pale : 'transparent'}
>
<Icon
name={item.icon}
variant={folder === item.id ? 'filled' : 'outlined'}
color={folder === item.id ? C.ink : C.muted}
size={19}
/>
<Text c={C.ink} fw={folder === item.id ? 'bold' : 'normal'} flex={1}>
{item.label}
</Text>
{item.id === 'inbox' && unread > 0 && (
<Text c={C.ink} fw="bold" size={12}>
{unread}
</Text>
)}
</Block>
))}
<Block gap={0} flex={1} />
<Text c={C.muted} size={11} p={12}>
Local mail demo · no delivery
</Text>
</Block>
);
return (
<SafeArea gap={0} flex={1} bg={C.white}>
<StatusBar barStyle="dark-content" />
<AppShell
withSafeArea={false}
withBorder={false}
header={{ height: 65 }}
navbar={{ width: 230, breakpoint: 'md', expandOnHover: false }}
>
<AppShell.Header withBorder={false}>
<Block h={65} direction="row" align="center" px={17} gap={14} borderBottomWidth={1} borderBottomColor={C.line}>
{!wide && (
<NavbarControls>
{({ openNavbar }) => (
<IconButton icon="menu" variant="ghost" iconColor={C.muted} accessibilityLabel="Show folders" onPress={openNavbar} />
)}
</NavbarControls>
)}
<Icon name="mail" variant="filled" color={C.red} size={27} />
<Text c={C.ink} fw="bold" size={wide ? 24 : 19}>
Postbox
</Text>
<Block
gap={0}
flex={1}
maw={650}
ml={wide ? 40 : 0}
direction="row"
bg="#F0F4F9"
radius={22}
align="center"
px={13}
>
<Icon name="search" color={C.muted} size={19} />
<Input
variant="unstyled"
value={query}
onChangeText={setQuery}
placeholder="Search mail"
mb={0}
flex={1}
inputColor={C.ink}
inputFontSize={14}
/>
</Block>
</Block>
</AppShell.Header>
<AppShell.Navbar accessibilityLabel="Mail folders" withBorder={false}>
<NavbarControls>{({ closeNavbar }) => sidebar(closeNavbar)}</NavbarControls>
</AppShell.Navbar>
<AppShell.Main centerContent={false}>
<Block gap={0} flex={1} direction="row">
{(!selected || wide) && (
<Block
gap={0}
w={wide ? 390 : '100%'}
bg={C.white}
borderRightWidth={wide ? 1 : 0}
borderRightColor={C.line}
>
<Block
gap={0}
px={18}
py={15}
borderBottomWidth={1}
borderBottomColor={C.line}
direction="row"
justify="space-between"
>
<Text c={C.ink} fw="bold" size={20}>
{query ? 'Search results' : FOLDERS.find((item) => item.id === folder)?.label}
</Text>
<Text c={C.muted} size={11}>
{shown.length} messages
</Text>
</Block>
<ScrollArea flex={1}>
{shown.length === 0 && (
<Text c={C.muted} p={24}>
No messages here.
</Text>
)}
{shown.map((item) => (
<Block
key={item.id}
onPress={() => openMessage(item.id)}
accessibilityRole="button"
accessibilityLabel={`Open ${item.subject}`}
direction="row"
gap={10}
px={14}
py={13}
bg={selected === item.id ? '#EAF1FB' : item.read ? C.white : '#F4F7FC'}
borderBottomWidth={1}
borderBottomColor={C.line}
>
<Avatar
size="md"
fallback={item.from.slice(0, 2).toUpperCase()}
bg={avatarColor(item.from)}
textColor={C.white}
/>
<Block flex={1} miw={0} gap={3}>
<Block direction="row" justify="space-between" gap={5}>
<Text c={C.ink} fw={item.read ? 'medium' : 'bold'} size={13} numberOfLines={1} flex={1}>
{item.from}
</Text>
<Text c={C.muted} size={10}>
{item.time}
</Text>
</Block>
<Text c={C.ink} fw={item.read ? 'normal' : 'bold'} size={12} numberOfLines={1}>
{item.subject}
</Text>
<Text c={C.muted} size={12} numberOfLines={1}>
{item.body}
</Text>
</Block>
<Block
gap={0}
onPress={(event) => {
event.stopPropagation();
toggleStar(item.id);
}}
accessibilityLabel={item.starred ? 'Unstar message' : 'Star message'}
alignSelf="center"
p={4}
>
<Icon
name="star"
variant={item.starred ? 'filled' : 'outlined'}
color={item.starred ? '#F2B632' : C.muted}
size={19}
/>
</Block>
</Block>
))}
</ScrollArea>
{!wide && (
<Block
gap={0}
onPress={() => startCompose()}
accessibilityRole="button"
accessibilityLabel="Compose message"
position="absolute"
right={18}
bottom={18}
bg={C.pale}
radius={17}
px={17}
py={13}
>
<Text c={C.ink} fw="bold">
✎ Compose
</Text>
</Block>
)}
</Block>
)}
{(wide || selected) && (
<Block gap={0} flex={1} bg={C.white}>
{active ? (
<>
<Block direction="row" align="center" gap={4} p={10} borderBottomWidth={1} borderBottomColor={C.line}>
{!wide && (
<IconButton
icon="arrowLeft"
variant="ghost"
iconColor={C.ink}
accessibilityLabel="Back to messages"
onPress={() => setSelected(null)}
/>
)}
<IconButton
icon="folder"
variant="ghost"
iconColor={C.muted}
accessibilityLabel="Archive message"
onPress={() => moveMessage(active.id, 'archive')}
/>
<IconButton
icon="trash"
variant="ghost"
iconColor={C.muted}
accessibilityLabel="Delete message"
onPress={() => moveMessage(active.id, 'trash')}
/>
<IconButton
icon="mail"
variant="ghost"
iconColor={C.muted}
accessibilityLabel="Mark unread"
onPress={() => {
setMessages((old) =>
old.map((item) => (item.id === active.id ? { ...item, read: false } : item)),
);
setSelected(null);
}}
/>
</Block>
<ScrollArea contentProps={{ p: wide ? 32 : 20, maw: 850, w: '100%' }}>
<Text c={C.ink} fw="bold" size={27}>
{active.subject}
</Text>
<Block direction="row" align="center" gap={12} mt={27}>
<Avatar
size="lg"
fallback={active.from.slice(0, 2).toUpperCase()}
bg={avatarColor(active.from)}
textColor={C.white}
/>
<Block gap={0} flex={1}>
<Text c={C.ink} fw="bold" size={14}>
{active.from}
</Text>
<Text c={C.muted} size={11}>
to {active.to} · {active.time}
</Text>
</Block>
<IconButton
icon="star"
variant="ghost"
iconColor={active.starred ? '#F2B632' : C.muted}
accessibilityLabel="Toggle star"
onPress={() => toggleStar(active.id)}
/>
</Block>
<Text c={C.ink} size={15} lh={25} mt={30} mb={30}>
{active.body}
</Text>
{active.folder !== 'sent' && (
<Block gap={0} alignSelf="flex-start">
<Button
title="↩ Reply"
variant="outline"
color={C.muted}
onPress={() => startCompose(active)}
/>
</Block>
)}
<Text c={C.muted} size={11} mt={20}>
Demo inbox. Replies stay on this device.
</Text>
</ScrollArea>
</>
) : (
<Block flex={1} align="center" justify="center" gap={12}>
<Icon name="mail" color="#C8CED7" size={55} />
<Text c={C.muted}>Select a message to read it.</Text>
</Block>
)}
</Block>
)}
</Block>
</AppShell.Main>
</AppShell>
{compose && (
<Dialog opened accessibilityLabel="New message" onClose={saveDraft}>
<Block gap={0}>
<Block gap={0} bg="#EEF2F8" px={17} h={45} direction="row" align="center" justify="space-between">
<Text c={C.ink} fw="bold">
New message
</Text>
<IconButton
icon="x"
variant="ghost"
iconColor={C.ink}
accessibilityLabel="Save draft and close"
onPress={saveDraft}
/>
</Block>
<Input
variant="outline"
value={to}
onChangeText={setTo}
placeholder="To"
autoCapitalize="none"
keyboardType="email-address"
mb={0}
inputColor={C.ink}
/>
<Input
variant="outline"
value={subject}
onChangeText={setSubject}
placeholder="Subject"
mb={0}
inputColor={C.ink}
/>
<TextArea variant="outline" value={body} onChangeText={setBody} placeholder="Write your message" mb={0} />
<Block gap={0} p={12} alignSelf="flex-start">
<Button title="Send demo message" color={C.blue} disabled={!to.trim() || !body.trim()} onPress={send} />
</Block>
</Block>
</Dialog>
)}
</SafeArea>
);
}
export default function App() {
return (
<SafeAreaProvider>
<PlocksProvider theme={{ colorScheme: 'light' }}>
<PostboxScreen />
</PlocksProvider>
</SafeAreaProvider>
);
}