@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 gainsreplyTarget: Message | null,replyPreviews,startReply(id),cancelReply(),jumpToMessage(id),loadNewerMessages(),returnToLatest(),clearHighlight(),highlightedMessageId,jumpInFlight,hasNewerMessages,isLoadingNewer,windowModeand the capability flagscanJumpToMessages/canResolveReplyPreviews. - Replying.
startReplycaptures a confirmed row (a no-op for pending or removed ones and while the caller's role is known to beREAD) and leaves edit mode; the two are mutually exclusive. The quote rides on the existing send:sendMessagestampsreplyToMessageIdon 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.
replyPreviewshas three states: aReplyPreviewonce 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 amessage_deletedevent or delete response (terminal), by amessage.updatedrow 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
windowModetojumped. 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.sendMessagereturns to the tail first and does not send if that fails. - Default surfaces. Rows offer
Reply to messageon every confirmed message the caller may write to — it does not require the row to be their own and is never routed throughcanEditMessage; narrow it withcanReplyToMessage. A reply renders a.ckui-message-quoteblock (Original message unavailablewhen the parent is gone), activatable asGo to quoted message. The composer shows a cancellable.ckui-composer__replyingstrip, andJump to latestleaves a jumped window. New tokens:--ckui-highlightand thequoteappearance part. - Adapters.
ConvoKitUiClientgains OPTIONALgetMessageContext?(conversationId, options)andgetReplyPreviews?(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,MessageContextOptionsandMessageContextPageare re-exported. - Mixed fleet. A 0.8 client ignores
replyToMessageIdentirely. 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 througherror, 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 itsJump to latestway back, which reloads through the ordinary history route such a backend still serves. A coded404 MESSAGE_NOT_FOUNDnever 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 exposeseditingMessage(the snapshot being edited,nulloutside edit mode),canEditMessages/canDeleteMessages(whether the adapter has the members),startEditing(messageId),cancelEditing(),saveEdit(text): Promise<boolean>anddeleteMessage(messageId): Promise<boolean>.startEditingis a no-op for foreign, pending or removed rows, when your role is known to beREAD, and without adapter support; it sends nothing.saveEditsends the snapshot'srevision, never the live row's: the server compares it against what the user saw.trueends edit mode;falsekeeps it, witherrorset.deleteMessageis never optimistic: the row goes once the server confirms (or answersMESSAGE_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), setserrorand 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
errorwithout evicting history. Cancel, Escape and a successful save restore the stash (emittingonTypingChangefor 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: nulland 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 onmessage_deleted. Late edit responses, hydrations and row images for a removed id are dropped, a latemessage_deletedfor 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.7updatedAt ?? createdAtrule. - Default rows. Eligible rows (own, confirmed, role not
READ, callback present) render.ckui-message-actionswithEdit messageandDelete messagebuttons, 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; neverdisplay: none. Delete opens an inlinerole="group"namedDelete this message?withDelete(Confirm delete) andCancel(Cancel delete); passconfirmDelete(message)to use your own dialog instead..ckui-message-edited(Edited,aria-labelEdited) sits beside the time on rows withrevision > 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 visibleCancelnamedCancel editing) precedes the form; the primary action becomes a check icon namedSave message(Send messageotherwise); Enter saves, Escape cancels. The singlesendeach view hands to its composer saves while editing, so custom composers need no branching; they additionally receiveediting?: { message, cancel }. - Controlled views.
ConversationViewacceptseditingMessage,onEditMessage,onSaveEdit(message, text)(falsekeeps 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) andconfirmDelete;MessageListViewacceptsonEditMessage,onDeleteMessage,canEditMessageandconfirmDelete. Without the callbacks the markup is byte-identical to 0.7.0. CustomrenderMessagefunctions receiveisEdited,canEdit,canDeleteand, exactly when available,edit()andremove()(which asksconfirmDeletewhen given, then callsonDeleteMessage). The boundConversationwires the controller only when the adapter supports the action, and passescanEditMessage/confirmDeletethrough. - Adapter additions (additive).
ConvoKitUiClientgains optionaleditMessage?(messageId, { text: string | null; revision: number })(resolving with the updatedMessage) anddeleteMessage?(messageId); the default adapter forwards both to the core's author calls. Reject a stale revision withstatus: 409andcode: 'REVISION_CONFLICT', a gone message withcode: 'MESSAGE_NOT_FOUND'; anything else keeps the row. Without the members no action renders and the controller methods reject.EditMessageInputis 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 forlistInboxin 0.6.0; seePARITY.md. - Mixed fleet. A 0.7 client ignores
revisionand keeps its timestamp rule; a 0.8 client on a 0.7 backend parses every row asrevision: 0, and the author calls answer an uncoded 404 that surfaces througherrorwithout removing anything. Consumer-builtMessageliterals gain the requiredrevisionmember.
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 exposemarkUnread(conversationId)andclearUnread(conversationId, { ifVersion? }).clearUnreadresolves with the server'scleared(this request removed a marker), not with whether the room is read: an unmarked room or a staleifVersionresolvesfalsewith 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'sprivateStateVersionis at or above the stored one, replacesunreadMarkedAtandprivateStateVersionand recomputesisUnreadas 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 througherrorwithout evicting rows. There is no built-in row affordance: call the methods fromrenderConversationItem, a menu or the controller you receive throughonControllerChange. - The dot rule. A row is unread (
data-unread, stronger title) whensummary.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 thebadgetoken withrole="img"and the accessible nameUnread. Never0 unread. Custom renderers keep receivingsummary, now withisUnread,unreadMarkedAtandprivateStateVersion. - Capture at open. SDK-backed rooms read the caller's own
conversation.membership(served only to the caller bygetConversationon a 0.7 backend) the first time the store'sconversationgoes from null to a DTO within an open: on theloadInitialhappy path, or on the reconcile that follows a transient first-load failure. A laterrefresh()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 nomembershipand 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 untargetedmarkConversationRead(conversationId), under the same triggers and visibility gating as the load acknowledgement: on load withmarkReadOnLoad(default), on an explicitmarkRead(), deferred while hidden and issued on becoming visible; never once a row is rendered and never for an unmarked room.cleared: falseis not an error. - Cross-device. A mark, a clear, or an acknowledgement that clears emits
the app-topic
inbox_activityon 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).
ConvoKitUiClientgains optionalmarkConversationUnread?(conversationId)(returning the coreConversationPrivateState) andclearConversationUnread?(conversationId, options?)(returningClearUnreadResult); the default adapter forwards both.markUnread/clearUnreadreject 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, butoptionsmay now carryprivateStateVersion: 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 extendmarkConversationReadwith the version, so custom implementers there adjust as they did forlistInboxin 0.6.0; seePARITY.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 custompageLoader,ConversationList/useConversationListpage the activity-ordered inbox by an opaque server cursor instead ofgetConversationsby offset.hasMorefollows the server'snextCursor. 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.
ConversationListStategainssummaries, aReadonlyMap<string, InboxSummary>keyed by conversation id (latestMessage,unreadCount,unreadCountCapped,readPosition,lastReadAt,activityAt), andcurrentUserId, the bound session's user (''without a session, in legacy mode and after dispose).conversationsstays aConversation[], 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 whosenextCursorequals the cursor you asked with, or an empty page that still claims more, fails withInbox 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.comparatorreplaces the row order entirely, as before. Leave it unset to keep inbox order; the old(a, b) => b.updatedAt - a.updatedAtpattern is no longer recommended becauseupdatedAtis not an activity key. - Refresh.
refresh()(also triggered by live signals) re-walks the inbox from the head in pages ofmin(100, target - consumed)withtarget = 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 andhasMoreatomically. An exhausted hidden inbox publishes an empty list withhasMore: false. Transient failures keep rows and seterror; 401/403 from either endpoint and 404 from the legacy endpoint evict; 400 keeps rows and setserror. - Default rows. The secondary line becomes the preview when a summary with
a non-empty body exists:
You: hifor your own latest message (DMs included),Ana: hifrom 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 (orFile),LocationorContact. Rows also show the activity time in the device zone and an unread badge:5, or99+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, or99+ unreadwhen 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.badgemaps to--ckui-badge(default#18181b; the label uses--ckui-outgoing-text). Override it throughConvoKitThemeProvideror the CSS variable. - Render props.
renderConversationItemreceives additive optionalsummaryandcurrentUserId;ConversationListViewaccepts optionalsummariesandcurrentUserId(absent means today's rows and noYou:prefix).inboxPreviewText(summary, conversation, currentUserId),mergeInboxEntriesandcompareInboxOrderare 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-topiconInboxActivity(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 ofactivityRefreshWindowMs(default 500) and later signals do not extend it; when it fires the list refetches once. Pass0to refetch on every signal. Manualrefresh()and reconnect reconciliation stay immediate. Room stores keep subscribing toonInboxChangedonly. - Inline Retry. When rows are rendered and
erroris set,Retryre-requests the next page ifhasMoreis true andonLoadMoreis 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 callsonRefresh. The button renders only when one of those callbacks exists. - The 404 fallback. If
listInboxanswers 404 (the route is absent after a rollback or on staging), the store marks the inbox unavailable for its lifetime, clearssummariesandcurrentUserId, warns once in the console and re-runs the same operation throughgetConversationswithout evicting rows. The next login (a new store) probes the inbox again. - Adapter additions (additive).
ConvoKitUiClientgains optionallistInbox?({ limit, cursor, archived })returning the coreInboxPage, andonInboxActivity?(handler, onError?). The default adapter forwards both toclient.listInboxandclient.realtime.onInboxActivity(client.clientId, …). Custom adapters withoutlistInbox, and any custompageLoader, keep the 0.5 offset path with emptysummaries.
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,ConversationViewandMessageListViewexpose and acceptreadPositionByUserIdbesidereadAtByUserId. Readers follow the unified rule from the core SDK'sreadThroughhelper: a user's read position covers the message by(createdAt, id)when present; otherwise the acknowledgement time inreadAtByUserIdmust be at or aftercreatedAt. Legacy participants (readPosition: null) therefore keep working, customreadersResolverfunctions and render props are unchanged, andreaderIdsFor/defaultReadersResolvertake 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
Conversationfollowsdocument.visibilityStateandvisibilitychange; hook consumers callcontroller.setVisible(boolean)themselves. The store is visible until told otherwise (nodocument,prerender,null/undefinedcount 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.markReadOnLoadandmarkReadOnReceivekeep their meaning: with bothfalseno 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 settingerror. A deletion re-issues only wheremarkReadOnReceiveis 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 aserror. - Custom
ConvoKitUiClientadapters:markConversationRead(conversationId, options?)gains an optional{ throughMessageId }argument. Forward it to your backend unchanged and reject an unknown target withcode: 'MESSAGE_NOT_FOUND'(see the 0.5.0 adapter change in the CHANGELOG). Participants and read events should carryreadPositionwhen 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/beforeIdvalues, 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.isReconcilingexposes 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
ConvoKitUiClientadapters must implementsessionIdentity,onConnectionEvent(includingonSessionEnded), typedonMessage,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 validateThe 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.
