diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 82d095bc19..8bd49741df 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -6,6 +6,7 @@ import '../../shared/widgets/message_attachment_corners.dart'; import 'dart:io'; import 'dart:math' as math; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -15,10 +16,13 @@ import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:buzz/shared/theme/buzz_icons.dart'; import 'package:open_filex/open_filex.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/widgets/media_loading_placeholder.dart'; +import '../../shared/widgets/modal_presentation.dart'; +import '../../shared/widgets/sheet_action_section.dart'; import '../../shared/mentions/mention_bindings.dart'; import '../../shared/mentions/mention_tags.dart'; import '../../shared/deeplink/deep_link.dart'; @@ -44,6 +48,7 @@ import 'message_content/channel_token_pattern.dart'; import 'message_mention_pill.dart'; import 'message_gallery.dart'; import 'message_gallery_frame.dart'; +import 'message_long_press_region.dart'; import 'message_media_avatar.dart'; import 'message_media_geometry.dart'; import 'voice_note_attachment.dart'; @@ -54,6 +59,7 @@ part 'message_content/bubble_shape.dart'; part 'message_content/inline_components.dart'; part 'message_content/token_pill.dart'; part 'message_content/video_preview.dart'; +part 'message_content/link_actions.dart'; typedef OpenDownloadedFile = Future Function( @@ -684,54 +690,68 @@ class MessageContent extends HookConsumerWidget { ) : linkTextWidget; - // Mobile has no repo/PR/issue destination yet. Keep these presentation-only - // instead of exposing a control whose tap cannot do anything. + final highlightColor = (baseStyle.color ?? context.colors.primary) + .withValues(alpha: 0.2); + + // Mobile has no repo/PR/issue destination yet, so these chips have no tap + // and no Open action, but a hold still copies or shares the link. if (buzzLink is EntityDeepLink) { - return IgnorePointer(child: renderedLink); + return _LinkLongPress( + url: url, + onOpen: null, + highlightColor: highlightColor, + child: renderedLink, + ); } - return GestureDetector( - onTap: () async { - final uri = Uri.tryParse(url); - if (uri == null) return; + Future openLink() async { + final uri = Uri.tryParse(url); + if (uri == null) return; - // Rendered channel URLs must use the same callback as `#channel` - // references so detail-page callers can suppress self-navigation. - // Message and join links still need the top-level authenticated - // dispatcher. - if (uri.scheme == 'buzz') { - final deepLink = parseBuzzDeepLink(uri); - if (deepLink case ChannelDeepLink(:final channelId)) { - resolvedChannelTap(channelId); - } else if (deepLink is MessageDeepLink || - deepLink is InviteDeepLink) { - ref.read(pendingDeepLinkProvider.notifier).open(uri); - } - return; + // Rendered channel URLs must use the same callback as `#channel` + // references so detail-page callers can suppress self-navigation. + // Message and join links still need the top-level authenticated + // dispatcher. + if (uri.scheme == 'buzz') { + final deepLink = parseBuzzDeepLink(uri); + if (deepLink case ChannelDeepLink(:final channelId)) { + resolvedChannelTap(channelId); + } else if (deepLink is MessageDeepLink || deepLink is InviteDeepLink) { + ref.read(pendingDeepLinkProvider.notifier).open(uri); } - if (uri.scheme != 'http' && uri.scheme != 'https') return; + return; + } + if (uri.scheme != 'http' && uri.scheme != 'https') return; - final auth = ref.read(mediaGetAuthServiceProvider); - if (!auth.isRelayMediaUrl(url)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - return; - } + final auth = ref.read(mediaGetAuthServiceProvider); + if (!auth.isRelayMediaUrl(url)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + return; + } - try { - await ref.read(openDownloadedFileProvider)( - url, - auth.headersFor(url), - text, - ); - } catch (_) { - if (!context.mounted) return; - ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( - const SnackBar(content: Text('Could not open attachment')), - kind: ToastKind.error, - ); - } - }, - child: renderedLink, + try { + await ref.read(openDownloadedFileProvider)( + url, + auth.headersFor(url), + text, + ); + } catch (_) { + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context)?.showFeedbackSnackBar( + const SnackBar(content: Text('Could not open attachment')), + kind: ToastKind.error, + ); + } + } + + // openLink handles only these schemes; the sheet hides Open for others. + final scheme = Uri.tryParse(url)?.scheme; + final canOpen = scheme == 'buzz' || scheme == 'http' || scheme == 'https'; + return _LinkLongPress( + url: url, + onOpen: canOpen ? () => unawaited(openLink()) : null, + highlightColor: highlightColor, + child: GestureDetector(onTap: openLink, child: renderedLink), ); } } diff --git a/mobile/lib/features/channels/message_content/link_actions.dart b/mobile/lib/features/channels/message_content/link_actions.dart new file mode 100644 index 0000000000..315e20fee5 --- /dev/null +++ b/mobile/lib/features/channels/message_content/link_actions.dart @@ -0,0 +1,172 @@ +part of '../message_content.dart'; + +/// Wraps a rendered link so a hold opens [_showLinkActions] instead of the +/// surrounding message's menu. +/// +/// The recognizer shares [iosMessageLongPressDuration] with the message row. +/// Pointer events reach the deepest recognizer first, so at equal deadlines +/// the link's timer fires and wins the arena before the message row's does, +/// the same way reaction pills claim their own hold. +class _LinkLongPress extends HookWidget { + final String url; + + /// Opens the link; null hides Open for destinations nothing can open. + final VoidCallback? onOpen; + + /// Tint painted behind the link while a finger is down on it. + final Color highlightColor; + final Widget child; + + const _LinkLongPress({ + required this.url, + required this.onOpen, + required this.highlightColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + // Only this link rebuilds on press; the surrounding markdown does not. + final pressed = useState(false); + void open() => _showLinkActions(context, url: url, onOpen: onOpen); + void release() => pressed.value = false; + + return Semantics( + onLongPress: open, + child: RawGestureDetector( + gestures: { + LongPressGestureRecognizer: + GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer( + duration: defaultTargetPlatform == TargetPlatform.iOS + ? iosMessageLongPressDuration + : null, + ), + (recognizer) { + // Fires on every pointer down, so a tap highlights too; a + // tap or scroll rejects the hold and clears it via cancel. + recognizer + ..onLongPressDown = (_) { + pressed.value = true; + } + ..onLongPressCancel = release + ..onLongPress = open + ..onLongPressUp = release; + }, + ), + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: pressed.value ? highlightColor : null, + borderRadius: BorderRadius.circular(Radii.xs), + ), + child: child, + ), + ), + ); + } +} + +/// Link-specific actions: open, copy and share the destination. +void _showLinkActions( + BuildContext context, { + required String url, + required VoidCallback? onOpen, +}) { + unawaited(HapticFeedback.mediumImpact()); + final uri = Uri.tryParse(url); + // Web links share as URLs so the share sheet can preview them; any other + // link (buzz://, mailto:) shares as its text. + final isWebLink = + uri != null && (uri.scheme == 'http' || uri.scheme == 'https'); + unawaited( + showBuzzModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + title: 'Link', + builder: (sheetContext) => SafeArea( + // A long URL and large text can outgrow a short landscape sheet. + child: SingleChildScrollView( + child: IconTheme.merge( + data: const IconThemeData(size: 22), + child: Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xs, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Text( + url, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: sheetContext.textTheme.bodySmall?.copyWith( + color: sheetContext.colors.onSurfaceVariant, + ), + ), + ), + SheetActionSection( + children: [ + if (onOpen != null) + ListTile( + leading: const Icon(BuzzIcons.externalLink), + title: const Text('Open link'), + onTap: () { + Navigator.of(sheetContext).pop(); + onOpen(); + }, + ), + ListTile( + leading: const Icon(BuzzIcons.copy), + title: const Text('Copy link'), + onTap: () { + Navigator.of(sheetContext).pop(); + unawaited( + copyToClipboard( + context, + url, + message: 'Link copied', + ), + ); + }, + ), + ListTile( + leading: const Icon(BuzzIcons.share2), + title: const Text('Share link'), + onTap: () { + final renderBox = + context.findRenderObject() as RenderBox?; + final shareOrigin = renderBox == null + ? null + : renderBox.localToGlobal(Offset.zero) & + renderBox.size; + Navigator.of(sheetContext).pop(); + unawaited( + SharePlus.instance.share( + ShareParams( + uri: isWebLink ? uri : null, + text: isWebLink ? null : url, + sharePositionOrigin: shareOrigin, + ), + ), + ); + }, + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 9949730bc7..e16ab61674 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -959,6 +959,7 @@ void main() { bubbleLayoutTests(); messageWidthTests(); swipeReplyTests(); + swipeReplyLinkTests(); reviewAvatarTests(); setUp(() async { SharedPreferences.setMockInitialValues({}); diff --git a/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart b/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart index 1ae3668f2b..d5a2010acd 100644 --- a/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/swipe_reply_tests.dart @@ -292,3 +292,41 @@ void swipeReplyTests() { expect(find.byType(ThreadDetailPage), findsNothing); }); } + +void swipeReplyLinkTests() { + for (final onLink in [true, false]) { + testWidgets( + 'right drag starting ${onLink ? 'on a link' : 'beside a link'} replies', + variant: const TargetPlatformVariant({ + TargetPlatform.iOS, + TargetPlatform.android, + }), + (tester) async { + await tester.pumpWidget( + _buildTestable( + messages: [ + _textMsg( + id: 'swipe-link', + pubkey: 'alice', + content: 'Read [the doc](https://example.com/doc) carefully', + ), + ], + ), + ); + await tester.pumpAndSettle(); + final gesture = await tester.startGesture( + tester.getCenter( + onLink ? find.text('the doc') : find.textContaining('carefully'), + ), + ); + await gesture.moveBy(const Offset(25, 0)); + await gesture.moveBy(const Offset(100, 0)); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + expect(find.byType(ThreadDetailPage), findsOneWidget); + expect(find.text('Copy link'), findsNothing); + }, + ); + } +} diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index ba8b017c83..755fcabd02 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -25,6 +25,7 @@ import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/features/channels/message_long_press_region.dart'; import 'package:buzz/features/channels/media_viewer_page.dart'; import 'package:buzz/features/channels/voice_note_attachment.dart'; import 'package:buzz/features/channels/voice_note_waveform.dart'; @@ -668,6 +669,142 @@ void main() { expect(openedHeaders?['Authorization'], startsWith('Nostr ')); }); + testWidgets('a link nothing can open offers only copy and share', ( + tester, + ) async { + await tester.pumpWidget( + _testable( + const MessageContent( + content: 'Write to [support](mailto:support@example.com)', + ), + ), + ); + await tester.longPress(find.text('support')); + await tester.pumpAndSettle(); + expect(find.text('mailto:support@example.com'), findsOneWidget); + expect(find.text('Copy link'), findsOneWidget); + expect(find.text('Share link'), findsOneWidget); + expect(find.text('Open link'), findsNothing); + }); + + for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) { + testWidgets('holding a link opens link actions on ${platform.name}', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + const url = 'https://relay.example/media/report.pdf'; + final opened = []; + final clipboard = []; + var messageMenus = 0; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + clipboard.add((call.arguments as Map)['text'] as String); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + final auth = MediaGetAuthService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ); + + await tester.pumpWidget( + _testable( + MessageLongPressInkWell( + onLongPress: (_) => messageMenus++, + child: const MessageContent( + content: 'See the [report]($url) before standup', + ), + ), + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(auth), + openDownloadedFileProvider.overrideWithValue( + (url, headers, filename) async => opened.add(url), + ), + ], + ), + ); + + // A hold on the surrounding text still opens the message menu. + await tester.longPress(find.textContaining('before standup')); + await tester.pumpAndSettle(); + expect(messageMenus, 1); + expect(find.text('Copy link'), findsNothing); + + // A finger on the link tints it until the pointer moves away. + Color? linkTint() => + (tester + .widget( + find + .ancestor( + of: find.text('report'), + matching: find.byType(DecoratedBox), + ) + .first, + ) + .decoration + as BoxDecoration) + .color; + expect(linkTint(), isNull); + final press = await tester.startGesture( + tester.getCenter(find.text('report')), + ); + await tester.pump(); + expect(linkTint(), isNotNull); + await press.moveBy(const Offset(0, 40)); + await tester.pump(); + expect(linkTint(), isNull); + await press.up(); + await tester.pumpAndSettle(); + expect(messageMenus, 1); + expect(opened, isEmpty); + + // A hold on the link opens its own actions instead. + await tester.longPress(find.text('report')); + await tester.pumpAndSettle(); + expect(messageMenus, 1); + expect(find.text(url), findsOneWidget); + expect(find.text('Share link'), findsOneWidget); + + await tester.tap(find.text('Copy link')); + await tester.pumpAndSettle(); + expect(clipboard, [url]); + expect(find.text('Copy link'), findsNothing); + + await tester.longPress(find.text('report')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Open link')); + await tester.pumpAndSettle(); + expect(opened, [url]); + + // An ordinary tap still opens the link directly. + await tester.tap(find.text('report')); + await tester.pumpAndSettle(); + expect(opened, [url, url]); + expect(messageMenus, 1); + + // A short landscape sheet still reaches every action. + _setSurfaceSize(tester, const Size(640, 260)); + addTearDown(tester.view.reset); + await tester.pumpAndSettle(); + await tester.longPress(find.text('report')); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Share link')); + await tester.pumpAndSettle(); + expect(find.text('Share link').hitTestable(), findsOneWidget); + debugDefaultTargetPlatformOverride = null; + }); + } + test('buildImageViewerRoute uses modal-style page route builder', () { final route = buildImageViewerRoute( imageUrl: 'https://example.com/media/image.png', @@ -2873,20 +3010,31 @@ Photos find.bySemanticsLabel('Pull request cdcdcdcd in repository buzz'), findsOneWidget, ); + // Repo, PR and issue chips have no destination, so a hold offers + // only Copy and Share for the exact URL. + final clipboard = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + clipboard.add((call.arguments as Map)['text'] as String); + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null), + ); for (final url in urls.skip(2)) { - final chipKey = ValueKey('buzz-link-chip:$url'); - final ignoredChip = find.ancestor( - of: find.byKey(chipKey), - matching: find.byWidgetPredicate( - (widget) => widget is IgnorePointer && widget.ignoring, - ), - ); - expect(ignoredChip, findsOneWidget, reason: url); - expect( - tester.widget(ignoredChip).ignoring, - isTrue, - reason: url, - ); + await tester.longPress(find.byKey(ValueKey('buzz-link-chip:$url'))); + await tester.pumpAndSettle(); + expect(find.text(url), findsOneWidget, reason: url); + expect(find.text('Share link'), findsOneWidget, reason: url); + expect(find.text('Open link'), findsNothing, reason: url); + await tester.tap(find.text('Copy link')); + await tester.pumpAndSettle(); + expect(clipboard.last, url); } }, );