mirror of
https://github.com/block/buzz.git
synced 2026-11-11 11:51:13 +00:00
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:
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
172
mobile/lib/features/channels/message_content/link_actions.dart
Normal file
172
mobile/lib/features/channels/message_content/link_actions.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -959,6 +959,7 @@ void main() {
|
||||
bubbleLayoutTests();
|
||||
messageWidthTests();
|
||||
swipeReplyTests();
|
||||
swipeReplyLinkTests();
|
||||
reviewAvatarTests();
|
||||
setUp(() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user