import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/gestures.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter_hooks/flutter_hooks.dart"; import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/event.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; import "package:nexus/models/content/content.dart"; import "package:nexus/models/content/create.dart"; import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/history_visibility.dart"; import "package:nexus/models/content/join_rules.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/pinned_events.dart"; import "package:nexus/models/content/power_levels.dart"; import "package:nexus/models/content/server_acl.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/topic.dart"; import "package:nexus/models/event.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/lazy_loading/message_displayname.dart"; import "package:nexus/widgets/renderers/message.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/timestamp.dart"; class const EventRenderer( final int eventRowId, { required final String roomId, final int? previousEventRowId, final bool textOnly = false, final int? maxLines, final JumpToEvent? jumpToEvent, final IList Function(Event event)? getEventOptions, super.key, }) extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final errorStyle = TextStyle(color: colorScheme.error); final focusNode = useFocusNode(); useListenable(focusNode); final (event, previousEvent) = ref.watch( RoomsController.provider.select((rooms) { final event = rooms[roomId]?.events[eventRowId]; if (event == null) return (null, null); final edited = event.lastEditRowId == 0 ? null : rooms[roomId]?.events[event.lastEditRowId]; return ( edited == null ? event : event.copyWith( content: edited.content, localContent: edited.localContent, ), previousEventRowId == null ? null : rooms[roomId]?.events[previousEventRowId!], ); }), ); if (event == null) return SizedBox.shrink(); final isGrouped = previousEvent?.content is MessageContent && previousEvent?.redactedBy == null && previousEvent?.relationType != "m.replace" && event.sender == previousEvent?.sender && event.pmp?.id == previousEvent?.pmp?.id; final child = event.redactedBy != null || event.relationType == "m.replace" ? null : switch (event.content) { Content(:final parseError?) => Row( children: [ ErrorDialog( "An error occurred while parsing event ${event.eventId}:\n$parseError", parseError.stackTrace, ), ], ), MessageContent() || EncryptedContent() || StickerContent() => MessageRenderer( event, onTapReply: jumpToEvent == null ? null : () async { final replyEvent = await ref.read( EventController.provider( .new(roomId: roomId, eventId: event.replyTo!), ).future, ); if (replyEvent != null) { await jumpToEvent!(replyEvent.rowId); } }, isGrouped: isGrouped, maxLines: maxLines, textOnly: textOnly, ), MembershipContent content => switch (event.previousContent) { MembershipContent(:final status) => status == content.status ? null : MembershipRenderer(event, maxLines: maxLines), _ => MembershipRenderer(event, maxLines: maxLines), }, AvatarContent() => GenericEventRenderer( Icons.interests, maxLines: maxLines, [ WidgetSpan( alignment: .middle, child: MessageDisplayname( event, shouldWrap: maxLines == null, ), ), TextSpan(text: "changed the room avatar"), ], ), CreateContent() => GenericEventRenderer( Icons.add, maxLines: maxLines, [ WidgetSpan( alignment: .middle, child: MessageDisplayname( event, shouldWrap: maxLines == null, ), ), TextSpan(text: "created the room"), ], ), PowerLevelsContent() => GenericEventRenderer( Icons.power, maxLines: maxLines, [ WidgetSpan( alignment: .middle, child: MessageDisplayname( event, shouldWrap: maxLines == null, ), ), TextSpan(text: "changed the room's power levels"), ], ), JoinRulesContent() => GenericEventRenderer( Icons.rule, maxLines: maxLines, [ WidgetSpan( alignment: .middle, child: MessageDisplayname( event, shouldWrap: maxLines == null, ), ), TextSpan(text: "changed the room's join rules"), ], ), TopicContent() => GenericEventRenderer( Icons.description, maxLines: maxLines, [ WidgetSpan( alignment: .middle, child: MessageDisplayname( event, shouldWrap: maxLines == null, ), ), TextSpan(text: "updated the room topic"), ], ), HistoryVisibilityContent(:final historyVisibility) => GenericEventRenderer(Icons.history, maxLines: maxLines, [ WidgetSpan( alignment: .middle, child: MessageDisplayname( event, shouldWrap: maxLines == null, ), ), TextSpan( text: "changed the room's history visibility to ${switch (historyVisibility) { .invited => "since invited", .joined => "since joined", .shared => "all history visible (shared)", .worldReadable => "all history visible (world readable)", }}", ), ]), PinnedEventsContent() => GenericEventRenderer( Icons.push_pin, maxLines: maxLines, [ WidgetSpan( alignment: .middle, child: MessageDisplayname( event, shouldWrap: maxLines == null, ), ), TextSpan(text: "pinned/unpinned some events"), ], ), ServerACLContent() => GenericEventRenderer( Icons.list, maxLines: maxLines, [ WidgetSpan( alignment: .middle, child: MessageDisplayname( event, shouldWrap: maxLines == null, ), ), TextSpan(text: "updated the server ban list"), ], ), CanonicalAliasContent(:final alias, :final altAliases) => GenericEventRenderer(Icons.numbers, maxLines: maxLines, [ WidgetSpan( alignment: .middle, child: MessageDisplayname( event, shouldWrap: maxLines == null, ), ), TextSpan( text: switch ([ if (event.previousContent case CanonicalAliasContent( alias: final prevAlias, altAliases: final prevAltAliases, )) ...[ if (prevAlias != alias) if (alias == null) "removed the room's canonical alias" else "changed the room's canonical alias to $alias", if (prevAltAliases .remove(alias ?? "") .remove(prevAlias ?? "") != altAliases .remove(alias ?? "") .remove(prevAlias ?? "")) "changed the room's aliases", ] else ...[ if (alias != null) "set the room's canonical alias", if (altAliases.isNotEmpty) "set the room's aliases", ], ]) { [] => "did something related to room aliases", List prev => prev.join(" and "), }, ), ]), _ => null, }; final contextMenuCallback = getEventOptions == null ? null : (PositionedGestureDetails details) => context.showContextMenu( globalPosition: details.globalPosition, children: getEventOptions!(event).toList(), ); return Column( crossAxisAlignment: .start, children: [ if (child != null) if (textOnly) child else ...[ Builder( builder: (context) => FocusableActionDetector( focusNode: focusNode, actions: contextMenuCallback == null ? null : { ActivateIntent: CallbackAction( onInvoke: (_) { final renderBox = context.findRenderObject() as RenderBox; final topLeft = renderBox.localToGlobal( Offset.zero, ); context.showContextMenu( globalPosition: topLeft, children: getEventOptions!(event).toList(), ); return null; }, ), }, child: Container( decoration: BoxDecoration( color: focusNode.hasPrimaryFocus ? theme.colorScheme.surfaceContainerHighest : null, ), child: GestureDetector( onSecondaryTapUp: contextMenuCallback, onLongPressStart: contextMenuCallback, child: Padding( padding: EdgeInsets.symmetric(horizontal: 8) .copyWith(top: isGrouped ? 0 : 8), child: Wrap( verticalDirection: .up, spacing: 6, crossAxisAlignment: .center, children: [ child, if (child is! MessageRenderer) Timestamp(event.timestamp), ], ), ), ), ), ), ), ...[ if (child is! MessageRenderer) ReactionRow(event), if (event.sendError != null && event.sendError != "not sent") Padding( padding: .only(bottom: 4), child: Row( spacing: 4, children: [ Text( event.sendError!, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.error, ), ), InkWell( child: Text( "Hide", style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.primary, decoration: .underline, decorationColor: theme.colorScheme.primary, ), ), onTap: () => ref .read( RoomChatController.provider(( roomId: roomId, contextualEvent: null, )).notifier, ) .hideClientSticky(event.rowId), ), ], ), ), ].map((child) => Padding(padding: .only(left: 4), child: child)), ] else if (textOnly) Text("Unknown event type", style: errorStyle), ], ); } }