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
6 changes: 5 additions & 1 deletion docs/docs_screenshots/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1084,6 +1090,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
onUserAvatarTap: widget.onUserAvatarTap,
onReactionsTap: widget.onReactionsTap,
onReactionTap: widget.onReactionTap,
onReactionLongPress: widget.onReactionLongPress,
onQuotedMessageTap: widget.onQuotedMessageTap,
onMessageLinkTap: widget.onMessageLinkTap,
onUserMentionTap: widget.onUserMentionTap,
Expand Down Expand Up @@ -1210,6 +1217,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
onUserAvatarTap: widget.onUserAvatarTap,
onReactionsTap: widget.onReactionsTap,
onReactionTap: widget.onReactionTap,
onReactionLongPress: widget.onReactionLongPress,
onMessageLinkTap: widget.onMessageLinkTap,
onUserMentionTap: widget.onUserMentionTap,
onMentionTap: widget.onMentionTap,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ class StreamMessageContent extends StatefulWidget {
this.onMentionTap,
this.onAnyMentionTap,
this.onReactionTap,
this.onReactionLongPress,
this.onQuotedMessageTap,
this.reactionSorting,
});
Expand Down Expand Up @@ -110,6 +111,13 @@ class StreamMessageContent extends StatefulWidget {
/// clustered or overflow chip). If null, tapping reactions has no effect.
final ValueSetter<Reaction?>? 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<Reaction?>? onReactionLongPress;

/// Called when the quoted message is tapped.
///
/// If null, tapping the quoted message has no effect.
Expand Down Expand Up @@ -170,6 +178,7 @@ class _StreamMessageContentState extends State<StreamMessageContent> {
message: widget.message,
sorting: widget.reactionSorting,
onReactionTap: widget.onReactionTap,
onReactionLongPress: widget.onReactionLongPress,
child: Builder(
builder: (context) {
final bubbleContent = ConstrainedBox(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class StreamMessageReactions extends StatelessWidget {
this.position,
this.sorting,
this.onReactionTap,
this.onReactionLongPress,
this.child,
});

Expand Down Expand Up @@ -56,6 +57,16 @@ class StreamMessageReactions extends StatelessWidget {
/// clustered or overflow chip). If null, tapping has no effect.
final ValueSetter<Reaction?>? 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<Reaction?>? onReactionLongPress;

/// The child widget (typically the message bubble) that reactions are
/// displayed on.
final Widget? child;
Expand Down Expand Up @@ -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,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReactionGroup>? reactionSorting,
MessageActionsBuilder? actionsBuilder,
Expand Down Expand Up @@ -116,6 +117,7 @@ class StreamMessageItem extends StatelessWidget {
onReplyTap: onReplyTap,
onReactionsTap: onReactionsTap,
onReactionTap: onReactionTap,
onReactionLongPress: onReactionLongPress,
onQuotedMessageTap: onQuotedMessageTap,
reactionSorting: reactionSorting,
actionsBuilder: actionsBuilder,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ReactionGroup>? reactionSorting,
MessageActionsBuilder? actionsBuilder,
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
24 changes: 24 additions & 0 deletions packages/stream_chat_flutter/lib/src/utils/typedefs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
7 changes: 6 additions & 1 deletion packages/stream_chat_flutter/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () {
Expand Down
Loading
Loading