diff --git a/assets/l10n/app_ar.arb b/assets/l10n/app_ar.arb index 0967ef424bc..5ca12087231 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 58822303fdb..10600275530 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/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index 6ff41633fd6..501eb577bfa 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 542b85031bc..721b20ac02c 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 b6bc9f72e7f..6936cfe7364 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 7adbc9ae8ad..c4314716454 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 99c545f98e2..fc530fccaaa 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 e7a05a58aae..f817d400a8e 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 20829845886..f6d8f1e41c4 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 fabfa06eb4f..d6e04126d37 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 74ff5069889..15f41d04916 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -6,13 +6,15 @@ 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'; extension ComposeContentAutocomplete on ComposeContentController { - AutocompleteIntent? autocompleteIntent() { + AutocompleteIntent? autocompleteIntent(ZulipLocalizations localizations) { if (!selection.isValid || !selection.isNormalized) { // We don't require [isCollapsed] to be true because we've seen that // autocorrect and even backspace involve programmatically expanding the @@ -55,7 +57,8 @@ extension ComposeContentAutocomplete on ComposeContentController { if (charAtPos == '@') { final match = _mentionIntentRegex.matchAsPrefix(textUntilCursor, pos); if (match == null) continue; - query = MentionAutocompleteQuery(match[2]!, silent: match[1]! == '_'); + query = MentionAutocompleteQuery(match[2]!, + silent: match[1]! == '_', localizations: localizations); } else if (charAtPos == ':') { final match = _emojiIntentRegex.matchAsPrefix(textUntilCursor, pos); if (match == null) continue; @@ -423,8 +426,8 @@ class MentionAutocompleteView extends AutocompleteView computeWildcardMentionResults({ + required bool isComposingChannelMessage}) { + if (query.silent) return []; + + final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9) + + final wildcardMentions = []; + // Only one of the (all, everyone, channel, stream) channel wildcards are + // shown. + if (query.testWildcardOption(WildcardMentionOption.all)) { + wildcardMentions.add(WildcardMentionAutocompleteResult( + wildcard: WildcardMentionOption.all)); + } else if (query.testWildcardOption(WildcardMentionOption.everyone)) { + wildcardMentions.add(WildcardMentionAutocompleteResult( + wildcard: WildcardMentionOption.everyone)); + } else if (isComposingChannelMessage) { + if (query.testWildcardOption(WildcardMentionOption.channel) && isChannelWildcardAvailable) { + wildcardMentions.add(WildcardMentionAutocompleteResult( + wildcard: WildcardMentionOption.channel)); + } else if (query.testWildcardOption(WildcardMentionOption.stream)) { + wildcardMentions.add(WildcardMentionAutocompleteResult( + wildcard: WildcardMentionOption.stream)); + } + } + + final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(sever-8) + if (isComposingChannelMessage + && isTopicWildcardAvailable + && query.testWildcardOption(WildcardMentionOption.topic)) { + wildcardMentions.add(WildcardMentionAutocompleteResult( + wildcard: WildcardMentionOption.topic)); + } + return wildcardMentions; + } + @override Future?> computeResults() async { final results = []; + // Give priority to wildcard mentions. + results.addAll(computeWildcardMentionResults(isComposingChannelMessage: + narrow is ChannelNarrow || narrow is TopicNarrow)); + if (await filterCandidates(filter: _testUser, candidates: sortedUsers, results: results)) { return null; @@ -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. /// @@ -684,19 +728,26 @@ abstract class ComposeAutocompleteQuery extends AutocompleteQuery { /// A @-mention autocomplete query, used by [MentionAutocompleteView]. class MentionAutocompleteQuery extends ComposeAutocompleteQuery { - MentionAutocompleteQuery(super.raw, {this.silent = false}); + MentionAutocompleteQuery(super.raw, {this.silent = false, required this.localizations}); /// Whether the user wants a silent mention (@_query, vs. @query). final bool silent; + final ZulipLocalizations localizations; + @override MentionAutocompleteView initViewModel(PerAccountStore store, Narrow narrow) { return MentionAutocompleteView.init(store: store, narrow: narrow, query: this); } + bool testWildcardOption(WildcardMentionOption wildcardOption) { + // 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 +771,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 +852,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.wildcard}); -// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult { + final WildcardMentionOption wildcard; +} + +// 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 b59a3efcc7b..e12480e144d 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -5,6 +5,31 @@ import 'internal_link.dart'; import 'narrow.dart'; import 'store.dart'; +/// The available user wildcard mention options, +/// known to the server as [canonicalString]. +/// +/// There isn't an API doc for these yet, but see the values in the Help Center: +/// https://zulip.com/help/mention-a-user-or-group +/// https://zulip.com/help/dm-mention-alert-notifications +enum WildcardMentionOption { + all(canonicalString: 'all'), + everyone(canonicalString: 'everyone'), + channel(canonicalString: 'channel'), + // Deprecated in FL 247. Empirically, current servers (FL 330) + // still parse "@**stream**" in messages though. + stream(canonicalString: 'stream'), + topic(canonicalString: 'topic'); // New in FL 224. + + const WildcardMentionOption({required this.canonicalString}); + + /// The string identifying this option (e.g. "all" as in "@**all**"). + // If servers want to localize this, we can compute it + // dynamically from server data instead of hard-coding it. + final String canonicalString; + + String get name => throw UnsupportedError('Use [canonicalString] instead.'); +} + // // Put functions for nontrivial message-content generation in this file. // @@ -101,18 +126,33 @@ 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) + assert(isChannelWildcardAvailable || wildcardOption != WildcardMentionOption.channel); + final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(sever-8) + assert(isTopicWildcardAvailable || wildcardOption != WildcardMentionOption.topic); + + final name = wildcardOption == WildcardMentionOption.stream && isChannelWildcardAvailable + ? WildcardMentionOption.channel.canonicalString + : wildcardOption.canonicalString; + return '@**$name**'; +} + /// https://spec.commonmark.org/0.30/#inline-link /// /// The "link text" is made by enclosing [visibleText] in square brackets. @@ -145,7 +185,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 +209,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/widgets/autocomplete.dart b/lib/widgets/autocomplete.dart index ba921e7f08c..903076c8cdd 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'; @@ -21,7 +24,7 @@ abstract class AutocompleteField? autocompleteIntent(); + AutocompleteIntent? autocompleteIntent(BuildContext context); Widget buildItem(BuildContext context, int index, ResultT option); @@ -40,7 +43,7 @@ class _AutocompleteFieldState super.controller as ComposeContentController; @override - AutocompleteIntent? autocompleteIntent() => controller.autocompleteIntent(); + AutocompleteIntent? autocompleteIntent( + BuildContext context) => controller.autocompleteIntent(ZulipLocalizations.of(context)); @override ComposeAutocompleteView initViewModel(BuildContext context, ComposeAutocompleteQuery query) { @@ -180,7 +184,7 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem(option: option), + MentionAutocompleteResult() => _MentionAutocompleteItem( + option: option, narrow: narrow), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; return InkWell( @@ -223,18 +230,23 @@ class ComposeAutocomplete extends AutocompleteField= 247; // TODO(server-9) + final localizations = ZulipLocalizations.of(context); + final description = switch (wildcard) { + 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: '${wildcard.canonicalString} ', children: [ + TextSpan(text: description, style: TextStyle(fontSize: 12, + color: Colors.black.withValues(alpha: 0.8)))])); + } } class _EmojiAutocompleteItem extends StatelessWidget { @@ -310,7 +346,8 @@ class TopicAutocomplete extends AutocompleteField super.controller as ComposeTopicController; @override - AutocompleteIntent? autocompleteIntent() => controller.autocompleteIntent(); + AutocompleteIntent? autocompleteIntent( + BuildContext context) => controller.autocompleteIntent(); @override TopicAutocompleteView initViewModel(BuildContext context, TopicAutocompleteQuery query) { @@ -319,7 +356,7 @@ class TopicAutocomplete extends AutocompleteField { - Subject?> get autocompleteIntent => has((c) => c.autocompleteIntent(), 'autocompleteIntent'); + Subject?> get autocompleteIntent => has((c) => c.autocompleteIntent(GlobalLocalizations.zulipLocalizations), 'autocompleteIntent'); } extension ComposeTopicControllerChecks on Subject { diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index d6ed9574e04..1d5d832d032 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -8,7 +8,9 @@ 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/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 +23,8 @@ import 'autocomplete_checks.dart'; typedef MarkedTextParse = ({int? expectedSyntaxStart, TextEditingValue value}); +final localizations = GlobalLocalizations.zulipLocalizations; + void main() { ({int? expectedSyntaxStart, TextEditingValue value}) parseMarkedText(String markedText) { final TextSelection selection; @@ -88,8 +92,8 @@ void main() { }); } - MentionAutocompleteQuery mention(String raw) => MentionAutocompleteQuery(raw, silent: false); - MentionAutocompleteQuery silentMention(String raw) => MentionAutocompleteQuery(raw, silent: true); + MentionAutocompleteQuery mention(String raw) => MentionAutocompleteQuery(raw, silent: false, localizations: localizations); + MentionAutocompleteQuery silentMention(String raw) => MentionAutocompleteQuery(raw, silent: true, localizations: localizations); EmojiAutocompleteQuery emoji(String raw) => EmojiAutocompleteQuery(raw); doTest('', null); @@ -259,7 +263,7 @@ void main() { await store.addUsers([eg.selfUser, eg.otherUser, eg.thirdUser]); final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('Third')); + query: MentionAutocompleteQuery('Third', localizations: localizations)); bool done = false; view.addListener(() { done = true; }); await Future(() {}); @@ -289,7 +293,7 @@ void main() { }); final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('Third')); + query: MentionAutocompleteQuery('Third', localizations: localizations)); view.addListener(() { searchDone = true; }); @@ -313,7 +317,7 @@ void main() { bool done = false; final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('User 2222')); + query: MentionAutocompleteQuery('User 2222', localizations: localizations)); view.addListener(() { done = true; }); await Future(() {}); @@ -336,12 +340,12 @@ void main() { bool done = false; final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('User 1111')); + query: MentionAutocompleteQuery('User 1111', localizations: localizations)); view.addListener(() { done = true; }); await Future(() {}); check(done).isFalse(); - view.query = MentionAutocompleteQuery('User 234'); + view.query = MentionAutocompleteQuery('User 234', localizations: localizations); // …new query goes through all batches await Future(() {}); @@ -371,7 +375,7 @@ void main() { bool done = false; final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('User 110')); + query: MentionAutocompleteQuery('User 110', localizations: localizations)); view.addListener(() { done = true; }); await Future(() {}); @@ -395,7 +399,7 @@ void main() { group('MentionAutocompleteQuery.testUser', () { void doCheck(String rawQuery, User user, bool expected) { - final result = MentionAutocompleteQuery(rawQuery) + final result = MentionAutocompleteQuery(rawQuery, localizations: localizations) .testUser(user, AutocompleteDataCache()); expected ? check(result).isTrue() : check(result).isFalse(); } @@ -438,7 +442,7 @@ void main() { }); }); - group('MentionAutocompleteView sorting users results', () { + group('MentionAutocompleteView sorting mention results', () { late PerAccountStore store; Future prepare({ @@ -626,7 +630,7 @@ void main() { group('ranking across signals', () { void checkPrecedes(Narrow narrow, User userA, Iterable usersB) { final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('')); + query: MentionAutocompleteQuery('', localizations: localizations)); for (final userB in usersB) { check(view.debugCompareUsers(userA, userB)).isLessThan(0); check(view.debugCompareUsers(userB, userA)).isGreaterThan(0); @@ -635,7 +639,7 @@ void main() { void checkRankEqual(Narrow narrow, List users) { final view = MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery('')); + query: MentionAutocompleteQuery('', localizations: localizations)); 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); @@ -753,7 +757,7 @@ void main() { await prepare(users: [eg.user(), eg.user()], messages: []); const narrow = CombinedFeedNarrow(); check(() => MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery(''))) + query: MentionAutocompleteQuery('', localizations: localizations))) .throws(); }); @@ -761,7 +765,7 @@ void main() { await prepare(users: [eg.user(), eg.user()], messages: []); const narrow = MentionsNarrow(); check(() => MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery(''))) + query: MentionAutocompleteQuery('', localizations: localizations))) .throws(); }); @@ -769,13 +773,94 @@ void main() { await prepare(users: [eg.user(), eg.user()], messages: []); const narrow = StarredMessagesNarrow(); check(() => MentionAutocompleteView.init(store: store, narrow: narrow, - query: MentionAutocompleteQuery(''))) + query: MentionAutocompleteQuery('', localizations: localizations))) .throws(); }); }); + group('computeWildcardMentionResults', () { + Iterable getWildcardsFor(String rawQuery, { + bool isSilent = false, + required Narrow narrow, + int? zulipFeatureLevel, + }) { + final store = eg.store( + account: eg.account(user: eg.selfUser, zulipFeatureLevel: zulipFeatureLevel), + initialSnapshot: eg.initialSnapshot(zulipFeatureLevel: zulipFeatureLevel)); + final view = MentionAutocompleteView.init(store: store, narrow: narrow, + query: MentionAutocompleteQuery(rawQuery, silent: isSilent, localizations: localizations)); + final results = view.computeWildcardMentionResults( + isComposingChannelMessage: narrow is ChannelNarrow + || narrow is TopicNarrow + ).map((e) => e.wildcard); + view.dispose(); + return results; + } + + 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 wildcards) in testCases) { + test('for "$query" query in ${narrow.runtimeType} narrow wildcards are $wildcards', () async { + check(getWildcardsFor(query, narrow: narrow)).deepEquals(wildcards); + }); + } + + test('no wildcards for a silent mention', () { + check(getWildcardsFor('', isSilent: true, narrow: channelNarrow)).isEmpty(); + check(getWildcardsFor('all', isSilent: true, narrow: topicNarrow)).isEmpty(); + check(getWildcardsFor('everyone', isSilent: true, narrow: dmNarrow)).isEmpty(); + }); + + test('${WildcardMentionOption.channel} is available FL-247 onwards', () { + check(getWildcardsFor('channel', + narrow: channelNarrow, zulipFeatureLevel: 247)) + .deepEquals([WildcardMentionOption.channel]); + }); + + test('${WildcardMentionOption.channel} is not available before FL-247', () { + check(getWildcardsFor('channel', + narrow: channelNarrow, zulipFeatureLevel: 246)) + .deepEquals([]); + }); + + test('${WildcardMentionOption.topic} is available FL-224 onwards', () { + check(getWildcardsFor('topic', + narrow: channelNarrow, zulipFeatureLevel: 224)) + .deepEquals([WildcardMentionOption.topic]); + }); + + test('${WildcardMentionOption.topic} is not available before FL-224', () { + check(getWildcardsFor('topic', + narrow: channelNarrow, zulipFeatureLevel: 223)) + .deepEquals([]); + }); + }); + 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, @@ -783,12 +868,17 @@ void main() { 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 getWildcardsFromResults(Iterable results) + => results.map((e) => (e as WildcardMentionAutocompleteResult).wildcard); + final stream = eg.stream(); const topic = 'topic'; final topicNarrow = eg.topicNarrow(stream.streamId, topic); @@ -812,20 +902,27 @@ 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. + // 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('', localizations: localizations)); + check(getWildcardsFromResults(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', localizations: localizations)); + check(getWildcardsFromResults(results2.take(2))) + .deepEquals([WildcardMentionOption.stream, WildcardMentionOption.topic]); + check(getUsersFromResults(results2.skip(2))).deepEquals([2, 3]); + final results3 = await getResults(topicNarrow, MentionAutocompleteQuery('f', localizations: localizations)); + check(getWildcardsFromResults(results3.take(0))).deepEquals([]); + check(getUsersFromResults(results3.skip(0))).deepEquals([5, 4]); }); }); diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 9d6387cd5c6..ceda0d4cd6e 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 a49fb41bded..a4b9cda0935 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -13,6 +13,7 @@ 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/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import '../api/fake_api.dart'; @@ -177,7 +178,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'); @@ -199,7 +200,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); @@ -221,6 +222,69 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + void checkWildcardShown(WildcardMentionOption wildcard, PerAccountStore store, { + 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, store, expected: true); + checkWildcardShown(WildcardMentionOption.topic, store, expected: true); + checkWildcardShown(WildcardMentionOption.all, store, expected: false); + checkWildcardShown(WildcardMentionOption.everyone, store, expected: false); + checkWildcardShown(WildcardMentionOption.stream, store, 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, store, expected: false); + checkWildcardShown(WildcardMentionOption.topic, store, expected: false); + checkWildcardShown(WildcardMentionOption.all, store, expected: false); + checkWildcardShown(WildcardMentionOption.everyone, store, expected: false); + checkWildcardShown(WildcardMentionOption.stream, store, expected: false); + + // Then a new autocomplete intent brings up options again + // TODO(#226): Remove this extra edit when this bug is fixed. + await tester.enterText(composeInputFinder, 'hello @chan'); + await tester.enterText(composeInputFinder, 'hello @channel'); + await tester.pumpAndSettle(); // async computation; options appear + checkWildcardShown(WildcardMentionOption.channel, store, expected: true); + checkWildcardShown(WildcardMentionOption.topic, store, expected: false); + checkWildcardShown(WildcardMentionOption.all, store, expected: false); + checkWildcardShown(WildcardMentionOption.everyone, store, expected: false); + checkWildcardShown(WildcardMentionOption.stream, store, expected: false); + + // Removing autocomplete intent causes options to disappear + // TODO(#226): Remove one of these edits when this bug is fixed. + await tester.enterText(composeInputFinder, ''); + await tester.enterText(composeInputFinder, ' '); + checkWildcardShown(WildcardMentionOption.channel, store, expected: false); + checkWildcardShown(WildcardMentionOption.topic, store, expected: false); + checkWildcardShown(WildcardMentionOption.all, store, expected: false); + checkWildcardShown(WildcardMentionOption.everyone, store, expected: false); + checkWildcardShown(WildcardMentionOption.stream, store, expected: false); + + debugNetworkImageHttpClientProvider = null; + }); }); group('emoji', () {