mobile: long-press a message link for open, copy and share (#9135)

Holding a link in a message used to open the whole-message actions
sheet, so there was no way to copy or share just the link. Now a hold on
a link opens a small **Link** sheet showing the URL with **Open link**,
**Copy link** and **Share link**. Web links share as URLs so the share
sheet can preview them; other links (such as `buzz://`) share as text.
Open link appears only for destinations the app can open (web and
`buzz://` links), so a `mailto:` link offers Copy and Share. Repo, PR
and issue chips, which have no mobile destination, also offer Copy and
Share on hold. While a finger is down, the link gets a light tint behind
it, the same kind of press highlight the message row paints. A hold
anywhere else in the message still opens the message actions, and a tap
on a link still opens it directly.

The link gets its own long-press recognizer with the same hold deadline
as the message row. Pointer events reach the deeper recognizer first, so
at equal deadlines the link wins the arena before the message row, the
same mechanism reaction pills already use to claim their own hold.
Rendering is otherwise unchanged: the link widget tree only gains a
`RawGestureDetector`, a `Semantics` wrapper and a `DecoratedBox` for the
tint, press state lives in the link widget so only the link rebuilds,
and the sheet is built only when opened. Open reuses the existing tap
path (external browser, authenticated relay-media download, or Buzz deep
link).

### Validation

Added tests: a journey in `message_content_test.dart`, run with both iOS
(200 ms) and Android (default) hold timing, that holds surrounding text
(message menu opens, link sheet doesn't), holds the link (link sheet,
not the message menu), copies it (clipboard gets the URL), opens it from
the sheet and by a plain tap, and checks the press tint appears on touch
and clears when the finger moves away. Removing the link recognizer or
the tint makes it fail. Another test holds a `mailto:` link and checks
the sheet offers Copy and Share but not Open. Swipe-to-reply tests in
`swipe_reply_tests.dart` drag a message starting on its link and beside
it, with both hold timings, and both open the reply.

Full `just mobile-check mobile-test` passes (after rebasing onto main,
two `hosted_backup_service_test.dart` tests failed once under load and
pass on rerun; this PR doesn't touch that code).

Automated app run on an iPhone 17 Pro simulator (iOS 27) signed in to a
Builderlab community, in a private QA channel: held message text
(message actions sheet, dismissed and composer focus restored as
before), held a link (Link sheet), Copy link (toast, simulator
pasteboard holds the URL), held again and Share link (system share sheet
with the page preview). The simulator's share sheet then stopped
responding to dismissal, a known simulator share-extension quirk
unrelated to this change. After adding the tint, a second simulator run
held the link and captured the pressed state (below). Not covered:
Android device run, VoiceOver.


https://github.com/user-attachments/assets/6f5bda14-14af-450e-b021-f98ebc072f9b

| Held | Link sheet | Copied |
| --- | --- | --- |
| <img
src="https://github.com/user-attachments/assets/86262d85-b745-41b4-8c40-96372d0cec61"
width="300" alt="Link held, tinted"> | <img
src="https://github.com/user-attachments/assets/7c9fe05a-17a0-47c4-851e-e89456bc4520"
width="300" alt="Link sheet"> | <img
src="https://github.com/user-attachments/assets/d97c417a-a79b-4dd2-a43e-35ace7ea5552"
width="300" alt="Link copied"> |

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-07 15:40:16 -08:00
committed by GitHub
parent e86d0e23a3
commit a7a534d476
5 changed files with 433 additions and 54 deletions

View File

@@ -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<void> 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<void> 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),
);
}
}

View File

@@ -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>(
() => 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<void>(
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,
),
),
);
},
),
],
),
],
),
),
),
),
),
),
);
}

View File

@@ -959,6 +959,7 @@ void main() {
bubbleLayoutTests();
messageWidthTests();
swipeReplyTests();
swipeReplyLinkTests();
reviewAvatarTests();
setUp(() async {
SharedPreferences.setMockInitialValues({});

View File

@@ -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);
},
);
}
}

View File

@@ -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 = <String>[];
final clipboard = <String>[];
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<DecoratedBox>(
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 = <String>[];
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<IgnorePointer>(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);
}
},
);