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-native-ui

v0.10.0

Published

Extensible React Native conversation UI for ConvoKit.

Downloads

1,356

Readme

@convokitapp/react-native-ui

Controlled and SDK-backed ConvoKit chat components for bare React Native and Expo. The package has no navigation, picker, filesystem, or Expo dependency.

<ConvoKitConversationList
  sdk={client}
  onConversationSelected={setSelectedConversation}
/>

<ConvoKitConversation
  sdk={client}
  conversationId={selectedConversation.id}
  onBack={() => setSelectedConversation(null)}
/>

Use the *View components when application state is owned outside ConvoKit. All major rows and states accept render callbacks, and ConvoKitUiProvider provides platform-neutral theme tokens.

Pagination, optimistic sends, realtime reconciliation, deletion tombstones, edit mode for the caller's own messages, quoted replies and jump-to-message windows, typing indicators, read receipts, and media rendering live in shared controllers. Navigation, safe areas, pickers, downloads, and attachment opening stay behind application callbacks. This package deliberately imports neither Expo modules nor app-specific native libraries, keeping a future react-native-web target open.

Optimistic rows display Sending… until acknowledgement. A confirmed message shows its server timestamp, and Read by N once other participants have read it. ConvoKit does not report recipient delivery, so the default rows never claim a delivered state; replace renderReadReceipt for custom wording.

Read receipts

Receipts follow the server's read positions. ConversationState carries readPositionByUserId (each participant's monotonic ReadPosition, seeded from the conversation and advanced by read events) beside readAtByUserId (the acknowledgement time). A user has read a message when their position is at or after it in (createdAt, id) order; a legacy participant without a position falls back to lastReadAt >= createdAt. Positions never move backward, and the local user's own read is never invented on the device. The controlled views take the same two maps; readAtByUserId alone still works, and resolveReaderIds implements the rule for custom readersResolver callbacks.

Automatic acknowledgement keeps markReadOnLoad and markReadOnReceive (both default to true; both false means no read request is ever sent). The request names the newest rendered, confirmed message (throughMessageId), so a request that arrives late cannot mark later messages read. Only a newly rendered foreign message is a receive trigger; media-only rows count once they are hydrated, pending rows are never targeted, and a room without a confirmed rendered message sends nothing. Requests coalesce: one in flight, one follow-up resolved at send time, none when the target is at or before the last acknowledged one. Acknowledgements are gated on visibility: useConvoKitConversation and ConvoKitConversation report AppState through controller.setVisible, a hidden app defers the request, and returning to the foreground re-issues only a deferred one. Apps that own a controller can reuse useConvoKitVisibility(controller). If the server no longer knows the target (MESSAGE_NOT_FOUND), or the in-flight or last acknowledged target is deleted, the controller retries once with the next newest rendered row. A deletion re-issues only where markReadOnReceive is enabled: a room that opted out of receive acknowledgements never sends one because a deletion arrived (an explicit markRead() still acknowledges the newest rendered row). Membership failures still surface as error.

Custom ConvoKitUiClient adapters receive the target as markConversationRead(id, { throughMessageId }). Adapters that ignore it acknowledge the newest message on the server at request time, and adapters should reject an unknown target with an error whose code is MESSAGE_NOT_FOUND so the retry applies.

Mixed fleet: precise receipts need the sender and the reader on 0.5 with the backend read-position migration; 0.4 receivers keep timestamp semantics but keep parsing the additive payload.

Inbox previews and unread counts

ConvoKitConversationList and useConvoKitConversationList page the inbox (listInbox, cursor-based) whenever the adapter exposes it and no custom pageLoader is set. ConversationListState then carries summaries, a map from conversation id to InboxSummary (latestMessage, unreadCount, unreadCountCapped, readPosition, lastReadAt, activityAt, and since 0.7 isUnread, unreadMarkedAt, privateStateVersion), and currentUserId, the bound user ('' without a session, on the legacy path and after dispose). isRefreshing reports a background walk. conversations, filters, comparators, custom offset pageLoaders and the existing renderer signatures are unchanged.

Ordering follows the server: activityAt descending, then id descending. Pages are merged by conversation id, a later entry replaces an earlier one (a room that moved keeps its newest summary), and the activity order is re-applied whenever pages are combined, on loadMore and on refresh. Setting filter.comparator replaces that order; Conversation.updatedAt is never an ordering input. refresh() walks from the head in pages of at most 100 until the loaded window is covered and something is visible, then swaps rows, summaries and the cursor atomically, so it never publishes an empty list with hasMore while more pages exist. Cursor pages may legitimately repeat loaded ids; only a cursor that does not advance is an error. mergeInboxEntries and compareInboxActivity implement the rule for custom stores.

Live updates: the list subscribes to onInboxChanged (structural changes and deletions, refetched immediately) and onInboxActivity (message inserts and edits, read-position advances). Activity is throttled by activityRefreshWindowMs (default 500): the first signal starts the window, later signals ride along, and one refresh runs when it fires; 0 refreshes immediately. inbox_changed, a manual refresh() and a rejoin stay immediate and drop a pending activity timer. Room controllers subscribe to onInboxChanged only (never onInboxActivity) and reconcile the open room on every signal, exactly as on a rejoin, so a title or membership change, or a deletion whose message_deleted was missed, reaches the room without a foreground return. A signal that lands during the initial load, a reconcile or a history page queues one refresh() that runs afterwards (the SDK also delivers inbox_changed on every join, so an open room reconciles once right after loading), and a burst coalesces into that one refresh. The subscription is released with the room's other subscriptions on dispose and on the next loadInitial().

Default rows replace the participants/description line with a one-line preview when the summary has a latest message with a body: You: hi for the caller's own message (DMs included), Ana: hi for a listed, named sender in rooms with more than two participants, plain hi otherwise (DMs, departed or unnamed senders). Media-only messages read Photo, the file name or File, Location and Contact; a room without a body keeps today's line. The activity time is shown in the device zone. An unread badge renders while unreadCount > 0 or the count is capped: the visible label is the count, or 99+ above 99 and when the server capped it at 1,000; the accessible name is <count> unread (99+ unread when capped) and the visible label is hidden from the accessibility tree. The row's own accessible name appends , <count> unread. Unread titles use the heavier weight. The badge colour is the new theme token colors.badge (unset means primary). A room the caller marked unread without a count renders a numberless dot instead; see Mark unread.

Custom rows receive the additive summary and currentUserId on ConversationRowContext; conversationPreview(conversation, summary, currentUserId) and unreadBadge(summary) return the default strings. The controlled ConvoKitConversationListView accepts summaries and currentUserId (absent means today's rows and no You: prefix). Pull-to-refresh is driven by the pull itself, never by background refreshes. The inline error Retry requests the next page when hasMore and onLoadMore are set, otherwise it calls onRefresh, and renders only when one of those exists.

Custom ConvoKitUiClient adapters may implement the optional listInbox({ limit, cursor, archived }) and onInboxActivity(handler); DefaultConvoKitUiClient does. An adapter without listInbox, or a custom pageLoader, uses getConversations with summaries empty. A 404 from listInbox (the route is absent on a rolled-back backend) marks the inbox unavailable for that store, clears summaries, warns once and re-runs the same operation through getConversations without evicting rows; 401/403 from either endpoint and 404 from the legacy endpoint evict the rows, 400 keeps them and sets error.

Mark unread

Since 0.7 a user 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 changed. It needs the 0.7 backend and @convokitapp/react-native 0.7.

InboxSummary carries isUnread (unreadCount > 0 || unreadCountCapped || unreadMarkedAt !== null), unreadMarkedAt (null while nothing is marked) and privateStateVersion. A row is unread while isUnread, the count is above zero or the count is capped, and uses the heavier title weight. unreadBadge keeps returning the numeric badge for a count (99+ when capped, marker or not); when isUnread is true while the count is 0 and not capped it returns { label: '', accessibilityLabel: 'Unread', dot: true } and the default row renders an 8-pt circle in the badge colour with the accessible name Unread, never an invented count. The row's own accessible name becomes Open <title>, Unread. A summary built without isUnread keeps the numeric rule, so controlled lists with older fixtures render as before.

ConversationListController (and so useConvoKitConversationList) exposes markUnread(conversationId) and clearUnread(conversationId, { ifVersion? }). They call the adapter's markConversationUnread / clearConversationUnread; on any answer the response's { unreadMarkedAt, privateStateVersion } is applied to the room's current summary as one unit and isUnread is recomputed, but only when the response is newer than the stored version, so a delayed answer never resurrects a marker a newer action removed; an answer that lands after the store was disposed, retired or reloaded (possibly for another user) is dropped. clearUnread resolves the response's cleared: whether this request removed the marker, not whether the room is read. A stale ifVersion or an unmarked room resolves false with the current state applied. A request failure sets error, keeps the row and rejects (catch the promise), so only a cleared: false answer resolves false. An adapter without the two members makes both methods reject, and so does a controller that is not active: disposed, retired after the session ended, bound to a session the shared client has since replaced, or never loaded. Both reject with ConversationListController is not active before any request goes out, because a private marker must never be written under another user's login; error is untouched (there is no live snapshot to report into). Other devices learn of a change through onInboxActivity, which the list already refetches from. There is no default row action: wire one through a custom renderItem (a long-press, a menu) that calls the controller returned by useConvoKitConversationList, or pass your own controller to ConvoKitConversationList. On the legacy path (custom pageLoader, adapter without listInbox) the methods still call the adapter but there is no summary to patch.

Capture at open: ConversationController reads conversation.membership.privateStateVersion (the caller's own row, returned by getConversation from a 0.7 backend) the first time the open's conversation loads (on loadInitial, or on the refresh() that follows a transient first-load failure) and sends it as privateStateVersion with every targeted acknowledgement of that open. The backend clears the marker only when that version is still current, so a mark issued after the room opened survives the acknowledgements that open keeps sending; a later refresh() (including the one useConvoKitConversation triggers on foreground) never re-captures, and loadInitial() or a new controller captures again. Against a 0.6 backend (no membership) acknowledgements carry no version and are byte-identical to 0.6. An opened room that was marked and renders nothing has no target to acknowledge, so the controller calls clearConversationUnread(id, { ifVersion: captured }) once per open instead: under the same gating as the load acknowledgement (markReadOnLoad, deferred while hidden and issued on visibility, also by an explicit markRead()), and never once a row was rendered, because the targeted acknowledgement clears then. cleared: false is not an error; a failed request surfaces as error. The controller still never sends an acknowledgement without a target.

0.7.0 adapter change (additive): ConvoKitUiClient gains the optional markConversationUnread(id) and clearConversationUnread(id, { ifVersion? }); DefaultConvoKitUiClient implements both. Adapters without them keep compiling: the list methods reject and an empty marked room keeps its marker. markConversationRead(id, options) is unchanged, but options may now carry privateStateVersion: forward the options unchanged, since an adapter that drops them acknowledges without a version and never clears the marker.

Mixed fleet: a 0.6 list ignores isUnread and shows no dot; a 0.7 list against a 0.6 backend derives isUnread from the count with no marker, and the /unread calls fail with status 404.

Edit and delete your own messages

Since 0.8 a user can edit the text of their own messages and delete them. It needs the 0.8 backend and @convokitapp/react-native 0.8. Rows carry the core's Message.revision (0 when sent, +1 on every edit); revision > 0 is the only edited signal and the default rows show Edited beside the time. Consumer-built Message literals gain revision: 0.

Controller members. ConversationController (and useConvoKitConversation) owns edit mode. ConversationState carries editingMessage, the snapshot of the caller's message being edited (null outside edit mode), and canEditMessages / canDeleteMessages, whether the adapter implements the 0.8 members. startEditing(messageId) enters edit mode on one of the caller's own confirmed rows and is a no-op for foreign, pending, removed or unknown rows, for a READ role when the conversation reports one (the self-only membership, else the caller's participants entry) and without adapter support; cancelEditing() leaves it. saveEdit(text) trims the text (empty becomes null, which clears the caption of a message with attachments; a text-only message is never saved empty and sends nothing) and calls the adapter with the snapshot's revision, captured when editing began, never the live row's. Success merges the response under the deletion and precedence guards, leaves edit mode and resolves true. A stale revision (409 REVISION_CONFLICT) reloads the row once: the row shows the other content, the snapshot is refreshed so the next save carries the fresh revision, error carries the conflict and the composer keeps the edited text. The same conflict state is entered without a request whenever a row for the edited id with a higher revision reaches the store (an UPDATE image, a hydration, a reconcile), except while that row's own save is in flight: its images merge and wait for the response (a success ends edit mode; any other failure re-checks the row). A save or reload that answers 404 with code MESSAGE_NOT_FOUND removes the row and leaves edit mode; any other failure (403, network, a 0.7 backend's uncoded 404) sets error and keeps the row and the session; every failure resolves false. deleteMessage(messageId) removes the row only once the adapter resolves or answers MESSAGE_NOT_FOUND (tombstone first, so late edit responses, row images and hydrations for the id are dropped and the read acknowledgement re-targets), leaves edit mode when it was that row and resolves true; other failures keep the row, set error and resolve false. A remote message_deleted for the edited row, or a refresh() whose page no longer carries it, leaves edit mode.

Deletion safety across a reconcile: refresh() (a rejoin, an inbox_changed signal, the foreground return) tombstones every confirmed row it knew when the reconcile was requested that the refetched range no longer carries, exactly as a message_deleted would, so a deletion missed while offline cannot be resurrected by a late edit or send response, a hydration or a row image, and the read acknowledgement re-targets as for any deletion. The reconciled window is the rendered rows up to the server's page cap of 100. A full page reconciles from its oldest row on: rows older than that window are dropped from view, never tombstoned, and hasOlderMessages turns true so the next loadOlderMessages() brings them back. Tombstones last until the next loadInitial().

Row precedence: when two rows for one id both carry a usable revision and they differ, the higher one wins and a lower one never overwrites, so a late edit response, or a refresh() page fetched before an edit, never rewinds a newer live row; equal revisions, pending rows and rows from a 0.7 backend keep the updatedAt ?? createdAt rule.

Default rows. A row is eligible when the view was given onEditMessage / onDeleteMessage, the row is the caller's own and confirmed, and the caller's role is not READ. An eligible row is wrapped in a long-pressable with the Message actions accessibility action (and hint); a long press opens an Alert sheet with Edit message, Delete message and Cancel, and deleting confirms with Delete this message? (Delete / Cancel; dismissing declines). Pass confirmDelete(message) (resolving a boolean) to replace the dialog, and canEditMessage(message) to replace the own/role rule for both actions (pending rows stay ineligible). Rows that are not eligible, and every row without the callbacks, render exactly as in 0.7.

Default composer. While editingMessage is set the composer is in edit mode: the unsent draft is stashed and the field is prefilled with the message text without a typing update, a banner (a polite live region) reads Editing message with the original text beside Cancel (accessible name Cancel editing), and the primary action reads Save with the accessible name Save message (Send message otherwise). Save is enabled while the trimmed field is non-empty or the edited message has attachments, so a caption can be cleared; author edits change text only, so the Add attachment control is hidden while editing. Cancel and a successful save restore the stash and report typing for it; a failed save keeps the text and edit mode; when the host leaves edit mode externally (the row was removed) the field keeps user-changed text and restores the stash only when it is empty or unchanged. Custom composers receive the additive editing (the message) and cancelEdit on the renderComposer input (ComposerContext) while editing; send() saves then, so no branching is needed.

Controlled views. ConvoKitMessageListView accepts onEditMessage(message), onDeleteMessage(message) (false reports that nothing was deleted), canEditMessage and confirmDelete; ConvoKitConversationView adds editingMessage, onSaveEdit(message, text) (false keeps edit mode and the text, like onSendMessage) and onCancelEdit. Without the callbacks nothing new renders. Custom rows receive isEdited, canEdit, canDelete and, only while allowed, edit() and remove() on MessageRowContext. remove() asks confirmDelete when the view was given one and otherwise calls onDeleteMessage at once, resolving whether the row was deleted: confirmation UI is the custom row's own. Only the default row shows the built-in Delete this message? dialog when no confirmDelete is set. The bound ConvoKitConversation wires the controller (editingMessage, startEditing, saveEdit, cancelEditing, deleteMessage) only when the adapter supports the members and passes confirmDelete and canEditMessage through.

0.8.0 adapter change (additive): ConvoKitUiClient gains the optional editMessage(messageId, { text, revision }) (both keys always sent; a stale revision must reject with an error whose code is REVISION_CONFLICT, a message the server no longer knows with MESSAGE_NOT_FOUND) and deleteMessage(messageId); DefaultConvoKitUiClient implements both. Adapters without them keep compiling: no action renders and the two controller methods reject.

Attachments are kept as they are by an edit and are not retracted by a delete for members who already received them. Mixed fleet: a 0.7 view ignores revision and shows no actions; a 0.8 view against a 0.7 backend renders the actions but every save and delete fails with an uncoded 404 that keeps the row and the draft.

Quoted replies and jump to message

Since 0.9 a message can quote another message in the same room, and the reader can jump to the quoted one even when it is far outside the loaded window. It needs the 0.9 backend and @convokitapp/react-native 0.9. Rows carry the core's Message.replyToMessageId (absent when the row is not a reply); the reference is write-once, so an edit never changes it and it survives the deletion of the message it points at. Consumer-built Message literals may omit the key.

Controller members. ConversationState gains replyTarget (the message the next send quotes, null outside reply mode), replyPreviews (quoted parents by message id), highlightedMessageId, windowMode (live or jumped), hasNewerMessages, isLoadingNewer, and canJumpToMessages / canResolveReplyPreviews (whether the adapter implements the 0.9 members). startReply(messageId) quotes one of the room's rendered messages — any member's row, not only the caller's own — and is a no-op for pending, removed or unknown rows and for a READ role when the conversation reports one; cancelReply() drops it. The next sendMessage() carries the target, stamps it on the optimistic row so the quoted block renders before acknowledgement, and clears replyTarget on success. Entering edit mode clears the reply target and starting a reply leaves edit mode: the composer has one modal state. Deleting the target row clears it too.

Preview resolution is batched, never one request per row. After every page load, reconcile and burst of live inserts the controller issues one getReplyPreviews call for the distinct replyToMessageId values the rendered rows reference; the SDK chunks at 50 per request. A parent that is itself in the loaded window is derived locally (text cut at 500 characters with textTruncated set) and costs nothing. replyPreviews has three states per id: a ReplyPreview, the terminal 'unavailable', and no entry at all, which means "not resolved yet" — render the reference without its text, never the unavailable copy. An id missing from a batch that resolved is the only deletion signal and becomes 'unavailable' for good; a batch that rejects writes no entry for any of its ids, surfaces through error and is retried on the next trigger. A cached entry is invalidated by a message_deleted (or a delete response) for that id, by an edit of that row, and by a reconnect, which marks every non-terminal entry stale. Entries no rendered row references are dropped, so the map stays bounded by the window.

Jumping. jumpToMessage(messageId) highlights a row that is already in the window without a request. Otherwise one getMessageContext centred on the id replaces the window, windowMode becomes jumped and hasOlderMessages / hasNewerMessages come from the response's cursors. A jump is a window operation, never a re-open: the acknowledgement floor, the tombstones, the private state captured when the room opened, the edit session and the reply target all survive it, and the initial-load path never runs. A coded 404 MESSAGE_NOT_FOUND — the guaranteed answer once the quoted message is deleted — marks that preview 'unavailable' instead of surfacing an error and leaves the window untouched; any other failure sets error and also leaves it untouched. jumpToMessage is a no-op while a send is in flight, so a replacement can never strand a pending row.

While jumped: both ends page through getMessageContext and the stored cursors (loadOlderMessages() as usual, loadNewerMessages() for the newer end), realtime inserts are recorded but not rendered, and no read is acknowledged (the newest rendered row is not the room's newest). Edits, revisions and tombstones for rows inside the window still apply. A queued reconcile does not walk the history: it re-reads the jumped window with one bounded, centred request anchored at the jump target (or at the window's midpoint once the window has been paged), tombstones only inside the range the response returned, and leaves the owed tail reconcile owed so it runs on the return to live.

returnToLatest() is the only way back. It sets windowMode = 'live' before issuing the newest-page request — never after the response — so inserts arriving during it merge under the usual precedence, and the rows recorded while jumped join the rendered set, and are acknowledged, at that moment. A newer page that reports no newer cursor does not flip the window in place: that answer was only true at the server's query time, so returnToLatest() runs instead. If the reload fails the store stays jumped with the window intact, keeps the affordance and surfaces the error; it never lands in live on an unreloaded window. sendMessage() awaits returnToLatest() first when the window is jumped and does not send if it fails, leaving isSending false and the draft and the reply target untouched.

Default rows. A row is replyable when the view was given onReplyToMessage, the row is confirmed and the caller's role is not READ — any member may quote any message, so unlike editing this is not limited to the caller's own rows and the canEditMessage override is deliberately not consulted. Reply joins the long-press Alert sheet ahead of Edit message and Delete message; a row whose only would-be action is unavailable is not long-pressable at all, so the sheet never opens on Cancel alone. A row that carries replyToMessageId shows a quoted block above its text with the parent's author and text (or its attachment count), Original message unavailable once the parent is gone, or the reference alone while it is not yet resolved. The block is a button when the view was given onJumpToMessage, with the accessible name Quoted message from <author>, Original message unavailable or Quoted message; it stays activatable while the parent is gone. The jumped-to row is tinted with colors.highlight (the themed accent at low opacity when the token is unset, exactly as colors.badge falls back) for about two seconds, and the move is announced through AccessibilityInfo; a drag clears the highlight early.

Default composer. While replyTarget is set a cancellable strip above the input (a polite live region) reads Replying to <name> with the quoted text beside Cancel (accessible name Cancel reply). Sending clears it, cancelling clears it, and the primary action stays Send message: the quote rides on the store's send, not on a new callback. Custom composers receive the additive replying (the message) and cancelReply on the renderComposer input (ComposerContext), flat, as editing / cancelEdit are.

Controlled views. ConvoKitMessageListView accepts onReplyToMessage, replyPreviewByMessageId (a ReadonlyMap<string, ReplyPreview | 'unavailable'>, the readAtByUserId idiom), onJumpToMessage(messageId), highlightedMessageId, hasNewerMessages, isLoadingNewer, onLoadNewer and onHighlightDismissed; ConvoKitConversationView adds replyTarget, onCancelReply and onReturnToLatest, and renders the Jump to latest control (accessible name Jump to latest messages) exactly when onReturnToLatest is given. The newer-edge pagination trigger is the list's start edge — the opposite one from onLoadOlder — and only fires while hasNewerMessages is true, which only a jumped window reports. Without the new callbacks nothing new renders and the markup is the 0.8 markup. Custom rows receive canReply and, only where they apply, reply(), replyPreview and jumpToReplyTarget() on MessageRowContext. The bound ConvoKitConversation wires all of it from the controller, passing onJumpToMessage only while canJumpToMessages and onReturnToLatest only while the window is jumped, and takes replyPreviewWindowMs (the live-insert batching window, default 250) and highlightDurationMs (default 2000).

Scrolling to a row uses scrollToIndex against the rendered list, with onScrollToIndexFailed nudging to an estimated offset and re-attempting at most three times — rows are variable height, so there is no getItemLayout and a target outside the render window cannot be measured up front. MessageRowContext.chronologicalIndex is unchanged: with the default orientation it has always been the rendered (visual) position, and 0.9 does not move it.

0.9.0 adapter change (additive): ConvoKitUiClient gains the optional getReplyPreviews(conversationId, messageIds) and getMessageContext(conversationId, { messageId?, olderCursor?, newerCursor?, limit? }), and sendMessage's input gains the optional replyToMessageId; DefaultConvoKitUiClient implements all three. Adapters without the two new members keep compiling, and a 0.8 adapter still satisfies the widened sendMessage: canJumpToMessages / canResolveReplyPreviews are then false, no jump affordance renders and quoted blocks show the reference without text.

Mixed fleet. Against a 0.8 backend both new routes answer Express's unmatched route with an uncoded 404, which is not "message gone": the preview batch is treated as unresolved (never 'unavailable'), a jump leaves the window untouched, the failure surfaces once, and after the first such rejection the corresponding capability flag turns false for the life of the controller so the affordances disappear instead of failing repeatedly. A coded MESSAGE_NOT_FOUND never trips that — it is a real missing target. Sending a quote to a 0.8 backend is not rejected: the backend ignores the unknown body key, so the message is sent without its reference and renders with no quoted block. A 0.8 view against the 0.9 backend ignores replyToMessageId entirely.

Controlled components

Use ConvoKitConversationListView, ConvoKitConversationView and ConvoKitMessageListView when the application owns state. useConvoKitConversationList and useConvoKitConversation return the same controllers the bound components use, without forcing a state library. Pass summaries and currentUserId from the list state to ConvoKitConversationListView for previews and badges (both optional), and readPositionByUserId (plus readAtByUserId for users without a position) to the conversation views for receipts. useConvoKitConversation reports AppState itself; when you construct a ConversationController yourself, call setVisible(false) while the room is not on screen (or reuse useConvoKitVisibility(controller)) 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); leave them out and no action renders. Reply and jump state works the same way: pass replyTarget, replyPreviews, highlightedMessageId, hasNewerMessages and isLoadingNewer with startReply, cancelReply, jumpToMessage, loadNewerMessages, returnToLatest and clearHighlight, or leave them out and nothing new renders.

import { Alert, Pressable, Text } from 'react-native'
import {
  ConvoKitConversationListView, ConvoKitConversationView, DefaultConvoKitUiClient, conversationPreview,
  unreadBadge, useConvoKitConversation, useConvoKitConversationList,
} from '@convokitapp/react-native-ui'

const uiClient = new DefaultConvoKitUiClient(sdk)

function Room({ roomId }: { roomId: string }) {
  const { controller, state } = useConvoKitConversation({ client: uiClient, conversationId: roomId })
  if (!state.conversation) return null
  return <ConvoKitConversationView
    conversation={state.conversation}
    messages={state.messages}
    currentUserId={state.currentUserId}
    typingUserIds={state.typingUserIds}
    readPositionByUserId={state.readPositionByUserId}
    readAtByUserId={state.readAtByUserId}
    isSending={state.isSending}
    error={state.error}
    onSendMessage={async ({ text }) => (await controller.sendMessage({ text })) !== null}
    onTypingChanged={typing => void controller.updateTyping(typing)}
    onLoadOlder={() => controller.loadOlderMessages()}
    hasOlderMessages={state.hasOlderMessages}
    editingMessage={state.editingMessage}
    onEditMessage={message => controller.startEditing(message.id)}
    onSaveEdit={(_message, text) => controller.saveEdit(text)}
    onCancelEdit={() => controller.cancelEditing()}
    onDeleteMessage={message => controller.deleteMessage(message.id)}
    replyTarget={state.replyTarget}
    replyPreviewByMessageId={state.replyPreviews}
    highlightedMessageId={state.highlightedMessageId}
    hasNewerMessages={state.hasNewerMessages}
    isLoadingNewer={state.isLoadingNewer}
    onReplyToMessage={message => controller.startReply(message.id)}
    onCancelReply={() => controller.cancelReply()}
    onJumpToMessage={messageId => void controller.jumpToMessage(messageId)}
    onLoadNewer={() => controller.loadNewerMessages()}
    onHighlightDismissed={() => controller.clearHighlight()}
    {...(state.windowMode === 'jumped' ? { onReturnToLatest: () => void controller.returnToLatest() } : {})}
    confirmDelete={message => new Promise<boolean>(resolve => Alert.alert(
      'Delete this message?', message.text ?? undefined,
      [{ text: 'Keep', style: 'cancel', onPress: () => resolve(false) }, { text: 'Delete', style: 'destructive', onPress: () => resolve(true) }],
    ))}
  />
}

function Inbox({ onOpen }: { onOpen(roomId: string): void }) {
  const { controller, state } = useConvoKitConversationList({ client: uiClient, activityRefreshWindowMs: 500 })
  return <ConvoKitConversationListView
    conversations={state.conversations}
    summaries={state.summaries}
    currentUserId={state.currentUserId}
    isInitialLoading={state.isInitialLoading}
    isLoadingMore={state.isLoadingMore}
    hasMore={state.hasMore}
    error={state.error}
    onRefresh={() => controller.refresh()}
    onLoadMore={() => controller.loadMore()}
    onConversationSelected={conversation => onOpen(conversation.id)}
    renderItem={({ conversation, summary, currentUserId, onPress }) => {
      const badge = summary ? unreadBadge(summary) : null
      return <Pressable
        onPress={onPress}
        onLongPress={() => { void controller.markUnread(conversation.id).catch(error => Alert.alert(String(error))) }}
        accessibilityLabel={badge ? `Open ${conversation.displayTitle}, ${badge.accessibilityLabel}` : `Open ${conversation.displayTitle}`}
      >
        <Text>{conversation.displayTitle}</Text>
        {summary && <Text>{conversationPreview(conversation, summary, currentUserId) ?? conversation.description}</Text>}
        {badge && <Text>{badge.dot ? 'Unread' : badge.label}</Text>}
      </Pressable>
    }}
  />
}

markUnread and clearUnread reject on a request failure and when the controller is not active, so keep the catch. A ConversationListController you construct yourself (new ConversationListController({ client })) drives the same view; dispose it when the screen unmounts.

Customization

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

<ConvoKitConversationView
  {...roomProps}
  renderReadReceipt={(_message, readerIds) => <Text>{readerIds.size ? `Seen by ${readerIds.size}` : 'Sent'}</Text>}
  renderMedia={({ media }) => media.type === 'location' ? <MyMap location={media} /> : undefined}
  renderMessage={({ message, isCurrentUser, isEdited, edit, remove, reply, replyPreview, jumpToReplyTarget }) => (
    <View style={{ alignItems: isCurrentUser ? 'flex-end' : 'flex-start' }}>
      {message.replyToMessageId !== undefined && <Pressable accessibilityRole="button" onPress={jumpToReplyTarget}>
        <Text numberOfLines={2}>
          {replyPreview === 'unavailable' ? 'Original message unavailable' : replyPreview?.text ?? ''}
        </Text>
      </Pressable>}
      <Text>{message.text}</Text>
      {isEdited && <Text accessibilityLabel="Edited">(edited)</Text>}
      {reply && <Pressable accessibilityRole="button" onPress={reply}><Text>Reply</Text></Pressable>}
      {edit && <Pressable accessibilityRole="button" onPress={edit}><Text>Edit</Text></Pressable>}
      {remove && <Pressable accessibilityRole="button" onPress={() => void remove()}><Text>Delete</Text></Pressable>}
    </View>
  )}
  renderComposer={({ value, setValue, send, isSending, editing, cancelEdit, replying, cancelReply }) => (
    <View>
      {editing && <View>
        <Text>Editing: {editing.text}</Text>
        <Pressable accessibilityRole="button" accessibilityLabel="Cancel editing" onPress={cancelEdit}><Text>Cancel</Text></Pressable>
      </View>}
      {replying && <View>
        <Text>Replying to: {replying.text}</Text>
        <Pressable accessibilityRole="button" accessibilityLabel="Cancel reply" onPress={cancelReply}><Text>Cancel</Text></Pressable>
      </View>}
      <TextInput value={value} onChangeText={setValue} onSubmitEditing={send} />
      <Pressable accessibilityRole="button" disabled={isSending} onPress={send}><Text>{editing ? 'Save' : 'Send'}</Text></Pressable>
    </View>
  )}
/>

send() saves while editing is present and sends otherwise, so a custom composer needs no branching; edit and remove are present on a row exactly when that action is available to the viewer. A custom row's remove() asks confirmDelete when the view was given one and otherwise calls onDeleteMessage at once, so the row above owns its confirmation UI unless the host passes confirmDelete; the default row keeps the built-in dialog. A row's reply is present exactly when the viewer may quote it, and replyPreview / jumpToReplyTarget only when the row carries a reference: a replyPreview of undefined means the quoted parent is not resolved yet, not that it is gone. ConvoKitUiProvider supplies the theme tokens (colors.badge and colors.highlight included) that the default rows read through useConvoKitTheme.

Version 0.9.0 requires @convokitapp/react-native 0.9.x (peer >=0.9.0 <0.10.0). Publish @convokitapp/react-native before publishing this package.

Emoji reactions

ConvoKitConversationView renders a picker, count chips, and a paged reactor list. Custom rows can use the exported ReactionBar. The controller batches visible summaries and refetches after private invalidations or reconnect. Emoji sequences are exact (👍 differs from 👍🏽); READ members can inspect reactors, while READ_WRITE members can toggle their own reaction.