add reaction support

This commit is contained in:
Henry Hiles 2026-05-21 16:17:21 -04:00
commit 2451555479
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
9 changed files with 233 additions and 163 deletions

View file

@ -0,0 +1,56 @@
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/controllers/client_controller.dart";
import "package:nexus/controllers/rooms_controller.dart";
import "package:nexus/models/configs/reactions_config.dart";
import "package:nexus/models/content/reaction.dart";
import "package:nexus/models/requests/get_related_events_request.dart";
class ReactionsController extends AsyncNotifier<IMap<String, IList<String>>> {
final ReactionsConfig config;
ReactionsController(this.config);
@override
Future<IMap<String, IList<String>>> build() async {
final eventInfo = ref.watch(
RoomsController.provider.select((value) {
final event = value[config.roomId]?.events[config.eventRowId];
return event == null ? null : (event.eventId, event.reactions);
}),
);
final reactionEvents = eventInfo?.$2.isNotEmpty == true
? await ref
.watch(ClientController.provider.notifier)
.getRelatedEvents(
GetRelatedEventsRequest(
roomId: config.roomId,
eventId: eventInfo!.$1,
relationType: "m.annotation",
),
)
: null;
return reactionEvents
?.where((event) => event.redactedBy == null)
.fold<IMap<String, IList<String>>>(IMap(), (acc, event) {
if (event.content case ReactionContent(:final key?)) {
return acc.update(
key,
(list) => list.add(event.sender),
ifAbsent: () => IList([event.sender]),
);
}
return acc;
}) ??
const IMap.empty();
}
static final provider =
AsyncNotifierProvider.family<
ReactionsController,
IMap<String, IList<String>>,
ReactionsConfig
>(ReactionsController.new);
}

View file

@ -67,8 +67,8 @@ void main() async {
await windowManager.setMinimumSize(Size.square(500)); await windowManager.setMinimumSize(Size.square(500));
} }
// FlutterError.onError = (FlutterErrorDetails details) => FlutterError.onError = (FlutterErrorDetails details) =>
// showError(details.exception.toString(), details.stack); showError(details.exception.toString(), details.stack);
runApp( runApp(
ProviderScope( ProviderScope(

View file

@ -0,0 +1,14 @@
import "package:freezed_annotation/freezed_annotation.dart";
part "reactions_config.freezed.dart";
part "reactions_config.g.dart";
@freezed
abstract class ReactionsConfig with _$ReactionsConfig {
const factory ReactionsConfig({
required String roomId,
required int eventRowId,
}) = _ReactionsConfig;
factory ReactionsConfig.fromJson(Map<String, Object?> json) =>
_$ReactionsConfigFromJson(json);
}

View file

@ -1,45 +0,0 @@
import "package:flutter/material.dart";
import "package:nexus/models/event.dart";
import "package:nexus/widgets/reaction_row.dart";
class EventWrapper extends StatelessWidget {
final Event event;
final Widget child;
final bool isFlashing;
const EventWrapper(
this.event,
this.child, {
this.isFlashing = false,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(12)),
child: AnimatedContainer(
padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0),
color: isFlashing
? Theme.of(context).colorScheme.onSurface.withAlpha(50)
: Colors.transparent,
duration: Duration(milliseconds: 250),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
child,
if (event.sendError != null && event.sendError != "not sent")
Text(
event.sendError!,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error,
),
),
ReactionRow(event),
],
),
),
);
}
}

View file

@ -0,0 +1,20 @@
import "package:flutter/material.dart";
class FlashWrapper extends StatelessWidget {
final Widget child;
final bool isFlashing;
const FlashWrapper(this.child, {this.isFlashing = false, super.key});
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(12)),
child: AnimatedContainer(
padding: isFlashing ? EdgeInsets.all(8) : EdgeInsets.all(0),
color: isFlashing
? Theme.of(context).colorScheme.onSurface.withAlpha(50)
: Colors.transparent,
duration: Duration(milliseconds: 250),
child: child,
),
);
}

View file

@ -4,11 +4,15 @@ 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_controller.dart"; import "package:nexus/controllers/client_state_controller.dart";
import "package:nexus/controllers/cross_cache_controller.dart"; import "package:nexus/controllers/cross_cache_controller.dart";
import "package:nexus/controllers/reactions_controller.dart";
import "package:nexus/controllers/room_chat_controller.dart"; import "package:nexus/controllers/room_chat_controller.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/extensions/mxc_to_https.dart";
import "package:nexus/main.dart"; import "package:nexus/models/configs/reactions_config.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/main.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
class ReactionRow extends ConsumerWidget { class ReactionRow extends ConsumerWidget {
final Event event; final Event event;
@ -18,100 +22,99 @@ class ReactionRow extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final clientState = ref.watch(ClientStateController.provider); final clientState = ref.watch(ClientStateController.provider);
return SizedBox.shrink(); return switch (ref.watch(
ReactionsController.provider(
ReactionsConfig(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);
// TODO: IMPL final selected =
// return Wrap( reactors?[reaction]?.contains(clientState!.userId) ??
// spacing: 4, false;
// runSpacing: 4, return Tooltip(
// children: clientState?.homeserverUrl == null message: reactors?[reaction]?.join(", ") ?? "",
// ? [] child: ChoiceChip(
// : event.reactions showCheckmark: false,
// .mapTo( selected: selected,
// (reaction, reactors) => HookBuilder( label: Row(
// builder: (context) { mainAxisSize: MainAxisSize.min,
// final enabled = useState(true); spacing: 8,
// final selected = reactors.contains(clientState!.userId); children: [
// return Tooltip( Flexible(
// message: reactors.join(", "), child: reaction.startsWith("mxc://")
// child: ChoiceChip( ? Image(
// showCheckmark: false, height: 20,
// selected: selected, image: CachedNetworkImage(
// label: Row( headers: ref.headers,
// mainAxisSize: MainAxisSize.min, Uri.parse(reaction)
// spacing: 8, .mxcToHttps(
// children: [ clientState!.homeserverUrl!,
// Flexible( )
// child: reaction.startsWith("mxc://") .toString(),
// ? Image( ref.watch(CrossCacheController.provider),
// height: 20, ),
// image: CachedNetworkImage( )
// headers: ref.headers, : Text(
// Uri.parse(reaction) reaction,
// .mxcToHttps( overflow: TextOverflow.ellipsis,
// clientState.homeserverUrl!, ),
// ) ),
// .toString(), Text(
// ref.watch( count.toString(),
// CrossCacheController.provider, overflow: TextOverflow.ellipsis,
// ), ),
// ), ],
// ) ),
// : Text( onSelected: enabled.value
// reaction, ? (value) async {
// overflow: TextOverflow.ellipsis, enabled.value = false;
// ), try {
// ), final controller = ref.watch(
// Text( RoomChatController.provider(
// reactors.length.toString(), event.roomId,
// overflow: TextOverflow.ellipsis, ).notifier,
// ), );
// ],
// ),
// onSelected: enabled.value
// ? (value) async {
// enabled.value = false;
// try {
// final roomId = ref.watch(
// SelectedRoomController.provider.select(
// (value) => value?.metadata?.id,
// ),
// );
// if (roomId == null ||
// clientState.userId == null) {
// return;
// }
// final controller = ref.watch( if (selected) {
// RoomChatController.provider( await controller
// roomId, .removeReaction(
// ).notifier, reaction,
// ); event,
clientState!.userId!,
// if (selected) { )
// await controller .onError(showError);
// .removeReaction( } else {
// reaction, await controller
// event, .sendReaction(reaction, event)
// clientState.userId!, .onError(showError);
// ) }
// .onError(showError); } finally {
// } else { enabled.value = true;
// await controller }
// .sendReaction(reaction, event) }
// .onError(showError); : null,
// } ),
// } finally { );
// enabled.value = true; },
// } ),
// } )
// : null, .toList(),
// ), ),
// );
// }, AsyncError(:final error, :final stackTrace) => ErrorDialog(
// ), error,
// ) stackTrace,
// .toList(), ),
// ); };
} }
} }

View file

@ -24,10 +24,11 @@ import "package:nexus/widgets/expandable_image.dart";
import "package:nexus/widgets/html/html.dart"; import "package:nexus/widgets/html/html.dart";
import "package:nexus/widgets/lazy_loading/message_avatar.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.dart";
import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart";
import "package:nexus/widgets/link_preview.dart"; import "package:nexus/widgets/url_preview.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/video.dart";
import "package:nexus/widgets/players/audio.dart"; import "package:nexus/widgets/players/audio.dart";
import "package:nexus/widgets/reaction_row.dart";
import "package:nexus/widgets/renderers/membership.dart"; import "package:nexus/widgets/renderers/membership.dart";
import "package:nexus/widgets/renderers/generic_event.dart"; import "package:nexus/widgets/renderers/generic_event.dart";
import "package:nexus/widgets/file_card.dart"; import "package:nexus/widgets/file_card.dart";
@ -356,16 +357,21 @@ class EventRenderer extends ConsumerWidget {
style: errorStyle, style: errorStyle,
), ),
}, },
if (event.lastEditRowId != 0) if (event.lastEditRowId != 0)
Text( Text(
"(edited)", "(edited)",
style: theme.textTheme.labelSmall, style: theme.textTheme.labelSmall,
), ),
if (linkify(body).firstWhereOrNull( if (linkify(body).firstWhereOrNull(
(element) => element is UrlElement, (element) => element is UrlElement,
) )
case final UrlElement link?) case final UrlElement link?)
LinkPreview(link.url), UrlPreview(link.url),
SizedBox(height: 4),
ReactionRow(event),
], ],
], ],
), ),
@ -408,21 +414,38 @@ class EventRenderer extends ConsumerWidget {
_ => null, _ => null,
}; };
return child == null return Column(
? textOnly crossAxisAlignment: CrossAxisAlignment.start,
? Text("Unknown event type", style: errorStyle) children: [
: SizedBox.shrink() if (child != null) ...[
: (textOnly if (textOnly)
? child child
: GestureDetector( else
GestureDetector(
onSecondaryTapUp: contextMenuCallback, onSecondaryTapUp: contextMenuCallback,
onLongPressStart: contextMenuCallback, onLongPressStart: contextMenuCallback,
child: Padding( child: Padding(
padding: isGrouped padding: isGrouped ? EdgeInsets.zero : EdgeInsets.only(top: 8),
? EdgeInsets.zero
: EdgeInsets.only(top: 8),
child: child, child: child,
), ),
)); ),
if (event.content is! MessageContent)
Padding(
padding: EdgeInsetsGeometry.only(left: 12),
child: ReactionRow(event),
),
if (event.sendError != null && event.sendError != "not sent")
Text(
event.sendError!,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.error,
),
),
] else if (textOnly)
Text("Unknown event type", style: errorStyle),
],
);
} }
} }

View file

@ -21,7 +21,7 @@ import "package:nexus/widgets/emoji_picker_button.dart";
import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/renderers/event.dart";
import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/widgets/event_wrapper.dart"; import "package:nexus/widgets/flash_wrapper.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/form_text_input.dart"; import "package:nexus/widgets/form_text_input.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
@ -352,8 +352,7 @@ class RoomChat extends HookConsumerWidget {
itemBuilder: (_, index) { itemBuilder: (_, index) {
final event = value[index]; final event = value[index];
final previousEvent = value.getOrNull(index + 1); final previousEvent = value.getOrNull(index + 1);
return EventWrapper( return FlashWrapper(
event,
EventRenderer( EventRenderer(
event, event,
onTapReply: () async { onTapReply: () async {

View file

@ -7,9 +7,9 @@ import "package:nexus/helpers/extensions/better_when.dart";
import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/extensions/get_headers.dart";
import "package:nexus/helpers/launch_helper.dart"; import "package:nexus/helpers/launch_helper.dart";
class LinkPreview extends ConsumerWidget { class UrlPreview extends ConsumerWidget {
final String link; final String link;
const LinkPreview(this.link, {super.key}); const UrlPreview(this.link, {super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox( Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox(