From fee12cb94d23df67b6a3ad8354cbb2dcf02808cd Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 19 May 2026 10:07:15 -0400 Subject: [PATCH] fix up url embeds --- lib/controllers/url_preview_controller.dart | 8 +- lib/models/open_graph_data.dart | 4 +- lib/widgets/chat_page/html/html.dart | 1 + lib/widgets/chat_page/render_event.dart | 425 ++++++++++---------- lib/widgets/chat_page/room_chat.dart | 37 +- lib/widgets/link_preview.dart | 78 ++-- pubspec.lock | 11 +- pubspec.yaml | 6 + 8 files changed, 294 insertions(+), 276 deletions(-) diff --git a/lib/controllers/url_preview_controller.dart b/lib/controllers/url_preview_controller.dart index 15eac11..ad89b55 100644 --- a/lib/controllers/url_preview_controller.dart +++ b/lib/controllers/url_preview_controller.dart @@ -26,15 +26,15 @@ class UrlPreviewController extends AsyncNotifier { ); if (response.statusCode == 200) { - final decodedValue = json.decode(response.body) as Map?; - if (decodedValue?.isNotEmpty == true) return null; + final decodedValue = json.decode(response.body); + if (decodedValue is! Map) return null; - final mxc = decodedValue!["og:image"]; + final mxc = decodedValue["og:image"]; final image = mxc == null ? null : Uri.tryParse(mxc)?.mxcToHttps(homeserver); - return OpenGraphData.fromJson({...decodedValue, "og:image": image}); + return OpenGraphData.fromJson(decodedValue).copyWith(imageUrl: image); } } } diff --git a/lib/models/open_graph_data.dart b/lib/models/open_graph_data.dart index 4076edd..d7e840d 100644 --- a/lib/models/open_graph_data.dart +++ b/lib/models/open_graph_data.dart @@ -7,11 +7,11 @@ abstract class OpenGraphData with _$OpenGraphData { const factory OpenGraphData({ @JsonKey(name: "og:title") required String? title, @JsonKey(name: "og:description") required String? description, - @JsonKey(name: "og:image") required String? imageUrl, + @JsonKey(name: "og:image") required Uri? imageUrl, @JsonKey(name: "og:image:width") required double? width, @JsonKey(name: "og:image:height") required double? height, }) = _OpenGraphData; - factory OpenGraphData.fromJson(Map json) => + factory OpenGraphData.fromJson(Map json) => _$OpenGraphDataFromJson(json); } diff --git a/lib/widgets/chat_page/html/html.dart b/lib/widgets/chat_page/html/html.dart index fb533ad..2f93264 100644 --- a/lib/widgets/chat_page/html/html.dart +++ b/lib/widgets/chat_page/html/html.dart @@ -23,6 +23,7 @@ class Html extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) => HtmlWidget( html, + buildAsync: false, textStyle: textStyle, customWidgetBuilder: (element) { if (element.attributes.keys.contains("data-mx-profile-fallback")) { diff --git a/lib/widgets/chat_page/render_event.dart b/lib/widgets/chat_page/render_event.dart index 957f6d7..99c9f04 100644 --- a/lib/widgets/chat_page/render_event.dart +++ b/lib/widgets/chat_page/render_event.dart @@ -1,8 +1,10 @@ +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/helpers/extensions/get_headers.dart"; @@ -59,232 +61,239 @@ class RenderEvent extends ConsumerWidget { children: getEventOptions!(event).toList(), ); - return GestureDetector( - onSecondaryTapUp: contextMenuCallback, - onLongPressStart: contextMenuCallback, - child: switch (event.content) { - MessageContent() => Row( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - isGrouped || textOnly - ? SizedBox(width: 40) - : MessageAvatar(event, height: 40), - Expanded( - child: Column( - spacing: 4, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!isGrouped && !textOnly) - Row( - spacing: 4, - children: [ - MessageDisplayname( + final child = switch (event.content) { + MessageContent() => Row( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 8, + children: [ + if (!textOnly) + if (isGrouped) + SizedBox(width: 40) + else + MessageAvatar(event, height: 40), + Expanded( + child: Column( + spacing: 4, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isGrouped && !textOnly) + Row( + spacing: 4, + children: [ + Flexible( + child: MessageDisplayname( event, style: TextStyle(fontWeight: FontWeight.bold), ), - Flexible(child: timestamp), - ], - ), - ClipRRect( - borderRadius: BorderRadius.all(Radius.circular(8)), - child: Container( - padding: EdgeInsets.symmetric( - vertical: 8, - horizontal: 12, ), - decoration: BoxDecoration( - color: - ref.watch( - ClientStateController.provider.select( - (value) => value?.userId, - ), - ) == - event.sender - ? (event.eventId.startsWith("~") - ? colorScheme.onPrimary - : colorScheme.primaryContainer) - : colorScheme.surfaceContainer, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Quoted( // TODO: Show replies - // EventText(replyEvent textOnly: true, maxLines: 1,) - // ), - switch (event.content) { - Content(:final parseError?) => SelectableText( - "An error occurred while parsing this message:\n$parseError", - style: errorStyle, - ), - TextMessageContent( - :final body, - :final formattedBody, - :final format, - ) || - ImageMessageContent( - :final body, - :final formattedBody, - :final format, - ) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - format == "org.matrix.custom.html" && !textOnly - ? Html( - textStyle: - event.localContent?.bigEmoji == true - ? TextStyle(fontSize: 32) - : null, - formattedBody!.replaceAllMapped( - RegExp( - "(]*>.*?<\\/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"; - }, - ), - ) - : Linkify( - text: body, - maxLines: maxLines, - options: LinkifyOptions( - humanize: false, - ), - onOpen: (link) => ref - .watch(LaunchHelper.provider) - .launchUrl(Uri.parse(link.url)), - linkStyle: TextStyle( - color: Theme.of( - context, - ).colorScheme.primary, - ), - ), - if (event.content case ImageMessageContent( - :final url, - :final info, - )) - switch (url?.mxcToHttps( - ref.watch( + Flexible(child: timestamp), + ], + ), + ClipRRect( + borderRadius: textOnly + ? BorderRadius.zero + : BorderRadius.all(Radius.circular(8)), + child: Container( + padding: textOnly + ? EdgeInsets.zero + : EdgeInsets.symmetric(vertical: 8, horizontal: 12), + decoration: textOnly + ? null + : BoxDecoration( + color: + ref.watch( ClientStateController.provider.select( - (value) => value!.homeserverUrl!, + (value) => value?.userId, + ), + ) == + event.sender + ? (event.eventId.startsWith("~") + ? colorScheme.onPrimary + : colorScheme.primaryContainer) + : colorScheme.surfaceContainer, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quoted( // TODO: Show replies + // EventText(replyEvent textOnly: true, maxLines: 1,) + // ), + switch (event.content) { + Content(:final parseError?) => SelectableText( + "An error occurred while parsing this message:\n$parseError", + style: errorStyle, + ), + TextMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + NoticeMessageContent( + :final body, + :final formattedBody, + :final format, + ) || + ImageMessageContent( + :final body, + :final formattedBody, + :final format, + ) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + format == "org.matrix.custom.html" && !textOnly + ? Html( + textStyle: + event.localContent?.bigEmoji == true + ? TextStyle(fontSize: 32) + : null, + formattedBody!.replaceAllMapped( + RegExp( + "(]*>.*?<\\/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"; + }, + ), + ) + : Linkify( + text: body, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + options: LinkifyOptions(humanize: false), + onOpen: (link) => ref + .watch(LaunchHelper.provider) + .launchUrl(Uri.parse(link.url)), + linkStyle: TextStyle( + color: Theme.of( + context, + ).colorScheme.primary, ), ), - )) { - final url? => ExpandableImage( - url.toString(), - child: ClipRRect( - borderRadius: BorderRadius.all( - Radius.circular(8), - ), - child: Image( - image: CachedNetworkImage( - url.toString(), - ref.watch( - CrossCacheController.provider, - ), - headers: ref.headers, + if (event.content case ImageMessageContent( + :final url, + :final info, + )) + switch (url?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + )) { + final url? => ExpandableImage( + url.toString(), + child: ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + child: Image( + image: CachedNetworkImage( + url.toString(), + ref.watch( + CrossCacheController.provider, ), - width: info?.width, - height: info?.height, - loadingBuilder: - (_, child, loadingProgress) => - loadingProgress == null - ? child - : switch (info?.blurHash) { - final blurHash? => - SizedBox( - width: - info?.width ?? - info?.height ?? - 200, - height: - info?.height ?? - info?.width ?? - 200, - child: BlurHash( - hash: blurHash, - ), - ), - _ => Loading(), - }, - errorBuilder: - (context, error, stackTrace) => - Center( - child: Text( - "Image Failed to Load", - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.error, + headers: ref.headers, + ), + width: info?.width, + height: info?.height, + loadingBuilder: + (_, child, loadingProgress) => + loadingProgress == null + ? child + : switch (info?.blurHash) { + final blurHash? => SizedBox( + width: + info?.width ?? + info?.height ?? + 200, + height: + info?.height ?? + info?.width ?? + 200, + child: BlurHash( + hash: blurHash, ), ), + _ => Loading(), + }, + errorBuilder: + (context, error, stackTrace) => + Center( + child: Text( + "Image Failed to Load", + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.error, + ), ), - ), + ), ), ), - _ => Text( - "Nexus currently cannot handle encrypted media", - style: errorStyle, - ), - }, - if (event.lastEditRowId != null) - Text( - "(edited)", - style: theme.textTheme.labelSmall, ), - if (RegExp( - r'''https?://[^\s"'<>]+''', - ).allMatches(body).firstOrNull?.group(0) - case final link?) - LinkPreview(link), - ], - ), - _ => - textOnly - ? Text( - "Unknown message type", - style: errorStyle, - ) - : SizedBox.shrink(), - }, - ], - ), + _ => Text( + "Nexus currently cannot handle encrypted media", + style: errorStyle, + ), + }, + if (event.lastEditRowId != null && !textOnly) + Text( + "(edited)", + style: theme.textTheme.labelSmall, + ), + if (linkify(body).firstWhereOrNull( + (element) => element is UrlElement, + ) + case final UrlElement link?) + LinkPreview(link.url), + ], + ), + _ => Text("Unknown message type", style: errorStyle), + }, + ], ), ), - ], - ), + ), + ], ), - ], - ), - AvatarContent() => Row( - spacing: 4, - children: [ - SizedBox(width: 4), - Icon(Icons.numbers), - MessageDisplayname( - event, - style: TextStyle( - color: theme.colorScheme.primary, - fontWeight: FontWeight.bold, - ), + ), + ], + ), + AvatarContent() => Row( + spacing: 4, + children: [ + SizedBox(width: 4), + Icon(Icons.numbers), + MessageDisplayname( + event, + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, ), - Text("changed the room avatar"), - ], - ), - _ => - textOnly - ? Text("Unknown event type", style: errorStyle) - : SizedBox.shrink(), - }, + ), + Text("changed the room avatar"), + ], + ), + _ => null, + }; + + return GestureDetector( + onSecondaryTapUp: contextMenuCallback, + onLongPressStart: contextMenuCallback, + child: child == null + ? textOnly + ? Text("Unknown event type", style: errorStyle) + : SizedBox.shrink() + : Padding(padding: EdgeInsets.symmetric(vertical: 8), child: child), ); } } diff --git a/lib/widgets/chat_page/room_chat.dart b/lib/widgets/chat_page/room_chat.dart index 5dfe52d..a9383f9 100644 --- a/lib/widgets/chat_page/room_chat.dart +++ b/lib/widgets/chat_page/room_chat.dart @@ -325,28 +325,25 @@ class RoomChat extends HookConsumerWidget { SuperSliverList.builder( listController: listController.value, itemCount: value.length, - itemBuilder: (_, index) => Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: EventWrapper( + itemBuilder: (_, index) => EventWrapper( + value[index], + RenderEvent( value[index], - RenderEvent( - value[index], - onTapReply: () => - listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => - Duration(milliseconds: 250), - curve: (_) => Curves.easeInOut, - ), - getEventOptions: getEventOptions, - // TODO: Reimplement grouping - isGrouped: false, - ), - // TODO: Reimplement flashing - isFlashing: false, + onTapReply: () => + listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => + Duration(milliseconds: 250), + curve: (_) => Curves.easeInOut, + ), + getEventOptions: getEventOptions, + // TODO: Reimplement grouping + isGrouped: false, ), + // TODO: Reimplement flashing + isFlashing: false, ), ), ], diff --git a/lib/widgets/link_preview.dart b/lib/widgets/link_preview.dart index 1186097..0089762 100644 --- a/lib/widgets/link_preview.dart +++ b/lib/widgets/link_preview.dart @@ -17,44 +17,48 @@ class LinkPreview extends ConsumerWidget { .betterWhen( data: (preview) => preview == null ? SizedBox.shrink() - : InkWell( - onTap: () => - ref.watch(LaunchHelper.provider).launchUrl(Uri.parse(link)), - child: Card( - child: Column( - children: [ - if (preview.title != null) - Text( - preview.title!, - style: Theme.of(context).textTheme.labelLarge, - ), - if (preview.description != null) - Text(preview.description!), - if (preview.imageUrl != null) - Image( - errorBuilder: (_, _, _) => SizedBox.shrink(), - width: preview.width, - height: preview.height, - image: CachedNetworkImage( - preview.imageUrl!, - ref.watch(CrossCacheController.provider), - headers: ref.headers, - ), - fit: BoxFit.cover, - ), - ], + : ConstrainedBox( + constraints: BoxConstraints.loose(Size.fromWidth(400)), + child: InkWell( + onTap: () => ref + .watch(LaunchHelper.provider) + .launchUrl(Uri.parse(link)), + child: Card( + child: Padding( + padding: EdgeInsetsGeometry.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + if (preview.title != null) + Text( + preview.title!, + style: Theme.of(context).textTheme.titleLarge, + ), + if (preview.description != null) ...[ + Text(preview.description!), + SizedBox(height: 4), + ], + if (preview.imageUrl != null) + ClipRRect( + borderRadius: BorderRadius.all( + Radius.circular(8), + ), + child: Image( + errorBuilder: (_, _, _) => SizedBox.shrink(), + width: preview.width, + image: CachedNetworkImage( + preview.imageUrl.toString(), + ref.watch(CrossCacheController.provider), + headers: ref.headers, + ), + fit: BoxFit.fitWidth, + ), + ), + ], + ), + ), ), - // text: link, - // backgroundColor: isSentByMe - // ? colorScheme.inversePrimary - // : colorScheme.surfaceContainerLow, - // outsidePadding: EdgeInsets.only(top: 4), - // insidePadding: EdgeInsets.symmetric( - // vertical: 8, - // horizontal: 16, - // ), - // linkPreviewData: preview, - // onLinkPreviewDataFetched: (_) => null, ), ), ); diff --git a/pubspec.lock b/pubspec.lock index 9ccfe8d..01a7833 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -720,12 +720,13 @@ packages: source: hosted version: "3.0.2" linkify: - dependency: transitive + dependency: "direct overridden" description: - name: linkify - sha256: "4139ea77f4651ab9c315b577da2dd108d9aa0bd84b5d03d33323f1970c645832" - url: "https://pub.dev" - source: hosted + path: "." + ref: "fix/consecutive-periods-loose-url" + resolved-ref: e990021f30b8535b462d41a39f37019045ae55f4 + url: "https://github.com/appelladev/linkify" + source: git version: "5.0.0" lints: dependency: transitive diff --git a/pubspec.yaml b/pubspec.yaml index d511fe6..dd7f365 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -11,6 +11,12 @@ flutter: environment: sdk: "3.11.4" +dependency_overrides: + linkify: + git: + url: https://github.com/appelladev/linkify + ref: fix/consecutive-periods-loose-url + dependencies: flutter: sdk: flutter