new linkify handling
Should fix mentions in non-HTML messages
This commit is contained in:
parent
bcb0c57015
commit
40f5d0f459
7 changed files with 123 additions and 42 deletions
45
lib/helpers/extensions/linkify_html.dart
Normal file
45
lib/helpers/extensions/linkify_html.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/helpers/matrix_linkifier.dart
Normal file
38
lib/helpers/matrix_linkifier.dart
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
style: style,
|
||||||
text: text,
|
children: linkifyText(text)
|
||||||
maxLines: maxLines,
|
.map<InlineSpan>(
|
||||||
style: style,
|
(element) => switch (element) {
|
||||||
options: .new(humanize: false),
|
LinkableElement(:final url) when url.linkInfo != null =>
|
||||||
onOpen: (link) =>
|
WidgetSpan(
|
||||||
ref.read(LaunchHelper.provider).launchUrl(.parse(link.url)),
|
alignment: .baseline,
|
||||||
linkStyle: .new(color: Theme.of(context).colorScheme.primary),
|
baseline: .alphabetic,
|
||||||
overflow: maxLines == null ? null : .ellipsis,
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
10
pubspec.lock
10
pubspec.lock
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue