noma_chat 0.27.0
noma_chat: ^0.27.0 copied to clipboard
Plug & play Flutter chat: SDK with REST + real-time client, offline Hive cache, UI adapter and ready-to-use UI components for the Nomasystems chat backend.
Changelog #
All notable changes to noma_chat are documented in this file.
The format is based on Keep a Changelog,
and the package follows Semantic Versioning. From 1.0.0
onwards, breaking changes require a major version bump.
0.27.0 - 2026-08-21 #
Minor bump: no API is removed or narrowed, but several defaults now behave differently — the unread divider anchors somewhere else, a group's grey ✓✓ stops waiting on members who never showed up, an empty room draws a card, and the row whose context menu is open is tinted. Every one of them is opt-out. A host that upgrades and rebuilds compiles untouched.
Added #
-
A room with no messages is a starting card, not a dead end.
ChatViewBuilders.emptyRoomBuilderbuilds what an empty room shows, receiving anEmptyRoomInfo—roomId(nullwhile a DM is still a local draft),isGroup,currentUser,otherUsers/otherUser, andonSendFirstMessage, which sends text exactly as the composer would.onSendFirstMessageisnullin a room that cannot be written to (read-only, blocked, or no send callback wired), so a card knows when to hide its offer. Returnnullfrom the builder for a room you have nothing to say about and the SDK's own card is drawn instead, so a host can decorate the rooms it recognizes and leave the rest alone.EmptyRoomStateis the layout itself, exported so a host can keep the SDK's spacing and theming while supplyingheader(its own card above the explanation) andactions(the buttons under it).DefaultEmptyRoomStateis the fallback: the SDK explanation plus, in a 1:1 that can be written to, a one-tap 👋 as the first message. The suggestion is an emoji and not a phrase because the SDK cannot translate a greeting into a locale it does not ship.ChatViewBehaviors.emptyTitle/emptySubtitle/emptyIconstill replace the labels without replacing the card. Diverges from WhatsApp, which leaves an empty room bare except for its encryption notice. -
MessageList.activeRowMessageId,activeRowColorandactiveRowDecorationBuilder— the row whose context menu is open, and how it is painted while it is. -
ChatController(groupReceiptPolicy:)withGroupReceiptPolicy, for hosts that want the old strict divisor back (allMembers). -
MessageSearchView.currentUserId,emptyPromptText,resultCountLabelBuilder,showResultNavigationandautofocus. -
resolveUnreadBoundary, the pure function that decides where the "N new messages" line lands, exported so a host can reason about (or test) the same decision the room makes. -
A legend for the ticks, and times that do not lie.
DeliveryStatusLegendSheetis a sheet a host can open from its own room menu: the five delivery states, each drawn with the glyph the bubbles actually use (so a custombubble.statusIconBuilderis honoured) and described with the same words the screen reader already speaks, plus a note about the group rule. Configurable throughtheme,isGroup,states,entryBuilderandtitle.MessageInfoSheetnow says when only when it knows: the server keeps a read cursor per participant, not a timestamp per message, so an exact time exists only for the message that cursor points at. Every other message gets "no exact time" rather than a plausible-looking wrong one.showApproximateReceiptTimes: trueopts into the honest upper bound ("by 10:42 at the latest") instead. Overridable throughreceiptTimeFormatterandreceiptSubtitleBuilder; the per-row data is exposed asMessageReceiptDetail. -
Twelve strings moved into
ChatUiLocalizationsthat used to be hardcoded in the search view and the sheets above, so a host can translate them:searchPromptEmpty, the singular/plural pair behindsearchResultCount(count)(which goes through CLDR plural rules rather thancount == 1), and the legend's own labels.
Changed #
-
The "N new messages" divider anchors on the reader's own read cursor, not on a count taken from the end of whatever page happens to be loaded. Counting back N places put the line above the date separator and above the reader's own messages. The line now sits on the first message after the cursor, never on one of the reader's own, and is not drawn at all until the first page of history has settled. With no cursor available it degrades to the old count-back, restricted to incoming messages.
-
A group's grey ✓✓ no longer waits on members who never acknowledged anything. A roster entry that has never produced a single receipt cannot be told apart from an invitee who never showed up, and holding every other member's delivery state on them left the sender with a bubble that never moved. The divisor is now the members who have ever confirmed something in the room, falling back to the whole roster while nobody has. Blue stays strict: it still means every member read the message. Revert with
ChatController(groupReceiptPolicy: GroupReceiptPolicy.allMembers). -
Sender grouping breaks on a system message, the way it already broke on a date separator: the first bubble after one shows its name and avatar again instead of reading as a continuation of a run interrupted minutes ago.
-
The row whose context menu is open is tinted for as long as the menu stays up — the WhatsApp treatment for a message being acted on. Opt out with
MessageList.highlightRowWhileContextMenuOpen: false, or take the decision over by drivingactiveRowMessageIdyourself. -
In-room search opens focused, says what it searches before anything is typed, labels the user's own hits, and heads the list with a result count plus previous/next arrows.
Fixed #
-
A failed attachment no longer blanks the chat list row. The revert read the previous message out of the room's own message controller alone, which is empty for a room the user never opened, so the row went blank instead of falling back to the preview it had been showing all along.
RoomListMutatornow remembers the confirmed preview each row carried before the optimistic one and restores it when the send fails. -
NomaChatViewstopped dropping two host builders. It rebuildsChatViewBuildersto layer the adapter's defaults underneath, andblockedMessageBuilderandbatchUserFetcherwere never copied across — a host that wired either throughNomaChatViewsaw it silently ignored (wiring them onChatViewdirectly always worked).
Known limitations #
-
MessageSearchView's opening prompt and result count still ship asen/esliterals insidemessage_search_delegate.dartrather than asChatUiLocalizationskeys; both are overridable per call site. SeeCONVENTIONS.md§4. -
The empty-room card's built-in first-message suggestion is a single emoji. Word suggestions are the host's to supply, through
EmptyRoomState.suggestionsfrom anemptyRoomBuilder, until the strings live in the localization bundle.
0.26.0 - 2026-08-20 #
Minor bump carrying two breaking additions and three changed defaults.
A host that upgrades and rebuilds compiles untouched unless it switches
exhaustively over MessageAction or implements ChatClient itself; the
behaviour changes are all opt-out. See MIGRATING.md for the upgrade path.
Fixed #
-
A failed attachment is no longer a dead end. A photo whose
POST /attachmentsnever landed left a bubble that could not be sent, could not be removed, and that the chat list went on advertising as sent — the user walked away believing they had sent a picture nobody received. Three things changed:The bytes now survive the failure.
ChatUiAdapter.failedUploads(aFailedUploadRegistry) holds them, somessages.retrySendon that bubble re-uploads the same file instead of refusing withattachment_never_uploaded. Only the two failures that prove the bytes never left were ever recoverable before, through the offline queue; every other upload failure — a 5xx, a gateway timing the request out, a rejected content type — had nothing to retry with. Retention is memory-only, ends with the session, and is bounded by two tunable caps (maxEntries, default 8;maxBytesPerEntry, default 12 MB); past them the retry refuses exactly as it did before.ChatUiAdapter.messages.discardFailed(roomId, messageId)is the way out for a user who gives up: the bubble, its cached pending copy and its retained bytes go, and nothing is sent. It is surfaced asMessageAction.discardFailedon failed outgoing rows, in place of "Delete" — which would promise a deletion for everyone that has nobody to reach, and which the delete window hid outright once the row aged, leaving the bubble unremovable.The chat list stops lying. A media send that fails now takes its optimistic preview back off the row, falling back to the room's newest real message or clearing it when the failed send was the only one. Text sends are unchanged.
Both routes also empty the offline queue of that row. A send that failed on connectivity leaves a copy of itself in the queue, and the queue drains on every reconnect: without this, discarding sent the photo the user had just taken back, and retrying sent it twice — under two idempotency keys the server has no way to relate. Neither can be undone once it lands in a room somebody else is reading.
discardFailedandretrySendnow drop the queued copy through the newChatClient.cancelOfflineSend.A retried voice note keeps its recording. The retry re-read the clip's length off the failed row but not its waveform, so a seven-second note went back out drawn as a flat bar.
-
A refused edit says so, and gives back what was typed. An edit confirmed after the server-side window closed came back 403
edit_window_expired, and the SDK swallowed it whole: the composer shut, the bubble rolled back to the original wording, and nothing appeared on screen — so the user believed they had corrected what they wrote. The refusal now surfaces through the operation-error stream as a localized snackbar (ChatUiLocalizations.editWindowExpired), and the composer re-opens in editing mode carrying the attempt rather than the wording the server still holds. Only that refusal reopens it: the expired window is the one failure the user is told about, so it is the one where the composer coming back reads as an explanation instead of an unexplained jump back into editing — a network hiccup leaves the composer shut, as before. Opt out withChatViewBehaviors(restoreComposerOnEditFailure: false); the mechanism underneath isChatController.setEditingMessage(message, draftText:)andChatController.editingDraftText. -
A notice raised while a route is coming down is no longer lost. Every short message the SDK shows on its own — an unblock that failed, a group that could not be created, a role change the server refused, the ten or so of them — went straight to
ScaffoldMessenger.of(context).showSnackBar. That call walks everyScaffoldregistered with the messenger, and aScaffoldunregisters indispose, never indeactivate: between the frame that removes a route and the end of that same frame, one dyingScaffoldanywhere under the messenger threw the call of whoever was publishing, taking the notice and the rest of that callback's work with it. They now all go throughshowChatNotice, which publishes after the frame when the tree is still settling and swallows nothing. -
A host that kept its own
enabledActionsis not stranded on a failed row. The menu swapsdeletefordiscardFailedon a send that failed, so an action set written before this release — one that hasdeleteand nodiscardFailed— came back with no destructive action at all, leaving a red bubble with no way out. Such a set now keeps its owndeleteon those rows, ungated by the delete window, which has nothing to say about a message the server never saw.NomaChatView's built-in delete callback discards a failed row instead of deleting it, without a dialog: asking the server to delete a message it never received fails, and leaves the bubble exactly where it was. -
A room header's participant count follows joins and leaves.
RoomListItem.memberCountonly ever came from a room-detail fetch, and auser_joinedframe refreshed the roster without going back for it. The count therefore kept whatever number the room was opened with — contradicting the "… joined" system card printed right underneath it — and survived a leave-and-reopen, because the cached detail was stale as well. Bothuser_joinedanduser_leftnow invalidate the cached detail and re-read it, the wayuser_role_changedalready did — the eviction completing before the read starts, so a slow store can no longer wipe the fresh detail it was meant to replace.Opening a room re-reads its detail too. A refresh driven only by frames is only as good as the socket: one
user_joinedlost to a reconnect and the count stayed wrong for as long as the row lived, which is precisely what "it was still wrong after leaving and coming back" meant. Entry is the cheap, self-healing moment to ask again, and it also picks up a renamed room, a new avatar and a read-only flag set while the app was away. Reads are single-flighted per room and a burst of roster frames collapses into one detail read plus one trailing re-read, so a plan filling up does not turn into oneGET /rooms/{id}per frame. -
Non-text bubbles reach a screen reader with a body. A photo, a video, a shared location, a document and a failed upload all announced themselves as "You: , Sent" — sender, empty text, status. They now read "You: Photo, Sent", "You: Location, Sent", "You: contract.pdf, Sent", reusing the descriptions the chat list had been able to produce all along (emoji-free: a screen reader reads "📷" out loud). A failed send announces
Failed, where it used to announce nothing at all.A caption no longer swallows what it captions: a photo sent with a line of text read as that line alone, leaving no clue there was an image above it, and now reads "You: Photo, en la playa, Sent". A forward is announced as one — the "Forwarded" marker the bubble draws was never spoken, and a forward carrying no text of its own was the last row still reaching a screen reader as "You: , Sent".
Changed #
-
Breaking —
MessageActiongaineddiscardFailed. Only an exhaustiveswitchoverMessageActionwith no default arm needs a change; same shape as a newMessageTypeorChatFailurevariant. It is in the default action set of bothMessageContextMenuandNomaChatView, andChatViewroutes it toChatViewCallbacks.onDiscardFailedMessage. -
Breaking —
ChatClientgainedcancelOfflineSend(String tempId). It drops whatever the offline queue holds for an optimistic row and returns how many operations went. Only a host implementingChatClientitself needs a change;0is the right answer for a client with no offline queue, which is whatMockChatClientreturns. -
Deleting a message now asks first.
MessageAction.deletedeletes for everyone and cannot be undone, and the gesture that starts it is a long press on a whole row — while blocking a contact and clearing a chat, both recoverable, each already confirmed.NomaChatView's built-in delete callback now shows a confirmation dialog (deleteMessageConfirmTitle/deleteMessageConfirmBody).MessageAction.deleteForMeandMessageAction.discardFailedare not gated: neither leaves the device. Opt out withChatViewBehaviors(confirmDeleteForEveryone: false). A host that supplies its ownonDeleteMessage, or replaces the long-press menu throughonMessageLongPress, owns the confirmation itself. -
Blocking someone now prunes their content in group rooms. A block used to be cosmetic there: the name and avatar came off the bubble while the text, the shared location and the photo stayed exactly where they were, and nothing said a blocked person was in the room. Their rows are now replaced by a one-line placeholder — the new
BlockedContentPolicy.placeholderdefault, which prunes the content while keeping the room honest about who is in it.ChatViewBehaviors.blockedContentPolicyalso takeshide(drop the rows outright) andshow(the previous behaviour, for a host whose backend already filters server-side). System rows are never pruned — they are the room narrating itself, not the blocked person speaking. 1:1 chats are untouched: they already collapse into the blocked-contact banner over an intact history, and pruning one would be the room saying the same thing twice and losing the conversation to say it.The prune reaches every surface that carried the content past the bubble: the quoted strip a reply paints of a blocked message, the reactions a blocked user left on anyone's message (subtracted from the counts; anonymous counts, with no reactor ids on the message, are left alone rather than guessed at), and the room list preview of a group whose last message is theirs (
RoomTile.blockedSenderIds/.blockedContentPolicy, wired for free byRoomListViewfrom theadapterit is already given). A group that is pruning also carries a one-line notice (blockedInRoomNotice) so a reader can tell why a stretch of the conversation went quiet — the ficha's "some indicator in the room". The notice appears only while the blocked person actually has content in the room.blockedSenderIdsare chat user ids — the same space asChatMessage.from,RoomListItem.otherUserIdandChatUiAdapter.contacts.block. A host whose own user ids differ has to map them first: an id that matches nobody prunes nothing, exactly like an empty set.
Added #
ChatUiAdapter.failedUploads— theFailedUploadRegistrydescribed above, exported so its two caps can be tuned.ChatUiAdapter.messages.discardFailed(roomId, messageId)— drops a failed outgoing row for good. Returns aNotFoundFailurefor anything that is not a failed row of that room.ChatViewBehaviors.confirmDeleteForEveryone,.restoreComposerOnEditFailure,.blockedContentPolicy,.blockedSenderIds— all four default to the behaviour described above and all four are opt-out.ChatViewBuilders.blockedMessageBuilder— replaces the built-in blocked-sender placeholder.ChatViewCallbacks.onDiscardFailedMessage— wired byNomaChatViewtomessages.discardFailed.MessageList.blockedSenderIds/.blockedContentPolicy/.blockedMessageBuilder,RoomTile.blockedSenderIds/.blockedContentPolicy,RoomListView.blockedSenderIds/.blockedContentPolicyandMessageContextMenu.isFailed— the same knobs for a host driving those widgets directly.MessageListprunes in groups only, resolved from its ownisGroup.ChatController.setEditingMessage(message, {draftText})andChatController.editingDraftText.showChatNotice(context, message, {snackBarBuilder})andChatNoticeScope— the single door every SDK notice goes through, and the host's override for it. Nothing has to be mounted for the notices to work; mount aChatNoticeScopeabove yourMaterialApp(so the routes the SDK pushes inherit it) to present them your own way, and returnfalsefrom the presenter for the ones you would rather leave to the SDK.- Six localized strings in all twelve bundled locales:
editWindowExpired,deleteMessageConfirmTitle,deleteMessageConfirmBody,blockedMessageHidden,blockedInRoomNotice,discardMessage. They are confirmations and action labels, which the Nordic + Eastern-EU tier (sv,no,da,pl,cs) covers by policy rather than leaving to the English fallback — a dialog asking to delete a message for everyone is the last place to answer in a language the reader did not pick.
0.25.0 - 2026-08-19 #
Minor bump: a new opt-in analytics channel, additive across the board — no existing signature changed, no type removed. A host that upgrades and rebuilds compiles untouched and, wiring nothing new, emits exactly as before.
Added #
-
ChatAnalyticsSink/ChatAnalyticsEvent— a product-analytics channel, deliberately separate frommetricCallback/MetricCallback(seeTELEMETRY.md): this one is where room and message identifiers are allowed to travel, because a product funnel is meaningless without them. Four events:roomOpened,messageReceived,voicePlayed,sendOutcome.ChatAnalyticsEventis afreezedsealed union — consumerswitchstatements need a wildcard case to stay forward compatible with a future variant, same asMessageTypeorChatFailure. SeeANALYTICS.mdfor the full contract, emission sites, and a wiring example.Settable on
ChatConfig.analyticsSink(forNomaChat.create/fromConfig) and directly onChatUiAdapter's constructor — the latter matters for a host that buildsChatUiAdapterby hand instead of going throughNomaChat.create, since a callback that only lived onChatConfigwould never reach it.nullby default: wiring this is entirely opt-in, and a throwing sink is caught and dropped exactly like every other user-supplied callback in this SDK — analytics can never break the chat.Identifiers travel unhashed and the SDK does not sample, batch, or drop events — see
ANALYTICS.mdfor why (a consumer that needs hashed ids, likeWB, applies its own sanitizer unconditionally on the way out; a second transformation point inside the SDK would just be a second place for that mapping to drift).ChatViewCallbacks.onVoicePlayedis new too —NomaChatViewalways wires its own default there (publishing the analytics event) and additionally calls whatever the host sets, so a host callback never silently disables the SDK's own emission.Each event documents what it does and does not count — which send paths emit
sendOutcome, why an unmaterialized DM draft emits noroomOpened, whatvoicePlayed.firstListenreally means — inANALYTICS.mdunder "Known limits of the four events". Read that section before building a funnel on top of these. -
ChatUiAdapter.dm.isDraftRoutingKey(key)— tells a syntheticdraft:<otherUserId>routing key apart from a server-side room id, which callers previously had to do by re-encoding the prefix themselves.
0.24.0 - 2026-08-18 #
Minor bump: three behaviour fixes in the chat surface, no API added, none removed, no signature changed. A host that upgrades and rebuilds compiles untouched. Two things change on screen and one of them can break a test — the message context menu now opens from anywhere on the row, not only from the bubble.
Changed #
-
The long-press that selects a message spans the whole row. WhatsApp behaviour: the avatar, the empty half of the line beside the bubble, the reaction bar and the thread link all open the message menu, where before the gesture was clipped to the bubble's own box — at most 75 % of the width, and frequently much less for a short message.
onMessageLongPressand every callback above it keep their signature; what changed is the area that fires them.The recognizer moved, it was not duplicated: there is one
LongPressGestureRecognizerper row, now at row level withHitTestBehavior.opaqueso the empty side of the line is live. Whatever the message text did on long-press it still does, unchanged by this release: selectable text wins that arena on its own, but it is only selectable when the host leavesonSwipeToReplyunwired —MessageBubblesetsenableSelection: onSwipeToReply == null, andNomaChatViewalways wires swipe-to-reply, so under the default surface the text long-press opened the menu before this release and still does.Nothing changes for a screen reader. The row detector is built with
excludeFromSemantics: true, so it publishes no node: aGestureDetectorotherwise contributes a node carrying alongPressaction, and on iOS an action alone is enough to make a node focusable — VoiceOver would have gained a second, label-less, full-width stop stacked on the bubble's own. The long-press action stays declared exactly once, on the bubble'sSemantics, which is the node that carries the label.test/ui/widgets/message_bubble_row_long_press_test.dartfails if that flag is ever dropped.Risk for a host's tests: an existing test that long-presses at coordinates inside the bubble still passes — the area only grew. A test that asserted a long-press outside the bubble does nothing will now see the menu open.
Fixed #
-
Delivery ticks are right on the first frame after opening a room. A room whose own sent messages had already been delivered painted a single ✓ and swapped to ✓✓ a moment later. The state was never wrong, only late: the cached message rows carry the receipt they held when they were written, and a ✓✓ that arrived as a realtime event while the room was closed never reached them, so recovering it waited on two network round trips (the message page, then the receipt cursors).
Two halves, both on the local side, neither of them a new stored field — the per-message
receiptand the per-room cursor box were both already on disk:- Opening a room now reads the stored receipt cursors and applies them in the
same synchronous turn as the cached rows, before the first frame is
scheduled.
getRoomReceiptsstays pinned tonetworkFirst, and the network pass still runs and still wins — a receipt is applied monotonically, so a stale local cursor can only mark fewer rows, never walk a tick backwards. - A receipt frame that arrives for a room nobody has open now writes the
cursor to the receipt box. With no controller there was nothing to advance
and nothing to drain, so that ✓✓ used to exist only as the in-memory
room-list tick while the cached rows kept saying ✓. A
sentframe and the user's own read receipts are ignored, as before.
An adapter built without a
cache:is unaffected in either direction. - Opening a room now reads the stored receipt cursors and applies them in the
same synchronous turn as the cached rows, before the first frame is
scheduled.
-
The chat list shows a preview line for a location, a forward and a deleted message. A map arrived as an untyped row with an empty body and the row's subtitle came out blank; the same for a forward with no text of its own, and a deleted message still showed its old text. Two things fixed it:
- The room-listing mapper now honours
messageTypeforlocation,forward,replyandaudio, andisDeleted, on thelastUnreadMessageprojection — the backend emits them from the same vocabulary the package already uses to type a message. Where the field is absent, a location is still recognised from numericlat/lngin the metadata, the way the full-message mapper already did. - A document or a named audio file keeps its name in the preview
(
📄 contrato.pdfinstead of📄 File). The name travels in the message metadata and the listing mapper was only reading a top-level field that the projection does not send.
A reaction on the last message is deliberately not read as "this row is a reaction": the
reactionfield of a listing row lists the reactions the message received, and treating it as the row's own type would replace a perfectly good text preview with a reaction sentence. Reaction previews keep coming from the realtime path, as before. - The room-listing mapper now honours
-
The chat-list preview strings are translated in all 12 shipped languages.
previewLocation,previewPhoto,previewVideo,previewVoiceTemplate,previewDocumentTemplate,attachmentPreview,audioPreview,previewSticker, the deleted-message pair and the three reaction-preview templates existed only inen, es, fr, de, it, pt, caand fell back to English insv, no, da, pl, cs. They are now set in all of them. No key was added or renamed. The rest of the documented English-fallback gap in those five locales is unchanged.
0.23.0 - 2026-08-18 #
Minor bump: the automation vocabulary of 0.22.0 gains one attribute and one
name. Nothing is removed, no signature changes, no rendering moves and no
screen-reader announcement changes, so a host that upgrades and rebuilds
compiles untouched. The one thing that can break is a test that hardcoded
the old message-bubble name — see Changed.
Added #
-
A message bubble says who wrote it, as an attribute of its name. The bubble now answers to
chat_message_<messageId>_outgoingwhen the current user sent it and tochat_message_<messageId>_incomingotherwise, on both halves — the row'sValueKeyand the bubble'sSemantics(identifier:). Until now the only thing that told the two apart on screen was the bubble colour and which side of the room it sat on, neither of which a driver can read, and the screen-reader label that does say it is localised.messageBubbleSemanticsIdis exported so a test asks the SDK for the name instead of re-deriving it. -
The delivery tick of a message row carries its own name.
chat_message_<messageId>_status, published on both halves ofMessageStatusIcon, so a driver points at the tick of one specific message instead of at "some check somewhere".messageStatusSemanticsIdis exported.Read the reach before you build a harness on it. A bubble consolidates the announcements of everything it contains into a single screen-reader label and excludes its own subtree, so inside a bubble the two halves land on two nodes: the
ValueKeyon the tick, and the identifier on a bare sibling node — name only, no label, value, hint or action — stacked over the bubble's corner. That keeps the message reading as one unit with its delivery state announced once. What it costs is iOS:SemanticsObject.isAccessibilityElementis decided byisFocusable, which asks for a label, a value, a hint or a non-scrolling action and does not look at the identifier, so a node carrying only a name is not published as aUIAccessibilityElementand an XCUITest oridbdump will not list it. Inside a bubble, therefore:ValueKey(widget tests,integration_test, the VM Service) everywhere,resource-idon Android, and nothing on iOS. On iOS assert delivery from the bubble's own node, whose label ends in the localised delivery state. AMessageStatusIcongiven amessageIdand rendered outside a bubble is the simple case: both halves sit on the icon itself and it is published normally, because its own label makes it focusable. -
MessageStatusIcon.messageId— optional,nullby default. Names the tick;nullin the room-list preview, where the icon summarises the last message of a room rather than a row of a timeline and has no single id to answer to. AstatusIconBuilderoverride replaces the SDK's icon and, with it, the name.
Changed #
- The message bubble name gains the authorship suffix.
chat_message_<messageId>becomeschat_message_<messageId>_outgoingorchat_message_<messageId>_incoming. The suffix wraps the identity the name already carried, never replaces it with a positional one, and the list'sfindChildIndexCallback— which parses the key back to reconcile rows — moves in the same change, so scroll-position stability is unaffected. A test or driver that hardcoded the old string updates it, or better, callsmessageBubbleSemanticsId(messageId, isOutgoing: …).
0.22.0 - 2026-08-16 #
Minor bump, not a patch on 0.21.0: the release adds public API (one
constructor field and seven exported helpers). Nothing is removed and no
signature changes, so a host that upgrades and rebuilds compiles untouched.
Added #
- Stable automation names on the chat room and its eleven internal surfaces.
49 names (39 fixed, 10 templated on a row's own id) now travel with the widgets
the SDK paints: the composer field, the send and attach buttons, every message
bubble, reaction pills and the reaction picker, the media / documents / links
gallery and its tabs, the full-screen image viewer, starred messages, in-room
search, the attachment sheet and the camera's viewfinder, shutter and review
step — including their loading, empty and error states. Every name is published
twice with the same literal: as the widget's
ValueKey, which is whatfind.byKeyand anintegration_testsee from inside the app, and asSemantics(identifier:), which surfaces outside it asresource-idon Android andaccessibilityIdentifieron iOS, so the same string drives a widget test, a UiAutomator dump and an XCUITest run. Names read<area>_<element>_<kind>in lower snake case under achat_prefix (chat_send_button,chat_gallery_media_tab,chat_camera_review_send); collection rows carry their own id (chat_message_<messageId>,chat_starred_item_<messageId>). Accessibility is untouched: where aSemanticsnode already existed onlyidentifier:was added, so every label,button/enabledflag and custom action reads exactly as before, and noSemanticswas nested inside another. AttachmentSheetOption.identifier— optional,nullby default. Names a row ofAttachmentPickerSheetfor automation: the string becomes both the row'sValueKeyand itsSemantics.identifier, so a driver points at an option regardless of the locale itslabelrenders in. The four built-in rows carrychat_attachment_option_camera/_gallery/_file/_location; a host row inextraOptionsthat passes nothing falls back tochat_attachment_option_extra_<position>, stable only while the list keeps its order — pass a name of your own when it does not.- Seven exported helpers that build the templated names, so a test asks the SDK
for a row's name instead of re-deriving the format:
attachmentSemanticsId,mediaCellSemanticsId,docRowSemanticsId(media_gallery_view.dart,docs_list_view.dart),linkRowSemanticsId,searchResultSemanticsId,starredRowSemanticsIdandstarredUnstarSemanticsId.attachmentSemanticsIdis the shared suffix — the backend'sattachmentIdwhen it sent one, otherwise the url + sender + timestamp triple — so the same attachment answers to the same name on the Media grid and on the Docs list.
Changed #
- Widget keys renamed to the new naming. The five bare keys on the camera
screen (
preview,close,flip,recordingPill,controls) are nowchat_camera_preview,chat_camera_close,chat_camera_flip,chat_camera_recording_pillandchat_camera_controls. Three list reconciliation keys gain the matching prefix while keeping the identity they already carried: a message row goes fromValueKey(message.id)tochat_message_<messageId>, a documents row from<url>-<senderId>-<timestamp>tochat_gallery_doc_<attachmentId>(falling back to that same triple when the backend sent no attachment id), and a links row from<url>-<timestamp>tochat_gallery_link_<url>-<timestamp>. Gallery grid cells, which reconciled by position, gain a key of their own —chat_gallery_media_<attachmentId>, with the same fallback as the documents row. None of these keys is part of the exported API and none was ever documented as one, so nothing to compile breaks; a host that hardcoded one of the old strings in its own widget tests updates the string. - The
MediaGalleryPagetabs are built withTab(child: Text(...))instead ofTab(text: ...)so the name rides the label. TheTextis a verbatim copy of whatTabbuilds internally fortext:(softWrap: false,overflow: TextOverflow.fade), and the widgets stayTabs, so the tab bar measures and renders identically.
Known limitations #
- The package publishes the names and nothing else: turning the semantics tree on
is the host's call (
WidgetsBinding.instance.ensureSemantics()under a test flavour, or the platform's own accessibility service). Without it theSemanticshalf is invisible to a native driver, while theValueKeyhalf works regardless. - Surfaces the host owns are still the host's to name — the
AppBararoundMessageSearchViewandStarredMessagesView, and any attachment sheet injected in place of the SDK's own.
0.21.0 - 2026-08-15 #
Minor bump, not a patch on 0.20.0: the enum addition below is a source break
for hosts that switch over it exhaustively, the camera stops sending on its
own, and the package gains a video_player dependency.
Added #
- A confirmation step between the shutter and the send.
CameraCapturePageno longer hands a capture back the moment the finger lifts: what the shutter produces lands onCameraCaptureReview(exported, and usable on its own — a plain widget with no routing baked in) with exactly three ways out. Send is the only one that returns the capture; Retake deletes the file and goes back to the live viewfinder; Discard leaves the camera with nothing. The system back gesture on the review is a retake, not a silent exit. A capture nobody confirmed is deleted on teardown — including the case where the host pops the route out from under the review — because nothing else collects the camera's cache directory. Three new strings (cameraRetake,cameraDiscard,pausePreview) in every locale that already translated thecamera*family;send,playPreviewandcloseare reused. video_playeris now a dependency, used by exactly one widget:CameraVideoPreview, the review step's playable clip (tap to play, tap to pause, a finished clip restarts from the first frame; a container the platform decoder cannot open degrades to a static placeholder so the capture stays sendable). Hosts that would rather not ship a second video stack replace it wholesale — throughCameraCapturePage(videoPreviewBuilder: …)when they push the screen themselves, or through the newChatViewBuilders.videoPreviewBuilderfor the flowNomaChatViewopens from the composer's Camera row. With the slot wired, nothing the SDK renders touchesvideo_player.ChatTheme.cameraCaptureSendButtonColorandChatTheme.cameraCaptureReviewActionStyle— the review step's Send button fill (defaults toDefaultPalette.cameraCaptureSendButton, the same green as the composer's send button) and its Retake label style (falls back tocameraCaptureHintStyle, then to the capture screen's own white-on-black).- 14 flat
ChatThemeslots for the in-room search screen, so it stops looking stock-Material inside a themed app:messageSearchBackgroundColor,messageSearchFieldFillColor,messageSearchFieldTextStyle,messageSearchFieldHintStyle,messageSearchFieldCursorColor,messageSearchFieldBorderColor,messageSearchFieldBorderRadius,messageSearchFieldIconColor,messageSearchResultTitleStyle,messageSearchResultSnippetStyle,messageSearchResultHighlightStyle,messageSearchResultTimestampStyle,messageSearchEmptyTextStyle,messageSearchProgressColor. The split of responsibilities is unchanged — the host still owns theScaffoldandAppBararoundMessageSearchView, the SDK themes what it paints inside. Every slot isnullby default and an unthemed host renders exactly as before; all four presets (lightPreset,darkPreset,branded,highContrast) now fill them, so the search screen follows a preset like every other surface. No new strings. ChatTheme.galleryBackgroundColor— the surface behind the media gallery's media / documents / links tabs, which until now inherited theScaffolddefault and read as a stray grey panel under a host that tints its own pages. Falls back togalleryAppBarBackgroundColor, then to theScaffolddefault. Deliberately notbackgroundColor: that one is the chat wallpaper.AttachmentPolicy.deniedExtensions, defaulting toAttachmentPolicy.defaultDeniedExtensions— 20 OS-executable and script-dropper extensions (exe,msi,bat,cmd,com,scr,pif,cpl,msc,apk,dex,sh,ps1,vbs,vbe,jse,wsf,wsh,reg,jar). It is a constructor default, so every existing policy inherits it, includingAttachmentPolicy.unrestrictedandNomaChatView.defaultAttachmentPolicy. The point is the stance it makes safe: a chat can now be default-allow — send any file type, except the dangerous ones — instead of reaching forallowedMimeTypesand rejecting every uncommon-but-safe extension (.xyz,.log, a proprietary export) as collateral. Only a trailing token shaped like an extension (≤ 8 ASCII alphanumerics) is matched, so a prose tail (report.final version) is not an extension; a name whose tail spells a denied one (newsletter-acme.com) is refused on purpose. Narrow, widen or disable it withcopyWith(deniedExtensions: {...})—{}turns the check off entirely.AttachmentPolicy.validatetakes an optionalfileName, anddeniesFileNameanswers the extension question on its own. BothAttachmentPickers.pickFileandChatMessagesController.sendAttachmentnow pass it, so the floor holds on the upload path a host reaches directly (web drag-and-drop, a share-intent handler), not only behind the pickers. The image/video pick paths are unchanged — they pass nofileNameand behave exactly as before.
Changed #
- Breaking (behaviour): the in-app camera does not auto-send any more. Tapping the
shutter or releasing a hold-to-record now opens the review step described above
instead of resolving
CameraCapturePage.show(). The signature is unchanged — it still returnsCameraCaptureResult?— butnullnow also means "the user discarded the take on the review", not only "cancelled before capturing". Hosts that treatnullas a cancellation need no changes; hosts that assumed a non-null result followed every shutter press do.NomaChatViewhandles this itself: its Camera row only ever sees captures the user confirmed. ChatTheme.videoHeightis a maximum, not a fixed height. The video bubble's poster frame is painted at the clip's own aspect ratio, scaled down to fit the bubble width and this ceiling, so a portrait clip is no longer stretched into a landscape strip. The default is now 250 (was an exact 180), matchingimageMaxHeightso a clip and a photo of the same shape take the same room. States with no real frame to size from — pending download, upload and failure placeholders, a missing thumbnail — keep the previous full-width / 180 look. A host that setvideoHeightto pin a row height gets a shorter bubble for a landscape clip than before.RoomDefaults.videoThumbnailMaxWidthfollows it from 480 to 720: on a portrait clip the long edge is now the height, and 480 left the poster frame visibly soft on a dense screen. It is still tens of kilobytes atvideoThumbnailQuality.ChatBubbleTheme.uploadProgressColorfalls back tostatusReadColorbeforestatusColor(and only then toDefaultPalette.uploadProgressColor). The ring and the read tick mark the same thing — the message made it — so a host that themes its read ticks now gets the ring for free; the old chain painted it in the muted grey of a pending tick, which reads as the opposite of progress. Hosts that themestatusColorbut notstatusReadColorare unaffected.- The search screen's query field defers to the app's own
InputDecorationThemewhere its slots are unset: it no longer passes an explicitfilled: false(which overrode an ambientfilled: true), andmessageSearchFieldBorderRadiusset withoutmessageSearchFieldBorderColornow reshapes the outline the ambient theme draws — border, enabled and focused — instead of replacing it (a radius alone previously discarded the ambientborderslot specifically, painting the SDK's own outline over whatever shape the host had drawn there). And withmessageSearchFieldBorderColorset — as all three colour presets now do — the focused state is no longer a repaint of the enabled one: it widens and, when the theme also carriesmessageSearchFieldCursorColor, tints towards it, so a focused query field keeps a visible ring instead of looking identical to an idle one. A host that themes the ambientInputDecorationTheme.focusedBorderdirectly still wins verbatim over this heuristic. - Breaking (source):
AttachmentPolicyViolationKindgains a third value,extensionDenied. Exhaustiveswitches over it stop compiling until the case is added.AttachmentRejectReasonis deliberately not touched: a denied extension is reported asmimeNotAllowed, reusing the existingChatUiLocalizations.attachmentTypeNotAllowedstring in every locale, so hosts switching on a rejection'sreason(and their l10n) need no changes at all.
0.20.0 #
Added #
ChatRoomsController.hydrate({type})— the disk half ofrooms.load(), callable on its own. Returns theRoomHydrationStatusit published onroomHydrationNotifier. It never emits a request, and it is safe beforeconnect()and before the user exists server-side: nothing it reads is set up by either. Until nowloadAllwas the only door to the cache, so a host could not paint from disk without first paying for a handshake — a cache-first SDK handing its cached rows out behind a network round-trip. Concurrent calls share a single pass, and it deliberately does not mark the list initialized:initializedNotifierandonRoomsLoadedstill mean "a network pass completed".ChatMembersApi.listacceptscachePolicy, and the roster is now persisted locally undermembers:$roomIdwith a newCacheConfig.ttlMembers(12 h, matchingttlRooms). The cache path is deliberately narrow: it applies only whenpaginationisnullandexpandis empty. Any other shape goes straight to the network in both directions — one record per room cannot answer "page 3", and serving a bare cached roster to a caller that asked forexpand: [users]would blank every name and avatar it was about to render. Naming nocachePolicykeeps the pre-cache semantics exactly: the call goes to the network and a failed fetch is aChatFailureResult, never the roster on disk. Deferring toCacheConfig.defaultReadPolicy(networkFirst) instead would have flipped every existing caller'sfold(showError, render)into "render a stale roster, never show an error" without a line of their code changing. The answer is still written through to the cache either way, so aCachePolicy.cacheOnlyreader — the SDK's own disk-only hydration pass, or yours — finds it there. Opt into the offline fallback by naming the policy you want.ChatLocalDatasource.saveRoomMembers/getRoomMembers/deleteRoomMembers, with default no-op implementations so a third-party datasource keeps compiling and keeps working.getRoomMemberskeeps "nothing stored" (ChatSuccess(null)) apart from "the store could not be read" (ChatFailureResult), the same distinctiongetUserRoomsdocuments.HiveChatDatasourcestores them in a newchat_room_membersbox, cascaded fromdeleteRoom, from room eviction and fromclear(); no schema bump is needed (a box that does not exist opens empty).
Changed #
ChatUiAdapter.connect()now hydrates the room list from disk before opening the socket, when the host has not already calledrooms.hydrate()itself. A host that does nothing gets its cached rows ahead of the handshake instead of after it. The cost is local I/O in front of the connection; a store that throws is logged and skipped, because an unreadable cache must never stop a connection.signOut()/dispose()rearm it, so the next session hydrates again;disconnect()does not, so a background→foreground cycle will not overwrite rows that realtime events already advanced.- A cold start now names its DM rows from disk. The cache pass of
loadAllresolves DM contacts again — withCachePolicy.cacheOnlythreaded through bothmembers.listand the peer'susers.get, so it still emits nothing. Before, a device that had never resolved a DM in this session painted it anonymous (no title, no avatar) until the network pass landed, and with no connectivity, forever — even with the peer's profile sitting on disk. The session's in-memory replay added in 0.19.0 only ever covered a warm reopen. - Reverses a 0.19.0 behaviour note: the cache pass collapses duplicate DM rooms for the same contact again, now that it can tell they share a peer without emitting anything. It still never persists the loser's eviction — only an authoritative (network) pass does that.
- The cached roster is invalidated by every local mutation that can change it (
invite,remove,leave,updateRole,ban,unban;invite/remove/leavealso droproomDetail:$roomIdbecause they movememberCount) and by every remote roster event, through the singleChatUiAdapter.notifyRoomMembersChangedchokepoint.UserRoleChangedEventnow goes through that chokepoint too — it did not before, and a role travels in the cached row, so a promotion to admin would otherwise have rendered stale for a whole TTL.muteUser/unmuteUserdeliberately do not invalidate: the mute flag does not ride onRoomUser.
Fixed #
CachePolicy.cacheOnlyno longer reaches the network on a client built without a cache.users.get,rooms.getUserRooms,rooms.get,members.list,contacts.list,messages.listandmessages.getReactionsall fell through to their network branch when there was no store to read, so the one policy whose contract is that it emits nothing issued a request per call — one per conversation on the disk-only hydration pass. They now answer the same missCacheManageranswers for an empty store:NetworkFailure('No cached data available'). If you passcacheOnlyon a cache-less client expecting data, name the policy you actually want.- On a client that has a cache,
messages.getReactionsresolved underCacheConfig.defaultReadPolicy(networkFirst) instead of the policy passed, so an explicitcacheOnlystill fetched.
Breaking #
ChatMembersApi.listgained an optional namedCachePolicy? cachePolicy. Callers are unaffected; any class thatimplements ChatMembersApiand declareslistexplicitly must add the parameter. Fakes that fall back tonoSuchMethodkeep compiling. SeeMIGRATING.md.
0.19.0 #
Added #
ChatUiAdapter.roomHydrationNotifier, aValueListenable<RoomHydrationStatus>reporting what the disk pass of the room load could contribute:pending,unavailable,emptyorhydrated, plus how many rows were painted and which listing they came from. It updates once perloadRooms, as soon as the cache pass has written to the room list and before any network pass is attempted, so a host can pick its first frame — skeleton, empty state or list — without guessing. Nothing else answered this:roomListControllerstays silent whenmergeRoomschanges nothing (the warm-reopen case), andonRoomsLoadedonly fires after a network pass. It is aValueListenablerather than a stream on purpose, so a widget that subscribes late still reads the outcome.RoomHydrationStatusandRoomHydrationOutcomeare exported frompackage:noma_chat/noma_chat.dart.RoomListController.unreadRoomCount()andunreadArchivedRoomCount(): how many conversations carry unread messages in the main list and in the archive, using the same predicates asroomsandarchivedRooms(hidden+deletedRoomIds) but independent of the active text filter. Both takeincludeMuted(defaultfalse, WhatsApp parity: a muted room should not feed a badge that alerts). They replace the hand-written filter overallRoomsthat every consumer was rewriting to paint a tab badge or an archive header.DeliveredConfirmationCoordinator.reset()to forget confirmed delivery cursors on sign-out or user switch, plusconfirmedCursorCountfor diagnostics.PresenceRegistryandDeliveredConfirmationCoordinatorboth accept an optionalValueListenable<ChatConnectionState> connectionState; without it their behaviour is unchanged.
Changed #
- The cache pass of
loadAll— the instant-from-disk startup — no longer emits any network request: noGET /presence, nomembers.listper DM, nousers.get, no delivery confirmations, no sender hydration. It previously issued 1 awaited request plus 2N + M + K more right in front of the first paint, and with no connectivity the awaitedpresence.bootstrap()put a full timeout ahead of it. - The room list no longer flashes blank on a warm reopen. The cache pass now rebuilds a DM's identity
— peer, title, avatar, presence — from the session's in-memory state instead of overwriting the
enriched row and buying it back with a
members.list. - DM contact resolution reads the peer profile with an explicit
CachePolicy.cacheFirstinstead of falling through to the defaultnetworkFirst, so a peer already on disk stops costing aGET /users/{id}per DM per cold start.CacheConfig.ttlUsersstill applies (6 h by default), so a renamed peer refreshes on its own. - Behaviour change worth noting: the cache pass no longer collapses duplicate DM rooms for the
same contact, because doing so requires
members.list, which has no cache path. If the backend holds two rooms for one contact and the device has not reconciled them yet, both rows show until the network pass collapses them and evicts the loser. ChatRoomsApi.getUserRoomsnow tells an empty cache apart from an unreadable one. A local cache that reads cleanly and holds no rooms resolves toChatSuccess(UserRooms(rooms: [], invitedRooms: []))rather than a cache miss — the SDK stating "this device knows you have no rooms", which is what lets a host paint an honest empty state instead of a spinner. Only a cache that could not be read (I/O error, corrupt store) counts as a miss, and underCachePolicy.cacheOnlythat is the only thing surfacing as aChatFailureResult. Cache read failures are now logged atwarninstead of being silent.- A failed network pass is no longer masked when the cache answered "you have zero rooms". Masking now applies only when the cache had something to paint, so a failed load stops presenting itself as a successful empty one. Behaviour for a cache with rooms is unchanged.
ChatUiAdapter.signOut()routes throughChatClient.logout(), so client-owned session state is torn down along with the adapter's — chiefly the offline queue. An attachment or message parked there by a connectivity failure carries no record of who queued it and drains on the next connection, whichever account that connection authenticates as. Clearing the persistent cache, allsignOut()did before, only wiped the queue's stored copy: the in-memory queue survived, re-persisted on the next enqueue, and replayed the signed-out account's upload under the next user. It now also clears the client's permanently-failed operation ids, its cache-manager TTL timestamps and its confirmed delivery cursors, all of which belong to the account leaving.disconnect()is unchanged and still preserves the offline queue — it remains the teardown for background/foreground transitions.ChatUiAdapter.cancelAttachmentUploadnow aborts a voice note in flight and removes its bubble; previously it was a no-op for voice, whichsendVoicedid not register a cancel token for. A host wiring a single cancel control to every pending row will now cancel recordings it did not cancel before.disconnect(clearRooms: true)aborts in-flight uploads along with the rest of the connection state.forward()mints its temp id from a per-adapter sequence instead of appending the target room key, soChatMessage.idand theclientMessageIdsent to the server no longer carry that suffix. Two sends in the same microsecond used to collide on all three registries keyed by it.
Fixed #
attachmentUploadCancellableForandvoiceUploadProgressForno longer hand back a listenable the SDK later destroys. Both registries flip the value and release the notifier instead of disposing it, so a host that resolves one and subscribes itself keeps a usable object after the send ends or after asignOut(). Previously its nextaddListenerthrewA ValueNotifier was used after being disposed.- An attachment or voice upload whose transport raises, instead of returning a failure, now lands in
the same visible state as a returned failure: the row is marked failed with a retry affordance, its
cached copy is persisted as failed, and the bytes are offered to the offline queue. It used to strand
the optimistic row pending forever.
sendAttachmentandsendVoicehonour theirFuture<ChatResult<ChatMessage>>signature and answer with aChatFailureResultcarrying the original error inUnexpectedFailure.originalErrorrather than propagating it. Such a failure is deliberately not replayed by the offline queue: a raise does not prove the bytes never reached the server, and re-uploading one that did would bill a duplicate blob. - A voice note whose upload failed is marked failed on the optimistic row again, instead of staying pending forever while its cached copy and the offline queue both treated it as failed.
- Delivery confirmations are no longer sent twice per room list sync. The same
markRoomAsDeliveredfired once on the cache pass and again on the network pass, and once more on every background revalidation. Only successes are remembered, so a failure is still retried. - An attachment upload that throws after the bytes have landed no longer leaves the progress ring and its cancel control wired forever, and no longer leaks the progress notifier.
LinkPreviewFetcher.fetchkeeps working for a host that narrows its return type. The.timeoutcall site reified the narrowed type, soonTimeout: () => nullthrew a_TypeErrorat the call boundary before subscribing.- Ending a session no longer leaves a stale user-cancelled mark behind.
- A deleted row no longer exposes a "cancel upload" action to screen readers.
0.18.0 #
Added #
-
The camera lives in the SDK.
CameraCapturePage.show(context:, theme:)opens a capture screen and returns aCameraCaptureResult;NomaChatViewwires it as the default camera action, and a host can still override it throughChatViewCallbacks.onPickCamera. It ships pinch-to-zoom, a shutter that only changes colour while recording, a lens switch that recovers the previous camera when a bind fails, and a permission flow that tells a plain refusal apart from one the OS will not prompt for again — offering a route to Settings for the second. Consumers no longer hand-roll a preview, a shutter and a permission dance to send a photo from a chat.platforms:still declares all six targets.cameraandpermission_handlercover mobile and web;PlatformSupport.supportsInAppCameraCapturehides the screen where the plugin has no implementation, and the picker falls back toimage_pickerthere, so the Camera option never disappears. Android hosts: the camera plugin merges<uses-feature android:name= "android.hardware.camera.any" />into your manifest withandroid:requireddefaulting to true, and Play then hides the app from camera-less devices.README.mdandINTEGRATION.md§2b carry the four declarations needed to lift that filter — noteandroid:requiredis OR-merged, so a plainfalseloses silently. -
Videos carry a poster frame.
VideoThumbnaileris the seam, with a working default, so the frame extractor can be swapped in one file. The frame is generated after the clip's upload succeeds, bounded by a deadline, and never blocks the send: a failure degrades to a preview-less video. Its blob gets its own attachment id, which is what the bubble, the quote preview and the media gallery fetch — they were previously handed the clip's id, so rendering a 40×40 thumbnail downloaded the whole video. -
An upload can be cancelled while it is in flight. The progress ring fills for real, and its centre cancels.
ChatUiAdapter.attachmentUploadCancellableForreports whether that is still possible, separately from progress, andNomaChatViewwires it by default. -
NomaChatView.attachmentPolicyapplies one size and type limit across the camera, gallery and file paths, to the pre-check and to the send alike. Rejections surface instead of being dropped. -
ChatConfig.metricCallbackreaches image processing. Oneimage_metadata_stripmetric per call, carrying an outcome and a reason code — no bytes, no names, no paths, and nothing at all when the callback is null.TELEMETRY.mddocuments every value.
Changed #
-
Images are rebuilt, not edited. The metadata stripper used to walk JPEG markers and keep a whitelist; three independent adversarial reviews each found a new way through it, the last one proving GPS riding through under a relabelled marker and two working channels inside the colour profile the whitelist deliberately kept. Images are now decoded to pixels and encoded again, so nothing from the source container survives because nothing from it is read: marker laundering, EXIF, GPS, XMP, Motion Photo and MPO trailers, JUMBF, thumbnails and comments all go, including the frame and table bodies previously documented as an inherent limit. Orientation survives as pixels rather than as a tag. PNG gets the same treatment; formats that cannot be rebuilt are reported rather than silently passed. An image that cannot be processed is still sent as-is so a rare odd-but-valid photo stays sendable, and the metric says so.
Rebuilding drops the colour profile, which would make every Display-P3 photo arrive oversaturated. Rather than carry the source profile back in, the colour space is identified and a fresh profile is emitted, built from this package's own constants — its colorants and transfer curves come out byte-identical to the system profile they replace, derived from the primaries alone. sRGB stays untagged, which already means sRGB to every receiver. Adobe RGB and Rec. 2020 are still converted by the receiver as sRGB, as before, but the metric now names it.
-
pickFilere-encodes a JPEG or PNG picked as a generic file. That path preserved bytes before; it is now lossy, in exchange for the guarantees above. -
Default attachment limits. Video drops from 100 MB to 32 MB and gallery and file picks rise from 25 MB to 32 MB, so one ceiling governs every path. Hosts set their own through
NomaChatView.attachmentPolicy. -
Upload progress lives until the row has a real state. It used to be retired the moment the bytes landed, which left a window where a rebuild painted a broken photo with a live play button and a tap that opened an empty URL.
Fixed #
- Upload progress moves. The payload was handed to the HTTP client as a single chunk, so the progress callback fired once, at the end — the ring span the whole upload without filling. It is now streamed in bounded pieces over views of the same buffer, with no second copy of a hundred-megabyte clip.
- A quoted image renders its own preview instead of being handed the referenced video's, which is what made replying to a clip download the clip.
- A cancelled upload removes its message rather than leaving it failed, and is told apart from a genuine network failure, so backgrounding mid-upload still queues offline as before.
- A send abandoned by a logout no longer writes into a cleared cache or posts under the session that just ended.
- A failure a retry cannot clear — the bytes never reached the server — shows an error rather than a retry arrow that does nothing.
- The recording gate cannot be left armed by a start that resolves after an interruption, and a clip lost that way says so.
- A lens switch cannot be raced by the shutter into disposing the controller it is rebinding, and a teardown failure during the switch is no longer reported as a failed switch.
- A permission plugin that throws no longer leaves the camera screen on a spinner forever.
- A capture is measured on disk before it is read into memory, and its file is deleted whether it was sent or refused.
Removed #
VideoBubble.attachmentRef— renamed tothumbnailRef. It resolves the poster frame, never the clip; the rename is what makes the old mistake unrepresentable.ReplyPreview.attachmentRef— replaced byroomId. The widget resolves the blob it needs, so no caller can hand it the wrong one.
Known limitations #
- HEIC is not rebuilt — no pure-Dart decoder exists. The picker paths are unaffected because
image_pickertranscodes to JPEG on iOS; a raw.heicchosen throughpickFilepasses through untouched and is reported as such. - An image whose decoder rejects it is returned unchanged, by design, and reported as not stripped.
- Camera capture and poster frames are mobile-only; the gates in
PlatformSupportsay where.
0.17.0 #
Security #
-
A URL that came in a message is launched only when it is
httporhttps. Three places hand a message's URL to the platform launcher: the tap handlerChatViewinstalls when the host wires noonTapLink, the OpenGraph cardLinkPreviewBubblepaints, and the Links tab of the media gallery. None of them looked at the scheme. Message text is filtered upstream — the markdown parser only ever linkifieshttp://andhttps://— but the preview card is not: itsurlis read from the messagemetadata, which the transport copies through verbatim from whoever sent the message. A third party could therefore send an ordinary-looking card, with a title and a domain line of their choosing, whose tap openedjavascript:,file:,intent://…or a deep link into the host app. The SDK passed the string tourl_launcheras it arrived.All three now resolve the URL through one allowlist:
httpandhttpsonly, a bare domain read ashttps, everything else refused. A refused URL launches nothing and says nothing — at the tap site a hostile scheme is indistinguishable from a typo, and a warning dialog on every miss only teaches people to dismiss warnings.LinkPreviewBubblegoes one step further and paints no card at all for a URL it would refuse to open: the whole card is a tap target whose title, description and domain line are chosen by the sender, so leaving it on screen would be an affordance that lies about where it goes. Ordinary web links are unaffected — every link the parser has ever produced is one, as is every preview card built from a real page. Hosts that pass their ownonTapLinkare unaffected too, and own the filtering of whatever they choose to open.
Fixed #
-
A failed attachment or voice send keeps the blob it already uploaded.
sendAttachmentandsendVoiceupload first and post the message second. When the upload landed but the post did not — the room not settled yet and answering 404 is the common case, right after a DM is created — the optimistic bubble was marked failed still holding the placeholder it was painted with: an emptyattachmentUrl, noattachmentId, and metadata without either.retrySendre-posts that row verbatim, so retrying a failed photo, file, camera capture or voice note published a message pointing at nothing, and the sender saw it as delivered. Hosts that noticed had to fall back to re-picking the file, which uploads a second copy of the same bytes.Both methods now patch the row with the URL, the
attachmentIdand the enriched metadata the upload resolved, in the controller and in the pending-message cache alike. The cached copy is written as soon as the upload resolves, before the send is attempted, so a process killed with the send in flight still rehydrates a row that carries the blob and can be retried without uploading the bytes again. Retrying re-posts the blob that is already on the server under the originalclientMessageId, so a retry can neither re-upload nor duplicate the message — including a retry that fails again and is retried once more. Nothing changes when the send succeeds, and an upload that itself failed has nothing to patch — see the entries below for what happens to that bubble. -
Retrying an attachment or voice bubble whose upload never landed no longer publishes an empty media message. The bubble painted for a failed upload holds no blob at all, and the bundled chat view offers a retry on every failed bubble. Taking it re-posted the row verbatim, so an attachment or voice message pointing at nothing landed in the room, shown as delivered to the sender and impossible to take back.
messages.retrySendnow refuses that row: it posts nothing, leaves the bubble failed and returns aValidationFailurewhoseerrors['reason']isattachment_never_uploaded.A row counts as having a blob when any of
attachmentUrl,attachmentIdor theattachmentUrl/attachmentIdkeys ofmetadatacarries a non-empty value —metadatais wheresendVoiceputs them, and where a host drivingmessages.senditself may put them, so those rows keep retrying as they did in 0.16.0. An empty string counts as absent.What the user can actually do about it: pick the file again. Nothing else recovers that bubble on its own, and this release deliberately narrows the one path that used to look like it did (see the offline-queue entry below). Automatic replay happens only when the host configured a cache — no
cacheConfig, no offline queue — and the upload failed in a way that proves the bytes never left the device. Every other upload failure ends with a failed bubble whose retry is refused. The bundled chat view now says so out of the box:NomaChatViewmounts anOperationFeedbackListeneroveradapter.operationErrorsitself (see Changed), so the refusal reaches the user as a localized snackbar with no host wiring. Hosts with their ownerrorLabelBuildershould route thatreasonto a "pick the file again" message of their own. -
An upload whose 2xx carries neither an id nor a url is reported as a failure.
POST /attachmentswas parsed leniently: a response body with noattachmentId/idand nogetUrl/urlproduced anAttachmentUploadResultwith an empty id and a null url, andsendAttachment/sendVoicethen posted a perfectly ordinary-looking media message pointing at nothing — no retry needed, first attempt.attachments.uploadnow returns aServerFailurefor that body, which routes the bubble through the existing upload-failure branch instead. -
An attachment or voice send that reached the server without answering no longer paints a second bubble. The optimistic rows of
sendAttachmentandsendVoicewere built without theclientMessageIdthat text sends carry, and that key is the only way the authoritativenew_messageevent can recognise the row it belongs to. When the send landed but its response did not — areceive-phase timeout, a 5xx after persistence, or anack_mode=asyncprovisional echo — the event found nothing to reconcile and added a bubble of its own: the same photo or voice note twice, one of them stuck in its failed or sending state for good. Both rows now carry the key, so the event replaces the optimistic bubble exactly as it already did for text.Known limitation, and it is new in this release. The same key now also reaches the cold-start rehydration path, which still decides whether a cached pending row was superseded by comparing message text within a timestamp window — media rows carry no text, so a stale failed media row still sitting in the pending cache is not recognised as superseded. Precisely because it now carries the
clientMessageId, re-adding it resolves onto the authoritative message and repaints an already-delivered message as failed until the next reload; 0.16.0 painted a second, duplicate bubble in that same situation. The heuristic predates this release, the symptom does not. Tracked inISSUES.mdunder "Pending-row rehydration matches on text + timestamp, not the idempotency key", together with the fix (match on the key before the heuristic), deliberately left out of a release scoped to the send path. -
An upload that timed out after the bytes were on the wire is no longer replayed by the offline queue.
POST /attachmentscarries no idempotency key and the server mints a freshattachmentIdon every call, so replaying an upload that may already have landed leaves a duplicate blob behind. The queue now accepts an upload failure only when it proves the bytes never arrived — aNetworkFailure, or aTimeoutFailurewhosekind.isPreResponse— the same predicate the equally non-idempotent text send has always applied.The trade-off, stated plainly: a
receive-phase orunknowntimeout on an upload — the common shape of a bad network dropping while waiting for the201— now has no automatic recovery at all. It marks the bubble failed, it is not queued, andretrySendon it is refused because nothing was uploaded to re-post. The user has to pick the file again; the bundled UI tells them so. The alternative was replaying an upload that may already have landed, which leaves an orphan blob on the server for every attempt. That stands untilPOST /attachmentstakes an idempotency key. -
Every link in every message was dead — tapping a URL in a bubble now opens it. The markdown parser has always painted a bare
http:///https://URL blue and underlined, the universal "this is tappable" affordance, and it attaches the tap recognizer only when it is handed anonTapLink.ChatViewhas always built one (callbacks.onTapLink ?? _defaultOpenLink, which opens the URL in the system browser viaurl_launcher), andMessageListhas always forwarded it — butMessageBubblenever passed it on to theTextBubbleit builds. The handler existed, was correct, and died one line short of its destination, so no link in any message has ever been tappable, in any host, with or without custom wiring. The bubble now forwards it.This is visible to every host on upgrade and needs no wiring: URLs in message text become tappable and open in the system browser. A host that already passes its own
ChatViewCallbacks.onTapLink— in-app webview, deep-link router, confirmation dialog — keeps winning: the default is only the??fallback, and it is now actually reachable. Hosts that want links to stay inert pass anonTapLinkthat does nothing. Link styling is unchanged — the blue underline was already painted and still is, so goldens do not move. This covers the message timeline; untouched, and each tracked inISSUES.md: the reply bubbles insideThreadView, which build their ownMessageBubbleand accept noonTapLink, and@mentions, which the parser still paints as tappable with no callback anywhere in the public API.LinkPreviewBubbleand the links tab of the media gallery had working taps already. -
Registering
ChatUiLocalizations.delegatenow translates the chat UI. The delegate,ofandoverrideresolved the right instance for the active locale and no widget consulted them: every widget readChatTheme.l10n, whose default is English. A host that followed the guide to the letter — delegate registered,supportedLocalesset, app locale in Spanish — got a chat in English with no clue why. Widgets now resolve throughChatTheme.l10nOf(context), which returns the instance the host put onChatTheme.l10nand otherwise reads theLocalizationsancestor, so both routes work and the ancestor route follows app-locale changes at runtime.Hosts that already pass
l10nthrough the theme see no change — an explicit instance still wins. The one case that moves is a host whose app locale is not English and whose theme carries the canonicalChatUiLocalizations.enverbatim (includingforLanguageCode('en'),forLanguageCode(null)and any unsupported code, which all return that instance): that theme reads as "not set" and now follows the ancestor. PassingChatUiLocalizations.en.copyWith()pins English. The limitation is documented onChatThemeL10nand tracked inISSUES.md. -
A membership banner is no longer stuck in the language it was written in. "Alice joined", "You removed Bob" and the role-change notice are composed by the adapter — which has no
BuildContext— and then persisted, so the sentence stayed in whatever language the session had when the event arrived: switching the app to Spanish left every old banner in English, on every device, forever. The row now carries the ingredients that produced it (event, the two user ids, the display names resolved at the time, and whether the local user is the subject or the actor — seeSystemMessageMetadataKeys), andMessageBubblerebuilds the sentence on every paint with the localizations it is rendering with. Display names stay as they were resolved: they are proper nouns, and re-resolving them per paint would cost a user lookup to change nothing.Rows written by earlier versions keep their stored text, since they carry ids but no names — re-localizing them would put raw user ids on screen, which is worse than an English banner. The new public helper
localizedSystemMessageText(message, l10n)is what the bubble calls and is exported, so a host with its own system-message rendering can call it too. A hostsystemMessageTextResolverstill wins over both, unchanged. -
The chat list was the one screen the delegate could not reach, and now it is not. A row's preview — "📷 Photo", "🎤 Voice message (0:14)", "Forwarded", the deleted marker, "Alice reacted 👍 to …" — was composed once, by the adapter, in whatever language the session had when the message landed, and then stored on the row and cached. Registering the delegate and switching the app to Spanish translated every screen except the one users spend the most time on, and no amount of host wiring fixed a row already written. Worse, one shape came out wrong in any language: a photo with no caption stored the generic "📎 Attachment" in the very slot the renderer reads captions from, so the row read "📷 📎 Attachment" instead of "📷 Photo", on every host, forever.
Nothing is composed into a row any more.
RoomListItem.lastMessagenow holds the sender's own text and nothing else —nullwhen they wrote none — and the row carries what the preview needs instead: the type, the mime type, the file name, the voice duration, the deleted flag, and, new in this release,lastMessageReactionTargetText/lastMessageReactionTargetTypefor the message a reaction was aimed at.RoomTilebuilds the sentence from those on every paint, withtheme.l10nOf(context), so the list follows the app locale live like every other widget and the "📷 📎 Attachment" row is gone. Text a person wrote is never rewritten, because nothing rewrites anything: the only string kept is theirs.Two host-visible changes.
RoomListItem.lastMessage(andUnreadRoom.lastMessage, which feeds it) no longer carries a label for a captionless photo, voice note, forward, reaction or deletion — read the row's structured fields, or call the exportedbuildLastMessagePreview, to render one. And the room-list search filter, which matches on that field, now matches what people typed rather than the SDK's own labels. Rows cached by an older version keep the label they were written with until the nextloadRoomsor the next message in that room refreshes them. -
ChatUiAdapter.l10nis settable, and registering the delegate is now enough on its own. It was afinalfield, so the strings the adapter composes where noBuildContextis in reach were pinned to the language the session connected with, and the only documented way to move them was to dispose the adapter and build a new one. That made a language change cost a disconnect and a full reconnect, and a reconnect that fails leaves a host with no chat at all until the app restarts — a heavy and failure-prone price for re-reading a few strings.It is now a property whose setter every handler reads through on each use, so assigning a new bundle re-points the whole adapter in place: no teardown, no reconnect, no await, nothing that can fail. With the previews gone from the row, one string is left that a widget cannot recompute for itself because it is stored there rather than derived at paint time — the self-chat title — and the setter re-stamps it, touching only a row whose title is exactly what the outgoing bundle would have produced.
The SDK now assigns it for you.
NomaChatView, andRoomListViewwhen handed the new optionaladapter:, push the localizations their subtree resolved into the adapter as their dependencies settle, with the same precedenceChatTheme.l10nOfuses: an explicitChatTheme.l10nfirst, theLocalizationsancestor otherwise. A host that assignsadapter.l10nitself — or passes a non-defaultl10n:to the constructor or toNomaChat.create— keeps full control and is never pushed to, so existing wiring such as WB'sChatService.updateLanguagebehaves exactly as before. Readingadapter.l10nis unchanged. -
ChatUiLocalizations.override(...)reaches every string. It declared 237 of the 274 fields and forwarded 236 of those:attachmentUploadingTemplatewas accepted and silently dropped, and 37 more —retry,messageInfo,readBy,deliveredTo,starredMessages, themute*andpresence*sets,archived,loadMore,error,reason,avatar,email,searchEmoji, the*Failedtoasts and the rest — could not be overridden at all. The parameter list now mirrorscopyWithone for one and forwards all of it.
Changed #
-
NomaChatViewmounts the bundledOperationFeedbackListeneritself. The listener has shipped with the package for a while, but nothing inside the package mounted it: a host that renderedNomaChatView— the drop-in path this package advertises — got no snackbar for a moderation rejection, and none for the refused retry described above. The retry button did nothing and said nothing, whatever the docs claimed. The view now wraps its own subtree in the listener, fed fromadapter.operationSuccessesandadapter.operationErrorsand localized with the verythemeit is already rendering with, so the feedback works with zero host wiring.Wrapping the view by hand still gives exactly one snackbar. A host that already wraps it in an
OperationFeedbackListenerwired to both streams keeps that one and only that one: the view reads what the listener above it delivers and adds nothing, so those integrations are untouched and need no edit. What the view checks is what the wrapper shows, not that a wrapper exists —errorsis optional on that widget, and a listener mounted for successes only would otherwise have silenced the very failures this release exists to surface. In that case the view mounts a failures-only listener underneath: the wrapper keeps its success confirmations, the failures get said once, and neither is announced twice. A listener mounted withenabled: falsestill claims the whole subtree — silencing your own listener is a request for silence, and that switch would be dead if the view spoke over it.A host that routes the two streams into feedback UI that is not this widget — a global error banner, an analytics-driven toast — decides: pass
ChatViewBehaviors(showOperationFeedback: false)when that UI already speaks for the same events, or leave the default on and let the SDK cover the ones it does not. The same flag is the switch for a layout with two chat views on screen where only one should speak.What actually changes for a host with no feedback wiring at all: pinning, unpinning and deleting now confirm themselves with a snackbar, and moderation rejections and refused retries now explain themselves. Forwarding confirms itself too, for the hosts that wire it — see the entry below on why it is no longer in the default menu. Every string comes from
ChatUiLocalizationsthrough the view's own theme, so it is already translated, already overridable, and an empty string still suppresses its snackbar. -
MessageAction.forwardis no longer inNomaChatView's default context menu. The tile was painted on every long-press with its icon and its label, and tapping it closed the sheet and did nothing at all: neitherChatViewnorNomaChatViewhad a branch for it, and the only remaining exit wasChatViewCallbacks.onContextMenuAction, which a drop-in host does not pass. Choosing the target rooms is a product decision the package cannot make on a host's behalf, so it now leaves the action out instead of offering a dead control.Hosts that already wire forwarding must add the action back — one line, and the behaviour is exactly what it was:
NomaChatView( roomId: roomId, adapter: adapter, contextMenuActionsResolver: (room, defaults) => {...defaults, MessageAction.forward}, callbacks: ChatViewCallbacks( onContextMenuAction: (message, action) { if (action == MessageAction.forward) openForwardSheet(message); }, ), );Nothing else about forwarding changed:
MessageForwardSheet,adapter.messages.forwardand thefeedbackForwardedconfirmation are untouched, and the bundled feedback listener still shows that confirmation once the host's own sheet completes the operation. -
A video bubble no longer paints a play button nobody answers.
VideoBubbledrew its 56×56 play overlay unconditionally whenever no upload was in flight, but the tap travels toChatViewCallbacks.onTapVideo, which — unlikeonTapImageandonTapFile— has no default inNomaChatView: the package bundles no video player. A host that wired nothing showed a thumbnail with a large, obvious play button that did absolutely nothing when tapped. The overlay is now painted only when a handler is wired, so an unwired video reads as a still. WireonTapVideoto get the affordance back; the upload-in-progress state is unchanged (placeholder and progress ring, no overlay, taps ignored). -
The default app bar's title row is tappable only when
onAppBarTapis wired.NomaChatViewalways handedChatRoomAppBara non-null closure —() => onAppBarTap?.call(room)— so theInkWellbehind the avatar, title and subtitle was permanently live: it painted a Material splash and swallowed the tap while the defaultonAppBarTapofnullmade the closure a no-op. Opening a room or user profile is navigation, and the package has no screen it can route to on a host's behalf, so the callback is now propagated as it arrives:nullin, no ripple, no consumed tap. WireonAppBarTap—GroupInfoPageandUserInfoPageship with the package — to get the affordance back, exactly as before. Hosts with their ownappBarBuilderwere never affected. -
RoomListViewno longer opens a room context menu it cannot answer. Every tile handed itsInkWella non-nullonLongPress, so a long press always openedRoomContextMenu, and withcontextMenuActionsleft at its default that sheet painted every action it knows for the row: Mute or Unmute, Pin or Unpin, Mark as read when the row had unread messages, and Delete on every row without exception. Picking one closed the sheet and calledonContextMenuAction, which a drop-in host does not pass — a full modal sheet of dead tiles, Delete included, opened by the package without the host ever asking for it. Unlike the bubble menu, this view takes aRoomListControllerand no adapter, and that controller is a pure view-model: it can mutate the in-memory list but cannot mute, pin, mark read or delete a room on the server. There is no subset of those actions with a working default to keep, so the long press is now wired only when something can answer it —onContextMenuAction,onLongPressRoom, or acontextMenuBuilderthat owns the sheet outright. Wire any of the three and the menu behaves exactly as it did. -
An invitation row paints "Accept" and "Reject" only when they are wired.
RoomTiledrew both buttons on everyroom.isInvitationrow and handed each one a nullable callback. A button with no handler behind it registers no tap recognizer, so the touch did not stop there: it fell through to the tile's ownInkWelland opened the conversation. Pressing "Reject" on an invitation therefore entered it — the wrong action rather than no action, on the only control the row offers for answering at all. Each button is now painted only when its own handler exists (RoomListView.onAcceptInvitation/onRejectInvitation, forwarded per row), so a tap always lands on the thing it says it does, and a row with neither wired falls back to the ordinary last-message preview. Hosts that already answer both buttons see no change. -
ChatRoomsApi.updateCachedRoomPreviewreplaces the whole last-message block when it is told the type. Every field it takes describes one message, but each was merged with??against what the row already held, so a plain text message landing after a photo inherited the photo's mime type and rendered as one, and a reaction's quoted snippet outlived the reaction. A call that passeslastMessageTypenow states the row's new last message outright and the rest of the block is replaced,nulls included; a call that omits it still patches a single field (a receipt, a deletion) and leaves the block alone. Two optional parameters were added for the reaction fields (see Added); a custom UI that calls this method itself needs no edit unless it wants them.
Added #
-
RoomListItem.lastMessageReactionTargetText/.lastMessageReactionTargetType, mirrored onUnreadRoom, persisted in the preview cache, and settable throughChatRoomsApi.updateCachedRoomPreview— the text (or, failing that, the type) of the message a reaction was aimed at, so "Alice reacted 👍 to …" can be rebuilt at paint time in the reader's own language instead of being frozen when the reaction landed. All optional; nothing to wire. -
RoomListView.adapter— optional, and the only reason to pass it is localization: the view hands the adapter the bundle its subtree resolved, so a host that registersChatUiLocalizations.delegategets the strings composed off-screen in the app's language with no assignment of its own. The view stays adapter-free for everything else it renders, and a host that setsChatUiAdapter.l10nitself is never overridden. -
ChatViewBehaviors.showOperationFeedback— opts the chat view out of mounting the bundledOperationFeedbackListener(defaulttrue, see Changed). Optional named parameter with a default, like every other knob on that class. -
OperationFeedbackListener.coverageAboveandOperationFeedbackCoverage— what a listener mounted above a given context already delivers:none,successesOnly(mounted without anerrorsstream, so failures reach nobody through it) oreverything. This is howNomaChatViewdecides what to mount, and it is public so a host composing its own feedback widgets can make the same call. Nothing to wire for the drop-in path. -
ChatUiLocalizations.attachmentNeverUploaded— "That file was never uploaded — pick it again to send it." (translated ines,fr,de,it,ptandca; English elsewhere). The listenerNomaChatViewmounts shows it as a soft snackbar whenretrySendis refused witherrors['reason'] == 'attachment_never_uploaded', so the bundled retry button explains itself instead of doing nothing. Override it like any other string, withcopyWithon theChatUiLocalizationsyou put onChatTheme.l10n, or throughChatUiLocalizations.override(...). The field has a default, so nothing has to change to upgrade. -
MockAttachmentsApi.uploadCount(package:noma_chat/noma_chat_testing.dart) — how many timesuploadhas been called, failures included. Lets a test assert that a path which re-posts an already-uploaded blob, such asretrySendon an attachment whose send failed, does not upload the bytes a second time.
Removed #
-
packages/noma_chat_otel/— the OpenTelemetry companion is gone from the repo and from the published archive. Its documented install route was a git dependency on this repository (path: packages/noma_chat_otel), and it also travelled insidenoma_chat's own tarball; both stop resolving from this version on. It was under a hundred lines turningChatConfig.metricCallbackinto one instantaneous span per event, and its span naming could not be customized without rewriting the callback anyway — which is the whole adapter:config: ChatConfig( metricCallback: (metric, data) => tracer.startSpan('noma_chat.$metric', attributes: attrs(data)).end(), ),attrsis your own map-to-attributes conversion for whichever OTel binding you use, and the span names are now yours to choose.ChatConfig.metricCallbackitself is unchanged, andTELEMETRY.mdstill documents every metric name, its fields and when it fires. -
benchmark/— the micro-benchmark scripts are gone from the repo and from the published archive. Three standalonedart runprograms (event parser, message mapper, offline queue) plus their README, used to compare throughput before and after a change on a maintainer's machine. They were never public API and were never importable as a library, but they did travel inside the archive; a consumer running them out of their pub cache no longer finds them.
Docs #
-
The localization guide describes the two routes that now work. The class documentation,
doc/DEVELOPER_GUIDE.mdand theLocalizationsDelegatebullet of the 0.6.0 entry below said the widgets resolve the active instance throughLocalizations; between 0.6.0 and this release they did not, and a host that registered the delegate got an English chat with no clue why. Both routes are real as of the Fixed entry above, and the docs now spell out the precedence between them, the runtime-locale behaviour, and the one case where an explicit English theme loses to the ancestor:// Route 1 — explicit, wins over the ambient locale. NomaChatView( roomId: roomId, adapter: adapter, theme: ChatTheme.defaults.copyWith( l10n: ChatUiLocalizations.forLanguageCode(code), ), ); // Route 2 — register the delegate, leave the theme alone. MaterialApp( localizationsDelegates: const [ChatUiLocalizations.delegate, /* … */], supportedLocales: ChatUiLocalizations.supportedLocales, home: NomaChatView(roomId: roomId, adapter: adapter), );
0.16.0 #
Security #
-
The local cache is now namespaced per user. Every Hive box the SDK opens is prefixed with a digest of the signed-in user's id —
u_followed by 32 hex characters — so two accounts on the same device get two disjoint stores. Until now they shared one: signing out and signing in as somebody else left the previous user's rooms, contacts, display names and message history in place, and the new session read them as its own.NomaChat.create()passescurrentUser.idfor you — nothing to do if you use it. If you build the datasource yourself, pass the id:HiveChatDatasource.create(userId: userId). Omitting it selects the old device-wide layout, which is still shared by every account that opens it.The id is digested rather than spelled out, so no box on disk carries a user's id in its name: a host that hand-deletes box files on logout, or goes looking for the boxes belonging to a given user, will not find them. Clear a user's cache through the datasource's own
clear().One consequence to check before upgrading: the id now derives the store's name, so
NomaChat.create()andHiveChatDatasource.create()throwArgumentErrorfor a blank or whitespace-only id. In 0.15 the id never reached the cache and such a session opened normally. If your host builds a session before the id is known, passenableCache: falsefor it. -
A cache that turns out to belong to another account is destroyed before it is read, and when it cannot be destroyed the session is refused:
create()throws aStateErrorrather than returning a datasource that would serve the surviving boxes to the signed-in user. Nothing is claimed on that path, so the next launch tries the destruction again. It takes a store whose namespace two ids somehow share — a host that respells its ids between releases, a backup restored from another device — plus a write failure on top, so no ordinary install reaches it; handle it like any other cache failure and retry, or open that session withenableCache: false.
Added #
-
adoptUnscopedCacheFor— opt in to carrying the pre-0.16 local history over. A device upgrading from an earlier version still holds the old device-wide store, and by default nothing is adopted from it: the store carries no record of whose it is, so the SDK will not guess. It is left untouched and reclaimed from disk after 30 days (unscopedCacheRetention), and the user re-fetches their history from the server on first open — visible as an empty room until the network answers, and as the permanent loss of anything the server no longer serves.If your app can never have had a second account signed in on the same install, you can say so and the old store is moved into that user's namespace:
final chat = await NomaChat.create( /* ...required params... */ currentUser: ChatUser(id: userId, displayName: name), adoptUnscopedCacheFor: userId, );This is an assertion, not a hint. If it is wrong, the named user inherits the other person's rooms, contacts and message history and sees it as their own — the exact leak the scoping above closes, re-opened by hand. It is refused when it names anyone other than the signed-in user (the old store is then left on disk, still adoptable by whoever it belongs to), and refused when the store's own owner stamp disagrees with it.
Whatever the answer, it is written into that user's store as a migration record, and that record is what stops the question being asked a second time — not the
cacheOwnerstamp, which answers a different question (whose a store is, so that one found stamped for somebody else is destroyed rather than served). The one exception is deliberate: a refusal taken while you were passing nothing is reopened when you start passing the parameter, so shipping the scoping in one release and the assertion in the next still carries the history over, as long as the retention window has not expired.On that reopen path the old store can be a release older than the one adopting it, so adoption fills gaps and never writes over live state. Two consequences: contacts and invited rooms are stored as lists rather than keyed by id, so each is carried over whole or not at all — and not at all once the user has a list of their own; and the queue of unsent operations is never carried over, because it holds instructions rather than state and nothing in it records how old they are.
An adoption interrupted before it completes — the process killed mid-move — resumes on the next launch instead of stranding what was left behind. The old store's own meta box is the last thing removed, so for as long as anything of it remains, it is still there to be found.
-
unscopedCacheRetentionandorphanGracePeriodare now reachable fromNomaChat.create()(30 and 7 days by default), not only fromHiveChatDatasource.create(). -
HiveChatDatasource.purgeUnscopedCache()deletes the old device-wide layout on demand, for hosts that would rather reclaim the space than wait out the retention window. Pass the sameencryptionCipheryour store uses: the per-room boxes are found by reading room ids out of the global ones, and without the right cipher they are silently left on disk.
Fixed #
- A double tick never turns back into a single one. Receipt state is now monotonic: it only ever advances. Three paths used to walk it backwards — a REST row (which carries no receipt at all) replacing a message already acked over the event stream, a room reclassified between 1:1 and group, and a re-aggregation under a roster that had shrunk. The fourth way in, an out-of-order frame, was already guarded in 0.15. Every path that replaces a message row — the server echo that confirms an optimistic send included — now defers to that same comparison, held in one place, so the lower value is discarded instead of being written.
- A group's read ticks survive re-entering the app. The aggregate for a group is "read once
every other member has read", which needs the member list as the divisor. When the controller was
rebuilt before its roster had hydrated, that divisor was zero and the aggregate reported
sent, downgrading every already-read message in the room. An empty roster on a group is now treated as "not derivable yet" and leaves the existing state alone. - Receipts survive the app being killed. They arrive as events and were never written anywhere, so every ✓✓ died with the process and the room re-opened showing single ticks. Advanced receipts are now mirrored onto the message rows and persisted, and a cached row's receipt is merged rather than overwritten when a receipt-less network row replaces it.
- Re-opening a room recovers the ticks the event stream missed. The rehydration that replays the
server's read cursor only applied its
lastReadMessageIdwhen that message was inside the loaded window, and fell back to the timestamp comparison only when the backend had sent no id at all — so a room re-opened on its most recent page, where the cursor has usually paginated out, matched neither branch and left every older bubble on one tick. The fallback now also covers the paginated-out case — as far as the local cache can carry it: the cursor message is placed in conversation order by its own timestamp, looked up in the cache, and a cursor the cache does not hold either still marks nothing rather than guess. That is a fresh install, the first open afterclear()— where the cache holds only the page just loaded — and any session running withenableCache: false. Such a room opens on single ticks until a receipt event or a later launch fills them in. What the fallback recovers from a cursor is written to the cache, so the next cold start renders from it instead of repeating the round trip. A whole-room read — the shape the backend sends with nolastReadMessageIdat all — is applied to the screen but deliberately never written: a stored tick can only ever move up, and there is no cursor behind that one to justify making it permanent. - A message that failed to send is no longer filed away as delivered and read. A peer's receipt advances every older message of yours at once, and optimistic rows sat in that range, so a failed send picked up the tick of a later successful one — and, new in this release, was written into the message history carrying it. Two things followed on the next cold start: the failed message rendered as delivered and read, and once you retried it successfully the room showed it twice. Unsent rows now take no receipt at all, and one that already carried a receipt has it revoked the moment the send is declared pending or failed.
- A cold start no longer wipes the message history of rooms this device only joined. The orphan
sweep destroyed the message box of any room absent from the
chat_roomsbox — butsaveRoomshas a single caller in the SDK, the room-creation path, so on any install that joined its rooms instead of creating them that box is empty and every room looked orphaned. The sweep now needs positive proof of deletion: a room must be missing from two authoritative room listings, spread over a grace period (orphanGracePeriod, 7 days by default), and unattested by every local source that knows about rooms. An install that is offline, or that has not loaded its room list yet, produces no candidates at all and loses nothing.
Changed #
- The per-room receipts list is pinned to network-first rather than following whatever read
policy the consumer configured.
networkFirstis already the default, so this changes nothing unless you setdefaultReadPolicy: CachePolicy.cacheFirst— under which a peer reading while the app is not running leaves no local signal that could invalidate the stored copy, and a long message TTL pinned the room's ticks to a stale snapshot for as long as that TTL ran. The cached rows still render instantly on open, and receipts only advance, so the round trip costs no perceived latency and cannot regress what is on screen. The cache remains the offline fallback. Sending a read receipt, and any receipt-bearing event, now also expires the freshness entry behind that list, so the next read goes to the network; the stored rows themselves are kept and keep serving offline reads. clear()now removes the pending and reaction boxes of every room it knows about, not only the message boxes it had opened. A logout that cleared the cache used to leave unsent drafts and reactions on disk for any room the session had not visited, where nothing would ever read them again and nothing would delete them either. It also deliberately preserves two keys it wrote itself — the store's owner and the record of whether the pre-0.16 cache was adopted — so clearing a cache does not make the next launch re-ask a question that was already answered.
0.15.0 #
Changed #
- A voice message starts recording the moment the finger touches the mic button, instead of after a half-second hold. Everything that follows is unchanged: slide up to lock, slide left to cancel, release to send.
- BREAKING —
StartRecordingResultlostpermissionJustGrantedand gainedaborted. Code thatswitches exhaustively over the enum will no longer compile.permissionJustGrantedexisted only to feed a heuristic that timedhasPermission()and guessed whether the OS permission dialog had been shown, dropping that first recording; it is gone, and a first grant now records like any other.abortedis returned when the touch that asked for the recording is already over before the platform recorder gets armed. Migration: delete thepermissionJustGrantedbranch — thestartedbranch covers what it used to — and treatabortedlikealreadyRunning, i.e. as a non-event with no message for the user.
Fixed #
- The mic button no longer steals gestures from the rest of the composer. The recorder listened
through a
GestureDetectorwrapping the whole composer, so its long-press recognizer competed in the gesture arena with everything underneath it. It is now a plainListener, which observes the pointer stream without ever claiming it, and the mic button's own rectangle is what decides whether a touch starts a recording. - A tap on the mic no longer flashes the recording row. Capture starts on touch down, so a
stray tap used to swap the composer to the recording UI and straight back. The recording state is
now announced to listeners only once the touch outlives a short window
(
VoiceRecordingController.revealDelay, 120 ms); the capture itself is untouched, so audio from the first millisecond still ends up in the message. - A tap too short to be a recording no longer opens the platform audio session. Arming the recorder activates the shared audio session, and on iOS that interrupts whatever the user is listening to. When the finger lifts before the recorder is armed, the start is now abandoned and the recorder is never touched.
- An interrupted touch discards the recording. A pointer cancelled by the system (an incoming call, a parent scrollable taking the gesture over) reached no handler at all, leaving the recording running with no finger left to end it. Cancelled touches now drop it; a locked recording, which no longer depends on the finger, is left running.
0.14.2 #
Fixed #
- Picked photos no longer carry their EXIF metadata, and with it the GPS coordinates of where
they were taken, which until now travelled to every room member who downloaded the original file.
requestFullMetadata: falsealready covered iOS, but it does nothing on Android:image_picker_androidcopies EXIF from the source file unconditionally whenever it resizes, and offers no flag to suppress it. Picked bytes now go throughJpegMetadataStripper, which drops the EXIF, XMP/IPTC and comment segments of a JPEG without adding an image-processing dependency. Non-JPEG picks, and any JPEG that cannot be parsed with full confidence, are returned untouched — corrupting a photo would be a worse outcome than leaving metadata on it. Covers both the image pickers and the generic file picker.
0.14.1 #
Fixed #
- Image bubbles size to their content. A portrait photo left wide empty margins on both sides of the bubble instead of the bubble following the image.
- The full-screen image viewer loads authenticated images. Tapping an
attachment opened a viewer that fetched the URL without the bearer token,
so it answered 401 and the image never appeared.
onTapImagenow defaults to a loader that carries the session. - Attachment uploads have their own timeout. They inherited the 30 second timeout meant for small JSON calls, which a photo or a video over a slow connection does not fit in; since the upload is a POST, the retry interceptor deliberately excludes it, so the send just failed.
- Photos no longer ship their EXIF location. A picked image carried the
GPS coordinates of wherever it was taken, so anyone who downloaded it
learned where the sender had been. Not requested on iOS any more; on
Android
image_pickerstill copies EXIF when it resizes, so that half is still open. - The chat is usable with a screen reader. The message bubble excluded its own semantics and declared no actions, so a reader could read a message and reach nothing else: no context menu to reply, react, forward, delete, pin or copy, no retry on a failed send, no way to open an attachment or enter a thread.
- Consumer behaviours merge onto the defaults instead of replacing them wholesale, so enabling one thing no longer switches the rest off.
- The room cache evicts the least recently used room, not the first one alphabetically.
- Close buttons in the media viewer and the attachment sheet are labelled.
0.14.0 #
Breaking changes #
ChatClientgained two members:pendingOperationCountandflushPendingOperations(). Anything thatimplements ChatClientand spells out every member explicitly — in practice, hand-written test fakes with nonoSuchMethodfallback — stops compiling until both are added.NomaChatClientandMockChatClientalready implement them, so only your own fakes are affected; in this repository the change broke 13 test files, and a consumer with a similar fake will see the same. The patch is two lines per fake — see MIGRATING.md.- The five
ChatUiAdaptersub-controllers areinterface classinstead offinal class:ChatRoomsController,ChatMessagesController,ChatDmController,ChatContactsControllerandChatProfileController. This only widens what callers may do — nothing that compiled against0.13.xstops compiling — but it is the change that makes them mockable from outside the package, so it belongs here: a mock declared asextends Mock implements ChatRoomsControllernow compiles in your own test suite. While they werefinal, mocking the adapter meant reaching for the@internalpass-throughs onChatUiAdapter(adapter.loadRooms(),adapter.openDirectMessageDraft(),adapter.draftRoutingKey(), …) and silencinginvalid_use_of_internal_member. That workaround can go: move those call sites toadapter.rooms.*/adapter.dm.*and delete the ignore.
Behaviour changes #
Neither of these breaks compilation, but both change when something happens. Read them before upgrading.
- The offline queue drains on the first connection of a session, not only
after a reconnect. The drain used to be gated on having connected at
least once already, so anything queued during a cold start — the classic
"the user sends a message while the socket is still coming up" — sat in
the queue until the connection dropped and came back. It now goes out as
soon as the first
connectedevent lands. If any part of your app quietly depended on that delay (a screen that assumed it could still cancel a queued send, say), those sends now leave earlier. Missed-unread catch-up is deliberately not affected: it still runs only after a real disconnect→reconnect cycle, because a session that never dropped has nothing to catch up on. - WebSocket close code
4002(auth_failed) now invalidates the cached token, like4003/4004, and repeated rejections stop the reconnect loop. On4002the transport used to reconnect with the very token the server had just refused, so a stale credential turned into an endless connect →4002→ reconnect cycle. The cached token is now dropped, which forces the next attempt to fetch a fresh one; and after 3 consecutive token-rejecting closes (4002/4003/4004) with no successful authentication in between, the transport terminates the session — it stops reconnecting and emits a terminalChatAuthException— instead of looping forever. A successful auth, or an explicitconnect(), resets the counter. A host that hand-rolled a "watch for the error state, refresh the token, reconnect" patch to work around this can delete it.
Added #
-
ChatClient.pendingOperationCount— how many operations are sitting in the offline queue right now (0on a client configured without one), so a "N pending" badge no longer needs the host to shadow-count sends itself. -
ChatClient.flushPendingOperations()— forces an immediate drain attempt instead of waiting for the next connection. The queue already drains on every connect, so this is for an explicit "retry sending" affordance, not for normal operation. -
Duplicate-submission guard on room and member operations.
rooms.create,rooms.updateConfig,members.invite(and thereforemembers.joinWithToken, which delegates to it) andmembers.removenow go through a single-flight registry: a second call with an identical payload while the first is still in flight — a double-tap on "Create group", or a caller invoking the method twice before the first future resolves — shares the first call's result instead of issuing a second request. Each of the four also sends a deterministicIdempotency-Keyheader, derived from the canonical request content so the same logical request always derives the same key, whatever order the payload was built in.What this does not buy you: the backend does not read
Idempotency-Keyyet — verified againstchat_engine, whose only real server-side dedup is theclientMessageIdbody field on message sends. So this protects against duplication that originates in the client (a double tap, a local retry of a request that never left) and nothing more. A retry whose original request did reach the server and was applied before the client saw the failure — a timeout after the server committed, a connection dropped post-commit — will still duplicate server-side. This is not end-to-end idempotency; the header is forward-looking and starts paying off the day the backend honours it. -
The resilience primitives are now public surface, exported from the advanced barrel (
package:noma_chat/noma_chat_advanced.dart):computeBackoffMs,CircuitBreaker/CircuitStateandCircuitBreakerRegistry— the same pieces the SDK's ownRetryInterceptoruses internally. If your app calls the backend outside the bundled HTTP client, reuse these instead of reimplementing a weaker backoff (the jitter is applied before the cap, so a retry never overshoots the maximum delay agreed with the server — an easy detail to get wrong by hand).
Fixed #
- Attachments that failed to download when the backend handed back a signed
URL relative to the API base (
/v1/…rather than an absolutehttps://…). The URL reached the HTTP layer verbatim and never resolved, so the download errored out. It is now resolved against the configured base URL first. The legacy header-only fallback, used when the backend returns no signed URL at all, is unchanged. - Tapping an image bubble now opens the full-screen
ImageViewerout of the box, wired to the same authenticated media loader the bubbles render through.NomaChatViewpreviously forwardedonTapImagewith no default at all, so opening the viewer was left to the host — and a host that handedImageVieweronly a URL got the broken-image fallback, because attachment downloads are Bearer-protected andCachedNetworkImagenever sends that header. A host-suppliedonTapImagestill wins. Hosts that build anImageViewerthemselves must passmediaLoaderandattachmentRef(adapter.defaultAttachmentMediaLoader) or drop their override and take the default. - Image bubbles no longer stretch to the full bubble width when the picture
is taller than it is wide. The bubble now sizes itself to the shape the
photo is actually painted at — its aspect ratio scaled down to fit the
available width and
ChatTheme.imageMaxHeight(250 by default) — instead of leaving a wide empty margin beside a portrait photo. The metadata row is aligned within the picture's width rather than the bubble's, with a floor so a very narrow image cannot squeeze the timestamp. MessageBubblewas unreachable with a screen reader beyond the plain text label — TalkBack/VoiceOver could hear a message but had no way to open the long-press context menu (reply/react/forward/delete/pin/copy), retry a failed send, open an image/video/file attachment, or view a thread's replies: all of it lived inside the bubble'sexcludeSemanticssubtree with no equivalent action on the outer node. The context menu and retry are now exposed as alongPressaction and aRetrycustom action on the bubble itself (same pattern asMapButton: keepexcludeSemantics, re-declare the callback on the same node); opening an image/video/file attachment is the bubble'stapaction. Reactions and the thread reply-count row keep their own un-excludedSemanticsnodes instead of being swallowed by the bubble's — fixing this exposed a latent duplicate- announcement bug inReactionBar("👍 1, 👍 1"), also fixed alongside it. Known gap: audio play/pause stays unreachable — the toggle is private toAudioBubble, with no callback the bubble can surface as an action.- No screen-reader announcement when a new message arrives while a chat
is open —
MessageListhad no live region for message content, unlikeTypingIndicator/ConnectionBanner, which already announce their own state changes. Incoming messages (not your own outgoing sends, and not loading older history via pagination) now update aliveRegionlabel with"{sender}: {preview}", reusing the same WhatsApp-style preview textRoomTileshows for a room's last message. - Photos sent through the chat kept their full EXIF, including GPS
coordinates and capture timestamp — every recipient who downloaded the
original file could see where and when it was taken.
pickImageFromCamera/pickImageFromGallery/pickMultipleMedianow requestrequestFullMetadata: falsefromimage_picker, which drops the GPS/EXIF block on iOS. This is an iOS-only mitigation:image_picker's Android implementation copies EXIF from the source file unconditionally whenever it resizes (which every picker here triggers viaimageQuality: 85), with no equivalent flag — closing that gap needs either an image-processing dependency this package doesn't carry, or a server-side strip on upload.
Changed #
CachePolicyis no longer marked@experimental. It is a core, stable concept of the cache API, and the annotation forced an// ignore: experimental_member_useon every consumer that named a policy explicitly. Those ignores can go.
0.13.1 #
Added #
DeliveryReceiptClient(confirmMessageDelivered) — a standalone, lightweight REST entry point that confirms a message as delivered without a fullNomaChatClient. It takes aChatConfig, an auth token, a room id and a message id and issues the delivery receipt over REST only (no WebSocket, cache, or DI), so it can run from a background push isolate.AuthenticatedAttachmentLoader— media bubbles, the media gallery, the full-screen viewer and the reply preview now load attachment bytes through the authenticated client instead of fetching the signed download URL directly.
Fixed #
- Image, audio and video attachments that silently failed to display when
the signed download URL was fetched without the auth header (a
401that degraded to a fallback). Media is now loaded via the authenticated client and renders reliably. setActiveRoom's optimistic unread-count clear is deferred to a microtask so it never notifies the room list mid-build, which could otherwise surface as a build-phase error in a consumer that also listens to the room list.exportChatnow includes the room title in the exported header.
Changed #
- The media gallery page picks up the chat theme (new AppBar/TabBar theme fields), and the message input can autofocus when a chat opens.
0.13.0 #
Added #
- Structured logging pipeline.
ChatLogTag/ChatLogLevel/ChatLogRecord, pluggableChatLogSink(ConsoleChatLogSink,CallbackChatLogSink,BufferChatLogSink,MultiChatLogSink) andChatLogExporter.exportToFilefor a one-tap shareable log file.ChatConfiggainslogSink/logLevel/logTags/logMessageContentand alogsgetter every subsystem logs through; the existingloggercallback keeps working unchanged (CallbackChatLogSinkbridges it whenlogSinkis leftnull). ChatMessage.attachmentId— the stable id an attachment was uploaded under, propagated through the full send path (REST, cache, offline queue, WS-ack synthetic echo) so the recipient (and the sender, on re-open) can re-mint a fresh signed download URL instead of trusting a persisted one that may have expired.SignedAttachmentUrlResolver/AttachmentUrlResolver/AttachmentRef(ui/services/attachment_url_resolver.dart): re-mints signed URLs on expiry, wired as the defaultChatViewBuilders.attachmentUrlResolverbyNomaChatView.AudioBubble/ImageBubble/VideoBubblegainattachmentRef/urlResolverparams and retry once via the resolver on a load error;MessageBubblegainsroomId/attachmentUrlResolver.AttachmentPickersmethods gainonRejected(AttachmentRejection,AttachmentRejectReason) — a policy violation or unreadable file is no longer a silent drop with just awarnlog line.ChatMessagesController.sendAttachmentnow paints an optimistic bubble with live upload progress (ChatUiAdapter.attachmentUploadProgressFor) before the upload even starts, and leaves it visibly failed on error — parity withsendVoiceinstead of a blank bubble for the whole upload.ChatUiLocalizationsgainsattachmentTooLarge/attachmentTypeNotAllowed/attachmentUnreadable, translated for en/es/fr/de/it/pt (other locales fall back to English).ChatMessagesController.sendAttachment/sendVoiceenter the offline retry queue on a connectivity-flavored upload failure instead of requiring a manual retry —ChatClient.enqueueOfflineAttachment(Breaking: new required interface method;NomaChatClientandMockChatClientboth implement it, defaulting to a no-op when no offline queue is configured) queues the bytes + metadata as aPendingSendAttachmentand replays the whole upload+send on reconnect, reconciling the optimistic bubble via the existingonOfflineMessageSenthook (same tempId).ImageBubble/VideoBubble/FileBubblegainuploadProgressand show a placeholder + upload-progress ring while non-null — parity withAudioBubble.uploadProgressfor the attachment types that previously showed a broken-image icon (or nothing) for the whole upload.ChatViewBuilders/MessageListgainattachmentUploadProgressFor, defaulted byNomaChatViewtoChatUiAdapter.attachmentUploadProgressForso the ring shows up without the host wiring anything.ChatRoomsController.open()fast-fails with a typedNetworkFailureinstead of waiting out the fullrequestTimeoutwhen the client already knows the realtime channel isdisconnected.RoomListItem.lastSeen— mirrorsChatPresence.lastSeen, kept in sync byPresenceRegistry.update/bootstrap.ChatRoomAppBarnow renders a "last seen …" subtitle for an offline 1:1 peer instead of leaving the subtitle blank (ChatUiLocalizations.lastSeenTemplate, translated for en/es/fr/de/it/pt).- WS pong watchdog (dead-peer detection).
WsTransportnow arms a timeout (ChatConfig.wsPongTimeout, default 10s) on every ping (wsPingInterval, default 30s) and forces a reconnect if the matchingpongnever arrives — previously a "zombie" socket (stuck half-open after a NAT timeout or mobile network handoff) never surfaced as anonError/onDoneclose and silently stopped delivering realtime events. Toggle withwsPongWatchdogEnabled(defaulttrue; verified safe — the backend always answerspingwithpong). Reconnect backoff gains explicit tunables:wsMaxReconnectDelay(default 60s) andwsReconnectJitterMs(default 1000).RealtimeTransportgainslastPongAge. ChatConnectionState.authenticating— emitted between the socket opening and the server confirmingauth_ok(previously indistinguishable fromconnecting).isWorkingnow includes it. Breaking for any exhaustiveswitchover the enum (see Changed below).- SDK-owned app lifecycle.
ChatUiAdapter(andNomaChat.create/.fromConfig/.fromClient) gainmanageAppLifecycle(defaulttrue) andlifecyclePolicy(ChatLifecyclePolicy.standard()by default,.pushOptimized()also provided). When enabled, the adapter registers its ownWidgetsBindingObserverand reconnects on resume / optionally disconnects after a grace period on pause — the host no longer needs a separateAppLifecycleServicefor chat. Registration is best-effort: it silently no-ops if no Flutter binding is available yet (e.g. aChatUiAdapterbuilt in a plain unit test), so it never crashes a host or a test that doesn't expect it. ChatUiAdapter.resync()— a full reconnect resync (room listforceNetwork: true+ the foregrounded room's messages, backfilling anything missed while disconnected). A no-op until the adapter's firstloadRoomshas ever completed (initializedNotifier) — there is nothing to resync for a session that hasn't bootstrapped its room list yet, and firing early could race the host's own initial load. Triggered automatically on every fresh reconnect viaenableReconnectResync(defaulttrue, adapter constructor param), debounced to at most once every 5 seconds so a flappy connection or a resume racing an in-flight reconnect can't double-resync. Centralized in the adapter's existing reconnect hook — no second "did we just reconnect" detection point.- Cache-first room list, with a self-healing background revalidation.
RoomListController.mergeRooms(incoming, {required authoritative})— upserts rows in place instead of clear-then-refill. A non-authoritative merge (a cache read, or a best-effort background pass) never drops a row it can't vouch for, so a partial/empty response can't blank the list; an authoritative merge (a full server snapshot) reconciles fully, same end state assetRooms, but without ever exposing listeners to an empty list in between.RoomEnricher.loadAllno longer hard-skips the network pass when realtime is already trusted — it now fires a background revalidation (mergeRooms(authoritative: true)) instead, so a stale or partial cache snapshot self-heals without the caller ever seeing an empty screen. Guarded pertypeso repeatedloadRooms()calls (e.g. a screen re-opening) never fan out into overlapping network passes for the same request. ChatRoomsController.open(roomId, {fetchIfMissing = true})— opens a room by id, fetching its detail from the server when it isn't already known toroomListController(the deep-link case: a push notification or shared link pointing at a room the local list/cache hasn't synced yet). Returns a readyChatControlleron success, or a typedChatFailurethe host can branch on instead of collapsing every case to "this chat doesn't exist":NotFoundFailure(really gone / not a member),AuthFailure/ForbiddenFailure(session/permission — NOT the same as not-found), orNetworkFailure/TimeoutFailure(transient — retry, don't tell the user the chat is gone).
Fixed #
- Room list flicker between refreshes for duplicate DM rooms (root
cause of part of S1). The duplicate-DM tie-break used to fall back to
"whichever room was already bound wins" when neither candidate had
history (or both shared the exact same
lastMessageTime) — but which one that was depended on which of the two async DM resolutions happened to complete first, which flips from refresh to refresh under normal scheduling. The tie-break is now a deterministicroomIdcomparison, independent of resolution order: the same pair of duplicate rooms always resolves to the same winner. - A non-authoritative (cache) pass of the duplicate-DM dedupe no
longer evicts the losing room from the persistent cache — it now
only suppresses it from the visible list. Only an authoritative
(network) pass persists the eviction (drops the cached room/detail and
disposes its
ChatController). Previously a cache-only guess could permanently destroy state a later authoritative pass might still have needed to reconcile correctly. ChatUiAdapter.logsnow propagateslogLevelandlogMessageContent— both were silently clamped towarn+ redacted regardless of what the host passed, so sub-managerdebuglines (presence bootstrap, signed attachment re-mint, optimistic send) never reached alogLevel: debughost, and message text stayed redacted even withlogMessageContent: true.ChatUiAdapter(andNomaChat.create/fromConfig/fromClient) now acceptlogLevel/logMessageContentand forward them, mirroringChatConfig.logs.PresenceRegistry.bootstrapnow applies every changed DM room via a singleRoomListController.mergeRoomscall instead of oneupdateRoomper room — a reconnect with N one-to-one rooms used to re-sort + re-index + notify the whole list N times (O(n² log n)); it's now one pass.ChatViewno longer flashes the empty state for a room with no cached history:ChatController.isLoadingInitial(set whileChatMessagesController.load's cache+network phases are in flight) now gates the empty state, matching the loading/empty splitRoomListViewalready had. A brand-new draft DM (which never runsload) still renders its empty composer immediately.- Opening a room now clears its room-list unread badge immediately,
client-side (
ChatUiAdapter.setActiveRoom), instead of waiting formarkAsRead's network round-trip — matches the bubble-level receipt behavior and avoids the badge visibly lagging on a slow connection. ChatUiAdapter.dispose()now disposesblockedUsersListenable— it was the only one of the adapter's four broadcast notifiers left out, leaking aChangeNotifierand its listeners on every teardown.RoomEnricher's background room-list revalidation (fired on every cache-freshloadRooms, e.g. every screen reopen) is now debounced pertype(default 5s, same window as reconnect-resync) — the in-flight guard alone only stopped concurrent passes, so a rapid open/close/reopen still re-ran the full network fetch + per-DM enrichment pass each time.- Offline queue self-duplication on a failed drain. Replaying a queued
send/delete/addReaction/deleteReaction/pinMessage/unpinMessage/starMessage/unstarMessage(and DMsendDirectMessage) from the drain loop went back through the same enqueue-on-failure decorator that queued it in the first place — a failed retry left BOTH a fresh copy (from the decorator) and the backoff-requeued original (fromOfflineQueue._drainWith) in the queue.OfflineQueuedMessagesApi/ContactsApigainenqueueOnFailure(defaulttrue; the drain replay path passesfalse), so_drainWith's backoff is the single place that re-enqueues a retried op. ChatRoomsController.open()(deep-link fetch of a room not yet known locally) now fast-fails with a typedNetworkFailurewhenChatClient.connectionStateis alreadydisconnected, instead of waiting out the fullrequestTimeout(default 30s) on a REST call very unlikely to succeed.RoomListController.mergeRoomsno longer drops a locally created room it hasn't heard back about yet: an authoritative snapshot older than a room's own creation/edit timestamp can't vouch for its absence, so that row is now spared instead of evicted. An authoritative empty snapshot that does carry a capture time still clears every row that predates it, so a genuinely empty room list (no rooms left) no longer leaves a phantom row behind.ChatUiAdapter.resync()no longer silently drops a reconnect that lands while a previous resync is still in flight — it's coalesced into a follow-up pass instead of being swallowed by the debounce window. Its debounce seal is now per-attempt (a late failure can't clobber a newer attempt's seal) and is also reverted when the resync throws, not only when it returns a typed failure.- The room list's per-row receipt tick and
ChatController's aggregated per-message receipt now both apply a monotonic rank guard (sent < delivered < read) — areceipt_updatedevent that arrives out of order (e.g. a queueddeliveredlanding after a livereadfor the same message) can no longer regress the tick backwards. PresenceRegistry.bootstrapno longer lets a stale REST snapshot overwrite a livePresenceChangedEventthat arrived while the snapshot request was still in flight — the fresher live update wins regardless of which one resolves first.- Background room-list revalidation could wipe a healthy list on a
transient blip (regression reopening S1).
RoomEnricher.loadAll's self-healing background pass (see "Cache-first room list" above) reused the same fully-authoritative, row-dropping merge as an explicit pull-to-refresh — a single short/empty network response on an automatic, invisible background refresh was enough to drop real rooms from the list.RoomListController.mergeRoomsgains anallowRoomRemovalpath so the background pass can still reconcile DM-dedupe/kicked-room state authoritatively without ever being allowed to delete a row. - The reconnect-triggered
ChatUiAdapter.resync()was still fully authoritative and reopened the same S1 regression the fix above closed for the background pass.resync()goes throughloadRooms(forceNetwork: true)— the very same foreground path an explicit pull-to-refresh uses — so theallowRoomRemovalguard above never reached it, and a reconnect is exactly the moment network is flakiest (the best-effort backend read behindgetUserRoomscan fail closed to a short/empty page).loadAll/loadRooms/ChatRoomsController.loadnow thread an explicitallowRoomRemovalparameter (independent offorceNetwork, which both callers set) down to the foreground network merge;resync()passesfalse, matching_backgroundRevalidate. Only an explicit, user-initiated pull-to-refresh (the defaultallowRoomRemoval: true) still prunes rooms the server no longer returns. Defense in depth:RoomListController .mergeRoomsalso hardened so a totally empty incoming snapshot is now always a no-op, even whenauthoritativeandsnapshotAtare set — a full wipe is indistinguishable, over the wire, from the same fail-closed blip, and is never how a genuine room removal actually arrives (those come one at a time, via realtime events or a partial authoritative snapshot). - Offline attachment queue had no size cap and re-encoded the whole
queue to base64 on every mutation.
enqueueOfflineAttachmentnow rejects an attachment overCacheConfig.offlineQueueMaxAttachmentBytes(default 10 MB) via the existingonOperationDroppedcallback ('attachment_too_large') instead of queueing it and running out of memory later, andPendingSendAttachment's base64 payload is memoized per byte buffer so persisting the queue no longer re-encodes every pending attachment's bytes on eachenqueue()/drain pass. - A resumed app could reconnect onto a zombie WebSocket and never
recover realtime (regression reopening S3/S5).
WsTransport.connect()is a no-op while the transport already believes it'sconnected— but after an OS-suspended socket dies without a proper close, that belief is wrong, so app resume silently did nothing. Transports gainverifyLiveness(): on resume, a connectedWsTransportsends an immediate ping and arms the existing pong watchdog instead of trusting its own state; a timeout forces the real reconnect (Disconnected+Connectedevents), which in turn re-triggers presence bootstrap andresync().AutoFailoverTransport.connect()now probes the primary's liveness instead of unconditionally resetting_primaryHasConnectedwhen the primary is already connected, fixing a related failover regression where a resume could delay promoting the SSE/polling fallback after a subsequent blip.
Changed #
ChatUiAdapter.disconnect()gains{bool clearRooms = false}. The new default is cache-first and resumable: the room list, the currently foregrounded room'sChatControllerand the DM contact↔room binding all survive adisconnect()— the list never flashes empty across a background/reconnect cycle, and a subsequentresync()can backfill the open conversation. PassclearRooms: truefor the previous eager-wipe behavior (also whatsignOut()/dispose()use internally — logout is unaffected).ChatConnectionStateaddsauthenticating(see Added above) — any exhaustiveswitchover the enum in host code must add a case for it (the SDK's ownConnectionBannerandAutoFailoverTransportalready do, mapping it to the same treatment asconnecting).
Notes #
enableReconnectCatchUp(NomaChatClient, unread catch-up on reconnect, defaultfalse, not activated by WB) is a pre-existing, distinct reconnect mechanism one layer below the newChatUiAdapter.resync()/enableReconnectResync. Left untouched this release; the two can overlap if a consumer enables both — not merged here.logMessageContentdefaults tofalse. Message/caption text passed toChatLogger.content()is redacted unless explicitly enabled — intended for a temporary diagnostics build, not for production.ChatMessage.attachmentIdclosing S6 (audio/photo URL re-mint) for a received message additionally requires the backend to echoattachmentIdon reads (getRoomMessages,new_message,sendRoomMessage) and to persist the stable slot URL rather than a TTL-bound signed one — see thechat_engine0.13.0-train changes. The SDK falls back to parsing an id out of the URL (attachmentIdFromUrl) when the backend hasn't rolled the field out yet.
0.12.1 - 2026-07-17 #
Removed #
- Internal audit notes are no longer part of the repository, and maintainer
docs (
ISSUES.md,CONVENTIONS.md) are excluded from the published package tarball.
0.12.0 - 2026-07-17 #
Fixed #
- DM typing indicators now work over an active WebSocket connection.
contacts.sendTyping()used to emit a WStypingframe addressed bycontactIdwhile realtime was connected — but the backend'stypingframe is room-scoped (it requiresroomIdand answers{"error":"typing","reason":"missing_roomId"}), so the peer never saw the indicator; only the REST fallback used when realtime was down actually worked. DM typing is now ALWAYS routed throughPOST /contacts/{id}/activity, which the peer receives as aDmActivityEvent. The deadsendDmTypingWS path was removed from the internal transport interface and every implementation. Room typing (messages.sendTyping()) is unaffected. - WS close code
4006(transport_disabled) is now handled. The server emits it when the WebSocket transport is disabled at runtime; the SDK used to treat it as a generic drop and reconnect in a loop against a server that closes every socket the same way. On4006the WS transport now suspends its reconnect loop and marks itself unavailable for the session, andRealtimeMode.autopromotes the SSE/polling fallback immediately (even when WS never connected once). The cached token is untouched — this is a transport condition, not an auth one. A laterconnect()(e.g. after re-login or app restart) tries WS again. nomaChatSdkVersion(theX-Noma-Chat-Version/User-Agentconstant) was left at0.10.1by the0.11.0release; synced to the package version, un-breaking theversion_sync_testgate.
Changed #
send()/sendDirectMessage()results are provisional under the backend'sack_mode = async(an opt-in deployment mode; the backend default issync). The201echo is built before persistence: itsiddoes NOT match the stored message. The SDK now detects that case and returns the message with the newChatMessage.isProvisionalflag set andclientMessageIdstamped (the authoritativenew_messageevent carries the same key). All SDK stores reconcile byclientMessageId:ChatControllerreplaces the optimistic/pending row with the event message (no duplicates, no row stranded under the provisional id), the message cache never persists a provisional echo, and the bundled UI keeps the bubble in the sending state until the event confirms it. Migration note for consumers: do not use theidreturned bysend()/sendDirectMessage()for immediate follow-ups (react / edit / delete / pin). CheckisProvisional; whentrue, wait for theNewMessageEventwhoseclientMessageIdmatches and use that message'sid.sendViaWs()'s synthetic ack message is now also flaggedisProvisionaland carries itsclientMessageId.contacts.sendDirectMessage()now always sends aclientMessageId(auto-generated when omitted — a new optional parameter lets callers supply their own), so DM sends are idempotent under retries and offline-queue drains, matchingmessages.send().
Docs #
- Bundled OpenAPI contract (
doc/chat-api-openapi.yml) resynced with the backend: room preferences consolidated underPATCH /rooms/{id}/preferences(the room-level/hidden,/muteand/pinendpoints no longer exist — message pin/unpin endpoints are untouched),DELETE /users/meself-deletion alias, machine-readableerrortokens on error bodies, async-ACK provisional echo semantics, WS close codes4006/4007, androomIdon/messages/searchnow documented as optional (global search across the caller's rooms confirmed — closes the spec-drift item inISSUES.md; the "no room id on hits" caveat remains). - README installation snippet bumped to
noma_chat: ^0.11.0.
0.11.0 - 2026-07-06 #
Removed #
- BREAKING: Certificate pinning removed.
ChatConfig.certificatePins(and the correspondingcertificatePinsparameter onNomaChat.create) no longer exists, along with the internal pinning interceptor, its platform adapters and the publicCertificatePinningExceptiontype. The SDK now relies solely on the platform's standard TLS validation against the operating system's CA trust store. Consumers that were passingcertificatePinsmust delete the argument; those that need pinning should enforce it outside the SDK (an OS-level network security config, HSTS + Certificate Transparency logs at the deployment layer, or a customDioHTTP adapter). the internal audit item ALTA-001 is closed as RESOLVED-BY-REMOVAL. package:cryptodependency. Its only consumer was the pinning interceptor removed above; no other code in the SDK used it.
Fixed #
- Cache invalidation race on message writes (
send/update/delete/markRoomAsRead).CachedMessagesApinow invalidates themessages:*androoms:all/rooms:unreadTTL keys before writing the mutation to the local cache, not after. Previously a concurrentcacheFirstreader landing between the cache write and the invalidation call could observe a "still fresh" TTL paired with stale data. AddedCacheManager.invalidateKeys, a batch primitive that invalidates several keys as one step instead of several separateinvalidate()calls. rooms.updateConfig()no longer leaves a stale avatar/name in the cached room list. The cachedUnreadRoomentry for the room is now patched in place with the newname/avatarUrl(or cleared, forclearAvatar: true) at the same time theroomDetail/rooms:all/rooms:unreadTTL keys are invalidated — previously the room list would keep rendering the old avatar/name from the cachedUnreadRoomuntil the next full rooms refetch replaced it.HiveChatDatasourcenow logs the concrete reason a cached record was discarded (missing field, invalid timestamp, etc.) for every skipped entry, not just the first one in the batch. The aggregated"Skipped N corrupted records"warning is still emitted alongside the per-record detail.- Example app:
enableHttpLogis now gated onkDebugModeinstead of hardcoded totrue, so a release build of the example never ships with HTTP body logging on. - Offline queue no longer risks a duplicate send on an ambiguous-phase
timeout. The pre-response gate now explicitly documents (and tests)
that
TimeoutKind.unknown— the defensive default when the timeout phase can't be determined — is treated like areceivetimeout for non-idempotent operations, not like a pre-response one.send()was already correct in practice; this closes the gap between the intended contract and its test coverage. NomaChatClient.connect()is now safe to call twice in quick succession. A repeatedconnect()fired before the first call resolves now awaits the same in-flightFutureinstead of racing it — previously both calls could observe the same non-null internal event subscription, cancel it twice, and reassign it out of order, leaking a transport subscription.- Offline queue drain no longer spins through the whole queue when the
front operation is still in backoff. Previously a
drain()call would re-queue every remaining still-backing-off operation one by one (O(queue length) work for no effect); it now stops at the first operation whosenextRetryAthasn't elapsed yet and leaves the rest of the queue untouched and in order. RestClient.post()validates the response body type likeget()already did: a 2xx body that is not a JSON object (e.g. an array) now surfaces as a typedChatApiExceptioninstead of an unhandled cast error.- Rate-limit back-off is clamped to
[1 s, 5 min]. ARetry-After/X-RateLimit-Resetof zero or negative seconds (server clock skew) no longer produces an immediate-retry stampede, and an absurdly large value no longer stalls the client for hours. - WebSocket close codes 4003/4004 now explicitly close the client-side sink before a reconnect is scheduled, releasing the half-closed socket instead of leaking it alongside the fresh connection.
WsTransport.dispose()latches a disposed flag synchronously and every emit checks it, so late callbacks from in-flight reconnect timers or channel teardown can no longer race the controllers being closed.
Added #
ChatMessage.silentlyDropped.contacts.sendDirectMessage()returns success with a synthesizedReceiptStatus.sentmessage when the backend answers204 No Content(recipient has blocked the sender) — this new field istrueon that synthesized message so callers can distinguish "accepted but never delivered" from a normal send instead of showing "sent" with no further explanation. Persisted through the local cache round-trip. Additive, defaults tofalseeverywhere else.addReaction,pinMessage,unpinMessage,starMessageandunstarMessagenow retry through the offline queue on aNetworkFailureor pre-responseTimeoutFailure, matching the existingsend/deletebehaviour. Previously a network drop while reacting to, pinning, or starring a message failed silently with no retry once connectivity returned.NomaChatClient.onOperationDropped. Fires when the offline queue gives up on a pending operation (queue full, TTL expired, or max retries exhausted). Defaults to recording the operation id, queryable via the newisOperationPermanentlyFailed(id)/permanentlyFailedOperationIdsso a host app can show a "delivery failed" indicator without wiring anything itself; still fully overridable with a custom closure.ws_auth_timeoutmetric + structuredwarnlog when the WebSocket auth handshake exceedsChatConfig.authTimeout, tagged with the configured timeout and the current reconnect attempt.- Token-refresh circuit breaker in
BearerAuthInterceptor. Consecutive 401s that survive a token refresh are counted (metricauth_refresh_retry_failure); after 3 of them further 401s skip the refresh entirely (metricauth_circuit_open) and go straight toonAuthFailure, so a revoked account cannot hammer the token endpoint. A successful retry — orinvalidateCache()with fresh credentials — closes the circuit.ChatConfig.metricCallbackis now forwarded to the bearer interceptor. NomaChat.fromConfig({required config, required currentUser, ...}). New factory that builds the SDK from a pre-assembledChatConfigwithout re-statingbaseUrl/realtimeUrl/tokenProvider.NomaChat.createandfromClientare unchanged.- UI customization hooks.
ChatViewBuilders.batchUserFetcher(batch user resolution, removes the reaction-sheet N+1),ChatViewBuilders.statusIconBuilder(override the delivery-status tick),RoomListView.selectedRoomId/onSelectionChanged(master-detail selection),AttachmentSheetOption.previewBuilder,AudioBubble.initialPlaybackSpeed/onPlaybackSpeedChanged(persist playback speed across sessions),ForwardedBubble.sourceTimestamp, andGroupMembersView.pageSize(paginated member lists). - Platform-support gates.
PlatformSupport.supportsVoiceRecording,supportsFilePickerandsupportsLocalStorage, plusStartRecordingResult.unsupportedandVoiceRecorderGesture.onUnsupported— voice recording on Web now reports "unsupported" instead of a misleading "permission denied". - 5 new locales (
sv,no,da,pl,cs; 12 total) andChatUiLocalizations.loadMore. LinkPreviewFetcher.cancel(url)/cancelAll(). Aborts the in-flight link-preview HTTP request (via DioCancelToken) on URL change, dismiss or dispose, releasing the socket promptly.example/androidtarget generated so the sample app builds on Android.
Docs #
TELEMETRY.md(new). Full metric-by-metric reference for every event emitted throughChatConfig.metricCallback/CacheManager.onMetric/HiveChatDatasource.onMetric— name, fields, and firing condition for cache, offline-queue, HTTP, auth and WebSocket metrics. Referenced byCONVENTIONS.md§10.3 andSECURITY.md, which previously pointed at a file that did not exist.ISSUES.md(new). Tracks the golden-testsqfliteskip workaround, thegolden_toolkit→alchemistmigration plan (not executed — needs a session withpubspec.yamlin scope), the/messages/searchspec/dartdoc mismatch on global search, and the non-customizablenoma_chat_otelspan naming.doc/DEVELOPER_GUIDE.mdgained worked examples for scheduled messages (schedule/listScheduled/cancelScheduled), an end-to-endactAsUserIddelegation walkthrough,ForwardInfo(plus a no-E2EE note on forwarding), room- vs global-scoped message search,AttachmentPolicyMIME/size filtering, threeRoomTitleResolveruse cases (nickname book, role-based titles, pre-hydration fallback), and a new "Observability" section coveringChatConfig.metricCallbackand thenoma_chat_otelcompanion package.- 21 stale golden baselines regenerated (
test/golden/goldens/*.png,bubbles_dark_test.dart+bubbles_light_test.dart+message_status_test.dart) viaflutter test --update-goldens test/golden/— no widget code changed; the prior baselines predated unrelated visual changes elsewhere in this audit-remediation pass.TESTING.md's skipped-golden count corrected from a stale "4" to the actual "2" (ImageBubbleonly;LinkPreviewBubblewas never skipped, just rendered without its optional OG image).
0.10.1 - 2026-07-03 #
Added #
- Cross-platform capability gating (
PlatformSupport). Attachment and avatar UI now degrade gracefully on platforms whose plugins do not cover every target: camera capture and image crop are offered on mobile (crop on mobile only), while downloaded files open natively on mobile and fall back to the OS default handler viaurl_launcheron desktop. Derived fromkIsWeb+defaultTargetPlatform(neverdart:io), so it resolves on web too, hiding controls a platform cannot honour instead of surfacing ones that silently fail. - Example app now builds for desktop and web (Linux, macOS, Windows, web) in addition to Android and iOS.
Changed #
ChatConfigURL validation exempts loopback hosts from the release-mode HTTPS requirement.http://tolocalhost,127.0.0.0/8, or::1stays allowed in release builds — loopback traffic never leaves the device and every platform treats it as a secure context — while every other host still requireshttps://(pentest M-10). The127.match is anchored to an IPv4 literal so a DNS host such as127.evil.comis not mistaken for loopback.
0.10.0 - 2026-06-17 #
Added #
-
Global message search —
messages.search()roomIdis now optional.roomIdchanged from a required to an optional named argument (String? roomId). Callmessages.search(query)to search globally across every room the caller belongs to (the backend scopes results to the authenticated user's rooms);messages.search(query, roomId: 'x')keeps the single-room behaviour. TheroomIdquery param is sent toGET /messages/searchonly when non-null. Non-breaking for existing single-room callers, who already passroomId:by name. NewChatMessagesApiinterface contract — see the migration note for custom implementers. -
Unified room preferences —
rooms.patchPreferences(). Newrooms.patchPreferences(roomId, {muted?, muteUntil?, pinned?, hidden?})sends a single partialPATCH /rooms/{roomId}/preferencesand returns the merged server-side state as a newRoomPreferencesmodel (muted,pinned,hidden,muteUntil?). Pass only the fields you want to change; a non-nullmuteUntilis sent as an ISO-8601 string for WhatsApp-style timed mutes. This is the single write path for room preferences on the data API.ChatResultgains adiscardValue()helper (plus a matchingFuture<ChatResult<T>>extension) that drops a success value toChatResult<void>while preserving the outcome. NewChatRoomsApiinterface method — see the migration note for custom implementers. -
Stable error tokens —
ChatFailure.errorToken. EveryChatFailurenow exposes an optionalString? errorToken: a stable snake_case symbolic code from the server's vocabulary (room_not_found,edit_window_expired,blocked,rate_limited,cannot_delete_other_user, …) surfaced alongside the existing{code, detail}. Host apps should branch and localize on the token instead of the Englishmessage. Well-known constants live on the newChatErrorTokensholder; the field is aString?(not an enum) so a new server token never breaks the SDK. The token also rides onOperationError.failure.errorToken. Purely additive. -
GDPR self-deletion —
users.deleteCurrentUser(). New method callingDELETE /users/me, the robust default for self-service account erasure (the server resolves the principal from the auth token, so it can't target the wrong account). NewChatUsersApiinterface method — see the migration note for custom implementers. -
Member-list
usersexpansion — no more N+1 for group rosters.members.listgains anexpandparam; passing[RoomMemberExpand.users]sends?expand=usersand the backend embeds each member'sdisplayName+avatarUrlstraight in the row.RoomUsergains nullabledisplayName/avatarUrl(populated only on an expanded response). Rendering a group roster no longer needs aGET /users/{id}per member — onelistcall carries everything. The built-inGroupMembersViewnow requests this expansion and seeds the adapter user cache from the embedded fields, eliminating the per-member profile fetch out of the box. Backward-compatible: withoutexpandthe fields staynulland the user-cache fallback is unchanged. Purely additive. -
Canonical reactions endpoint —
messages.addReaction(). Newmessages.addReaction(roomId, messageId, emoji: '👍')POSTs the dedicated/rooms/{roomId}/messages/{messageId}/reactionssub-resource (HTTP201) instead of synthesising a reaction-typed message viasend(messageType: MessageType.reaction). Modelling a reaction as a first-class sub-resource keeps it out of the timeline and the offline send queue.messages.deleteReactiongains an optionalemoji— when supplied it sends?emoji=…so a specific reaction can be removed (omit it to clear the user's reaction wholesale, the historical behaviour). The built-in optimistic UI reacts and un-reacts through these canonical calls.addReaction/deleteReactionare the only supported reaction API; the SDK no longer sends reactions viasend(messageType: MessageType.reaction). NewChatMessagesApimethods — see the migration note for custom implementers. -
Bidirectional opaque cursor pagination.
ChatCursorPaginationParamscarries an opaquecursor(String) plus adirection(ChatCursorDirection.older/.newer, emitted as thedirectionquery param;nulllets the backend default tonewer).ChatPaginatedResponseexposes two seq-based cursors:prevCursor(parsed from the responseprevfield, anchored on the oldest message of the page) andnextCursor(parsed fromnext, anchored on the newest). To load older history passprevCursorwithdirection: ChatCursorDirection.older; to catch up on newer messages passnextCursorwithdirection: ChatCursorDirection.newer.hasMorereports whether more pages exist in the requested direction. The cursors are seq-based, so paging never skips or replays messages that share an exact millisecond. The load-more, chat-export, media-gallery and polling/manual realtime paths all run on these cursors. -
Signed attachment URLs —
attachments.signedUrl()(primary download path). Newattachments.signedUrl(attachmentId, roomId: ...)returns anAttachmentSignedUrlwhose.urlis absolute, short-lived, and self-authorizing (HMAC signature + expiry + user baked in) — it drops straight intoImage.network/CachedNetworkImage/ a native viewer with no auth headers to re-attach. HitsGET /attachments/{attachmentId}/signed-url?roomId=...; the backend authorizes by room membership fail-closed.attachments.downloadgained an optionalroomId: when present it takes this same signed-URL path under the hood (falling back to aroomId-scoped header request only if the backend returns no URL). NewChatErrorTokens.notARoomMember(not_a_room_member) is surfaced on the resultingForbiddenFailure.errorTokenwhen the caller isn't a member of the room. NewChatAttachmentsApi.signedUrlmethod — see the migration note for custom implementers. -
Canonical managed-users list —
users.getManagedByParent(). Newusers.getManagedByParent(parentId, {pagination})callsGET /users/{parentId}/managed-users, the backend's canonical replacement for the oldGET /managed-users/{userId}list path (operationIdgetManagedUsersByParent). Returns the paginated{users, hasMore}response shape. The only managed-users list method; it replaces the removedgetManaged(see Removed). Wired through theChatUsersApiinterface, the REST implementation, and the mock client. See the migration note for custom implementers. -
NomaChatView— drop-in chat-room screen. WrapsChatRoomAppBar+ChatViewand auto-wires the seven per-room behaviors hosts used to reimplement by hand (history + pin load, unread divider, group member hydration, blocked / room-removed reactions, role-aware context menu, report dialog, reaction-user fetcher). Additive —ChatViewis unchanged and stays available for fully custom screens. See the migration guide and the Developer Guide for the override slots. A matching quickstart was added to the README so the common case isNomaChat.create(...)+NomaChatView(...), with the persistent Hive cache initialized automatically (defaultenableCache: trueonNomaChat.createopens the store; no manualHive.initFlutter()needed for the default path). -
Group invite links —
members.joinWithToken+ChatInviteLink. Public / invitable rooms can be joined via a shareable link: build one from a room'spublicTokenwithChatInviteLink(...).toUri(base), and self-join from an incoming deep link withmembers.joinWithToken(roomId, token: …)(a wrapper overinvitewithinviteAndJoinfor the current user).toUriandChatInviteLink.tryParseaccept custom query-parameter names. Surfaced in the room menu via the newChatRoomOption.inviteViaLinkpreset (copies the link to the clipboard by default).joinWithTokenis a newChatMembersApiinterface method — see the migration note for custom implementers. -
Export a chat —
adapter.messages.exportChat(roomId). Returns aChatExportwhosetextis the room's full history as a WhatsApp-style transcript; writing the file and sharing it is left to the host app (no new dependency). Surfaced viaChatRoomOption.exportChat. -
"Message info" sheet —
MessageInfoSheet+MessageAction.info. Lists who read / was delivered a message.NomaChatViewwires it automatically:MessageAction.infois in the default context-menu set and shows only on the user's own messages. (MessageActiongained aninfovalue — affects exhaustiveswitches on custom menus only.) -
Idempotent sends —
clientMessageId.messages.sendaccepts an optionalclientMessageId(≤128 chars); when set, the backend makes the send idempotent over(roomId, sender, clientMessageId)and a POST retry that replays the key returns the already-persisted message instead of a duplicate. The key round-trips inside the responsemetadata.clientMessageId, which the SDK reads back ontoChatMessage.clientMessageId.NomaChatView/ the adapter generate one per optimistic message and the offline queue reuses it on every retry, so a send that actually landed before a network failure surfaced is never duplicated. Pass your own only for custom send flows. -
Starred messages —
MessageAction.star+StarredMessagesView. Per-user bookmarks (WhatsApp-style).messages.starMessage/unstarMessageand the paginated cross-roommessages.listStarredare new onChatMessagesApi; the adapter exposesstar/unstar/loadStarred.MessageAction.staris in the default context menu (wired inNomaChatView), andStarredMessagesView(or.fromAdapter(adapter)) renders the list. -
Mute with a duration —
rooms.mute(roomId, until:). Optionaluntil(aDateTime); omit it for a permanent mute.ChatRoomOption.muteRoomis now duration-aware (onMute(DateTime? until)+onUnmute()) and the SDK presents aMuteDurationSheet(8h / 1 week / always) on tap.RoomDetail,UnreadRoomandRoomListItemgained amuteUntilfield. -
"@" mention badge + Archived section.
UnreadRoom/RoomListItemgainedunreadMentions;RoomTileshows an "@" badge when it is> 0.RoomListViewrenders a collapsible Archived section for hidden rooms (backed by the existinghiddenpref);RoomListControllerexposesarchivedRooms/hasArchivedRooms, andChatRoomOption.archiveChat/unarchiveChatmap torooms.hide/unhide. -
Edit / delete windows + typed
403failures.ChatViewBehaviorsgainededitWindow(default 15 min) anddeleteWindow(default 2 days):NomaChatViewhides the edit / delete context-menu actions on the user's own messages once the window closes (nulldisables). A late attempt the backend rejects now surfaces as the typedEditWindowExpiredFailure/DeleteWindowExpiredFailureinstead of a generic forbidden failure. -
ChatConfig.actAsUserId(managed-user delegation). Set it to act on behalf of a managed user — every REST request then injectsX-From-User-Id: <actAsUserId>. The backend enforces the parent→managed relationship (403if not allowed). REST only; does not change the real-time identity. -
rooms.create(..., forceGroup: true). By default a contacts room with a single other member collapses to a DM-style room; passforceGroup: trueto keep it a named group. Defaults tofalse, so existing calls are unchanged. -
members.invitenow reports per-user outcomes. It returnsChatResult<InviteResult>(wasChatResult<void>) so callers can inspect the per-user result when the backend answers207 Multi-Status(some users banned / already members / etc.). TheuserRoleparameter was removed (the backend never accepted a per-invite role) and an optionaltokenparameter was added for public-room joins. See the migration guide for the before/after. -
Cursor-based delivery ticks (WhatsApp-style). The SDK now consumes the two new realtime events of the
1.0.0backend:message_acked(the server durably persisted an own message — single gray tick; surfaced asMessageAckedEventwith the server-assignedseqand the message metadata echoed for client-side correlation) andmessage_delivered(a user's delivered cursor advanced — one event flips the double gray tick on every message at-or-before the cursor, for any author). Cursors are max-registers: duplicated or reordered events are harmless by construction. -
ChatMessagesApi.markRoomAsDelivered(roomId, lastDeliveredMessageId:)— consolidated delivered-cursor confirmation: one call per conversation covers any number of messages, via the new WebSocketdeliveredframe when connected and the receipts endpoint otherwise. Prefer it oversendReceipt(status: delivered)(legacy per-message path, rerouted server-side to the same cursor). -
ChatUiAdapter.autoConfirmDelivery(defaulttrue): the adapter confirms delivery automatically — on live messages in non-active rooms, on chat load, and on the post-login/reconnect room sync — coalesced per room (at most one confirmation in flight; a burst costs ≤2 calls). Turn it off to drive confirmation manually throughmarkRoomAsDelivered. -
ReadReceiptgainslastDeliveredMessageId/lastDeliveredAt(additive, nullable). Receipt rehydration on chat open now restores delivered ticks too, and read coverage uses conversation order againstlastReadMessageIdinstead of the over-marking timestamp comparison (kept only as fallback for whole-room reads). -
ChatBubbleTheme.statusIconBuilder— per-state override of the delivery-status icon, applied both at the bubble corner and next to the room-list preview. The builder receives aMessageStatusIconData(MessageDeliveryState— sending / sent / delivered / read / failed — plus the suggested size and, in bubbles, the message); returningnullfalls back to the SDK default for that state, so partial overrides are one switch case away. The default rendering is unchanged. -
ChatBubbleTheme.statusPendingColor— dedicated color for the pending clock shown while a message is in flight (falls back tostatusColor, so existing themes look the same). The clock also gains a "Sending" semantics label (ChatUiLocalizations.statusSending).
Compatibility: 0.9.x clients keep working against a backend that emits the new events (unknown types are ignored), but their live delivered tick stops updating — the backend emits
message_deliveredinstead of the legacyreceipt_updated{status: delivered}. Bubbles jump from sent to read; ticks in listings stay correct. Upgrade to 0.10.0 to restore live delivered ticks.
Changed #
-
lastUnreadMessagepreview is now object-or-null only.RoomMapper.unreadRoomFromJsonreads the room preview exclusively from the nestedlastUnreadMessageobject; when it isnullor absent the room has no unread preview (alllastMessage*fields stay null). The legacy flatlastMessage*fallback fields and the "magic 0" handling are gone. No public model change —UnreadRoomis unchanged. -
Typed-failure routing is now token-first. The exception mapper prefers the server's stable
errortoken to choose the typed failure (e.g.edit_window_expired→EditWindowExpiredFailure, account-deactivation tokens →AuthFailure), keeping the legacydetailstring-matching as a fallback for older servers. No behavior change against existing backends. -
users.delete(userId)is own-account-only. The backend tightenedDELETE /users/{userId}to the caller's own id; a non-own id returns a 403 that surfaces as aForbiddenFailurecarrying thecannot_delete_other_usertoken. PreferdeleteCurrentUser(). -
messages.sendnow autogenerates aclientMessageIdwhen omitted. The server-side dedup is a partial unique index over messages that carry aclientMessageId, so a rawsend()without one could be persisted twice if retried after a transient 429/5xx.send()now generates a UUID v4 when the caller doesn't passclientMessageId, making retries safe for every consumer (the canonical UI path already passed one). Pass your own value only to correlate with an external id. The field is always sent now. -
Certificate pinning documented honestly as not-yet-enforced.
ChatConfig.certificatePinsandCertificatePinningInterceptorare an experimental skeleton: the native handshake hook is not wired, so no certificate is validated against the pins and there is no MITM protection today.SECURITY.md, thecertificatePinsdartdoc and the audit history were corrected to stop claiming otherwise, and the SDK now emits awarnlog when pins are configured. No behaviour change — pinning was already a no-op. -
ChatConfig.ssePathdefault changed from/eventsto/eventsource. The old default never worked against CHT/NRTE; this is a fix, not a regression. Callers that overridessePathexplicitly are unaffected. -
Dropped
json_annotation/json_serializabledependencies. The SDK no longer uses these code-gen packages; they were never part of the public API and removing them has no consumer impact (add them to your ownpubspec.yamlif you relied on them transitively). -
Backend contract pinned to OpenAPI
1.0.0. The bundled spec (doc/chat-api-openapi.yml) now tracks the first stable version of the Nomasystems chat API (previously an internal2.10.0numbering that never shipped). The copy stays byte-identical to the backend source of truth. -
Managed-user webhook config speaks the
1.0.0wire format. It is now serialized as{ url, authMethod, authToken }instead of the old nestedauthobject. The publicWebhookConfigmodel is unchanged (bearer token, or basic username + password); basic credentials are sent as standard base64user:pass. Legacy nestedauth{}payloads are still parsed for resilience against stale servers or caches.
Deprecated #
- Header-only attachment download. Calling
attachments.download(id, metadata: ...)withoutroomId(thex-attachment-metadataheader-authorized flow) is deprecated. The backend now enforces room membership and requires aroomId; the header alone no longer authorizes a download and returns403 not_a_room_member. PassroomIdto take the signed-URL path, or useattachments.signedUrl(...)directly. SeeMIGRATING.md.
Removed #
- Legacy XMPP sender/identity aliases. The SDK no longer reads the
deprecated
jid/fromJid(and the secondaryid) fallbacks.UserMapper.contactFromJsonparsesuserIdonly andRoomMapper.unreadRoomFromJsonparses the preview sender fromfromonly (EventParserlikewise drops thefromJidalias). Current backends emit the canonical fields, so this is a no-op against them; servers that emit only the dropped aliases are no longer supported. users.getManaged(userId). Removed. Useusers.getManagedByParent(parentId)(canonicalGET /users/{parentId}/managed-users) — same arguments and response shape. Dropped from theChatUsersApiinterface, the REST implementation, and the mock. SeeMIGRATING.md.- Data-API room-preference toggles
rooms.mute/unmute/pin/unpin/hide/unhide. Removed fromChatRoomsApi(interface, REST implementation, and mock). Callrooms.patchPreferences(...)directly. The optimistic single-flag wrappers on the UI adapter (adapter.rooms.mute/unmute/pin/unpin/hide/unhide) are unchanged and now drivepatchPreferencesinternally. The user-moderationmembers.muteUser/unmuteUser(a different endpoint) are unaffected. SeeMIGRATING.md. - Reaction-via-send path. The SDK no longer issues reactions through
send(messageType: MessageType.reaction);messages.addReaction/deleteReactionare the only supported reaction API. The generalmessages.sendstill acceptsmessageType/reactionfor other uses. ChatCursorPaginationParams.before/.after(ISO-8601 timestamp paging). Removed entirely. They no longer exist as fields, are no longer emitted asbefore/afterquery params, and the timestamp/id boundary dedup that backed them in the polling realtime engine is gone. All paging is now driven by the opaquecursor+direction(older/newer) against theprevCursor/nextCursoranchors. SeeMIGRATING.md.
Fixed #
-
User profile page now reflects the backend after its background refresh.
UserInfoPagepaints from the user cache for an instant first frame, then always re-fetches the profile from the backend. The re-fetch wrote only local widget state, so a cache entry seeded by a roster / members endpoint (which may omitbio) kept shadowing the fresh record and the description never appeared. The fetched record is now fed back into the shared user cache, so the always-on refresh wins and the liveListenableBuilderrepaints. -
Polling could skip messages sharing an exact millisecond. The REST polling/manual
RefreshEnginetracked progress by last-seen timestamp plus a boundary id set. When the backend now returns an opaquenextcursor the engine switches to seq-based cursor polling (and drops the timestamp dedup), eliminating the identical-timestamp skip. Old backends withoutnextkeep the timestamp path (soft degradation). Stale pagination state carried into a freshly built engine is purged on its first tick so the upgrade can't replay or skip across the scheme change. -
Realtime parser hardened against off-contract payloads. Several
EventParserhandlers read wire fields with rawas String?/as int?casts (and one non-nullableas StringforlastSeen), so a backend that shipped a field with an unexpected type (e.g. a numericlastSeen) threw an uncaughtTypeErrorout of the WebSocket stream callback and could stop event delivery. Every field is now read through a safe type check and degrades gracefully (the field, or the event, is dropped). As defense in depth,WsTransportwraps event dispatch in a guard so no parser error can tear down the stream — matching the SSE path, which already guardedparseNrte. Re-enables and broadens the previously-skippedFUZZ-BUG-2regression group to cover every handler. -
Quickstart room-list snippets now compile. The README and Developer Guide examples referenced a non-existent
RoomListController(chat: chat)constructor and omittedcurrentUserId(needed for own-message ticks and the group "You:" prefix). They now usechat.roomListControllerwithcurrentUserId; the Developer Guide no longer shows a manualdispose()(the SDK owns the controller) or non-existentonInvitation*setters, using the realRoomListViewonAcceptInvitation/onRejectInvitationcallbacks. -
Media gallery and DM/conversation history now paginate older pages.
attachments.listInRoom,contacts.getDirectMessagesandcontacts.getConversationMessagesbuilt theirChatPaginatedResponsewithout parsing thenext/prevcursors from the response (a regression from the opaque-cursor migration), soprevCursor/nextCursorwere alwaysnulland the "shared in this chat" gallery, DMs and conversation timelines stopped after the first page even whenhasMore == true. They now parsejson['next']/json['prev']likemessages.listdoes. -
Timestamps and day separators now render in the device's local time zone.
DateFormatter.formatTime/formatSeparator/isSameDay/isToday/isYesterdayformatted the backend's UTCDateTimedirectly, so users outside UTC saw wrong clock times and could see a message land on the wrong calendar day. All helpers now call.toLocal()first, matching the export and starred-message formatters. -
Group delivery ticks no longer stick on "read by all" during member hydration.
ChatControllerinferred 1:1-vs-group purely fromotherUsers.length, which is 0–1 before the member list loads; a group whose members hadn't hydrated yet was treated as a 1:1, so a single peer's read flag flipped every message to the blue "read by all" tick permanently. The group flag is now pinned explicitly viaChatController.setIsGroup(...)(wired fromRoomListItem.isGroupthe moment the room opens),_aggregateStatusnever collapses a known group to 1:1 (and stays atsentuntil members are known), andsetOtherUsersrecomputes receipts whenever the member count changes. -
SSE reconnect / RefreshEngine re-entrancy races.
SseTransport._doConnectnow cancels any armed reconnect timer and prior request before connecting (mirror ofWsTransport), so aconnect()racing a scheduled reconnect can no longer open two parallel streams that double-emit events.RefreshEngine.tickgained a_tickingre-entrancy guard (likeOfflineQueue) so a fast poll interval or a mid-tickrefreshRoomcan't interleave cursor/snapshot mutations. -
Direct message to a contact who has blocked you (HTTP 204) no longer yields a phantom message. Per the
1.0.0contract the backend silently drops it with an empty body (WhatsApp parity). The SDK now synthesizes a localsentmessage instead of an empty, id-less one, so the bubble shows as sent and never advances to delivered/read — exactly what a blocked sender sees. -
RateLimitFailure.retryAfteris now populated against CHT. CHT's429sendsX-RateLimit-Reset(seconds until the window resets) and noRetry-After; the SDK now readsX-RateLimit-Resetas a fallback, soretryAfter(and the retry interceptor's back-off) reflect the real reset window instead of beingnull. No code change required. -
Terminal auth close (
4005 too_many_auth_attempts) suspends both transports. It stops the WebSocket and prevents the SSE failover from reconnecting with the rejected token. The SDK emits a terminalChatAuthException(exception.terminal == true) and stays inerroruntil a fresh token is obtained andconnect()is called again — listen for it to drive a re-authentication prompt.
Confirmed #
message_acked/message_deliveredWebSocket events (MessageAckedEvent/MessageDeliveredEvent) andreceipt_updated(ReceiptUpdatedEvent) are parsed and dispatched by the SDK — documented in the event catalogue. No code change.
0.9.2 - 2026-05-29 #
Docs #
- Documented that the SDK targets a Nomasystems chat backend defined by a public OpenAPI 3.0 contract; any backend that implements the spec works. The README now links a rendered API reference (Redoc) and the source spec.
- Added the backend OpenAPI contract to the repository (
doc/chat-api-openapi.yml, OpenAPI 3.0.1). Kept on GitHub and linked from the README; excluded from the published tarball via.pubignore(consumers don't need it in their pub cache). - Noted that the Nomasystems chat backend is planned to be open-sourced but is not public yet; for commercial use contact
info@nomasystems.com. Added the Nomasystems website. - Renamed "UI Kit" to "UI components" across the README, dartdoc API docs and developer docs.
- Screenshots and the demo GIF now have transparent backgrounds so they render cleanly on pub.dev (light and dark themes).
- Fixed a broken README link (
INTEGRATING.md→INTEGRATION.md).
0.9.1 - 2026-05-29 #
Dependencies #
- Breaking (consumers): minimum SDK raised to Flutter 3.44 / Dart 3.12.
Required by
record7, which dropped support for older SDKs. recordbumped^6.0.0→^7.0.0(the audio recorder used by voice messages). The Dart API we use (start/stop/pause/hasPermission) is unchanged; record 7's breaking changes are native-only (Android background service, iOSmanageAudioSession) and unused here.file_pickerlower bound raised>=9.0.0→>=11.0.0. The attachment picker calls theFilePicker.pickFilesstatic API, which only exists from file_picker 11.0.0 (it was instance-based before) — the old>=9.0.0constraint let the package resolve to a version where the code did not compile.
Docs #
- README quick-start now pins
noma_chat: ^0.9.0(was a stale^1.0.0).
0.9.0 - 2026-05-29 #
Security #
- HTTP debug logger (
enableHttpLog: true) now redacts sensitive values in request/response bodies (password,token,secret,authorization,api_key,otp,pin,credentialand common variants) and replaces binary payloads with a<binary N bytes>placeholder. Previously bodies were logged verbatim and could leak credentials to whichever sink the consumer wired (Sentry, file log, console). Opt-in flag andloggercallback semantics are unchanged.
Robustness #
HiveChatDatasourceserializes per-room writes (saveMessages,updateMessage,deleteMessage,clearMessages) through an internal per-roomIdlock. Concurrent saves to the same room can no longer leave the message-id index pointing to a key that was just removed. Cross-room writes still run in parallel.RestClientnow exposescancelPending()and the facade calls it ondisconnect/dispose/logout, so in-flight HTTP requests are aborted instead of resurfacing as 401s through a staletokenProvider.BearerAuthInterceptortoken refresh resets the WebSocket reconnect attempt counter only onauth_ok, not on everyconnect()call — prevents a programmatic reconnect from clobbering an in-progress backoff schedule.AutoFailoverTransportre-arms the SSE fallback on every primary drop, not just the first one — connectivity recovers cleanly after a primary- fallback double failure.
RetryInterceptorno longer retries non-idempotent verbs (POST, PATCH, DELETE) on transient connection errors by default. Opt back in withoptions.extra['idempotent'] = trueper request when the caller can guarantee safe replay.- Exponential backoff with jitter is now computed in a single helper
(
computeBackoffMs) used by WS, SSE and HTTP retry layers. Jitter is added before the cap so the maximum delay is honoured exactly. AutoFailoverTransport.dispose()now propagates to both the primary and fallback transports. Previously only streams and subscriptions were cleaned up; the inner transport event/state streams were never closed, leaking listeners across reconnect cycles.WsTransport._onMessagenow wrapsjsonDecodein a try/catch so a malformed frame (invalid JSON, non-UTF-8 bytes) is silently discarded rather than propagating an uncaughtFormatExceptionto the zone.MessageDto.fromJsonno longer hard-castsid,from, andtimestampfields. Non-string values (e.g. integer ids from certain backends) are coerced viatoString()instead of throwing_TypeError. Similarlytext_historyguards against non-List values.PollingConfig.intervalbelow the 5 s floor is now clamped to 5 s with a warning instead of throwingArgumentError. A bad value supplied by the consumer degrades the polling cadence rather than crashingNomaChat.createat login.
Public surface #
- Breaking: types prefixed for clarity.
Result→ChatResult,Success→ChatSuccess,Failure→ChatFailure*(the existing failure hierarchy keeps itsChatFailurebase name and theResultvariant renames toChatFailureResult),PaginationParams→ChatPaginationParams,CursorPaginationParams→ChatCursorPaginationParams,PaginatedResponse→ChatPaginatedResponse,SortOrder→ChatSortOrder. Reduces collisions with apps that already useResult/Pagination/SortOrderfrom other libraries. ChatLocalDatasourceandCachePolicymoved out oflib/src/_internal/(which is meant to be opaque) intolib/src/cache/. The barrel export paths are unchanged.MockChatClientand its eightMock*Apisiblings moved from the primarypackage:noma_chat/noma_chat.dartbarrel to a dedicatedpackage:noma_chat/noma_chat_testing.dart. Production apps no longer see test scaffolding in autocomplete; testsimportthe testing barrel explicitly.MetricCallbackexported frompackage:noma_chat/noma_chat_advanced.dart(was reachable only by path before).ChatLoggermentioned in earlier changelog drafts is renamed to the typedef it actually is (void Function(String level, String message)).ChatRoomsApi.updateRoom/updateConfiggains aclearAvatarflag. Whentruethe SDK sends an explicit empty avatar so a group photo can be removed (the backend's merge-with-preserved config otherwise keeps the old one). Mutually exclusive with a non-nullavatarUrl.RoomDetailandRoomListItemgain aselfMutedfield (moderation mute: an admin/owner silenced the current user in the room, distinct frommuted= the user's own notification preference).isReadOnlynow also returnstruewhenselfMuted, so the composer goes read-only.UserInfoPageadded and exported — a read-only WhatsApp-style "user info" page for a DM peer (large avatar, display name, bio). The read-only twin ofProfileSettingsPage.ChatConfig.eventBufferSizedefault changed from0to20. Late subscribers (e.g. a secondChatController) now replay the last 20 events on attach instead of none; set it back to0to opt out.
UI #
- Accessibility: composer send/attach/camera/voice and voice-recorder
overlay buttons enlarged to ≥48 dp tap targets (WCAG AA). Status icon
in message bubbles now exposes a
Semanticslabel (sent,delivered,read, …) and the timestamp/status/reactions row is wrapped inMergeSemanticsso screen readers announce the row once. MessageListtyping-row branch no longer recomputesisGroupfromotherUsers.length; reuses the host-providedwidget.isGrouplike the message branch already did. Fixes typing label/avatar regressions for callers that wireisGroupexplicitly.- Audio bubble migrated to
ValueListenableBuilder<Duration>for the seek bar; the play button, speed button and status row no longer rebuild on every player tick. - Cache:
CacheManager._timestampsis persisted to a Hive meta box so cold-starts no longer always fall throughcacheFirstto network for rooms/contacts. chat_room_options_menu.dartfactoryblockUserdocumented for parity with the others.
Internal / tests #
ChatUiAdaptersub-API split: the 71 public methods now live in their five sub-controllers (ChatMessagesController,ChatRoomsController,ChatContactsController,ChatProfileController,ChatDmController) instead of in the adapter itself. Each controller is apart of '../chat_ui_adapter.dart'and accesses the adapter's state through a single_areference. The adapter retains a thin pass-through for every method (adapter.sendMessage(...)⇒adapter.messages.send(...)), so existing callers and tests work unchanged.chat_ui_adapter.dartdrops from 2591 → 1706 LOC (-34%). Seeplans/split_chat_ui_adapter.mdfor the sessions journal.chat_ui_adapterfurther decomposed:RoomListMutatorandMemberEventHandlerextracted as standalone collaborators. Adapter drops from ~2960 LOC to ~2300 LOC.MessageInputvoice-recorder gesture machine extracted toMessageInputVoiceController(ChangeNotifier) — composer state is no longer entangled with drag/lock/overlay logic.ChatTheme.copyWith(~250 manual lines) replaced with the Freezed generator; adding a slot is now a one-line edit.MessageList,MessageBubble,TextBubbleandChatViewbuildmethods broken into_build*helpers (no behaviour change, just legibility).- 31 cross-barrel self-imports inside
lib/src/*replaced with relative paths. The symbolic cycle (lib/noma_chat.dartexporting files that importpackage:noma_chat/noma_chat.dart) is gone. lib/src/_internal/util/backoff.dartadded (shared helper, see above).test/cache/hive_chat_datasource_test.dartandtest/sdk/api/api_repositories_test.dartsplit into smaller per-entity files.- CI now also runs
flutter analyze/flutter testoverexample/so breaking the public API can no longer go undetected through the demo app.
Docs #
CHANGELOG: the long-standing[Unreleased]summary cut into this0.9.0entry. Covers changes since the 2026-05-260.6.0audit.ARCHITECTURE.mdand the auto-generated dartdoc strings cleaned of refactor history ("Promoted from part of","Extracted from","since 0.3.0") — historical context lives here in the changelog.
0.6.0 - 2026-05-26 #
Architecture #
- Three-layer package —
ChatClient(REST + real-time + cache-aware sub-APIs),HiveChatDatasource(persistent local cache, opt-in but on by default),ChatUiAdapter(bridges SDK events to per-room controllers and drives the UI Kit). Result<T, ChatFailure>everywhere on the public surface. Nothrowleaks out of the SDK; theResultsealed type withSuccess/Failurecases is pattern-matchable. Helpers:dataOrThrow,failureOrThrow,castFailure<R>(),getOrElse,mapFailure,fold.ChatFailurehierarchy — sealedAuthFailure,NotFoundFailure,NetworkFailure,ValidationFailure,ConflictFailure,CacheFailure,UnknownFailure. Each carries a cause when available.- Models are Freezed. All 17 SDK models and the
RoomListItemUI model use Freezed forcopyWith/==/hashCode/toString. Identity-equality preserved on entities that need it (ChatMessage,ChatRoom,ChatUser,ChatContact,RoomUser,InvitedRoom,ScheduledMessage,ChatPresence,BulkPresenceResponse) via@Freezed(equal: false)+ manual==.
Theming #
-
Cohesive sub-themes —
ChatBubbleTheme,ChatInputTheme,ChatRoomListTheme,ChatMarkdownTheme. Each groups the slots that belong together (e.g.bubble.outgoingColor,input.backgroundColor,roomList.unreadBadgeColor,markdown.boldStyle). -
Flat slots for cross-cutting surfaces —
backgroundColor,avatarBackgroundColor,presenceAvailableColor,audioPlayButtonColor,videoBorderRadius,linkPreviewBackgroundColor,reactionTextStyle, the context menu, attachment picker and image viewer colours, etc., remain top-level onChatThemeitself. -
Factories —
ChatTheme.lightPreset()andChatTheme.darkPreset()set rich defaults across every visible surface;ChatTheme.resolved(BuildContext)picks one based on the platform brightness;ChatTheme.branded({accent, contrastingOnAccent})derives ~12 accent slots from a single colour;ChatTheme.highContrast()returns a WCAG-AAA-friendly preset.final theme = ChatTheme( bubble: ChatBubbleTheme(outgoingColor: Colors.green), input: ChatInputTheme(backgroundColor: Colors.white), markdown: ChatMarkdownTheme( boldStyle: TextStyle(fontWeight: FontWeight.w800), ), roomList: ChatRoomListTheme( nameStyle: TextStyle(fontSize: 16), ), );
Localization #
-
Seven shipped locales —
en,es,fr,de,it,pt,ca. All user-facing strings (system messages, action labels, attachment type names, voice message templates, deleted-message placeholders) live inChatUiLocalizations. -
LocalizationsDelegate—ChatUiLocalizations.delegateintegrates with Flutter's standard l10n flow:MaterialApp( localizationsDelegates: const [ ChatUiLocalizations.delegate, GlobalMaterialLocalizations.delegate, // … ], supportedLocales: ChatUiLocalizations.supportedLocales, );Widgets call
ChatUiLocalizations.of(context); the SDK falls back to English when no delegate is registered (handy in tests and quick demos).
Real-time transports #
ChatConfig.realtimeMode chooses how live updates arrive:
| Mode | What it does |
|---|---|
auto (default) |
WebSocket primary, automatic SSE fallback when WS connect/upgrade fails. |
webSocketOnly |
WS only; disconnects surface as errors instead of falling back. |
serverSentEventsOnly |
SSE only; useful on networks that drop WebSockets. |
polling |
REST polling diff. Configurable interval; no typing/presence events. |
manual |
No background work. The host app calls chat.refresh() to pull updates. |
All transports emit events onto the same chat.client.events stream.
SSE has a client-side idle watchdog (ChatConfig.sseIdleTimeout,
default 60 s) that reconnects on long silence to mitigate zombie
streams.
Cache #
- Hive CE backend (
HiveChatDatasource), opt-in viacache:onNomaChat.create(a default instance is wired up automatically). - Per-API
CachePolicy—cacheFirst,networkOnly,cacheOnly,cacheThenNetwork— surfaces explicitly on read methods. - Eviction policy — FIFO with configurable per-room cap +
per-entry TTL. Tunable via
CacheConfig. - Schema migration —
CacheSchemaMigratorruns step-by-step migrations between recorded schema versions, falling back to a wipe-and-rebuild only when no path is registered. - Avatar storage — pluggable
AvatarStorageinterface; the default delegates toclient.attachments.upload.
Offline queue #
- Sealed
PendingOperationwith nine concrete subclasses (SendMessage,EditMessage,DeleteMessage,SendReaction,DeleteReaction,MarkAsRead,PinMessage,UnpinMessage,ToggleRoomFlag). Each carries its ownMap<String, dynamic> toJson()so serialization stays cohesive with the type. - Exponential backoff with a configurable ceiling
(
OfflineQueue.maxBackoffSecs). - Drain runs through an injected
PendingOperationExecutorso the queue stays decoupled fromChatClient.
UI Kit #
- Message bubbles for text, image, video, audio, file and
location, with a shared
BubbleMetadataRowthat handles thetimestamp + receipt-statuscorner consistently. - Composer (
MessageInput) with mentions, replies, edits, attachments, voice recording (slide-to-cancel, lock-to-keep), link preview, send-on-Enter on desktop. - Room list with unread badges, mute / pin / hide / archive
affordances, WhatsApp-style last-message previews
(
📷 Photo,🎤 Voice message (0:14), etc.) andTú:/You:prefix in groups. - Reactions — long-press to pick, double-tap to react, picker sheet, aggregated badges under the bubble.
- Group flows —
MemberPickerSheet→GroupSetupPage→GroupInfoPage. Avatar pipeline:AvatarPickerSheet→AvatarCropPage(square crop with pinch + pan + rotate). - Profile —
ProfileSettingsPagefor display name + avatar + optional bio/email.
Observability #
- Pluggable logger —
ChatConfig.logger: void Function(String level, String message)?. Levels aredebug/info/warn/error. Propagated to interceptors, transports, cache datasource and offline queue; the consumer passes their own implementation to forward to telemetry. OperationErrorstream — the adapter publishes(OperationKind, ChatFailure, roomId/messageId/userId)for every mutation failure, so a host app drives a single global banner instead of wrapping each call site.LinkPreviewFetcher.cacheStats— entries, capacity, in-flight, hits, misses, failure retries, evictions, hit rate. Useful for debug overlays.
Utilities #
Result<T, ChatFailure>+ helpers (above).PaginatedResult<T>withnextCursor/hasMorefor SDK pagination.MimeClassifier(MimeKind { image, gif, video, audio, file }classifyMime(String?)) — single source of truth for "what kind of attachment is this".
DateFormatter— context-aware "12:34", "Yesterday", weekday name, full date.MarkdownParser— inline-only (**bold**,*italic*,~~strike~~,`code`); the parser's scope and the deliberate non-support (block markdown, links) are documented in the file.
Platform support #
pubspec.yaml declares all six Flutter targets — android, ios,
macos, linux, windows, web. Production-tested: Android and
iOS. Voice recording on web is disabled (the controller stages
recordings on the local filesystem before sending); calling
startRecording() returns permissionDenied instead of crashing.
See the README "Platform support" table for the breakdown.
Lints & tests #
analysis_options.yamlenablesstrict-casts,strict-inference,strict-raw-typesplus the canonicalprefer_const_*/prefer_final_*ruleset.- Suite size: 1710 tests passing, 2 skipped. Coverage > 90% on every leaf module. Golden tests for the seven non-network bubbles in light + dark themes (19 baselines), plus the five outgoing status icons.
0.3.1 - 2026-05-14 #
Pana-score patch. No public API or behaviour change; consumers on
^0.3.0 pick this up automatically.
Fixed #
- Pana static analysis (40/50 → 50/50): the four
chat_ui_adapter_*part files introduced by the 0.3.0 SRP refactor had drifted from the Dart formatter.dart format --set-exit-if-changedfailed on pana's side, dropping the static-analysis score by 10 points. Now formatted. - Stale dartdoc reference:
ChatUiAdapter.presenceForreferenced the private_bootstrapPresencesymbol that was relocated to_PresenceManager.bootstrapin 0.3.0; the comment now describes the bootstrap source without naming an internal symbol.
Changed #
-
VoiceRecordingControllerno longer importsdart:ioorpath_providerdirectly. The filesystem helpers (getTemporaryDirectory(),File,Directory,FileSystemException) live in_voice_recorder_io.dartwith a Web stub in_voice_recorder_io_web.dart; the controller picks them up via a conditional import (if (dart.library.js_interop)).This is a step towards full WASM compatibility but does not move the pana platform-support score by itself (the remaining WASM blocker is in
audioplayers→path_provider). A future WASM-compatible audio backend would now drop the package straight to 160/160 with no further changes on our side.
Notes #
- Pana on pub.dev for the (still-published) 0.3.0 reports 140/160 — this 0.3.1 lifts it to 150/160 once published, matching the local measurement.
0.3.0 - 2026-05-13 #
Quality + architecture release. No public API breaking changes; the audio backend migration is transparent to consumers.
Changed #
- Audio backend: migrated from
just_audiotoaudioplayers ^6.1.0. Same feature surface (play / pause / seek / playback rate / state stream) butaudioplayersships implementations for all six Flutter targets, unblocking Linux and Windows.pubspec.yamlplatforms:now lists android / ios / macos / linux / windows / web; see README "Platform support" for the production / best effort breakdown. ChatClientinterface:set onOfflineMessageSentis now part of the abstract contract (was concrete-only onNomaChatClient). The UI adapter no longer needs anas NomaChatClientcast.MockChatClientand any customChatClientimpl in tests implement the setter (no-op is fine).ChatUiAdapterinternal SRP refactor (no API change): the 2272-line monolith was split into fourpart ofcollaborators —_PresenceManager,_ChatEventRouter,_RoomEnricher,_OptimisticHandler. The facade is now ~1500 lines and the responsibilities are obvious from the file layout.MockChatClient.roomsnow emitsRoomUpdatedEventafter each successfulmute/unmute/pin/unpin/hide/unhideto match the real client's event semantics. Tests that count events should expect one per mutation.- Models: every public value-object class in
lib/src/models/andlib/src/ui/models/is now annotated@immutable. No runtime difference; the analyzer now flags accidental subclassed mutability.
Fixed #
loadRooms()and_enrichAndSetRoomsguard_disposedafter every long await so they cannot write to a disposedValueNotifierorRoomListController.rejectInvitationnow restores the room on network failure (previously it dropped the invitation permanently if the request errored out).sendThreadReplyno longer double-emits tooperationErrors: bothOperationKind.sendMessageandOperationKind.sendThreadReplyused to fire for a single failure.sendMessageaccepts an optionaloperationKindoverride and the thread-reply path uses it to emit a single, more specific kind.loadMoreMessageswraps its body intry/finallysocontroller.setLoadingMore(false)runs even if the SDK call leaks an exception past theResultwrapper.VoiceRecordingController.startRecording()early-returns withStartRecordingResult.permissionDeniedon Web (it was crashing ondart:io/path_provider). A MediaRecorder-backed Web flow is on the roadmap.LinkPreviewFetcherretries cached failures after a configurable TTL (default 5 min) instead of cachingnullforever. Transient network glitches no longer poison the per-session preview cache.- Hardcoded English Semantics labels in
ImageBubble,VideoBubbleandScrollToBottomButtonare now routed throughtheme.l10n. A newscrollToBottomlocalisation key was added across all seven shipped locales (en / es / fr / de / it / pt / ca). - Dark + high-contrast themes now ship explicit
markdownCodeStyleandmarkdownLinkStyleoverrides; the previous defaults bled light-mode values into the dark UI and failed WCAG AA contrast for inline links. - A handful of dark-theme accent colours (
reactionBackgroundColor,audioPlayButtonColor,audioListenedIconColor,audioUnlistenedIconColor,linkPreviewBackgroundColor) are now overridden inChatTheme.darkinstead of inheriting light defaults. - Voice upload progress
ValueNotifiers detached after a completed upload are now tracked and disposed duringadapter.dispose()(they used to outlive the adapter when the optimistic bubble held a reference). _resolveDmContactrewritten from aFuture.sync().then().catchError()chain toasync/await+try/catchwith an explicitunawaited()so the fire-and-forget intent is visible at the call site.
Documentation #
- README
Platform supporttable rewritten to reflect the audioplayers migration (six platforms supported via the new backend; voice recording on Web is documented as "Limited" with the reason). RELEASING.mdupdated for the now-live automated publishing flow, including the three pub.dev configuration toggles and the four failure modes a maintainer might hit.TESTING.mdtest counts refreshed to reflect the current suite size (1474+) and the 80% coverage gate enforced in CI.markdown_parser.dartdartdoc now lists the supported inline syntax and the deliberate non-support ([label](url), block markdown).
Tests #
- 1485 tests passing on Linux (CI), + 4 skipped. On macOS the 19 golden
bubble diffs fail by ~1% pixel-diff because the baselines are
generated on Linux for CI; regenerate locally with
flutter test --update-goldensif needed. - Coverage 80.55% (8248/10239), enforced ≥80% in CI.
0.2.1 - 2026-05-13 #
Post-publish polish driven by the pub.dev scoring report. No behavioural
changes; consumers on ^0.2.0 pick this up automatically.
Fixed #
- Static analysis: 17 stale
*.freezed.dartfiles were left behind from an earlier migration of plain models off Freezed.dart analyzeignored them locally (excluded viaanalysis_options.yaml) but pana ran a separate analysis that surfaced 1 176 errors against them. The files are now deleted; the remainingadmin_models.freezed.dartis genuinely generated and stays. hive_celower bound: bumped from^2.7.0to^2.19.0. Older versions did not yet exposepackage:hive_ce/hive_ce.dart, so a consumer withdart pub downgradewould fail to compile.just_audioconstraint: bumped from^0.9.42to^0.10.0so the package tracks the current stable line.
Changed #
pubspec.yamlnow declaresplatforms:explicitly. Supported targets are android, ios, macos, web. Windows and Linux are excluded becausejust_audio(transitive, used for voice playback) does not support them.- README has a new Platform support section documenting which platforms are production-tested vs best-effort vs unsupported, with the exact transitive-dep blocker for Windows/Linux.
0.2.0 - 2026-05-13 #
First public release. The SDK has been used internally for several months and the API surface, UI Kit, persistent cache and adapter are considered stable enough for external evaluation; the pre-1.0 versioning keeps room for breaking changes informed by real-world feedback before committing to a 1.0 contract.
Added #
- Message search end-to-end:
MessageSearchController,MessageSearchViewwith case-insensitive query highlighting, andChatView.initialMessageIdto scroll-and-highlight a target message after navigating back from results. - Read receipts: blue double-check in
MessageStatusIcon(defaultmessageStatusReadColorshipped inChatTheme.defaults) and automaticReadReceiptAvatarsrow in group rooms when receipts are available. Public helperreadersFor(ChatMessage, List<ReadReceipt>)for custom derivations. - Optimistic UI across the adapter: every mutating operation
(
sendMessage,editMessage,deleteMessage,sendReaction,deleteReaction,muteRoom/unmuteRoom,pinRoom/unpinRoom,pinMessage/unpinMessage,hideRoom, …) updates local state first and rolls back on failure. - Operation errors stream:
ChatUiAdapter.operationErrors— a broadcastStream<OperationError>carryingOperationKind, the originalChatFailureandroomId/messageId/userIdcontext for every adapter failure. Designed for global snackbars and telemetry without wrapping each call site. - Pinned messages state in
ChatController(pinnedMessages+addPin/removePin/setPins/clearPins/isPinned).adapter.loadPins(roomId)now seeds it too. - Dark theme shipped as
ChatTheme.darkandChatTheme.highContrast. - Example app with four pages (home, chat room, message search, pinned
messages) and a
GlobalErrorBannerthat subscribes tooperationErrors. - Comprehensive dartdoc across all public APIs (entry points, sub-APIs, models, controllers, theme, l10n, every widget and bubble).
Tests #
- 1156 tests passing + 4 skipped in the full suite.
- Golden tests for the seven non-network bubbles in light and dark themes plus the five outgoing message status icons (19 baselines).
- Integration tests exercising the full adapter flow against
MockChatClient. - Performance regression guard for
HiveChatDatasourceon 10k messages. - Accessibility audit using
meetsGuideline(Android/iOS tap target, labeled tap target, text contrast). - System-message l10n parity across the seven shipped locales
(
en,es,fr,de,it,pt,ca).
Known limitations #
- Golden tests for
ImageBubbleandLinkPreviewBubbleare skipped:CachedNetworkImagepulls influtter_cache_manager→sqflite+path_provider, which is impractical to mock in plain widget tests without an extra dependency such assqflite_common_ffi. - Push notifications integration is not part of this release.
ChatEventdoes not yet emitMessagePinnedEvent/MessageUnpinnedEvent, so cross-client pin synchronisation requires a manualloadPinsrefresh.
0.1.0 Unreleased #
Initial development version. Used internally during the SDK's design and not published to pub.dev.
