
Messenger with a searchable conversation list, unread counts that clear when opened, and a thread that sticks to the newest message, with attachments and an emoji picker in the composer. On phones the list and thread become two screens. Ships composable parts.
@hirael
registry/hirael/bases/radix/blocks/chat-01/chat-01.tsx
// Chat 01 from Hirael <https://hirael.com/blocks/chat/chat-01>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael
'use client';
import * as React from 'react';
import {
ArrowDown,
ArrowLeft,
Check,
CheckCheck,
FileText,
ImageIcon,
Paperclip,
Search,
SendHorizontal,
SmilePlus,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Avatar, AvatarFallback } from '@/registry/hirael/bases/radix/ui/avatar';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/registry/hirael/bases/radix/ui/empty';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
InputGroupTextarea,
} from '@/registry/hirael/bases/radix/ui/input-group';
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from '@/registry/hirael/bases/radix/ui/item';
import { Popover, PopoverContent, PopoverTrigger } from '@/registry/hirael/bases/radix/ui/popover';
import { EmojiPicker, EmojiPickerList, EmojiPickerSearch } from '@/registry/hirael/bases/radix/components/emoji-picker';
import { formatBytes } from '@/registry/hirael/lib/format-bytes';
export type ChatMessageStatus = 'sent' | 'delivered' | 'read';
export type ChatAttachmentKind = 'image' | 'file';
const EASE = 'ease-[cubic-bezier(0.22,1,0.36,1)]';
const ENTER = `animate-in fade-in slide-in-from-bottom-4 duration-500 ${EASE} fill-mode-both motion-reduce:animate-none`;
const SWAP = `animate-in fade-in slide-in-from-bottom-1 duration-250 ${EASE} fill-mode-both motion-reduce:animate-none`;
const prefersReducedMotion = () => {
return typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
};
interface ChatAvatarProps extends React.ComponentProps<'span'> {
initials: string;
/** Shows a presence dot on the end side. */
online?: boolean;
size?: 'sm' | 'default' | 'lg';
}
const ChatAvatar = ({ initials, online = false, size = 'default', className, ...props }: ChatAvatarProps) => {
return (
<span data-slot="chat-avatar" className={cn('relative inline-flex shrink-0', className)} {...props}>
<Avatar size={size}>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
{online ? (
<span
data-slot="chat-avatar-presence"
aria-hidden
className="absolute end-0 bottom-0 size-2.5 rounded-full bg-success ring-2 ring-card"
/>
) : null}
</span>
);
};
interface ChatListItemProps extends Omit<React.ComponentProps<'a'>, 'children'> {
name: string;
preview: React.ReactNode;
/** Time for today, a day label for anything older. */
time: string;
unread?: number;
active?: boolean;
avatar?: React.ReactNode;
}
/** A link, so a real app can point `href` at the conversation's route. */
const ChatListItem = ({ name, preview, time, unread = 0, active = false, avatar, ...props }: ChatListItemProps) => {
const hasUnread = unread > 0;
return (
<Item asChild size="sm" variant={active ? 'muted' : 'default'}>
<a
data-slot="chat-list-item"
data-active={active || undefined}
data-unread={hasUnread || undefined}
aria-current={active ? 'page' : undefined}
{...props}
>
{avatar ? <ItemMedia>{avatar}</ItemMedia> : null}
<ItemContent className="min-w-0">
<ItemTitle className="w-full min-w-0">
<span className={cn('truncate', hasUnread && 'font-semibold')}>{name}</span>
</ItemTitle>
<ItemDescription>
<span className={cn('block truncate', hasUnread && 'text-foreground')}>{preview}</span>
</ItemDescription>
</ItemContent>
<ItemActions className="flex-col items-end self-start">
<span
className={cn('text-xs tabular-nums', hasUnread ? 'font-medium text-primary' : 'text-muted-foreground')}
>
{time}
</span>
{hasUnread ? (
<Badge data-slot="chat-list-item-unread">
<span className="tabular-nums">{unread}</span>
<span className="sr-only"> unread</span>
</Badge>
) : null}
</ItemActions>
</a>
</Item>
);
};
type ChatHeaderProps = React.ComponentProps<'div'>;
const ChatHeader = ({ className, ...props }: ChatHeaderProps) => {
return (
<div
data-slot="chat-header"
className={cn('flex h-14 shrink-0 items-center gap-3 border-b border-border px-3 sm:px-4', className)}
{...props}
/>
);
};
interface ChatThreadContextValue {
atBottom: boolean;
unseen: boolean;
scrollToBottom: () => void;
}
const ChatThreadContext = React.createContext<ChatThreadContextValue | null>(null);
const useChatThread = () => {
const context = React.useContext(ChatThreadContext);
if (!context) {
throw new Error('ChatThread parts must be rendered inside <ChatThread>.');
}
return context;
};
interface ChatThreadProps extends React.ComponentProps<'div'> {
/** Id of the newest message. When it changes while the reader is scrolled up, the thread stays put and flags new messages. */
latestId?: string;
/** The newest message is the reader's own, so the thread always jumps to it. */
latestFromSelf?: boolean;
/** How close to the bottom (px) still counts as the bottom. */
threshold?: number;
}
const ChatThread = ({
latestId,
latestFromSelf = false,
threshold = 48,
className,
children,
...props
}: ChatThreadProps) => {
const viewportRef = React.useRef<HTMLDivElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const pinnedRef = React.useRef(true);
const autoScrollingRef = React.useRef(false);
const [atBottom, setAtBottom] = React.useState(true);
const [unseen, setUnseen] = React.useState(false);
const [lastSeenId, setLastSeenId] = React.useState(latestId);
if (latestId !== lastSeenId) {
setLastSeenId(latestId);
if (!atBottom && !latestFromSelf) setUnseen(true);
}
const scrollToBottom = React.useCallback(() => {
const el = viewportRef.current;
if (!el) return;
pinnedRef.current = true;
// Already at the bottom means no scroll event will come to clear the flag.
autoScrollingRef.current = el.scrollHeight - el.scrollTop - el.clientHeight > 1;
el.scrollTo({ top: el.scrollHeight, behavior: prefersReducedMotion() ? 'auto' : 'smooth' });
}, []);
const onScroll = React.useCallback(() => {
const el = viewportRef.current;
if (!el) return;
const pinned = el.scrollHeight - el.scrollTop - el.clientHeight < threshold;
// Intermediate positions of a smooth scroll would flash the pill on and off.
if (autoScrollingRef.current) {
if (!pinned) return;
autoScrollingRef.current = false;
}
pinnedRef.current = pinned;
setAtBottom(pinned);
if (pinned) setUnseen(false);
}, [threshold]);
// Typing indicators and replies grow the content without scrolling it, so follow the box size.
React.useLayoutEffect(() => {
const viewport = viewportRef.current;
const content = contentRef.current;
if (!viewport || !content) return;
viewport.scrollTop = viewport.scrollHeight;
const observer = new ResizeObserver(() => {
if (pinnedRef.current) viewport.scrollTop = viewport.scrollHeight;
});
observer.observe(content);
return () => observer.disconnect();
}, []);
React.useLayoutEffect(() => {
if (latestFromSelf) scrollToBottom();
}, [latestId, latestFromSelf, scrollToBottom]);
const context = React.useMemo(() => ({ atBottom, unseen, scrollToBottom }), [atBottom, unseen, scrollToBottom]);
return (
<ChatThreadContext.Provider value={context}>
<div
ref={viewportRef}
data-slot="chat-thread"
role="log"
aria-live="polite"
onScroll={onScroll}
className={cn('relative flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain', className)}
{...props}
>
<div
ref={contentRef}
data-slot="chat-thread-content"
className="flex min-h-full shrink-0 flex-col justify-end gap-3 p-3 sm:p-4"
>
{children}
</div>
</div>
</ChatThreadContext.Provider>
);
};
interface ChatThreadJumpProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'children'> {
label?: string;
/** Shown instead of `label` when messages arrived while the reader was scrolled up. */
newLabel?: string;
}
/** Render it as the last child of the thread. */
const ChatThreadJump = ({ label = 'Jump to latest', newLabel = 'New messages', ...props }: ChatThreadJumpProps) => {
const { atBottom, unseen, scrollToBottom } = useChatThread();
if (atBottom) return null;
return (
<div data-slot="chat-thread-jump" className="sticky bottom-2 z-10 -mt-3 flex h-0 justify-center overflow-visible">
<Button
type="button"
variant={unseen ? 'default' : 'outline'}
size="sm"
onClick={scrollToBottom}
className={cn(SWAP, '-translate-y-full')}
{...props}
>
<ArrowDown aria-hidden />
{unseen ? newLabel : label}
</Button>
</div>
);
};
type ChatDaySeparatorProps = React.ComponentProps<'div'>;
const ChatDaySeparator = ({ className, children, ...props }: ChatDaySeparatorProps) => {
return (
<div
data-slot="chat-day-separator"
className={cn('flex items-center gap-3 py-1 text-[11px] text-muted-foreground uppercase', className)}
{...props}
>
<span aria-hidden className="h-px flex-1 bg-border" />
<span>{children}</span>
<span aria-hidden className="h-px flex-1 bg-border" />
</div>
);
};
const ChatMessageGroupContext = React.createContext(false);
interface ChatMessageGroupProps extends React.ComponentProps<'div'> {
/** Sent by the reader: the group sits on the end side. */
own?: boolean;
}
const ChatMessageGroup = ({ own = false, className, ...props }: ChatMessageGroupProps) => {
return (
<ChatMessageGroupContext.Provider value={own}>
<div
data-slot="chat-message-group"
data-own={own || undefined}
className={cn('flex w-full items-start gap-2', own && 'flex-row-reverse', className)}
{...props}
/>
</ChatMessageGroupContext.Provider>
);
};
type ChatMessageStackProps = React.ComponentProps<'div'>;
const ChatMessageStack = ({ className, ...props }: ChatMessageStackProps) => {
const own = React.useContext(ChatMessageGroupContext);
return (
<div
data-slot="chat-message-stack"
className={cn('flex max-w-[80%] min-w-0 flex-col gap-0.5', own ? 'items-end' : 'items-start', className)}
{...props}
/>
);
};
type ChatMessageSenderProps = React.ComponentProps<'span'>;
const ChatMessageSender = ({ className, ...props }: ChatMessageSenderProps) => {
return (
<span
data-slot="chat-message-sender"
className={cn('px-1 pb-0.5 text-xs font-medium text-muted-foreground', className)}
{...props}
/>
);
};
type ChatBubbleProps = React.ComponentProps<'div'>;
/** Corners next to a neighbouring bubble in the same group tighten, so a run reads as one block. */
const ChatBubble = ({ className, ...props }: ChatBubbleProps) => {
const own = React.useContext(ChatMessageGroupContext);
return (
<div
data-slot="chat-bubble"
className={cn(
'flex max-w-full flex-col gap-2 rounded-2xl px-3.5 py-2 text-sm leading-relaxed break-words whitespace-pre-wrap',
own
? 'bg-primary text-primary-foreground [&:has(+[data-slot=chat-bubble])]:rounded-ee-md [[data-slot=chat-bubble]+&]:rounded-se-md'
: 'bg-muted text-foreground [&:has(+[data-slot=chat-bubble])]:rounded-es-md [[data-slot=chat-bubble]+&]:rounded-ss-md',
className,
)}
{...props}
/>
);
};
interface ChatBubbleAttachmentProps extends Omit<React.ComponentProps<'div'>, 'children'> {
name: string;
size: number;
kind?: ChatAttachmentKind;
}
const ChatBubbleAttachment = ({ name, size, kind = 'file', className, ...props }: ChatBubbleAttachmentProps) => {
const own = React.useContext(ChatMessageGroupContext);
const Icon = kind === 'image' ? ImageIcon : FileText;
return (
<div
data-slot="chat-bubble-attachment"
className={cn(
'flex min-w-0 items-center gap-2.5 rounded-lg px-2.5 py-2 whitespace-normal',
own ? 'bg-primary-foreground/15' : 'bg-background',
className,
)}
{...props}
>
<Icon aria-hidden className="size-4 shrink-0" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">{name}</span>
<span className={cn('text-xs tabular-nums', own ? 'text-primary-foreground/75' : 'text-muted-foreground')}>
{formatBytes(size)}
</span>
</span>
</div>
);
};
const STATUS_LABEL: Record<ChatMessageStatus, string> = {
sent: 'Sent',
delivered: 'Delivered',
read: 'Read',
};
interface ChatMessageMetaProps extends React.ComponentProps<'div'> {
time: string;
/** Delivery state of the reader's own message. */
status?: ChatMessageStatus;
}
const ChatMessageMeta = ({ time, status, className, ...props }: ChatMessageMetaProps) => {
const StatusIcon = status === 'sent' ? Check : CheckCheck;
return (
<div
data-slot="chat-message-meta"
className={cn('flex items-center gap-1.5 px-1 pt-0.5 text-[11px] text-muted-foreground tabular-nums', className)}
{...props}
>
<time>{time}</time>
{status ? (
<span
data-slot="chat-message-status"
data-status={status}
className={cn('inline-flex items-center gap-1', status === 'read' && 'text-primary')}
>
<StatusIcon aria-hidden className="size-3.5" />
{STATUS_LABEL[status]}
</span>
) : null}
</div>
);
};
interface ChatTypingProps extends React.ComponentProps<'div'> {
/** Announced to screen readers, e.g. "Maya is typing". */
label?: string;
}
const ChatTyping = ({ label = 'Typing', className, ...props }: ChatTypingProps) => {
return (
<div
data-slot="chat-typing"
role="status"
aria-label={label}
className={cn('inline-flex w-fit items-center gap-1 rounded-2xl bg-muted px-3.5 py-3', className)}
{...props}
>
{[0, 150, 300].map((delay) => (
<span
key={delay}
aria-hidden
className="size-1.5 animate-bounce rounded-full bg-muted-foreground motion-reduce:animate-none"
style={{ animationDelay: `${delay}ms` }}
/>
))}
</div>
);
};
interface ChatComposerContextValue {
value: string;
setValue: (next: string) => void;
canSend: boolean;
send: () => void;
insertText: (text: string) => void;
inputRef: React.RefObject<HTMLTextAreaElement | null>;
disabled: boolean;
}
const ChatComposerContext = React.createContext<ChatComposerContextValue | null>(null);
const useChatComposer = () => {
const context = React.useContext(ChatComposerContext);
if (!context) {
throw new Error('ChatComposer parts must be rendered inside <ChatComposer>.');
}
return context;
};
interface ChatComposerProps extends Omit<React.ComponentProps<'form'>, 'onSubmit' | 'defaultValue'> {
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
/** Receives the trimmed text; the input clears afterwards. */
onSend?: (text: string) => void;
/** Allow sending with no text, for example when a file is attached. */
allowEmpty?: boolean;
disabled?: boolean;
}
/** A form around an InputGroup: compose the input and addons inside it. */
const ChatComposer = ({
value: valueProp,
defaultValue = '',
onValueChange,
onSend,
allowEmpty = false,
disabled = false,
className,
children,
...props
}: ChatComposerProps) => {
const [internalValue, setInternalValue] = React.useState(defaultValue);
const isControlled = valueProp !== undefined;
const value = isControlled ? valueProp : internalValue;
const inputRef = React.useRef<HTMLTextAreaElement>(null);
const canSend = !disabled && (value.trim().length > 0 || allowEmpty);
const setValue = React.useCallback(
(next: string) => {
if (!isControlled) setInternalValue(next);
onValueChange?.(next);
},
[isControlled, onValueChange],
);
const send = React.useCallback(() => {
if (!canSend) return;
onSend?.(value.trim());
setValue('');
inputRef.current?.focus();
}, [canSend, onSend, value, setValue]);
const insertText = React.useCallback(
(text: string) => {
const el = inputRef.current;
const start = el?.selectionStart ?? value.length;
const end = el?.selectionEnd ?? value.length;
setValue(value.slice(0, start) + text + value.slice(end));
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(start + text.length, start + text.length);
});
},
[value, setValue],
);
const context = React.useMemo(
() => ({ value, setValue, canSend, send, insertText, inputRef, disabled }),
[value, setValue, canSend, send, insertText, disabled],
);
return (
<ChatComposerContext.Provider value={context}>
<form
data-slot="chat-composer"
onSubmit={(event) => {
event.preventDefault();
send();
}}
className={cn('w-full', className)}
{...props}
>
<InputGroup>{children}</InputGroup>
</form>
</ChatComposerContext.Provider>
);
};
type ChatComposerInputProps = Omit<
React.ComponentProps<typeof InputGroupTextarea>,
'value' | 'defaultValue' | 'onChange' | 'disabled'
>;
/** Enter sends, Shift+Enter adds a line. */
const ChatComposerInput = ({ className, onKeyDown, ...props }: ChatComposerInputProps) => {
const { value, setValue, send, inputRef, disabled } = useChatComposer();
return (
<InputGroupTextarea
ref={inputRef}
rows={1}
name="message"
aria-label="Message"
{...props}
value={value}
disabled={disabled}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
send();
}
}}
className={cn('max-h-36', className)}
/>
);
};
type ChatComposerSendProps = Omit<React.ComponentProps<typeof InputGroupButton>, 'type'>;
const ChatComposerSend = ({ children, ...props }: ChatComposerSendProps) => {
const { canSend } = useChatComposer();
return (
<InputGroupButton
type="submit"
data-slot="chat-composer-send"
variant="default"
size="icon-sm"
aria-label="Send message"
disabled={!canSend}
{...props}
>
{children ?? <SendHorizontal aria-hidden className="rtl:rotate-180" />}
</InputGroupButton>
);
};
interface ChatComposerAttachmentProps extends Omit<React.ComponentProps<typeof Badge>, 'children'> {
name: string;
size: number;
kind?: ChatAttachmentKind;
onRemove?: () => void;
}
const ChatComposerAttachment = ({ name, size, kind = 'file', onRemove, ...props }: ChatComposerAttachmentProps) => {
const Icon = kind === 'image' ? ImageIcon : FileText;
return (
<Badge data-slot="chat-composer-attachment" variant="outline" className="max-w-full" {...props}>
<Icon aria-hidden />
<span className="max-w-40 truncate">{name}</span>
<span className="font-normal text-muted-foreground tabular-nums">{formatBytes(size)}</span>
{onRemove ? (
<button
type="button"
aria-label={`Remove ${name}`}
onClick={onRemove}
className="-me-1 rounded-full p-0.5 text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="size-3" />
</button>
) : null}
</Badge>
);
};
export {
ChatAvatar,
ChatListItem,
ChatHeader,
ChatThread,
ChatThreadJump,
ChatDaySeparator,
ChatMessageGroup,
ChatMessageStack,
ChatMessageSender,
ChatBubble,
ChatBubbleAttachment,
ChatMessageMeta,
ChatTyping,
ChatComposer,
ChatComposerInput,
ChatComposerSend,
ChatComposerAttachment,
useChatThread,
useChatComposer,
};
const ME = 'me';
interface Person {
name: string;
initials: string;
online: boolean;
lastSeen?: string;
}
const PEOPLE: Record<string, Person> = {
maya: { name: 'Maya Chen', initials: 'MC', online: true },
omar: { name: 'Omar Haddad', initials: 'OH', online: true },
priya: { name: 'Priya Nair', initials: 'PN', online: false, lastSeen: 'Last seen 2 hours ago' },
jonas: { name: 'Jonas Weber', initials: 'JW', online: true },
leo: { name: 'Leo Martins', initials: 'LM', online: false, lastSeen: 'Last seen today at 08:16' },
daniel: { name: 'Daniel Okafor', initials: 'DO', online: false, lastSeen: 'Last seen yesterday' },
};
interface Attachment {
id: string;
name: string;
size: number;
kind: ChatAttachmentKind;
}
interface Message {
id: string;
from: string;
text: string;
/** Day label used for separators, e.g. "Today". */
day: string;
/** 24-hour clock, e.g. "09:14". */
time: string;
status?: ChatMessageStatus;
attachments?: Attachment[];
}
interface Conversation {
id: string;
/** Group chats have a title; direct chats take the other person's name. */
title?: string;
initials?: string;
members: string[];
unread: number;
messages: Message[];
/** Scripted answers, used one per message you send. */
replies: { from: string; text: string }[];
typing?: string;
}
const CONVERSATIONS: Conversation[] = [
{
id: 'crew',
title: 'Climbing crew',
initials: 'CC',
members: ['jonas', 'leo', 'priya'],
unread: 0,
messages: [
{
id: 'c1',
from: 'jonas',
text: 'Saturday session still on? The new routes go up Friday night.',
day: 'Today',
time: '08:02',
},
{ id: 'c2', from: 'leo', text: "I'm in. Can someone bring the spare chalk bag?", day: 'Today', time: '08:15' },
{ id: 'c3', from: ME, text: 'I have it. 10:00 at the gym?', day: 'Today', time: '08:40', status: 'read' },
{ id: 'c4', from: 'jonas', text: '10:00 works.', day: 'Today', time: '11:26' },
{ id: 'c5', from: 'jonas', text: 'I also booked the auto-belay lane for 11.', day: 'Today', time: '11:27' },
{ id: 'c6', from: 'priya', text: 'Count me in, I will be there around 10:30.', day: 'Today', time: '11:50' },
],
replies: [
{ from: 'jonas', text: 'Perfect, see you all there.' },
{ from: 'jonas', text: 'Bring tape too, the new crimps are sharp.' },
],
},
{
id: 'maya',
members: ['maya'],
unread: 2,
messages: [
{
id: 'm1',
from: 'maya',
text: 'Did you see the flight prices for Lisbon? They dropped again.',
day: 'Yesterday',
time: '21:04',
},
{
id: 'm2',
from: 'maya',
text: 'There is a direct one on Oct 17, back on the 21st.',
day: 'Yesterday',
time: '21:05',
},
{
id: 'm3',
from: ME,
text: 'Just checked. 142 each way, lowest I have seen.',
day: 'Yesterday',
time: '21:30',
status: 'read',
},
{
id: 'm4',
from: ME,
text: 'Want me to book both seats tonight?',
day: 'Yesterday',
time: '21:31',
status: 'read',
},
{
id: 'm5',
from: 'maya',
text: 'Give me until tomorrow, I need to confirm the Friday off.',
day: 'Yesterday',
time: '21:45',
},
{ id: 'm6', from: 'maya', text: 'Got it approved. Friday is off.', day: 'Today', time: '09:12' },
{
id: 'm7',
from: 'maya',
text: 'Book the 07:40 one if it is still there.\nWindow seat for me please.',
day: 'Today',
time: '09:13',
},
],
replies: [
{ from: 'maya', text: 'Thank you. I will send you my half tonight.' },
{ from: 'maya', text: 'Also found a flat in Alfama, sending the link later.' },
],
},
{
id: 'omar',
members: ['omar'],
unread: 0,
messages: [
{
id: 'o1',
from: 'omar',
text: 'Hey, do you still have my 35mm lens? No rush, just checking.',
day: 'Monday',
time: '18:20',
},
{
id: 'o2',
from: ME,
text: 'Yes, it is in my bag. Coffee on Thursday and I will bring it?',
day: 'Monday',
time: '18:52',
status: 'read',
},
{ id: 'o3', from: 'omar', text: 'Deal. The place by the station?', day: 'Monday', time: '18:53' },
{ id: 'o4', from: ME, text: 'See you at 8:30.', day: 'Monday', time: '18:55', status: 'read' },
],
replies: [{ from: 'omar', text: 'Sounds good. I owe you a croissant.' }],
},
{
id: 'priya',
members: ['priya'],
unread: 0,
messages: [
{
id: 'p1',
from: 'priya',
text: 'Final slides for Thursday are attached.',
day: 'Sep 18',
time: '15:10',
attachments: [{ id: 'p1a', name: 'q3-review-final.pdf', size: 2_516_582, kind: 'file' }],
},
{
id: 'p2',
from: ME,
text: 'Thanks, they look great. I will take the roadmap section.',
day: 'Sep 18',
time: '15:32',
status: 'read',
},
],
replies: [],
},
{
id: 'daniel',
members: ['daniel'],
unread: 1,
messages: [
{
id: 'd1',
from: ME,
text: 'Daniel, meet Lena. She is hiring a backend lead and I think you two should talk.',
day: 'Sep 15',
time: '10:02',
status: 'read',
},
{
id: 'd2',
from: 'daniel',
text: 'Thanks for the intro. I will reach out to her today.',
day: 'Sep 15',
time: '10:40',
},
],
replies: [],
},
];
const GROUP_GAP_MINUTES = 10;
const toMinutes = (time: string) => {
const [hours, minutes] = time.split(':').map(Number);
return hours * 60 + minutes;
};
type ThreadRow =
{ type: 'day'; key: string; label: string } | { type: 'group'; key: string; from: string; messages: Message[] };
const groupMessages = (messages: readonly Message[]): ThreadRow[] => {
const rows: ThreadRow[] = [];
let day: string | undefined;
for (const message of messages) {
if (message.day !== day) {
rows.push({ type: 'day', key: `day-${message.day}`, label: message.day });
day = message.day;
}
const last = rows[rows.length - 1];
const previous = last.type === 'group' ? last.messages[last.messages.length - 1] : undefined;
if (
last.type === 'group' &&
previous &&
last.from === message.from &&
toMinutes(message.time) - toMinutes(previous.time) <= GROUP_GAP_MINUTES
) {
last.messages.push(message);
} else {
rows.push({ type: 'group', key: message.id, from: message.from, messages: [message] });
}
}
return rows;
};
const clock = () => new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit' }).format(new Date());
const conversationName = (conversation: Conversation) => conversation.title ?? PEOPLE[conversation.members[0]].name;
const conversationInitials = (conversation: Conversation) =>
conversation.initials ?? PEOPLE[conversation.members[0]].initials;
const isGroup = (conversation: Conversation) => conversation.members.length > 1;
const isOnline = (conversation: Conversation) => !isGroup(conversation) && PEOPLE[conversation.members[0]].online;
const conversationStatus = (conversation: Conversation) => {
if (conversation.typing) {
return isGroup(conversation) ? `${PEOPLE[conversation.typing].name.split(' ')[0]} is typing` : 'Typing';
}
if (isGroup(conversation)) {
const online = conversation.members.filter((id) => PEOPLE[id].online).length;
return `${conversation.members.length + 1} members, ${online} online`;
}
const person = PEOPLE[conversation.members[0]];
return person.online ? 'Online' : (person.lastSeen ?? 'Offline');
};
const previewOf = (conversation: Conversation, draft: string | undefined) => {
if (draft?.trim()) return `Draft: ${draft.trim()}`;
const last = conversation.messages[conversation.messages.length - 1];
const body = last.text || (last.attachments?.length ? last.attachments.map((file) => file.name).join(', ') : '');
if (last.from === ME) return `You: ${body}`;
return isGroup(conversation) ? `${PEOPLE[last.from].name.split(' ')[0]}: ${body}` : body;
};
const ComposerEmoji = () => {
const { insertText, inputRef } = useChatComposer();
const [open, setOpen] = React.useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<InputGroupButton size="icon-sm" aria-label="Add emoji">
<SmilePlus aria-hidden />
</InputGroupButton>
</PopoverTrigger>
<PopoverContent
align="end"
side="top"
className="w-auto p-0"
onCloseAutoFocus={(event) => {
event.preventDefault();
inputRef.current?.focus();
}}
>
<EmojiPicker
onEmojiSelect={(emoji) => {
insertText(emoji);
setOpen(false);
}}
>
<EmojiPickerSearch placeholder="Search emoji" />
<EmojiPickerList className="h-48" />
</EmojiPicker>
</PopoverContent>
</Popover>
);
};
const Chat01 = () => {
const [conversations, setConversations] = React.useState(CONVERSATIONS);
const [activeId, setActiveId] = React.useState(CONVERSATIONS[0].id);
const [view, setView] = React.useState<'list' | 'thread'>('list');
const [query, setQuery] = React.useState('');
const [drafts, setDrafts] = React.useState<Record<string, string>>({});
const [attachments, setAttachments] = React.useState<Attachment[]>([]);
const activeIdRef = React.useRef(activeId);
const timersRef = React.useRef(new Set<number>());
const pendingReplyRef = React.useRef(new Set<string>());
const counterRef = React.useRef(0);
const fileRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
activeIdRef.current = activeId;
}, [activeId]);
React.useEffect(() => {
const timers = timersRef.current;
return () => timers.forEach((id) => window.clearTimeout(id));
}, []);
const schedule = (fn: () => void, ms: number) => {
const id = window.setTimeout(() => {
timersRef.current.delete(id);
fn();
}, ms);
timersRef.current.add(id);
};
const nextId = (prefix: string) => {
counterRef.current += 1;
return `${prefix}-${counterRef.current}`;
};
const updateConversation = (id: string, change: (conversation: Conversation) => Conversation) => {
setConversations((current) =>
current.map((conversation) => (conversation.id === id ? change(conversation) : conversation)),
);
};
const moveToTop = (id: string) => {
setConversations((current) => {
const target = current.find((conversation) => conversation.id === id);
return target ? [target, ...current.filter((conversation) => conversation.id !== id)] : current;
});
};
const active = conversations.find((conversation) => conversation.id === activeId) ?? conversations[0];
const open = (id: string) => {
setActiveId(id);
setView('thread');
setAttachments([]);
updateConversation(id, (conversation) => ({ ...conversation, unread: 0 }));
};
const send = (text: string) => {
const conversation = active;
const id = nextId(conversation.id);
const message: Message = {
id,
from: ME,
text,
day: 'Today',
time: clock(),
status: 'sent',
attachments: attachments.length ? attachments : undefined,
};
updateConversation(conversation.id, (current) => ({ ...current, messages: [...current.messages, message] }));
moveToTop(conversation.id);
setAttachments([]);
const setStatus = (status: ChatMessageStatus) =>
updateConversation(conversation.id, (current) => ({
...current,
messages: current.messages.map((item) =>
item.from === ME && item.status && item.status !== 'read' ? { ...item, status } : item,
),
}));
schedule(() => setStatus('delivered'), 600);
const reply = conversation.replies[0];
const reader = reply?.from ?? conversation.members[0];
if (!PEOPLE[reader].online) return;
schedule(() => setStatus('read'), 1500);
if (!reply || pendingReplyRef.current.has(conversation.id)) return;
pendingReplyRef.current.add(conversation.id);
schedule(() => updateConversation(conversation.id, (current) => ({ ...current, typing: reply.from })), 2300);
schedule(() => {
pendingReplyRef.current.delete(conversation.id);
updateConversation(conversation.id, (current) => ({
...current,
typing: undefined,
replies: current.replies.slice(1),
unread: activeIdRef.current === current.id ? 0 : current.unread + 1,
messages: [
...current.messages,
{ id: nextId(current.id), from: reply.from, text: reply.text, day: 'Today', time: clock() },
],
}));
moveToTop(conversation.id);
}, 4600);
};
const addFiles = (files: File[]) => {
setAttachments((current) => [
...current,
...files.map((file, index) => ({
id: `${file.name}-${file.lastModified}-${index}`,
name: file.name,
size: file.size,
kind: file.type.startsWith('image/') ? ('image' as const) : ('file' as const),
})),
]);
};
const needle = query.trim().toLowerCase();
const visible = needle
? conversations.filter(
(conversation) =>
conversationName(conversation).toLowerCase().includes(needle) ||
conversation.messages.some((message) => message.text.toLowerCase().includes(needle)),
)
: conversations;
const totalUnread = conversations.reduce((sum, conversation) => sum + conversation.unread, 0);
const rows = groupMessages(active.messages);
const latest = active.messages[active.messages.length - 1];
const lastOwnId = [...active.messages].reverse().find((message) => message.from === ME)?.id;
const firstName = conversationName(active).split(' ')[0];
return (
<section data-slot="chat-01" className="bg-background py-16 sm:py-24">
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6">
<div
data-slot="chat-01-frame"
className={cn(
ENTER,
'grid h-[640px] grid-cols-1 overflow-hidden rounded-xl border border-border bg-card text-card-foreground md:grid-cols-[300px_minmax(0,1fr)]',
)}
>
<aside
data-slot="chat-01-sidebar"
className={cn('min-h-0 flex-col border-border md:flex md:border-e', view === 'list' ? 'flex' : 'hidden')}
>
<div className="flex flex-col gap-3 border-b border-border p-3">
<div className="flex h-8 items-baseline justify-between gap-2 px-1">
<h2 className="text-base font-semibold">Messages</h2>
<span className="text-xs text-muted-foreground tabular-nums">
{totalUnread > 0 ? `${totalUnread} unread` : 'All read'}
</span>
</div>
<InputGroup>
<InputGroupAddon>
<Search aria-hidden />
</InputGroupAddon>
<InputGroupInput
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search"
aria-label="Search conversations"
/>
</InputGroup>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{visible.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>No matches</EmptyTitle>
<EmptyDescription>No names or messages contain “{query.trim()}”.</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<ItemGroup aria-label="Conversations">
{visible.map((conversation) => {
const last = conversation.messages[conversation.messages.length - 1];
return (
<ChatListItem
key={conversation.id}
name={conversationName(conversation)}
time={last.day === 'Today' ? last.time : last.day}
preview={conversation.typing ? 'Typing' : previewOf(conversation, drafts[conversation.id])}
unread={conversation.unread}
active={conversation.id === activeId}
href={`#${conversation.id}`}
onClick={(event) => {
event.preventDefault();
open(conversation.id);
}}
avatar={
<ChatAvatar initials={conversationInitials(conversation)} online={isOnline(conversation)} />
}
/>
);
})}
</ItemGroup>
)}
</div>
</aside>
<div
data-slot="chat-01-conversation"
className={cn('min-h-0 min-w-0 flex-col md:flex', view === 'thread' ? 'flex' : 'hidden')}
>
<ChatHeader>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Back to conversations"
onClick={() => setView('list')}
className="-ms-1 md:hidden"
>
<ArrowLeft aria-hidden className="rtl:rotate-180" />
</Button>
<ChatAvatar initials={conversationInitials(active)} online={isOnline(active)} />
<div className="flex min-w-0 flex-col">
<h3 className="truncate text-sm font-semibold">{conversationName(active)}</h3>
<p className="truncate text-xs text-muted-foreground">{conversationStatus(active)}</p>
</div>
</ChatHeader>
<ChatThread key={active.id} latestId={latest.id} latestFromSelf={latest.from === ME}>
{rows.map((row) => {
if (row.type === 'day') {
return <ChatDaySeparator key={row.key}>{row.label}</ChatDaySeparator>;
}
const own = row.from === ME;
const person = PEOPLE[row.from];
const last = row.messages[row.messages.length - 1];
return (
<ChatMessageGroup key={row.key} own={own}>
{!own && isGroup(active) ? <ChatAvatar size="sm" initials={person.initials} /> : null}
<ChatMessageStack>
{!own && isGroup(active) ? <ChatMessageSender>{person.name}</ChatMessageSender> : null}
{row.messages.map((message) => (
<ChatBubble key={message.id} title={message.time} className={SWAP}>
{message.attachments?.map((file) => (
<ChatBubbleAttachment key={file.id} name={file.name} size={file.size} kind={file.kind} />
))}
{message.text ? <span>{message.text}</span> : null}
</ChatBubble>
))}
<ChatMessageMeta
time={last.time}
status={own && last.id === lastOwnId ? last.status : undefined}
/>
</ChatMessageStack>
</ChatMessageGroup>
);
})}
{active.typing ? (
<ChatMessageGroup className={SWAP}>
{isGroup(active) ? <ChatAvatar size="sm" initials={PEOPLE[active.typing].initials} /> : null}
<ChatMessageStack>
<ChatTyping label={`${PEOPLE[active.typing].name} is typing`} />
</ChatMessageStack>
</ChatMessageGroup>
) : null}
<ChatThreadJump />
</ChatThread>
<div className="shrink-0 border-t border-border p-3">
<ChatComposer
value={drafts[active.id] ?? ''}
onValueChange={(value) => setDrafts((current) => ({ ...current, [active.id]: value }))}
onSend={send}
allowEmpty={attachments.length > 0}
>
{attachments.length > 0 ? (
<InputGroupAddon align="block-start" className="flex-wrap">
{attachments.map((file) => (
<ChatComposerAttachment
key={file.id}
name={file.name}
size={file.size}
kind={file.kind}
onRemove={() => setAttachments((current) => current.filter((item) => item.id !== file.id))}
/>
))}
</InputGroupAddon>
) : null}
<ChatComposerInput placeholder={`Message ${isGroup(active) ? conversationName(active) : firstName}`} />
<InputGroupAddon align="block-end">
<InputGroupButton size="icon-sm" aria-label="Attach files" onClick={() => fileRef.current?.click()}>
<Paperclip aria-hidden />
</InputGroupButton>
<ComposerEmoji />
<span className="ms-auto hidden text-xs font-normal sm:inline">Shift+Enter for a new line</span>
<ChatComposerSend className="max-sm:ms-auto" />
</InputGroupAddon>
</ChatComposer>
<input
ref={fileRef}
type="file"
multiple
tabIndex={-1}
aria-hidden
className="sr-only"
onChange={(event) => {
addFiles(Array.from(event.target.files ?? []));
event.target.value = '';
}}
/>
</div>
</div>
</div>
</div>
</section>
);
};
export default Chat01;




























