From d5bba82070cee9b843bb408e4123f61cb959c1d7 Mon Sep 17 00:00:00 2001 From: Eric Poulsen Date: Sat, 18 Jul 2026 16:00:36 -0700 Subject: [PATCH] Issue 52: make URLs in chat messages tappable Extends the existing #hashtag/@mention token parser in ChatMessageText to detect http(s):// and www. URLs, trimming trailing sentence punctuation from the tappable span. Tapping opens the link via url_launcher in the external browser. --- android/app/src/main/AndroidManifest.xml | 7 ++ lib/l10n/app_de.arb | 1 + lib/l10n/app_en.arb | 1 + lib/l10n/app_localizations.dart | 6 ++ lib/l10n/app_localizations_de.dart | 3 + lib/l10n/app_localizations_en.dart | 3 + lib/widgets/chat_message_text.dart | 69 ++++++++++++++++++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 48 ++++++++++--- pubspec.yaml | 1 + 10 files changed, 130 insertions(+), 11 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 920f7ab..06b85eb 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -112,5 +112,12 @@ + + + + + + diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 9393f36..b983396 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -312,6 +312,7 @@ "linkCopied": "Link kopiert", "copied": "Kopiert", + "couldNotOpenLink": "Link konnte nicht geöffnet werden", "debugLogsTitleWithCount": "Debug-Protokolle ({count})", "@debugLogsTitleWithCount": { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index a6a6e11..229cbcd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -312,6 +312,7 @@ "linkCopied": "Link copied", "copied": "Copied", + "couldNotOpenLink": "Could not open link", "debugLogsTitleWithCount": "Debug Logs ({count})", "@debugLogsTitleWithCount": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 855eec9..c9bf7ba 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1442,6 +1442,12 @@ abstract class AppLocalizations { /// **'Copied'** String get copied; + /// No description provided for @couldNotOpenLink. + /// + /// In en, this message translates to: + /// **'Could not open link'** + String get couldNotOpenLink; + /// No description provided for @debugLogsTitleWithCount. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index ed82870..5dd3ddc 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -718,6 +718,9 @@ class AppLocalizationsDe extends AppLocalizations { @override String get copied => 'Kopiert'; + @override + String get couldNotOpenLink => 'Link konnte nicht geöffnet werden'; + @override String debugLogsTitleWithCount(int count) { return 'Debug-Protokolle ($count)'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index efd3b48..0080d22 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -712,6 +712,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get copied => 'Copied'; + @override + String get couldNotOpenLink => 'Could not open link'; + @override String debugLogsTitleWithCount(int count) { return 'Debug Logs ($count)'; diff --git a/lib/widgets/chat_message_text.dart b/lib/widgets/chat_message_text.dart index 004df0b..71087b7 100644 --- a/lib/widgets/chat_message_text.dart +++ b/lib/widgets/chat_message_text.dart @@ -4,15 +4,19 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../l10n/app_localizations.dart'; import '../repositories/channel_repository.dart'; -/// Renders a chat message with tappable [#hashtag] links and styled [@mention]s. +/// Renders a chat message with tappable [#hashtag] links, tappable URLs, and +/// styled [@mention]s. /// /// Tapping a #hashtag presents a confirmation dialog that joins the channel /// whose PSK is derived from the name alone — no QR exchange needed. /// +/// Tapping a URL opens it in the device's default browser. +/// /// @mentions are highlighted in the secondary colour but are not currently /// interactive (the exact mention format may vary by firmware version). class ChatMessageText extends StatefulWidget { @@ -29,8 +33,33 @@ class ChatMessageText extends StatefulWidget { } class _ChatMessageTextState extends State { - /// Matches #hashtag (alphanumeric, underscore, hyphen) and @mention (non-whitespace). - static final _tokenPattern = RegExp(r'(#[a-zA-Z0-9_-]+|@\[[^\]]+\])'); + /// Matches #hashtag (alphanumeric, underscore, hyphen), @mention + /// (non-whitespace), and http(s)/www URLs. + static final _tokenPattern = RegExp( + r'(#[a-zA-Z0-9_-]+|@\[[^\]]+\]|https?://\S+|www\.\S+)', + ); + + /// Trailing characters trimmed off a matched URL so sentence punctuation + /// immediately after a link (e.g. "see https://example.com.") isn't + /// swallowed into the tappable span. + static const _urlTrailingPunctuation = '.,;:!?\'")]}'; + + static bool _isUrl(String token) => + token.startsWith('http://') || + token.startsWith('https://') || + token.startsWith('www.'); + + /// End offset of [match] with any trailing punctuation trimmed off, for + /// URL tokens only (other token types are returned unchanged). + int _effectiveEnd(RegExpMatch match) { + if (!_isUrl(match.group(0)!)) return match.end; + var end = match.end; + while (end > match.start && + _urlTrailingPunctuation.contains(widget.text[end - 1])) { + end--; + } + return end; + } // Recognizers and matches are built once and reused across rebuilds. // Rebuilt only in didUpdateWidget when widget.text changes, which never @@ -74,6 +103,10 @@ class _ChatMessageTextState extends State { if (token.startsWith('#')) { _recognizers.add(TapGestureRecognizer() ..onTap = () => _onHashtagTapped(context, token)); + } else if (_isUrl(token)) { + final url = widget.text.substring(match.start, _effectiveEnd(match)); + _recognizers.add( + TapGestureRecognizer()..onTap = () => _onUrlTapped(context, url)); } } } @@ -106,6 +139,23 @@ class _ChatMessageTextState extends State { ), recognizer: _recognizers[recIdx++], )); + } else if (_isUrl(token)) { + final urlEnd = _effectiveEnd(match); + spans.add(TextSpan( + text: widget.text.substring(match.start, urlEnd), + style: baseStyle?.copyWith( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ), + recognizer: _recognizers[recIdx++], + )); + if (urlEnd < match.end) { + spans.add(TextSpan( + text: widget.text.substring(urlEnd, match.end), + style: baseStyle, + )); + } } else { // @mention — visual highlight only (format TBD by firmware) spans.add(TextSpan( @@ -130,6 +180,19 @@ class _ChatMessageTextState extends State { return RichText(text: TextSpan(children: spans)); } + Future _onUrlTapped(BuildContext context, String url) async { + final uri = Uri.tryParse(url.startsWith('http') ? url : 'https://$url'); + final l10n = AppLocalizations.of(context)!; + if (uri == null || + !await launchUrl(uri, mode: LaunchMode.externalApplication)) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.couldNotOpenLink)), + ); + } + } + } + Future _onHashtagTapped(BuildContext context, String tag) async { final channelRepository = context.read(); final l10n = AppLocalizations.of(context)!; diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index be1ee9a..4f2ea53 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -18,6 +18,7 @@ import share_plus import shared_preferences_foundation import sqflite_darwin import sqlite3_flutter_libs +import url_launcher_macos import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { @@ -34,5 +35,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 9f0aeb7..75cefa0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -737,10 +737,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -753,10 +753,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mgrs_dart: dependency: transitive description: @@ -1270,10 +1270,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" timezone: dependency: transitive description: @@ -1314,6 +1314,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 + url: "https://pub.dev" + source: hosted + version: "6.3.32" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -1322,6 +1346,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" url_launcher_platform_interface: dependency: transitive description: @@ -1459,5 +1491,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0-0 <4.0.0" - flutter: ">=3.27.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1566550..f302a0d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,7 @@ dependencies: http: ^1.2.0 package_info_plus: ^8.0.0 battery_plus: ^6.2.0 + url_launcher: ^6.3.2 # Map tile caching / offline maps cached_network_image: ^3.4.1