diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 84e19a9cfa..61231bf002 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..6a35686e46
--- /dev/null
+++ b/assets/icons/person.svg
@@ -0,0 +1,10 @@
+
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/lib/api/model/events.dart b/lib/api/model/events.dart
index 62789333e1..2904173e81 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -62,6 +62,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);
@@ -733,6 +734,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 94fe288150..350179733f 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -477,6 +477,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 f4cc2fe5fc..cb3df052ac 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;
@@ -132,6 +134,7 @@ class InitialSnapshot {
required this.serverTypingStartedExpiryPeriodMilliseconds,
required this.serverTypingStoppedWaitPeriodMilliseconds,
required this.serverTypingStartedWaitPeriodMilliseconds,
+ required this.mutedUsers,
required this.realmEmoji,
required this.recentPrivateConversations,
required this.savedSnippets,
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index 36afb0a39f..b8c5a4b54f 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)),
),
@@ -130,6 +134,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,
'saved_snippets': instance.savedSnippets,
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index 131a51991b..93c7dd0a78 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] or [MutedUsersEvent].
+///
+/// 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 67fc606031..4e5f23e931 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/model/store.dart b/lib/model/store.dart
index 240e3ab4e4..24439f00bb 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -644,6 +644,13 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
@override
Iterable get allUsers => _users.allUsers;
+ @override
+ List get mutedUsers => _users.mutedUsers;
+
+ @override
+ bool isUserMuted(int id, {List? mutedUsers}) =>
+ _users.isUserMuted(id, mutedUsers: mutedUsers);
+
final UserStoreImpl _users;
final TypingStatus typingStatus;
@@ -943,6 +950,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
assert(debugLog("server event: reaction/${event.op}"));
_messages.handleReactionEvent(event);
+ case MutedUsersEvent():
+ assert(debugLog("server event: muted_users"));
+ _users.handleMutedUsersEvent(event);
+ notifyListeners();
+
case UnexpectedEvent():
assert(debugLog("server event: ${jsonEncode(event.toJson())}")); // TODO log better
}
diff --git a/lib/model/user.dart b/lib/model/user.dart
index 05ab2747df..973fa98654 100644
--- a/lib/model/user.dart
+++ b/lib/model/user.dart
@@ -1,7 +1,9 @@
import '../api/model/events.dart';
import '../api/model/initial_snapshot.dart';
import '../api/model/model.dart';
+import 'algorithms.dart';
import 'localizations.dart';
+import 'narrow.dart';
import 'store.dart';
/// The portion of [PerAccountStore] describing the users in the realm.
@@ -66,6 +68,24 @@ mixin UserStore on PerAccountStoreBase {
return getUser(message.senderId)?.fullName
?? message.senderFullName;
}
+
+ /// All the users muted by [selfUser], sorted by [MutedUserItem.id] ascending.
+ List get mutedUsers;
+
+ /// Whether the user with the given [id] is muted by [selfUser].
+ ///
+ /// By default, looks for the user id in [UserStore.mutedUsers] unless
+ /// [mutedUsers] is non-null, in which case looks in the latter.
+ bool isUserMuted(int id, {List? mutedUsers});
+
+ /// Whether all of the users corresponding to [DmNarrow.otherRecipientIds]
+ /// are muted by [selfUser];
+ ///
+ /// By default, looks for the recipients in [UserStore.mutedUsers] unless
+ /// [mutedUsers] is non-null, in which case looks in the latter.
+ bool allRecipientsMuted(DmNarrow narrow, {List? mutedUsers}) {
+ return !narrow.otherRecipientIds.any((id) => !isUserMuted(id, mutedUsers: mutedUsers));
+ }
}
/// The implementation of [UserStore] that does the work.
@@ -81,16 +101,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 = _sortMutedUsers(initialSnapshot.mutedUsers);
final Map _users;
+ @override
+ final List mutedUsers;
+
@override
User? getUser(int userId) => _users[userId];
@override
Iterable get allUsers => _users.values;
+ @override
+ bool isUserMuted(int id, {List? mutedUsers}) {
+ return binarySearchByKey(
+ mutedUsers == null ? this.mutedUsers : _sortMutedUsers(mutedUsers), id,
+ (item, id) => item.id.compareTo(id)) >= 0;
+ }
+
+ static List _sortMutedUsers(List mutedUsers) {
+ return mutedUsers..sort((a, b) => a.id.compareTo(b.id));
+ }
+
void handleRealmUserEvent(RealmUserEvent event) {
switch (event) {
case RealmUserAddEvent():
@@ -129,4 +164,9 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore {
}
}
}
+
+ void handleMutedUsersEvent(MutedUsersEvent event) {
+ mutedUsers.clear();
+ mutedUsers.addAll(_sortMutedUsers(event.mutedUsers));
+ }
}
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 0f6a5c75a1..f36ebc7d21 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 dcd063a62a..29232556a5 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -1264,7 +1264,7 @@ class DmRecipientHeader extends StatelessWidget {
child: Icon(
color: designVariables.title,
size: 16,
- ZulipIcons.user)),
+ ZulipIcons.two_person)),
Expanded(
child: Text(title,
style: recipientHeaderTextStyle(context),
diff --git a/test/example_data.dart b/test/example_data.dart
index d803196269..d099bb3e6d 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -979,6 +979,7 @@ InitialSnapshot initialSnapshot({
int? serverTypingStartedExpiryPeriodMilliseconds,
int? serverTypingStoppedWaitPeriodMilliseconds,
int? serverTypingStartedWaitPeriodMilliseconds,
+ List? mutedUsers,
Map? realmEmoji,
List? recentPrivateConversations,
List? savedSnippets,
@@ -1015,6 +1016,7 @@ InitialSnapshot initialSnapshot({
serverTypingStoppedWaitPeriodMilliseconds ?? 5000,
serverTypingStartedWaitPeriodMilliseconds:
serverTypingStartedWaitPeriodMilliseconds ?? 10000,
+ mutedUsers: mutedUsers ?? [],
realmEmoji: realmEmoji ?? {},
recentPrivateConversations: recentPrivateConversations ?? [],
savedSnippets: savedSnippets ?? [],
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 df05b4f0cc..298f99b177 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -1278,7 +1278,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();
}