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