App.tsx
import { useEffect, useRef, useState } from 'react';
import { 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 { Input, SafeArea, ScrollArea, Block, Avatar, Icon, IconButton, PlocksProvider, Text } from '@plocks/ui';
const C = {
rail: '#1E1F22',
sidebar: '#2B2D31',
main: '#313338',
top: '#2B2D31',
input: '#383A40',
white: '#F2F3F5',
muted: '#A6ADB6',
dim: '#828891',
line: '#202127',
blurple: '#5865F2',
green: '#3BA55D',
};
const SERVERS = [
{
id: 'design',
icon: '✦',
name: 'Design Den',
color: '#5865F2',
categories: [
{
title: 'WELCOME',
channels: [
['welcome', 'welcome'],
['rules', 'guidelines'],
],
},
{
title: 'HANGOUT',
channels: [
['general', 'general'],
['inspiration', 'inspiration'],
['feedback', 'feedback'],
],
},
],
},
{
id: 'garden',
icon: '🌱',
name: 'The Garden',
color: '#427D62',
categories: [
{
title: 'LOUNGE',
channels: [
['garden-general', 'general'],
['garden-plants', 'plant-talk'],
['garden-photos', 'photos'],
],
},
],
},
{
id: 'music',
icon: '♫',
name: 'After Hours',
color: '#B7678B',
categories: [
{
title: 'CHANNELS',
channels: [
['music-general', 'general'],
['music-finds', 'new-finds'],
['music-playlists', 'playlists'],
],
},
],
},
];
type Message = {
id: string;
author: string;
initials: string;
color: string;
text: string;
time: string;
reactions?: string[];
};
type MessageMap = Record<string, Message[]>;
const SEED: MessageMap = {
general: [
{
id: '1',
author: 'Maya',
initials: 'MA',
color: '#A871C4',
text: 'Good morning everyone! What are you working on today?',
time: 'Today at 9:14 AM',
reactions: ['☀️ 3'],
},
{
id: '2',
author: 'Leo',
initials: 'LE',
color: '#5B95B5',
text: 'A tiny icon set for our new project. The details are taking forever but I love it.',
time: 'Today at 9:18 AM',
reactions: ['✨ 2'],
},
{
id: '3',
author: 'Nina',
initials: 'NI',
color: '#D88479',
text: 'That sounds lovely! Drop a preview when you can 👀',
time: 'Today at 9:21 AM',
},
{
id: '4',
author: 'Owen',
initials: 'OW',
color: '#7DA66F',
text: 'I just made coffee and opened Figma. That counts as progress, right?',
time: 'Today at 9:26 AM',
reactions: ['☕ 5'],
},
],
inspiration: [
{
id: '5',
author: 'Maya',
initials: 'MA',
color: '#A871C4',
text: 'This color palette has been living in my head all week: lilac, moss, and warm cream.',
time: 'Yesterday at 4:02 PM',
reactions: ['💜 4'],
},
{
id: '6',
author: 'Nina',
initials: 'NI',
color: '#D88479',
text: 'A little reminder that your moodboards are allowed to be messy.',
time: 'Today at 8:40 AM',
},
],
feedback: [
{
id: '7',
author: 'Leo',
initials: 'LE',
color: '#5B95B5',
text: 'If anyone has a spare minute, I would love fresh eyes on a navigation idea.',
time: 'Today at 10:04 AM',
},
],
welcome: [
{
id: '8',
author: 'Maya',
initials: 'MA',
color: '#A871C4',
text: 'Welcome to Design Den! Introduce yourself and make yourself at home.',
time: 'Monday at 12:00 PM',
},
],
rules: [
{
id: '9',
author: 'Maya',
initials: 'MA',
color: '#A871C4',
text: 'Be kind, give useful feedback, and credit the work you share.',
time: 'Monday at 12:00 PM',
},
],
'garden-general': [
{
id: '10',
author: 'Ari',
initials: 'AR',
color: '#8BAE78',
text: 'Morning, garden crew! What is growing on your windowsill?',
time: 'Today at 8:06 AM',
},
],
'garden-plants': [
{
id: '11',
author: 'Ari',
initials: 'AR',
color: '#8BAE78',
text: 'My monstera finally has a new leaf 🌿',
time: 'Today at 8:16 AM',
},
],
'garden-photos': [
{
id: '12',
author: 'Sam',
initials: 'SA',
color: '#D0A472',
text: 'The light in the greenhouse was unreal this morning.',
time: 'Today at 9:30 AM',
},
],
'music-general': [
{
id: '13',
author: 'Jules',
initials: 'JU',
color: '#BB78A9',
text: 'What is everyone listening to tonight?',
time: 'Today at 7:22 PM',
},
],
'music-finds': [
{
id: '14',
author: 'Jules',
initials: 'JU',
color: '#BB78A9',
text: 'Found a little jazz track that feels like driving home in the rain.',
time: 'Today at 7:38 PM',
},
],
'music-playlists': [
{
id: '15',
author: 'Rae',
initials: 'RA',
color: '#7785B8',
text: 'Post your favorite late night playlists here.',
time: 'Yesterday at 11:18 PM',
},
],
};
const MEMBERS = [
{ name: 'Maya', initials: 'MA', color: '#A871C4', role: 'Admin' },
{ name: 'Leo', initials: 'LE', color: '#5B95B5', role: 'Online' },
{ name: 'Nina', initials: 'NI', color: '#D88479', role: 'Online' },
{ name: 'Owen', initials: 'OW', color: '#7DA66F', role: 'Online' },
{ name: 'You', initials: 'YO', color: '#5865F2', role: 'Online' },
];
const STORAGE = 'plocks-guildline-example:v1';
const timestamp = () => new Date().toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
function ChatScreen() {
const { width } = useWindowDimensions();
const wide = width >= 820;
const showMembers = width >= 1180;
const [serverId, setServerId] = useState('design');
const [channelId, setChannelId] = useState('general');
const [messages, setMessages] = useState<MessageMap>(SEED);
const [draft, setDraft] = useState('');
const [showChannels, setShowChannels] = useState(true);
const [search, setSearch] = useState('');
const [ready, setReady] = useState(false);
const scroll = useRef<ScrollView>(null);
const server = SERVERS.find((item) => item.id === serverId)!;
const channel =
server.categories.flatMap((category) => category.channels).find(([id]) => id === channelId)?.[1] ?? 'general';
const currentMessages = messages[channelId] ?? [];
const filtered = currentMessages.filter((message) =>
`${message.author} ${message.text}`.toLowerCase().includes(search.toLowerCase()),
);
useEffect(() => {
AsyncStorage.getItem(STORAGE)
.then((raw) => {
if (!raw) return;
const saved = JSON.parse(raw) as MessageMap;
if (saved && typeof saved === 'object') setMessages({ ...SEED, ...saved });
})
.catch(() => {})
.finally(() => setReady(true));
}, []);
useEffect(() => {
if (ready) AsyncStorage.setItem(STORAGE, JSON.stringify(messages)).catch(() => {});
}, [messages, ready]);
function openServer(id: string) {
const next = SERVERS.find((item) => item.id === id)!;
setServerId(id);
setChannelId(next.categories[0].channels[0][0]);
setShowChannels(true);
setSearch('');
}
function openChannel(id: string) {
setChannelId(id);
setShowChannels(false);
setSearch('');
}
function send() {
if (!draft.trim()) return;
const message: Message = {
id: `local-${Date.now()}`,
author: 'You',
initials: 'YO',
color: C.blurple,
text: draft.trim(),
time: `Today at ${timestamp()}`,
};
setMessages((old) => ({ ...old, [channelId]: [...(old[channelId] ?? []), message] }));
setDraft('');
}
function react(id: string) {
setMessages((old) => ({
...old,
[channelId]: (old[channelId] ?? []).map((message) =>
message.id === id ? { ...message, reactions: [...(message.reactions ?? []), '💜 1'] } : message,
),
}));
}
const serverRail = (
<Block w={72} bg={C.rail} align="center" py={14} gap={12}>
<Block
gap={0}
onPress={() => openServer('design')}
accessibilityLabel="Home"
w={48}
h={48}
radius={16}
bg={C.blurple}
align="center"
justify="center"
>
<Text c="#fff" fw="bold" size={27}>
✦
</Text>
</Block>
<Block gap={0} w={30} h={2} bg="#46484F" my={2} />
{SERVERS.map((item) => (
<Block
gap={0}
key={item.id}
onPress={() => openServer(item.id)}
accessibilityRole="button"
accessibilityLabel={item.name}
w={48}
h={48}
radius={serverId === item.id ? 16 : 24}
bg={item.color}
align="center"
justify="center"
>
<Text c="#fff" fw="bold" size={25}>
{item.icon}
</Text>
</Block>
))}
</Block>
);
const channelSidebar = (
<Block gap={0} flex={wide ? undefined : 1} w={wide ? 245 : undefined} bg={C.sidebar}>
<Block
gap={0}
h={54}
px={16}
borderBottomWidth={1}
borderBottomColor={C.line}
direction="row"
align="center"
justify="space-between"
>
<Text c={C.white} fw="bold" size={17}>
{server.name}
</Text>
<Icon name="chevronDown" color={C.white} size={18} />
</Block>
<ScrollArea flex={1} contentProps={{ pt: 15, px: 8 }}>
{server.categories.map((category) => (
<Block gap={0} key={category.title} mb={23}>
<Text c={C.muted} fw="bold" size={11} ml={10} mb={7}>
⌄ {category.title}
</Text>
{category.channels.map(([id, name]) => (
<Block
key={id}
onPress={() => openChannel(id)}
accessibilityRole="button"
accessibilityLabel={`Open ${name} channel`}
direction="row"
align="center"
gap={9}
px={10}
h={35}
radius={5}
bg={channelId === id ? '#404249' : 'transparent'}
>
<Icon name="number" color={channelId === id ? C.white : C.dim} size={21} />
<Text c={channelId === id ? C.white : C.muted} fw={channelId === id ? 'semibold' : 'normal'} size={15}>
{name}
</Text>
</Block>
))}
</Block>
))}
</ScrollArea>
<Block p={10} bg="#232428" direction="row" align="center" gap={9}>
<Avatar size="md" fallback="YO" bg={C.blurple} textColor="#fff" />
<Block gap={0} flex={1}>
<Text c={C.white} fw="bold" size={12}>
You
</Text>
<Text c={C.muted} size={11}>
Online
</Text>
</Block>
<Icon name="settings" color={C.muted} size={18} />
</Block>
</Block>
);
return (
<SafeArea gap={0} flex={1} bg={C.main}>
<StatusBar barStyle="light-content" />
<Block gap={0} flex={1} direction="row">
{serverRail}
{(wide || showChannels) && channelSidebar}
{(wide || !showChannels) && (
<Block gap={0} flex={1} miw={0} bg={C.main}>
<Block
h={54}
borderBottomWidth={1}
borderBottomColor={C.line}
direction="row"
align="center"
gap={9}
px={15}
>
{!wide && (
<IconButton
icon="menu"
variant="ghost"
iconColor={C.muted}
accessibilityLabel="Show channels"
onPress={() => setShowChannels(true)}
/>
)}
<Icon name="number" color={C.dim} size={24} />
<Text c={C.white} fw="bold" size={16}>
{channel}
</Text>
<Block gap={0} w={1} h={23} bg="#52545A" mx={5} />
<Text c={C.muted} size={12} numberOfLines={1} flex={1}>
A place to connect and share.
</Text>
<Icon name="search" color={C.muted} size={20} />
</Block>
<Block gap={0} flex={1} direction="row">
<Block gap={0} flex={1} miw={0}>
<ScrollArea
ref={scroll}
flex={1}
contentProps={{ py: 20, grow: 1, justify: 'flex-end' }}
onContentSizeChange={() => scroll.current?.scrollToEnd({ animated: false })}
>
<Block gap={0} px={20} mb={16}>
<Block gap={0} w={55} h={55} radius={28} bg="#404249" align="center" justify="center" mb={12}>
<Icon name="number" color={C.white} size={30} />
</Block>
<Text c={C.white} fw="bold" size={26}>
Welcome to #{channel}!
</Text>
<Text c={C.muted} size={13}>
This is the beginning of the #{channel} channel.
</Text>
</Block>
<Block gap={0} h={1} bg="#53565F" mx={20} mb={14} />
{filtered.map((message) => (
<Block key={message.id} direction="row" gap={13} px={18} py={9}>
<Avatar size="md" fallback={message.initials} bg={message.color} textColor="#fff" />
<Block gap={0} flex={1} miw={0}>
<Block direction="row" align="center" gap={8}>
<Text c={C.white} fw="bold" size={14}>
{message.author}
</Text>
<Text c={C.dim} size={10}>
{message.time}
</Text>
</Block>
<Text c="#DBDEE1" size={14} lh={22} mt={3}>
{message.text}
</Text>
{!!message.reactions?.length && (
<Block direction="row" gap={5} mt={6}>
{message.reactions.map((reaction, i) => (
<Block
gap={0}
key={i}
onPress={() => react(message.id)}
bg="#404249"
radius={8}
px={7}
py={4}
>
<Text c={C.white} size={12}>
{reaction}
</Text>
</Block>
))}
</Block>
)}
{!message.reactions?.length && (
<Block
gap={0}
onPress={() => react(message.id)}
accessibilityLabel="React with heart"
alignSelf="flex-start"
pt={4}
>
<Text c={C.dim} size={11}>
♡ React
</Text>
</Block>
)}
</Block>
</Block>
))}
{search && !filtered.length && (
<Text c={C.muted} ta="center" p={25}>
No messages found.
</Text>
)}
</ScrollArea>
<Block gap={0} px={16} pb={16} pt={7}>
<Block direction="row" align="center" bg={C.input} radius={8} px={12} gap={10}>
<Icon name="plus" color={C.muted} size={22} />
<Input
variant="unstyled"
value={draft}
onChangeText={setDraft}
onEnter={send}
placeholder={`Message #${channel}`}
placeholderTextColor={C.dim}
mb={0}
flex={1}
mih={44}
inputColor={C.white}
inputFontSize={14}
/>
<IconButton
icon="arrowRight"
variant="ghost"
iconColor={draft.trim() ? C.white : C.dim}
accessibilityLabel="Send message"
disabled={!draft.trim()}
onPress={send}
/>
</Block>
</Block>
</Block>
{showMembers && (
<Block gap={0} w={215} bg={C.sidebar} p={15}>
<Text c={C.muted} fw="bold" size={11} mb={15}>
ONLINE — {MEMBERS.length}
</Text>
{MEMBERS.map((member) => (
<Block key={member.name} direction="row" align="center" gap={10} mb={17}>
<Avatar size="md" fallback={member.initials} bg={member.color} textColor="#fff" />
<Block gap={0}>
<Text c={C.white} fw="semibold" size={13}>
{member.name}
</Text>
<Text c={C.dim} size={11}>
{member.role}
</Text>
</Block>
</Block>
))}
<Input
variant="filled"
value={search}
onChangeText={setSearch}
placeholder="Search this channel"
placeholderTextColor={C.dim}
mb={0}
mt={15}
radius={6}
inputColor={C.white}
inputFontSize={12}
/>
</Block>
)}
</Block>
</Block>
)}
</Block>
</SafeArea>
);
}
export default function App() {
return (
<SafeAreaProvider>
<PlocksProvider theme={{ colorScheme: 'dark' }}>
<ChatScreen />
</PlocksProvider>
</SafeAreaProvider>
);
}