diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index e42cc5c777..c6471b5e92 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -987,12 +987,20 @@ class Channel { } } - /// Retry the operation on the message based on the failed state. + /// Retries operations on a message based on its failed state. /// - /// For example, if the message failed to send, it will retry sending the - /// message and vice-versa. + /// This method examines the message's state and performs the appropriate + /// retry action: + /// - For [MessageState.sendingFailed], it attempts to send the message. + /// - For [MessageState.updatingFailed], it attempts to update the message. + /// - For [MessageState.deletingFailed], it attempts to delete the message. + /// with the same 'hard' parameter that was used in the original request + /// - For messages with [isBouncedWithError], it attempts to send the message. Future retryMessage(Message message) async { - assert(message.state.isFailed, 'Message state is not failed'); + assert( + message.state.isFailed || message.isBouncedWithError, + 'Only failed or bounced messages can be retried', + ); return message.state.maybeWhen( failed: (state, _) => state.when( @@ -1000,7 +1008,14 @@ class Channel { updatingFailed: () => updateMessage(message), deletingFailed: (hard) => deleteMessage(message, hard: hard), ), - orElse: () => throw StateError('Message state is not failed'), + orElse: () { + // Check if the message is bounced with error. + if (message.isBouncedWithError) return sendMessage(message); + + throw StateError( + 'Only failed or bounced messages can be retried', + ); + }, ); } diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index ca5f445327..c4b7b47881 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,31 @@ +## Upcoming Beta + +🛑️ Breaking + +- `StreamReactionPicker` now requires reactions to be explicitly handled via `onReactionPicked`. *(Automatic handling is no longer supported.)* +- `StreamMessageAction` is now generic `(StreamMessageAction)`, enhancing type safety. Individual onTap callbacks have been removed; actions are now handled centrally by widgets like `StreamMessageWidget.onCustomActionTap` or modals using action types. +- `StreamMessageReactionsModal` no longer requires the `messageTheme` parameter. The theme now automatically derives from the `reverse` property. + +For more details, please refer to the [migration guide](Unpublished). + +✅ Added + +- Added new `StreamMessageActionsBuilder` which provides a list of actions to be displayed in the message actions modal. +- Added new `StreamMessageActionConfirmationModal` for confirming destructive actions like delete or flag. +- Added new `StreamMessageModal` and `showStreamMessageModal` for consistent message-related modals with improved transitions and backdrop effects. + ```dart + showStreamMessageModal( + context: context, + ...other parameters, + builder: (context) => StreamMessageModal( + ...other parameters, + headerBuilder: (context) => YourCustomHeader(), + contentBuilder: (context) => YourCustomContent(), + ), + ); + ``` +- Exported `StreamMessageActionsModal` and `StreamModeratedMessageActionsModal` which are now based on `StreamMessageModal` for consistent styling and behavior. + ## Upcoming 🐞 Fixed diff --git a/packages/stream_chat_flutter/dart_test.yaml b/packages/stream_chat_flutter/dart_test.yaml new file mode 100644 index 0000000000..c329c9c85d --- /dev/null +++ b/packages/stream_chat_flutter/dart_test.yaml @@ -0,0 +1,5 @@ +# The existence of this file prevents warnings about unrecognized tags when running Alchemist tests. + +tags: + golden: + timeout: 15s \ No newline at end of file diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart b/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart index 6c71eee4ee..536a45ff7a 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_channel_avatar.dart @@ -108,7 +108,7 @@ class StreamChannelAvatar extends StatelessWidget { final fallbackWidget = Center( child: Text( - channel.name?[0] ?? '', + channel.name?.characters.firstOrNull ?? '', style: TextStyle( color: colorTheme.barsBg, fontWeight: FontWeight.bold, diff --git a/packages/stream_chat_flutter/lib/src/context_menu_items/context_menu_reaction_picker.dart b/packages/stream_chat_flutter/lib/src/context_menu_items/context_menu_reaction_picker.dart deleted file mode 100644 index fb22d44d27..0000000000 --- a/packages/stream_chat_flutter/lib/src/context_menu_items/context_menu_reaction_picker.dart +++ /dev/null @@ -1,168 +0,0 @@ -import 'package:ezanimation/ezanimation.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template contextMenuReactionPicker} -/// Allows the user to select reactions to a message on desktop & web via -/// context menu. -/// -/// This differs slightly from [StreamReactionPicker] in order to match our -/// design spec. -/// -/// Used by the `_buildContextMenu()` function found in `message_widget.dart`. -/// It is not recommended to use this widget directly. -/// {@endtemplate} -class ContextMenuReactionPicker extends StatefulWidget { - /// {@macro contextMenuReactionPicker} - const ContextMenuReactionPicker({ - super.key, - required this.message, - }); - - /// The message to react to. - final Message message; - - @override - State createState() => - _ContextMenuReactionPickerState(); -} - -class _ContextMenuReactionPickerState extends State - with TickerProviderStateMixin { - List animations = []; - - Future triggerAnimations() async { - for (final a in animations) { - a.start(); - await Future.delayed(const Duration(milliseconds: 100)); - } - } - - Future pop() async { - for (final a in animations) { - a.stop(); - } - Navigator.of(context).pop(); - } - - /// Add a reaction to the message - void sendReaction(BuildContext context, String reactionType) { - StreamChannel.of(context).channel.sendReaction( - widget.message, - reactionType, - enforceUnique: - StreamChatConfiguration.of(context).enforceUniqueReactions, - ); - pop(); - } - - /// Remove a reaction from the message - void removeReaction(BuildContext context, Reaction reaction) { - StreamChannel.of(context).channel.deleteReaction(widget.message, reaction); - pop(); - } - - @override - void dispose() { - for (final a in animations) { - a.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final reactionIcons = StreamChatConfiguration.of(context).reactionIcons; - - if (animations.isEmpty && reactionIcons.isNotEmpty) { - reactionIcons.forEach((element) { - animations.add( - EzAnimation.tween( - Tween(begin: 0.0, end: 1.0), - const Duration(milliseconds: 250), - curve: Curves.easeInOutBack, - ), - ); - }); - - triggerAnimations(); - } - - final child = Material( - color: StreamChatTheme.of(context).messageListViewTheme.backgroundColor ?? - Theme.of(context).scaffoldBackgroundColor, - //clipBehavior: Clip.hardEdge, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - spacing: 16, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceAround, - mainAxisSize: MainAxisSize.min, - children: reactionIcons.map((reactionIcon) { - final ownReactionIndex = widget.message.ownReactions?.indexWhere( - (reaction) => reaction.type == reactionIcon.type, - ) ?? - -1; - final index = reactionIcons.indexOf(reactionIcon); - - final child = reactionIcon.builder( - context, - ownReactionIndex != -1, - 24, - ); - - return ConstrainedBox( - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - child: RawMaterialButton( - elevation: 0, - shape: ContinuousRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - onPressed: () { - if (ownReactionIndex != -1) { - removeReaction( - context, - widget.message.ownReactions![ownReactionIndex], - ); - } else { - sendReaction( - context, - reactionIcon.type, - ); - } - }, - child: AnimatedBuilder( - animation: animations[index], - builder: (context, child) => Transform.scale( - scale: animations[index].value, - child: child, - ), - child: child, - ), - ), - ); - }).toList(), - ), - ), - ); - - return TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - curve: Curves.easeInOutBack, - duration: const Duration(milliseconds: 500), - builder: (context, val, widget) => Transform.scale( - scale: val, - child: widget, - ), - child: child, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/context_menu_items/download_menu_item.dart b/packages/stream_chat_flutter/lib/src/context_menu_items/download_menu_item.dart deleted file mode 100644 index a3f4d5a207..0000000000 --- a/packages/stream_chat_flutter/lib/src/context_menu_items/download_menu_item.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template downloadMenuItem} -/// Defines a "download" context menu item that allows a user to download -/// a given attachment. -/// -/// Used in [DesktopFullscreenMedia]. -/// {@endtemplate} -class DownloadMenuItem extends StatelessWidget { - /// {@macro downloadMenuItem} - const DownloadMenuItem({ - super.key, - required this.attachment, - }); - - /// The attachment to download. - final Attachment attachment; - - @override - Widget build(BuildContext context) { - return StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.download), - title: Text(context.translations.downloadLabel), - onClick: () async { - Navigator.of(context).pop(); - StreamAttachmentHandler.instance.downloadAttachment(attachment); - }, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/context_menu_items/stream_chat_context_menu_item.dart b/packages/stream_chat_flutter/lib/src/context_menu_items/stream_chat_context_menu_item.dart deleted file mode 100644 index dfe64684e2..0000000000 --- a/packages/stream_chat_flutter/lib/src/context_menu_items/stream_chat_context_menu_item.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamChatContextMenuItem} -/// Builds a context menu item according to Stream design specification. -/// {@endtemplate} -class StreamChatContextMenuItem extends StatelessWidget { - /// {@macro streamChatContextMenuItem} - const StreamChatContextMenuItem({ - super.key, - this.child, - this.leading, - this.title, - this.onClick, - }); - - /// The child widget for this menu item. Usually a [DesktopReactionPicker]. - /// - /// Leave null in order to use the default menu item widget. - final Widget? child; - - /// The widget to lead the menu item with. Usually an [Icon]. - /// - /// If [child] is specified, this will be ignored. - final Widget? leading; - - /// The title of the menu item. Usually a [Text]. - /// - /// If [child] is specified, this will be ignored. - final Widget? title; - - /// The action to perform when the menu item is clicked. - /// - /// If [child] is specified, this will be ignored. - final VoidCallback? onClick; - - @override - Widget build(BuildContext context) { - return Ink( - color: StreamChatTheme.of(context).messageListViewTheme.backgroundColor ?? - Theme.of(context).scaffoldBackgroundColor, - child: child ?? - ListTile( - dense: true, - leading: leading, - title: title, - onTap: onClick, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart index acfc26477d..ed3ebcbcea 100644 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/download_menu_item.dart'; import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; import 'package:stream_chat_flutter/src/fullscreen_media/gallery_navigation_item.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; @@ -123,12 +122,11 @@ class _FullScreenMediaDesktopState extends State { children: [ ContextMenuArea( verticalPadding: 0, - builder: (_) => [ - DownloadMenuItem( - attachment: - widget.mediaAttachmentPackages[_currentPage.value].attachment, - ), - ], + builder: (context) { + final index = _currentPage.value; + final mediaAttachment = widget.mediaAttachmentPackages[index]; + return [_DownloadMenuItem(mediaAttachment: mediaAttachment)]; + }, child: _PlaylistPlayer( packages: videoPackages.values.toList(), autoStart: widget.autoplayVideos, @@ -359,8 +357,8 @@ class _FullScreenMediaDesktopState extends State { child: ContextMenuArea( verticalPadding: 0, builder: (_) => [ - DownloadMenuItem( - attachment: attachment, + _DownloadMenuItem( + mediaAttachment: currentAttachmentPackage, ), ], child: Video( @@ -384,6 +382,52 @@ class _FullScreenMediaDesktopState extends State { } } +/// {@template streamDownloadMenuItem} +/// A context menu item for downloading an attachment from a message. +/// +/// This widget displays a download option in a context menu, allowing users to +/// download the attachment associated with a message. +/// +/// It uses [StreamMessageActionItem] and [StreamMessageAction] to create a +/// consistent UI with other message actions. +/// {@endtemplate} +class _DownloadMenuItem extends StatelessWidget { + /// {@macro streamDownloadMenuItem} + const _DownloadMenuItem({ + required this.mediaAttachment, + }); + + /// The attachment package containing the message and attachment to download. + final StreamAttachmentPackage mediaAttachment; + static const String _attachmentKey = 'attachment'; + + @override + Widget build(BuildContext context) { + return StreamMessageActionItem( + action: StreamMessageAction( + leading: const StreamSvgIcon(icon: StreamSvgIcons.download), + title: Text(context.translations.downloadLabel), + action: CustomMessageAction( + message: mediaAttachment.message, + extraData: {_attachmentKey: mediaAttachment.attachment}, + ), + ), + // TODO: Use a callback to handle the action instead of onTap. + onTap: (action) async { + if (action is! CustomMessageAction) return; + final attachment = action.extraData[_attachmentKey] as Attachment?; + if (attachment == null) return; + + final popped = await Navigator.of(context).maybePop(); + if (popped) { + final handler = StreamAttachmentHandler.instance; + return handler.downloadAttachment(attachment).ignore(); + } + }, + ); + } +} + /// Class for packaging up things required for videos class DesktopVideoPackage { /// Constructor for creating [VideoPackage] diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index 87437b86a5..e08562e7a7 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/message_list_view/message_list_view.dart'; import 'package:stream_chat_flutter/src/misc/connection_status_builder.dart'; @@ -727,8 +729,7 @@ class DefaultTranslations implements Translations { @override String get flagMessageQuestion => - 'Do you want to send a copy of this message to a' - '\nmoderator for further investigation?'; + 'Do you want to send a copy of this message to a moderator for further investigation?'; @override String get flagLabel => 'FLAG'; @@ -751,7 +752,7 @@ class DefaultTranslations implements Translations { @override String get deleteMessageQuestion => - 'Are you sure you want to permanently delete this\nmessage?'; + 'Are you sure you want to permanently delete this message?'; @override String get operationCouldNotBeCompletedText => diff --git a/packages/stream_chat_flutter/lib/src/message_action/message_action.dart b/packages/stream_chat_flutter/lib/src/message_action/message_action.dart new file mode 100644 index 0000000000..27e4fb00fa --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_action/message_action.dart @@ -0,0 +1,70 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +part 'message_action_type.dart'; + +/// {@template streamMessageAction} +/// A class that represents an action that can be performed on a message. +/// +/// This class is used to define actions that appear in message action menus +/// or option lists, providing a consistent structure for message-related +/// actions including their visual representation and behavior. +/// {@endtemplate} +class StreamMessageAction { + /// {@macro streamMessageAction} + const StreamMessageAction({ + required this.action, + this.isDestructive = false, + this.leading, + this.iconColor, + this.title, + this.titleTextColor, + this.titleTextStyle, + this.backgroundColor, + }); + + /// The [MessageAction] that this item represents. + final T action; + + /// Whether the action is destructive. + /// + /// Destructive actions are typically displayed with a red color to indicate + /// that they will remove or delete content. + /// + /// Defaults to `false`. + final bool isDestructive; + + /// A widget to display before the title. + /// + /// Typically an [Icon] or a [CircleAvatar] widget. + final Widget? leading; + + /// The color for the [leading] icon. + /// + /// If this property is null, the icon will use the default color provided by + /// the theme or parent widget. + final Color? iconColor; + + /// The primary content of the action item. + /// + /// Typically a [Text] widget. + /// + /// This should not wrap. To enforce the single line limit, use + /// [Text.maxLines]. + final Widget? title; + + /// The color for the text in the [title]. + /// + /// If this property is null, the text will use the default color provided by + /// the theme or parent widget. + final Color? titleTextColor; + + /// The text style for the [title]. + /// + /// If this property is null, the title will use the default text style + /// provided by the theme or parent widget. + final TextStyle? titleTextStyle; + + /// Defines the background color of the action item. + final Color? backgroundColor; +} diff --git a/packages/stream_chat_flutter/lib/src/message_action/message_action_item.dart b/packages/stream_chat_flutter/lib/src/message_action/message_action_item.dart new file mode 100644 index 0000000000..fa4af9328c --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_action/message_action_item.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_action/message_action.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// {@template streamMessageActionItem} +/// A widget that represents an action item within a message interface. +/// +/// This widget is typically used in action menus or option lists related to +/// messages, providing a consistent appearance for selectable actions with an +/// optional icon and title. +/// {@endtemplate} +class StreamMessageActionItem extends StatelessWidget { + /// {@macro streamMessageActionItem} + const StreamMessageActionItem({ + super.key, + required this.action, + this.onTap, + }); + + /// The underlying action that this item represents. + final StreamMessageAction action; + + /// Called when the user taps this action item. + /// + /// This callback provides the tap handling for the action item, and is + /// typically used to execute the associated action or dismiss menus. + final OnMessageActionTap? onTap; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + final iconColor = switch (action.isDestructive) { + true => action.iconColor ?? colorTheme.accentError, + false => action.iconColor ?? colorTheme.textLowEmphasis, + }; + + final titleTextColor = switch (action.isDestructive) { + true => action.titleTextColor ?? colorTheme.accentError, + false => action.titleTextColor ?? colorTheme.textHighEmphasis, + }; + + final titleTextStyle = action.titleTextStyle ?? textTheme.body; + final backgroundColor = action.backgroundColor ?? colorTheme.barsBg; + + return InkWell( + onTap: switch (onTap) { + final onTap? => () => onTap(action.action), + _ => null, + }, + child: Ink( + color: backgroundColor, + child: IconTheme.merge( + data: IconThemeData(color: iconColor), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 9, + horizontal: 16, + ), + child: Row( + spacing: 16, + mainAxisSize: MainAxisSize.min, + children: [ + if (action.leading case final leading?) leading, + if (action.title case final title?) + DefaultTextStyle( + style: titleTextStyle.copyWith( + color: titleTextColor, + ), + child: title, + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_action/message_action_type.dart b/packages/stream_chat_flutter/lib/src/message_action/message_action_type.dart new file mode 100644 index 0000000000..e92ed7da6b --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_action/message_action_type.dart @@ -0,0 +1,131 @@ +part of 'message_action.dart'; + +/// {@template onMessageActionTap} +/// Signature for a function that is called when a message action is tapped. +/// {@endtemplate} +typedef OnMessageActionTap = void Function(T action); + +/// {@template messageAction} +/// A sealed class that represents different actions that can be performed on a +/// message. +/// {@endtemplate} +sealed class MessageAction { + /// {@macro messageAction} + const MessageAction({required this.message}); + + /// The message this action applies to. + final Message message; +} + +/// Action to show reaction selector for adding reactions to a message +final class SelectReaction extends MessageAction { + /// Create a new select reaction action + const SelectReaction({ + required super.message, + required this.reaction, + this.enforceUnique = false, + }); + + /// The reaction to be added or removed from the message. + final Reaction reaction; + + /// Whether to enforce unique reactions. + final bool enforceUnique; +} + +/// Action to copy message content to clipboard +final class CopyMessage extends MessageAction { + /// Create a new copy message action + const CopyMessage({required super.message}); +} + +/// Action to delete a message from the conversation +final class DeleteMessage extends MessageAction { + /// Create a new delete message action + const DeleteMessage({required super.message}); +} + +/// Action to hard delete a message permanently from the conversation +final class HardDeleteMessage extends MessageAction { + /// Create a new hard delete message action + const HardDeleteMessage({required super.message}); +} + +/// Action to modify content of an existing message +final class EditMessage extends MessageAction { + /// Create a new edit message action + const EditMessage({required super.message}); +} + +/// Action to flag a message for moderator review +final class FlagMessage extends MessageAction { + /// Create a new flag message action + const FlagMessage({required super.message}); +} + +/// Action to mark a message as unread for later viewing +final class MarkUnread extends MessageAction { + /// Create a new mark unread action + const MarkUnread({required super.message}); +} + +/// Action to mute a user to prevent notifications from their messages +final class MuteUser extends MessageAction { + /// Create a new mute user action + const MuteUser({required super.message, required this.user}); + + /// The user to be muted. + final User user; +} + +/// Action to unmute a user to receive notifications from their messages +final class UnmuteUser extends MessageAction { + /// Create a new unmute user action + const UnmuteUser({required super.message, required this.user}); + + /// The user to be unmuted. + final User user; +} + +/// Action to pin a message to make it prominently visible in the channel +final class PinMessage extends MessageAction { + /// Create a new pin message action + const PinMessage({required super.message}); +} + +/// Action to remove a previously pinned message +final class UnpinMessage extends MessageAction { + /// Create a new unpin message action + const UnpinMessage({required super.message}); +} + +/// Action to attempt to resend a message that failed to send +final class ResendMessage extends MessageAction { + /// Create a new resend message action + const ResendMessage({required super.message}); +} + +/// Action to create a reply with quoted original message content +final class QuotedReply extends MessageAction { + /// Create a new quoted reply action + const QuotedReply({required super.message}); +} + +/// Action to start a threaded conversation from a message +final class ThreadReply extends MessageAction { + /// Create a new thread reply action + const ThreadReply({required super.message}); +} + +/// Custom message action that allows for additional data to be passed +/// along with the message. +final class CustomMessageAction extends MessageAction { + /// Create a new custom message action + const CustomMessageAction({ + required super.message, + this.extraData = const {}, + }); + + /// Map of extra data associated with the action. + final Map extraData; +} diff --git a/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart b/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart new file mode 100644 index 0000000000..d7bc0aafea --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_action/message_actions_builder.dart @@ -0,0 +1,248 @@ +import 'package:flutter/widgets.dart'; +import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/message_action/message_action.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@template streamMessageActionsBuilder} +/// A utility class that provides a builder for message actions +/// which can be reused across mobile platforms. +/// {@endtemplate} +class StreamMessageActionsBuilder { + /// Private constructor to prevent instantiation + StreamMessageActionsBuilder._(); + + /// Returns a list of message actions for the "bounced with error" state. + /// + /// This method builds a list of [StreamMessageAction]s that are applicable to + /// the given [message] when it is in the "bounced with error" state. + /// + /// The actions include options to retry sending the message, edit or delete + /// the message. + static List buildBouncedErrorActions({ + required BuildContext context, + required Message message, + }) { + // If the message is not bounced with an error, we don't show any actions. + if (!message.isBouncedWithError) return []; + + return [ + StreamMessageAction( + action: ResendMessage(message: message), + iconColor: StreamChatTheme.of(context).colorTheme.accentPrimary, + title: Text(context.translations.sendAnywayLabel), + leading: const StreamSvgIcon(icon: StreamSvgIcons.circleUp), + ), + StreamMessageAction( + action: EditMessage(message: message), + title: Text(context.translations.editMessageLabel), + leading: const StreamSvgIcon(icon: StreamSvgIcons.edit), + ), + StreamMessageAction( + isDestructive: true, + action: HardDeleteMessage(message: message), + title: Text(context.translations.deleteMessageLabel), + leading: const StreamSvgIcon(icon: StreamSvgIcons.delete), + ), + ]; + } + + /// Returns a list of message actions based on the provided message and + /// channel capabilities. + /// + /// This method builds a list of [StreamMessageAction]s that are applicable to + /// the given [message] in the [channel], considering the permissions of the + /// [currentUser] and the current state of the message. + static List buildActions({ + required BuildContext context, + required Message message, + required Channel channel, + OwnUser? currentUser, + Iterable? customActions, + }) { + // If the message is deleted, we don't show any actions. + if (message.isDeleted) return []; + + final messageState = message.state; + if (messageState.isFailed) { + return [ + if (messageState.isSendingFailed || messageState.isUpdatingFailed) ...[ + StreamMessageAction( + action: ResendMessage(message: message), + leading: const StreamSvgIcon(icon: StreamSvgIcons.circleUp), + iconColor: StreamChatTheme.of(context).colorTheme.accentPrimary, + title: Text( + context.translations.toggleResendOrResendEditedMessage( + isUpdateFailed: messageState.isUpdatingFailed, + ), + ), + ), + ], + if (message.state.isDeletingFailed) + StreamMessageAction( + isDestructive: true, + action: ResendMessage(message: message), + leading: const StreamSvgIcon(icon: StreamSvgIcons.delete), + title: Text( + context.translations.toggleDeleteRetryDeleteMessageText( + isDeleteFailed: true, + ), + ), + ), + ]; + } + + final isSentByCurrentUser = message.user?.id == currentUser?.id; + final isThreadMessage = message.parentId != null; + final isParentMessage = (message.replyCount ?? 0) > 0; + final canShowInChannel = message.showInChannel ?? true; + final isPrivateMessage = message.hasRestrictedVisibility; + final canSendReply = channel.canSendReply; + final canPinMessage = channel.canPinMessage; + final canQuoteMessage = channel.canQuoteMessage; + final canReceiveReadEvents = channel.canReceiveReadEvents; + final canUpdateAnyMessage = channel.canUpdateAnyMessage; + final canUpdateOwnMessage = channel.canUpdateOwnMessage; + final canDeleteAnyMessage = channel.canDeleteAnyMessage; + final canDeleteOwnMessage = channel.canDeleteOwnMessage; + final containsPoll = message.poll != null; + final containsGiphy = message.attachments.any( + (attachment) => attachment.type == AttachmentType.giphy, + ); + + final messageActions = []; + + if (canQuoteMessage) { + messageActions.add( + StreamMessageAction( + action: QuotedReply(message: message), + title: Text(context.translations.replyLabel), + leading: const StreamSvgIcon(icon: StreamSvgIcons.reply), + ), + ); + } + + if (canSendReply && !isThreadMessage) { + messageActions.add( + StreamMessageAction( + action: ThreadReply(message: message), + title: Text(context.translations.threadReplyLabel), + leading: const StreamSvgIcon(icon: StreamSvgIcons.threadReply), + ), + ); + } + + if (canReceiveReadEvents) { + StreamMessageAction markUnreadAction() { + return StreamMessageAction( + action: MarkUnread(message: message), + title: Text(context.translations.markAsUnreadLabel), + leading: const StreamSvgIcon(icon: StreamSvgIcons.messageUnread), + ); + } + + // If message is a parent message, it can be marked unread independent of + // other logic. + if (isParentMessage) { + messageActions.add(markUnreadAction()); + } + // If the message is in the channel view, only other user messages can be + // marked unread. + else if (!isSentByCurrentUser && (!isThreadMessage || canShowInChannel)) { + messageActions.add(markUnreadAction()); + } + } + + if (message.text case final text? when text.isNotEmpty) { + messageActions.add( + StreamMessageAction( + action: CopyMessage(message: message), + title: Text(context.translations.copyMessageLabel), + leading: const StreamSvgIcon(icon: StreamSvgIcons.copy), + ), + ); + } + + if (!containsPoll && !containsGiphy) { + if (canUpdateAnyMessage || (canUpdateOwnMessage && isSentByCurrentUser)) { + messageActions.add( + StreamMessageAction( + action: EditMessage(message: message), + title: Text(context.translations.editMessageLabel), + leading: const StreamSvgIcon(icon: StreamSvgIcons.edit), + ), + ); + } + } + + // Pinning a private message is not allowed, simply because pinning a + // message is meant to bring attention to that message, that is not possible + // with a message that is only visible to a subset of users. + if (canPinMessage && !isPrivateMessage) { + final isPinned = message.pinned; + final label = context.translations.togglePinUnpinText; + + final action = switch (isPinned) { + true => UnpinMessage(message: message), + false => PinMessage(message: message) + }; + + messageActions.add( + StreamMessageAction( + action: action, + title: Text(label.call(pinned: isPinned)), + leading: const StreamSvgIcon(icon: StreamSvgIcons.pin), + ), + ); + } + + if (canDeleteAnyMessage || (canDeleteOwnMessage && isSentByCurrentUser)) { + final label = context.translations.toggleDeleteRetryDeleteMessageText; + + messageActions.add( + StreamMessageAction( + isDestructive: true, + action: DeleteMessage(message: message), + leading: const StreamSvgIcon(icon: StreamSvgIcons.delete), + title: Text(label.call(isDeleteFailed: false)), + ), + ); + } + + if (!isSentByCurrentUser) { + messageActions.add( + StreamMessageAction( + action: FlagMessage(message: message), + title: Text(context.translations.flagMessageLabel), + leading: const StreamSvgIcon(icon: StreamSvgIcons.flag), + ), + ); + } + + if (message.user case final messageUser? + when channel.config?.mutes == true && !isSentByCurrentUser) { + final mutedUsers = currentUser?.mutes.map((mute) => mute.target.id); + final isMuted = mutedUsers?.contains(messageUser.id) ?? false; + final label = context.translations.toggleMuteUnmuteUserText; + + final action = switch (isMuted) { + true => UnmuteUser(message: message, user: messageUser), + false => MuteUser(message: message, user: messageUser), + }; + + messageActions.add( + StreamMessageAction( + action: action, + title: Text(label.call(isMuted: isMuted)), + leading: const StreamSvgIcon(icon: StreamSvgIcons.mute), + ), + ); + } + + // Add all the remaining custom actions if provided. + if (customActions case final actions?) messageActions.addAll(actions); + + return messageActions; + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/copy_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/copy_message_button.dart deleted file mode 100644 index cdb7415f21..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/copy_message_button.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template copyMessageButton} -/// Allows a user to copy the text of a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class CopyMessageButton extends StatelessWidget { - /// {@macro copyMessageButton} - const CopyMessageButton({ - super.key, - required this.onTap, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - size: 24, - icon: StreamSvgIcons.copy, - color: streamChatThemeData.primaryIconTheme.color, - ), - const SizedBox(width: 16), - Text( - context.translations.copyMessageLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/delete_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/delete_message_button.dart deleted file mode 100644 index 45e0477a5d..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/delete_message_button.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template deleteMessageButton} -/// A button that allows a user to delete the selected message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class DeleteMessageButton extends StatelessWidget { - /// {@macro deleteMessageButton} - const DeleteMessageButton({ - super.key, - required this.isDeleteFailed, - required this.onTap, - }); - - /// Indicates whether the deletion has failed or not. - final bool isDeleteFailed; - - /// The action (deleting the message) to be performed on tap. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: theme.colorTheme.accentError, - ), - const SizedBox(width: 16), - Text( - context.translations.toggleDeleteRetryDeleteMessageText( - isDeleteFailed: isDeleteFailed, - ), - style: theme.textTheme.body.copyWith( - color: theme.colorTheme.accentError, - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/edit_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/edit_message_button.dart deleted file mode 100644 index 33165ac8a4..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/edit_message_button.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template editMessageButton} -/// Allows a user to edit a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class EditMessageButton extends StatelessWidget { - /// {@macro editMessageButton} - const EditMessageButton({ - super.key, - required this.onTap, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback? onTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.edit, - color: streamChatThemeData.primaryIconTheme.color, - ), - const SizedBox(width: 16), - Text( - context.translations.editMessageLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/flag_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/flag_message_button.dart deleted file mode 100644 index 5c57547108..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/flag_message_button.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template flagMessageButton} -/// Allows a user to flag a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class FlagMessageButton extends StatelessWidget { - /// {@macro flagMessageButton} - const FlagMessageButton({ - super.key, - required this.onTap, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: streamChatThemeData.primaryIconTheme.color, - ), - const SizedBox(width: 16), - Text( - context.translations.flagMessageLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/mam_widgets.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/mam_widgets.dart deleted file mode 100644 index e756d682b2..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/mam_widgets.dart +++ /dev/null @@ -1,8 +0,0 @@ -export 'copy_message_button.dart'; -export 'delete_message_button.dart'; -export 'edit_message_button.dart'; -export 'flag_message_button.dart'; -export 'pin_message_button.dart'; -export 'reply_button.dart'; -export 'resend_message_button.dart'; -export 'thread_reply_button.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/mark_unread_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/mark_unread_message_button.dart deleted file mode 100644 index 12c38d0210..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/mark_unread_message_button.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template markUnreadMessageButton} -/// Allows a user to mark message (and all messages onwards) as unread. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class MarkUnreadMessageButton extends StatelessWidget { - /// {@macro markUnreadMessageButton} - const MarkUnreadMessageButton({ - super.key, - required this.onTap, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.messageUnread, - color: streamChatThemeData.primaryIconTheme.color, - size: 24, - ), - const SizedBox(width: 16), - Text( - context.translations.markAsUnreadLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_action.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/message_action.dart deleted file mode 100644 index f3acaac964..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_action.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:flutter/widgets.dart'; -import 'package:stream_chat_flutter/src/utils/typedefs.dart'; - -/// {@template streamMessageAction} -/// Class describing a message action -/// {@endtemplate} -class StreamMessageAction { - /// {@macro streamMessageAction} - StreamMessageAction({ - this.leading, - this.title, - this.onTap, - }); - - /// leading widget - final Widget? leading; - - /// title widget - final Widget? title; - - /// {@macro onMessageTap} - final OnMessageTap? onTap; -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart deleted file mode 100644 index 069c1aea51..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart +++ /dev/null @@ -1,425 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart' hide ButtonStyle; -import 'package:stream_chat_flutter/src/message_actions_modal/mam_widgets.dart'; -import 'package:stream_chat_flutter/src/message_actions_modal/mark_unread_message_button.dart'; -import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_align.dart'; -import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template messageActionsModal} -/// Constructs a modal with actions for a message -/// {@endtemplate} -class MessageActionsModal extends StatefulWidget { - /// {@macro messageActionsModal} - const MessageActionsModal({ - super.key, - required this.message, - required this.messageWidget, - required this.messageTheme, - this.showReactionPicker = true, - this.showDeleteMessage = true, - this.showEditMessage = true, - this.onReplyTap, - this.onEditMessageTap, - this.onConfirmDeleteTap, - this.onThreadReplyTap, - this.showCopyMessage = true, - this.showReplyMessage = true, - this.showResendMessage = true, - this.showThreadReplyMessage = true, - this.showMarkUnreadMessage = true, - this.showFlagButton = true, - this.showPinButton = true, - this.editMessageInputBuilder, - this.reverse = false, - this.customActions = const [], - this.onCopyTap, - }); - - /// Widget that shows the message - final Widget messageWidget; - - /// Builder for edit message - final EditMessageInputBuilder? editMessageInputBuilder; - - /// The action to perform when "thread reply" is tapped - final OnMessageTap? onThreadReplyTap; - - /// The action to perform when "reply" is tapped - final OnMessageTap? onReplyTap; - - /// The action to perform when "Edit Message" is tapped. - final OnMessageTap? onEditMessageTap; - - /// The action to perform when delete confirmation button is tapped. - final Future Function(Message)? onConfirmDeleteTap; - - /// Message in focus for actions - final Message message; - - /// [StreamMessageThemeData] for message - final StreamMessageThemeData messageTheme; - - /// Flag for showing reaction picker. - final bool showReactionPicker; - - /// Callback when copy is tapped - final OnMessageTap? onCopyTap; - - /// Callback when delete is tapped - final bool showDeleteMessage; - - /// Flag for showing copy action - final bool showCopyMessage; - - /// Flag for showing edit action - final bool showEditMessage; - - /// Flag for showing resend action - final bool showResendMessage; - - /// Flag for showing mark unread action - final bool showMarkUnreadMessage; - - /// Flag for showing reply action - final bool showReplyMessage; - - /// Flag for showing thread reply action - final bool showThreadReplyMessage; - - /// Flag for showing flag action - final bool showFlagButton; - - /// Flag for showing pin action - final bool showPinButton; - - /// Flag for reversing message - final bool reverse; - - /// List of custom actions - final List customActions; - - @override - _MessageActionsModalState createState() => _MessageActionsModalState(); -} - -class _MessageActionsModalState extends State { - bool _showActions = true; - - @override - Widget build(BuildContext context) { - final mediaQueryData = MediaQuery.of(context); - final user = StreamChat.of(context).currentUser; - final orientation = mediaQueryData.orientation; - - final fontSize = widget.messageTheme.messageTextStyle?.fontSize; - final streamChatThemeData = StreamChatTheme.of(context); - - final channel = StreamChannel.of(context).channel; - - final canSendReaction = channel.canSendReaction; - - final child = Center( - child: SingleChildScrollView( - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.showReactionPicker && canSendReaction) - LayoutBuilder( - builder: (context, constraints) { - return Align( - alignment: Alignment( - calculateReactionsHorizontalAlignment( - user, - widget.message, - constraints, - fontSize, - orientation, - ), - 0, - ), - child: StreamReactionPicker( - message: widget.message, - ), - ); - }, - ), - const SizedBox(height: 10), - IgnorePointer( - child: widget.messageWidget, - ), - const SizedBox(height: 8), - Padding( - padding: EdgeInsets.only( - left: widget.reverse ? 0 : 40, - ), - child: SizedBox( - width: mediaQueryData.size.width * 0.75, - child: Material( - color: streamChatThemeData.colorTheme.appBg, - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.showReplyMessage && - widget.message.state.isCompleted) - ReplyButton( - onTap: () { - Navigator.of(context).pop(); - if (widget.onReplyTap != null) { - widget.onReplyTap?.call(widget.message); - } - }, - ), - if (widget.showThreadReplyMessage && - (widget.message.state.isCompleted) && - widget.message.parentId == null) - ThreadReplyButton( - message: widget.message, - onThreadReplyTap: widget.onThreadReplyTap, - ), - if (widget.showMarkUnreadMessage) - MarkUnreadMessageButton(onTap: () async { - try { - await channel.markUnread(widget.message.id); - } catch (ex) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.translations.markUnreadError, - ), - ), - ); - } - - Navigator.of(context).pop(); - }), - if (widget.showResendMessage) - ResendMessageButton( - message: widget.message, - channel: channel, - ), - if (widget.showEditMessage) - EditMessageButton( - onTap: switch (widget.onEditMessageTap) { - final onTap? => () => onTap(widget.message), - _ => null, - }, - ), - if (widget.showCopyMessage) - CopyMessageButton( - onTap: () { - widget.onCopyTap?.call(widget.message); - }, - ), - if (widget.showFlagButton) - FlagMessageButton( - onTap: _showFlagDialog, - ), - if (widget.showPinButton) - PinMessageButton( - onTap: _togglePin, - pinned: widget.message.pinned, - ), - if (widget.showDeleteMessage) - DeleteMessageButton( - isDeleteFailed: - widget.message.state.isDeletingFailed, - onTap: _showDeleteBottomSheet, - ), - ...widget.customActions - .map((action) => _buildCustomAction( - context, - action, - )), - ].insertBetween( - Container( - height: 1, - color: streamChatThemeData.colorTheme.borders, - ), - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.of(context).maybePop(), - child: Stack( - children: [ - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - ), - child: ColoredBox( - color: streamChatThemeData.colorTheme.overlay, - ), - ), - ), - if (_showActions) - TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutBack, - builder: (context, val, child) => Transform.scale( - scale: val, - child: child, - ), - child: child, - ), - ], - ), - ); - } - - InkWell _buildCustomAction( - BuildContext context, - StreamMessageAction messageAction, - ) { - return InkWell( - onTap: () => messageAction.onTap?.call(widget.message), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - messageAction.leading ?? const Empty(), - const SizedBox(width: 16), - messageAction.title ?? const Empty(), - ], - ), - ), - ); - } - - Future _showFlagDialog() async { - final client = StreamChat.of(context).client; - - final streamChatThemeData = StreamChatTheme.of(context); - final answer = await showConfirmationBottomSheet( - context, - title: context.translations.flagMessageLabel, - icon: StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: streamChatThemeData.colorTheme.accentError, - size: 24, - ), - question: context.translations.flagMessageQuestion, - okText: context.translations.flagLabel, - cancelText: context.translations.cancelLabel, - ); - - final theme = streamChatThemeData; - if (answer == true) { - try { - await client.flagMessage(widget.message.id); - await showInfoBottomSheet( - context, - icon: StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: theme.colorTheme.accentError, - size: 24, - ), - details: context.translations.flagMessageSuccessfulText, - title: context.translations.flagMessageSuccessfulLabel, - okText: context.translations.okLabel, - ); - } catch (err) { - if (err is StreamChatNetworkError && - err.errorCode == ChatErrorCode.inputError) { - await showInfoBottomSheet( - context, - icon: StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: theme.colorTheme.accentError, - size: 24, - ), - details: context.translations.flagMessageSuccessfulText, - title: context.translations.flagMessageSuccessfulLabel, - okText: context.translations.okLabel, - ); - } else { - _showErrorAlertBottomSheet(); - } - } - } - } - - Future _togglePin() async { - final channel = StreamChannel.of(context).channel; - - Navigator.of(context).pop(); - try { - if (!widget.message.pinned) { - await channel.pinMessage(widget.message); - } else { - await channel.unpinMessage(widget.message); - } - } catch (e) { - _showErrorAlertBottomSheet(); - } - } - - /// Shows a "delete message" bottom sheet on mobile platforms. - Future _showDeleteBottomSheet() async { - setState(() => _showActions = false); - final answer = await showConfirmationBottomSheet( - context, - title: context.translations.deleteMessageLabel, - icon: StreamSvgIcon( - icon: StreamSvgIcons.flag, - color: StreamChatTheme.of(context).colorTheme.accentError, - size: 24, - ), - question: context.translations.deleteMessageQuestion, - okText: context.translations.deleteLabel, - cancelText: context.translations.cancelLabel, - ); - - if (answer == true) { - try { - Navigator.of(context).pop(); - final onConfirmDeleteTap = widget.onConfirmDeleteTap; - if (onConfirmDeleteTap != null) { - await onConfirmDeleteTap(widget.message); - } else { - await StreamChannel.of(context).channel.deleteMessage(widget.message); - } - } catch (err) { - _showErrorAlertBottomSheet(); - } - } else { - setState(() => _showActions = true); - } - } - - void _showErrorAlertBottomSheet() { - showInfoBottomSheet( - context, - icon: StreamSvgIcon( - icon: StreamSvgIcons.error, - color: StreamChatTheme.of(context).colorTheme.accentError, - size: 24, - ), - details: context.translations.operationCouldNotBeCompletedText, - title: context.translations.somethingWentWrongError, - okText: context.translations.okLabel, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/moderated_message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/moderated_message_actions_modal.dart deleted file mode 100644 index 6d9669e5d1..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/moderated_message_actions_modal.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/utils/extensions.dart'; - -/// {@template moderatedMessageActionsModal} -/// A modal that is shown when a message is flagged by moderation policies. -/// -/// This modal allows users to: -/// - Send the message anyway, overriding the moderation warning -/// - Edit the message to comply with community guidelines -/// - Delete the message -/// -/// The modal provides clear guidance to users about the moderation issue -/// and options to address it. -/// {@endtemplate} -class ModeratedMessageActionsModal extends StatelessWidget { - /// {@macro moderatedMessageActionsModal} - const ModeratedMessageActionsModal({ - super.key, - this.onSendAnyway, - this.onEditMessage, - this.onDeleteMessage, - }); - - /// Callback function called when the user chooses to send the message - /// despite the moderation warning. - final VoidCallback? onSendAnyway; - - /// Callback function called when the user chooses to edit the message. - final VoidCallback? onEditMessage; - - /// Callback function called when the user chooses to delete the message. - final VoidCallback? onDeleteMessage; - - @override - Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final textTheme = theme.textTheme; - final colorTheme = theme.colorTheme; - - final actions = [ - TextButton( - onPressed: onSendAnyway, - style: TextButton.styleFrom( - textStyle: theme.textTheme.body, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.sendAnywayLabel), - ), - TextButton( - onPressed: onEditMessage, - style: TextButton.styleFrom( - textStyle: theme.textTheme.body, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.editMessageLabel), - ), - TextButton( - onPressed: onDeleteMessage, - style: TextButton.styleFrom( - textStyle: theme.textTheme.body, - foregroundColor: theme.colorTheme.accentPrimary, - disabledForegroundColor: theme.colorTheme.disabled, - ), - child: Text(context.translations.deleteMessageLabel), - ), - ]; - - return BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), - child: AlertDialog( - clipBehavior: Clip.antiAlias, - backgroundColor: colorTheme.appBg, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - icon: const StreamSvgIcon(icon: StreamSvgIcons.flag), - iconColor: colorTheme.accentPrimary, - title: Text(context.translations.moderationReviewModalTitle), - titleTextStyle: textTheme.headline.copyWith( - color: colorTheme.textHighEmphasis, - ), - content: Text( - context.translations.moderationReviewModalDescription, - textAlign: TextAlign.center, - ), - contentTextStyle: textTheme.body.copyWith( - color: colorTheme.textLowEmphasis, - ), - actions: actions, - actionsAlignment: MainAxisAlignment.center, - actionsOverflowAlignment: OverflowBarAlignment.center, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/pin_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/pin_message_button.dart deleted file mode 100644 index 07313065ae..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/pin_message_button.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template pinMessageButton} -/// Allows a user to pin or unpin a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class PinMessageButton extends StatelessWidget { - /// {@macro pinMessageButton} - const PinMessageButton({ - super.key, - required this.onTap, - required this.pinned, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback onTap; - - /// Whether the selected message is currently pinned or not. - final bool pinned; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.pin, - color: streamChatThemeData.primaryIconTheme.color, - size: 24, - ), - const SizedBox(width: 16), - Text( - context.translations.togglePinUnpinText( - pinned: pinned, - ), - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/reply_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/reply_button.dart deleted file mode 100644 index b4340d8f96..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/reply_button.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template replyButton} -/// Allows a user to reply to a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class ReplyButton extends StatelessWidget { - /// {@macro replyButton} - const ReplyButton({ - super.key, - required this.onTap, - }); - - /// The callback to perform when the button is tapped. - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.reply, - color: streamChatThemeData.primaryIconTheme.color, - ), - const SizedBox(width: 16), - Text( - context.translations.replyLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/resend_message_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/resend_message_button.dart deleted file mode 100644 index 8c94c621ad..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/resend_message_button.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template resendMessageButton} -/// Allows a user to resend a message that has failed to be sent. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class ResendMessageButton extends StatelessWidget { - /// {@macro resendMessageButton} - const ResendMessageButton({ - super.key, - required this.message, - required this.channel, - }); - - /// The message to resend. - final Message message; - - /// The [StreamChannel] above this widget. - final Channel channel; - - @override - Widget build(BuildContext context) { - final isUpdateFailed = message.state.isUpdatingFailed; - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: () { - Navigator.of(context).pop(); - channel.retryMessage(message); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.circleUp, - color: streamChatThemeData.colorTheme.accentPrimary, - ), - const SizedBox(width: 16), - Text( - context.translations.toggleResendOrResendEditedMessage( - isUpdateFailed: isUpdateFailed, - ), - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal/thread_reply_button.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/thread_reply_button.dart deleted file mode 100644 index f5ffb4a357..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/thread_reply_button.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template threadReplyButton} -/// Allows a user to start a thread reply to a message. -/// -/// Used by [MessageActionsModal]. Should not be used by itself. -/// {@endtemplate} -class ThreadReplyButton extends StatelessWidget { - /// {@macro threadReplyButton} - const ThreadReplyButton({ - super.key, - required this.message, - this.onThreadReplyTap, - }); - - /// The message to start a thread reply to. - final Message message; - - /// The action to perform when "thread reply" is tapped - final OnMessageTap? onThreadReplyTap; - - @override - Widget build(BuildContext context) { - final streamChatThemeData = StreamChatTheme.of(context); - return InkWell( - onTap: () { - Navigator.of(context).pop(); - if (onThreadReplyTap != null) { - onThreadReplyTap?.call(message); - } - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 11, horizontal: 16), - child: Row( - children: [ - StreamSvgIcon( - icon: StreamSvgIcons.threadReply, - color: streamChatThemeData.primaryIconTheme.color, - ), - const SizedBox(width: 16), - Text( - context.translations.threadReplyLabel, - style: streamChatThemeData.textTheme.body, - ), - ], - ), - ), - ); - } -} 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 ff5a617bee..5ed61a4f61 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 @@ -1005,10 +1005,6 @@ class _StreamMessageListViewState extends State { final isMyMessage = message.user!.id == StreamChat.of(context).currentUser!.id; final isOnlyEmoji = message.text?.isOnlyEmoji ?? false; - final currentUser = StreamChat.of(context).currentUser; - final members = StreamChannel.of(context).channel.state?.members ?? []; - final currentUserMember = - members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); final hasFileAttachment = message.attachments.any((it) => it.type == AttachmentType.file); @@ -1025,6 +1021,11 @@ class _StreamMessageListViewState extends State { final borderSide = isOnlyEmoji ? BorderSide.none : null; final defaultMessageWidget = StreamMessageWidget( + message: message, + reverse: isMyMessage, + showUsername: !isMyMessage, + showReactions: !message.isDeleted && !message.state.isDeletingFailed, + showReactionPicker: !message.isDeleted && !message.state.isDeletingFailed, showReplyMessage: false, showResendMessage: false, showThreadReplyMessage: false, @@ -1032,9 +1033,6 @@ class _StreamMessageListViewState extends State { showDeleteMessage: false, showEditMessage: false, showMarkUnreadMessage: false, - message: message, - reverse: isMyMessage, - showUsername: !isMyMessage, padding: const EdgeInsets.all(8), showSendingIndicator: false, attachmentPadding: EdgeInsets.all( @@ -1077,13 +1075,6 @@ class _StreamMessageListViewState extends State { : _streamTheme.otherMessageTheme, onMessageTap: widget.onMessageTap, onMessageLongPress: widget.onMessageLongPress, - showPinButton: currentUserMember != null && - streamChannel?.channel.canPinMessage == true && - // Pinning a restricted visibility message is not allowed, simply - // because pinning a message is meant to bring attention to that - // message, that is not possible with a message that is only visible - // to a subset of users. - !message.hasRestrictedVisibility, ); if (widget.parentMessageBuilder != null) { @@ -1286,15 +1277,11 @@ class _StreamMessageListViewState extends State { final borderSide = isOnlyEmoji ? BorderSide.none : null; - final currentUser = StreamChat.of(context).currentUser; - final members = StreamChannel.of(context).channel.state?.members ?? []; - final currentUserMember = - members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); - Widget messageWidget = StreamMessageWidget( message: message, reverse: isMyMessage, - showReactions: !message.isDeleted, + showReactions: !message.isDeleted && !message.state.isDeletingFailed, + showReactionPicker: !message.isDeleted && !message.state.isDeletingFailed, padding: const EdgeInsets.symmetric(horizontal: 8), showInChannelIndicator: showInChannelIndicator, showThreadReplyIndicator: showThreadReplyIndicator, @@ -1396,13 +1383,6 @@ class _StreamMessageListViewState extends State { : _streamTheme.otherMessageTheme, onMessageTap: widget.onMessageTap, onMessageLongPress: widget.onMessageLongPress, - showPinButton: currentUserMember != null && - streamChannel?.channel.canPinMessage == true && - // Pinning a restricted visibility message is not allowed, simply - // because pinning a message is meant to bring attention to that - // message, that is not possible with a message that is only visible - // to a subset of users. - !message.hasRestrictedVisibility, ); if (widget.messageBuilder != null) { diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_action_confirmation_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_action_confirmation_modal.dart new file mode 100644 index 0000000000..ec79afc166 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_action_confirmation_modal.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/adaptive_dialog_action.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// {@template streamMessageActionConfirmationModal} +/// A confirmation modal dialog for message actions in Stream Chat. +/// +/// This widget creates a platform-adaptive confirmation dialog that can be used +/// when a user attempts to perform an action on a message that requires +/// confirmation (like delete, flag, etc). +/// +/// The dialog presents two options: cancel and confirm, with customizable text +/// for both actions. The confirm action can be styled as destructive for +/// actions like deletion. +/// +/// Example usage: +/// +/// ```dart +/// showDialog( +/// context: context, +/// builder: (context) => StreamMessageActionConfirmationModal( +/// title: Text('Delete Message'), +/// content: Text('Are you sure you want to delete this message?'), +/// confirmActionTitle: Text('Delete'), +/// isDestructiveAction: true, +/// ), +/// ).then((confirmed) { +/// if (confirmed == true) { +/// // Perform the action +/// } +/// }); +/// ``` +/// {@endtemplate} +class StreamMessageActionConfirmationModal extends StatelessWidget { + /// Creates a message action confirmation modal. + /// + /// The [cancelActionTitle] defaults to a Text widget with 'Cancel'. + /// The [confirmActionTitle] defaults to a Text widget with 'Confirm'. + /// Set [isDestructiveAction] to true for actions like deletion that should + /// be highlighted as destructive. + const StreamMessageActionConfirmationModal({ + super.key, + this.title, + this.content, + this.cancelActionTitle = const Text('Cancel'), + this.confirmActionTitle = const Text('Confirm'), + this.isDestructiveAction = false, + }); + + /// The title of the dialog. + /// + /// Typically a [Text] widget. + final Widget? title; + + /// The content of the dialog, displayed below the title. + /// + /// Typically a [Text] widget that provides more details about the action. + final Widget? content; + + /// The widget to display as the cancel action button. + /// + /// Defaults to a [Text] widget with 'Cancel'. + /// When pressed, this action dismisses the dialog and returns false. + final Widget cancelActionTitle; + + /// The widget to display as the confirm action button. + /// + /// Defaults to a [Text] widget with 'Confirm'. + /// When pressed, this action dismisses the dialog and returns true. + final Widget confirmActionTitle; + + /// Whether the confirm action is destructive (like deletion). + /// + /// When true, the confirm action will be styled accordingly + /// (e.g., in red on iOS/macOS). + final bool isDestructiveAction; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + final textTheme = theme.textTheme; + + final actions = [ + AdaptiveDialogAction( + onPressed: () => Navigator.of(context).maybePop(false), + child: cancelActionTitle, + ), + AdaptiveDialogAction( + onPressed: () => Navigator.of(context).maybePop(true), + isDefaultAction: true, + isDestructiveAction: isDestructiveAction, + child: confirmActionTitle, + ), + ]; + + return AlertDialog.adaptive( + clipBehavior: Clip.antiAlias, + backgroundColor: colorTheme.barsBg, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: title, + titleTextStyle: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + content: content, + contentTextStyle: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + actions: actions, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart new file mode 100644 index 0000000000..5b45f9360f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_align.dart'; + +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template streamMessageActionsModal} +/// A modal that displays a list of actions that can be performed on a message. +/// +/// This widget presents a customizable menu of actions for a message, such as +/// reply, edit, delete, etc., along with an optional reaction picker. +/// +/// Typically used when a user long-presses on a message to see available +/// actions. +/// {@endtemplate} +class StreamMessageActionsModal extends StatelessWidget { + /// {@macro streamMessageActionsModal} + const StreamMessageActionsModal({ + super.key, + required this.message, + required this.messageActions, + required this.messageWidget, + this.reverse = false, + this.showReactionPicker = false, + this.onActionTap, + }); + + /// The message object that actions will be performed on. + /// + /// This is the message the user selected to see available actions. + final Message message; + + /// List of custom actions that will be displayed in the modal. + /// + /// Each action is represented by a [StreamMessageAction] object which defines + /// the action's appearance and behavior. + final List messageActions; + + /// The widget representing the message being acted upon. + /// + /// This is typically displayed at the top of the modal as a reference for the + /// user. + final Widget messageWidget; + + /// Whether the message should be displayed in reverse direction. + /// + /// This affects how the modal and reactions are displayed and aligned. + /// Set to `true` for right-aligned messages (typically the current user's). + /// Set to `false` for left-aligned messages (typically other users'). + /// + /// Defaults to `false`. + final bool reverse; + + /// Controls whether to show the reaction picker at the top of the modal. + /// + /// When `true`, users can add reactions directly from the modal. + /// When `false`, the reaction picker is hidden. + /// + /// Defaults to `false`. + final bool showReactionPicker; + + /// Callback triggered when a message action is tapped. + /// + /// Provides the tapped [MessageAction] object to the callback. + final OnMessageActionTap? onActionTap; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + final Widget? reactionPicker = switch (showReactionPicker) { + false => null, + true => LayoutBuilder( + builder: (context, constraints) { + final orientation = MediaQuery.of(context).orientation; + final messageTheme = theme.getMessageTheme(reverse: reverse); + final messageFontSize = messageTheme.messageTextStyle?.fontSize; + + final alignment = message.calculateReactionPickerAlignment( + constraints: constraints, + fontSize: messageFontSize, + orientation: orientation, + reverse: reverse, + ); + + final onReactionPicked = switch (onActionTap) { + null => null, + final onActionTap => (reaction) { + return onActionTap.call( + SelectReaction(message: message, reaction: reaction), + ); + }, + }; + + final config = StreamChatConfiguration.of(context); + final reactionIcons = config.reactionIcons; + + return Align( + alignment: alignment, + child: StreamReactionPicker( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: onReactionPicked, + ), + ); + }, + ), + }; + + final alignment = switch (reverse) { + true => AlignmentDirectional.centerEnd, + false => AlignmentDirectional.centerStart, + }; + + return StreamMessageModal( + alignment: alignment, + headerBuilder: (context) { + return Column( + spacing: 10, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: alignment.toColumnCrossAxisAlignment(), + children: [ + reactionPicker, + IgnorePointer(child: messageWidget), + ].nonNulls.toList(growable: false), + ); + }, + contentBuilder: (context) { + final actions = Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: { + ...messageActions.map( + (action) => StreamMessageActionItem( + action: action, + onTap: onActionTap, + ), + ), + }.insertBetween(Divider(height: 1, color: theme.colorTheme.borders)), + ); + + return FractionallySizedBox( + widthFactor: 0.78, + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: actions, + ), + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_modal.dart new file mode 100644 index 0000000000..377d162857 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_modal.dart @@ -0,0 +1,165 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; + +/// Shows a modal dialog with customized transitions and backdrop effects. +/// +/// This function is a wrapper around [showGeneralDialog] that provides +/// a consistent look and feel for message-related modals in Stream Chat. +/// +/// Returns a [Future] that resolves to the value passed to [Navigator.pop] +/// when the dialog is closed. +Future showStreamMessageModal({ + required BuildContext context, + required WidgetBuilder builder, + bool useSafeArea = true, + bool barrierDismissible = true, + String? barrierLabel, + Color? barrierColor, + Duration transitionDuration = const Duration(milliseconds: 300), + RouteTransitionsBuilder? transitionBuilder, + bool useRootNavigator = true, + RouteSettings? routeSettings, + Offset? anchorPoint, +}) { + assert(debugCheckHasMaterialLocalizations(context), ''); + final localizations = MaterialLocalizations.of(context); + + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + final capturedThemes = InheritedTheme.capture( + from: context, + to: Navigator.of(context, rootNavigator: useRootNavigator).context, + ); + + return showGeneralDialog( + context: context, + useRootNavigator: useRootNavigator, + anchorPoint: anchorPoint, + routeSettings: routeSettings, + transitionDuration: transitionDuration, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor ?? colorTheme.overlay, + barrierLabel: barrierLabel ?? localizations.modalBarrierDismissLabel, + transitionBuilder: (context, animation, secondaryAnimation, child) { + final sigma = 10 * animation.value; + final scaleAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation(parent: animation, curve: Curves.easeInOutBack), + ); + + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), + child: ScaleTransition(scale: scaleAnimation, child: child), + ); + }, + pageBuilder: (context, animation, secondaryAnimation) { + final pageChild = Builder(builder: builder); + + var dialog = capturedThemes.wrap(pageChild); + if (useSafeArea) dialog = SafeArea(child: dialog); + return dialog; + }, + ); +} + +/// {@template streamMessageModal} +/// A customizable modal widget for displaying message-related content. +/// +/// This widget provides a consistent container for message actions and other +/// message-related modal content. It handles layout, animation, and keyboard +/// adjustments automatically. +/// +/// The modal can contain a header (optional) and content section (required), +/// and will adjust its position when the keyboard appears. +/// {@endtemplate} +class StreamMessageModal extends StatelessWidget { + /// Creates a Stream message modal. + /// + /// The [contentBuilder] parameter is required to build the main content + /// of the modal. The [headerBuilder] is optional and can be used to add + /// a header above the main content. + const StreamMessageModal({ + super.key, + this.spacing = 8.0, + this.headerBuilder, + required this.contentBuilder, + this.insetAnimationDuration = const Duration(milliseconds: 100), + this.insetAnimationCurve = Curves.decelerate, + this.insetPadding = const EdgeInsets.all(8), + this.alignment = Alignment.center, + }); + + /// Vertical spacing between header and content sections. + final double spacing; + + /// Optional builder for the header section of the modal. + final WidgetBuilder? headerBuilder; + + /// Required builder for the main content of the modal. + final WidgetBuilder contentBuilder; + + /// The duration of the animation to show when the system keyboard intrudes + /// into the space that the modal is placed in. + /// + /// Defaults to 100 milliseconds. + final Duration insetAnimationDuration; + + /// The curve to use for the animation shown when the system keyboard intrudes + /// into the space that the modal is placed in. + /// + /// Defaults to [Curves.decelerate]. + final Curve insetAnimationCurve; + + /// The amount of padding added to [MediaQueryData.viewInsets] on the outside + /// of the modal. This defines the minimum space between the screen's edges + /// and the modal. + /// + /// Defaults to `EdgeInsets.zero`. + final EdgeInsets insetPadding; + + /// How to align the [StreamMessageModal]. + /// + /// Defaults to [Alignment.center]. + final AlignmentGeometry alignment; + + @override + Widget build(BuildContext context) { + final effectivePadding = MediaQuery.viewInsetsOf(context) + insetPadding; + + final child = Align( + alignment: alignment, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 280), + child: Material( + type: MaterialType.transparency, + child: Column( + spacing: spacing, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: alignment.toColumnCrossAxisAlignment(), + children: [ + if (headerBuilder case final builder?) builder(context), + contentBuilder(context), + ], + ), + ), + ), + ); + + return AnimatedPadding( + padding: effectivePadding, + duration: insetAnimationDuration, + curve: insetAnimationCurve, + child: MediaQuery.removeViewInsets( + removeLeft: true, + removeTop: true, + removeRight: true, + removeBottom: true, + context: context, + child: child, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart new file mode 100644 index 0000000000..ef230bc596 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_align.dart'; +import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_card.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template streamMessageReactionsModal} +/// A modal that displays message reactions and allows users to add reactions. +/// +/// This modal contains: +/// 1. A reaction picker (optional) that appears at the top +/// 2. The original message widget +/// 3. A display of all current reactions with user avatars +/// +/// The modal uses [StreamMessageModal] as its base layout and customizes +/// both the header and content sections to display reaction-specific +/// information. +/// {@endtemplate} +class StreamMessageReactionsModal extends StatelessWidget { + /// {@macro streamMessageReactionsModal} + const StreamMessageReactionsModal({ + super.key, + required this.message, + required this.messageWidget, + this.reverse = false, + this.showReactionPicker = true, + this.onReactionPicked, + this.onUserAvatarTap, + }); + + /// The message for which to display and manage reactions. + final Message message; + + /// The original message widget that will be displayed in the modal. + final Widget messageWidget; + + /// Whether the message should be displayed in reverse direction. + /// + /// This affects how the modal and reactions are displayed and aligned. + /// Set to `true` for right-aligned messages (typically the current user's). + /// Set to `false` for left-aligned messages (typically other users'). + /// + /// Defaults to `false`. + final bool reverse; + + /// Controls whether to show the reaction picker at the top of the modal. + /// + /// When `true`, users can add reactions directly from the modal. + /// When `false`, the reaction picker is hidden. + final bool showReactionPicker; + + /// Callback triggered when a user adds or toggles a reaction. + /// + /// Provides the selected [Reaction] object to the callback. + final OnMessageActionTap? onReactionPicked; + + /// Callback triggered when a user avatar is tapped in the reactions list. + /// + /// Provides the [User] object associated with the tapped avatar. + final void Function(User)? onUserAvatarTap; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final messageTheme = theme.getMessageTheme(reverse: reverse); + + final Widget? reactionPicker = switch (showReactionPicker) { + false => null, + true => LayoutBuilder( + builder: (context, constraints) { + final orientation = MediaQuery.of(context).orientation; + final messageFontSize = messageTheme.messageTextStyle?.fontSize; + + final alignment = message.calculateReactionPickerAlignment( + constraints: constraints, + fontSize: messageFontSize, + orientation: orientation, + reverse: reverse, + ); + + final onReactionPicked = switch (this.onReactionPicked) { + null => null, + final onPicked => (reaction) { + return onPicked.call( + SelectReaction(message: message, reaction: reaction), + ); + }, + }; + + final config = StreamChatConfiguration.of(context); + final reactionIcons = config.reactionIcons; + + return Align( + alignment: alignment, + child: StreamReactionPicker( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: onReactionPicked, + ), + ); + }, + ), + }; + + final alignment = switch (reverse) { + true => AlignmentDirectional.centerEnd, + false => AlignmentDirectional.centerStart, + }; + + return StreamMessageModal( + alignment: alignment, + headerBuilder: (context) { + return Column( + spacing: 10, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: alignment.toColumnCrossAxisAlignment(), + children: [ + reactionPicker, + IgnorePointer(child: messageWidget), + ].nonNulls.toList(growable: false), + ); + }, + contentBuilder: (context) { + final currentUser = StreamChat.of(context).currentUser; + if (currentUser == null) return const Empty(); + + final reactions = message.latestReactions; + final hasReactions = reactions != null && reactions.isNotEmpty; + if (!hasReactions) return const Empty(); + + return FractionallySizedBox( + widthFactor: 0.78, + child: ReactionsCard( + message: message, + currentUser: currentUser, + messageTheme: messageTheme, + onUserAvatarTap: onUserAvatarTap, + ), + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_modal/moderated_message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/moderated_message_actions_modal.dart new file mode 100644 index 0000000000..da655979a7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_modal/moderated_message_actions_modal.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/icons/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/message_action/message_action.dart'; +import 'package:stream_chat_flutter/src/misc/adaptive_dialog_action.dart'; +import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@template moderatedMessageActionsModal} +/// A modal that is shown when a message is flagged by moderation policies. +/// +/// This modal allows users to: +/// - Send the message anyway, overriding the moderation warning +/// - Edit the message to comply with community guidelines +/// - Delete the message +/// +/// The modal provides clear guidance to users about the moderation issue +/// and options to address it. +/// {@endtemplate} +class ModeratedMessageActionsModal extends StatelessWidget { + /// {@macro moderatedMessageActionsModal} + const ModeratedMessageActionsModal({ + super.key, + required this.message, + required this.messageActions, + this.onActionTap, + }); + + /// The message object that actions will be performed on. + /// + /// This is the message the user selected to see available actions. + final Message message; + + /// List of custom actions that will be displayed in the modal. + /// + /// Each action is represented by a [StreamMessageAction] object which defines + /// the action's appearance and behavior. + final List messageActions; + + /// Callback triggered when a moderated message action is tapped. + /// + /// Provides the tapped [MessageAction] object to the callback. + final OnMessageActionTap? onActionTap; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + final actions = [ + ...messageActions.map( + (action) => AdaptiveDialogAction( + onPressed: switch (onActionTap) { + final onTap? => () => onTap.call(action.action), + _ => null, + }, + isDestructiveAction: action.isDestructive, + child: action.title ?? const Empty(), + ), + ), + ]; + + return AlertDialog.adaptive( + clipBehavior: Clip.antiAlias, + backgroundColor: colorTheme.barsBg, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + icon: const StreamSvgIcon(icon: StreamSvgIcons.flag), + iconColor: colorTheme.accentPrimary, + title: Text(context.translations.moderationReviewModalTitle), + titleTextStyle: textTheme.headline.copyWith( + color: colorTheme.textHighEmphasis, + ), + content: Text( + context.translations.moderationReviewModalDescription, + textAlign: TextAlign.center, + ), + contentTextStyle: textTheme.body.copyWith( + color: colorTheme.textLowEmphasis, + ), + actions: actions, + actionsAlignment: MainAxisAlignment.center, + actionsOverflowAlignment: OverflowBarAlignment.center, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart index 076cb4117d..4f27668bb3 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart @@ -39,10 +39,9 @@ class StreamEphemeralMessage extends StatelessWidget { child: GiphyEphemeralMessage( message: message, onActionPressed: (name, value) { - streamChannel.channel.sendAction( - message, - {name: value}, - ); + return streamChannel.channel.sendAction(message, { + name: value, + }).ignore(); }, ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index b8ac368678..87cba1c2e0 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -1,14 +1,11 @@ +import 'dart:async'; + import 'package:contextmenu/contextmenu.dart'; import 'package:flutter/material.dart' hide ButtonStyle; import 'package:flutter/services.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/conditional_parent_builder/conditional_parent_builder.dart'; import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/context_menu_reaction_picker.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart'; -import 'package:stream_chat_flutter/src/dialogs/dialogs.dart'; -import 'package:stream_chat_flutter/src/message_actions_modal/message_actions_modal.dart'; -import 'package:stream_chat_flutter/src/message_actions_modal/moderated_message_actions_modal.dart'; import 'package:stream_chat_flutter/src/message_widget/message_widget_content.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -97,6 +94,7 @@ class StreamMessageWidget extends StatefulWidget { this.widthFactor = 0.78, this.onQuotedMessageTap, this.customActions = const [], + this.onCustomActionTap, this.onAttachmentTap, this.imageAttachmentThumbnailSize = const Size(400, 400), this.imageAttachmentThumbnailResizeType = 'clip', @@ -256,7 +254,7 @@ class StreamMessageWidget extends StatefulWidget { /// {@template showReactionPicker} /// Whether or not to show the reaction picker. - /// Used in [StreamMessageReactionsModal] and [MessageActionsModal]. + /// Used in [StreamMessageReactionsModal] and [StreamMessageActionsModal]. /// {@endtemplate} final bool showReactionPicker; @@ -375,6 +373,11 @@ class StreamMessageWidget extends StatefulWidget { /// {@endtemplate} final List customActions; + /// Callback for when a custom message action is tapped. + /// + /// {@macro onMessageActionTap} + final OnMessageActionTap? onCustomActionTap; + /// {@macro onMessageWidgetAttachmentTap} final StreamAttachmentWidgetTapCallback? onAttachmentTap; @@ -456,6 +459,7 @@ class StreamMessageWidget extends StatefulWidget { OnReactionsTap? onReactionsTap, OnReactionsHover? onReactionsHover, List? customActions, + OnMessageActionTap? onCustomActionTap, void Function(Message message, Attachment attachment)? onAttachmentTap, Widget Function(BuildContext, User)? userAvatarBuilder, Size? imageAttachmentThumbnailSize, @@ -524,6 +528,7 @@ class StreamMessageWidget extends StatefulWidget { onReactionsTap: onReactionsTap ?? this.onReactionsTap, onReactionsHover: onReactionsHover ?? this.onReactionsHover, customActions: customActions ?? this.customActions, + onCustomActionTap: onCustomActionTap ?? this.onCustomActionTap, onAttachmentTap: onAttachmentTap ?? this.onAttachmentTap, userAvatarBuilder: userAvatarBuilder ?? this.userAvatarBuilder, imageAttachmentThumbnailSize: @@ -640,54 +645,20 @@ class _StreamMessageWidgetState extends State (widget.message.latestReactions?.isNotEmpty == true) && !widget.message.isDeleted; - bool get shouldShowReplyAction => - widget.showReplyMessage && !isFailedState && widget.onReplyTap != null; - - bool get shouldShowEditAction => - widget.showEditMessage && - !isDeleteFailed && - !hasPoll && - !widget.message.attachments - .any((element) => element.type == AttachmentType.giphy); - - bool get shouldShowResendAction => - widget.showResendMessage && (isSendFailed || isUpdateFailed); - - bool get shouldShowCopyAction => - widget.showCopyMessage && - !isFailedState && - widget.message.text?.trim().isNotEmpty == true; - - bool get shouldShowThreadReplyAction => - widget.showThreadReplyMessage && - !isFailedState && - widget.onThreadTap != null; - - bool get shouldShowDeleteAction => widget.showDeleteMessage || isDeleteFailed; - @override bool get wantKeepAlive => widget.message.attachments.isNotEmpty; - late StreamChatThemeData _streamChatTheme; - late StreamChatState _streamChat; - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _streamChatTheme = StreamChatTheme.of(context); - _streamChat = StreamChat.of(context); - } - @override Widget build(BuildContext context) { super.build(context); + final theme = StreamChatTheme.of(context); + final streamChat = StreamChat.of(context); + final avatarWidth = widget.messageTheme.avatarTheme?.constraints.maxWidth ?? 40; final bottomRowPadding = widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; - final showReactions = shouldShowReactions; - return ConditionalParentBuilder( builder: (context, child) { final message = widget.message; @@ -703,107 +674,102 @@ class _StreamMessageWidgetState extends State ); }, child: Material( - type: MaterialType.transparency, - child: AnimatedContainer( - duration: const Duration(seconds: 1), - color: isPinned && widget.showPinHighlight - ? _streamChatTheme.colorTheme.highlight - // ignore: deprecated_member_use - : _streamChatTheme.colorTheme.barsBg.withOpacity(0), - child: Portal( - child: PlatformWidgetBuilder( - mobile: (context, child) { - final message = widget.message; - return InkWell( - onTap: switch (widget.onMessageTap) { - final onTap? => () => onTap(message), - _ => null, - }, - onLongPress: switch (widget.onMessageLongPress) { - final onLongPress? => () => onLongPress(message), - // If the message is not yet sent or deleted, we don't want - // to handle long press events by default. - _ when message.state.isDeleted => null, - _ when message.state.isOutgoing => null, - _ => () => _onMessageLongPressed(context, message), - }, - child: child, - ); - }, - desktop: (_, child) => MouseRegion(child: child), - web: (_, child) => MouseRegion(child: child), - child: Padding( - padding: widget.padding ?? const EdgeInsets.all(8), - child: FractionallySizedBox( - alignment: widget.reverse - ? Alignment.centerRight - : Alignment.centerLeft, - widthFactor: widget.widthFactor, - child: Builder(builder: (context) { - return MessageWidgetContent( - streamChatTheme: _streamChatTheme, - showUsername: showUsername, - showTimeStamp: showTimeStamp, - showEditedLabel: showEditedLabel, - showThreadReplyIndicator: showThreadReplyIndicator, - showSendingIndicator: showSendingIndicator, - showInChannel: showInChannel, - isGiphy: isGiphy, - isOnlyEmoji: isOnlyEmoji, - hasUrlAttachments: hasUrlAttachments, - messageTheme: widget.messageTheme, - reverse: widget.reverse, - message: widget.message, - hasNonUrlAttachments: hasNonUrlAttachments, - hasPoll: hasPoll, - hasQuotedMessage: hasQuotedMessage, - textPadding: widget.textPadding, - attachmentBuilders: widget.attachmentBuilders, - attachmentPadding: widget.attachmentPadding, - attachmentShape: widget.attachmentShape, - onAttachmentTap: widget.onAttachmentTap, - onReplyTap: widget.onReplyTap, - onThreadTap: widget.onThreadTap, - onShowMessage: widget.onShowMessage, - attachmentActionsModalBuilder: - widget.attachmentActionsModalBuilder, - avatarWidth: avatarWidth, - bottomRowPadding: bottomRowPadding, - isFailedState: isFailedState, - isPinned: isPinned, - messageWidget: widget, - showBottomRow: showBottomRow, - showPinHighlight: widget.showPinHighlight, - showReactionPickerTail: calculateReactionTailEnabled( - ReactionTailType.list, - ), - showReactions: showReactions, - onReactionsTap: () { - final message = widget.message; - return switch (widget.onReactionsTap) { - final onReactionsTap? => onReactionsTap(message), - _ => _showMessageReactionsModal(context, message), - }; - }, - onReactionsHover: widget.onReactionsHover, - showUserAvatar: widget.showUserAvatar, - streamChat: _streamChat, - translateUserAvatar: widget.translateUserAvatar, - shape: widget.shape, - borderSide: widget.borderSide, - borderRadiusGeometry: widget.borderRadiusGeometry, - textBuilder: widget.textBuilder, - quotedMessageBuilder: widget.quotedMessageBuilder, - onLinkTap: widget.onLinkTap, - onMentionTap: widget.onMentionTap, - onQuotedMessageTap: widget.onQuotedMessageTap, - bottomRowBuilderWithDefaultWidget: - widget.bottomRowBuilderWithDefaultWidget, - onUserAvatarTap: widget.onUserAvatarTap, - userAvatarBuilder: widget.userAvatarBuilder, - ); - }), - ), + color: switch (isPinned && widget.showPinHighlight) { + true => theme.colorTheme.highlight, + false => Colors.transparent, + }, + child: Portal( + child: PlatformWidgetBuilder( + mobile: (context, child) { + final message = widget.message; + return InkWell( + onTap: switch (widget.onMessageTap) { + final onTap? => () => onTap(message), + _ => null, + }, + onLongPress: switch (widget.onMessageLongPress) { + final onLongPress? => () => onLongPress(message), + // If the message is not yet sent or deleted, we don't want + // to handle long press events by default. + _ when message.state.isDeleted => null, + _ when message.state.isOutgoing => null, + _ => () => _onMessageLongPressed(context, message), + }, + child: child, + ); + }, + desktop: (_, child) => MouseRegion(child: child), + web: (_, child) => MouseRegion(child: child), + child: Padding( + padding: widget.padding ?? const EdgeInsets.all(8), + child: FractionallySizedBox( + alignment: switch (widget.reverse) { + true => Alignment.centerRight, + false => Alignment.centerLeft, + }, + widthFactor: widget.widthFactor, + child: Builder(builder: (context) { + return MessageWidgetContent( + streamChatTheme: theme, + showUsername: showUsername, + showTimeStamp: showTimeStamp, + showEditedLabel: showEditedLabel, + showThreadReplyIndicator: showThreadReplyIndicator, + showSendingIndicator: showSendingIndicator, + showInChannel: showInChannel, + isGiphy: isGiphy, + isOnlyEmoji: isOnlyEmoji, + hasUrlAttachments: hasUrlAttachments, + messageTheme: widget.messageTheme, + reverse: widget.reverse, + message: widget.message, + hasNonUrlAttachments: hasNonUrlAttachments, + hasPoll: hasPoll, + hasQuotedMessage: hasQuotedMessage, + textPadding: widget.textPadding, + attachmentBuilders: widget.attachmentBuilders, + attachmentPadding: widget.attachmentPadding, + attachmentShape: widget.attachmentShape, + onAttachmentTap: widget.onAttachmentTap, + onReplyTap: widget.onReplyTap, + onThreadTap: widget.onThreadTap, + onShowMessage: widget.onShowMessage, + attachmentActionsModalBuilder: + widget.attachmentActionsModalBuilder, + avatarWidth: avatarWidth, + bottomRowPadding: bottomRowPadding, + isFailedState: isFailedState, + isPinned: isPinned, + messageWidget: widget, + showBottomRow: showBottomRow, + showPinHighlight: widget.showPinHighlight, + showReactionPickerTail: widget.showReactionTail == true, + showReactions: shouldShowReactions, + onReactionsTap: () { + final message = widget.message; + return switch (widget.onReactionsTap) { + final onReactionsTap? => onReactionsTap(message), + _ => _showMessageReactionsModal(context, message), + }; + }, + onReactionsHover: widget.onReactionsHover, + showUserAvatar: widget.showUserAvatar, + streamChat: streamChat, + translateUserAvatar: widget.translateUserAvatar, + shape: widget.shape, + borderSide: widget.borderSide, + borderRadiusGeometry: widget.borderRadiusGeometry, + textBuilder: widget.textBuilder, + quotedMessageBuilder: widget.quotedMessageBuilder, + onLinkTap: widget.onLinkTap, + onMentionTap: widget.onMentionTap, + onQuotedMessageTap: widget.onQuotedMessageTap, + bottomRowBuilderWithDefaultWidget: + widget.bottomRowBuilderWithDefaultWidget, + onUserAvatarTap: widget.onUserAvatarTap, + userAvatarBuilder: widget.userAvatarBuilder, + ); + }), ), ), ), @@ -812,6 +778,49 @@ class _StreamMessageWidgetState extends State ); } + List _buildBouncedErrorMessageActions({ + required BuildContext context, + required Message message, + }) { + final actions = StreamMessageActionsBuilder.buildBouncedErrorActions( + context: context, + message: message, + ); + + return actions; + } + + List _buildMessageActions({ + required BuildContext context, + required Message message, + required Channel channel, + OwnUser? currentUser, + List? customActions, + }) { + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + customActions: customActions, + )..retainWhere( + (it) => switch (it.action) { + QuotedReply() => widget.showReplyMessage, + ThreadReply() => widget.showThreadReplyMessage, + MarkUnread() => widget.showMarkUnreadMessage, + ResendMessage() => widget.showResendMessage, + EditMessage() => widget.showEditMessage, + CopyMessage() => widget.showCopyMessage, + FlagMessage() => widget.showFlagButton, + PinMessage() => widget.showPinButton, + DeleteMessage() => widget.showDeleteMessage, + _ => true, // Retain all the remaining actions. + }, + ); + + return actions; + } + List _buildDesktopOrWebActions( BuildContext context, Message message, @@ -827,48 +836,23 @@ class _StreamMessageWidgetState extends State BuildContext context, Message message, ) { - final theme = StreamChatTheme.of(context); final channel = StreamChannel.of(context).channel; + final actions = _buildBouncedErrorMessageActions( + context: context, + message: message, + ); + return [ - StreamChatContextMenuItem( - leading: StreamSvgIcon( - icon: StreamSvgIcons.circleUp, - color: theme.colorTheme.accentPrimary, - ), - title: Text(context.translations.sendAnywayLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - channel.sendMessage(message).ignore(); - }, - ), - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.edit), - title: Text(context.translations.editMessageLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - showEditMessageSheet( - context: context, - channel: channel, - message: message, - editMessageInputBuilder: widget.editMessageInputBuilder, - ); - }, - ), - StreamChatContextMenuItem( - leading: StreamSvgIcon( - icon: StreamSvgIcons.delete, - color: theme.colorTheme.accentError, - ), - title: Text( - context.translations.deleteMessageLabel, - style: TextStyle(color: theme.colorTheme.accentError), - ), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - channel.deleteMessage(message, hard: true).ignore(); - }, - ), + ...actions.map((action) { + return StreamMessageActionItem( + action: action, + onTap: (action) async { + final popped = await Navigator.of(context).maybePop(); + if (popped) return _onActionTap(context, channel, action).ignore(); + }, + ); + }), ]; } @@ -876,203 +860,86 @@ class _StreamMessageWidgetState extends State BuildContext context, Message message, ) { - final theme = StreamChatTheme.of(context); final channel = StreamChannel.of(context).channel; + final currentUser = channel.client.state.currentUser; + final showPicker = widget.showReactionPicker && channel.canSendReaction; + + final actions = _buildMessageActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + customActions: widget.customActions, + ); + + void onActionTap(MessageAction action) async { + final popped = await Navigator.of(context).maybePop(); + if (popped) return _onActionTap(context, channel, action).ignore(); + } return [ - if (widget.showReactionPicker) - StreamChatContextMenuItem( - child: StreamChannel( - channel: channel, - child: ContextMenuReactionPicker(message: message), - ), - ), - if (shouldShowReplyAction) ...[ - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.reply), - title: Text(context.translations.replyLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - widget.onReplyTap?.call(message); - }, - ), - ], - if (widget.showMarkUnreadMessage) - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.messageUnread), - title: Text(context.translations.markAsUnreadLabel), - onClick: () async { - Navigator.of(context, rootNavigator: true).pop(); - try { - await channel.markUnread(message.id); - } catch (ex) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - context.translations.markUnreadError, - ), - ), - ); - } - }, - ), - if (shouldShowThreadReplyAction) - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.threadReply), - title: Text(context.translations.threadReplyLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - widget.onThreadTap?.call(message); - }, - ), - if (shouldShowCopyAction) - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.copy), - title: Text(context.translations.copyMessageLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - final copiedMessage = message.replaceMentions(linkify: false); - if (copiedMessage.text case final text?) { - Clipboard.setData(ClipboardData(text: text)); - } - }, - ), - if (shouldShowEditAction) ...[ - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.edit), - title: Text(context.translations.editMessageLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - showEditMessageSheet( - context: context, - channel: channel, - message: message, - editMessageInputBuilder: widget.editMessageInputBuilder, - ); - }, - ), - ], - if (widget.showPinButton) - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.pin), - title: Text( - context.translations.togglePinUnpinText(pinned: isPinned), - ), - onClick: () async { - Navigator.of(context, rootNavigator: true).pop(); - try { - await switch (isPinned) { - true => channel.unpinMessage(message), - false => channel.pinMessage(message), - }; - } catch (e) { - throw Exception(e); - } - }, - ), - if (shouldShowResendAction) - StreamChatContextMenuItem( - leading: const StreamSvgIcon(icon: StreamSvgIcons.sendMessage), - title: Text( - context.translations.toggleResendOrResendEditedMessage( - isUpdateFailed: message.state.isUpdatingFailed, - ), - ), - onClick: () async { - Navigator.of(context, rootNavigator: true).pop(); - await channel.retryMessage(message); - }, - ), - if (shouldShowDeleteAction) - StreamChatContextMenuItem( - leading: StreamSvgIcon( - color: theme.colorTheme.accentError, - icon: StreamSvgIcons.delete, - ), - title: Text( - context.translations.deleteMessageLabel, - style: TextStyle(color: theme.colorTheme.accentError), + if (showPicker) + StreamReactionPicker( + message: message, + scrollable: false, + borderRadius: BorderRadius.zero, + reactionIcons: StreamChatConfiguration.of(context).reactionIcons, + onReactionPicked: (reaction) => onActionTap( + SelectReaction(message: message, reaction: reaction), ), - onClick: () async { - Navigator.of(context, rootNavigator: true).pop(); - final deleted = await showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const DeleteMessageDialog(), - ); - if (deleted == true) { - try { - await switch (widget.onConfirmDeleteTap) { - final onConfirmDeleteTap? => onConfirmDeleteTap(message), - _ => channel.deleteMessage(message), - }; - } catch (e) { - showDialog( - context: context, - builder: (_) => const MessageDialog(), - ); - } - } - }, ), - ...widget.customActions.map( - (e) => StreamChatContextMenuItem( - leading: e.leading, - title: e.title, - onClick: () => e.onTap?.call(message), - ), - ), + ...actions.map((action) { + return StreamMessageActionItem( + action: action, + onTap: onActionTap, + ); + }), ]; } - void _showMessageReactionsModal( + Future _showMessageReactionsModal( BuildContext context, Message message, ) { final channel = StreamChannel.of(context).channel; + final showPicker = widget.showReactionPicker && channel.canSendReaction; - showDialog( - useRootNavigator: false, + return showStreamMessageModal( context: context, - useSafeArea: false, - barrierColor: _streamChatTheme.colorTheme.overlay, - builder: (context) => StreamChannel( - channel: channel, - child: StreamMessageReactionsModal( - message: message, - showReactionPicker: widget.showReactionPicker, - messageWidget: widget.copyWith( + useRootNavigator: false, + builder: (context) => StreamMessageReactionsModal( + message: message, + reverse: widget.reverse, + onUserAvatarTap: widget.onUserAvatarTap, + showReactionPicker: showPicker, + onReactionPicked: (action) async { + final popped = await Navigator.of(context).maybePop(); + if (popped) return _onActionTap(context, channel, action).ignore(); + }, + messageWidget: StreamChannel( + channel: channel, + child: widget.copyWith( key: const Key('MessageWidget'), - message: message.copyWith( - text: (message.text?.length ?? 0) > 200 - ? '${message.text!.substring(0, 200)}...' - : message.text, - ), + message: message.trimmed, showReactions: false, - showReactionTail: calculateReactionTailEnabled( - ReactionTailType.reactions, - ), showUsername: false, showTimestamp: false, translateUserAvatar: false, showSendingIndicator: false, padding: EdgeInsets.zero, - showReactionPicker: widget.showReactionPicker, showPinHighlight: false, - showUserAvatar: - message.user!.id == channel.client.state.currentUser!.id - ? DisplayWidget.gone - : DisplayWidget.show, + showReactionTail: showPicker, + showUserAvatar: switch (widget.reverse) { + true => DisplayWidget.gone, + false => DisplayWidget.show, + }, ), - onUserAvatarTap: widget.onUserAvatarTap, - messageTheme: widget.messageTheme, - reverse: widget.reverse, ), ), ); } - void _onMessageLongPressed( + Future _onMessageLongPressed( BuildContext context, Message message, ) { @@ -1083,10 +950,10 @@ class _StreamMessageWidgetState extends State return _onMessageActions(context, message); } - void _onBouncedErrorMessageActions( + Future _onBouncedErrorMessageActions( BuildContext context, Message message, - ) { + ) async { if (widget.onBouncedErrorMessageActions case final onActions?) { return onActions(context, message); } @@ -1094,42 +961,37 @@ class _StreamMessageWidgetState extends State return _showBouncedErrorMessageActionsDialog(context, message); } - void _showBouncedErrorMessageActionsDialog( + Future _showBouncedErrorMessageActionsDialog( BuildContext context, Message message, - ) { + ) async { final channel = StreamChannel.of(context).channel; - showDialog( + final actions = _buildBouncedErrorMessageActions( context: context, - builder: (context) { - return ModeratedMessageActionsModal( - onSendAnyway: () { - Navigator.of(context).pop(); - channel.sendMessage(widget.message).ignore(); - }, - onEditMessage: () { - Navigator.of(context).pop(); - showEditMessageSheet( - context: context, - channel: channel, - message: widget.message, - editMessageInputBuilder: widget.editMessageInputBuilder, - ); - }, - onDeleteMessage: () { - Navigator.of(context).pop(); - channel.deleteMessage(message, hard: true).ignore(); - }, - ); - }, + message: message, + ); + + void onActionTap(MessageAction action) async { + final popped = await Navigator.of(context).maybePop(); + if (popped) return _onActionTap(context, channel, action).ignore(); + } + + return showStreamMessageModal( + context: context, + useRootNavigator: false, + builder: (_) => ModeratedMessageActionsModal( + message: message, + messageActions: actions, + onActionTap: onActionTap, + ), ); } - void _onMessageActions( + Future _onMessageActions( BuildContext context, Message message, - ) { + ) async { if (widget.onMessageActions case final onActions?) { return onActions(context, message); } @@ -1137,107 +999,193 @@ class _StreamMessageWidgetState extends State return _showMessageActionModalDialog(context, message); } - void _showMessageActionModalDialog( + Future _showMessageActionModalDialog( BuildContext context, Message message, ) { final channel = StreamChannel.of(context).channel; + final currentUser = channel.client.state.currentUser; + final showPicker = widget.showReactionPicker && channel.canSendReaction; - showDialog( - useRootNavigator: false, + final actions = _buildMessageActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + customActions: widget.customActions, + ); + + void onActionTap(MessageAction action) async { + final popped = await Navigator.of(context).maybePop(); + if (popped) return _onActionTap(context, channel, action).ignore(); + } + + return showStreamMessageModal( context: context, - useSafeArea: false, - barrierColor: _streamChatTheme.colorTheme.overlay, - builder: (context) { - return StreamChannel( + useRootNavigator: false, + builder: (context) => StreamMessageActionsModal( + message: message, + reverse: widget.reverse, + messageActions: actions, + showReactionPicker: showPicker, + onActionTap: onActionTap, + messageWidget: StreamChannel( channel: channel, - child: MessageActionsModal( - message: message, - messageWidget: widget.copyWith( - key: const Key('MessageWidget'), - message: message.copyWith( - text: (message.text?.length ?? 0) > 200 - ? '${message.text!.substring(0, 200)}...' - : message.text, - ), - showReactions: false, - showReactionTail: calculateReactionTailEnabled( - ReactionTailType.messageActions, - ), - showUsername: false, - showTimestamp: false, - translateUserAvatar: false, - showSendingIndicator: false, - padding: EdgeInsets.zero, - showPinHighlight: false, - showUserAvatar: - message.user!.id == channel.client.state.currentUser!.id - ? DisplayWidget.gone - : DisplayWidget.show, - ), - onEditMessageTap: (message) { - Navigator.of(context).pop(); - showEditMessageSheet( - context: context, - channel: channel, - message: message, - editMessageInputBuilder: widget.editMessageInputBuilder, - ); - }, - onCopyTap: (message) { - Navigator.of(context).pop(); - final copiedMessage = message.replaceMentions(linkify: false); - if (copiedMessage.text case final text?) { - Clipboard.setData(ClipboardData(text: text)); - } + child: widget.copyWith( + key: const Key('MessageWidget'), + message: message.trimmed, + showReactions: false, + showUsername: false, + showTimestamp: false, + translateUserAvatar: false, + showSendingIndicator: false, + padding: EdgeInsets.zero, + showPinHighlight: false, + showReactionTail: showPicker, + showUserAvatar: switch (widget.reverse) { + true => DisplayWidget.gone, + false => DisplayWidget.show, }, - messageTheme: widget.messageTheme, - reverse: widget.reverse, - showDeleteMessage: shouldShowDeleteAction, - onConfirmDeleteTap: widget.onConfirmDeleteTap, - editMessageInputBuilder: widget.editMessageInputBuilder, - onReplyTap: widget.onReplyTap, - onThreadReplyTap: widget.onThreadTap, - showResendMessage: shouldShowResendAction, - showCopyMessage: shouldShowCopyAction, - showEditMessage: shouldShowEditAction, - showReactionPicker: widget.showReactionPicker, - showReplyMessage: shouldShowReplyAction, - showThreadReplyMessage: shouldShowThreadReplyAction, - showFlagButton: widget.showFlagButton, - showPinButton: widget.showPinButton, - showMarkUnreadMessage: widget.showMarkUnreadMessage, - customActions: widget.customActions, ), - ); - }, + ), + ), + ); + } + + Future _onActionTap( + BuildContext context, + Channel channel, + MessageAction action, + ) async { + return switch (action) { + SelectReaction() => + _selectReaction(context, action.message, channel, action.reaction), + CopyMessage() => _copyMessage(action.message, channel), + DeleteMessage() => _maybeDeleteMessage(context, action.message, channel), + HardDeleteMessage() => channel.deleteMessage(action.message, hard: true), + EditMessage() => _editMessage(context, action.message, channel), + FlagMessage() => _maybeFlagMessage(context, action.message, channel), + MarkUnread() => channel.markUnread(action.message.id), + MuteUser() => channel.client.muteUser(action.user.id), + UnmuteUser() => channel.client.unmuteUser(action.user.id), + PinMessage() => channel.pinMessage(action.message), + UnpinMessage() => channel.unpinMessage(action.message), + ResendMessage() => channel.retryMessage(action.message), + QuotedReply() => widget.onReplyTap?.call(action.message), + ThreadReply() => widget.onThreadTap?.call(action.message), + CustomMessageAction() => widget.onCustomActionTap?.call(action), + }; + } + + Future _copyMessage( + Message message, + Channel channel, + ) async { + final presentableMessage = message.replaceMentions(linkify: false); + + final messageText = presentableMessage.text; + if (messageText == null || messageText.isEmpty) return; + + return Clipboard.setData(ClipboardData(text: messageText)); + } + + Future _maybeDeleteMessage( + BuildContext context, + Message message, + Channel channel, + ) async { + final confirmDelete = await showStreamMessageModal( + context: context, + builder: (context) => StreamMessageActionConfirmationModal( + title: Text(context.translations.deleteMessageLabel), + content: Text(context.translations.deleteMessageQuestion), + cancelActionTitle: Text(context.translations.cancelLabel.sentenceCase), + confirmActionTitle: Text(context.translations.deleteLabel.sentenceCase), + isDestructiveAction: true, + ), ); + + if (confirmDelete == false) return null; + + return channel.deleteMessage(message); } - /// Calculates if the reaction picker tail should be enabled. - bool calculateReactionTailEnabled(ReactionTailType type) { - if (widget.showReactionTail != null) return widget.showReactionTail!; - - switch (type) { - case ReactionTailType.list: - return false; - case ReactionTailType.messageActions: - return widget.showReactionPicker; - case ReactionTailType.reactions: - return widget.showReactionPicker; + Future _editMessage( + BuildContext context, + Message message, + Channel channel, + ) { + return showEditMessageSheet( + context: context, + channel: channel, + message: message, + editMessageInputBuilder: widget.editMessageInputBuilder, + ); + } + + Future _maybeFlagMessage( + BuildContext context, + Message message, + Channel channel, + ) async { + final confirmFlag = await showStreamMessageModal( + context: context, + builder: (context) => StreamMessageActionConfirmationModal( + title: Text(context.translations.flagMessageLabel), + content: Text(context.translations.flagMessageQuestion), + cancelActionTitle: Text(context.translations.cancelLabel.sentenceCase), + confirmActionTitle: Text(context.translations.flagLabel.sentenceCase), + isDestructiveAction: true, + ), + ); + + if (confirmFlag == false) return null; + + final messageId = message.id; + return channel.client.flagMessage(messageId); + } + + Future _selectReaction( + BuildContext context, + Message message, + Channel channel, + Reaction reaction, + ) { + final ownReactions = [...?message.ownReactions]; + final shouldDelete = ownReactions.any((it) => it.type == reaction.type); + + if (shouldDelete) { + return channel.deleteReaction(message, reaction); + } + + final configurations = StreamChatConfiguration.of(context); + final enforceUnique = configurations.enforceUniqueReactions; + + return channel.sendReaction( + message, + reaction.type, + enforceUnique: enforceUnique, + ); + } +} + +extension on Message { + Message get trimmed { + if (text case final messageText? when messageText.length > 200) { + return copyWith(text: '${messageText.substring(0, 200)}...'); } + + return this; } } -/// Enum for declaring the location of the message for which the reaction picker -/// is to be enabled. -enum ReactionTailType { - /// Message is in the [StreamMessageListView] - list, +extension on String { + String get sentenceCase { + if (isEmpty) return this; - /// Message is in the [MessageActionsModal] - messageActions, + final firstChar = this[0].toUpperCase(); + final restOfString = substring(1).toLowerCase(); - /// Message is in the message reactions modal - reactions, + return '$firstChar$restOfString'; + } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart index a6ae651476..7e3ae0bbc1 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart @@ -363,9 +363,9 @@ class MessageWidgetContent extends StatelessWidget { ), // TODO: Make tail part of the Reaction Picker. if (showReactionPickerTail) - Positioned( - right: reverse ? null : 4, - left: reverse ? 4 : null, + PositionedDirectional( + end: reverse ? null : 4, + start: reverse ? 4 : null, top: -8, child: CustomPaint( painter: ReactionBubblePainter( diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart index ccbd8a0e8a..33f8bc79ac 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content_components.dart @@ -3,7 +3,6 @@ export 'message_card.dart'; export 'parse_attachments.dart'; export 'pinned_message.dart'; export 'quoted_message.dart'; -export 'reactions/message_reactions_modal.dart'; export 'reactions/reaction_bubble.dart'; export 'reactions/reaction_indicator.dart'; export 'user_avatar_transform.dart'; diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart deleted file mode 100644 index a3eed77874..0000000000 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart +++ /dev/null @@ -1,129 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_align.dart'; -import 'package:stream_chat_flutter/src/message_widget/reactions/reactions_card.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamMessageReactionsModal} -/// Modal widget for displaying message reactions -/// {@endtemplate} -class StreamMessageReactionsModal extends StatelessWidget { - /// {@macro streamMessageReactionsModal} - const StreamMessageReactionsModal({ - super.key, - required this.message, - required this.messageWidget, - required this.messageTheme, - this.showReactionPicker = true, - this.reverse = false, - this.onUserAvatarTap, - }); - - /// Widget that shows the message - final Widget messageWidget; - - /// Message to display reactions of - final Message message; - - /// [StreamMessageThemeData] to apply to [message] - final StreamMessageThemeData messageTheme; - - /// {@macro reverse} - final bool reverse; - - /// Flag for showing reaction picker. - final bool showReactionPicker; - - /// {@macro onUserAvatarTap} - final void Function(User)? onUserAvatarTap; - - @override - Widget build(BuildContext context) { - final user = StreamChat.of(context).currentUser; - final channel = StreamChannel.of(context).channel; - final orientation = MediaQuery.of(context).orientation; - final canSendReaction = channel.canSendReaction; - final fontSize = messageTheme.messageTextStyle?.fontSize; - - final child = Center( - child: SingleChildScrollView( - child: SafeArea( - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showReactionPicker && canSendReaction) - LayoutBuilder( - builder: (context, constraints) { - return Align( - alignment: Alignment( - calculateReactionsHorizontalAlignment( - user, - message, - constraints, - fontSize, - orientation, - ), - 0, - ), - child: StreamReactionPicker( - message: message, - ), - ); - }, - ), - const SizedBox(height: 10), - IgnorePointer( - child: messageWidget, - ), - if (message.latestReactions?.isNotEmpty == true) ...[ - const SizedBox(height: 8), - ReactionsCard( - currentUser: user!, - message: message, - messageTheme: messageTheme, - ), - ], - ], - ), - ), - ), - ), - ); - - return GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => Navigator.of(context).maybePop(), - child: Stack( - children: [ - Positioned.fill( - child: BackdropFilter( - filter: ImageFilter.blur( - sigmaX: 10, - sigmaY: 10, - ), - child: DecoratedBox( - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.overlay, - ), - ), - ), - ), - TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOutBack, - builder: (context, val, widget) => Transform.scale( - scale: val, - child: widget, - ), - child: child, - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_picker.dart index 5f61d1dfcc..440843b447 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_picker.dart @@ -1,170 +1,112 @@ -import 'package:ezanimation/ezanimation.dart'; import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_widget/reactions/reaction_picker_icon_list.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamReactionPicker} /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/reaction_picker.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/reaction_picker_paint.png) /// -/// Allows the user to select reactions to a message on mobile. +/// A widget that displays a horizontal list of reaction icons that users can +/// select to react to a message. /// -/// It is not recommended to use this widget directly as it's one of the -/// default widgets used by [StreamMessageWidget.onMessageActions]. +/// The reaction picker can be configured with custom reaction icons, padding, +/// border radius, and can be made scrollable or static depending on the +/// specific needs. /// {@endtemplate} -class StreamReactionPicker extends StatefulWidget { +class StreamReactionPicker extends StatelessWidget { /// {@macro streamReactionPicker} const StreamReactionPicker({ super.key, required this.message, + required this.reactionIcons, + this.onReactionPicked, + this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + this.scrollable = true, + this.borderRadius = const BorderRadius.all(Radius.circular(24)), }); - /// Message to attach the reaction to - final Message message; + /// Creates a [StreamReactionPicker] using the default reaction icons + /// provided by the [StreamChatConfiguration]. + /// + /// This is the recommended way to create a reaction picker + /// as it ensures that the icons are consistent with the rest of the app. + /// + /// The [onReactionPicked] callback is optional and can be used to handle + /// the reaction selection. + factory StreamReactionPicker.builder( + BuildContext context, + Message message, + OnReactionPicked? onReactionPicked, + ) { + final config = StreamChatConfiguration.of(context); + final reactionIcons = config.reactionIcons; + + final platform = Theme.of(context).platform; + return switch (platform) { + TargetPlatform.iOS || TargetPlatform.android => StreamReactionPicker( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: onReactionPicked, + ), + _ => StreamReactionPicker( + message: message, + scrollable: false, + borderRadius: BorderRadius.zero, + reactionIcons: reactionIcons, + onReactionPicked: onReactionPicked, + ), + }; + } - @override - _StreamReactionPickerState createState() => _StreamReactionPickerState(); -} + /// Message to attach the reaction to. + final Message message; -class _StreamReactionPickerState extends State - with TickerProviderStateMixin { - List animations = []; + /// List of reaction icons to display. + final List reactionIcons; - @override - Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); - final reactionIcons = StreamChatConfiguration.of(context).reactionIcons; + /// {@macro onReactionPressed} + final OnReactionPicked? onReactionPicked; - if (animations.isEmpty && reactionIcons.isNotEmpty) { - reactionIcons.forEach((element) { - animations.add( - EzAnimation.tween( - Tween(begin: 0.0, end: 1.0), - const Duration(milliseconds: 500), - curve: Curves.easeInOutBack, - ), - ); - }); + /// Padding around the reaction picker. + /// + /// Defaults to `EdgeInsets.symmetric(horizontal: 8, vertical: 4)`. + final EdgeInsets padding; - triggerAnimations(); - } + /// Whether the reaction picker should be scrollable. + /// + /// Defaults to `true`. + final bool scrollable; - final child = Material( - borderRadius: BorderRadius.circular(24), - color: chatThemeData.colorTheme.barsBg, - clipBehavior: Clip.hardEdge, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Row( - spacing: 16, - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: reactionIcons.map((reactionIcon) { - final ownReactionIndex = widget.message.ownReactions?.indexWhere( - (reaction) => reaction.type == reactionIcon.type, - ) ?? - -1; - final index = reactionIcons.indexOf(reactionIcon); + /// Border radius for the reaction picker. + /// + /// Defaults to a circular border with a radius of 24. + final BorderRadius? borderRadius; - final child = reactionIcon.builder( - context, - ownReactionIndex != -1, - 24, - ); + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); - return ConstrainedBox( - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - child: RawMaterialButton( - elevation: 0, - shape: ContinuousRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - onPressed: () { - if (ownReactionIndex != -1) { - removeReaction( - context, - widget.message.ownReactions![ownReactionIndex], - ); - } else { - sendReaction( - context, - reactionIcon.type, - ); - } - }, - child: AnimatedBuilder( - animation: animations[index], - builder: (context, child) => Transform.scale( - scale: animations[index].value, - child: child, - ), - child: child, - ), - ), - ); - }).toList(), - ), - ), + final reactionPicker = ReactionPickerIconList( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: onReactionPicked, ); - return TweenAnimationBuilder( - tween: Tween(begin: 0, end: 1), - curve: Curves.easeInOutBack, - duration: const Duration(milliseconds: 500), - builder: (context, val, widget) => Transform.scale( - scale: val, - child: widget, + return DecoratedBox( + decoration: BoxDecoration( + color: theme.colorTheme.barsBg, + borderRadius: borderRadius, + ), + child: Padding( + padding: padding, + child: switch (scrollable) { + false => reactionPicker, + true => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: reactionPicker, + ), + }, ), - child: child, ); } - - Future triggerAnimations() async { - for (final a in animations) { - a.start(); - await Future.delayed(const Duration(milliseconds: 100)); - } - } - - Future pop() async { - for (final a in animations) { - a.stop(); - } - Navigator.of(context).pop(); - } - - /// Add a reaction to the message - void sendReaction(BuildContext context, String reactionType) { - StreamChannel.of(context).channel.sendReaction( - widget.message, - reactionType, - enforceUnique: - StreamChatConfiguration.of(context).enforceUniqueReactions, - ); - pop(); - } - - /// Remove a reaction from the message - void removeReaction(BuildContext context, Reaction reaction) { - StreamChannel.of(context).channel.deleteReaction(widget.message, reaction); - pop(); - } - - @override - void dispose() { - for (final a in animations) { - a.dispose(); - } - super.dispose(); - } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_picker_icon_list.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_picker_icon_list.dart new file mode 100644 index 0000000000..98bbacaa59 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reaction_picker_icon_list.dart @@ -0,0 +1,202 @@ +import 'package:collection/collection.dart'; +import 'package:ezanimation/ezanimation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/reaction_icon.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@template onReactionPressed} +/// Callback called when a reaction icon is pressed. +/// {@endtemplate} +typedef OnReactionPicked = void Function(Reaction reaction); + +/// {@template reactionPickerIconList} +/// A widget that displays a list of reactionIcons that can be picked by a user. +/// +/// This widget shows a row of reaction icons with animated entry. When a user +/// taps on a reaction icon, the [onReactionPicked] callback is invoked with the +/// selected reaction. +/// +/// The reactions displayed are configured via [reactionIcons] and the widget +/// tracks which reactions the current user has already added to the [message]. +/// {@endtemplate} +class ReactionPickerIconList extends StatefulWidget { + /// {@macro reactionPickerIconList} + const ReactionPickerIconList({ + super.key, + required this.message, + required this.reactionIcons, + this.onReactionPicked, + }); + + /// The message to display reactions for. + final Message message; + + /// The list of available reaction icons. + final List reactionIcons; + + /// {@macro onReactionPressed} + final OnReactionPicked? onReactionPicked; + + @override + State createState() => _ReactionPickerIconListState(); +} + +class _ReactionPickerIconListState extends State { + List _iconAnimations = []; + + void _triggerAnimations() async { + for (final animation in _iconAnimations) { + if (mounted) animation.start(); + // Add a small delay between the start of each animation. + await Future.delayed(const Duration(milliseconds: 100)); + } + } + + void _dismissAnimations() { + for (final animation in _iconAnimations) { + animation.stop(); + } + } + + void _disposeAnimations() { + for (final animation in _iconAnimations) { + animation.dispose(); + } + } + + @override + void initState() { + super.initState(); + _iconAnimations = List.generate( + widget.reactionIcons.length, + (index) => EzAnimation.tween( + Tween(begin: 0.0, end: 1.0), + kThemeAnimationDuration, + curve: Curves.easeInOutBack, + ), + ); + + // Trigger animations at the end of the frame to avoid jank. + WidgetsBinding.instance.endOfFrame.then((_) => _triggerAnimations()); + } + + @override + void didUpdateWidget(covariant ReactionPickerIconList oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.reactionIcons.length != widget.reactionIcons.length) { + // Dismiss and dispose old animations. + _dismissAnimations(); + _disposeAnimations(); + + // Initialize new animations. + _iconAnimations = List.generate( + widget.reactionIcons.length, + (index) => EzAnimation.tween( + Tween(begin: 0.0, end: 1.0), + kThemeAnimationDuration, + curve: Curves.easeInOutBack, + ), + ); + + // Trigger animations at the end of the frame to avoid jank. + WidgetsBinding.instance.endOfFrame.then((_) => _triggerAnimations()); + } + } + + @override + void dispose() { + _dismissAnimations(); + _disposeAnimations(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final child = Wrap( + spacing: 4, + runSpacing: 4, + runAlignment: WrapAlignment.center, + alignment: WrapAlignment.spaceAround, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ...widget.reactionIcons.mapIndexed((index, icon) { + bool reactionCheck(Reaction reaction) => reaction.type == icon.type; + + final ownReactions = [...?widget.message.ownReactions]; + final reaction = ownReactions.firstWhereOrNull(reactionCheck); + + final animation = _iconAnimations[index]; + return AnimatedBuilder( + animation: animation, + builder: (context, child) => Transform.scale( + scale: animation.value, + child: child, + ), + child: ReactionIconButton( + icon: icon, + // If the reaction is present, it is selected. + isSelected: reaction != null, + onPressed: switch (widget.onReactionPicked) { + final onPicked? => () { + final type = icon.type; + final pickedReaction = reaction ?? Reaction(type: type); + return onPicked(pickedReaction); + }, + _ => null, + }, + ), + ); + }), + ], + ); + + return TweenAnimationBuilder( + tween: Tween(begin: 0, end: 1), + curve: Curves.easeInOutBack, + duration: const Duration(milliseconds: 500), + builder: (context, scale, child) { + return Transform.scale(scale: scale, child: child); + }, + child: child, + ); + } +} + +/// {@template reactionIconButton} +/// A button that displays a reaction icon. +/// +/// This button is used in the reaction picker to display individual reaction +/// options. +/// {@endtemplate} +class ReactionIconButton extends StatelessWidget { + /// {@macro reactionIconButton} + const ReactionIconButton({ + super.key, + required this.icon, + required this.isSelected, + this.onPressed, + }); + + /// Whether this reaction is currently selected by the user. + final bool isSelected; + + /// The reaction icon to display. + final StreamReactionIcon icon; + + /// Callback triggered when the reaction icon is pressed. + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return IconButton( + iconSize: 24, + icon: icon.builder(context, isSelected, 24), + padding: const EdgeInsets.all(4), + style: IconButton.styleFrom( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size.square(24), + ), + onPressed: onPressed, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_align.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_align.dart index a7baad7eec..218ac3f866 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_align.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/reactions/reactions_align.dart @@ -1,51 +1,64 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -/// This method calculates the align that the modal of reactions should have. -/// This is an approximation based on the size of the message and the -/// available space in the screen. -double calculateReactionsHorizontalAlignment( - User? user, - Message message, - BoxConstraints constraints, - double? fontSize, - Orientation orientation, -) { - final maxWidth = constraints.maxWidth; +/// Extension on [Message] that provides alignment calculations for reaction +/// pickers. +/// +/// This extension adds functionality to determine the optimal positioning of +/// reaction pickers based on message size and available screen space. +extension ReactionPickerAlignment on Message { + /// Calculates the alignment for the reaction picker based on the message size + /// and the available space. + /// + /// The [fontSize] is used to calculate the size of the message, if not + /// provided, the font size of 1 is used. + /// + /// The [orientation] is used to calculate the size of the message, if not + /// provided, the orientation of the device is used. + AlignmentGeometry calculateReactionPickerAlignment({ + required BoxConstraints constraints, + double? fontSize, + Orientation orientation = Orientation.portrait, + bool reverse = false, + }) { + final maxWidth = constraints.maxWidth; - final roughSentenceSize = message.roughMessageSize(fontSize); - final hasAttachments = message.attachments.isNotEmpty; - final isReply = message.quotedMessageId != null; - final isAttachment = hasAttachments && !isReply; + final roughSentenceSize = roughMessageSize(fontSize); + final hasAttachments = attachments.isNotEmpty; + final isReply = quotedMessageId != null; + final isAttachment = hasAttachments && !isReply; - // divFactor is the percentage of the available space that the message takes. - // When the divFactor is bigger than 0.5 that means that the messages is - // bigger than 50% of the available space and the modal should have an offset - // in the direction that the message grows. When the divFactor is smaller - // than 0.5 then the offset should be to he side opposite of the message - // growth. - // In resume, when divFactor > 0.5 then result > 0, when divFactor < 0.5 - // then result < 0. - var divFactor = 0.5; + // divFactor is the percentage of the available space that the message + // takes. + // + // When the divFactor is bigger than 0.5 that means that the messages is + // bigger than 50% of the available space and the modal should have an + // offset in the direction that the message grows. When the divFactor is + // smaller than 0.5 then the offset should be to he side opposite of the + // message growth. + // + // In resume, when divFactor > 0.5 then result > 0, when divFactor < 0.5 + // then result < 0. + var divFactor = 0.5; - // When in portrait, attachments normally take 75% of the screen, when in - // landscape, attachments normally take 50% of the screen. - if (isAttachment) { - if (orientation == Orientation.portrait) { - divFactor = 0.75; + // When in portrait, attachments normally take 75% of the screen, when in + // landscape, attachments normally take 50% of the screen. + if (isAttachment) { + divFactor = switch (orientation) { + Orientation.portrait => 0.75, + Orientation.landscape => 0.5, + }; } else { - divFactor = 0.5; + divFactor = roughSentenceSize == 0 ? 0.5 : (roughSentenceSize / maxWidth); } - } else { - divFactor = roughSentenceSize == 0 ? 0.5 : (roughSentenceSize / maxWidth); - } - final signal = user?.id == message.user?.id ? 1 : -1; - final result = signal * (1 - divFactor * 2.0); + final signal = reverse ? 1 : -1; + final result = signal * (1 - divFactor * 2.0); - // Ensure reactions don't get pushed past the edge of the screen. - // - // This happens if divFactor is really big. When this happens, we can simply - // move the model all the way to the end of screen. - return result.clamp(-1, 1); + // Ensure reactions don't get pushed past the edge of the screen. + // + // This happens if divFactor is really big. When this happens, we can simply + // move the model all the way to the end of screen. + return AlignmentDirectional(result.clamp(-1, 1), 0); + } } diff --git a/packages/stream_chat_flutter/lib/src/misc/adaptive_dialog_action.dart b/packages/stream_chat_flutter/lib/src/misc/adaptive_dialog_action.dart new file mode 100644 index 0000000000..159c55b557 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/adaptive_dialog_action.dart @@ -0,0 +1,71 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// A platform-adaptive dialog action button that renders appropriately based on +/// the platform. +/// +/// This widget uses [CupertinoDialogAction] on iOS and macOS platforms, +/// and [TextButton] on all other platforms, maintaining the appropriate +/// platform design language. +/// +/// The styling is influenced by the [StreamChatTheme] to ensure consistent +/// appearance with other Stream Chat components. +class AdaptiveDialogAction extends StatelessWidget { + /// Creates an adaptive dialog action. + const AdaptiveDialogAction({ + super.key, + this.onPressed, + this.isDefaultAction = false, + this.isDestructiveAction = false, + required this.child, + }); + + /// The callback that is called when the action is tapped. + final VoidCallback? onPressed; + + /// Whether this action is the default choice in the dialog. + /// + /// Default actions use emphasized styling (bold text) on iOS/macOS. + /// This has no effect on other platforms. + final bool isDefaultAction; + + /// Whether this action performs a destructive action like deletion. + /// + /// Destructive actions are displayed with red text on iOS/macOS. + /// This has no effect on other platforms. + final bool isDestructiveAction; + + /// The widget to display as the content of the action. + /// + /// Typically a [Text] widget. + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + + return switch (Theme.of(context).platform) { + TargetPlatform.iOS || TargetPlatform.macOS => CupertinoTheme( + data: CupertinoTheme.of(context).copyWith( + primaryColor: theme.colorTheme.accentPrimary, + ), + child: CupertinoDialogAction( + onPressed: onPressed, + isDefaultAction: isDefaultAction, + isDestructiveAction: isDestructiveAction, + child: child, + ), + ), + _ => TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + textStyle: theme.textTheme.body, + foregroundColor: theme.colorTheme.accentPrimary, + disabledForegroundColor: theme.colorTheme.disabled, + ), + child: child, + ), + }; + } +} diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index 771f4ccb41..d3200ff478 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -673,6 +673,15 @@ class StreamChatThemeData { /// Theme configuration for the [StreamDraftListTile] widget. final StreamDraftListTileThemeData draftListTileTheme; + /// Returns the theme for the message based on the [reverse] parameter. + /// + /// If [reverse] is true, it returns the [otherMessageTheme], otherwise it + /// returns the [ownMessageTheme]. + StreamMessageThemeData getMessageTheme({bool reverse = false}) { + if (reverse) return otherMessageTheme; + return ownMessageTheme; + } + /// Creates a copy of [StreamChatThemeData] with specified attributes /// overridden. StreamChatThemeData copyWith({ diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index dd33838495..f98af3526c 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -694,3 +694,27 @@ extension AttachmentPlaylistExtension on Iterable { ]; } } + +/// Extension to convert [AlignmentGeometry] to the corresponding +/// [CrossAxisAlignment]. +extension ColumnAlignmentExtension on AlignmentGeometry { + /// Converts an [AlignmentGeometry] to the most appropriate + /// [CrossAxisAlignment] value. + CrossAxisAlignment toColumnCrossAxisAlignment() { + final x = switch (this) { + Alignment(x: final x) => x, + AlignmentDirectional(start: final start) => start, + _ => null, + }; + + // If the alignment is unknown, fallback to the center alignment. + if (x == null) return CrossAxisAlignment.center; + + return switch (x) { + 0.0 => CrossAxisAlignment.center, + < 0 => CrossAxisAlignment.start, + > 0 => CrossAxisAlignment.end, + _ => CrossAxisAlignment.center, // fallback (in case of NaN etc) + }; + } +} diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index db05edbbfd..a8887c459c 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -52,7 +52,9 @@ export 'src/indicators/upload_progress_indicator.dart'; export 'src/keyboard_shortcuts/keyboard_shortcut_runner.dart'; export 'src/localization/stream_chat_localizations.dart'; export 'src/localization/translations.dart' show DefaultTranslations; -export 'src/message_actions_modal/message_action.dart'; +export 'src/message_action/message_action.dart'; +export 'src/message_action/message_action_item.dart'; +export 'src/message_action/message_actions_builder.dart'; export 'src/message_input/attachment_picker/stream_attachment_picker.dart'; export 'src/message_input/attachment_picker/stream_attachment_picker_bottom_sheet.dart'; export 'src/message_input/audio_recorder/audio_recorder_controller.dart'; @@ -67,6 +69,11 @@ export 'src/message_input/stream_message_send_button.dart'; export 'src/message_input/stream_message_text_field.dart'; export 'src/message_list_view/message_details.dart'; export 'src/message_list_view/message_list_view.dart'; +export 'src/message_modal/message_action_confirmation_modal.dart'; +export 'src/message_modal/message_actions_modal.dart'; +export 'src/message_modal/message_modal.dart'; +export 'src/message_modal/message_reactions_modal.dart'; +export 'src/message_modal/moderated_message_actions_modal.dart'; export 'src/message_widget/deleted_message.dart'; export 'src/message_widget/message_text.dart'; export 'src/message_widget/message_widget.dart'; @@ -76,6 +83,7 @@ export 'src/message_widget/poll_message.dart'; export 'src/message_widget/reactions/reaction_picker.dart'; export 'src/message_widget/system_message.dart'; export 'src/message_widget/text_bubble.dart'; +export 'src/misc/adaptive_dialog_action.dart'; export 'src/misc/animated_circle_border_painter.dart'; export 'src/misc/back_button.dart'; export 'src/misc/connection_status_builder.dart'; diff --git a/packages/stream_chat_flutter/test/src/context_menu_items/download_menu_item_test.dart b/packages/stream_chat_flutter/test/src/context_menu_items/download_menu_item_test.dart deleted file mode 100644 index 23b3a4c533..0000000000 --- a/packages/stream_chat_flutter/test/src/context_menu_items/download_menu_item_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/download_menu_item.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - group('DownloadMenuItem tests', () { - testWidgets('renders ListTile widget', (tester) async { - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockClient(), - child: child, - ), - home: Scaffold( - body: Center( - child: DownloadMenuItem( - attachment: MockAttachment(), - ), - ), - ), - ), - ); - - expect(find.byType(ListTile), findsOneWidget); - }); - - goldenTest( - 'golden test for DownloadMenuItem', - fileName: 'download_menu_item_0', - constraints: const BoxConstraints.tightFor(width: 300, height: 100), - builder: () => MaterialAppWrapper( - builder: (context, child) => StreamChatTheme( - data: StreamChatThemeData.light(), - child: child!, - ), - home: Scaffold( - body: Center( - child: DownloadMenuItem( - attachment: MockAttachment(), - ), - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/context_menu_items/goldens/ci/download_menu_item_0.png b/packages/stream_chat_flutter/test/src/context_menu_items/goldens/ci/download_menu_item_0.png deleted file mode 100644 index d4751f48c5..0000000000 Binary files a/packages/stream_chat_flutter/test/src/context_menu_items/goldens/ci/download_menu_item_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/context_menu_items/goldens/ci/stream_chat_context_menu_item_0.png b/packages/stream_chat_flutter/test/src/context_menu_items/goldens/ci/stream_chat_context_menu_item_0.png deleted file mode 100644 index 1cfb96377a..0000000000 Binary files a/packages/stream_chat_flutter/test/src/context_menu_items/goldens/ci/stream_chat_context_menu_item_0.png and /dev/null differ diff --git a/packages/stream_chat_flutter/test/src/context_menu_items/stream_chat_context_menu_item_test.dart b/packages/stream_chat_flutter/test/src/context_menu_items/stream_chat_context_menu_item_test.dart deleted file mode 100644 index 798e649cfb..0000000000 --- a/packages/stream_chat_flutter/test/src/context_menu_items/stream_chat_context_menu_item_test.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:alchemist/alchemist.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../material_app_wrapper.dart'; -import '../mocks.dart'; - -void main() { - group('StreamChatContextMenuItem tests', () { - testWidgets('renders ListTile widget', (tester) async { - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockClient(), - child: child, - ), - home: const Scaffold( - body: Center( - child: StreamChatContextMenuItem(), - ), - ), - ), - ); - - expect(find.byType(ListTile), findsOneWidget); - }); - - goldenTest( - 'golden test for StreamChatContextMenuItem', - fileName: 'stream_chat_context_menu_item_0', - constraints: const BoxConstraints.tightFor(width: 300, height: 80), - builder: () => MaterialAppWrapper( - builder: (context, child) => StreamChatTheme( - data: StreamChatThemeData.light(), - child: child!, - ), - home: Scaffold( - body: Center( - child: StreamChatContextMenuItem( - leading: const Icon(Icons.download), - title: const Text('Download'), - onClick: () {}, - ), - ), - ), - ), - ); - }); -} diff --git a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/delete_message_dialog_0.png b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/delete_message_dialog_0.png index 9314acdc86..7d9d740d77 100644 Binary files a/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/delete_message_dialog_0.png and b/packages/stream_chat_flutter/test/src/dialogs/goldens/ci/delete_message_dialog_0.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_dark.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_dark.png new file mode 100644 index 0000000000..23412da5e8 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_light.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_light.png new file mode 100644 index 0000000000..e3ddc3bab7 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_child_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_dark.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_dark.png new file mode 100644 index 0000000000..f212165ce5 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_light.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_light.png new file mode 100644 index 0000000000..1d60ee570f Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_custom_styling_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_dark.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_dark.png new file mode 100644 index 0000000000..ceb15cacdd Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_light.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_light.png new file mode 100644 index 0000000000..57f4026a02 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_delete_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_dark.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_dark.png new file mode 100644 index 0000000000..efa8574b75 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_light.png b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_light.png new file mode 100644 index 0000000000..e86d33395e Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_action/goldens/ci/stream_message_action_item_reply_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_action/message_action_item_test.dart b/packages/stream_chat_flutter/test/src/message_action/message_action_item_test.dart new file mode 100644 index 0000000000..1edf748ec1 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_action/message_action_item_test.dart @@ -0,0 +1,167 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + final message = Message( + id: 'test-message', + text: 'Hello, world!', + user: User(id: 'test-user'), + ); + + testWidgets('renders with title and icon', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageActionItem( + action: StreamMessageAction( + title: const Text('Reply'), + leading: const Icon(Icons.reply), + action: QuotedReply(message: message), + ), + ), + ), + ); + + expect(find.text('Reply'), findsOneWidget); + expect(find.byIcon(Icons.reply), findsOneWidget); + }); + + testWidgets('calls onTap when tapped', (tester) async { + Message? tappedMessage; + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageActionItem( + onTap: (action) => tappedMessage = action.message, + action: StreamMessageAction( + title: const Text('Reply'), + leading: const Icon(Icons.reply), + action: QuotedReply(message: message), + ), + ), + ), + ); + + await tester.tap(find.byType(InkWell)); + await tester.pump(); + + expect(tappedMessage, message); + }); + + testWidgets( + 'applies destructive styling when isDestructive is true', + (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageActionItem( + action: StreamMessageAction( + isDestructive: true, + title: const Text('Delete'), + leading: const Icon(Icons.delete), + action: DeleteMessage(message: message), + ), + ), + ), + ); + + expect(find.text('Delete'), findsOneWidget); + expect(find.byIcon(Icons.delete), findsOneWidget); + + // The icon and text should have the error color + final theme = StreamChatTheme.of(tester.element(find.text('Delete'))); + final iconTheme = IconTheme.of(tester.element(find.byIcon(Icons.delete))); + + expect(iconTheme.color, theme.colorTheme.accentError); + }, + ); + + group('StreamMessageActionItem Golden Tests', () { + for (final brightness in Brightness.values) { + final theme = brightness.name; + + // Test standard action + goldenTest( + 'StreamMessageActionItem (Reply) in $theme theme', + fileName: 'stream_message_action_item_reply_$theme', + constraints: const BoxConstraints(maxWidth: 300, maxHeight: 60), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionItem( + action: StreamMessageAction( + title: const Text('Reply'), + leading: const Icon(Icons.reply), + action: QuotedReply(message: message), + ), + ), + ), + ); + + // Test destructive action (like delete) + goldenTest( + 'StreamMessageActionItem (Delete) in $theme theme', + fileName: 'stream_message_action_item_delete_$theme', + constraints: const BoxConstraints(maxWidth: 300, maxHeight: 60), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionItem( + action: StreamMessageAction( + isDestructive: true, + title: const Text('Delete Message'), + leading: const Icon(Icons.delete), + action: DeleteMessage(message: message), + ), + ), + ), + ); + + // Test with custom styling + goldenTest( + 'StreamMessageActionItem with custom styling in $theme theme', + fileName: 'stream_message_action_item_custom_styling_$theme', + constraints: const BoxConstraints(maxWidth: 300, maxHeight: 60), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionItem( + action: StreamMessageAction( + title: const Text('Styled Action'), + titleTextStyle: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: Colors.purple[700], + ), + leading: const Icon(Icons.favorite), + iconColor: Colors.pink[400], + backgroundColor: Colors.amber[100], + action: CustomMessageAction(message: message), + ), + ), + ), + ); + } + }); +} + +Widget _wrapWithMaterialApp( + Widget child, { + Brightness? brightness, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), + ), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart b/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart new file mode 100644 index 0000000000..75d0a92c71 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_action/message_actions_builder_test.dart @@ -0,0 +1,561 @@ +// ignore_for_file: cascade_invocations, avoid_redundant_argument_values + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +/// Creates a test message with customizable properties. +Message createTestMessage({ + String id = 'test-message', + String text = 'Test message', + String userId = 'test-user', + bool pinned = false, + String? parentId, + Poll? poll, + MessageType type = MessageType.regular, + int? replyCount, +}) { + return Message( + id: id, + text: text, + user: User(id: userId), + pinned: pinned, + parentId: parentId, + poll: poll, + type: type, + deletedAt: type == MessageType.deleted ? DateTime.now() : null, + replyCount: replyCount, + moderation: switch (type) { + MessageType.error => const Moderation( + action: ModerationAction.bounce, + originalText: 'Original message text that violated policy', + ), + _ => null, + }, + ); +} + +const allChannelCapabilities = [ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.pinMessage, + ChannelCapability.readEvents, + ChannelCapability.deleteOwnMessage, + ChannelCapability.deleteAnyMessage, + ChannelCapability.updateOwnMessage, + ChannelCapability.updateAnyMessage, + ChannelCapability.quoteMessage, +]; + +void main() { + final message = createTestMessage(); + final currentUser = OwnUser(id: 'current-user'); + + setUpAll(() { + registerFallbackValue(Message()); + // registerFallbackValue(const StreamMessageActionType('any')); + }); + + MockChannel _getChannelWithCapabilities( + List capabilities, { + bool enableMutes = true, + }) { + final customChannel = MockChannel(ownCapabilities: capabilities); + final channelConfig = ChannelConfig(mutes: enableMutes); + when(() => customChannel.config).thenReturn(channelConfig); + return customChannel; + } + + Future _getContext(WidgetTester tester) async { + late BuildContext context; + await tester.pumpWidget( + StreamChatTheme( + data: StreamChatThemeData.light(), + child: Builder(builder: (ctx) { + context = ctx; + return const SizedBox.shrink(); + }), + ), + ); + return context; + } + + testWidgets('builds default message actions', (tester) async { + final context = await _getContext(tester); + + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + // Verify default actions + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + actions.expects(); + }); + + testWidgets('returns empty set for deleted messages', (tester) async { + final context = await _getContext(tester); + final deletedMessage = createTestMessage(type: MessageType.deleted); + + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: deletedMessage, + channel: channel, + currentUser: currentUser, + ); + + expect(actions.isEmpty, isTrue); + }); + + testWidgets('includes custom actions', (tester) async { + final context = await _getContext(tester); + final customAction = StreamMessageAction( + title: const Text('Custom'), + leading: const Icon(Icons.star), + action: CustomMessageAction( + message: message, + extraData: {'customKey': 'customValue'}, + ), + ); + + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + customActions: [customAction], + ); + + actions.expects( + reason: 'Custom action should be included', + ); + }); + + group('permission-based actions', () { + testWidgets( + 'includes/excludes edit action based on authorship', + (tester) async { + final context = await _getContext(tester); + + // Own message test + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final ownMessage = createTestMessage(userId: currentUser.id); + final ownActions = StreamMessageActionsBuilder.buildActions( + context: context, + message: ownMessage, + channel: channel, + currentUser: currentUser, + ); + + ownActions.expects( + reason: 'Edit action should be available for own messages', + ); + + // Other user's message test + final otherUserActions = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + otherUserActions.expects( + reason: 'Edit action should be available for others messages', + ); + }, + ); + + testWidgets('excludes edit action for messages with polls', (tester) async { + final context = await _getContext(tester); + + final pollMessage = createTestMessage( + userId: currentUser.id, + poll: Poll( + id: 'poll-id', + name: 'What is your favorite color?', + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + options: const [ + PollOption(text: 'Option 1'), + PollOption(text: 'Option 2'), + ], + ), + ); + + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: pollMessage, + channel: channel, + currentUser: currentUser, + ); + + actions.notExpects( + reason: 'Edit action should not be available for poll messages', + ); + }); + + testWidgets( + 'includes/excludes delete action based on permission', + (tester) async { + final context = await _getContext(tester); + + // With delete permission + final channel = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.updateOwnMessage, + ChannelCapability.deleteOwnMessage, + ]); + + final ownMessage = createTestMessage(userId: currentUser.id); + final actionsWithPerm = StreamMessageActionsBuilder.buildActions( + context: context, + message: ownMessage, + channel: channel, + currentUser: currentUser, + ); + + actionsWithPerm.expects( + reason: 'Delete action should be available with permission', + ); + + // Without delete permission + final channelWithoutDeletePerm = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.updateOwnMessage, + ]); + + final actionsWithoutPerm = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channelWithoutDeletePerm, + currentUser: currentUser, + ); + + actionsWithoutPerm.notExpects( + reason: 'Delete action should not be available without permission', + ); + }, + ); + + testWidgets( + 'includes/excludes pin action based on permission', + (tester) async { + final context = await _getContext(tester); + + // With pin permission + final channel = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.pinMessage, + ]); + + final actionsWithPerm = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + actionsWithPerm.expects( + reason: 'Pin action should be available with pin permission', + ); + + // Without pin permission + final channelWithoutPinPerm = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ]); + + final actionsWithoutPerm = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channelWithoutPinPerm, + currentUser: currentUser, + ); + + actionsWithoutPerm.notExpects( + reason: 'Pin action should not be available without permission', + ); + }, + ); + + testWidgets('shows unpin action for pinned messages', (tester) async { + final context = await _getContext(tester); + + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final pinnedMessage = createTestMessage(pinned: true); + final actions = StreamMessageActionsBuilder.buildActions( + context: context, + message: pinnedMessage, + channel: channel, + currentUser: currentUser, + ); + + actions.expects( + reason: 'Unpin action should be available for pinned messages', + ); + }); + + testWidgets( + 'includes/excludes flag action based on authorship', + (tester) async { + final context = await _getContext(tester); + + // Other user's message + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final actionsOtherUser = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: currentUser, + ); + + actionsOtherUser.expects( + reason: "Flag action should be available for others' messages", + ); + + // Own message + final ownMessage = createTestMessage(userId: currentUser.id); + final actionsOwnMessage = StreamMessageActionsBuilder.buildActions( + context: context, + message: ownMessage, + channel: channel, + currentUser: currentUser, + ); + + actionsOwnMessage.notExpects( + reason: 'Flag action should not be available for own messages', + ); + }, + ); + + testWidgets( + 'handles mute action correctly based on user and config', + (tester) async { + final context = await _getContext(tester); + + // User with no mutes + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final userWithNoMutes = OwnUser(id: 'current-user', mutes: const []); + final actionsForNoMutes = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: userWithNoMutes, + ); + + actionsForNoMutes.expects( + reason: 'Mute action should be available for users with no mutes', + ); + + // User with mutes + final userWithMutes = OwnUser( + id: 'current-user', + mutes: [ + Mute( + user: User(id: 'test-user'), + target: User(id: 'test-user'), + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ), + ], + ); + + final actionsForMutedUser = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channel, + currentUser: userWithMutes, + ); + + actionsForMutedUser.expects( + reason: 'Unmute action should be available for already muted users', + ); + + // Own message + final ownMessage = createTestMessage(userId: currentUser.id); + final actionsForOwnMessage = StreamMessageActionsBuilder.buildActions( + context: context, + message: ownMessage, + channel: channel, + currentUser: currentUser, + ); + + actionsForOwnMessage.notExpects( + reason: 'Mute action should not be available for own messages', + ); + + // Channel without mutes enabled + final channelWithoutMutes = _getChannelWithCapabilities( + allChannelCapabilities, + enableMutes: false, + ); + + final muteDisabledActions = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channelWithoutMutes, + currentUser: currentUser, + ); + + muteDisabledActions.notExpects( + reason: 'Mute action unavailable when channel mutes are disabled', + ); + }, + ); + + testWidgets( + 'handles thread and quote reply actions correctly', + (tester) async { + final context = await _getContext(tester); + + // Thread message + final channel = _getChannelWithCapabilities(allChannelCapabilities); + final threadMessage = createTestMessage(parentId: 'parent-message-id'); + final actionsForThreadMessage = + StreamMessageActionsBuilder.buildActions( + context: context, + message: threadMessage, + channel: channel, + currentUser: currentUser, + ); + + actionsForThreadMessage.notExpects( + reason: 'Thread reply unavailable for thread messages', + ); + + // Channel without quote permission + final channelWithoutQuote = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ]); + + final actionsWithoutQuote = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channelWithoutQuote, + currentUser: currentUser, + ); + + actionsWithoutQuote.notExpects( + reason: 'Quote reply unavailable without quote permission', + ); + }, + ); + + testWidgets('handles mark unread action correctly', (tester) async { + final context = await _getContext(tester); + + // With read events capability + final parentMessage = createTestMessage( + id: 'parent-message', + text: 'Parent message', + replyCount: 5, + ); + + final channelWithReadEvents = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ChannelCapability.readEvents, + ]); + + final actionsWithReadEvents = StreamMessageActionsBuilder.buildActions( + context: context, + message: parentMessage, + channel: channelWithReadEvents, + currentUser: currentUser, + ); + + actionsWithReadEvents.expects( + reason: 'Mark unread available with read events capability', + ); + + // Without read events capability + final channelWithoutReadEvents = _getChannelWithCapabilities([ + ChannelCapability.sendMessage, + ChannelCapability.sendReply, + ]); + + final actionsWithoutReadEvents = StreamMessageActionsBuilder.buildActions( + context: context, + message: message, + channel: channelWithoutReadEvents, + currentUser: currentUser, + ); + + actionsWithoutReadEvents.notExpects( + reason: 'Mark unread unavailable without read events capability', + ); + }); + }); + + group('buildBouncedErrorActions', () { + testWidgets('returns empty set for non-bounced messages', (tester) async { + final context = await _getContext(tester); + final regularMessage = createTestMessage(); + + final actions = StreamMessageActionsBuilder.buildBouncedErrorActions( + context: context, + message: regularMessage, + ); + + expect(actions.isEmpty, isTrue, reason: 'No actions for regular message'); + }); + + testWidgets( + 'builds actions for bounced messages with error', + (tester) async { + final context = await _getContext(tester); + final bouncedMessage = createTestMessage(type: MessageType.error); + + final actions = StreamMessageActionsBuilder.buildBouncedErrorActions( + context: context, + message: bouncedMessage, + ); + + // Verify the specific actions for bounced messages + actions.expects( + reason: 'Send Anyway action should be included', + ); + actions.expects( + reason: 'Edit Message action should be included', + ); + actions.expects( + reason: 'Delete Message action should be included', + ); + + // Verify the count is correct + expect(actions.length, 3, reason: 'Should have exactly 3 actions'); + }, + ); + }); +} + +/// Extension on Set to simplify action type checks. +extension StreamMessageActionSetExtension on List { + void expects({String? reason}) { + final containsActionType = this.any((it) => it.action is T); + return expect(containsActionType, isTrue, reason: reason); + } + + void notExpects({String? reason}) { + final containsActionType = this.any((it) => it.action is T); + return expect(containsActionType, isFalse, reason: reason); + } +} diff --git a/packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart deleted file mode 100644 index 194652b336..0000000000 --- a/packages/stream_chat_flutter/test/src/message_actions_modal/message_actions_modal_test.dart +++ /dev/null @@ -1,916 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/src/message_actions_modal/message_actions_modal.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - setUpAll(() { - registerFallbackValue( - MaterialPageRoute(builder: (context) => const SizedBox())); - registerFallbackValue(Message()); - }); - - testWidgets( - 'it should show the all actions', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - state: MessageState.sent, - ), - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('MessageWidget')), findsOneWidget); - expect(find.text('Thread Reply'), findsOneWidget); - expect(find.text('Reply'), findsOneWidget); - expect(find.text('Edit Message'), findsOneWidget); - expect(find.text('Delete Message'), findsOneWidget); - expect(find.text('Copy Message'), findsOneWidget); - expect(find.text('Mark as Unread'), findsOneWidget); - }, - ); - - testWidgets( - 'it should show the reaction picker', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel( - ownCapabilities: [ - ChannelCapability.sendMessage, - ChannelCapability.sendReaction, - ], - ); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - state: MessageState.sent, - ), - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(StreamReactionPicker), findsOneWidget); - }, - ); - - testWidgets( - 'it should not show the reaction picker', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - showReactionPicker: false, - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - state: MessageState.sent, - ), - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byType(StreamReactionPicker), findsNothing); - }, - ); - - testWidgets( - 'it should show some actions', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - showCopyMessage: false, - showReplyMessage: false, - showThreadReplyMessage: false, - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - expect(find.byKey(const Key('MessageWidget')), findsOneWidget); - expect(find.text('Reply'), findsNothing); - expect(find.text('Thread reply'), findsNothing); - expect(find.text('Edit message'), findsNothing); - expect(find.text('Delete message'), findsNothing); - expect(find.text('Copy message'), findsNothing); - }, - ); - - testWidgets( - 'it should show custom actions', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - var tapped = false; - await tester.pumpWidget(MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - customActions: [ - StreamMessageAction( - leading: const Icon(Icons.check), - title: const Text('title'), - onTap: (m) { - tapped = true; - }, - ), - ], - ), - ), - ), - ), - ), - )); - - await tester.pumpAndSettle(); - - expect(find.byIcon(Icons.check), findsOneWidget); - expect(find.text('title'), findsOneWidget); - - await tester.tap(find.text('title')); - - expect(tapped, true); - }, - ); - - testWidgets( - 'tapping on reply should call the callback', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - var tapped = false; - - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - messageWidget: const Text('test'), - onReplyTap: (m) { - tapped = true; - }, - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - state: MessageState.sent, - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Reply')); - - expect(tapped, true); - }, - ); - - testWidgets( - 'tapping on thread reply should call the callback', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - var tapped = false; - - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - streamChatThemeData: streamTheme, - client: client, - child: SizedBox( - child: StreamChannel( - channel: channel, - child: MessageActionsModal( - messageWidget: const Text('test'), - onThreadReplyTap: (m) { - tapped = true; - }, - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - state: MessageState.sent, - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Thread Reply')); - - expect(tapped, true); - }, - ); - - testWidgets( - 'tapping on edit should show the edit bottom sheet', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.state).thenReturn(channelState); - when(channel.getRemainingCooldown).thenReturn(0); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: Builder( - builder: (context) => StreamChannel( - showLoading: false, - channel: channel, - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - onEditMessageTap: (message) => showEditMessageSheet( - context: context, - message: message, - channel: channel, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Edit Message')); - - await tester.pumpAndSettle(); - - expect(find.byType(StreamMessageInput), findsOneWidget); - }, - ); - - testWidgets( - 'tapping on edit should show use the custom builder', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - editMessageInputBuilder: (context, m) => const Text('test'), - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Edit Message')); - - await tester.pumpAndSettle(); - - expect(find.text('test'), findsOneWidget); - }, - ); - - testWidgets( - 'tapping on copy should use the callback', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - var tapped = false; - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - onCopyTap: (m) => tapped = true, - message: Message( - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Copy Message')); - - expect(tapped, true); - }, - ); - - testWidgets( - 'tapping on resend should call retry message', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - final message = Message( - state: MessageState.sendingFailed, - text: 'test', - user: User( - id: 'user-id', - ), - ); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.retryMessage(message)) - .thenAnswer((_) async => SendMessageResponse()); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: message, - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Resend')); - - verify(() => channel.retryMessage(message)).called(1); - }, - ); - - testWidgets( - 'tapping on flag message should show the dialog', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Flag Message')); - await tester.pumpAndSettle(); - - expect(find.text('Flag Message'), findsNWidgets(2)); - - await tester.tap(find.text('FLAG')); - await tester.pumpAndSettle(); - - verify(() => client.flagMessage('testid')).called(1); - }, - ); - - testWidgets( - 'if flagging a message throws an error the error dialog should appear', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.flagMessage(any())) - .thenThrow(StreamChatNetworkError(ChatErrorCode.internalSystemError)); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Flag Message')); - await tester.pumpAndSettle(); - - expect(find.text('Flag Message'), findsNWidgets(2)); - - await tester.tap(find.text('FLAG')); - await tester.pumpAndSettle(); - - expect(find.text('Something went wrong'), findsOneWidget); - }, - ); - - testWidgets( - 'if flagging an already flagged message no error should appear', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => client.flagMessage(any())) - .thenThrow(StreamChatNetworkError(ChatErrorCode.inputError)); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Flag Message')); - await tester.pumpAndSettle(); - - expect(find.text('Flag Message'), findsNWidgets(2)); - - await tester.tap(find.text('FLAG')); - await tester.pumpAndSettle(); - - expect(find.text('Message flagged'), findsOneWidget); - }, - ); - - testWidgets( - 'tapping on delete message should call client.delete', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Delete Message')); - await tester.pumpAndSettle(); - - expect(find.text('Delete Message'), findsOneWidget); - - await tester.tap(find.text('DELETE')); - await tester.pumpAndSettle(); - - verify(() => channel.deleteMessage(any())).called(1); - }, - ); - - testWidgets( - 'tapping on delete message should call client.delete', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - when(() => channel.deleteMessage(any())) - .thenThrow(StreamChatNetworkError(ChatErrorCode.internalSystemError)); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Delete Message')); - await tester.pumpAndSettle(); - - expect(find.text('Delete Message'), findsOneWidget); - - await tester.tap(find.text('DELETE')); - await tester.pumpAndSettle(); - - expect(find.text('Something went wrong'), findsOneWidget); - }, - ); - - testWidgets( - 'tapping on unread message should call client.unread', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final themeData = ThemeData(); - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: child, - ), - theme: themeData, - home: Scaffold( - body: StreamChannel( - showLoading: false, - channel: channel, - child: SizedBox( - child: MessageActionsModal( - messageWidget: const Text('test'), - message: Message( - id: 'testid', - text: 'test', - user: User( - id: 'user-id', - ), - ), - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Mark as Unread')); - await tester.pumpAndSettle(); - - verify(() => channel.markUnread(any())).called(1); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_dark.png new file mode 100644 index 0000000000..cb199ba4b5 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_light.png new file mode 100644 index 0000000000..d101acfd46 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/moderated_message_actions_modal_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_dark.png new file mode 100644 index 0000000000..92f37b9379 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_light.png new file mode 100644 index 0000000000..5f7ea359bc Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_dark.png new file mode 100644 index 0000000000..12f8f7dc9c Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_light.png new file mode 100644 index 0000000000..16c3c34fa3 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png new file mode 100644 index 0000000000..77aea6972b Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png new file mode 100644 index 0000000000..b6a2441716 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png new file mode 100644 index 0000000000..5230a22574 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png new file mode 100644 index 0000000000..f76c527500 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png new file mode 100644 index 0000000000..365dd24050 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png new file mode 100644 index 0000000000..b329b800bf Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png new file mode 100644 index 0000000000..67283039be Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png new file mode 100644 index 0000000000..d3938c024c Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart new file mode 100644 index 0000000000..5ca7f35479 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart @@ -0,0 +1,258 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + final message = Message( + id: 'test-message', + text: 'This is a test message', + createdAt: DateTime.now(), + user: User(id: 'test-user', name: 'Test User'), + ); + + final messageActions = [ + StreamMessageAction( + title: const Text('Reply'), + leading: const StreamSvgIcon(icon: StreamSvgIcons.reply), + action: QuotedReply(message: message), + ), + StreamMessageAction( + title: const Text('Thread Reply'), + leading: const StreamSvgIcon(icon: StreamSvgIcons.threadReply), + action: ThreadReply(message: message), + ), + StreamMessageAction( + title: const Text('Copy Message'), + leading: const StreamSvgIcon(icon: StreamSvgIcons.copy), + action: CopyMessage(message: message), + ), + StreamMessageAction( + isDestructive: true, + title: const Text('Delete Message'), + leading: const StreamSvgIcon(icon: StreamSvgIcons.delete), + action: DeleteMessage(message: message), + ), + ]; + + group('StreamMessageActionsModal', () { + testWidgets('renders message widget and actions correctly', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: const Text('Message Widget'), + ), + ), + ); + + // Use a longer timeout to ensure everything is rendered + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.text('Message Widget'), findsOneWidget); + expect(find.text('Reply'), findsOneWidget); + expect(find.text('Thread Reply'), findsOneWidget); + expect(find.text('Copy Message'), findsOneWidget); + expect(find.text('Delete Message'), findsOneWidget); + }); + + testWidgets('renders with reaction picker when enabled', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: const Text('Message Widget'), + showReactionPicker: true, + ), + ), + ); + + // Use a longer timeout to ensure everything is rendered + await tester.pumpAndSettle(const Duration(seconds: 1)); + expect(find.byType(StreamReactionPicker), findsOneWidget); + }); + + testWidgets( + 'calls onActionTap with SelectReaction when reaction is selected', + (tester) async { + MessageAction? messageAction; + + // Define custom reaction icons for testing + final testReactionIcons = [ + StreamReactionIcon( + type: 'like', + builder: (context, isActive, size) => const Icon(Icons.thumb_up), + ), + StreamReactionIcon( + type: 'love', + builder: (context, isActive, size) => const Icon(Icons.favorite), + ), + ]; + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: const Text('Message Widget'), + showReactionPicker: true, + onActionTap: (action) => messageAction = action, + ), + reactionIcons: testReactionIcons, + ), + ); + + // Use a longer timeout to ensure everything is rendered + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Verify reaction picker is shown + expect(find.byType(StreamReactionPicker), findsOneWidget); + + // Find and tap the first reaction (like) + final reactionIconFinder = find.byIcon(Icons.thumb_up); + expect(reactionIconFinder, findsOneWidget); + await tester.tap(reactionIconFinder); + await tester.pumpAndSettle(); + + expect(messageAction, isA()); + // Verify callback was called with correct reaction type + expect((messageAction! as SelectReaction).reaction.type, 'like'); + + // Find and tap the second reaction (love) + final loveIconFinder = find.byIcon(Icons.favorite); + expect(loveIconFinder, findsOneWidget); + await tester.tap(loveIconFinder); + await tester.pumpAndSettle(); + + expect(messageAction, isA()); + // Verify callback was called with correct reaction type + expect((messageAction! as SelectReaction).reaction.type, 'love'); + }, + ); + }); + + group('StreamMessageActionsModal Golden Tests', () { + Widget buildMessageWidget({bool reverse = false}) { + return Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + final messageTheme = theme.getMessageTheme(reverse: reverse); + + return Align( + alignment: reverse ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: messageTheme.messageBackgroundColor, + ), + child: Text( + message.text ?? '', + style: messageTheme.messageTextStyle, + ), + ), + ); + }, + ); + } + + for (final brightness in Brightness.values) { + final theme = brightness.name; + + goldenTest( + 'StreamMessageActionsModal in $theme theme', + fileName: 'stream_message_actions_modal_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: buildMessageWidget(), + ), + ), + ); + + goldenTest( + 'StreamMessageActionsModal with reaction picker in $theme theme', + fileName: 'stream_message_actions_modal_with_reactions_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: buildMessageWidget(), + showReactionPicker: true, + ), + ), + ); + + goldenTest( + 'StreamMessageActionsModal reversed in $theme theme', + fileName: 'stream_message_actions_modal_reversed_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: buildMessageWidget(reverse: true), + reverse: true, + ), + ), + ); + + goldenTest( + 'StreamMessageActionsModal reversed with reaction picker in $theme theme', + fileName: 'stream_message_actions_modal_reversed_with_reactions_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + StreamMessageActionsModal( + message: message, + messageActions: messageActions, + messageWidget: buildMessageWidget(reverse: true), + showReactionPicker: true, + reverse: true, + ), + ), + ); + } + }); +} + +Widget _wrapWithMaterialApp( + Widget child, { + Brightness? brightness, + List? reactionIcons, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChatConfiguration( + data: StreamChatConfigurationData(reactionIcons: reactionIcons), + child: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: ColoredBox( + color: theme.colorTheme.overlay, + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), + ), + ); + }), + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart new file mode 100644 index 0000000000..c153889075 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart @@ -0,0 +1,277 @@ +import 'package:alchemist/alchemist.dart'; +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/reactions/reactions_card.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import '../mocks.dart'; + +void main() { + final message = Message( + id: 'test-message', + text: 'This is a test message', + createdAt: DateTime.now(), + user: User(id: 'test-user', name: 'Test User'), + latestReactions: [ + Reaction( + type: 'love', + messageId: 'test-message', + user: User(id: 'user-1', name: 'User 1'), + createdAt: DateTime.now(), + ), + Reaction( + type: 'like', + messageId: 'test-message', + user: User(id: 'user-2', name: 'User 2'), + createdAt: DateTime.now(), + ), + ], + reactionGroups: { + 'love': ReactionGroup(count: 1, sumScores: 1), + 'like': ReactionGroup(count: 1, sumScores: 1), + }, + ); + + late MockClient mockClient; + + setUp(() { + mockClient = MockClient(); + + final mockClientState = MockClientState(); + when(() => mockClient.state).thenReturn(mockClientState); + + // Mock the current user for the message reactions test + final currentUser = OwnUser(id: 'current-user', name: 'Current User'); + when(() => mockClientState.currentUser).thenReturn(currentUser); + }); + + tearDown(() => reset(mockClient)); + + group('StreamMessageReactionsModal', () { + testWidgets( + 'renders message widget and reactions correctly', + (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + client: mockClient, + StreamMessageReactionsModal( + message: message, + messageWidget: const Text('Message Widget'), + ), + ), + ); + + // Use a longer timeout to ensure everything is rendered + await tester.pumpAndSettle(const Duration(seconds: 2)); + expect(find.text('Message Widget'), findsOneWidget); + // Check for reaction picker + expect(find.byType(StreamReactionPicker), findsOneWidget); + // Check for reaction details + expect(find.byType(ReactionsCard), findsOneWidget); + }, + ); + + testWidgets( + 'calls onUserAvatarTap when user avatar is tapped', + (tester) async { + User? tappedUser; + + // Create just the StreamUserAvatar directly + await tester.pumpWidget( + _wrapWithMaterialApp( + client: mockClient, + StreamMessageReactionsModal( + message: message, + messageWidget: const Text('Message Widget'), + onUserAvatarTap: (user) { + tappedUser = user; + }, + ), + ), + ); + + await tester.pumpAndSettle(); + + final avatar = find.descendant( + of: find.byType(ReactionsCard), + matching: find.byType(StreamUserAvatar), + ); + + // Verify the avatar is rendered + expect(avatar, findsNWidgets(2)); + + // Tap on the first avatar directly + await tester.tap(avatar.first); + await tester.pumpAndSettle(); + + // Verify the callback was called + expect(tappedUser, isNotNull); + }, + ); + + testWidgets( + 'calls onReactionPicked with SelectReaction when reaction is selected', + (tester) async { + MessageAction? messageAction; + + // Define custom reaction icons for testing + final testReactionIcons = [ + StreamReactionIcon( + type: 'like', + builder: (context, isActive, size) => const Icon(Icons.thumb_up), + ), + StreamReactionIcon( + type: 'love', + builder: (context, isActive, size) => const Icon(Icons.favorite), + ), + StreamReactionIcon( + type: 'camera', + builder: (context, isActive, size) => const Icon(Icons.camera), + ), + StreamReactionIcon( + type: 'call', + builder: (context, isActive, size) => const Icon(Icons.call), + ), + ]; + + await tester.pumpWidget( + _wrapWithMaterialApp( + client: mockClient, + reactionIcons: testReactionIcons, + StreamMessageReactionsModal( + message: message, + messageWidget: const Text('Message Widget'), + onReactionPicked: (action) => messageAction = action, + ), + ), + ); + + // Use a longer timeout to ensure everything is rendered + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Verify reaction picker is shown + expect(find.byType(StreamReactionPicker), findsOneWidget); + + // Find and tap the camera reaction (camera) + final reactionIconFinder = find.byIcon(Icons.camera); + expect(reactionIconFinder, findsOneWidget); + await tester.tap(reactionIconFinder); + await tester.pumpAndSettle(); + + expect(messageAction, isA()); + // Verify callback was called with correct reaction type + expect((messageAction! as SelectReaction).reaction.type, 'camera'); + + // Find and tap the call reaction (call) + final loveIconFinder = find.byIcon(Icons.call); + expect(loveIconFinder, findsOneWidget); + await tester.tap(loveIconFinder); + await tester.pumpAndSettle(); + + expect(messageAction, isA()); + // Verify callback was called with correct reaction type + expect((messageAction! as SelectReaction).reaction.type, 'call'); + }, + ); + }); + + group('StreamMessageReactionsModal Golden Tests', () { + Widget buildMessageWidget({bool reverse = false}) { + return Builder( + builder: (context) { + final theme = StreamChatTheme.of(context); + final messageTheme = theme.getMessageTheme(reverse: reverse); + + return Align( + alignment: reverse ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: messageTheme.messageBackgroundColor, + ), + child: Text( + message.text ?? '', + style: messageTheme.messageTextStyle, + ), + ), + ); + }, + ); + } + + for (final brightness in Brightness.values) { + final theme = brightness.name; + + goldenTest( + 'StreamMessageReactionsModal in $theme theme', + fileName: 'stream_message_reactions_modal_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + builder: () => _wrapWithMaterialApp( + client: mockClient, + brightness: brightness, + StreamMessageReactionsModal( + message: message, + messageWidget: buildMessageWidget(), + onReactionPicked: (_) {}, + ), + ), + ); + + goldenTest( + 'StreamMessageReactionsModal reversed in $theme theme', + fileName: 'stream_message_reactions_modal_reversed_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600), + builder: () => _wrapWithMaterialApp( + client: mockClient, + brightness: brightness, + StreamMessageReactionsModal( + message: message, + messageWidget: buildMessageWidget(reverse: true), + reverse: true, + onReactionPicked: (_) {}, + ), + ), + ); + } + }); +} + +Widget _wrapWithMaterialApp( + Widget child, { + required StreamChatClient client, + Brightness? brightness, + List? reactionIcons, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + // Mock the connectivity stream to always return wifi. + connectivityStream: Stream.value([ConnectivityResult.wifi]), + child: StreamChatConfiguration( + data: StreamChatConfigurationData(reactionIcons: reactionIcons), + child: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: ColoredBox( + color: theme.colorTheme.overlay, + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), + ), + ); + }), + ), + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_modal/moderated_message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/moderated_message_actions_modal_test.dart new file mode 100644 index 0000000000..59e76cf347 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_modal/moderated_message_actions_modal_test.dart @@ -0,0 +1,139 @@ +// ignore_for_file: lines_longer_than_80_chars + +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + final message = Message( + id: 'test-message', + type: MessageType.error, + text: 'This is a test message', + createdAt: DateTime.now(), + user: User(id: 'test-user', name: 'Test User'), + moderation: const Moderation( + action: ModerationAction.bounce, + originalText: 'This is a test message with flagged content', + ), + ); + + final messageActions = [ + StreamMessageAction( + title: const Text('Send Anyway'), + leading: const StreamSvgIcon(icon: StreamSvgIcons.send), + action: ResendMessage(message: message), + ), + StreamMessageAction( + title: const Text('Edit Message'), + leading: const StreamSvgIcon(icon: StreamSvgIcons.edit), + action: EditMessage(message: message), + ), + StreamMessageAction( + isDestructive: true, + title: const Text('Delete Message'), + leading: const StreamSvgIcon(icon: StreamSvgIcons.delete), + action: HardDeleteMessage(message: message), + ), + ]; + + group('ModeratedMessageActionsModal', () { + testWidgets('renders title, content and actions correctly', (tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + ModeratedMessageActionsModal( + message: message, + messageActions: messageActions, + ), + ), + ); + + // Use a longer timeout to ensure everything is rendered + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Check for icon, title and content + expect(find.byType(StreamSvgIcon), findsWidgets); + expect(find.byType(Text), findsWidgets); + + // Check for actions + expect(find.text('Send Anyway'), findsOneWidget); + expect(find.text('Edit Message'), findsOneWidget); + expect(find.text('Delete Message'), findsOneWidget); + }); + + testWidgets('action buttons call the correct callbacks', (tester) async { + MessageAction? messageAction; + + await tester.pumpWidget( + _wrapWithMaterialApp( + ModeratedMessageActionsModal( + message: message, + messageActions: messageActions, + onActionTap: (action) => messageAction = action, + ), + ), + ); + + await tester.pumpAndSettle(); + + // Tap on Send Anyway button + await tester.tap(find.text('Send Anyway')); + await tester.pumpAndSettle(); + expect(messageAction, isA()); + + // Tap on Edit Message button + await tester.tap(find.text('Edit Message')); + await tester.pumpAndSettle(); + expect(messageAction, isA()); + + // Tap on Delete Message button + await tester.tap(find.text('Delete Message')); + await tester.pumpAndSettle(); + expect(messageAction, isA()); + }); + }); + + group('ModeratedMessageActionsModal Golden Tests', () { + for (final brightness in Brightness.values) { + final theme = brightness.name; + + goldenTest( + 'ModeratedMessageActionsModal in $theme theme', + fileName: 'moderated_message_actions_modal_$theme', + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 350), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + ModeratedMessageActionsModal( + message: message, + messageActions: messageActions, + ), + ), + ); + } + }); +} + +Widget _wrapWithMaterialApp( + Widget child, { + Brightness? brightness, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: ColoredBox( + color: theme.colorTheme.overlay, + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), + ), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_reactions_modal/message_reactions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_reactions_modal/message_reactions_modal_test.dart deleted file mode 100644 index 8cf78680b3..0000000000 --- a/packages/stream_chat_flutter/test/src/message_reactions_modal/message_reactions_modal_test.dart +++ /dev/null @@ -1,121 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - testWidgets( - 'control test', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final themeData = ThemeData(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - final message = Message( - id: 'test', - text: 'test message', - user: User( - id: 'test-user', - ), - ); - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: StreamChannel( - channel: channel, - child: StreamMessageReactionsModal( - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - message: message, - messageTheme: streamTheme.ownMessageTheme, - ), - ), - ), - ), - ); - - await tester.pump(const Duration(milliseconds: 1000)); - - expect(find.byType(StreamReactionBubble), findsNothing); - - expect(find.byType(StreamUserAvatar), findsNothing); - }, - ); - - testWidgets( - 'it should apply passed parameters', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final themeData = ThemeData(); - - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); - - final streamTheme = StreamChatThemeData.fromTheme(themeData); - - final message = Message( - id: 'test', - text: 'test message', - user: User( - id: 'test-user', - ), - latestReactions: [ - Reaction( - messageId: 'test', - user: User(id: 'testid'), - type: 'test', - ), - ], - ); - - // ignore: prefer_function_declarations_over_variables - final onUserAvatarTap = (u) => print('ok'); - - await tester.pumpWidget( - MaterialApp( - theme: themeData, - home: StreamChat( - client: client, - streamChatThemeData: streamTheme, - child: StreamChannel( - channel: channel, - child: StreamMessageReactionsModal( - messageWidget: const Text( - 'test', - key: Key('MessageWidget'), - ), - message: message, - messageTheme: streamTheme.ownMessageTheme, - reverse: true, - showReactionPicker: false, - onUserAvatarTap: onUserAvatarTap, - ), - ), - ), - ), - ); - - await tester.pump(const Duration(milliseconds: 1000)); - - expect(find.byKey(const Key('MessageWidget')), findsOneWidget); - - expect(find.byType(StreamReactionBubble), findsOneWidget); - expect(find.byType(StreamUserAvatar), findsOneWidget); - }, - ); -} diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_selected_dark.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_selected_dark.png new file mode 100644 index 0000000000..ec2744779b Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_selected_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_selected_light.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_selected_light.png new file mode 100644 index 0000000000..d50722b8f2 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_selected_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_unselected_dark.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_unselected_dark.png new file mode 100644 index 0000000000..7dfc978dc2 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_unselected_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_unselected_light.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_unselected_light.png new file mode 100644 index 0000000000..56eb4672a0 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_icon_button_unselected_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_dark.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_dark.png new file mode 100644 index 0000000000..78280c1832 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_light.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_light.png new file mode 100644 index 0000000000..a9f0c98372 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_selected_dark.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_selected_dark.png new file mode 100644 index 0000000000..45484fa773 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_selected_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_selected_light.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_selected_light.png new file mode 100644 index 0000000000..5e2433e806 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/reaction_picker_icon_list_selected_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_dark.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_dark.png new file mode 100644 index 0000000000..0fcbd270b5 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_light.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_light.png new file mode 100644 index 0000000000..f6f59b9000 Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_selected_dark.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_selected_dark.png new file mode 100644 index 0000000000..757ebb5a0c Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_selected_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_selected_light.png b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_selected_light.png new file mode 100644 index 0000000000..cac18319fa Binary files /dev/null and b/packages/stream_chat_flutter/test/src/message_widget/reactions/goldens/ci/stream_reaction_picker_selected_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/reaction_picker_icon_list_test.dart b/packages/stream_chat_flutter/test/src/message_widget/reactions/reaction_picker_icon_list_test.dart new file mode 100644 index 0000000000..511fc1b858 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/reactions/reaction_picker_icon_list_test.dart @@ -0,0 +1,387 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/message_widget/reactions/reaction_picker_icon_list.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + final reactionIcons = [ + StreamReactionIcon( + type: 'love', + builder: (context, isSelected, iconSize) => StreamSvgIcon( + icon: StreamSvgIcons.loveReaction, + size: iconSize, + color: isSelected ? Colors.red : Colors.grey.shade700, + ), + ), + StreamReactionIcon( + type: 'thumbsUp', + builder: (context, isSelected, iconSize) => StreamSvgIcon( + icon: StreamSvgIcons.thumbsUpReaction, + size: iconSize, + color: isSelected ? Colors.blue : Colors.grey.shade700, + ), + ), + StreamReactionIcon( + type: 'thumbsDown', + builder: (context, isSelected, iconSize) => StreamSvgIcon( + icon: StreamSvgIcons.thumbsDownReaction, + size: iconSize, + color: isSelected ? Colors.orange : Colors.grey.shade700, + ), + ), + StreamReactionIcon( + type: 'lol', + builder: (context, isSelected, iconSize) => StreamSvgIcon( + icon: StreamSvgIcons.lolReaction, + size: iconSize, + color: isSelected ? Colors.amber : Colors.grey.shade700, + ), + ), + StreamReactionIcon( + type: 'wut', + builder: (context, isSelected, iconSize) => StreamSvgIcon( + icon: StreamSvgIcons.wutReaction, + size: iconSize, + color: isSelected ? Colors.purple : Colors.grey.shade700, + ), + ), + ]; + + group('ReactionIconButton', () { + testWidgets( + 'renders correctly with selected state', + (WidgetTester tester) async { + await tester.pumpWidget( + _wrapWithMaterialApp( + ReactionIconButton( + icon: reactionIcons.first, + isSelected: true, + onPressed: () {}, + ), + ), + ); + + expect(find.byType(ReactionIconButton), findsOneWidget); + expect(find.byType(IconButton), findsOneWidget); + }, + ); + + testWidgets( + 'triggers callback when pressed', + (WidgetTester tester) async { + var callbackTriggered = false; + + await tester.pumpWidget( + _wrapWithMaterialApp( + ReactionIconButton( + icon: reactionIcons.first, + isSelected: false, + onPressed: () { + callbackTriggered = true; + }, + ), + ), + ); + + await tester.tap(find.byType(IconButton)); + await tester.pump(); + + expect(callbackTriggered, isTrue); + }, + ); + + group('Golden tests', () { + for (final brightness in [Brightness.light, Brightness.dark]) { + final theme = brightness.name; + + goldenTest( + 'ReactionIconButton unselected in $theme theme', + fileName: 'reaction_icon_button_unselected_$theme', + constraints: const BoxConstraints.tightFor(width: 60, height: 60), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + ReactionIconButton( + icon: reactionIcons.first, + isSelected: false, + onPressed: () {}, + ), + ), + ); + + goldenTest( + 'ReactionIconButton selected in $theme theme', + fileName: 'reaction_icon_button_selected_$theme', + constraints: const BoxConstraints.tightFor(width: 60, height: 60), + builder: () => _wrapWithMaterialApp( + brightness: brightness, + ReactionIconButton( + icon: reactionIcons.first, + isSelected: true, + onPressed: () {}, + ), + ), + ); + } + }); + }); + + group('ReactionPickerIconList', () { + testWidgets( + 'renders all reaction icons', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + ReactionPickerIconList( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: (_) {}, + ), + ), + ); + + // Wait for animations to complete + await tester.pumpAndSettle(); + + // Should find same number of IconButtons as reactionIcons + expect(find.byType(IconButton), findsNWidgets(reactionIcons.length)); + }, + ); + + testWidgets( + 'triggers onReactionPicked when a reaction icon is tapped', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + Reaction? pickedReaction; + + await tester.pumpWidget( + _wrapWithMaterialApp( + ReactionPickerIconList( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: (reaction) { + pickedReaction = reaction; + }, + ), + ), + ); + + // Wait for animations to complete + await tester.pumpAndSettle(); + + // Tap the first reaction icon + await tester.tap(find.byType(IconButton).first); + await tester.pump(); + + // Verify callback was triggered with correct reaction type + expect(pickedReaction, isNotNull); + expect(pickedReaction!.type, 'love'); + }, + ); + + testWidgets( + 'shows reaction icons with animation', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + ReactionPickerIconList( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: (_) {}, + ), + ), + ); + + // Initially the animations should be starting + await tester.pump(); + + // After animation completes + await tester.pumpAndSettle(); + + // Should have all reactions visible + expect( + find.byType(ReactionIconButton), + findsNWidgets(reactionIcons.length), + ); + }, + ); + + testWidgets( + 'properly handles message with existing reactions', + (WidgetTester tester) async { + // Create a message with an existing reaction + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ownReactions: [ + Reaction( + type: 'love', + messageId: 'test-message', + userId: 'test-user', + ), + ], + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + Material( + child: ReactionPickerIconList( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: (_) {}, + ), + ), + ), + ); + + // Wait for animations + await tester.pumpAndSettle(); + + // All reaction buttons should be rendered + expect( + find.byType(ReactionIconButton), + findsNWidgets(reactionIcons.length), + ); + }, + ); + + testWidgets( + 'updates when reactionIcons change', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + // Build with initial set of reaction icons + await tester.pumpWidget( + _wrapWithMaterialApp( + ReactionPickerIconList( + message: message, + // Only first two reactions + reactionIcons: reactionIcons.sublist(0, 2), + onReactionPicked: (_) {}, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byType(IconButton), findsNWidgets(2)); + + // Rebuild with all reaction icons + await tester.pumpWidget( + _wrapWithMaterialApp( + ReactionPickerIconList( + message: message, + reactionIcons: reactionIcons, // All three reactions + onReactionPicked: (_) {}, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byType(IconButton), findsNWidgets(5)); + }, + ); + + group('Golden tests', () { + for (final brightness in [Brightness.light, Brightness.dark]) { + final theme = brightness.name; + + goldenTest( + 'ReactionPickerIconList in $theme theme', + fileName: 'reaction_picker_icon_list_$theme', + constraints: const BoxConstraints.tightFor(width: 400, height: 100), + builder: () { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + return _wrapWithMaterialApp( + brightness: brightness, + ReactionPickerIconList( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: (_) {}, + ), + ); + }, + ); + + goldenTest( + 'ReactionPickerIconList with selected reaction in $theme theme', + fileName: 'reaction_picker_icon_list_selected_$theme', + constraints: const BoxConstraints.tightFor(width: 400, height: 100), + builder: () { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ownReactions: [ + Reaction( + type: 'love', + messageId: 'test-message', + userId: 'test-user', + ), + ], + ); + + return _wrapWithMaterialApp( + brightness: brightness, + ReactionPickerIconList( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: (_) {}, + ), + ); + }, + ); + } + }); + }); +} + +Widget _wrapWithMaterialApp( + Widget child, { + Brightness? brightness, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), + ), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_flutter/test/src/message_widget/reactions/reaction_picker_test.dart b/packages/stream_chat_flutter/test/src/message_widget/reactions/reaction_picker_test.dart new file mode 100644 index 0000000000..8447b10396 --- /dev/null +++ b/packages/stream_chat_flutter/test/src/message_widget/reactions/reaction_picker_test.dart @@ -0,0 +1,198 @@ +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/message_widget/reactions/reaction_picker_icon_list.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +void main() { + final reactionIcons = [ + StreamReactionIcon( + type: 'love', + builder: (context, isSelected, iconSize) => StreamSvgIcon( + icon: StreamSvgIcons.loveReaction, + size: iconSize, + color: isSelected ? Colors.red : Colors.grey.shade700, + ), + ), + StreamReactionIcon( + type: 'thumbsUp', + builder: (context, isSelected, iconSize) => StreamSvgIcon( + icon: StreamSvgIcons.thumbsUpReaction, + size: iconSize, + color: isSelected ? Colors.blue : Colors.grey.shade700, + ), + ), + StreamReactionIcon( + type: 'thumbsDown', + builder: (context, isSelected, iconSize) => StreamSvgIcon( + icon: StreamSvgIcons.thumbsDownReaction, + size: iconSize, + color: isSelected ? Colors.orange : Colors.grey.shade700, + ), + ), + StreamReactionIcon( + type: 'lol', + builder: (context, isSelected, iconSize) => StreamSvgIcon( + icon: StreamSvgIcons.lolReaction, + size: iconSize, + color: isSelected ? Colors.amber : Colors.grey.shade700, + ), + ), + StreamReactionIcon( + type: 'wut', + builder: (context, isSelected, iconSize) => StreamSvgIcon( + icon: StreamSvgIcons.wutReaction, + size: iconSize, + color: isSelected ? Colors.purple : Colors.grey.shade700, + ), + ), + ]; + + testWidgets( + 'renders with correct message and reaction icons', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamReactionPicker( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: (_) {}, + ), + ), + ); + + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Verify the widget renders with correct structure + expect(find.byType(StreamReactionPicker), findsOneWidget); + expect(find.byType(ReactionPickerIconList), findsOneWidget); + + // Verify the correct number of reaction icons + expect(find.byType(IconButton), findsNWidgets(reactionIcons.length)); + }, + ); + + testWidgets( + 'calls onReactionPicked when a reaction is selected', + (WidgetTester tester) async { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + Reaction? pickedReaction; + + await tester.pumpWidget( + _wrapWithMaterialApp( + StreamReactionPicker( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: (reaction) { + pickedReaction = reaction; + }, + ), + ), + ); + + // Wait for animations to complete + await tester.pumpAndSettle(const Duration(seconds: 1)); + + // Tap the first reaction icon + await tester.tap(find.byType(IconButton).first); + await tester.pump(); + + // Verify the callback was called with the correct reaction + expect(pickedReaction, isNotNull); + // Updated to match first reaction type in the list + expect(pickedReaction!.type, 'love'); + }, + ); + + group('Golden tests', () { + for (final brightness in [Brightness.light, Brightness.dark]) { + final theme = brightness.name; + + goldenTest( + 'StreamReactionPicker in $theme theme', + fileName: 'stream_reaction_picker_$theme', + constraints: const BoxConstraints.tightFor(width: 400, height: 100), + builder: () { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ); + + return _wrapWithMaterialApp( + brightness: brightness, + StreamReactionPicker( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: (_) {}, + ), + ); + }, + ); + + goldenTest( + 'StreamReactionPicker with selected reaction in $theme theme', + fileName: 'stream_reaction_picker_selected_$theme', + constraints: const BoxConstraints.tightFor(width: 400, height: 100), + builder: () { + final message = Message( + id: 'test-message', + text: 'Hello world', + user: User(id: 'test-user'), + ownReactions: [ + Reaction( + type: 'love', + messageId: 'test-message', + userId: 'test-user', + ), + ], + ); + + return _wrapWithMaterialApp( + brightness: brightness, + StreamReactionPicker( + message: message, + reactionIcons: reactionIcons, + onReactionPicked: (_) {}, + ), + ); + }, + ); + } + }); +} + +Widget _wrapWithMaterialApp( + Widget child, { + Brightness? brightness, +}) { + return MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), + ), + ); + }), + ), + ); +} diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index ced86d92f1..1986b0f0fc 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Catalan (`ca`). @@ -172,8 +174,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get enablePhotoAndVideoAccessMessage => - "Si us plau, permet l'accés a les teves fotos" - '\ni vídeos per a que puguis compartir-los'; + "Si us plau, permet l'accés a les teves fotos i vídeos per a que puguis compartir-los"; @override String get allowGalleryAccessMessage => "Permet l'accés a la galeria"; @@ -183,8 +184,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - "Vols enviar una còpia d'aquest missatge a un" - '\nmoderador per una major investigació?'; + "Vols enviar una còpia d'aquest missatge a un moderador per una major investigació?"; @override String get flagLabel => 'REPORTA'; @@ -207,7 +207,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { @override String get deleteMessageQuestion => - 'Estàs segur que vols esborrar aquest\nmissatge de forma permanent?'; + 'Estàs segur que vols esborrar aquest missatge de forma permanent?'; @override String get operationCouldNotBeCompletedText => @@ -449,8 +449,8 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String unreadMessagesSeparatorText() => 'Missatges nous'; @override - String get enableFileAccessMessage => "Habilita l'accés als fitxers" - '\nper poder compartir-los amb amics'; + String get enableFileAccessMessage => + "Habilita l'accés als fitxers per poder compartir-los amb amics"; @override String get allowFileAccessMessage => "Permet l'accés als fitxers"; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index 92d7643b5a..93b1e2c28b 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for German (`de`). @@ -162,8 +164,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get enablePhotoAndVideoAccessMessage => - 'Bitte aktivieren Sie den Zugriff auf Ihre Fotos' - '\nund Videos, damit Sie sie mit Freunden teilen können.'; + 'Bitte aktivieren Sie den Zugriff auf Ihre Fotos und Videos, damit Sie sie mit Freunden teilen können.'; @override String get allowGalleryAccessMessage => 'Zugang zu Ihrer Galerie gewähren'; @@ -173,8 +174,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - 'Möchten Sie eine Kopie dieser Nachricht an einen' - '\nModerator für weitere Untersuchungen senden?'; + 'Möchten Sie eine Kopie dieser Nachricht an einen Moderator für weitere Untersuchungen senden?'; @override String get flagLabel => 'MELDEN'; @@ -443,8 +443,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { @override String get enableFileAccessMessage => - 'Bitte aktivieren Sie den Zugriff auf Dateien,' - '\ndamit Sie sie mit Freunden teilen können.'; + 'Bitte aktivieren Sie den Zugriff auf Dateien, damit Sie sie mit Freunden teilen können.'; @override String get allowFileAccessMessage => 'Zugriff auf Dateien zulassen'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index a8e28a10e5..fdc85f41cc 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for English (`en`). @@ -169,8 +171,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get enablePhotoAndVideoAccessMessage => - 'Please enable access to your photos' - '\nand videos so you can share them with friends.'; + 'Please enable access to your photos and videos so you can share them with friends.'; @override String get allowGalleryAccessMessage => 'Allow access to your gallery'; @@ -180,8 +181,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - 'Do you want to send a copy of this message to a' - '\nmoderator for further investigation?'; + 'Do you want to send a copy of this message to a moderator for further investigation?'; @override String get flagLabel => 'FLAG'; @@ -204,7 +204,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get deleteMessageQuestion => - 'Are you sure you want to permanently delete this\nmessage?'; + 'Are you sure you want to permanently delete this message?'; @override String get operationCouldNotBeCompletedText => @@ -446,8 +446,8 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String unreadMessagesSeparatorText() => 'New messages'; @override - String get enableFileAccessMessage => 'Please enable access to files' - '\nso you can share them with friends.'; + String get enableFileAccessMessage => + 'Please enable access to files so you can share them with friends.'; @override String get allowFileAccessMessage => 'Allow access to files'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 887ebb58e9..887854d929 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Spanish (`es`). @@ -173,8 +175,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { @override String get enablePhotoAndVideoAccessMessage => - 'Por favor, permita el acceso a sus fotos' - '\ny vídeos para que pueda compartirlos con sus amigos.'; + 'Por favor, permita el acceso a sus fotos y vídeos para que pueda compartirlos con sus amigos.'; @override String get allowGalleryAccessMessage => 'Permitir el acceso a su galería'; @@ -184,8 +185,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - '¿Quiere enviar una copia de este mensaje a un' - '\nmoderador para una mayor investigación?'; + '¿Quiere enviar una copia de este mensaje a un moderador para una mayor investigación?'; @override String get flagLabel => 'REPORTAR'; @@ -208,7 +208,7 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { @override String get deleteMessageQuestion => - '¿Estás seguro de que quieres borrar este\nmensaje de forma permanente?'; + '¿Estás seguro de que quieres borrar este mensaje de forma permanente?'; @override String get operationCouldNotBeCompletedText => @@ -451,8 +451,8 @@ No es posible añadir más de $limit archivos adjuntos String unreadMessagesSeparatorText() => 'Nuevos mensajes'; @override - String get enableFileAccessMessage => 'Habilite el acceso a los archivos' - '\npara poder compartirlos con amigos.'; + String get enableFileAccessMessage => + 'Habilite el acceso a los archivos para poder compartirlos con amigos.'; @override String get allowFileAccessMessage => 'Permitir el acceso a los archivos'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 09f4c0ecb3..7c1fcbc994 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for French (`fr`). @@ -172,8 +174,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { @override String get enablePhotoAndVideoAccessMessage => - "Veuillez autoriser l'accès à vos photos" - '\net vidéos afin de pouvoir les partager avec vos amis.'; + "Veuillez autoriser l'accès à vos photos et vidéos afin de pouvoir les partager avec vos amis."; @override String get allowGalleryAccessMessage => "Autoriser l'accès à votre galerie"; @@ -183,8 +184,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - 'Voulez-vous envoyer une copie de ce message à un' - '\nmodérateur pour une enquête plus approfondie ?'; + 'Voulez-vous envoyer une copie de ce message à un modérateur pour une enquête plus approfondie ?'; @override String get flagLabel => 'SIGNALER'; @@ -207,7 +207,7 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { @override String get deleteMessageQuestion => - 'Êtes-vous sûr de vouloir supprimer définitivement ce\nmessage ?'; + 'Êtes-vous sûr de vouloir supprimer définitivement ce message ?'; @override String get operationCouldNotBeCompletedText => @@ -451,8 +451,7 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ @override String get enableFileAccessMessage => - "Veuillez autoriser l'accès aux fichiers" - '\nafin de pouvoir les partager avec des amis.'; + "Veuillez autoriser l'accès aux fichiers afin de pouvoir les partager avec des amis."; @override String get allowFileAccessMessage => "Autoriser l'accès aux fichiers"; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index 5ba1eb4b26..34694c6466 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Hindi (`hi`). @@ -167,8 +169,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get enablePhotoAndVideoAccessMessage => - 'कृपया अपने फ़ोटो और वीडियो तक पहुंच सक्षम करें' - '\nताकि आप उन्हें मित्रों के साथ साझा कर सकें।'; + 'कृपया अपने फ़ोटो और वीडियो तक पहुंच सक्षम करे ताकि आप उन्हें मित्रों के साथ साझा कर सकें।'; @override String get allowGalleryAccessMessage => 'अपनी गैलरी तक पहुंच की अनुमति दें'; @@ -177,8 +178,8 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get flagMessageLabel => 'फ्लैग संदेश'; @override - String get flagMessageQuestion => 'क्या आप आगे की जांच के लिए इस संदेश की' - '\nएक प्रति मॉडरेटर को भेजना चाहते हैं?'; + String get flagMessageQuestion => + 'क्या आप आगे की जांच के लिए इस संदेश की एक प्रति मॉडरेटर को भेजना चाहते हैं?'; @override String get flagLabel => 'फ्लैग'; @@ -201,7 +202,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get deleteMessageQuestion => - 'क्या आप वाकई इस संदेश को स्थायी रूप से\nहटाना चाहते हैं?'; + 'क्या आप वाकई इस संदेश को स्थायी रूप से हटाना चाहते हैं?'; @override String get operationCouldNotBeCompletedText => @@ -444,8 +445,8 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String unreadMessagesSeparatorText() => 'नए संदेश।'; @override - String get enableFileAccessMessage => 'कृपया फ़ाइलों तक पहुंच सक्षम करें ताकि' - '\nआप उन्हें मित्रों के साथ साझा कर सकें।'; + String get enableFileAccessMessage => + 'कृपया फ़ाइलों तक पहुंच सक्षम करें ताकि आप उन्हें मित्रों के साथ साझा कर सकें।'; @override String get allowFileAccessMessage => 'फाइलों तक पहुंच की अनुमति दें'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 04c3a500db..0f4ab0b2a4 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Italian (`it`). @@ -176,8 +178,7 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; @override String get enablePhotoAndVideoAccessMessage => - "Per favore attiva l'accesso alle foto" - '\ne ai video cosí potrai condividerli con i tuoi amici.'; + "Per favore attiva l'accesso alle foto e ai video cosí potrai condividerli con i tuoi amici."; @override String get allowGalleryAccessMessage => "Permetti l'accesso alla galleria"; @@ -186,8 +187,8 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; String get flagMessageLabel => 'Segnala messaggio'; @override - String get flagMessageQuestion => 'Vuoi mandare una copia di questo messaggio' - '\nad un moderatore?'; + String get flagMessageQuestion => + 'Vuoi mandare una copia di questo messaggio ad un moderatore?'; @override String get flagLabel => 'SEGNALA'; @@ -210,7 +211,7 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; @override String get deleteMessageQuestion => - 'Sei sicuro di voler definitivamente cancellare questo\nmessaggio?'; + 'Sei sicuro di voler definitivamente cancellare questo messaggio?'; @override String get operationCouldNotBeCompletedText => @@ -453,8 +454,8 @@ Attenzione: il limite massimo di $limit file è stato superato. String unreadMessagesSeparatorText() => 'Nouveaux messages'; @override - String get enableFileAccessMessage => "Per favore attiva l'accesso ai file" - '\ncosí potrai condividerli con i tuoi amici.'; + String get enableFileAccessMessage => + "Per favore attiva l'accesso ai file cosí potrai condividerli con i tuoi amici."; @override String get allowFileAccessMessage => "Consenti l'accesso ai file"; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index d2a55c4c83..c5a714075e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -163,8 +163,8 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get addMoreFilesLabel => 'ファイルの追加'; @override - String get enablePhotoAndVideoAccessMessage => 'お友達と共有できるように、写真' - '\nやビデオへのアクセスを有効にしてください。'; + String get enablePhotoAndVideoAccessMessage => + 'お友達と共有できるように、写真やビデオへのアクセスを有効にしてください。'; @override String get allowGalleryAccessMessage => 'ギャラリーへのアクセスを許可する'; @@ -172,8 +172,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get flagMessageLabel => 'メッセージをフラグする'; @override - String get flagMessageQuestion => 'このメッセージのコピーを' - '\nモデレーターに送って、さらに調査してもらいますか?'; + String get flagMessageQuestion => 'このメッセージのコピーをモデレーターに送って、さらに調査してもらいますか?'; @override String get flagLabel => 'フラグする'; @@ -194,8 +193,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get deleteMessageLabel => 'メッセージを削除する'; @override - String get deleteMessageQuestion => 'このメッセージ' - '\nを完全に削除してもよろしいですか?'; + String get deleteMessageQuestion => 'このメッセージを完全に削除してもよろしいですか?'; @override String get operationCouldNotBeCompletedText => '操作を完了できませんでした。'; @@ -429,8 +427,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String unreadMessagesSeparatorText() => '新しいメッセージ。'; @override - String get enableFileAccessMessage => - '友達と共有できるように、' '\nファイルへのアクセスを有効にしてください。'; + String get enableFileAccessMessage => '友達と共有できるように、ファイルへのアクセスを有効にしてください。'; @override String get allowFileAccessMessage => 'ファイルへのアクセスを許可する'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index 816f6d129b..62b14d818b 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -163,8 +163,8 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get addMoreFilesLabel => '파일을 추가함'; @override - String get enablePhotoAndVideoAccessMessage => '친구와 공유할 수 있도록 사진과' - '\n동영상에 액세스할 수 있도록 설정하십시오.'; + String get enablePhotoAndVideoAccessMessage => + '친구와 공유할 수 있도록 사진과 동영상에 액세스할 수 있도록 설정하십시오.'; @override String get allowGalleryAccessMessage => '갤러리에 대한 액세스를 허용합니다'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index cb8b2c80ab..d1a841e01e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Norwegian (`no`). @@ -165,8 +167,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get enablePhotoAndVideoAccessMessage => - 'Vennligst gi tillatelse til dine bilder' - '\nog videoer så du kan dele de med dine venner.'; + 'Vennligst gi tillatelse til dine bilder og videoer så du kan dele de med dine venner.'; @override String get allowGalleryAccessMessage => 'Tillat tilgang til galleri'; @@ -176,8 +177,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get flagMessageQuestion => - 'Ønsker du å sende en kopi av denne meldingen til en' - '\nmoderator for videre undersøkelser'; + 'Ønsker du å sende en kopi av denne meldingen til en moderator for videre undersøkelser'; @override String get flagLabel => 'RAPPORTER'; @@ -423,7 +423,6 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String toggleMuteUnmuteUserQuestion({required bool isMuted}) { if (isMuted) { - // ignore: lines_longer_than_80_chars return 'Er du sikker på at du vil oppheve ignoreringen av denne brukeren?'; } return 'Er du sikker på at du vil ignorere denne brukeren?'; @@ -437,7 +436,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { @override String get enableFileAccessMessage => - 'Aktiver tilgang til filer slik' '\nat du kan dele dem med venner.'; + 'Aktiver tilgang til filer slik at du kan dele dem med venner.'; @override String get allowFileAccessMessage => 'Gi tilgang til filer'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index 2fd941c773..415ddd0ecd 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -1,3 +1,5 @@ +// ignore_for_file: lines_longer_than_80_chars + part of 'stream_chat_localizations.dart'; /// The translations for Portuguese (`pt`). @@ -168,8 +170,7 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { @override String get enablePhotoAndVideoAccessMessage => - 'Por favor, permita o acesso às suas fotos' - '\ne vídeos para que possa compartilhar com sua rede.'; + 'Por favor, permita o acesso às suas fotos e vídeos para que possa compartilhar com sua rede.'; @override String get allowGalleryAccessMessage => 'Permitir acesso à sua galeria'; @@ -178,8 +179,8 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { String get flagMessageLabel => 'Denunciar mensagem'; @override - String get flagMessageQuestion => 'Gostaria de enviar esta mensagem ao' - '\nmoderador para maior investigação?'; + String get flagMessageQuestion => + 'Gostaria de enviar esta mensagem ao moderador para maior investigação?'; @override String get flagLabel => 'DENUNCIAR'; @@ -202,7 +203,7 @@ class StreamChatLocalizationsPt extends GlobalStreamChatLocalizations { @override String get deleteMessageQuestion => - 'Você tem certeza que deseja apagar essa\nmensagem permanentemente?'; + 'Você tem certeza que deseja apagar essa mensagem permanentemente?'; @override String get operationCouldNotBeCompletedText => @@ -450,7 +451,7 @@ Não é possível adicionar mais de $limit arquivos de uma vez @override String get enableFileAccessMessage => - 'Ative o acesso aos arquivos' '\npara poder compartilhá-los com amigos.'; + 'Ative o acesso aos arquivos para poder compartilhá-los com amigos.'; @override String get allowFileAccessMessage => 'Permitir acesso aos arquivos';