new linkify handling

Should fix mentions in non-HTML messages
This commit is contained in:
Henry Hiles 2026-11-01 16:42:23 -05:00
commit 40f5d0f459
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
7 changed files with 123 additions and 42 deletions

View file

@ -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 `<a>` 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();
}
}

View file

@ -0,0 +1,38 @@
import "package:linkify/linkify.dart";
/// Splits [text] into plain text and URL, `matrix:` and email links.
List<LinkifyElement> 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<LinkifyElement> parse(
List<LinkifyElement> elements,
LinkifyOptions options,
) => elements
.expand(
(element) => element is TextElement ? _split(element.text) : [element],
)
.toList();
Iterable<LinkifyElement> _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));
}
}

View file

@ -1,26 +1,46 @@
import "package:flutter/gestures.dart";
import "package:material_ui/material_ui.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: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/launch_helper.dart";
import "package:nexus/helpers/matrix_linkifier.dart";
import "package:nexus/widgets/html/mention_chip.dart";
final class const LinkifiedText( final class const LinkifiedText(
final String text, { final String text, {
final String? roomId,
final int? maxLines, final int? maxLines,
final TextStyle? style, final TextStyle? style,
super.key, super.key,
}) extends ConsumerWidget { }) extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => Widget build(BuildContext context, WidgetRef ref) => Text.rich(
MaterialUiCompatibilityBridge( TextSpan(
child: Linkify(
text: text,
maxLines: maxLines,
style: style, style: style,
options: .new(humanize: false), children: linkifyText(text)
onOpen: (link) => .map<InlineSpan>(
ref.read(LaunchHelper.provider).launchUrl(.parse(link.url)), (element) => switch (element) {
linkStyle: .new(color: Theme.of(context).colorScheme.primary), LinkableElement(:final url) when url.linkInfo != null =>
overflow: maxLines == null ? null : .ellipsis, 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,
); );
} }

View file

@ -5,6 +5,7 @@ import "package:linkify/linkify.dart";
import "package:nexus/controllers/client_state.dart"; import "package:nexus/controllers/client_state.dart";
import "package:nexus/controllers/event.dart"; import "package:nexus/controllers/event.dart";
import "package:nexus/helpers/extensions/get_filename.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/encrypted.dart";
import "package:nexus/models/content/message.dart"; import "package:nexus/models/content/message.dart";
import "package:nexus/models/content/sticker.dart"; import "package:nexus/models/content/sticker.dart";
@ -77,26 +78,11 @@ class const MessageRenderer(
? Html( ? Html(
roomId: event.roomId, roomId: event.roomId,
textStyle: textStyle, textStyle: textStyle,
formattedBody.replaceAllMapped( formattedBody.linkifiedHtml,
RegExp(
r"(<a\b[^>]*>.*?<\/a>)|(\bhttps?:\/\/[^\s<]+)",
caseSensitive: false,
dotAll: true,
),
(m) {
// If it's already an <a> tag, leave it unchanged
if (m.group(1) != null) {
return m.group(1)!;
}
// Otherwise, wrap the bare URL
final url = m.group(2)!;
return "<a href=\"$url\">$url</a>";
},
),
) )
: LinkifiedText( : LinkifiedText(
body.isEmpty ? content.filename ?? "" : body, body.isEmpty ? content.filename ?? "" : body,
roomId: event.roomId,
style: textStyle, style: textStyle,
maxLines: maxLines, maxLines: maxLines,
), ),

View file

@ -174,7 +174,7 @@ class const RoomSummarySheet(
if (summary.topic != null) if (summary.topic != null)
ListTile( ListTile(
title: Text("Topic"), title: Text("Topic"),
subtitle: LinkifiedText(summary.topic!), subtitle: LinkifiedText(summary.topic!, roomId: summary.roomId),
leading: Icon(Icons.info), leading: Icon(Icons.info),
), ),
if (isKnocking) if (isKnocking)

View file

@ -503,14 +503,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.14.4" 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: flutter_lints:
dependency: "direct dev" dependency: "direct dev"
description: description:
@ -672,7 +664,7 @@ packages:
source: hosted source: hosted
version: "3.4.3" version: "3.4.3"
html: html:
dependency: transitive dependency: "direct main"
description: description:
name: html name: html
sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699" sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"

View file

@ -89,8 +89,8 @@ dependencies:
ffigen: 23.0.0 ffigen: 23.0.0
timeago: 3.7.1 timeago: 3.7.1
http: 1.6.0 http: 1.6.0
flutter_linkify: 6.0.0
linkify: 5.0.0 linkify: 5.0.0
html: 0.15.7
flutter_blurhash: 0.9.1 flutter_blurhash: 0.9.1
super_sliver_list: 0.4.1 super_sliver_list: 0.4.1
media_kit: 1.2.6 media_kit: 1.2.6