@poolse/react-ui
Prebuilt React components for the web, built on the
@poolse/react hooks. Mount <PoolseInbox> for a whole
inbox, <ConversationView> for one chat, or use the smaller pieces.
npm install @poolse/react-ui @poolse/react @poolse/sdk
Import the stylesheet once, at your app's root:
import '@poolse/react-ui/styles.css';
react 18 or newer is a peer dependency. Markdown is rendered with
react-markdown and remark-gfm, which install with the package. For rooms
you also need livekit-client (see Rooms and calls).
The build starts with 'use client', so it works in Next.js App Router
client components. Don't add @poolse/* to Next's transpilePackages.
Quick start: an inbox
'use client';
import '@poolse/react-ui/styles.css';
import { PoolseProvider } from '@poolse/react';
import { PoolseInbox } from '@poolse/react-ui';
import type { InboxUser } from '@poolse/react-ui';
const users: InboxUser[] = [
{ externalId: 'alice', name: 'Alice', subtitle: '[email protected]', avatarUrl: null },
{ externalId: 'bob', name: 'Bob', subtitle: '[email protected]', avatarUrl: null },
];
export default function Inbox() {
return (
<PoolseProvider config={config /* see @poolse/react */}>
<div style={{ height: '100vh' }}>
<PoolseInbox
title="Chats"
users={users}
labelFor={(id) => users.find((u) => u.externalId === id)?.name ?? id}
avatarFor={(id) => users.find((u) => u.externalId === id)?.avatarUrl ?? null}
/>
</div>
</PoolseProvider>
);
}
Quick start: one conversation
'use client';
import '@poolse/react-ui/styles.css';
import { PoolseProvider } from '@poolse/react';
import { ConversationView } from '@poolse/react-ui';
export default function Chat({ conversationId }: { conversationId: string }) {
return (
<PoolseProvider config={config}>
<div style={{ height: '100vh' }}>
<ConversationView conversationId={conversationId} />
</div>
</PoolseProvider>
);
}
The components fill their container, so give the container a height.
Names and avatars
Every labelFor(externalId) and avatarFor(externalId) prop takes your
user id. When you pass labelFor, it wins. Without it, names come from your
userResolver (see useUser), and
without that the external id itself is shown. The exceptions are the call
components (CallVideoGrid, RoomStage), whose labelFor takes the poolse
user id, because call participants are identified that way.
<PoolseInbox>
A conversation list and the open conversation, side by side on wide screens
and one at a time (with a back button) at 720 px and narrower. When you pass
users, the header shows New chat and New group buttons that open a
picker.
| Prop | Type | Default | Meaning |
|---|---|---|---|
title | string | 'Chats' | Header above the list. |
users | InboxUser[] | — | Your user directory, for the pickers. Without it, no New chat / New group buttons. |
labelFor | (externalId) => string | — | Names in rows, headers and bubbles. |
avatarFor | (externalId) => string | null | — | Avatar URLs. |
renderRightAction | () => ReactNode | — | Extra content on the right of the list header (for example a settings button). |
renderConversationRow | (conv, callerExternalId, selected) => ReactNode | — | Replace each list row. |
emptyState | ReactNode | string | — | Shown when there are no conversations. |
interface InboxUser {
externalId: string;
name: string;
subtitle?: string;
avatarUrl?: string | null;
}
Pass a ref to drive it from outside (PoolseInboxHandle):
| Method | Behaviour |
|---|---|
open(conversationId) | Show a conversation. |
openDirect(externalId) | Promise<void>. Creates a direct conversation with that user and opens it. The server returns the existing one if there is one. |
openGroup(name, memberExternalIds) | Promise<void>. Creates a group with those members and opens it. |
promptNewChat() / promptNewGroup() | Open the picker. |
close() | Back to the list. |
const inbox = useRef<PoolseInboxHandle>(null);
// …
<PoolseInbox ref={inbox} users={users} />
<button onClick={() => inbox.current?.openDirect('bob')}>Message Bob</button>
<ConversationView>
One conversation: message list, composer, typing indicator, read receipts, attachments, reactions, quotes and threads. Every feature is on by default.
<ConversationView
conversationId={id}
labelFor={(externalId) => directory[externalId]?.name ?? externalId}
onMarkedRead={markConversationRead}
threads={false}
/>
| Prop | Type | Default | Meaning |
|---|---|---|---|
conversationId | string | required | |
labelFor | (externalId) => string | — | Sender and mention names. |
emptyState | ReactNode | — | Shown when the conversation has no messages. |
renderMessage | (msg, currentUserId) => ReactNode | — | Replaces each row completely. Avatars, sender labels, grouping, actions and reactions are then yours to draw. currentUserId is the poolse user id. |
loadFonts | boolean | true | Add the brand fonts' Google Fonts <link> to the page. Set false if you load fonts yourself or your CSP forbids it. |
reactions | boolean | true | Reaction strip and emoji picker. |
mentions | boolean | true | @ autocomplete in the composer. |
attachments | boolean | true | Attach button, drag and drop, upload strip, attachments in bubbles. |
actions | boolean | true | Hover menu: react, reply, quote, copy, edit, delete. |
threads | boolean | true | Reply in thread, the reply count pill and the thread pane. |
quotations | boolean | true | Quote a message; the quote shows above the composer. |
readReceipts | boolean | true | Read ticks on your messages. Also hidden when the user's read receipts aren't visible. |
markdown | boolean | true | Render bodies as GitHub-flavoured Markdown. Raw HTML isn't rendered. |
maxBodyLength | number | 200 | Longer bodies collapse behind "Read more". 0 disables it. |
grouping | boolean | true | Join consecutive messages from one sender. |
groupingWindowMs | number | 300000 | Messages further apart than this (5 minutes) start a new group. |
daySeparators | boolean | true | A day label between messages on different days. |
senderLabels | 'auto' | 'always' | 'never' | 'auto' | Names above other people's messages. auto: only with more than two members. |
avatars | 'auto' | 'always' | 'never' | 'auto' | Avatars beside other people's messages. Same auto rule. |
onMarkedRead | (conversationId) => void | — | Called after the view advances your read cursor. Wire it to useConversations().markConversationRead so a list badge clears at once. |
onCallBack | (media: 'audio' | 'video') => void | — | Shows "Call back" on call-log rows worth calling back. Place the call yourself. |
onJoinCall | (call: LiveCall) => void | — | Shows a "Join" banner while a call or room is live here and you aren't in it. Called when Join is clicked. |
currentCallId | string | null | null | The call you are already on; its banner is hidden. |
What the view does for you:
- Loads older messages when you scroll within 400 px of the top.
- Stays at the bottom while you are there; otherwise counts new messages in a badge.
- Marks the newest message read when it is on screen.
- Accepts files dropped anywhere on it.
- Shows "Connecting…", "Reconnecting…" or "Disconnected" while the socket isn't connected.
- Opens threads in a side pane, full screen at 760 px and narrower.
- Renders call-log messages as call rows.
Chat components
<ChatHeader>
The title bar. For a direct conversation it shows the other member's name, avatar and online dot; for a group, the name and member count.
| Prop | Type | Meaning |
|---|---|---|
conversationId | string | Required. |
labelFor / avatarFor | resolvers | See Names and avatars. |
onBack | () => void | Shows a back button. |
onPress | () => void | Makes the title clickable. |
onMembersPress | () => void | Shows a members button. |
rightSlot | ReactNode | Your own content on the right. |
<ThreadView>
A thread's replies and a composer, as used by ConversationView. Esc and
the close button call onClose.
| Prop | Type | Meaning |
|---|---|---|
conversationId | string | Required. |
rootMessage | Message | Required. The message the thread hangs off. |
onClose | () => void | |
renderMessage | (msg, currentUserId) => ReactNode | Replace each reply. |
<MessageBubble>
One message bubble: sender label, quote, attachments, body, time and read tick. It renders; it doesn't fetch.
| Prop | Type | Default | Meaning |
|---|---|---|---|
message | Message | required | |
currentUserId | string | null | required | Poolse user id of the viewer; decides which side the bubble sits on. |
readState | 'sent' | 'read' | — | Read tick for your own message. |
labelFor | (externalId) => string | — | |
onQuotedClick | (quotedMessageId) => void | — | Click on the quote. |
groupPosition | 'standalone' | 'first' | 'middle' | 'last' | — | Corner shape inside a group. |
maxBodyLength | number | — | "Read more" threshold. |
markdown | boolean | — | |
showSenderName | boolean | — | |
actionsTrigger | ReactNode | — | Content placed in the bubble for opening actions. |
showAttachments | boolean | — | false hides attachments so you can draw them yourself. |
Images are laid out inside the bubble: one image full width, two side by
side, three as one large and two small, four or more in a 2×2 grid with a
+N overlay. Other files show as cards below the images.
<EditableMessageBubble>
MessageBubble with an edit mode you control.
| Prop | Type | Meaning |
|---|---|---|
message | Message | Required. |
currentUserId | string | null | Required. |
readState | 'sent' | 'read' | |
editing | boolean | Required. Shows a text box instead of the body. |
onSave | (body: string) => Promise<unknown> | void | Required. Enter saves. |
onCancel | () => void | Required. Esc cancels. |
<MessageActions>
The hover menu for one message. Each action appears only when you pass its
handler. onReply is labelled "Reply in thread" and onQuote is labelled
"Reply" (a quote in the main list).
| Prop | Type |
|---|---|
onReact | (emoji: string) => void |
onReply | () => void |
onQuote | () => void |
onCopy | () => void |
onEdit | () => void |
onDelete | () => void |
children | ReactNode |
<MessageComposer>
A text box with a send button and, when attachments is on, an attach
button and upload strip. Enter sends; Shift+Enter adds a line. Enter on a
list line (- item, 1. item) continues the list.
| Prop | Type | Meaning |
|---|---|---|
onSend | (body: string, opts?: { quoted_message_id?: string; attachment_ids?: string[] }) => Promise<unknown> | void | Required. |
onTyping | () => void | Called as the user types; wire it to useTyping().signalTyping. |
placeholder | string | |
disabled | boolean | |
replyingTo | Message | null | Shows the message being quoted. |
onCancelReply | () => void | |
labelFor | (externalId) => string | |
attachments | boolean | Turn uploads on. |
With a ref, it exposes addFiles(files: File[]) to add files to the
upload queue (it does nothing when attachments is off).
<MentionInput>
The composer with @ autocomplete over the conversation's members. Arrow
keys move through the list and Enter or Tab picks. It sends a
MessageCreateRequest with mentions filled in.
| Prop | Type | Meaning |
|---|---|---|
conversationId | string | Required. |
onSend | (req: MessageCreateRequest) => Promise<unknown> | void | Required. |
onTyping, placeholder, disabled, labelFor, replyingTo, onCancelReply, attachments | As on MessageComposer. |
<TypingIndicator>
| Prop | Type | Meaning |
|---|---|---|
typing | Set<string> | Required. External ids, as from useTyping. |
labelFor | (externalId) => string |
It takes no space for an empty set. Otherwise it shows three dots and text such as "Alice is typing", "Alice and Bob are typing" or "3 people are typing".
<ReactionStrip> and <ReactionPicker>
ReactionStrip shows a message's reactions with counts and highlights
yours; clicking toggles yours. It uses useReactions.
| Prop | Type | Default | Meaning |
|---|---|---|---|
messageId | string | required | |
conversationId | string | required | |
initialReactions | ReactionMap | — | Usually message.reactions. |
currentUserId | string | null | — | Poolse user id; enables instant updates. |
picker | boolean | true | Show a button that opens the picker. |
ReactionPicker is the six-emoji popover (👍 ❤️ 😂 🎉 😢 🙏) on its own.
Props: onPick(emoji) (required) and onClose(). Arrow keys move and Esc
closes.
<AttachmentPreview>
One attachment: an image thumbnail that opens full screen on click, or a
file card with a download link. It fetches the download URL with
useAttachmentUrl.
| Prop | Type | Meaning |
|---|---|---|
attachment | Attachment | { id: string } | Required. With only an id, it guesses image or file from the download URL. |
onClick | (att: { id: string }) => void | Replaces the default click. |
<UploadQueueStrip>
One chip per upload, with progress and cancel and dismiss buttons.
| Prop | Type |
|---|---|
items | UploadItem[] (from useAttachmentUpload().queue) |
onCancel | (localId: string) => void |
onDismiss | (localId: string) => void |
<ConversationList>
The list of conversations: avatar, name, last message preview, time and
unread badge. Direct conversations show the other member through
labelFor / avatarFor. Call-log previews read as call text.
| Prop | Type | Meaning |
|---|---|---|
selectedId | string | null | Highlights a row. |
onSelect | (conv: Conversation) => void | |
renderItem | (conv, selected) => ReactNode | Replace each row. |
emptyState | ReactNode | |
conversations | Conversation[] | Pass these (with loading, error, unreadCounts) to render your own state instead of calling useConversations inside. |
loading | boolean | |
error | Error | null | |
unreadCounts | Record<string, number> | |
labelFor / avatarFor | resolvers |
<MemberList>
A conversation's members with role badges and online dots.
| Prop | Type | Meaning |
|---|---|---|
conversationId | string | Required. |
labelFor / avatarFor | resolvers | |
onlineExternalIds | Set<string> | Who to mark online, for example usePresence().online. |
canRemove | (m: Membership) => boolean | Shows a remove button where it returns true. Removing calls useMembers().removeMember. |
renderItem | (m: Membership) => ReactNode | Replace each row. |
emptyState | ReactNode |
<UserPickerSheet>
The modal behind New chat and New group.
| Prop | Type | Meaning |
|---|---|---|
visible | boolean | Required. |
onClose | () => void | Required. |
users | InboxUser[] | Required. |
excludeIds | string[] | External ids to leave out (for example yourself). |
mode | 'single' | 'group' | Required. single: one tap picks a person. group: a name field and multiple selection. |
onPickDirect | (externalId) => void | single mode. |
onCreateGroup | (name, memberExternalIds) => void | group mode. |
<GroupDetailsSheet>
A modal with the group's name, avatar and members.
| Prop | Type | Meaning |
|---|---|---|
visible | boolean | Required. |
onClose | () => void | Required. |
conversationId | string | Required. |
labelFor / avatarFor | resolvers | |
footer | ReactNode | Your own actions at the bottom. |
<CallLogRow>
A call-log message drawn as a centred call row ("Missed video call", "Outgoing voice call · 3:12").
| Prop | Type | Meaning |
|---|---|---|
msg | Message (at least type, body, inserted_at, deleted_at) | Required. |
meId | string | null | Required. Poolse user id of the viewer. |
onCallBack | (media) => void | Shows "Call back" where it makes sense. |
<LiveCallBanner>
"Call in progress · Join". ConversationView shows it when you pass
onJoinCall.
| Prop | Type | Meaning |
|---|---|---|
call | LiveCall | Required. From useLiveCall. |
inCall | number | Required. How many people are in it. |
onJoin | (call: LiveCall) => void | Required. |
Rooms and calls
Drop-in rooms on the web, driven by useRoom.
Install livekit-client and pass the module to useRoom:
npm install livekit-client
import * as livekit from 'livekit-client';
import { useRoom } from '@poolse/react';
import { RoomJoinBar, RoomStage } from '@poolse/react-ui';
function ConversationWithRoom({ conversationId }: { conversationId: string }) {
const room = useRoom(conversationId, { livekit });
return (
<>
<RoomJoinBar room={room} />
{room.joined ? <RoomStage room={room} title="Team room" /> : null}
</>
);
}
<RoomJoinBar>
While a room runs: "Video room · 2 in the room · Join". While none does: "Start a room" with Voice and Video buttons. Renders nothing once you are in.
| Prop | Type | Default | Meaning |
|---|---|---|---|
room | UseRoom | required | |
allowStart | boolean | true | false hides the start buttons, so people can only join. |
<RoomStage>
The room you are in: everyone's video (or their initial while the camera is off), an elapsed timer, and controls for microphone, camera and output, each with a device menu, plus Leave. It fills its container.
| Prop | Type | Meaning |
|---|---|---|
room | UseRoom | Required. |
title | string | |
labelFor | (userId) => string | Poolse user id to name. |
className | string |
<CallVideoGrid>
The video tiles on their own, for your own call screen.
| Prop | Type | Meaning |
|---|---|---|
participants | CallParticipant[] | Required. From useCallRoom().participants. |
labelFor | (userId) => string | Poolse user id to name. |
className | string | |
onLocalClick | () => void | Makes your own tile clickable. |
<CallControlButton> and <CallDeviceMenu>
A round call control and the device menu it can open.
CallControlButton props: icon (an IconName,
required), label (required), onClick (required), active (show it as
switched on), tone ('neutral', 'danger' or 'accept'), large,
disabled, hasMenu (shows a caret) and onMenuClick.
CallDeviceMenu props: title, devices (CallDevice[]), activeId,
onSelect(kind, deviceId) and onClose, all required except activeId.
Identity components
<Avatar>
A round image with an initials fallback and an optional online dot.
| Prop | Type | Default |
|---|---|---|
src | string | null | — |
name | string | null | — (used for initials and aria-label) |
online | boolean | — |
size | 'sm' | 'md' | 'lg' | 'md' |
className | string | — |
<UserName> and useDisplayName
<UserName externalId labelFor? emptyFallback? className? /> renders a
user's name. useDisplayName(externalId, labelFor?, emptyFallback = 'Unknown')
returns it as a string. Order: labelFor, then your userResolver, then the
external id. With no id, emptyFallback.
Brand components
<PoolseIcon name size? label? />: one of 52 built-in icons, for examplesend,attachment,reply,check-double,mic-off,video,phone-off,users.sizedefaults to1.2em. Passlabel={null}for a decorative icon. Other SVG props pass through.IconNameis the type ofname.<PoolseLogo variant? size? label? />:variantis'lockup'(default),'mark','wordmark'or'mono'(PoolseLogoVariant);sizedefaults to 28.<PoolseFonts />andusePoolseFonts(enabled = true): add the Google Fonts link for Bricolage Grotesque, Hanken Grotesk and JetBrains Mono to the page, once.ConversationViewdoes this unlessloadFonts={false}.
Theming
Every colour, radius, shadow and font is a CSS variable. Override them in your own stylesheet:
:root {
--poolse-brand: #0070f3;
--poolse-brand-strong: #0050b3;
--poolse-brand-soft: #e0eaff;
--poolse-on-brand: #ffffff;
}
| Group | Variables |
|---|---|
| Brand | --poolse-brand, --poolse-brand-strong, --poolse-brand-soft, --poolse-on-brand |
| Surfaces and text | --poolse-paper, --poolse-surface, --poolse-surface-2, --poolse-ink, --poolse-ink-2, --poolse-ink-3, --poolse-border |
| Status | --poolse-presence, --poolse-success, --poolse-warning, --poolse-error, --poolse-info |
| Type | --poolse-font-display, --poolse-font-body, --poolse-font-mono, --poolse-font-size, --poolse-line-height |
| Radius | --poolse-radius-sm, --poolse-radius-md, --poolse-radius-lg, --poolse-radius-xl, --poolse-radius-pill |
| Shadow | --poolse-shadow-sm, --poolse-shadow-md, --poolse-shadow-lg |
Dark mode. The stylesheet follows the system's prefers-color-scheme.
To force a mode, set data-theme="dark" or data-theme="light" on any
element around the components.
Accessibility
- The message list is
role="log"with polite live announcements. TypingIndicatorisrole="status"witharia-live="polite".MentionInputis a combobox with a listbox of options.ReactionPickeris a menu with arrow-key navigation; Esc closes it.Avatarisrole="img"labelled with the name.- On touch screens (no hover) the message actions open on tap.
- The image viewer and the sheets close on Esc and on a click outside.
Ways to customise
- CSS variables, for colours, type and shape.
- Feature flags on
ConversationView(threads={false}, …). - Render props:
renderMessageonConversationViewandThreadView,renderItemonConversationListandMemberList,renderConversationRowonPoolseInbox. - Build your own view from the exported pieces (
MessageBubble,MessageComposer,TypingIndicator,ThreadView, …) and the@poolse/reacthooks. The fully wired row thatConversationViewuses internally isn't exported.
Exported types
ConversationViewProps, ChatHeaderProps, PoolseInboxProps,
PoolseInboxHandle, InboxUser, UserPickerSheetProps,
GroupDetailsSheetProps, MessageBubbleProps, CallLogRowProps,
LiveCallBannerProps, RoomJoinBarProps, RoomStageProps,
EditableMessageBubbleProps, MessageComposerProps, MessageActionsProps,
TypingIndicatorProps, ThreadViewProps, AvatarProps, UserNameProps,
ConversationListProps, MemberListProps, AttachmentPreviewProps,
ReactionStripProps, MentionInputProps, PoolseIconProps, IconName,
PoolseLogoProps, PoolseLogoVariant, CallVideoGridProps,
CallControlButtonProps, CallDeviceMenuProps.