From e55675eb1f02effa968cc5415af40fded0dcb4e5 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 22 May 2024 12:22:52 +0430 Subject: [PATCH 1/5] action-sheet: Rename `CopyButton` to `CopyMessageTextButton` The reason for this renaming is to have a clear distinction between the existing "copy message text" and the upcoming "copy message link" buttons. --- assets/l10n/app_en.arb | 8 ++++---- lib/widgets/action_sheet.dart | 10 +++++----- test/widgets/action_sheet_test.dart | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index b0152fc28e..3c00755d4f 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -43,8 +43,8 @@ "@permissionsDeniedReadExternalStorage": { "description": "Message for dialog asking the user to grant permissions for external storage read access." }, - "actionSheetOptionCopy": "Copy message text", - "@actionSheetOptionCopy": { + "actionSheetOptionCopyMessageText": "Copy message text", + "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." }, "actionSheetOptionShare": "Share", @@ -168,8 +168,8 @@ "@successLinkCopied": { "description": "Success message after copy link action completed." }, - "successMessageCopied": "Message Copied", - "@successMessageCopied": { + "successMessageTextCopied": "Message text copied", + "@successMessageTextCopied": { "description": "Message when content of a message was copied to the user's system clipboard." }, "composeBoxAttachFilesTooltip": "Attach files", diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 424d666016..0c72594874 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -44,7 +44,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes message: message, messageListContext: context, ), - CopyButton(message: message, messageListContext: context), + CopyMessageTextButton(message: message, messageListContext: context), ]); }); } @@ -330,8 +330,8 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { } } -class CopyButton extends MessageActionSheetMenuItemButton { - CopyButton({ +class CopyMessageTextButton extends MessageActionSheetMenuItemButton { + CopyMessageTextButton({ super.key, required super.message, required super.messageListContext, @@ -341,7 +341,7 @@ class CopyButton extends MessageActionSheetMenuItemButton { @override String label(ZulipLocalizations zulipLocalizations) { - return zulipLocalizations.actionSheetOptionCopy; + return zulipLocalizations.actionSheetOptionCopyMessageText; } @override void onPressed(BuildContext context) async { @@ -362,7 +362,7 @@ class CopyButton extends MessageActionSheetMenuItemButton { if (!messageListContext.mounted) return; copyWithPopup(context: context, - successContent: Text(zulipLocalizations.successMessageCopied), + successContent: Text(zulipLocalizations.successMessageTextCopied), data: ClipboardData(text: rawContent)); } } diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index a006fa640f..b8ef415fd9 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -449,7 +449,7 @@ void main() { }); }); - group('CopyButton', () { + group('CopyMessageTextButton', () { setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, @@ -457,7 +457,7 @@ void main() { ); }); - Future tapCopyButton(WidgetTester tester) async { + Future tapCopyMessageTextButton(WidgetTester tester) async { await tester.ensureVisible(find.byIcon(Icons.copy, skipOffstage: false)); await tester.tap(find.byIcon(Icons.copy)); await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e @@ -469,7 +469,7 @@ void main() { final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); - await tapCopyButton(tester); + await tapCopyMessageTextButton(tester); await tester.pump(Duration.zero); check(await Clipboard.getData('text/plain')).isNotNull().text.equals('Hello world'); }); @@ -480,7 +480,7 @@ void main() { final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); prepareRawContentResponseError(store); - await tapCopyButton(tester); + await tapCopyMessageTextButton(tester); await tester.pump(Duration.zero); // error arrives; error dialog shows await tester.tap(find.byWidget(checkErrorDialog(tester, From 63b57fe990b846abd8aa9348d4671d4386cd7484 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 6 Jun 2024 12:08:21 +0430 Subject: [PATCH 2/5] action-sheet: Pass the correct `BuildContext` for showing `SnackBar` Before this, the `SnackBar` wouldn't show as the correct `BuildContext` was not passed to `copyWithPopup` method. Fixes: #732 --- lib/widgets/action_sheet.dart | 2 +- test/widgets/action_sheet_test.dart | 31 ++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 0c72594874..29086deb84 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -361,7 +361,7 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton { if (!messageListContext.mounted) return; - copyWithPopup(context: context, + copyWithPopup(context: messageListContext, successContent: Text(zulipLocalizations.successMessageTextCopied), data: ClipboardData(text: rawContent)); } diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index b8ef415fd9..80d91e630d 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/binding.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; @@ -84,11 +85,12 @@ void main() { void prepareRawContentResponseSuccess(PerAccountStore store, { required Message message, required String rawContent, + Duration delay = Duration.zero, }) { // Prepare fetch-raw-Markdown response // TODO: Message should really only differ from `message` // in its content / content_type, not in `id` or anything else. - (store.connection as FakeApiConnection).prepare(json: + (store.connection as FakeApiConnection).prepare(delay: delay, json: GetMessageResult(message: eg.streamMessage(contentMarkdown: rawContent)).toJson()); } @@ -474,6 +476,33 @@ void main() { check(await Clipboard.getData('text/plain')).isNotNull().text.equals('Hello world'); }); + testWidgets('success with a snackbar', (tester) async { + // for #732 regression check below + testBinding.deviceInfoResult = IosDeviceInfo(systemVersion: '16.0'); + + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + prepareRawContentResponseSuccess(store, + message: message, + rawContent: 'Hello world', + delay: const Duration(milliseconds: 500)); + + await tapCopyMessageTextButton(tester); + for (int i = 0; i < 5; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + + // regression check for #732 + final snackbar = tester.widget(find.byType(SnackBar)); + check(snackbar.behavior).equals(SnackBarBehavior.floating); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + tester.widget(find.descendant(matchRoot: true, + of: find.byWidget(snackbar.content), + matching: find.text(zulipLocalizations.successMessageTextCopied))); + }); + testWidgets('request has an error', (WidgetTester tester) async { final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); From aeb6a94ffe230cc7d6f7d7ec3df3c10330ca7e8e Mon Sep 17 00:00:00 2001 From: Greg Price Date: Mon, 1 Jul 2024 17:59:12 -0700 Subject: [PATCH 3/5] action-sheet test: Simplify and explain a bit more the #732 repro test --- test/widgets/action_sheet_test.dart | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 80d91e630d..f78c57b97f 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -476,25 +476,23 @@ void main() { check(await Clipboard.getData('text/plain')).isNotNull().text.equals('Hello world'); }); - testWidgets('success with a snackbar', (tester) async { - // for #732 regression check below + testWidgets('can show snackbar on success', (tester) async { + // Regression test for: https://github.com/zulip/zulip-flutter/issues/732 testBinding.deviceInfoResult = IosDeviceInfo(systemVersion: '16.0'); final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - prepareRawContentResponseSuccess(store, - message: message, - rawContent: 'Hello world', + // Make the request take a bit of time to complete… + prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world', delay: const Duration(milliseconds: 500)); - await tapCopyMessageTextButton(tester); - for (int i = 0; i < 5; i++) { - await tester.pump(const Duration(milliseconds: 100)); - } + // … and pump a frame to finish the NavigationState.pop animation… + await tester.pump(const Duration(milliseconds: 250)); + // … before the request finishes. This is the repro condition for #732. + await tester.pump(const Duration(milliseconds: 250)); - // regression check for #732 final snackbar = tester.widget(find.byType(SnackBar)); check(snackbar.behavior).equals(SnackBarBehavior.floating); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; From 191f4e33099f74f718d1d7b8735d993ba52aba72 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 6 Jun 2024 12:14:17 +0430 Subject: [PATCH 4/5] action-sheet: Move the "Share" option to the bottom of options This is because in the next commit(s) the "Copy link to message" button will be added and we want to make it next to the "Share" button at the bottom of the options. --- lib/widgets/action_sheet.dart | 116 ++++++++++++------------- test/widgets/action_sheet_test.dart | 128 ++++++++++++++-------------- 2 files changed, 122 insertions(+), 122 deletions(-) diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 29086deb84..52328ffbd2 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -39,12 +39,12 @@ void showMessageActionSheet({required BuildContext context, required Message mes return Column(children: [ if (!hasThumbsUpReactionVote) AddThumbsUpButton(message: message, messageListContext: context), StarButton(message: message, messageListContext: context), - ShareButton(message: message, messageListContext: context), if (isComposeBoxOffered) QuoteAndReplyButton( message: message, messageListContext: context, ), CopyMessageTextButton(message: message, messageListContext: context), + ShareButton(message: message, messageListContext: context), ]); }); } @@ -164,63 +164,6 @@ class StarButton extends MessageActionSheetMenuItemButton { } } -class ShareButton extends MessageActionSheetMenuItemButton { - ShareButton({ - super.key, - required super.message, - required super.messageListContext, - }); - - @override IconData get icon => Icons.adaptive.share; - - @override - String label(ZulipLocalizations zulipLocalizations) { - return zulipLocalizations.actionSheetOptionShare; - } - - @override void onPressed(BuildContext context) async { - // Close the message action sheet; we're about to show the share - // sheet. (We could do this after the sharing Future settles - // with [ShareResultStatus.success], but on iOS I get impatient with - // how slowly our action sheet dismisses in that case.) - // TODO(#24): Fix iOS bug where this call causes the keyboard to - // reopen (if it was open at the time of this - // `showMessageActionSheet` call) and cover a large part of the - // share sheet. - Navigator.of(context).pop(); - final zulipLocalizations = ZulipLocalizations.of(messageListContext); - - final rawContent = await fetchRawContentWithFeedback( - context: messageListContext, - messageId: message.id, - errorDialogTitle: zulipLocalizations.errorSharingFailed, - ); - - if (rawContent == null) return; - - if (!messageListContext.mounted) return; - - // TODO: to support iPads, we're asked to give a - // `sharePositionOrigin` param, or risk crashing / hanging: - // https://pub.dev/packages/share_plus#ipad - // Perhaps a wart in the API; discussion: - // https://github.com/zulip/zulip-flutter/pull/12#discussion_r1130146231 - final result = await Share.share(rawContent); - - switch (result.status) { - // The plugin isn't very helpful: "The status can not be determined". - // Until we learn otherwise, assume something wrong happened. - case ShareResultStatus.unavailable: - if (!messageListContext.mounted) return; - await showErrorDialog(context: messageListContext, - title: zulipLocalizations.errorSharingFailed); - case ShareResultStatus.success: - case ShareResultStatus.dismissed: - // nothing to do - } - } -} - /// Fetch and return the raw Markdown content for [messageId], /// showing an error dialog on failure. Future fetchRawContentWithFeedback({ @@ -366,3 +309,60 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton { data: ClipboardData(text: rawContent)); } } + +class ShareButton extends MessageActionSheetMenuItemButton { + ShareButton({ + super.key, + required super.message, + required super.messageListContext, + }); + + @override IconData get icon => Icons.adaptive.share; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionShare; + } + + @override void onPressed(BuildContext context) async { + // Close the message action sheet; we're about to show the share + // sheet. (We could do this after the sharing Future settles + // with [ShareResultStatus.success], but on iOS I get impatient with + // how slowly our action sheet dismisses in that case.) + // TODO(#24): Fix iOS bug where this call causes the keyboard to + // reopen (if it was open at the time of this + // `showMessageActionSheet` call) and cover a large part of the + // share sheet. + Navigator.of(context).pop(); + final zulipLocalizations = ZulipLocalizations.of(messageListContext); + + final rawContent = await fetchRawContentWithFeedback( + context: messageListContext, + messageId: message.id, + errorDialogTitle: zulipLocalizations.errorSharingFailed, + ); + + if (rawContent == null) return; + + if (!messageListContext.mounted) return; + + // TODO: to support iPads, we're asked to give a + // `sharePositionOrigin` param, or risk crashing / hanging: + // https://pub.dev/packages/share_plus#ipad + // Perhaps a wart in the API; discussion: + // https://github.com/zulip/zulip-flutter/pull/12#discussion_r1130146231 + final result = await Share.share(rawContent); + + switch (result.status) { + // The plugin isn't very helpful: "The status can not be determined". + // Until we learn otherwise, assume something wrong happened. + case ShareResultStatus.unavailable: + if (!messageListContext.mounted) return; + await showErrorDialog(context: messageListContext, + title: zulipLocalizations.errorSharingFailed); + case ShareResultStatus.success: + case ShareResultStatus.dismissed: + // nothing to do + } + } +} diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index f78c57b97f..c28118ec79 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -247,70 +247,6 @@ void main() { }); }); - group('ShareButton', () { - // Tests should call this. - MockSharePlus setupMockSharePlus() { - final mock = MockSharePlus(); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( - MethodChannelShare.channel, - mock.handleMethodCall, - ); - return mock; - } - - Future tapShareButton(WidgetTester tester) async { - await tester.ensureVisible(find.byIcon(Icons.adaptive.share, skipOffstage: false)); - await tester.tap(find.byIcon(Icons.adaptive.share)); - await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e - } - - testWidgets('request succeeds; sharing succeeds', (WidgetTester tester) async { - final mockSharePlus = setupMockSharePlus(); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - - prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); - await tapShareButton(tester); - await tester.pump(Duration.zero); - check(mockSharePlus.sharedString).equals('Hello world'); - }); - - testWidgets('request succeeds; sharing fails', (WidgetTester tester) async { - final mockSharePlus = setupMockSharePlus(); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - - prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); - mockSharePlus.resultString = 'dev.fluttercommunity.plus/share/unavailable'; - await tapShareButton(tester); - await tester.pump(Duration.zero); - check(mockSharePlus.sharedString).equals('Hello world'); - await tester.pump(); - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Sharing failed'))); - }); - - testWidgets('request has an error', (WidgetTester tester) async { - final mockSharePlus = setupMockSharePlus(); - final message = eg.streamMessage(); - await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - - prepareRawContentResponseError(store); - await tapShareButton(tester); - await tester.pump(Duration.zero); // error arrives; error dialog shows - - await tester.tap(find.byWidget(checkErrorDialog(tester, - expectedTitle: 'Sharing failed', - expectedMessage: 'That message does not seem to exist.', - ))); - - check(mockSharePlus.sharedString).isNull(); - }); - }); - group('QuoteAndReplyButton', () { ComposeBoxController? findComposeBoxController(WidgetTester tester) { return tester.widget(find.byType(ComposeBox)) @@ -517,4 +453,68 @@ void main() { check(await Clipboard.getData('text/plain')).isNull(); }); }); + + group('ShareButton', () { + // Tests should call this. + MockSharePlus setupMockSharePlus() { + final mock = MockSharePlus(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + MethodChannelShare.channel, + mock.handleMethodCall, + ); + return mock; + } + + Future tapShareButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(Icons.adaptive.share, skipOffstage: false)); + await tester.tap(find.byIcon(Icons.adaptive.share)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('request succeeds; sharing succeeds', (WidgetTester tester) async { + final mockSharePlus = setupMockSharePlus(); + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); + await tapShareButton(tester); + await tester.pump(Duration.zero); + check(mockSharePlus.sharedString).equals('Hello world'); + }); + + testWidgets('request succeeds; sharing fails', (WidgetTester tester) async { + final mockSharePlus = setupMockSharePlus(); + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); + mockSharePlus.resultString = 'dev.fluttercommunity.plus/share/unavailable'; + await tapShareButton(tester); + await tester.pump(Duration.zero); + check(mockSharePlus.sharedString).equals('Hello world'); + await tester.pump(); + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Sharing failed'))); + }); + + testWidgets('request has an error', (WidgetTester tester) async { + final mockSharePlus = setupMockSharePlus(); + final message = eg.streamMessage(); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + prepareRawContentResponseError(store); + await tapShareButton(tester); + await tester.pump(Duration.zero); // error arrives; error dialog shows + + await tester.tap(find.byWidget(checkErrorDialog(tester, + expectedTitle: 'Sharing failed', + expectedMessage: 'That message does not seem to exist.', + ))); + + check(mockSharePlus.sharedString).isNull(); + }); + }); } From 22c0f3edaaf635df7d1e42995909167622a80df6 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 30 May 2024 11:04:05 +0430 Subject: [PATCH 5/5] action-sheet: Add "Copy message link" button Fixes: #673 --- assets/l10n/app_en.arb | 8 +++++++ lib/widgets/action_sheet.dart | 34 +++++++++++++++++++++++++++++ test/widgets/action_sheet_test.dart | 28 ++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 3c00755d4f..2ce7b9f011 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -47,6 +47,10 @@ "@actionSheetOptionCopyMessageText": { "description": "Label for copy message text button on action sheet." }, + "actionSheetOptionCopyMessageLink": "Copy link to message", + "@actionSheetOptionCopyMessageLink": { + "description": "Label for copy message link button on action sheet." + }, "actionSheetOptionShare": "Share", "@actionSheetOptionShare": { "description": "Label for share button on action sheet." @@ -172,6 +176,10 @@ "@successMessageTextCopied": { "description": "Message when content of a message was copied to the user's system clipboard." }, + "successMessageLinkCopied": "Message link copied", + "@successMessageLinkCopied": { + "description": "Message when link of a message was copied to the user's system clipboard." + }, "composeBoxAttachFilesTooltip": "Attach files", "@composeBoxAttachFilesTooltip": { "description": "Tooltip for compose box icon to attach a file to the message." diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 52328ffbd2..b224d74769 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -6,6 +6,8 @@ import 'package:share_plus/share_plus.dart'; import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; +import '../model/internal_link.dart'; +import '../model/narrow.dart'; import 'clipboard.dart'; import 'compose_box.dart'; import 'dialog.dart'; @@ -44,6 +46,7 @@ void showMessageActionSheet({required BuildContext context, required Message mes messageListContext: context, ), CopyMessageTextButton(message: message, messageListContext: context), + CopyMessageLinkButton(message: message, messageListContext: context), ShareButton(message: message, messageListContext: context), ]); }); @@ -310,6 +313,37 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton { } } +class CopyMessageLinkButton extends MessageActionSheetMenuItemButton { + CopyMessageLinkButton({ + super.key, + required super.message, + required super.messageListContext, + }); + + @override IconData get icon => Icons.link; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionCopyMessageLink; + } + + @override void onPressed(BuildContext context) { + Navigator.of(context).pop(); + final zulipLocalizations = ZulipLocalizations.of(messageListContext); + + final store = PerAccountStoreWidget.of(messageListContext); + final messageLink = narrowLink( + store, + SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), + nearMessageId: message.id, + ); + + copyWithPopup(context: messageListContext, + successContent: Text(zulipLocalizations.successMessageLinkCopied), + data: ClipboardData(text: messageLink.toString())); + } +} + class ShareButton extends MessageActionSheetMenuItemButton { ShareButton({ super.key, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index c28118ec79..9b654eb4ff 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -10,6 +10,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/model/binding.dart'; import 'package:zulip/model/compose.dart'; +import 'package:zulip/model/internal_link.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -454,6 +455,33 @@ void main() { }); }); + group('CopyMessageLinkButton', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + MockClipboard().handleMethodCall, + ); + }); + + Future tapCopyMessageLinkButton(WidgetTester tester) async { + await tester.ensureVisible(find.byIcon(Icons.link, skipOffstage: false)); + await tester.tap(find.byIcon(Icons.link)); + await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e + } + + testWidgets('copies message link to clipboard', (tester) async { + final message = eg.streamMessage(); + final narrow = TopicNarrow.ofMessage(message); + await setupToMessageActionSheet(tester, message: message, narrow: narrow); + final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + await tapCopyMessageLinkButton(tester); + await tester.pump(Duration.zero); + final expectedLink = narrowLink(store, narrow, nearMessageId: message.id).toString(); + check(await Clipboard.getData('text/plain')).isNotNull().text.equals(expectedLink); + }); + }); + group('ShareButton', () { // Tests should call this. MockSharePlus setupMockSharePlus() {