Inbox
Conversation list beside the open chat.
Messages
Luis Ortega
Frontend engineer
Luis Ortega
My October payslip shows 6 hours of overtime, but I logged 9.
10:40
Luis Ortega
The extra 3 hours were the release on Saturday 3 Oct.
10:42
Enter sends the message. Shift+Enter adds a new line.
inbox.tsx
"use client";
import { useState } from "react";
import { LuArrowLeft, LuEllipsis, LuPhone } from "react-icons/lu";
import {
Avatar,
ChatHeader,
Composer,
ConversationItem,
IconButton,
MessageBubble,
Presence,
SearchField,
} from "@nexera-ui/react";
interface Conversation {
name: string;
initials: string;
role: string;
presence: "online" | "away" | "busy" | "offline";
time: string;
unread?: number;
muted?: boolean;
messages: { from: "them" | "me"; text: string; time: string }[];
}
const CONVERSATIONS: [Conversation, ...Conversation[]] = [
{
name: "Luis Ortega",
initials: "LO",
role: "Frontend engineer",
presence: "online",
time: "10:42",
unread: 2,
messages: [
{
from: "them",
text: "My October payslip shows 6 hours of overtime, but I logged 9.",
time: "10:40",
},
{
from: "them",
text: "The extra 3 hours were the release on Saturday 3 Oct.",
time: "10:42",
},
],
},
{
name: "Mia Jensen",
initials: "MJ",
role: "Product manager",
presence: "busy",
time: "09:15",
messages: [
{
from: "me",
text: "Your parental leave starts on 2 November. I sent the form to your inbox.",
time: "09:02",
},
{ from: "them", text: "Signed and returned, thanks Sara.", time: "09:15" },
],
},
{
name: "Yuki Tanaka",
initials: "YT",
role: "Recruiter",
presence: "away",
time: "Yesterday",
messages: [
{
from: "them",
text: "Can you add the new hire's bank details before the 25th?",
time: "16:20",
},
],
},
{
name: "#payroll-team",
initials: "#",
role: "4 members",
presence: "offline",
time: "Mon",
muted: true,
messages: [
{ from: "them", text: "Chloe: the October run is locked for review.", time: "Mon 17:05" },
],
},
];
/** Inbox: the conversation list beside the open chat; on a narrow screen the list and the chat take turns. */
export default function Inbox() {
const [query, setQuery] = useState("");
const [current, setCurrent] = useState(CONVERSATIONS[0].name);
const [showChat, setShowChat] = useState(false);
const [read, setRead] = useState<string[]>([]);
const [replies, setReplies] = useState<Record<string, string[]>>({});
const open = CONVERSATIONS.find((c) => c.name === current) ?? CONVERSATIONS[0];
const visible = CONVERSATIONS.filter((c) => c.name.toLowerCase().includes(query.toLowerCase()));
return (
<div className="@container bg-page px-4 py-8 @3xl:px-8">
<h2 className="mb-4 text-heading-h3 text-primary">Messages</h2>
<div className="flex h-[600px] overflow-hidden rounded-xl border border-default bg-surface shadow-sm">
<nav
aria-label="Conversations"
className={`${showChat ? "hidden" : "flex"} w-full flex-col gap-2 border-default p-3 @3xl:flex @3xl:w-80 @3xl:shrink-0 @3xl:border-e`}
>
<SearchField
aria-label="Search conversations"
placeholder="Search conversations"
value={query}
onChange={setQuery}
/>
{visible.length === 0 ? (
<p className="p-3 text-body-small text-secondary">No conversations match “{query}”.</p>
) : (
<ul className="m-0 flex list-none flex-col gap-1 overflow-y-auto p-0">
{visible.map((c) => (
<li key={c.name}>
<ConversationItem
name={c.name}
preview={c.messages.at(-1)?.text ?? ""}
time={c.time}
isMuted={c.muted ?? false}
{...(c.unread && !read.includes(c.name) ? { unread: c.unread } : {})}
isCurrent={c.name === current}
aria-current={c.name === current ? "true" : undefined}
avatar={
<Avatar
size="lg"
initials={c.initials}
isDecorative
presence={<Presence status={c.presence} isDecorative />}
/>
}
onPress={() => {
setCurrent(c.name);
setRead((r) => [...r, c.name]);
setShowChat(true);
}}
/>
</li>
))}
</ul>
)}
</nav>
<section
aria-label={`Conversation with ${open.name}`}
className={`${showChat ? "flex" : "hidden"} min-w-0 flex-1 flex-col @3xl:flex`}
>
<div className="flex items-center border-b border-default">
<IconButton
variant="ghost"
className="ms-2 @3xl:hidden"
aria-label="Back to conversations"
icon={<LuArrowLeft />}
onPress={() => {
setShowChat(false);
}}
/>
<ChatHeader
className="min-w-0 flex-1 border-0"
name={open.name}
headingLevel={3}
status={open.role}
avatar={
<Avatar
size="md"
initials={open.initials}
isDecorative
presence={<Presence status={open.presence} isDecorative />}
/>
}
actions={
<>
<IconButton variant="ghost" aria-label={`Call ${open.name}`} icon={<LuPhone />} />
<IconButton variant="ghost" aria-label="More options" icon={<LuEllipsis />} />
</>
}
/>
</div>
<div
role="log"
aria-label="Messages"
className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto p-4"
>
{open.messages.map((m, i) =>
m.from === "me" ? (
<MessageBubble
key={i}
direction="outgoing"
name="Sara Malik"
time={m.time}
status="read"
>
{m.text}
</MessageBubble>
) : (
<MessageBubble
key={i}
name={open.name}
time={m.time}
avatar={<Avatar size="md" initials={open.initials} isDecorative />}
>
{m.text}
</MessageBubble>
),
)}
{(replies[open.name] ?? []).map((text, i) => (
<MessageBubble
key={`r${i}`}
direction="outgoing"
name="Sara Malik"
time="Now"
status="sent"
>
{text}
</MessageBubble>
))}
</div>
<div className="border-t border-default">
<Composer
key={open.name}
aria-label={`Reply to ${open.name}`}
placeholder={`Reply to ${open.name.split(" ")[0]}`}
onSend={(text) => {
setReplies((r) => ({ ...r, [open.name]: [...(r[open.name] ?? []), text] }));
}}
/>
</div>
</section>
</div>
</div>
);
}
More messaging and collaboration blocks
- ChatOne conversation with bubbles, reactions and a composer.
- Helpdesk assistantAsk HR or support a question and get answers.
- Comment threadDiscuss a record with replies and reactions.
- Notification centreUnread notifications in a popover.
- AnnouncementA banner for news that affects everyone.
- Document editorWrite and format text with links and lists.
- Day separators and system notesShow who joined and where each day starts.