noma_chat 0.10.0
noma_chat: ^0.10.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.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.
