diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 9b2efa2a6f..fe344189de 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -12,6 +12,7 @@ import 'compose.dart'; import 'emoji.dart'; import 'narrow.dart'; import 'store.dart'; +import 'user.dart'; extension ComposeContentAutocomplete on ComposeContentController { AutocompleteIntent? autocompleteIntent() { @@ -649,7 +650,7 @@ class MentionAutocompleteView extends AutocompleteView VisibilityEffect.unmuted, - (true, false) => VisibilityEffect.muted, - _ => VisibilityEffect.none, + (false, true) => UserTopicVisibilityEffect.unmuted, + (true, false) => UserTopicVisibilityEffect.muted, + _ => UserTopicVisibilityEffect.none, }; } } diff --git a/lib/model/message.dart b/lib/model/message.dart index 9e9e45ca6a..ef95071540 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -351,6 +351,12 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes } } + void handleMutedUsersEvent(MutedUsersEvent event) { + for (final view in _messageListViews) { + view.handleMutedUsersEvent(event); + } + } + void handleMessageEvent(MessageEvent event) { // If the message is one we already know about (from a fetch), // clobber it with the one from the event system. diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 475622f025..9128617b13 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -13,6 +13,7 @@ import 'content.dart'; import 'message.dart'; import 'narrow.dart'; import 'store.dart'; +import 'user.dart'; export '../api/route/messages.dart' show Anchor, AnchorCode, NumericAnchor; @@ -668,10 +669,12 @@ class MessageListView with ChangeNotifier, _MessageSequence { bool _messageVisible(MessageBase message) { switch (narrow) { case CombinedFeedNarrow(): - return switch (message.conversation) { + final conversation = message.conversation; + return switch (conversation) { StreamConversation(:final streamId, :final topic) => store.isTopicVisible(streamId, topic), - DmConversation() => true, + DmConversation() => !store.shouldMuteDmConversation( + DmNarrow.ofConversation(conversation, selfUserId: store.selfUserId)), }; case ChannelNarrow(:final streamId): @@ -682,22 +685,48 @@ class MessageListView with ChangeNotifier, _MessageSequence { case TopicNarrow(): case DmNarrow(): + return true; + case MentionsNarrow(): case StarredMessagesNarrow(): case KeywordSearchNarrow(): + if (message.conversation case DmConversation(:final allRecipientIds)) { + return !store.shouldMuteDmConversation(DmNarrow( + allRecipientIds: allRecipientIds, selfUserId: store.selfUserId)); + } + return true; + } + } + + /// Whether [_messageVisible] is true for all possible messages. + /// + /// This is useful for an optimization. + bool get _allMessagesVisible { + switch (narrow) { + case CombinedFeedNarrow(): + case ChannelNarrow(): + return false; + + case TopicNarrow(): + case DmNarrow(): return true; + + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + return false; } } /// Whether this event could affect the result that [_messageVisible] /// would ever have returned for any possible message in this message list. - VisibilityEffect _canAffectVisibility(UserTopicEvent event) { + UserTopicVisibilityEffect _canAffectVisibility(UserTopicEvent event) { switch (narrow) { case CombinedFeedNarrow(): return store.willChangeIfTopicVisible(event); case ChannelNarrow(:final streamId): - if (event.streamId != streamId) return VisibilityEffect.none; + if (event.streamId != streamId) return UserTopicVisibilityEffect.none; return store.willChangeIfTopicVisibleInStream(event); case TopicNarrow(): @@ -705,25 +734,26 @@ class MessageListView with ChangeNotifier, _MessageSequence { case MentionsNarrow(): case StarredMessagesNarrow(): case KeywordSearchNarrow(): - return VisibilityEffect.none; + return UserTopicVisibilityEffect.none; } } - /// Whether [_messageVisible] is true for all possible messages. - /// - /// This is useful for an optimization. - bool get _allMessagesVisible { - switch (narrow) { + /// Whether this event could affect the result that [_messageVisible] + /// would ever have returned for any possible message in this message list. + MutedUsersVisibilityEffect _mutedUsersEventCanAffectVisibility(MutedUsersEvent event) { + switch(narrow) { case CombinedFeedNarrow(): - case ChannelNarrow(): - return false; + return store.mightChangeShouldMuteDmConversation(event); + case ChannelNarrow(): case TopicNarrow(): case DmNarrow(): + return MutedUsersVisibilityEffect.none; + case MentionsNarrow(): case StarredMessagesNarrow(): case KeywordSearchNarrow(): - return true; + return store.mightChangeShouldMuteDmConversation(event); } } @@ -1011,10 +1041,10 @@ class MessageListView with ChangeNotifier, _MessageSequence { void handleUserTopicEvent(UserTopicEvent event) { switch (_canAffectVisibility(event)) { - case VisibilityEffect.none: + case UserTopicVisibilityEffect.none: return; - case VisibilityEffect.muted: + case UserTopicVisibilityEffect.muted: bool removed = _removeMessagesWhere((message) => message is StreamMessage && message.streamId == event.streamId @@ -1029,7 +1059,35 @@ class MessageListView with ChangeNotifier, _MessageSequence { notifyListeners(); } - case VisibilityEffect.unmuted: + case UserTopicVisibilityEffect.unmuted: + // TODO get the newly-unmuted messages from the message store + // For now, we simplify the task by just refetching this message list + // from scratch. + if (fetched) { + _reset(); + notifyListeners(); + fetchInitial(); + } + } + } + + void handleMutedUsersEvent(MutedUsersEvent event) { + switch (_mutedUsersEventCanAffectVisibility(event)) { + case MutedUsersVisibilityEffect.none: + return; + + case MutedUsersVisibilityEffect.muted: + final anyRemoved = _removeMessagesWhere((message) { + if (message is! DmMessage) return false; + final narrow = DmNarrow.ofMessage(message, selfUserId: store.selfUserId); + return store.shouldMuteDmConversation(narrow, event: event); + }); + if (anyRemoved) { + notifyListeners(); + } + + case MutedUsersVisibilityEffect.mixed: + case MutedUsersVisibilityEffect.unmuted: // TODO get the newly-unmuted messages from the message store // For now, we simplify the task by just refetching this message list // from scratch. diff --git a/lib/model/narrow.dart b/lib/model/narrow.dart index f7ee187116..ff4ccfbbc0 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -203,11 +203,21 @@ class DmNarrow extends Narrow implements SendableNarrow { required int selfUserId, }) { return DmNarrow( + // TODO should this really be making a copy of `allRecipientIds`? allRecipientIds: List.unmodifiable(message.conversation.allRecipientIds), selfUserId: selfUserId, ); } + factory DmNarrow.ofConversation(DmConversation conversation, { + required int selfUserId, + }) { + return DmNarrow( + allRecipientIds: conversation.allRecipientIds, + selfUserId: selfUserId, + ); + } + /// A [DmNarrow] from an item in [InitialSnapshot.recentPrivateConversations]. factory DmNarrow.ofRecentDmConversation(RecentDmConversation conversation, {required int selfUserId}) { return DmNarrow.withOtherUsers(conversation.userIds, selfUserId: selfUserId); diff --git a/lib/model/store.dart b/lib/model/store.dart index b3ce59206a..651b2d9534 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -665,6 +665,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor bool isUserMuted(int userId, {MutedUsersEvent? event}) => _users.isUserMuted(userId, event: event); + @override + MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event) => + _users.mightChangeShouldMuteDmConversation(event); + final UserStoreImpl _users; final TypingStatus typingStatus; @@ -983,6 +987,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor case MutedUsersEvent(): assert(debugLog("server event: muted_users")); + _messages.handleMutedUsersEvent(event); + // Update _users last, so other handlers can compare to the old value. _users.handleMutedUsersEvent(event); notifyListeners(); diff --git a/lib/model/typing_status.dart b/lib/model/typing_status.dart index 1ddd72c48b..cce9b2568f 100644 --- a/lib/model/typing_status.dart +++ b/lib/model/typing_status.dart @@ -22,7 +22,7 @@ class TypingStatus extends PerAccountStoreBase with ChangeNotifier { Iterable get debugActiveNarrows => _timerMapsByNarrow.keys; Iterable typistIdsInNarrow(SendableNarrow narrow) => - _timerMapsByNarrow[narrow]?.keys ?? []; + _timerMapsByNarrow[narrow]?.keys ?? const []; // Using SendableNarrow as the key covers the narrows // where typing notices are supported (topics and DMs). diff --git a/lib/model/user.dart b/lib/model/user.dart index 3c68154e22..23c4d53816 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -1,7 +1,9 @@ import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; +import 'algorithms.dart'; import 'localizations.dart'; +import 'narrow.dart'; import 'store.dart'; /// The portion of [PerAccountStore] describing the users in the realm. @@ -85,6 +87,38 @@ mixin UserStore on PerAccountStoreBase { /// Looks for [userId] in a private [Set], /// or in [event.mutedUsers] instead if event is non-null. bool isUserMuted(int userId, {MutedUsersEvent? event}); + + /// Whether the self-user has muted everyone in [narrow]. + /// + /// Returns false for the self-DM. + /// + /// Calls [isUserMuted] for each participant, passing along [event]. + bool shouldMuteDmConversation(DmNarrow narrow, {MutedUsersEvent? event}) { + if (narrow.otherRecipientIds.isEmpty) return false; + return narrow.otherRecipientIds.every( + (userId) => isUserMuted(userId, event: event)); + } + + /// Whether the given event might change the result of [shouldMuteDmConversation] + /// for its list of muted users, compared to the current state. + MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event); +} + +/// Whether and how a given [MutedUsersEvent] may affect the results +/// that [UserStore.shouldMuteDmConversation] would give for some messages. +enum MutedUsersVisibilityEffect { + /// The event will have no effect on the visibility results. + none, + + /// The event may change some visibility results from true to false. + muted, + + /// The event may change some visibility results from false to true. + unmuted, + + /// The event may change some visibility results from false to true, + /// and some from true to false. + mixed; } /// The implementation of [UserStore] that does the work. @@ -118,6 +152,29 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { return (event?.mutedUsers.map((item) => item.id) ?? _mutedUsers).contains(userId); } + @override + MutedUsersVisibilityEffect mightChangeShouldMuteDmConversation(MutedUsersEvent event) { + final sortedOld = _mutedUsers.toList()..sort(); + final sortedNew = event.mutedUsers.map((u) => u.id).toList()..sort(); + assert(isSortedWithoutDuplicates(sortedOld)); + assert(isSortedWithoutDuplicates(sortedNew)); + final union = setUnion(sortedOld, sortedNew); + + final willMuteSome = sortedOld.length < union.length; + final willUnmuteSome = sortedNew.length < union.length; + + switch ((willUnmuteSome, willMuteSome)) { + case (true, false): + return MutedUsersVisibilityEffect.unmuted; + case (false, true): + return MutedUsersVisibilityEffect.muted; + case (true, true): + return MutedUsersVisibilityEffect.mixed; + case (false, false): // TODO(log)? + return MutedUsersVisibilityEffect.none; + } + } + void handleRealmUserEvent(RealmUserEvent event) { switch (event) { case RealmUserAddEvent(): diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 34dcc2bf2c..55f268f8fc 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1327,13 +1327,14 @@ class _TypingStatusWidgetState extends State with PerAccount final store = PerAccountStoreWidget.of(context); final zulipLocalizations = ZulipLocalizations.of(context); final typistIds = model!.typistIdsInNarrow(narrow); - if (typistIds.isEmpty) return const SizedBox(); - final text = switch (typistIds.length) { + final filteredTypistIds = typistIds.whereNot(store.isUserMuted); + if (filteredTypistIds.isEmpty) return const SizedBox(); + final text = switch (filteredTypistIds.length) { 1 => zulipLocalizations.onePersonTyping( - store.userDisplayName(typistIds.first)), + store.userDisplayName(filteredTypistIds.first)), 2 => zulipLocalizations.twoPeopleTyping( - store.userDisplayName(typistIds.first), - store.userDisplayName(typistIds.last)), + store.userDisplayName(filteredTypistIds.first), + store.userDisplayName(filteredTypistIds.last)), _ => zulipLocalizations.manyPeopleTyping, }; diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index f81a66de42..56f098790f 100644 --- a/lib/widgets/new_dm_sheet.dart +++ b/lib/widgets/new_dm_sheet.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import '../api/model/model.dart'; import '../generated/l10n/zulip_localizations.dart'; @@ -68,7 +69,9 @@ class _NewDmPickerState extends State with PerAccountStoreAwareStat } void _initSortedUsers(PerAccountStore store) { - sortedUsers = List.from(store.allUsers) + final sansMuted = store.allUsers + .whereNot((user) => store.isUserMuted(user.userId)); + sortedUsers = List.from(sansMuted) ..sort((a, b) => MentionAutocompleteView.compareByDms(a, b, store: store)); _updateFilteredUsers(store); } diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 97c53ac4b1..2982a14cd6 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -52,6 +52,7 @@ class _RecentDmConversationsPageBodyState extends State muted, means muted', () async { @@ -242,7 +243,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); checkChanges(store, UserTopicVisibilityPolicy.muted, - VisibilityEffect.muted, VisibilityEffect.muted); + UserTopicVisibilityEffect.muted, UserTopicVisibilityEffect.muted); }); test('stream muted, policy none -> followed, means none/unmuted', () async { @@ -250,7 +251,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); checkChanges(store, UserTopicVisibilityPolicy.followed, - VisibilityEffect.none, VisibilityEffect.unmuted); + UserTopicVisibilityEffect.none, UserTopicVisibilityEffect.unmuted); }); test('stream muted, policy none -> muted, means muted/none', () async { @@ -258,7 +259,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1, isMuted: true)); checkChanges(store, UserTopicVisibilityPolicy.muted, - VisibilityEffect.muted, VisibilityEffect.none); + UserTopicVisibilityEffect.muted, UserTopicVisibilityEffect.none); }); final policies = [ @@ -293,10 +294,10 @@ void main() { final newVisibleInStream = store.isTopicVisibleInStream(stream1.streamId, eg.t('topic')); final newVisible = store.isTopicVisible(stream1.streamId, eg.t('topic')); - VisibilityEffect fromOldNew(bool oldVisible, bool newVisible) { - if (newVisible == oldVisible) return VisibilityEffect.none; - if (newVisible) return VisibilityEffect.unmuted; - return VisibilityEffect.muted; + UserTopicVisibilityEffect fromOldNew(bool oldVisible, bool newVisible) { + if (newVisible == oldVisible) return UserTopicVisibilityEffect.none; + if (newVisible) return UserTopicVisibilityEffect.unmuted; + return UserTopicVisibilityEffect.muted; } check(willChangeInStream) .equals(fromOldNew(oldVisibleInStream, newVisibleInStream)); diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 7062aeffa9..0779af52a1 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -81,12 +81,18 @@ void main() { Narrow narrow = const CombinedFeedNarrow(), Anchor anchor = AnchorCode.newest, ZulipStream? stream, + List? users, + List? mutedUserIds, }) async { stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); store = eg.store(); await store.addStream(stream); await store.addSubscription(subscription); + await store.addUsers([...?users, eg.selfUser]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } connection = store.connection as FakeApiConnection; notifiedCount = 0; model = MessageListView.init(store: store, narrow: narrow, anchor: anchor) @@ -1164,6 +1170,144 @@ void main() { })); }); + group('MutedUsersEvent', () { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + final users = [user1, user2, user3]; + + test('CombinedFeedNarrow', () async { + await prepare(narrow: CombinedFeedNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user1, user2]), + eg.dmMessage(id: 3, from: eg.selfUser, to: [user2, user3]), + eg.dmMessage(id: 4, from: eg.selfUser, to: []), + eg.streamMessage(id: 5), + ]); + checkHasMessageIds([1, 2, 3, 4, 5]); + + await store.setMutedUsers([user1.userId]); + checkNotifiedOnce(); + checkHasMessageIds([2, 3, 4, 5]); + + await store.setMutedUsers([user1.userId, user2.userId]); + checkNotifiedOnce(); + checkHasMessageIds([3, 4, 5]); + }); + + test('MentionsNarrow', () async { + await prepare(narrow: MentionsNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1], + flags: [MessageFlag.mentioned]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user2], + flags: [MessageFlag.mentioned]), + eg.streamMessage(id: 3, flags: [MessageFlag.mentioned]), + ]); + checkHasMessageIds([1, 2, 3]); + + await store.setMutedUsers([user1.userId]); + checkNotifiedOnce(); + checkHasMessageIds([2, 3]); + }); + + test('StarredMessagesNarrow', () async { + await prepare(narrow: StarredMessagesNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1], + flags: [MessageFlag.starred]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user2], + flags: [MessageFlag.starred]), + eg.streamMessage(id: 3, flags: [MessageFlag.starred]), + ]); + checkHasMessageIds([1, 2, 3]); + + await store.setMutedUsers([user1.userId]); + checkNotifiedOnce(); + checkHasMessageIds([2, 3]); + }); + + test('ChannelNarrow -> do nothing', () async { + await prepare(narrow: ChannelNarrow(eg.defaultStreamMessageStreamId), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(id: 1), + ]); + checkHasMessageIds([1]); + + await store.setMutedUsers([user1.userId]); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('TopicNarrow -> do nothing', () async { + await prepare(narrow: TopicNarrow(eg.defaultStreamMessageStreamId, + TopicName('topic')), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(id: 1, topic: 'topic'), + ]); + checkHasMessageIds([1]); + + await store.setMutedUsers([user1.userId]); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('DmNarrow -> do nothing', () async { + await prepare( + narrow: DmNarrow.withUser(user1.userId, selfUserId: eg.selfUser.userId), + users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + ]); + checkHasMessageIds([1]); + + await store.setMutedUsers([user1.userId]); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('unmute a user -> refetch from scratch', () => awaitFakeAsync((async) async { + await prepare(narrow: CombinedFeedNarrow(), users: users, + mutedUserIds: [user1.userId]); + final messages = [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.streamMessage(id: 2), + ]; + await prepareMessages(foundOldest: true, messages: messages); + checkHasMessageIds([2]); + + connection.prepare( + json: newestResult(foundOldest: true, messages: messages).toJson()); + await store.setMutedUsers([]); + checkNotifiedOnce(); + check(model).fetched.isFalse(); + checkHasMessageIds([]); + + async.elapse(Duration.zero); + checkNotifiedOnce(); + checkHasMessageIds([1, 2]); + })); + + test('unmute a user before initial fetch completes -> do nothing', () => awaitFakeAsync((async) async { + await prepare(narrow: CombinedFeedNarrow(), users: users, + mutedUserIds: [user1.userId]); + final messages = [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.streamMessage(id: 2), + ]; + connection.prepare( + json: newestResult(foundOldest: true, messages: messages).toJson()); + final fetchFuture = model.fetchInitial(); + await store.setMutedUsers([]); + checkNotNotified(); + + await fetchFuture; + checkNotifiedOnce(); + checkHasMessageIds([1, 2]); + })); + }); + group('DeleteMessageEvent', () { final stream = eg.stream(); final messages = List.generate(30, (i) => eg.streamMessage(stream: stream)); @@ -3012,20 +3156,33 @@ void checkInvariants(MessageListView model) { (it) => it.isNotNull().isTrue(), ]); - if (message is! MessageBase) continue; - final conversation = message.conversation; - switch (model.narrow) { - case CombinedFeedNarrow(): - check(model.store.isTopicVisible(conversation.streamId, conversation.topic)) - .isTrue(); - case ChannelNarrow(): - check(model.store.isTopicVisibleInStream(conversation.streamId, conversation.topic)) - .isTrue(); - case TopicNarrow(): - case DmNarrow(): - case MentionsNarrow(): - case StarredMessagesNarrow(): - case KeywordSearchNarrow(): + if (message is MessageBase) { + final conversation = message.conversation; + switch (model.narrow) { + case CombinedFeedNarrow(): + check(model.store.isTopicVisible(conversation.streamId, conversation.topic)) + .isTrue(); + case ChannelNarrow(): + check(model.store.isTopicVisibleInStream(conversation.streamId, conversation.topic)) + .isTrue(); + case TopicNarrow(): + case DmNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + } + } else if (message is DmMessage) { + final narrow = DmNarrow.ofMessage(message, selfUserId: model.store.selfUserId); + switch (model.narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + case KeywordSearchNarrow(): + check(model.store.shouldMuteDmConversation(narrow)).isFalse(); + case ChannelNarrow(): + case TopicNarrow(): + case DmNarrow(): + } } } diff --git a/test/model/user_test.dart b/test/model/user_test.dart index 27b07c129d..50e8c71db7 100644 --- a/test/model/user_test.dart +++ b/test/model/user_test.dart @@ -2,6 +2,9 @@ import 'package:checks/checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/model/user.dart'; import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; @@ -80,26 +83,67 @@ void main() { }); }); - testWidgets('MutedUsersEvent', (tester) async { - final user1 = eg.user(userId: 1); - final user2 = eg.user(userId: 2); - final user3 = eg.user(userId: 3); - - final store = eg.store(initialSnapshot: eg.initialSnapshot( - realmUsers: [user1, user2, user3], - mutedUsers: [MutedUserItem(id: 2), MutedUserItem(id: 1)])); - check(store.isUserMuted(1)).isTrue(); - check(store.isUserMuted(2)).isTrue(); - check(store.isUserMuted(3)).isFalse(); - - await store.handleEvent(eg.mutedUsersEvent([2, 1, 3])); - check(store.isUserMuted(1)).isTrue(); - check(store.isUserMuted(2)).isTrue(); - check(store.isUserMuted(3)).isTrue(); - - await store.handleEvent(eg.mutedUsersEvent([2, 3])); - check(store.isUserMuted(1)).isFalse(); - check(store.isUserMuted(2)).isTrue(); - check(store.isUserMuted(3)).isTrue(); + group('MutedUsersEvent', () { + testWidgets('smoke', (tester) async { + late PerAccountStore store; + + void checkDmConversationMuted(List otherUserIds, bool expected) { + final narrow = DmNarrow.withOtherUsers(otherUserIds, selfUserId: store.selfUserId); + check(store.shouldMuteDmConversation(narrow)).equals(expected); + } + + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + + store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUsers: [user1, user2, user3], + mutedUsers: [MutedUserItem(id: 2), MutedUserItem(id: 1)])); + check(store.isUserMuted(1)).isTrue(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isFalse(); + checkDmConversationMuted([1], true); + checkDmConversationMuted([1, 2], true); + checkDmConversationMuted([2, 3], false); + checkDmConversationMuted([1, 2, 3], false); + + await store.handleEvent(eg.mutedUsersEvent([2, 1, 3])); + check(store.isUserMuted(1)).isTrue(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isTrue(); + checkDmConversationMuted([1, 2, 3], true); + + await store.handleEvent(eg.mutedUsersEvent([2, 3])); + check(store.isUserMuted(1)).isFalse(); + check(store.isUserMuted(2)).isTrue(); + check(store.isUserMuted(3)).isTrue(); + checkDmConversationMuted([1], false); + checkDmConversationMuted([], false); + }); + + group('mightChangeShouldMuteDmConversation', () { + void doTest( + String description, + List before, + List after, + MutedUsersVisibilityEffect expected, + ) { + testWidgets(description, (tester) async { + final store = eg.store(); + await store.addUser(eg.selfUser); + await store.addUsers(before.map((id) => eg.user(userId: id))); + await store.setMutedUsers(before); + final event = eg.mutedUsersEvent(after); + check(store.mightChangeShouldMuteDmConversation(event)).equals(expected); + }); + } + + doTest('none', [1], [1], MutedUsersVisibilityEffect.none); + doTest('none (empty to empty)', [], [], MutedUsersVisibilityEffect.none); + doTest('muted', [1], [1, 2], MutedUsersVisibilityEffect.muted); + doTest('unmuted', [1, 2], [1], MutedUsersVisibilityEffect.unmuted); + doTest('mixed', [1, 2, 3], [1, 2, 4], MutedUsersVisibilityEffect.mixed); + doTest('mixed (all replaced)', [1], [2], MutedUsersVisibilityEffect.mixed); + }); }); } diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index c6be56359e..70d204a101 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -893,6 +893,30 @@ void main() { // Wait for the pending timers to end. await tester.pump(const Duration(seconds: 15)); }); + + testWidgets('muted user typing', (tester) async { + await setupMessageListPage(tester, + narrow: topicNarrow, users: users, messages: [streamMessage]); + + await checkTyping(tester, + eg.typingEvent(topicNarrow, TypingOp.start, eg.otherUser.userId), + expected: 'Other User is typing…'); + + await checkTyping(tester, + eg.typingEvent(topicNarrow, TypingOp.start, eg.thirdUser.userId), + expected: 'Other User and Third User are typing…'); + + await store.setMutedUsers([eg.otherUser.userId]); + await tester.pump(); + + await checkTyping(tester, + eg.typingEvent(topicNarrow, TypingOp.start, eg.thirdUser.userId), + expected: 'Third User is typing…', // no "Other User" + ); + + // Wait for the pending timers to end. + await tester.pump(const Duration(seconds: 15)); + }); }); group('MarkAsReadWidget', () { diff --git a/test/widgets/new_dm_sheet_test.dart b/test/widgets/new_dm_sheet_test.dart index 65d92f72a2..fc9567d78d 100644 --- a/test/widgets/new_dm_sheet_test.dart +++ b/test/widgets/new_dm_sheet_test.dart @@ -21,6 +21,7 @@ import 'test_app.dart'; Future setupSheet(WidgetTester tester, { required List users, + List? mutedUserIds, }) async { addTearDown(testBinding.reset); @@ -31,6 +32,9 @@ Future setupSheet(WidgetTester tester, { await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers(users); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } await tester.pumpWidget(TestZulipApp( navigatorObservers: [testNavObserver], @@ -106,17 +110,24 @@ void main() { }); group('user filtering', () { + final mutedUser = eg.user(fullName: 'Someone Muted'); final testUsers = [ eg.user(fullName: 'Alice Anderson'), eg.user(fullName: 'Bob Brown'), eg.user(fullName: 'Charlie Carter'), + mutedUser, ]; - testWidgets('shows all users initially', (tester) async { - await setupSheet(tester, users: testUsers); + testWidgets('shows all non-muted users initially', (tester) async { + await setupSheet(tester, users: testUsers, mutedUserIds: [mutedUser.userId]); check(find.text('Alice Anderson')).findsOne(); check(find.text('Bob Brown')).findsOne(); check(find.text('Charlie Carter')).findsOne(); + + check(find.byIcon(ZulipIcons.check_circle_unchecked)).findsExactly(3); + check(find.byIcon(ZulipIcons.check_circle_checked)).findsNothing(); + check(find.text('Someone Muted')).findsNothing(); + check(find.text('Muted user')).findsNothing(); }); testWidgets('shows filtered users based on search', (tester) async { diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index e543658d55..3eb49f2ca8 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -69,11 +69,11 @@ Future setupPage(WidgetTester tester, { void main() { TestZulipBinding.ensureInitialized(); - group('RecentDmConversationsPage', () { - Finder findConversationItem(Narrow narrow) => find.byWidgetPredicate( - (widget) => widget is RecentDmConversationsItem && widget.narrow == narrow, - ); + Finder findConversationItem(Narrow narrow) => find.byWidgetPredicate( + (widget) => widget is RecentDmConversationsItem && widget.narrow == narrow, + ); + group('RecentDmConversationsPage', () { testWidgets('appearance when empty', (tester) async { await setupPage(tester, users: [], dmMessages: []); check(find.text('You have no direct messages yet! Why not start the conversation?')) @@ -260,8 +260,8 @@ void main() { mutedUserIds: [user.userId], dmMessages: [message]); - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, 'Muted user'); + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + check(findConversationItem(narrow)).findsNothing(); }); }); @@ -346,8 +346,8 @@ void main() { mutedUserIds: [user0.userId, user1.userId], dmMessages: [message]); - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, 'Muted user, Muted user'); + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + check(findConversationItem(narrow)).findsNothing(); }); });