diff --git a/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart b/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart
index 06744e5b..8cf48b7b 100644
--- a/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart
+++ b/app/lib/features/geckoview/features/contextmenu/extensions/hit_result.dart
@@ -36,6 +36,13 @@ extension HitResultJson on HitResult {
}
extension HitResultX on HitResult {
+ String? getLinkText() {
+ return switch (this) {
+ UnknownHitResult(linkText: final linkText) => linkText,
+ _ => null,
+ };
+ }
+
Uri? tryGetLink() {
const maxTitleLength = 2500;
@@ -91,6 +98,8 @@ extension HitResultX on HitResult {
bool get hasLink => tryGetLink() != null;
+ bool get hasLinkText => getLinkText()?.isNotEmpty == true;
+
bool isImage() {
return (this is ImageHitResult || this is ImageSrcHitResult) && hasSrc;
}
@@ -131,7 +140,10 @@ extension HitResultX on HitResult {
HitResult withCleanedLink(String cleanedUrl) {
return switch (this) {
- UnknownHitResult() => UnknownHitResult(src: cleanedUrl),
+ UnknownHitResult(:final linkText) => UnknownHitResult(
+ src: cleanedUrl,
+ linkText: linkText,
+ ),
ImageSrcHitResult(:final src) => ImageSrcHitResult(
src: src,
uri: cleanedUrl,
diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_link_text.dart b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_link_text.dart
new file mode 100644
index 00000000..5d7d4ecb
--- /dev/null
+++ b/app/lib/features/geckoview/features/contextmenu/presentation/candidates/copy_link_text.dart
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2024-2026 Fabian Freund.
+ *
+ * This file is part of WebLibre
+ * (see https://weblibre.eu).
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
+import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
+import 'package:go_router/go_router.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:nullability/nullability.dart';
+import 'package:weblibre/features/geckoview/features/contextmenu/extensions/hit_result.dart';
+
+class CopyLinkText extends HookConsumerWidget {
+ final HitResult hitResult;
+
+ const CopyLinkText({super.key, required this.hitResult});
+
+ static bool isSupported(HitResult hitResult) {
+ return hitResult.isUri() && hitResult.hasLinkText;
+ }
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ return ListTile(
+ leading: const Icon(MdiIcons.textShort),
+ title: const Text('Copy link text'),
+ onTap: () async {
+ await hitResult.getLinkText().mapNotNull((linkText) async {
+ await Clipboard.setData(ClipboardData(text: linkText));
+
+ if (context.mounted) {
+ context.pop();
+ }
+ });
+ },
+ );
+ }
+}
diff --git a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart
index 5d229604..7fdb8e78 100644
--- a/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart
+++ b/app/lib/features/geckoview/features/contextmenu/presentation/context_menu_dialog.dart
@@ -28,6 +28,7 @@ import 'package:weblibre/features/geckoview/features/contextmenu/presentation/ca
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_image.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_image_location.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_link.dart';
+import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/copy_link_text.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/launch_external.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_image_new_tab.dart';
import 'package:weblibre/features/geckoview/features/contextmenu/presentation/candidates/open_in_container.dart';
@@ -112,6 +113,8 @@ class ContextMenuDialog extends HookConsumerWidget {
if (showContainerUi && OpenInContainer.isSupported(effective))
OpenInContainer(hitResult: effective),
if (CopyLink.isSupported(effective)) CopyLink(hitResult: effective),
+ if (CopyLinkText.isSupported(effective))
+ CopyLinkText(hitResult: effective),
if (SaveFile.isSupported(effective)) SaveFile(hitResult: effective),
if (ShareLink.isSupported(effective)) ShareLink(hitResult: effective),
if (ShareImage.isSupported(effective)) ShareImage(hitResult: effective),
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt
index 2e07d774..da114f8c 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/middleware/FlutterEventMiddleware.kt
@@ -104,7 +104,7 @@ class FlutterEventMiddleware(private val flutterEvents: GeckoStateEvents) : Midd
is HitResult.IMAGE -> ImageHitResult(result.src, result.title)
is HitResult.IMAGE_SRC -> ImageSrcHitResult(result.src, result.uri)
is HitResult.PHONE -> PhoneHitResult(result.src)
- is HitResult.UNKNOWN -> UnknownHitResult(result.src)
+ is HitResult.UNKNOWN -> UnknownHitResult(result.src, result.linkText)
is HitResult.VIDEO -> VideoHitResult(result.src, result.title)
}
) { _ -> }
diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
index 124caa25..2d416f0d 100644
--- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
+++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt
@@ -2468,18 +2468,21 @@ sealed class HitResult
* Generated class from Pigeon that represents data sent in messages.
*/
data class UnknownHitResult (
- val src: String
+ val src: String,
+ val linkText: String? = null
) : HitResult()
{
companion object {
fun fromList(pigeonVar_list: List): UnknownHitResult {
val src = pigeonVar_list[0] as String
- return UnknownHitResult(src)
+ val linkText = pigeonVar_list[1] as String?
+ return UnknownHitResult(src, linkText)
}
}
fun toList(): List {
return listOf(
src,
+ linkText,
)
}
override fun equals(other: Any?): Boolean {
diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
index 2c883e1a..3301d59c 100644
--- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
+++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart
@@ -3009,13 +3009,17 @@ sealed class HitResult {
class UnknownHitResult extends HitResult {
UnknownHitResult({
required this.src,
+ this.linkText,
});
String src;
+ String? linkText;
+
List