From 4e4134bd92860df4c2b9a2eef58f55147f127b69 Mon Sep 17 00:00:00 2001 From: Xare123 <57245242+Xare123@users.noreply.github.com> Date: Sat, 15 Aug 2026 17:23:04 -0700 Subject: [PATCH] Preserve complete URLs and support multiple link previews --- .../interactive/interactive_holder.dart | 1 + .../interactive/url_preview.legacy.dart | 24 +++- lib/helpers/network/metadata_helper.dart | 34 +++--- lib/helpers/types/constants.dart | 4 +- lib/helpers/ui/message_widget_helpers.dart | 108 ++++++++++-------- test/helpers/message_widget_helpers_test.dart | 74 ++++++++++++ 6 files changed, 174 insertions(+), 71 deletions(-) create mode 100644 test/helpers/message_widget_helpers_test.dart diff --git a/lib/app/layouts/conversation_view/widgets/message/interactive/interactive_holder.dart b/lib/app/layouts/conversation_view/widgets/message/interactive/interactive_holder.dart index 920e0806a5..ef06c4961b 100644 --- a/lib/app/layouts/conversation_view/widgets/message/interactive/interactive_holder.dart +++ b/lib/app/layouts/conversation_view/widgets/message/interactive/interactive_holder.dart @@ -202,6 +202,7 @@ class _InteractiveHolderState extends CustomState with AutomaticKeepAliveClientMixin { Message get message => widget.message; - late Metadata? metadata = MetadataHelper.mapIsNotEmpty(message.metadata) ? Metadata.fromJson(message.metadata!) : null; + String? get effectiveUrl => widget.previewUrl ?? message.url ?? message.text; + + // message.metadata stores one preview, so only the primary URL may use or + // update it. Other links keep their metadata local to their preview widget. + bool get isPrimaryUrl => widget.previewUrl == null || widget.previewUrl == message.url; + + late Metadata? metadata = isPrimaryUrl && MetadataHelper.mapIsNotEmpty(message.metadata) + ? Metadata.fromJson(message.metadata!) + : null; @override void initState() { @@ -33,13 +43,13 @@ class _LegacyUrlPreviewState extends OptimizedState with Autom updateObx(() async { if (metadata == null) { try { - metadata = await MetadataHelper.fetchMetadata(message); + metadata = await MetadataHelper.fetchMetadata(message, previewUrl: widget.previewUrl); } catch (ex, stack) { Logger.error("Failed to fetch metadata!", error: ex, trace: stack); return; } // If the data isn't empty, save/update it in the DB - if (MetadataHelper.isNotEmpty(metadata)) { + if (isPrimaryUrl && MetadataHelper.isNotEmpty(metadata)) { message.updateMetadata(metadata); } setState(() {}); @@ -53,12 +63,14 @@ class _LegacyUrlPreviewState extends OptimizedState with Autom @override Widget build(BuildContext context) { super.build(context); - final siteText = Uri.tryParse(metadata?.url ?? message.text ?? "")?.host; + final siteText = Uri.tryParse(metadata?.url ?? effectiveUrl ?? "")?.host; return InkWell( onTap: () async { - if ((metadata?.url ?? message.text) != null) { + final target = metadata?.url ?? effectiveUrl; + final parsed = target == null ? null : Uri.tryParse(target); + if (parsed != null) { await launchUrl( - Uri.parse((metadata?.url ?? message.text)!), + parsed, mode: LaunchMode.externalApplication, ); } diff --git a/lib/helpers/network/metadata_helper.dart b/lib/helpers/network/metadata_helper.dart index d9e895d9ce..cd261ddde1 100644 --- a/lib/helpers/network/metadata_helper.dart +++ b/lib/helpers/network/metadata_helper.dart @@ -21,22 +21,24 @@ class MetadataHelper { static final Map> _metaCache = {}; - static Future fetchMetadata(Message message) async { + static Future fetchMetadata(Message message, {String? previewUrl}) async { Metadata? data; - // If we have a cached item for this already, return that future - if (_metaCache.containsKey(message.guid)) { - return _metaCache[message.guid]!.future; - } - - // Create a new completer for this request - Completer completer = Completer(); - _metaCache[message.guid!] = completer; // Get the URL - String url = message.url!; + String url = previewUrl ?? message.url!; if (!url.startsWith("http")) { url = "https://$url"; } + + // A message may contain multiple links, so each URL needs its own cache + // entry and in-flight request. + final cacheKey = "${message.guid}|$url"; + if (_metaCache.containsKey(cacheKey)) { + return _metaCache[cacheKey]!.future; + } + + Completer completer = Completer(); + _metaCache[cacheKey] = completer; try { data = await MetadataFetch.extract(url); } catch (ex, stack) { @@ -49,9 +51,11 @@ class MetadataHelper { } // If the URL is supposedly to an actual image, set the image to the URL manually - RegExp exp = RegExp(r"(.png|.jpg|.gif|.tiff|.jpeg)$"); - if (data?.image == null && data?.title == null && data!.url != null && exp.hasMatch(data.url!)) { - data.image = data.url; + final urlPath = Uri.tryParse(url)?.path ?? url; + final imageExtension = RegExp(r"\.(png|jpe?g|gif|tiff?|webp|heic|bmp)$", caseSensitive: false); + if (data?.image == null && data?.title == null && imageExtension.hasMatch(urlPath)) { + data ??= Metadata(); + data.image = url; data.title = "Image Preview"; } @@ -75,9 +79,7 @@ class MetadataHelper { // Delete from the cache after 15 seconds (arbitrary) Future.delayed(const Duration(seconds: 15), () { - if (_metaCache.containsKey(message.guid)) { - _metaCache.remove(message.guid); - } + _metaCache.remove(cacheKey); }); // Tell everyone that it's complete diff --git a/lib/helpers/types/constants.dart b/lib/helpers/types/constants.dart index 6e374265ba..f7c8d615d5 100644 --- a/lib/helpers/types/constants.dart +++ b/lib/helpers/types/constants.dart @@ -140,7 +140,9 @@ enum PlayerStatus { NONE, STOPPED, PAUSED, PLAYING, ENDED } enum PayloadType {url, app} final urlRegex = RegExp( - r"(?:^| )(((((H|h)(T|t)|(F|f))(T|t)(P|p)((S|s)?))\://)|www.)[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,6}(\:[0-9]{1,5})*(/($|[a-zA-Z0-9\.\,\;\?\'\\\+&%\$#@!^*()\=~_\/-]+))*"); + r"(?:(?:https?|ftp)://|www\.)[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,63}([-a-zA-Z0-9/()@:%_.+~#?&=*\[\]!$';,]*)\b", + caseSensitive: false, +); final RegExp emojiRegex = RegExp(r"[#*0-9]\uFE0F?\u20E3|[\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23ED-\u23EF\u23F1\u23F2\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB\u25FC\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692\u2694-\u2697\u2699\u269B\u269C\u26A0\u26A7\u26AA\u26B0\u26B1\u26BD\u26BE\u26C4\u26C8\u26CF\u26D1\u26E9\u26F0-\u26F5\u26F7\u26F8\u26FA\u2702\u2708\u2709\u270F\u2712\u2714\u2716\u271D\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u27A1\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B55\u3030\u303D\u3297\u3299]\uFE0F?|[\u261D\u270C\u270D](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\u270A\u270B](?:\uD83C[\uDFFB-\uDFFF])?|[\u23E9-\u23EC\u23F0\u23F3\u25FD\u2693\u26A1\u26AB\u26C5\u26CE\u26D4\u26EA\u26FD\u2705\u2728\u274C\u274E\u2753-\u2755\u2795-\u2797\u27B0\u27BF\u2B50]|\u26D3\uFE0F?(?:\u200D\uD83D\uDCA5)?|\u26F9(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\u2764\uFE0F?(?:\u200D(?:\uD83D\uDD25|\uD83E\uDE79))?|\uD83C(?:[\uDC04\uDD70\uDD71\uDD7E\uDD7F\uDE02\uDE37\uDF21\uDF24-\uDF2C\uDF36\uDF7D\uDF96\uDF97\uDF99-\uDF9B\uDF9E\uDF9F\uDFCD\uDFCE\uDFD4-\uDFDF\uDFF5\uDFF7]\uFE0F?|[\uDF85\uDFC2\uDFC7](?:\uD83C[\uDFFB-\uDFFF])?|[\uDFC4\uDFCA](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDFCB\uDFCC](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDCCF\uDD8E\uDD91-\uDD9A\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF43\uDF45-\uDF4A\uDF4C-\uDF7C\uDF7E-\uDF84\uDF86-\uDF93\uDFA0-\uDFC1\uDFC5\uDFC6\uDFC8\uDFC9\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF8-\uDFFF]|\uDDE6\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF]|\uDDE7\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF]|\uDDE8\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF]|\uDDE9\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF]|\uDDEA\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA]|\uDDEB\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7]|\uDDEC\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE]|\uDDED\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA]|\uDDEE\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9]|\uDDEF\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5]|\uDDF0\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF]|\uDDF1\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE]|\uDDF2\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF]|\uDDF3\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF]|\uDDF4\uD83C\uDDF2|\uDDF5\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE]|\uDDF6\uD83C\uDDE6|\uDDF7\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC]|\uDDF8\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF]|\uDDF9\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF]|\uDDFA\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF]|\uDDFB\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA]|\uDDFC\uD83C[\uDDEB\uDDF8]|\uDDFD\uD83C\uDDF0|\uDDFE\uD83C[\uDDEA\uDDF9]|\uDDFF\uD83C[\uDDE6\uDDF2\uDDFC]|\uDF44(?:\u200D\uD83D\uDFEB)?|\uDF4B(?:\u200D\uD83D\uDFE9)?|\uDFC3(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?|\uDFF3\uFE0F?(?:\u200D(?:\u26A7\uFE0F?|\uD83C\uDF08))?|\uDFF4(?:\u200D\u2620\uFE0F?|\uDB40\uDC67\uDB40\uDC62\uDB40(?:\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDC73\uDB40\uDC63\uDB40\uDC74|\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F)?)|\uD83D(?:[\uDC3F\uDCFD\uDD49\uDD4A\uDD6F\uDD70\uDD73\uDD76-\uDD79\uDD87\uDD8A-\uDD8D\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA\uDECB\uDECD-\uDECF\uDEE0-\uDEE5\uDEE9\uDEF0\uDEF3]\uFE0F?|[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDC8F\uDC91\uDCAA\uDD7A\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC](?:\uD83C[\uDFFB-\uDFFF])?|[\uDC6E\uDC70\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4\uDEB5](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD74\uDD90](?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?|[\uDC00-\uDC07\uDC09-\uDC14\uDC16-\uDC25\uDC27-\uDC3A\uDC3C-\uDC3E\uDC40\uDC44\uDC45\uDC51-\uDC65\uDC6A\uDC79-\uDC7B\uDC7D-\uDC80\uDC84\uDC88-\uDC8E\uDC90\uDC92-\uDCA9\uDCAB-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDDA4\uDDFB-\uDE2D\uDE2F-\uDE34\uDE37-\uDE41\uDE43\uDE44\uDE48-\uDE4A\uDE80-\uDEA2\uDEA4-\uDEB3\uDEB7-\uDEBF\uDEC1-\uDEC5\uDED0-\uDED2\uDED5-\uDED7\uDEDC-\uDEDF\uDEEB\uDEEC\uDEF4-\uDEFC\uDFE0-\uDFEB\uDFF0]|\uDC08(?:\u200D\u2B1B)?|\uDC15(?:\u200D\uD83E\uDDBA)?|\uDC26(?:\u200D(?:\u2B1B|\uD83D\uDD25))?|\uDC3B(?:\u200D\u2744\uFE0F?)?|\uDC41\uFE0F?(?:\u200D\uD83D\uDDE8\uFE0F?)?|\uDC68(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDC68\uDC69]\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?)|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?\uDC68\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D\uDC68\uD83C[\uDFFB-\uDFFE])))?))?|\uDC69(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:\uDC8B\u200D\uD83D)?[\uDC68\uDC69]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D(?:[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?|\uDC69\u200D\uD83D(?:\uDC66(?:\u200D\uD83D\uDC66)?|\uDC67(?:\u200D\uD83D[\uDC66\uDC67])?))|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFC-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFD-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFD\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D\uD83D(?:[\uDC68\uDC69]|\uDC8B\u200D\uD83D[\uDC68\uDC69])\uD83C[\uDFFB-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83D[\uDC68\uDC69]\uD83C[\uDFFB-\uDFFE])))?))?|\uDC6F(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDD75(?:\uFE0F|\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|\uDE2E(?:\u200D\uD83D\uDCA8)?|\uDE35(?:\u200D\uD83D\uDCAB)?|\uDE36(?:\u200D\uD83C\uDF2B\uFE0F?)?|\uDE42(?:\u200D[\u2194\u2195]\uFE0F?)?|\uDEB6(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?)|\uD83E(?:[\uDD0C\uDD0F\uDD18-\uDD1F\uDD30-\uDD34\uDD36\uDD77\uDDB5\uDDB6\uDDBB\uDDD2\uDDD3\uDDD5\uDEC3-\uDEC5\uDEF0\uDEF2-\uDEF8](?:\uD83C[\uDFFB-\uDFFF])?|[\uDD26\uDD35\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD\uDDCF\uDDD4\uDDD6-\uDDDD](?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDDDE\uDDDF](?:\u200D[\u2640\u2642]\uFE0F?)?|[\uDD0D\uDD0E\uDD10-\uDD17\uDD20-\uDD25\uDD27-\uDD2F\uDD3A\uDD3F-\uDD45\uDD47-\uDD76\uDD78-\uDDB4\uDDB7\uDDBA\uDDBC-\uDDCC\uDDD0\uDDE0-\uDDFF\uDE70-\uDE7C\uDE80-\uDE88\uDE90-\uDEBD\uDEBF-\uDEC2\uDECE-\uDEDB\uDEE0-\uDEE8]|\uDD3C(?:\u200D[\u2640\u2642]\uFE0F?|\uD83C[\uDFFB-\uDFFF])?|\uDDCE(?:\uD83C[\uDFFB-\uDFFF])?(?:\u200D(?:[\u2640\u2642]\uFE0F?(?:\u200D\u27A1\uFE0F?)?|\u27A1\uFE0F?))?|\uDDD1(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1|\uDDD1\u200D\uD83E\uDDD2(?:\u200D\uD83E\uDDD2)?|\uDDD2(?:\u200D\uD83E\uDDD2)?))|\uD83C(?:\uDFFB(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFC-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFC(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFD-\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFD(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFE(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFD\uDFFF]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?|\uDFFF(?:\u200D(?:[\u2695\u2696\u2708]\uFE0F?|\u2764\uFE0F?\u200D(?:\uD83D\uDC8B\u200D)?\uD83E\uDDD1\uD83C[\uDFFB-\uDFFE]|\uD83C[\uDF3E\uDF73\uDF7C\uDF84\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E(?:[\uDDAF\uDDBC\uDDBD](?:\u200D\u27A1\uFE0F?)?|[\uDDB0-\uDDB3]|\uDD1D\u200D\uD83E\uDDD1\uD83C[\uDFFB-\uDFFF])))?))?|\uDEF1(?:\uD83C(?:\uDFFB(?:\u200D\uD83E\uDEF2\uD83C[\uDFFC-\uDFFF])?|\uDFFC(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFD-\uDFFF])?|\uDFFD(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])?|\uDFFE(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFD\uDFFF])?|\uDFFF(?:\u200D\uD83E\uDEF2\uD83C[\uDFFB-\uDFFE])?))?)"); diff --git a/lib/helpers/ui/message_widget_helpers.dart b/lib/helpers/ui/message_widget_helpers.dart index 8507ce9aa7..407fc0712e 100644 --- a/lib/helpers/ui/message_widget_helpers.dart +++ b/lib/helpers/ui/message_widget_helpers.dart @@ -13,6 +13,39 @@ import 'package:maps_launcher/maps_launcher.dart'; import 'package:tuple/tuple.dart'; import 'package:url_launcher/url_launcher.dart'; +@visibleForTesting +List messageUrlMatches(String text) => + urlRegex.allMatches(text).toList(growable: false); + +@visibleForTesting +void markEnrichedMessageRange( + List annotations, Tuple3, List?> annotation) { + final range = annotation.item2; + final extras = []; + for (final existing in annotations) { + if (existing.range[0] < range[0] && existing.range[1] > range[0]) { + final duplicate = existing.copy(); + duplicate.range[1] = range[0]; + extras.add(duplicate); + existing.range[0] = range[0]; + } + + if (existing.range[0] < range[1] && existing.range[1] > range[1]) { + final duplicate = existing.copy(); + duplicate.range[0] = range[1]; + extras.add(duplicate); + existing.range[1] = range[1]; + } + } + annotations.addAll(extras); + + for (final existing in annotations) { + if (existing.range[0] >= range[0] && existing.range[1] <= range[1]) { + existing.renderExtras.add(annotation); + } + } +} + List buildMessageSpans(BuildContext context, MessagePart part, Message message, {Color? colorOverride, bool hideBodyText = false}) { final textSpans = []; final textStyle = (context.theme.extensions[BubbleText] as BubbleText).bubbleText.apply( @@ -79,44 +112,16 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message color: colorOverride ?? (message.isFromMe! ? context.theme.colorScheme.onPrimary : context.theme.colorScheme.properOnSurface), fontSizeFactor: message.isBigEmoji ? 3 : 1, ); - // extract rich content - final urlRegex = RegExp(r'((https?://)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}([-a-zA-Z0-9/()@:%_.~#?&=*\[\]]*)\b'); - List annotations = part.annotations.map((a) => a.copy()).toList(); - void markRange(Tuple3, List?> annotation) { - var range = annotation.item2; - List extras = []; - // split em up correctly - for (var a in annotations) { - if (a.range[0] < range[0] && a.range[1] > range[0]) { - var dup = a.copy(); - dup.range[1] = range[0]; - extras.add(dup); - - // end this range at the start of my new range - a.range[0] = range[0]; - } - - if (a.range[0] < range[1] && a.range[1] > range[1]) { - var dup = a.copy(); - dup.range[0] = range[1]; - extras.add(dup); - - // end this range at the start of my new range - a.range[1] = range[1]; - } - } - annotations.addAll(extras); - - for (var a in annotations) { - if (a.range[0] >= range[0] && a.range[1] <= range[1]) { - a.renderExtras.add(annotation); - } - } - } final controller = cvc(message.chat.target ?? cm.activeChat!.chat); if (!isNullOrEmpty(part.text)) { + // Apply complete URL ranges before ML Kit can shorten them. + for (final match in messageUrlMatches(part.text!)) { + markEnrichedMessageRange( + annotations, Tuple3("link", [match.start, match.end], null)); + } + if (!kIsWeb && !kIsDesktop && ss.settings.smartReply.value) { if (controller.mlKitParsedText["${message.guid!}-${part.part}"] == null) { try { @@ -129,32 +134,39 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message final entities = controller.mlKitParsedText["${message.guid!}-${part.part}"] ?? []; for (EntityAnnotation element in entities) { if (element.entities.first is AddressEntity) { - markRange(Tuple3("map", [element.start, element.end], null)); + markEnrichedMessageRange( + annotations, Tuple3("map", [element.start, element.end], null)); } else if (element.entities.first is PhoneEntity) { - markRange(Tuple3("phone", [element.start, element.end], null)); + markEnrichedMessageRange( + annotations, Tuple3("phone", [element.start, element.end], null)); } else if (element.entities.first is EmailEntity) { - markRange(Tuple3("email", [element.start, element.end], null)); + markEnrichedMessageRange( + annotations, Tuple3("email", [element.start, element.end], null)); } else if (element.entities.first is UrlEntity) { - markRange(Tuple3("link", [element.start, element.end], null)); + markEnrichedMessageRange( + annotations, Tuple3("link", [element.start, element.end], null)); } else if (element.entities.first is DateTimeEntity) { final ent = (element.entities.first as DateTimeEntity); if (part.text?.substring(element.start, element.end).toLowerCase() == "now") { continue; } - markRange(Tuple3("date", [element.start, element.end], [ent.timestamp])); + markEnrichedMessageRange( + annotations, + Tuple3("date", [element.start, element.end], [ent.timestamp])); } else if (element.entities.first is TrackingNumberEntity) { final ent = (element.entities.first as TrackingNumberEntity); - markRange(Tuple3("tracking", [element.start, element.end], [ent.carrier, ent.number])); + markEnrichedMessageRange( + annotations, + Tuple3("tracking", [element.start, element.end], + [ent.carrier, ent.number])); } else if (element.entities.first is FlightNumberEntity) { final ent = (element.entities.first as FlightNumberEntity); - markRange(Tuple3("flight", [element.start, element.end], [ent.airlineCode, ent.flightNumber])); + markEnrichedMessageRange( + annotations, + Tuple3("flight", [element.start, element.end], + [ent.airlineCode, ent.flightNumber])); } } - } else { - List matches = urlRegex.allMatches(part.text!).toList(); - for (RegExpMatch match in matches) { - markRange(Tuple3("link", [match.start, match.end], null)); - } } } @@ -214,7 +226,7 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message ..onTap = () async { if (type == "link") { String url = text; - if (!url.startsWith("http://") && !url.startsWith("https://")) { + if (Uri.tryParse(url)?.hasScheme != true) { url = "http://$url"; } await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); @@ -255,4 +267,4 @@ Future> buildEnrichedMessageSpans(BuildContext context, Message } return textSpans; -} \ No newline at end of file +} diff --git a/test/helpers/message_widget_helpers_test.dart b/test/helpers/message_widget_helpers_test.dart new file mode 100644 index 0000000000..e307270688 --- /dev/null +++ b/test/helpers/message_widget_helpers_test.dart @@ -0,0 +1,74 @@ +import 'package:bluebubbles/database/global/message_part.dart'; +import 'package:bluebubbles/helpers/ui/message_widget_helpers.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tuple/tuple.dart'; + +void main() { + group('messageUrlMatches', () { + test('finds separate same-host URLs with different paths', () { + const first = 'https://example.com/one'; + const second = 'https://example.com/two'; + + final matches = messageUrlMatches('$first and $second'); + + expect(matches.map((match) => match.group(0)), [first, second]); + }); + + test( + 'keeps query, plus, percent, fragment, and parentheses characters', + () { + const url = + r'https://example.com/path/(part)?q=alpha+beta%2Bgamma#section-1'; + + final matches = messageUrlMatches('Open $url please'); + + expect(matches, hasLength(1)); + expect(matches.single.group(0), url); + }, + ); + + test('does not include sentence punctuation after a URL', () { + const url = 'https://example.com/path?value=one+two'; + + final matches = messageUrlMatches('Read $url. Then continue.'); + + expect(matches, hasLength(1)); + expect(matches.single.group(0), url); + }); + + test('accepts modern top-level domains longer than six characters', () { + const url = 'https://example.technology/path'; + + final matches = messageUrlMatches(url); + + expect(matches, hasLength(1)); + expect(matches.single.group(0), url); + }); + }); + + test('preserves the complete URL when another annotation ends at a plus', () { + const url = 'https://example.com/path?query=alpha+beta&next=1'; + final plusIndex = url.indexOf('+'); + final annotations = [ + Annotation(range: [0, url.length]), + ]; + + markEnrichedMessageRange( + annotations, + const Tuple3('link', [0, url.length], null), + ); + markEnrichedMessageRange(annotations, Tuple3('link', [0, plusIndex], null)); + annotations.sort((a, b) => a.range[0].compareTo(b.range[0])); + + expect(annotations.map((annotation) => annotation.range), [ + [0, plusIndex], + [plusIndex, url.length], + ]); + expect( + annotations.every( + (annotation) => annotation.renderExtras.first.item1 == 'link', + ), + isTrue, + ); + }); +}