diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 712db462d5..9c155110f2 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -2682,5 +2682,8 @@ "adminPanel": "Admin Panel", "rejectInvite" : "No thanks, delete", "acceptInvite" : "Yes please, join", - "askToInvite": " wants you to join this chat. What do you say?" + "askToInvite": " wants you to join this chat. What do you say?", + "select" : "Select", + "copyMessageText" : "Copy message text", + "pinMessage" : "Pin message" } \ No newline at end of file diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 71937fa8a7..723d8e647c 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -13,7 +13,9 @@ import 'package:fluffychat/domain/model/download_file/download_file_for_preview_ import 'package:fluffychat/domain/model/preview_file/document_uti.dart'; import 'package:fluffychat/domain/model/preview_file/supported_preview_file_types.dart'; import 'package:fluffychat/domain/usecase/download_file_for_preview_interactor.dart'; +import 'package:fluffychat/pages/chat/chat_horizontal_action_menu.dart'; import 'package:fluffychat/pages/chat/chat_view.dart'; +import 'package:fluffychat/pages/chat/context_item_chat_action.dart'; import 'package:fluffychat/pages/chat/dialog_accept_invite_widget.dart'; import 'package:fluffychat/pages/chat/event_info_dialog.dart'; import 'package:fluffychat/pages/chat/recording_dialog.dart'; @@ -22,6 +24,7 @@ import 'package:fluffychat/presentation/mixins/media_picker_mixin.dart'; import 'package:fluffychat/presentation/mixins/send_files_mixin.dart'; import 'package:fluffychat/presentation/model/forward/forward_argument.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; +import 'package:fluffychat/utils/extension/build_context_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/ios_badge_client_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/utils/network_connection_service.dart'; @@ -30,6 +33,8 @@ import 'package:fluffychat/utils/permission_service.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/responsive/responsive_utils.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/mixins/popup_context_menu_action_mixin.dart'; +import 'package:fluffychat/widgets/mixins/popup_menu_widget_mixin.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; @@ -67,7 +72,12 @@ class Chat extends StatefulWidget { } class ChatController extends State - with CommonMediaPickerMixin, MediaPickerMixin, SendFilesMixin { + with + CommonMediaPickerMixin, + MediaPickerMixin, + SendFilesMixin, + PopupContextMenuActionMixin, + PopupMenuWidgetMixin { final NetworkConnectionService networkConnectionService = getIt.get(); @@ -85,6 +95,7 @@ class ChatController extends State final AutoScrollController scrollController = AutoScrollController(); final AutoScrollController forwardListController = AutoScrollController(); + final ValueNotifier focusHover = ValueNotifier(null); FocusNode inputFocus = FocusNode(); @@ -1286,6 +1297,143 @@ class ChatController extends State ); } + void onHover(bool isHovered, int index, Event event) { + if (timeline!.events[index - 1].eventId == event.eventId && + !responsive.isMobile(context) && + !selectMode) { + focusHover.value = isHovered ? event.eventId : null; + } + } + + List listHorizontalActionMenuBuilder() { + final listAction = [ + ChatHorizontalActionMenu.reply, + ChatHorizontalActionMenu.forward, + ChatHorizontalActionMenu.more, + ]; + return listAction + .map( + (action) => ContextMenuItemChatAction( + action, + action.getContextMenuItemState(), + ), + ) + .toList(); + } + + void handleHorizontalActionMenu( + BuildContext context, + ChatHorizontalActionMenu actions, + Event event, + ) { + switch (actions) { + case ChatHorizontalActionMenu.reply: + replyAction(replyTo: event); + break; + case ChatHorizontalActionMenu.forward: + onSelectMessage(event); + forwardEventsAction(); + break; + case ChatHorizontalActionMenu.more: + handleContextMenuAction(context, event); + break; + } + } + + List _popupMenuActionTile( + BuildContext context, + Event event, + ) { + return [ + _buildSelectPopupMenuItem(context, event), + _buildCopyMessagePopupMenuItem(context, event), + _buildPinMessagePopupMenuItem(context, event), + _buildForwardPopupMenuItem(context, event), + ]; + } + + PopupMenuEntry _buildSelectPopupMenuItem( + BuildContext context, + Event event, + ) { + return PopupMenuItem( + padding: EdgeInsets.zero, + child: popupItem( + context, + L10n.of(context)!.select, + iconAction: Icons.check_circle_outline, + onCallbackAction: () { + onSelectMessage(event); + }, + ), + ); + } + + PopupMenuEntry _buildCopyMessagePopupMenuItem( + BuildContext context, + Event event, + ) { + return PopupMenuItem( + padding: EdgeInsets.zero, + child: popupItem( + context, + L10n.of(context)!.copyMessageText, + iconAction: Icons.content_copy, + onCallbackAction: () { + onSelectMessage(event); + copyEventsAction(); + }, + ), + ); + } + + PopupMenuEntry _buildPinMessagePopupMenuItem( + BuildContext context, + Event event, + ) { + return PopupMenuItem( + padding: EdgeInsets.zero, + child: popupItem( + context, + L10n.of(context)!.pinMessage, + iconAction: Icons.push_pin, + onCallbackAction: () { + onSelectMessage(event); + pinEvent(); + }, + ), + ); + } + + PopupMenuEntry _buildForwardPopupMenuItem( + BuildContext context, + Event event, + ) { + return PopupMenuItem( + padding: EdgeInsets.zero, + child: popupItem( + context, + L10n.of(context)!.forward, + iconAction: Icons.shortcut, + onCallbackAction: () { + onSelectMessage(event); + forwardEventsAction(); + }, + ), + ); + } + + void handleContextMenuAction( + BuildContext context, + Event event, + ) { + openPopupMenuAction( + context, + context.getCurrentRelativeRectOfWidget(), + _popupMenuActionTile(context, event), + ); + } + @override Widget build(BuildContext context) { return ChatView(this, key: widget.key); diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 11322d0d9a..0bca68a0a1 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -61,13 +61,13 @@ class ChatEventList extends StatelessWidget { controller: controller.scrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, childrenDelegate: SliverChildBuilderDelegate( - (BuildContext context, int i) { + (BuildContext context, int index) { // Footer to display typing indicator and read receipts: - if (i == 0) { + if (index == 0) { return const SizedBox.shrink(); } // Request history button or progress indicator: - if (i == controller.timeline!.events.length + 1) { + if (index == controller.timeline!.events.length + 1) { if (controller.timeline!.isRequestingHistory) { return const Center( child: CircularProgressIndicator.adaptive(strokeWidth: 2), @@ -88,12 +88,12 @@ class ChatEventList extends StatelessWidget { } // The message at this index: - final currentEventIndex = i - 1; + final currentEventIndex = index - 1; final event = controller.timeline!.events[currentEventIndex]; final previousEvent = currentEventIndex > 0 ? controller.timeline!.events[currentEventIndex - 1] : null; - final nextEvent = i < controller.timeline!.events.length + final nextEvent = index < controller.timeline!.events.length ? controller.timeline!.events[currentEventIndex + 1] : null; return AutoScrollTag( @@ -125,6 +125,12 @@ class ChatEventList extends StatelessWidget { previousEvent: previousEvent, nextEvent: nextEvent, controller: controller, + onHover: (isHover, event) => + controller.onHover(isHover, index, event), + isHover: controller.focusHover, + listHorizontalActionMenu: + controller.listHorizontalActionMenuBuilder(), + onMenuAction: controller.handleHorizontalActionMenu, ) : Container(), ); diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index e0bd32856b..b5d573c0eb 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -2,6 +2,8 @@ import 'dart:math' as math; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; +import 'package:fluffychat/pages/chat/chat_horizontal_action_menu.dart'; +import 'package:fluffychat/pages/chat/context_item_chat_action.dart'; import 'package:fluffychat/pages/chat/events/message/message_style.dart'; import 'package:fluffychat/pages/chat/events/message_reactions.dart'; import 'package:fluffychat/pages/chat/events/message_time.dart'; @@ -10,6 +12,7 @@ import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/widgets/avatar/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/swipeable.dart'; +import 'package:fluffychat/widgets/twake_components/twake_icon_button.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:linagora_design_flutter/colors/linagora_sys_colors.dart'; @@ -21,6 +24,8 @@ import 'reply_content.dart'; import 'state_message.dart'; import 'verification_request_content.dart'; +typedef OnMenuAction = Function(BuildContext, ChatHorizontalActionMenu, Event); + class Message extends StatelessWidget { final Event event; final Event? previousEvent; @@ -30,10 +35,14 @@ class Message extends StatelessWidget { final void Function(Event)? onInfoTab; final void Function(String)? scrollToEventId; final void Function(SwipeDirection) onSwipe; + final void Function(bool, Event)? onHover; + final ValueNotifier isHover; final bool longPressSelect; final bool selected; final Timeline timeline; final ChatController controller; + final List listHorizontalActionMenu; + final OnMenuAction? onMenuAction; const Message( this.event, { @@ -43,12 +52,16 @@ class Message extends StatelessWidget { this.onSelect, this.onInfoTab, this.onAvatarTab, + this.onHover, this.scrollToEventId, required this.onSwipe, this.selected = false, required this.timeline, required this.controller, + required this.isHover, + required this.listHorizontalActionMenu, Key? key, + this.onMenuAction, }) : super(key: key); /// Indicates wheither the user may use a mouse instead @@ -57,527 +70,539 @@ class Message extends StatelessWidget { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - if (!{ - EventTypes.Message, - EventTypes.Sticker, - EventTypes.Encrypted, - EventTypes.CallInvite - }.contains(event.type)) { - if (event.type.startsWith('m.call.')) { - return Container(); + return InkWell( + hoverColor: Colors.transparent, + onTap: () {}, + onHover: (hover) { + onHover!(hover, event); + }, + child: LayoutBuilder( + builder: (context, constraints) { + if (!{ + EventTypes.Message, + EventTypes.Sticker, + EventTypes.Encrypted, + EventTypes.CallInvite + }.contains(event.type)) { + if (event.type.startsWith('m.call.')) { + return Container(); + } + return StateMessage(event); } - return StateMessage(event); - } - if (event.type == EventTypes.Message && - event.messageType == EventTypes.KeyVerificationRequest) { - return VerificationRequestContent(event: event, timeline: timeline); - } + if (event.type == EventTypes.Message && + event.messageType == EventTypes.KeyVerificationRequest) { + return VerificationRequestContent(event: event, timeline: timeline); + } - final client = Matrix.of(context).client; - final ownMessage = event.senderId == client.userID; - final alignment = ownMessage ? Alignment.topRight : Alignment.topLeft; - final displayTime = event.type == EventTypes.RoomCreate || - nextEvent == null || - !event.originServerTs.sameEnvironment(nextEvent!.originServerTs); - final textColor = Theme.of(context).colorScheme.onBackground; - final rowMainAxisAlignment = - ownMessage ? MainAxisAlignment.end : MainAxisAlignment.start; + final client = Matrix.of(context).client; + final ownMessage = event.senderId == client.userID; + final alignment = ownMessage ? Alignment.topRight : Alignment.topLeft; + final displayTime = event.type == EventTypes.RoomCreate || + nextEvent == null || + !event.originServerTs.sameEnvironment(nextEvent!.originServerTs); + final textColor = Theme.of(context).colorScheme.onBackground; + final rowMainAxisAlignment = + ownMessage ? MainAxisAlignment.end : MainAxisAlignment.start; - final displayEvent = event.getDisplayEvent(timeline); - final noBubble = { - MessageTypes.Sticker, - }.contains(event.messageType) && - !event.redacted; - final timelineOverlayMessage = { - MessageTypes.Video, - MessageTypes.Image, - }.contains(event.messageType); - final timelineText = { - MessageTypes.Text, - }.contains(event.messageType); - final noPadding = { - MessageTypes.File, - MessageTypes.Audio, - }.contains(event.messageType); + final displayEvent = event.getDisplayEvent(timeline); + final noBubble = { + MessageTypes.Sticker, + }.contains(event.messageType) && + !event.redacted; + final timelineOverlayMessage = { + MessageTypes.Video, + MessageTypes.Image, + }.contains(event.messageType); + final timelineText = { + MessageTypes.Text, + }.contains(event.messageType); + final noPadding = { + MessageTypes.File, + MessageTypes.Audio, + }.contains(event.messageType); - final rowChildren = [ - _placeHolderWidget( - isSameSender(previousEvent, event), - ownMessage, - event, - ), - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: - ownMessage ? MainAxisAlignment.end : MainAxisAlignment.start, - children: [ - // if (ownMessage && event.messageType == MessageTypes.Image) - // ReplyIconWidget(isOwnMessage: ownMessage), - Container( - constraints: BoxConstraints( - maxWidth: MessageStyle.messageBubbleWidth(context), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Container( - alignment: alignment, - padding: const EdgeInsets.only(left: 8), - child: Material( - color: Colors.transparent, - borderRadius: MessageStyle.bubbleBorderRadius, - borderOnForeground: false, - child: InkWell( - onHover: (b) => useMouse = true, - onTap: !useMouse && longPressSelect - ? () {} - : () => onSelect!(event), - onLongPress: !longPressSelect - ? null - : () => onSelect!(event), - borderRadius: MessageStyle.bubbleBorderRadius, - hoverColor: Colors.transparent, - focusColor: Colors.transparent, - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - child: Stack( - alignment: ownMessage - ? Alignment.bottomRight - : Alignment.bottomLeft, + final rowChildren = [ + _placeHolderWidget( + isSameSender(previousEvent, event), + ownMessage, + event, + ), + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: ownMessage + ? MainAxisAlignment.end + : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (ownMessage) _menuActionsRowBuilder(context, ownMessage), + Container( + alignment: alignment, + padding: const EdgeInsets.only(left: 8), + child: Material( + color: Colors.transparent, + borderRadius: MessageStyle.bubbleBorderRadius, + borderOnForeground: false, + child: InkWell( + onHover: (hover) { + useMouse = true; + }, + onTap: !useMouse && longPressSelect + ? () {} + : () => onSelect!(event), + onLongPress: + !longPressSelect ? null : () => onSelect!(event), + borderRadius: MessageStyle.bubbleBorderRadius, + hoverColor: Colors.transparent, + focusColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + child: Stack( + alignment: ownMessage + ? AlignmentDirectional.bottomStart + : AlignmentDirectional.bottomEnd, + children: [ + Column( children: [ - Column( - children: [ - Container( - decoration: BoxDecoration( - borderRadius: - MessageStyle.bubbleBorderRadius, - color: ownMessage - ? Theme.of(context) - .colorScheme - .primaryContainer - : Theme.of(context) - .colorScheme - .surface, - ), - padding: noBubble - ? const EdgeInsets.symmetric( - horizontal: 16.0, - ) - : EdgeInsets.only( - left: 8 * - AppConfig.bubbleSizeFactor, - right: 8 * - AppConfig.bubbleSizeFactor, - top: 8 * - AppConfig.bubbleSizeFactor, - bottom: timelineOverlayMessage - ? 8 * - AppConfig.bubbleSizeFactor - : 0 * - AppConfig - .bubbleSizeFactor, - ), - constraints: const BoxConstraints( - maxWidth: - FluffyThemes.columnWidth * 1.5, - ), - child: LayoutBuilder( - builder: ( - context, - availableBubbleContraints, - ) => - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - ownMessage || - event.room.isDirectChat - ? const SizedBox(height: 0) - : FutureBuilder( - future: - event.fetchSenderUser(), - builder: - (context, snapshot) { - final displayName = snapshot - .data - ?.calcDisplayname() ?? - event - .senderFromMemoryOrFallback - .calcDisplayname(); - return Padding( - padding: - EdgeInsets.only( - left: event.messageType == - MessageTypes - .Image - ? 0 - : 8.0, - bottom: 4.0, - ), - child: Text( - displayName, - style: Theme.of( - context, - ) - .textTheme - .labelMedium - ?.copyWith( - fontWeight: - FontWeight - .w500, - color: Theme.of( - context, - ) - .colorScheme - .primary, - ), - ), - ); - }, - ), - IntrinsicHeight( - child: Stack( - alignment: - Alignment.bottomRight, - children: [ - Padding( + Container( + decoration: BoxDecoration( + borderRadius: + MessageStyle.bubbleBorderRadius, + color: ownMessage + ? Theme.of(context) + .colorScheme + .primaryContainer + : Theme.of(context).colorScheme.surface, + ), + padding: noBubble + ? const EdgeInsets.symmetric( + horizontal: 16.0, + ) + : EdgeInsets.only( + left: 8 * AppConfig.bubbleSizeFactor, + right: 8 * AppConfig.bubbleSizeFactor, + top: 8 * AppConfig.bubbleSizeFactor, + bottom: timelineOverlayMessage + ? 8 * AppConfig.bubbleSizeFactor + : 0 * AppConfig.bubbleSizeFactor, + ), + constraints: BoxConstraints( + maxWidth: MessageStyle.messageBubbleWidth( + context, + ), + ), + child: LayoutBuilder( + builder: ( + context, + availableBubbleContraints, + ) => + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + ownMessage || event.room.isDirectChat + ? const SizedBox(height: 0) + : FutureBuilder( + future: event.fetchSenderUser(), + builder: (context, snapshot) { + final displayName = snapshot + .data + ?.calcDisplayname() ?? + event + .senderFromMemoryOrFallback + .calcDisplayname(); + return Padding( padding: EdgeInsets.only( - bottom: noPadding || - timelineOverlayMessage + left: event.messageType == + MessageTypes.Image ? 0 - : 8, + : 8.0, + bottom: 4.0, ), - child: IntrinsicWidth( - child: Column( - mainAxisSize: - MainAxisSize.min, - crossAxisAlignment: - CrossAxisAlignment - .start, - children: [ - if (event - .relationshipType == - RelationshipTypes - .reply) - FutureBuilder< - Event?>( - future: event - .getReplyEvent( - timeline, - ), - builder: ( - BuildContext - context, - snapshot, - ) { - final replyEvent = - snapshot - .hasData - ? snapshot - .data! - : Event( - eventId: - event.relationshipEventId!, - content: { - 'msgtype': 'm.text', - 'body': '...' - }, - senderId: - event.senderId, - type: - 'm.room.message', - room: - event.room, - status: - EventStatus.sent, - originServerTs: - DateTime.now(), - ); - return InkWell( - onTap: () { - if (scrollToEventId != - null) { - scrollToEventId!( - replyEvent - .eventId, + child: Text( + displayName, + style: Theme.of( + context, + ) + .textTheme + .labelMedium + ?.copyWith( + fontWeight: + FontWeight.w500, + color: Theme.of( + context, + ) + .colorScheme + .primary, + ), + ), + ); + }, + ), + IntrinsicHeight( + child: Stack( + alignment: Alignment.bottomRight, + children: [ + Padding( + padding: EdgeInsets.only( + bottom: noPadding || + timelineOverlayMessage + ? 0 + : 8, + ), + child: IntrinsicWidth( + child: Column( + mainAxisSize: + MainAxisSize.min, + crossAxisAlignment: + CrossAxisAlignment + .start, + children: [ + if (event + .relationshipType == + RelationshipTypes + .reply) + FutureBuilder( + future: event + .getReplyEvent( + timeline, + ), + builder: ( + BuildContext + context, + snapshot, + ) { + final replyEvent = + snapshot.hasData + ? snapshot + .data! + : Event( + eventId: + event.relationshipEventId!, + content: { + 'msgtype': + 'm.text', + 'body': + '...' + }, + senderId: + event.senderId, + type: + 'm.room.message', + room: event + .room, + status: + EventStatus.sent, + originServerTs: + DateTime.now(), ); - } - }, - child: - AbsorbPointer( - child: - Container( - margin: EdgeInsets - .symmetric( - vertical: - 4.0 * - AppConfig.bubbleSizeFactor, - ), - child: - ReplyContent( - replyEvent, - ownMessage: - ownMessage, - timeline: - timeline, - chatController: - controller, - ), - ), - ), - ); + return InkWell( + onTap: () { + if (scrollToEventId != + null) { + scrollToEventId!( + replyEvent + .eventId, + ); + } }, - ), - Stack( - children: [ - MessageContent( - displayEvent, - textColor: - textColor, - onInfoTab: - onInfoTab, - endOfBubbleWidget: - Padding( - padding: - const EdgeInsets - .only( - left: 8.0, - right: 4.0, + child: + AbsorbPointer( + child: + Container( + margin: EdgeInsets + .symmetric( + vertical: 4.0 * + AppConfig + .bubbleSizeFactor, ), child: - MessageTime( - timelineOverlayMessage: - timelineOverlayMessage, - controller: - controller, - event: - event, + ReplyContent( + replyEvent, ownMessage: ownMessage, timeline: timeline, + chatController: + controller, ), ), + ), + ); + }, + ), + Stack( + children: [ + MessageContent( + displayEvent, + textColor: + textColor, + onInfoTab: + onInfoTab, + endOfBubbleWidget: + Padding( + padding: + const EdgeInsets + .only( + left: 8.0, + right: 4.0, + ), + child: + MessageTime( + timelineOverlayMessage: + timelineOverlayMessage, controller: controller, - backgroundColor: - ownMessage - ? Theme - .of( - context, - ) - .colorScheme - .primaryContainer - : Theme - .of( - context, - ).colorScheme.surface, - onTapSelectMode: - () => controller - .selectMode - ? onSelect!( - event, - ) - : null, - onTapPreview: - !controller - .selectMode - ? () {} - : null, + event: event, + ownMessage: + ownMessage, + timeline: + timeline, ), - if (timelineOverlayMessage) - Positioned( - right: 8, - bottom: 4.0, - child: - MessageTime( - timelineOverlayMessage: - timelineOverlayMessage, - controller: - controller, - event: + ), + controller: + controller, + backgroundColor: + ownMessage + ? Theme.of( + context, + ) + .colorScheme + .primaryContainer + : Theme.of( + context, + ) + .colorScheme + .surface, + onTapSelectMode: + () => controller + .selectMode + ? onSelect!( event, - ownMessage: - ownMessage, - timeline: - timeline, - ), - ), - ], + ) + : null, + onTapPreview: + !controller + .selectMode + ? () {} + : null, ), - if (event - .hasAggregatedEvents( - timeline, - RelationshipTypes - .edit, - )) - Padding( - padding: - EdgeInsets - .only( - top: 4.0 * - AppConfig - .bubbleSizeFactor, - ), - child: Row( - mainAxisSize: - MainAxisSize - .min, - children: [ - Icon( - Icons - .edit_outlined, - color: textColor - .withAlpha( - 164, - ), - size: 14, - ), - Text( - ' - ${displayEvent.originServerTs.localizedTimeShort(context)}', - style: - TextStyle( - color: textColor - .withAlpha( - 164, - ), - fontSize: - 12, - ), - ), - ], + if (timelineOverlayMessage) + Positioned( + right: 8, + bottom: 4.0, + child: + MessageTime( + timelineOverlayMessage: + timelineOverlayMessage, + controller: + controller, + event: event, + ownMessage: + ownMessage, + timeline: + timeline, ), ), ], ), - ), - ), - if (timelineText) - Positioned( - child: Padding( - padding: - const EdgeInsets - .only( - left: 6, - right: 8.0, - bottom: 4.0, - ), - child: MessageTime( - timelineOverlayMessage: - timelineOverlayMessage, - controller: - controller, - event: event, - ownMessage: - ownMessage, - timeline: timeline, + if (event + .hasAggregatedEvents( + timeline, + RelationshipTypes.edit, + )) + Padding( + padding: + EdgeInsets.only( + top: 4.0 * + AppConfig + .bubbleSizeFactor, + ), + child: Row( + mainAxisSize: + MainAxisSize + .min, + children: [ + Icon( + Icons + .edit_outlined, + color: textColor + .withAlpha( + 164, + ), + size: 14, + ), + Text( + ' - ${displayEvent.originServerTs.localizedTimeShort(context)}', + style: + TextStyle( + color: textColor + .withAlpha( + 164, + ), + fontSize: 12, + ), + ), + ], + ), ), - ), - ), - ], + ], + ), + ), ), - ), - ], + if (timelineText) + Positioned( + child: Padding( + padding: + const EdgeInsets.only( + left: 6, + right: 8.0, + bottom: 4.0, + ), + child: MessageTime( + timelineOverlayMessage: + timelineOverlayMessage, + controller: controller, + event: event, + ownMessage: ownMessage, + timeline: timeline, + ), + ), + ), + ], + ), ), - ), + ], ), - if (event.hasAggregatedEvents( - timeline, - RelationshipTypes.reaction, - )) - const SizedBox(height: 24) - ], + ), ), if (event.hasAggregatedEvents( timeline, RelationshipTypes.reaction, - )) ...[ - Positioned( - left: 8, - right: 0, - bottom: 0, - child: MessageReactions(event, timeline), - ), - const SizedBox(width: 4), - ], + )) + const SizedBox(height: 24) ], ), - ), + if (event.hasAggregatedEvents( + timeline, + RelationshipTypes.reaction, + )) ...[ + Positioned( + left: 8, + right: 0, + bottom: 0, + child: MessageReactions(event, timeline), + ), + const SizedBox(width: 4), + ], + ], ), ), - ], + ), ), - ), - // if (!ownMessage && event.messageType == MessageTypes.Image) - // ReplyIconWidget(isOwnMessage: !ownMessage) - ], + if (!ownMessage) _menuActionsRowBuilder(context, ownMessage), + ], + ), ), - ), - ]; - final row = Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: rowMainAxisAlignment, - children: rowChildren, - ); + ]; + final row = Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: rowMainAxisAlignment, + children: rowChildren, + ); - return Column( - children: [ - if (displayTime) - StickyTimestampWidget( - content: event.originServerTs.relativeTime(context), - ), - Swipeable( - key: ValueKey(event.eventId), - background: const Padding( - padding: EdgeInsets.symmetric(horizontal: 12.0), - child: Center( - child: Icon(Icons.reply_outlined), + return Column( + children: [ + if (displayTime) + StickyTimestampWidget( + content: event.originServerTs.relativeTime(context), ), - ), - onOverScrollTheMaxOffset: () => HapticFeedback.heavyImpact(), - maxOffset: 0.4, - movementDuration: const Duration(milliseconds: 100), - swipeIntensity: 2.5, - direction: SwipeDirection.endToStart, - onSwipe: onSwipe, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: ownMessage - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - GestureDetector( - onLongPress: () => - controller.selectMode ? onSelect!(event) : null, - onTap: () => - controller.selectMode ? onSelect!(event) : null, - child: Center( - child: Padding( - padding: EdgeInsets.only( - left: selected ? 0 : 8.0, - right: selected - ? 0 - : ownMessage - ? 8.0 - : 16.0, - top: selected ? 0 : 1.0, - bottom: selected ? 0 : 1.0, + Swipeable( + key: ValueKey(event.eventId), + background: const Padding( + padding: EdgeInsets.symmetric(horizontal: 12.0), + child: Center( + child: Icon(Icons.reply_outlined), + ), + ), + onOverScrollTheMaxOffset: () => HapticFeedback.heavyImpact(), + maxOffset: 0.4, + movementDuration: const Duration(milliseconds: 100), + swipeIntensity: 2.5, + direction: SwipeDirection.endToStart, + onSwipe: onSwipe, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: ownMessage + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + GestureDetector( + onLongPress: () => + controller.selectMode ? onSelect!(event) : null, + onTap: () => + controller.selectMode ? onSelect!(event) : null, + child: Center( + child: Padding( + padding: EdgeInsets.only( + left: selected ? 0 : 8.0, + right: selected + ? 0 + : ownMessage + ? 8.0 + : 16.0, + top: selected ? 0 : 1.0, + bottom: selected ? 0 : 1.0, + ), + child: _messageSelectedWidget(context, row), ), - child: _messageSelectedWidget(context, row), ), ), - ), - ], + ], + ), ), - ), - ], - ); + ], + ); + }, + ), + ); + } + + Widget _menuActionsRowBuilder(BuildContext context, bool ownMessage) { + return ValueListenableBuilder( + valueListenable: isHover, + builder: (context, isHover, child) { + if (isHover != null && isHover.contains(event.eventId) && !selected) { + return child!; + } + return const SizedBox(); }, + child: Container( + decoration: BoxDecoration( + color: Colors.transparent, + borderRadius: BorderRadius.circular(24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: listHorizontalActionMenu.map((item) { + return Padding( + padding: const EdgeInsetsDirectional.all(4), + child: TwakeIconButton( + icon: item.action.getIcon(), + imagePath: item.action.getImagePath(), + tooltip: item.action.getTitle(context), + preferBelow: false, + onTapDown: (context) => onMenuAction!( + context, + item.action, + event, + ), + ), + ); + }).toList(), + ), + ), ); } diff --git a/lib/resource/image_paths.dart b/lib/resource/image_paths.dart index 0370910944..4c085f75d7 100644 --- a/lib/resource/image_paths.dart +++ b/lib/resource/image_paths.dart @@ -30,6 +30,7 @@ class ImagePaths { static String get icApplicationGrid => _getImagePath('ic_application_grid.svg'); static String get icUsersOutline => _getImagePath('ic_users_outline.svg'); + static String get icReply => _getImagePath('ic_reply.svg'); static String _getImagePath(String imageName) { return AssetsPaths.images + imageName; diff --git a/lib/widgets/twake_components/twake_icon_button.dart b/lib/widgets/twake_components/twake_icon_button.dart index 2a6d11ebbf..e5253d3e27 100644 --- a/lib/widgets/twake_components/twake_icon_button.dart +++ b/lib/widgets/twake_components/twake_icon_button.dart @@ -1,6 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +typedef OnTapIconButtonCallbackAction = void Function(); +typedef OnTapDownIconButtonCallbackAction = void Function( + BuildContext, +); + class TwakeIconButton extends StatelessWidget { final BoxDecoration? buttonDecoration; @@ -20,7 +25,13 @@ class TwakeIconButton extends StatelessWidget { final double? weight; - final VoidCallback? onPressed; + final OnTapIconButtonCallbackAction? onPressed; + + final OnTapDownIconButtonCallbackAction? onTapDown; + + final bool? preferBelow; + + final Color? hoverColor; const TwakeIconButton({ Key? key, @@ -32,34 +43,43 @@ class TwakeIconButton extends StatelessWidget { this.size, this.fill, this.weight, + this.preferBelow, + this.hoverColor, + this.onTapDown, this.margin = const EdgeInsets.all(0), this.buttonDecoration, }) : super(key: key); @override Widget build(BuildContext context) { - return Container( - padding: margin, - decoration: - buttonDecoration ?? const BoxDecoration(shape: BoxShape.circle), - child: InkWell( - onTap: onPressed, - customBorder: const CircleBorder(), - radius: paddingAll, - child: Tooltip( - message: tooltip, - child: Padding( - padding: EdgeInsets.all(paddingAll ?? 8.0), - child: icon != null - ? Icon( - icon, - size: size, - fill: fill, - weight: weight, - ) - : imagePath != null - ? SvgPicture.asset(imagePath!) - : null, + return Material( + color: Colors.transparent, + child: Container( + padding: margin, + decoration: + buttonDecoration ?? const BoxDecoration(shape: BoxShape.circle), + child: InkWell( + onTap: onPressed, + onTapDown: (_) => onTapDown?.call(context), + customBorder: const CircleBorder(), + radius: paddingAll, + hoverColor: hoverColor, + child: Tooltip( + preferBelow: preferBelow, + message: tooltip, + child: Padding( + padding: EdgeInsets.all(paddingAll ?? 8.0), + child: icon != null + ? Icon( + icon, + size: size, + fill: fill, + weight: weight, + ) + : imagePath != null + ? SvgPicture.asset(imagePath!) + : null, + ), ), ), ),