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/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 74ff506988..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; + final ZulipLocalizations localizations; static List _usersByRelevance({ required PerAccountStore store, @@ -492,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; @@ -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/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/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 df7313b6d6..3f3c32bd59 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -14,6 +14,7 @@ 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'; @@ -151,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'); @@ -173,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); @@ -195,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', () {