From 05d25869efdd2d5fa354331fb674ab26392c6d25 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Mon, 9 Nov 2026 17:14:46 -0500 Subject: [PATCH] add reactions popover Fixes #168 --- lib/helpers/extensions/scheme_to_theme.dart | 2 + lib/helpers/extensions/show_image_viewer.dart | 83 ++++++++++ lib/widgets/expandable_image.dart | 81 ++-------- lib/widgets/reaction_key.dart | 25 +++ lib/widgets/reaction_row.dart | 116 +++++++------- lib/widgets/reactions_popover.dart | 144 ++++++++++++++++++ 6 files changed, 323 insertions(+), 128 deletions(-) create mode 100644 lib/helpers/extensions/show_image_viewer.dart create mode 100644 lib/widgets/reaction_key.dart create mode 100644 lib/widgets/reactions_popover.dart diff --git a/lib/helpers/extensions/scheme_to_theme.dart b/lib/helpers/extensions/scheme_to_theme.dart index 97d239d..73ce176 100644 --- a/lib/helpers/extensions/scheme_to_theme.dart +++ b/lib/helpers/extensions/scheme_to_theme.dart @@ -20,6 +20,8 @@ extension SchemeToTheme on ColorScheme { shape: RoundedRectangleBorder(borderRadius: .circular(16)), color: surfaceContainerHigh, ), + // So ink at the edges of a sheet doesn't spill past its rounded corners + bottomSheetTheme: .new(clipBehavior: .antiAlias), appBarTheme: AppBarTheme( titleSpacing: 0, backgroundColor: surfaceContainerLow, diff --git a/lib/helpers/extensions/show_image_viewer.dart b/lib/helpers/extensions/show_image_viewer.dart new file mode 100644 index 0000000..ec40344 --- /dev/null +++ b/lib/helpers/extensions/show_image_viewer.dart @@ -0,0 +1,83 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/save_attachment.dart"; +import "package:nexus/helpers/extensions/size_to_string.dart"; +import "package:nexus/helpers/mxc_image.dart"; +import "package:nexus/models/requests/download_media.dart"; +import "package:nexus/widgets/error_dialog.dart"; + +extension ShowImageViewer on BuildContext { + /// Shows the image from [request] fullscreen, zoomable. + /// + /// If [downloadFilename] is set, a download button is shown, suggesting it. + void showImageViewer( + WidgetRef ref, + DownloadMediaRequest request, { + String? downloadFilename, + int? size, + }) => showDialog( + context: this, + builder: (context) => SafeArea( + child: Stack( + children: [ + Positioned.fill( + child: GestureDetector( + onTap: Navigator.of(context).pop, + child: InteractiveViewer( + maxScale: 10, + child: Image( + errorBuilder: (_, error, stackTrace) => ErrorDialog( + "Loading failed for ${request.mxc}\nError: $error", + stackTrace, + ), + image: MxcImage(ref, request), + ), + ), + ), + ), + if (size != null) + Align( + alignment: .topLeft, + child: Padding( + padding: .all(32), + child: Card( + margin: .zero, + child: Padding( + padding: .symmetric(horizontal: 12, vertical: 8), + child: Text(size.sizeAsString), + ), + ), + ), + ), + Align( + alignment: .topRight, + child: Padding( + padding: .all(32), + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + if (downloadFilename != null) + M3EButton( + onPressed: () => context.saveAttachment( + ref, + request.mxc, + encrypted: request.encrypted, + filename: downloadFilename, + ), + child: Icon(Icons.download), + ), + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], + ), + ), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/expandable_image.dart b/lib/widgets/expandable_image.dart index 848596f..34a1f76 100644 --- a/lib/widgets/expandable_image.dart +++ b/lib/widgets/expandable_image.dart @@ -1,11 +1,7 @@ import "package:material_ui/material_ui.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; -import "package:m3e_buttons/m3e_buttons.dart"; -import "package:nexus/helpers/extensions/save_attachment.dart"; -import "package:nexus/helpers/extensions/size_to_string.dart"; -import "package:nexus/helpers/mxc_image.dart"; +import "package:nexus/helpers/extensions/show_image_viewer.dart"; import "package:nexus/models/requests/download_media.dart"; -import "package:nexus/widgets/error_dialog.dart"; final class const ExpandableImage( final DownloadMediaRequest? request, { @@ -18,72 +14,15 @@ final class const ExpandableImage( }) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => InkWell( - onTap: request == null - ? null - : () => showDialog( - context: context, - builder: (_) => SafeArea( - child: Stack( - children: [ - Positioned.fill( - child: GestureDetector( - onTap: Navigator.of(context).pop, - child: InteractiveViewer( - maxScale: 10, - child: Image( - errorBuilder: (_, error, stackTrace) => ErrorDialog( - "Loading failed for ${request?.mxc}\nError: $error", - stackTrace, - ), - image: MxcImage(ref, request!), - ), - ), - ), - ), - if (size != null) - Align( - alignment: .topLeft, - child: Padding( - padding: .all(32), - child: Card( - margin: .zero, - child: Padding( - padding: .symmetric(horizontal: 12, vertical: 8), - child: Text(size!.sizeAsString), - ), - ), - ), - ), - Align( - alignment: .topRight, - child: Padding( - padding: .all(32), - child: Row( - mainAxisSize: .min, - spacing: 8, - children: [ - if (downloadFilename != null) - M3EButton( - onPressed: () => context.saveAttachment( - ref, - request!.mxc, - encrypted: request!.encrypted, - filename: downloadFilename, - ), - child: Icon(Icons.download), - ), - M3EButton( - onPressed: Navigator.of(context).pop, - child: Icon(Icons.close), - ), - ], - ), - ), - ), - ], - ), - ), - ), + onTap: switch (request) { + final request? => () => context.showImageViewer( + ref, + request, + downloadFilename: downloadFilename, + size: size, + ), + null => null, + }, child: child, ); } diff --git a/lib/widgets/reaction_key.dart b/lib/widgets/reaction_key.dart new file mode 100644 index 0000000..ca00602 --- /dev/null +++ b/lib/widgets/reaction_key.dart @@ -0,0 +1,25 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/mxc_image.dart"; + +/// A reaction's key, either an emoji or a custom (mxc) image. +class const ReactionKey( + final String reaction, { + required final double height, + final TextStyle? style, + super.key, +}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) => + reaction.startsWith("mxc://") + ? Image( + height: height, + image: MxcImage(ref, .new(mxc: Uri.parse(reaction))), + errorBuilder: (context, _, _) => Icon( + Icons.broken_image, + size: height, + color: Theme.of(context).colorScheme.error, + ), + ) + : Text(reaction, style: style, overflow: .ellipsis); +} diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index 288ebd6..ecf1f9e 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -1,12 +1,15 @@ +import "package:flutter/gestures.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/reactions.dart"; import "package:nexus/controllers/room_chat.dart"; -import "package:nexus/helpers/mxc_image.dart"; +import "package:material_adaptive_sheets/material_adaptive_sheets.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/error_dialog.dart"; +import "package:nexus/widgets/reaction_key.dart"; +import "package:nexus/widgets/reactions_popover.dart"; import "package:nexus/main.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; @@ -36,65 +39,64 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget { final selected = reactors?[reaction]?.contains(clientState!.userId) ?? false; - return Tooltip( - message: reactors?[reaction]?.join(", ") ?? "", - child: ChoiceChip( - showCheckmark: false, - selected: selected, - label: Row( - mainAxisSize: .min, - spacing: 8, - children: [ - Flexible( - child: reaction.startsWith("mxc://") - ? Image( - height: 20, - image: MxcImage( - ref, - .new(mxc: Uri.parse(reaction)), - ), - errorBuilder: (context, _, _) => Icon( - Icons.broken_image, - color: Theme.of(context) - .colorScheme - .error, - ), - ) - : Text(reaction, overflow: .ellipsis), - ), - Text(count.toString(), overflow: .ellipsis), - ], - ), - onSelected: enabled.value - ? (value) async { - enabled.value = false; - try { - final controller = ref.watch( - RoomChatController.provider(( - roomId: event.roomId, - contextualEvent: null, - threadRoot: null, - )).notifier, - ); + void showReactions(PositionedGestureDetails details) => + context.showAdaptivePopover( + (_) => ReactionsPopover( + event, + initialReaction: reaction, + ), + anchor: context.globalRectAt(details.globalPosition), + ); - if (selected) { - await controller - .removeReaction( - reaction, - event, - clientState!.userId!, - ) - .onError(showError); - } else { - await controller - .sendReaction(reaction, event) - .onError(showError); + return GestureDetector( + onSecondaryTapUp: showReactions, + onLongPressStart: showReactions, + child: Tooltip( + message: reactors?[reaction]?.join(", ") ?? "", + child: ChoiceChip( + showCheckmark: false, + selected: selected, + label: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + Flexible( + child: ReactionKey(reaction, height: 20), + ), + Text(count.toString(), overflow: .ellipsis), + ], + ), + onSelected: enabled.value + ? (value) async { + enabled.value = false; + try { + final controller = ref.watch( + RoomChatController.provider(( + roomId: event.roomId, + contextualEvent: null, + threadRoot: null, + )).notifier, + ); + + if (selected) { + await controller + .removeReaction( + reaction, + event, + clientState!.userId!, + ) + .onError(showError); + } else { + await controller + .sendReaction(reaction, event) + .onError(showError); + } + } finally { + enabled.value = true; } - } finally { - enabled.value = true; } - } - : null, + : null, + ), ), ); }, diff --git a/lib/widgets/reactions_popover.dart b/lib/widgets/reactions_popover.dart new file mode 100644 index 0000000..01f2efa --- /dev/null +++ b/lib/widgets/reactions_popover.dart @@ -0,0 +1,144 @@ +import "dart:math"; + +import "package:fast_immutable_collections/fast_immutable_collections.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/reactions.dart"; +import "package:nexus/controllers/user.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/show_user_popover.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/helpers/extensions/show_image_viewer.dart"; +import "package:nexus/widgets/reaction_key.dart"; + +/// Every reaction to an event in tabs, each listing who reacted with it. +class const ReactionsPopover( + final Event event, { + final String? initialReaction, + super.key, +}) extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final reactions = ref.watch( + ReactionsController.provider( + .new(roomId: event.roomId, eventRowId: event.rowId), + ), + ); + // Ordered like the reaction row on the timeline + final order = event.reactions.keys.toIList(); + final keys = + reactions.value?.keys.toIList().sortOrdered( + (a, b) => order.indexOf(a).compareTo(order.indexOf(b)), + ) ?? + const .empty(); + + final vsync = useSingleTickerProvider(keys: [keys.length]); + final tabController = useMemoized( + () => TabController( + length: keys.length, + initialIndex: keys + .indexOf(initialReaction ?? "") + .clamp(0, max(0, keys.length - 1)), + vsync: vsync, + ), + [keys.length], + ); + useEffect(() => tabController.dispose, [tabController]); + useListenable(tabController); + + return reactions.betterWhen( + skipLoadingOnReload: true, + data: (reactions) { + if (keys.isEmpty) return SizedBox.shrink(); + + final reaction = keys[tabController.index]; + + return Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + TabBar( + controller: tabController, + isScrollable: true, + tabAlignment: .start, + tabs: keys.map((reaction) { + void expand() => context.showImageViewer( + ref, + .new(mxc: Uri.parse(reaction)), + downloadFilename: reaction.split("/").last, + ); + final isImage = reaction.startsWith("mxc://"); + + return GestureDetector( + onSecondaryTap: isImage ? expand : null, + onLongPress: isImage ? expand : null, + child: Tab( + child: Row( + mainAxisSize: .min, + spacing: 8, + children: [ + ReactionKey(reaction, height: 20), + Text(reactions[reaction]!.length.toString()), + ], + ), + ), + ); + }).toList(), + ), + Flexible( + child: AnimatedSize( + duration: Durations.medium2, + curve: Curves.easeInOutCubicEmphasized, + alignment: .topCenter, + child: SingleChildScrollView( + key: ValueKey(reaction), + padding: .symmetric(vertical: 8), + child: Column( + crossAxisAlignment: .stretch, + children: reactions[reaction]! + .map( + (userId) => Consumer( + builder: (context, ref, _) { + final member = ref + .watch( + UserController.provider( + .new( + roomId: event.roomId, + userId: userId, + ), + ), + ) + .value; + final name = + member?.displayName ?? userId.localpart; + + return ListTile( + leading: AvatarOrHash( + member?.avatarUrl, + name, + dimension: 40, + ), + title: Text(name, overflow: .ellipsis), + subtitle: Text(userId, overflow: .ellipsis), + onTap: () => context.showUserPopover( + userId, + roomId: event.roomId, + ), + ); + }, + ), + ) + .toList(), + ), + ), + ), + ), + ], + ); + }, + ); + } +}