@livqeno/react/chat gives you two layers for building a chat UI in
React:
- Hooks that hold the state: the inbox, a conversation's messages with paging and optimistic sends, typing, presence, unread counts, moderation.
- Components built on those hooks:
<ChannelList>,<MessageList>,<MessageComposer>and<Thread>. They're unstyled by default, take aclassNameon every part, and come with an optional stylesheet.
Where this fits: your backend mints a chat token
and your page passes it to <ChatProvider>. Everything else on this page
goes inside that provider. Use the components to get going quickly, or
only the hooks if you want full control of the markup.
Set up the provider
'use client';
import { ChatProvider } from '@livqeno/react/chat';
export function Chat({ grant }: { grant: { token: string; apiUrl: string } }) {
return (
<ChatProvider token={grant.token} apiUrl={grant.apiUrl}>
<Messenger />
</ChatProvider>
);
}<ChatProvider> owns one connection for its whole subtree: the inbox, every
open conversation and every thread share it. Client-only: never render it
from a Server Component. The older <LivqenoChat room=…> provider still
works for a single-conversation UI.
Drop-in components
import { ChannelList, MessageList, MessageComposer, Thread } from '@livqeno/react/chat';
import '@livqeno/react/chat/styles.css'; // optional
function Messenger() {
const [room, setRoom] = useState<string>();
const [threadRoot, setThreadRoot] = useState<string>();
return (
<div className="layout">
<ChannelList selectedId={room} onSelect={(c) => setRoom(c.id)} />
{room && (
<section>
<MessageList room={room} onOpenThread={(messageId) => setThreadRoot(messageId)} />
<MessageComposer room={room} />
</section>
)}
{room && threadRoot && <Thread room={room} messageId={threadRoot} onClose={() => setThreadRoot(undefined)} />}
</div>
);
}| Component | What it does |
|---|---|
<ChannelList> | The inbox: conversations newest first, last message, unread badge, "load more". |
<MessageList> | One conversation's messages, oldest at the top. Loads older pages as you scroll up and only renders the rows in view, so long histories stay fast. Pending and failed sends from the outbox appear inline with a retry button. Shows link previews, reactions, reply counts and "deleted" placeholders. |
<MessageComposer> | A text box that sends on Enter, signals typing, uploads attachments with chat.attachments.upload(), and turns @ picks into mentions. Shows the slow-mode countdown and muted state. |
<Thread> | A thread's root and its replies, paged, with its own composer. |
Styling
The components render plain elements with stable class names prefixed
livqeno- (for example livqeno-message-list, livqeno-message) and expose
state as data attributes (data-status="pending|sending|failed",
data-own, data-unread, data-deleted, data-pinned). Every component
takes className, and lists also take itemClassName / messageClassName;
renderItem and renderMessage replace a row's markup entirely. Nothing is
styled unless you import the stylesheet:
import '@livqeno/react/chat/styles.css';It follows Livqeno's dark-first look: a #0a0a0a canvas, #262626
borders, Inter. Every color is a CSS variable on .livqeno-chat (--livqeno-bg, --livqeno-border, --livqeno-text, …),
so you can retheme it by overriding a few variables instead of rewriting
the rules.
Hooks
Every component above is built from these. Use them directly when you want your own markup.
| Hook | Returns |
|---|---|
useInbox() | { conversations, totalUnread, loading, hasMore, error, loadMore, refresh }. Live; see Inbox. |
useUnreadCount(room?) | Unread count for one conversation, or the total when no room is given. |
useConversation(room, { pageSize }) | { messages, loadOlder, hasMore, loading, error, pinned, send, retry, cancel, markRead }. messages is oldest first and includes optimistic rows for outbox entries (localStatus: 'pending' | 'sending' | 'failed'), replaced in place by the stored message once the server saves it. send resolves undefined when the send was queued for reconnect. |
useTyping(room?) | { typingUsers, onInput, stop }. Call onInput on every keystroke; it throttles itself. |
usePresence() | { [userId]: status } for the provider's room. |
usePresenceCount(room?) | Number online, including large rooms that only send counts (see Presence). |
useModeration(room?) | This user's muted/mutedUntil/banned/bannedUntil state in the room, plus listBlocks, block, unblock, flagMessage, and for moderators ban, unban, mute, unmute. |
function Conversation({ room }: { room: string }) {
const { messages, send, loadOlder, hasMore } = useConversation(room);
const { typingUsers, onInput } = useTyping(room);
return (
<>
{hasMore && <button onClick={() => loadOlder()}>Older</button>}
{messages.map((m) => (
<p key={m.clientMessageId ?? m.id} className={m.localStatus === 'failed' ? 'failed' : undefined}>
{m.text}
</p>
))}
{typingUsers.length > 0 && <em>{typingUsers.join(', ')} typing…</em>}
<input onChange={onInput} onKeyDown={(e) => e.key === 'Enter' && send(e.currentTarget.value)} />
</>
);
}Each hook subscribes to just its own slice of state, so a typing indicator doesn't re-render the message list.