diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 0d3f273d16..79847e13b3 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1007,17 +1007,13 @@ "@noEarlierMessages": { "description": "Text to show at the start of a message list if there are no earlier messages." }, - "mutedSender": "Muted sender", - "@mutedSender": { - "description": "Name for a muted user to display in message list." - }, - "revealButtonLabel": "Reveal message for muted sender", + "revealButtonLabel": "Reveal message", "@revealButtonLabel": { "description": "Label for the button revealing hidden message from a muted sender in message list." }, "mutedUser": "Muted user", "@mutedUser": { - "description": "Name for a muted user to display all over the app." + "description": "Text to display in place of a muted user's name." }, "scrollToBottomTooltip": "Scroll to bottom", "@scrollToBottomTooltip": { diff --git a/assets/l10n/app_pl.arb b/assets/l10n/app_pl.arb index 982ca98be4..3a90ef6be1 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1089,10 +1089,6 @@ "@mutedSender": { "description": "Name for a muted user to display in message list." }, - "revealButtonLabel": "Odsłoń wiadomość od wyciszonego użytkownika", - "@revealButtonLabel": { - "description": "Label for the button revealing hidden message from a muted sender in message list." - }, "mutedUser": "Wyciszony użytkownik", "@mutedUser": { "description": "Name for a muted user to display all over the app." diff --git a/assets/l10n/app_ru.arb b/assets/l10n/app_ru.arb index eb79229d04..9624b8f8d9 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1081,10 +1081,6 @@ "@mutedSender": { "description": "Name for a muted user to display in message list." }, - "revealButtonLabel": "Показать сообщение отключенного отправителя", - "@revealButtonLabel": { - "description": "Label for the button revealing hidden message from a muted sender in message list." - }, "mutedUser": "Отключенный пользователь", "@mutedUser": { "description": "Name for a muted user to display all over the app." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 68d47c3787..e789eb026e 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1499,19 +1499,13 @@ abstract class ZulipLocalizations { /// **'No earlier messages'** String get noEarlierMessages; - /// Name for a muted user to display in message list. - /// - /// In en, this message translates to: - /// **'Muted sender'** - String get mutedSender; - /// Label for the button revealing hidden message from a muted sender in message list. /// /// In en, this message translates to: - /// **'Reveal message for muted sender'** + /// **'Reveal message'** String get revealButtonLabel; - /// Name for a muted user to display all over the app. + /// Text to display in place of a muted user's name. /// /// In en, this message translates to: /// **'Muted user'** diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 2910711c42..c2706b16b3 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -820,10 +820,7 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index a01b813f0f..440c033bf6 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -820,10 +820,7 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 9f41726924..540ab559a6 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -820,10 +820,7 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 7d800ac7a8..f187cdf8ef 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -820,10 +820,7 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 5d6c814002..7b6d438760 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -820,10 +820,7 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index efa03e9f48..5a415d5e26 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -833,10 +833,7 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get noEarlierMessages => 'Brak historii'; @override - String get mutedSender => 'Wyciszony nadawca'; - - @override - String get revealButtonLabel => 'Odsłoń wiadomość od wyciszonego użytkownika'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Wyciszony użytkownik'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 9d7b09ded9..c03ef6db1a 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -835,10 +835,7 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get noEarlierMessages => 'Предшествующих сообщений нет'; @override - String get mutedSender => 'Отключенный отправитель'; - - @override - String get revealButtonLabel => 'Показать сообщение отключенного отправителя'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Отключенный пользователь'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 51aace2d53..4b4a5269c4 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -822,10 +822,7 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 97b5e26af1..c68835e1ef 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -834,10 +834,7 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get noEarlierMessages => 'Немає попередніх повідомлень'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index e72db65ad7..9cc1e15556 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -820,10 +820,7 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get revealButtonLabel => 'Reveal message'; @override String get mutedUser => 'Muted user'; diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index cd3fa7d7d2..3fb043badb 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() { @@ -648,7 +649,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/compose.dart b/lib/model/compose.dart index 2aa2ed9f44..f1145e555f 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -130,14 +130,33 @@ String wrapWithBacktickFence({required String content, String? infoString}) { /// To omit the user ID part ("|13313") whenever the name part is unambiguous, /// pass the full UserStore. This means accepting a linear scan /// through all users; avoid it in performance-sensitive codepaths. +/// +/// See also [userMentionFromMessage]. String userMention(User user, {bool silent = false, UserStore? users}) { bool includeUserId = users == null || users.allUsers.where((u) => u.fullName == user.fullName) .take(2).length == 2; - - return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**'; + return _userMentionImpl( + silent: silent, + fullName: user.fullName, + userId: includeUserId ? user.userId : null); } +/// An @-mention of an individual user, like @**Chris Bobbe|13313**, +/// from sender data in a [Message]. +/// +/// The user ID part ("|13313") is always included. +/// +/// See also [userMention]. +String userMentionFromMessage(Message message, {bool silent = false, required UserStore users}) => + _userMentionImpl( + silent: silent, + fullName: users.senderDisplayName(message, replaceIfMuted: false), + userId: message.senderId); + +String _userMentionImpl({required bool silent, required String fullName, int? userId}) => + '@${silent ? '_' : ''}**$fullName${userId != null ? '|$userId' : ''}**'; + /// An @-mention of all the users in a conversation, like @**channel**. String wildcardMention(WildcardMentionOption wildcardOption, { required PerAccountStore store, @@ -190,13 +209,11 @@ String quoteAndReplyPlaceholder( PerAccountStore store, { required Message message, }) { - final sender = store.getUser(message.senderId); - assert(sender != null); // TODO(#716): should use `store.senderDisplayName` final url = narrowLink(store, SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), nearMessageId: message.id); - // See note in [quoteAndReply] about asking `mention` to omit the | part. - return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(#1285) + return '${userMentionFromMessage(message, silent: true, users: store)} ' + '${inlineLink('said', url)}: ' // TODO(#1285) '*${zulipLocalizations.composeBoxLoadingMessage(message.id)}*\n'; } @@ -212,14 +229,14 @@ String quoteAndReply(PerAccountStore store, { required Message message, required String rawContent, }) { - final sender = store.getUser(message.senderId); - assert(sender != null); // TODO(#716): should use `store.senderDisplayName` final url = narrowLink(store, SendableNarrow.ofMessage(message, selfUserId: store.selfUserId), nearMessageId: message.id); - // Could ask `mention` to omit the | part unless the mention is ambiguous… - // but that would mean a linear scan through all users, and the extra noise - // won't much matter with the already probably-long message link in there too. - return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(#1285) - '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}'; + // Could ask userMentionFromMessage to omit the | part unless the mention + // is ambiguous… but that would mean a linear scan through all users, + // and the extra noise won't much matter with the already probably-long + // message link in there too. + return '${userMentionFromMessage(message, silent: true, users: store)} ' + '${inlineLink('said', url)}:\n' // TODO(#1285) + '${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}'; } diff --git a/lib/model/message.dart b/lib/model/message.dart index 1dfe421368..f8f70b9bf1 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -287,6 +287,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 a7aff0dcbc..ee35a90754 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; @@ -622,10 +623,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): @@ -636,45 +639,71 @@ class MessageListView with ChangeNotifier, _MessageSequence { case TopicNarrow(): case DmNarrow(): + return true; + case MentionsNarrow(): case StarredMessagesNarrow(): + 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(): + 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 _userTopicEventCanAffectVisibility(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(): case DmNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): - 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(): - return true; + return store.mightChangeShouldMuteDmConversation(event); } } @@ -950,11 +979,11 @@ class MessageListView with ChangeNotifier, _MessageSequence { } void handleUserTopicEvent(UserTopicEvent event) { - switch (_canAffectVisibility(event)) { - case VisibilityEffect.none: + switch (_userTopicEventCanAffectVisibility(event)) { + case UserTopicVisibilityEffect.none: return; - case VisibilityEffect.muted: + case UserTopicVisibilityEffect.muted: bool removed = _removeMessagesWhere((message) => message is StreamMessage && message.streamId == event.streamId @@ -969,7 +998,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 104334a956..5c3910d5aa 100644 --- a/lib/model/narrow.dart +++ b/lib/model/narrow.dart @@ -200,11 +200,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 5171807a8e..10a8e53f49 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -649,6 +649,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; @@ -675,10 +679,13 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor return byDate.difference(dateJoined).inDays >= realmWaitingPeriodThreshold; } - /// The given user's real email address, if known, for displaying in the UI. + /// The user's real email address, if known, for displaying in the UI. /// - /// Returns null if self-user isn't able to see [user]'s real email address. - String? userDisplayEmail(User user) { + /// Returns null if self-user isn't able to see the user's real email address, + /// or if the user isn't actually a user we know about. + String? userDisplayEmail(int userId) { + final user = getUser(userId); + if (user == null) return null; if (zulipFeatureLevel >= 163) { // TODO(server-7) // A non-null value means self-user has access to [user]'s real email, // while a null value means it doesn't have access to the email. @@ -955,6 +962,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..809132a816 100644 --- a/lib/model/typing_status.dart +++ b/lib/model/typing_status.dart @@ -21,8 +21,8 @@ class TypingStatus extends PerAccountStoreBase with ChangeNotifier { Iterable get debugActiveNarrows => _timerMapsByNarrow.keys; - Iterable typistIdsInNarrow(SendableNarrow narrow) => - _timerMapsByNarrow[narrow]?.keys ?? []; + Iterable? typistIdsInNarrow(SendableNarrow narrow) => + _timerMapsByNarrow[narrow]?.keys; // 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 f5079bfd31..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. @@ -44,27 +46,40 @@ mixin UserStore on PerAccountStoreBase { /// The name to show the given user as in the UI, even for unknown users. /// - /// This is the user's [User.fullName] if the user is known, - /// and otherwise a translation of "(unknown user)". + /// If the user is muted and [replaceIfMuted] is true (the default), + /// this is [ZulipLocalizations.mutedUser]. + /// + /// Otherwise this is the user's [User.fullName] if the user is known, + /// or (if unknown) [ZulipLocalizations.unknownUserName]. /// /// When a [Message] is available which the user sent, /// use [senderDisplayName] instead for a better-informed fallback. - String userDisplayName(int userId) { + String userDisplayName(int userId, {bool replaceIfMuted = true}) { + if (replaceIfMuted && isUserMuted(userId)) { + return GlobalLocalizations.zulipLocalizations.mutedUser; + } return getUser(userId)?.fullName ?? GlobalLocalizations.zulipLocalizations.unknownUserName; } /// The name to show for the given message's sender in the UI. /// - /// If the user is known (see [getUser]), this is their current [User.fullName]. + /// If the sender is muted and [replaceIfMuted] is true (the default), + /// this is [ZulipLocalizations.mutedUser]. + /// + /// Otherwise, if the user is known (see [getUser]), + /// this is their current [User.fullName]. /// If unknown, this uses the fallback value conveniently provided on the /// [Message] object itself, namely [Message.senderFullName]. /// /// For a user who isn't the sender of some known message, /// see [userDisplayName]. - String senderDisplayName(Message message) { - return getUser(message.senderId)?.fullName - ?? message.senderFullName; + String senderDisplayName(Message message, {bool replaceIfMuted = true}) { + final senderId = message.senderId; + if (replaceIfMuted && isUserMuted(senderId)) { + return GlobalLocalizations.zulipLocalizations.mutedUser; + } + return getUser(senderId)?.fullName ?? message.senderFullName; } /// Whether the user with [userId] is muted by the self-user. @@ -72,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. @@ -105,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/action_sheet.dart b/lib/widgets/action_sheet.dart index 6bd4e1024a..2f32209b0f 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -589,6 +589,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final markAsUnreadSupported = store.zulipFeatureLevel >= 155; // TODO(server-6) final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; + final isSenderMuted = store.isUserMuted(message.senderId); + final optionButtons = [ if (popularEmojiLoaded) ReactionButtons(message: message, pageContext: pageContext), @@ -597,6 +599,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes QuoteAndReplyButton(message: message, pageContext: pageContext), if (showMarkAsUnreadButton) MarkAsUnreadButton(message: message, pageContext: pageContext), + if (isSenderMuted) + UnrevealMutedMessageButton(message: message, pageContext: pageContext), CopyMessageTextButton(message: message, pageContext: pageContext), CopyMessageLinkButton(message: message, pageContext: pageContext), ShareButton(message: message, pageContext: pageContext), @@ -902,6 +906,27 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { } } +class UnrevealMutedMessageButton extends MessageActionSheetMenuItemButton { + UnrevealMutedMessageButton({ + super.key, + required super.message, + required super.pageContext, + }); + + @override + IconData get icon => ZulipIcons.eye_off; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionHideMutedMessage; + } + + @override + void onPressed() { + findMessageListPage().unrevealMutedMessage(message.id); + } +} + class CopyMessageTextButton extends MessageActionSheetMenuItemButton { CopyMessageTextButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index 676b30a45c..bfb633ee66 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -275,10 +275,9 @@ class _MentionAutocompleteItem extends StatelessWidget { String? sublabel; switch (option) { case UserMentionAutocompleteResult(:var userId): - final user = store.getUser(userId)!; // must exist because UserMentionAutocompleteResult avatar = Avatar(userId: userId, size: 36, borderRadius: 4); - label = user.fullName; - sublabel = store.userDisplayEmail(user); + label = store.userDisplayName(userId); + sublabel = store.userDisplayEmail(userId); case WildcardMentionAutocompleteResult(:var wildcardOption): avatar = SizedBox.square(dimension: 36, child: const Icon(ZulipIcons.three_person, size: 24)); diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart index fb5968b97a..f142c2fa24 100644 --- a/lib/widgets/button.dart +++ b/lib/widgets/button.dart @@ -18,17 +18,30 @@ class ZulipWebUiKitButton extends StatelessWidget { super.key, this.attention = ZulipWebUiKitButtonAttention.medium, this.intent = ZulipWebUiKitButtonIntent.info, + this.size = ZulipWebUiKitButtonSize.normal, required this.label, + this.icon, required this.onPressed, }); final ZulipWebUiKitButtonAttention attention; final ZulipWebUiKitButtonIntent intent; + final ZulipWebUiKitButtonSize size; final String label; + final IconData? icon; final VoidCallback onPressed; WidgetStateColor _backgroundColor(DesignVariables designVariables) { switch ((attention, intent)) { + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.neutral): + return WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.neutralButtonBg.withFadedAlpha(0.3), + ~WidgetState.pressed: designVariables.neutralButtonBg.withAlpha(0), + }); + case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.info): + throw UnimplementedError(); case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.info): return WidgetStateColor.fromMap({ WidgetState.pressed: designVariables.btnBgAttMediumIntInfoActive, @@ -44,6 +57,13 @@ class ZulipWebUiKitButton extends StatelessWidget { Color _labelColor(DesignVariables designVariables) { switch ((attention, intent)) { + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.neutral): + // TODO nit: don't fade in pressed state + return designVariables.neutralButtonLabel.withFadedAlpha(0.85); + case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.neutral): + case (ZulipWebUiKitButtonAttention.minimal, ZulipWebUiKitButtonIntent.info): + throw UnimplementedError(); case (ZulipWebUiKitButtonAttention.medium, ZulipWebUiKitButtonIntent.info): return designVariables.btnLabelAttMediumIntInfo; case (ZulipWebUiKitButtonAttention.high, ZulipWebUiKitButtonIntent.info): @@ -53,7 +73,8 @@ class ZulipWebUiKitButton extends StatelessWidget { TextStyle _labelStyle(BuildContext context, {required TextScaler textScaler}) { final designVariables = DesignVariables.of(context); - // Values chosen from the Figma frame for zulip-flutter's compose box: + // Normal-size values chosen from the Figma frame for zulip-flutter's + // compose box: // https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3988-38201&m=dev // Commented values come from the Figma page "Zulip Web UI kit": // https://www.figma.com/design/msWyAJ8cnMHgOMPxi7BUvA/Zulip-Web-UI-kit?node-id=1-8&p=f&m=dev @@ -61,17 +82,22 @@ class ZulipWebUiKitButton extends StatelessWidget { // https://github.com/zulip/zulip-flutter/pull/1432#discussion_r2023880851 return TextStyle( color: _labelColor(designVariables), - fontSize: 17, // 16 - height: 1.20, // 1.25 - letterSpacing: proportionalLetterSpacing(context, textScaler: textScaler, - 0.006, - baseFontSize: 17), // 16 + fontSize: _forSize(16, 17 /* 16 */), + height: _forSize(1, 1.20 /* 1.25 */), + letterSpacing: _forSize( + 0, + proportionalLetterSpacing(context, textScaler: textScaler, + 0.006, + baseFontSize: 17 /* 16 */), + ), ).merge(weightVariableTextStyle(context, wght: 600)); // 500 } BorderSide _borderSide(DesignVariables designVariables) { switch (attention) { + case ZulipWebUiKitButtonAttention.minimal: + return BorderSide.none; case ZulipWebUiKitButtonAttention.medium: // TODO inner shadow effect like `box-shadow: inset`, following Figma; // needs Flutter support for something like that: @@ -87,6 +113,12 @@ class ZulipWebUiKitButton extends StatelessWidget { } } + T _forSize(T small, T normal) => + switch (size) { + ZulipWebUiKitButtonSize.small => small, + ZulipWebUiKitButtonSize.normal => normal, + }; + @override Widget build(BuildContext context) { final designVariables = DesignVariables.of(context); @@ -104,27 +136,41 @@ class ZulipWebUiKitButton extends StatelessWidget { // from shrinking to zero as the button grows to accommodate a larger label final textScaler = MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5); + final buttonHeight = _forSize(24, 28); + + final labelColor = _labelColor(designVariables); + return AnimatedScaleOnTap( scaleEnd: 0.96, duration: Duration(milliseconds: 100), - child: TextButton( + child: TextButton.icon( + // TODO the gap between the icon and label should be 6px, not 8px + icon: icon != null ? Icon(icon) : null, style: TextButton.styleFrom( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 4 - densityVerticalAdjustment), - foregroundColor: _labelColor(designVariables), + iconSize: 16, + iconColor: labelColor, + padding: EdgeInsets.symmetric( + horizontal: _forSize(6, 10), + vertical: 4 - densityVerticalAdjustment, + ), + foregroundColor: labelColor, shape: RoundedRectangleBorder( side: _borderSide(designVariables), - borderRadius: BorderRadius.circular(4)), + borderRadius: BorderRadius.circular(_forSize(6, 4))), splashFactory: NoSplash.splashFactory, - // These three arguments make the button 28px tall vertically, + // These three arguments make the button `buttonHeight` tall, // but with vertical padding to make the touch target 44px tall: // https://github.com/zulip/zulip-flutter/pull/1432#discussion_r2023907300 visualDensity: visualDensity, tapTargetSize: MaterialTapTargetSize.padded, - minimumSize: Size(kMinInteractiveDimension, 28 - densityVerticalAdjustment), + minimumSize: Size( + kMinInteractiveDimension, + buttonHeight - densityVerticalAdjustment, + ), ).copyWith(backgroundColor: _backgroundColor(designVariables)), onPressed: onPressed, - child: ConstrainedBox( + label: ConstrainedBox( constraints: BoxConstraints(maxWidth: 240), child: Text(label, textScaler: textScaler, @@ -139,10 +185,15 @@ enum ZulipWebUiKitButtonAttention { high, medium, // low, + + /// An ad hoc value for the "Reveal message" button + /// on a message from a muted sender: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6092-50786&m=dev + minimal, } enum ZulipWebUiKitButtonIntent { - // neutral, + neutral, // warning, // danger, info, @@ -150,6 +201,17 @@ enum ZulipWebUiKitButtonIntent { // brand, } +enum ZulipWebUiKitButtonSize { + /// A smaller size than the one in the Zulip Web UI Kit. + /// + /// This was ad hoc for mobile, for the "Reveal message" button + /// on a message from a muted sender: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6092-50786&m=dev + small, + + normal, +} + /// Apply [Transform.scale] to the child widget when tapped, and reset its scale /// when released, while animating the transitions. class AnimatedScaleOnTap extends StatefulWidget { diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index a53d628a2c..10d2158cb5 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -859,9 +859,11 @@ class _FixedDestinationContentInput extends StatelessWidget { case DmNarrow(otherRecipientIds: [final otherUserId]): final store = PerAccountStoreWidget.of(context); - final fullName = store.getUser(otherUserId)?.fullName; - if (fullName == null) return zulipLocalizations.composeBoxGenericContentHint; - return zulipLocalizations.composeBoxDmContentHint(fullName); + final user = store.getUser(otherUserId); + if (user == null) return zulipLocalizations.composeBoxGenericContentHint; + // TODO write a test where the user is muted + return zulipLocalizations.composeBoxDmContentHint( + store.userDisplayName(otherUserId, replaceIfMuted: false)); case DmNarrow(): // A group DM thread. return zulipLocalizations.composeBoxGroupDmContentHint; diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 44411434fd..78dd6bc551 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1662,18 +1662,23 @@ class Avatar extends StatelessWidget { required this.userId, required this.size, required this.borderRadius, + this.replaceIfMuted = true, }); final int userId; final double size; final double borderRadius; + final bool replaceIfMuted; @override Widget build(BuildContext context) { return AvatarShape( size: size, borderRadius: borderRadius, - child: AvatarImage(userId: userId, size: size)); + child: AvatarImage( + userId: userId, + size: size, + replaceIfMuted: replaceIfMuted)); } } @@ -1687,10 +1692,12 @@ class AvatarImage extends StatelessWidget { super.key, required this.userId, required this.size, + this.replaceIfMuted = true, }); final int userId; final double size; + final bool replaceIfMuted; @override Widget build(BuildContext context) { @@ -1701,6 +1708,10 @@ class AvatarImage extends StatelessWidget { return const SizedBox.shrink(); } + if (replaceIfMuted && store.isUserMuted(userId)) { + return _AvatarPlaceholder(size: size); + } + final resolvedUrl = switch (user.avatarUrl) { null => null, // TODO(#255): handle computing gravatars var avatarUrl => store.tryResolveUrl(avatarUrl), @@ -1721,6 +1732,32 @@ class AvatarImage extends StatelessWidget { } } +/// A placeholder avatar for muted users. +/// +/// Wrap this with [AvatarShape]. +// TODO(#1558) use this as a fallback in more places (?) and update dartdoc. +class _AvatarPlaceholder extends StatelessWidget { + const _AvatarPlaceholder({required this.size}); + + /// The size of the placeholder box. + /// + /// This should match the `size` passed to the wrapping [AvatarShape]. + /// The placeholder's icon will be scaled proportionally to this. + final double size; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return DecoratedBox( + decoration: BoxDecoration(color: designVariables.avatarPlaceholderBg), + child: Icon(ZulipIcons.person, + // Where the avatar placeholder appears in the Figma, + // this is how the icon is sized proportionally to its box. + size: size * 20 / 32, + color: designVariables.avatarPlaceholderIcon)); + } +} + /// A rounded square shape, to wrap an [AvatarImage] or similar. class AvatarShape extends StatelessWidget { const AvatarShape({ diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index cd1822bbac..7f101a81ce 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -395,6 +395,7 @@ class _DmItem extends StatelessWidget { final store = PerAccountStoreWidget.of(context); final designVariables = DesignVariables.of(context); + // TODO write a test where a/the recipient is muted final title = switch (narrow.otherRecipientIds) { // TODO dedupe with [RecentDmConversationsItem] [] => store.selfUser.fullName, [var otherUserId] => store.userDisplayName(otherUserId), diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 5b51d3e909..7199c72a5c 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -166,6 +166,7 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { @override Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); final themeData = Theme.of(context); final appBarBackgroundColor = Colors.grey.shade900.withValues(alpha: 0.87); @@ -194,13 +195,19 @@ class _LightboxPageLayoutState extends State<_LightboxPageLayout> { shape: const Border(), // Remove bottom border from [AppBarTheme] elevation: appBarElevation, title: Row(children: [ - Avatar(size: 36, borderRadius: 36 / 8, userId: widget.message.senderId), + Avatar( + size: 36, + borderRadius: 36 / 8, + userId: widget.message.senderId, + replaceIfMuted: false, + ), const SizedBox(width: 8), Expanded( child: RichText( text: TextSpan(children: [ TextSpan( - text: '${widget.message.senderFullName}\n', // TODO(#716): use `store.senderDisplayName` + // TODO write a test where the sender is muted; check this and avatar + text: '${store.senderDisplayName(widget.message, replaceIfMuted: false)}\n', // Restate default style: themeData.textTheme.titleLarge!.copyWith(color: appBarForegroundColor)), diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 14c33ad5fd..fa7e716c76 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -13,6 +13,7 @@ import '../model/typing_status.dart'; import 'action_sheet.dart'; import 'actions.dart'; import 'app_bar.dart'; +import 'button.dart'; import 'color.dart'; import 'compose_box.dart'; import 'content.dart'; @@ -139,6 +140,14 @@ abstract class MessageListPageState { /// /// This is null if [MessageList] has not mounted yet. MessageListView? get model; + + /// For a message from a muted sender, reveal the sender and content, + /// replacing the "Muted user" placeholder. + void revealMutedMessage(int messageId); + + /// For a message from a muted sender, hide the sender and content again + /// with the "Muted user" placeholder. + void unrevealMutedMessage(int messageId); } class MessageListPage extends StatefulWidget { @@ -155,6 +164,21 @@ class MessageListPage extends StatefulWidget { initNarrow: narrow, initAnchorMessageId: initAnchorMessageId)); } + /// The "revealed" state of a message from a muted sender. + /// + /// This is updated via [MessageListPageState.revealMutedMessage] + /// and [MessageListPageState.unrevealMutedMessage]. + /// + /// Uses the efficient [BuildContext.dependOnInheritedWidgetOfExactType], + /// so this is safe to call in a build method. + static RevealedMutedMessagesState revealedMutedMessagesOf(BuildContext context) { + final state = + context.dependOnInheritedWidgetOfExactType<_RevealedMutedMessagesProvider>() + ?.state; + assert(state != null, 'No _RevealedMutedMessagesProvider ancestor'); + return state!; + } + /// The [MessageListPageState] above this context in the tree. /// /// Uses the inefficient [BuildContext.findAncestorStateOfType]; @@ -186,6 +210,18 @@ class _MessageListPageState extends State implements MessageLis MessageListView? get model => _messageListKey.currentState?.model; final GlobalKey<_MessageListState> _messageListKey = GlobalKey(); + final _revealedMutedMessages = RevealedMutedMessagesState(); + + @override + void revealMutedMessage(int messageId) { + _revealedMutedMessages._add(messageId); + } + + @override + void unrevealMutedMessage(int messageId) { + _revealedMutedMessages._remove(messageId); + } + @override void initState() { super.initState(); @@ -256,9 +292,7 @@ class _MessageListPageState extends State implements MessageLis initAnchor = useFirstUnread ? AnchorCode.firstUnread : AnchorCode.newest; } - // Insert a PageRoot here, to provide a context that can be used for - // MessageListPage.ancestorOf. - return PageRoot(child: Scaffold( + Widget result = Scaffold( appBar: ZulipAppBar( buildTitle: (willCenterTitle) => MessageListAppBarTitle(narrow: narrow, willCenterTitle: willCenterTitle), @@ -302,10 +336,45 @@ class _MessageListPageState extends State implements MessageLis if (ComposeBox.hasComposeBox(narrow)) ComposeBox(key: _composeBoxKey, narrow: narrow) ]); - }))); + })); + + // Insert a PageRoot here (under MessageListPage), + // to provide a context that can be used for MessageListPage.ancestorOf. + result = PageRoot(child: result); + + result = _RevealedMutedMessagesProvider(state: _revealedMutedMessages, + child: result); + + return result; + } +} + +class RevealedMutedMessagesState extends ChangeNotifier { + final Set _revealedMessages = {}; + + bool isMutedMessageRevealed(int messageId) => + _revealedMessages.contains(messageId); + + void _add(int messageId) { + _revealedMessages.add(messageId); + notifyListeners(); + } + + void _remove(int messageId) { + _revealedMessages.remove(messageId); + notifyListeners(); } } +class _RevealedMutedMessagesProvider extends InheritedNotifier { + const _RevealedMutedMessagesProvider({ + required RevealedMutedMessagesState state, + required super.child, + }) : super(notifier: state); + + RevealedMutedMessagesState get state => notifier!; +} + class _TopicListButton extends StatelessWidget { const _TopicListButton({required this.streamId}); @@ -943,13 +1012,16 @@ 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) { + if (typistIds == null) return const SizedBox(); + final filteredTypistIds = typistIds + .whereNot((userId) => store.isUserMuted(userId)); + 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, }; @@ -1514,31 +1586,40 @@ class _SenderRow extends StatelessWidget { final sender = store.getUser(message.senderId); final time = _kMessageTimestampFormat .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); + + final showAsMuted = store.isUserMuted(message.senderId) + && message is Message // i.e., not an outbox message + && !MessageListPage.revealedMutedMessagesOf(context) + .isMutedMessageRevealed((message as Message).id); + return Padding( padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: localizedTextBaseline(context), children: [ Flexible( child: GestureDetector( - onTap: () => Navigator.push(context, + onTap: () => showAsMuted ? null : Navigator.push(context, ProfilePage.buildRoute(context: context, userId: message.senderId)), child: Row( children: [ Avatar(size: 32, borderRadius: 3, - userId: message.senderId), + userId: message.senderId, + replaceIfMuted: showAsMuted), const SizedBox(width: 8), Flexible( child: Text(message is Message - ? store.senderDisplayName(message as Message) + ? store.senderDisplayName(message as Message, + replaceIfMuted: showAsMuted) : store.userDisplayName(message.senderId), style: TextStyle( fontSize: 18, height: (22 / 18), - color: designVariables.title, + color: showAsMuted + ? designVariables.title.withFadedAlpha(0.5) + : designVariables.title, ).merge(weightVariableTextStyle(context, wght: 600)), overflow: TextOverflow.ellipsis)), if (sender?.isBot ?? false) ...[ @@ -1619,9 +1700,15 @@ class MessageWithPossibleSender extends StatelessWidget { } } + final showAsMuted = store.isUserMuted(message.senderId) + && !MessageListPage.revealedMutedMessagesOf(context) + .isMutedMessageRevealed(message.id); + return GestureDetector( behavior: HitTestBehavior.translucent, - onLongPress: () => showMessageActionSheet(context: context, message: message), + onLongPress: showAsMuted + ? null // TODO write a test for this + : () => showMessageActionSheet(context: context, message: message), child: Padding( padding: const EdgeInsets.only(top: 4), child: Column(children: [ @@ -1632,28 +1719,40 @@ class MessageWithPossibleSender extends StatelessWidget { textBaseline: localizedTextBaseline(context), children: [ const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - content, - if ((message.reactions?.total ?? 0) > 0) - ReactionChipsList(messageId: message.id, reactions: message.reactions!), - if (editMessageErrorStatus != null) - _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) - else if (editStateText != null) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text(editStateText, - textAlign: TextAlign.end, - style: TextStyle( - color: designVariables.labelEdited, - fontSize: 12, - height: (12 / 12), - letterSpacing: proportionalLetterSpacing(context, - 0.05, baseFontSize: 12)))) - else - Padding(padding: const EdgeInsets.only(bottom: 4)) - ])), + Expanded(child: showAsMuted + ? Align( + alignment: AlignmentDirectional.topStart, + child: ZulipWebUiKitButton( + label: zulipLocalizations.revealButtonLabel, + icon: ZulipIcons.eye, + size: ZulipWebUiKitButtonSize.small, + intent: ZulipWebUiKitButtonIntent.neutral, + attention: ZulipWebUiKitButtonAttention.minimal, + onPressed: () { + MessageListPage.ancestorOf(context).revealMutedMessage(message.id); + })) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + if ((message.reactions?.total ?? 0) > 0) + ReactionChipsList(messageId: message.id, reactions: message.reactions!), + if (editMessageErrorStatus != null) + _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) + else if (editStateText != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(editStateText, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.labelEdited, + fontSize: 12, + height: (12 / 12), + letterSpacing: proportionalLetterSpacing(context, + 0.05, baseFontSize: 12)))) + else + Padding(padding: const EdgeInsets.only(bottom: 4)) + ])), SizedBox(width: 16, child: star), ]), diff --git a/lib/widgets/new_dm_sheet.dart b/lib/widgets/new_dm_sheet.dart index f81a66de42..2ea266c40b 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 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/profile.dart b/lib/widgets/profile.dart index f1328b3367..ba0fe568e0 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -44,12 +44,17 @@ class ProfilePage extends StatelessWidget { return const _ProfileErrorPage(); } - final displayEmail = store.userDisplayEmail(user); + final displayEmail = store.userDisplayEmail(userId); final items = [ Center( - child: Avatar(userId: userId, size: 200, borderRadius: 200 / 8)), + child: Avatar( + userId: userId, + size: 200, + borderRadius: 200 / 8, + replaceIfMuted: false)), const SizedBox(height: 16), - Text(user.fullName, + // TODO write a test where the user is muted; check this and avatar + Text(store.userDisplayName(userId, replaceIfMuted: false), textAlign: TextAlign.center, style: _TextStyles.primaryFieldText .merge(weightVariableTextStyle(context, wght: 700))), @@ -75,7 +80,9 @@ class ProfilePage extends StatelessWidget { ]; return Scaffold( - appBar: ZulipAppBar(title: Text(user.fullName)), + appBar: ZulipAppBar( + // TODO write a test where the user is muted + title: Text(store.userDisplayName(userId, replaceIfMuted: false))), body: SingleChildScrollView( child: Center( child: ConstrainedBox( diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index d392998268..d1d5221031 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -52,6 +52,7 @@ class _RecentDmConversationsPageBodyState extends State { labelMenuButton: const Color(0xff222222), labelSearchPrompt: const Color(0xff000000).withValues(alpha: 0.5), mainBackground: const Color(0xfff0f0f0), + neutralButtonBg: const Color(0xff8c84ae), + neutralButtonLabel: const Color(0xff433d5c), radioBorder: Color(0xffbbbdc8), radioFillSelected: Color(0xff4370f0), textInput: const Color(0xff000000), @@ -178,11 +180,11 @@ class DesignVariables extends ThemeExtension { bgSearchInput: const Color(0xffe3e3e3), textMessage: const Color(0xff262626), channelColorSwatches: ChannelColorSwatches.light, + avatarPlaceholderBg: const Color(0x33808080), + avatarPlaceholderIcon: Colors.black.withValues(alpha: 0.5), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), - groupDmConversationIcon: Colors.black.withValues(alpha: 0.5), - groupDmConversationIconBg: const Color(0x33808080), inboxItemIconMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), loginOrDivider: const Color(0xffdedede), loginOrDividerText: const Color(0xff575757), @@ -240,6 +242,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), labelSearchPrompt: const Color(0xffffffff).withValues(alpha: 0.5), mainBackground: const Color(0xff1d1d1d), + neutralButtonBg: const Color(0xffd4d1e0), + neutralButtonLabel: const Color(0xffa9a3c2), radioBorder: Color(0xff626573), radioFillSelected: Color(0xff4e7cfa), textInput: const Color(0xffffffff).withValues(alpha: 0.9), @@ -247,14 +251,14 @@ class DesignVariables extends ThemeExtension { bgSearchInput: const Color(0xff313131), textMessage: const Color(0xffffffff).withValues(alpha: 0.8), channelColorSwatches: ChannelColorSwatches.dark, + // TODO(design-dark) need proper dark-theme color (this is ad hoc) + avatarPlaceholderBg: const Color(0x33cccccc), + // TODO(design-dark) need proper dark-theme color (this is ad hoc) + avatarPlaceholderIcon: Colors.white.withValues(alpha: 0.5), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), // the same as the light mode in Figma contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), // the same as the light mode in Figma // TODO(design-dark) need proper dark-theme color (this is ad hoc) dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), - // TODO(design-dark) need proper dark-theme color (this is ad hoc) - groupDmConversationIcon: Colors.white.withValues(alpha: 0.5), - // TODO(design-dark) need proper dark-theme color (this is ad hoc) - groupDmConversationIconBg: const Color(0x33cccccc), inboxItemIconMarker: const HSLColor.fromAHSL(0.4, 0, 0, 1).toColor(), loginOrDivider: const Color(0xff424242), loginOrDividerText: const Color(0xffa8a8a8), @@ -317,6 +321,8 @@ class DesignVariables extends ThemeExtension { required this.labelMenuButton, required this.labelSearchPrompt, required this.mainBackground, + required this.neutralButtonBg, + required this.neutralButtonLabel, required this.radioBorder, required this.radioFillSelected, required this.textInput, @@ -324,11 +330,11 @@ class DesignVariables extends ThemeExtension { required this.bgSearchInput, required this.textMessage, required this.channelColorSwatches, + required this.avatarPlaceholderBg, + required this.avatarPlaceholderIcon, required this.contextMenuCancelBg, required this.contextMenuCancelPressedBg, required this.dmHeaderBg, - required this.groupDmConversationIcon, - required this.groupDmConversationIconBg, required this.inboxItemIconMarker, required this.loginOrDivider, required this.loginOrDividerText, @@ -395,6 +401,8 @@ class DesignVariables extends ThemeExtension { final Color labelMenuButton; final Color labelSearchPrompt; final Color mainBackground; + final Color neutralButtonBg; + final Color neutralButtonLabel; final Color radioBorder; final Color radioFillSelected; final Color textInput; @@ -406,11 +414,11 @@ class DesignVariables extends ThemeExtension { final ChannelColorSwatches channelColorSwatches; // Not named variables in Figma; taken from older Figma drafts, or elsewhere. + final Color avatarPlaceholderBg; + final Color avatarPlaceholderIcon; final Color contextMenuCancelBg; // In Figma, but unnamed. final Color contextMenuCancelPressedBg; // In Figma, but unnamed. final Color dmHeaderBg; - final Color groupDmConversationIcon; - final Color groupDmConversationIconBg; final Color inboxItemIconMarker; final Color loginOrDivider; // TODO(design-dark) need proper dark-theme color (this is ad hoc) final Color loginOrDividerText; // TODO(design-dark) need proper dark-theme color (this is ad hoc) @@ -468,6 +476,8 @@ class DesignVariables extends ThemeExtension { Color? labelMenuButton, Color? labelSearchPrompt, Color? mainBackground, + Color? neutralButtonBg, + Color? neutralButtonLabel, Color? radioBorder, Color? radioFillSelected, Color? textInput, @@ -475,11 +485,11 @@ class DesignVariables extends ThemeExtension { Color? bgSearchInput, Color? textMessage, ChannelColorSwatches? channelColorSwatches, + Color? avatarPlaceholderBg, + Color? avatarPlaceholderIcon, Color? contextMenuCancelBg, Color? contextMenuCancelPressedBg, Color? dmHeaderBg, - Color? groupDmConversationIcon, - Color? groupDmConversationIconBg, Color? inboxItemIconMarker, Color? loginOrDivider, Color? loginOrDividerText, @@ -536,6 +546,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: labelMenuButton ?? this.labelMenuButton, labelSearchPrompt: labelSearchPrompt ?? this.labelSearchPrompt, mainBackground: mainBackground ?? this.mainBackground, + neutralButtonBg: neutralButtonBg ?? this.neutralButtonBg, + neutralButtonLabel: neutralButtonLabel ?? this.neutralButtonLabel, radioBorder: radioBorder ?? this.radioBorder, radioFillSelected: radioFillSelected ?? this.radioFillSelected, textInput: textInput ?? this.textInput, @@ -543,11 +555,11 @@ class DesignVariables extends ThemeExtension { bgSearchInput: bgSearchInput ?? this.bgSearchInput, textMessage: textMessage ?? this.textMessage, channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches, + avatarPlaceholderBg: avatarPlaceholderBg ?? this.avatarPlaceholderBg, + avatarPlaceholderIcon: avatarPlaceholderIcon ?? this.avatarPlaceholderIcon, contextMenuCancelBg: contextMenuCancelBg ?? this.contextMenuCancelBg, contextMenuCancelPressedBg: contextMenuCancelPressedBg ?? this.contextMenuCancelPressedBg, dmHeaderBg: dmHeaderBg ?? this.dmHeaderBg, - groupDmConversationIcon: groupDmConversationIcon ?? this.groupDmConversationIcon, - groupDmConversationIconBg: groupDmConversationIconBg ?? this.groupDmConversationIconBg, inboxItemIconMarker: inboxItemIconMarker ?? this.inboxItemIconMarker, loginOrDivider: loginOrDivider ?? this.loginOrDivider, loginOrDividerText: loginOrDividerText ?? this.loginOrDividerText, @@ -611,6 +623,8 @@ class DesignVariables extends ThemeExtension { labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, labelSearchPrompt: Color.lerp(labelSearchPrompt, other.labelSearchPrompt, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, + neutralButtonBg: Color.lerp(neutralButtonBg, other.neutralButtonBg, t)!, + neutralButtonLabel: Color.lerp(neutralButtonLabel, other.neutralButtonLabel, t)!, radioBorder: Color.lerp(radioBorder, other.radioBorder, t)!, radioFillSelected: Color.lerp(radioFillSelected, other.radioFillSelected, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, @@ -618,11 +632,11 @@ class DesignVariables extends ThemeExtension { bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, textMessage: Color.lerp(textMessage, other.textMessage, t)!, channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t), + avatarPlaceholderBg: Color.lerp(avatarPlaceholderBg, other.avatarPlaceholderBg, t)!, + avatarPlaceholderIcon: Color.lerp(avatarPlaceholderIcon, other.avatarPlaceholderIcon, t)!, contextMenuCancelBg: Color.lerp(contextMenuCancelBg, other.contextMenuCancelBg, t)!, contextMenuCancelPressedBg: Color.lerp(contextMenuCancelPressedBg, other.contextMenuCancelPressedBg, t)!, dmHeaderBg: Color.lerp(dmHeaderBg, other.dmHeaderBg, t)!, - groupDmConversationIcon: Color.lerp(groupDmConversationIcon, other.groupDmConversationIcon, t)!, - groupDmConversationIconBg: Color.lerp(groupDmConversationIconBg, other.groupDmConversationIconBg, t)!, inboxItemIconMarker: Color.lerp(inboxItemIconMarker, other.inboxItemIconMarker, t)!, loginOrDivider: Color.lerp(loginOrDivider, other.loginOrDivider, t)!, loginOrDividerText: Color.lerp(loginOrDividerText, other.loginOrDividerText, t)!, diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index cab073db48..b95bb58ccb 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -404,19 +404,33 @@ void main() { }); group('MentionAutocompleteQuery.testUser', () { + late PerAccountStore store; + void doCheck(String rawQuery, User user, bool expected) { final result = MentionAutocompleteQuery(rawQuery) - .testUser(user, AutocompleteDataCache()); + .testUser(user, AutocompleteDataCache(), store); expected ? check(result).isTrue() : check(result).isFalse(); } test('user is always excluded when not active regardless of other criteria', () { + store = eg.store(); + doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: false), false); // When active then other criteria will be checked doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: true), true); }); + test('user is always excluded when muted, regardless of other criteria', () async { + store = eg.store(); + await store.setMutedUsers([1]); + doCheck('Full Name', eg.user(userId: 1, fullName: 'Full Name'), false); + // When not muted, then other criteria will be checked + doCheck('Full Name', eg.user(userId: 2, fullName: 'Full Name'), true); + }); + test('user is included if fullname words match the query', () { + store = eg.store(); + doCheck('', eg.user(fullName: 'Full Name'), true); doCheck('', eg.user(fullName: ''), true); // Unlikely case, but should not crash doCheck('Full Name', eg.user(fullName: 'Full Name'), true); diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index 4f06cc2fcc..5c00aca814 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -223,7 +223,8 @@ void main() { void checkChanges(PerAccountStore store, UserTopicVisibilityPolicy newPolicy, - VisibilityEffect expectedInStream, VisibilityEffect expectedOverall) { + UserTopicVisibilityEffect expectedInStream, + UserTopicVisibilityEffect expectedOverall) { final event = mkEvent(newPolicy); check(store.willChangeIfTopicVisibleInStream(event)).equals(expectedInStream); check(store.willChangeIfTopicVisible (event)).equals(expectedOverall); @@ -234,7 +235,7 @@ void main() { await store.addStream(stream1); await store.addSubscription(eg.subscription(stream1)); checkChanges(store, UserTopicVisibilityPolicy.followed, - VisibilityEffect.none, VisibilityEffect.none); + UserTopicVisibilityEffect.none, UserTopicVisibilityEffect.none); }); test('stream not muted, policy none -> 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/compose_test.dart b/test/model/compose_test.dart index bfbc170ca1..2031b69d28 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -1,5 +1,6 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/model/compose.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/store.dart'; @@ -225,26 +226,69 @@ hello group('mention', () { group('user', () { final user = eg.user(userId: 123, fullName: 'Full Name'); - test('not silent', () { + final message = eg.streamMessage(sender: user); + test('not silent', () async { + final store = eg.store(); + await store.addUser(user); check(userMention(user, silent: false)).equals('@**Full Name|123**'); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); }); - test('silent', () { + test('silent', () async { + final store = eg.store(); + await store.addUser(user); check(userMention(user, silent: true)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; has two users with same fullName', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName)]); check(userMention(user, silent: true, users: store)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; has two same-name users but one of them is deactivated', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName, isActive: false)]); check(userMention(user, silent: true, users: store)).equals('@_**Full Name|123**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); }); test('`users` passed; user has unique fullName', () async { final store = eg.store(); await store.addUsers([user, eg.user(userId: 234, fullName: 'Another Name')]); check(userMention(user, silent: true, users: store)).equals('@_**Full Name**'); + check(userMentionFromMessage(message, silent: true, users: store)) + .equals('@_**Full Name|123**'); + }); + + test('userMentionFromMessage, known user', () async { + final user = eg.user(userId: 123, fullName: 'Full Name'); + final store = eg.store(); + await store.addUser(user); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: user.userId, fullName: 'New Name')); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**New Name|123**'); + }); + + test('userMentionFromMessage, unknown user', () async { + final store = eg.store(); + check(store.getUser(user.userId)).isNull(); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); + }); + + test('userMentionFromMessage, muted user', () async { + final store = eg.store(); + await store.addUser(user); + await store.setMutedUsers([user.userId]); + check(store.isUserMuted(user.userId)).isTrue(); + check(userMentionFromMessage(message, silent: false, users: store)) + .equals('@**Full Name|123**'); // not replaced with 'Muted user' }); }); diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index a19229e4a2..f99dac9f69 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)); @@ -2956,19 +3100,31 @@ void checkInvariants(MessageListView model) { for (final message in allMessages) { check(model.narrow.containsMessage(message)).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(): + 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(): + } + } else if (message is DmMessage) { + final narrow = DmNarrow.ofMessage(message, selfUserId: model.store.selfUserId); + switch (model.narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + check(model.store.shouldMuteDmConversation(narrow)).isFalse(); + case ChannelNarrow(): + case TopicNarrow(): + case DmNarrow(): + } } } diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 0196611e1d..d979e737f9 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -267,6 +267,10 @@ extension PerAccountStoreTestExtension on PerAccountStore { } } + Future setMutedUsers(List userIds) async { + await handleEvent(eg.mutedUsersEvent(userIds)); + } + Future addStream(ZulipStream stream) async { await addStreams([stream]); } diff --git a/test/model/typing_status_test.dart b/test/model/typing_status_test.dart index 01d817680a..60023d7dc9 100644 --- a/test/model/typing_status_test.dart +++ b/test/model/typing_status_test.dart @@ -65,7 +65,7 @@ void main() { } void checkTypists(Map> typistsByNarrow) { - final actualTypistsByNarrow = >{}; + final actualTypistsByNarrow = ?>{}; for (final narrow in model.debugActiveNarrows) { actualTypistsByNarrow[narrow] = model.typistIdsInNarrow(narrow); } 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/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 16cc36b096..d467aeea56 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -41,6 +41,7 @@ import '../model/binding.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; +import '../test_images.dart'; import '../test_share_plus.dart'; import 'compose_box_checks.dart'; import 'dialog_checks.dart'; @@ -53,10 +54,13 @@ late FakeApiConnection connection; Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + User? sender, + List? mutedUserIds, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, bool shouldSetServerEmojiData = true, bool useLegacyServerEmojiData = false, + Future Function()? beforeLongPress, }) async { addTearDown(testBinding.reset); assert(narrow.containsMessage(message)); @@ -70,10 +74,13 @@ Future setupToMessageActionSheet(WidgetTester tester, { store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([ eg.selfUser, - eg.user(userId: message.senderId), + sender ?? eg.user(userId: message.senderId), if (narrow is DmNarrow) ...narrow.otherRecipientIds.map((id) => eg.user(userId: id)), ]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } if (message is StreamMessage) { final stream = eg.stream(streamId: message.streamId); await store.addStream(stream); @@ -94,6 +101,8 @@ Future setupToMessageActionSheet(WidgetTester tester, { // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); + await beforeLongPress?.call(); + // Request the message action sheet. // // We use `warnIfMissed: false` to suppress warnings in cases where @@ -1334,6 +1343,79 @@ void main() { }); }); + group('UnrevealMutedMessageButton', () { + final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); + final message = eg.streamMessage(sender: user, + content: '

A message

', reactions: [eg.unicodeEmojiReaction]); + + final revealButtonFinder = find.widgetWithText(ZulipWebUiKitButton, + 'Reveal message'); + + final contentFinder = find.descendant( + of: find.byType(MessageContent), + matching: find.text('A message', findRichText: true)); + + testWidgets('not visible if message is from normal sender (not muted)', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user); + check(store.isUserMuted(user.userId)).isFalse(); + + check(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('visible if message is from muted sender and revealed', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user, + mutedUserIds: [user.userId], + beforeLongPress: () async { + check(contentFinder).findsNothing(); + await tester.tap(revealButtonFinder); + await tester.pump(); + check(contentFinder).findsOne(); + }, + ); + + check(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('when pressed, unreveals the message', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + sender: user, + mutedUserIds: [user.userId], + beforeLongPress: () async { + check(contentFinder).findsNothing(); + await tester.tap(revealButtonFinder); + await tester.pump(); + check(contentFinder).findsOne(); + }); + + await tester.ensureVisible(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.eye_off)); + await tester.pumpAndSettle(); + + check(contentFinder).findsNothing(); + check(revealButtonFinder).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + }); + group('CopyMessageTextButton', () { setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( diff --git a/test/widgets/button_test.dart b/test/widgets/button_test.dart index da9136b8a2..62f2fad7d1 100644 --- a/test/widgets/button_test.dart +++ b/test/widgets/button_test.dart @@ -16,72 +16,84 @@ void main() { TestZulipBinding.ensureInitialized(); group('ZulipWebUiKitButton', () { - final textScaleFactorVariants = ValueVariant(Set.of(kTextScaleFactors)); - - testWidgets('vertical outer padding is preserved as text scales', (tester) async { - addTearDown(testBinding.reset); - tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; - addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - - final buttonFinder = find.byType(ZulipWebUiKitButton); - - await tester.pumpWidget(TestZulipApp( - child: UnconstrainedBox( - child: ZulipWebUiKitButton(label: 'Cancel', onPressed: () {})))); - await tester.pump(); - - final element = tester.element(buttonFinder); - final renderObject = element.renderObject as RenderBox; - final size = renderObject.size; - check(size).height.equals(44); // includes outer padding - - final textScaler = TextScaler.linear(textScaleFactorVariants.currentValue!) - .clamp(maxScaleFactor: 1.5); - final expectedButtonHeight = max(28.0, // configured min height - (textScaler.scale(17) * 1.20).roundToDouble() // text height - + 4 + 4); // vertical padding - - // Rounded rectangle paints with the intended height… - final expectedRRect = RRect.fromLTRBR( - 0, 0, // zero relative to the position at this paint step - size.width, expectedButtonHeight, Radius.circular(4)); - check(renderObject).legacyMatcher( - // `paints` isn't a [Matcher] so we wrap it with `equals`; - // awkward but it works - equals(paints..drrect(outer: expectedRRect))); - - // …and that height leaves at least 4px for vertical outer padding. - check(expectedButtonHeight).isLessOrEqual(44 - 2 - 2); - }, variant: textScaleFactorVariants); - - testWidgets('vertical outer padding responds to taps, not just painted area', (tester) async { - addTearDown(testBinding.reset); - tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; - addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); - - final buttonFinder = find.byType(ZulipWebUiKitButton); - - int numTapsHandled = 0; - await tester.pumpWidget(TestZulipApp( - child: UnconstrainedBox( - child: ZulipWebUiKitButton( - label: 'Cancel', - onPressed: () => numTapsHandled++)))); - await tester.pump(); - - final element = tester.element(buttonFinder); - final renderObject = element.renderObject as RenderBox; - final size = renderObject.size; - check(size).height.equals(44); // includes outer padding - - // Outer padding responds to taps, not just the painted part. - final buttonCenter = tester.getCenter(buttonFinder); - int numTaps = 0; - for (double y = -22; y < 22; y++) { - await tester.tapAt(buttonCenter + Offset(0, y)); - numTaps++; - } - check(numTapsHandled).equals(numTaps); - }, variant: textScaleFactorVariants); + void testVerticalOuterPadding({required ZulipWebUiKitButtonSize sizeVariant}) { + final textScaleFactorVariants = ValueVariant(Set.of(kTextScaleFactors)); + T forSizeVariant(T small, T normal) => + switch (sizeVariant) { + ZulipWebUiKitButtonSize.small => small, + ZulipWebUiKitButtonSize.normal => normal, + }; + + testWidgets('vertical outer padding is preserved as text scales; $sizeVariant', (tester) async { + addTearDown(testBinding.reset); + tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final buttonFinder = find.byType(ZulipWebUiKitButton); + + await tester.pumpWidget(TestZulipApp( + child: UnconstrainedBox( + child: ZulipWebUiKitButton( + label: 'Cancel', + onPressed: () {}, + size: sizeVariant)))); + await tester.pump(); + + final element = tester.element(buttonFinder); + final renderObject = element.renderObject as RenderBox; + final size = renderObject.size; + check(size).height.equals(44); // includes outer padding + + final textScaler = TextScaler.linear(textScaleFactorVariants.currentValue!) + .clamp(maxScaleFactor: 1.5); + final expectedButtonHeight = max(forSizeVariant(24.0, 28.0), // configured min height + (textScaler.scale(forSizeVariant(16, 17) * forSizeVariant(1, 1.20)).roundToDouble() // text height + + 4 + 4)); // vertical padding + + // Rounded rectangle paints with the intended height… + final expectedRRect = RRect.fromLTRBR( + 0, 0, // zero relative to the position at this paint step + size.width, expectedButtonHeight, Radius.circular(forSizeVariant(6, 4))); + check(renderObject).legacyMatcher( + // `paints` isn't a [Matcher] so we wrap it with `equals`; + // awkward but it works + equals(paints..drrect(outer: expectedRRect))); + + // …and that height leaves at least 4px for vertical outer padding. + check(expectedButtonHeight).isLessOrEqual(44 - 2 - 2); + }, variant: textScaleFactorVariants); + + testWidgets('vertical outer padding responds to taps, not just painted area', (tester) async { + addTearDown(testBinding.reset); + tester.platformDispatcher.textScaleFactorTestValue = textScaleFactorVariants.currentValue!; + addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue); + + final buttonFinder = find.byType(ZulipWebUiKitButton); + + int numTapsHandled = 0; + await tester.pumpWidget(TestZulipApp( + child: UnconstrainedBox( + child: ZulipWebUiKitButton( + label: 'Cancel', + onPressed: () => numTapsHandled++)))); + await tester.pump(); + + final element = tester.element(buttonFinder); + final renderObject = element.renderObject as RenderBox; + final size = renderObject.size; + check(size).height.equals(44); // includes outer padding + + // Outer padding responds to taps, not just the painted part. + final buttonCenter = tester.getCenter(buttonFinder); + int numTaps = 0; + for (double y = -22; y < 22; y++) { + await tester.tapAt(buttonCenter + Offset(0, y)); + numTaps++; + } + check(numTapsHandled).equals(numTaps); + }, variant: textScaleFactorVariants); + } + testVerticalOuterPadding(sizeVariant: ZulipWebUiKitButtonSize.small); + testVerticalOuterPadding(sizeVariant: ZulipWebUiKitButtonSize.normal); }); } diff --git a/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index de3ad7227c..676baa8b6c 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -227,6 +227,27 @@ void main() { } } } + + testWidgets('show "Muted user" label for muted reactors', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + + await prepare(); + await store.addUsers([user1, user2]); + await store.setMutedUsers([user1.userId]); + await setupChipsInBox(tester, + reactions: [ + Reaction.fromJson({'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': user1.userId}), + Reaction.fromJson({'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': user2.userId}), + ]); + + final reactionChipFinder = find.byType(ReactionChip); + check(reactionChipFinder).findsOne(); + check(find.descendant( + of: reactionChipFinder, + matching: find.text('Muted user, User 2') + )).findsOne(); + }); }); testWidgets('Smoke test for light/dark/lerped', (tester) async { diff --git a/test/widgets/lightbox_test.dart b/test/widgets/lightbox_test.dart index fda7122123..39076c4bf8 100644 --- a/test/widgets/lightbox_test.dart +++ b/test/widgets/lightbox_test.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'package:video_player_platform_interface/video_player_platform_interface.dart'; import 'package:video_player/video_player.dart'; +import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; @@ -204,6 +205,8 @@ class FakeVideoPlayerPlatform extends Fake void main() { TestZulipBinding.ensureInitialized(); + late PerAccountStore store; + group('LightboxHero', () { late PerAccountStore store; late FakeApiConnection connection; @@ -316,10 +319,16 @@ void main() { Future setupPage(WidgetTester tester, { Message? message, + List? users, required Uri? thumbnailUrl, }) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + if (users != null) { + await store.addUsers(users); + } // ZulipApp instead of TestZulipApp because we need the navigator to push // the lightbox route. The lightbox page works together with the route; @@ -351,20 +360,41 @@ void main() { debugNetworkImageHttpClientProvider = null; }); - testWidgets('app bar shows sender name and date', (tester) async { - prepareBoringImageHttpClient(); - final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; - final message = eg.streamMessage(sender: eg.otherUser, timestamp: timestamp); - await setupPage(tester, message: message, thumbnailUrl: null); - - // We're looking for a RichText, in the app bar, with both the - // sender's name and the timestamp. + void checkAppBarNameAndDate(WidgetTester tester, String expectedName, String expectedDate) { final labelTextWidget = tester.widget( find.descendant(of: find.byType(AppBar).last, - matching: find.textContaining(findRichText: true, - eg.otherUser.fullName))); + matching: find.textContaining(findRichText: true, expectedName))); check(labelTextWidget.text.toPlainText()) - .contains('Jul 23, 2024 23:12:24'); + .contains(expectedDate); + } + + testWidgets('app bar shows sender name and date; updates when name changes', (tester) async { + prepareBoringImageHttpClient(); + final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; + final sender = eg.user(fullName: 'Old name'); + final message = eg.streamMessage(sender: sender, timestamp: timestamp); + await setupPage(tester, message: message, thumbnailUrl: null, users: [sender]); + check(store.getUser(sender.userId)).isNotNull(); + + checkAppBarNameAndDate(tester, 'Old name', 'Jul 23, 2024 23:12:24'); + + await store.handleEvent(RealmUserUpdateEvent(id: 1, + userId: sender.userId, fullName: 'New name')); + await tester.pump(); + checkAppBarNameAndDate(tester, 'New name', 'Jul 23, 2024 23:12:24'); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('app bar shows sender name and date; unknown sender', (tester) async { + prepareBoringImageHttpClient(); + final timestamp = DateTime.parse("2024-07-23 23:12:24").millisecondsSinceEpoch ~/ 1000; + final sender = eg.user(fullName: 'Sender name'); + final message = eg.streamMessage(sender: sender, timestamp: timestamp); + await setupPage(tester, message: message, thumbnailUrl: null, users: []); + check(store.getUser(sender.userId)).isNull(); + + checkAppBarNameAndDate(tester, 'Sender name', 'Jul 23, 2024 23:12:24'); debugNetworkImageHttpClientProvider = null; }); diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index f048bf437d..a085491b20 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -64,6 +64,7 @@ void main() { GetMessagesResult? fetchResult, List? streams, List? users, + List? mutedUserIds, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, int? zulipFeatureLevel, @@ -86,6 +87,9 @@ void main() { // prepare message list data await store.addUser(eg.selfUser); await store.addUsers(users ?? []); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } if (fetchResult != null) { assert(foundOldest && messageCount == null && messages == null); } else { @@ -323,6 +327,22 @@ void main() { matching: find.text('channel foo')), ).findsOne(); }); + + testWidgets('shows "Muted user" label for muted users in DM narrow', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + final user3 = eg.user(userId: 3, fullName: 'User 3'); + final mutedUsers = [1, 3]; + + await setupMessageListPage(tester, + narrow: DmNarrow.withOtherUsers([1, 2, 3], selfUserId: 10), + users: [user1, user2, user3], + mutedUserIds: mutedUsers, + messageCount: 1, + ); + + check(find.text('DMs with Muted user, User 2, Muted user')).findsOne(); + }); }); group('presents message content appropriately', () { @@ -812,9 +832,19 @@ void main() { await checkTyping(tester, eg.typingEvent(narrow, TypingOp.stop, eg.otherUser.userId), expected: 'Third User and Fourth User are typing…'); + await store.setMutedUsers([eg.thirdUser.userId]); + await tester.pump(); + check(tester.widget(finder)).data.equals('Fourth User is typing…'); // Verify that typing indicators expire after a set duration. await tester.pump(const Duration(seconds: 15)); check(finder.evaluate()).isEmpty(); + // Muted user starts typing again; nothing is shown. + await store.handleEvent( + eg.typingEvent(narrow, TypingOp.start, eg.thirdUser.userId)); + await tester.pump(); + check(finder.evaluate()).isEmpty(); + await tester.pump(const Duration(seconds: 15)); + check(finder.evaluate()).isEmpty(); }); } @@ -1422,6 +1452,21 @@ void main() { "${zulipLocalizations.unknownUserName}, ${eg.thirdUser.fullName}"))); }); + testWidgets('show "Muted user" label for muted users', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + final user3 = eg.user(userId: 3, fullName: 'User 3'); + final mutedUsers = [1, 3]; + + await setupMessageListPage(tester, + users: [user1, user2, user3], + mutedUserIds: mutedUsers, + messages: [eg.dmMessage(from: eg.selfUser, to: [user1, user2, user3])] + ); + + check(find.text('You and Muted user, Muted user, User 2')).findsOne(); + }); + testWidgets('icon color matches text color', (tester) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await setupMessageListPage(tester, messages: [ @@ -1625,6 +1670,71 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + group('Muted sender', () { + void checkMessage(Message message, {required bool expectIsMuted}) { + final mutedLabel = 'Muted user'; + final mutedLabelFinder = find.widgetWithText(MessageWithPossibleSender, + mutedLabel); + + final avatarFinder = find.byWidgetPredicate( + (widget) => widget is Avatar && widget.userId == message.senderId); + final mutedAvatarFinder = find.descendant( + of: avatarFinder, + matching: find.byIcon(ZulipIcons.person)); + final nonmutedAvatarFinder = find.descendant( + of: avatarFinder, + matching: find.byType(RealmContentNetworkImage)); + + final senderName = store.senderDisplayName(message, replaceIfMuted: false); + assert(senderName != mutedLabel); + final senderNameFinder = find.widgetWithText(MessageWithPossibleSender, + senderName); + + final contentFinder = find.descendant( + of: find.byType(MessageContent), + matching: find.text('A message', findRichText: true)); + + check(mutedLabelFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); + check(senderNameFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + check(mutedAvatarFinder.evaluate().length).equals(expectIsMuted ? 1 : 0); + check(nonmutedAvatarFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + check(contentFinder.evaluate().length).equals(expectIsMuted ? 0 : 1); + } + + final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); + final message = eg.streamMessage(sender: user, + content: '

A message

', reactions: [eg.unicodeEmojiReaction]); + + testWidgets('muted appearance', (tester) async { + prepareBoringImageHttpClient(); + await setupMessageListPage(tester, + users: [user], mutedUserIds: [user.userId], messages: [message]); + checkMessage(message, expectIsMuted: true); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('not-muted appearance', (tester) async { + prepareBoringImageHttpClient(); + await setupMessageListPage(tester, + users: [user], mutedUserIds: [], messages: [message]); + checkMessage(message, expectIsMuted: false); + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('"Reveal message" button', (tester) async { + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester, + users: [user], mutedUserIds: [user.userId], messages: [message]); + checkMessage(message, expectIsMuted: true); + await tester.tap(find.text('Reveal message')); + await tester.pump(); + checkMessage(message, expectIsMuted: false); + + debugNetworkImageHttpClientProvider = null; + }); + }); }); group('OutboxMessageWithPossibleSender', () { 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/poll_test.dart b/test/widgets/poll_test.dart index a6bd74c77e..8e3d66c3bb 100644 --- a/test/widgets/poll_test.dart +++ b/test/widgets/poll_test.dart @@ -28,12 +28,16 @@ void main() { WidgetTester tester, SubmessageData? submessageContent, { Iterable? users, + List? mutedUserIds, Iterable<(User, int)> voterIdxPairs = const [], }) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers(users ?? [eg.selfUser, eg.otherUser]); + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } connection = store.connection as FakeApiConnection; message = eg.streamMessage( @@ -96,6 +100,18 @@ void main() { check(findTextAtRow('100', index: 0)).findsOne(); }); + testWidgets('muted voters', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await preparePollWidget(tester, pollWidgetData, + users: [user1, user2], + mutedUserIds: [user2.userId], + voterIdxPairs: [(user1, 0), (user2, 0), (user2, 1)]); + + check(findTextAtRow('(User 1, Muted user)', index: 0)).findsOne(); + check(findTextAtRow('(Muted user)', index: 1)).findsOne(); + }); + testWidgets('show unknown voter', (tester) async { await preparePollWidget(tester, pollWidgetData, users: [eg.selfUser], voterIdxPairs: [(eg.thirdUser, 1)]); diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 30f6433528..ac461fe73b 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -1,11 +1,14 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/profile.dart'; @@ -13,15 +16,19 @@ import 'package:zulip/widgets/profile.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; import '../model/test_store.dart'; +import '../test_images.dart'; import '../test_navigation.dart'; import 'message_list_checks.dart'; import 'page_checks.dart'; import 'profile_page_checks.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupPage(WidgetTester tester, { required int pageUserId, List? users, + List? mutedUserIds, List? customProfileFields, Map? realmDefaultExternalAccounts, NavigatorObserver? navigatorObserver, @@ -32,12 +39,15 @@ Future setupPage(WidgetTester tester, { customProfileFields: customProfileFields, realmDefaultExternalAccounts: realmDefaultExternalAccounts); await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUser(eg.selfUser); if (users != null) { await store.addUsers(users); } + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, @@ -237,6 +247,43 @@ void main() { check(textFinder.evaluate()).length.equals(1); }); + testWidgets('page builds; user field with muted user', (tester) async { + prepareBoringImageHttpClient(); + + Finder avatarFinder(int userId) => find.byWidgetPredicate( + (widget) => widget is Avatar && widget.userId == userId); + Finder mutedAvatarFinder(int userId) => find.descendant( + of: avatarFinder(userId), + matching: find.byIcon(ZulipIcons.person)); + Finder nonmutedAvatarFinder(int userId) => find.descendant( + of: avatarFinder(userId), + matching: find.byType(RealmContentNetworkImage)); + + final users = [ + eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: '[2,3]'), + }), + eg.user(userId: 2, fullName: 'test user2', avatarUrl: '/foo.png'), + eg.user(userId: 3, fullName: 'test user3', avatarUrl: '/bar.png'), + ]; + + await setupPage(tester, + users: users, + mutedUserIds: [2], + pageUserId: 1, + customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)]); + + check(find.text('Muted user')).findsOne(); + check(mutedAvatarFinder(2)).findsOne(); + check(nonmutedAvatarFinder(2)).findsNothing(); + + check(find.text('test user3')).findsOne(); + check(mutedAvatarFinder(3)).findsNothing(); + check(nonmutedAvatarFinder(3)).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + testWidgets('page builds; dm links to correct narrow', (tester) async { final pushedRoutes = >[]; final testNavObserver = TestNavigatorObserver() diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index b7307ef6f2..3eb49f2ca8 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -27,6 +27,7 @@ import 'test_app.dart'; Future setupPage(WidgetTester tester, { required List dmMessages, required List users, + List? mutedUserIds, NavigatorObserver? navigatorObserver, String? newNameForSelfUser, }) async { @@ -39,6 +40,9 @@ Future setupPage(WidgetTester tester, { for (final user in users) { await store.addUser(user); } + if (mutedUserIds != null) { + await store.setMutedUsers(mutedUserIds); + } await store.addMessages(dmMessages); @@ -65,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?')) @@ -238,13 +242,27 @@ void main() { }); group('1:1', () { - testWidgets('has right title/avatar', (tester) async { - final user = eg.user(userId: 1); - final message = eg.dmMessage(from: eg.selfUser, to: [user]); - await setupPage(tester, users: [user], dmMessages: [message]); - - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, user.fullName); + group('has right title/avatar', () { + testWidgets('non-muted user', (tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, users: [user], dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, user.fullName); + }); + + testWidgets('muted user', (tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, + users: [user], + mutedUserIds: [user.userId], + dmMessages: [message]); + + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + check(findConversationItem(narrow)).findsNothing(); + }); }); testWidgets('no error when user somehow missing from user store', (tester) async { @@ -292,15 +310,45 @@ void main() { return result; } - testWidgets('has right title/avatar', (tester) async { - final users = usersList(2); - final user0 = users[0]; - final user1 = users[1]; - final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); - await setupPage(tester, users: users, dmMessages: [message]); - - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); + group('has right title/avatar', () { + testWidgets('no users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, users: users, dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); + }); + + testWidgets('some users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, + users: users, + mutedUserIds: [user0.userId], + dmMessages: [message]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, 'Muted user, ${user1.fullName}'); + }); + + testWidgets('all users muted', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage(tester, + users: users, + mutedUserIds: [user0.userId, user1.userId], + dmMessages: [message]); + + final narrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); + check(findConversationItem(narrow)).findsNothing(); + }); }); testWidgets('no error when one user somehow missing from user store', (tester) async {