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

PropTypeDefaultMeaning
titlestring'Chats'Header above the list.
usersInboxUser[]—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.
emptyStateReactNode | 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):

MethodBehaviour
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}
/>
PropTypeDefaultMeaning
conversationIdstringrequired
labelFor(externalId) => string—Sender and mention names.
emptyStateReactNode—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.
loadFontsbooleantrueAdd the brand fonts' Google Fonts <link> to the page. Set false if you load fonts yourself or your CSP forbids it.
reactionsbooleantrueReaction strip and emoji picker.
mentionsbooleantrue@ autocomplete in the composer.
attachmentsbooleantrueAttach button, drag and drop, upload strip, attachments in bubbles.
actionsbooleantrueHover menu: react, reply, quote, copy, edit, delete.
threadsbooleantrueReply in thread, the reply count pill and the thread pane.
quotationsbooleantrueQuote a message; the quote shows above the composer.
readReceiptsbooleantrueRead ticks on your messages. Also hidden when the user's read receipts aren't visible.
markdownbooleantrueRender bodies as GitHub-flavoured Markdown. Raw HTML isn't rendered.
maxBodyLengthnumber200Longer bodies collapse behind "Read more". 0 disables it.
groupingbooleantrueJoin consecutive messages from one sender.
groupingWindowMsnumber300000Messages further apart than this (5 minutes) start a new group.
daySeparatorsbooleantrueA 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.
currentCallIdstring | nullnullThe 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.

PropTypeMeaning
conversationIdstringRequired.
labelFor / avatarForresolversSee Names and avatars.
onBack() => voidShows a back button.
onPress() => voidMakes the title clickable.
onMembersPress() => voidShows a members button.
rightSlotReactNodeYour own content on the right.

<ThreadView>

A thread's replies and a composer, as used by ConversationView. Esc and the close button call onClose.

PropTypeMeaning
conversationIdstringRequired.
rootMessageMessageRequired. The message the thread hangs off.
onClose() => void
renderMessage(msg, currentUserId) => ReactNodeReplace each reply.

<MessageBubble>

One message bubble: sender label, quote, attachments, body, time and read tick. It renders; it doesn't fetch.

PropTypeDefaultMeaning
messageMessagerequired
currentUserIdstring | nullrequiredPoolse 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.
maxBodyLengthnumber—"Read more" threshold.
markdownboolean—
showSenderNameboolean—
actionsTriggerReactNode—Content placed in the bubble for opening actions.
showAttachmentsboolean—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.

PropTypeMeaning
messageMessageRequired.
currentUserIdstring | nullRequired.
readState'sent' | 'read'
editingbooleanRequired. Shows a text box instead of the body.
onSave(body: string) => Promise<unknown> | voidRequired. Enter saves.
onCancel() => voidRequired. 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).

PropType
onReact(emoji: string) => void
onReply() => void
onQuote() => void
onCopy() => void
onEdit() => void
onDelete() => void
childrenReactNode

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

PropTypeMeaning
onSend(body: string, opts?: { quoted_message_id?: string; attachment_ids?: string[] }) => Promise<unknown> | voidRequired.
onTyping() => voidCalled as the user types; wire it to useTyping().signalTyping.
placeholderstring
disabledboolean
replyingToMessage | nullShows the message being quoted.
onCancelReply() => void
labelFor(externalId) => string
attachmentsbooleanTurn 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.

PropTypeMeaning
conversationIdstringRequired.
onSend(req: MessageCreateRequest) => Promise<unknown> | voidRequired.
onTyping, placeholder, disabled, labelFor, replyingTo, onCancelReply, attachmentsAs on MessageComposer.

<TypingIndicator>

PropTypeMeaning
typingSet<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.

PropTypeDefaultMeaning
messageIdstringrequired
conversationIdstringrequired
initialReactionsReactionMap—Usually message.reactions.
currentUserIdstring | null—Poolse user id; enables instant updates.
pickerbooleantrueShow 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.

PropTypeMeaning
attachmentAttachment | { id: string }Required. With only an id, it guesses image or file from the download URL.
onClick(att: { id: string }) => voidReplaces the default click.

<UploadQueueStrip>

One chip per upload, with progress and cancel and dismiss buttons.

PropType
itemsUploadItem[] (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.

PropTypeMeaning
selectedIdstring | nullHighlights a row.
onSelect(conv: Conversation) => void
renderItem(conv, selected) => ReactNodeReplace each row.
emptyStateReactNode
conversationsConversation[]Pass these (with loading, error, unreadCounts) to render your own state instead of calling useConversations inside.
loadingboolean
errorError | null
unreadCountsRecord<string, number>
labelFor / avatarForresolvers

<MemberList>

A conversation's members with role badges and online dots.

PropTypeMeaning
conversationIdstringRequired.
labelFor / avatarForresolvers
onlineExternalIdsSet<string>Who to mark online, for example usePresence().online.
canRemove(m: Membership) => booleanShows a remove button where it returns true. Removing calls useMembers().removeMember.
renderItem(m: Membership) => ReactNodeReplace each row.
emptyStateReactNode

<UserPickerSheet>

The modal behind New chat and New group.

PropTypeMeaning
visiblebooleanRequired.
onClose() => voidRequired.
usersInboxUser[]Required.
excludeIdsstring[]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) => voidsingle mode.
onCreateGroup(name, memberExternalIds) => voidgroup mode.

<GroupDetailsSheet>

A modal with the group's name, avatar and members.

PropTypeMeaning
visiblebooleanRequired.
onClose() => voidRequired.
conversationIdstringRequired.
labelFor / avatarForresolvers
footerReactNodeYour own actions at the bottom.

<CallLogRow>

A call-log message drawn as a centred call row ("Missed video call", "Outgoing voice call · 3:12").

PropTypeMeaning
msgMessage (at least type, body, inserted_at, deleted_at)Required.
meIdstring | nullRequired. Poolse user id of the viewer.
onCallBack(media) => voidShows "Call back" where it makes sense.

<LiveCallBanner>

"Call in progress · Join". ConversationView shows it when you pass onJoinCall.

PropTypeMeaning
callLiveCallRequired. From useLiveCall.
inCallnumberRequired. How many people are in it.
onJoin(call: LiveCall) => voidRequired.

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.

PropTypeDefaultMeaning
roomUseRoomrequired
allowStartbooleantruefalse 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.

PropTypeMeaning
roomUseRoomRequired.
titlestring
labelFor(userId) => stringPoolse user id to name.
classNamestring

<CallVideoGrid>

The video tiles on their own, for your own call screen.

PropTypeMeaning
participantsCallParticipant[]Required. From useCallRoom().participants.
labelFor(userId) => stringPoolse user id to name.
classNamestring
onLocalClick() => voidMakes 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.

PropTypeDefault
srcstring | null—
namestring | null— (used for initials and aria-label)
onlineboolean—
size'sm' | 'md' | 'lg''md'
classNamestring—

<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 example send, attachment, reply, check-double, mic-off, video, phone-off, users. size defaults to 1.2em. Pass label={null} for a decorative icon. Other SVG props pass through. IconName is the type of name.
  • <PoolseLogo variant? size? label? />: variant is 'lockup' (default), 'mark', 'wordmark' or 'mono' (PoolseLogoVariant); size defaults to 28.
  • <PoolseFonts /> and usePoolseFonts(enabled = true): add the Google Fonts link for Bricolage Grotesque, Hanken Grotesk and JetBrains Mono to the page, once. ConversationView does this unless loadFonts={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;
}
GroupVariables
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.
  • TypingIndicator is role="status" with aria-live="polite".
  • MentionInput is a combobox with a listbox of options.
  • ReactionPicker is a menu with arrow-key navigation; Esc closes it.
  • Avatar is role="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

  1. CSS variables, for colours, type and shape.
  2. Feature flags on ConversationView (threads={false}, …).
  3. Render props: renderMessage on ConversationView and ThreadView, renderItem on ConversationList and MemberList, renderConversationRow on PoolseInbox.
  4. Build your own view from the exported pieces (MessageBubble, MessageComposer, TypingIndicator, ThreadView, …) and the @poolse/react hooks. The fully wired row that ConversationView uses 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.

@poolse/react-ui · poolse docs