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/clearUnreadon 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. |
Component gallery
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

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

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

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.