Skip to content
Merged
6 changes: 5 additions & 1 deletion packages/stream_chat/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
## Upcoming

✅ Added

- Added an `upsert` flag to `ChannelClientState.updateMessage` (defaults to `true`). Pass `false` to update a message only if it's already loaded in the state, skipping unknown messages instead of adding them.

🔄 Changed

- `StreamChatClient.updateSystemEnvironment` now sanitizes the passed `SystemEnvironment`: `sdkName`, `sdkVersion`, and `osName` are locked to internal defaults, and `sdkIdentifier` only accepts the `dart` → `flutter` promotion (other values, including a `flutter` → `dart` demotion, are ignored). `appName`, `appVersion`, `osVersion`, and `deviceModel` continue to pass through as-is.
Expand All @@ -8,7 +12,7 @@

- `StreamChatNetworkError.fromDioException` no longer throws `FormatException` when an edge/proxy returns a non-JSON body (e.g. a plain `upstream request timeout` from a 504); the original network error now surfaces with `statusCode` / `statusMessage` intact.
- `ComparableField` now folds diacritics/ligatures and ignores case when comparing strings, so `SortOption` on fields like `name` no longer pushes lowercase or non-ASCII names (`jhon`, `Łukasz`, `Øystein`) to the end of client-sorted lists ([#2601](https://github.com/GetStream/stream-chat-flutter/issues/2601)).

- Fixed `message.updated` and soft `message.deleted` events being incorrectly upserted into `ChannelState.messages` (and thread reply lists) when they targeted a message outside the currently loaded window.

## 10.1.0

Expand Down
51 changes: 43 additions & 8 deletions packages/stream_chat/lib/src/client/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3194,7 +3194,7 @@ class ChannelClientState {
final message = event.message;
if (message == null) return;

return updateMessage(message);
return updateMessage(message, upsert: false);
}),
);
}
Expand Down Expand Up @@ -3305,13 +3305,18 @@ class ChannelClientState {
);
}

/// Updates the [message] in the state if it exists. Adds it otherwise.
/// Updates the [message] in the state.
///
/// Reconciles via `Message.updateWith`, so locally-known enrichment
/// (poll, sharedLocation, ownReactions, nested quotedMessage) is
/// preserved when [message] omits those fields. Use [replaceMessage]
/// for paths that need a strict overwrite.
void updateMessage(Message message) => _updateMessages([message]);
///
/// When [upsert] is `true` (the default) and [message] isn't already in
/// the state, it's added. When `false`, an unknown [message] is skipped
/// and the state is left unchanged; only a message already loaded in the
/// state is updated.
void updateMessage(Message message, {bool upsert = true}) => _updateMessages([message], upsert: upsert);

/// Replaces the [message] in the state if it exists, no-op otherwise.
///
Expand Down Expand Up @@ -3924,24 +3929,26 @@ class ChannelClientState {
if (messages.isEmpty) return;

if (hardDelete) return _removeMessages(messages);
return _updateMessages(messages);
return _updateMessages(messages, upsert: false);
}

void _updateMessages(
Iterable<Message> messages, {
Message Function(Message original, Message updated) update = _mergeUpdate,
bool upsert = true,
}) {
if (messages.isEmpty) return;

_updateThreadMessages(messages, update: update);
_updateChannelMessages(messages, update: update);
_updateThreadMessages(messages, update: update, upsert: upsert);
_updateChannelMessages(messages, update: update, upsert: upsert);
_updatePinnedMessages(messages, update: update);
_updateActiveLiveLocations(messages);
Comment on lines 3944 to 3945

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets also add the flag to these two

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that the _updatePinnedMessages method is fine if it always does an upsertion. For example, if a message gets pinned, we would get a message.updated event for that message with the pinned indicator flipped. In such case, we should anyway add the message to the pinned messages state. Otherwise, we need to explicitly check if the message.updated event was received for the purpose of pinning a message.

Similarly for the _updateActiveLiveLocation -> I think that we are also fine if we always upsert them? In this case it would mean that a message outside of the loaded window was updated with a live location, but that is (I believe) already a valid scenario? We should get all live locations when loading the channel, unrelated to which page we load?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense 👍🏼

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: 36f12dd

}

void _updateThreadMessages(
Iterable<Message> messages, {
Message Function(Message original, Message updated) update = _mergeUpdate,
bool upsert = true,
}) {
if (messages.isEmpty) return;

Expand All @@ -3958,11 +3965,20 @@ class ChannelClientState {

final updatedThreads = {...threads};
for (final MapEntry(key: thread, :value) in messagesByThread.entries) {
final threadMessages = updatedThreads[thread] ?? <Message>[];
final existingThreadMessages = updatedThreads[thread];

// Don't create a phantom entry for a thread that wasn't loaded: with
// `upsert: false` an out-of-window reply is dropped, so there's nothing
// to merge. Writing it back would make `threads.containsKey(parentId)`
// report a thread that was never paged in.
if (existingThreadMessages == null && !upsert) continue;

final threadMessages = existingThreadMessages ?? <Message>[];
final updatedThreadMessages = _mergeMessagesIntoExisting(
existing: threadMessages,
toMerge: value,
update: update,
upsert: upsert,
);

// Update the thread with the modified message list.
Expand All @@ -3976,6 +3992,7 @@ class ChannelClientState {
void _updateChannelMessages(
Iterable<Message> messages, {
Message Function(Message original, Message updated) update = _mergeUpdate,
bool upsert = true,
}) {
if (messages.isEmpty) return;

Expand All @@ -3996,6 +4013,7 @@ class ChannelClientState {
existing: channelMessages,
toMerge: affectedMessages,
update: update,
upsert: upsert,
);

// Calculate the new last message at time.
Expand Down Expand Up @@ -4092,14 +4110,20 @@ class ChannelClientState {
required Iterable<Message> existing,
required Iterable<Message> toMerge,
Message Function(Message original, Message updated) update = _mergeUpdate,
bool upsert = true,
}) {
if (toMerge.isEmpty) return existing;

// [update] decides whether each pair is reconciled (default — see
// `_mergeUpdate`) or replaced (`_replaceUpdate`, used by local rollback
// paths that don't want enrichment fallback to keep optimistic values).
//
// [upsert] controls whether ids not already in [existing] are inserted.
// Event-driven paths (`message.updated`, `message.deleted` soft) pass
// `upsert: false` so an out-of-window message isn't dropped into a gap
// between the loaded slice and history the client hasn't paged in yet.
final existingList = existing is List<Message> ? existing : existing.toList();
final toMergeList = toMerge is List<Message> ? toMerge : toMerge.toList();
var toMergeList = toMerge is List<Message> ? toMerge : toMerge.toList();

// Single-message fast path. The hot ingest path (server echoes, edits,
// reactions, read receipts) always lands here, and `lastIndexWhere` +
Expand All @@ -4108,6 +4132,10 @@ class ChannelClientState {
if (toMergeList.length == 1) {
final message = toMergeList.first;
final oldIndex = existingList.lastIndexWhere((it) => it.id == message.id);

// upsert: false — skip update if message is not loaded
if (oldIndex == -1 && !upsert) return existingList;

final resolved = oldIndex == -1 ? message : update(existingList[oldIndex], message);

final mergedMessages = existingList.sortedUpsertAt(
Expand All @@ -4127,6 +4155,13 @@ class ChannelClientState {
);
}

// upsert: false - skip messages not loaded in the window
if (!upsert) {
final existingIds = {for (final m in existingList) m.id};
toMergeList = toMergeList.where((m) => existingIds.contains(m.id)).toList();
if (toMergeList.isEmpty) return existingList;
}

// Batch path: receiver (`existingList`) is maintained sorted as a
// state invariant; `mergeSorted` sorts `toMergeList` internally and
// returns a sorted result.
Expand Down
Loading
Loading