From b9713e7ac3b5e90b9513c4f56243ad4b770495de Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 17 Aug 2026 17:32:45 +0200 Subject: [PATCH 1/7] feat(ui): add onReactionLongPress reporting the long-pressed reaction Adds a per-reaction long-press callback to StreamMessageItem and StreamMessageListView, reporting the message's BuildContext and a ReactionLongPressDetails so apps can open a reactions sheet. Left null, the chips register no long-press gesture, so the message's own long-press handling (the actions modal) keeps working as before. Points stream_core_flutter at a local path dep for the matching core change; swap back to a git ref before merging. Co-Authored-By: Claude Opus 5 (1M context) --- docs/docs_screenshots/pubspec.yaml | 3 +- melos.yaml | 5 +- packages/stream_chat_flutter/CHANGELOG.md | 4 + .../message_list_view/message_list_view.dart | 8 ++ .../components/stream_message_content.dart | 9 ++ .../components/stream_message_reactions.dart | 15 +++ .../message_widget/stream_message_item.dart | 15 +++ .../lib/src/utils/typedefs.dart | 24 ++++ packages/stream_chat_flutter/pubspec.yaml | 3 +- .../stream_message_reactions_test.dart | 120 ++++++++++++++++++ 10 files changed, 203 insertions(+), 3 deletions(-) diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 399e6daeeb..7822d5b664 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -19,7 +19,8 @@ dependencies: sdk: flutter record: ^6.2.0 stream_chat_flutter: ^10.3.0 - stream_core_flutter: ^0.5.0 + stream_core_flutter: + path: ../../../stream-core-flutter/packages/stream_core_flutter dev_dependencies: alchemist: ^0.14.0 diff --git a/melos.yaml b/melos.yaml index 49c190529a..d96ee9fcf7 100644 --- a/melos.yaml +++ b/melos.yaml @@ -104,7 +104,10 @@ command: stream_chat_persistence: ^10.3.0 streaming_shared_preferences: ^2.0.0 svg_icon_widget: ^0.0.1 - stream_core_flutter: ^0.5.0 + # TODO: Swap back to a git ref once the onReactionLongPressed change is + # merged into stream-core-flutter. + stream_core_flutter: + path: ../../../stream-core-flutter/packages/stream_core_flutter stream_thumbnail: ^0.1.0 synchronized: ^3.4.0 thumblr: ^0.0.4 diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index e6b9ff690b..ba452dd26b 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,5 +1,9 @@ ## Upcoming +✅ Added + +- Added `onReactionLongPress` to `StreamMessageItem` and `StreamMessageListView`, reporting the long-pressed message's `BuildContext` and a `ReactionLongPressDetails` with the `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). When null, long-pressing a reaction keeps falling through to the message actions modal. + 🔄 Changed - Raised minimum Flutter to `>=3.44.0` and Dart SDK to `^3.12.0`. diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index 045ace9524..e0ca7f074e 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -116,6 +116,7 @@ class StreamMessageListView extends StatefulWidget { this.onUserAvatarTap, @Deprecated('Use onReactionTap instead. onReactionTap also reports the tapped reaction.') this.onReactionsTap, this.onReactionTap, + this.onReactionLongPress, this.onQuotedMessageTap, this.onMessageLinkTap, @Deprecated('Use onMentionTap and switch on StreamUserMention instead') this.onUserMentionTap, @@ -194,6 +195,11 @@ class StreamMessageListView extends StatefulWidget { /// Forwarded to each [StreamMessageItem] in the list. final OnReactionTap? onReactionTap; + /// {@macro onReactionLongPress} + /// + /// Forwarded to each [StreamMessageItem] in the list. + final OnReactionLongPress? onReactionLongPress; + /// Called when a quoted message is tapped. /// /// When provided, this callback is forwarded to each @@ -1084,6 +1090,7 @@ class _StreamMessageListViewState extends State { onUserAvatarTap: widget.onUserAvatarTap, onReactionsTap: widget.onReactionsTap, onReactionTap: widget.onReactionTap, + onReactionLongPress: widget.onReactionLongPress, onQuotedMessageTap: widget.onQuotedMessageTap, onMessageLinkTap: widget.onMessageLinkTap, onUserMentionTap: widget.onUserMentionTap, @@ -1210,6 +1217,7 @@ class _StreamMessageListViewState extends State { onUserAvatarTap: widget.onUserAvatarTap, onReactionsTap: widget.onReactionsTap, onReactionTap: widget.onReactionTap, + onReactionLongPress: widget.onReactionLongPress, onMessageLinkTap: widget.onMessageLinkTap, onUserMentionTap: widget.onUserMentionTap, onMentionTap: widget.onMentionTap, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart index b0c2872c57..844d129b88 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart @@ -41,6 +41,7 @@ class StreamMessageContent extends StatefulWidget { this.onMentionTap, this.onAnyMentionTap, this.onReactionTap, + this.onReactionLongPress, this.onQuotedMessageTap, this.reactionSorting, }); @@ -110,6 +111,13 @@ class StreamMessageContent extends StatefulWidget { /// clustered or overflow chip). If null, tapping reactions has no effect. final ValueSetter? onReactionTap; + /// Called when a reaction chip is long-pressed, with the pressed [Reaction]. + /// + /// Reports `null` when the long press does not map to a single reaction (a + /// clustered or overflow chip). If null, long-pressing reactions falls + /// through to the enclosing message's long-press handler. + final ValueSetter? onReactionLongPress; + /// Called when the quoted message is tapped. /// /// If null, tapping the quoted message has no effect. @@ -170,6 +178,7 @@ class _StreamMessageContentState extends State { message: widget.message, sorting: widget.reactionSorting, onReactionTap: widget.onReactionTap, + onReactionLongPress: widget.onReactionLongPress, child: Builder( builder: (context) { final bubbleContent = ConstrainedBox( diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart index b31d60345c..6bd8439f10 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_reactions.dart @@ -25,6 +25,7 @@ class StreamMessageReactions extends StatelessWidget { this.position, this.sorting, this.onReactionTap, + this.onReactionLongPress, this.child, }); @@ -56,6 +57,16 @@ class StreamMessageReactions extends StatelessWidget { /// clustered or overflow chip). If null, tapping has no effect. final ValueSetter? onReactionTap; + /// Called when a reaction chip is long-pressed, with the pressed [Reaction]. + /// + /// Reports `null` when the long press does not map to a single reaction (a + /// clustered or overflow chip). If null, the chips register no long-press + /// gesture, leaving it to an ancestor. + /// + /// Only fires while [onReactionTap] is also set, since a chip without a tap + /// callback is disabled. + final ValueSetter? onReactionLongPress; + /// The child widget (typically the message bubble) that reactions are /// displayed on. final Widget? child; @@ -105,6 +116,10 @@ class StreamMessageReactions extends StatelessWidget { final onTap? => (item) => onTap(reactionOf(item)), _ => null, }, + onReactionLongPressed: switch (onReactionLongPress) { + final onLongPress? => (item) => onLongPress(reactionOf(item)), + _ => null, + }, items: [...?items], child: child, ); diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart index 17a6c4487d..24ba93ef28 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart @@ -86,6 +86,7 @@ class StreamMessageItem extends StatelessWidget { @Deprecated('Use onReactionTap instead. onReactionTap also reports the tapped reaction.') void Function(Message)? onReactionsTap, OnReactionTap? onReactionTap, + OnReactionLongPress? onReactionLongPress, void Function(Message quotedMessage)? onQuotedMessageTap, Comparator? reactionSorting, MessageActionsBuilder? actionsBuilder, @@ -116,6 +117,7 @@ class StreamMessageItem extends StatelessWidget { onReplyTap: onReplyTap, onReactionsTap: onReactionsTap, onReactionTap: onReactionTap, + onReactionLongPress: onReactionLongPress, onQuotedMessageTap: onQuotedMessageTap, reactionSorting: reactionSorting, actionsBuilder: actionsBuilder, @@ -173,6 +175,7 @@ class StreamMessageItemProps { this.onReplyTap, @Deprecated('Use onReactionTap instead. onReactionTap also reports the tapped reaction.') this.onReactionsTap, this.onReactionTap, + this.onReactionLongPress, this.onQuotedMessageTap, this.reactionSorting, this.actionsBuilder, @@ -326,6 +329,12 @@ class StreamMessageItemProps { /// the full list of reactions. final OnReactionTap? onReactionTap; + /// {@macro onReactionLongPress} + /// + /// If null, long-pressing a reaction falls through to the message's own + /// long-press handling, which opens the [StreamMessageActionsModal]. + final OnReactionLongPress? onReactionLongPress; + /// Called when an inline quoted message is tapped. /// /// Receives the [Message] that was quoted. Typically used to scroll to @@ -391,6 +400,7 @@ class StreamMessageItemProps { @Deprecated('Use onReactionTap instead. onReactionTap also reports the tapped reaction.') void Function(Message)? onReactionsTap, OnReactionTap? onReactionTap, + OnReactionLongPress? onReactionLongPress, void Function(Message)? onQuotedMessageTap, Comparator? reactionSorting, MessageActionsBuilder? actionsBuilder, @@ -417,6 +427,7 @@ class StreamMessageItemProps { onReplyTap: onReplyTap ?? this.onReplyTap, onReactionsTap: onReactionsTap ?? this.onReactionsTap, onReactionTap: onReactionTap ?? this.onReactionTap, + onReactionLongPress: onReactionLongPress ?? this.onReactionLongPress, onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, reactionSorting: reactionSorting ?? this.reactionSorting, actionsBuilder: actionsBuilder ?? this.actionsBuilder, @@ -551,6 +562,10 @@ class DefaultStreamMessageItem extends StatelessWidget { (_, final onReactionsTap?) => (_) => onReactionsTap(message), _ => (_) => _showMessageReactionsModal(context, message), }, + onReactionLongPress: switch (props.onReactionLongPress) { + final onLongPress? => (reaction) => onLongPress(context, .new(message: message, reaction: reaction)), + _ => null, + }, ); Widget result = Material( diff --git a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart index 709fd9a549..06e789bd34 100644 --- a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart +++ b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart @@ -214,6 +214,30 @@ class ReactionTapDetails { final Reaction? reaction; } +/// {@template onReactionLongPress} +/// The action to perform when a message's reaction is long-pressed. +/// +/// The [BuildContext] is the context at the long-pressed message, useful for +/// navigation or showing overlays relative to it. +/// {@endtemplate} +typedef OnReactionLongPress = void Function(BuildContext context, ReactionLongPressDetails details); + +/// Details of a reaction long press, passed to [OnReactionLongPress]. +@immutable +class ReactionLongPressDetails { + /// Creates details for a reaction long press. + const ReactionLongPressDetails({required this.message, required this.reaction}); + + /// The message whose reaction was long-pressed. + final Message message; + + /// The long-pressed reaction. + /// + /// `null` when the long press does not map to a single reaction (for example + /// a clustered or overflow chip). + final Reaction? reaction; +} + /// {@template onReactionsHover} /// The action to perform when a message's reactions are hovered. /// {@endtemplate} diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 0fb8e03106..7c464b0e27 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -55,7 +55,8 @@ dependencies: share_plus: ">=12.0.2 <14.0.0" shimmer: ^3.0.0 stream_chat_flutter_core: ^10.3.0 - stream_core_flutter: ^0.5.0 + stream_core_flutter: + path: ../../../stream-core-flutter/packages/stream_core_flutter stream_thumbnail: ^0.1.0 svg_icon_widget: ^0.0.1 synchronized: ^3.4.0 diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_reactions_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_reactions_test.dart index 1274f17bad..ebfc585380 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_reactions_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_reactions_test.dart @@ -9,6 +9,7 @@ void main() { required Message message, StreamReactionsType? type, ValueChanged? onReactionTap, + ValueChanged? onReactionLongPress, }) { return tester.pumpWidget( MaterialApp( @@ -22,6 +23,7 @@ void main() { message: message, type: type, onReactionTap: onReactionTap, + onReactionLongPress: onReactionLongPress, ), ), ), @@ -127,4 +129,122 @@ void main() { expect(called, isTrue); expect(tapped, isNull); }); + + testWidgets('segmented: long-pressing a chip reports its reaction', (tester) async { + Reaction? longPressed; + final message = Message( + reactionGroups: {'love': ReactionGroup(count: 2)}, + ); + + await pumpReactions( + tester, + message: message, + type: StreamReactionsType.segmented, + onReactionTap: (_) {}, + onReactionLongPress: (reaction) => longPressed = reaction, + ); + + await tester.longPress(find.byType(IconButton).first); + expect(longPressed?.type, 'love'); + }); + + testWidgets('segmented: long press reports the full own reaction when present', (tester) async { + Reaction? longPressed; + final message = Message( + reactionGroups: {'love': ReactionGroup(count: 1)}, + ownReactions: [ + Reaction( + type: 'love', + user: User(id: 'u1'), + ), + ], + ); + + await pumpReactions( + tester, + message: message, + type: StreamReactionsType.segmented, + onReactionTap: (_) {}, + onReactionLongPress: (reaction) => longPressed = reaction, + ); + + await tester.longPress(find.byType(IconButton).first); + // The user's own reaction is reported with its full data, not a template. + expect(longPressed?.type, 'love'); + expect(longPressed?.user?.id, 'u1'); + }); + + testWidgets('segmented: long-pressing the overflow chip reports null', (tester) async { + var called = false; + Reaction? longPressed; + // More groups than the visible segment limit (4) so an overflow chip shows. + final message = Message( + reactionGroups: { + 'like': ReactionGroup(count: 1), + 'love': ReactionGroup(count: 1), + 'haha': ReactionGroup(count: 1), + 'wow': ReactionGroup(count: 1), + 'sad': ReactionGroup(count: 1), + }, + ); + + await pumpReactions( + tester, + message: message, + type: StreamReactionsType.segmented, + onReactionTap: (_) {}, + onReactionLongPress: (reaction) { + called = true; + longPressed = reaction; + }, + ); + + // The overflow "+N" chip is the trailing chip; it maps to no single reaction. + await tester.longPress(find.byType(IconButton).last); + expect(called, isTrue); + expect(longPressed, isNull); + }); + + testWidgets('clustered: long-pressing the grouped chip reports null', (tester) async { + var called = false; + Reaction? longPressed; + final message = Message( + reactionGroups: { + 'love': ReactionGroup(count: 2), + 'like': ReactionGroup(count: 1), + }, + ); + + await pumpReactions( + tester, + message: message, + type: StreamReactionsType.clustered, + onReactionTap: (_) {}, + onReactionLongPress: (reaction) { + called = true; + longPressed = reaction; + }, + ); + + await tester.longPress(find.byType(IconButton).first); + expect(called, isTrue); + expect(longPressed, isNull); + }); + + testWidgets('registers no long-press gesture when onReactionLongPress is null', (tester) async { + final message = Message( + reactionGroups: {'love': ReactionGroup(count: 2)}, + ); + + await pumpReactions( + tester, + message: message, + type: StreamReactionsType.segmented, + onReactionTap: (_) {}, + ); + + // Left null so the gesture falls through to the enclosing message. + final button = tester.widget(find.byType(IconButton).first); + expect(button.onLongPress, isNull); + }); } From 50bd90bc347cc04d81e93db4dc06c80d5e0aab17 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 17 Aug 2026 17:38:56 +0200 Subject: [PATCH 2/7] test(ui): pin the reaction long-press gesture arena outcome The chips sit inside the message row's own long-press InkWell, so both recognizers compete. Assert the chip wins when onReactionLongPress is set, and that the message long press still fires when it is not. Co-Authored-By: Claude Opus 5 (1M context) --- .../stream_message_item_test.dart | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart index 32e5199b30..abfcdd933d 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/message_widget/components/stream_message_reactions.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../mocks.dart'; @@ -103,6 +104,95 @@ void main() { }); }); + group('StreamMessageItem reaction long press', () { + final currentUser = OwnUser(id: 'current-user'); + final otherUser = User(id: 'other-user'); + + Widget buildScene({ + OnReactionLongPress? onReactionLongPress, + void Function(Message)? onMessageLongPress, + }) { + final client = MockClient(); + final clientState = MockClientState(); + final channel = MockChannel(); + final channelState = MockChannelState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(currentUser); + when(() => clientState.currentUserStream).thenAnswer((_) => Stream.value(currentUser)); + when(() => channel.client).thenReturn(client); + when(() => channel.state).thenReturn(channelState); + + final message = Message( + id: 'test-message', + text: 'Parent message', + createdAt: DateTime(2026), + user: otherUser, + state: MessageState.sent, + reactionGroups: {'love': ReactionGroup(count: 2)}, + ); + + return MaterialApp( + localizationsDelegates: const [_FakeLocalizationsDelegate()], + home: StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + child: StreamChannel( + channel: channel, + child: Scaffold( + body: StreamMessageItem( + message: message, + onReactionLongPress: onReactionLongPress, + onMessageLongPress: onMessageLongPress, + ), + ), + ), + ), + ); + } + + // The reaction chips sit inside the message row's own long-press InkWell, + // so both recognizers enter the same gesture arena. + Finder reactionChip() => find.descendant( + of: find.byType(StreamMessageReactions), + matching: find.byType(IconButton), + ); + + testWidgets('the chip wins over the message long press', (tester) async { + Reaction? longPressed; + var messageLongPressed = false; + + await tester.pumpWidget( + buildScene( + onReactionLongPress: (_, details) => longPressed = details.reaction, + onMessageLongPress: (_) => messageLongPressed = true, + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress(reactionChip().first); + await tester.pumpAndSettle(); + + expect(longPressed?.type, 'love'); + expect(messageLongPressed, isFalse); + }); + + testWidgets('the message long press still fires when onReactionLongPress is null', (tester) async { + var messageLongPressed = false; + + await tester.pumpWidget( + buildScene(onMessageLongPress: (_) => messageLongPressed = true), + ); + await tester.pumpAndSettle(); + + // No chip-level recognizer is registered, so the gesture falls through. + await tester.longPress(reactionChip().first); + await tester.pumpAndSettle(); + + expect(messageLongPressed, isTrue); + }); + }); + // The widget tests above deliberately never see the shipped strings, so pin // the default table's pluralization here. test('DefaultTranslations pluralizes the thread reply count', () { From 98c57d475aed00ebe5d0a03652f72b9d5f7de1c9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 17 Aug 2026 17:43:34 +0200 Subject: [PATCH 3/7] chore(deps): point stream_core_flutter at the core PR ref Swaps the local path dep for the commit ref on the core branch so CI can resolve it. Re-point at a merged SHA once GetStream/stream-core-flutter#153 lands. Co-Authored-By: Claude Opus 5 (1M context) --- docs/docs_screenshots/pubspec.yaml | 5 ++++- melos.yaml | 7 ++++--- packages/stream_chat_flutter/pubspec.yaml | 6 +++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 7822d5b664..3f8ca59887 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -20,7 +20,10 @@ dependencies: record: ^6.2.0 stream_chat_flutter: ^10.3.0 stream_core_flutter: - path: ../../../stream-core-flutter/packages/stream_core_flutter + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 91a16b2f86b88b497f3de6d9505ddd8e64db7908 + path: packages/stream_core_flutter dev_dependencies: alchemist: ^0.14.0 diff --git a/melos.yaml b/melos.yaml index d96ee9fcf7..dcc14869f6 100644 --- a/melos.yaml +++ b/melos.yaml @@ -104,10 +104,11 @@ command: stream_chat_persistence: ^10.3.0 streaming_shared_preferences: ^2.0.0 svg_icon_widget: ^0.0.1 - # TODO: Swap back to a git ref once the onReactionLongPressed change is - # merged into stream-core-flutter. stream_core_flutter: - path: ../../../stream-core-flutter/packages/stream_core_flutter + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 91a16b2f86b88b497f3de6d9505ddd8e64db7908 + path: packages/stream_core_flutter stream_thumbnail: ^0.1.0 synchronized: ^3.4.0 thumblr: ^0.0.4 diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 7c464b0e27..9c94b2740d 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -56,7 +56,11 @@ dependencies: shimmer: ^3.0.0 stream_chat_flutter_core: ^10.3.0 stream_core_flutter: - path: ../../../stream-core-flutter/packages/stream_core_flutter + # ignore: invalid_dependency + git: + url: https://github.com/GetStream/stream-core-flutter.git + ref: 91a16b2f86b88b497f3de6d9505ddd8e64db7908 + path: packages/stream_core_flutter stream_thumbnail: ^0.1.0 svg_icon_widget: ^0.0.1 synchronized: ^3.4.0 From d7606876c5968e07edbc12fa6de6772b6d5b73c7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 18 Aug 2026 13:23:03 +0200 Subject: [PATCH 4/7] feat(ui): open the reaction detail sheet on reaction tap and long press The chips now always claim the long press and open the detail sheet pre-filtered to that reaction, rather than falling through to the message actions modal. Tap gets the same pre-filtering. Rewrites the long-press tests around the new contract and documents the behaviour change in the changelog. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 7 ++- .../components/stream_message_content.dart | 4 +- .../message_widget/stream_message_item.dart | 20 ++++--- .../stream_message_item_test.dart | 57 +++++++++++++------ 4 files changed, 59 insertions(+), 29 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index ba452dd26b..e3a987addd 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -2,7 +2,12 @@ ✅ Added -- Added `onReactionLongPress` to `StreamMessageItem` and `StreamMessageListView`, reporting the long-pressed message's `BuildContext` and a `ReactionLongPressDetails` with the `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). When null, long-pressing a reaction keeps falling through to the message actions modal. +- Added `onReactionLongPress` to `StreamMessageItem` and `StreamMessageListView`, reporting the long-pressed message's `BuildContext` and a `ReactionLongPressDetails` with the `message` and `reaction` (the reaction is `null` for a clustered or overflow chip that maps to no single reaction). + +⚠️ Changed + +- Long-pressing a reaction chip now opens the `ReactionDetailSheet` instead of the message actions modal. The chips always claim the long press, so this applies whether or not `onReactionLongPress` is set. +- Tapping or long-pressing a reaction chip now opens the `ReactionDetailSheet` pre-filtered to that reaction; it previously opened unfiltered. 🔄 Changed diff --git a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart index 844d129b88..b397debf9e 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/components/stream_message_content.dart @@ -114,8 +114,8 @@ class StreamMessageContent extends StatefulWidget { /// Called when a reaction chip is long-pressed, with the pressed [Reaction]. /// /// Reports `null` when the long press does not map to a single reaction (a - /// clustered or overflow chip). If null, long-pressing reactions falls - /// through to the enclosing message's long-press handler. + /// clustered or overflow chip). If null, the chips register no long-press + /// gesture, leaving it to an ancestor. final ValueSetter? onReactionLongPress; /// Called when the quoted message is tapped. diff --git a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart index 24ba93ef28..58429371b6 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/stream_message_item.dart @@ -325,14 +325,16 @@ class StreamMessageItemProps { /// {@macro onReactionTap} /// - /// If null, the default behaviour opens a [ReactionDetailSheet] showing - /// the full list of reactions. + /// If null, the default behaviour opens a [ReactionDetailSheet] pre-filtered + /// to the tapped reaction. final OnReactionTap? onReactionTap; /// {@macro onReactionLongPress} /// - /// If null, long-pressing a reaction falls through to the message's own - /// long-press handling, which opens the [StreamMessageActionsModal]. + /// If null, the default behaviour matches [onReactionTap] and opens a + /// [ReactionDetailSheet] pre-filtered to the long-pressed reaction. The + /// chips always claim the long press, so it never reaches the message's own + /// long-press handling. final OnReactionLongPress? onReactionLongPress; /// Called when an inline quoted message is tapped. @@ -560,11 +562,11 @@ class DefaultStreamMessageItem extends StatelessWidget { onReactionTap: switch ((props.onReactionTap, props.onReactionsTap)) { (final onReactionTap?, _) => (reaction) => onReactionTap(context, .new(message: message, reaction: reaction)), (_, final onReactionsTap?) => (_) => onReactionsTap(message), - _ => (_) => _showMessageReactionsModal(context, message), + _ => (reaction) => _showMessageReactionsModal(context, message, initialReaction: reaction), }, onReactionLongPress: switch (props.onReactionLongPress) { final onLongPress? => (reaction) => onLongPress(context, .new(message: message, reaction: reaction)), - _ => null, + _ => (reaction) => _showMessageReactionsModal(context, message, initialReaction: reaction), }, ); @@ -732,13 +734,15 @@ class DefaultStreamMessageItem extends StatelessWidget { // Opens the reaction detail sheet and handles the returned action. Future _showMessageReactionsModal( BuildContext context, - Message message, - ) async { + Message message, { + Reaction? initialReaction, + }) async { final channel = StreamChannel.of(context).channel; final action = await ReactionDetailSheet.show( context: context, message: message, + initialReactionType: initialReaction?.type, ); if (action is! MessageAction) return; diff --git a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart index abfcdd933d..5c1753408e 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/stream_message_item_test.dart @@ -132,39 +132,53 @@ void main() { reactionGroups: {'love': ReactionGroup(count: 2)}, ); + // Wrapping above the navigator keeps pushed routes — the reaction detail + // sheet — under StreamChat and StreamChannel. The sheet renders shipped + // strings, so this group uses the real translations. return MaterialApp( - localizationsDelegates: const [_FakeLocalizationsDelegate()], - home: StreamChat( + builder: (context, child) => StreamChat( client: client, connectivityStream: Stream.value(const [ConnectivityResult.mobile]), - child: StreamChannel( - channel: channel, - child: Scaffold( - body: StreamMessageItem( - message: message, - onReactionLongPress: onReactionLongPress, - onMessageLongPress: onMessageLongPress, - ), - ), + child: StreamChannel(channel: channel, child: child!), + ), + home: Scaffold( + body: StreamMessageItem( + message: message, + onReactionLongPress: onReactionLongPress, + onMessageLongPress: onMessageLongPress, ), ), ); } // The reaction chips sit inside the message row's own long-press InkWell, - // so both recognizers enter the same gesture arena. + // so both recognizers enter the same gesture arena. The chip always + // registers one, so it always wins over the message's own long press. Finder reactionChip() => find.descendant( of: find.byType(StreamMessageReactions), matching: find.byType(IconButton), ); - testWidgets('the chip wins over the message long press', (tester) async { + testWidgets('reports the long-pressed reaction', (tester) async { Reaction? longPressed; + + await tester.pumpWidget( + buildScene(onReactionLongPress: (_, details) => longPressed = details.reaction), + ); + await tester.pumpAndSettle(); + + await tester.longPress(reactionChip().first); + await tester.pumpAndSettle(); + + expect(longPressed?.type, 'love'); + }); + + testWidgets('takes precedence over the message long press', (tester) async { var messageLongPressed = false; await tester.pumpWidget( buildScene( - onReactionLongPress: (_, details) => longPressed = details.reaction, + onReactionLongPress: (_, __) {}, onMessageLongPress: (_) => messageLongPressed = true, ), ); @@ -173,11 +187,15 @@ void main() { await tester.longPress(reactionChip().first); await tester.pumpAndSettle(); - expect(longPressed?.type, 'love'); expect(messageLongPressed, isFalse); }); - testWidgets('the message long press still fires when onReactionLongPress is null', (tester) async { + testWidgets('opens the detail sheet filtered to the reaction by default', (tester) async { + // The sheet needs more height than the default test surface. + tester.view.physicalSize = const Size(1200, 2000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + var messageLongPressed = false; await tester.pumpWidget( @@ -185,11 +203,14 @@ void main() { ); await tester.pumpAndSettle(); - // No chip-level recognizer is registered, so the gesture falls through. await tester.longPress(reactionChip().first); await tester.pumpAndSettle(); - expect(messageLongPressed, isTrue); + final sheet = tester.widget(find.byType(ReactionDetailSheet)); + expect(sheet.initialReactionType, 'love'); + // The default pre-empts the message's long press rather than falling + // through to the actions modal. + expect(messageLongPressed, isFalse); }); }); From 562698b09dfb2cdd3f1ce6727939218b0b0cca08 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 18 Aug 2026 14:22:28 +0200 Subject: [PATCH 5/7] docs(ui): correct the reaction sheet changelog entries Pre-filtering only applies to chips mapping to a single reaction; clustered and overflow chips report null and open unfiltered. Also disambiguates that the chips claiming the long press is what is unconditional, not the sheet. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_chat_flutter/CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index e3a987addd..57a682cfe6 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -6,8 +6,8 @@ ⚠️ Changed -- Long-pressing a reaction chip now opens the `ReactionDetailSheet` instead of the message actions modal. The chips always claim the long press, so this applies whether or not `onReactionLongPress` is set. -- Tapping or long-pressing a reaction chip now opens the `ReactionDetailSheet` pre-filtered to that reaction; it previously opened unfiltered. +- Long-pressing a reaction chip no longer opens the message actions modal; the chips always claim the long press. Left unset, `onReactionLongPress` defaults to opening the `ReactionDetailSheet`. +- Tapping or long-pressing a reaction chip now opens the `ReactionDetailSheet` pre-filtered to that reaction; it previously opened unfiltered. Clustered and overflow chips map to no single reaction, so they still open unfiltered. 🔄 Changed From 1b67307d214637db6546dbc285d4a571238915a7 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 18 Aug 2026 14:31:58 +0200 Subject: [PATCH 6/7] chore(deps): point stream_core_flutter at the merged core ref GetStream/stream-core-flutter#153 landed on main as 4b7ed86, so the pin moves off the PR branch commit. Co-Authored-By: Claude Opus 5 (1M context) --- docs/docs_screenshots/pubspec.yaml | 2 +- melos.yaml | 2 +- packages/stream_chat_flutter/pubspec.yaml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 3f8ca59887..1766a3b40b 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 91a16b2f86b88b497f3de6d9505ddd8e64db7908 + ref: 4b7ed86a52c3109fad0a6f5118120082e378c610 path: packages/stream_core_flutter dev_dependencies: diff --git a/melos.yaml b/melos.yaml index dcc14869f6..4172629cd9 100644 --- a/melos.yaml +++ b/melos.yaml @@ -107,7 +107,7 @@ command: stream_core_flutter: git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 91a16b2f86b88b497f3de6d9505ddd8e64db7908 + ref: 4b7ed86a52c3109fad0a6f5118120082e378c610 path: packages/stream_core_flutter stream_thumbnail: ^0.1.0 synchronized: ^3.4.0 diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 9c94b2740d..dcb6d1bc51 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -59,7 +59,7 @@ dependencies: # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git - ref: 91a16b2f86b88b497f3de6d9505ddd8e64db7908 + ref: 4b7ed86a52c3109fad0a6f5118120082e378c610 path: packages/stream_core_flutter stream_thumbnail: ^0.1.0 svg_icon_widget: ^0.0.1 From 8578618b851b521afca76041b326ac3e4ec8f69c Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 18 Aug 2026 14:34:50 +0200 Subject: [PATCH 7/7] chore(deps): restore the git-dep note on stream_core_flutter The release warning was lost when the dependency went back to a pub constraint; it matters more now that the pin is a git ref again. Co-Authored-By: Claude Opus 5 (1M context) --- packages/stream_chat_flutter/pubspec.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index dcb6d1bc51..bba0f2a0f3 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -56,6 +56,13 @@ dependencies: shimmer: ^3.0.0 stream_chat_flutter_core: ^10.3.0 stream_core_flutter: + # The ignore below silences `invalid_dependency` because we occasionally + # pin stream_core_flutter to a git ref to iterate on it alongside this + # SDK between its releases. + # + # **Note:** Before publishing stream_chat_flutter, this MUST be swapped + # back to a pub version constraint — git deps are not allowed on pub.dev + # and will block the release. # ignore: invalid_dependency git: url: https://github.com/GetStream/stream-core-flutter.git