Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
58 changes: 58 additions & 0 deletions migrations/v10-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This guide includes breaking changes grouped by release phase:
### 🚧 Upcoming Beta

- [onAttachmentTap](#-onattachmenttap)
- [ReactionPickerIconList](#-reactionpickericonlist)

### 🚧 v10.0.0-beta.8

Expand Down Expand Up @@ -115,6 +116,62 @@ StreamMessageWidget(

---

### 🛠 ReactionPickerIconList

#### Key Changes:

- `message` parameter has been removed
- `reactionIcons` type changed from `List<StreamReactionIcon>` to `List<ReactionPickerIcon>`
- `onReactionPicked` callback renamed to `onIconPicked` with new signature: `ValueSetter<ReactionPickerIcon>`
- `iconBuilder` parameter changed from default value to nullable with internal fallback
- Message-specific logic (checking for own reactions) moved to parent widget

#### Migration Steps:

**Before:**
```dart
ReactionPickerIconList(
message: message,
reactionIcons: icons,
onReactionPicked: (reaction) {
// Handle reaction
channel.sendReaction(message, reaction);
},
)
```

**After:**
```dart
// Map StreamReactionIcon to ReactionPickerIcon with selection state
final ownReactions = [...?message.ownReactions];
final ownReactionsMap = {for (final it in ownReactions) it.type: it};

final pickerIcons = icons.map((icon) {
return ReactionPickerIcon(
type: icon.type,
builder: icon.builder,
isSelected: ownReactionsMap[icon.type] != null,
);
}).toList();

ReactionPickerIconList(
reactionIcons: pickerIcons,
onIconPicked: (pickerIcon) {
final reaction = ownReactionsMap[pickerIcon.type] ??
Reaction(type: pickerIcon.type);
// Handle reaction
channel.sendReaction(message, reaction);
},
)
```

> ⚠️ **Important:**
> - This is typically an internal widget used by `StreamReactionPicker`
> - If you were using it directly, you now need to handle reaction selection state externally
> - Use `StreamReactionPicker` for most use cases instead of `ReactionPickerIconList`

---

## 🧪 Migration for v10.0.0-beta.8

### 🛠 customAttachmentPickerOptions
Expand Down Expand Up @@ -831,6 +888,7 @@ StreamMessageWidget(
- ✅ Update `onAttachmentTap` callback signature to include `BuildContext` as first parameter
- ✅ Return `FutureOr<bool>` from `onAttachmentTap` - `true` if handled, `false` for default behavior
- ✅ Leverage automatic fallback to default handling for standard attachment types (images, videos, URLs)
- ✅ Update any direct usage of `ReactionPickerIconList` to handle reaction selection state externally

### For v10.0.0-beta.8:
- ✅ Replace `customAttachmentPickerOptions` with `attachmentPickerOptionsBuilder` to access and modify default options
Expand Down
39 changes: 38 additions & 1 deletion packages/stream_chat_flutter/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,42 @@
## Upcoming Beta

✅ Added

- Added `reactionIndicatorBuilder` parameter to `StreamMessageWidget` for customizing reaction
indicators. Users can now display reaction counts alongside emojis on mobile, matching desktop/web
behavior. Fixes [#2434](https://github.com/GetStream/stream-chat-flutter/issues/2434).
```dart
// Example: Show reaction count next to emoji
StreamMessageWidget(
message: message,
reactionIndicatorBuilder: (context, message, onTap) {
return StreamReactionIndicator(
message: message,
onTap: onTap,
reactionIcons: StreamChatConfiguration.of(context).reactionIcons,
reactionIconBuilder: (context, icon) {
final count = message.reactionGroups?[icon.type]?.count ?? 0;
return Row(
children: [
icon.build(context),
const SizedBox(width: 4),
Text('$count'),
],
);
},
);
},
)
```

- Added `reactionIconBuilder` and `backgroundColor` parameters to `StreamReactionPicker`.
- Exported `StreamReactionIndicator` and related components (`ReactionIndicatorBuilder`,
`ReactionIndicatorIconBuilder`, `ReactionIndicatorIcon`, `ReactionIndicatorIconList`).

🛑️ Breaking

- `onAttachmentTap` callback signature has changed to support custom attachment handling with automatic fallback to default behavior. The callback now receives `BuildContext` as the first parameter and returns `FutureOr<bool>` to indicate if the attachment was handled.
- `onAttachmentTap` callback signature changed to include `BuildContext` as first parameter and
returns `FutureOr<bool>` to indicate if handled.
```dart
// Before
StreamMessageWidget(
Expand All @@ -29,6 +63,9 @@
)
```

- `ReactionPickerIconList` constructor changed: removed `message` parameter, changed `reactionIcons`
type to `List<ReactionPickerIcon>`, renamed `onReactionPicked` to `onIconPicked`.

For more details, please refer to the [migration guide](../../migrations/v10-migration.md).

## 10.0.0-beta.8
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ class StreamMessageWidget extends StatefulWidget {
this.imageAttachmentThumbnailCropType = 'center',
this.attachmentActionsModalBuilder,
this.reactionPickerBuilder = StreamReactionPicker.builder,
this.reactionIndicatorBuilder = StreamReactionIndicator.builder,
});

/// {@template onMentionTap}
Expand Down Expand Up @@ -386,6 +387,9 @@ class StreamMessageWidget extends StatefulWidget {
/// {@macro reactionPickerBuilder}
final ReactionPickerBuilder reactionPickerBuilder;

/// {@macro reactionIndicatorBuilder}
final ReactionIndicatorBuilder reactionIndicatorBuilder;

/// Size of the image attachment thumbnail.
final Size imageAttachmentThumbnailSize;

Expand Down Expand Up @@ -469,6 +473,7 @@ class StreamMessageWidget extends StatefulWidget {
String? imageAttachmentThumbnailCropType,
AttachmentActionsBuilder? attachmentActionsModalBuilder,
ReactionPickerBuilder? reactionPickerBuilder,
ReactionIndicatorBuilder? reactionIndicatorBuilder,
}) {
return StreamMessageWidget(
key: key ?? this.key,
Expand Down Expand Up @@ -545,6 +550,8 @@ class StreamMessageWidget extends StatefulWidget {
attachmentActionsModalBuilder ?? this.attachmentActionsModalBuilder,
reactionPickerBuilder:
reactionPickerBuilder ?? this.reactionPickerBuilder,
reactionIndicatorBuilder:
reactionIndicatorBuilder ?? this.reactionIndicatorBuilder,
);
}

Expand Down Expand Up @@ -770,6 +777,7 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
widget.bottomRowBuilderWithDefaultWidget,
onUserAvatarTap: widget.onUserAvatarTap,
userAvatarBuilder: widget.userAvatarBuilder,
reactionIndicatorBuilder: widget.reactionIndicatorBuilder,
),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class MessageWidgetContent extends StatelessWidget {
required this.showEditedLabel,
required this.messageWidget,
required this.onThreadTap,
required this.reactionIndicatorBuilder,
this.onUserAvatarTap,
this.borderRadiusGeometry,
this.borderSide,
Expand Down Expand Up @@ -224,6 +225,9 @@ class MessageWidgetContent extends StatelessWidget {
/// {@macro userAvatarBuilder}
final Widget Function(BuildContext, User)? userAvatarBuilder;

/// {@macro reactionIndicatorBuilder}
final ReactionIndicatorBuilder reactionIndicatorBuilder;

@override
Widget build(BuildContext context) {
return Column(
Expand Down Expand Up @@ -273,6 +277,7 @@ class MessageWidgetContent extends StatelessWidget {
onTap: onReactionsTap,
visible: isMobileDevice && showReactions,
anchorOffset: const Offset(0, 36),
reactionIndicatorBuilder: reactionIndicatorBuilder,
childSizeDelta: switch (showUserAvatar) {
DisplayWidget.gone => Offset.zero,
// Size adjustment for the user avatar
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,25 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/reactions/indicator/reaction_indicator_icon_list.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

/// {@template reactionIndicatorBuilder}
/// Signature for a function that builds a custom reaction indicator widget.
///
/// This allows users to customize how reactions are displayed on messages,
/// including showing reaction counts alongside emojis.
///
/// Parameters:
/// - [context]: The build context.
/// - [message]: The message containing the reactions to display.
/// - [onTap]: An optional callback triggered when the reaction indicator
/// is tapped.
/// {@endtemplate}
typedef ReactionIndicatorBuilder = Widget Function(
BuildContext context,
Message message,
VoidCallback? onTap,
);

/// {@template streamReactionIndicator}
/// A widget that displays a horizontal list of reaction icons that users have
/// reacted with on a message.
Expand All @@ -17,33 +34,71 @@ class StreamReactionIndicator extends StatelessWidget {
super.key,
this.onTap,
required this.message,
required this.reactionIcons,
this.reactionIconBuilder,
this.backgroundColor,
this.padding = const EdgeInsets.all(8),
this.scrollable = true,
this.borderRadius = const BorderRadius.all(Radius.circular(26)),
this.reactionSorting = ReactionSorting.byFirstReactionAt,
});

/// Message to attach the reaction to.
final Message message;
/// Creates a [StreamReactionIndicator] using the default reaction icons
/// provided by the [StreamChatConfiguration].
///
/// This is the recommended way to create a reaction indicator
/// as it ensures that the icons are consistent with the rest of the app.
///
/// The [onTap] callback is optional and can be used to handle
/// when the reaction indicator is tapped.
factory StreamReactionIndicator.builder(
BuildContext context,
Message message,
VoidCallback? onTap,
) {
final config = StreamChatConfiguration.of(context);
final reactionIcons = config.reactionIcons;

final currentUser = StreamChat.maybeOf(context)?.currentUser;
final isMyMessage = message.user?.id == currentUser?.id;

final theme = StreamChatTheme.of(context);
final messageTheme = theme.getMessageTheme(reverse: isMyMessage);

return StreamReactionIndicator(
onTap: onTap,
message: message,
reactionIcons: reactionIcons,
backgroundColor: messageTheme.reactionsBackgroundColor,
);
}

/// Callback triggered when the reaction indicator is tapped.
final VoidCallback? onTap;

/// Message to attach the reaction to.
final Message message;

/// The list of available reaction icons.
final List<StreamReactionIcon> reactionIcons;

/// Optional custom builder for reaction indicator icons.
final ReactionIndicatorIconBuilder? reactionIconBuilder;

/// Background color for the reaction indicator.
final Color? backgroundColor;

/// Padding around the reaction picker.
/// Padding around the reaction indicator.
///
/// Defaults to `EdgeInsets.all(8)`.
final EdgeInsets padding;

/// Whether the reaction picker should be scrollable.
/// Whether the reaction indicator should be scrollable.
///
/// Defaults to `true`.
final bool scrollable;

/// Border radius for the reaction picker.
/// Border radius for the reaction indicator.
///
/// Defaults to a circular border with a radius of 26.
final BorderRadius? borderRadius;
Expand All @@ -56,35 +111,40 @@ class StreamReactionIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = StreamChatTheme.of(context);
final config = StreamChatConfiguration.of(context);
final reactionIcons = config.reactionIcons;

final ownReactions = {...?message.ownReactions?.map((it) => it.type)};
final indicatorIcons = message.reactionGroups?.entries
.sortedByCompare((it) => it.value, reactionSorting)
.map((group) {
final reactionIcon = reactionIcons.firstWhere(
(it) => it.type == group.key,
orElse: () => const StreamReactionIcon.unknown(),
);

return ReactionIndicatorIcon(
type: reactionIcon.type,
builder: reactionIcon.builder,
isSelected: ownReactions.contains(reactionIcon.type),
);
});
final reactionIcons = {for (final it in this.reactionIcons) it.type: it};

final sortedReactionGroups = message.reactionGroups?.entries
.sortedByCompare((it) => it.value, reactionSorting);

final indicatorIcons = sortedReactionGroups?.map(
(group) {
final reactionType = group.key;
final reactionIcon = switch (reactionIcons[reactionType]) {
final icon? => icon,
_ => const StreamReactionIcon.unknown(),
};

return ReactionIndicatorIcon(
type: reactionType,
builder: reactionIcon.builder,
isSelected: ownReactions.contains(reactionType),
);
},
);

final reactionIndicator = ReactionIndicatorIconList(
iconBuilder: reactionIconBuilder,
indicatorIcons: [...?indicatorIcons],
);

final isSingleIndicatorIcon = indicatorIcons?.length == 1;
final extraPadding = switch (isSingleIndicatorIcon) {
true => EdgeInsets.zero,
false => const EdgeInsets.symmetric(horizontal: 4),
};

final indicator = ReactionIndicatorIconList(
indicatorIcons: [...?indicatorIcons],
);

return Material(
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
Expand All @@ -94,11 +154,11 @@ class StreamReactionIndicator extends StatelessWidget {
child: Padding(
padding: padding.add(extraPadding),
child: switch (scrollable) {
false => reactionIndicator,
true => SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: indicator,
child: reactionIndicator,
),
false => indicator,
},
),
),
Expand Down
Loading
Loading