ConvoKit Flutter SDK
Flutter SDK for adding ConvoKit conversations, messages, realtime events, and media uploads to a Flutter app.
The SDK talks to your ConvoKit backend over REST and uses Supabase Realtime for live messaging events. Your ConvoKit client secret should stay on your server; mobile and web clients should only receive short-lived user tokens from your own token endpoint.
Features
Quoted replies and jump-to-message (0.9.0)
ConvoKit.sendMessage(conversationId: id, text: ..., replyToMessageId: parent.id)
quotes another message in the same room, and Message.replyToMessageId
carries the reference on every surface (REST rows, inbox previews and
Realtime row images). The reference is write-once and survives the quoted
message being edited or deleted. Resolve the quoted text in one batched call
per rendered page with ConvoKit.getReplyPreviews(id, messageIds: [...]),
which returns bounded ReplyPreview rows and simply omits ids that are gone.
ConvoKit.getMessageContext(id, messageId: parent.id) loads a window centred
on a message that is not in the loaded page, with olderCursor/newerCursor
for paging either way, so a room can open at a quoted message instead of
paging back from the newest page. Message.copyWith(...) keeps the new field
alive through row rebuilds. See
Quoted replies and message context.
This requires the coordinated 0.9 backend release: an older backend ignores
replyToMessageId on a send and answers both new routes with an
unmatched-route 404 without a code, which must not be read as "message gone".
Edit and delete your own messages (0.8.0)
ConvoKit.editMessage(id, text: newText, revision: message.revision) edits
the text of the caller's own message and ConvoKit.deleteMessage(id) removes
it, through the author-tier PATCH/DELETE /api/v1/messages/:id/own routes
(separate from the administrative /api/v1/messages/:id routes your server
calls with the client secret). Message gains revision (0 at creation,
incremented by one on every content edit, author or administrative) and the
derived isEdited (revision > 0); updatedAt is never the edited signal.
An edit sends the revision the user was shown and a stale one is rejected
with 409 REVISION_CONFLICT; a null text clears the caption of a message
with attachments. See Editing and deleting messages.
This requires the coordinated 0.8 backend release: an older backend answers
the /own routes with an unmatched-route 404 without a code and sends no
revision (parsed as 0, never edited).
Mark unread (0.7.0)
ConvoKit.markConversationUnread(id) sets a private "mark unread" marker on
the caller's own membership and clearConversationUnread(id, ifVersion: v)
removes it, both returning the caller's private state (unreadMarkedAt,
privateStateVersion). Inbox entries gain isUnread, unreadMarkedAt and
privateStateVersion; a marked room is isUnread with its real unreadCount
(render a numberless dot when the count is 0). getConversation exposes the
caller's own row as Conversation.membership; capture its
privateStateVersion when a room opens and pass it to
markConversationRead(id, throughMessageId:, privateStateVersion:) so only
acknowledgements of that open clear the marker. See Mark unread.
Other members, participant DTOs, webhooks and read events never carry the
marker; the caller's other devices refetch on onInboxActivity. This requires
the coordinated 0.7 backend release: an older backend answers the /unread
routes with 404, sends no membership sibling (the field is null) and no new
inbox fields (isUnread is derived, privateStateVersion is 0).
Inbox previews and unread counts (0.6.0)
ConvoKit.listInbox() returns the caller's rooms ordered by activity with the
latest message preview, an accurate unread count and the caller's own read
state per entry, paged by an opaque cursor (default 30, at most 100 per page).
ConvoKit.realtime.onInboxActivity(appId) signals message sends, edits and
read-position advances anywhere in the app so an inbox screen can refetch
previews and counts; onInboxChanged keeps its structural semantics. See
Inbox. This requires the coordinated 0.6 backend release: an older
backend answers GET /api/v1/inbox with 404 (ConvoKitNotFoundException) and
never broadcasts inbox_activity. getConversations is unchanged.
Precise read positions (0.5.0)
ConvoKit.markConversationRead(id, throughMessageId: messageId) acknowledges a
concrete message instead of "everything the server has now", so a delayed
request can no longer mark a message that arrived after the screen rendered as
read. The backend stores the message's (createdAt, id) cursor as a
ReadPosition that only advances; Participant.readPosition and
ReadEvent.readPosition expose it beside the acknowledgement time, and the
top-level readThrough(...) helper applies the same rule as every other
ConvoKit client. See Read receipts. This requires the
coordinated backend release: an older backend ignores the target and returns no
positions, and both fields stay null. ConvoKitException.code carries the
backend's machine-readable error code when its body includes one.
In development: live inbox and send correlation
These additions require the upcoming coordinated backend/SDK/UI release; they are not included in published 0.3.x packages yet.
ConvoKit.realtime.onInboxChanged(appId) emits an empty signal on room,
membership, profile and cascade changes, and on every verified join/rejoin.
Use the connected app ID from ConvoKit.lastTokenClaims['app_id'] and refetch
authorized conversations in a custom UI. The matching UI package handles this
automatically. This replaces the unimplemented onConversationUpdate() stub.
Every send carries a UUID clientMessageId, returned on Message through REST,
history and Realtime. The SDK generates it when omitted. Custom optimistic UIs
can call createClientMessageId() before showing the pending message and pass
it to ConvoKit.sendMessage(clientMessageId: id, ...). Match by ID, room and
sender, never by similar text or attachments. Reuse the ID only to retry the
same send; identical retries return the existing message, conflicts return 409.
Published baseline
- Configure a ConvoKit app client and connect an app user.
- Create, fetch, archive, unarchive, and leave conversations.
- Send and paginate messages with text, image, and file media; fetch a single message by id; edit and delete your own messages; quote a message as a reply and load a window of context around any message.
- Look up app user profiles, including their last-seen snapshot.
- Subscribe to realtime message (insert/update), typing, read receipt, and presence events.
- Upload user avatars, conversation images, and message attachments through short-lived R2 upload URLs, then verify each object with the backend.
- Session auto-refresh: the SDK refreshes tokens proactively ahead of expiry and reactively on a 401, transparently to callers.
- Typed errors (
ConvoKitAuthException,ConvoKitNotFoundException, etc.) so callers can distinguish transient failures from permanent ones. - Use typed Dart models for
Conversation,Message,Participant, andAppUser.
Installation
Version 0.3 requires Flutter 3.19 or later (Dart 3.3+) and
supabase_flutter >=2.14.0 <3.0.0. Earlier pre-release versions are unsupported
after the coordinated backend cutover. The SDK discovers
the Supabase URL and publishable key automatically. Customers configure neither.
Add the package to your Flutter app:
flutter pub add convokit_flutter
Then import it:
import 'package:convokit_flutter/convokit_flutter.dart';
Setup
Configure ConvoKit once when your app starts. The tokenProvider must call your
own backend and return a ConvoKit JWT for the current app user. Do not embed
your ConvoKit client secret in a Flutter app.
import 'dart:convert';
import 'package:convokit_flutter/convokit_flutter.dart';
import 'package:http/http.dart' as http;
Future<void> bootstrapConvoKit() async {
ConvoKit.configure(
clientId: 'your-convokit-client-id',
tokenProvider: (appUserId) async {
final response = await http.post(
Uri.parse('https://your-api.example.com/convokit/token'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({'appUserId': appUserId}),
);
if (response.statusCode != 200) {
throw Exception('Failed to fetch ConvoKit token');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
return body['token'] as String;
},
);
}
ConvoKit uses the managed https://api.convokit.app endpoint automatically.
For local development, testing, or a self-hosted deployment only, pass
backendUrl to ConvoKit.configure; ConvoKit.defaultBackendUrl exposes the
managed default.
Call connectUser after your app has identified the current user:
await ConvoKit.connectUser('app-user-123');
When the user signs out, disconnect realtime subscriptions:
await ConvoKit.disconnectUser();
ConvoKit owns a dedicated Supabase client; a host app may use its own
Supabase.instance independently. Switching users or reconfiguring retires the
previous session, closes its streams and rejects pending operations. Capture a
new ConvoKit.realtime after connecting the replacement user.
Renewal uses the earlier expiration of the REST and Realtime JWTs, starts before expiry, and is shared across concurrent requests. Temporary renewal failures use bounded backoff; an independent deadline closes an expired session even if your token provider hangs. The provider must issue fresh, extended credentials.
Conversations
final conversations = await ConvoKit.getConversations(limit: 20);
final conversation = await ConvoKit.createConversation(
participants: ['app-user-123', 'app-user-456'],
title: 'Product support',
);
await ConvoKit.updateConversationTitle(
conversationId: conversation.id,
title: 'Billing support',
);
await ConvoKit.archiveConversation(conversation.id);
await ConvoKit.unarchiveConversation(conversation.id);
await ConvoKit.leaveConversation(conversation.id);
getConversations lists rooms in creation order with offset paging and is
unchanged. Inbox screens should use listInbox below.
Inbox
listInbox returns one InboxEntry per living membership, newest activity
first, with the latest message preview and the caller's unread count:
final page = await ConvoKit.listInbox(limit: 30);
for (final entry in page.entries) {
final Conversation conversation = entry.conversation;
final Message? preview = entry.latestMessage; // null for an empty room
final int unread = entry.unreadCount;
final bool capped = entry.unreadCountCapped; // true: render "99+"
final bool isUnread = entry.isUnread; // count, cap or a private marker
final DateTime? marked = entry.unreadMarkedAt; // the caller's own marker
final int version = entry.privateStateVersion; // see "Mark unread"
final ReadPosition? mine = entry.readPosition; // the caller's own position
final DateTime activityAt = entry.activityAt; // the ordering key
}
// Next page: pass the cursor unchanged; null means this was the last page.
if (page.nextCursor != null) {
final more = await ConvoKit.listInbox(cursor: page.nextCursor);
}
// Archived memberships instead of active ones.
final archived = await ConvoKit.listInbox(archived: true);
Each entry is an InboxEntry with conversation and an InboxSummary
(entry.summary: latestMessage, unreadCount, unreadCountCapped,
readPosition, lastReadAt, isUnread, unreadMarkedAt,
privateStateVersion, activityAt); the summary fields are also available
directly on the entry. conversation has the getConversations
shape, except that participants is bounded to at most ten members (the
caller, the latest sender when still a member, then active members by id);
fetch getConversation(id) for the full roster. latestMessage has the
getMessages row shape with at most the first four media items.
Ordering: activityAt is the newest surviving message's creation time, or the
room's creation time for an empty room, with ties broken by conversation id
descending. Editing a message changes the preview but never moves the room;
deleting the newest message recalculates both. Server order is authoritative
within a page; when merging pages after live updates, dedupe by conversation
id and let the later entry win.
Unread counts follow the same readThrough rule as read receipts: messages
from other participants after the caller's read position (or after
lastReadAt for a membership without a position; everything from others for a
fresh membership). Own messages never count. The backend counts over a bounded
window: when more than 1,000 messages follow the position, unreadCount is a
lower bound over the first 1,000 and unreadCountCapped is true.
isUnread is unreadCount > 0 || unreadCountCapped || unreadMarkedAt != null:
the count plus the caller's own mark unread marker. Render the
numeric badge (or "99+") when the count is positive or capped, and a numberless
dot (accessible name "Unread") when isUnread is true with a zero count; never
invent a number for a marker. A 0.6 backend sends none of the three fields:
isUnread is then derived from the count, unreadMarkedAt is null and
privateStateVersion is 0. An InboxSummary built locally derives isUnread
the same way unless it is passed explicitly.
Paging: limit must be an integer in 1..100 (default 30); any other value
throws ArgumentError before a request is sent. cursor is opaque; pass a
previous page's nextCursor unchanged. It stays valid after its room moves or
is deleted. A malformed cursor fails with ConvoKitValidationException
carrying code == 'INVALID_CURSOR'; other rejected parameters carry
INVALID_ARGUMENT. Restart from the head (no cursor) on INVALID_CURSOR, do
not evict the session. getConversations and its offset paging are unchanged.
Live updates: subscribe to both app-hub streams. onInboxChanged still fires
for room, membership, profile, deletion and cascade changes and on every
verified join/rejoin; onInboxActivity fires after a message insert or edit,
after any member's read-position advance and after the caller's own unread
marker changes (a mark, a clear, or an acknowledgement that clears it), and is
never synthesised on a join. Refetch listInbox from the head on either
signal, throttling activity signals in busy apps:
final appId = ConvoKit.lastTokenClaims['app_id'] as String;
final structuralSub =
ConvoKit.realtime.onInboxChanged(appId).listen((_) => reload());
final activitySub =
ConvoKit.realtime.onInboxActivity(appId).listen((_) => reload());
This requires the coordinated 0.6 backend release.
Messages
final message = await ConvoKit.sendMessage(
conversationId: conversation.id,
text: 'Hey, can you take a look?',
);
final messages = await ConvoKit.getMessages(
conversationId: conversation.id,
limit: 50,
);
final single = await ConvoKit.getMessage(message.id);
For older-history paging in 0.3+, pass beforeCreatedAt: messages.last.createdAt
and beforeId: messages.last.id together (only when the page is nonempty).
Keep offset at zero. The backend orders by timestamp then message ID descending;
the cursor still works if its original row has been deleted. This requires the
coordinated 0.3 backend cursor release.
To send media, upload the bytes first and pass the returned URL in the message payload:
final url = await ConvoKit.uploadMessageMedia(
bytes: imageBytes,
fileName: 'screenshot.png',
conversationId: conversation.id,
);
await ConvoKit.sendMessage(
conversationId: conversation.id,
text: 'Screenshot attached',
media: [
{
'type': 'image',
'url': url,
'name': 'screenshot.png',
},
],
);
The SDK completes each successful R2 upload with the ConvoKit backend before returning its media URL. This lets the backend verify the stored object and record its authoritative size before it is attached to a message.
Editing and deleting messages
The author of a message may edit its text or delete it while still an active
member with a writing role. These are the author-tier routes
PATCH /api/v1/messages/:id/own and DELETE /api/v1/messages/:id/own; the
administrative PATCH/DELETE /api/v1/messages/:id routes stay on your
server behind the client secret and are not reachable from this SDK.
// Edit: send the revision of the row the user was shown.
final edited = await ConvoKit.editMessage(
message.id,
text: 'Hey, can you take a look today?',
revision: message.revision,
);
// edited.revision == message.revision + 1, edited.isEdited == true
// Clear the caption of a message that has attachments.
await ConvoKit.editMessage(photo.id, text: null, revision: photo.revision);
// Delete: unconditional, no revision.
await ConvoKit.deleteMessage(message.id);
Revisions: every Message carries revision, 0 as created and incremented by
exactly one on every content edit, whether by the author or by an
administrative edit (media-only administrative edits included). isEdited is
revision > 0 on every surface (REST rows, inbox previews and Realtime UPDATE
row images); never derive "edited" from updatedAt, which the backend always
sends. The backend applies an edit only when the sent revision is still
current and otherwise answers 409:
try {
await ConvoKit.editMessage(message.id, text: draft, revision: message.revision);
} on ConvoKitValidationException catch (error) {
if (error.statusCode == 409 && error.code == 'REVISION_CONFLICT') {
// Someone else changed the message: reload it, show the new content and
// retry with current.revision. The draft is untouched.
final current = await ConvoKit.getMessage(message.id);
}
}
Precedence for consumers that merge rows for one id (a REST response beside a
live UPDATE row image): the higher revision wins and a lower one never
overwrites it; equal revisions, and rows without a usable revision (pending
sends, a 0.7 backend where every row is 0), fall back to
updatedAt ?? createdAt.
Text: the backend trims it; an empty string is stored as null. A null text
clears the caption of a message that has attachments and is rejected with a
plain 400 (ConvoKitValidationException whose code is null) for a text-only
message, because a message must keep text or at least one media item; only a
malformed text or revision body is a 400 with code INVALID_ARGUMENT.
Attachments are never changed by an edit; the content alias is not accepted.
Both keys are always sent, so text: null travels as JSON null rather than
being omitted.
Errors: a message that does not exist, was deleted, or belongs to a room the
caller is not an active member of (also a departed member) is a single 404
with code MESSAGE_NOT_FOUND (ConvoKitNotFoundException) for both calls;
another member's message is a 403 (ConvoKitAuthException); a stale
revision is the 409 above. A revision outside 0..2147483647 throws
ArgumentError before any request. Against a 0.7 backend the /own routes
answer an unmatched-route 404 without a code: do not treat that as the
message being gone.
Signals: an edit reaches the room as a Realtime UPDATE row image carrying the
new revision and fires onInboxActivity on the app hub so inbox screens
refetch previews; it never moves the room's activityAt.
Deleting cannot be undone. The message and its attachment records leave the
conversation for every member: the caller's other devices and every other
member receive onMessageDeleted for the room and onInboxChanged on the
app hub, and the inbox preview moves to the previous surviving message. Files
already received or downloaded cannot be retracted; stored files are
reclaimed by the existing user or app deletion cleanup, not by this call.
Keep a deletion marker so a late REST response or Realtime row for the id
cannot restore it.
Quoted replies and message context
A message can quote another message in the same conversation. Pass the target's id when sending; the key is omitted entirely when you do not, so a plain send is byte-identical to one from a 0.8 SDK.
final reply = await ConvoKit.sendMessage(
conversationId: conversation.id,
text: 'Agreed.',
replyToMessageId: parent.id,
);
// reply.replyToMessageId == parent.id
The reference is write-once: the backend sets it at send and neither
editMessage nor an administrative edit can change it. It also survives the
quoted message being edited or deleted, so a dangling id means the original is
gone, not that the row is broken. A missing, deleted or foreign target on the
send is a 404 with code MESSAGE_NOT_FOUND; a blank id or one over 64
characters is a 400 INVALID_ARGUMENT. Retrying a send with the same
clientMessageId returns the stored row unchanged, even if the quoted message
was deleted in between; retrying it with a different reply target is a 409.
Message.replyToMessageId is null when the row is not a reply. A missing key
(a 0.8 backend) and an explicit null (a 0.9 backend, which sends the key on
every row) are the same single state — there is no third "unknown".
Resolving the quoted text is a separate, batched read. Row images and REST
rows never carry a preview, so collect the distinct replyToMessageId values
of the rows you are about to render and ask for them in one call:
final parentIds = [
for (final m in messages)
if (m.replyToMessageId != null) m.replyToMessageId!,
];
final previews = await ConvoKit.getReplyPreviews(
conversation.id,
messageIds: parentIds,
);
final byId = {for (final p in previews) p.id: p};
The SDK trims each id, de-duplicates preserving first-seen order and splits
the distinct list into requests of at most 50, merging them in chunk order —
callers never chunk. Chunking is all-or-nothing: if any request fails the call
throws and returns nothing, so an unsent chunk's ids can never be mistaken for
deleted messages. Ids that do not exist, were deleted, or belong to another
room are simply absent from a returned result; that absence is the only
deletion signal and is never an error. Render an absent id as "original
message unavailable" and keep the reference and the jump affordance. An empty
list, a blank id or an id over 64 characters throws ArgumentError before any
request is issued.
Each ReplyPreview carries id, conversationId, senderId (the same
value Message.senderId carries, named for the wire key so the model is
identical on every ConvoKit SDK), the first 500 characters of text with
textTruncated, createdAt, the parent's current revision and
mediaCount. It has value equality, so a cached entry can be
compared with a refreshed one. A parent that is already in the loaded window
needs no request at all — derive the preview locally.
Jumping to a message that is not in the loaded window needs a window around
it. getMessages can only walk older from the newest page; getMessageContext
is the random-access companion:
final window = await ConvoKit.getMessageContext(
conversation.id,
messageId: parent.id,
limit: 50,
);
// window.messages is newest-first and centred on parent.id
final older = await ConvoKit.getMessageContext(
conversation.id,
olderCursor: window.olderCursor,
);
Exactly one of messageId, olderCursor and newerCursor must be given, and
limit must be 1..100 (default 30); anything else throws ArgumentError
before any request. With messageId the window is always
min(limit, messages in the room) long — never short merely because the
target sits near an end — and always contains the target. The cursors are
opaque; pass them back unchanged.
newerCursor == null means the window touched the room's newest message at
query time. It does not stay true: messages sent during the round trip are
not in the window. Return to the live tail with a normal newest-page
getMessages load rather than treating a jumped window as live.
Both new routes are conversation-scoped and check active membership before any
message id is read, so an outsider, a departed member or a room in another app
is a 404 Conversation not found (ConvoKitNotFoundException with a null
code) and never leaks which ids exist. A read-only role may read both; they
never answer 403. An unknown, deleted or foreign messageId on
getMessageContext is a 404 with code MESSAGE_NOT_FOUND.
Version skew: against a 0.8 backend neither route exists, so Express answers
the unmatched route with a 404 that carries no code. That is a missing
backend, not a missing message. Distinguish the two by the code:
try {
await ConvoKit.getMessageContext(conversation.id, messageId: parent.id);
} on ConvoKitNotFoundException catch (error) {
if (error.code == 'MESSAGE_NOT_FOUND') {
// The parent really is gone: show "original message unavailable".
} else {
// No 0.9 backend: hide the jump affordance instead of failing per row.
}
}
Rebuilding rows: use Message.copyWith(...) rather than a hand-written
Message( literal. Every nullable field is sentinel-guarded, so omitting it
keeps the current value and passing null clears it:
// Keep hydrated attachments when a bare Realtime row image wins a merge.
final merged = incoming.copyWith(media: known.media);
// Stamp the composer's reply target on an optimistic row.
final pending = row.copyWith(replyToMessageId: replyTarget?.id);
A field-by-field literal silently drops whatever it forgets, which is how a reply loses its reference — and with it the quoted block and the jump affordance — the moment the row is re-wrapped for a pending send, a media hydration or a row image.
Users
final user = await ConvoKit.getUser('app-user-456');
// user.name, user.imageUrl, user.lastSeenAt
final users = await ConvoKit.getUsers(limit: 50);
getUser/getUsers return AppUser — named to avoid colliding with
supabase_flutter's own User type.
Realtime
Subscribe through ConvoKit.realtime after connectUser completes.
final messageSub = ConvoKit.realtime
.onMessage(conversation.id)
.listen((event) {
switch (event.type) {
case MessageChangeType.insert:
// Render the new message: event.message
break;
case MessageChangeType.update:
// Replace the edited message in place: event.message carries the new
// revision (event.message.isEdited); keep the higher revision.
break;
}
});
// Deletion events contain IDs only, not a full old Message.
final deletionSub = ConvoKit.realtime
.onMessageDeleted(conversation.id)
.listen((event) {
// Remove event.id from event.conversationId in your local message cache.
// Retain a deletion marker so an older HTTP/Realtime row cannot restore it.
});
final typingSub = ConvoKit.realtime
.onTyping(conversation.id)
.listen((event) {
// event.userId and event.isTyping
});
final readSub = ConvoKit.realtime
.onReadReceipt(conversation.id)
.listen((event) {
// event.userId, event.readAt and event.readPosition (null from a 0.4 backend)
});
Send typing indicators and read receipts with the REST helpers:
await ConvoKit.sendTyping(
conversationId: conversation.id,
isTyping: true,
);
await ConvoKit.markConversationRead(
conversation.id,
throughMessageId: newestRenderedMessage.id,
);
Presence events are scoped to the app ID in the issued JWT. Read the scope from the connected user's claims (the managed API currently also uses it as the client ID):
final presenceSub = ConvoKit.realtime
.onPresence(ConvoKit.lastTokenClaims['app_id'] as String)
.listen((event) {
// event.userId, event.isOnline, event.lastSeenAt
});
await ConvoKit.updatePresence(isOnline: true);
Cancel stream subscriptions from your widget or state manager when they are no longer needed.
Typing, read-receipt, and deletion listeners share one private room channel. Cancelling one
does not replace or disconnect the other; cancelling the last listener releases
that channel. Presence, onInboxChanged and onInboxActivity share the private
app hub the same way. All SDK channels are private and authenticated before subscription.
Observe ConvoKit.errors for background session failures and
ConvoKit.realtime.errors for sanitized Realtime failures. Attach an onError
handler to event subscriptions too. ConvoKitSessionException.code distinguishes
session changes, expiry and connection errors without exposing provider responses.
ConvoKit.realtime.connectionEvents emits a topic and
RealtimeConnectionStatus.subscribed, interrupted, or closed. A subsequent
subscribed event lets your UI refetch REST history after a reconnect; Realtime
does not replay missed messages. A join acknowledgement is not a delivery receipt
or proof that asynchronous replication setup succeeded—continue observing errors.
Postgres Changes does not apply SELECT RLS to removed rows. This release does not
subscribe to raw DELETE events or assume a full deleted Message is available.
The onMessageDeleted() stream decodes the backend's private
message_deleted notification as MessageDeletedEvent(id, conversationId).
It validates that the payload belongs to the subscribed room. Replace the old
MessageChangeType.delete branch with this separate stream; the enum now has
only insert and update. This requires the coordinated backend/SDK release.
Notifications are best-effort, do not replay, and currently cover explicit
administrator message deletion, not user/room/app cascades. Refetch REST history
on rejoin to reconcile missed deletions. Published 0.3.x requires refresh/reopen
to discover cascade changes and new rooms; the upcoming onInboxChanged()
contract described above removes that limitation.
Private room/app topics are discovered and rotated automatically on membership
loss. Remaining listeners rejoin without changing customer configuration; removed
users cannot receive subsequent activity on a retired topic. Events already sent
while access was valid may still be in flight.
See Postgres Changes limitations.
Read receipts
Acknowledge the newest message the user has actually seen. Order candidates by
(createdAt, id), not by list index, and never target a pending (unsent) row:
await ConvoKit.markConversationRead(
conversation.id,
throughMessageId: newestRenderedMessage.id,
);
The backend resolves that message's (createdAt, id) position and stores it
only when it is newer than the stored one, so an older target after a newer one
is a harmless no-op and positions never move backwards for a living membership
(leaving or being removed discards it). Calling without throughMessageId
keeps the legacy behaviour: the backend targets its newest message at request
time, which can race with messages that arrived after the screen rendered.
lastReadAt still records the acknowledgement time on every accepted call.
Pass the privateStateVersion captured when the room opened so the
acknowledgement also clears the caller's own unread marker; see
Mark unread.
A targeted call whose message no longer exists, or belongs to another room or
app, throws ConvoKitNotFoundException with code == 'MESSAGE_NOT_FOUND';
re-resolve the newest rendered message excluding that id and retry once. A
membership failure is also a 404 but carries no code. A blank target throws
ConvoKitValidationException before any request is sent.
Compute receipts with the shared rule instead of comparing timestamps:
final participant = conversation.participants
.firstWhere((p) => p.appUserId == readerId);
final seen = readThrough(
message,
readPosition: participant.readPosition,
lastReadAt: participant.lastReadAt,
);
// Live updates: a `read` event is broadcast only when the position advanced.
ConvoKit.realtime.onReadReceipt(conversation.id).listen((event) {
final seenNow = readThrough(
message,
readPosition: event.readPosition,
lastReadAt: event.readAt,
);
});
readThrough prefers the position (ReadPosition.covers(message): a later
createdAt, or an equal createdAt and an id that compares greater or equal by
code units) and falls back to lastReadAt >= message.createdAt only when no
position exists yet. Positions can be null while lastReadAt is set: an
acknowledgement in an empty room, a membership recorded before the backend
migration, or a 0.4 backend. Never fabricate the local user's own position from
the device clock; take it from participant responses and read events.
Mixed fleets: precise receipts need both the sender and the reader on 0.5. Readers on 0.4 keep timestamp semantics and still parse the additive payloads.
Mark unread
A user can mark a room unread for themselves only. The marker lives on the
caller's own membership row: other members, participant DTOs, the
conversation.updated webhook and read events never carry it.
// Mark: sets the marker and bumps the private state version.
final ConversationPrivateState state =
await ConvoKit.markConversationUnread(conversation.id);
// state.unreadMarkedAt (non-null), state.privateStateVersion (>= 1)
// Clear without acknowledging a message, only while the version still matches.
final ClearUnreadResult result = await ConvoKit.clearConversationUnread(
conversation.id,
ifVersion: state.privateStateVersion,
);
// result.cleared is false when nothing was marked or the version moved on;
// result.unreadMarkedAt and result.privateStateVersion are the current state.
markConversationUnread bumps privateStateVersion on every call, also on a
repeat mark, so an acknowledgement that captured the older version can no
longer clear the marker: the user's latest intention wins. unreadCount is not
inflated; the room's inbox entry becomes isUnread and a zero-count room shows
a dot rather than a number. Any living membership may mark, including a
read-only role; a membership failure is a 404 without a code.
Capture at open: getConversation(id) returns the caller's own row as
conversation.membership (role, lastReadAt, readPosition,
unreadMarkedAt, privateStateVersion). Read membership?.privateStateVersion
the first time a room screen loads its conversation and send that captured
value with every acknowledgement of that open:
final conversation = await ConvoKit.getConversation(id);
final int? captured = conversation.membership?.privateStateVersion;
await ConvoKit.markConversationRead(
id,
throughMessageId: newestRenderedMessage.id,
privateStateVersion: captured, // omitted from the body when null
);
The backend clears the marker only when the sent version equals the current
one; a mismatch (a repeat mark, or a mark from another device after the
capture) advances the read position as usual and keeps the marker. A call
without privateStateVersion (the legacy body) never clears it. Keep the value
captured at open across later refreshes of the same screen; do not re-read it
from a refreshed conversation, or a mark made while the room was open would be
cleared by an acknowledgement that predates it. membership is null against a
0.6 backend: send no version then.
Empty rooms: there is no message to acknowledge, so a room opened with
membership.unreadMarkedAt set and nothing to render should call
clearConversationUnread(id, ifVersion: captured) once instead. A version-only
markConversationRead(id, privateStateVersion: captured) also clears the marker
in an empty room; in a non-empty one it still clears the marker but also acknowledges through the newest stored message, like the legacy form.
Lists: apply a mark or clear response to a cached entry only when
response.privateStateVersion >= entry.privateStateVersion (replace
unreadMarkedAt and privateStateVersion together and recompute isUnread
from the stored counts and the new marker); ignore a lower version, so a delayed
response cannot resurrect a marker a newer action removed. Other devices learn
of marker changes through onInboxActivity and refetch listInbox.
Validation: privateStateVersion and ifVersion must be integers in
0..2147483647; any other value throws ArgumentError before a request is sent.
A version the backend rejects surfaces as ConvoKitValidationException with
code == 'INVALID_ARGUMENT'. Leaving a room keeps the row and its marker;
rejoining clears the marker and bumps the version; removal deletes the row.
The /unread routes require the coordinated 0.7 backend release (an older
backend answers 404).
Media Helpers
final avatarUrl = await ConvoKit.uploadUserAvatar(
userId: ConvoKit.currentUserId,
imageBytes: avatarBytes,
fileName: 'avatar.png',
);
final conversationImageUrl = await ConvoKit.uploadConversationImage(
conversationId: conversation.id,
imageBytes: imageBytes,
fileName: 'group.png',
);
await ConvoKit.deleteUserAvatar(userId: ConvoKit.currentUserId);
await ConvoKit.deleteConversationImage(conversationId: conversation.id);
Error Handling
REST helpers throw ConvoKitException when the backend returns a non-success
response. statusCode carries the HTTP status and code the backend's
machine-readable error code when the JSON body includes one (for example
MESSAGE_NOT_FOUND, or REVISION_CONFLICT on the 409 a stale
editMessage receives); it is null otherwise. There is no dedicated conflict
exception type: a 409 is a ConvoKitValidationException, so branch on
statusCode and code.
Only an explicit HTTP 401 can refresh and retry a REST request once. HTTP 403, 5xx and network failures do not automatically replay writes: a lost response may follow a successful mutation. Storage PUTs never receive ConvoKit credentials and are not retried automatically. An upload interrupted by logout may leave bytes at storage, but cannot complete or attach them under a replacement user.
try {
await ConvoKit.sendMessage(
conversationId: conversation.id,
text: 'Hello',
);
} on ConvoKitException catch (error) {
// Show a retry state or log error.message.
}
Testing
Offline SDK regression and acceptance-helper tests do not contact a backend:
flutter test --coverage test/convokit_flutter_test.dart test/live_security_checks_test.dart test/client_session_test.dart test/session_http_test.dart test/realtime_protocol_test.dart
flutter analyze
CI runs these checks on Flutter 3.19.6 at the Supabase Flutter 2.14.0 floor
(flutter pub upgrade, then flutter pub downgrade supabase_flutter supabase realtime_client) and
Flutter 3.38.5 with the newest compatible dependencies (flutter pub upgrade).
This checks the Supabase/Realtime provider floors too; other transitive packages
use compatible resolutions, not every historical minimum. The publishing
workflow requires the same checks. Neither CI job uses
live credentials or counts as staging acceptance.
The separate live smoke suite requires the matching security-cutover backend and Supabase configuration, an existing staging app user with active room membership, and an existing message that user may read. Store this JSON outside the repository, replacing the placeholders with staging-only values:
{
"BACKEND_URL": "https://staging-api.example.invalid",
"CLIENT_ID": "STAGING_CLIENT_ID",
"CLIENT_SECRET": "STAGING_CLIENT_SECRET",
"APP_USER_ID": "EXISTING_ACTIVE_MEMBER",
"MESSAGE_ID": "EXISTING_READABLE_MESSAGE"
}
flutter test test/integration_test.dart \
--dart-define-from-file=/absolute/private/convokit-staging.json
Missing values fail before connecting; there is no implicit production endpoint,
fallback user, or silent skip. A plain flutter test includes the live file and
therefore also requires this configuration. This runner simulates a trusted
server's token exchange: never embed its client secret or define file in a
customer Flutter app. It does not create/delete fixture records or broadcast
messages, but authentication and reads can produce backend usage records.
The live suite checks generation/app-bound tokens, an exact REST-verified Message row through PostgREST, explicit PostgreSQL permission denials for 11 sensitive tables (including Conversation), and private app/room/message channel joins. Supabase URL and publishable key are discovered by the SDK; they are not test configuration or customer setup parameters. Private channel authorization and row visibility are separate controls. See Realtime authorization.
A successful join is not evidence of event delivery. Release acceptance still requires the two-app/two-member/non-member/departed-member matrix, direct-write denial, messages/typing/receipts/presence/files, reconnects, token expiry and already-joined access revocation. Run actual event assertions against staging; offline tests and this read-only smoke suite do not replace that matrix. In particular, verify DELETE behavior separately under the deployed RLS configuration; see Postgres Changes limitations.
Publishing
Publishing to pub.flutter-io.cn is automated through GitHub Actions when a version tag is pushed, after both compatibility test jobs pass. A tag must match the package version; never reuse an already published version for changed source.
Before the workflow can publish, the package must already exist on pub.flutter-io.cn. If
this is the first release, publish it manually once with dart pub publish or
flutter pub publish.
Enable automated publishing from the pub.flutter-io.cn package admin page with:
- Repository:
ConvoKitApp/ConvoKit-Flutter-SDK - Tag pattern:
v{{version}} - GitHub Actions environment:
pub.flutter-io.cn
To publish a new version:
- Update
versioninpubspec.yaml. - Update
CHANGELOG.md. - Commit and push the changes.
- Push a matching version tag:
git tag v<new-version>
git push origin v<new-version>
License
ConvoKit Flutter SDK is licensed under the Apache License, Version 2.0. See
LICENSE for details.