diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index c4e9d6e5c6..26cd786315 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -51,6 +51,10 @@ "@actionSheetOptionCopyMessageLink": { "description": "Label for copy message link button on action sheet." }, + "actionSheetOptionMarkAsUnread": "Mark as unread from here", + "@actionSheetOptionMarkAsUnread": { + "description": "Label for mark as unread button on action sheet." + }, "actionSheetOptionShare": "Share", "@actionSheetOptionShare": { "description": "Label for share button on action sheet." @@ -436,6 +440,21 @@ "@errorMarkAsReadFailedTitle": { "description": "Error title when mark as read action failed." }, + "markAsUnreadComplete": "Marked {num, plural, =1{1 message} other{{num} messages}} as unread.", + "@markAsUnreadComplete": { + "description": "Message when marking messages as unread has completed.", + "placeholders": { + "num": {"type": "int", "example": "4"} + } + }, + "markAsUnreadInProgress": "Marking messages as unread...", + "@markAsUnreadInProgress": { + "description": "Progress message when marking messages as unread." + }, + "errorMarkAsUnreadFailedTitle": "Mark as unread failed", + "@errorMarkAsUnreadFailedTitle": { + "description": "Error title when mark as unread action failed." + }, "today": "Today", "@today": { "description": "Term to use to reference the current day." diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 9ff17385ec..faa3d5f011 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -8,6 +8,7 @@ import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../model/internal_link.dart'; import '../model/narrow.dart'; +import 'actions.dart'; import 'clipboard.dart'; import 'compose_box.dart'; import 'dialog.dart'; @@ -28,6 +29,10 @@ void showMessageActionSheet({required BuildContext context, required Message mes // any message list, so that's fine. 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; final hasThumbsUpReactionVote = message.reactions ?.aggregated.any((reactionWithVotes) => @@ -46,6 +51,11 @@ void showMessageActionSheet({required BuildContext context, required Message mes message: message, messageListContext: 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), @@ -278,6 +288,29 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { } } +class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { + MarkAsUnreadButton({ + super.key, + required super.message, + required super.messageListContext, + required this.narrow, + }); + + final Narrow narrow; + + @override IconData get icon => Icons.mark_chat_unread_outlined; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionMarkAsUnread; + } + + @override void onPressed(BuildContext context) async { + Navigator.of(context).pop(); + markNarrowAsUnreadFromMessage(messageListContext, message, narrow); + } +} + class CopyMessageTextButton extends MessageActionSheetMenuItemButton { CopyMessageTextButton({ super.key, diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart index 553e977598..a18ab135cb 100644 --- a/lib/widgets/actions.dart +++ b/lib/widgets/actions.dart @@ -16,101 +16,174 @@ import '../model/narrow.dart'; import 'dialog.dart'; import 'store.dart'; -Future markNarrowAsRead( - BuildContext context, - Narrow narrow, - bool useLegacy, // TODO(server-6) -) async { +Future markNarrowAsRead(BuildContext context, Narrow narrow) async { final store = PerAccountStoreWidget.of(context); final connection = store.connection; + final zulipLocalizations = ZulipLocalizations.of(context); + final useLegacy = connection.zulipFeatureLevel! < 155; // TODO(server-6) if (useLegacy) { - return await _legacyMarkNarrowAsRead(context, narrow); + try { + await _legacyMarkNarrowAsRead(context, narrow); + return; + } catch (e) { + if (!context.mounted) return; + await showErrorDialog(context: context, + title: zulipLocalizations.errorMarkAsReadFailedTitle, + message: e.toString()); // TODO(#741): extract user-facing message better + return; + } + } + + final didPass = await updateMessageFlagsStartingFromAnchor( + context: context, + // Include `is:unread` in the narrow. That has a database index, so + // this can be an important optimization in narrows with a lot of history. + // The server applies the same optimization within the (deprecated) + // specialized endpoints for marking messages as read; see + // `do_mark_stream_messages_as_read` in `zulip:zerver/actions/message_flags.py`. + apiNarrow: narrow.apiEncode()..add(ApiNarrowIsUnread()), + // Use [AnchorCode.oldest], because [AnchorCode.firstUnread] + // will be the oldest non-muted unread message, which would + // result in muted unreads older than the first unread not + // being processed. + anchor: AnchorCode.oldest, + // [AnchorCode.oldest] is an anchor ID lower than any valid + // message ID. + includeAnchor: false, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read, + onCompletedMessage: zulipLocalizations.markAsReadComplete, + progressMessage: zulipLocalizations.markAsReadInProgress, + onFailedTitle: zulipLocalizations.errorMarkAsReadFailedTitle); + + if (!didPass || !context.mounted) return; + if (narrow is CombinedFeedNarrow) { + PerAccountStoreWidget.of(context).unreads.handleAllMessagesReadSuccess(); } +} - // Compare web's `mark_all_as_read` in web/src/unread_ops.js - // and zulip-mobile's `markAsUnreadFromMessage` in src/action-sheets/index.js . +Future markNarrowAsUnreadFromMessage( + BuildContext context, + Message message, + Narrow narrow, +) async { + final connection = PerAccountStoreWidget.of(context).connection; + assert(connection.zulipFeatureLevel! >= 155); // TODO(server-6) final zulipLocalizations = ZulipLocalizations.of(context); - final scaffoldMessenger = ScaffoldMessenger.of(context); - // Use [AnchorCode.oldest], because [AnchorCode.firstUnread] - // will be the oldest non-muted unread message, which would - // result in muted unreads older than the first unread not - // being processed. - Anchor anchor = AnchorCode.oldest; - int responseCount = 0; - int updatedCount = 0; + await updateMessageFlagsStartingFromAnchor( + context: context, + apiNarrow: narrow.apiEncode(), + anchor: NumericAnchor(message.id), + includeAnchor: true, + op: UpdateMessageFlagsOp.remove, + flag: MessageFlag.read, + onCompletedMessage: zulipLocalizations.markAsUnreadComplete, + progressMessage: zulipLocalizations.markAsUnreadInProgress, + onFailedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle); +} - // Include `is:unread` in the narrow. That has a database index, so - // this can be an important optimization in narrows with a lot of history. - // The server applies the same optimization within the (deprecated) - // specialized endpoints for marking messages as read; see - // `do_mark_stream_messages_as_read` in `zulip:zerver/actions/message_flags.py`. - final apiNarrow = narrow.apiEncode()..add(ApiNarrowIsUnread()); +/// Add or remove the given flag from the anchor to the end of the narrow, +/// showing feedback to the user on progress or failure. +/// +/// This has the semantics of [updateMessageFlagsForNarrow] +/// (see https://zulip.com/api/update-message-flags-for-narrow) +/// with `numBefore: 0` and infinite `numAfter`. It operates by calling that +/// endpoint with a finite `numAfter` as a batch size, in a loop. +/// +/// If the operation requires more than one batch, the user is shown progress +/// feedback through [SnackBar], using [progressMessage] and [onCompletedMessage]. +/// If the operation fails, the user is shown an error dialog box with title +/// [onFailedTitle]. +/// +/// Returns true just if the operation finished successfully. +Future updateMessageFlagsStartingFromAnchor({ + required BuildContext context, + required List apiNarrow, + required Anchor anchor, + required bool includeAnchor, + required UpdateMessageFlagsOp op, + required MessageFlag flag, + required String Function(int) onCompletedMessage, + required String progressMessage, + required String onFailedTitle, +}) async { + try { + final store = PerAccountStoreWidget.of(context); + final connection = store.connection; + final scaffoldMessenger = ScaffoldMessenger.of(context); - while (true) { - final result = await updateMessageFlagsForNarrow(connection, - anchor: anchor, - // [AnchorCode.oldest] is an anchor ID lower than any valid - // message ID; and follow-up requests will have already - // processed the anchor ID, so we just want this to be - // unconditionally false. - includeAnchor: false, - // There is an upper limit of 5000 messages per batch - // (numBefore + numAfter <= 5000) enforced on the server. - // See `update_message_flags_in_narrow` in zerver/views/message_flags.py . - // zulip-mobile uses `numAfter` of 5000, but web uses 1000 - // for more responsive feedback. See zulip@f0d87fcf6. - numBefore: 0, - numAfter: 1000, - narrow: apiNarrow, - op: UpdateMessageFlagsOp.add, - flag: MessageFlag.read); - if (!context.mounted) { - scaffoldMessenger.clearSnackBars(); - return; - } - responseCount++; - updatedCount += result.updatedCount; + // Compare web's `mark_all_as_read` in web/src/unread_ops.js + // and zulip-mobile's `markAsUnreadFromMessage` in src/action-sheets/index.js . + int responseCount = 0; + int updatedCount = 0; + while (true) { + final result = await updateMessageFlagsForNarrow(connection, + anchor: anchor, + includeAnchor: includeAnchor, + // There is an upper limit of 5000 messages per batch + // (numBefore + numAfter <= 5000) enforced on the server. + // See `update_message_flags_in_narrow` in zerver/views/message_flags.py . + // zulip-mobile uses `numAfter` of 5000, but web uses 1000 + // for more responsive feedback. See zulip@f0d87fcf6. + numBefore: 0, + numAfter: 1000, + narrow: apiNarrow, + op: op, + flag: flag); + if (!context.mounted) { + scaffoldMessenger.clearSnackBars(); + return false; + } + responseCount++; + updatedCount += result.updatedCount; - if (result.foundNewest) { - if (responseCount > 1) { - // We previously showed an in-progress [SnackBar], so say we're done. - // There may be a backlog of [SnackBar]s accumulated in the queue - // so be sure to clear them out here. - scaffoldMessenger - ..clearSnackBars() - ..showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, - content: Text(zulipLocalizations.markAsReadComplete(updatedCount)))); + if (result.foundNewest) { + if (responseCount > 1) { + // We previously showed an in-progress [SnackBar], so say we're done. + // There may be a backlog of [SnackBar]s accumulated in the queue + // so be sure to clear them out here. + scaffoldMessenger + ..clearSnackBars() + ..showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, + content: Text(onCompletedMessage(updatedCount)))); + } + return true; } - return; - } - if (result.lastProcessedId == null) { - // No messages were in the range of the request. - // This should be impossible given that `foundNewest` was false - // (and that our `numAfter` was positive.) - await showErrorDialog(context: context, - title: zulipLocalizations.errorMarkAsReadFailedTitle, - message: zulipLocalizations.errorInvalidResponse); - return; - } - anchor = NumericAnchor(result.lastProcessedId!); + if (result.lastProcessedId == null) { + final zulipLocalizations = ZulipLocalizations.of(context); + // No messages were in the range of the request. + // This should be impossible given that `foundNewest` was false + // (and that our `numAfter` was positive.) + showErrorDialog(context: context, + title: onFailedTitle, + message: zulipLocalizations.errorInvalidResponse); + return false; + } + anchor = NumericAnchor(result.lastProcessedId!); + includeAnchor = false; - // The task is taking a while, so tell the user we're working on it. - // No need to say how many messages, as the [MarkAsUnread] widget - // should follow along. - // TODO: Ideally we'd have a progress widget here that showed up based - // on actual time elapsed -- so it could appear before the first - // batch returns, if that takes a while -- and that then stuck - // around continuously until the task ends. For now we use a - // series of [SnackBar]s, which may feel a bit janky. - // There is complexity in tracking the status of each [SnackBar], - // due to having no way to determine which is currently active, - // or if there is an active one at all. Resetting the [SnackBar] here - // results in the same message popping in and out and the user experience - // is better for now if we allow them to run their timer through - // and clear the backlog later. - scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, - content: Text(zulipLocalizations.markAsReadInProgress))); + // The task is taking a while, so tell the user we're working on it. + // TODO: Ideally we'd have a progress widget here that showed up based + // on actual time elapsed -- so it could appear before the first + // batch returns, if that takes a while -- and that then stuck + // around continuously until the task ends. For now we use a + // series of [SnackBar]s, which may feel a bit janky. + // There is complexity in tracking the status of each [SnackBar], + // due to having no way to determine which is currently active, + // or if there is an active one at all. Resetting the [SnackBar] here + // results in the same message popping in and out and the user experience + // is better for now if we allow them to run their timer through + // and clear the backlog later. + scaffoldMessenger.showSnackBar(SnackBar(behavior: SnackBarBehavior.floating, + content: Text(progressMessage))); + } + } catch (e) { + if (!context.mounted) return false; + showErrorDialog(context: context, + title: onFailedTitle, + message: e.toString()); // TODO(#741): extract user-facing message better + return false; } } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 2cdd4b1689..904b3892db 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -16,7 +16,6 @@ import 'actions.dart'; import 'app_bar.dart'; import 'compose_box.dart'; import 'content.dart'; -import 'dialog.dart'; import 'emoji_reaction.dart'; import 'icons.dart'; import 'page.dart'; @@ -709,28 +708,9 @@ class _MarkAsReadWidgetState extends State { void _handlePress(BuildContext context) async { if (!context.mounted) return; - - final store = PerAccountStoreWidget.of(context); - final connection = store.connection; - final useLegacy = connection.zulipFeatureLevel! < 155; setState(() => _loading = true); - - try { - await markNarrowAsRead(context, widget.narrow, useLegacy); - } catch (e) { - if (!context.mounted) return; - final zulipLocalizations = ZulipLocalizations.of(context); - showErrorDialog(context: context, - title: zulipLocalizations.errorMarkAsReadFailedTitle, - message: e.toString()); // TODO(#741): extract user-facing message better - return; - } finally { - setState(() => _loading = false); - } - if (!context.mounted) return; - if (widget.narrow is CombinedFeedNarrow && !useLegacy) { - PerAccountStoreWidget.of(context).unreads.handleAllMessagesReadSuccess(); - } + await markNarrowAsRead(context, widget.narrow); + setState(() => _loading = false); } @override diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 85e0dd3f66..07c9017a69 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -31,6 +31,8 @@ import 'compose_box_checks.dart'; import 'dialog_checks.dart'; import 'test_app.dart'; +late FakeApiConnection connection; + /// Simulates loading a [MessageListPage] and long-pressing on [message]. Future setupToMessageActionSheet(WidgetTester tester, { required Message message, @@ -46,7 +48,7 @@ Future setupToMessageActionSheet(WidgetTester tester, { await store.addStream(stream); await store.addSubscription(eg.subscription(stream)); } - final connection = store.connection as FakeApiConnection; + connection = store.connection as FakeApiConnection; // prepare message list data connection.prepare(json: GetMessagesResult( @@ -385,6 +387,65 @@ void main() { }); }); + group('MarkAsUnread', () { + testWidgets('not visible if message is not read', (tester) async { + final unreadMessage = eg.streamMessage(flags: []); + await setupToMessageActionSheet(tester, message: unreadMessage, narrow: TopicNarrow.ofMessage(unreadMessage)); + + check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).isEmpty(); + }); + + testWidgets('visible if message is read', (tester) async { + final readMessage = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: readMessage, narrow: TopicNarrow.ofMessage(readMessage)); + + check(find.byIcon(Icons.mark_chat_unread_outlined).evaluate()).single; + }); + + group('onPressed', () { + testWidgets('smoke test', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: 1, lastProcessedId: 1980, + foundOldest: true, foundNewest: true).toJson()); + + await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + 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.deepEquals({ + 'anchor': '${message.id}', + 'include_anchor': 'true', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(TopicNarrow.ofMessage(message).apiEncode()), + 'op': 'remove', + 'flag': 'read', + }); + }); + + testWidgets('shows error when fails', (tester) async { + final message = eg.streamMessage(flags: [MessageFlag.read]); + await setupToMessageActionSheet(tester, message: message, narrow: TopicNarrow.ofMessage(message)); + + connection.prepare(exception: http.ClientException('Oops')); + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + await tester.ensureVisible(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.tap(find.byIcon(Icons.mark_chat_unread_outlined, skipOffstage: false)); + await tester.pumpAndSettle(); + checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorMarkAsUnreadFailedTitle, + expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); + }); + }); + }); + group('CopyMessageTextButton', () { setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( diff --git a/test/widgets/actions_test.dart b/test/widgets/actions_test.dart index d049031180..32ca563f9f 100644 --- a/test/widgets/actions_test.dart +++ b/test/widgets/actions_test.dart @@ -24,27 +24,27 @@ import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); - group('markNarrowAsRead', () { - late PerAccountStore store; - late FakeApiConnection connection; - late BuildContext context; + late PerAccountStore store; + late FakeApiConnection connection; + late BuildContext context; - Future prepare(WidgetTester tester, { - UnreadMessagesSnapshot? unreadMsgs, - }) async { - addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( - unreadMsgs: unreadMsgs)); - store = await testBinding.globalStore.perAccount(eg.selfAccount.id); - connection = store.connection as FakeApiConnection; + Future prepare(WidgetTester tester, { + UnreadMessagesSnapshot? unreadMsgs, + }) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot( + unreadMsgs: unreadMsgs)); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; - await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, - child: const Scaffold(body: Placeholder()))); - // global store, per-account store get loaded - await tester.pumpAndSettle(); - context = tester.element(find.byType(Placeholder)); - } + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, + child: const Scaffold(body: Placeholder()))); + // global store, per-account store get loaded + await tester.pumpAndSettle(); + context = tester.element(find.byType(Placeholder)); + } + group('markNarrowAsRead', () { testWidgets('smoke test on modern server', (tester) async { final narrow = TopicNarrow.ofMessage(eg.streamMessage()); await prepare(tester); @@ -52,7 +52,7 @@ void main() { processedCount: 11, updatedCount: 3, firstProcessedId: null, lastProcessedId: null, foundOldest: true, foundNewest: true).toJson()); - markNarrowAsRead(context, narrow, false); + markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); final apiNarrow = narrow.apiEncode()..add(ApiNarrowIsUnread()); check(connection.lastRequest).isA() @@ -69,7 +69,6 @@ void main() { }); }); - testWidgets('use is:unread optimization', (tester) async { const narrow = CombinedFeedNarrow(); await prepare(tester); @@ -77,7 +76,7 @@ void main() { processedCount: 11, updatedCount: 3, firstProcessedId: null, lastProcessedId: null, foundOldest: true, foundNewest: true).toJson()); - markNarrowAsRead(context, narrow, false); + markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); check(connection.lastRequest).isA() ..method.equals('POST') @@ -93,51 +92,6 @@ void main() { }); }); - testWidgets('pagination', (tester) async { - // Check that `lastProcessedId` returned from an initial - // response is used as `anchorId` for the subsequent request. - final narrow = TopicNarrow.ofMessage(eg.streamMessage()); - await prepare(tester); - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 1000, updatedCount: 890, - firstProcessedId: 1, lastProcessedId: 1989, - foundOldest: true, foundNewest: false).toJson()); - markNarrowAsRead(context, narrow, false); - final apiNarrow = narrow.apiEncode()..add(ApiNarrowIsUnread()); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 20, updatedCount: 10, - firstProcessedId: 2000, lastProcessedId: 2023, - foundOldest: false, foundNewest: true).toJson()); - await tester.pumpAndSettle(); - check(find.bySubtype().evaluate()).length.equals(1); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': '1989', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - }); - testWidgets('on mark-all-as-read when Unreads.oldUnreadsMissing: true', (tester) async { const narrow = CombinedFeedNarrow(); await prepare(tester); @@ -147,41 +101,10 @@ void main() { processedCount: 11, updatedCount: 3, firstProcessedId: null, lastProcessedId: null, foundOldest: true, foundNewest: true).toJson()); - markNarrowAsRead(context, narrow, false); + markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); await tester.pumpAndSettle(); check(store.unreads.oldUnreadsMissing).isFalse(); - }, skip: true, // TODO move this functionality inside markNarrowAsRead - ); - - testWidgets('on invalid response', (tester) async { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final narrow = TopicNarrow.ofMessage(eg.streamMessage()); - await prepare(tester); - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 1000, updatedCount: 0, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: false).toJson()); - markNarrowAsRead(context, narrow, false); - await tester.pump(Duration.zero); - final apiNarrow = narrow.apiEncode()..add(ApiNarrowIsUnread()); - check(connection.lastRequest).isA() - ..method.equals('POST') - ..url.path.equals('/api/v1/messages/flags/narrow') - ..bodyFields.deepEquals({ - 'anchor': 'oldest', - 'include_anchor': 'false', - 'num_before': '0', - 'num_after': '1000', - 'narrow': jsonEncode(apiNarrow), - 'op': 'add', - 'flag': 'read', - }); - - await tester.pumpAndSettle(); - checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorMarkAsReadFailedTitle, - expectedMessage: zulipLocalizations.errorInvalidResponse); }); testWidgets('CombinedFeedNarrow on legacy server', (tester) async { @@ -192,7 +115,7 @@ void main() { connection.zulipFeatureLevel = 154; connection.prepare(json: {}); - markNarrowAsRead(context, narrow, true); // TODO move legacy-server check inside markNarrowAsRead + markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); check(connection.lastRequest).isA() ..method.equals('POST') @@ -210,7 +133,7 @@ void main() { await prepare(tester); connection.zulipFeatureLevel = 154; connection.prepare(json: {}); - markNarrowAsRead(context, narrow, true); // TODO move legacy-server check inside markNarrowAsRead + markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); check(connection.lastRequest).isA() ..method.equals('POST') @@ -225,7 +148,7 @@ void main() { await prepare(tester); connection.zulipFeatureLevel = 154; connection.prepare(json: {}); - markNarrowAsRead(context, narrow, true); // TODO move legacy-server check inside markNarrowAsRead + markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); check(connection.lastRequest).isA() ..method.equals('POST') @@ -247,7 +170,7 @@ void main() { connection.zulipFeatureLevel = 154; connection.prepare(json: UpdateMessageFlagsResult(messages: [message.id]).toJson()); - markNarrowAsRead(context, narrow, true); // TODO move legacy-server check inside markNarrowAsRead + markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); check(connection.lastRequest).isA() ..method.equals('POST') @@ -267,7 +190,7 @@ void main() { connection.zulipFeatureLevel = 154; connection.prepare(json: UpdateMessageFlagsResult(messages: [message.id]).toJson()); - markNarrowAsRead(context, narrow, true); // TODO move legacy-server check inside markNarrowAsRead + markNarrowAsRead(context, narrow); await tester.pump(Duration.zero); check(connection.lastRequest).isA() ..method.equals('POST') @@ -278,19 +201,133 @@ void main() { 'flag': 'read', }); }); + }); - testWidgets('catch-all api errors', (tester) async { + group('updateMessageFlagsStartingFromAnchor', () { + String onCompletedMessage(int count) => 'onCompletedMessage($count)'; + const progressMessage = 'progressMessage'; + const onFailedTitle = 'onFailedTitle'; + final narrow = TopicNarrow.ofMessage(eg.streamMessage()); + final apiNarrow = narrow.apiEncode()..add(ApiNarrowIsUnread()); + + Future invokeUpdateMessageFlagsStartingFromAnchor() => + updateMessageFlagsStartingFromAnchor( + context: context, + apiNarrow: apiNarrow, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read, + includeAnchor: false, + anchor: AnchorCode.oldest, + onCompletedMessage: onCompletedMessage, + onFailedTitle: onFailedTitle, + progressMessage: progressMessage); + + testWidgets('smoke test', (tester) async { + await prepare(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: 1, lastProcessedId: 1980, + foundOldest: true, foundNewest: true).toJson()); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + await tester.pump(Duration.zero); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(apiNarrow), + 'op': 'add', + 'flag': 'read', + }); + check(await didPass).isTrue(); + }); + + testWidgets('pagination', (tester) async { + // Check that `lastProcessedId` returned from an initial + // response is used as `anchorId` for the subsequent request. + await prepare(tester); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1000, updatedCount: 890, + firstProcessedId: 1, lastProcessedId: 1989, + foundOldest: true, foundNewest: false).toJson()); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(apiNarrow), + 'op': 'add', + 'flag': 'read', + }); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 20, updatedCount: 10, + firstProcessedId: 2000, lastProcessedId: 2023, + foundOldest: false, foundNewest: true).toJson()); + await tester.pumpAndSettle(); + check(find.bySubtype().evaluate()).length.equals(1); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': '1989', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(apiNarrow), + 'op': 'add', + 'flag': 'read', + }); + check(await didPass).isTrue(); + }); + + testWidgets('on invalid response', (tester) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - const narrow = CombinedFeedNarrow(); + await prepare(tester); + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1000, updatedCount: 0, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: false).toJson()); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); + await tester.pump(Duration.zero); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags/narrow') + ..bodyFields.deepEquals({ + 'anchor': 'oldest', + 'include_anchor': 'false', + 'num_before': '0', + 'num_after': '1000', + 'narrow': jsonEncode(apiNarrow), + 'op': 'add', + 'flag': 'read', + }); + + await tester.pumpAndSettle(); + checkErrorDialog(tester, + expectedTitle: onFailedTitle, + expectedMessage: zulipLocalizations.errorInvalidResponse); + check(await didPass).isFalse(); + }); + + testWidgets('catch-all api errors', (tester) async { await prepare(tester); connection.prepare(exception: http.ClientException('Oops')); - markNarrowAsRead(context, narrow, false); + final didPass = invokeUpdateMessageFlagsStartingFromAnchor(); await tester.pump(Duration.zero); await tester.pumpAndSettle(); checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorMarkAsReadFailedTitle, + expectedTitle: onFailedTitle, expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); - }, skip: true, // TODO move this functionality inside markNarrowAsRead - ); + check(await didPass).isFalse(); + }); }); }