diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index a5bb75779a..d7fd14303b 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1047,17 +1047,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 1d919c5a9d..2a0b5be2ed 100644 --- a/assets/l10n/app_pl.arb +++ b/assets/l10n/app_pl.arb @@ -1085,10 +1085,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 acff65ee7f..20e302cc63 100644 --- a/assets/l10n/app_ru.arb +++ b/assets/l10n/app_ru.arb @@ -1077,10 +1077,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 241a3bbd16..3887e381a2 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1563,19 +1563,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 e62354d420..a4e972abc4 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -855,10 +855,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 a7965d81ad..8ca5d081e3 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -881,9 +881,6 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get noEarlierMessages => 'Keine früheren Nachrichten'; - @override - String get mutedSender => 'Stummgeschalteter Absender'; - @override String get revealButtonLabel => 'Nachricht für stummgeschalteten Absender anzeigen'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 0178fe9406..f065d5f59c 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -855,10 +855,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_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 847cf68981..8fc5df768b 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -876,9 +876,6 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get noEarlierMessages => 'Nessun messaggio precedente'; - @override - String get mutedSender => 'Mittente silenziato'; - @override String get revealButtonLabel => 'Mostra messaggio per mittente silenziato'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index d7c84a08cb..eccff7ea5d 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -855,10 +855,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 98bad7d7b8..69557352b5 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -855,10 +855,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 1a9bd161e0..21e8f3e478 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -868,10 +868,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 fced1a4980..f4f25c7d20 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -872,10 +872,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 0742cfb143..4558dcd872 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -857,10 +857,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_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index 885a18c31a..e6f4275f77 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -883,9 +883,6 @@ class ZulipLocalizationsSl extends ZulipLocalizations { @override String get noEarlierMessages => 'Ni starejših sporočil'; - @override - String get mutedSender => 'Utišan pošiljatelj'; - @override String get revealButtonLabel => 'Prikaži sporočilo utišanega pošiljatelja'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 92bd6b9185..98ba4b11e1 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -871,9 +871,6 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get noEarlierMessages => 'Немає попередніх повідомлень'; - @override - String get mutedSender => 'Заглушений відправник'; - @override String get revealButtonLabel => 'Показати повідомлення заглушеного відправника'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 5befa99eea..b15d029eb1 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -855,10 +855,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'; @@ -1687,9 +1684,6 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh { @override String get noEarlierMessages => '没有更早的消息了'; - @override - String get mutedSender => '静音发送者'; - @override String get revealButtonLabel => '显示静音用户发送的消息'; 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/store.dart b/lib/model/store.dart index 7551c8be85..b3ce59206a 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -693,10 +693,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. diff --git a/lib/model/user.dart b/lib/model/user.dart index f5079bfd31..3c68154e22 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -44,27 +44,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. diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index a78ba323c7..d040ea8bcf 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,9 @@ void showMessageActionSheet({required BuildContext context, required Message mes QuoteAndReplyButton(message: message, pageContext: pageContext), if (showMarkAsUnreadButton) MarkAsUnreadButton(message: message, pageContext: pageContext), + if (isSenderMuted) + // The message must have been revealed in order to open this action sheet. + UnrevealMutedMessageButton(message: message, pageContext: pageContext), CopyMessageTextButton(message: message, pageContext: pageContext), CopyMessageLinkButton(message: message, pageContext: pageContext), ShareButton(message: message, pageContext: pageContext), @@ -904,6 +909,30 @@ 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() { + // The message should have been revealed in order to reach this action sheet. + assert(MessageListPage.revealedMutedMessagesOf(pageContext) + .isMutedMessageRevealed(message.id)); + 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 eee41d785e..2966b4dc46 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1666,6 +1666,7 @@ class Avatar extends StatelessWidget { required this.borderRadius, this.backgroundColor, this.showPresence = true, + this.replaceIfMuted = true, }); final int userId; @@ -1673,6 +1674,7 @@ class Avatar extends StatelessWidget { final double borderRadius; final Color? backgroundColor; final bool showPresence; + final bool replaceIfMuted; @override Widget build(BuildContext context) { @@ -1684,7 +1686,7 @@ class Avatar extends StatelessWidget { borderRadius: borderRadius, backgroundColor: backgroundColor, userIdForPresence: showPresence ? userId : null, - child: AvatarImage(userId: userId, size: size)); + child: AvatarImage(userId: userId, size: size, replaceIfMuted: replaceIfMuted)); } } @@ -1698,10 +1700,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) { @@ -1712,6 +1716,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), @@ -1732,6 +1740,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. /// /// If [userIdForPresence] is provided, this will paint a [PresenceCircle] 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 39ab1b4f04..ffd1d3f1fd 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -14,6 +14,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'; @@ -148,6 +149,14 @@ abstract class MessageListPageState { /// "Mark as unread from here" in the message action sheet. bool? get markReadOnScroll; set markReadOnScroll(bool? value); + + /// 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 { @@ -164,6 +173,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 MessageListPage ancestor'); + return state!; + } + /// The [MessageListPageState] above this context in the tree. /// /// Uses the inefficient [BuildContext.findAncestorStateOfType]; @@ -231,6 +255,18 @@ class _MessageListPageState extends State implements MessageLis }); } + final _revealedMutedMessages = RevealedMutedMessagesState(); + + @override + void revealMutedMessage(int messageId) { + _revealedMutedMessages._add(messageId); + } + + @override + void unrevealMutedMessage(int messageId) { + _revealedMutedMessages._remove(messageId); + } + @override void initState() { super.initState(); @@ -301,9 +337,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), @@ -348,10 +382,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}); @@ -1690,16 +1759,21 @@ 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( @@ -1708,16 +1782,20 @@ class _SenderRow extends StatelessWidget { size: 32, borderRadius: 3, showPresence: false, + replaceIfMuted: showAsMuted, userId: message.senderId), 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) ...[ @@ -1798,9 +1876,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: [ @@ -1811,28 +1895,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/profile.dart b/lib/widgets/profile.dart index 6c8e8b0b5e..27c8486fe8 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -47,7 +47,7 @@ class ProfilePage extends StatelessWidget { final nameStyle = _TextStyles.primaryFieldText .merge(weightVariableTextStyle(context, wght: 700)); - final displayEmail = store.userDisplayEmail(user); + final displayEmail = store.userDisplayEmail(userId); final items = [ Center( child: Avatar( @@ -56,7 +56,9 @@ class ProfilePage extends StatelessWidget { borderRadius: 200 / 8, // Would look odd with this large image; // we'll show it by the user's name instead. - showPresence: false)), + showPresence: false, + replaceIfMuted: false, + )), const SizedBox(height: 16), Text.rich( TextSpan(children: [ @@ -65,7 +67,8 @@ class ProfilePage extends StatelessWidget { fontSize: nameStyle.fontSize!, textScaler: MediaQuery.textScalerOf(context), ), - TextSpan(text: user.fullName), + // TODO write a test where the user is muted; check this and avatar + TextSpan(text: store.userDisplayName(userId, replaceIfMuted: false)), ]), textAlign: TextAlign.center, style: nameStyle), @@ -91,7 +94,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 28a0561f0d..5758a39d76 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -119,9 +119,9 @@ class RecentDmConversationsItem extends StatelessWidget { // // 'Chris、Greg、Alya' title = narrow.otherRecipientIds.map(store.userDisplayName) .join(', '); - avatar = ColoredBox(color: designVariables.groupDmConversationIconBg, + avatar = ColoredBox(color: designVariables.avatarPlaceholderBg, child: Center( - child: Icon(color: designVariables.groupDmConversationIcon, + child: Icon(color: designVariables.avatarPlaceholderIcon, ZulipIcons.group_dm))); } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index b837780d3f..99a3148027 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -171,6 +171,8 @@ class DesignVariables extends ThemeExtension { 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), statusAway: Color(0xff73788c).withValues(alpha: 0.25), @@ -185,11 +187,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), @@ -247,6 +249,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), statusAway: Color(0xffabaeba).withValues(alpha: 0.30), @@ -261,14 +265,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), @@ -331,6 +335,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.statusAway, @@ -341,11 +347,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, @@ -412,6 +418,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 statusAway; @@ -426,11 +434,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) @@ -488,6 +496,8 @@ class DesignVariables extends ThemeExtension { Color? labelMenuButton, Color? labelSearchPrompt, Color? mainBackground, + Color? neutralButtonBg, + Color? neutralButtonLabel, Color? radioBorder, Color? radioFillSelected, Color? statusAway, @@ -498,11 +508,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, @@ -559,6 +569,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, statusAway: statusAway ?? this.statusAway, @@ -569,11 +581,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, @@ -637,6 +649,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)!, statusAway: Color.lerp(statusAway, other.statusAway, t)!, @@ -647,11 +661,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/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/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/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index ebb6cb9b71..bee941abe0 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 @@ -1335,6 +1344,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 9ff4849b1b..a8b24414f9 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -228,6 +228,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 3165222c45..7ccde30032 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'; @@ -205,6 +206,8 @@ void main() { TestZulipBinding.ensureInitialized(); MessageListPage.debugEnableMarkReadOnScroll = false; + late PerAccountStore store; + group('LightboxHero', () { late PerAccountStore store; late FakeApiConnection connection; @@ -317,10 +320,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; @@ -352,20 +361,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 fd8dd6f10b..8011640be9 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -65,6 +65,7 @@ void main() { GetMessagesResult? fetchResult, List? streams, List? users, + List? mutedUserIds, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, int? zulipFeatureLevel, @@ -87,6 +88,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 { @@ -324,6 +328,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', () { @@ -1423,6 +1443,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: [ @@ -1626,6 +1661,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/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..e543658d55 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); @@ -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]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, 'Muted user'); + }); }); 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]); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, 'Muted user, Muted user'); + }); }); testWidgets('no error when one user somehow missing from user store', (tester) async {