diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index b0152fc28e..2ce7b9f011 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -43,10 +43,14 @@ "@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." }, + "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." @@ -168,10 +172,14 @@ "@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." }, + "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 424d666016..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'; @@ -39,12 +41,13 @@ 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, ), - CopyButton(message: message, messageListContext: context), + CopyMessageTextButton(message: message, messageListContext: context), + CopyMessageLinkButton(message: message, messageListContext: context), + ShareButton(message: message, messageListContext: context), ]); }); } @@ -164,63 +167,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({ @@ -330,8 +276,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 +287,7 @@ class CopyButton extends MessageActionSheetMenuItemButton { @override String label(ZulipLocalizations zulipLocalizations) { - return zulipLocalizations.actionSheetOptionCopy; + return zulipLocalizations.actionSheetOptionCopyMessageText; } @override void onPressed(BuildContext context) async { @@ -361,8 +307,96 @@ class CopyButton extends MessageActionSheetMenuItemButton { if (!messageListContext.mounted) return; - copyWithPopup(context: context, - successContent: Text(zulipLocalizations.successMessageCopied), + copyWithPopup(context: messageListContext, + successContent: Text(zulipLocalizations.successMessageTextCopied), data: ClipboardData(text: rawContent)); } } + +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, + 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 a006fa640f..9b654eb4ff 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -8,7 +8,9 @@ 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/internal_link.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -84,11 +86,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()); } @@ -245,70 +248,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)) @@ -449,7 +388,7 @@ void main() { }); }); - group('CopyButton', () { + group('CopyMessageTextButton', () { setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( SystemChannels.platform, @@ -457,7 +396,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,18 +408,43 @@ 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'); }); + 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); + + // 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); + // … 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)); + + 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)); 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, @@ -490,4 +454,95 @@ void main() { check(await Clipboard.getData('text/plain')).isNull(); }); }); + + 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() { + 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(); + }); + }); }