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', () {