Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions 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` and `ChannelClientState.updateThreadInfo` (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,6 +12,7 @@

- Fixed a deprecation warning from `equatable` causing CI analysis to fail.
- `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.

## 9.26.0

Expand Down
136 changes: 80 additions & 56 deletions packages/stream_chat/lib/src/client/channel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2819,7 +2819,7 @@ class ChannelClientState {
pollId: oldMessage?.pollId,
ownReactions: oldMessage?.ownReactions,
);
updateMessage(message);
updateMessage(message, upsert: false);
}));
}

Expand Down Expand Up @@ -2935,74 +2935,82 @@ class ChannelClientState {
);
}

/// Updates the [message] in the state if it exists. Adds it otherwise.
void updateMessage(Message message) {
/// Updates the [message] in the state if it exists. Adds it otherwise,
/// unless [upsert] is `false`, in which case an unknown [message] is skipped.
void updateMessage(Message message, {bool upsert = true}) {
// Determine if the message should be displayed in the channel view.
if (message.parentId == null || message.showInChannel == true) {
// Scan from the tail: server echoes, edits, and reactions almost
// always target a recent message, so `lastIndexWhere` exits in a
// handful of comparisons instead of walking the full list.
final oldIndex = messages.lastIndexWhere((m) => m.id == message.id);
final oldMessage = oldIndex == -1 ? null : messages[oldIndex];

// Carry over local-only timestamps; no-op when there's no prior.
var updatedMessage = message.syncWith(oldMessage);

// Restore `quotedMessage` stripped by a partial-update payload —
// the server omits it when only updating other fields.
if (oldMessage != null &&
updatedMessage.quotedMessageId != null &&
updatedMessage.quotedMessage == null &&
oldMessage.quotedMessage != null) {
updatedMessage = updatedMessage.copyWith(
quotedMessage: oldMessage.quotedMessage,

// Handle updates to pinned messages.
final newPinnedMessages = _updatePinnedMessages(message);

if (oldIndex == -1 && !upsert) {
_channelState = _channelState.copyWith(
pinnedMessages: newPinnedMessages,
);
}
} else {
final oldMessage = oldIndex == -1 ? null : messages[oldIndex];

// Carry over local-only timestamps; no-op when there's no prior.
var updatedMessage = message.syncWith(oldMessage);

// Restore `quotedMessage` stripped by a partial-update payload —
// the server omits it when only updating other fields.
if (oldMessage != null &&
updatedMessage.quotedMessageId != null &&
updatedMessage.quotedMessage == null &&
oldMessage.quotedMessage != null) {
updatedMessage = updatedMessage.copyWith(
quotedMessage: oldMessage.quotedMessage,
);
}

var newMessages = messages.sortedUpsertAt(
oldIndex,
updatedMessage,
compare: _sortByCreatedAt,
);
var newMessages = messages.sortedUpsertAt(
oldIndex,
updatedMessage,
compare: _sortByCreatedAt,
);

// When the target of a quote is deleted, rewrite the embedded
// `quotedMessage` in every message quoting it. `updateIf` returns
// the same list reference when nothing matches.
if (oldMessage != null && message.isDeleted) {
newMessages = newMessages.updateIf(
(it) => it.quotedMessageId == message.id,
(it) => it.copyWith(
quotedMessage: updatedMessage.copyWith(
type: message.type,
deletedAt: message.deletedAt,
// When the target of a quote is deleted, rewrite the embedded
// `quotedMessage` in every message quoting it. `updateIf` returns
// the same list reference when nothing matches.
if (oldMessage != null && message.isDeleted) {
newMessages = newMessages.updateIf(
(it) => it.quotedMessageId == message.id,
(it) => it.copyWith(
quotedMessage: updatedMessage.copyWith(
type: message.type,
deletedAt: message.deletedAt,
),
),
),
);
}
);
}

// Handle updates to pinned messages.
final newPinnedMessages = _updatePinnedMessages(message);
// Calculate the new last message at time.
var lastMessageAt = _channelState.channel?.lastMessageAt;
lastMessageAt ??= message.createdAt;
if (MessageRules.canUpdateChannelLastMessageAt(message, _channel)) {
lastMessageAt = [lastMessageAt, message.createdAt].max;
}

// Calculate the new last message at time.
var lastMessageAt = _channelState.channel?.lastMessageAt;
lastMessageAt ??= message.createdAt;
if (MessageRules.canUpdateChannelLastMessageAt(message, _channel)) {
lastMessageAt = [lastMessageAt, message.createdAt].max;
// Apply the updated lists to the channel state.
_channelState = _channelState.copyWith(
messages: newMessages,
pinnedMessages: newPinnedMessages,
channel: _channelState.channel?.copyWith(
lastMessageAt: lastMessageAt,
),
);
}

// Apply the updated lists to the channel state.
_channelState = _channelState.copyWith(
messages: newMessages,
pinnedMessages: newPinnedMessages,
channel: _channelState.channel?.copyWith(
lastMessageAt: lastMessageAt,
),
);
}

// If the message is part of a thread, update thread information.
if (message.parentId case final parentId?) {
updateThreadInfo(parentId, [message]);
updateThreadInfo(parentId, [message], upsert: upsert);
}
}

Expand Down Expand Up @@ -3090,7 +3098,7 @@ class ChannelClientState {
/// Removes/Updates the [message] based on the [hardDelete] value.
void deleteMessage(Message message, {bool hardDelete = false}) {
if (hardDelete) return removeMessage(message);
return updateMessage(message);
return updateMessage(message, upsert: false);
}

void _listenReadEvents() {
Expand Down Expand Up @@ -3453,16 +3461,32 @@ class ChannelClientState {
}

/// Update threads with updated information about messages.
void updateThreadInfo(String parentId, List<Message> messages) {
void updateThreadInfo(
String parentId,
List<Message> messages, {
bool upsert = true,
}) {
var messagesToMerge = messages;
if (!upsert) {
final existingThread = threads[parentId];
// Don't create a phantom entry for a thread that was never paged in,
// and only update replies already loaded in it.
if (existingThread == null) return;
final existingIds = {for (final m in existingThread) m.id};
messagesToMerge =
messages.where((m) => existingIds.contains(m.id)).toList();
if (messagesToMerge.isEmpty) return;
}

final newThreads = {...threads}..update(
parentId,
(original) => original.merge(
messages,
messagesToMerge,
key: (message) => message.id,
update: (original, updated) => updated.syncWith(original),
compare: _sortByCreatedAt,
),
ifAbsent: () => messages.sorted(_sortByCreatedAt),
ifAbsent: () => messagesToMerge.sorted(_sortByCreatedAt),
);

_threads = newThreads;
Expand Down
Loading
Loading