livqenoDocs

ChatUI Components

UI Components

Hooks and unstyled React components for an inbox, a message list, a composer and threads.

@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 a className on 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>
  );
}
ComponentWhat 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.

HookReturns
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.

Next steps