diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 84e19a9cfa..b57e157854 100644 Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg new file mode 100644 index 0000000000..bfc6c2dbed --- /dev/null +++ b/assets/icons/eye.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/eye_off.svg b/assets/icons/eye_off.svg new file mode 100644 index 0000000000..48d942da5d --- /dev/null +++ b/assets/icons/eye_off.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/person.svg b/assets/icons/person.svg new file mode 100644 index 0000000000..b032f41b53 --- /dev/null +++ b/assets/icons/person.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/user.svg b/assets/icons/two_person.svg similarity index 100% rename from assets/icons/user.svg rename to assets/icons/two_person.svg diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ea2e10cff3..719e4c9342 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -128,6 +128,10 @@ "@actionSheetOptionMarkAsUnread": { "description": "Label for mark as unread button on action sheet." }, + "actionSheetOptionHideMutedMessage": "Hide muted message again", + "@actionSheetOptionHideMutedMessage": { + "description": "Label for hide muted message again button on action sheet." + }, "actionSheetOptionShare": "Share", "@actionSheetOptionShare": { "description": "Label for share button on action sheet." @@ -872,6 +876,18 @@ "@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": { + "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." + }, "scrollToBottomTooltip": "Scroll to bottom", "@scrollToBottomTooltip": { "description": "Tooltip for button to scroll to bottom." diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index 0479b0428f..624ef175f9 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -55,6 +55,7 @@ sealed class Event { } // case 'muted_topics': … // TODO(#422) we ignore this feature on older servers case 'user_topic': return UserTopicEvent.fromJson(json); + case 'muted_users': return MutedUsersEvent.fromJson(json); case 'message': return MessageEvent.fromJson(json); case 'update_message': return UpdateMessageEvent.fromJson(json); case 'delete_message': return DeleteMessageEvent.fromJson(json); @@ -664,6 +665,24 @@ class UserTopicEvent extends Event { Map toJson() => _$UserTopicEventToJson(this); } +/// A Zulip event of type `muted_users`: https://zulip.com/api/get-events#muted_users +@JsonSerializable(fieldRename: FieldRename.snake) +class MutedUsersEvent extends Event { + @override + @JsonKey(includeToJson: true) + String get type => 'muted_users'; + + final List mutedUsers; + + MutedUsersEvent({required super.id, required this.mutedUsers}); + + factory MutedUsersEvent.fromJson(Map json) => + _$MutedUsersEventFromJson(json); + + @override + Map toJson() => _$MutedUsersEventToJson(this); +} + /// A Zulip event of type `message`: https://zulip.com/api/get-events#message @JsonSerializable(fieldRename: FieldRename.snake) class MessageEvent extends Event { diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index 35206d77b9..2cbb35209b 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -428,6 +428,22 @@ const _$UserTopicVisibilityPolicyEnumMap = { UserTopicVisibilityPolicy.unknown: null, }; +MutedUsersEvent _$MutedUsersEventFromJson(Map json) => + MutedUsersEvent( + id: (json['id'] as num).toInt(), + mutedUsers: + (json['muted_users'] as List) + .map((e) => MutedUserItem.fromJson(e as Map)) + .toList(), + ); + +Map _$MutedUsersEventToJson(MutedUsersEvent instance) => + { + 'id': instance.id, + 'type': instance.type, + 'muted_users': instance.mutedUsers, + }; + MessageEvent _$MessageEventFromJson(Map json) => MessageEvent( id: (json['id'] as num).toInt(), message: Message.fromJson( diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index 054230a256..0bbb2b58d1 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -44,6 +44,8 @@ class InitialSnapshot { // final List<…> mutedTopics; // TODO(#422) we ignore this feature on older servers + final List mutedUsers; + final Map realmEmoji; final List recentPrivateConversations; @@ -130,6 +132,7 @@ class InitialSnapshot { required this.serverTypingStartedExpiryPeriodMilliseconds, required this.serverTypingStoppedWaitPeriodMilliseconds, required this.serverTypingStartedWaitPeriodMilliseconds, + required this.mutedUsers, required this.realmEmoji, required this.recentPrivateConversations, required this.subscriptions, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 570d7c2bba..aea6a5dd28 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -38,6 +38,10 @@ InitialSnapshot _$InitialSnapshotFromJson( (json['server_typing_started_wait_period_milliseconds'] as num?) ?.toInt() ?? 10000, + mutedUsers: + (json['muted_users'] as List) + .map((e) => MutedUserItem.fromJson(e as Map)) + .toList(), realmEmoji: (json['realm_emoji'] as Map).map( (k, e) => MapEntry(k, RealmEmojiItem.fromJson(e as Map)), ), @@ -126,6 +130,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) => instance.serverTypingStoppedWaitPeriodMilliseconds, 'server_typing_started_wait_period_milliseconds': instance.serverTypingStartedWaitPeriodMilliseconds, + 'muted_users': instance.mutedUsers, 'realm_emoji': instance.realmEmoji, 'recent_private_conversations': instance.recentPrivateConversations, 'subscriptions': instance.subscriptions, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index 90769e815b..2c9e81e168 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -110,6 +110,24 @@ class CustomProfileFieldExternalAccountData { Map toJson() => _$CustomProfileFieldExternalAccountDataToJson(this); } + +/// An item in the [InitialSnapshot.mutedUsers]. +/// +/// For docs, search for "muted_users:" +/// in . +@JsonSerializable(fieldRename: FieldRename.snake) +class MutedUserItem { + final int id; + final int timestamp; + + const MutedUserItem({required this.id, required this.timestamp}); + + factory MutedUserItem.fromJson(Map json) => + _$MutedUserItemFromJson(json); + + Map toJson() => _$MutedUserItemToJson(this); +} + /// An item in [InitialSnapshot.realmEmoji] or [RealmEmojiUpdateEvent]. /// /// For docs, search for "realm_emoji:" diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index cddf78beb0..ba272dd681 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -68,6 +68,15 @@ Map _$CustomProfileFieldExternalAccountDataToJson( 'url_pattern': instance.urlPattern, }; +MutedUserItem _$MutedUserItemFromJson(Map json) => + MutedUserItem( + id: (json['id'] as num).toInt(), + timestamp: (json['timestamp'] as num).toInt(), + ); + +Map _$MutedUserItemToJson(MutedUserItem instance) => + {'id': instance.id, 'timestamp': instance.timestamp}; + RealmEmojiItem _$RealmEmojiItemFromJson(Map json) => RealmEmojiItem( emojiCode: json['id'] as String, diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index e326703e4b..0f99667cf6 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -302,6 +302,12 @@ abstract class ZulipLocalizations { /// **'Mark as unread from here'** String get actionSheetOptionMarkAsUnread; + /// Label for hide muted message again button on action sheet. + /// + /// In en, this message translates to: + /// **'Hide muted message again'** + String get actionSheetOptionHideMutedMessage; + /// Label for share button on action sheet. /// /// In en, this message translates to: @@ -1286,6 +1292,24 @@ 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'** + String get revealButtonLabel; + + /// Name for a muted user to display all over the app. + /// + /// In en, this message translates to: + /// **'Muted user'** + String get mutedUser; + /// Tooltip for button to scroll to bottom. /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 8d36fa6bd0..6ab03b6014 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -110,6 +110,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -709,6 +712,15 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index a74a2e1eaf..bc8b1b4d95 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -110,6 +110,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -709,6 +712,15 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index c11a3fae23..d16aa8b3ae 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -110,6 +110,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -709,6 +712,15 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index d23bd323fd..0403f49acc 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -110,6 +110,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Mark as unread from here'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Share'; @@ -709,6 +712,15 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index e1a6bd45f4..b652785b2e 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -115,6 +115,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Odtąd oznacz jako nieprzeczytane'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Udostępnij'; @@ -720,6 +723,15 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get noEarlierMessages => 'Brak historii'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Przewiń do dołu'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 78b68e812a..05f4a486ee 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -115,6 +115,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Отметить как непрочитанные начиная отсюда'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Поделиться'; @@ -723,6 +726,15 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get noEarlierMessages => 'Предшествующих сообщений нет'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Пролистать вниз'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 193ac26d8e..c571b01bb7 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -111,6 +111,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get actionSheetOptionMarkAsUnread => 'Označiť ako neprečítané od tejto správy'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Zdielať'; @@ -711,6 +714,15 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get noEarlierMessages => 'No earlier messages'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index c1898631fd..7c7e3ff3ec 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -115,6 +115,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get actionSheetOptionMarkAsUnread => 'Позначити як непрочитане звідси'; + @override + String get actionSheetOptionHideMutedMessage => 'Hide muted message again'; + @override String get actionSheetOptionShare => 'Поширити'; @@ -723,6 +726,15 @@ class ZulipLocalizationsUk extends ZulipLocalizations { @override String get noEarlierMessages => 'Немає попередніх повідомлень'; + @override + String get mutedSender => 'Muted sender'; + + @override + String get revealButtonLabel => 'Reveal message for muted sender'; + + @override + String get mutedUser => 'Muted user'; + @override String get scrollToBottomTooltip => 'Прокрутити вниз'; diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 073255bc9d..c62f5b7839 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -1,5 +1,6 @@ import 'dart:math'; +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; @@ -449,7 +450,7 @@ class MentionAutocompleteView extends AutocompleteView store.isUserMuted(user.userId)).toList() ..sort(_comparator(store: store, narrow: narrow)); } diff --git a/lib/model/recent_dm_conversations.dart b/lib/model/recent_dm_conversations.dart index 8428ecdf63..a3030f046e 100644 --- a/lib/model/recent_dm_conversations.dart +++ b/lib/model/recent_dm_conversations.dart @@ -8,6 +8,7 @@ import '../api/model/model.dart'; import '../api/model/events.dart'; import 'narrow.dart'; import 'store.dart'; +import 'user.dart'; /// A view-model for the recent-DM-conversations UI. /// @@ -17,7 +18,9 @@ class RecentDmConversationsView extends PerAccountStoreBase with ChangeNotifier factory RecentDmConversationsView({ required CorePerAccountStore core, required List initial, + required UserStore userStore, }) { + initial = _filterRecentDmConversations(initial, userStore); final entries = initial.map((conversation) => MapEntry( DmNarrow.ofRecentDmConversation(conversation, selfUserId: core.selfUserId), conversation.maxMessageId, @@ -35,18 +38,32 @@ class RecentDmConversationsView extends PerAccountStoreBase with ChangeNotifier return RecentDmConversationsView._( core: core, + userStore: userStore, map: Map.fromEntries(entries), sorted: QueueList.from(entries.map((e) => e.key)), latestMessagesByRecipient: latestMessagesByRecipient, ); } + static List _filterRecentDmConversations( + List recentDms, + UserStore userStore, + ) { + return recentDms + .where((conversation) => + conversation.userIds.any((id) => !userStore.isUserMuted(id))) + .toList(); + } + RecentDmConversationsView._({ required super.core, + required UserStore userStore, required this.map, required this.sorted, required this.latestMessagesByRecipient, - }); + }) : _userStore = userStore; + + final UserStore _userStore; /// The latest message ID in each conversation. final Map map; @@ -158,4 +175,6 @@ class RecentDmConversationsView extends PerAccountStoreBase with ChangeNotifier // TODO update from messages loaded in message lists. When doing so, // review handleMessageEvent so it acknowledges the subtle races that can // happen when taking data from outside the event system. + + void handleMutedUsersEvent(MutedUsersEvent event) {} } diff --git a/lib/model/store.dart b/lib/model/store.dart index b3b1b62b98..e803ed9a25 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -464,6 +464,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor accountId: accountId, selfUserId: account.userId, ); + final users = UserStoreImpl(core: core, initialSnapshot: initialSnapshot); final channels = ChannelStoreImpl(initialSnapshot: initialSnapshot); return PerAccountStore._( core: core, @@ -487,7 +488,7 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor typingStartedWaitPeriod: Duration( milliseconds: initialSnapshot.serverTypingStartedWaitPeriodMilliseconds), ), - users: UserStoreImpl(core: core, initialSnapshot: initialSnapshot), + users: users, typingStatus: TypingStatus(core: core, typingStartedExpiryPeriod: Duration(milliseconds: initialSnapshot.serverTypingStartedExpiryPeriodMilliseconds), ), @@ -498,8 +499,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor core: core, channelStore: channels, ), - recentDmConversationsView: RecentDmConversationsView(core: core, - initial: initialSnapshot.recentPrivateConversations), + recentDmConversationsView: RecentDmConversationsView( + initial: initialSnapshot.recentPrivateConversations, + core: core, + userStore: users, + ), recentSenders: RecentSenders(), ); } @@ -630,6 +634,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor @override Iterable get allUsers => _users.allUsers; + @override + bool isUserMuted(int id) => _users.isUserMuted(id); + final UserStoreImpl _users; final TypingStatus typingStatus; @@ -883,6 +890,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor _channels.handleUserTopicEvent(event); notifyListeners(); + case MutedUsersEvent(): + assert(debugLog("server event: muted_users")); + _users.handleMutedUsersEvent(event); + notifyListeners(); + case MessageEvent(): assert(debugLog("server event: message ${jsonEncode(event.message.toJson())}")); _messages.handleMessageEvent(event); diff --git a/lib/model/user.dart b/lib/model/user.dart index 05ab2747df..2c13edb243 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -1,6 +1,7 @@ import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; +import 'algorithms.dart'; import 'localizations.dart'; import 'store.dart'; @@ -66,6 +67,9 @@ mixin UserStore on PerAccountStoreBase { return getUser(message.senderId)?.fullName ?? message.senderFullName; } + + /// Whether the user with the given [id] is muted by [selfUser]. + bool isUserMuted(int id); } /// The implementation of [UserStore] that does the work. @@ -81,16 +85,31 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { initialSnapshot.realmUsers .followedBy(initialSnapshot.realmNonActiveUsers) .followedBy(initialSnapshot.crossRealmBots) - .map((user) => MapEntry(user.userId, user))); + .map((user) => MapEntry(user.userId, user))), + _mutedUsers = initialSnapshot.mutedUsers, + _mutedUsersSorted = _sortMutedUsers(initialSnapshot.mutedUsers); final Map _users; + // Currently we don't need this, but we would need it if we wanted to display + // muted users in the order of server. + // ignore: unused_field + List _mutedUsers; + + List _mutedUsersSorted; + @override User? getUser(int userId) => _users[userId]; @override Iterable get allUsers => _users.values; + @override + bool isUserMuted(int id) { + return binarySearchByKey(_mutedUsersSorted, id, + (item, id) => item.id.compareTo(id)) >= 0; + } + void handleRealmUserEvent(RealmUserEvent event) { switch (event) { case RealmUserAddEvent(): @@ -129,4 +148,13 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { } } } + + void handleMutedUsersEvent(MutedUsersEvent event) { + _mutedUsers = event.mutedUsers; + _mutedUsersSorted = _sortMutedUsers(event.mutedUsers); + } + + static List _sortMutedUsers(List mutedUsers) { + return mutedUsers..sort((a, b) => a.id.compareTo(b.id)); + } } diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 88114d48bb..b8f4d2b5e3 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -568,6 +568,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 = [ ReactionButtons(message: message, pageContext: pageContext), StarButton(message: message, pageContext: pageContext), @@ -575,6 +577,9 @@ void showMessageActionSheet({required BuildContext context, required Message mes QuoteAndReplyButton(message: message, pageContext: pageContext), if (showMarkAsUnreadButton) MarkAsUnreadButton(message: message, pageContext: pageContext), + if (isSenderMuted) + HideMutedMessageButton(message: message, pageContext: pageContext, + messageContext: context), CopyMessageTextButton(message: message, pageContext: pageContext), CopyMessageLinkButton(message: message, pageContext: pageContext), ShareButton(message: message, pageContext: pageContext), @@ -841,6 +846,31 @@ class MarkAsUnreadButton extends MessageActionSheetMenuItemButton { } } +class HideMutedMessageButton extends MessageActionSheetMenuItemButton { + HideMutedMessageButton({ + super.key, + required super.message, + required super.pageContext, + required this.messageContext, + }); + + final BuildContext messageContext; + + @override + IconData get icon => ZulipIcons.eye_off; + + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionHideMutedMessage; + } + + @override + void onPressed() { + if (!messageContext.mounted) return; + PossibleMutedMessage.of(messageContext).changeMuteStatus(true); + } +} + class CopyMessageTextButton extends MessageActionSheetMenuItemButton { CopyMessageTextButton({super.key, required super.message, required super.pageContext}); diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index 40b510305d..c873a9470e 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -17,6 +17,7 @@ import '../model/internal_link.dart'; import '../model/katex.dart'; import 'actions.dart'; import 'code_block.dart'; +import 'color.dart'; import 'dialog.dart'; import 'icons.dart'; import 'inset_shadow.dart'; @@ -26,6 +27,7 @@ import 'poll.dart'; import 'scrolling.dart'; import 'store.dart'; import 'text.dart'; +import 'theme.dart'; /// A central place for styles for Zulip content (rendered Zulip Markdown). /// @@ -1658,18 +1660,44 @@ class Avatar extends StatelessWidget { required this.userId, required this.size, required this.borderRadius, + this.showAsMuted = false, }); final int userId; final double size; final double borderRadius; + final bool showAsMuted; @override Widget build(BuildContext context) { return AvatarShape( size: size, borderRadius: borderRadius, - child: AvatarImage(userId: userId, size: size)); + child: showAsMuted + ? AvatarPlaceholder( + // Scale the icon proportionally to match the Figma design. + iconSize: size * 20 / 32 + ) + : AvatarImage(userId: userId, size: size)); + } +} + +/// A placeholder avatar for muted users. +/// +/// Wrap this with [AvatarShape]. +class AvatarPlaceholder extends StatelessWidget { + const AvatarPlaceholder({super.key, this.iconSize}); + + final double? iconSize; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return DecoratedBox( + decoration: BoxDecoration( + color: designVariables.grey250.withFadedAlpha(0.5)), + child: Icon(ZulipIcons.person, size: iconSize, + color: designVariables.grey550.withFadedAlpha(0.5))); } } diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart index 0f6d490a97..1a21753e80 100644 --- a/lib/widgets/emoji_reaction.dart +++ b/lib/widgets/emoji_reaction.dart @@ -162,7 +162,9 @@ class ReactionChip extends StatelessWidget { ? userIds.map((id) { return id == store.selfUserId ? zulipLocalizations.reactedEmojiSelfUser - : store.userDisplayName(id); + : store.isUserMuted(id) + ? zulipLocalizations.mutedUser + : store.userDisplayName(id); }).join(', ') : userIds.length.toString(); diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index ab5ad446db..454da93e3b 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -111,7 +111,7 @@ class _HomePageState extends State { narrow: const CombinedFeedNarrow()))), button(_HomePageTab.channels, ZulipIcons.hash_italic), // TODO(#1094): Users - button(_HomePageTab.directMessages, ZulipIcons.user), + button(_HomePageTab.directMessages, ZulipIcons.two_person), _NavigationBarButton( icon: ZulipIcons.menu, selected: false, onPressed: () => _showMainMenu(context, tabNotifier: _tab)), @@ -515,7 +515,7 @@ class _DirectMessagesButton extends _NavigationBarMenuButton { const _DirectMessagesButton({required super.tabNotifier}); @override - IconData get icon => ZulipIcons.user; + IconData get icon => ZulipIcons.two_person; @override String label(ZulipLocalizations zulipLocalizations) { diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart index bab7b152de..5e83955db8 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -66,89 +66,98 @@ abstract final class ZulipIcons { /// The Zulip custom icon "edit". static const IconData edit = IconData(0xf10e, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "eye". + static const IconData eye = IconData(0xf10f, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "eye_off". + static const IconData eye_off = IconData(0xf110, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "follow". - static const IconData follow = IconData(0xf10f, fontFamily: "Zulip Icons"); + static const IconData follow = IconData(0xf111, fontFamily: "Zulip Icons"); /// The Zulip custom icon "format_quote". - static const IconData format_quote = IconData(0xf110, fontFamily: "Zulip Icons"); + static const IconData format_quote = IconData(0xf112, fontFamily: "Zulip Icons"); /// The Zulip custom icon "globe". - static const IconData globe = IconData(0xf111, fontFamily: "Zulip Icons"); + static const IconData globe = IconData(0xf113, fontFamily: "Zulip Icons"); /// The Zulip custom icon "group_dm". - static const IconData group_dm = IconData(0xf112, fontFamily: "Zulip Icons"); + static const IconData group_dm = IconData(0xf114, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_italic". - static const IconData hash_italic = IconData(0xf113, fontFamily: "Zulip Icons"); + static const IconData hash_italic = IconData(0xf115, fontFamily: "Zulip Icons"); /// The Zulip custom icon "hash_sign". - static const IconData hash_sign = IconData(0xf114, fontFamily: "Zulip Icons"); + static const IconData hash_sign = IconData(0xf116, fontFamily: "Zulip Icons"); /// The Zulip custom icon "image". - static const IconData image = IconData(0xf115, fontFamily: "Zulip Icons"); + static const IconData image = IconData(0xf117, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inbox". - static const IconData inbox = IconData(0xf116, fontFamily: "Zulip Icons"); + static const IconData inbox = IconData(0xf118, fontFamily: "Zulip Icons"); /// The Zulip custom icon "info". - static const IconData info = IconData(0xf117, fontFamily: "Zulip Icons"); + static const IconData info = IconData(0xf119, fontFamily: "Zulip Icons"); /// The Zulip custom icon "inherit". - static const IconData inherit = IconData(0xf118, fontFamily: "Zulip Icons"); + static const IconData inherit = IconData(0xf11a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "language". - static const IconData language = IconData(0xf119, fontFamily: "Zulip Icons"); + static const IconData language = IconData(0xf11b, fontFamily: "Zulip Icons"); /// The Zulip custom icon "lock". - static const IconData lock = IconData(0xf11a, fontFamily: "Zulip Icons"); + static const IconData lock = IconData(0xf11c, fontFamily: "Zulip Icons"); /// The Zulip custom icon "menu". - static const IconData menu = IconData(0xf11b, fontFamily: "Zulip Icons"); + static const IconData menu = IconData(0xf11d, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_checked". - static const IconData message_checked = IconData(0xf11c, fontFamily: "Zulip Icons"); + static const IconData message_checked = IconData(0xf11e, fontFamily: "Zulip Icons"); /// The Zulip custom icon "message_feed". - static const IconData message_feed = IconData(0xf11d, fontFamily: "Zulip Icons"); + static const IconData message_feed = IconData(0xf11f, fontFamily: "Zulip Icons"); /// The Zulip custom icon "mute". - static const IconData mute = IconData(0xf11e, fontFamily: "Zulip Icons"); + static const IconData mute = IconData(0xf120, fontFamily: "Zulip Icons"); + + /// The Zulip custom icon "person". + static const IconData person = IconData(0xf121, fontFamily: "Zulip Icons"); /// The Zulip custom icon "read_receipts". - static const IconData read_receipts = IconData(0xf11f, fontFamily: "Zulip Icons"); + static const IconData read_receipts = IconData(0xf122, fontFamily: "Zulip Icons"); /// The Zulip custom icon "send". - static const IconData send = IconData(0xf120, fontFamily: "Zulip Icons"); + static const IconData send = IconData(0xf123, fontFamily: "Zulip Icons"); /// The Zulip custom icon "settings". - static const IconData settings = IconData(0xf121, fontFamily: "Zulip Icons"); + static const IconData settings = IconData(0xf124, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share". - static const IconData share = IconData(0xf122, fontFamily: "Zulip Icons"); + static const IconData share = IconData(0xf125, fontFamily: "Zulip Icons"); /// The Zulip custom icon "share_ios". - static const IconData share_ios = IconData(0xf123, fontFamily: "Zulip Icons"); + static const IconData share_ios = IconData(0xf126, fontFamily: "Zulip Icons"); /// The Zulip custom icon "smile". - static const IconData smile = IconData(0xf124, fontFamily: "Zulip Icons"); + static const IconData smile = IconData(0xf127, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star". - static const IconData star = IconData(0xf125, fontFamily: "Zulip Icons"); + static const IconData star = IconData(0xf128, fontFamily: "Zulip Icons"); /// The Zulip custom icon "star_filled". - static const IconData star_filled = IconData(0xf126, fontFamily: "Zulip Icons"); + static const IconData star_filled = IconData(0xf129, fontFamily: "Zulip Icons"); /// The Zulip custom icon "three_person". - static const IconData three_person = IconData(0xf127, fontFamily: "Zulip Icons"); + static const IconData three_person = IconData(0xf12a, fontFamily: "Zulip Icons"); /// The Zulip custom icon "topic". - static const IconData topic = IconData(0xf128, fontFamily: "Zulip Icons"); + static const IconData topic = IconData(0xf12b, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf129, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "two_person". + static const IconData two_person = IconData(0xf12c, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "user". - static const IconData user = IconData(0xf12a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "unmute". + static const IconData unmute = IconData(0xf12d, fontFamily: "Zulip Icons"); // END GENERATED ICON DATA } diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index a8c0c12b59..6a97ea8cfa 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -319,7 +319,7 @@ class _AllDmsHeaderItem extends _HeaderItem { @override String title(ZulipLocalizations zulipLocalizations) => zulipLocalizations.recentDmConversationsSectionHeader; - @override IconData get icon => ZulipIcons.user; + @override IconData get icon => ZulipIcons.two_person; // TODO(design) check if this is the right variable for these @override Color collapsedIconColor(context) => DesignVariables.of(context).labelMenuButton; diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 90a0762d34..0cc786082f 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -423,7 +423,10 @@ class MessageListAppBarTitle extends StatelessWidget { if (otherRecipientIds.isEmpty) { return Text(zulipLocalizations.dmsWithYourselfPageTitle); } else { - final names = otherRecipientIds.map(store.userDisplayName); + final names = otherRecipientIds.map((id) => + store.isUserMuted(id) + ? zulipLocalizations.mutedUser + : store.userDisplayName(id)); // TODO show avatars return Text( zulipLocalizations.dmsWithOthersPageTitle(names.join(', '))); @@ -1221,7 +1224,9 @@ class DmRecipientHeader extends StatelessWidget { title = zulipLocalizations.messageListGroupYouAndOthers( message.conversation.allRecipientIds .where((id) => id != store.selfUserId) - .map(store.userDisplayName) + .map((id) => store.isUserMuted(id) + ? zulipLocalizations.mutedUser + : store.userDisplayName(id)) .sorted() .join(", ")); } else { @@ -1252,7 +1257,7 @@ class DmRecipientHeader extends StatelessWidget { child: Icon( color: designVariables.title, size: 16, - ZulipIcons.user)), + ZulipIcons.two_person)), Expanded( child: Text(title, style: recipientHeaderTextStyle(context), @@ -1363,22 +1368,53 @@ String formatHeaderDate( // Design referenced from: // - https://github.com/zulip/zulip-mobile/issues/5511 // - https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=538%3A20849&mode=dev -class MessageWithPossibleSender extends StatelessWidget { +// - https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6089-28385&t=zjBHYbg3XaDaMLB3-0 +class MessageWithPossibleSender extends StatefulWidget { const MessageWithPossibleSender({super.key, required this.item}); final MessageListMessageItem item; + @override + State createState() => _MessageWithPossibleSenderState(); +} + +class _MessageWithPossibleSenderState extends State { + late PerAccountStore store; + late final Message message; + late bool showAsMuted; + + @override + void initState() { + super.initState(); + message = widget.item.message; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + store = PerAccountStoreWidget.of(context); + showAsMuted = store.isUserMuted(message.senderId); + } + + void changeMuteStatus(bool newValue) { + setState(() { + showAsMuted = newValue; + }); + } + @override Widget build(BuildContext context) { - final store = PerAccountStoreWidget.of(context); + final localizations = ZulipLocalizations.of(context); final messageListTheme = MessageListTheme.of(context); final designVariables = DesignVariables.of(context); - final message = item.message; final sender = store.getUser(message.senderId); Widget? senderRow; - if (item.showSender) { + if (widget.item.showSender) { + final senderName = showAsMuted + ? localizations.mutedSender + : message.senderFullName; // TODO(#716): use `store.senderDisplayName` final time = _kMessageTimestampFormat .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); senderRow = Row( @@ -1388,20 +1424,21 @@ class MessageWithPossibleSender extends StatelessWidget { 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, showAsMuted: showAsMuted), const SizedBox(width: 8), Flexible( - child: Text(message.senderFullName, // TODO(#716): use `store.senderDisplayName` + child: Text(senderName, style: TextStyle( fontSize: 18, height: (22 / 18), - color: designVariables.title, + color: designVariables.title.withFadedAlpha( + showAsMuted ? 0.5 : 1), ).merge(weightVariableTextStyle(context, wght: 600)), overflow: TextOverflow.ellipsis)), if (sender?.isBot ?? false) ...[ @@ -1424,7 +1461,6 @@ class MessageWithPossibleSender extends StatelessWidget { ]); } - final localizations = ZulipLocalizations.of(context); String? editStateText; switch (message.editState) { case MessageEditState.edited: @@ -1445,40 +1481,103 @@ class MessageWithPossibleSender extends StatelessWidget { child: Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)); } - return GestureDetector( - behavior: HitTestBehavior.translucent, - onLongPress: () => showMessageActionSheet(context: context, message: message), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Column(children: [ - if (senderRow != null) - Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), - child: senderRow), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: localizedTextBaseline(context), - children: [ - const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - MessageContent(message: message, content: item.content), - if ((message.reactions?.total ?? 0) > 0) - ReactionChipsList(messageId: message.id, reactions: message.reactions!), - if (editStateText != null) - Text(editStateText, - textAlign: TextAlign.end, - style: TextStyle( - color: designVariables.labelEdited, - fontSize: 12, - height: (12 / 12), - letterSpacing: proportionalLetterSpacing( - context, 0.05, baseFontSize: 12))), - ])), - SizedBox(width: 16, - child: star), - ]), - ]))); + Widget? revealButton; + if (showAsMuted) { + revealButton = TextButton.icon( + onPressed: () { + changeMuteStatus(false); + }, + style: TextButton.styleFrom( + minimumSize: Size.zero, + padding: EdgeInsets.symmetric(vertical: 4, horizontal: 6), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + splashFactory: NoSplash.splashFactory, + ).copyWith( + backgroundColor: WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.neutralButtonBg, + ~WidgetState.pressed: designVariables.neutralButtonBg + .withFadedAlpha(0), + }), + foregroundColor: WidgetStateColor.fromMap({ + WidgetState.pressed: designVariables.neutralButtonLabel, + ~WidgetState.pressed: designVariables.neutralButtonLabel + .withFadedAlpha(0.85), + }), + ), + icon: Icon(ZulipIcons.eye), + label: Text(localizations.revealButtonLabel, + style: TextStyle(fontSize: 16, height: 1) + .merge(weightVariableTextStyle(context, wght: 600)))); + } + + return PossibleMutedMessage( + changeMuteStatus: changeMuteStatus, + child: Builder( + builder: (context) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPress: showAsMuted + ? null + : () => showMessageActionSheet(context: context, message: message), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Column(children: [ + if (senderRow != null) + Padding(padding: const EdgeInsets.fromLTRB(16, 2, 16, 0), + child: senderRow), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: localizedTextBaseline(context), + children: [ + const SizedBox(width: 16), + if (showAsMuted) + Padding(padding: const EdgeInsets.symmetric(vertical: 2.0), + child: revealButton) + else + ...[ + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + MessageContent(message: message, content: widget.item.content), + if ((message.reactions?.total ?? 0) > 0) + ReactionChipsList(messageId: message.id, reactions: message.reactions!), + if (editStateText != null) + Text(editStateText, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.labelEdited, + fontSize: 12, + height: (12 / 12), + letterSpacing: proportionalLetterSpacing( + context, 0.05, baseFontSize: 12))), + ])), + SizedBox(width: 16, child: star), + ], + ]), + ]))); + } + ), + ); + } +} + +class PossibleMutedMessage extends InheritedWidget { + const PossibleMutedMessage({ + super.key, + required this.changeMuteStatus, + required super.child, + }); + + final ValueChanged changeMuteStatus; + + @override + bool updateShouldNotify(covariant PossibleMutedMessage oldWidget) => false; + + static PossibleMutedMessage of(BuildContext context) { + final value = context.getInheritedWidgetOfExactType(); + assert(value != null, 'No PossibleMutedMessage ancestor'); + return value!; } } diff --git a/lib/widgets/poll.dart b/lib/widgets/poll.dart index b851c55525..222c002a1b 100644 --- a/lib/widgets/poll.dart +++ b/lib/widgets/poll.dart @@ -80,7 +80,10 @@ class _PollWidgetState extends State { // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Zixuan']) // // 'Chris、Greg、Alya、Zixuan' final voterNames = option.voters - .map(store.userDisplayName) + .map((userId) => + store.isUserMuted(userId) + ? zulipLocalizations.mutedUser + : store.userDisplayName(userId)) .join(', '); return Row( diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index f1328b3367..be8730ada7 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -263,6 +263,8 @@ class _UserWidget extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final localizations = ZulipLocalizations.of(context); + final isMuted = store.isUserMuted(userId); return InkWell( onTap: () => Navigator.push(context, ProfilePage.buildRoute(context: context, @@ -271,10 +273,12 @@ class _UserWidget extends StatelessWidget { padding: const EdgeInsets.all(8), child: Row(children: [ // TODO(#196) render active status - Avatar(userId: userId, size: 32, borderRadius: 32 / 8), + Avatar(userId: userId, size: 32, borderRadius: 32 / 8, + showAsMuted: isMuted), const SizedBox(width: 8), Expanded( - child: Text(store.userDisplayName(userId), + child: Text( + isMuted ? localizations.mutedUser : store.userDisplayName(userId), style: _TextStyles.customProfileFieldText)), ]))); } diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 982dde4f08..ee03c83fae 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../generated/l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import '../model/recent_dm_conversations.dart'; import '../model/unreads.dart'; @@ -79,6 +80,7 @@ class RecentDmConversationsItem extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final localizations = ZulipLocalizations.of(context); final designVariables = DesignVariables.of(context); final String title; @@ -88,16 +90,28 @@ class RecentDmConversationsItem extends StatelessWidget { title = store.selfUser.fullName; avatar = AvatarImage(userId: store.selfUserId, size: _avatarSize); case [var otherUserId]: - // TODO(#296) actually don't show this row if the user is muted? - // (should we offer a "spam folder" style summary screen of recent - // 1:1 DM conversations from muted users?) - title = store.userDisplayName(otherUserId); - avatar = AvatarImage(userId: otherUserId, size: _avatarSize); + // Although we currently don't display a DM conversation with a muted + // user, maybe in the future we will have the "Search by location" + // feature similar to web where a DM conversation with a muted user is + // displayed if searched for explicitly. + // https://zulip.com/help/search-for-messages#search-by-location + final isUserMuted = store.isUserMuted(otherUserId); + title = isUserMuted + ? localizations.mutedUser + : store.userDisplayName(otherUserId); + avatar = isUserMuted + ? AvatarPlaceholder( + // Scale the icon proportionally to match the Figma design. + iconSize: _avatarSize * 20 / 32) + : AvatarImage(userId: otherUserId, size: _avatarSize); default: // TODO(i18n): List formatting, like you can do in JavaScript: // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) // // 'Chris、Greg、Alya' - title = narrow.otherRecipientIds.map(store.userDisplayName) + title = narrow.otherRecipientIds.map((id) => + store.isUserMuted(id) + ? localizations.mutedUser + : store.userDisplayName(id)) .join(', '); avatar = ColoredBox(color: designVariables.groupDmConversationIconBg, child: Center( diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index eea9677045..5309ab03b4 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -155,12 +155,16 @@ class DesignVariables extends ThemeExtension { contextMenuItemText: const Color(0xff381da7), editorButtonPressedBg: Colors.black.withValues(alpha: 0.06), foreground: const Color(0xff000000), + grey250: const Color(0xffbbbdc8), + grey550: const Color(0xff626573), icon: const Color(0xff6159e1), iconSelected: const Color(0xff222222), labelCounterUnread: const Color(0xff222222), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), mainBackground: const Color(0xfff0f0f0), + neutralButtonBg: const Color(0xff8c84ae).withValues(alpha: 0.3), + neutralButtonLabel: const Color(0xff433d5c), textInput: const Color(0xff000000), title: const Color(0xff1a1a1a), bgSearchInput: const Color(0xffe3e3e3), @@ -212,12 +216,16 @@ class DesignVariables extends ThemeExtension { contextMenuItemText: const Color(0xff9398fd), editorButtonPressedBg: Colors.white.withValues(alpha: 0.06), foreground: const Color(0xffffffff), + grey250: const Color(0xffbbbdc8), + grey550: const Color(0xff626573), icon: const Color(0xff7977fe), iconSelected: Colors.white.withValues(alpha: 0.8), labelCounterUnread: const Color(0xffffffff).withValues(alpha: 0.7), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), mainBackground: const Color(0xff1d1d1d), + neutralButtonBg: const Color(0xffd4d1e0).withValues(alpha: 0.3), + neutralButtonLabel: const Color(0xffa9a3c2), textInput: const Color(0xffffffff).withValues(alpha: 0.9), title: const Color(0xffffffff).withValues(alpha: 0.9), bgSearchInput: const Color(0xff313131), @@ -277,12 +285,16 @@ class DesignVariables extends ThemeExtension { required this.contextMenuItemText, required this.editorButtonPressedBg, required this.foreground, + required this.grey250, + required this.grey550, required this.icon, required this.iconSelected, required this.labelCounterUnread, required this.labelEdited, required this.labelMenuButton, required this.mainBackground, + required this.neutralButtonBg, + required this.neutralButtonLabel, required this.textInput, required this.title, required this.bgSearchInput, @@ -343,12 +355,16 @@ class DesignVariables extends ThemeExtension { final Color contextMenuItemText; final Color editorButtonPressedBg; final Color foreground; + final Color grey250; + final Color grey550; final Color icon; final Color iconSelected; final Color labelCounterUnread; final Color labelEdited; final Color labelMenuButton; final Color mainBackground; + final Color neutralButtonBg; + final Color neutralButtonLabel; final Color textInput; final Color title; final Color bgSearchInput; @@ -404,12 +420,16 @@ class DesignVariables extends ThemeExtension { Color? contextMenuItemText, Color? editorButtonPressedBg, Color? foreground, + Color? grey250, + Color? grey550, Color? icon, Color? iconSelected, Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, Color? mainBackground, + Color? neutralButtonBg, + Color? neutralButtonLabel, Color? textInput, Color? title, Color? bgSearchInput, @@ -460,12 +480,16 @@ class DesignVariables extends ThemeExtension { contextMenuItemText: contextMenuItemText ?? this.contextMenuItemText, editorButtonPressedBg: editorButtonPressedBg ?? this.editorButtonPressedBg, foreground: foreground ?? this.foreground, + grey250: grey250 ?? this.grey250, + grey550: grey550 ?? this.grey550, icon: icon ?? this.icon, iconSelected: iconSelected ?? this.iconSelected, labelCounterUnread: labelCounterUnread ?? this.labelCounterUnread, labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, mainBackground: mainBackground ?? this.mainBackground, + neutralButtonBg: neutralButtonBg ?? this.neutralButtonBg, + neutralButtonLabel: neutralButtonLabel ?? this.neutralButtonLabel, textInput: textInput ?? this.textInput, title: title ?? this.title, bgSearchInput: bgSearchInput ?? this.bgSearchInput, @@ -523,12 +547,16 @@ class DesignVariables extends ThemeExtension { contextMenuItemText: Color.lerp(contextMenuItemText, other.contextMenuItemText, t)!, editorButtonPressedBg: Color.lerp(editorButtonPressedBg, other.editorButtonPressedBg, t)!, foreground: Color.lerp(foreground, other.foreground, t)!, + grey250: Color.lerp(grey250, other.grey250, t)!, + grey550: Color.lerp(grey550, other.grey550, t)!, icon: Color.lerp(icon, other.icon, t)!, iconSelected: Color.lerp(iconSelected, other.iconSelected, t)!, labelCounterUnread: Color.lerp(labelCounterUnread, other.labelCounterUnread, t)!, labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, mainBackground: Color.lerp(mainBackground, other.mainBackground, t)!, + neutralButtonBg: Color.lerp(neutralButtonBg, other.neutralButtonBg, t)!, + neutralButtonLabel: Color.lerp(neutralButtonLabel, other.neutralButtonLabel, t)!, textInput: Color.lerp(textInput, other.textInput, t)!, title: Color.lerp(title, other.title, t)!, bgSearchInput: Color.lerp(bgSearchInput, other.bgSearchInput, t)!, diff --git a/test/example_data.dart b/test/example_data.dart index fc3acfc5a4..ded7607682 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -907,6 +907,7 @@ InitialSnapshot initialSnapshot({ int? serverTypingStartedExpiryPeriodMilliseconds, int? serverTypingStoppedWaitPeriodMilliseconds, int? serverTypingStartedWaitPeriodMilliseconds, + List? mutedUsers, Map? realmEmoji, List? recentPrivateConversations, List? subscriptions, @@ -942,6 +943,7 @@ InitialSnapshot initialSnapshot({ serverTypingStoppedWaitPeriodMilliseconds ?? 5000, serverTypingStartedWaitPeriodMilliseconds: serverTypingStartedWaitPeriodMilliseconds ?? 10000, + mutedUsers: mutedUsers ?? [], realmEmoji: realmEmoji ?? {}, recentPrivateConversations: recentPrivateConversations ?? [], subscriptions: subscriptions ?? [], // TODO add subscriptions to default diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 3c8db1dfcf..f27f25e5ab 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -110,7 +110,7 @@ void main () { of: find.byType(ZulipAppBar), matching: find.text('Channels'))).findsOne(); - await tester.tap(find.byIcon(ZulipIcons.user)); + await tester.tap(find.byIcon(ZulipIcons.two_person)); await tester.pump(); check(find.descendant( of: find.byType(ZulipAppBar), diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 047a036cb5..82d250a01d 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1236,7 +1236,7 @@ void main() { final textSpan = tester.renderObject(find.text( zulipLocalizations.messageListGroupYouAndOthers( zulipLocalizations.unknownUserName))).text; - final icon = tester.widget(find.byIcon(ZulipIcons.user)); + final icon = tester.widget(find.byIcon(ZulipIcons.two_person)); check(textSpan).style.isNotNull().color.isNotNull().isSameColorAs(icon.color!); }); }); diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 44322ccea1..4849a997cf 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -56,7 +56,7 @@ Future setupPage(WidgetTester tester, { // Switch to direct messages tab. await tester.tap(find.descendant( of: find.byType(Center), - matching: find.byIcon(ZulipIcons.user))); + matching: find.byIcon(ZulipIcons.two_person))); await tester.pump(); }