diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 0ef0c3f461..e8dc736bd1 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/eye.svg b/assets/icons/eye.svg
new file mode 100644
index 0000000000..bfc6c2dbed
--- /dev/null
+++ b/assets/icons/eye.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/eye_off.svg b/assets/icons/eye_off.svg
new file mode 100644
index 0000000000..48d942da5d
--- /dev/null
+++ b/assets/icons/eye_off.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/person.svg b/assets/icons/person.svg
new file mode 100644
index 0000000000..6a35686e46
--- /dev/null
+++ b/assets/icons/person.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/icons/user.svg b/assets/icons/two_person.svg
similarity index 100%
rename from assets/icons/user.svg
rename to assets/icons/two_person.svg
diff --git a/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/api/model/events.dart b/lib/api/model/events.dart
index 62789333e1..2904173e81 100644
--- a/lib/api/model/events.dart
+++ b/lib/api/model/events.dart
@@ -62,6 +62,7 @@ sealed class Event {
}
// case 'muted_topics': … // TODO(#422) we ignore this feature on older servers
case 'user_topic': return UserTopicEvent.fromJson(json);
+ case 'muted_users': return MutedUsersEvent.fromJson(json);
case 'message': return MessageEvent.fromJson(json);
case 'update_message': return UpdateMessageEvent.fromJson(json);
case 'delete_message': return DeleteMessageEvent.fromJson(json);
@@ -733,6 +734,24 @@ class UserTopicEvent extends Event {
Map toJson() => _$UserTopicEventToJson(this);
}
+/// A Zulip event of type `muted_users`: https://zulip.com/api/get-events#muted_users
+@JsonSerializable(fieldRename: FieldRename.snake)
+class MutedUsersEvent extends Event {
+ @override
+ @JsonKey(includeToJson: true)
+ String get type => 'muted_users';
+
+ final List mutedUsers;
+
+ MutedUsersEvent({required super.id, required this.mutedUsers});
+
+ factory MutedUsersEvent.fromJson(Map json) =>
+ _$MutedUsersEventFromJson(json);
+
+ @override
+ Map toJson() => _$MutedUsersEventToJson(this);
+}
+
/// A Zulip event of type `message`: https://zulip.com/api/get-events#message
@JsonSerializable(fieldRename: FieldRename.snake)
class MessageEvent extends Event {
diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart
index 94fe288150..350179733f 100644
--- a/lib/api/model/events.g.dart
+++ b/lib/api/model/events.g.dart
@@ -477,6 +477,22 @@ const _$UserTopicVisibilityPolicyEnumMap = {
UserTopicVisibilityPolicy.unknown: null,
};
+MutedUsersEvent _$MutedUsersEventFromJson(Map json) =>
+ MutedUsersEvent(
+ id: (json['id'] as num).toInt(),
+ mutedUsers:
+ (json['muted_users'] as List)
+ .map((e) => MutedUserItem.fromJson(e as Map))
+ .toList(),
+ );
+
+Map _$MutedUsersEventToJson(MutedUsersEvent instance) =>
+ {
+ 'id': instance.id,
+ 'type': instance.type,
+ 'muted_users': instance.mutedUsers,
+ };
+
MessageEvent _$MessageEventFromJson(Map json) => MessageEvent(
id: (json['id'] as num).toInt(),
message: Message.fromJson(
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index f4cc2fe5fc..cb3df052ac 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -44,6 +44,8 @@ class InitialSnapshot {
// final List<…> mutedTopics; // TODO(#422) we ignore this feature on older servers
+ final List mutedUsers;
+
final Map realmEmoji;
final List recentPrivateConversations;
@@ -132,6 +134,7 @@ class InitialSnapshot {
required this.serverTypingStartedExpiryPeriodMilliseconds,
required this.serverTypingStoppedWaitPeriodMilliseconds,
required this.serverTypingStartedWaitPeriodMilliseconds,
+ required this.mutedUsers,
required this.realmEmoji,
required this.recentPrivateConversations,
required this.savedSnippets,
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index 36afb0a39f..b8c5a4b54f 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -38,6 +38,10 @@ InitialSnapshot _$InitialSnapshotFromJson(
(json['server_typing_started_wait_period_milliseconds'] as num?)
?.toInt() ??
10000,
+ mutedUsers:
+ (json['muted_users'] as List)
+ .map((e) => MutedUserItem.fromJson(e as Map))
+ .toList(),
realmEmoji: (json['realm_emoji'] as Map).map(
(k, e) => MapEntry(k, RealmEmojiItem.fromJson(e as Map)),
),
@@ -130,6 +134,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) =>
instance.serverTypingStoppedWaitPeriodMilliseconds,
'server_typing_started_wait_period_milliseconds':
instance.serverTypingStartedWaitPeriodMilliseconds,
+ 'muted_users': instance.mutedUsers,
'realm_emoji': instance.realmEmoji,
'recent_private_conversations': instance.recentPrivateConversations,
'saved_snippets': instance.savedSnippets,
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index 131a51991b..10942fc593 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] or [MutedUsersEvent].
+///
+/// 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/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/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
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,33 @@ 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;
+ }
+ }
+
+ /// Whether [_messageVisible] is true for all possible messages.
+ ///
+ /// This is useful for an optimization.
+ bool get _allMessagesVisible {
+ switch (narrow) {
+ case CombinedFeedNarrow():
+ case ChannelNarrow():
case MentionsNarrow():
+ // case ReactionsNarrow(): // TODO
case StarredMessagesNarrow():
+ return false;
+
+ case TopicNarrow():
+ case DmNarrow():
return true;
}
}
@@ -497,20 +526,19 @@ class MessageListView with ChangeNotifier, _MessageSequence {
}
}
- /// Whether [_messageVisible] is true for all possible messages.
- ///
- /// This is useful for an optimization.
- bool get _allMessagesVisible {
- switch (narrow) {
+ /// 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 ChannelNarrow():
- return false;
+ case MentionsNarrow():
+ case StarredMessagesNarrow():
+ return store.willChangeIfRecipientMuted(event);
+ case ChannelNarrow():
case TopicNarrow():
case DmNarrow():
- case MentionsNarrow():
- case StarredMessagesNarrow():
- return true;
+ return null;
}
}
@@ -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/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 18a09e32ce..834ebc5218 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(),
);
}
@@ -645,6 +649,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;
@@ -949,6 +960,13 @@ class PerAccountStore extends PerAccountStoreBase with ChangeNotifier, EmojiStor
assert(debugLog("server event: reaction/${event.op}"));
_messages.handleReactionEvent(event);
+ case MutedUsersEvent():
+ assert(debugLog("server event: muted_users"));
+ _messages.handleMutedUsersEvent(event);
+ // Update _users last, so other handlers can compare to the old value.
+ _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..3e6cd9a113 100644
--- a/lib/model/user.dart
+++ b/lib/model/user.dart
@@ -1,7 +1,10 @@
+import 'package:flutter/foundation.dart';
+
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.
@@ -66,6 +69,47 @@ 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});
+
+ /// 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));
+ }
+
+ /// 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.
@@ -81,7 +125,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 +136,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 +186,9 @@ class UserStoreImpl extends PerAccountStoreBase with UserStore {
}
}
}
+
+ void handleMutedUsersEvent(MutedUsersEvent event) {
+ mutedUsers.clear();
+ mutedUsers.addAll(_toUserIds(event.mutedUsers));
+ }
}
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/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart
index 0f6d490a97..1a21753e80 100644
--- a/lib/widgets/emoji_reaction.dart
+++ b/lib/widgets/emoji_reaction.dart
@@ -162,7 +162,9 @@ class ReactionChip extends StatelessWidget {
? userIds.map((id) {
return id == store.selfUserId
? zulipLocalizations.reactedEmojiSelfUser
- : store.userDisplayName(id);
+ : store.isUserMuted(id)
+ ? zulipLocalizations.mutedUser
+ : store.userDisplayName(id);
}).join(', ')
: userIds.length.toString();
diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart
index ab5ad446db..454da93e3b 100644
--- a/lib/widgets/home.dart
+++ b/lib/widgets/home.dart
@@ -111,7 +111,7 @@ class _HomePageState extends State {
narrow: const CombinedFeedNarrow()))),
button(_HomePageTab.channels, ZulipIcons.hash_italic),
// TODO(#1094): Users
- button(_HomePageTab.directMessages, ZulipIcons.user),
+ button(_HomePageTab.directMessages, ZulipIcons.two_person),
_NavigationBarButton( icon: ZulipIcons.menu,
selected: false,
onPressed: () => _showMainMenu(context, tabNotifier: _tab)),
@@ -515,7 +515,7 @@ class _DirectMessagesButton extends _NavigationBarMenuButton {
const _DirectMessagesButton({required super.tabNotifier});
@override
- IconData get icon => ZulipIcons.user;
+ IconData get icon => ZulipIcons.two_person;
@override
String label(ZulipLocalizations zulipLocalizations) {
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index 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..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 {
@@ -1303,7 +1308,7 @@ class DmRecipientHeader extends StatelessWidget {
child: Icon(
color: designVariables.title,
size: 16,
- ZulipIcons.user)),
+ ZulipIcons.two_person)),
Expanded(
child: Text(title,
style: recipientHeaderTextStyle(context),
@@ -1414,18 +1419,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 +1453,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 +1493,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 +1596,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 +1615,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 +1748,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/poll.dart b/lib/widgets/poll.dart
index b851c55525..222c002a1b 100644
--- a/lib/widgets/poll.dart
+++ b/lib/widgets/poll.dart
@@ -80,7 +80,10 @@ class _PollWidgetState extends State {
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Zixuan'])
// // 'Chris、Greg、Alya、Zixuan'
final voterNames = option.voters
- .map(store.userDisplayName)
+ .map((userId) =>
+ store.isUserMuted(userId)
+ ? zulipLocalizations.mutedUser
+ : store.userDisplayName(userId))
.join(', ');
return Row(
diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart
index f1328b3367..be8730ada7 100644
--- a/lib/widgets/profile.dart
+++ b/lib/widgets/profile.dart
@@ -263,6 +263,8 @@ class _UserWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final store = PerAccountStoreWidget.of(context);
+ final localizations = ZulipLocalizations.of(context);
+ final isMuted = store.isUserMuted(userId);
return InkWell(
onTap: () => Navigator.push(context,
ProfilePage.buildRoute(context: context,
@@ -271,10 +273,12 @@ class _UserWidget extends StatelessWidget {
padding: const EdgeInsets.all(8),
child: Row(children: [
// TODO(#196) render active status
- Avatar(userId: userId, size: 32, borderRadius: 32 / 8),
+ Avatar(userId: userId, size: 32, borderRadius: 32 / 8,
+ showAsMuted: isMuted),
const SizedBox(width: 8),
Expanded(
- child: Text(store.userDisplayName(userId),
+ child: Text(
+ isMuted ? localizations.mutedUser : store.userDisplayName(userId),
style: _TextStyles.customProfileFieldText)),
])));
}
diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart
index 982dde4f08..44ec153aba 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';
@@ -48,7 +49,7 @@ class _RecentDmConversationsPageBodyState extends State
+ store.isUserMuted(id)
+ ? localizations.mutedUser
+ : store.userDisplayName(id))
.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/example_data.dart b/test/example_data.dart
index b87cbb6dc8..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());
@@ -1019,6 +1024,7 @@ InitialSnapshot initialSnapshot({
int? serverTypingStartedExpiryPeriodMilliseconds,
int? serverTypingStoppedWaitPeriodMilliseconds,
int? serverTypingStartedWaitPeriodMilliseconds,
+ List? mutedUsers,
Map? realmEmoji,
List? recentPrivateConversations,
List? savedSnippets,
@@ -1055,6 +1061,7 @@ InitialSnapshot initialSnapshot({
serverTypingStoppedWaitPeriodMilliseconds ?? 5000,
serverTypingStartedWaitPeriodMilliseconds:
serverTypingStartedWaitPeriodMilliseconds ?? 10000,
+ mutedUsers: mutedUsers ?? [],
realmEmoji: realmEmoji ?? {},
recentPrivateConversations: recentPrivateConversations ?? [],
savedSnippets: savedSnippets ?? [],
diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart
index cab073db48..f1d7c08275 100644
--- a/test/model/autocomplete_test.dart
+++ b/test/model/autocomplete_test.dart
@@ -404,9 +404,12 @@ void main() {
});
group('MentionAutocompleteQuery.testUser', () {
+ final store = eg.store(initialSnapshot: eg.initialSnapshot(
+ mutedUsers: [MutedUserItem(id: 1)]));
+
void doCheck(String rawQuery, User user, bool expected) {
final result = MentionAutocompleteQuery(rawQuery)
- .testUser(user, AutocompleteDataCache());
+ .testUser(user, AutocompleteDataCache(), store);
expected ? check(result).isTrue() : check(result).isFalse();
}
@@ -416,6 +419,12 @@ void main() {
doCheck('Full Name', eg.user(fullName: 'Full Name', isActive: true), true);
});
+ test('user is always excluded when muted, regardless of other criteria', () {
+ doCheck('Full Name', eg.user(userId: 1, fullName: 'Full Name'), false);
+ // When not muted, then other criteria will be checked
+ doCheck('Full Name', eg.user(userId: 2, fullName: 'Full Name'), true);
+ });
+
test('user is included if fullname words match the query', () {
doCheck('', eg.user(fullName: 'Full Name'), true);
doCheck('', eg.user(fullName: ''), true); // Unlikely case, but should not crash
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/recent_dm_conversations_checks.dart b/test/model/recent_dm_conversations_checks.dart
index bbb78593f9..3a4e68dfb6 100644
--- a/test/model/recent_dm_conversations_checks.dart
+++ b/test/model/recent_dm_conversations_checks.dart
@@ -6,6 +6,7 @@ import 'package:zulip/model/recent_dm_conversations.dart';
extension RecentDmConversationsViewChecks on Subject {
Subject