diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index d06a57995f..844f820c13 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -13,7 +13,6 @@ import '../model/internal_link.dart'; import '../model/narrow.dart'; import 'actions.dart'; import 'clipboard.dart'; -import 'compose_box.dart'; import 'dialog.dart'; import 'icons.dart'; import 'inset_shadow.dart'; @@ -30,11 +29,12 @@ void showMessageActionSheet({required BuildContext context, required Message mes // The UI that's conditioned on this won't live-update during this appearance // of the action sheet (we avoid calling composeBoxControllerOf in a build - // method; see its doc). But currently it will be constant through the life of - // any message list, so that's fine. + // method; see its doc). + // So we rely on the fact that isComposeBoxOffered for any given message list + // will be constant through the page's life. final messageListPage = MessageListPage.ancestorOf(context); final isComposeBoxOffered = messageListPage.composeBoxController != null; - final narrow = messageListPage.narrow; + final isMessageRead = message.flags.contains(MessageFlag.read); final markAsUnreadSupported = store.connection.zulipFeatureLevel! >= 155; // TODO(server-6) final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; @@ -48,15 +48,15 @@ void showMessageActionSheet({required BuildContext context, required Message mes final optionButtons = [ if (!hasThumbsUpReactionVote) - AddThumbsUpButton(message: message, messageListContext: context), - StarButton(message: message, messageListContext: context), + AddThumbsUpButton(message: message, pageContext: context), + StarButton(message: message, pageContext: context), if (isComposeBoxOffered) - QuoteAndReplyButton(message: message, messageListContext: context), + QuoteAndReplyButton(message: message, pageContext: context), if (showMarkAsUnreadButton) - MarkAsUnreadButton(message: message, messageListContext: context, narrow: narrow), - CopyMessageTextButton(message: message, messageListContext: context), - CopyMessageLinkButton(message: message, messageListContext: context), - ShareButton(message: message, messageListContext: context), + MarkAsUnreadButton(message: message, pageContext: context), + CopyMessageTextButton(message: message, pageContext: context), + CopyMessageLinkButton(message: message, pageContext: context), + ShareButton(message: message, pageContext: context), ]; showModalBottomSheet( @@ -95,15 +95,44 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget { MessageActionSheetMenuItemButton({ super.key, required this.message, - required this.messageListContext, - }) : assert(messageListContext.findAncestorWidgetOfExactType() != null); + required this.pageContext, + }) : assert(pageContext.findAncestorWidgetOfExactType() != null); IconData get icon; String label(ZulipLocalizations zulipLocalizations); - void onPressed(BuildContext context); + + /// Called when the button is pressed, after dismissing the action sheet. + /// + /// If the action may take a long time, this method is responsible for + /// arranging any form of progress feedback that may be desired. + /// + /// For operations that need a [BuildContext], see [pageContext]. + void onPressed(); final Message message; - final BuildContext messageListContext; + + /// A context within the [MessageListPage] this action sheet was + /// triggered from. + final BuildContext pageContext; + + /// The [MessageListPageState] this action sheet was triggered from. + /// + /// Uses the inefficient [BuildContext.findAncestorStateOfType]; + /// don't call this in a build method. + MessageListPageState findMessageListPage() { + assert(pageContext.mounted, + 'findMessageListPage should be called only when pageContext is known to still be mounted'); + return MessageListPage.ancestorOf(pageContext); + } + + void _handlePressed(BuildContext context) { + // Dismiss the enclosing action sheet immediately, + // for swift UI feedback that the user's selection was received. + Navigator.of(context).pop(); + + assert(pageContext.mounted); + onPressed(); + } @override Widget build(BuildContext context) { @@ -118,7 +147,7 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget { ).copyWith(backgroundColor: WidgetStateColor.resolveWith((states) => designVariables.contextMenuItemBg.withValues( alpha: states.contains(WidgetState.pressed) ? 0.20 : 0.12))), - onPressed: () => onPressed(context), + onPressed: () => _handlePressed(context), child: Text(label(zulipLocalizations), style: const TextStyle(fontSize: 20, height: 24 / 20) .merge(weightVariableTextStyle(context, wght: 600)), @@ -154,11 +183,7 @@ class MessageActionSheetCancelButton extends StatelessWidget { // This button is very temporary, to complete #125 before we have a way to // choose an arbitrary reaction (#388). So, skipping i18n. class AddThumbsUpButton extends MessageActionSheetMenuItemButton { - AddThumbsUpButton({ - super.key, - required super.message, - required super.messageListContext, - }); + AddThumbsUpButton({super.key, required super.message, required super.pageContext}); @override IconData get icon => ZulipIcons.smile; @@ -167,18 +192,17 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton { return 'React with 👍'; // TODO(i18n) skip translation for now } - @override void onPressed(BuildContext context) async { - Navigator.of(context).pop(); + @override void onPressed() async { String? errorMessage; try { - await addReaction(PerAccountStoreWidget.of(messageListContext).connection, + await addReaction(PerAccountStoreWidget.of(pageContext).connection, messageId: message.id, reactionType: ReactionType.unicodeEmoji, emojiCode: '1f44d', emojiName: '+1', ); } catch (e) { - if (!messageListContext.mounted) return; + if (!pageContext.mounted) return; switch (e) { case ZulipApiException(): @@ -188,18 +212,14 @@ class AddThumbsUpButton extends MessageActionSheetMenuItemButton { default: } - showErrorDialog(context: context, + showErrorDialog(context: pageContext, title: 'Adding reaction failed', message: errorMessage); } } } class StarButton extends MessageActionSheetMenuItemButton { - StarButton({ - super.key, - required super.message, - required super.messageListContext, - }); + StarButton({super.key, required super.message, required super.pageContext}); @override IconData get icon => _isStarred ? ZulipIcons.star_filled : ZulipIcons.star; @@ -212,19 +232,18 @@ class StarButton extends MessageActionSheetMenuItemButton { : zulipLocalizations.actionSheetOptionStarMessage; } - @override void onPressed(BuildContext context) async { - Navigator.of(context).pop(); - final zulipLocalizations = ZulipLocalizations.of(messageListContext); + @override void onPressed() async { + final zulipLocalizations = ZulipLocalizations.of(pageContext); final op = message.flags.contains(MessageFlag.starred) ? UpdateMessageFlagsOp.remove : UpdateMessageFlagsOp.add; try { - final connection = PerAccountStoreWidget.of(messageListContext).connection; + final connection = PerAccountStoreWidget.of(pageContext).connection; await updateMessageFlags(connection, messages: [message.id], op: op, flag: MessageFlag.starred); } catch (e) { - if (!messageListContext.mounted) return; + if (!pageContext.mounted) return; String? errorMessage; switch (e) { @@ -235,7 +254,7 @@ class StarButton extends MessageActionSheetMenuItemButton { default: } - showErrorDialog(context: messageListContext, + showErrorDialog(context: pageContext, title: switch(op) { UpdateMessageFlagsOp.remove => zulipLocalizations.errorUnstarMessageFailedTitle, UpdateMessageFlagsOp.add => zulipLocalizations.errorStarMessageFailedTitle, @@ -293,11 +312,7 @@ Future fetchRawContentWithFeedback({ } class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { - QuoteAndReplyButton({ - super.key, - required super.message, - required super.messageListContext, - }); + QuoteAndReplyButton({super.key, required super.message, required super.pageContext}); @override IconData get icon => ZulipIcons.format_quote; @@ -306,17 +321,13 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { return zulipLocalizations.actionSheetOptionQuoteAndReply; } - @override void onPressed(BuildContext context) async { - // Close the message action sheet. We'll show the request progress - // in the compose-box content input with a "[Quoting…]" placeholder. - Navigator.of(context).pop(); - final zulipLocalizations = ZulipLocalizations.of(messageListContext); + @override void onPressed() async { + final zulipLocalizations = ZulipLocalizations.of(pageContext); // This will be null only if the compose box disappeared after the // message action sheet opened, and before "Quote and reply" was pressed. // Currently a compose box can't ever disappear, so this is impossible. - ComposeBoxController composeBoxController = - MessageListPage.ancestorOf(messageListContext).composeBoxController!; + var composeBoxController = findMessageListPage().composeBoxController!; final topicController = composeBoxController.topicController; if ( topicController != null @@ -325,26 +336,28 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { ) { topicController.value = TextEditingValue(text: message.topic); } + + // This inserts a "[Quoting…]" placeholder into the content input, + // giving the user a form of progress feedback. final tag = composeBoxController.contentController - .registerQuoteAndReplyStart(PerAccountStoreWidget.of(messageListContext), + .registerQuoteAndReplyStart(PerAccountStoreWidget.of(pageContext), message: message, ); final rawContent = await fetchRawContentWithFeedback( - context: messageListContext, + context: pageContext, messageId: message.id, errorDialogTitle: zulipLocalizations.errorQuotationFailed, ); - if (!messageListContext.mounted) return; + if (!pageContext.mounted) return; // This will be null only if the compose box disappeared during the // quotation request. Currently a compose box can't ever disappear, // so this is impossible. - composeBoxController = - MessageListPage.ancestorOf(messageListContext).composeBoxController!; + composeBoxController = findMessageListPage().composeBoxController!; composeBoxController.contentController - .registerQuoteAndReplyEnd(PerAccountStoreWidget.of(messageListContext), tag, + .registerQuoteAndReplyEnd(PerAccountStoreWidget.of(pageContext), tag, message: message, rawContent: rawContent, ); @@ -355,14 +368,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { } class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { - MarkAsUnreadButton({ - super.key, - required super.message, - required super.messageListContext, - required this.narrow, - }); - - final Narrow narrow; + MarkAsUnreadButton({super.key, required super.message, required super.pageContext}); @override IconData get icon => Icons.mark_chat_unread_outlined; @@ -371,18 +377,14 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { return zulipLocalizations.actionSheetOptionMarkAsUnread; } - @override void onPressed(BuildContext context) async { - Navigator.of(context).pop(); - unawaited(markNarrowAsUnreadFromMessage(messageListContext, message, narrow)); + @override void onPressed() async { + final narrow = findMessageListPage().narrow; + unawaited(markNarrowAsUnreadFromMessage(pageContext, message, narrow)); } } class CopyMessageTextButton extends MessageActionSheetMenuItemButton { - CopyMessageTextButton({ - super.key, - required super.message, - required super.messageListContext, - }); + CopyMessageTextButton({super.key, required super.message, required super.pageContext}); @override IconData get icon => ZulipIcons.copy; @@ -391,35 +393,31 @@ class CopyMessageTextButton extends MessageActionSheetMenuItemButton { return zulipLocalizations.actionSheetOptionCopyMessageText; } - @override void onPressed(BuildContext context) async { - // Close the message action sheet. We won't be showing request progress, - // but hopefully it won't take long at all, and + @override void onPressed() async { + // This action doesn't show request progress. + // But hopefully it won't take long at all; and // fetchRawContentWithFeedback has a TODO for giving feedback if it does. - Navigator.of(context).pop(); - final zulipLocalizations = ZulipLocalizations.of(messageListContext); + + final zulipLocalizations = ZulipLocalizations.of(pageContext); final rawContent = await fetchRawContentWithFeedback( - context: messageListContext, + context: pageContext, messageId: message.id, errorDialogTitle: zulipLocalizations.errorCopyingFailed, ); if (rawContent == null) return; - if (!messageListContext.mounted) return; + if (!pageContext.mounted) return; - copyWithPopup(context: messageListContext, + copyWithPopup(context: pageContext, successContent: Text(zulipLocalizations.successMessageTextCopied), data: ClipboardData(text: rawContent)); } } class CopyMessageLinkButton extends MessageActionSheetMenuItemButton { - CopyMessageLinkButton({ - super.key, - required super.message, - required super.messageListContext, - }); + CopyMessageLinkButton({super.key, required super.message, required super.pageContext}); @override IconData get icon => Icons.link; @@ -428,29 +426,24 @@ class CopyMessageLinkButton extends MessageActionSheetMenuItemButton { return zulipLocalizations.actionSheetOptionCopyMessageLink; } - @override void onPressed(BuildContext context) { - Navigator.of(context).pop(); - final zulipLocalizations = ZulipLocalizations.of(messageListContext); + @override void onPressed() { + final zulipLocalizations = ZulipLocalizations.of(pageContext); - final store = PerAccountStoreWidget.of(messageListContext); + final store = PerAccountStoreWidget.of(pageContext); final messageLink = narrowLink( store, SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), nearMessageId: message.id, ); - copyWithPopup(context: messageListContext, + copyWithPopup(context: pageContext, successContent: Text(zulipLocalizations.successMessageLinkCopied), data: ClipboardData(text: messageLink.toString())); } } class ShareButton extends MessageActionSheetMenuItemButton { - ShareButton({ - super.key, - required super.message, - required super.messageListContext, - }); + ShareButton({super.key, required super.message, required super.pageContext}); @override IconData get icon => defaultTargetPlatform == TargetPlatform.iOS @@ -462,27 +455,27 @@ class ShareButton extends MessageActionSheetMenuItemButton { 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); + @override void onPressed() async { + // TODO(#591): Fix iOS bug where if the keyboard was open before the call + // to `showMessageActionSheet`, it reappears briefly between + // the `pop` of the action sheet and the appearance of the share sheet. + // + // (Alternatively we could delay the [NavigatorState.pop] that + // dismisses the action sheet until after the sharing Future settles + // with [ShareResultStatus.success]. But on iOS one gets impatient with + // how slowly our action sheet dismisses in that case.) + + final zulipLocalizations = ZulipLocalizations.of(pageContext); final rawContent = await fetchRawContentWithFeedback( - context: messageListContext, + context: pageContext, messageId: message.id, errorDialogTitle: zulipLocalizations.errorSharingFailed, ); if (rawContent == null) return; - if (!messageListContext.mounted) return; + if (!pageContext.mounted) return; // TODO: to support iPads, we're asked to give a // `sharePositionOrigin` param, or risk crashing / hanging: @@ -495,8 +488,8 @@ class ShareButton extends MessageActionSheetMenuItemButton { // 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; - showErrorDialog(context: messageListContext, + if (!pageContext.mounted) return; + showErrorDialog(context: pageContext, title: zulipLocalizations.errorSharingFailed); case ShareResultStatus.success: case ShareResultStatus.dismissed: diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 3081a6aa9a..aaf13c0b17 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/messages.dart'; @@ -25,6 +26,7 @@ import '../api/fake_api.dart'; import '../example_data.dart' as eg; import '../flutter_checks.dart'; import '../model/binding.dart'; +import '../model/message_list_test.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; @@ -33,6 +35,7 @@ import 'compose_box_checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; +late PerAccountStore store; late FakeApiConnection connection; /// Simulates loading a [MessageListPage] and long-pressing on [message]. @@ -43,7 +46,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUser(eg.user(userId: message.senderId)); if (message is StreamMessage) { final stream = eg.stream(streamId: message.streamId); @@ -52,16 +55,8 @@ Future setupToMessageActionSheet(WidgetTester tester, { } connection = store.connection as FakeApiConnection; - // prepare message list data - connection.prepare(json: GetMessagesResult( - anchor: message.id, - foundNewest: true, - foundOldest: true, - foundAnchor: true, - historyLimited: false, - messages: [message], - ).toJson()); - + connection.prepare(json: newestResult( + foundOldest: true, messages: [message]).toJson()); await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: MessageListPage(initNarrow: narrow))); @@ -78,7 +73,7 @@ void main() { TestZulipBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized(); - void prepareRawContentResponseSuccess(PerAccountStore store, { + void prepareRawContentResponseSuccess({ required Message message, required String rawContent, Duration delay = Duration.zero, @@ -86,17 +81,17 @@ void main() { // 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(delay: delay, json: + connection.prepare(delay: delay, json: GetMessageResult(message: eg.streamMessage(contentMarkdown: rawContent)).toJson()); } - void prepareRawContentResponseError(PerAccountStore store) { + void prepareRawContentResponseError() { final fakeResponseJson = { 'code': 'BAD_REQUEST', 'msg': 'Invalid message(s)', 'result': 'error', }; - (store.connection as FakeApiConnection).prepare(httpStatus: 400, json: fakeResponseJson); + connection.prepare(httpStatus: 400, json: fakeResponseJson); } group('AddThumbsUpButton', () { @@ -109,9 +104,7 @@ void main() { testWidgets('success', (tester) async { final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - final connection = store.connection as FakeApiConnection; connection.prepare(json: {}); await tapButton(tester); await tester.pump(Duration.zero); @@ -129,9 +122,6 @@ void main() { testWidgets('request has an error', (tester) async { final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - - final connection = store.connection as FakeApiConnection; connection.prepare(httpStatus: 400, json: { 'code': 'BAD_REQUEST', @@ -163,9 +153,7 @@ void main() { testWidgets('star success', (tester) async { final message = eg.streamMessage(flags: []); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - final connection = store.connection as FakeApiConnection; connection.prepare(json: {}); await tapButton(tester); await tester.pump(Duration.zero); @@ -183,9 +171,7 @@ void main() { testWidgets('unstar success', (tester) async { final message = eg.streamMessage(flags: [MessageFlag.starred]); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - final connection = store.connection as FakeApiConnection; connection.prepare(json: {}); await tapButton(tester, starred: true); await tester.pump(Duration.zero); @@ -203,11 +189,8 @@ void main() { testWidgets('star request has an error', (tester) async { final message = eg.streamMessage(flags: []); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final connection = store.connection as FakeApiConnection; - connection.prepare(httpStatus: 400, json: { 'code': 'BAD_REQUEST', 'msg': 'Invalid message(s)', @@ -224,11 +207,8 @@ void main() { testWidgets('unstar request has an error', (tester) async { final message = eg.streamMessage(flags: [MessageFlag.starred]); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final connection = store.connection as FakeApiConnection; - connection.prepare(httpStatus: 400, json: { 'code': 'BAD_REQUEST', 'msg': 'Invalid message(s)', @@ -297,7 +277,6 @@ void main() { testWidgets('in channel narrow', (tester) async { final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: ChannelNarrow(message.streamId)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); final composeBoxController = findComposeBoxController(tester)!; final contentController = composeBoxController.contentController; @@ -309,7 +288,7 @@ void main() { topicController?.value = const TextEditingValue(text: kNoTopicTopic); final valueBefore = contentController.value; - prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapQuoteAndReplyButton(tester); checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); await tester.pump(Duration.zero); // message is fetched; compose box updates @@ -321,13 +300,12 @@ void main() { testWidgets('in topic narrow', (tester) async { final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); final composeBoxController = findComposeBoxController(tester)!; final contentController = composeBoxController.contentController; final valueBefore = contentController.value; - prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapQuoteAndReplyButton(tester); checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); await tester.pump(Duration.zero); // message is fetched; compose box updates @@ -340,13 +318,12 @@ void main() { final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]); await setupToMessageActionSheet(tester, message: message, narrow: DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); final composeBoxController = findComposeBoxController(tester)!; final contentController = composeBoxController.contentController; final valueBefore = contentController.value; - prepareRawContentResponseSuccess(store, message: message, rawContent: 'Hello world'); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapQuoteAndReplyButton(tester); checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); await tester.pump(Duration.zero); // message is fetched; compose box updates @@ -358,13 +335,12 @@ void main() { testWidgets('request has an error', (tester) async { final message = eg.streamMessage(); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); final composeBoxController = findComposeBoxController(tester)!; final contentController = composeBoxController.contentController; final valueBefore = contentController.value = TextEditingValue.empty; - prepareRawContentResponseError(store); + prepareRawContentResponseError(); await tapQuoteAndReplyButton(tester); checkLoadingState(store, contentController, valueBefore: valueBefore, message: message); await tester.pump(Duration.zero); // error arrives; error dialog shows @@ -445,6 +421,48 @@ void main() { }); }); + testWidgets('on topic move, acts on new topic', (tester) async { + final stream = eg.stream(); + const topic = 'old topic'; + final message = eg.streamMessage(flags: [MessageFlag.read], + stream: stream, topic: topic); + await setupToMessageActionSheet(tester, message: message, + narrow: TopicNarrow.ofMessage(message)); + + // Get the action sheet fully deployed while the old narrow applies. + // (This way we maximize the range of potential bugs this test can catch, + // by giving the code maximum opportunity to latch onto the old topic.) + await tester.pumpAndSettle(); + + final newStream = eg.stream(); + const newTopic = 'other topic'; + // This result isn't quite realistic for this request: it should get + // the updated channel/stream ID and topic, because we don't even + // start the request until after we get the move event. + // But constructing the right result is annoying at the moment, and + // it doesn't matter anyway: [MessageStoreImpl.reconcileMessages] will + // keep the version updated by the event. If that somehow changes in + // some future refactor, it'll cause this test to fail. + connection.prepare(json: newestResult( + foundOldest: true, messages: [message]).toJson()); + await store.handleEvent(eg.updateMessageEventMoveFrom( + newStreamId: newStream.streamId, newTopic: newTopic, + propagateMode: PropagateMode.changeAll, + origMessages: [message])); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: 1, lastProcessedId: 1980, + foundOldest: true, foundNewest: true).toJson()); + await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.pumpAndSettle(); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields['narrow'].equals( + jsonEncode(TopicNarrow(newStream.streamId, newTopic).apiEncode())); + }); + testWidgets('shows error when fails', (tester) async { final message = eg.streamMessage(flags: [MessageFlag.read]); await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); @@ -479,9 +497,8 @@ void main() { testWidgets('success', (tester) async { 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'); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapCopyMessageTextButton(tester); await tester.pump(Duration.zero); check(await Clipboard.getData('text/plain')).isNotNull().text.equals('Hello world'); @@ -493,10 +510,9 @@ void main() { 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', + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world', delay: const Duration(milliseconds: 500)); await tapCopyMessageTextButton(tester); // … and pump a frame to finish the NavigationState.pop animation… @@ -515,9 +531,8 @@ void main() { testWidgets('request has an error', (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); + prepareRawContentResponseError(); await tapCopyMessageTextButton(tester); await tester.pump(Duration.zero); // error arrives; error dialog shows @@ -547,7 +562,6 @@ void main() { 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); @@ -577,9 +591,8 @@ void main() { 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'); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); await tapShareButton(tester); await tester.pump(Duration.zero); check(mockSharePlus.sharedString).equals('Hello world'); @@ -589,9 +602,8 @@ void main() { 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'); + prepareRawContentResponseSuccess(message: message, rawContent: 'Hello world'); mockSharePlus.resultString = 'dev.fluttercommunity.plus/share/unavailable'; await tapShareButton(tester); await tester.pump(Duration.zero); @@ -605,9 +617,8 @@ void main() { 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); + prepareRawContentResponseError(); await tapShareButton(tester); await tester.pump(Duration.zero); // error arrives; error dialog shows