Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -112,5 +112,12 @@
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
<!-- Required by url_launcher to open http(s) links on Android 11+, see:
https://pub.dev/packages/url_launcher#configuration -->
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
</manifest>
1 change: 1 addition & 0 deletions lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@

"linkCopied": "Link kopiert",
"copied": "Kopiert",
"couldNotOpenLink": "Link konnte nicht geöffnet werden",

"debugLogsTitleWithCount": "Debug-Protokolle ({count})",
"@debugLogsTitleWithCount": {
Expand Down
1 change: 1 addition & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@

"linkCopied": "Link copied",
"copied": "Copied",
"couldNotOpenLink": "Could not open link",

"debugLogsTitleWithCount": "Debug Logs ({count})",
"@debugLogsTitleWithCount": {
Expand Down
6 changes: 6 additions & 0 deletions lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_localizations_de.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)';
Expand Down
3 changes: 3 additions & 0 deletions lib/l10n/app_localizations_en.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)';
Expand Down
69 changes: 66 additions & 3 deletions lib/widgets/chat_message_text.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -29,8 +33,33 @@ class ChatMessageText extends StatefulWidget {
}

class _ChatMessageTextState extends State<ChatMessageText> {
/// 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
Expand Down Expand Up @@ -74,6 +103,10 @@ class _ChatMessageTextState extends State<ChatMessageText> {
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));
}
}
}
Expand Down Expand Up @@ -106,6 +139,23 @@ class _ChatMessageTextState extends State<ChatMessageText> {
),
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(
Expand All @@ -130,6 +180,19 @@ class _ChatMessageTextState extends State<ChatMessageText> {
return RichText(text: TextSpan(children: spans));
}

Future<void> _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<void> _onHashtagTapped(BuildContext context, String tag) async {
final channelRepository = context.read<ChannelRepository>();
final l10n = AppLocalizations.of(context)!;
Expand Down
2 changes: 2 additions & 0 deletions macos/Flutter/GeneratedPluginRegistrant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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"))
}
48 changes: 40 additions & 8 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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"
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down