import "package:collection/collection.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:linkify/linkify.dart"; import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/event.dart"; import "package:nexus/helpers/extensions/get_filename.dart"; import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/event.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/message_image.dart"; import "package:nexus/widgets/reaction_row.dart"; import "package:nexus/widgets/timestamp.dart"; import "package:nexus/widgets/url_preview.dart"; import "package:nexus/widgets/event_preview.dart"; import "package:nexus/widgets/players/video.dart"; import "package:nexus/widgets/players/audio.dart"; class const MessageRenderer( final Event event, { final VoidCallback? onTapReply, final bool textOnly = false, final bool isGrouped = false, final int? maxLines, super.key, }) extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final errorStyle = TextStyle(color: colorScheme.error); final textStyle = TextStyle( fontSize: event.localContent?.bigEmoji == true ? 32 : null, fontStyle: event.content is EmoteMessageContent ? .italic : null, ); final rendered = switch (event.content) { EncryptedContent() => Text("Unable to decrypt event", style: errorStyle), StickerContent(:final body, :final url, :final info) => textOnly ? Text(body, maxLines: maxLines, overflow: .ellipsis) : ConstrainedBox( constraints: .loose(.square(200)), child: MessageImage(url, info: info, encrypted: false), ), // TODO: Handle locations // LocationMessageContent(:final body , :final geoUri) => MessageContent content => switch (content) { 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: .start, children: [ format == .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.isEmpty ? content.filename ?? "" : body, style: textStyle, maxLines: maxLines, ), if (!textOnly) ...[ if (event.content case ImageMessageContent(:final url) || FileMessageContent(:final url) || VideoMessageContent(:final url) || AudioMessageContent(:final url)) ConstrainedBox( constraints: .loose(.square(500)), child: switch (event.content) { VideoMessageContent(:final info, :final file) => VideoPlayer(url, info, encrypted: file != null), AudioMessageContent(:final info, :final file) => AudioPlayer(url, info, encrypted: file != null), FileMessageContent(:final info, :final filename) => FileCard(url, info, filename: filename), ImageMessageContent(:final info, :final file) => MessageImage(url, info: info, encrypted: file != null), _ => SizedBox.shrink(), }, ), if (event.lastEditRowId != 0) Text("(edited)", style: theme.textTheme.labelSmall), if (linkify(body) .firstWhereOrNull((element) => element is UrlElement) case final UrlElement link?) if (Uri.tryParse(link.url) case final Uri url?) UrlPreview(url), ], ], ), LocationMessageContent(:final String? body) || UnknownMessageContent(:final body) => body == null ? Text("This message is redacted", style: errorStyle) : Wrap( spacing: 8, children: [ Text("Unknown message type:", style: errorStyle), Text(body), ], ), }, _ => throw Exception("This is impossible"), }; return Row( crossAxisAlignment: .start, mainAxisSize: .min, spacing: 6, children: [ if (!textOnly) if (isGrouped) SizedBox(width: 40) else MessageAvatar(event, height: 40), Flexible( child: Column( spacing: 2, crossAxisAlignment: .start, children: [ if (!isGrouped && !textOnly) Row( spacing: 4, children: [ Flexible(child: MessageDisplayname(event)), Flexible(flex: 0, child: Timestamp(event.timestamp)), ], ), Card( margin: textOnly ? .zero : .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 ? .zero : .all(12), child: Column( crossAxisAlignment: .start, children: [ if (!textOnly && event.replyTo != null) Card( margin: .only(bottom: 8), color: theme.colorScheme.surfaceContainerHigh, child: InkWell( onTap: onTapReply, child: Padding( padding: .symmetric(vertical: 8, horizontal: 12), child: SizedBox( height: 48, child: switch (ref.watch( EventController.provider( .new( 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, ), _ => Loading(dimension: 12), }, ), ), ), ), rendered, if (!textOnly) ReactionRow(event), ], ), ), ), ], ), ), ], ); } }