diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 57a301f2e8..15fce5b25a 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/three_person.svg b/assets/icons/three_person.svg
new file mode 100644
index 0000000000..294155cf5a
--- /dev/null
+++ b/assets/icons/three_person.svg
@@ -0,0 +1,8 @@
+
diff --git a/assets/l10n/app_ar.arb b/assets/l10n/app_ar.arb
index 0967ef424b..5ca1208723 100644
--- a/assets/l10n/app_ar.arb
+++ b/assets/l10n/app_ar.arb
@@ -1 +1,11 @@
-{}
+{
+ "wildcardMentionAll": "الجميع",
+ "wildcardMentionEveryone": "الكل",
+ "wildcardMentionChannel": "القناة",
+ "wildcardMentionStream": "الدفق",
+ "wildcardMentionTopic": "الموضوع",
+ "wildcardMentionChannelDescription": "إخطار القناة",
+ "wildcardMentionStreamDescription": "إخطار الدفق",
+ "wildcardMentionAllDmDescription": "إخطار المستلمين",
+ "wildcardMentionTopicDescription": "إخطار الموضوع"
+}
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 58822303fd..1060027553 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -641,6 +641,42 @@
"@manyPeopleTyping": {
"description": "Text to display when there are multiple users typing."
},
+ "wildcardMentionAll": "all",
+ "@wildcardMentionAll": {
+ "description": "Text for \"@all\" wildcard-mention autocomplete option when writing a channel or DM message."
+ },
+ "wildcardMentionEveryone": "everyone",
+ "@wildcardMentionEveryone": {
+ "description": "Text for \"@everyone\" wildcard-mention autocomplete option when writing a channel or DM message."
+ },
+ "wildcardMentionChannel": "channel",
+ "@wildcardMentionChannel": {
+ "description": "Text for \"@channel\" wildcard-mention autocomplete option when writing a channel message."
+ },
+ "wildcardMentionStream": "stream",
+ "@wildcardMentionStream": {
+ "description": "Text for \"@stream\" wildcard-mention autocomplete option when writing a channel message in older servers."
+ },
+ "wildcardMentionTopic": "topic",
+ "@wildcardMentionTopic": {
+ "description": "Text for \"@topic\" wildcard-mention autocomplete option when writing a channel message."
+ },
+ "wildcardMentionChannelDescription": "Notify channel",
+ "@wildcardMentionChannelDescription": {
+ "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message."
+ },
+ "wildcardMentionStreamDescription": "Notify stream",
+ "@wildcardMentionStreamDescription": {
+ "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard-mention autocomplete options when writing a channel message in older servers."
+ },
+ "wildcardMentionAllDmDescription": "Notify recipients",
+ "@wildcardMentionAllDmDescription": {
+ "description": "Description for \"@all\" and \"@everyone\" wildcard-mention autocomplete options when writing a DM message."
+ },
+ "wildcardMentionTopicDescription": "Notify topic",
+ "@wildcardMentionTopicDescription": {
+ "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message."
+ },
"messageIsEditedLabel": "EDITED",
"@messageIsEditedLabel": {
"description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"
diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart
index 0d5e0b50c2..1adc44196f 100644
--- a/lib/api/model/initial_snapshot.dart
+++ b/lib/api/model/initial_snapshot.dart
@@ -64,6 +64,11 @@ class InitialSnapshot {
final List? userTopics; // TODO(server-6)
+ /// The policy for who can use wildcard mentions in large channels.
+ ///
+ /// Search for "realm_wildcard_mention_policy" in https://zulip.com/api/register-queue.
+ final RealmWildcardMentionPolicy realmWildcardMentionPolicy;
+
final bool realmMandatoryTopics;
/// The number of days until a user's account is treated as a full member.
@@ -127,6 +132,7 @@ class InitialSnapshot {
required this.streams,
required this.userSettings,
required this.userTopics,
+ required this.realmWildcardMentionPolicy,
required this.realmMandatoryTopics,
required this.realmWaitingPeriodThreshold,
required this.realmDefaultExternalAccounts,
@@ -151,6 +157,22 @@ enum EmailAddressVisibility {
@JsonValue(5) moderators,
}
+@JsonEnum(valueField: 'apiValue')
+enum RealmWildcardMentionPolicy {
+ everyone(apiValue: 1),
+ members(apiValue: 2),
+ fullMembers(apiValue: 3),
+ admins(apiValue: 5),
+ nobody(apiValue: 6),
+ moderators(apiValue: 7);
+
+ const RealmWildcardMentionPolicy({required this.apiValue});
+
+ final int? apiValue;
+
+ int? toJson() => apiValue;
+}
+
/// An item in `realm_default_external_accounts`.
///
/// For docs, search for "realm_default_external_accounts:"
diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart
index 454282844c..a69b6ebafe 100644
--- a/lib/api/model/initial_snapshot.g.dart
+++ b/lib/api/model/initial_snapshot.g.dart
@@ -58,6 +58,9 @@ InitialSnapshot _$InitialSnapshotFromJson(Map json) =>
userTopics: (json['user_topics'] as List?)
?.map((e) => UserTopicItem.fromJson(e as Map))
.toList(),
+ realmWildcardMentionPolicy: $enumDecode(
+ _$RealmWildcardMentionPolicyEnumMap,
+ json['realm_wildcard_mention_policy']),
realmMandatoryTopics: json['realm_mandatory_topics'] as bool,
realmWaitingPeriodThreshold:
(json['realm_waiting_period_threshold'] as num).toInt(),
@@ -109,6 +112,7 @@ Map _$InitialSnapshotToJson(InitialSnapshot instance) =>
'streams': instance.streams,
'user_settings': instance.userSettings,
'user_topics': instance.userTopics,
+ 'realm_wildcard_mention_policy': instance.realmWildcardMentionPolicy,
'realm_mandatory_topics': instance.realmMandatoryTopics,
'realm_waiting_period_threshold': instance.realmWaitingPeriodThreshold,
'realm_default_external_accounts': instance.realmDefaultExternalAccounts,
@@ -127,6 +131,15 @@ const _$EmailAddressVisibilityEnumMap = {
EmailAddressVisibility.moderators: 5,
};
+const _$RealmWildcardMentionPolicyEnumMap = {
+ RealmWildcardMentionPolicy.everyone: 1,
+ RealmWildcardMentionPolicy.members: 2,
+ RealmWildcardMentionPolicy.fullMembers: 3,
+ RealmWildcardMentionPolicy.admins: 5,
+ RealmWildcardMentionPolicy.nobody: 6,
+ RealmWildcardMentionPolicy.moderators: 7,
+};
+
RealmDefaultExternalAccount _$RealmDefaultExternalAccountFromJson(
Map json) =>
RealmDefaultExternalAccount(
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 6ff41633fd..501eb577bf 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -957,6 +957,60 @@ abstract class ZulipLocalizations {
/// **'Several people are typing…'**
String get manyPeopleTyping;
+ /// Text for "@all" wildcard-mention autocomplete option when writing a channel or DM message.
+ ///
+ /// In en, this message translates to:
+ /// **'all'**
+ String get wildcardMentionAll;
+
+ /// Text for "@everyone" wildcard-mention autocomplete option when writing a channel or DM message.
+ ///
+ /// In en, this message translates to:
+ /// **'everyone'**
+ String get wildcardMentionEveryone;
+
+ /// Text for "@channel" wildcard-mention autocomplete option when writing a channel message.
+ ///
+ /// In en, this message translates to:
+ /// **'channel'**
+ String get wildcardMentionChannel;
+
+ /// Text for "@stream" wildcard-mention autocomplete option when writing a channel message in older servers.
+ ///
+ /// In en, this message translates to:
+ /// **'stream'**
+ String get wildcardMentionStream;
+
+ /// Text for "@topic" wildcard-mention autocomplete option when writing a channel message.
+ ///
+ /// In en, this message translates to:
+ /// **'topic'**
+ String get wildcardMentionTopic;
+
+ /// Description for "@all", "@everyone", "@channel", and "@stream" wildcard-mention autocomplete options when writing a channel message.
+ ///
+ /// In en, this message translates to:
+ /// **'Notify channel'**
+ String get wildcardMentionChannelDescription;
+
+ /// Description for "@all", "@everyone", and "@stream" wildcard-mention autocomplete options when writing a channel message in older servers.
+ ///
+ /// In en, this message translates to:
+ /// **'Notify stream'**
+ String get wildcardMentionStreamDescription;
+
+ /// Description for "@all" and "@everyone" wildcard-mention autocomplete options when writing a DM message.
+ ///
+ /// In en, this message translates to:
+ /// **'Notify recipients'**
+ String get wildcardMentionAllDmDescription;
+
+ /// Description for "@topic" wildcard-mention autocomplete options when writing a channel message.
+ ///
+ /// In en, this message translates to:
+ /// **'Notify topic'**
+ String get wildcardMentionTopicDescription;
+
/// Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)
///
/// 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 542b85031b..721b20ac02 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -508,6 +508,33 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get manyPeopleTyping => 'Several people are typing…';
+ @override
+ String get wildcardMentionAll => 'الجميع';
+
+ @override
+ String get wildcardMentionEveryone => 'الكل';
+
+ @override
+ String get wildcardMentionChannel => 'القناة';
+
+ @override
+ String get wildcardMentionStream => 'الدفق';
+
+ @override
+ String get wildcardMentionTopic => 'الموضوع';
+
+ @override
+ String get wildcardMentionChannelDescription => 'إخطار القناة';
+
+ @override
+ String get wildcardMentionStreamDescription => 'إخطار الدفق';
+
+ @override
+ String get wildcardMentionAllDmDescription => 'إخطار المستلمين';
+
+ @override
+ String get wildcardMentionTopicDescription => 'إخطار الموضوع';
+
@override
String get messageIsEditedLabel => 'EDITED';
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index b6bc9f72e7..6936cfe736 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -508,6 +508,33 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get manyPeopleTyping => 'Several people are typing…';
+ @override
+ String get wildcardMentionAll => 'all';
+
+ @override
+ String get wildcardMentionEveryone => 'everyone';
+
+ @override
+ String get wildcardMentionChannel => 'channel';
+
+ @override
+ String get wildcardMentionStream => 'stream';
+
+ @override
+ String get wildcardMentionTopic => 'topic';
+
+ @override
+ String get wildcardMentionChannelDescription => 'Notify channel';
+
+ @override
+ String get wildcardMentionStreamDescription => 'Notify stream';
+
+ @override
+ String get wildcardMentionAllDmDescription => 'Notify recipients';
+
+ @override
+ String get wildcardMentionTopicDescription => 'Notify topic';
+
@override
String get messageIsEditedLabel => 'EDITED';
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 7adbc9ae8a..c431471645 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -508,6 +508,33 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get manyPeopleTyping => 'Several people are typing…';
+ @override
+ String get wildcardMentionAll => 'all';
+
+ @override
+ String get wildcardMentionEveryone => 'everyone';
+
+ @override
+ String get wildcardMentionChannel => 'channel';
+
+ @override
+ String get wildcardMentionStream => 'stream';
+
+ @override
+ String get wildcardMentionTopic => 'topic';
+
+ @override
+ String get wildcardMentionChannelDescription => 'Notify channel';
+
+ @override
+ String get wildcardMentionStreamDescription => 'Notify stream';
+
+ @override
+ String get wildcardMentionAllDmDescription => 'Notify recipients';
+
+ @override
+ String get wildcardMentionTopicDescription => 'Notify topic';
+
@override
String get messageIsEditedLabel => 'EDITED';
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 99c545f98e..fc530fccaa 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -508,6 +508,33 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get manyPeopleTyping => 'Several people are typing…';
+ @override
+ String get wildcardMentionAll => 'all';
+
+ @override
+ String get wildcardMentionEveryone => 'everyone';
+
+ @override
+ String get wildcardMentionChannel => 'channel';
+
+ @override
+ String get wildcardMentionStream => 'stream';
+
+ @override
+ String get wildcardMentionTopic => 'topic';
+
+ @override
+ String get wildcardMentionChannelDescription => 'Notify channel';
+
+ @override
+ String get wildcardMentionStreamDescription => 'Notify stream';
+
+ @override
+ String get wildcardMentionAllDmDescription => 'Notify recipients';
+
+ @override
+ String get wildcardMentionTopicDescription => 'Notify topic';
+
@override
String get messageIsEditedLabel => 'EDITED';
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index e7a05a58aa..f817d400a8 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -508,6 +508,33 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get manyPeopleTyping => 'Wielu ludzi coś pisze…';
+ @override
+ String get wildcardMentionAll => 'all';
+
+ @override
+ String get wildcardMentionEveryone => 'everyone';
+
+ @override
+ String get wildcardMentionChannel => 'channel';
+
+ @override
+ String get wildcardMentionStream => 'stream';
+
+ @override
+ String get wildcardMentionTopic => 'topic';
+
+ @override
+ String get wildcardMentionChannelDescription => 'Notify channel';
+
+ @override
+ String get wildcardMentionStreamDescription => 'Notify stream';
+
+ @override
+ String get wildcardMentionAllDmDescription => 'Notify recipients';
+
+ @override
+ String get wildcardMentionTopicDescription => 'Notify topic';
+
@override
String get messageIsEditedLabel => 'ZMIENIONO';
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index 2082984588..f6d8f1e41c 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -508,6 +508,33 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
@override
String get manyPeopleTyping => 'Несколько человек набирают сообщения…';
+ @override
+ String get wildcardMentionAll => 'all';
+
+ @override
+ String get wildcardMentionEveryone => 'everyone';
+
+ @override
+ String get wildcardMentionChannel => 'channel';
+
+ @override
+ String get wildcardMentionStream => 'stream';
+
+ @override
+ String get wildcardMentionTopic => 'topic';
+
+ @override
+ String get wildcardMentionChannelDescription => 'Notify channel';
+
+ @override
+ String get wildcardMentionStreamDescription => 'Notify stream';
+
+ @override
+ String get wildcardMentionAllDmDescription => 'Notify recipients';
+
+ @override
+ String get wildcardMentionTopicDescription => 'Notify topic';
+
@override
String get messageIsEditedLabel => 'ИЗМЕНЕНО';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index fabfa06eb4..d6e04126d3 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -508,6 +508,33 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get manyPeopleTyping => 'Niekoľko ludí píše…';
+ @override
+ String get wildcardMentionAll => 'all';
+
+ @override
+ String get wildcardMentionEveryone => 'everyone';
+
+ @override
+ String get wildcardMentionChannel => 'channel';
+
+ @override
+ String get wildcardMentionStream => 'stream';
+
+ @override
+ String get wildcardMentionTopic => 'topic';
+
+ @override
+ String get wildcardMentionChannelDescription => 'Notify channel';
+
+ @override
+ String get wildcardMentionStreamDescription => 'Notify stream';
+
+ @override
+ String get wildcardMentionAllDmDescription => 'Notify recipients';
+
+ @override
+ String get wildcardMentionTopicDescription => 'Notify topic';
+
@override
String get messageIsEditedLabel => 'UPRAVENÉ';
diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart
index cd651bdc65..dffe44d6fe 100644
--- a/lib/model/autocomplete.dart
+++ b/lib/model/autocomplete.dart
@@ -6,7 +6,9 @@ import 'package:flutter/services.dart';
import '../api/model/events.dart';
import '../api/model/model.dart';
import '../api/route/channels.dart';
+import '../generated/l10n/zulip_localizations.dart';
import '../widgets/compose_box.dart';
+import 'compose.dart';
import 'emoji.dart';
import 'narrow.dart';
import 'store.dart';
@@ -417,18 +419,21 @@ class MentionAutocompleteView extends AutocompleteView sortedUsers;
-
- @override
- Future?> computeResults() async {
- final results = [];
- if (await filterCandidates(filter: _testUser,
- candidates: sortedUsers, results: results)) {
- return null;
- }
- return results;
- }
-
- MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) {
- if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
- return UserMentionAutocompleteResult(userId: user.userId);
- }
- return null;
- }
+ final ZulipLocalizations localizations;
static List _usersByRelevance({
required PerAccountStore store,
@@ -509,8 +498,6 @@ class MentionAutocompleteView extends AutocompleteView results,
+ required bool isComposingChannelMessage,
+ }) {
+ if (query.silent) return;
+
+ bool tryOption(WildcardMentionOption option) {
+ if (query.testWildcardOption(option, localizations: localizations)) {
+ results.add(WildcardMentionAutocompleteResult(wildcardOption: option));
+ return true;
+ }
+ return false;
+ }
+
+ // Only one of the (all, everyone, channel, stream) channel wildcards are
+ // shown.
+ all: {
+ if (tryOption(WildcardMentionOption.all)) break all;
+ if (tryOption(WildcardMentionOption.everyone)) break all;
+ if (isComposingChannelMessage) {
+ final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
+ if (isChannelWildcardAvailable && tryOption(WildcardMentionOption.channel)) break all;
+ if (tryOption(WildcardMentionOption.stream)) break all;
+ }
+ }
+
+ final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(server-8)
+ if (isComposingChannelMessage && isTopicWildcardAvailable) {
+ tryOption(WildcardMentionOption.topic);
+ }
+ }
+
+ @override
+ Future?> computeResults() async {
+ final results = [];
+ // Give priority to wildcard mentions.
+ computeWildcardMentionResults(results: results,
+ isComposingChannelMessage: narrow is ChannelNarrow || narrow is TopicNarrow);
+
+ if (await filterCandidates(filter: _testUser,
+ candidates: sortedUsers, results: results)) {
+ return null;
+ }
+ return results;
+ }
+
+ MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) {
+ if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
+ return UserMentionAutocompleteResult(userId: user.userId);
+ }
+ return null;
+ }
+
@override
void dispose() {
store.autocompleteViewManager.unregisterMentionAutocomplete(this);
@@ -642,13 +682,17 @@ class MentionAutocompleteView extends AutocompleteView _lowercaseWords;
+ late final String _lowercase;
+
+ late final List _lowercaseWords;
/// Whether all of this query's words have matches in [words] that appear in order.
///
@@ -679,7 +723,11 @@ abstract class ComposeAutocompleteQuery extends AutocompleteQuery {
/// Construct an [AutocompleteView] initialized with this query
/// and ready to handle queries of the same type.
- ComposeAutocompleteView initViewModel(PerAccountStore store, Narrow narrow);
+ ComposeAutocompleteView initViewModel({
+ required PerAccountStore store,
+ required ZulipLocalizations localizations,
+ required Narrow narrow,
+ });
}
/// A @-mention autocomplete query, used by [MentionAutocompleteView].
@@ -690,13 +738,24 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
final bool silent;
@override
- MentionAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) {
- return MentionAutocompleteView.init(store: store, narrow: narrow, query: this);
+ MentionAutocompleteView initViewModel({
+ required PerAccountStore store,
+ required ZulipLocalizations localizations,
+ required Narrow narrow,
+ }) {
+ return MentionAutocompleteView.init(
+ store: store, localizations: localizations, narrow: narrow, query: this);
+ }
+
+ bool testWildcardOption(WildcardMentionOption wildcardOption, {
+ required ZulipLocalizations localizations}) {
+ // TODO(#237): match insensitively to diacritics
+ return wildcardOption.canonicalString.contains(_lowercase)
+ || wildcardOption.localizedCanonicalString(localizations).contains(_lowercase);
}
bool testUser(User user, AutocompleteDataCache cache) {
// TODO(#236) test email too, not just name
-
if (!user.isActive) return false;
return _testName(user, cache);
@@ -720,6 +779,19 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
int get hashCode => Object.hash('MentionAutocompleteQuery', raw, silent);
}
+extension WildcardMentionOptionExtension on WildcardMentionOption {
+ /// A translation of [canonicalString], from [localizations].
+ String localizedCanonicalString(ZulipLocalizations localizations) {
+ return switch (this) {
+ WildcardMentionOption.all => localizations.wildcardMentionAll,
+ WildcardMentionOption.everyone => localizations.wildcardMentionEveryone,
+ WildcardMentionOption.channel => localizations.wildcardMentionChannel,
+ WildcardMentionOption.stream => localizations.wildcardMentionStream,
+ WildcardMentionOption.topic => localizations.wildcardMentionTopic,
+ };
+ }
+}
+
/// Cached data that is used for autocomplete
/// but kept around in between autocomplete interactions.
///
@@ -788,9 +860,14 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
final int userId;
}
-// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
+/// An autocomplete result for an @-mention of all the users in a conversation.
+class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
+ WildcardMentionAutocompleteResult({required this.wildcardOption});
+
+ final WildcardMentionOption wildcardOption;
+}
-// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
+// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
/// An autocomplete interaction for choosing a topic for a message.
class TopicAutocompleteView extends AutocompleteView {
diff --git a/lib/model/compose.dart b/lib/model/compose.dart
index b59a3efcc7..13b9d59cf5 100644
--- a/lib/model/compose.dart
+++ b/lib/model/compose.dart
@@ -5,6 +5,28 @@ import 'internal_link.dart';
import 'narrow.dart';
import 'store.dart';
+/// The available user wildcard mention options,
+/// known to the server as [canonicalString].
+///
+/// See API docs:
+/// https://zulip.com/api/message-formatting#mentions-and-silent-mentions
+enum WildcardMentionOption {
+ all(canonicalString: 'all'),
+ everyone(canonicalString: 'everyone'),
+ channel(canonicalString: 'channel'),
+ // TODO(server-9): Deprecated in FL 247. Empirically, current servers (FL 339)
+ // still parse "@**stream**" in messages though.
+ stream(canonicalString: 'stream'),
+ topic(canonicalString: 'topic'); // TODO(server-8): New in FL 224.
+
+ const WildcardMentionOption({required this.canonicalString});
+
+ /// The string identifying this option (e.g. "all" as in "@**all**").
+ final String canonicalString;
+
+ String get name => throw UnsupportedError('Use [canonicalString] instead.');
+}
+
//
// Put functions for nontrivial message-content generation in this file.
//
@@ -101,18 +123,42 @@ String wrapWithBacktickFence({required String content, String? infoString}) {
return resultBuffer.toString();
}
-/// An @-mention, like @**Chris Bobbe|13313**.
+/// An @-mention of an individual user, like @**Chris Bobbe|13313**.
///
/// To omit the user ID part ("|13313") whenever the name part is unambiguous,
/// pass a Map of all users we know about. This means accepting a linear scan
/// through all users; avoid it in performance-sensitive codepaths.
-String mention(User user, {bool silent = false, Map? users}) {
+String userMention(User user, {bool silent = false, Map? users}) {
bool includeUserId = users == null
|| users.values.where((u) => u.fullName == user.fullName).take(2).length == 2;
return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**';
}
+/// An @-mention of all the users in a conversation, like @**channel**.
+String wildcardMention(WildcardMentionOption wildcardOption, {
+ required PerAccountStore store,
+}) {
+ final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
+ final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(server-8)
+
+ String name = wildcardOption.canonicalString;
+ switch (wildcardOption) {
+ case WildcardMentionOption.all:
+ case WildcardMentionOption.everyone:
+ break;
+ case WildcardMentionOption.channel:
+ assert(isChannelWildcardAvailable);
+ case WildcardMentionOption.stream:
+ if (isChannelWildcardAvailable) {
+ name = WildcardMentionOption.channel.canonicalString;
+ }
+ case WildcardMentionOption.topic:
+ assert(isTopicWildcardAvailable);
+ }
+ return '@**$name**';
+}
+
/// https://spec.commonmark.org/0.30/#inline-link
///
/// The "link text" is made by enclosing [visibleText] in square brackets.
@@ -145,7 +191,7 @@ String quoteAndReplyPlaceholder(PerAccountStore store, {
SendableNarrow.ofMessage(message, selfUserId: store.selfUserId),
nearMessageId: message.id);
// See note in [quoteAndReply] about asking `mention` to omit the | part.
- return '${mention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
+ return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
'*(loading message ${message.id})*\n'; // TODO(i18n) ?
}
@@ -169,6 +215,6 @@ String quoteAndReply(PerAccountStore store, {
// Could ask `mention` to omit the | part unless the mention is ambiguous…
// but that would mean a linear scan through all users, and the extra noise
// won't much matter with the already probably-long message link in there too.
- return '${mention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(i18n) ?
+ return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(i18n) ?
'${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}';
}
diff --git a/lib/model/emoji.dart b/lib/model/emoji.dart
index 2728600e44..b0ec5f7324 100644
--- a/lib/model/emoji.dart
+++ b/lib/model/emoji.dart
@@ -5,6 +5,7 @@ import '../api/model/events.dart';
import '../api/model/initial_snapshot.dart';
import '../api/model/model.dart';
import '../api/route/realm.dart';
+import '../generated/l10n/zulip_localizations.dart';
import 'algorithms.dart';
import 'autocomplete.dart';
import 'narrow.dart';
@@ -465,7 +466,11 @@ class EmojiAutocompleteQuery extends ComposeAutocompleteQuery {
}
@override
- EmojiAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) {
+ EmojiAutocompleteView initViewModel({
+ required PerAccountStore store,
+ required ZulipLocalizations localizations,
+ required Narrow narrow,
+ }) {
return EmojiAutocompleteView.init(store: store, query: this);
}
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 17629074ac..7603c7f452 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -268,6 +268,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
globalStore: globalStore,
connection: connection,
realmUrl: realmUrl,
+ realmWildcardMentionPolicy: initialSnapshot.realmWildcardMentionPolicy,
realmMandatoryTopics: initialSnapshot.realmMandatoryTopics,
realmWaitingPeriodThreshold: initialSnapshot.realmWaitingPeriodThreshold,
maxFileUploadSizeMib: initialSnapshot.maxFileUploadSizeMib,
@@ -312,6 +313,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
required GlobalStore globalStore,
required this.connection,
required this.realmUrl,
+ required this.realmWildcardMentionPolicy,
required this.realmMandatoryTopics,
required this.realmWaitingPeriodThreshold,
required this.maxFileUploadSizeMib,
@@ -377,6 +379,7 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
Uri? tryResolveUrl(String reference) => _tryResolveUrl(realmUrl, reference);
String get zulipVersion => account.zulipVersion;
+ final RealmWildcardMentionPolicy realmWildcardMentionPolicy; // TODO(#668): update this realm setting
final bool realmMandatoryTopics; // TODO(#668): update this realm setting
/// For docs, please see [InitialSnapshot.realmWaitingPeriodThreshold].
final int realmWaitingPeriodThreshold; // TODO(#668): update this realm setting
diff --git a/lib/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart
index ba921e7f08..40d1f2bf16 100644
--- a/lib/widgets/autocomplete.dart
+++ b/lib/widgets/autocomplete.dart
@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
+import '../generated/l10n/zulip_localizations.dart';
import '../model/emoji.dart';
+import '../model/store.dart';
import 'content.dart';
import 'emoji.dart';
+import 'icons.dart';
import 'store.dart';
import '../model/autocomplete.dart';
import '../model/compose.dart';
@@ -173,7 +176,9 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem(option: option),
+ MentionAutocompleteResult() => _MentionAutocompleteItem(
+ option: option, narrow: narrow),
EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option),
};
return InkWell(
@@ -223,18 +231,47 @@ class ComposeAutocomplete extends AutocompleteField= 247; // TODO(server-9)
+ final localizations = ZulipLocalizations.of(context);
+ final description = switch (wildcardOption) {
+ WildcardMentionOption.all || WildcardMentionOption.everyone => isDmNarrow
+ ? localizations.wildcardMentionAllDmDescription
+ : isChannelWildcardAvailable
+ ? localizations.wildcardMentionChannelDescription
+ : localizations.wildcardMentionStreamDescription,
+ WildcardMentionOption.channel => localizations.wildcardMentionChannelDescription,
+ WildcardMentionOption.stream => isChannelWildcardAvailable
+ ? localizations.wildcardMentionChannelDescription
+ : localizations.wildcardMentionStreamDescription,
+ WildcardMentionOption.topic => localizations.wildcardMentionTopicDescription,
+ };
+ return Text.rich(TextSpan(text: '${wildcardOption.canonicalString} ', children: [
+ TextSpan(text: description, style: TextStyle(fontSize: 12,
+ color: DefaultTextStyle.of(context).style.color?.withValues(alpha: 0.8)))]));
+ }
@override
Widget build(BuildContext context) {
+ final store = PerAccountStoreWidget.of(context);
Widget avatar;
- String label;
+ Widget label;
switch (option) {
case UserMentionAutocompleteResult(:var userId):
- avatar = Avatar(userId: userId, size: 32, borderRadius: 3);
- label = PerAccountStoreWidget.of(context).users[userId]!.fullName;
+ avatar = Avatar(userId: userId, size: 32, borderRadius: 3); // web uses 21px
+ label = Text(store.users[userId]!.fullName);
+ case WildcardMentionAutocompleteResult(:var wildcardOption):
+ avatar = const Icon(ZulipIcons.three_person, size: 29); // web uses 19px
+ label = wildcardLabel(wildcardOption, context: context, store: store);
}
return Padding(
@@ -242,7 +279,7 @@ class _MentionAutocompleteItem extends StatelessWidget {
child: Row(children: [
avatar,
const SizedBox(width: 8),
- Text(label),
+ label,
]));
}
}
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index ba21bec42f..7d58305fb4 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -120,14 +120,17 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "star_filled".
static const IconData star_filled = IconData(0xf120, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "three_person".
+ static const IconData three_person = IconData(0xf121, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf121, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf122, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf122, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf123, fontFamily: "Zulip Icons");
/// The Zulip custom icon "user".
- static const IconData user = IconData(0xf123, fontFamily: "Zulip Icons");
+ static const IconData user = IconData(0xf124, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/test/example_data.dart b/test/example_data.dart
index 2ec9c17dee..6b84bf185c 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -856,6 +856,7 @@ InitialSnapshot initialSnapshot({
List? streams,
UserSettings? userSettings,
List? userTopics,
+ RealmWildcardMentionPolicy? realmWildcardMentionPolicy,
bool? realmMandatoryTopics,
int? realmWaitingPeriodThreshold,
Map? realmDefaultExternalAccounts,
@@ -891,6 +892,7 @@ InitialSnapshot initialSnapshot({
emojiset: Emojiset.google,
),
userTopics: userTopics,
+ realmWildcardMentionPolicy: realmWildcardMentionPolicy ?? RealmWildcardMentionPolicy.everyone,
realmMandatoryTopics: realmMandatoryTopics ?? true,
realmWaitingPeriodThreshold: realmWaitingPeriodThreshold ?? 0,
realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {},
diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart
index 3d680aca0e..da05030493 100644
--- a/test/model/autocomplete_test.dart
+++ b/test/model/autocomplete_test.dart
@@ -7,8 +7,11 @@ import 'package:test/scaffolding.dart';
import 'package:zulip/api/model/initial_snapshot.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/route/channels.dart';
+import 'package:zulip/generated/l10n/zulip_localizations.dart';
import 'package:zulip/model/autocomplete.dart';
+import 'package:zulip/model/compose.dart';
import 'package:zulip/model/emoji.dart';
+import 'package:zulip/model/localizations.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/widgets/compose_box.dart';
@@ -21,6 +24,11 @@ import 'autocomplete_checks.dart';
typedef MarkedTextParse = ({int? expectedSyntaxStart, TextEditingValue value});
+final zulipLocalizations = GlobalLocalizations.zulipLocalizations;
+final zulipLocalizationsArabic =
+ lookupZulipLocalizations(ZulipLocalizations.supportedLocales
+ .firstWhere((locale) => locale.languageCode == 'ar'));
+
void main() {
({int? expectedSyntaxStart, TextEditingValue value}) parseMarkedText(String markedText) {
final TextSelection selection;
@@ -258,8 +266,8 @@ void main() {
final store = eg.store();
await store.addUsers([eg.selfUser, eg.otherUser, eg.thirdUser]);
- final view = MentionAutocompleteView.init(store: store, narrow: narrow,
- query: MentionAutocompleteQuery('Third'));
+ final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
+ narrow: narrow, query: MentionAutocompleteQuery('Third'));
bool done = false;
view.addListener(() { done = true; });
await Future(() {});
@@ -288,8 +296,8 @@ void main() {
check(searchDone).isFalse();
});
- final view = MentionAutocompleteView.init(store: store, narrow: narrow,
- query: MentionAutocompleteQuery('Third'));
+ final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
+ narrow: narrow, query: MentionAutocompleteQuery('Third'));
view.addListener(() {
searchDone = true;
});
@@ -312,8 +320,8 @@ void main() {
}
bool done = false;
- final view = MentionAutocompleteView.init(store: store, narrow: narrow,
- query: MentionAutocompleteQuery('User 2222'));
+ final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
+ narrow: narrow, query: MentionAutocompleteQuery('User 2222'));
view.addListener(() { done = true; });
await Future(() {});
@@ -335,8 +343,8 @@ void main() {
}
bool done = false;
- final view = MentionAutocompleteView.init(store: store, narrow: narrow,
- query: MentionAutocompleteQuery('User 1111'));
+ final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
+ narrow: narrow, query: MentionAutocompleteQuery('User 1111'));
view.addListener(() { done = true; });
await Future(() {});
@@ -370,8 +378,8 @@ void main() {
}
bool done = false;
- final view = MentionAutocompleteView.init(store: store, narrow: narrow,
- query: MentionAutocompleteQuery('User 110'));
+ final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
+ narrow: narrow, query: MentionAutocompleteQuery('User 110'));
view.addListener(() { done = true; });
await Future(() {});
@@ -625,8 +633,8 @@ void main() {
group('ranking across signals', () {
void checkPrecedes(Narrow narrow, User userA, Iterable usersB) {
- final view = MentionAutocompleteView.init(store: store, narrow: narrow,
- query: MentionAutocompleteQuery(''));
+ final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
+ narrow: narrow, query: MentionAutocompleteQuery(''));
for (final userB in usersB) {
check(view.debugCompareUsers(userA, userB)).isLessThan(0);
check(view.debugCompareUsers(userB, userA)).isGreaterThan(0);
@@ -634,8 +642,8 @@ void main() {
}
void checkRankEqual(Narrow narrow, List users) {
- final view = MentionAutocompleteView.init(store: store, narrow: narrow,
- query: MentionAutocompleteQuery(''));
+ final view = MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
+ narrow: narrow, query: MentionAutocompleteQuery(''));
for (int i = 0; i < users.length; i++) {
for (int j = i + 1; j < users.length; j++) {
check(view.debugCompareUsers(users[i], users[j])).equals(0);
@@ -752,43 +760,48 @@ void main() {
test('CombinedFeedNarrow gives error', () async {
await prepare(users: [eg.user(), eg.user()], messages: []);
const narrow = CombinedFeedNarrow();
- check(() => MentionAutocompleteView.init(store: store, narrow: narrow,
- query: MentionAutocompleteQuery('')))
+ check(() => MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
+ narrow: narrow, query: MentionAutocompleteQuery('')))
.throws();
});
test('MentionsNarrow gives error', () async {
await prepare(users: [eg.user(), eg.user()], messages: []);
const narrow = MentionsNarrow();
- check(() => MentionAutocompleteView.init(store: store, narrow: narrow,
- query: MentionAutocompleteQuery('')))
+ check(() => MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
+ narrow: narrow, query: MentionAutocompleteQuery('')))
.throws();
});
test('StarredMessagesNarrow gives error', () async {
await prepare(users: [eg.user(), eg.user()], messages: []);
const narrow = StarredMessagesNarrow();
- check(() => MentionAutocompleteView.init(store: store, narrow: narrow,
- query: MentionAutocompleteQuery('')))
+ check(() => MentionAutocompleteView.init(store: store, localizations: zulipLocalizations,
+ narrow: narrow, query: MentionAutocompleteQuery('')))
.throws();
});
});
test('final results end-to-end', () async {
- Future> getResults(
+ Future> getResults(
Narrow narrow, MentionAutocompleteQuery query) async {
bool done = false;
- final view = MentionAutocompleteView.init(store: store, narrow: narrow,
- query: query);
+ final view = MentionAutocompleteView.init(store: store,
+ localizations: zulipLocalizations, narrow: narrow, query: query);
view.addListener(() { done = true; });
await Future(() {});
check(done).isTrue();
- final results = view.results
- .map((e) => (e as UserMentionAutocompleteResult).userId);
+ final results = view.results;
view.dispose();
return results;
}
+ Iterable getUsersFromResults(Iterable results)
+ => results.map((e) => (e as UserMentionAutocompleteResult).userId);
+
+ Iterable getWildcardOptionsFromResults(Iterable results)
+ => results.map((e) => (e as WildcardMentionAutocompleteResult).wildcardOption);
+
final stream = eg.stream();
const topic = 'topic';
final topicNarrow = eg.topicNarrow(stream.streamId, topic);
@@ -812,20 +825,133 @@ void main() {
RecentDmConversation(userIds: [1, 2], maxMessageId: 100),
]);
- // Check the ranking of the full list of users.
+ // Check the ranking of the full list of mentions.
// The order should be:
- // 1. Users most recent in the current topic/stream.
- // 2. Users most recent in the DM conversations.
- // 3. Human vs. Bot users (human users come first).
- // 4. Alphabetical order by name.
- check(await getResults(topicNarrow, MentionAutocompleteQuery('')))
+ // 1. Wildcards before individual users.
+ // 2. Users most recent in the current topic/stream.
+ // 3. Users most recent in the DM conversations.
+ // 4. Human vs. Bot users (human users come first).
+ // 5. Users by name alphabetical order.
+ final results1 = await getResults(topicNarrow, MentionAutocompleteQuery(''));
+ check(getWildcardOptionsFromResults(results1.take(2)))
+ .deepEquals([WildcardMentionOption.all, WildcardMentionOption.topic]);
+ check(getUsersFromResults(results1.skip(2)))
.deepEquals([1, 5, 4, 2, 7, 3, 6]);
// Check the ranking applies also to results filtered by a query.
- check(await getResults(topicNarrow, MentionAutocompleteQuery('t')))
- .deepEquals([2, 3]);
- check(await getResults(topicNarrow, MentionAutocompleteQuery('f')))
- .deepEquals([5, 4]);
+ final results2 = await getResults(topicNarrow, MentionAutocompleteQuery('t'));
+ check(getWildcardOptionsFromResults(results2.take(2)))
+ .deepEquals([WildcardMentionOption.stream, WildcardMentionOption.topic]);
+ check(getUsersFromResults(results2.skip(2))).deepEquals([2, 3]);
+ final results3 = await getResults(topicNarrow, MentionAutocompleteQuery('f'));
+ check(getWildcardOptionsFromResults(results3.take(0))).deepEquals([]);
+ check(getUsersFromResults(results3.skip(0))).deepEquals([5, 4]);
+ });
+ });
+
+ group('MentionAutocompleteView.computeWildcardMentionResults', () {
+ Iterable getWildcardOptionsFor(String rawQuery, {
+ bool isSilent = false,
+ required Narrow narrow,
+ int? zulipFeatureLevel,
+ ZulipLocalizations? localizations,
+ }) {
+ final store = eg.store(
+ account: eg.account(user: eg.selfUser, zulipFeatureLevel: zulipFeatureLevel),
+ initialSnapshot: eg.initialSnapshot(zulipFeatureLevel: zulipFeatureLevel));
+ localizations ??= zulipLocalizations;
+ final view = MentionAutocompleteView.init(store: store, localizations: localizations,
+ narrow: narrow, query: MentionAutocompleteQuery(rawQuery, silent: isSilent));
+ final results = [];
+ view.computeWildcardMentionResults(results: results,
+ isComposingChannelMessage: narrow is ChannelNarrow
+ || narrow is TopicNarrow);
+ view.dispose();
+ return results.map((e) => (e as WildcardMentionAutocompleteResult).wildcardOption);
+ }
+
+ const channelNarrow = ChannelNarrow(1);
+ const topicNarrow = TopicNarrow(1, TopicName('topic'));
+ final dmNarrow = DmNarrow.withUser(10, selfUserId: 5);
+
+ final testCases = [
+ ('', channelNarrow, [WildcardMentionOption.all, WildcardMentionOption.topic]),
+ ('', topicNarrow, [WildcardMentionOption.all, WildcardMentionOption.topic]),
+ ('', dmNarrow, [WildcardMentionOption.all]),
+
+ ('c', channelNarrow, [WildcardMentionOption.channel, WildcardMentionOption.topic]),
+ ('ch', topicNarrow, [WildcardMentionOption.channel]),
+ ('str', channelNarrow, [WildcardMentionOption.stream]),
+ ('e', topicNarrow, [WildcardMentionOption.everyone]),
+ ('everyone', channelNarrow, [WildcardMentionOption.everyone]),
+ ('t', topicNarrow, [WildcardMentionOption.stream, WildcardMentionOption.topic]),
+ ('topic', channelNarrow, [WildcardMentionOption.topic]),
+ ('topic etc', topicNarrow, []),
+
+ ('a', dmNarrow, [WildcardMentionOption.all]),
+ ('every', dmNarrow, [WildcardMentionOption.everyone]),
+ ('channel', dmNarrow, []),
+ ('stream', dmNarrow, []),
+ ('topic', dmNarrow, []),
+ ];
+
+ for (final (String query, Narrow narrow, List wildcardOptions) in testCases) {
+ test('query "$query" in ${narrow.runtimeType} -> $wildcardOptions', () async {
+ check(getWildcardOptionsFor(query, narrow: narrow)).deepEquals(wildcardOptions);
+ });
+ }
+
+ final localizedTestCases = [
+ ('ال', channelNarrow, [WildcardMentionOption.all, WildcardMentionOption.topic]),
+ ('الجميع', topicNarrow, [WildcardMentionOption.all]),
+ ('الموضوع', channelNarrow, [WildcardMentionOption.topic]),
+ ('ق', topicNarrow, [WildcardMentionOption.channel]),
+ ('دفق', channelNarrow, [WildcardMentionOption.stream]),
+ ('الكل', dmNarrow, [WildcardMentionOption.everyone]),
+
+ ('top', channelNarrow, [WildcardMentionOption.topic]),
+ ('channel', topicNarrow, [WildcardMentionOption.channel]),
+ ('every', dmNarrow, [WildcardMentionOption.everyone]),
+ ];
+
+ for (final (String localizedQuery, Narrow narrow, List wildcardOptions) in localizedTestCases) {
+ test('different locale -> query "$localizedQuery" in ${narrow.runtimeType} -> $wildcardOptions', () async {
+ check(getWildcardOptionsFor(localizedQuery, narrow: narrow,
+ localizations: zulipLocalizationsArabic)).deepEquals(wildcardOptions);
+ });
+ }
+
+ test('no wildcards for a silent mention', () {
+ check(getWildcardOptionsFor('', isSilent: true, narrow: channelNarrow))
+ .isEmpty();
+ check(getWildcardOptionsFor('all', isSilent: true, narrow: topicNarrow))
+ .isEmpty();
+ check(getWildcardOptionsFor('everyone', isSilent: true, narrow: dmNarrow))
+ .isEmpty();
+ });
+
+ test('${WildcardMentionOption.channel} is available FL-247 onwards', () {
+ check(getWildcardOptionsFor('channel',
+ narrow: channelNarrow, zulipFeatureLevel: 247))
+ .deepEquals([WildcardMentionOption.channel]);
+ });
+
+ test('${WildcardMentionOption.channel} is not available before FL-247', () {
+ check(getWildcardOptionsFor('channel',
+ narrow: channelNarrow, zulipFeatureLevel: 246))
+ .deepEquals([]);
+ });
+
+ test('${WildcardMentionOption.topic} is available FL-224 onwards', () {
+ check(getWildcardOptionsFor('topic',
+ narrow: channelNarrow, zulipFeatureLevel: 224))
+ .deepEquals([WildcardMentionOption.topic]);
+ });
+
+ test('${WildcardMentionOption.topic} is not available before FL-224', () {
+ check(getWildcardOptionsFor('topic',
+ narrow: channelNarrow, zulipFeatureLevel: 223))
+ .deepEquals([]);
});
});
diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart
index 9d6387cd5c..ceda0d4cd6 100644
--- a/test/model/compose_test.dart
+++ b/test/model/compose_test.dart
@@ -1,6 +1,7 @@
import 'package:checks/checks.dart';
import 'package:test/scaffolding.dart';
import 'package:zulip/model/compose.dart';
+import 'package:zulip/model/store.dart';
import '../example_data.dart' as eg;
import 'test_store.dart';
@@ -221,27 +222,54 @@ hello
});
group('mention', () {
- final user = eg.user(userId: 123, fullName: 'Full Name');
- test('not silent', () {
- check(mention(user, silent: false)).equals('@**Full Name|123**');
+ group('user', () {
+ final user = eg.user(userId: 123, fullName: 'Full Name');
+ test('not silent', () {
+ check(userMention(user, silent: false)).equals('@**Full Name|123**');
+ });
+ test('silent', () {
+ check(userMention(user, silent: true)).equals('@_**Full Name|123**');
+ });
+ test('`users` passed; has two users with same fullName', () async {
+ final store = eg.store();
+ await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName)]);
+ check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
+ });
+ test('`users` passed; has two same-name users but one of them is deactivated', () async {
+ final store = eg.store();
+ await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName, isActive: false)]);
+ check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
+ });
+ test('`users` passed; user has unique fullName', () async {
+ final store = eg.store();
+ await store.addUsers([user, eg.user(userId: 234, fullName: 'Another Name')]);
+ check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name**');
+ });
});
- test('silent', () {
- check(mention(user, silent: true)).equals('@_**Full Name|123**');
- });
- test('`users` passed; has two users with same fullName', () async {
- final store = eg.store();
- await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName)]);
- check(mention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
- });
- test('`users` passed; has two same-name users but one of them is deactivated', () async {
- final store = eg.store();
- await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName, isActive: false)]);
- check(mention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
- });
- test('`users` passed; user has unique fullName', () async {
- final store = eg.store();
- await store.addUsers([user, eg.user(userId: 234, fullName: 'Another Name')]);
- check(mention(user, silent: true, users: store.users)).equals('@_**Full Name**');
+
+ test('wildcard', () {
+ PerAccountStore store({int? zulipFeatureLevel}) {
+ return eg.store(
+ account: eg.account(user: eg.selfUser,
+ zulipFeatureLevel: zulipFeatureLevel),
+ initialSnapshot: eg.initialSnapshot(
+ zulipFeatureLevel: zulipFeatureLevel));
+ }
+
+ check(wildcardMention(WildcardMentionOption.all, store: store()))
+ .equals('@**all**');
+ check(wildcardMention(WildcardMentionOption.everyone, store: store()))
+ .equals('@**everyone**');
+ check(wildcardMention(WildcardMentionOption.channel, store: store()))
+ .equals('@**channel**');
+ check(wildcardMention(WildcardMentionOption.stream,
+ store: store(zulipFeatureLevel: 247)))
+ .equals('@**channel**');
+ check(wildcardMention(WildcardMentionOption.stream,
+ store: store(zulipFeatureLevel: 246)))
+ .equals('@**stream**');
+ check(wildcardMention(WildcardMentionOption.topic, store: store()))
+ .equals('@**topic**');
});
});
diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart
index 9aa638b7ab..3f3c32bd59 100644
--- a/test/widgets/autocomplete_test.dart
+++ b/test/widgets/autocomplete_test.dart
@@ -13,6 +13,8 @@ import 'package:zulip/model/localizations.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/model/typing_status.dart';
+import 'package:zulip/widgets/compose_box.dart';
+import 'package:zulip/widgets/icons.dart';
import 'package:zulip/widgets/message_list.dart';
import '../api/fake_api.dart';
@@ -34,7 +36,9 @@ import 'test_app.dart';
/// before the end of the test.
Future setupToComposeInput(WidgetTester tester, {
List users = const [],
+ Narrow? narrow,
}) async {
+ assert(narrow is ChannelNarrow? || narrow is SendableNarrow?);
TypingNotifier.debugEnable = false;
addTearDown(TypingNotifier.debugReset);
@@ -45,8 +49,24 @@ Future setupToComposeInput(WidgetTester tester, {
await store.addUsers(users);
final connection = store.connection as FakeApiConnection;
+ narrow ??= DmNarrow(
+ allRecipientIds: [eg.selfUser.userId, eg.otherUser.userId],
+ selfUserId: eg.selfUser.userId);
// prepare message list data
- final message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]);
+ final Message message;
+ switch(narrow) {
+ case DmNarrow():
+ message = eg.dmMessage(from: eg.selfUser, to: [eg.otherUser]);
+ case ChannelNarrow(:final streamId):
+ final stream = eg.stream(streamId: streamId);
+ message = eg.streamMessage(stream: stream);
+ await store.addStream(stream);
+ case TopicNarrow(:final streamId, :final topic):
+ final stream = eg.stream(streamId: streamId);
+ message = eg.streamMessage(stream: stream, topic: topic.apiName);
+ await store.addStream(stream);
+ default: throw StateError('unexpected narrow type');
+ }
connection.prepare(json: GetMessagesResult(
anchor: message.id,
foundNewest: true,
@@ -59,15 +79,13 @@ Future setupToComposeInput(WidgetTester tester, {
prepareBoringImageHttpClient();
await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
- child: MessageListPage(initNarrow: DmNarrow(
- allRecipientIds: [eg.selfUser.userId, eg.otherUser.userId],
- selfUserId: eg.selfUser.userId))));
+ child: MessageListPage(initNarrow: narrow)));
// global store, per-account store, and message list get loaded
await tester.pumpAndSettle();
- // (hint text of compose input in a 1:1 DM)
- final finder = find.widgetWithText(TextField, 'Message @${eg.otherUser.fullName}');
+ final finder = find.byWidgetPredicate((widget) => widget is TextField
+ && widget.controller is ComposeContentController);
check(finder.evaluate()).isNotEmpty();
return finder;
}
@@ -134,7 +152,7 @@ void main() {
check(avatarFinder.evaluate().length).equals(expected ? 1 : 0);
}
- testWidgets('options appear, disappear, and change correctly', (tester) async {
+ testWidgets('user options appear, disappear, and change correctly', (tester) async {
final user1 = eg.user(userId: 1, fullName: 'User One', avatarUrl: 'user1.png');
final user2 = eg.user(userId: 2, fullName: 'User Two', avatarUrl: 'user2.png');
final user3 = eg.user(userId: 3, fullName: 'User Three', avatarUrl: 'user3.png');
@@ -156,7 +174,7 @@ void main() {
await tester.tap(find.text('User Three'));
await tester.pump();
check(tester.widget(composeInputFinder).controller!.text)
- .contains(mention(user3, users: store.users));
+ .contains(userMention(user3, users: store.users));
checkUserShown(user1, store, expected: false);
checkUserShown(user2, store, expected: false);
checkUserShown(user3, store, expected: false);
@@ -178,6 +196,46 @@ void main() {
debugNetworkImageHttpClientProvider = null;
});
+
+ void checkWildcardShown(WildcardMentionOption wildcard, {required bool expected}) {
+ final richTextFinder = find.textContaining(wildcard.canonicalString, findRichText: true);
+ final iconFinder = find.byIcon(ZulipIcons.three_person);
+ final wildcardItemFinder = find.ancestor(of: richTextFinder,
+ matching: find.ancestor(of: iconFinder, matching: find.byType(Row)));
+ check(wildcardItemFinder.evaluate().length).equals(expected ? 1 : 0);
+ }
+
+ testWidgets('wildcard options appear, disappear, and change correctly', (tester) async {
+ final composeInputFinder = await setupToComposeInput(tester,
+ narrow: const ChannelNarrow(1));
+ final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+
+ // Options are filtered correctly for query
+ // TODO(#226): Remove this extra edit when this bug is fixed.
+ await tester.enterText(composeInputFinder, 'hello @');
+ await tester.enterText(composeInputFinder, 'hello @c');
+ await tester.pumpAndSettle(); // async computation; options appear
+
+ checkWildcardShown(WildcardMentionOption.channel, expected: true);
+ checkWildcardShown(WildcardMentionOption.topic, expected: true);
+ checkWildcardShown(WildcardMentionOption.all, expected: false);
+ checkWildcardShown(WildcardMentionOption.everyone, expected: false);
+ checkWildcardShown(WildcardMentionOption.stream, expected: false);
+
+ // Finishing autocomplete updates compose box; causes options to disappear
+ await tester.tap(find.textContaining(WildcardMentionOption.channel.canonicalString,
+ findRichText: true));
+ await tester.pump();
+ check(tester.widget(composeInputFinder).controller!.text)
+ .contains(wildcardMention(WildcardMentionOption.channel, store: store));
+ checkWildcardShown(WildcardMentionOption.channel, expected: false);
+ checkWildcardShown(WildcardMentionOption.topic, expected: false);
+ checkWildcardShown(WildcardMentionOption.all, expected: false);
+ checkWildcardShown(WildcardMentionOption.everyone, expected: false);
+ checkWildcardShown(WildcardMentionOption.stream, expected: false);
+
+ debugNetworkImageHttpClientProvider = null;
+ });
});
group('emoji', () {