From 29f7508fbdae521fdb4f52044c58c08a2f810fcd Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 31 Jul 2026 12:55:25 +0200 Subject: [PATCH 1/3] fix(llc): keep reaction group while count stays positive on delete Message.deleteMyReaction dropped the entire ReactionGroup whenever the updated sumScores reached 0, even while the count was still greater than zero, so count-based reaction UIs lost other users' reactions during the optimistic delete until the next server event. Gate the group's survival on count alone, mirroring the backend which derives groups from the reaction count (sum of scores is an independent aggregate that may legitimately be zero). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/stream_chat/CHANGELOG.md | 1 + .../lib/src/core/models/message.dart | 8 +++- .../models/message_reaction_helper_test.dart | 45 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index a063de46f1..1b17226cae 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -22,6 +22,7 @@ - Fixed `Channel.name`/`image`/`extraData` setters throwing after a *failed* initialization; they now only throw once the channel is successfully initialized. - Fixed `Channel.initialized` staying errored after a failed init; it now reflects a subsequent successful (re)initialization. - Fixed a `StateError` (`Cannot add new events after calling close`) thrown when the client is disposed while a reconnect recovery is still in flight. +- Fixed `Message.deleteMyReaction` dropping an entire reaction group when its summed scores reached zero even though other users' reactions kept the count positive; the group is now retained as long as its count stays above zero. ## 10.2.0 diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 8b2707c800..6b2252a74b 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -1026,10 +1026,16 @@ extension MessageReactionHelper on Message { if (group == null) continue; // Update the reaction group. + // + // The group exists as long as at least one reaction remains, mirroring + // the backend which derives groups from the reaction count alone. The + // score is an independent aggregate and must not gate the group, else a + // group whose scores net to zero would be dropped while its count (and + // thus other users' reactions) is still positive. final updatedCount = group.count - 1; final updatedSumScores = group.sumScores - reaction.score; - if (updatedCount > 0 && updatedSumScores > 0) { + if (updatedCount > 0) { reactionGroups[type] = group.copyWith( count: updatedCount, sumScores: updatedSumScores, diff --git a/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart b/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart index 0aa5ea5561..04a8f938f9 100644 --- a/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart +++ b/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart @@ -361,6 +361,51 @@ void main() { // Should have updated the reaction group counts expect(updatedMessage.reactionGroups, isEmpty); }); + + test('should keep reaction group when count remains after sumScores ' + 'reaches zero', () { + // Own reaction with a score that cancels out the group total, leaving + // sumScores at 0 while another user's reaction keeps count positive. + final ownReaction = Reaction( + type: 'like', + score: 0, + user: testUser, + userId: testUser.id, + messageId: emptyMessage.id, + ); + + final otherUser = User(id: 'other-user-id'); + final otherReaction = Reaction( + type: 'like', + score: 0, + user: otherUser, + userId: otherUser.id, + messageId: emptyMessage.id, + ); + + final messageWithReactions = emptyMessage.copyWith( + ownReactions: [ownReaction], + latestReactions: [ownReaction, otherReaction], + reactionGroups: { + 'like': ReactionGroup( + count: 2, + sumScores: 0, + firstReactionAt: ownReaction.createdAt, + lastReactionAt: otherReaction.createdAt, + ), + }, + ); + + final updatedMessage = messageWithReactions.deleteMyReaction(); + + // The group must survive because another user's reaction remains, even + // though the summed scores are zero. + expect(updatedMessage.reactionGroups!.length, 1); + expect(updatedMessage.reactionGroups!['like']!.count, 1); + expect(updatedMessage.reactionGroups!['like']!.sumScores, 0); + expect(updatedMessage.latestReactions!.length, 1); + expect(updatedMessage.latestReactions!.first.userId, otherUser.id); + }); }); }); } From 668ed317df0a0560ecfaffe388c26e0c94217a18 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 31 Jul 2026 13:08:49 +0200 Subject: [PATCH 2/3] fix(llc): keep legacy-payload reaction group while count is positive The _reactionGroupsReadValue fallback that synthesizes reaction groups from the old reaction_counts/reaction_scores maps dropped a group when its sum_scores was 0, discarding a still-populated group at parse time. Gate on count only, matching the delete-path fix. Also trims the two reaction-group comments to one terse line each. Addresses review feedback on #2858. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/stream_chat/CHANGELOG.md | 1 + .../lib/src/core/models/message.dart | 11 +++-------- .../test/src/core/models/message_test.dart | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 1b17226cae..deac7fac77 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -23,6 +23,7 @@ - Fixed `Channel.initialized` staying errored after a failed init; it now reflects a subsequent successful (re)initialization. - Fixed a `StateError` (`Cannot add new events after calling close`) thrown when the client is disposed while a reconnect recovery is still in flight. - Fixed `Message.deleteMyReaction` dropping an entire reaction group when its summed scores reached zero even though other users' reactions kept the count positive; the group is now retained as long as its count stays above zero. +- Fixed reaction groups synthesized from legacy `reaction_counts`/`reaction_scores` payloads being discarded at parse time when their score total was zero despite a positive count. ## 10.2.0 diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 6b2252a74b..eadc6fc2bb 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -172,7 +172,8 @@ class Message extends Equatable implements ComparableFieldProvider { final count = reactionCounts?[type] ?? 0; final sumScores = reactionScores?[type] ?? 0; - if (count == 0 || sumScores == 0) continue; + // Keep the group while count is positive; score may be zero. + if (count == 0) continue; final now = DateTime.timestamp(); groups[type] = { 'count': count, @@ -1025,13 +1026,7 @@ extension MessageReactionHelper on Message { final group = reactionGroups.remove(type); if (group == null) continue; - // Update the reaction group. - // - // The group exists as long as at least one reaction remains, mirroring - // the backend which derives groups from the reaction count alone. The - // score is an independent aggregate and must not gate the group, else a - // group whose scores net to zero would be dropped while its count (and - // thus other users' reactions) is still positive. + // Keep the group while count is positive; score may be zero. final updatedCount = group.count - 1; final updatedSumScores = group.sumScores - reaction.score; diff --git a/packages/stream_chat/test/src/core/models/message_test.dart b/packages/stream_chat/test/src/core/models/message_test.dart index 5185fd42ea..dac26b3021 100644 --- a/packages/stream_chat/test/src/core/models/message_test.dart +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -520,6 +520,24 @@ void main() { expect(message.reactionGroups!['love']!.sumScores, 5); }, ); + + test( + 'synthesizes groups from legacy reaction_counts/reaction_scores ' + 'even when the score total is zero', + () { + final message = Message.fromJson(const { + 'reaction_counts': {'like': 2}, + 'reaction_scores': {'like': 0}, + }); + + // The group must be retained because its count is positive, even + // though the summed scores are zero. + expect(message.reactionGroups, isNotNull); + expect(message.reactionGroups!.containsKey('like'), isTrue); + expect(message.reactionGroups!['like']!.count, 2); + expect(message.reactionGroups!['like']!.sumScores, 0); + }, + ); }); }); From e0d821c43510a25ea154ff98f4c90d5bbd7adaaa Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 31 Jul 2026 13:16:36 +0200 Subject: [PATCH 3/3] test(llc): address review feedback on reaction-group fix - Guard the legacy synthesizer on count <= 0 so a malformed negative count can't build an invalid ReactionGroup (CodeRabbit). - Use a realistic delete-path fixture (own score 1, other score -1) instead of unreachable score 0 reactions (renefloor). - Extend the legacy-payload test to cover a negative score total. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/stream_chat/CHANGELOG.md | 2 +- .../stream_chat/lib/src/core/models/message.dart | 6 +++--- .../models/message_reaction_helper_test.dart | 16 ++++++++-------- .../test/src/core/models/message_test.dart | 13 +++++++------ 4 files changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index deac7fac77..83018bf1b6 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -23,7 +23,7 @@ - Fixed `Channel.initialized` staying errored after a failed init; it now reflects a subsequent successful (re)initialization. - Fixed a `StateError` (`Cannot add new events after calling close`) thrown when the client is disposed while a reconnect recovery is still in flight. - Fixed `Message.deleteMyReaction` dropping an entire reaction group when its summed scores reached zero even though other users' reactions kept the count positive; the group is now retained as long as its count stays above zero. -- Fixed reaction groups synthesized from legacy `reaction_counts`/`reaction_scores` payloads being discarded at parse time when their score total was zero despite a positive count. +- Fixed reaction groups synthesized from legacy `reaction_counts`/`reaction_scores` payloads being discarded at parse time when their score total was zero or negative despite a positive count. ## 10.2.0 diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index eadc6fc2bb..025e666216 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -172,8 +172,8 @@ class Message extends Equatable implements ComparableFieldProvider { final count = reactionCounts?[type] ?? 0; final sumScores = reactionScores?[type] ?? 0; - // Keep the group while count is positive; score may be zero. - if (count == 0) continue; + // Keep the group while count is positive; score may be zero or negative. + if (count <= 0) continue; final now = DateTime.timestamp(); groups[type] = { 'count': count, @@ -1026,7 +1026,7 @@ extension MessageReactionHelper on Message { final group = reactionGroups.remove(type); if (group == null) continue; - // Keep the group while count is positive; score may be zero. + // Keep the group while count is positive; score may be zero or negative. final updatedCount = group.count - 1; final updatedSumScores = group.sumScores - reaction.score; diff --git a/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart b/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart index 04a8f938f9..de1960eb61 100644 --- a/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart +++ b/packages/stream_chat/test/src/core/models/message_reaction_helper_test.dart @@ -362,13 +362,13 @@ void main() { expect(updatedMessage.reactionGroups, isEmpty); }); - test('should keep reaction group when count remains after sumScores ' - 'reaches zero', () { - // Own reaction with a score that cancels out the group total, leaving - // sumScores at 0 while another user's reaction keeps count positive. + test('should keep reaction group with non-positive score sum while ' + 'count remains positive', () { + // A positively-scored own reaction and a negatively-scored reaction + // from another user net the group score to zero while count is 2. final ownReaction = Reaction( type: 'like', - score: 0, + score: 1, user: testUser, userId: testUser.id, messageId: emptyMessage.id, @@ -377,7 +377,7 @@ void main() { final otherUser = User(id: 'other-user-id'); final otherReaction = Reaction( type: 'like', - score: 0, + score: -1, user: otherUser, userId: otherUser.id, messageId: emptyMessage.id, @@ -399,10 +399,10 @@ void main() { final updatedMessage = messageWithReactions.deleteMyReaction(); // The group must survive because another user's reaction remains, even - // though the summed scores are zero. + // though the remaining summed score is negative. expect(updatedMessage.reactionGroups!.length, 1); expect(updatedMessage.reactionGroups!['like']!.count, 1); - expect(updatedMessage.reactionGroups!['like']!.sumScores, 0); + expect(updatedMessage.reactionGroups!['like']!.sumScores, -1); expect(updatedMessage.latestReactions!.length, 1); expect(updatedMessage.latestReactions!.first.userId, otherUser.id); }); diff --git a/packages/stream_chat/test/src/core/models/message_test.dart b/packages/stream_chat/test/src/core/models/message_test.dart index dac26b3021..e2b8ef14a8 100644 --- a/packages/stream_chat/test/src/core/models/message_test.dart +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -523,19 +523,20 @@ void main() { test( 'synthesizes groups from legacy reaction_counts/reaction_scores ' - 'even when the score total is zero', + 'even when the score total is zero or negative', () { final message = Message.fromJson(const { - 'reaction_counts': {'like': 2}, - 'reaction_scores': {'like': 0}, + 'reaction_counts': {'like': 2, 'dislike': 3}, + 'reaction_scores': {'like': 0, 'dislike': -3}, }); - // The group must be retained because its count is positive, even - // though the summed scores are zero. + // Both groups must be retained because their count is positive, even + // though the summed scores are zero and negative respectively. expect(message.reactionGroups, isNotNull); - expect(message.reactionGroups!.containsKey('like'), isTrue); expect(message.reactionGroups!['like']!.count, 2); expect(message.reactionGroups!['like']!.sumScores, 0); + expect(message.reactionGroups!['dislike']!.count, 3); + expect(message.reactionGroups!['dislike']!.sumScores, -3); }, ); });