ConvoKit Flutter UI

Plug-and-play, extensible Flutter UI for the convokit_flutter SDK. The package provides a conversation inbox and a complete selected-conversation surface without taking control away from the host app.

UI 0.8.0 requires convokit_flutter 0.8.x and the coordinated backend release. Private room/app topics are discovered and refreshed automatically by the core SDK.

Included

  • SDK-backed and controlled conversation-list components
  • Inbox rows in activity order with the latest-message preview, the activity time and an unread badge computed from the caller's read position
  • A private "mark unread" marker per room: markUnread / clearUnread on the list controller, a numberless dot on default rows, and acknowledgements that clear the marker only while the version captured at open is current
  • Cursor pagination with automatic scroll-to-load-more (offset pagination for custom page loaders)
  • Search, archived, participant, predicate, and custom-sort filters
  • SDK-backed and controlled conversation components
  • Older-message pagination and realtime message, typing, and read events
  • Text sending and structured image/file/media rendering
  • Editing and deleting the connected user's own messages: row actions, a composer edit mode that keeps drafts, revision-guarded saves with visible conflicts, and an "Edited" caption
  • Default read indicators calculated from precise participant read positions
  • Read acknowledgements that target the newest rendered message and pause while the app is not in the foreground
  • Theme tokens plus builders for every major state and component
  • A replaceable client boundary for caching, analytics, offline state, custom authorization, or an alternative state-management layer

The package is deliberately split into two layers:

Layer Use when
ConvoKitConversationList / ConvoKitConversation You want working SDK-backed UI with minimal setup.
ConvoKitConversationListView / ConvoKitConversationView Your application already owns state and operations.

These are real renders of the package widgets using backend-free fixture data. The complete, runnable implementation is in lib/showcase/component_showcase_app.dart, with widget coverage in test/component_showcase_test.dart.

Standard components

Standard ConvoKit conversation list and chat components

The default conversation rows with previews and unread badges, header, message bubbles, structured file card, read receipts, and composer. This version also demonstrates summaries, currentUserId, onRefresh, onAddAttachment, readPositionByUserId, and reverseMessages: true.

Branded customer support

Branded ConvoKit customer support interface

The same controlled widgets styled as a support workspace. It replaces only selected pieces through rowBuilder (custom rows that read the preview and unread count from ConvoKitInboxRow), headerBuilder, mediaBlockBuilder, readReceiptBuilder, and composerBuilder.

Compact operations

Compact ConvoKit operations interface

A dense dashboard treatment using custom padding, separators, message rows, typing indicator, and composer, with reverseMessages: false.

Run the public examples repository:

git clone https://github.com/ConvoKitApp/ConvoKit-Flutter-UI-Examples.git
cd ConvoKit-Flutter-UI-Examples
flutter run -d chrome

On Flutter web, append ?variant=standard, ?variant=branded, or ?variant=compact to open a specific configuration directly.

Install

Add both the core SDK and UI package:

flutter pub add convokit_flutter convokit_flutter_ui

Configure and connect the core SDK before constructing an SDK-backed UI controller. The app's client secret belongs only on the token server; never put it in Flutter.

ConvoKit.configure(
  clientId: 'public-client-id',
  tokenProvider: (appUserId) => tokenService.issueToken(appUserId),
);
await ConvoKit.connectUser(currentAppUser.id);

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 conversations render an outgoing message immediately, reconcile it with the server response and realtime echo, and restore an unchanged draft if the send fails.

Plug-and-play UI

Use the list at the top level, then open a conversation selected by the user:

class Inbox extends StatelessWidget {
  const Inbox({super.key});

  @override
  Widget build(BuildContext context) {
    return ConvoKitConversationList(
      onConversationSelected: (conversation) {
        Navigator.of(context).push(
          MaterialPageRoute<void>(
            builder: (_) => Scaffold(
              body: ConvoKitConversation(
                conversationId: conversation.id,
                onBack: () => Navigator.of(context).pop(),
                onAttachmentTap: (context, message, attachment) {
                  // Open the URL with the host app's browser/download policy.
                },
              ),
            ),
          ),
        );
      },
    );
  }
}

The list requests the next conversation page near its bottom. The conversation requests older message pages near its oldest edge. Controllers de-duplicate records and ignore stale asynchronous results.

Inbox previews and unread counts

SDK-backed lists page GET /api/v1/inbox by opaque cursor in activity order (the newest surviving message's time, or the room's creation time for an empty room; edits do not move a room, deleting its newest message recomputes it). state.summaries maps every loaded conversation id to the core SDK's InboxSummary: latestMessage (the GET /messages row shape, at most four media items), unreadCount (messages from other participants after the caller's read position; own messages never count), unreadCountCapped (true when more than 1,000 messages follow the position, so the count is a lower bound), the caller's readPosition and lastReadAt, isUnread (unreadCount > 0 || unreadCountCapped || unreadMarkedAt != null), the caller's private "mark unread" state unreadMarkedAt and privateStateVersion (0.7.0, see Mark unread), and activityAt. state.currentUserId is the connected user's id while the list is bound to a session in inbox mode; it is null without a session, with a custom pageLoader and after disposal.

Default rows render the preview instead of the participants line when a summary has a latest message with a body: You: prefixes the connected user's own message (direct messages included), <name>: prefixes another participant's message in a room with more than two participants when that sender is still a participant with a name, and a media-only message reads Photo, the file name (or File), Location or Contact. The trailing time is activityAt in the device zone (HH:mm, like message rows). While isUnread, unreadCount > 0 or the count is capped, the title uses the bolder weight. A count or a capped count shows a badge with the count, 99+ above 99 or when capped (even with a marker), announced to assistive technology as <count> unread (99+ unread when capped) with the visible digits hidden. A marker without a count (isUnread true, unreadCount 0, not capped) shows ConvoKitUnreadDot, an 8-point numberless dot announced as Unread; no count is invented, so 0 unread is never announced. ConvoKitUiThemeData.badgeColor styles both and defaults to primaryColor (effectiveBadgeColor). Rows without a summary render as in 0.5.

Custom rows receive the same data through rowBuilder, which wins over itemBuilder:

ConvoKitConversationList(
  onConversationSelected: open,
  rowBuilder: (context, row, onTap) {
    final summary = row.summary; // null without inbox data
    final preview = convoKitInboxPreview(
      conversation: row.conversation,
      summary: summary,
      currentUserId: row.currentUserId,
    );
    return ListTile(
      onTap: onTap,
      title: Text(row.conversation.displayTitle),
      subtitle: preview == null ? null : Text(preview),
      trailing: summary == null
          ? null
          : summary.unreadCount > 0 || summary.unreadCountCapped
              ? ConvoKitUnreadBadge(
                  unreadCount: summary.unreadCount,
                  capped: summary.unreadCountCapped,
                )
              : summary.isUnread
                  ? const ConvoKitUnreadDot()
                  : null,
    );
  },
);

ConvoKitInboxRow carries conversation, summary, index and currentUserId. Controlled ConvoKitConversationListViews accept summaries and currentUserId; without them rows render as in 0.5 and no You: prefix appears. itemBuilder keeps its (context, conversation, index, onTap) signature.

SDK-backed lists arrive in inbox order; server order is authoritative within a page and the local (activityAt desc, id desc) order is applied whenever pages are merged. Setting ConvoKitConversationFilter.comparator replaces that order entirely. Conversation.updatedAt is never an ordering input.

Mark unread

The connected user can mark a room unread for themselves only. The marker is private membership state: it never appears in participant DTOs, other members' lists, webhooks or read events, and unreadCount is never inflated by it. The list controller exposes it:

// A custom row action, menu item or swipe: no default gesture is built in.
await conversations.markUnread(conversation.id);

// Remove the marker without acknowledging any message. `ifVersion` makes the
// clear conditional on the version the caller last saw.
final cleared = await conversations.clearUnread(
  conversation.id,
  ifVersion: summary.privateStateVersion,
);

markUnread calls the adapter's markConversationUnread (POST /api/v1/conversations/:id/unread); clearUnread calls clearConversationUnread (DELETE /api/v1/conversations/:id/unread, with ?privateStateVersion= when ifVersion is given) and returns the response's cleared: whether this request removed the marker. false is a normal answer (nothing was marked, or the version no longer matched), not an error. On any 200 the response's unreadMarkedAt and privateStateVersion replace the current summary's as one unit and isUnread is recomputed from the stored counts and the new marker, but only when the response version is at least the stored one: privateStateVersion increases on every mark (also a repeat mark) and every effective clear, so a delayed response can never resurrect a marker a newer action removed. Failures are recorded in state.error without evicting rows. With a custom pageLoader (no summaries) the adapter is still called but nothing is patched. The caller's other devices learn of a mark or clear through the activity signal below, which refetches the inbox.

Default rows render the marker as the dot described above when the room has no unread messages, and keep the numeric badge when it has. Custom rows read row.summary.isUnread, unreadMarkedAt and privateStateVersion.

Opening the room clears the marker the way reading does, version-guarded: see Read acknowledgements. Mixed fleet: a 0.6 list ignores isUnread and shows no dot; a room against a 0.6 backend receives no membership, sends no version and never clears the marker.

Filtering and pagination

Own a controller when filters need to change after construction:

final conversations = ConvoKitConversationListController(
  pageSize: 25,
  initialFilter: const ConvoKitConversationFilter(archived: false),
);

await conversations.setQuery('design');

await conversations.setFilter(
  ConvoKitConversationFilter(
    participantIds: const {'app_user_42'},
    predicate: (conversation) => conversation.description != null,
    comparator: (a, b) => a.displayTitle.compareTo(b.displayTitle),
  ),
);

archived is sent to the SDK. Text, participants, predicates, and ordering are applied locally. If a local filter produces no result in the current source page, the controller keeps paging until it finds a match or reaches the end. pageSize (1..100, default 30) is the cursor page size; hasMore reflects the server's nextCursor.

For server-side search or a different source, provide pageLoader. Custom loaders keep the offset request shape, creation order and no summaries:

final conversations = ConvoKitConversationListController(
  pageLoader: (request) async {
    return repository.searchConversations(
      query: request.filter.query,
      limit: request.limit,
      offset: request.offset,
    );
  },
);

UI customization

The controlled widgets let the host replace only what it needs:

ConvoKitConversationView(
  conversation: state.conversation,
  messages: state.messages,
  currentUserId: state.userId,
  readPositionByUserId: state.readPositionByUserId,
  readAtByUserId: state.readAtByUserId,
  typingUserIds: state.typingUserIds,
  onSendMessage: controller.sendText,
  onLoadOlder: controller.loadOlder,
  hasOlderMessages: state.hasOlder,
  headerBuilder: (context, conversation, onBack, onRefresh) {
    return MyConversationHeader(conversation: conversation);
  },
  messageBuilder: (context, message, index, isMine, sender, readers) {
    return MyMessageBubble(message: message, readers: readers);
  },
  mediaBlockBuilder: (context, block, message, isMine) {
    return block['type'] == 'poll' ? MyPoll(block: block) : null;
  },
  composerBuilder: (context, text, isSending, send, addAttachment) {
    return MyComposer(controller: text, onSend: send);
  },
);

Available replacement points include conversation rows, separators, loading, empty and error states, header, message row, individual media blocks, read receipts, typing indicator, composer, attachment taps, user-name resolution, scroll controllers, padding, thresholds, and list direction.

To style the defaults, install the theme extension:

MaterialApp(
  theme: ThemeData(
    extensions: const [
      ConvoKitUiThemeData.light(),
    ],
  ),
  home: const Inbox(),
);

Use copyWith to replace individual color and sizing tokens. badgeColor (0.6.0) styles the unread badge on default rows and is optional: unset, the badge uses primaryColor; the digits use outgoingTextColor.

Functional customization

Implement ConvoKitUiClient and pass it to either controller when the UI should use a repository, cache, offline queue, analytics wrapper, or a custom authorization policy. DefaultConvoKitUiClient delegates directly to the existing static ConvoKit SDK.

Externally supplied controllers remain owned by the host and must be disposed there. Controllers created internally by plug-and-play widgets are disposed by the widgets.

Optimistic outgoing rows display Sending… until the backend confirms them through HTTP, live updates or authorized history. The server timestamp is then rendered in the viewer's local timezone. Custom message builders can use isConvoKitPendingMessage(message) to present the same state.

Edit and delete your own messages

The connected user can edit the text of their own confirmed messages and delete them; other members' messages, pending rows and rooms where the caller's role is READ (from Conversation.membership.role or the caller's participant row, when known) never offer the actions. The room controller owns edit mode:

final state = controller.state;
state.canEditMessages;   // session loaded and the caller may write
state.editingMessage;    // the snapshot being edited, or null
controller.startEditing(message.id); // no-op for ineligible rows, no typing
await controller.saveEdit(text);     // true when edit mode ended with the row
controller.cancelEditing();
await controller.deleteMessage(message.id); // true when the row is gone

saveEdit sends the trimmed text through the adapter's editMessage (PATCH /api/v1/messages/:id/own) with the revision of the snapshot captured by startEditing, never the live row's: the backend applies the edit only while that revision is current. An empty text is sent as null and clears the caption of a message with attachments; a text-only message cannot be saved empty and sends nothing. Attachments are never changed by an edit.

Conflicts are visible. A 409 REVISION_CONFLICT reloads the row once through getMessage: the row shows the server's content, editingMessage is replaced by it so the next save carries the fresh revision, state.error carries the conflict (a ConvoKitException with code == 'REVISION_CONFLICT') and edit mode stays on with the draft untouched. The same state is entered without a request when a row for the edited id with a higher revision reaches the controller while editing (a live row image, a hydration, a reconcile or a refresh); the echo of this controller's own in-flight save is not a conflict, and a newer row that lands during a save that then fails is one once the request settles. A 404 with code MESSAGE_NOT_FOUND on the save or on the reload removes the row and ends edit mode. Every other failure (403, a 404 without that code such as an older backend's unmatched-route answer, network or server errors) sets state.error and keeps the row and edit mode, so the draft survives; none of these evict the loaded history.

deleteMessage (DELETE /api/v1/messages/:id/own) keeps the row until the adapter answers: success or a 404 with code MESSAGE_NOT_FOUND tombstones and removes it, retargets the read acknowledgement when it was the target, and ends edit mode when it was the edited row. Late edit responses, row images and hydrations for that id are dropped and the room's own deletion broadcast is a no-op; other failures set state.error and keep the row. Deleting cannot be undone: the message and its attachment records leave the conversation for every member, files already received or downloaded cannot be retracted, and stored files are reclaimed by the existing user or app deletion cleanup. A deletion from another device, a MESSAGE_NOT_FOUND answer or a reconcile that no longer finds the edited row also ends edit mode.

Row precedence: when two rows for one id carry different Message.revisions the higher wins and a lower one never overwrites it, so a stale edit response cannot rewind a newer live row; equal revisions (pending rows, every row on a 0.7 backend) keep the updatedAt ?? createdAt rule with complete-wins-on-ties.

Default rows and composer

ConvoKitConversation wires everything: on the connected user's own confirmed rows a long press, or the "Message actions" accessibility action, opens a bottom sheet with "Edit message" and "Delete message". Delete asks "Delete this message?" with "Cancel" (tooltip "Cancel delete") and "Delete" (tooltip "Confirm delete"); pass confirmDelete to replace that dialog with your own (FutureOr<bool> Function(Message)). Rows that are not eligible render exactly as in 0.7.0. Every row whose Message.isEdited is true shows "Edited" beside its time, for every sender; custom messageBuilder rows read message.isEdited and call the controller themselves (the builder typedefs are unchanged).

In edit mode the default composer shows an "Editing message" banner (a live region) with the original text and a visible "Cancel" (tooltip "Cancel editing"); the primary action becomes a check icon with the tooltip "Save message" ("Send message" otherwise), enabled when the trimmed field is non-empty or the edited row has attachments. Entering edit mode stashes the unsent draft and prefills the field with the message text without reporting typing; Cancel and a successful save restore the stash and report onTypingChanged from it; an external clear (the row was removed) keeps text the user changed and restores the stash only when the field is empty or still equals the message text; a refreshed snapshot with the same id updates the banner and leaves the field alone. The field is never cleared while a save is in flight, so a failure or a conflict keeps the edited text.

The one send callback the view hands to composerBuilder saves while editingMessage is set and sends otherwise, so a custom composer needs no branching: read controller.state.editingMessage to render your own banner and call controller.cancelEditing() from it.

Controlled views

ConvoKitConversationView accepts editingMessage, onEditMessage, onSaveEdit (FutureOr<bool> Function(Message, String); false keeps the edited text and edit mode, like a failed send keeps its draft), onCancelEdit, onDeleteMessage (FutureOr<bool> Function(Message)) and confirmDelete; ConvoKitMessageListView accepts onEditMessage, onDeleteMessage and confirmDelete. Edit mode stays yours: the view is a function of editingMessage and the callbacks. Without onEditMessage / onDeleteMessage no actions render and the markup is identical to 0.7.0.

ConvoKitConversationView(
  // ...
  editingMessage: state.editingMessage,
  onEditMessage: (message) => setState(() => state.editingMessage = message),
  onSaveEdit: (message, text) async {
    final saved = await api.editMessage(message.id, text: text, revision: message.revision);
    // Merge `saved` into your rows, then leave edit mode.
    setState(() => state.editingMessage = null);
    return true; // false keeps the draft and edit mode (a failure or 409)
  },
  onCancelEdit: () => setState(() => state.editingMessage = null),
  onDeleteMessage: (message) => api.deleteMessage(message.id),
);

Mixed fleet: against a 0.7 backend every row is revision 0, no "Edited" caption appears, and the author routes answer an uncoded 404 that surfaces as state.error with the row and edit mode kept. 0.7 clients ignore revision.

Read receipts

ConvoKitConversationController.state.readPositionByUserId stores the newest message each participant has confirmed reading, as the server's (createdAt, id) cursor, seeded from conversation participants and advanced by realtime read events; positions only move forward. state.readAtByUserId keeps each participant's last acknowledgement time for custom "seen at" renderers. state.readerIdsFor(message) returns the users whose read state covers that message under one rule: the position when the participant has one, otherwise lastReadAt >= createdAt (a legacy membership or an empty-room acknowledgement). Equal creation times fall back to id order, matching the backend cursor and every other ConvoKit client. The default outgoing bubble displays one check once the server has confirmed the send (announced as "Sent" to assistive technology) and two checks plus a reader count after another participant has read it (announced as "Read"). Replace readReceiptBuilder for avatars, detailed labels, or product-specific rules; it receives the same reader set. The controlled views accept readPositionByUserId beside readAtByUserId; either alone works.

Read acknowledgements

SDK-backed controllers acknowledge reads through a concrete message so a delayed request cannot mark messages that arrived later as read. markReadOnLoad acknowledges after the first history page renders, markReadOnReceive after an incoming message from another participant renders (a media-only row counts once hydration shows it, and a refresh that discovers new foreign rows counts too), and markRead() on demand. The target is always the newest non-pending row of state.messages by (createdAt, id), never a raw realtime row; a room with nothing rendered sends nothing. One request is in flight at a time, a follow-up resolves its target when it is sent, and a target at or below the last acknowledged one is skipped. Read state for the connected user is never written from the device clock; it arrives from the server like everyone else's.

ConvoKitConversationController.setVisible(bool) defers acknowledgements while the conversation is hidden and re-issues only a suppressed one when it becomes visible; controllers start visible. ConvoKitConversation wires this to the app lifecycle: AppLifecycleState.resumed is visible, paused/inactive/hidden/ detached are hidden, and an unreported initial state counts as visible. Hosts that own a controller can call setVisible from route or tab visibility as well. With both markReadOnLoad and markReadOnReceive false the controller never sends a read request, including on visibility changes.

If the backend rejects a targeted read with ConvoKitException.code == 'MESSAGE_NOT_FOUND', or the in-flight/last acknowledged target is deleted, the controller marks that id unacknowledgeable and re-issues once for the next newest rendered row without surfacing an error. A 404 without that code means the membership is gone; it surfaces as state.error and evicts the room like any other access denial.

Capture at open (0.7.0): the room controller reads Conversation.membership.privateStateVersion (and whether unreadMarkedAt is set) from the first conversation DTO of the loaded session, on loadInitial or on the first successful reconcile after a transient first-load failure (which then also applies markReadOnLoad), and never replaces it from a later reconcile; loadInitial(), session end and disposal reset it. Every targeted acknowledgement of that open sends the captured privateStateVersion beside throughMessageId, so the server clears the connected user's own marker only while that version is still current; a repeat mark or a mark from another device made after the capture survives a delayed acknowledgement, and the position rule is unchanged. A DTO without membership (a 0.6 backend) sends no version. An empty room has nothing to acknowledge, so when it opened with the marker set the controller calls clearConversationUnread(conversationId, ifVersion: captured) once per open, under the same triggers (markReadOnLoad after hydration, or an explicit markRead()) and the same visibility gating (deferred while hidden, issued on setVisible(true)); never once a row is rendered, since the targeted acknowledgement clears it; cleared: false is not an error and a failure takes the acknowledgement's fail path. Room controllers still never send an untargeted acknowledgement and never write the marker locally; the list learns of the clear through the activity signal.

Mixed fleet: precise receipts need the sender and the reader on 0.5. A 0.4 reader keeps timestamp semantics but still parses the additive payloads.

Live inbox updates

SDK-backed inboxes listen to two signals on the shared private app channel. inboxChanges (room creation, membership changes, metadata changes, deletion/cascades and every verified initial join/rejoin) triggers an immediate authorized REST refresh. inboxActivity (a message insert or edit, a read-position advance, or a change to the connected user's own "mark unread" marker anywhere in the app) is throttled: the first signal starts a timer for activityRefreshWindowMs (default 500 ms; 0 refreshes immediately), later signals inside the window are absorbed without extending it, and one refresh runs when it fires. A structural change during a pending window refreshes at once and drops the timer; disposal and session end cancel it. Manual refresh() (pull-to-refresh) stays immediate. Notifications contain no room contents or identifiers. Open room controllers reconcile on inboxChanges only, so removed access clears their view and activity never costs them a request.

Refresh walks the inbox from its head by cursor with limit = min(100, target - consumed) where target = max(pageSize, loadedCount) until the server has no further page or the loaded count is covered and at least one row passes the local filter, so a fully hidden head page never publishes an empty list while more pages exist. Pages merge by conversation id (a later entry wins, then the window is re-sorted by activity) and rows, summaries, cursor and hasMore swap atomically, retaining visible rows during loading, local filters and previously loaded pages. Custom page loaders keep their offset walk and loaded page count and receive the current filter on each request. Bursts coalesce; a change arriving during a fetch schedules another pass. Transient errors and 400 responses (for example an INVALID_CURSOR) retain rows and set state.error; access denial (401/403 from either endpoint, 404 from the legacy endpoint) or session replacement clears them.

If the backend answers listInbox with 404 (the route is absent after a rollback or on a staging deployment), the controller switches to the offset getConversations path for the rest of its session without clearing rows, warns once through debugPrint, re-runs the same operation, and reports an empty summaries; loadInitial() tries the inbox endpoint again.

Reconnect and session recovery

SDK-backed room controllers refetch persisted receipts and page through the currently viewed history range after either private room channel joins/rejoins. refresh() / reconcile() use the same non-destructive recovery path. Existing messages and pending sends stay visible; state.isReconciling reports progress. Newer live changes win over an older HTTP response, and read positions only advance. A device-clock timestamp is never treated as a confirmed read receipt.

Raw Postgres events omit the related media table. The UI shows text immediately and fetches the complete authorized message through getMessage(id). It retains attachments during provisional edits, and waits for the full response before displaying a media-only message. The server's Message.revision (then updatedAt) prevents old responses from rewinding edits. Lookups are coalesced per ID and limited to eight in flight per controller, including across reloads; this adds REST requests for observed row changes. Retired, deleted and superseded responses are discarded.

The controller creates a UUID clientMessageId before displaying the pending row and forwards it unchanged to the core/backend. A matching canonical message from the same sender and room replaces that row atomically, even before the HTTP acknowledgement. Identical text or attachment counts never identify a send. Media-only echoes retain the pending preview until authorized hydration finishes. If a confirmed send's HTTP response is lost, the composer treats it as success without restoring the draft or resurrecting a subsequently deleted message. Typing-stop remains independent of send completion.

Continued composer input renews typing at most once per half typingTimeout (1.5 seconds with the default 3-second timeout), not on every keystroke. Idle input sends no keepalives; idle timeout or explicit stop clears typing. Disconnect, disposal and session replacement cancel the timers, and failures from older typing requests cannot reset a newer request's state.

Room controllers also consume the core SDK's private ID-only deletion stream. Deleted messages disappear without manufacturing an old message body. Deletion markers last until an explicit initial reload/session reset; delayed send responses, history pages, and older Realtime rows cannot restore those IDs. Realtime does not replay missed events, so reconnect recovery still reconciles edits/deletions within loaded history. The new inbox stream also invalidates cascade changes. If REST recovery fails, the error is exposed and a subsequent refresh or rejoin retries it; an interrupted join is not a successful recovery.

Custom ConvoKitUiClient implementations must provide a stable sessionIdentity for one login (including app identity), return null on logout, and replace it on every new login even when the user ID is unchanged. Close connectionEvents when that session ends. Emit logical messages:<room> / conversation:<room> join statuses; closed as an event may be a recoverable channel replacement, whereas stream completion ends the session. For getMessages(before:), use the cursor's (createdAt, id) values, offset zero, and strict descending order. Implement onMessageDeleted() as Stream<MessageDeletedEvent> containing only id and conversationId; onMessage() now handles inserts/updates only. Implement getMessage(id) with the same app/room authorization as history, returning the complete current message and authoritative related media list. Preserve Message.updatedAt when present; it is distinct from creation time. For 0.4.0, implement inboxChanges with mutation and verified-join signals, forward sendMessage(clientMessageId:), and preserve that ID on HTTP/history/live messages. These additions require the 0.4.0 core/backend contract. No raw-DELETE fallback is used. For 0.5.0, markConversationRead gains an optional named throughMessageId (markConversationRead(String conversationId, {String? throughMessageId})); Dart implements adapters must add the parameter and forward it. Reject a missing, deleted or foreign target with a ConvoKitException whose code is MESSAGE_NOT_FOUND so the controller retargets instead of reporting an error, and keep a membership failure as a 404 without a code. Return participants with readPosition and read events with readPosition when your backend provides them; the controllers fall back to lastReadAt/readAt otherwise. For 0.6.0, implement Future<InboxPage> listInbox({required int limit, String? cursor, required bool archived}) and Stream<void> get inboxActivity. listInbox returns one cursor page in (activityAt desc, id desc) order with at most limit entries, unique non-blank conversation ids, and a nextCursor that differs from the requested one (null on the last page); forward it to ConvoKit.listInbox or serve the same shape from your backend. Signal an absent endpoint with a ConvoKitException whose statusCode is 404 so the controller falls back to getConversations without previews; a malformed cursor is a 400 with code INVALID_CURSOR. inboxActivity emits empty signals after message inserts, edits and read-position advances and is never synthesised on joins (that stays with inboxChanges); return const Stream.empty() when your backend has no such signal. For 0.7.0, markConversationRead gains an optional named privateStateVersion (markConversationRead(String conversationId, {String? throughMessageId, int? privateStateVersion})) and the interface gains Future<ConversationPrivateState> markConversationUnread(String conversationId) and Future<ClearUnreadResult> clearConversationUnread(String conversationId, {int? ifVersion}); Dart implements adapters must add all three. Forward the version unchanged (an adapter that drops it never clears the marker from a room), return the private state (unreadMarkedAt, privateStateVersion) from the mark and clear responses, and answer a no-op clear (nothing marked, or a version mismatch) with cleared: false rather than an error. Return the caller's own membership as Conversation.membership from getConversation when your backend provides it; without it the room sends no version. inboxActivity should also follow the connected user's own marker changes so other devices refresh. For 0.8.0, the interface gains Future<Message> editMessage(String messageId, {required String? text, required int revision}) and Future<void> deleteMessage(String messageId); Dart implements adapters must add both. Send both keys on the wire with a null text as JSON null, return the updated row, answer a stale revision with a ConvoKitException whose statusCode is 409 and code is REVISION_CONFLICT (the controller reloads the row through getMessage once), and answer a missing, deleted or inaccessible message with a 404 whose code is MESSAGE_NOT_FOUND on both calls (the controller removes the row; a 404 without that code is an error that keeps it). Never change attachments from editMessage. Preserve Message.revision on every row you return or emit, including provisional rows built from raw row images.

Controllers clear cached data after session replacement or history access denial; they never silently bind to another user. Connect the intended user and call loadInitial() again, or construct new controllers. A custom inbox pageLoader used with a connected client shares that client's lifecycle and inbox signals. Offline custom loaders and fully controlled widgets remain owned by their host.

Media

Default image and file cards tolerate missing URLs, names, and numeric/string file sizes. onAttachmentTap intentionally delegates opening and downloading to the host app, where authentication and platform behavior belong. Unknown structured types receive a safe fallback; return a widget from mediaBlockBuilder to support custom blocks such as polls, locations, contacts, audio, or commerce cards.

Verification

dart format --output=none --set-exit-if-changed lib test
flutter analyze
flutter test

See the public ConvoKit-Flutter-UI-Examples repository for runnable default, branded, compact, and SDK-backed application configurations.

Libraries

convokit_flutter_ui
Extensible, SDK-backed Flutter UI for ConvoKit.