add reactions popover

Fixes #168
This commit is contained in:
Henry Hiles 2026-11-09 17:14:46 -05:00
commit 05d25869ef
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 323 additions and 128 deletions

View file

@ -20,6 +20,8 @@ extension SchemeToTheme on ColorScheme {
shape: RoundedRectangleBorder(borderRadius: .circular(16)), shape: RoundedRectangleBorder(borderRadius: .circular(16)),
color: surfaceContainerHigh, color: surfaceContainerHigh,
), ),
// So ink at the edges of a sheet doesn't spill past its rounded corners
bottomSheetTheme: .new(clipBehavior: .antiAlias),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
titleSpacing: 0, titleSpacing: 0,
backgroundColor: surfaceContainerLow, backgroundColor: surfaceContainerLow,

View file

@ -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),
),
],
),
),
),
],
),
),
);
}

View file

@ -1,11 +1,7 @@
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:m3e_buttons/m3e_buttons.dart"; import "package:nexus/helpers/extensions/show_image_viewer.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/models/requests/download_media.dart";
import "package:nexus/widgets/error_dialog.dart";
final class const ExpandableImage( final class const ExpandableImage(
final DownloadMediaRequest? request, { final DownloadMediaRequest? request, {
@ -18,72 +14,15 @@ final class const ExpandableImage(
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => InkWell( Widget build(BuildContext context, WidgetRef ref) => InkWell(
onTap: request == null onTap: switch (request) {
? null final request? => () => context.showImageViewer(
: () => 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, ref,
request!.mxc, request,
encrypted: request!.encrypted, downloadFilename: downloadFilename,
filename: downloadFilename, size: size,
),
child: Icon(Icons.download),
),
M3EButton(
onPressed: Navigator.of(context).pop,
child: Icon(Icons.close),
),
],
),
),
),
],
),
),
), ),
null => null,
},
child: child, child: child,
); );
} }

View file

@ -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);
}

View file

@ -1,12 +1,15 @@
import "package:flutter/gestures.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/reactions.dart"; import "package:nexus/controllers/reactions.dart";
import "package:nexus/controllers/room_chat.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/models/event.dart";
import "package:nexus/widgets/error_dialog.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:nexus/main.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart";
@ -36,7 +39,19 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
final selected = final selected =
reactors?[reaction]?.contains(clientState!.userId) ?? reactors?[reaction]?.contains(clientState!.userId) ??
false; false;
return Tooltip( void showReactions(PositionedGestureDetails details) =>
context.showAdaptivePopover(
(_) => ReactionsPopover(
event,
initialReaction: reaction,
),
anchor: context.globalRectAt(details.globalPosition),
);
return GestureDetector(
onSecondaryTapUp: showReactions,
onLongPressStart: showReactions,
child: Tooltip(
message: reactors?[reaction]?.join(", ") ?? "", message: reactors?[reaction]?.join(", ") ?? "",
child: ChoiceChip( child: ChoiceChip(
showCheckmark: false, showCheckmark: false,
@ -46,21 +61,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
spacing: 8, spacing: 8,
children: [ children: [
Flexible( Flexible(
child: reaction.startsWith("mxc://") child: ReactionKey(reaction, height: 20),
? 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), Text(count.toString(), overflow: .ellipsis),
], ],
@ -96,6 +97,7 @@ class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
} }
: null, : null,
), ),
),
); );
}, },
), ),

View file

@ -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(),
),
),
),
),
],
);
},
);
}
}