From 0fff6a6638ff783647b8b925e37848bab120120b Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Wed, 19 Feb 2025 22:21:46 +0430 Subject: [PATCH 01/14] api: Add InitialSnapshot.mutedUsers --- lib/api/model/initial_snapshot.dart | 3 +++ lib/api/model/initial_snapshot.g.dart | 5 +++++ lib/api/model/model.dart | 16 ++++++++++++++++ lib/api/model/model.g.dart | 6 ++++++ test/example_data.dart | 2 ++ 5 files changed, 32 insertions(+) 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..03f7d35434 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -110,6 +110,22 @@ 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; + + const MutedUserItem({required this.id}); + + 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..1d87530efd 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -68,6 +68,12 @@ Map _$CustomProfileFieldExternalAccountDataToJson( 'url_pattern': instance.urlPattern, }; +MutedUserItem _$MutedUserItemFromJson(Map json) => + MutedUserItem(id: (json['id'] as num).toInt()); + +Map _$MutedUserItemToJson(MutedUserItem instance) => + {'id': instance.id}; + RealmEmojiItem _$RealmEmojiItemFromJson(Map json) => RealmEmojiItem( emojiCode: json['id'] as String, diff --git a/test/example_data.dart b/test/example_data.dart index b87cbb6dc8..ca0808f1a7 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1019,6 +1019,7 @@ InitialSnapshot initialSnapshot({ int? serverTypingStartedExpiryPeriodMilliseconds, int? serverTypingStoppedWaitPeriodMilliseconds, int? serverTypingStartedWaitPeriodMilliseconds, + List? mutedUsers, Map? realmEmoji, List? recentPrivateConversations, List? savedSnippets, @@ -1055,6 +1056,7 @@ InitialSnapshot initialSnapshot({ serverTypingStoppedWaitPeriodMilliseconds ?? 5000, serverTypingStartedWaitPeriodMilliseconds: serverTypingStartedWaitPeriodMilliseconds ?? 10000, + mutedUsers: mutedUsers ?? [], realmEmoji: realmEmoji ?? {}, recentPrivateConversations: recentPrivateConversations ?? [], savedSnippets: savedSnippets ?? [], From 3e4c18c5e494ebceaf925125c006e6f19f28b638 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 1 May 2025 22:18:27 +0430 Subject: [PATCH 02/14] api: Add muted_users event --- lib/api/model/events.dart | 19 +++++++++++++++++++ lib/api/model/events.g.dart | 16 ++++++++++++++++ lib/api/model/model.dart | 2 +- lib/model/store.dart | 4 ++++ test/example_data.dart | 5 +++++ 5 files changed, 45 insertions(+), 1 deletion(-) 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/model.dart b/lib/api/model/model.dart index 03f7d35434..10942fc593 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -110,7 +110,7 @@ class CustomProfileFieldExternalAccountData { Map toJson() => _$CustomProfileFieldExternalAccountDataToJson(this); } -/// An item in the [InitialSnapshot.mutedUsers]. +/// An item in the [InitialSnapshot.mutedUsers] or [MutedUsersEvent]. /// /// For docs, search for "muted_users:" /// in . diff --git a/lib/model/store.dart b/lib/model/store.dart index 18a09e32ce..af1c41e857 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -949,6 +949,10 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor assert(debugLog("server event: reaction/${event.op}")); _messages.handleReactionEvent(event); + case MutedUsersEvent(): + // TODO handle + break; + case UnexpectedEvent(): assert(debugLog("server event: ${jsonEncode(event.toJson())}")); // TODO log better } diff --git a/test/example_data.dart b/test/example_data.dart index ca0808f1a7..e494a7e7c9 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -735,6 +735,11 @@ UserTopicEvent userTopicEvent( ); } +MutedUsersEvent mutedUsersEvent(List userIds) { + return MutedUsersEvent(id: 1, + mutedUsers: userIds.map((id) => MutedUserItem(id: id)).toList()); +} + MessageEvent messageEvent(Message message, {int? localMessageId}) => MessageEvent(id: 0, message: message, localMessageId: localMessageId?.toString()); From d7374c20e1f0069989c531fc48c2c9d3fcc5b792 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sat, 22 Feb 2025 21:44:16 +0430 Subject: [PATCH 03/14] user: Add UserStore.mutedUsers with helper methods, updating with events --- lib/model/store.dart | 12 ++++++++++-- lib/model/user.dart | 32 +++++++++++++++++++++++++++++++- test/model/store_checks.dart | 1 + test/model/test_store.dart | 16 ++++++++++++++++ test/model/user_test.dart | 18 ++++++++++++++++++ 5 files changed, 76 insertions(+), 3 deletions(-) diff --git a/lib/model/store.dart b/lib/model/store.dart index af1c41e857..6a25909b29 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -645,6 +645,13 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor @override Iterable get allUsers => _users.allUsers; + @override + Set get mutedUsers => _users.mutedUsers; + + @override + bool isUserMuted(int id, {Set? mutedUsers}) => + _users.isUserMuted(id, mutedUsers: mutedUsers); + final UserStoreImpl _users; final TypingStatus typingStatus; @@ -950,8 +957,9 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor _messages.handleReactionEvent(event); case MutedUsersEvent(): - // TODO handle - break; + 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..d8a56ad303 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; @@ -66,6 +68,16 @@ mixin UserStore on PerAccountStoreBase { return getUser(message.senderId)?.fullName ?? message.senderFullName; } + + /// Ids of all the users muted by [selfUser]. + @visibleForTesting + Set 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, {Set? mutedUsers}); } /// The implementation of [UserStore] that does the work. @@ -81,7 +93,8 @@ 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 = _toUserIds(initialSnapshot.mutedUsers); final Map _users; @@ -91,6 +104,18 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { @override Iterable get allUsers => _users.values; + @override + final Set mutedUsers; + + @override + bool isUserMuted(int id, {Set? mutedUsers}) { + return (mutedUsers ?? this.mutedUsers).contains(id); + } + + static Set _toUserIds(List mutedUserItems) { + return Set.from(mutedUserItems.map((item) => item.id)); + } + void handleRealmUserEvent(RealmUserEvent event) { switch (event) { case RealmUserAddEvent(): @@ -129,4 +154,9 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore { } } } + + void handleMutedUsersEvent(MutedUsersEvent event) { + mutedUsers.clear(); + mutedUsers.addAll(_toUserIds(event.mutedUsers)); + } } diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 93e24dffdd..3c0cfcbc85 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -57,6 +57,7 @@ extension PerAccountStoreChecks on Subject { Subject get selfUserId => has((x) => x.selfUserId, 'selfUserId'); Subject get userSettings => has((x) => x.userSettings, 'userSettings'); Subject> get savedSnippets => has((x) => x.savedSnippets, 'savedSnippets'); + Subject> get mutedUsers => has((x) => x.mutedUsers, 'mutedUsers'); Subject> get streams => has((x) => x.streams, 'streams'); Subject> get streamsByName => has((x) => x.streamsByName, 'streamsByName'); Subject> get subscriptions => has((x) => x.subscriptions, 'subscriptions'); diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 0196611e1d..484b7b31a6 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; @@ -267,6 +268,21 @@ extension PerAccountStoreTestExtension on PerAccountStore { } } + Future muteUser(int id) async { + await handleEvent(eg.mutedUsersEvent([...mutedUsers, id])); + } + + Future muteUsers(List ids) async { + for (final id in ids) { + await muteUser(id); + } + } + + Future unmuteUser(int id) async { + await handleEvent(eg.mutedUsersEvent( + mutedUsers.whereNot((userId) => userId == id).toList())); + } + Future addStream(ZulipStream stream) async { await addStreams([stream]); } diff --git a/test/model/user_test.dart b/test/model/user_test.dart index 63ac1589c7..a68b95d779 100644 --- a/test/model/user_test.dart +++ b/test/model/user_test.dart @@ -5,6 +5,7 @@ import 'package:zulip/api/model/model.dart'; import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; +import 'store_checks.dart'; import 'test_store.dart'; void main() { @@ -79,4 +80,21 @@ void main() { check(getUser()).deliveryEmail.equals('c@mail.example'); }); }); + + testWidgets('MutedUsersEvent', (tester) async { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + + final store = eg.store(initialSnapshot: eg.initialSnapshot( + realmUsers: [user1, user2, user3], + mutedUsers: [MutedUserItem(id: 2), MutedUserItem(id: 1)])); + check(store).mutedUsers.deepEquals({2, 1}); + + await store.handleEvent(eg.mutedUsersEvent([2, 1, 3])); + check(store).mutedUsers.deepEquals({2, 1, 3}); + + await store.handleEvent(eg.mutedUsersEvent([2, 3])); + check(store).mutedUsers.deepEquals({2, 3}); + }); } From e82dd6da746415937aabbc7bb7721aa01de2a355 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 25 Feb 2025 22:05:26 +0430 Subject: [PATCH 04/14] icons: Add "person", "eye", and "eye_off" icons Also renamed "user" to "two_person" to make it consistent with other icons of the same purpose. New icons taken from: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5968-237884&t=dku3J5Fv2dmWo7ht-0 https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5968-264632&t=dku3J5Fv2dmWo7ht-0 https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5968-291260&t=B5jAmeUMMG4dlsU7-0 --- assets/icons/ZulipIcons.ttf | Bin 14384 -> 15200 bytes assets/icons/eye.svg | 3 + assets/icons/eye_off.svg | 3 + assets/icons/person.svg | 10 +++ assets/icons/{user.svg => two_person.svg} | 0 lib/widgets/home.dart | 4 +- lib/widgets/icons.dart | 71 ++++++++++-------- lib/widgets/inbox.dart | 2 +- lib/widgets/message_list.dart | 2 +- test/widgets/home_test.dart | 2 +- test/widgets/message_list_test.dart | 2 +- .../widgets/recent_dm_conversations_test.dart | 2 +- 12 files changed, 63 insertions(+), 38 deletions(-) create mode 100644 assets/icons/eye.svg create mode 100644 assets/icons/eye_off.svg create mode 100644 assets/icons/person.svg rename assets/icons/{user.svg => two_person.svg} (100%) diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf index 0ef0c3f461d17e792be725867eb5298a77c384c8..e8dc736bd1035415480128fa9b59708a7b63b7ec 100644 GIT binary patch delta 2653 zcmb7GZERat8Gg^b_Vw3|lR9?3nz@N@o77HvZC`&}JBjT$u^n%!5^Ziv8D6~5+3g@- zYtpP0(L8OPKtoWK3rMj2C`cgIP1Uqa(^L(_j|mMV3gX9v#GeXjXqx(i0;(zmh3DMd zEafM;dhUDfd)}|-eUFdw_WJFuHZdad(2FEeY_4{4rat~u2NBB<*@6qTsCMf2wNHuc zr(yNu?ZawtuC!J1Mg33 zi1jX!ps%lOY+i2WUn9!g!S{1lKTtZVCIhf|pj=h1-ifoS5oBlE?@%Wlrk~MA^cnNB z8TNhl2X;?5D*RZCh-vZL;x)39Oe$4qkv2)7E{fralSTKmB+bw%vXR3KoHGNbU}d8abs%Asl4e33fpqN7!Y+rx zByE;5o}Q!@ty9>XXcS5I zrpeDk5=FaU#r3?&6G)gtf1LQ@^PfPGlh7PNF|cZzW}_x2^FWXS9y*MYNMbLik`x<}s9TO87b8BZ@#bk4APPLJrUFSQ1Fi%j3ShBZrXsfA=%^ z<~xrpT5Kyga5gaH$BUo!Va#X$pm2=}O$w#%*>svM-unse{`_)CS%A$ftZ*?2uNs5) z0#l8;ebu;OcN)L3M+ET)#x1*M3`sVFInNqbq^{_Fq~w2p12euFhjim^xsSBJ!nYfp z5%4tVS-NyrHEu~tJc0BDq)VdA3?@2-$rf>XrcosCRT&-4fldXj*Kj;bM#%96*EoI& zDk4kBy>t*U3i+g-nVf=edO8)KEyr@%%8WKw!u|`!wB$0{PO1G2FAjfmK*K@;a>+sz z^2-(qkY_C9A-OLHzc-r}bjX&44CGl0laS{uOhG=oi#Dhcebqt{a@hhdU|O*-4Y_I| z1-WJ+4!Le&7VCU0`Jfz3#FZ# zE=dU89Vgc`=-EjNd+wOawQ=Ydx zf01Y8Uvxatal?DU`(jsL*R`&H9JqYoQ{Ra1uiaDKZ~LqMd;kA~%;u>RJ{GQ#uya~& zy4p4kr=JBw>#qEbb*vYn{X%$Vor7qML zRP!a$;Zh@;ZIll0NfsU(t8J87Fv!XqwTIR(ao37=Qng&w1#sCT^f9h+6@+R;-D$0t z_4uVwlt~UrWflj0bR^mC_A*CM(9`;;q^RO(Qty>|{XK~uy<276Hm#>E`jW%TR0d1D zIuuzvCHE`x^(X4fzq=OZ-1=+j*X2)QkM@Qxzx8B3dpsNs<)Z_vuV`a&MfUuB@|BYB zpe^8g{72tfK4dyXJW74g<_QG`z9{}$mj88KR{G`Z5tm&4UX!yed?oz4tUS0Ms;6sT z%eowW$NShoG#`qFiym2tGaKs<4LXG$Wl(Sp*S3$FJ}Iv)pm@ga$#@eE;FWM-Q=9rk;d|iK|m{Z2`#m8#ZayZhm+}0ZUj_-&=-npQ~*iL`E&USv1I4$7+rw6H5B{s*Z z>`~(z>6c`&d8z3D8f&eV;nu(Ikk*^$H`Z2-tNK$u_vZ6!jookQ!s^O}&8G2BJ^Pi4ToYQjLl6@}1#O^wFJs^PltIFK5o~ zZG2EFLje%LCAblLbo}X&@h>Mn1e5{5z4yd;)R?$6z5{q$sC9m(m@gf>S-1cx&;|t0H+k9Ov<~K{ql#-xO1Z-c*7YogwW(}b49l+IJT3%iIRiFL~i0^WL_R5XU z>M^GRR`Gem*x4(0bY;wLQ|8MXs6hv=<7@ng-;^%pymD39R`y+suDfoyC*eg95j=wD zu?83F5ats>6fyL{L>vjO!sy2Ul3cslq9ctXzL)$2pAb^ii_xy%u~*8_{$-#$t_^tL za~xZaV}qG{@DQpQFa+HRXws2MX_k5x6I0QG`{AeDU@AG%0m@rx7op~$Bx3GR6ur zsHe6_Elp>l_BJ%K!6Akg%@xi=l#B39sJh2L?52;EchmL^o7G*wQ!#iR!vzDd^+YMSKxxiTE5DZVYXR;A+%WnogklhT1@K=w*_ zsb(T#FvHGyPN*&+zZKZS#6HP z@NiV(6p7I^(X+}_Gm_U!sYO|sDbyg;#>L@>bnp>b43>7(J+4>mbKW7P*S_jqvK!RX z_Rro9`zNnvmsF49v7hu`w7*yDqWg3ues@@pScOP6UJd&UwavZ8ez^Ed;{vvR>9t#Z zoqCK~X-3kSN}Q!8IkZ7$l3}W%LnF+~Vi7s^JIdA!+e^M@=w4;i{Bc^RQ@R-+NYbh= zV??qeVJndvijEFbe#w4I^_7E+40l$%`5PssC1Rv`i4f@v5^2&Ii4^IqgiczJFiGbm z;-vEuyoa$MktDrP7B8Zocu``IbWx&@v?!4w<>l;vcPN%6BBUjWEa^)UBcv-5VbWCz zi*!vQ!^;`#5;@Whi6PRLC8DIf;T?z$ZAlDQf|}~G&ESc0t9Ml0@wNCiec$-EtGcS* z)co3lc2oO3@KoSdaJE{jPSku`J5l>?omF?OZnyq={eHt-V^ib%|Nl8SMy?&#+dM2# z0*dSQ?aFR&s;bi5GFe+W+3g8do{gVVDm`XQseF={bn!o=Ci#YaE%}-qvR=18v!;UT S`ciRyt>9T-Ev(o*sl-1J2@Syj 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/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 be088afd48..e90d8e58e5 100644 --- a/lib/widgets/icons.dart +++ b/lib/widgets/icons.dart @@ -66,92 +66,101 @@ 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 "topics". - static const IconData topics = IconData(0xf129, fontFamily: "Zulip Icons"); + static const IconData topics = IconData(0xf12c, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "unmute". - static const IconData unmute = IconData(0xf12a, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "two_person". + static const IconData two_person = IconData(0xf12d, fontFamily: "Zulip Icons"); - /// The Zulip custom icon "user". - static const IconData user = IconData(0xf12b, fontFamily: "Zulip Icons"); + /// The Zulip custom icon "unmute". + static const IconData unmute = IconData(0xf12e, 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 e25445e514..816cd8dfb6 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1303,7 +1303,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/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 81cc384ef8..304e65dec2 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -1355,7 +1355,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(); } From da91fce2eb54438cb9e12671e5d6ecfca7a1061f Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sun, 23 Mar 2025 07:22:36 +0430 Subject: [PATCH 05/14] msglist: Distinguish messages sent by muted users Figma design: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6089-28385&t=28DdYiTs6fXWR9ua-0 --- assets/l10n/app_en.arb | 14 +- lib/generated/l10n/zulip_localizations.dart | 16 +- .../l10n/zulip_localizations_ar.dart | 7 +- .../l10n/zulip_localizations_de.dart | 7 +- .../l10n/zulip_localizations_en.dart | 7 +- .../l10n/zulip_localizations_ja.dart | 7 +- .../l10n/zulip_localizations_nb.dart | 7 +- .../l10n/zulip_localizations_pl.dart | 7 +- .../l10n/zulip_localizations_ru.dart | 7 +- .../l10n/zulip_localizations_sk.dart | 7 +- .../l10n/zulip_localizations_uk.dart | 7 +- .../l10n/zulip_localizations_zh.dart | 7 +- lib/widgets/action_sheet.dart | 30 +++ lib/widgets/content.dart | 32 ++- lib/widgets/message_list.dart | 211 +++++++++++++----- lib/widgets/recent_dm_conversations.dart | 4 +- lib/widgets/theme.dart | 48 ++-- test/widgets/action_sheet_test.dart | 76 ++++++- test/widgets/message_list_test.dart | 80 +++++++ 19 files changed, 441 insertions(+), 140 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index aa01eda043..4b009a8779 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -983,17 +983,13 @@ "@noEarlierMessages": { "description": "Text to show at the start of a message list if there are no earlier messages." }, - "mutedSender": "Muted sender", - "@mutedSender": { - "description": "Name for a muted user to display in message list." - }, - "revealButtonLabel": "Reveal message for muted sender", - "@revealButtonLabel": { - "description": "Label for the button revealing hidden message from a muted sender in message list." - }, "mutedUser": "Muted user", "@mutedUser": { - "description": "Name for a muted user to display all over the app." + "description": "Text to display in place of a muted user's name" + }, + "revealButtonLabel": "Reveal message", + "@revealButtonLabel": { + "description": "Label for the button revealing hidden message from a muted user in message list." }, "scrollToBottomTooltip": "Scroll to bottom", "@scrollToBottomTooltip": { diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 306596044b..7f957cd56c 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1463,24 +1463,18 @@ abstract class ZulipLocalizations { /// **'No earlier messages'** String get noEarlierMessages; - /// Name for a muted user to display in message list. + /// Text to display in place of a muted user's name /// /// In en, this message translates to: - /// **'Muted sender'** - String get mutedSender; + /// **'Muted user'** + String get mutedUser; - /// Label for the button revealing hidden message from a muted sender in message list. + /// Label for the button revealing hidden message from a muted user in message list. /// /// In en, this message translates to: - /// **'Reveal message for muted sender'** + /// **'Reveal message'** String get revealButtonLabel; - /// Name for a muted user to display all over the app. - /// - /// 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 92b2ce681c..dba8c5a175 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -797,13 +797,10 @@ class ZulipLocalizationsAr extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get mutedUser => 'Muted user'; @override - String get mutedUser => 'Muted user'; + String get revealButtonLabel => 'Reveal message'; @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 54faf4fde4..e2c59bf5ef 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -797,13 +797,10 @@ class ZulipLocalizationsDe extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get mutedUser => 'Muted user'; @override - String get mutedUser => 'Muted user'; + String get revealButtonLabel => 'Reveal message'; @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 dacb23923a..84a8fc26a6 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -797,13 +797,10 @@ class ZulipLocalizationsEn extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get mutedUser => 'Muted user'; @override - String get mutedUser => 'Muted user'; + String get revealButtonLabel => 'Reveal message'; @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 9d7e3ce291..85a697b2a0 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -797,13 +797,10 @@ class ZulipLocalizationsJa extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get mutedUser => 'Muted user'; @override - String get mutedUser => 'Muted user'; + String get revealButtonLabel => 'Reveal message'; @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 92f9e33706..fefdcc16c6 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -797,13 +797,10 @@ class ZulipLocalizationsNb extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get mutedUser => 'Muted user'; @override - String get mutedUser => 'Muted user'; + String get revealButtonLabel => 'Reveal message'; @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 2657910637..e4886f0dff 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -808,13 +808,10 @@ class ZulipLocalizationsPl extends ZulipLocalizations { String get noEarlierMessages => 'Brak historii'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get mutedUser => 'Muted user'; @override - String get mutedUser => 'Muted user'; + String get revealButtonLabel => 'Reveal message'; @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 bd4ee4423c..aca00fc787 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -811,13 +811,10 @@ class ZulipLocalizationsRu extends ZulipLocalizations { String get noEarlierMessages => 'Предшествующих сообщений нет'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get mutedUser => 'Muted user'; @override - String get mutedUser => 'Muted user'; + String get revealButtonLabel => 'Reveal message'; @override String get scrollToBottomTooltip => 'Пролистать вниз'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 93103de344..a9ff7943d6 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -799,13 +799,10 @@ class ZulipLocalizationsSk extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get mutedUser => 'Muted user'; @override - String get mutedUser => 'Muted user'; + String get revealButtonLabel => 'Reveal message'; @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 9f49e2df4d..242aab4446 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -811,13 +811,10 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get noEarlierMessages => 'Немає попередніх повідомлень'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get mutedUser => 'Muted user'; @override - String get mutedUser => 'Muted user'; + String get revealButtonLabel => 'Reveal message'; @override String get scrollToBottomTooltip => 'Прокрутити вниз'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 8b3760c36d..87095fa3a6 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -797,13 +797,10 @@ class ZulipLocalizationsZh extends ZulipLocalizations { String get noEarlierMessages => 'No earlier messages'; @override - String get mutedSender => 'Muted sender'; - - @override - String get revealButtonLabel => 'Reveal message for muted sender'; + String get mutedUser => 'Muted user'; @override - String get mutedUser => 'Muted user'; + String get revealButtonLabel => 'Reveal message'; @override String get scrollToBottomTooltip => 'Scroll to bottom'; diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index 6bd4e1024a..472c6c00c7 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.dart @@ -589,6 +589,8 @@ void showMessageActionSheet({required BuildContext context, required Message mes final markAsUnreadSupported = store.zulipFeatureLevel >= 155; // TODO(server-6) final showMarkAsUnreadButton = markAsUnreadSupported && isMessageRead; + final isSenderMuted = store.isUserMuted(message.senderId); + final optionButtons = [ if (popularEmojiLoaded) ReactionButtons(message: message, pageContext: pageContext), @@ -597,6 +599,9 @@ void showMessageActionSheet({required BuildContext context, required Message mes QuoteAndReplyButton(message: message, pageContext: pageContext), if (showMarkAsUnreadButton) MarkAsUnreadButton(message: message, pageContext: pageContext), + if (isSenderMuted) + HideMutedMessageButton(message: message, pageContext: pageContext, + messageContext: context), CopyMessageTextButton(message: message, pageContext: pageContext), CopyMessageLinkButton(message: message, pageContext: pageContext), ShareButton(message: message, pageContext: pageContext), @@ -902,6 +907,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 62801ab867..21df59530e 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -1659,18 +1659,48 @@ 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(size: size) + : AvatarImage(userId: userId, size: size)); + } +} + +/// A placeholder avatar for muted users. +/// +/// Wrap this with [AvatarShape]. +class AvatarPlaceholder extends StatelessWidget { + const AvatarPlaceholder({super.key, required this.size}); + + /// The size of the placeholder box. + /// + /// This should match the `size` passed to the wrapping [AvatarShape]. + /// The placeholder's icon will be scaled proportionally to this. + final double size; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return DecoratedBox( + decoration: BoxDecoration( + color: designVariables.avatarPlaceholderBg), + child: Icon(ZulipIcons.person, + // Where the avatar placeholder appears in the Figma, + // this is how the icon is sized proportionally to its box. + size: size * 20 / 32, + color: designVariables.avatarPlaceholderIcon)); } } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 816cd8dfb6..4c8197da2e 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -1414,18 +1414,29 @@ String formatHeaderDate( final _kMessageTimestampFormat = DateFormat('h:mm aa', 'en_US'); class _SenderRow extends StatelessWidget { - const _SenderRow({required this.message, required this.showTimestamp}); + const _SenderRow({ + required this.message, + required this.showTimestamp, + required this.showAsMuted, + }); final MessageBase message; final bool showTimestamp; + final bool showAsMuted; @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 sender = store.getUser(message.senderId); + final senderName = showAsMuted + ? localizations.mutedUser + : message is Message + ? store.senderDisplayName(message as Message) + : store.userDisplayName(message.senderId); final time = _kMessageTimestampFormat .format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); return Padding( @@ -1437,22 +1448,21 @@ class _SenderRow 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 is Message - ? store.senderDisplayName(message as Message) - : store.userDisplayName(message.senderId), + 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) ...[ @@ -1478,20 +1488,87 @@ class _SenderRow extends StatelessWidget { } } +class _RevealButton extends StatelessWidget { + const _RevealButton({required this.onPressed}); + + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final localizations = ZulipLocalizations.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0), + child: TextButton.icon( + onPressed: onPressed, + 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)))), + ); + } +} + /// A Zulip message, showing the sender's name and avatar if specified. // 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 designVariables = DesignVariables.of(context); - final message = item.message; final zulipLocalizations = ZulipLocalizations.of(context); String? editStateText; @@ -1514,7 +1591,7 @@ class MessageWithPossibleSender extends StatelessWidget { child: Icon(ZulipIcons.star_filled, size: 16, color: designVariables.star)); } - Widget content = MessageContent(message: message, content: item.content); + Widget content = MessageContent(message: message, content: widget.item.content); final editMessageErrorStatus = store.getEditMessageErrorStatus(message.id); if (editMessageErrorStatus != null) { @@ -1533,45 +1610,60 @@ class MessageWithPossibleSender extends StatelessWidget { } } - return GestureDetector( - behavior: HitTestBehavior.translucent, - onLongPress: () => showMessageActionSheet(context: context, message: message), - child: Padding( - padding: const EdgeInsets.only(top: 4), - child: Column(children: [ - if (item.showSender) - _SenderRow(message: message, showTimestamp: true), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: localizedTextBaseline(context), - children: [ - const SizedBox(width: 16), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - content, - if ((message.reactions?.total ?? 0) > 0) - ReactionChipsList(messageId: message.id, reactions: message.reactions!), - if (editMessageErrorStatus != null) - _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) - else if (editStateText != null) - Padding( - padding: const EdgeInsets.only(bottom: 4), - child: Text(editStateText, - textAlign: TextAlign.end, - style: TextStyle( - color: designVariables.labelEdited, - fontSize: 12, - height: (12 / 12), - letterSpacing: proportionalLetterSpacing(context, - 0.05, baseFontSize: 12)))) - else - Padding(padding: const EdgeInsets.only(bottom: 4)) - ])), - SizedBox(width: 16, - child: star), - ]), - ]))); + 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.only(top: 4), + child: Column(children: [ + if (widget.item.showSender) + _SenderRow(message: message, showTimestamp: true, + showAsMuted: showAsMuted), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: localizedTextBaseline(context), + children: [ + const SizedBox(width: 16), + if (showAsMuted) + _RevealButton(onPressed: () => changeMuteStatus(false)) + else + ...[ + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + content, + if ((message.reactions?.total ?? 0) > 0) + ReactionChipsList(messageId: message.id, reactions: message.reactions!), + if (editMessageErrorStatus != null) + _EditMessageStatusRow(messageId: message.id, status: editMessageErrorStatus) + else if (editStateText != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text(editStateText, + textAlign: TextAlign.end, + style: TextStyle( + color: designVariables.labelEdited, + fontSize: 12, + height: (12 / 12), + letterSpacing: proportionalLetterSpacing( + context, 0.05, baseFontSize: 12))), + ) + else + Padding(padding: const EdgeInsets.only(bottom: 4)) + ])), + SizedBox(width: 16, child: star), + ] + ]), + ]))); + } + ), + ); } } @@ -1651,3 +1743,22 @@ class _RestoreEditMessageGestureDetector extends StatelessWidget { child: child); } } + +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/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 982dde4f08..17be97d992 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -99,9 +99,9 @@ class RecentDmConversationsItem extends StatelessWidget { // // 'Chris、Greg、Alya' title = narrow.otherRecipientIds.map(store.userDisplayName) .join(', '); - avatar = ColoredBox(color: designVariables.groupDmConversationIconBg, + avatar = ColoredBox(color: designVariables.avatarPlaceholderBg, child: Center( - child: Icon(color: designVariables.groupDmConversationIcon, + child: Icon(color: designVariables.avatarPlaceholderIcon, ZulipIcons.group_dm))); } diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 492f82c88a..0bd552d7ab 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -165,16 +165,18 @@ class DesignVariables extends ThemeExtension { 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), textMessage: const Color(0xff262626), channelColorSwatches: ChannelColorSwatches.light, + avatarPlaceholderBg: const Color(0x33808080), + avatarPlaceholderIcon: Colors.black.withValues(alpha: 0.5), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(), - groupDmConversationIcon: Colors.black.withValues(alpha: 0.5), - groupDmConversationIconBg: const Color(0x33808080), inboxItemIconMarker: const HSLColor.fromAHSL(0.5, 0, 0, 0.2).toColor(), loginOrDivider: const Color(0xffdedede), loginOrDividerText: const Color(0xff575757), @@ -226,19 +228,21 @@ class DesignVariables extends ThemeExtension { 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), textMessage: const Color(0xffffffff).withValues(alpha: 0.8), channelColorSwatches: ChannelColorSwatches.dark, + // TODO(design-dark) need proper dark-theme color (this is ad hoc) + avatarPlaceholderBg: const Color(0x33cccccc), + // TODO(design-dark) need proper dark-theme color (this is ad hoc) + avatarPlaceholderIcon: Colors.white.withValues(alpha: 0.5), contextMenuCancelBg: const Color(0xff797986).withValues(alpha: 0.15), // the same as the light mode in Figma contextMenuCancelPressedBg: const Color(0xff797986).withValues(alpha: 0.20), // the same as the light mode in Figma // TODO(design-dark) need proper dark-theme color (this is ad hoc) dmHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(), - // TODO(design-dark) need proper dark-theme color (this is ad hoc) - groupDmConversationIcon: Colors.white.withValues(alpha: 0.5), - // TODO(design-dark) need proper dark-theme color (this is ad hoc) - groupDmConversationIconBg: const Color(0x33cccccc), inboxItemIconMarker: const HSLColor.fromAHSL(0.4, 0, 0, 1).toColor(), loginOrDivider: const Color(0xff424242), loginOrDividerText: const Color(0xffa8a8a8), @@ -295,16 +299,18 @@ class DesignVariables extends ThemeExtension { required this.labelEdited, required this.labelMenuButton, required this.mainBackground, + required this.neutralButtonBg, + required this.neutralButtonLabel, required this.textInput, required this.title, required this.bgSearchInput, required this.textMessage, required this.channelColorSwatches, + required this.avatarPlaceholderBg, + required this.avatarPlaceholderIcon, required this.contextMenuCancelBg, required this.contextMenuCancelPressedBg, required this.dmHeaderBg, - required this.groupDmConversationIcon, - required this.groupDmConversationIconBg, required this.inboxItemIconMarker, required this.loginOrDivider, required this.loginOrDividerText, @@ -365,6 +371,8 @@ class DesignVariables extends ThemeExtension { final Color labelEdited; final Color labelMenuButton; final Color mainBackground; + final Color neutralButtonBg; + final Color neutralButtonLabel; final Color textInput; final Color title; final Color bgSearchInput; @@ -374,11 +382,11 @@ class DesignVariables extends ThemeExtension { final ChannelColorSwatches channelColorSwatches; // Not named variables in Figma; taken from older Figma drafts, or elsewhere. + final Color avatarPlaceholderBg; + final Color avatarPlaceholderIcon; final Color contextMenuCancelBg; // In Figma, but unnamed. final Color contextMenuCancelPressedBg; // In Figma, but unnamed. final Color dmHeaderBg; - final Color groupDmConversationIcon; - final Color groupDmConversationIconBg; final Color inboxItemIconMarker; final Color loginOrDivider; // TODO(design-dark) need proper dark-theme color (this is ad hoc) final Color loginOrDividerText; // TODO(design-dark) need proper dark-theme color (this is ad hoc) @@ -424,22 +432,26 @@ class DesignVariables extends ThemeExtension { Color? contextMenuItemText, Color? editorButtonPressedBg, Color? foreground, + Color? listMenuItemBg, + Color? listMenuItemIcon, Color? icon, Color? iconSelected, Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, Color? mainBackground, + Color? neutralButtonBg, + Color? neutralButtonLabel, Color? textInput, Color? title, Color? bgSearchInput, Color? textMessage, ChannelColorSwatches? channelColorSwatches, + Color? avatarPlaceholderBg, + Color? avatarPlaceholderIcon, Color? contextMenuCancelBg, Color? contextMenuCancelPressedBg, Color? dmHeaderBg, - Color? groupDmConversationIcon, - Color? groupDmConversationIconBg, Color? inboxItemIconMarker, Color? loginOrDivider, Color? loginOrDividerText, @@ -490,16 +502,18 @@ class DesignVariables extends ThemeExtension { 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, textMessage: textMessage ?? this.textMessage, channelColorSwatches: channelColorSwatches ?? this.channelColorSwatches, + avatarPlaceholderBg: avatarPlaceholderBg ?? this.avatarPlaceholderBg, + avatarPlaceholderIcon: avatarPlaceholderIcon ?? this.avatarPlaceholderIcon, contextMenuCancelBg: contextMenuCancelBg ?? this.contextMenuCancelBg, contextMenuCancelPressedBg: contextMenuCancelPressedBg ?? this.contextMenuCancelPressedBg, dmHeaderBg: dmHeaderBg ?? this.dmHeaderBg, - groupDmConversationIcon: groupDmConversationIcon ?? this.groupDmConversationIcon, - groupDmConversationIconBg: groupDmConversationIconBg ?? this.groupDmConversationIconBg, inboxItemIconMarker: inboxItemIconMarker ?? this.inboxItemIconMarker, loginOrDivider: loginOrDivider ?? this.loginOrDivider, loginOrDividerText: loginOrDividerText ?? this.loginOrDividerText, @@ -557,16 +571,18 @@ class DesignVariables extends ThemeExtension { 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)!, textMessage: Color.lerp(textMessage, other.textMessage, t)!, channelColorSwatches: ChannelColorSwatches.lerp(channelColorSwatches, other.channelColorSwatches, t), + avatarPlaceholderBg: Color.lerp(avatarPlaceholderBg, other.avatarPlaceholderBg, t)!, + avatarPlaceholderIcon: Color.lerp(avatarPlaceholderIcon, other.avatarPlaceholderIcon, t)!, contextMenuCancelBg: Color.lerp(contextMenuCancelBg, other.contextMenuCancelBg, t)!, contextMenuCancelPressedBg: Color.lerp(contextMenuCancelPressedBg, other.contextMenuCancelPressedBg, t)!, dmHeaderBg: Color.lerp(dmHeaderBg, other.dmHeaderBg, t)!, - groupDmConversationIcon: Color.lerp(groupDmConversationIcon, other.groupDmConversationIcon, t)!, - groupDmConversationIconBg: Color.lerp(groupDmConversationIconBg, other.groupDmConversationIconBg, t)!, inboxItemIconMarker: Color.lerp(inboxItemIconMarker, other.inboxItemIconMarker, t)!, loginOrDivider: Color.lerp(loginOrDivider, other.loginOrDivider, t)!, loginOrDividerText: Color.lerp(loginOrDividerText, other.loginOrDividerText, t)!, diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 16cc36b096..f04e34d5e4 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -41,9 +41,11 @@ import '../model/binding.dart'; import '../model/test_store.dart'; import '../stdlib_checks.dart'; import '../test_clipboard.dart'; +import '../test_images.dart'; import '../test_share_plus.dart'; import 'compose_box_checks.dart'; import 'dialog_checks.dart'; +import 'message_list_test.dart'; import 'test_app.dart'; late PerAccountStore store; @@ -53,10 +55,13 @@ late FakeApiConnection connection; Future setupToMessageActionSheet(WidgetTester tester, { required Message message, required Narrow narrow, + User? messageSender, + List? mutedUserIds, bool? realmAllowMessageEditing, int? realmMessageContentEditLimitSeconds, bool shouldSetServerEmojiData = true, bool useLegacyServerEmojiData = false, + Future Function()? beforeLongPress, }) async { addTearDown(testBinding.reset); assert(narrow.containsMessage(message)); @@ -70,10 +75,11 @@ Future setupToMessageActionSheet(WidgetTester tester, { store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers([ eg.selfUser, - eg.user(userId: message.senderId), + messageSender ?? eg.user(userId: message.senderId), if (narrow is DmNarrow) ...narrow.otherRecipientIds.map((id) => eg.user(userId: id)), ]); + await store.muteUsers(mutedUserIds ?? []); if (message is StreamMessage) { final stream = eg.stream(streamId: message.streamId); await store.addStream(stream); @@ -94,6 +100,8 @@ Future setupToMessageActionSheet(WidgetTester tester, { // global store, per-account store, and message list get loaded await tester.pumpAndSettle(); + await beforeLongPress?.call(); + // Request the message action sheet. // // We use `warnIfMissed: false` to suppress warnings in cases where @@ -1334,6 +1342,72 @@ void main() { }); }); + group('HideMutedMessageButton', () { + final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); + final message = eg.streamMessage(sender: user, + content: '

A message

', reactions: [eg.unicodeEmojiReaction]); + + testWidgets('not visible if message is from normal sender (not muted)', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + messageSender: user, + ); + + check(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)).findsNothing(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('visible if message is from muted sender and revealed', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + messageSender: user, + mutedUserIds: [user.userId], + beforeLongPress: () async { + checkMessage(message, isMuted: true, store: store); + await tester.tap(find.text(localizations.revealButtonLabel)); + await tester.pump(); + checkMessage(message, isMuted: false, store: store); + }, + ); + + check(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('onPressed -> mutes the revealed message again', (tester) async { + prepareBoringImageHttpClient(); + + await setupToMessageActionSheet(tester, + message: message, + narrow: const CombinedFeedNarrow(), + messageSender: user, + mutedUserIds: [user.userId], + beforeLongPress: () async { + checkMessage(message, isMuted: true, store: store); + await tester.tap(find.text(localizations.revealButtonLabel)); + await tester.pump(); + checkMessage(message, isMuted: false, store: store); + }, + ); + + await tester.ensureVisible(find.byIcon(ZulipIcons.eye_off, skipOffstage: false)); + await tester.tap(find.byIcon(ZulipIcons.eye_off)); + await tester.pumpAndSettle(); + + checkMessage(message, isMuted: true, store: store); + + debugNetworkImageHttpClientProvider = null; + }); + }); + group('CopyMessageTextButton', () { setUp(() async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index 304e65dec2..f27542f243 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -24,6 +24,7 @@ import 'package:zulip/widgets/autocomplete.dart'; import 'package:zulip/widgets/color.dart'; import 'package:zulip/widgets/compose_box.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/emoji_reaction.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; @@ -48,6 +49,34 @@ import 'message_list_checks.dart'; import 'page_checks.dart'; import 'test_app.dart'; +final localizations = GlobalLocalizations.zulipLocalizations; + +void checkMessage(Message message, + {required bool isMuted, required PerAccountStore store}) { + final placeholderAvatarFinder = find.descendant( + of: find.byType(AvatarShape), + matching: find.byIcon(ZulipIcons.person)); + final imageAvatarFinder = find.descendant( + of: find.byType(AvatarShape), + matching: find.byType(RealmContentNetworkImage)); + + final mutedLabelFinder = find.text(localizations.mutedUser); + final nameFinder = find.text(store.senderDisplayName(message)); + + final revealButtonFinder = find.text(localizations.revealButtonLabel); + final contentFinder = find.byType(MessageContent); + final reactionsFinder = find.byType(ReactionChipsList); + + check(placeholderAvatarFinder.evaluate().length).equals(isMuted ? 1 : 0); + check(mutedLabelFinder.evaluate().length).equals(isMuted ? 1 : 0); + check(revealButtonFinder.evaluate().length).equals(isMuted ? 1 : 0); + + check(imageAvatarFinder.evaluate().length).equals(isMuted ? 0 : 1); + check(nameFinder.evaluate().length).equals(isMuted ? 0 : 1); + check(contentFinder.evaluate().length).equals(isMuted ? 0 : 1); + check(reactionsFinder.evaluate().length).equals(isMuted ? 0 : 1); +} + void main() { TestZulipBinding.ensureInitialized(); @@ -61,6 +90,7 @@ void main() { List? messages, List? streams, List? users, + List? mutedUserIds, List? subscriptions, UnreadMessagesSnapshot? unreadMsgs, int? zulipFeatureLevel, @@ -83,6 +113,7 @@ void main() { // prepare message list data await store.addUser(eg.selfUser); await store.addUsers(users ?? []); + await store.muteUsers(mutedUserIds ?? []); assert((messageCount == null) != (messages == null)); messages ??= List.generate(messageCount!, (index) { return eg.streamMessage(sender: eg.selfUser); @@ -1549,6 +1580,55 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + group('Muted sender', () { + final user = eg.user(userId: 1, fullName: 'User', avatarUrl: '/foo.png'); + final message =eg.streamMessage(sender: user, + content: '

A message

', reactions: [eg.unicodeEmojiReaction]); + + testWidgets('Sender muted -> Message muted', (tester) async { + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester, + users: [user], + mutedUserIds: [user.userId], + messages: [message], + ); + checkMessage(message, isMuted: true, store: store); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('Sender not muted -> Message not muted', (tester) async { + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester, + users: [user], + mutedUserIds: [], + messages: [message], + ); + checkMessage(message, isMuted: false, store: store); + + debugNetworkImageHttpClientProvider = null; + }); + + testWidgets('Tapping reveal button on muted message reveals the message', + (tester) async { + prepareBoringImageHttpClient(); + + await setupMessageListPage(tester, + users: [user], + mutedUserIds: [user.userId], + messages: [message], + ); + checkMessage(message, isMuted: true, store: store); + await tester.tap(find.text(localizations.revealButtonLabel)); + await tester.pump(); + checkMessage(message, isMuted: false, store: store); + + debugNetworkImageHttpClientProvider = null; + }); + }); }); group('Starred messages', () { From 50af93ed5fbb9f694d4721f4f3dc1e93fb26c2e1 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sun, 1 Jun 2025 21:22:02 +0430 Subject: [PATCH 06/14] msglist: Cover remaining places for muted users in message list page The places are: - App bar title in DM narrow - DM recipient header --- lib/widgets/message_list.dart | 9 ++++++-- test/widgets/message_list_test.dart | 35 +++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 4c8197da2e..348807a6eb 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -450,7 +450,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(', '))); @@ -1272,7 +1275,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 { diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart index f27542f243..0302073f69 100644 --- a/test/widgets/message_list_test.dart +++ b/test/widgets/message_list_test.dart @@ -346,6 +346,24 @@ void main() { matching: find.text('channel foo')), ).findsOne(); }); + + testWidgets('shows "Muted user" label for muted users in DM narrow', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + final user3 = eg.user(userId: 3, fullName: 'User 3'); + final mutedUsers = [1, 3]; + + await setupMessageListPage(tester, + narrow: DmNarrow.withOtherUsers([1, 2, 3], selfUserId: 10), + users: [user1, user2, user3], + mutedUserIds: mutedUsers, + messageCount: 1, + ); + + check(find.text(localizations.dmsWithOthersPageTitle( + '${localizations.mutedUser}, ${store.userDisplayName(user2.userId)}, ${localizations.mutedUser}')) + ).findsOne(); + }); }); group('presents message content appropriately', () { @@ -1377,6 +1395,23 @@ void main() { "${zulipLocalizations.unknownUserName}, ${eg.thirdUser.fullName}"))); }); + testWidgets('show "Muted user" label for muted users', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + final user3 = eg.user(userId: 3, fullName: 'User 3'); + final mutedUsers = [1, 3]; + + await setupMessageListPage(tester, + users: [user1, user2, user3], + mutedUserIds: mutedUsers, + messages: [eg.dmMessage(from: eg.selfUser, to: [user1, user2, user3])] + ); + + check(find.text(localizations.messageListGroupYouAndOthers( + '${localizations.mutedUser}, ${localizations.mutedUser}, ${store.userDisplayName(user2.userId)}')) + ).findsOne(); + }); + testWidgets('icon color matches text color', (tester) async { final zulipLocalizations = GlobalLocalizations.zulipLocalizations; await setupMessageListPage(tester, messages: [ From 51a15fe62ebd959e405f5c8b5e16c5862d011d0d Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Sun, 1 Jun 2025 21:28:13 +0430 Subject: [PATCH 07/14] emoji: Distinguish muted users in reactions --- lib/widgets/emoji_reaction.dart | 4 +++- test/widgets/emoji_reaction_test.dart | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) 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/test/widgets/emoji_reaction_test.dart b/test/widgets/emoji_reaction_test.dart index de3ad7227c..454fde178c 100644 --- a/test/widgets/emoji_reaction_test.dart +++ b/test/widgets/emoji_reaction_test.dart @@ -31,6 +31,7 @@ import '../stdlib_checks.dart'; import '../test_images.dart'; import 'content_test.dart'; import 'dialog_checks.dart'; +import 'message_list_test.dart'; import 'test_app.dart'; import 'text_test.dart'; @@ -227,6 +228,27 @@ void main() { } } } + + testWidgets('show "Muted user" label for muted reactors', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + + await prepare(); + await store.addUsers([user1, user2]); + await store.muteUsers([user1.userId]); + await setupChipsInBox(tester, + reactions: [ + Reaction.fromJson({'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': user1.userId}), + Reaction.fromJson({'emoji_name': '+1', 'emoji_code': '1f44d', 'reaction_type': 'unicode_emoji', 'user_id': user2.userId}), + ]); + + final reactionChipFinder = find.byType(ReactionChip); + check(reactionChipFinder).findsOne(); + check(find.descendant( + of: reactionChipFinder, + matching: find.text('${localizations.mutedUser}, ${store.userDisplayName(user2.userId)}') + )).findsOne(); + }); }); testWidgets('Smoke test for light/dark/lerped', (tester) async { From de6eff3a93419c831804cfb2b2c433247bf384bd Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 21 Mar 2025 20:18:18 +0430 Subject: [PATCH 08/14] poll: Distinguish muted users in polls --- lib/widgets/poll.dart | 5 ++++- test/widgets/poll_test.dart | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) 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/test/widgets/poll_test.dart b/test/widgets/poll_test.dart index a6bd74c77e..2645a45561 100644 --- a/test/widgets/poll_test.dart +++ b/test/widgets/poll_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/model/submessage.dart'; +import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/poll.dart'; @@ -28,12 +29,14 @@ void main() { WidgetTester tester, SubmessageData? submessageContent, { Iterable? users, + List? mutedUserIds, Iterable<(User, int)> voterIdxPairs = const [], }) async { addTearDown(testBinding.reset); await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUsers(users ?? [eg.selfUser, eg.otherUser]); + await store.muteUsers(mutedUserIds ?? []); connection = store.connection as FakeApiConnection; message = eg.streamMessage( @@ -96,6 +99,21 @@ void main() { check(findTextAtRow('100', index: 0)).findsOne(); }); + testWidgets('muted voters', (tester) async { + final user1 = eg.user(userId: 1, fullName: 'User 1'); + final user2 = eg.user(userId: 2, fullName: 'User 2'); + await preparePollWidget(tester, pollWidgetData, + users: [user1, user2], + mutedUserIds: [user2.userId], + voterIdxPairs: [(user1, 0), (user2, 0), (user2, 1)]); + + final localizations = GlobalLocalizations.zulipLocalizations; + check(findTextAtRow( + '(${store.userDisplayName(user1.userId)}, ${localizations.mutedUser})', + index: 0)).findsOne(); + check(findTextAtRow('(${localizations.mutedUser})', index: 1)).findsOne(); + }); + testWidgets('show unknown voter', (tester) async { await preparePollWidget(tester, pollWidgetData, users: [eg.selfUser], voterIdxPairs: [(eg.thirdUser, 1)]); From 10a83e9ebd6c137c39bdcd4846fe2af789c473ad Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 21 Mar 2025 21:48:11 +0430 Subject: [PATCH 09/14] recent-dms: Distinguish muted users in recent DMs page --- lib/widgets/recent_dm_conversations.dart | 24 +++++-- .../widgets/recent_dm_conversations_test.dart | 62 +++++++++++++------ 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 17be97d992..299d6863cf 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,26 @@ 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; 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; for example using: "dm:Chris Bobbe". + // 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(size: _avatarSize) + : 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.avatarPlaceholderBg, child: Center( diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 4849a997cf..5ae11177b0 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -19,12 +19,14 @@ import '../model/test_store.dart'; import '../test_navigation.dart'; import 'content_checks.dart'; import 'message_list_checks.dart'; +import 'message_list_test.dart'; import 'page_checks.dart'; import 'test_app.dart'; Future setupPage(WidgetTester tester, { required List dmMessages, required List users, + List? mutedUserIds, NavigatorObserver? navigatorObserver, String? newNameForSelfUser, }) async { @@ -37,6 +39,7 @@ Future setupPage(WidgetTester tester, { for (final user in users) { await store.addUser(user); } + await store.muteUsers(mutedUserIds ?? []); await store.addMessages(dmMessages); @@ -110,7 +113,7 @@ void main() { group('RecentDmConversationsItem', () { group('content/appearance', () { - void checkAvatar(WidgetTester tester, DmNarrow narrow) { + void checkAvatar(WidgetTester tester, DmNarrow narrow, {bool isMuted = false}) { final shape = tester.widget( find.descendant( of: find.byType(RecentDmConversationsItem), @@ -124,7 +127,9 @@ void main() { case []: // self-1:1 check(shape).child.isA().userId.equals(eg.selfUser.userId); case [var otherUserId]: // 1:1 - check(shape).child.isA().userId.equals(otherUserId); + isMuted + ? check(shape).child.isA() + : check(shape).child.isA().userId.equals(otherUserId); default: // group // TODO(#232): syntax like `check(find(…), findsOneWidget)` tester.widget(find.descendant( @@ -204,13 +209,23 @@ void main() { }); group('1:1', () { - testWidgets('has right title/avatar', (tester) async { - final user = eg.user(userId: 1); - final message = eg.dmMessage(from: eg.selfUser, to: [user]); - await setupPage(tester, users: [user], dmMessages: [message]); - - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, user.fullName); + group('has right title/avatar', () { + for (final isUserMuted in [false, true]) { + testWidgets(isUserMuted ? 'muted user' : 'normal user', (tester) async { + final user = eg.user(userId: 1); + final message = eg.dmMessage(from: eg.selfUser, to: [user]); + await setupPage(tester, + users: [user], + mutedUserIds: isUserMuted ? [user.userId] : [], + dmMessages: [message], + ); + + checkAvatar(tester, + DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId), + isMuted: isUserMuted); + checkTitle(tester, isUserMuted ? localizations.mutedUser : user.fullName); + }); + } }); testWidgets('no error when user somehow missing from user store', (tester) async { @@ -258,15 +273,26 @@ void main() { return result; } - testWidgets('has right title/avatar', (tester) async { - final users = usersList(2); - final user0 = users[0]; - final user1 = users[1]; - final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); - await setupPage(tester, users: users, dmMessages: [message]); - - checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); - checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); + group('has right title/avatar', () { + for (final areUsersMuted in [false, true]) { + testWidgets(areUsersMuted ? 'muted users' : 'normal users', (tester) async { + final users = usersList(2); + final user0 = users[0]; + final user1 = users[1]; + final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); + await setupPage( + tester, + users: users, + mutedUserIds: areUsersMuted ? [user0.userId, user1.userId] : [], + dmMessages: [message], + ); + + checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); + checkTitle(tester, areUsersMuted + ? '${localizations.mutedUser}, ${localizations.mutedUser}' + : '${user0.fullName}, ${user1.fullName}'); + }); + } }); testWidgets('no error when one user somehow missing from user store', (tester) async { From ad232adc86cd898785bd40328591fe97bcabb6ac Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Mon, 24 Feb 2025 23:58:17 +0430 Subject: [PATCH 10/14] recent-dms: Exclude DM conversations with muted users Conversations are excluded where all other recipients are muted. --- lib/model/recent_dm_conversations.dart | 11 +++++ lib/model/store.dart | 10 +++-- lib/model/user.dart | 13 ++++++ lib/widgets/recent_dm_conversations.dart | 2 +- .../model/recent_dm_conversations_checks.dart | 1 + test/model/recent_dm_conversations_test.dart | 40 +++++++++++++++++++ .../widgets/recent_dm_conversations_test.dart | 12 ++++++ 7 files changed, 85 insertions(+), 4 deletions(-) diff --git a/lib/model/recent_dm_conversations.dart b/lib/model/recent_dm_conversations.dart index 8428ecdf63..70caf8d6e0 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,6 +18,7 @@ class RecentDmConversationsView extends PerAccountStoreBase with ChangeNotifier factory RecentDmConversationsView({ required CorePerAccountStore core, required List initial, + required UserStore userStore, }) { final entries = initial.map((conversation) => MapEntry( DmNarrow.ofRecentDmConversation(conversation, selfUserId: core.selfUserId), @@ -35,6 +37,7 @@ 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, @@ -43,11 +46,14 @@ class RecentDmConversationsView extends PerAccountStoreBase with ChangeNotifier RecentDmConversationsView._({ required super.core, + required this.userStore, required this.map, required this.sorted, required this.latestMessagesByRecipient, }); + final UserStore userStore; + /// The latest message ID in each conversation. final Map map; @@ -63,6 +69,11 @@ class RecentDmConversationsView extends PerAccountStoreBase with ChangeNotifier /// it might have been sent by anyone in its conversation.) final Map latestMessagesByRecipient; + /// Same as [sorted] but excluding conversations where all the recipients are + /// muted. + QueueList get sortedFiltered => QueueList.from( + sorted.whereNot((narrow) => userStore.ignoreConversation(narrow))); + /// Insert the key at the proper place in [sorted]. /// /// Optimized, taking O(1) time, for the case where that place is the start, diff --git a/lib/model/store.dart b/lib/model/store.dart index 6a25909b29..2a7d1f9f84 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -471,6 +471,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, @@ -496,7 +497,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), ), @@ -508,8 +509,11 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor core: core, channelStore: channels, ), - recentDmConversationsView: RecentDmConversationsView(core: core, - initial: initialSnapshot.recentPrivateConversations), + recentDmConversationsView: RecentDmConversationsView( + core: core, + initial: initialSnapshot.recentPrivateConversations, + userStore: users, + ), recentSenders: RecentSenders(), ); } diff --git a/lib/model/user.dart b/lib/model/user.dart index d8a56ad303..843fb76170 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -4,6 +4,7 @@ import '../api/model/events.dart'; import '../api/model/initial_snapshot.dart'; import '../api/model/model.dart'; import 'localizations.dart'; +import 'narrow.dart'; import 'store.dart'; /// The portion of [PerAccountStore] describing the users in the realm. @@ -78,6 +79,18 @@ mixin UserStore on PerAccountStoreBase { /// 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, {Set? mutedUsers}); + + /// Ignores conversation where all of the users corresponding to + /// [DmNarrow.otherRecipientIds] are muted by [selfUser]. + /// + /// Returns false for self-1:1 conversation. + /// + /// By default, looks for the recipients in [UserStore.mutedUsers] unless + /// [mutedUsers] is non-null, in which case looks in the latter. + bool ignoreConversation(DmNarrow narrow, {Set? mutedUsers}) { + if (narrow.otherRecipientIds.isEmpty) return false; + return !narrow.otherRecipientIds.any((id) => !isUserMuted(id, mutedUsers: mutedUsers)); + } } /// The implementation of [UserStore] that does the work. diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 299d6863cf..44ec153aba 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -49,7 +49,7 @@ class _RecentDmConversationsPageBodyState extends State { Subject> get map => has((v) => v.map, 'map'); Subject> get sorted => has((v) => v.sorted, 'sorted'); + Subject> get sortedFiltered => has((v) => v.sortedFiltered, 'sortedFiltered'); Subject> get latestMessagesByRecipient => has( (v) => v.latestMessagesByRecipient, 'latestMessagesByRecipient'); } diff --git a/test/model/recent_dm_conversations_test.dart b/test/model/recent_dm_conversations_test.dart index 8905460e66..5309b6a975 100644 --- a/test/model/recent_dm_conversations_test.dart +++ b/test/model/recent_dm_conversations_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/recent_dm_conversations.dart'; @@ -24,6 +25,7 @@ void main() { ))).recentDmConversationsView ..map.isEmpty() ..sorted.isEmpty() + ..sortedFiltered.isEmpty() ..latestMessagesByRecipient.isEmpty(); check(eg.store(initialSnapshot: eg.initialSnapshot( @@ -38,7 +40,45 @@ void main() { key([1]): 100, }) ..sorted.deepEquals([key([1, 2]), key([]), key([1])]) + ..sortedFiltered.deepEquals([key([1, 2]), key([]), key([1])]) ..latestMessagesByRecipient.deepEquals({1: 300, 2: 300}); + + check(eg.store(initialSnapshot: eg.initialSnapshot( + recentPrivateConversations: [ + RecentDmConversation(userIds: [], maxMessageId: 200), + RecentDmConversation(userIds: [1], maxMessageId: 100), + RecentDmConversation(userIds: [2], maxMessageId: 400), + RecentDmConversation(userIds: [2, 1], maxMessageId: 300), // userIds out of order + RecentDmConversation(userIds: [1, 3], maxMessageId: 500), + RecentDmConversation(userIds: [2, 4], maxMessageId: 600), + ], + mutedUsers: [ + MutedUserItem(id: 1), + MutedUserItem(id: 3), + ]))).recentDmConversationsView + ..map.deepEquals({ + key([2, 4]): 600, + key([1, 3]): 500, + key([2]): 400, + key([1, 2]): 300, + key([]): 200, + key([1]): 100, + }) + ..sorted.deepEquals([ + key([2, 4]), + key([1, 3]), + key([2]), + key([1, 2]), + key([]), + key([1]), + ]) + ..sortedFiltered.deepEquals([ + key([2, 4]), + key([2]), + key([1, 2]), + key([]), + ]) + ..latestMessagesByRecipient.deepEquals({1: 500, 2: 600, 3: 500, 4: 600}); }); group('message event (new message)', () { diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart index 5ae11177b0..aa22b5d2d3 100644 --- a/test/widgets/recent_dm_conversations_test.dart +++ b/test/widgets/recent_dm_conversations_test.dart @@ -211,6 +211,12 @@ void main() { group('1:1', () { group('has right title/avatar', () { for (final isUserMuted in [false, true]) { + if (isUserMuted) { + // Right now, we don't show DM conversations where all other + // recipients are muted. + continue; + } + testWidgets(isUserMuted ? 'muted user' : 'normal user', (tester) async { final user = eg.user(userId: 1); final message = eg.dmMessage(from: eg.selfUser, to: [user]); @@ -275,6 +281,12 @@ void main() { group('has right title/avatar', () { for (final areUsersMuted in [false, true]) { + if (areUsersMuted) { + // Right now, we don't show DM conversations where all other + // recipients are muted. + continue; + } + testWidgets(areUsersMuted ? 'muted users' : 'normal users', (tester) async { final users = usersList(2); final user0 = users[0]; From 8626061559d53d0f73e08cf2d1f9b727b8492739 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Thu, 22 May 2025 21:50:08 +0430 Subject: [PATCH 11/14] msglist [nfc]: Place `_allMessagesVisible` right after `_messageVisible` This is solely for a better order. --- lib/model/message_list.dart | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index 8022ba8a7d..bc194f2262 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -478,39 +478,39 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } - /// Whether this event could affect the result that [_messageVisible] - /// would ever have returned for any possible message in this message list. - VisibilityEffect _canAffectVisibility(UserTopicEvent event) { + /// Whether [_messageVisible] is true for all possible messages. + /// + /// This is useful for an optimization. + bool get _allMessagesVisible { switch (narrow) { case CombinedFeedNarrow(): - return store.willChangeIfTopicVisible(event); - - case ChannelNarrow(:final streamId): - if (event.streamId != streamId) return VisibilityEffect.none; - return store.willChangeIfTopicVisibleInStream(event); + case ChannelNarrow(): + return false; case TopicNarrow(): case DmNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): - return VisibilityEffect.none; + return true; } } - /// Whether [_messageVisible] is true for all possible messages. - /// - /// This is useful for an optimization. - bool get _allMessagesVisible { + /// Whether this event could affect the result that [_messageVisible] + /// would ever have returned for any possible message in this message list. + VisibilityEffect _canAffectVisibility(UserTopicEvent event) { switch (narrow) { case CombinedFeedNarrow(): - case ChannelNarrow(): - return false; + return store.willChangeIfTopicVisible(event); + + case ChannelNarrow(:final streamId): + if (event.streamId != streamId) return VisibilityEffect.none; + return store.willChangeIfTopicVisibleInStream(event); case TopicNarrow(): case DmNarrow(): case MentionsNarrow(): case StarredMessagesNarrow(): - return true; + return VisibilityEffect.none; } } From 3d20ce10df244532cde4a120b22af8fa9bda6583 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Tue, 20 May 2025 10:38:11 +0430 Subject: [PATCH 12/14] msglist: Exclude DMs with muted users from applicable narrows Direct messages of those conversations are excluded where all of the other recipients are muted. Currently the applicable narrows are: - CombinedFeedNarrow - MentionsNarrow - StarredMessagesNarrow In the future, this will apply to the ReactionsNarrow from the Web app too, once we have it. --- lib/model/message.dart | 6 ++ lib/model/message_list.dart | 61 ++++++++++++- lib/model/store.dart | 2 + lib/model/user.dart | 19 ++++ test/model/message_list_test.dart | 146 +++++++++++++++++++++++++++++- test/model/user_test.dart | 32 +++++++ 6 files changed, 262 insertions(+), 4 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 719d0704f6..e8f335f4ac 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -489,6 +489,12 @@ class MessageStoreImpl extends PerAccountStoreBase with MessageStore, _OutboxMes poll.handleSubmessageEvent(event); } + void handleMutedUsersEvent(MutedUsersEvent event) { + for (final view in _messageListViews) { + view.handleMutedUsersEvent(event); + } + } + /// In debug mode, controls whether outbox messages should be created when /// [sendMessage] is called. /// diff --git a/lib/model/message_list.dart b/lib/model/message_list.dart index bc194f2262..d05803dbad 100644 --- a/lib/model/message_list.dart +++ b/lib/model/message_list.dart @@ -13,6 +13,7 @@ import 'content.dart'; import 'message.dart'; import 'narrow.dart'; import 'store.dart'; +import 'user.dart'; /// The number of messages to fetch in each request. const kMessageListFetchBatchSize = 100; // TODO tune @@ -461,7 +462,10 @@ class MessageListView with ChangeNotifier, _MessageSequence { return switch (message.conversation) { StreamConversation(:final streamId, :final topic) => store.isTopicVisible(streamId, topic), - DmConversation() => true, + DmConversation(:final allRecipientIds) => () { + return !store.ignoreConversation(DmNarrow( + allRecipientIds: allRecipientIds, selfUserId: store.selfUserId)); + }(), }; case ChannelNarrow(:final streamId): @@ -472,8 +476,15 @@ class MessageListView with ChangeNotifier, _MessageSequence { case TopicNarrow(): case DmNarrow(): + return true; + case MentionsNarrow(): + // case ReactionsNarrow(): // TODO case StarredMessagesNarrow(): + if (message.conversation case DmConversation(:final allRecipientIds)) { + return !store.ignoreConversation(DmNarrow( + allRecipientIds: allRecipientIds, selfUserId: store.selfUserId)); + } return true; } } @@ -485,12 +496,13 @@ class MessageListView with ChangeNotifier, _MessageSequence { switch (narrow) { case CombinedFeedNarrow(): case ChannelNarrow(): + case MentionsNarrow(): + // case ReactionsNarrow(): // TODO + case StarredMessagesNarrow(): return false; case TopicNarrow(): case DmNarrow(): - case MentionsNarrow(): - case StarredMessagesNarrow(): return true; } } @@ -514,6 +526,22 @@ class MessageListView with ChangeNotifier, _MessageSequence { } } + /// Whether this event could affect the result that [_messageVisible] + /// would ever have returned for any possible message in this message list. + MutenessEffect? _canAffectMuteness(MutedUsersEvent event) { + switch(narrow) { + case CombinedFeedNarrow(): + case MentionsNarrow(): + case StarredMessagesNarrow(): + return store.willChangeIfRecipientMuted(event); + + case ChannelNarrow(): + case TopicNarrow(): + case DmNarrow(): + return null; + } + } + /// Fetch messages, starting from scratch. Future fetchInitial() async { // TODO(#80): fetch from anchor firstUnread, instead of newest @@ -706,6 +734,33 @@ class MessageListView with ChangeNotifier, _MessageSequence { notifyListeners(); } + void handleMutedUsersEvent(MutedUsersEvent event) { + switch(_canAffectMuteness(event)) { + case MutenessEffect.added: + if (_removeMessagesWhere((message) => + (message is DmMessage + && store.ignoreConversation( + DmNarrow.ofMessage(message, selfUserId: store.selfUserId), + mutedUsers: {...event.mutedUsers.map((e) => e.id)}, + )))) { + notifyListeners(); + } + + case MutenessEffect.removed: + // TODO get the newly-unmuted messages from the message store + // For now, we simplify the task by just refetching this message list + // from scratch. + if (fetched) { + _reset(); + notifyListeners(); + fetchInitial(); + } + + default: + return; + } + } + /// Update data derived from the content of the given message. /// /// This does not notify listeners. diff --git a/lib/model/store.dart b/lib/model/store.dart index 2a7d1f9f84..834ebc5218 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -962,6 +962,8 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor case MutedUsersEvent(): assert(debugLog("server event: muted_users")); + _messages.handleMutedUsersEvent(event); + // Update _users last, so other handlers can compare to the old value. _users.handleMutedUsersEvent(event); notifyListeners(); diff --git a/lib/model/user.dart b/lib/model/user.dart index 843fb76170..3e6cd9a113 100644 --- a/lib/model/user.dart +++ b/lib/model/user.dart @@ -91,6 +91,25 @@ mixin UserStore on PerAccountStoreBase { if (narrow.otherRecipientIds.isEmpty) return false; return !narrow.otherRecipientIds.any((id) => !isUserMuted(id, mutedUsers: mutedUsers)); } + + /// Whether the given event will change the result of [allRecipientsMuted] + /// for its mutedUsers, compared to the current state. + MutenessEffect willChangeIfRecipientMuted(MutedUsersEvent event) { + assert(mutedUsers.length != event.mutedUsers.length); + return mutedUsers.length < event.mutedUsers.length + ? MutenessEffect.added + : MutenessEffect.removed; + } +} + +/// Whether a given [MutedUsersEvent] will affect the results +/// that [UserStore.allRecipientsMuted] would give for some messages. +enum MutenessEffect { + /// A new user is added to the muted users list. + added, + + /// A new user is removed from the muted users list. + removed, } /// The implementation of [UserStore] that does the work. diff --git a/test/model/message_list_test.dart b/test/model/message_list_test.dart index 11f2b3056b..51955b0884 100644 --- a/test/model/message_list_test.dart +++ b/test/model/message_list_test.dart @@ -66,12 +66,18 @@ void main() { void checkNotifiedOnce() => checkNotified(count: 1); /// Initialize [model] and the rest of the test state. - Future prepare({Narrow narrow = const CombinedFeedNarrow()}) async { + Future prepare({ + Narrow narrow = const CombinedFeedNarrow(), + List? users, + List? mutedUserIds, + }) async { final stream = eg.stream(streamId: eg.defaultStreamMessageStreamId); subscription = eg.subscription(stream); store = eg.store(); await store.addStream(stream); await store.addSubscription(subscription); + await store.addUsers([...?users, eg.selfUser]); + await store.muteUsers(mutedUserIds ?? []); connection = store.connection as FakeApiConnection; notifiedCount = 0; model = MessageListView.init(store: store, narrow: narrow) @@ -656,6 +662,144 @@ void main() { }); }); + group('MutedUsersEvent', () { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final user3 = eg.user(userId: 3); + final users = [user1, user2, user3]; + + test('CombinedFeedNarrow', () async { + await prepare(narrow: CombinedFeedNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user1, user2]), + eg.dmMessage(id: 3, from: eg.selfUser, to: [user2, user3]), + eg.dmMessage(id: 4, from: eg.selfUser, to: []), + eg.streamMessage(id: 5), + ]); + checkHasMessageIds([1, 2, 3, 4, 5]); + + await store.muteUser(user1.userId); + checkNotifiedOnce(); + checkHasMessageIds([2, 3, 4, 5]); + + await store.muteUser(user2.userId); + checkNotifiedOnce(); + checkHasMessageIds([3, 4, 5]); + }); + + test('MentionsNarrow', () async { + await prepare(narrow: MentionsNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1], + flags: [MessageFlag.mentioned]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user2], + flags: [MessageFlag.mentioned]), + eg.streamMessage(id: 3, flags: [MessageFlag.mentioned]), + ]); + checkHasMessageIds([1, 2, 3]); + + await store.muteUser(user1.userId); + checkNotifiedOnce(); + checkHasMessageIds([2, 3]); + }); + + test('StarredMessagesNarrow', () async { + await prepare(narrow: StarredMessagesNarrow(), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1], + flags: [MessageFlag.starred]), + eg.dmMessage(id: 2, from: eg.selfUser, to: [user2], + flags: [MessageFlag.starred]), + eg.streamMessage(id: 3, flags: [MessageFlag.starred]), + ]); + checkHasMessageIds([1, 2, 3]); + + await store.muteUser(user1.userId); + checkNotifiedOnce(); + checkHasMessageIds([2, 3]); + }); + + test('ChannelNarrow -> do nothing', () async { + await prepare(narrow: ChannelNarrow(eg.defaultStreamMessageStreamId), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(id: 1), + ]); + checkHasMessageIds([1]); + + await store.muteUser(user1.userId); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('TopicNarrow -> do nothing', () async { + await prepare(narrow: TopicNarrow(eg.defaultStreamMessageStreamId, + TopicName('topic')), users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.streamMessage(id: 1, topic: 'topic'), + ]); + checkHasMessageIds([1]); + + await store.muteUser(user1.userId); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('DmNarrow -> do nothing', () async { + await prepare( + narrow: DmNarrow.withUser(user1.userId, selfUserId: eg.selfUser.userId), + users: users); + await prepareMessages(foundOldest: true, messages: [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + ]); + checkHasMessageIds([1]); + + await store.muteUser(user1.userId); + checkNotNotified(); + checkHasMessageIds([1]); + }); + + test('unmute a user -> refetch from scratch', () => awaitFakeAsync((async) async { + await prepare(narrow: CombinedFeedNarrow(), users: users, + mutedUserIds: [user1.userId]); + final messages = [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.streamMessage(id: 2), + ]; + await prepareMessages(foundOldest: true, messages: messages); + checkHasMessageIds([2]); + + connection.prepare( + json: newestResult(foundOldest: true, messages: messages).toJson()); + await store.unmuteUser(user1.userId); + checkNotifiedOnce(); + check(model).fetched.isFalse(); + checkHasMessageIds([]); + + async.elapse(Duration.zero); + checkNotifiedOnce(); + checkHasMessageIds([1, 2]); + })); + + test('unmute a user before initial fetch completes -> do nothing', () => awaitFakeAsync((async) async { + await prepare(narrow: CombinedFeedNarrow(), users: users, + mutedUserIds: [user1.userId]); + final messages = [ + eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]), + eg.streamMessage(id: 2), + ]; + connection.prepare( + json: newestResult(foundOldest: true, messages: messages).toJson()); + final fetchFuture = model.fetchInitial(); + await store.unmuteUser(user1.userId); + checkNotNotified(); + + await fetchFuture; + checkNotifiedOnce(); + checkHasMessageIds([1, 2]); + })); + }); + group('notifyListenersIfMessagePresent', () { test('message present', () async { await prepare(narrow: const CombinedFeedNarrow()); diff --git a/test/model/user_test.dart b/test/model/user_test.dart index a68b95d779..e9ff7c6c43 100644 --- a/test/model/user_test.dart +++ b/test/model/user_test.dart @@ -2,6 +2,8 @@ import 'package:checks/checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/events.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/model/user.dart'; import '../api/model/model_checks.dart'; import '../example_data.dart' as eg; @@ -53,6 +55,36 @@ void main() { }); }); + group('willChangeIfRecipientMuted', () { + MutedUsersEvent mkEvent(List userIds) => + eg.mutedUsersEvent(userIds); + + void checkChanges(PerAccountStore store, + List userIds, + MutenessEffect expected, + ) { + check(store.willChangeIfRecipientMuted(mkEvent(userIds))).equals(expected); + } + + testWidgets('one muted user, event comes with two users -> added', (tester) async { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final store = eg.store(); + await store.addUsers([user1, user2]); + await store.muteUser(user1.userId); + checkChanges(store, [user1.userId, user2.userId], MutenessEffect.added); + }); + + testWidgets('two muted users, event comes with one user -> removed', (tester) async { + final user1 = eg.user(userId: 1); + final user2 = eg.user(userId: 2); + final store = eg.store(); + await store.addUsers([user1, user2]); + await store.muteUsers([user1.userId, user2.userId]); + checkChanges(store, [user1.userId], MutenessEffect.removed); + }); + }); + group('RealmUserUpdateEvent', () { // TODO write more tests for handling RealmUserUpdateEvent From b98de852e36e420583f72764cb1faa8fb35fb411 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 21 Mar 2025 20:51:08 +0430 Subject: [PATCH 13/14] profile: Distinguish muted users in custom profile fields of type User --- lib/widgets/profile.dart | 8 ++++-- test/widgets/profile_test.dart | 47 +++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) 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/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 30f6433528..6e03778041 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -1,11 +1,15 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; import 'package:zulip/api/model/model.dart'; +import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; +import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/content.dart'; +import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import 'package:zulip/widgets/page.dart'; import 'package:zulip/widgets/profile.dart'; @@ -13,15 +17,19 @@ import 'package:zulip/widgets/profile.dart'; import '../example_data.dart' as eg; import '../model/binding.dart'; import '../model/test_store.dart'; +import '../test_images.dart'; import '../test_navigation.dart'; import 'message_list_checks.dart'; import 'page_checks.dart'; import 'profile_page_checks.dart'; import 'test_app.dart'; +late PerAccountStore store; + Future setupPage(WidgetTester tester, { required int pageUserId, List? users, + List? mutedUserIds, List? customProfileFields, Map? realmDefaultExternalAccounts, NavigatorObserver? navigatorObserver, @@ -32,12 +40,13 @@ Future setupPage(WidgetTester tester, { customProfileFields: customProfileFields, realmDefaultExternalAccounts: realmDefaultExternalAccounts); await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); - final store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); await store.addUser(eg.selfUser); if (users != null) { await store.addUsers(users); } + await store.muteUsers(mutedUserIds ?? []); await tester.pumpWidget(TestZulipApp( accountId: eg.selfAccount.id, @@ -276,6 +285,42 @@ void main() { .deepEquals([1, 2, 3]); }); + testWidgets('page builds; muted user displayed as muted', (tester) async { + Finder avatarFinder(String url) => find.descendant( + of: find.byType(AvatarShape), + matching: find.byWidgetPredicate((widget) => switch(widget) { + RealmContentNetworkImage(:final src) => src == store.realmUrl.resolve(url), + _ => false, + })); + + final users = [ + eg.user(userId: 1, profileData: { + 0: ProfileFieldUserData(value: '[2,3]'), + }), + eg.user(userId: 2, fullName: 'test user2', avatarUrl: '/foo.png'), + eg.user(userId: 3, fullName: 'test user3', avatarUrl: '/bar.png'), + ]; + + prepareBoringImageHttpClient(); + + await setupPage(tester, + users: users, + mutedUserIds: [2], + pageUserId: 1, + customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)], + ); + + final localizations = GlobalLocalizations.zulipLocalizations; + check(find.text(localizations.mutedUser)).findsOne(); + check(find.byIcon(ZulipIcons.person)).findsOne(); + check(avatarFinder('/foo.png')).findsNothing(); + + check(find.text('test user3')).findsOne(); + check(avatarFinder('/bar.png')).findsOne(); + + debugNetworkImageHttpClientProvider = null; + }); + testWidgets('page builds; ensure long name does not overflow', (tester) async { final longString = 'X' * 400; final user = eg.user(userId: 1, fullName: longString); From 50c6764be24df664be118e1b08d2205da8a14ba0 Mon Sep 17 00:00:00 2001 From: Sayed Mahmood Sayedi Date: Fri, 21 Mar 2025 21:17:04 +0430 Subject: [PATCH 14/14] autocomplete: Exclude muted users from user-mention autocomplete Fixes: #296 --- lib/model/autocomplete.dart | 8 ++++++-- test/model/autocomplete_test.dart | 11 ++++++++++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 034199521d..0de5b0995b 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -12,6 +12,7 @@ import 'compose.dart'; import 'emoji.dart'; import 'narrow.dart'; import 'store.dart'; +import 'user.dart'; extension ComposeContentAutocomplete on ComposeContentController { AutocompleteIntent? autocompleteIntent() { @@ -449,6 +450,8 @@ class MentionAutocompleteView extends AutocompleteView store.isUserMuted(user.userId)).toList() + // ..sort(_comparator(store: store, narrow: narrow)); return store.allUsers.toList() ..sort(_comparator(store: store, narrow: narrow)); } @@ -649,7 +652,7 @@ class MentionAutocompleteView extends AutocompleteView