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