diff --git a/docs/docs_screenshots/pubspec.yaml b/docs/docs_screenshots/pubspec.yaml index 399e6daeeb..3f8ca59887 100644 --- a/docs/docs_screenshots/pubspec.yaml +++ b/docs/docs_screenshots/pubspec.yaml @@ -19,7 +19,11 @@ dependencies: sdk: flutter record: ^6.2.0 stream_chat_flutter: ^10.3.0 - stream_core_flutter: ^0.5.0 + 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 49c190529a..dcc14869f6 100644 --- a/melos.yaml +++ b/melos.yaml @@ -104,7 +104,11 @@ 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 + 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/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index e6b9ff690b..e3a987addd 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,5 +1,14 @@ ## 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). + +⚠️ 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 - 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..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 @@ -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, the chips register no long-press + /// gesture, leaving it to an ancestor. + 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..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 @@ -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, @@ -322,10 +325,18 @@ 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, 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. /// /// Receives the [Message] that was quoted. Typically used to scroll to @@ -391,6 +402,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 +429,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, @@ -549,7 +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)), + _ => (reaction) => _showMessageReactionsModal(context, message, initialReaction: reaction), }, ); @@ -717,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/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..9c94b2740d 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -55,7 +55,12 @@ 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: + # 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 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..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 @@ -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,116 @@ 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)}, + ); + + // 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( + builder: (context, child) => StreamChat( + client: client, + connectivityStream: Stream.value(const [ConnectivityResult.mobile]), + 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. 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('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: (_, __) {}, + onMessageLongPress: (_) => messageLongPressed = true, + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress(reactionChip().first); + await tester.pumpAndSettle(); + + expect(messageLongPressed, isFalse); + }); + + 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( + buildScene(onMessageLongPress: (_) => messageLongPressed = true), + ); + await tester.pumpAndSettle(); + + await tester.longPress(reactionChip().first); + await tester.pumpAndSettle(); + + 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); + }); + }); + // 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', () { 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); + }); }