diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 7c8d4981c7..0a92bd4545 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -533,5 +533,40 @@ "manyPeopleTyping": "Several people are typing…", "@manyPeopleTyping": { "description": "Text to display when there are multiple users typing." + }, + "all": "all", + "@all": { + "description": "Text for \"@all\" wildcard mention." + }, + "everyone": "everyone", + "@everyone": { + "description": "Text for \"@everyone\" wildcard mention." + }, + "channel": "channel", + "@channel": { + "description": "Text for \"@channel\" wildcard mention." + }, + "stream": "stream", + "@stream": { + "description": "Text for \"@stream\" wildcard mention." + }, + "topic": "topic", + "@topic": { + "description": "Text for \"@topic\" wildcard mention." + }, + "notifyChannel": "Notify {value, select, channel{channel} other{stream}}", + "@notifyChannel": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard mentions in a channel or topic narrow.", + "placeholders": { + "value": {"type": "String"} + } + }, + "notifyRecipients": "Notify recipients", + "@notifyRecipients": { + "description": "Description for \"@all\" and \"@everyone\" wildcard mentions in a DM narrow." + }, + "notifyTopic": "Notify topic", + "@notifyTopic": { + "description": "Description for \"@topic\" wildcard mention in a channel or topic narrow." } } diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 82186e6d8a..47234051be 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -187,7 +187,7 @@ class AutocompleteViewManager { /// * On reassemble, call [reassemble]. /// * When the object will no longer be used, call [dispose] to free /// resources on the [PerAccountStore]. -abstract class AutocompleteView extends ChangeNotifier { +abstract class AutocompleteView extends ChangeNotifier { AutocompleteView({required this.store}); final PerAccountStore store; @@ -284,20 +284,23 @@ abstract class AutocompleteView { +class MentionAutocompleteView extends AutocompleteView { MentionAutocompleteView._({ required super.store, required this.narrow, + required this.wildcards, required this.sortedUsers, }); factory MentionAutocompleteView.init({ required PerAccountStore store, required Narrow narrow, + required List wildcards, }) { final view = MentionAutocompleteView._( store: store, narrow: narrow, + wildcards: wildcards, sortedUsers: _usersByRelevance(store: store, narrow: narrow), ); store.autocompleteViewManager.registerMentionAutocomplete(view); @@ -305,25 +308,9 @@ class MentionAutocompleteView extends AutocompleteView wildcards; final List sortedUsers; - @override - Future?> computeResults() async { - final results = []; - if (await filterCandidates(filter: _testUser, - candidates: sortedUsers, results: results)) { - return null; - } - return results; - } - - MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) { - if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) { - return UserMentionAutocompleteResult(userId: user.userId); - } - return null; - } - static List _usersByRelevance({ required PerAccountStore store, required Narrow narrow, @@ -377,8 +364,6 @@ class MentionAutocompleteView extends AutocompleteView?> computeResults() async { + _isChannelWildcardIncluded = false; + final results = []; + // give priority to wildcard mentions + if (await filterCandidates(filter: _testWildcard, + candidates: wildcards, results: results)) { + return null; + } + if (await filterCandidates(filter: _testUser, + candidates: sortedUsers, results: results)) { + return null; + } + return results; + } + + MentionAutocompleteResult? _testWildcard(MentionAutocompleteQuery query, Wildcard wildcard) { + if (query.testWildcard(wildcard)) { + if (wildcard.type == WildcardType.channel) { + if (_isChannelWildcardIncluded) return null; + _isChannelWildcardIncluded = true; + } + return WildcardMentionAutocompleteResult(wildcardName: wildcard.name); + } + return null; + } + + MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) { + if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) { + return UserMentionAutocompleteResult(userId: user.userId); + } + return null; + } + @override void dispose() { store.autocompleteViewManager.unregisterMentionAutocomplete(this); @@ -493,6 +514,37 @@ class MentionAutocompleteView extends AutocompleteView= 247 (server-9). + final String value; // TODO(sever-9): remove, instead use [name] + + /// The full name of the wildcard to be shown in autocomplete suggestions. + /// + /// Ex: "all (Notify channel)" or "everyone (Notify recipients)". + final String fullDisplayName; + + final WildcardType type; +} + +enum WildcardType { + channel, + topic, // TODO(sever-8) +} + abstract class AutocompleteQuery { AutocompleteQuery(this.raw) : _lowercaseWords = raw.toLowerCase().split(' '); @@ -529,15 +581,14 @@ class MentionAutocompleteQuery extends AutocompleteQuery { /// Whether the user wants a silent mention (@_query, vs. @query). final bool silent; - bool testUser(User user, AutocompleteDataCache cache) { - // TODO(#236) test email too, not just name + bool testWildcard(Wildcard wildcard) { + return wildcard.name.contains(raw.toLowerCase()); + } + bool testUser(User user, AutocompleteDataCache cache) { if (!user.isActive) return false; - return _testName(user, cache); - } - - bool _testName(User user, AutocompleteDataCache cache) { + // TODO(#236) test email too, not just name return _testContainsQueryWords(cache.nameWordsForUser(user)); } @@ -585,11 +636,15 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult { final int userId; } -// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult { +class WildcardMentionAutocompleteResult extends MentionAutocompleteResult { + WildcardMentionAutocompleteResult({required this.wildcardName}); + + final String wildcardName; +} -// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult { +// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult { -class TopicAutocompleteView extends AutocompleteView { +class TopicAutocompleteView extends AutocompleteView { TopicAutocompleteView._({required super.store, required this.streamId}); factory TopicAutocompleteView.init({required PerAccountStore store, required int streamId}) { diff --git a/lib/model/compose.dart b/lib/model/compose.dart index b59a3efcc7..2e030c7f2e 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -101,18 +101,21 @@ String wrapWithBacktickFence({required String content, String? infoString}) { return resultBuffer.toString(); } -/// An @-mention, like @**Chris Bobbe|13313**. +/// An @user-mention, 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 @wildcard-mention, like @**channel**. +String wildcardMention(String wildcard) => '@**$wildcard**'; + /// https://spec.commonmark.org/0.30/#inline-link /// /// The "link text" is made by enclosing [visibleText] in square brackets. @@ -145,7 +148,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 +172,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 c1e82a5954..d69b7f40b9 100644 --- a/lib/widgets/autocomplete.dart +++ b/lib/widgets/autocomplete.dart @@ -1,14 +1,16 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; -import '../api/model/model.dart'; +import '../model/store.dart'; import 'content.dart'; +import 'icons.dart'; import 'store.dart'; import '../model/autocomplete.dart'; import '../model/compose.dart'; import '../model/narrow.dart'; import 'compose_box.dart'; -abstract class AutocompleteField extends StatefulWidget { +abstract class AutocompleteField extends StatefulWidget { const AutocompleteField({ super.key, required this.controller, @@ -24,14 +26,14 @@ abstract class AutocompleteField initViewModel(BuildContext context); + AutocompleteView initViewModel(BuildContext context); @override - State> createState() => _AutocompleteFieldState(); + State> createState() => _AutocompleteFieldState(); } -class _AutocompleteFieldState extends State> with PerAccountStoreAwareStateMixin> { - AutocompleteView? _viewModel; +class _AutocompleteFieldState extends State> with PerAccountStoreAwareStateMixin> { + AutocompleteView? _viewModel; void _initViewModel() { _viewModel = widget.initViewModel(context) @@ -71,7 +73,7 @@ class _AutocompleteFieldState oldWidget) { + void didUpdateWidget(covariant AutocompleteField oldWidget) { super.didUpdateWidget(oldWidget); if (widget.controller != oldWidget.controller) { oldWidget.controller.removeListener(_handleControllerChange); @@ -145,7 +147,7 @@ class _AutocompleteFieldState { +class ComposeAutocomplete extends AutocompleteField { const ComposeAutocomplete({ super.key, required this.narrow, @@ -165,7 +167,54 @@ class ComposeAutocomplete extends AutocompleteField _wildcards(BuildContext context, PerAccountStore store) { + final isDmNarrow = narrow is DmNarrow; + final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9) + final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 188; // TODO(sever-8) + final zulipLocalizations = ZulipLocalizations.of(context); + return [ + Wildcard( + name: zulipLocalizations.all, + value: 'all', + fullDisplayName: 'all (${isDmNarrow + ? zulipLocalizations.notifyRecipients + : zulipLocalizations.notifyChannel(isChannelWildcardAvailable + ? "channel" : "stream")})', + type: WildcardType.channel, + ), + Wildcard( + name: zulipLocalizations.everyone, + value: 'everyone', + fullDisplayName: 'everyone (${isDmNarrow + ? zulipLocalizations.notifyRecipients + : zulipLocalizations.notifyChannel(isChannelWildcardAvailable + ? "channel" : "stream")})', + type: WildcardType.channel, + ), + if (!isDmNarrow) ...[ + if (isChannelWildcardAvailable) Wildcard( + name: zulipLocalizations.channel, + value: 'channel', + fullDisplayName: 'channel (${zulipLocalizations.notifyChannel('channel')})', + type: WildcardType.channel, + ), + Wildcard( + name: zulipLocalizations.stream, + value: isChannelWildcardAvailable ? 'channel' : 'stream', + fullDisplayName: 'stream (${zulipLocalizations.notifyChannel(isChannelWildcardAvailable ? 'channel' : 'stream')})', + type: WildcardType.channel, + ), + if (isTopicWildcardAvailable) Wildcard( + name: zulipLocalizations.topic, + value: 'topic', + fullDisplayName: 'topic (${zulipLocalizations.notifyTopic})', + type: WildcardType.topic, + ), + ], + ]; } void _onTapOption(BuildContext context, MentionAutocompleteResult option) { @@ -183,7 +232,9 @@ class ComposeAutocomplete extends AutocompleteField w.name == wildcardName).fullDisplayName; } return InkWell( onTap: () { @@ -218,7 +274,7 @@ class ComposeAutocomplete extends AutocompleteField { +class TopicAutocomplete extends AutocompleteField { const TopicAutocomplete({ super.key, required this.streamId, diff --git a/test/model/compose_test.dart b/test/model/compose_test.dart index 37c7c50322..f3b49791cc 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -319,25 +319,25 @@ hello group('mention', () { final user = eg.user(userId: 123, fullName: 'Full Name'); test('not silent', () { - check(mention(user, silent: false)).equals('@**Full Name|123**'); + check(userMention(user, silent: false)).equals('@**Full Name|123**'); }); test('silent', () { - check(mention(user, silent: true)).equals('@_**Full Name|123**'); + 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(mention(user, silent: true, users: store.users)).equals('@_**Full Name|123**'); + 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(mention(user, silent: true, users: store.users)).equals('@_**Full Name|123**'); + 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(mention(user, silent: true, users: store.users)).equals('@_**Full Name**'); + check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name**'); }); }); diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 15c294696e..9c0b1af3f4 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -144,7 +144,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);