reorganize and rename widgets

This commit is contained in:
Henry Hiles 2026-11-09 17:25:02 -05:00
commit d5f29dc344
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
3 changed files with 568 additions and 547 deletions

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

@ -0,0 +1,115 @@
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:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/common/error_dialog.dart";
import "package:nexus/widgets/reactions/reaction_key.dart";
import "package:nexus/widgets/reactions/reactions_popover.dart";
import "package:nexus/main.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
class const ReactionRow(final Event event, {super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final clientState = ref.watch(ClientStateController.provider);
return Padding(
padding: .only(top: 4),
child: switch (ref.watch(
ReactionsController.provider(
.new(roomId: event.roomId, eventRowId: event.rowId),
),
)) {
AsyncData(value: final IMap<String, IList<String>>? reactors) ||
AsyncLoading(value: final reactors) => Wrap(
spacing: 4,
runSpacing: 4,
children: event.reactions
.where((_, value) => value != 0)
.mapTo(
(reaction, count) => HookBuilder(
builder: (context) {
final enabled = useState(true);
final selected =
reactors?[reaction]?.contains(clientState!.userId) ??
false;
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(", ") ?? "",
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;
}
}
: null,
),
),
);
},
),
)
.toList(),
),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
);
}
}

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/avatars/avatar_or_hash.dart";
import "package:nexus/helpers/extensions/show_image_viewer.dart";
import "package:nexus/widgets/reactions/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(),
),
),
),
),
],
);
},
);
}
}