From 809ee213b68bb025dd12a5d37f837b8d2f95a282 Mon Sep 17 00:00:00 2001 From: krille-chan Date: Sun, 5 Nov 2023 14:07:07 +0100 Subject: [PATCH] feat: Make all text in chat selectable on desktop --- lib/pages/chat/chat_event_list.dart | 184 +++++++++++----------- lib/pages/chat/events/message.dart | 202 ++++++++++++------------- lib/utils/custom_scroll_behaviour.dart | 1 - lib/widgets/hover_builder.dart | 29 ++++ 4 files changed, 222 insertions(+), 194 deletions(-) create mode 100644 lib/widgets/hover_builder.dart diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 848b46928..baa2e8806 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -31,104 +31,106 @@ class ChatEventList extends StatelessWidget { thisEventsKeyMap[controller.timeline!.events[i].eventId] = i; } - return ListView.custom( - padding: EdgeInsets.only( - top: 16, - bottom: 4, - left: horizontalPadding, - right: horizontalPadding, - ), - reverse: true, - controller: controller.scrollController, - keyboardDismissBehavior: PlatformInfos.isIOS - ? ScrollViewKeyboardDismissBehavior.onDrag - : ScrollViewKeyboardDismissBehavior.manual, - childrenDelegate: SliverChildBuilderDelegate( - (BuildContext context, int i) { - // Footer to display typing indicator and read receipts: - if (i == 0) { - if (controller.timeline!.isRequestingFuture) { - return const Center( - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ); - } - if (controller.timeline!.canRequestFuture) { - return Center( - child: IconButton( - onPressed: controller.requestFuture, - icon: const Icon(Icons.refresh_outlined), - ), + return SelectionArea( + child: ListView.custom( + padding: EdgeInsets.only( + top: 16, + bottom: 4, + left: horizontalPadding, + right: horizontalPadding, + ), + reverse: true, + controller: controller.scrollController, + keyboardDismissBehavior: PlatformInfos.isIOS + ? ScrollViewKeyboardDismissBehavior.onDrag + : ScrollViewKeyboardDismissBehavior.manual, + childrenDelegate: SliverChildBuilderDelegate( + (BuildContext context, int i) { + // Footer to display typing indicator and read receipts: + if (i == 0) { + if (controller.timeline!.isRequestingFuture) { + return const Center( + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ); + } + if (controller.timeline!.canRequestFuture) { + return Center( + child: IconButton( + onPressed: controller.requestFuture, + icon: const Icon(Icons.refresh_outlined), + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SeenByRow(controller), + TypingIndicators(controller), + ], ); } - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SeenByRow(controller), - TypingIndicators(controller), - ], - ); - } - // Request history button or progress indicator: - if (i == controller.timeline!.events.length + 1) { - if (controller.timeline!.isRequestingHistory) { - return const Center( - child: CircularProgressIndicator.adaptive(strokeWidth: 2), - ); - } - if (controller.timeline!.canRequestHistory) { - return Center( - child: IconButton( - onPressed: controller.requestHistory, - icon: const Icon(Icons.refresh_outlined), - ), - ); + // Request history button or progress indicator: + if (i == controller.timeline!.events.length + 1) { + if (controller.timeline!.isRequestingHistory) { + return const Center( + child: CircularProgressIndicator.adaptive(strokeWidth: 2), + ); + } + if (controller.timeline!.canRequestHistory) { + return Center( + child: IconButton( + onPressed: controller.requestHistory, + icon: const Icon(Icons.refresh_outlined), + ), + ); + } + return const SizedBox.shrink(); } - return const SizedBox.shrink(); - } - // The message at this index: - final event = controller.timeline!.events[i - 1]; + // The message at this index: + final event = controller.timeline!.events[i - 1]; - return AutoScrollTag( - key: ValueKey(event.eventId), - index: i - 1, - controller: controller.scrollController, - child: event.isVisibleInGui - ? Message( - event, - onSwipe: (direction) => - controller.replyAction(replyTo: event), - onInfoTab: controller.showEventInfo, - onAvatarTab: (Event event) => showAdaptiveBottomSheet( - context: context, - builder: (c) => UserBottomSheet( - user: event.senderFromMemoryOrFallback, - outerContext: context, - onMention: () => controller.sendController.text += - '${event.senderFromMemoryOrFallback.mention} ', + return AutoScrollTag( + key: ValueKey(event.eventId), + index: i - 1, + controller: controller.scrollController, + child: event.isVisibleInGui + ? Message( + event, + onSwipe: (direction) => + controller.replyAction(replyTo: event), + onInfoTab: controller.showEventInfo, + onAvatarTab: (Event event) => showAdaptiveBottomSheet( + context: context, + builder: (c) => UserBottomSheet( + user: event.senderFromMemoryOrFallback, + outerContext: context, + onMention: () => controller.sendController.text += + '${event.senderFromMemoryOrFallback.mention} ', + ), ), - ), - onSelect: controller.onSelectMessage, - scrollToEventId: (String eventId) => - controller.scrollToEventId(eventId), - longPressSelect: controller.selectedEvents.isNotEmpty, - selected: controller.selectedEvents - .any((e) => e.eventId == event.eventId), - timeline: controller.timeline!, - displayReadMarker: - controller.readMarkerEventId == event.eventId && - controller.timeline?.allowNewEvent == false, - nextEvent: i < controller.timeline!.events.length - ? controller.timeline!.events[i] - : null, - ) - : const SizedBox.shrink(), - ); - }, - childCount: controller.timeline!.events.length + 2, - findChildIndexCallback: (key) => - controller.findChildIndexCallback(key, thisEventsKeyMap), + onSelect: controller.onSelectMessage, + scrollToEventId: (String eventId) => + controller.scrollToEventId(eventId), + longPressSelect: controller.selectedEvents.isNotEmpty, + selected: controller.selectedEvents + .any((e) => e.eventId == event.eventId), + timeline: controller.timeline!, + displayReadMarker: + controller.readMarkerEventId == event.eventId && + controller.timeline?.allowNewEvent == false, + nextEvent: i < controller.timeline!.events.length + ? controller.timeline!.events[i] + : null, + ) + : const SizedBox.shrink(), + ); + }, + childCount: controller.timeline!.events.length + 2, + findChildIndexCallback: (key) => + controller.findChildIndexCallback(key, thisEventsKeyMap), + ), ), ); } diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 477af9153..84083d2db 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -10,6 +10,7 @@ import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import '../../../config/app_config.dart'; +import '../../../widgets/hover_builder.dart'; import 'message_content.dart'; import 'message_reactions.dart'; import 'reply_content.dart'; @@ -44,10 +45,6 @@ class Message extends StatelessWidget { super.key, }); - /// Indicates wheither the user may use a mouse instead - /// of touchscreen. - static bool useMouse = false; - @override Widget build(BuildContext context) { if (!{ @@ -117,31 +114,42 @@ class Message extends StatelessWidget { : Theme.of(context).colorScheme.primaryContainer; } - final row = Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: rowMainAxisAlignment, - children: [ - sameSender || ownMessage - ? SizedBox( + final row = InkWell( + onTap: longPressSelect ? () => onSelect!(event) : null, + onLongPress: () => onSelect!(event), + child: HoverBuilder( + builder: (context, hovered) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: rowMainAxisAlignment, + children: [ + if (hovered || selected) + SizedBox( width: Avatar.defaultSize, - child: Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Center( - child: SizedBox( - width: 16, - height: 16, - child: event.status == EventStatus.sending - ? const CircularProgressIndicator.adaptive( - strokeWidth: 2, - ) - : event.status == EventStatus.error - ? const Icon(Icons.error, color: Colors.red) - : null, - ), + height: Avatar.defaultSize - 8, + child: Checkbox.adaptive( + value: selected, + onChanged: (_) => onSelect?.call(event), + ), + ) + else if (sameSender || ownMessage) + SizedBox( + width: Avatar.defaultSize, + child: Center( + child: SizedBox( + width: 16, + height: 16, + child: event.status == EventStatus.sending + ? const CircularProgressIndicator.adaptive( + strokeWidth: 2, + ) + : event.status == EventStatus.error + ? const Icon(Icons.error, color: Colors.red) + : null, ), ), ) - : FutureBuilder( + else + FutureBuilder( future: event.fetchSenderUser(), builder: (context, snapshot) { final user = @@ -153,61 +161,59 @@ class Message extends StatelessWidget { ); }, ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - if (!sameSender) - Padding( - padding: const EdgeInsets.only(left: 8.0, bottom: 4), - child: ownMessage || event.room.isDirectChat - ? const SizedBox(height: 12) - : FutureBuilder( - future: event.fetchSenderUser(), - builder: (context, snapshot) { - final displayname = - snapshot.data?.calcDisplayname() ?? - event.senderFromMemoryOrFallback - .calcDisplayname(); - return Text( - displayname, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: (Theme.of(context).brightness == - Brightness.light - ? displayname.color - : displayname.lightColorText), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (!sameSender) + Padding( + padding: const EdgeInsets.only(left: 8.0, bottom: 4), + child: ownMessage || event.room.isDirectChat + ? const SizedBox(height: 12) + : FutureBuilder( + future: event.fetchSenderUser(), + builder: (context, snapshot) { + final displayname = + snapshot.data?.calcDisplayname() ?? + event.senderFromMemoryOrFallback + .calcDisplayname(); + return Text( + displayname, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: (Theme.of(context).brightness == + Brightness.light + ? displayname.color + : displayname.lightColorText), + ), + ); + }, + ), + ), + Container( + alignment: alignment, + padding: const EdgeInsets.only(left: 8), + child: Material( + color: noBubble ? Colors.transparent : color, + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + child: Container( + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + ), + padding: noBubble || noPadding + ? EdgeInsets.zero + : const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, ), - ); - }, + constraints: const BoxConstraints( + maxWidth: FluffyThemes.columnWidth * 1.5, ), - ), - Container( - alignment: alignment, - padding: const EdgeInsets.only(left: 8), - child: Material( - color: noBubble ? Colors.transparent : color, - borderRadius: borderRadius, - clipBehavior: Clip.antiAlias, - child: Container( - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(AppConfig.borderRadius), - ), - padding: noBubble || noPadding - ? EdgeInsets.zero - : const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - constraints: const BoxConstraints( - maxWidth: FluffyThemes.columnWidth * 1.5, - ), - child: Stack( - children: [ - Column( + child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -284,15 +290,15 @@ class Message extends StatelessWidget { ), ], ), - ], + ), ), ), - ), + ], ), - ], - ), + ), + ], ), - ], + ), ); Widget container; if (event.hasAggregatedEvents(timeline, RelationshipTypes.reaction) || @@ -392,26 +398,18 @@ class Message extends StatelessWidget { direction: SwipeDirection.endToStart, onSwipe: onSwipe, child: Center( - child: MouseRegion( - onEnter: (_) => useMouse = true, - onExit: (_) => useMouse = false, - child: InkWell( - onTap: longPressSelect || useMouse ? () => onSelect!(event) : null, - onLongPress: () => onSelect!(event), - child: Container( - color: selected - ? Theme.of(context).primaryColor.withAlpha(100) - : Theme.of(context).primaryColor.withAlpha(0), - constraints: const BoxConstraints( - maxWidth: FluffyThemes.columnWidth * 2.5, - ), - padding: const EdgeInsets.symmetric( - horizontal: 8.0, - vertical: 4.0, - ), - child: container, - ), + child: Container( + color: selected + ? Theme.of(context).primaryColor.withAlpha(100) + : Theme.of(context).primaryColor.withAlpha(0), + constraints: const BoxConstraints( + maxWidth: FluffyThemes.columnWidth * 2.5, + ), + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, ), + child: container, ), ), ); diff --git a/lib/utils/custom_scroll_behaviour.dart b/lib/utils/custom_scroll_behaviour.dart index 719b75473..ba25e9564 100644 --- a/lib/utils/custom_scroll_behaviour.dart +++ b/lib/utils/custom_scroll_behaviour.dart @@ -5,7 +5,6 @@ class CustomScrollBehavior extends MaterialScrollBehavior { @override Set get dragDevices => { PointerDeviceKind.touch, - PointerDeviceKind.mouse, PointerDeviceKind.trackpad, }; } diff --git a/lib/widgets/hover_builder.dart b/lib/widgets/hover_builder.dart new file mode 100644 index 000000000..f895d8532 --- /dev/null +++ b/lib/widgets/hover_builder.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class HoverBuilder extends StatefulWidget { + final Widget Function(BuildContext context, bool hovered) builder; + const HoverBuilder({required this.builder, super.key}); + + @override + State createState() => _HoverBuilderState(); +} + +class _HoverBuilderState extends State { + bool hovered = false; + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => hovered + ? null + : setState(() { + hovered = true; + }), + onExit: (_) => !hovered + ? null + : setState(() { + hovered = false; + }), + child: widget.builder(context, hovered), + ); + } +}