From e7fe06cbcf49c96d1daf54c3654eca1d5d3d6c32 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Fri, 27 Oct 2023 13:42:18 +0100 Subject: [PATCH] msglist: Add MarkAsRead widget --- assets/l10n/app_en.arb | 26 +++ lib/widgets/message_list.dart | 185 ++++++++++++++++- test/widgets/message_list_test.dart | 303 +++++++++++++++++++++++++++- 3 files changed, 507 insertions(+), 7 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 584db9c688..8a1ba67682 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -289,6 +289,10 @@ "num": {"type": "int", "example": "4"} } }, + "errorInvalidResponse": "The server sent an invalid response", + "@errorInvalidResponse": { + "description": "Error message when an API call returned an invalid response." + }, "errorNetworkRequestFailed": "Network request failed", "@errorNetworkRequestFailed": { "description": "Error message when a network request fails." @@ -323,6 +327,28 @@ "@serverUrlValidationErrorUnsupportedScheme": { "description": "Error message when URL has an unsupported scheme." }, + "markAsReadLabel": "Mark {num, plural, =1{1 message} other{{num} messages}} as read", + "@markAsReadLabel": { + "description": "Button text to mark messages as read.", + "placeholders": { + "num": {"type": "int", "example": "4"} + } + }, + "markAsReadComplete": "Marked {num, plural, =1{1 message} other{{num} messages}} as read.", + "@markAsReadComplete": { + "description": "Message when marking messages as read has completed.", + "placeholders": { + "num": {"type": "int", "example": "4"} + } + }, + "markAsReadInProgress": "Marking messages as read...", + "@markAsReadInProgress": { + "description": "Progress message when marking messages as read." + }, + "errorMarkAsReadFailedTitle": "Mark as read failed", + "@errorMarkAsReadFailedTitle": { + "description": "Error title when mark as read action failed." + }, "userRoleOwner": "Owner", "@userRoleOwner": { "description": "Label for UserRole.owner" diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index d6af914be2..85806968e8 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2,15 +2,19 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:intl/intl.dart'; import '../api/model/model.dart'; +import '../api/model/narrow.dart'; +import '../api/route/messages.dart'; import '../model/message_list.dart'; import '../model/narrow.dart'; import '../model/store.dart'; import 'action_sheet.dart'; import 'compose_box.dart'; import 'content.dart'; +import 'dialog.dart'; import 'icons.dart'; import 'page.dart'; import 'profile.dart'; @@ -274,10 +278,10 @@ class _MessageListState extends State with PerAccountStoreAwareStat final valueKey = key as ValueKey; final index = model!.findItemWithMessageId(valueKey.value); if (index == -1) return null; - return length - 1 - index; + return length - 1 - (index - 1); }, controller: scrollController, - itemCount: length, + itemCount: length + 1, // Setting reverse: true means the scroll starts at the bottom. // Flipping the indexes (in itemBuilder) means the start/bottom // has the latest messages. @@ -286,7 +290,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat // TODO on new message when scrolled up, anchor scroll to what's in view reverse: true, itemBuilder: (context, i) { - final data = model!.items[length - 1 - i]; + if (i == 0) return MarkAsReadWidget(narrow: widget.narrow); + + final data = model!.items[length - 1 - (i - 1)]; switch (data) { case MessageListHistoryStartItem(): return const Center( @@ -305,7 +311,7 @@ class _MessageListState extends State with PerAccountStoreAwareStat case MessageListMessageItem(): return MessageItem( key: ValueKey(data.message.id), - trailing: i == 0 ? const SizedBox(height: 8) : const SizedBox(height: 11), + trailing: i == 1 ? const SizedBox(height: 8) : const SizedBox(height: 11), item: data); } }); @@ -345,6 +351,60 @@ class ScrollToBottomButton extends StatelessWidget { } } +class MarkAsReadWidget extends StatelessWidget { + const MarkAsReadWidget({super.key, required this.narrow}); + + final Narrow narrow; + + void _handlePress(BuildContext context) async { + if (!context.mounted) return; + try { + await markNarrowAsRead(context, narrow); + } catch (e) { + if (!context.mounted) return; + final zulipLocalizations = ZulipLocalizations.of(context); + await showErrorDialog(context: context, + title: zulipLocalizations.errorMarkAsReadFailedTitle, + message: e.toString()); + } + // TODO: clear Unreads.oldUnreadsMissing when `narrow` is [AllMessagesNarrow] + // In the rare case that the user had more than 50K total unreads + // on the server, the client won't have known about all of them; + // this was communicated to the client via `oldUnreadsMissing`. + // + // However, since we successfully marked **everything** as read, + // we know that we now have a correct data set of unreads. + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final store = PerAccountStoreWidget.of(context); + final unreadCount = store.unreads.countInNarrow(narrow); + return AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: (unreadCount > 0) ? CrossFadeState.showSecond : CrossFadeState.showFirst, + firstChild: const SizedBox.shrink(), + secondChild: SizedBox(width: double.infinity, + // Design referenced from: + // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=132-9684&mode=design&t=jJwHzloKJ0TMOG4M-0 + child: ColoredBox( + // TODO(#368): this should pull from stream color + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.all(10), + child: FilledButton.icon( + style: FilledButton.styleFrom( + padding: const EdgeInsets.all(10), + textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w200), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), + ), + onPressed: () => _handlePress(context), + icon: const Icon(Icons.playlist_add_check), + label: Text(zulipLocalizations.markAsReadLabel(unreadCount))))))); + } +} + class RecipientHeader extends StatelessWidget { const RecipientHeader({super.key, required this.message}); @@ -635,3 +695,120 @@ final _kMessageTimestampStyle = TextStyle( fontSize: 12, fontWeight: FontWeight.w400, color: const HSLColor.fromAHSL(0.4, 0, 0, 0.2).toColor()); + +Future markNarrowAsRead(BuildContext context, Narrow narrow) async { + final store = PerAccountStoreWidget.of(context); + final connection = store.connection; + if (connection.zulipFeatureLevel! < 155) { // TODO(server-6) + return await _legacyMarkNarrowAsRead(context, narrow); + } + + // Compare web's `mark_all_as_read` in web/src/unread_ops.js + // and zulip-mobile's `markAsUnreadFromMessage` in src/action-sheets/index.js . + 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; + + final apiNarrow = switch (narrow) { + // Since there's a database index on is:unread, it's a fast + // search query and thus worth using as an optimization + // when processing all messages. + AllMessagesNarrow() => [ApiNarrowIsUnread()], + _ => narrow.apiEncode(), + }; + 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; + + 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)))); + } + 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!); + + // 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))); + } +} + +Future _legacyMarkNarrowAsRead(BuildContext context, Narrow narrow) async { + final store = PerAccountStoreWidget.of(context); + final connection = store.connection; + switch (narrow) { + case AllMessagesNarrow(): + await markAllAsRead(connection); + case StreamNarrow(:final streamId): + await markStreamAsRead(connection, streamId: streamId); + case TopicNarrow(:final streamId, :final topic): + await markTopicAsRead(connection, streamId: streamId, topicName: topic); + case DmNarrow(): + final unreadDms = store.unreads.dms[narrow]; + // Silently ignore this race-condition as the outcome + // (no unreads in this narrow) was the desired end-state + // of pushing the button. + if (unreadDms == null) return; + await updateMessageFlags(connection, + messages: unreadDms, + op: UpdateMessageFlagsOp.add, + flag: MessageFlag.read); + } +} diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index f95ff68793..1de30a719c 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1,10 +1,16 @@ +import 'dart:convert'; import 'dart:io'; import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.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/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/messages.dart'; +import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; @@ -21,6 +27,7 @@ import '../flutter_checks.dart'; import '../stdlib_checks.dart'; import '../test_images.dart'; import 'content_checks.dart'; +import 'dialog_checks.dart'; void main() { TestZulipBinding.ensureInitialized(); @@ -33,9 +40,10 @@ void main() { bool foundOldest = true, int? messageCount, List? messages, + UnreadMessagesSnapshot? unreadMsgs, }) async { addTearDown(testBinding.reset); - await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot(unreadMsgs: unreadMsgs)); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); connection = store.connection as FakeApiConnection; @@ -50,6 +58,8 @@ void main() { await tester.pumpWidget( MaterialApp( + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, home: GlobalStoreWidget( child: PerAccountStoreWidget( accountId: eg.selfAccount.id, @@ -71,7 +81,7 @@ void main() { testWidgets('basic', (tester) async { await setupMessageListPage(tester, foundOldest: false, messages: List.generate(200, (i) => eg.streamMessage(id: 950 + i, sender: eg.selfUser))); - check(itemCount(tester)).equals(201); + check(itemCount(tester)).equals(202); // Fling-scroll upward... await tester.fling(find.byType(MessageListPage), const Offset(0, 300), 8000); @@ -84,7 +94,7 @@ void main() { await tester.pump(Duration.zero); // Allow a frame for the response to arrive. // Now we have more messages. - check(itemCount(tester)).equals(301); + check(itemCount(tester)).equals(302); }); testWidgets('observe double-fetch glitch', (tester) async { @@ -409,4 +419,291 @@ void main() { ..status.equals(AnimationStatus.dismissed); }); }); + + group('MarkAsReadWidget', () { + bool isMarkAsReadButtonVisible(WidgetTester tester) { + // Zero height elements on the edge of a scrolling viewport + // are treated as invisible for hit-testing, see + // [SliverMultiBoxAdaptorElement.debugVisitOnstageChildren]. + // Set `skipOffstage: false` here to safely target the + // [MarkAsReadWidget] even when it is inactive. + return tester.getSize( + find.byType(MarkAsReadWidget, skipOffstage: false)).height > 0; + } + + testWidgets('from read to unread', (WidgetTester tester) async { + final message = eg.streamMessage(flags: [MessageFlag.read]); + await setupMessageListPage(tester, messages: [message]); + check(isMarkAsReadButtonVisible(tester)).isFalse(); + + store.handleEvent(eg.updateMessageFlagsRemoveEvent( + MessageFlag.read, [message])); + await tester.pumpAndSettle(); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + }); + + testWidgets('from unread to read', (WidgetTester tester) async { + final message = eg.streamMessage(flags: []); + final unreadMsgs = eg.unreadMsgs(streams:[ + UnreadStreamSnapshot(topic: message.subject, streamId: message.streamId, unreadMessageIds: [message.id]) + ]); + await setupMessageListPage(tester, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + store.handleEvent(UpdateMessageFlagsAddEvent( + id: 1, + flag: MessageFlag.read, + messages: [message.id], + all: false, + )); + await tester.pumpAndSettle(); + check(isMarkAsReadButtonVisible(tester)).isFalse(); + }); + + group('onPressed behavior', () { + final message = eg.streamMessage(flags: []); + final unreadMsgs = eg.unreadMsgs(streams: [ + UnreadStreamSnapshot(streamId: message.streamId, topic: message.subject, + unreadMessageIds: [message.id]), + ]); + + testWidgets('smoke test on modern server', (WidgetTester tester) async { + final narrow = TopicNarrow.ofMessage(message); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + final connection = store.connection as FakeApiConnection; + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + await tester.tap(find.byType(MarkAsReadWidget)); + 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(narrow.apiEncode()), + 'op': 'add', + 'flag': 'read', + }); + + await tester.pumpAndSettle(); // process pending timers + }); + + testWidgets('markAllMessagesAsRead uses is:unread optimization', (WidgetTester tester) async { + const narrow = AllMessagesNarrow(); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + final connection = store.connection as FakeApiConnection; + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + await tester.tap(find.byType(MarkAsReadWidget)); + 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': json.encode([{'operator': 'is', 'operand': 'unread'}]), + 'op': 'add', + 'flag': 'read', + }); + + await tester.pumpAndSettle(); // process pending timers + }); + + testWidgets('markNarrowAsRead pagination', (WidgetTester tester) async { + // Check that `lastProcessedId` returned from an initial + // response is used as `anchorId` for the subsequent request. + final narrow = TopicNarrow.ofMessage(message); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + final connection = store.connection as FakeApiConnection; + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1000, updatedCount: 890, + firstProcessedId: 1, lastProcessedId: 1989, + foundOldest: true, foundNewest: false).toJson()); + await tester.tap(find.byType(MarkAsReadWidget)); + 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(narrow.apiEncode()), + '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(narrow.apiEncode()), + 'op': 'add', + 'flag': 'read', + }); + }); + + testWidgets('markNarrowAsRead on invalid response', (WidgetTester tester) async { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + final narrow = TopicNarrow.ofMessage(message); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + final connection = store.connection as FakeApiConnection; + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 1000, updatedCount: 0, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: false).toJson()); + await tester.tap(find.byType(MarkAsReadWidget)); + 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(narrow.apiEncode()), + 'op': 'add', + 'flag': 'read', + }); + + await tester.pumpAndSettle(); + checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorMarkAsReadFailedTitle, + expectedMessage: zulipLocalizations.errorInvalidResponse); + }); + + testWidgets('AllMessagesNarrow on legacy server', (WidgetTester tester) async { + const narrow = AllMessagesNarrow(); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + final connection = store.connection as FakeApiConnection; + connection.zulipFeatureLevel = 154; + connection.prepare(json: {}); + await tester.tap(find.byType(MarkAsReadWidget)); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/mark_all_as_read') + ..bodyFields.deepEquals({}); + + await tester.pumpAndSettle(); // process pending timers + }); + + testWidgets('StreamNarrow on legacy server', (WidgetTester tester) async { + final narrow = StreamNarrow(message.streamId); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + final connection = store.connection as FakeApiConnection; + connection.zulipFeatureLevel = 154; + connection.prepare(json: {}); + await tester.tap(find.byType(MarkAsReadWidget)); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/mark_stream_as_read') + ..bodyFields.deepEquals({ + 'stream_id': message.streamId.toString(), + }); + + await tester.pumpAndSettle(); // process pending timers + }); + + testWidgets('TopicNarrow on legacy server', (WidgetTester tester) async { + final narrow = TopicNarrow.ofMessage(message); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + final connection = store.connection as FakeApiConnection; + connection.zulipFeatureLevel = 154; + connection.prepare(json: {}); + await tester.tap(find.byType(MarkAsReadWidget)); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/mark_topic_as_read') + ..bodyFields.deepEquals({ + 'stream_id': narrow.streamId.toString(), + 'topic_name': narrow.topic, + }); + + await tester.pumpAndSettle(); // process pending timers + }); + + testWidgets('DmNarrow on legacy server', (WidgetTester tester) async { + final message = eg.dmMessage(from: eg.otherUser, to: [eg.selfUser]); + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + final unreadMsgs = eg.unreadMsgs(dms: [ + UnreadDmSnapshot(otherUserId: eg.otherUser.userId, + unreadMessageIds: [message.id]), + ]); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + final connection = store.connection as FakeApiConnection; + connection.zulipFeatureLevel = 154; + connection.prepare(json: + UpdateMessageFlagsResult(messages: [message.id]).toJson()); + await tester.tap(find.byType(MarkAsReadWidget)); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/messages/flags') + ..bodyFields.deepEquals({ + 'messages': jsonEncode([message.id]), + 'op': 'add', + 'flag': 'read', + }); + + await tester.pumpAndSettle(); // process pending timers + }); + + testWidgets('catch-all api errors', (WidgetTester tester) async { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + const narrow = AllMessagesNarrow(); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + final connection = store.connection as FakeApiConnection; + connection.prepare(exception: http.ClientException('Oops')); + await tester.tap(find.byType(MarkAsReadWidget)); + await tester.pumpAndSettle(); + checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorMarkAsReadFailedTitle, + expectedMessage: 'Oops'); + }); + }); + }); }