npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@convokitapp/react-ui

v0.10.1

Published

Accessible, SDK-backed React chat components for ConvoKit.

Readme

ConvoKit React UI

Accessible, plug-and-play React chat components for @convokitapp/sdk. Defaults follow shadcn-style composition and CSS variables, use Radix Avatar for an accessible primitive, and remain framework-CSS independent.

The default skin uses neutral semantic tokens, separated list rows, compact square actions, restrained radii, and system typography so it fits naturally beside shadcn-style application UI. No Tailwind or shadcn runtime dependency is required.

0.9.0 quoted replies and jump to message

This release requires JavaScript SDK 0.9.x and the 0.9 backend (the new GET /api/v1/conversations/:id/context and GET /api/v1/conversations/:id/reply-previews routes). A member can quote a message when answering it, and anyone can jump from the quote to the original even when it is far outside the loaded history.

  • Controller members. useConversation / Conversation's controller gains replyTarget: Message | null, replyPreviews, startReply(id), cancelReply(), jumpToMessage(id), loadNewerMessages(), returnToLatest(), clearHighlight(), highlightedMessageId, jumpInFlight, hasNewerMessages, isLoadingNewer, windowMode and the capability flags canJumpToMessages / canResolveReplyPreviews.
  • Replying. startReply captures a confirmed row (a no-op for pending or removed ones and while the caller's role is known to be READ) and leaves edit mode; the two are mutually exclusive. The quote rides on the existing send: sendMessage stamps replyToMessageId on the optimistic row, omits the key entirely when no target is set, and clears the target only on success. The draft is never stashed — quoting adds context to what is being typed rather than replacing it.
  • Quoted parents are re-read, never copied. replyPreviews has three states: a ReplyPreview once resolved, the terminal 'unavailable' once a resolved batch reported the parent gone, and NO key while it is unresolved, which renders the reference with no quoted text. A parent inside the loaded window is derived from that window and costs no request; the rest are resolved by ONE batched call per page load, reconcile completion and live-insert burst. A call that rejects writes no entry for any id in it, so a failure never looks like a deletion. An entry is invalidated by a message_deleted event or delete response (terminal), by a message.updated row image or edit response (re-read on the next batch), and by a reconnect (every non-terminal entry).
  • Jumping. A target already on screen is centred and highlighted with no request. Anything else replaces the window with one centred context page and switches windowMode to jumped. A jump never re-opens the room: tombstones, the read-acknowledgement floor and the private state captured at open survive it, and it acknowledges nothing. While jumped both directions page through the context cursors, realtime inserts are recorded but not rendered (edits and deletions inside the window still apply), and a queued reconcile stays owed. returnToLatest() re-reads the newest page through the normal loader, renders the deferred rows and issues their acknowledgements; a failed reload stays jumped with the window intact. sendMessage returns to the tail first and does not send if that fails.
  • Default surfaces. Rows offer Reply to message on every confirmed message the caller may write to — it does not require the row to be their own and is never routed through canEditMessage; narrow it with canReplyToMessage. A reply renders a .ckui-message-quote block (Original message unavailable when the parent is gone), activatable as Go to quoted message. The composer shows a cancellable .ckui-composer__replying strip, and Jump to latest leaves a jumped window. New tokens: --ckui-highlight and the quote appearance part.
  • Adapters. ConvoKitUiClient gains OPTIONAL getMessageContext?(conversationId, options) and getReplyPreviews?(conversationId, messageIds), so existing adapters keep compiling; the Flutter and Android UI adapters add them as required members, as they did in 0.6.0. ReplyPreview, MessageContextOptions and MessageContextPage are re-exported.
  • Mixed fleet. A 0.8 client ignores replyToMessageId entirely. A 0.9 client on a 0.8 backend gets an uncoded 404 from both new routes: quotes stay UNRESOLVED (never 'unavailable' — an absent route says nothing about whether the parent exists), the failure surfaces once through error, and the capability is retired for the store's life so the affordance disappears instead of failing on every page. Retiring the jump hides the quote's activation and refuses new jumps; a window that is ALREADY jumped keeps its Jump to latest way back, which reloads through the ordinary history route such a backend still serves. A coded 404 MESSAGE_NOT_FOUND never retires anything: on a jump it marks that quote 'unavailable' instead of surfacing an error, which is the normal outcome of quoting a message someone has since deleted.

0.8.0 edit and delete your own messages

This release requires JavaScript SDK 0.8.x and the 0.8 backend (the author routes PATCH/DELETE /api/v1/messages/:id/own). Members can fix or retract what they sent; other members see the change or the removal live, and a message whose content changed after the send is labelled Edited.

  • Controller members. useConversation / Conversation's controller exposes editingMessage (the snapshot being edited, null outside edit mode), canEditMessages / canDeleteMessages (whether the adapter has the members), startEditing(messageId), cancelEditing(), saveEdit(text): Promise<boolean> and deleteMessage(messageId): Promise<boolean>. startEditing is a no-op for foreign, pending or removed rows, when your role is known to be READ, and without adapter support; it sends nothing. saveEdit sends the snapshot's revision, never the live row's: the server compares it against what the user saw. true ends edit mode; false keeps it, with error set. deleteMessage is never optimistic: the row goes once the server confirms (or answers MESSAGE_NOT_FOUND), and stays on any other failure.
  • Conflicts are visible. A stale revision (someone else, or another device, edited first) answers 409 REVISION_CONFLICT: the store reloads the row once, renders the current content, moves the snapshot to it (the banner shows what changed and the next save carries the fresh revision), sets error and keeps the text in the composer. The same state is entered locally, without a request, when a newer revision of the edited row arrives while editing. A reload that finds the message gone removes it and ends edit mode.
  • Failed edits preserve drafts. Entering edit mode stashes the unsent draft and prefills the field with the message text silently (no typing update). The field is never cleared while a save is in flight; a network failure, 403, 500 or an uncoded 404 (a 0.7 backend, a proxy) keeps the text and edit mode and surfaces through error without evicting history. Cancel, Escape and a successful save restore the stash (emitting onTypingChange for a non-empty one); if the row is removed while editing, text you changed stays and an untouched or emptied field gets the stash back.
  • Caption clearing. Save is enabled when the trimmed field is non-empty or the row has attachments: saving an empty field on a message with attachments sends text: null and keeps every attachment as it is. A text-only message can never be saved empty (no request). Author edits change text only; the attachment button is hidden while editing.
  • Deletion safety. The deleting device tombstones the id on success or MESSAGE_NOT_FOUND; other devices tombstone on message_deleted. Late edit responses, hydrations and row images for a removed id are dropped, a late message_deleted for it is a no-op, and a reconnect still tombstones ids missing from the refetched range. Deleting a message removes it and its attachments from the conversation for every member and cannot be undone; files already received or downloaded cannot be retracted.
  • Precedence. When both rows carry a usable revision (both present, at least one above 0) the higher revision wins everywhere rows meet (row images, hydration, send and edit responses, refresh overlay, history pages) and a lower one never overwrites; equal revisions, all-zero pairs and rows without the member fall back to the 0.7 updatedAt ?? createdAt rule.
  • Default rows. Eligible rows (own, confirmed, role not READ, callback present) render .ckui-message-actions with Edit message and Delete message buttons, always in the DOM. The only rule concealing them (opacity: 0 + pointer-events: none) lives inside @media (hover: hover) and (pointer: fine) and is lifted on .ckui-message-row:hover / :focus-within, so touch and keyboard users always see them; never display: none. Delete opens an inline role="group" named Delete this message? with Delete (Confirm delete) and Cancel (Cancel delete); pass confirmDelete(message) to use your own dialog instead. .ckui-message-edited (Edited, aria-label Edited) sits beside the time on rows with revision > 0, for any sender, never on pending rows. Ineligible rows render exactly as in 0.7.
  • Default composer. While editing, a role="status" banner (Editing message, the original text, a visible Cancel named Cancel editing) precedes the form; the primary action becomes a check icon named Save message (Send message otherwise); Enter saves, Escape cancels. The single send each view hands to its composer saves while editing, so custom composers need no branching; they additionally receive editing?: { message, cancel }.
  • Controlled views. ConversationView accepts editingMessage, onEditMessage, onSaveEdit(message, text) (false keeps edit mode and the text), onCancelEdit, onDeleteMessage, canEditMessage(message) (an override of the own-and-writable rule for both actions; pending rows are never eligible) and confirmDelete; MessageListView accepts onEditMessage, onDeleteMessage, canEditMessage and confirmDelete. Without the callbacks the markup is byte-identical to 0.7.0. Custom renderMessage functions receive isEdited, canEdit, canDelete and, exactly when available, edit() and remove() (which asks confirmDelete when given, then calls onDeleteMessage). The bound Conversation wires the controller only when the adapter supports the action, and passes canEditMessage / confirmDelete through.
  • Adapter additions (additive). ConvoKitUiClient gains optional editMessage?(messageId, { text: string | null; revision: number }) (resolving with the updated Message) and deleteMessage?(messageId); the default adapter forwards both to the core's author calls. Reject a stale revision with status: 409 and code: 'REVISION_CONFLICT', a gone message with code: 'MESSAGE_NOT_FOUND'; anything else keeps the row. Without the members no action renders and the controller methods reject. EditMessageInput is re-exported. The Flutter (Dart) and Android (Kotlin) UI adapters add the same members as required ones, so custom implementers there adjust as they did for listInbox in 0.6.0; see PARITY.md.
  • Mixed fleet. A 0.7 client ignores revision and keeps its timestamp rule; a 0.8 client on a 0.7 backend parses every row as revision: 0, and the author calls answer an uncoded 404 that surfaces through error without removing anything. Consumer-built Message literals gain the required revision member.

0.7.0 private mark unread

This release requires JavaScript SDK 0.7.x and the backend /unread routes. A viewer can flag a room to come back to; the marker is private (other members, webhooks and read events never see it) and unreadCount is not invented: a marked room with nothing unread shows a dot, not a number.

  • Controller methods. useConversationList / ConversationList's controller expose markUnread(conversationId) and clearUnread(conversationId, { ifVersion? }). clearUnread resolves with the server's cleared (this request removed a marker), not with whether the room is read: an unmarked room or a stale ifVersion resolves false with the current state. After the response the store looks the row up again in its current summaries (a refresh may have swapped them) and, only while it is still bound and the response's privateStateVersion is at or above the stored one, replaces unreadMarkedAt and privateStateVersion and recomputes isUnread as one unit; a lower version is ignored, so a delayed response can never bring back a marker a newer action removed. Failures reject and surface through error without evicting rows. There is no built-in row affordance: call the methods from renderConversationItem, a menu or the controller you receive through onControllerChange.
  • The dot rule. A row is unread (data-unread, stronger title) when summary.isUnread || unreadCount > 0 || unreadCountCapped. A count or a capped count keeps the numeric badge exactly as in 0.6, also while the marker is set (5, 99+, named <count> unread / 99+ unread); a room unread only through the marker renders .ckui-badge.ckui-badge--dot, an 8px circle in the badge token with role="img" and the accessible name Unread. Never 0 unread. Custom renderers keep receiving summary, now with isUnread, unreadMarkedAt and privateStateVersion.
  • Capture at open. SDK-backed rooms read the caller's own conversation.membership (served only to the caller by getConversation on a 0.7 backend) the first time the store's conversation goes from null to a DTO within an open: on the loadInitial happy path, or on the reconcile that follows a transient first-load failure. A later refresh() of the same open never re-reads it. Every targeted acknowledgement of that open sends { throughMessageId, privateStateVersion }; the server clears the marker only when the version still equals its own, so a mark made after the open (from the list, or another device) survives that open's acknowledgements until the room is reopened. Against a 0.6 backend the DTO has no membership and acknowledgements send no version, byte-identical to 0.6.
  • Empty rooms. A room with nothing rendered has no target whose acknowledgement could clear the marker. When the opened membership carried one, the store calls the adapter's clearConversationUnread(conversationId, { ifVersion: captured }) once per open, beside the unchanged legacy untargeted markConversationRead(conversationId), under the same triggers and visibility gating as the load acknowledgement: on load with markReadOnLoad (default), on an explicit markRead(), deferred while hidden and issued on becoming visible; never once a row is rendered and never for an unmarked room. cleared: false is not an error.
  • Cross-device. A mark, a clear, or an acknowledgement that clears emits the app-topic inbox_activity on the backend; the list's throttled refetch (activityRefreshWindowMs) picks it up, and room stores need nothing. A 0.6 list ignores the new fields and keeps its count-based badges.
  • Adapter additions (additive). ConvoKitUiClient gains optional markConversationUnread?(conversationId) (returning the core ConversationPrivateState) and clearConversationUnread?(conversationId, options?) (returning ClearUnreadResult); the default adapter forwards both. markUnread / clearUnread reject when they are absent, and the empty-room clear is skipped silently (such adapters leave the marker in empty rooms). markConversationRead(conversationId, options?) keeps its signature, but options may now carry privateStateVersion: forward it unchanged, or acknowledgements from your rooms never clear a marker. The Flutter (Dart) and Android (Kotlin) UI adapters add the same members as required ones and extend markConversationRead with the version, so custom implementers there adjust as they did for listInbox in 0.6.0; see PARITY.md.

0.6.0 inbox previews and unread counts

This release requires JavaScript SDK 0.6.x and the backend inbox route (GET /api/v1/inbox). SDK-backed lists now show what an inbox is expected to show: the latest message, when it happened, and how much is unread, in activity order.

  • Inbox mode. With the default adapter (or any adapter exposing listInbox) and no custom pageLoader, ConversationList / useConversationList page the activity-ordered inbox by an opaque server cursor instead of getConversations by offset. hasMore follows the server's nextCursor. Rows are ordered by the newest surviving message, or the creation time of an empty room; edits do not move a room, deleting its newest message recomputes both its preview and its position.
  • State. ConversationListState gains summaries, a ReadonlyMap<string, InboxSummary> keyed by conversation id (latestMessage, unreadCount, unreadCountCapped, readPosition, lastReadAt, activityAt), and currentUserId, the bound session's user ('' without a session, in legacy mode and after dispose). conversations stays a Conversation[], so every existing filter, comparator, page loader and row renderer keeps compiling and working.
  • Merge and ordering rule. Pages merge by conversation id with the later entry winning (a room that moved carries its new summary), and the loaded window is re-ordered (activityAt desc, id desc) whenever pages are merged. Server order is authoritative within one page. A page whose nextCursor equals the cursor you asked with, or an empty page that still claims more, fails with Inbox pagination did not advance; a full page of ids you already hold is valid (rooms fall below the cursor as others are bumped above it) and merges in place while the scan continues.
  • Comparator note. filter.comparator replaces the row order entirely, as before. Leave it unset to keep inbox order; the old (a, b) => b.updatedAt - a.updatedAt pattern is no longer recommended because updatedAt is not an activity key.
  • Refresh. refresh() (also triggered by live signals) re-walks the inbox from the head in pages of min(100, target - consumed) with target = max(pageSize, loadedCount) until the window is covered and something is visible, keeps walking past a fully hidden head page, and then swaps rows, summaries, cursor and hasMore atomically. An exhausted hidden inbox publishes an empty list with hasMore: false. Transient failures keep rows and set error; 401/403 from either endpoint and 404 from the legacy endpoint evict; 400 keeps rows and sets error.
  • Default rows. The secondary line becomes the preview when a summary with a non-empty body exists: You: hi for your own latest message (DMs included), Ana: hi from a known, named sender in a room with more than two participants, otherwise just the body. The body is the trimmed text or, for a media-only message, Photo, the file name (or File), Location or Contact. Rows also show the activity time in the device zone and an unread badge: 5, or 99+ above 99 and whenever the count is capped (the server counts at most 1,000 messages after your read position). The badge's accessible name carries the real count (100 unread, or 99+ unread when capped) and its visible label is hidden from the accessibility tree; the title uses the stronger weight while unread. Rooms without a message, an empty body, or rows served by the legacy path keep the 0.5 participants/description line.
  • Badge token. ConvoKitTheme.badge maps to --ckui-badge (default #18181b; the label uses --ckui-outgoing-text). Override it through ConvoKitThemeProvider or the CSS variable.
  • Render props. renderConversationItem receives additive optional summary and currentUserId; ConversationListView accepts optional summaries and currentUserId (absent means today's rows and no You: prefix). inboxPreviewText(summary, conversation, currentUserId), mergeInboxEntries and compareInboxOrder are exported for custom rows and controlled lists.
  • Live signals and activityRefreshWindowMs. onInboxChanged (membership, titles, deletions, join/rejoin) still refetches immediately and cancels any pending activity window. The new app-topic onInboxActivity (message inserts/edits and read-position advances, delivered to every connected client of the app) is throttled with max-wait semantics: the first signal starts a window of activityRefreshWindowMs (default 500) and later signals do not extend it; when it fires the list refetches once. Pass 0 to refetch on every signal. Manual refresh() and reconnect reconciliation stay immediate. Room stores keep subscribing to onInboxChanged only.
  • Inline Retry. When rows are rendered and error is set, Retry re-requests the next page if hasMore is true and onLoadMore is bound (bypassing the view's duplicate-request guard, which would otherwise swallow a retry of a page that never advanced the row count); otherwise it calls onRefresh. The button renders only when one of those callbacks exists.
  • The 404 fallback. If listInbox answers 404 (the route is absent after a rollback or on staging), the store marks the inbox unavailable for its lifetime, clears summaries and currentUserId, warns once in the console and re-runs the same operation through getConversations without evicting rows. The next login (a new store) probes the inbox again.
  • Adapter additions (additive). ConvoKitUiClient gains optional listInbox?({ limit, cursor, archived }) returning the core InboxPage, and onInboxActivity?(handler, onError?). The default adapter forwards both to client.listInbox and client.realtime.onInboxActivity(client.clientId, …). Custom adapters without listInbox, and any custom pageLoader, keep the 0.5 offset path with empty summaries.

0.5.0 precise read positions

This release requires JavaScript SDK 0.5.x and the backend read-position migration. Reads are now acknowledged through a concrete message instead of "everything as of now", so a delayed request cannot mark a message that arrived after the client rendered as read.

  • SDK-backed rooms call markConversationRead(conversationId, { throughMessageId }) with the newest non-pending row of the rendered list, ordered by (createdAt, id). A media-only row is acknowledged only once it is rendered, by its hydration or by a refresh page that already carries it; the raw realtime row is never the target. The untargeted legacy form is sent only when no message is rendered (an empty room).
  • useConversation, ConversationView and MessageListView expose and accept readPositionByUserId beside readAtByUserId. Readers follow the unified rule from the core SDK's readThrough helper: a user's read position covers the message by (createdAt, id) when present; otherwise the acknowledgement time in readAtByUserId must be at or after createdAt. Legacy participants (readPosition: null) therefore keep working, custom readersResolver functions and render props are unchanged, and readerIdsFor / defaultReadersResolver take the position map as an optional third/second argument. The local user's own read is never fabricated locally.
  • Acknowledgements are gated on visibility. The SDK-backed Conversation follows document.visibilityState and visibilitychange; hook consumers call controller.setVisible(boolean) themselves. The store is visible until told otherwise (no document, prerender, null/undefined count as visible). While hidden, load/receive acknowledgements are deferred and only a suppressed acknowledgement is re-issued on becoming visible. At most one request is in flight; a follow-up is resolved to the newest rendered row at send time and skipped when it is not newer than the last acknowledged target. markReadOnLoad and markReadOnReceive keep their meaning: with both false no read request is ever sent, including on visibility changes.
  • A targeted request that fails with ConvoKitError.code === 'MESSAGE_NOT_FOUND' (or a bare 404 from an adapter that exposes only a status), or deletion of the in-flight/last-acknowledged target, marks that id unacknowledgeable and re-issues once with the newest remaining row without setting error. A deletion re-issues only where markReadOnReceive is enabled, so a room that opted out of receive acknowledgements never sends one because a deletion arrived. A membership 404 (any other code) still surfaces as error.
  • Custom ConvoKitUiClient adapters: markConversationRead(conversationId, options?) gains an optional { throughMessageId } argument. Forward it to your backend unchanged and reject an unknown target with code: 'MESSAGE_NOT_FOUND' (see the 0.5.0 adapter change in the CHANGELOG). Participants and read events should carry readPosition when available.
  • Mixed fleet: precise receipts need the sender and the reader on 0.5. A 0.4 receiver keeps timestamp semantics but keeps parsing the additive payload.

0.4.0 live inbox, sending and recovery

This release requires JavaScript SDK 0.4.x and the coordinated backend. The core SDK discovers current private room/app topics automatically and rejoins when membership changes; no additional customer configuration is needed.

  • The default adapter exposes a session identity that stays stable on token renewal and changes on every new login, including the same user ID. Room and inbox caches are cleared when that session ends. After reconnecting a user, update application authentication state so the hook/composable can bind the new session (or recreate the adapter).
  • Initial history and older pages preserve live changes received while loading. Message pagination uses newest-first beforeCreatedAt/beforeId values, not an offset calculated from the current visible row count. Page sizes are integers from 1 through 100.
  • refresh() and successful room-channel joins reconcile the entire viewed history range, retaining the old snapshot until all required pages succeed. isReconciling exposes this background work. Transient failures preserve the view for retry; authoritative history 401/403/404 responses clear it.
  • Typed INSERT/UPDATE events and private ID-only deletion events are distinct. Deletion removes text and attachment cards, and a late HTTP response cannot restore a known-deleted message. A reconnect also removes deletions missed while offline.
  • Raw Postgres events contain the message row, not its related attachments. The UI displays text immediately and calls authenticated getMessage(id) for the complete message, including files and images. Existing attachments remain visible during edits until the full response confirms a change; media-only messages wait for that response instead of showing empty bubbles. This adds a REST lookup for observed row changes, limited to eight concurrent lookups per room store and coalesced by message ID. Obsolete responses cannot restore deleted messages or data from a previous login.
  • Read receipts use persisted participant positions and server read events, monotonically. A successful mark-read request does not fabricate a timestamp from the device clock. Remote typing expires and clears on disconnect.
  • Pending sends are bound to their original room/session. Reconciliation uses the exact clientMessageId, sender and room, never identical text or file count. A matching live/history row replaces its pending bubble immediately, even before HTTP acknowledgement; a later lost response cannot fail a confirmed send.
  • Custom ConvoKitUiClient adapters must implement sessionIdentity, onConnectionEvent (including onSessionEnded), typed onMessage, onMessageDeleted, getMessage(id), and paired message cursors. The message lookup must enforce the same app/room authorization and return the complete current record, including an authoritative attachment list. Custom inbox page loaders must be scoped to the same authenticated app and return bounded pages; their filters restart at offset zero. No legacy adapter fallback is provided.

The managed API default and customer token-provider workflow are unchanged. The SDK discovers the Supabase URL and publishable key automatically; customers do not configure those values or ship a backend secret.

SDK-backed components consume private inbox invalidations automatically, including after reconnect, while preserving filters and the loaded page window. It also correlates pending messages with live/history rows using the exact clientMessageId, so a live-first delivery never produces two bubbles. These changes require the paired 0.4.0 core and coordinated backend update.

For 0.4.0, custom adapters must add onInboxChanged(handler, onError?), notifying on committed changes and every initial/reconnected subscription. Forward clientMessageId unchanged in sends and parsed messages. Default SDK adapters do this for you; no component props or demo-specific reconciliation code is required.

Install published packages

npm install @convokitapp/sdk @convokitapp/react-ui

@convokitapp/react-ui 0.8.x requires @convokitapp/sdk >=0.8.0 <0.9.0.

Import the packaged styles once:

import '@convokitapp/react-ui/styles.css'

SDK-backed UI

Connect the core SDK, adapt it once, and render the list and selected room. The client secret belongs only in your token backend.

import { ConvoKitClient } from '@convokitapp/sdk'
import {
  Conversation,
  ConversationList,
  createConvoKitUiClient,
} from '@convokitapp/react-ui'

const sdk = new ConvoKitClient({
  clientId: 'public-client-id',
  tokenProvider: async (appUserId) =>
    fetch('/api/chat/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ appUserId }),
    }).then(async (response) => (await response.json()).token),
})

await sdk.connectUser(currentUser.id)
const uiClient = createConvoKitUiClient(sdk)

<ConversationList
  client={uiClient}
  onConversationSelect={(room) => setRoomId(room.id)}
/>

<Conversation
  client={uiClient}
  conversationId={roomId}
  onAttachmentClick={(media) => openAttachment(media)}
/>

The core SDK uses ConvoKit's managed https://api.convokit.app endpoint. Set backendUrl only for local testing or a self-hosted deployment.

SDK-backed lists render inbox previews and unread badges from listInbox and refetch on inbox activity (see the 0.6.0 notes above); the controller's markUnread / clearUnread set and remove the viewer's private marker (see the 0.7.0 notes above). SDK-backed conversations render an outgoing message immediately, reconcile it with the server response and realtime echo, and restore an unchanged draft if the send fails. They acknowledge reads through the newest rendered message while the document is visible, carrying the private state version captured at open; see the 0.5.0 and 0.7.0 notes above. Own confirmed rows offer Edit message / Delete message, the composer turns into an edit banner with Save message / Cancel editing, and edited rows are labelled Edited; see the 0.8.0 notes above. Every confirmed row the viewer may write to offers Reply to message, a reply renders its quoted parent with Go to quoted message, the composer shows a cancellable Replying to … strip, and a jump centres, focuses and briefly highlights its target with Jump to latest to come back; see the 0.9.0 notes above.

Optimistic rows display Sending… until acknowledgement. The final server timestamp is formatted in the viewer's local timezone. Custom renderers can use isConvoKitPendingMessage(message) to present the same state.

Controlled components

Use ConversationListView, ConversationView, and MessageListView when your application owns state. useConversationList and useConversation expose the same controller capabilities as the Flutter UI package without forcing a state-management library. Pass summaries and currentUserId from useConversationList to ConversationListView to render previews and badges (both optional), and readPositionByUserId (plus readAtByUserId for users without a position) to the conversation views to render receipts; when you drive useConversation yourself, call setVisible(false) while the room is not on screen so acknowledgements wait until it is. Edit mode is a pure function of editingMessage plus the callbacks: pass the controller's editingMessage, startEditing, saveEdit, cancelEditing and deleteMessage (or your own state) to ConversationView; leave them out and no action renders. Replying and jumping work the same way: replyTarget, onReplyToMessage, onCancelReply, replyPreviewByMessageId, onJumpToMessage, highlightedMessageId, jumpInFlight, onClearHighlight, hasNewerMessages, isLoadingNewer, onLoadNewer and onReturnToLatest are props, and MessageListViewProps takes all of them except replyTarget, onCancelReply and onReturnToLatest. jumpInFlight is the store's guard: while it is set the view suppresses stick-to-bottom, both pagination triggers and the highlight clear, because a programmatic scroll fires the same event a user's does.

const room = useConversation({ client: uiClient, conversationId: roomId })

<ConversationView
  conversation={room.conversation!}
  messages={room.messages}
  currentUserId={room.currentUserId}
  onSendMessage={async (text) => (await room.sendMessage({ text })) !== null}
  readPositionByUserId={room.readPositionByUserId}
  editingMessage={room.editingMessage}
  onEditMessage={(message) => room.startEditing(message.id)}
  onSaveEdit={(_message, text) => room.saveEdit(text)}
  onCancelEdit={room.cancelEditing}
  onDeleteMessage={(message) => room.deleteMessage(message.id)}
  confirmDelete={(message) => window.confirm(`Delete "${message.text ?? 'this message'}"?`)}
  replyTarget={room.replyTarget}
  onReplyToMessage={(message) => room.startReply(message.id)}
  onCancelReply={room.cancelReply}
  replyPreviewByMessageId={room.replyPreviews}
  onJumpToMessage={room.jumpToMessage}
  highlightedMessageId={room.highlightedMessageId}
  jumpInFlight={room.jumpInFlight}
  onClearHighlight={room.clearHighlight}
  hasNewerMessages={room.hasNewerMessages}
  isLoadingNewer={room.isLoadingNewer}
  onLoadNewer={room.loadNewerMessages}
  onReturnToLatest={room.returnToLatest}
  error={room.error}
/>

const list = useConversationList({ client: uiClient, activityRefreshWindowMs: 500 })

<ConversationListView
  conversations={list.conversations}
  summaries={list.summaries}
  currentUserId={list.currentUserId}
  hasMore={list.hasMore}
  error={list.error}
  onLoadMore={list.loadMore}
  onRefresh={list.refresh}
  onConversationSelect={(room) => setRoomId(room.id)}
  renderConversationItem={({ conversation, summary, currentUserId, onSelect }) => (
    <div>
      <button onClick={onSelect}>
        {conversation.displayTitle}
        {summary ? ` · ${inboxPreviewText(summary, conversation, currentUserId)}` : null}
        {summary && summary.unreadCount > 0 ? ` (${summary.unreadCount})` : summary?.isUnread ? ' •' : null}
      </button>
      <button onClick={() => void list.markUnread(conversation.id)}>Mark unread</button>
    </div>
  )}
/>

Customization

Outgoing messages show a compact sent/read check beside the timestamp. The read check announces the reader count to assistive technology. Use renderReadReceipt if your app wants additional visible receipt details.

Every meaningful section can be replaced through render props, including the conversation row, separator, header, message, media block, read receipt, typing indicator, composer, and loading/empty/error states.

<ConversationView
  {...state}
  onSendMessage={send}
  density="compact"
  classNames={{ root: 'h-full', outgoingMessage: 'my-message' }}
  styles={{ composer: { padding: 16 } }}
  renderReadReceipt={({ readerIds }) => (
    <span>{readerIds.size ? `Seen by ${readerIds.size}` : 'Sent'}</span>
  )}
  renderMedia={({ media }) =>
    media.type === 'location' ? <MyMap location={media} /> : undefined
  }
  renderMessage={({ message, isEdited, edit, remove, reply, replyPreview, jumpToReplyTarget }) => (
    <div>
      {message.replyToMessageId ? (
        <button type="button" onClick={jumpToReplyTarget} disabled={!jumpToReplyTarget}>
          {replyPreview === 'unavailable'
            ? 'Original message unavailable'
            : replyPreview?.text ?? ''}
        </button>
      ) : null}
      {message.text}
      {isEdited ? <em> (edited)</em> : null}
      {reply ? <button onClick={reply}>Reply</button> : null}
      {edit ? <button onClick={edit}>Edit</button> : null}
      {remove ? <button onClick={() => void remove()}>Delete</button> : null}
    </div>
  )}
  renderComposer={({ value, setValue, send, editing, replying }) => (
    <form onSubmit={(event) => { event.preventDefault(); send() }}>
      {editing ? <p>Editing… <button type="button" onClick={editing.cancel}>Cancel</button></p> : null}
      {replying ? <p>Replying to {replying.message.senderId} <button type="button" onClick={replying.cancel}>Cancel</button></p> : null}
      <textarea value={value} onChange={(event) => setValue(event.currentTarget.value)} />
      <button type="submit">{editing ? 'Save' : 'Send'}</button>
    </form>
  )}
/>

send saves while editing is present and sends otherwise, so a custom composer needs no branching; edit, remove and reply are present on a row exactly when that action is available to the viewer. replyPreview is present only while the row quotes another message AND the view carries a resolution for it — an ABSENT replyPreview means "not resolved yet", which renders the reference with no quoted text, and is deliberately different from 'unavailable', which means the original is gone. Custom rows are still addressable by a jump: the library's own row wrapper carries data-message-id and tabindex="-1" whatever they render.

Set unstyled to remove package component classes while retaining behavior and semantic markup. Use ConvoKitThemeProvider or override the documented --ckui-* variables (including --ckui-badge for the unread badge and --ckui-highlight for the jump highlight) to theme defaults without rebuilding CSS.

Public examples

Read the complete React UI documentation. The public ConvoKit-React-UI-Examples repository contains runnable standard, branded-support, and compact-operations configurations plus a live SDK-backed example. The package implementation remains in its private source repository.

Verification

npm ci
npm run validate

The release gate type-checks source and tests, runs component/controller tests with coverage thresholds, builds ESM/CommonJS/TypeScript declarations and CSS, checks Flutter UI parity, validates package exports, and inspects the npm tarball.

Emoji reactions

Conversation renders a picker, count chips, and a paged reactor list. Custom rows can render the exported ReactionBar with their reaction context. The store batches visible summaries, handles ID-only invalidations, and refetches after reconnect. Emoji sequences are exact (👍 differs from 👍🏽); READ members can inspect reactors, while READ_WRITE members can toggle their own reaction.