From 2ba620350d28eb0c6f523531fc7dc757548adade Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 26 May 2026 19:11:09 -0400 Subject: [PATCH 1/2] refactor message renderer into its own widget --- lib/widgets/renderers/event.dart | 359 +---------------------------- lib/widgets/renderers/message.dart | 357 ++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+), 347 deletions(-) create mode 100644 lib/widgets/renderers/message.dart diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 675f6b0..090a01a 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -1,39 +1,20 @@ -import "package:collection/collection.dart"; -import "package:cross_cache/cross_cache.dart"; import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:flutter/material.dart"; -import "package:flutter_blurhash/flutter_blurhash.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:linkify/linkify.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/cross_cache_controller.dart"; -import "package:nexus/controllers/event_controller.dart"; -import "package:nexus/helpers/extensions/get_headers.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; import "package:nexus/helpers/extensions/show_context_menu.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/membership.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; -import "package:nexus/models/requests/get_event_request.dart"; -import "package:nexus/widgets/event_preview.dart"; -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/linkified_text.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/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/file_card.dart"; -import "package:timeago/timeago.dart"; class EventRenderer extends ConsumerWidget { final Event event; @@ -58,15 +39,6 @@ class EventRenderer extends ConsumerWidget { final colorScheme = theme.colorScheme; final errorStyle = TextStyle(color: colorScheme.error); - final timestamp = Tooltip( - message: event.timestamp.toString(), - child: Text( - format(event.timestamp), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey), - ), - ); final contextMenuCallback = getEventOptions == null ? null : (details) => context.showContextMenu( @@ -74,11 +46,6 @@ class EventRenderer extends ConsumerWidget { children: getEventOptions!(event).toList(), ); - final textStyle = TextStyle( - fontSize: event.localContent?.bigEmoji == true ? 32 : null, - fontStyle: event.content is EmoteMessageContent ? FontStyle.italic : null, - ); - final child = event.redactedBy != null || event.relationType == "m.replace" ? null : switch (event.content) { @@ -86,318 +53,12 @@ class EventRenderer extends ConsumerWidget { "An error occurred while parsing this event:\n$parseError\n${parseError.stackTrace}", style: errorStyle, ), - MessageContent() || EncryptedContent() => Row( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - spacing: 8, - children: [ - if (!textOnly) - if (isGrouped) - SizedBox(width: 40) - else - MessageAvatar(event, height: 40), - Flexible( - child: Column( - spacing: 4, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isGrouped && !textOnly) - Row( - spacing: 4, - children: [ - Flexible(child: MessageDisplayname(event)), - Flexible(flex: 0, child: timestamp), - ], - ), - Card( - margin: textOnly - ? EdgeInsets.zero - : EdgeInsets.only(bottom: 4), - color: textOnly - ? Colors.transparent - : ref.watch( - ClientStateController.provider.select( - (value) => value?.userId, - ), - ) == - event.sender - ? (event.eventId.startsWith("~") - ? colorScheme.onPrimary - : colorScheme.primaryContainer) - : colorScheme.surfaceContainer, - elevation: textOnly ? 0 : null, - - child: Padding( - padding: textOnly - ? EdgeInsets.zero - : EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!textOnly && event.replyTo != null) - Card( - margin: EdgeInsets.only(bottom: 8), - color: theme.colorScheme.surfaceContainerHigh, - child: InkWell( - onTap: onTapReply, - child: Padding( - padding: EdgeInsetsGeometry.symmetric( - vertical: 8, - horizontal: 12, - ), - child: switch (ref.watch( - EventController.provider( - GetEventRequest( - roomId: event.roomId, - eventId: event.replyTo!, - ), - ), - )) { - AsyncData(:final value?) || - AsyncLoading( - :final value?, - ) => EventPreview(value), - AsyncError _ => Text( - "An error occurred while fetching the reply", - style: errorStyle, - ), - _ => Text("Fetching event..."), - }, - ), - ), - ), - switch (event.content) { - EncryptedContent() => Text( - "Unable to decrypt event", - style: errorStyle, - ), - // TODO: Handle locations - // LocationMessageContent(:final body , :final geoUri) => - TextMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - NoticeMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - EmoteMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - ImageMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - VideoMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - AudioMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - FileMessageContent( - :final body, - :final formattedBody, - :final format, - ) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - format == MessageFormat.html && !textOnly - ? Html( - roomId: event.roomId, - textStyle: textStyle, - formattedBody!.replaceAllMapped( - RegExp( - r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)", - caseSensitive: false, - dotAll: true, - ), - (m) { - // If it's already an tag, leave it unchanged - if (m.group(1) != null) { - return m.group(1)!; - } - - // Otherwise, wrap the bare URL - final url = m.group(2)!; - return "$url"; - }, - ), - ) - : LinkifiedText( - body, - style: textStyle, - maxLines: maxLines, - ), - - if (!textOnly) ...[ - if (event.content - case ImageMessageContent( - :final url, - ) || - FileMessageContent(:final url) || - VideoMessageContent(:final url) || - AudioMessageContent(:final url)) - switch (url?.mxcToHttps( - ref.watch( - ClientStateController.provider - .select( - (value) => - value!.homeserverUrl!, - ), - ), - )) { - final url? => ConstrainedBox( - constraints: BoxConstraints.loose( - Size.square(500), - ), - child: switch (event.content) { - VideoMessageContent( - :final info, - ) => - VideoPlayer(url, info), - AudioMessageContent( - :final info, - ) => - AudioPlayer(url, info), - FileMessageContent( - :final info, - :final filename, - ) => - FileCard( - url, - info, - filename: filename, - ), - ImageMessageContent(:final info) => ExpandableImage( - url.toString(), - child: ClipRRect( - borderRadius: - BorderRadius.all( - Radius.circular(8), - ), - child: Image( - image: CachedNetworkImage( - url.toString(), - ref.watch( - CrossCacheController - .provider, - ), - headers: ref.headers, - ), - width: info?.width, - loadingBuilder: - ( - _, - child, - loadingProgress, - ) => loadingProgress == null - ? child - : switch (info?.blurHash) { - final blurHash? => - info?.width == - null || - info?.height == - null - ? SizedBox( - width: - 200, - height: - 200, - child: BlurHash( - hash: - blurHash, - ), - ) - : AspectRatio( - aspectRatio: - info! - .width! / - info.height!, - child: SizedBox( - width: info - .width, - child: BlurHash( - hash: - blurHash, - ), - ), - ), - _ => Loading(), - }, - errorBuilder: - ( - context, - error, - stackTrace, - ) => Center( - child: Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.error, - ), - ), - ), - ), - ), - ), - _ => SizedBox.shrink(), - }, - ), - _ => Text( - "Nexus currently cannot handle encrypted media", - style: errorStyle, - ), - }, - - if (event.lastEditRowId != 0) - Text( - "(edited)", - style: theme.textTheme.labelSmall, - ), - - if (linkify(body).firstWhereOrNull( - (element) => element is UrlElement, - ) - case final UrlElement link?) - UrlPreview(link.url), - - SizedBox(height: 4), - ReactionRow(event), - ], - ], - ), - MessageContent(:final body) => Row( - spacing: 8, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - "Unknown message type:", - style: errorStyle, - ), - Text(body), - ], - ), - _ => throw Exception("This is impossible"), - }, - ], - ), - ), - ), - ], - ), - ), - ], + MessageContent() || EncryptedContent() => MessageRenderer( + event, + onTapReply: onTapReply, + isGrouped: isGrouped, + maxLines: maxLines, + textOnly: textOnly, ), MembershipContent content => event.previousContent is MembershipContent && @@ -409,6 +70,10 @@ class EventRenderer extends ConsumerWidget { MessageDisplayname(event), Text("changed the room avatar"), ]), + CreateContent() => GenericEventRenderer(Icons.add, [ + MessageDisplayname(event), + Text("created the room"), + ]), CanonicalAliasContent(:final alias, :final altAliases) => GenericEventRenderer(Icons.numbers, [ MessageDisplayname(event), diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart new file mode 100644 index 0000000..11d5556 --- /dev/null +++ b/lib/widgets/renderers/message.dart @@ -0,0 +1,357 @@ +import "package:collection/collection.dart"; +import "package:cross_cache/cross_cache.dart"; +import "package:flutter/material.dart"; +import "package:flutter_blurhash/flutter_blurhash.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:linkify/linkify.dart"; +import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/controllers/cross_cache_controller.dart"; +import "package:nexus/controllers/event_controller.dart"; +import "package:nexus/helpers/extensions/get_headers.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/models/content/encrypted.dart"; +import "package:nexus/models/content/message.dart"; +import "package:nexus/models/event.dart"; +import "package:nexus/models/requests/get_event_request.dart"; +import "package:nexus/widgets/expandable_image.dart"; +import "package:nexus/widgets/file_card.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/linkified_text.dart"; +import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/reaction_row.dart"; +import "package:nexus/widgets/url_preview.dart"; +import "package:timeago/timeago.dart"; +import "package:nexus/widgets/event_preview.dart"; +import "package:nexus/widgets/players/video.dart"; +import "package:nexus/widgets/players/audio.dart"; + +class MessageRenderer extends ConsumerWidget { + final Event event; + final bool textOnly; + final bool isGrouped; + final int? maxLines; + final VoidCallback? onTapReply; + const MessageRenderer( + this.event, { + this.onTapReply, + this.textOnly = false, + this.isGrouped = false, + this.maxLines, + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final errorStyle = TextStyle(color: colorScheme.error); + + final timestamp = Tooltip( + message: event.timestamp.toString(), + child: Text( + format(event.timestamp), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith(color: Colors.grey), + ), + ); + + final textStyle = TextStyle( + fontSize: event.localContent?.bigEmoji == true ? 32 : null, + fontStyle: event.content is EmoteMessageContent ? FontStyle.italic : null, + ); + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: 8, + children: [ + if (!textOnly) + if (isGrouped) + SizedBox(width: 40) + else + MessageAvatar(event, height: 40), + Flexible( + child: Column( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isGrouped && !textOnly) + Row( + spacing: 4, + children: [ + Flexible(child: MessageDisplayname(event)), + Flexible(flex: 0, child: timestamp), + ], + ), + Card( + margin: textOnly ? EdgeInsets.zero : EdgeInsets.only(bottom: 4), + color: textOnly + ? Colors.transparent + : ref.watch( + ClientStateController.provider.select( + (value) => value?.userId, + ), + ) == + event.sender + ? (event.eventId.startsWith("~") + ? colorScheme.onPrimary + : colorScheme.primaryContainer) + : colorScheme.surfaceContainer, + elevation: textOnly ? 0 : null, + + child: Padding( + padding: textOnly ? EdgeInsets.zero : EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!textOnly && event.replyTo != null) + Card( + margin: EdgeInsets.only(bottom: 8), + color: theme.colorScheme.surfaceContainerHigh, + child: InkWell( + onTap: onTapReply, + child: Padding( + padding: EdgeInsetsGeometry.symmetric( + vertical: 8, + horizontal: 12, + ), + child: switch (ref.watch( + EventController.provider( + GetEventRequest( + roomId: event.roomId, + eventId: event.replyTo!, + ), + ), + )) { + AsyncData(:final value?) || + AsyncLoading( + :final value?, + ) => EventPreview(value), + AsyncError _ => Text( + "An error occurred while fetching the reply", + style: errorStyle, + ), + _ => Text("Fetching event..."), + }, + ), + ), + ), + switch (event.content) { + EncryptedContent() => Text( + "Unable to decrypt event", + style: errorStyle, + ), + // TODO: Handle locations + // LocationMessageContent(:final body , :final geoUri) => + TextMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + NoticeMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + EmoteMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + ImageMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + VideoMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + AudioMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + FileMessageContent( + :final body, + :final formattedBody, + :final format, + ) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + format == MessageFormat.html && !textOnly + ? Html( + roomId: event.roomId, + textStyle: textStyle, + formattedBody!.replaceAllMapped( + RegExp( + r"(]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)", + caseSensitive: false, + dotAll: true, + ), + (m) { + // If it's already an tag, leave it unchanged + if (m.group(1) != null) { + return m.group(1)!; + } + + // Otherwise, wrap the bare URL + final url = m.group(2)!; + return "$url"; + }, + ), + ) + : LinkifiedText( + body, + style: textStyle, + maxLines: maxLines, + ), + + if (!textOnly) ...[ + if (event.content + case ImageMessageContent(:final url) || + FileMessageContent(:final url) || + VideoMessageContent(:final url) || + AudioMessageContent(:final url)) + switch (url?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + )) { + final url? => ConstrainedBox( + constraints: BoxConstraints.loose( + Size.square(500), + ), + child: switch (event.content) { + VideoMessageContent(:final info) => + VideoPlayer(url, info), + AudioMessageContent(:final info) => + AudioPlayer(url, info), + FileMessageContent( + :final info, + :final filename, + ) => + FileCard(url, info, filename: filename), + ImageMessageContent(:final info) => + ExpandableImage( + url.toString(), + child: ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + child: Image( + image: CachedNetworkImage( + url.toString(), + ref.watch( + CrossCacheController.provider, + ), + headers: ref.headers, + ), + width: info?.width, + loadingBuilder: + ( + _, + child, + loadingProgress, + ) => loadingProgress == null + ? child + : switch (info?.blurHash) { + final blurHash? => + info?.width == null || + info?.height == + null + ? SizedBox( + width: 200, + height: 200, + child: BlurHash( + hash: + blurHash, + ), + ) + : AspectRatio( + aspectRatio: + info! + .width! / + info.height!, + child: SizedBox( + width: info + .width, + child: BlurHash( + hash: + blurHash, + ), + ), + ), + _ => Loading(), + }, + errorBuilder: + ( + context, + error, + stackTrace, + ) => Center( + child: Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + ), + ), + ), + ), + ), + ), + _ => SizedBox.shrink(), + }, + ), + _ => Text( + "Nexus currently cannot handle encrypted media", + style: errorStyle, + ), + }, + + if (event.lastEditRowId != 0) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + + if (linkify(body).firstWhereOrNull( + (element) => element is UrlElement, + ) + case final UrlElement link?) + UrlPreview(link.url), + + SizedBox(height: 4), + ReactionRow(event), + ], + ], + ), + MessageContent(:final body) => Row( + spacing: 8, + mainAxisSize: MainAxisSize.min, + children: [ + Text("Unknown message type:", style: errorStyle), + Text(body), + ], + ), + _ => throw Exception("This is impossible"), + }, + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} From ec64a81fed92f90a70989a4e4bb342dbf8fe50ec Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 26 May 2026 19:13:19 -0400 Subject: [PATCH 2/2] add create event, fix generic event icon --- lib/widgets/renderers/event.dart | 14 +++++++------- lib/widgets/renderers/generic_event.dart | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 090a01a..71d7412 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -39,13 +39,6 @@ class EventRenderer extends ConsumerWidget { final colorScheme = theme.colorScheme; final errorStyle = TextStyle(color: colorScheme.error); - final contextMenuCallback = getEventOptions == null - ? null - : (details) => context.showContextMenu( - globalPosition: details.globalPosition, - children: getEventOptions!(event).toList(), - ); - final child = event.redactedBy != null || event.relationType == "m.replace" ? null : switch (event.content) { @@ -105,6 +98,13 @@ class EventRenderer extends ConsumerWidget { _ => null, }; + final contextMenuCallback = getEventOptions == null + ? null + : (details) => context.showContextMenu( + globalPosition: details.globalPosition, + children: getEventOptions!(event).toList(), + ); + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/widgets/renderers/generic_event.dart b/lib/widgets/renderers/generic_event.dart index 0046e33..5efd724 100644 --- a/lib/widgets/renderers/generic_event.dart +++ b/lib/widgets/renderers/generic_event.dart @@ -13,7 +13,7 @@ class GenericEventRenderer extends StatelessWidget { children: [ Padding( padding: EdgeInsets.symmetric(horizontal: 4), - child: Icon(Icons.people), + child: Icon(icon), ), Expanded(child: Wrap(spacing: 4, children: children)), ],