diff --git a/lib/controllers/reactions_controller.dart b/lib/controllers/reactions_controller.dart new file mode 100644 index 0000000..8c199a9 --- /dev/null +++ b/lib/controllers/reactions_controller.dart @@ -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>> { + final ReactionsConfig config; + ReactionsController(this.config); + + @override + Future>> 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(), (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>, + ReactionsConfig + >(ReactionsController.new); +} diff --git a/lib/main.dart b/lib/main.dart index 834aeef..b687ebd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -67,8 +67,8 @@ void main() async { await windowManager.setMinimumSize(Size.square(500)); } - // FlutterError.onError = (FlutterErrorDetails details) => - // showError(details.exception.toString(), details.stack); + FlutterError.onError = (FlutterErrorDetails details) => + showError(details.exception.toString(), details.stack); runApp( ProviderScope( diff --git a/lib/models/configs/reactions_config.dart b/lib/models/configs/reactions_config.dart new file mode 100644 index 0000000..5cae859 --- /dev/null +++ b/lib/models/configs/reactions_config.dart @@ -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 json) => + _$ReactionsConfigFromJson(json); +} diff --git a/lib/widgets/event_wrapper.dart b/lib/widgets/event_wrapper.dart deleted file mode 100644 index ba26e6a..0000000 --- a/lib/widgets/event_wrapper.dart +++ /dev/null @@ -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), - ], - ), - ), - ); - } -} diff --git a/lib/widgets/flash_wrapper.dart b/lib/widgets/flash_wrapper.dart new file mode 100644 index 0000000..f52ea25 --- /dev/null +++ b/lib/widgets/flash_wrapper.dart @@ -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, + ), + ); +} diff --git a/lib/widgets/reaction_row.dart b/lib/widgets/reaction_row.dart index 30ebf1c..4935ed7 100644 --- a/lib/widgets/reaction_row.dart +++ b/lib/widgets/reaction_row.dart @@ -4,11 +4,15 @@ import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client_state_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/helpers/extensions/get_headers.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/widgets/error_dialog.dart"; +import "package:nexus/main.dart"; +import "package:fast_immutable_collections/fast_immutable_collections.dart"; class ReactionRow extends ConsumerWidget { final Event event; @@ -18,100 +22,99 @@ class ReactionRow extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { 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>? 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 - // return Wrap( - // spacing: 4, - // runSpacing: 4, - // children: clientState?.homeserverUrl == null - // ? [] - // : event.reactions - // .mapTo( - // (reaction, reactors) => HookBuilder( - // builder: (context) { - // final enabled = useState(true); - // final selected = reactors.contains(clientState!.userId); - // return Tooltip( - // message: reactors.join(", "), - // child: ChoiceChip( - // showCheckmark: false, - // selected: selected, - // label: Row( - // mainAxisSize: MainAxisSize.min, - // spacing: 8, - // children: [ - // Flexible( - // child: reaction.startsWith("mxc://") - // ? Image( - // height: 20, - // image: CachedNetworkImage( - // headers: ref.headers, - // Uri.parse(reaction) - // .mxcToHttps( - // clientState.homeserverUrl!, - // ) - // .toString(), - // ref.watch( - // CrossCacheController.provider, - // ), - // ), - // ) - // : Text( - // reaction, - // overflow: TextOverflow.ellipsis, - // ), - // ), - // Text( - // reactors.length.toString(), - // overflow: TextOverflow.ellipsis, - // ), - // ], - // ), - // 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 selected = + reactors?[reaction]?.contains(clientState!.userId) ?? + false; + return Tooltip( + message: reactors?[reaction]?.join(", ") ?? "", + child: ChoiceChip( + showCheckmark: false, + selected: selected, + label: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + Flexible( + child: reaction.startsWith("mxc://") + ? Image( + height: 20, + image: CachedNetworkImage( + headers: ref.headers, + Uri.parse(reaction) + .mxcToHttps( + clientState!.homeserverUrl!, + ) + .toString(), + ref.watch(CrossCacheController.provider), + ), + ) + : Text( + reaction, + overflow: TextOverflow.ellipsis, + ), + ), + Text( + count.toString(), + overflow: TextOverflow.ellipsis, + ), + ], + ), + onSelected: enabled.value + ? (value) async { + enabled.value = false; + try { + final controller = ref.watch( + RoomChatController.provider( + event.roomId, + ).notifier, + ); - // final controller = ref.watch( - // RoomChatController.provider( - // roomId, - // ).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(), + ), - // 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, + ), + }; } } diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 855168e..611f7ee 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -24,10 +24,11 @@ import "package:nexus/widgets/expandable_image.dart"; import "package:nexus/widgets/html/html.dart"; import "package:nexus/widgets/lazy_loading/message_avatar.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/players/video.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/generic_event.dart"; import "package:nexus/widgets/file_card.dart"; @@ -356,16 +357,21 @@ class EventRenderer extends ConsumerWidget { style: errorStyle, ), }, + if (event.lastEditRowId != 0) Text( "(edited)", style: theme.textTheme.labelSmall, ), + if (linkify(body).firstWhereOrNull( (element) => element is UrlElement, ) case final UrlElement link?) - LinkPreview(link.url), + UrlPreview(link.url), + + SizedBox(height: 4), + ReactionRow(event), ], ], ), @@ -408,21 +414,38 @@ class EventRenderer extends ConsumerWidget { _ => null, }; - return child == null - ? textOnly - ? Text("Unknown event type", style: errorStyle) - : SizedBox.shrink() - : (textOnly - ? child - : GestureDetector( - onSecondaryTapUp: contextMenuCallback, - onLongPressStart: contextMenuCallback, - child: Padding( - padding: isGrouped - ? EdgeInsets.zero - : EdgeInsets.only(top: 8), - child: child, - ), - )); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (child != null) ...[ + if (textOnly) + child + else + GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: Padding( + padding: isGrouped ? EdgeInsets.zero : EdgeInsets.only(top: 8), + 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), + ], + ); } } diff --git a/lib/widgets/room_chat.dart b/lib/widgets/room_chat.dart index 1bcb1ac..1bff9fa 100644 --- a/lib/widgets/room_chat.dart +++ b/lib/widgets/room_chat.dart @@ -21,7 +21,7 @@ import "package:nexus/widgets/emoji_picker_button.dart"; import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/member_list.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/form_text_input.dart"; import "package:nexus/main.dart"; @@ -352,8 +352,7 @@ class RoomChat extends HookConsumerWidget { itemBuilder: (_, index) { final event = value[index]; final previousEvent = value.getOrNull(index + 1); - return EventWrapper( - event, + return FlashWrapper( EventRenderer( event, onTapReply: () async { diff --git a/lib/widgets/link_preview.dart b/lib/widgets/url_preview.dart similarity index 96% rename from lib/widgets/link_preview.dart rename to lib/widgets/url_preview.dart index e20e955..83c6604 100644 --- a/lib/widgets/link_preview.dart +++ b/lib/widgets/url_preview.dart @@ -7,9 +7,9 @@ import "package:nexus/helpers/extensions/better_when.dart"; import "package:nexus/helpers/extensions/get_headers.dart"; import "package:nexus/helpers/launch_helper.dart"; -class LinkPreview extends ConsumerWidget { +class UrlPreview extends ConsumerWidget { final String link; - const LinkPreview(this.link, {super.key}); + const UrlPreview(this.link, {super.key}); @override Widget build(BuildContext context, WidgetRef ref) => ConstrainedBox(