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();
}