diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 9b808a5a25..15132a2b10 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -483,5 +483,24 @@ "notifSelfUser": "You", "@notifSelfUser": { "description": "Display name for the user themself, to show after replying in an Android notification" + }, + "onePersonTyping": "{typist} is typing…", + "@onePersonTyping": { + "description": "Text to display when there is one user typing.", + "placeholders": { + "typist": {"type": "String", "example": "Alice"} + } + }, + "twoPeopleTyping": "{typist} and {otherTypist} are typing…", + "@twoPeopleTyping": { + "description": "Text to display when there are two users typing.", + "placeholders": { + "typist": {"type": "String", "example": "Alice"}, + "otherTypist": {"type": "String", "example": "Bob"} + } + }, + "manyPeopleTyping": "Several people are typing…", + "@manyPeopleTyping": { + "description": "Text to display when there are multiple users typing." } } diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 59e5bd1c60..a1b6aa011c 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -1,5 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; +import '../../model/algorithms.dart'; import 'json.dart'; import 'model.dart'; @@ -900,7 +901,7 @@ class TypingEvent extends Event { required this.recipientIds, required this.streamId, required this.topic, - }); + }) : assert(recipientIds == null || isSortedWithoutDuplicates(recipientIds)); static Object? _readSenderId(Map json, String key) { return (json['sender'] as Map)['user_id']; @@ -909,7 +910,7 @@ class TypingEvent extends Event { static List? _recipientIdsFromJson(Object? json) { if (json == null) return null; return (json as List).map( - (item) => (item as Map)['user_id'] as int).toList(); + (item) => (item as Map)['user_id'] as int).toList()..sort(); } factory TypingEvent.fromJson(Map json) { diff --git a/lib/api/route/events.dart b/lib/api/route/events.dart index 1565fe422c..bbd7be5a0a 100644 --- a/lib/api/route/events.dart +++ b/lib/api/route/events.dart @@ -16,7 +16,7 @@ Future registerQueue(ApiConnection connection) { 'notification_settings_null': true, 'bulk_message_deletion': true, 'user_avatar_url_field_optional': false, // TODO(#254): turn on - 'stream_typing_notifications': false, // TODO implement + 'stream_typing_notifications': true, 'user_settings_object': true, }, }); diff --git a/lib/model/typing_status.dart b/lib/model/typing_status.dart index d4637c12e6..d182b3efd8 100644 --- a/lib/model/typing_status.dart +++ b/lib/model/typing_status.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../api/model/events.dart'; +import '../log.dart'; import 'narrow.dart'; /// The model for tracking the typing status organized by narrows. @@ -38,6 +39,10 @@ class TypingStatus extends ChangeNotifier { } bool _addTypist(SendableNarrow narrow, int typistUserId) { + if (typistUserId == selfUserId) { + assert(debugLog('typing status: adding self as typist')); + return false; + } final narrowTimerMap = _timerMapsByNarrow[narrow] ??= {}; final typistTimer = narrowTimerMap[typistUserId]; final isNewTypist = typistTimer == null; @@ -64,7 +69,7 @@ class TypingStatus extends ChangeNotifier { void handleTypingEvent(TypingEvent event) { SendableNarrow narrow = switch (event.messageType) { MessageType.direct => DmNarrow( - allRecipientIds: event.recipientIds!..sort(), selfUserId: selfUserId), + allRecipientIds: event.recipientIds!, selfUserId: selfUserId), MessageType.stream => TopicNarrow(event.streamId!, event.topic!), }; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 0a76807064..cb9ff0a038 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_color_models/flutter_color_models.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:intl/intl.dart'; @@ -9,6 +10,7 @@ import '../api/model/model.dart'; import '../model/message_list.dart'; import '../model/narrow.dart'; import '../model/store.dart'; +import '../model/typing_status.dart'; import 'action_sheet.dart'; import 'actions.dart'; import 'compose_box.dart'; @@ -496,9 +498,9 @@ 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 - 2); + return length - 1 - (index - 3); }, - childCount: length + 2, + childCount: length + 3, (context, i) { // To reinforce that the end of the feed has been reached: // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20Mark-as-read/near/1680603 @@ -506,7 +508,9 @@ class _MessageListState extends State with PerAccountStoreAwareStat if (i == 1) return MarkAsReadWidget(narrow: widget.narrow); - final data = model!.items[length - 1 - (i - 2)]; + if (i == 2) return TypingStatusWidget(narrow: widget.narrow); + + final data = model!.items[length - 1 - (i - 3)]; return _buildItem(data, i); })); @@ -609,6 +613,64 @@ class ScrollToBottomButton extends StatelessWidget { } } +class TypingStatusWidget extends StatefulWidget { + const TypingStatusWidget({super.key, required this.narrow}); + + final Narrow narrow; + + @override + State createState() => _TypingStatusWidgetState(); +} + +class _TypingStatusWidgetState extends State with PerAccountStoreAwareStateMixin { + TypingStatus? model; + + @override + void onNewStore() { + model?.removeListener(_modelChanged); + model = PerAccountStoreWidget.of(context).typingStatus + ..addListener(_modelChanged); + } + + @override + void dispose() { + model?.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // The actual state lives in [model]. + // This method was called because that just changed. + }); + } + + @override + Widget build(BuildContext context) { + final narrow = widget.narrow; + if (narrow is! SendableNarrow) return const SizedBox(); + + final store = PerAccountStoreWidget.of(context); + final localizations = ZulipLocalizations.of(context); + final typistIds = model!.typistIdsInNarrow(narrow); + if (typistIds.isEmpty) return const SizedBox(); + final text = switch (typistIds.length) { + 1 => localizations.onePersonTyping( + store.users[typistIds.first]?.fullName ?? localizations.unknownUserName), + 2 => localizations.twoPeopleTyping( + store.users[typistIds.first]?.fullName ?? localizations.unknownUserName, + store.users[typistIds.last]?.fullName ?? localizations.unknownUserName), + _ => localizations.manyPeopleTyping, + }; + + return Padding( + padding: const EdgeInsetsDirectional.only(start: 16, top: 2), + child: Text(text, + style: const TextStyle( + color: HslColor(0, 0, 53), fontStyle: FontStyle.italic))); + } +} + class MarkAsReadWidget extends StatefulWidget { const MarkAsReadWidget({super.key, required this.narrow}); diff --git a/test/api/model/events_test.dart b/test/api/model/events_test.dart index d722e3229c..0f1f52f843 100644 --- a/test/api/model/events_test.dart +++ b/test/api/model/events_test.dart @@ -241,5 +241,12 @@ void main() { check(() => TypingEvent.fromJson({ ...baseJson, 'message_type': 'stream', 'stream_id': 123})).throws(); }); + + test('direct type sort recipient ids', () { + check(TypingEvent.fromJson({ + ...directMessageJson, + 'recipients': [4, 10, 8, 2, 1].map((e) => {'user_id': e, 'email': '$e@example.com'}).toList(), + })).recipientIds.isNotNull().deepEquals([1, 2, 4, 8, 10]); + }); }); } diff --git a/test/model/typing_status_test.dart b/test/model/typing_status_test.dart index 162e89f23f..a30a69bea6 100644 --- a/test/model/typing_status_test.dart +++ b/test/model/typing_status_test.dart @@ -98,21 +98,13 @@ void main() { checkNotifiedOnce(); }); - test('sort dm recipients', () { - prepareModel(selfUserId: 5); - final recipientIds = [5, 4, 10, 8, 2, 1]; - - final eventUnsorted = TypingEvent(id: 1, op: TypingOp.start, - senderId: 1, - messageType: MessageType.direct, - recipientIds: recipientIds, - streamId: null, topic: null); - // DmNarrow's constructor expects the recipient IDs to be sorted, - // and [model.handleTypingEvent] should handle that. - model.handleTypingEvent(eventUnsorted); - check(model.typistIdsInNarrow( - DmNarrow(allRecipientIds: recipientIds..sort(), selfUserId: 5), - )).single.equals(1); + test('ignore adding self as typist', () { + prepareModel(); + + model.handleTypingEvent( + eg.typingEvent(groupNarrow, TypingOp.start, eg.selfUser.userId)); + checkTypists({}); + checkNotNotified(); }); }); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 59e464864a..4bc3c296da 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -320,6 +320,293 @@ void main() { }); }); + group('TypingStatusWidget', () { + final users = [eg.selfUser, eg.otherUser, eg.thirdUser, eg.fourthUser]; + final finder = find.descendant( + of: find.byType(TypingStatusWidget), + matching: find.byType(Text) + ); + + Future checkTyping(WidgetTester tester, TypingEvent event, {required String expected}) async { + await store.handleEvent(event); + await tester.pump(); + check(tester.widget(finder)).data.equals(expected); + } + + final dmMessage = eg.dmMessage( + from: eg.selfUser, to: [eg.otherUser, eg.thirdUser, eg.fourthUser]); + final dmNarrow = DmNarrow.ofMessage(dmMessage, selfUserId: eg.selfUser.userId); + + final streamMessage = eg.streamMessage(); + final topicNarrow = TopicNarrow.ofMessage(streamMessage); + + for (final (description, message, narrow) in [ + ('typing in dm', dmMessage, dmNarrow), + ('typing in topic', streamMessage, topicNarrow), + ]) { + testWidgets(description, (tester) async { + await setupMessageListPage(tester, + narrow: narrow, users: users, messages: [message]); + await tester.pump(); + check(finder.evaluate()).isEmpty(); + await checkTyping(tester, + eg.typingEvent(narrow, TypingOp.start, eg.otherUser.userId), + expected: 'Other User is typing…'); + await checkTyping(tester, + eg.typingEvent(narrow, TypingOp.start, eg.selfUser.userId), + expected: 'Other User is typing…'); + await checkTyping(tester, + eg.typingEvent(narrow, TypingOp.start, eg.thirdUser.userId), + expected: 'Other User and Third User are typing…'); + await checkTyping(tester, + eg.typingEvent(narrow, TypingOp.start, eg.fourthUser.userId), + expected: 'Several people are typing…'); + await checkTyping(tester, + eg.typingEvent(narrow, TypingOp.stop, eg.otherUser.userId), + expected: 'Third User and Fourth User are typing…'); + // Verify that typing indicators expire after a set duration. + await tester.pump(const Duration(seconds: 15)); + check(finder.evaluate()).isEmpty(); + }); + } + + testWidgets('unknown user typing', (tester) async { + final streamMessage = eg.streamMessage(); + final narrow = TopicNarrow.ofMessage(streamMessage); + await setupMessageListPage(tester, + narrow: narrow, users: [], messages: [streamMessage]); + await checkTyping(tester, + eg.typingEvent(narrow, TypingOp.start, 1000), + expected: '(unknown user) is typing…', + ); + // Wait for the pending timers to end. + await tester.pump(const Duration(seconds: 15)); + }); + }); + + group('MarkAsReadWidget', () { + bool isMarkAsReadButtonVisible(WidgetTester tester) { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + final finder = find.text( + zulipLocalizations.markAllAsReadLabel).hitTestable(); + return finder.evaluate().isNotEmpty; + } + + testWidgets('from read to unread', (WidgetTester tester) async { + final message = eg.streamMessage(flags: [MessageFlag.read]); + await setupMessageListPage(tester, messages: [message]); + check(isMarkAsReadButtonVisible(tester)).isFalse(); + + await 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(channels:[ + UnreadChannelSnapshot(topic: message.topic, streamId: message.streamId, unreadMessageIds: [message.id]) + ]); + await setupMessageListPage(tester, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + await store.handleEvent(UpdateMessageFlagsAddEvent( + id: 1, + flag: MessageFlag.read, + messages: [message.id], + all: false, + )); + await tester.pumpAndSettle(); + check(isMarkAsReadButtonVisible(tester)).isFalse(); + }); + + testWidgets("messages don't shift position", (WidgetTester tester) async { + final message = eg.streamMessage(flags: []); + final unreadMsgs = eg.unreadMsgs(channels:[ + UnreadChannelSnapshot(topic: message.topic, streamId: message.streamId, + unreadMessageIds: [message.id]) + ]); + await setupMessageListPage(tester, + messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + check(tester.widgetList(find.byType(MessageItem))).length.equals(1); + final before = tester.getTopLeft(find.byType(MessageItem)).dy; + + await store.handleEvent(UpdateMessageFlagsAddEvent( + id: 1, + flag: MessageFlag.read, + messages: [message.id], + all: false, + )); + await tester.pumpAndSettle(); + check(isMarkAsReadButtonVisible(tester)).isFalse(); + check(tester.widgetList(find.byType(MessageItem))).length.equals(1); + final after = tester.getTopLeft(find.byType(MessageItem)).dy; + check(after).equals(before); + }); + + group('onPressed behavior', () { + // The markNarrowAsRead function has detailed unit tests of its own. + // These tests cover functionality that's outside that function, + // and a couple of smoke tests showing this button is wired up to it. + + final message = eg.streamMessage(flags: []); + final unreadMsgs = eg.unreadMsgs(channels: [ + UnreadChannelSnapshot(streamId: message.streamId, topic: message.topic, + unreadMessageIds: [message.id]), + ]); + + group('MarkAsReadAnimation', () { + void checkAppearsLoading(WidgetTester tester, bool expected) { + final semantics = tester.firstWidget(find.descendant( + of: find.byType(MarkAsReadWidget), + matching: find.byType(Semantics))); + check(semantics.properties.enabled).equals(!expected); + + final opacity = tester.widget(find.descendant( + of: find.byType(MarkAsReadWidget), + matching: find.byType(AnimatedOpacity))); + check(opacity.opacity).equals(expected ? 0.5 : 1.0); + } + + testWidgets('loading is changed correctly', (WidgetTester tester) async { + final narrow = TopicNarrow.ofMessage(message); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + connection.prepare( + delay: const Duration(milliseconds: 2000), + json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + + checkAppearsLoading(tester, false); + + await tester.tap(find.byType(MarkAsReadWidget)); + await tester.pump(); + checkAppearsLoading(tester, true); + + await tester.pump(const Duration(milliseconds: 2000)); + checkAppearsLoading(tester, false); + }); + + testWidgets('loading is changed correctly if request fails', (WidgetTester tester) async { + final narrow = TopicNarrow.ofMessage(message); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + connection.prepare(httpStatus: 400, json: { + 'code': 'BAD_REQUEST', + 'msg': 'Invalid message(s)', + 'result': 'error', + }); + + checkAppearsLoading(tester, false); + + await tester.tap(find.byType(MarkAsReadWidget)); + await tester.pump(); + checkAppearsLoading(tester, true); + + await tester.pump(const Duration(milliseconds: 2000)); + checkAppearsLoading(tester, false); + }); + }); + + 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(); + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + await tester.tap(find.byType(MarkAsReadWidget)); + 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(); // process pending timers + }); + + testWidgets('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(); + + 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['anchor'].equals('oldest'); + + 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['anchor'].equals('1989'); + }); + + testWidgets('markNarrowAsRead on mark-all-as-read when Unreads.oldUnreadsMissing: true', (tester) async { + const narrow = CombinedFeedNarrow(); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + store.unreads.oldUnreadsMissing = true; + + connection.prepare(json: UpdateMessageFlagsForNarrowResult( + processedCount: 11, updatedCount: 3, + firstProcessedId: null, lastProcessedId: null, + foundOldest: true, foundNewest: true).toJson()); + await tester.tap(find.byType(MarkAsReadWidget)); + await tester.pumpAndSettle(); + check(store.unreads.oldUnreadsMissing).isFalse(); + }); + + testWidgets('catch-all api errors', (WidgetTester tester) async { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + const narrow = CombinedFeedNarrow(); + await setupMessageListPage(tester, + narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); + check(isMarkAsReadButtonVisible(tester)).isTrue(); + + connection.prepare(exception: http.ClientException('Oops')); + await tester.tap(find.byType(MarkAsReadWidget)); + await tester.pumpAndSettle(); + checkErrorDialog(tester, + expectedTitle: zulipLocalizations.errorMarkAsReadFailedTitle, + expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); + }); + }); + }); + group('recipient headers', () { group('StreamMessageRecipientHeader', () { final stream = eg.stream(name: 'stream name'); @@ -843,227 +1130,4 @@ void main() { ..status.equals(AnimationStatus.dismissed); }); }); - - group('MarkAsReadWidget', () { - bool isMarkAsReadButtonVisible(WidgetTester tester) { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - final finder = find.text( - zulipLocalizations.markAllAsReadLabel).hitTestable(); - return finder.evaluate().isNotEmpty; - } - - testWidgets('from read to unread', (WidgetTester tester) async { - final message = eg.streamMessage(flags: [MessageFlag.read]); - await setupMessageListPage(tester, messages: [message]); - check(isMarkAsReadButtonVisible(tester)).isFalse(); - - await 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(channels:[ - UnreadChannelSnapshot(topic: message.topic, streamId: message.streamId, unreadMessageIds: [message.id]) - ]); - await setupMessageListPage(tester, messages: [message], unreadMsgs: unreadMsgs); - check(isMarkAsReadButtonVisible(tester)).isTrue(); - - await store.handleEvent(UpdateMessageFlagsAddEvent( - id: 1, - flag: MessageFlag.read, - messages: [message.id], - all: false, - )); - await tester.pumpAndSettle(); - check(isMarkAsReadButtonVisible(tester)).isFalse(); - }); - - testWidgets("messages don't shift position", (WidgetTester tester) async { - final message = eg.streamMessage(flags: []); - final unreadMsgs = eg.unreadMsgs(channels:[ - UnreadChannelSnapshot(topic: message.topic, streamId: message.streamId, - unreadMessageIds: [message.id]) - ]); - await setupMessageListPage(tester, - messages: [message], unreadMsgs: unreadMsgs); - check(isMarkAsReadButtonVisible(tester)).isTrue(); - check(tester.widgetList(find.byType(MessageItem))).length.equals(1); - final before = tester.getTopLeft(find.byType(MessageItem)).dy; - - await store.handleEvent(UpdateMessageFlagsAddEvent( - id: 1, - flag: MessageFlag.read, - messages: [message.id], - all: false, - )); - await tester.pumpAndSettle(); - check(isMarkAsReadButtonVisible(tester)).isFalse(); - check(tester.widgetList(find.byType(MessageItem))).length.equals(1); - final after = tester.getTopLeft(find.byType(MessageItem)).dy; - check(after).equals(before); - }); - - group('onPressed behavior', () { - // The markNarrowAsRead function has detailed unit tests of its own. - // These tests cover functionality that's outside that function, - // and a couple of smoke tests showing this button is wired up to it. - - final message = eg.streamMessage(flags: []); - final unreadMsgs = eg.unreadMsgs(channels: [ - UnreadChannelSnapshot(streamId: message.streamId, topic: message.topic, - unreadMessageIds: [message.id]), - ]); - - group('MarkAsReadAnimation', () { - void checkAppearsLoading(WidgetTester tester, bool expected) { - final semantics = tester.firstWidget(find.descendant( - of: find.byType(MarkAsReadWidget), - matching: find.byType(Semantics))); - check(semantics.properties.enabled).equals(!expected); - - final opacity = tester.widget(find.descendant( - of: find.byType(MarkAsReadWidget), - matching: find.byType(AnimatedOpacity))); - check(opacity.opacity).equals(expected ? 0.5 : 1.0); - } - - testWidgets('loading is changed correctly', (WidgetTester tester) async { - final narrow = TopicNarrow.ofMessage(message); - await setupMessageListPage(tester, - narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); - check(isMarkAsReadButtonVisible(tester)).isTrue(); - - connection.prepare( - delay: const Duration(milliseconds: 2000), - json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); - - checkAppearsLoading(tester, false); - - await tester.tap(find.byType(MarkAsReadWidget)); - await tester.pump(); - checkAppearsLoading(tester, true); - - await tester.pump(const Duration(milliseconds: 2000)); - checkAppearsLoading(tester, false); - }); - - testWidgets('loading is changed correctly if request fails', (WidgetTester tester) async { - final narrow = TopicNarrow.ofMessage(message); - await setupMessageListPage(tester, - narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); - check(isMarkAsReadButtonVisible(tester)).isTrue(); - - connection.prepare(httpStatus: 400, json: { - 'code': 'BAD_REQUEST', - 'msg': 'Invalid message(s)', - 'result': 'error', - }); - - checkAppearsLoading(tester, false); - - await tester.tap(find.byType(MarkAsReadWidget)); - await tester.pump(); - checkAppearsLoading(tester, true); - - await tester.pump(const Duration(milliseconds: 2000)); - checkAppearsLoading(tester, false); - }); - }); - - 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(); - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); - await tester.tap(find.byType(MarkAsReadWidget)); - 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(); // process pending timers - }); - - testWidgets('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(); - - 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['anchor'].equals('oldest'); - - 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['anchor'].equals('1989'); - }); - - testWidgets('markNarrowAsRead on mark-all-as-read when Unreads.oldUnreadsMissing: true', (tester) async { - const narrow = CombinedFeedNarrow(); - await setupMessageListPage(tester, - narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); - check(isMarkAsReadButtonVisible(tester)).isTrue(); - store.unreads.oldUnreadsMissing = true; - - connection.prepare(json: UpdateMessageFlagsForNarrowResult( - processedCount: 11, updatedCount: 3, - firstProcessedId: null, lastProcessedId: null, - foundOldest: true, foundNewest: true).toJson()); - await tester.tap(find.byType(MarkAsReadWidget)); - await tester.pumpAndSettle(); - check(store.unreads.oldUnreadsMissing).isFalse(); - }); - - testWidgets('catch-all api errors', (WidgetTester tester) async { - final zulipLocalizations = GlobalLocalizations.zulipLocalizations; - const narrow = CombinedFeedNarrow(); - await setupMessageListPage(tester, - narrow: narrow, messages: [message], unreadMsgs: unreadMsgs); - check(isMarkAsReadButtonVisible(tester)).isTrue(); - - connection.prepare(exception: http.ClientException('Oops')); - await tester.tap(find.byType(MarkAsReadWidget)); - await tester.pumpAndSettle(); - checkErrorDialog(tester, - expectedTitle: zulipLocalizations.errorMarkAsReadFailedTitle, - expectedMessage: 'NetworkException: Oops (ClientException: Oops)'); - }); - }); - }); }