From ade7a14ddc344373ae6bc9ed397fb6c5b6f8b0e8 Mon Sep 17 00:00:00 2001 From: Talha Date: Wed, 15 Jan 2025 20:15:08 +0500 Subject: [PATCH 01/13] add reaction UI to chat messages --- .../chat_room_messages_provider.dart | 17 ++ .../features/chat_ng/widgets/chat_bubble.dart | 38 ++-- .../chat_ng/widgets/events/chat_event.dart | 1 - .../widgets/events/chat_event_item.dart | 34 ++- .../reactions/reaction_chips_widget.dart | 101 +++++++++ .../reactions/reaction_detail_sheet.dart | 204 ++++++++++++++++++ .../widgets/reactions/reactions_list.dart | 59 +++++ 7 files changed, 434 insertions(+), 20 deletions(-) create mode 100644 app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart create mode 100644 app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart create mode 100644 app/lib/features/chat_ng/widgets/reactions/reactions_list.dart diff --git a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart index 9aa129ef65cb..1ba2b37396dd 100644 --- a/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart +++ b/app/lib/features/chat_ng/providers/chat_room_messages_provider.dart @@ -1,6 +1,7 @@ import 'package:acter/common/providers/chat_providers.dart'; import 'package:acter/common/providers/common_providers.dart'; import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/common/utils/utils.dart'; import 'package:acter/common/widgets/html_editor/models/mention_type.dart'; import 'package:acter/features/chat_ng/models/chat_room_state/chat_room_state.dart'; import 'package:acter/features/chat_ng/models/replied_to_msg_state.dart'; @@ -21,6 +22,7 @@ const _supportedTypes = [ typedef RoomMsgId = ({String roomId, String uniqueId}); typedef MentionQuery = (String, MentionType); +typedef ReactionItem = (String, List); final chatStateProvider = StateNotifierProvider.family( @@ -139,3 +141,18 @@ final repliedToMsgProvider = AsyncNotifierProvider.autoDispose .family(() { return RepliedToMessageNotifier(); }); + +final messageReactionsProvider = StateProvider.autoDispose + .family, RoomEventItem>((ref, item) { + List reactions = []; + + final reactionKeys = asDartStringList(item.reactionKeys()); + for (final key in reactionKeys) { + final records = item.reactionRecords(key); + if (records != null) { + reactions.add((key, records.toList())); + } + } + + return reactions; +}); diff --git a/app/lib/features/chat_ng/widgets/chat_bubble.dart b/app/lib/features/chat_ng/widgets/chat_bubble.dart index 96a3ba594239..a1c308df260a 100644 --- a/app/lib/features/chat_ng/widgets/chat_bubble.dart +++ b/app/lib/features/chat_ng/widgets/chat_bubble.dart @@ -7,7 +7,7 @@ class ChatBubble extends StatelessWidget { final Widget child; final int? messageWidth; final BoxDecoration decoration; - final CrossAxisAlignment bubbleAlignment; + final MainAxisAlignment bubbleAlignment; final bool isEdited; final Widget? repliedToBuilder; @@ -43,7 +43,7 @@ class ChatBubble extends StatelessWidget { bottomRight: Radius.circular(16), ), ), - bubbleAlignment: CrossAxisAlignment.start, + bubbleAlignment: MainAxisAlignment.start, isEdited: isEdited, repliedToBuilder: repliedToBuilder, child: child, @@ -73,8 +73,9 @@ class ChatBubble extends StatelessWidget { bottomRight: Radius.circular(isNextMessageInGroup ? 16 : 4), ), ), - bubbleAlignment: CrossAxisAlignment.end, + bubbleAlignment: MainAxisAlignment.end, repliedToBuilder: repliedToBuilder, + isEdited: isEdited, child: DefaultTextStyle.merge( style: theme.textTheme.bodySmall ?.copyWith(color: theme.colorScheme.onPrimary), @@ -91,12 +92,22 @@ class ChatBubble extends StatelessWidget { return Container( margin: const EdgeInsets.symmetric(horizontal: 8), - child: Column( - crossAxisAlignment: bubbleAlignment, + child: Row( + mainAxisAlignment: bubbleAlignment, + crossAxisAlignment: CrossAxisAlignment.end, children: [ + if (isEdited && bubbleAlignment == MainAxisAlignment.end) ...[ + Text( + L10n.of(context).edited, + style: chatTheme.emptyChatPlaceholderTextStyle + .copyWith(fontSize: 12), + ), + const SizedBox(width: 5), + ], + const SizedBox(width: 5), Container( constraints: BoxConstraints( - maxWidth: msgWidth ?? size.width, + maxWidth: msgWidth ?? size.width * 0.75, ), width: msgWidth, decoration: decoration, @@ -117,15 +128,14 @@ class ChatBubble extends StatelessWidget { ), ), ), - if (isEdited) - Align( - alignment: Alignment(0.9, 0.0), - child: Text( - L10n.of(context).edited, - style: chatTheme.emptyChatPlaceholderTextStyle - .copyWith(fontSize: 12), - ), + if (isEdited && bubbleAlignment == MainAxisAlignment.start) ...[ + const SizedBox(width: 5), + Text( + L10n.of(context).edited, + style: chatTheme.emptyChatPlaceholderTextStyle + .copyWith(fontSize: 12), ), + ], ], ), ); diff --git a/app/lib/features/chat_ng/widgets/events/chat_event.dart b/app/lib/features/chat_ng/widgets/events/chat_event.dart index 678a87481076..f9b35f747cbb 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event.dart @@ -76,7 +76,6 @@ class ChatEvent extends ConsumerWidget { mainAxisAlignment: !isUser ? MainAxisAlignment.start : MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end, - mainAxisSize: MainAxisSize.min, children: [ (!isNextMessageInGroup && !isUser) ? Padding( diff --git a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart index 996f6217c015..3d570721fae2 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart @@ -9,6 +9,7 @@ import 'package:acter/features/chat_ng/widgets/events/state_update_event.dart'; import 'package:acter/features/chat_ng/widgets/events/text_message_event.dart'; import 'package:acter/features/chat_ng/widgets/events/video_message_event.dart'; import 'package:acter/common/extensions/options.dart'; +import 'package:acter/features/chat_ng/widgets/reactions/reactions_list.dart'; import 'package:acter/features/chat_ng/widgets/replied_to_preview.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show RoomEventItem; @@ -35,11 +36,34 @@ class ChatEventItem extends StatelessWidget { return switch (eventType) { // handle message inner types separately - 'm.room.message' => buildMsgEventItem( - context, - roomId, - messageId, - item, + 'm.room.message' => Column( + crossAxisAlignment: + isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + buildMsgEventItem( + context, + roomId, + messageId, + item, + ), + // Emoji reactions + Container( + padding: EdgeInsets.only( + right: isUser ? 12 : 0, + left: isUser ? 0 : 12, + ), + child: FractionalTranslation( + translation: const Offset(0, -0.1), + child: ReactionsList( + roomId: roomId, + messageId: messageId, + item: item, + isNextMessageInGroup: isNextMessageInGroup, + ), + ), + ), + ], ), 'm.room.redaction' => isUser ? ChatBubble.user( diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart new file mode 100644 index 000000000000..17b5094b8acf --- /dev/null +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart @@ -0,0 +1,101 @@ +import 'package:acter/common/themes/app_theme.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show ReactionRecord; +import 'package:flutter/material.dart'; + +class ReactionChipsWidget extends StatelessWidget { + final List reactions; + final Function(String emoji) onReactionTap; + final VoidCallback onReactionLongPress; + + const ReactionChipsWidget({ + super.key, + required this.reactions, + required this.onReactionTap, + required this.onReactionLongPress, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Card( + elevation: 8, + margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + color: colorScheme.surface, + shape: RoundedRectangleBorder( + side: BorderSide(color: colorScheme.secondaryContainer), + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: Wrap( + direction: Axis.horizontal, + spacing: 3, + runSpacing: 3, + children: reactions + .map( + (reaction) => _ReactionChip( + emoji: reaction.$1, + records: reaction.$2, + onTap: () => onReactionTap(reaction.$1), + onLongPress: onReactionLongPress, + ), + ) + .toList(), + ), + ), + ); + } +} + +class _ReactionChip extends StatelessWidget { + final String emoji; + final List records; + final VoidCallback onTap; + final VoidCallback onLongPress; + + const _ReactionChip({ + required this.emoji, + required this.records, + required this.onTap, + required this.onLongPress, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final sentByMe = records.any((x) => x.sentByMe()); + final moreThanOne = records.length > 1; + + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + onLongPress: onLongPress, + child: Chip( + padding: moreThanOne + ? const EdgeInsets.only(right: 4) + : const EdgeInsets.symmetric(horizontal: 2), + color: WidgetStatePropertyAll( + sentByMe ? colorScheme.secondaryContainer : colorScheme.surface, + ), + visualDensity: VisualDensity.compact, + labelPadding: + sentByMe ? EdgeInsets.symmetric(horizontal: 3) : EdgeInsets.zero, + shape: const StadiumBorder(side: BorderSide(color: Colors.transparent)), + avatar: moreThanOne ? _buildEmojiText() : null, + label: moreThanOne + ? Text( + records.length.toString(), + style: Theme.of(context).textTheme.labelSmall, + ) + : _buildEmojiText(), + ), + ); + } + + Widget _buildEmojiText() { + return Text(emoji, style: EmojiConfig.emojiTextStyle); + } +} diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart new file mode 100644 index 000000000000..744f52e7630c --- /dev/null +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart @@ -0,0 +1,204 @@ +import 'package:acter/common/themes/app_theme.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ReactionDetailsSheet extends ConsumerStatefulWidget { + final String roomId; + final List reactions; + + const ReactionDetailsSheet({ + super.key, + required this.roomId, + required this.reactions, + }); + + @override + ConsumerState createState() => + _ReactionDetailsSheetState(); +} + +class _ReactionDetailsSheetState extends ConsumerState + with TickerProviderStateMixin { + late TabController _tabController; + late List _tabs; + + @override + void initState() { + super.initState(); + _initializeTabs(); + } + + void _initializeTabs() { + final total = widget.reactions.fold( + 0, + (sum, reaction) => sum + reaction.$2.length, + ); + + _tabs = [ + Tab(child: Chip(label: Text('All $total'))), + ...widget.reactions.map( + (reaction) => Tab( + child: Chip( + avatar: Text(reaction.$1, style: EmojiConfig.emojiTextStyle), + label: Text('${reaction.$2.length}'), + ), + ), + ), + ]; + + _tabController = TabController(length: _tabs.length, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildTabBar(), + _buildTabBarView(), + ], + ); + } + + Widget _buildTabBar() { + return TabBar( + isScrollable: true, + padding: const EdgeInsets.all(24), + controller: _tabController, + overlayColor: WidgetStateProperty.all(Colors.transparent), + indicator: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(12)), + ), + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: Colors.transparent, + tabs: _tabs, + ); + } + + Widget _buildTabBarView() { + final reactionData = _processReactionData(); + + return Flexible( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: TabBarView( + controller: _tabController, + children: [ + _ReactionUsersList( + roomId: widget.roomId, + users: reactionData.allUsers, + userReactions: reactionData.userReactions, + ), + ...widget.reactions.map( + (reaction) => _ReactionUsersList( + roomId: widget.roomId, + users: reactionData.reactionUsers[reaction.$1] ?? [], + userReactions: reactionData.userReactions, + ), + ), + ], + ), + ), + ); + } + + ({ + List allUsers, + Map> userReactions, + Map> reactionUsers, + }) _processReactionData() { + final userReactions = >{}; + final reactionUsers = >{}; + + for (final reaction in widget.reactions) { + final emoji = reaction.$1; + reactionUsers[emoji] = []; + + for (final record in reaction.$2) { + final userId = record.senderId().toString(); + userReactions.putIfAbsent(userId, () => []).add(emoji); + reactionUsers[emoji]?.add(userId); + } + } + + final allUsers = userReactions.keys.toList() + ..sort( + (a, b) => (userReactions[b]?.length ?? 0) + .compareTo(userReactions[a]?.length ?? 0), + ); + + return ( + allUsers: allUsers, + userReactions: userReactions, + reactionUsers: reactionUsers, + ); + } +} + +// Users list in reaction details +class _ReactionUsersList extends StatelessWidget { + final String roomId; + final List users; + final Map> userReactions; + + const _ReactionUsersList({ + required this.roomId, + required this.users, + required this.userReactions, + }); + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 10), + shrinkWrap: true, + itemCount: users.length, + itemBuilder: (context, index) { + final userId = users[index]; + return ReactionUserItem( + roomId: roomId, + userId: userId, + emojis: userReactions[userId] ?? [], + ); + }, + separatorBuilder: (context, index) => const SizedBox(height: 12), + ); + } +} + +class ReactionUserItem extends StatelessWidget { + final String roomId; + final String userId; + final List emojis; + + const ReactionUserItem({ + super.key, + required this.roomId, + required this.userId, + required this.emojis, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(userId), + trailing: Wrap( + children: emojis + .map( + (emoji) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Text(emoji, style: EmojiConfig.emojiTextStyle), + ), + ) + .toList(), + ), + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/reactions/reactions_list.dart b/app/lib/features/chat_ng/widgets/reactions/reactions_list.dart new file mode 100644 index 000000000000..5b02cb9dc753 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/reactions/reactions_list.dart @@ -0,0 +1,59 @@ +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter/features/chat_ng/widgets/reactions/reaction_chips_widget.dart'; +import 'package:acter/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('a3::chat::reactions_list'); + +class ReactionsList extends ConsumerWidget { + final String roomId; + final String messageId; + final RoomEventItem item; + final bool isNextMessageInGroup; + const ReactionsList({ + super.key, + required this.roomId, + required this.messageId, + required this.item, + required this.isNextMessageInGroup, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final reactions = ref.watch(messageReactionsProvider(item)); + if (reactions.isEmpty) return const SizedBox.shrink(); + return ReactionChipsWidget( + reactions: reactions, + onReactionTap: (emoji) => toggleReaction(ref, messageId, emoji), + onReactionLongPress: () => showReactionsSheet(context, reactions), + ); + } + + void showReactionsSheet(BuildContext context, List reactions) { + showModalBottomSheet( + context: context, + builder: (context) => ReactionDetailsSheet( + roomId: roomId, + reactions: reactions, + ), + ); + } + + Future toggleReaction( + WidgetRef ref, + String uniqueId, + String emoji, + ) async { + try { + final stream = await ref.read(timelineStreamProvider(roomId).future); + await stream.toggleReaction(uniqueId, emoji); + } catch (e, s) { + _log.severe('Reaction toggle failed', e, s); + } + } +} From a730182a9d2d3b5346f78248dff2962606a86105 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 16 Jan 2025 16:31:53 +0500 Subject: [PATCH 02/13] make reactions appear larger for easier interaction --- .../features/chat_ng/widgets/chat_bubble.dart | 36 +++++++++---------- .../reactions/reaction_chips_widget.dart | 19 ++++++---- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/app/lib/features/chat_ng/widgets/chat_bubble.dart b/app/lib/features/chat_ng/widgets/chat_bubble.dart index a1c308df260a..b0dbde12af8a 100644 --- a/app/lib/features/chat_ng/widgets/chat_bubble.dart +++ b/app/lib/features/chat_ng/widgets/chat_bubble.dart @@ -1,3 +1,4 @@ +import 'package:acter/common/extensions/acter_build_context.dart'; import 'package:acter/common/themes/acter_theme.dart'; import 'package:flutter/material.dart'; import 'package:acter/common/extensions/options.dart'; @@ -89,6 +90,8 @@ class ChatBubble extends StatelessWidget { final chatTheme = Theme.of(context).chatTheme; final size = MediaQuery.sizeOf(context); final msgWidth = messageWidth.map((w) => w.toDouble()); + final defaultWidth = + context.isLargeScreen ? size.width * 0.5 : size.width * 0.75; return Container( margin: const EdgeInsets.symmetric(horizontal: 8), @@ -96,25 +99,17 @@ class ChatBubble extends StatelessWidget { mainAxisAlignment: bubbleAlignment, crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (isEdited && bubbleAlignment == MainAxisAlignment.end) ...[ - Text( - L10n.of(context).edited, - style: chatTheme.emptyChatPlaceholderTextStyle - .copyWith(fontSize: 12), - ), - const SizedBox(width: 5), - ], const SizedBox(width: 5), Container( constraints: BoxConstraints( - maxWidth: msgWidth ?? size.width * 0.75, + maxWidth: msgWidth ?? defaultWidth, ), width: msgWidth, decoration: decoration, child: Padding( padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + horizontal: 16, + vertical: 16, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -124,18 +119,21 @@ class ChatBubble extends StatelessWidget { const SizedBox(height: 10), ], child, + if (isEdited) ...[ + const SizedBox(width: 5), + Align( + alignment: Alignment.bottomRight, + child: Text( + L10n.of(context).edited, + style: chatTheme.emptyChatPlaceholderTextStyle + .copyWith(fontSize: 12), + ), + ), + ], ], ), ), ), - if (isEdited && bubbleAlignment == MainAxisAlignment.start) ...[ - const SizedBox(width: 5), - Text( - L10n.of(context).edited, - style: chatTheme.emptyChatPlaceholderTextStyle - .copyWith(fontSize: 12), - ), - ], ], ), ); diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart index 17b5094b8acf..9cb185108905 100644 --- a/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart @@ -1,3 +1,4 @@ +import 'package:acter/common/extensions/acter_build_context.dart'; import 'package:acter/common/themes/app_theme.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' @@ -19,7 +20,7 @@ class ReactionChipsWidget extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - + final isLargeScreen = context.isLargeScreen; return Card( elevation: 8, margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), @@ -29,7 +30,9 @@ class ReactionChipsWidget extends StatelessWidget { borderRadius: BorderRadius.circular(12), ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 3), + padding: isLargeScreen + ? EdgeInsets.all(3) + : EdgeInsets.symmetric(horizontal: 3), child: Wrap( direction: Axis.horizontal, spacing: 3, @@ -74,9 +77,7 @@ class _ReactionChip extends StatelessWidget { onTap: onTap, onLongPress: onLongPress, child: Chip( - padding: moreThanOne - ? const EdgeInsets.only(right: 4) - : const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.symmetric(horizontal: 6), color: WidgetStatePropertyAll( sentByMe ? colorScheme.secondaryContainer : colorScheme.surface, ), @@ -85,10 +86,11 @@ class _ReactionChip extends StatelessWidget { sentByMe ? EdgeInsets.symmetric(horizontal: 3) : EdgeInsets.zero, shape: const StadiumBorder(side: BorderSide(color: Colors.transparent)), avatar: moreThanOne ? _buildEmojiText() : null, + labelStyle: Theme.of(context).textTheme.labelLarge, label: moreThanOne ? Text( records.length.toString(), - style: Theme.of(context).textTheme.labelSmall, + style: Theme.of(context).textTheme.labelLarge, ) : _buildEmojiText(), ), @@ -96,6 +98,9 @@ class _ReactionChip extends StatelessWidget { } Widget _buildEmojiText() { - return Text(emoji, style: EmojiConfig.emojiTextStyle); + return Text( + emoji, + style: EmojiConfig.emojiTextStyle?.copyWith(fontSize: 18), + ); } } From da166ed0f4e9afb0cc14f58449c48bc644ab381e Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 16 Jan 2025 18:06:39 +0500 Subject: [PATCH 03/13] implement emoji row with redesigned UX --- .../actions/emoji_selection_action.dart | 119 ++++++++++++++ .../widgets/events/chat_event_item.dart | 35 +++- .../reactions/reaction_selector_row.dart | 149 ++++++++++++++++++ 3 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 app/lib/features/chat_ng/actions/emoji_selection_action.dart create mode 100644 app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart diff --git a/app/lib/features/chat_ng/actions/emoji_selection_action.dart b/app/lib/features/chat_ng/actions/emoji_selection_action.dart new file mode 100644 index 000000000000..5eb0a90b61fd --- /dev/null +++ b/app/lib/features/chat_ng/actions/emoji_selection_action.dart @@ -0,0 +1,119 @@ +import 'dart:ui'; + +import 'package:acter/features/chat_ng/widgets/reactions/reaction_selector_row.dart'; +import 'package:flutter/material.dart'; + +void emojiSelectionAction({ + required BuildContext context, + required Offset position, + required Widget messageWidget, + required bool isUser, + required String roomId, + required String messageId, +}) { + final RenderBox box = context.findRenderObject() as RenderBox; + final Offset position = box.localToGlobal(Offset.zero); + final messageSize = box.size; + + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: '', + barrierColor: Colors.transparent, + transitionDuration: const Duration(milliseconds: 200), + pageBuilder: (context, animation, secondaryAnimation) { + return Stack( + children: [ + _ReactionOverlay( + animation: animation, + child: const SizedBox.expand(), + ), + Positioned( + left: position.dx, + top: position.dy, + width: messageSize.width, + child: messageWidget, + ), + Positioned( + left: position.dx, + top: position.dy - 60, + child: _AnimatedReactionRow( + animation: animation, + messageId: messageId, + child: ReactionSelectorRow( + isUser: isUser, + messageId: messageId, + roomId: roomId, + ), + ), + ), + ], + ); + }, + ); +} + +class _ReactionOverlay extends StatelessWidget { + final Animation animation; + final Widget child; + + const _ReactionOverlay({ + required this.animation, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Navigator.pop(context), + child: AnimatedBuilder( + animation: animation, + builder: (context, child) { + return BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 8 * animation.value, + sigmaY: 8 * animation.value, + ), + child: Container( + color: Colors.black.withOpacity(0.1 * animation.value), + child: child, + ), + ); + }, + child: child, + ), + ); + } +} + +class _AnimatedReactionRow extends StatelessWidget { + final Animation animation; + final Widget child; + final String messageId; + + const _AnimatedReactionRow({ + required this.animation, + required this.child, + required this.messageId, + }); + + @override + Widget build(BuildContext context) { + return Hero( + tag: messageId, + child: Material( + color: Colors.transparent, + child: AnimatedBuilder( + animation: animation, + builder: (context, child) { + return Transform.scale( + scale: Curves.easeOut.transform(animation.value), + child: child, + ); + }, + child: child, + ), + ), + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart index 3d570721fae2..3d505b2ac73b 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart @@ -1,6 +1,7 @@ import 'package:acter/features/chat/utils.dart'; import 'package:acter/features/chat/widgets/messages/encrypted_message.dart'; import 'package:acter/features/chat/widgets/messages/redacted_message.dart'; +import 'package:acter/features/chat_ng/actions/emoji_selection_action.dart'; import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; @@ -41,13 +42,35 @@ class ChatEventItem extends StatelessWidget { isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - buildMsgEventItem( - context, - roomId, - messageId, - item, + GestureDetector( + onLongPressStart: (details) { + final messageWidget = buildMsgEventItem( + context, + roomId, + messageId, + item, + ); + // reaction row + emojiSelectionAction( + context: context, + position: details.globalPosition, + messageWidget: messageWidget, + isUser: isUser, + roomId: roomId, + messageId: messageId, + ); + }, + child: Hero( + tag: messageId, + child: buildMsgEventItem( + context, + roomId, + messageId, + item, + ), + ), ), - // Emoji reactions + // Emoji reactions UI Container( padding: EdgeInsets.only( right: isUser ? 12 : 0, diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart new file mode 100644 index 000000000000..f0599d4af287 --- /dev/null +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2024, Acter Global, (c) 2022 Simform Solutions + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import 'package:acter/common/themes/app_theme.dart'; +import 'package:acter/common/utils/constants.dart'; +import 'package:acter/common/widgets/emoji_picker_widget.dart'; +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:atlas_icons/atlas_icons.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('a3::chat::reactions_selector_row'); + +class ReactionSelectorRow extends ConsumerWidget { + final double? size; + final String messageId; + final String roomId; + final bool isUser; + + const ReactionSelectorRow({ + super.key, + required this.isUser, + required this.messageId, + required this.roomId, + this.size, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.all(8), + margin: EdgeInsets.only( + bottom: 4, + left: isUser ? 0 : 8, + right: isUser ? 8 : 0, + ), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + color: Theme.of(context).colorScheme.surface.withOpacity(0.8), + ), + child: _buildEmojiRow(context, ref), + ), + ); + } + + Widget _buildEmojiRow(BuildContext context, WidgetRef ref) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + direction: Axis.horizontal, + spacing: 10.0, + children: [ + ..._buildEmojiButtons(context, ref), + _buildMoreButton(context, ref), + ], + ), + ], + ); + } + + List _buildEmojiButtons(BuildContext context, WidgetRef ref) { + return [ + heart, + thumbsUp, + prayHands, + faceWithTears, + clappingHands, + raisedHands, + astonishedFace, + ].map((emoji) => _buildEmojiButton(emoji, context, ref)).toList(); + } + + Widget _buildEmojiButton(String emoji, BuildContext context, WidgetRef ref) { + return InkWell( + onTap: () async { + await toggleReaction(ref, messageId, emoji); + if (context.mounted) Navigator.pop(context); + }, + child: Text( + emoji, + style: (EmojiConfig.emojiTextStyle ?? const TextStyle()) + .copyWith(fontSize: size ?? 28), + ), + ); + } + + Widget _buildMoreButton(BuildContext context, WidgetRef ref) { + return InkWell( + onTap: () => _showEmojiPicker(context, ref), + child: const Padding( + padding: EdgeInsets.only(top: 3), + child: Icon( + Atlas.dots_horizontal_thin, + size: 28, + ), + ), + ); + } + + Future toggleReaction( + WidgetRef ref, + String uniqueId, + String emoji, + ) async { + try { + final stream = await ref.read(timelineStreamProvider(roomId).future); + await stream.toggleReaction(uniqueId, emoji); + } catch (e, s) { + _log.severe('Reaction toggle failed', e, s); + } + } + + void _showEmojiPicker(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + enableDrag: false, + builder: (context) => EmojiPickerWidget( + withBoarder: true, + onEmojiSelected: (category, emoji) async { + await toggleReaction(ref, messageId, emoji.emoji); + if (context.mounted) Navigator.pop(context); + }, + onClosePicker: () => Navigator.pop(context), + ), + ); + } +} From 122f3512abb071a7fca63c712467b4f1e6020a75 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 16 Jan 2025 18:08:47 +0500 Subject: [PATCH 04/13] fix emoji picker dismissal view --- .../chat_ng/widgets/reactions/reaction_selector_row.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart index f0599d4af287..da177c43a5d8 100644 --- a/app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart @@ -140,7 +140,11 @@ class ReactionSelectorRow extends ConsumerWidget { withBoarder: true, onEmojiSelected: (category, emoji) async { await toggleReaction(ref, messageId, emoji.emoji); - if (context.mounted) Navigator.pop(context); + if (context.mounted) { + // we have overlays opened, dismiss both of them + Navigator.pop(context); + Navigator.pop(context); + } }, onClosePicker: () => Navigator.pop(context), ), From 53693f982137ecf143aa7af2c8aa83cb50857a82 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 16 Jan 2025 18:53:20 +0500 Subject: [PATCH 05/13] better renaming and documentation --- ...n_action.dart => reaction_selection_action.dart} | 13 +++++++------ .../chat_ng/widgets/events/chat_event_item.dart | 8 ++++---- ...ion_selector_row.dart => reaction_selector.dart} | 4 ++-- 3 files changed, 13 insertions(+), 12 deletions(-) rename app/lib/features/chat_ng/actions/{emoji_selection_action.dart => reaction_selection_action.dart} (91%) rename app/lib/features/chat_ng/widgets/reactions/{reaction_selector_row.dart => reaction_selector.dart} (98%) diff --git a/app/lib/features/chat_ng/actions/emoji_selection_action.dart b/app/lib/features/chat_ng/actions/reaction_selection_action.dart similarity index 91% rename from app/lib/features/chat_ng/actions/emoji_selection_action.dart rename to app/lib/features/chat_ng/actions/reaction_selection_action.dart index 5eb0a90b61fd..84875ade383c 100644 --- a/app/lib/features/chat_ng/actions/emoji_selection_action.dart +++ b/app/lib/features/chat_ng/actions/reaction_selection_action.dart @@ -1,9 +1,10 @@ import 'dart:ui'; -import 'package:acter/features/chat_ng/widgets/reactions/reaction_selector_row.dart'; +import 'package:acter/features/chat_ng/widgets/reactions/reaction_selector.dart'; import 'package:flutter/material.dart'; -void emojiSelectionAction({ +// reaction selector action on chat message +void reactionSelectionAction({ required BuildContext context, required Offset position, required Widget messageWidget, @@ -37,10 +38,10 @@ void emojiSelectionAction({ Positioned( left: position.dx, top: position.dy - 60, - child: _AnimatedReactionRow( + child: _AnimatedReactionSelector( animation: animation, messageId: messageId, - child: ReactionSelectorRow( + child: ReactionSelector( isUser: isUser, messageId: messageId, roomId: roomId, @@ -86,12 +87,12 @@ class _ReactionOverlay extends StatelessWidget { } } -class _AnimatedReactionRow extends StatelessWidget { +class _AnimatedReactionSelector extends StatelessWidget { final Animation animation; final Widget child; final String messageId; - const _AnimatedReactionRow({ + const _AnimatedReactionSelector({ required this.animation, required this.child, required this.messageId, diff --git a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart index 3d505b2ac73b..2264faa9bbec 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart @@ -1,7 +1,7 @@ import 'package:acter/features/chat/utils.dart'; import 'package:acter/features/chat/widgets/messages/encrypted_message.dart'; import 'package:acter/features/chat/widgets/messages/redacted_message.dart'; -import 'package:acter/features/chat_ng/actions/emoji_selection_action.dart'; +import 'package:acter/features/chat_ng/actions/reaction_selection_action.dart'; import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; @@ -50,8 +50,8 @@ class ChatEventItem extends StatelessWidget { messageId, item, ); - // reaction row - emojiSelectionAction( + // show reaction row + reactionSelectionAction( context: context, position: details.globalPosition, messageWidget: messageWidget, @@ -70,7 +70,7 @@ class ChatEventItem extends StatelessWidget { ), ), ), - // Emoji reactions UI + // reactions of message Container( padding: EdgeInsets.only( right: isUser ? 12 : 0, diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_selector.dart similarity index 98% rename from app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart rename to app/lib/features/chat_ng/widgets/reactions/reaction_selector.dart index da177c43a5d8..3fdaac5d4868 100644 --- a/app/lib/features/chat_ng/widgets/reactions/reaction_selector_row.dart +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_selector.dart @@ -30,13 +30,13 @@ import 'package:logging/logging.dart'; final _log = Logger('a3::chat::reactions_selector_row'); -class ReactionSelectorRow extends ConsumerWidget { +class ReactionSelector extends ConsumerWidget { final double? size; final String messageId; final String roomId; final bool isUser; - const ReactionSelectorRow({ + const ReactionSelector({ super.key, required this.isUser, required this.messageId, From f0a5e9b24d60f1a926a8a82985e1e3f3e993919b Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 16 Jan 2025 19:02:08 +0500 Subject: [PATCH 06/13] added changelogs --- .changes/2498-chat-ng-reactions.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/2498-chat-ng-reactions.md diff --git a/.changes/2498-chat-ng-reactions.md b/.changes/2498-chat-ng-reactions.md new file mode 100644 index 000000000000..c6b22a40e27e --- /dev/null +++ b/.changes/2498-chat-ng-reactions.md @@ -0,0 +1 @@ +-[Labs] Chat-NG : added the ability to select reaction (by long press) and see reactions (detail view on long press) on messages. From 0f061ae676b9caebaf7a1df6cb6362145d4f6950 Mon Sep 17 00:00:00 2001 From: Talha Date: Thu, 16 Jan 2025 19:03:22 +0500 Subject: [PATCH 07/13] update changelogs --- .changes/2498-chat-ng-reactions.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.changes/2498-chat-ng-reactions.md b/.changes/2498-chat-ng-reactions.md index c6b22a40e27e..86a22b1d7cbf 100644 --- a/.changes/2498-chat-ng-reactions.md +++ b/.changes/2498-chat-ng-reactions.md @@ -1 +1,4 @@ --[Labs] Chat-NG : added the ability to select reaction (by long press) and see reactions (detail view on long press) on messages. +[Labs] Chat-NG : + +- added the ability to select reaction (by long press) and see reactions (detail view on long press) on messages. +- moved `Edited` indicator inside chat bubble and UX improvements. From 04d02f47151f3114e128576446efd0b8f8fdbd6f Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 17 Jan 2025 18:07:54 +0500 Subject: [PATCH 08/13] fix member count flickering UI when sending reactions --- app/lib/features/chat_ng/pages/chat_room.dart | 2 +- app/pubspec.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/lib/features/chat_ng/pages/chat_room.dart b/app/lib/features/chat_ng/pages/chat_room.dart index 74b0a782490d..e57b735a97c0 100644 --- a/app/lib/features/chat_ng/pages/chat_room.dart +++ b/app/lib/features/chat_ng/pages/chat_room.dart @@ -63,7 +63,7 @@ class ChatRoomNgPage extends ConsumerWidget { lang.membersCount(members.length), style: Theme.of(context).textTheme.bodySmall, ), - skipLoadingOnReload: false, + skipLoadingOnReload: true, error: (e, s) { _log.severe('Failed to load active members', e, s); return Text(lang.errorLoadingMembersCount(e)); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 06a45dcef913..521ac6762502 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -78,8 +78,8 @@ dependencies: path: ../packages/rust_sdk # State Management - flutter_riverpod: ^2.5.1 - riverpod: ^2.5.1 + flutter_riverpod: ^2.6.1 + riverpod: ^2.6.1 # Routing go_router: ^14.1.4 From 51cc2470d5a15370222ed7df184c2f8ad7d3bd76 Mon Sep 17 00:00:00 2001 From: Talha Date: Fri, 17 Jan 2025 18:21:44 +0500 Subject: [PATCH 09/13] add avatar info to user reaction list --- .../reactions/reaction_detail_sheet.dart | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart index 744f52e7630c..1e4e02758c96 100644 --- a/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart @@ -1,5 +1,7 @@ +import 'package:acter/common/providers/room_providers.dart'; import 'package:acter/common/themes/app_theme.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -173,7 +175,7 @@ class _ReactionUsersList extends StatelessWidget { } } -class ReactionUserItem extends StatelessWidget { +class ReactionUserItem extends ConsumerWidget { final String roomId; final String userId; final List emojis; @@ -186,9 +188,24 @@ class ReactionUserItem extends StatelessWidget { }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final memberInfo = + ref.watch(memberAvatarInfoProvider((roomId: roomId, userId: userId))); return ListTile( - title: Text(userId), + leading: ActerAvatar( + options: AvatarOptions.DM( + AvatarInfo( + uniqueId: userId, + displayName: memberInfo.displayName, + avatar: memberInfo.avatar, + ), + ), + ), + title: Text(memberInfo.displayName ?? userId), + subtitle: memberInfo.displayName != null + ? Text(userId, style: theme.textTheme.labelLarge) + : null, trailing: Wrap( children: emojis .map( From 7c9b5e795dfc428a4d13c79a5677ad439a6fb0cc Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 20 Jan 2025 18:50:45 +0500 Subject: [PATCH 10/13] Feedback review (Kumar): code improvements --- .../actions/reaction_selection_action.dart | 5 +- .../actions/toggle_reaction_action.dart | 19 +++++++ .../features/chat_ng/widgets/chat_bubble.dart | 2 +- .../chat_ng/widgets/events/chat_event.dart | 8 +-- .../widgets/events/chat_event_item.dart | 32 ++++++------ .../widgets/events/member_update_event.dart | 10 ++-- .../widgets/events/text_message_event.dart | 8 +-- .../reactions/reaction_chips_widget.dart | 4 +- .../reactions/reaction_detail_sheet.dart | 2 + .../widgets/reactions/reaction_selector.dart | 51 +++---------------- .../widgets/reactions/reactions_list.dart | 23 ++------- 11 files changed, 64 insertions(+), 100 deletions(-) create mode 100644 app/lib/features/chat_ng/actions/toggle_reaction_action.dart diff --git a/app/lib/features/chat_ng/actions/reaction_selection_action.dart b/app/lib/features/chat_ng/actions/reaction_selection_action.dart index 84875ade383c..1670e52d75a0 100644 --- a/app/lib/features/chat_ng/actions/reaction_selection_action.dart +++ b/app/lib/features/chat_ng/actions/reaction_selection_action.dart @@ -6,9 +6,8 @@ import 'package:flutter/material.dart'; // reaction selector action on chat message void reactionSelectionAction({ required BuildContext context, - required Offset position, required Widget messageWidget, - required bool isUser, + required bool isMe, required String roomId, required String messageId, }) { @@ -42,7 +41,7 @@ void reactionSelectionAction({ animation: animation, messageId: messageId, child: ReactionSelector( - isUser: isUser, + isMe: isMe, messageId: messageId, roomId: roomId, ), diff --git a/app/lib/features/chat_ng/actions/toggle_reaction_action.dart b/app/lib/features/chat_ng/actions/toggle_reaction_action.dart new file mode 100644 index 000000000000..5d297049e497 --- /dev/null +++ b/app/lib/features/chat_ng/actions/toggle_reaction_action.dart @@ -0,0 +1,19 @@ +import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; + +final _log = Logger('a3::chat::toggle_reaction'); + +Future toggleReactionAction( + WidgetRef ref, + String roomId, + String uniqueId, + String emoji, +) async { + try { + final stream = await ref.read(timelineStreamProvider(roomId).future); + await stream.toggleReaction(uniqueId, emoji); + } catch (e, s) { + _log.severe('Reaction toggle failed', e, s); + } +} diff --git a/app/lib/features/chat_ng/widgets/chat_bubble.dart b/app/lib/features/chat_ng/widgets/chat_bubble.dart index b0dbde12af8a..9c5b5a2acb01 100644 --- a/app/lib/features/chat_ng/widgets/chat_bubble.dart +++ b/app/lib/features/chat_ng/widgets/chat_bubble.dart @@ -52,7 +52,7 @@ class ChatBubble extends StatelessWidget { } // for user's own messages - factory ChatBubble.user({ + factory ChatBubble.me({ Key? key, required BuildContext context, required Widget child, diff --git a/app/lib/features/chat_ng/widgets/events/chat_event.dart b/app/lib/features/chat_ng/widgets/events/chat_event.dart index f9b35f747cbb..91d3cb0406e3 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event.dart @@ -70,14 +70,14 @@ class ChatEvent extends ConsumerWidget { final options = AvatarOptions.DM(avatarInfo, size: 14); final myId = ref.watch(myUserIdStrProvider); final messageId = msg.uniqueId(); - final isUser = myId == item.sender(); + final isMe = myId == item.sender(); // TODO: render a regular timeline event return Row( mainAxisAlignment: - !isUser ? MainAxisAlignment.start : MainAxisAlignment.end, + !isMe ? MainAxisAlignment.start : MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end, children: [ - (!isNextMessageInGroup && !isUser) + (!isNextMessageInGroup && !isMe) ? Padding( padding: const EdgeInsets.only(left: 8), child: ActerAvatar(options: options), @@ -88,7 +88,7 @@ class ChatEvent extends ConsumerWidget { roomId: roomId, messageId: messageId, item: item, - isUser: isUser, + isMe: isMe, isNextMessageInGroup: isNextMessageInGroup, ), ), diff --git a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart index 2264faa9bbec..372bc84729f8 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart @@ -20,14 +20,14 @@ class ChatEventItem extends StatelessWidget { final String roomId; final String messageId; final RoomEventItem item; - final bool isUser; + final bool isMe; final bool isNextMessageInGroup; const ChatEventItem({ super.key, required this.roomId, required this.messageId, required this.item, - required this.isUser, + required this.isMe, required this.isNextMessageInGroup, }); @@ -39,11 +39,11 @@ class ChatEventItem extends StatelessWidget { // handle message inner types separately 'm.room.message' => Column( crossAxisAlignment: - isUser ? CrossAxisAlignment.end : CrossAxisAlignment.start, + isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ GestureDetector( - onLongPressStart: (details) { + onLongPressStart: (_) { final messageWidget = buildMsgEventItem( context, roomId, @@ -53,9 +53,8 @@ class ChatEventItem extends StatelessWidget { // show reaction row reactionSelectionAction( context: context, - position: details.globalPosition, messageWidget: messageWidget, - isUser: isUser, + isMe: isMe, roomId: roomId, messageId: messageId, ); @@ -73,8 +72,8 @@ class ChatEventItem extends StatelessWidget { // reactions of message Container( padding: EdgeInsets.only( - right: isUser ? 12 : 0, - left: isUser ? 0 : 12, + right: isMe ? 12 : 0, + left: isMe ? 0 : 12, ), child: FractionalTranslation( translation: const Offset(0, -0.1), @@ -82,14 +81,13 @@ class ChatEventItem extends StatelessWidget { roomId: roomId, messageId: messageId, item: item, - isNextMessageInGroup: isNextMessageInGroup, ), ), ), ], ), - 'm.room.redaction' => isUser - ? ChatBubble.user( + 'm.room.redaction' => isMe + ? ChatBubble.me( context: context, isNextMessageInGroup: isNextMessageInGroup, child: RedactedMessageWidget(), @@ -99,8 +97,8 @@ class ChatEventItem extends StatelessWidget { isNextMessageInGroup: isNextMessageInGroup, child: RedactedMessageWidget(), ), - 'm.room.encrypted' => isUser - ? ChatBubble.user( + 'm.room.encrypted' => isMe + ? ChatBubble.me( context: context, isNextMessageInGroup: isNextMessageInGroup, child: EncryptedMessageWidget(), @@ -111,7 +109,7 @@ class ChatEventItem extends StatelessWidget { child: EncryptedMessageWidget(), ), 'm.room.member' => MemberUpdateEvent( - isUser: isUser, + isMe: isMe, item: item, ), 'm.policy.rule.room' || @@ -194,7 +192,7 @@ class ChatEventItem extends StatelessWidget { return TextMessageEvent.emoji( content: content, roomId: roomId, - isUser: isUser, + isMe: isMe, ); } @@ -203,8 +201,8 @@ class ChatEventItem extends StatelessWidget { ? child = TextMessageEvent.notice(content: content, roomId: roomId) : child = TextMessageEvent(content: content, roomId: roomId); - if (isUser) { - return ChatBubble.user( + if (isMe) { + return ChatBubble.me( context: context, repliedToBuilder: repliedToBuilder, isNextMessageInGroup: isNextMessageInGroup, diff --git a/app/lib/features/chat_ng/widgets/events/member_update_event.dart b/app/lib/features/chat_ng/widgets/events/member_update_event.dart index 8c4474df4321..b318999a7d55 100644 --- a/app/lib/features/chat_ng/widgets/events/member_update_event.dart +++ b/app/lib/features/chat_ng/widgets/events/member_update_event.dart @@ -5,11 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; class MemberUpdateEvent extends StatelessWidget { - final bool isUser; + final bool isMe; final RoomEventItem item; const MemberUpdateEvent({ super.key, - required this.isUser, + required this.isMe, required this.item, }); @@ -22,7 +22,7 @@ class MemberUpdateEvent extends StatelessWidget { final firstName = simplifyUserId(senderId); if (msgType == 'Joined') { - if (isUser) { + if (isMe) { textMsg = lang.chatYouJoined; } else if (firstName != null) { textMsg = lang.chatJoinedDisplayName(firstName); @@ -30,7 +30,7 @@ class MemberUpdateEvent extends StatelessWidget { textMsg = lang.chatJoinedUserId(senderId); } } else if (msgType == 'InvitationAccepted') { - if (isUser) { + if (isMe) { textMsg = lang.chatYouAcceptedInvite; } else if (firstName != null) { textMsg = lang.chatInvitationAcceptedDisplayName(firstName); @@ -38,7 +38,7 @@ class MemberUpdateEvent extends StatelessWidget { textMsg = lang.chatInvitationAcceptedUserId(senderId); } } else if (msgType == 'Invited') { - if (isUser) { + if (isMe) { textMsg = lang.chatYouInvited; } else if (firstName != null) { textMsg = lang.chatInvitedDisplayName(firstName); diff --git a/app/lib/features/chat_ng/widgets/events/text_message_event.dart b/app/lib/features/chat_ng/widgets/events/text_message_event.dart index 68c5188ab997..795deeb72258 100644 --- a/app/lib/features/chat_ng/widgets/events/text_message_event.dart +++ b/app/lib/features/chat_ng/widgets/events/text_message_event.dart @@ -24,22 +24,22 @@ class TextMessageEvent extends StatelessWidget { required this.content, required this.roomId, required TextMessageType type, - bool isUser = false, + bool isMe = false, }) : _type = type, - _isUser = isUser; + _isUser = isMe; factory TextMessageEvent.emoji({ Key? key, required MsgContent content, required String roomId, - required bool isUser, + required bool isMe, }) { return TextMessageEvent.inner( key: key, content: content, roomId: roomId, type: TextMessageType.emoji, - isUser: isUser, + isMe: isMe, ); } diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart index 9cb185108905..b3c11449d5b9 100644 --- a/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_chips_widget.dart @@ -85,14 +85,14 @@ class _ReactionChip extends StatelessWidget { labelPadding: sentByMe ? EdgeInsets.symmetric(horizontal: 3) : EdgeInsets.zero, shape: const StadiumBorder(side: BorderSide(color: Colors.transparent)), - avatar: moreThanOne ? _buildEmojiText() : null, + avatar: _buildEmojiText(), labelStyle: Theme.of(context).textTheme.labelLarge, label: moreThanOne ? Text( records.length.toString(), style: Theme.of(context).textTheme.labelLarge, ) - : _buildEmojiText(), + : const SizedBox.shrink(), ), ); } diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart index 1e4e02758c96..b8b9bc9e57b7 100644 --- a/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart @@ -116,7 +116,9 @@ class _ReactionDetailsSheetState extends ConsumerState Map> userReactions, Map> reactionUsers, }) _processReactionData() { + // how many reactions per user final userReactions = >{}; + // how many users of single reaction final reactionUsers = >{}; for (final reaction in widget.reactions) { diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_selector.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_selector.dart index 3fdaac5d4868..a8005e9fc533 100644 --- a/app/lib/features/chat_ng/widgets/reactions/reaction_selector.dart +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_selector.dart @@ -1,44 +1,20 @@ -/* - * Copyright (c) 2024, Acter Global, (c) 2022 Simform Solutions - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ import 'package:acter/common/themes/app_theme.dart'; import 'package:acter/common/utils/constants.dart'; import 'package:acter/common/widgets/emoji_picker_widget.dart'; -import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/chat_ng/actions/toggle_reaction_action.dart'; import 'package:atlas_icons/atlas_icons.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; - -final _log = Logger('a3::chat::reactions_selector_row'); class ReactionSelector extends ConsumerWidget { final double? size; final String messageId; final String roomId; - final bool isUser; + final bool isMe; const ReactionSelector({ super.key, - required this.isUser, + required this.isMe, required this.messageId, required this.roomId, this.size, @@ -52,8 +28,8 @@ class ReactionSelector extends ConsumerWidget { padding: const EdgeInsets.all(8), margin: EdgeInsets.only( bottom: 4, - left: isUser ? 0 : 8, - right: isUser ? 8 : 0, + left: isMe ? 0 : 8, + right: isMe ? 8 : 0, ), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(20)), @@ -95,7 +71,7 @@ class ReactionSelector extends ConsumerWidget { Widget _buildEmojiButton(String emoji, BuildContext context, WidgetRef ref) { return InkWell( onTap: () async { - await toggleReaction(ref, messageId, emoji); + await toggleReactionAction(ref, roomId, messageId, emoji); if (context.mounted) Navigator.pop(context); }, child: Text( @@ -119,19 +95,6 @@ class ReactionSelector extends ConsumerWidget { ); } - Future toggleReaction( - WidgetRef ref, - String uniqueId, - String emoji, - ) async { - try { - final stream = await ref.read(timelineStreamProvider(roomId).future); - await stream.toggleReaction(uniqueId, emoji); - } catch (e, s) { - _log.severe('Reaction toggle failed', e, s); - } - } - void _showEmojiPicker(BuildContext context, WidgetRef ref) { showModalBottomSheet( context: context, @@ -139,7 +102,7 @@ class ReactionSelector extends ConsumerWidget { builder: (context) => EmojiPickerWidget( withBoarder: true, onEmojiSelected: (category, emoji) async { - await toggleReaction(ref, messageId, emoji.emoji); + await toggleReactionAction(ref, roomId, messageId, emoji.emoji); if (context.mounted) { // we have overlays opened, dismiss both of them Navigator.pop(context); diff --git a/app/lib/features/chat_ng/widgets/reactions/reactions_list.dart b/app/lib/features/chat_ng/widgets/reactions/reactions_list.dart index 5b02cb9dc753..496954339b54 100644 --- a/app/lib/features/chat_ng/widgets/reactions/reactions_list.dart +++ b/app/lib/features/chat_ng/widgets/reactions/reactions_list.dart @@ -1,4 +1,4 @@ -import 'package:acter/features/chat/providers/chat_providers.dart'; +import 'package:acter/features/chat_ng/actions/toggle_reaction_action.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:acter/features/chat_ng/widgets/reactions/reaction_chips_widget.dart'; import 'package:acter/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart'; @@ -6,21 +6,16 @@ import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show RoomEventItem; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:logging/logging.dart'; - -final _log = Logger('a3::chat::reactions_list'); class ReactionsList extends ConsumerWidget { final String roomId; final String messageId; final RoomEventItem item; - final bool isNextMessageInGroup; const ReactionsList({ super.key, required this.roomId, required this.messageId, required this.item, - required this.isNextMessageInGroup, }); @override @@ -29,7 +24,8 @@ class ReactionsList extends ConsumerWidget { if (reactions.isEmpty) return const SizedBox.shrink(); return ReactionChipsWidget( reactions: reactions, - onReactionTap: (emoji) => toggleReaction(ref, messageId, emoji), + onReactionTap: (emoji) => + toggleReactionAction(ref, roomId, messageId, emoji), onReactionLongPress: () => showReactionsSheet(context, reactions), ); } @@ -43,17 +39,4 @@ class ReactionsList extends ConsumerWidget { ), ); } - - Future toggleReaction( - WidgetRef ref, - String uniqueId, - String emoji, - ) async { - try { - final stream = await ref.read(timelineStreamProvider(roomId).future); - await stream.toggleReaction(uniqueId, emoji); - } catch (e, s) { - _log.severe('Reaction toggle failed', e, s); - } - } } From f40a4675b2f6110cdbb2bb65d3281b58f1cadb38 Mon Sep 17 00:00:00 2001 From: Talha Date: Mon, 20 Jan 2025 18:52:38 +0500 Subject: [PATCH 11/13] Feedback review (Kumar): update changelogs --- .changes/2498-chat-ng-reactions.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.changes/2498-chat-ng-reactions.md b/.changes/2498-chat-ng-reactions.md index 86a22b1d7cbf..9710766bc9c7 100644 --- a/.changes/2498-chat-ng-reactions.md +++ b/.changes/2498-chat-ng-reactions.md @@ -1,4 +1,5 @@ [Labs] Chat-NG : -- added the ability to select reaction (by long press) and see reactions (detail view on long press) on messages. -- moved `Edited` indicator inside chat bubble and UX improvements. +- Added the ability to do reaction on message (by long press). +- You can also see reactions details (By long press on reaction) of messages. +- Moved `Edited` indicator inside chat bubble and UX improvements. From a2f19341d798f79f70f870ba53e290494306fd12 Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 21 Jan 2025 21:22:49 +0500 Subject: [PATCH 12/13] Feedback review (ben): code improvements and widget tests --- .../widgets/events/chat_event_item.dart | 147 +--------- .../widgets/events/message_event_item.dart | 182 ++++++++++++ .../reactions/reaction_detail_sheet.dart | 62 +++-- .../common/mock_data/mock_avatar_info.dart | 5 +- app/test/common/mock_data/mock_user_id.dart | 12 +- .../chat_ng/reactions/reactions_test.dart | 263 ++++++++++++++++++ .../features/comments/add_comment_test.dart | 2 +- .../features/comments/comment_item_test.dart | 4 +- .../features/comments/comment_list_test.dart | 6 +- 9 files changed, 518 insertions(+), 165 deletions(-) create mode 100644 app/lib/features/chat_ng/widgets/events/message_event_item.dart create mode 100644 app/test/features/chat_ng/reactions/reactions_test.dart diff --git a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart index 372bc84729f8..33e44d27ec58 100644 --- a/app/lib/features/chat_ng/widgets/events/chat_event_item.dart +++ b/app/lib/features/chat_ng/widgets/events/chat_event_item.dart @@ -1,17 +1,10 @@ -import 'package:acter/features/chat/utils.dart'; import 'package:acter/features/chat/widgets/messages/encrypted_message.dart'; import 'package:acter/features/chat/widgets/messages/redacted_message.dart'; -import 'package:acter/features/chat_ng/actions/reaction_selection_action.dart'; import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; -import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; -import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; import 'package:acter/features/chat_ng/widgets/events/member_update_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/message_event_item.dart'; import 'package:acter/features/chat_ng/widgets/events/state_update_event.dart'; -import 'package:acter/features/chat_ng/widgets/events/text_message_event.dart'; -import 'package:acter/features/chat_ng/widgets/events/video_message_event.dart'; -import 'package:acter/common/extensions/options.dart'; -import 'package:acter/features/chat_ng/widgets/reactions/reactions_list.dart'; -import 'package:acter/features/chat_ng/widgets/replied_to_preview.dart'; + import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' show RoomEventItem; import 'package:flutter/material.dart'; @@ -37,54 +30,12 @@ class ChatEventItem extends StatelessWidget { return switch (eventType) { // handle message inner types separately - 'm.room.message' => Column( - crossAxisAlignment: - isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - GestureDetector( - onLongPressStart: (_) { - final messageWidget = buildMsgEventItem( - context, - roomId, - messageId, - item, - ); - // show reaction row - reactionSelectionAction( - context: context, - messageWidget: messageWidget, - isMe: isMe, - roomId: roomId, - messageId: messageId, - ); - }, - child: Hero( - tag: messageId, - child: buildMsgEventItem( - context, - roomId, - messageId, - item, - ), - ), - ), - // reactions of message - Container( - padding: EdgeInsets.only( - right: isMe ? 12 : 0, - left: isMe ? 0 : 12, - ), - child: FractionalTranslation( - translation: const Offset(0, -0.1), - child: ReactionsList( - roomId: roomId, - messageId: messageId, - item: item, - ), - ), - ), - ], + 'm.room.message' => MessageEventItem( + roomId: roomId, + messageId: messageId, + item: item, + isMe: isMe, + isNextMessageInGroup: isNextMessageInGroup, ), 'm.room.redaction' => isMe ? ChatBubble.me( @@ -137,88 +88,6 @@ class ChatEventItem extends StatelessWidget { }; } - Widget buildMsgEventItem( - BuildContext context, - String roomId, - String messageId, - RoomEventItem item, - ) { - final msgType = item.msgType(); - final content = item.msgContent(); - // shouldn't happen but in case return empty - if (msgType == null || content == null) return const SizedBox.shrink(); - - return switch (msgType) { - 'm.emote' || - 'm.notice' || - 'm.server_notice' || - 'm.text' => - buildTextMsgEvent(context, item), - 'm.image' => ImageMessageEvent( - messageId: messageId, - roomId: roomId, - content: content, - ), - 'm.video' => VideoMessageEvent( - roomId: roomId, - messageId: messageId, - content: content, - ), - 'm.file' => FileMessageEvent( - roomId: roomId, - messageId: messageId, - content: content, - ), - _ => _buildUnsupportedMessage(msgType), - }; - } - - Widget buildTextMsgEvent(BuildContext context, RoomEventItem item) { - final msgType = item.msgType(); - final repliedTo = item.inReplyTo(); - final wasEdited = item.wasEdited(); - final content = item.msgContent().expect('cannot be null'); - final isNotice = (msgType == 'm.notice' || msgType == 'm.server_notice'); - Widget? repliedToBuilder; - - // whether it contains `replied to` event. - if (repliedTo != null) { - repliedToBuilder = - RepliedToPreview(roomId: roomId, originalId: repliedTo); - } - - // if only consists of emojis - if (isOnlyEmojis(content.body())) { - return TextMessageEvent.emoji( - content: content, - roomId: roomId, - isMe: isMe, - ); - } - - late Widget child; - isNotice - ? child = TextMessageEvent.notice(content: content, roomId: roomId) - : child = TextMessageEvent(content: content, roomId: roomId); - - if (isMe) { - return ChatBubble.me( - context: context, - repliedToBuilder: repliedToBuilder, - isNextMessageInGroup: isNextMessageInGroup, - isEdited: wasEdited, - child: child, - ); - } - return ChatBubble( - context: context, - repliedToBuilder: repliedToBuilder, - isNextMessageInGroup: isNextMessageInGroup, - isEdited: wasEdited, - child: child, - ); - } - Widget _buildUnsupportedMessage(String? msgtype) { return Text( 'Unsupported event type: $msgtype', diff --git a/app/lib/features/chat_ng/widgets/events/message_event_item.dart b/app/lib/features/chat_ng/widgets/events/message_event_item.dart new file mode 100644 index 000000000000..0dd65a99244d --- /dev/null +++ b/app/lib/features/chat_ng/widgets/events/message_event_item.dart @@ -0,0 +1,182 @@ +import 'package:acter/features/chat/utils.dart'; +import 'package:acter/features/chat_ng/actions/reaction_selection_action.dart'; +import 'package:acter/features/chat_ng/widgets/chat_bubble.dart'; +import 'package:acter/features/chat_ng/widgets/events/file_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/image_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/text_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/events/video_message_event.dart'; +import 'package:acter/features/chat_ng/widgets/reactions/reactions_list.dart'; +import 'package:acter/common/extensions/options.dart'; +import 'package:acter/features/chat_ng/widgets/replied_to_preview.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show RoomEventItem; +import 'package:flutter/widgets.dart'; + +class MessageEventItem extends StatelessWidget { + final String roomId; + final String messageId; + final RoomEventItem item; + final bool isMe; + final bool isNextMessageInGroup; + + const MessageEventItem({ + super.key, + required this.roomId, + required this.messageId, + required this.item, + required this.isMe, + required this.isNextMessageInGroup, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: + isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildMessageUI(context, roomId, messageId, item, isMe), + _buildReactionsList(roomId, messageId, item, isMe), + ], + ); + } + + Widget _buildMessageUI( + BuildContext context, + String roomId, + String messageId, + RoomEventItem item, + bool isMe, + ) { + final messageWidget = buildMsgEventItem( + context, + roomId, + messageId, + item, + ); + + return GestureDetector( + onLongPressStart: (_) => reactionSelectionAction( + context: context, + messageWidget: messageWidget, + isMe: isMe, + roomId: roomId, + messageId: messageId, + ), + child: Hero( + tag: messageId, + child: messageWidget, + ), + ); + } + + Widget _buildReactionsList( + String roomId, + String messageId, + RoomEventItem item, + bool isMe, + ) { + return Padding( + padding: EdgeInsets.only( + right: isMe ? 12 : 0, + left: isMe ? 0 : 12, + ), + child: FractionalTranslation( + translation: Offset(0, -0.1), + child: ReactionsList( + roomId: roomId, + messageId: messageId, + item: item, + ), + ), + ); + } + + Widget buildMsgEventItem( + BuildContext context, + String roomId, + String messageId, + RoomEventItem item, + ) { + final msgType = item.msgType(); + final content = item.msgContent(); + // shouldn't happen but in case return empty + if (msgType == null || content == null) return const SizedBox.shrink(); + + return switch (msgType) { + 'm.emote' || + 'm.notice' || + 'm.server_notice' || + 'm.text' => + buildTextMsgEvent(context, item), + 'm.image' => ImageMessageEvent( + messageId: messageId, + roomId: roomId, + content: content, + ), + 'm.video' => VideoMessageEvent( + roomId: roomId, + messageId: messageId, + content: content, + ), + 'm.file' => FileMessageEvent( + roomId: roomId, + messageId: messageId, + content: content, + ), + _ => _buildUnsupportedMessage(msgType), + }; + } + + Widget buildTextMsgEvent(BuildContext context, RoomEventItem item) { + final msgType = item.msgType(); + final repliedTo = item.inReplyTo(); + final wasEdited = item.wasEdited(); + final content = item.msgContent().expect('cannot be null'); + final isNotice = (msgType == 'm.notice' || msgType == 'm.server_notice'); + Widget? repliedToBuilder; + + // whether it contains `replied to` event. + if (repliedTo != null) { + repliedToBuilder = + RepliedToPreview(roomId: roomId, originalId: repliedTo); + } + + // if only consists of emojis + if (isOnlyEmojis(content.body())) { + return TextMessageEvent.emoji( + content: content, + roomId: roomId, + isMe: isMe, + ); + } + + late Widget child; + isNotice + ? child = TextMessageEvent.notice(content: content, roomId: roomId) + : child = TextMessageEvent(content: content, roomId: roomId); + + if (isMe) { + return ChatBubble.me( + context: context, + repliedToBuilder: repliedToBuilder, + isNextMessageInGroup: isNextMessageInGroup, + isEdited: wasEdited, + child: child, + ); + } + return ChatBubble( + context: context, + repliedToBuilder: repliedToBuilder, + isNextMessageInGroup: isNextMessageInGroup, + isEdited: wasEdited, + child: child, + ); + } + + Widget _buildUnsupportedMessage(String? msgtype) { + return Text( + 'Unsupported event type: $msgtype', + ); + } +} diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart index b8b9bc9e57b7..3ad7c9b23214 100644 --- a/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart @@ -5,6 +5,12 @@ import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +typedef ReactionData = ({ + List allUsers, + Map> userReactions, + Map> reactionUsers, +}); + class ReactionDetailsSheet extends ConsumerStatefulWidget { final String roomId; final List reactions; @@ -24,13 +30,45 @@ class _ReactionDetailsSheetState extends ConsumerState with TickerProviderStateMixin { late TabController _tabController; late List _tabs; + late final ReactionData _reactionData; @override void initState() { super.initState(); + _reactionData = _processReactionData(); _initializeTabs(); } + @override + void didUpdateWidget(ReactionDetailsSheet oldWidget) { + super.didUpdateWidget(oldWidget); + + // Check if reactions have changed + final hasChanged = widget.reactions.length != oldWidget.reactions.length || + widget.reactions.any((reaction) { + final oldReaction = oldWidget.reactions.firstWhere( + (old) => old.$1 == reaction.$1, + orElse: () => (reaction.$1, []), + ); + return reaction.$2.length != oldReaction.$2.length; + }); + + if (hasChanged) { + setState(() { + _reactionData = _processReactionData(); + // dispose old one + _tabController.dispose(); + _initializeTabs(); + }); + } + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + void _initializeTabs() { final total = widget.reactions.fold( 0, @@ -43,7 +81,7 @@ class _ReactionDetailsSheetState extends ConsumerState (reaction) => Tab( child: Chip( avatar: Text(reaction.$1, style: EmojiConfig.emojiTextStyle), - label: Text('${reaction.$2.length}'), + label: Text(reaction.$2.length.toString()), ), ), ), @@ -52,12 +90,6 @@ class _ReactionDetailsSheetState extends ConsumerState _tabController = TabController(length: _tabs.length, vsync: this); } - @override - void dispose() { - _tabController.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { return Column( @@ -85,8 +117,6 @@ class _ReactionDetailsSheetState extends ConsumerState } Widget _buildTabBarView() { - final reactionData = _processReactionData(); - return Flexible( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14), @@ -95,14 +125,14 @@ class _ReactionDetailsSheetState extends ConsumerState children: [ _ReactionUsersList( roomId: widget.roomId, - users: reactionData.allUsers, - userReactions: reactionData.userReactions, + users: _reactionData.allUsers, + userReactions: _reactionData.userReactions, ), ...widget.reactions.map( (reaction) => _ReactionUsersList( roomId: widget.roomId, - users: reactionData.reactionUsers[reaction.$1] ?? [], - userReactions: reactionData.userReactions, + users: _reactionData.reactionUsers[reaction.$1] ?? [], + userReactions: _reactionData.userReactions, ), ), ], @@ -111,11 +141,7 @@ class _ReactionDetailsSheetState extends ConsumerState ); } - ({ - List allUsers, - Map> userReactions, - Map> reactionUsers, - }) _processReactionData() { + ReactionData _processReactionData() { // how many reactions per user final userReactions = >{}; // how many users of single reaction diff --git a/app/test/common/mock_data/mock_avatar_info.dart b/app/test/common/mock_data/mock_avatar_info.dart index bc57793329c1..4163f09db150 100644 --- a/app/test/common/mock_data/mock_avatar_info.dart +++ b/app/test/common/mock_data/mock_avatar_info.dart @@ -4,9 +4,12 @@ import 'package:mocktail/mocktail.dart'; class MockAvatarInfo extends Mock implements AvatarInfo { @override - String get uniqueId => 'mockUniqueId'; + final String uniqueId; + + String get userId => uniqueId; @override TooltipStyle get tooltip => TooltipStyle.Combined; + MockAvatarInfo({required this.uniqueId}); } diff --git a/app/test/common/mock_data/mock_user_id.dart b/app/test/common/mock_data/mock_user_id.dart index 4ed746f9f982..b44daf3e13c4 100644 --- a/app/test/common/mock_data/mock_user_id.dart +++ b/app/test/common/mock_data/mock_user_id.dart @@ -1,4 +1,14 @@ import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart'; import 'package:mocktail/mocktail.dart'; -class MockUserId extends Mock implements UserId {} +class MockUserId extends Mock implements UserId { + final String _value; + + MockUserId(this._value); + + @override + String toString() => _value; +} + +// Utility function to create mock user IDs from str +MockUserId createMockUserId(String value) => MockUserId(value); diff --git a/app/test/features/chat_ng/reactions/reactions_test.dart b/app/test/features/chat_ng/reactions/reactions_test.dart new file mode 100644 index 000000000000..57568b1d005c --- /dev/null +++ b/app/test/features/chat_ng/reactions/reactions_test.dart @@ -0,0 +1,263 @@ +import 'package:acter/common/providers/room_providers.dart'; +import 'package:acter/common/providers/sdk_provider.dart'; +import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; +import 'package:acter/features/chat_ng/widgets/reactions/reaction_chips_widget.dart'; +import 'package:acter/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart'; +import 'package:acter/features/chat_ng/widgets/reactions/reactions_list.dart'; +import 'package:acter_flutter_sdk/acter_flutter_sdk_ffi.dart' + show ReactionRecord, UserId; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../../common/mock_data/mock_avatar_info.dart'; +import '../../../common/mock_data/mock_user_id.dart'; +import '../../../helpers/mock_a3sdk.dart'; +import '../../../helpers/test_util.dart'; +import '../messages/chat_message_test.dart'; + +class MockReactionRecord extends Mock implements ReactionRecord { + final UserId _senderId; + + MockReactionRecord(this._senderId); + + @override + bool sentByMe() => false; + @override + UserId senderId() => _senderId; +} + +void main() { + group('Reactions widgets test', () { + group('tab initialization tests', () { + testWidgets('creates correct number of initial tabs', (tester) async { + final reactions = [ + ('👍', [MockReactionRecord(createMockUserId('user-1'))]), + ( + '❤️', + [ + MockReactionRecord(createMockUserId('user-2')), + MockReactionRecord(createMockUserId('user-3')), + ] + ), + ]; + + await tester.pumpProviderWidget( + overrides: [ + memberAvatarInfoProvider.overrideWith( + (ref, param) => MockAvatarInfo(uniqueId: param.userId), + ), + ], + child: MaterialApp( + home: Scaffold( + body: ReactionDetailsSheet( + roomId: 'test-room', + reactions: reactions, + ), + ), + ), + ); + + // Verify "All" tab plus one per reaction + expect(find.byType(Tab), findsNWidgets(3)); + expect(find.text('All 3'), findsOneWidget); + expect(find.text('1'), findsOneWidget); // 👍 count + expect(find.text('2'), findsOneWidget); // ❤️ count + }); + + testWidgets('handles empty reactions list', (tester) async { + await tester.pumpProviderWidget( + child: MaterialApp( + home: Scaffold( + body: ReactionDetailsSheet( + roomId: 'test-room', + reactions: [], + ), + ), + ), + ); + + expect(find.byType(Tab), findsNWidgets(1)); // Only "All" tab + expect(find.text('All 0'), findsOneWidget); + }); + }); + + group('reaction update tests', () { + final mockEvent = MockRoomEventItem(mockSender: 'user-1'); + final reactionsNotifier = StateController>([ + ('👍', [MockReactionRecord(createMockUserId('user-1'))]), + ]); + final overrides = [ + sdkProvider.overrideWith((ref) => MockActerSdk()), + memberAvatarInfoProvider.overrideWith( + (ref, param) => MockAvatarInfo(uniqueId: param.userId), + ), + messageReactionsProvider(mockEvent).overrideWith( + (ref) => reactionsNotifier.state, + ), + ]; + // Helper to open bottom sheet + Future openReactionsDetailSheet( + List reactions, + WidgetTester tester, + ) async { + await tester.tap(find.byType(ReactionChipsWidget)); + await tester.pumpAndSettle(); + + // Show bottom sheet directly + await tester.pump(); + showModalBottomSheet( + context: tester.element(find.byType(ReactionsList)), + builder: (context) => ReactionDetailsSheet( + roomId: 'test-room', + reactions: reactions, + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('updates tabs when reactions change', (tester) async { + await tester.pumpProviderWidget( + overrides: overrides, + child: MaterialApp( + home: Scaffold( + body: ReactionsList( + roomId: 'test-room', + messageId: 'message-1', + item: mockEvent, + ), + ), + ), + ); + + // First time opening with initial state + await openReactionsDetailSheet(reactionsNotifier.state, tester); + expect(find.byType(Tab), findsNWidgets(2)); + expect(find.text('All 1'), findsOneWidget); + + // Close sheet + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + + // Update reactions + final updatedReactions = [ + ('👍', [MockReactionRecord(createMockUserId('user-1'))]), + ('❤️', [MockReactionRecord(createMockUserId('user-2'))]), + ]; + reactionsNotifier.state = updatedReactions; + await tester.pumpAndSettle(); + + // Open bottom sheet again with updated state + await openReactionsDetailSheet(reactionsNotifier.state, tester); + expect( + find.byType(Tab), + findsNWidgets(3), + reason: 'Should have 3 tabs after adding a new reaction', + ); + expect( + find.text('All 2'), + findsOneWidget, + reason: 'Should show total of 2 reactions', + ); + }); + + testWidgets('updates when reaction count changes', (tester) async { + // reset notifier state before previous test run + reactionsNotifier.state = [ + ('👍', [MockReactionRecord(createMockUserId('user-1'))]), + ]; + await tester.pumpProviderWidget( + overrides: overrides, + child: MaterialApp( + home: Scaffold( + body: ReactionsList( + roomId: 'test-room', + messageId: 'message-1', + item: mockEvent, + ), + ), + ), + ); + + // Show initial state + await openReactionsDetailSheet(reactionsNotifier.state, tester); + expect(find.text('1'), findsOneWidget); + + // Close bottom sheet + await tester.tapAt(const Offset(20, 20)); + await tester.pumpAndSettle(); + + // Update state with second user + reactionsNotifier.state = [ + ( + '👍', + [ + MockReactionRecord(createMockUserId('user-1')), + MockReactionRecord(createMockUserId('user-2')), + ] + ), + ]; + await tester.pumpAndSettle(); + + // Show updated state + await openReactionsDetailSheet(reactionsNotifier.state, tester); + expect(find.text('2'), findsOneWidget); + }); + }); + + group('user list tests', () { + testWidgets('displays correct users count in each tab', (tester) async { + final reactions = [ + ( + '👍', + [ + MockReactionRecord(createMockUserId('user-1')), + MockReactionRecord(createMockUserId('user-2')), + ] + ), + ( + '❤️', + [ + MockReactionRecord(createMockUserId('user-2')), + MockReactionRecord(createMockUserId('user-3')), + ] + ), + ]; + + await tester.pumpProviderWidget( + overrides: [ + memberAvatarInfoProvider.overrideWith( + (ref, param) => MockAvatarInfo(uniqueId: param.userId), + ), + ], + child: MaterialApp( + home: Scaffold( + body: ReactionDetailsSheet( + roomId: 'test-room', + reactions: reactions, + ), + ), + ), + ); + + // Check "All" tab content + expect( + find.byType(ReactionUserItem), + findsNWidgets(3), + ); // All unique users + + // Check 👍 tab + await tester.tap(find.text('2').first); // First tab with count 2 + await tester.pumpAndSettle(); + + expect(find.byType(ReactionUserItem), findsNWidgets(2)); + + // Check ❤️ tab + await tester.tap(find.text('2').last); // Second tab with count 2 + await tester.pumpAndSettle(); + expect(find.byType(ReactionUserItem), findsNWidgets(2)); + }); + }); + }); +} diff --git a/app/test/features/comments/add_comment_test.dart b/app/test/features/comments/add_comment_test.dart index f5f1e48a15fa..5b08bd6d9800 100644 --- a/app/test/features/comments/add_comment_test.dart +++ b/app/test/features/comments/add_comment_test.dart @@ -14,7 +14,7 @@ void main() { setUp(() { mockCommentsManager = MockCommentsManager(); - mockAvatarInfo = MockAvatarInfo(); + mockAvatarInfo = MockAvatarInfo(uniqueId: 'user-1'); }); group('Add Comment', () { testWidgets('should display avatar and comment input', diff --git a/app/test/features/comments/comment_item_test.dart b/app/test/features/comments/comment_item_test.dart index 41bd919da66e..bd1fdf95277f 100644 --- a/app/test/features/comments/comment_item_test.dart +++ b/app/test/features/comments/comment_item_test.dart @@ -21,11 +21,11 @@ void main() { setUp(() { mockCommentsManager = MockCommentsManager(); mockComment = MockComment( - fakeSender: MockUserId(), + fakeSender: MockUserId('user-1'), fakeMsgContent: MockMsgContent(bodyText: 'This is a test message'), fakeOriginServerTs: DateTime.now().millisecondsSinceEpoch, ); - mockAvatarInfo = MockAvatarInfo(); + mockAvatarInfo = MockAvatarInfo(uniqueId: 'user-1'); // Mock the values expected by the widget when(() => mockCommentsManager.roomIdStr()).thenReturn('roomId'); diff --git a/app/test/features/comments/comment_list_test.dart b/app/test/features/comments/comment_list_test.dart index 7b8c6d193ab3..39e6c008bdc3 100644 --- a/app/test/features/comments/comment_list_test.dart +++ b/app/test/features/comments/comment_list_test.dart @@ -90,17 +90,17 @@ void main() { // Arrange final mockComment1 = MockComment( - fakeSender: MockUserId(), + fakeSender: MockUserId('user-1'), fakeMsgContent: MockMsgContent(bodyText: 'message 1'), fakeOriginServerTs: DateTime.now().millisecondsSinceEpoch, ); final mockComment2 = MockComment( - fakeSender: MockUserId(), + fakeSender: MockUserId('user-2'), fakeMsgContent: MockMsgContent(bodyText: 'message 2'), fakeOriginServerTs: DateTime.now().millisecondsSinceEpoch, ); final mockComment3 = MockComment( - fakeSender: MockUserId(), + fakeSender: MockUserId('user-3'), fakeMsgContent: MockMsgContent(bodyText: 'message 3'), fakeOriginServerTs: DateTime.now().millisecondsSinceEpoch, ); From 6354d3eead42d4b0d714ee77afe605a81e2c066c Mon Sep 17 00:00:00 2001 From: Talha Date: Tue, 21 Jan 2025 21:38:46 +0500 Subject: [PATCH 13/13] add l10n string --- app/ios/Podfile.lock | 2 +- .../chat_ng/widgets/reactions/reaction_detail_sheet.dart | 8 +++++++- app/lib/l10n/app_en.arb | 1 + 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/ios/Podfile.lock b/app/ios/Podfile.lock index dfcad4ba3386..c4dc047d1dca 100644 --- a/app/ios/Podfile.lock +++ b/app/ios/Podfile.lock @@ -324,4 +324,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: d2243213672c3c48aae53c36642ba411a6be7309 -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart b/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart index 3ad7c9b23214..c3bf796e2da9 100644 --- a/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart +++ b/app/lib/features/chat_ng/widgets/reactions/reaction_detail_sheet.dart @@ -3,6 +3,7 @@ import 'package:acter/common/themes/app_theme.dart'; import 'package:acter/features/chat_ng/providers/chat_room_messages_provider.dart'; import 'package:acter_avatar/acter_avatar.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; typedef ReactionData = ({ @@ -36,6 +37,11 @@ class _ReactionDetailsSheetState extends ConsumerState void initState() { super.initState(); _reactionData = _processReactionData(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); _initializeTabs(); } @@ -76,7 +82,7 @@ class _ReactionDetailsSheetState extends ConsumerState ); _tabs = [ - Tab(child: Chip(label: Text('All $total'))), + Tab(child: Chip(label: Text(L10n.of(context).allReactionsCount(total)))), ...widget.reactions.map( (reaction) => Tab( child: Chip( diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 0b48ce9ca3f4..e43e2178df1a 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -41,6 +41,7 @@ "@all": {}, "allMessages": "All Messages", "@allMessages": {}, + "allReactionsCount": "All {total}", "alreadyConfirmed": "Already confirmed", "@alreadyConfirmed": {}, "analyticsTitle": "Help us help you",