diff --git a/lib/helpers/extensions/linkify_html.dart b/lib/helpers/extensions/linkify_html.dart new file mode 100644 index 0000000..f7eec0c --- /dev/null +++ b/lib/helpers/extensions/linkify_html.dart @@ -0,0 +1,45 @@ +import "package:html/dom.dart"; +import "package:html/parser.dart"; +import "package:linkify/linkify.dart"; +import "package:nexus/helpers/matrix_linkifier.dart"; + +extension LinkifyHtml on String { + /// Wraps bare links in this HTML's text in `` tags. + /// + /// Only text nodes are touched, so attribute values are left alone, and + /// text already inside a link or code is skipped. + String get linkifiedHtml { + final fragment = parseFragment(this); + _linkifyNode(fragment); + return fragment.outerHtml; + } +} + +void _linkifyNode(Node node) { + if (node is Element && const {"a", "code", "pre"}.contains(node.localName)) { + return; + } + + for (final child in node.nodes.toList()) { + if (child is! Text) { + _linkifyNode(child); + continue; + } + + final elements = linkifyText(child.data); + if (!elements.any((element) => element is LinkableElement)) continue; + + elements + .map( + (element) => switch (element) { + LinkableElement(:final text, :final url) => + Element.tag("a") + ..attributes["href"] = url + ..append(Text(text)), + _ => Text(element.text), + }, + ) + .forEach((replacement) => node.insertBefore(replacement, child)); + child.remove(); + } +} diff --git a/lib/helpers/matrix_linkifier.dart b/lib/helpers/matrix_linkifier.dart new file mode 100644 index 0000000..addf468 --- /dev/null +++ b/lib/helpers/matrix_linkifier.dart @@ -0,0 +1,38 @@ +import "package:linkify/linkify.dart"; + +/// Splits [text] into plain text and URL, `matrix:` and email links. +List linkifyText(String text) => linkify( + text, + options: const LinkifyOptions(humanize: false), + linkifiers: const [UrlLinkifier(), MatrixLinkifier(), EmailLinkifier()], +); + +/// Linkifies `matrix:` URIs, which [UrlLinkifier] only handles for http(s). +final class const MatrixLinkifier() extends Linkifier { + static final _regex = RegExp( + r"\bmatrix:[^\s<>\x22\x27]*[^\s<>\x22\x27.,;:!?)\]}]", + caseSensitive: false, + ); + + @override + List parse( + List elements, + LinkifyOptions options, + ) => elements + .expand( + (element) => element is TextElement ? _split(element.text) : [element], + ) + .toList(); + + Iterable _split(String text) sync* { + var start = 0; + for (final match in _regex.allMatches(text)) { + if (match.start > start) { + yield TextElement(text.substring(start, match.start)); + } + yield UrlElement(match[0]!); + start = match.end; + } + if (start < text.length) yield TextElement(text.substring(start)); + } +} diff --git a/lib/widgets/linkified_text.dart b/lib/widgets/linkified_text.dart index 1c12d22..50a5ddb 100644 --- a/lib/widgets/linkified_text.dart +++ b/lib/widgets/linkified_text.dart @@ -1,26 +1,46 @@ +import "package:flutter/gestures.dart"; import "package:material_ui/material_ui.dart"; -import "package:flutter_linkify/flutter_linkify.dart"; +import "package:linkify/linkify.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:nexus/helpers/extensions/get_link_info.dart"; import "package:nexus/helpers/launch_helper.dart"; +import "package:nexus/helpers/matrix_linkifier.dart"; +import "package:nexus/widgets/html/mention_chip.dart"; final class const LinkifiedText( final String text, { + final String? roomId, final int? maxLines, final TextStyle? style, super.key, }) extends ConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => - MaterialUiCompatibilityBridge( - child: Linkify( - text: text, - maxLines: maxLines, - style: style, - options: .new(humanize: false), - onOpen: (link) => - ref.read(LaunchHelper.provider).launchUrl(.parse(link.url)), - linkStyle: .new(color: Theme.of(context).colorScheme.primary), - overflow: maxLines == null ? null : .ellipsis, - ), - ); + Widget build(BuildContext context, WidgetRef ref) => Text.rich( + TextSpan( + style: style, + children: linkifyText(text) + .map( + (element) => switch (element) { + LinkableElement(:final url) when url.linkInfo != null => + WidgetSpan( + alignment: .baseline, + baseline: .alphabetic, + child: MentionChip(url, roomId), + ), + LinkableElement(:final text, :final url) => TextSpan( + text: text, + style: .new(color: Theme.of(context).colorScheme.primary), + mouseCursor: SystemMouseCursors.click, + recognizer: TapGestureRecognizer() + ..onTap = () => + ref.read(LaunchHelper.provider).launchUrl(.parse(url)), + ), + _ => TextSpan(text: element.text), + }, + ) + .toList(), + ), + maxLines: maxLines, + overflow: maxLines == null ? null : .ellipsis, + ); } diff --git a/lib/widgets/renderers/message.dart b/lib/widgets/renderers/message.dart index b380577..d0e4f08 100644 --- a/lib/widgets/renderers/message.dart +++ b/lib/widgets/renderers/message.dart @@ -5,6 +5,7 @@ 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/helpers/extensions/linkify_html.dart"; import "package:nexus/models/content/encrypted.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/sticker.dart"; @@ -77,26 +78,11 @@ class const MessageRenderer( ? 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"; - }, - ), + formattedBody.linkifiedHtml, ) : LinkifiedText( body.isEmpty ? content.filename ?? "" : body, + roomId: event.roomId, style: textStyle, maxLines: maxLines, ), diff --git a/lib/widgets/room_summary_sheet.dart b/lib/widgets/room_summary_sheet.dart index dd7bc9b..f1700da 100644 --- a/lib/widgets/room_summary_sheet.dart +++ b/lib/widgets/room_summary_sheet.dart @@ -174,7 +174,7 @@ class const RoomSummarySheet( if (summary.topic != null) ListTile( title: Text("Topic"), - subtitle: LinkifiedText(summary.topic!), + subtitle: LinkifiedText(summary.topic!, roomId: summary.roomId), leading: Icon(Icons.info), ), if (isKnocking) diff --git a/pubspec.lock b/pubspec.lock index 624b328..ef07b85 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -503,14 +503,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.4" - flutter_linkify: - dependency: "direct main" - description: - name: flutter_linkify - sha256: "74669e06a8f358fee4512b4320c0b80e51cffc496607931de68d28f099254073" - url: "https://pub.dev" - source: hosted - version: "6.0.0" flutter_lints: dependency: "direct dev" description: @@ -672,7 +664,7 @@ packages: source: hosted version: "3.4.3" html: - dependency: transitive + dependency: "direct main" description: name: html sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" diff --git a/pubspec.yaml b/pubspec.yaml index 5c54c87..d561ee1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -89,8 +89,8 @@ dependencies: ffigen: 23.0.0 timeago: 3.7.1 http: 1.6.0 - flutter_linkify: 6.0.0 linkify: 5.0.0 + html: 0.15.7 flutter_blurhash: 0.9.1 super_sliver_list: 0.4.1 media_kit: 1.2.6