diff --git a/assets/l10n/app_ar.arb b/assets/l10n/app_ar.arb index 0db3279e44b..6e4e2d2d2bd 100644 --- a/assets/l10n/app_ar.arb +++ b/assets/l10n/app_ar.arb @@ -1,3 +1,6 @@ { - + "notifyChannel": "إخطار القناة", + "notifyStream": "إخطار الدفق", + "notifyRecipients": "إخطار المستلمين", + "notifyTopic": "إخطار الموضوع" } diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 58822303fdb..f6199daaa9b 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -641,6 +641,22 @@ "@manyPeopleTyping": { "description": "Text to display when there are multiple users typing." }, + "notifyChannel": "Notify channel", + "@notifyChannel": { + "description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard mentions in a channel or topic narrow." + }, + "notifyStream": "Notify stream", + "@notifyStream": { + "description": "Description for \"@all\", \"@everyone\", and \"@stream\" wildcard mentions in a stream or topic narrow." + }, + "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." + }, "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 00d7cfde72b..cfbacd8d69c 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -955,6 +955,30 @@ abstract class ZulipLocalizations { /// **'Several people are typing…'** String get manyPeopleTyping; + /// Description for "@all", "@everyone", "@channel", and "@stream" wildcard mentions in a channel or topic narrow. + /// + /// In en, this message translates to: + /// **'Notify channel'** + String get notifyChannel; + + /// Description for "@all", "@everyone", and "@stream" wildcard mentions in a stream or topic narrow. + /// + /// In en, this message translates to: + /// **'Notify stream'** + String get notifyStream; + + /// Description for "@all" and "@everyone" wildcard mentions in a DM narrow. + /// + /// In en, this message translates to: + /// **'Notify recipients'** + String get notifyRecipients; + + /// Description for "@topic" wildcard mention in a channel or topic narrow. + /// + /// In en, this message translates to: + /// **'Notify topic'** + String get notifyTopic; + /// 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..f80a6ee519e 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -508,6 +508,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get manyPeopleTyping => 'Several people are typing…'; + @override + String get notifyChannel => 'إخطار القناة'; + + @override + String get notifyStream => 'إخطار الدفق'; + + @override + String get notifyRecipients => 'إخطار المستلمين'; + + @override + String get notifyTopic => 'إخطار الموضوع'; + @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..ed0e0c525ba 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -508,6 +508,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get manyPeopleTyping => 'Several people are typing…'; + @override + String get notifyChannel => 'Notify channel'; + + @override + String get notifyStream => 'Notify stream'; + + @override + String get notifyRecipients => 'Notify recipients'; + + @override + String get notifyTopic => 'Notify topic'; + @override String get messageIsEditedLabel => 'EDITED'; diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart index c857da2c82f..a242699509b 100644 --- a/lib/generated/l10n/zulip_localizations_fr.dart +++ b/lib/generated/l10n/zulip_localizations_fr.dart @@ -508,6 +508,18 @@ class ZulipLocalizationsFr extends ZulipLocalizations { @override String get manyPeopleTyping => 'Several people are typing…'; + @override + String get notifyChannel => 'Notify channel'; + + @override + String get notifyStream => 'Notify stream'; + + @override + String get notifyRecipients => 'Notify recipients'; + + @override + String get notifyTopic => '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..c4e03508438 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -508,6 +508,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get manyPeopleTyping => 'Several people are typing…'; + @override + String get notifyChannel => 'Notify channel'; + + @override + String get notifyStream => 'Notify stream'; + + @override + String get notifyRecipients => 'Notify recipients'; + + @override + String get notifyTopic => '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 07746b3f27c..95ed57c523a 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -508,6 +508,18 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get manyPeopleTyping => 'Wielu ludzi coś pisze…'; + @override + String get notifyChannel => 'Notify channel'; + + @override + String get notifyStream => 'Notify stream'; + + @override + String get notifyRecipients => 'Notify recipients'; + + @override + String get notifyTopic => '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 9c2065376bb..5d3ecb0a5d8 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -508,6 +508,18 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get manyPeopleTyping => 'Several people are typing…'; + @override + String get notifyChannel => 'Notify channel'; + + @override + String get notifyStream => 'Notify stream'; + + @override + String get notifyRecipients => 'Notify recipients'; + + @override + String get notifyTopic => 'Notify topic'; + @override String get messageIsEditedLabel => 'EDITED'; diff --git a/lib/model/autocomplete.dart b/lib/model/autocomplete.dart index 43c8b383c7e..5b0e9c96692 100644 --- a/lib/model/autocomplete.dart +++ b/lib/model/autocomplete.dart @@ -423,8 +423,8 @@ class MentionAutocompleteView extends AutocompleteView get wildcardMentionResults { + final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9) + final isChannelOrTopicNarrow = narrow is ChannelNarrow || narrow is TopicNarrow; + + final wildcardMentions = []; + // Only one of the (all, everyone, channel, stream) channel wildcards are + // shown. + if (query.testWildcard(Wildcard.all)) { + wildcardMentions.add(WildcardMentionAutocompleteResult( + wildcard: Wildcard.all)); + } else if (query.testWildcard(Wildcard.everyone)) { + wildcardMentions.add(WildcardMentionAutocompleteResult( + wildcard: Wildcard.everyone)); + } else if (isChannelOrTopicNarrow) { + if (query.testWildcard(Wildcard.channel) && isChannelWildcardAvailable) { + wildcardMentions.add(WildcardMentionAutocompleteResult( + wildcard: Wildcard.channel)); + } else if (query.testWildcard(Wildcard.stream)) { + wildcardMentions.add(WildcardMentionAutocompleteResult( + wildcard: Wildcard.stream)); + } + } + + final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(sever-8) + if (isChannelOrTopicNarrow + && isTopicWildcardAvailable + && query.testWildcard(Wildcard.topic)) { + wildcardMentions.add(WildcardMentionAutocompleteResult( + wildcard: Wildcard.topic)); + } + return wildcardMentions; + } + @override Future?> computeResults() async { final results = []; + // Give priority to wildcard mentions. + results.addAll(wildcardMentionResults); + if (await filterCandidates(filter: _testUser, candidates: sortedUsers, results: results)) { return null; @@ -625,6 +659,9 @@ class MentionAutocompleteView extends AutocompleteView { diff --git a/lib/model/compose.dart b/lib/model/compose.dart index b59a3efcc7b..196f735632f 100644 --- a/lib/model/compose.dart +++ b/lib/model/compose.dart @@ -1,6 +1,7 @@ import 'dart:math'; import '../api/model/model.dart'; +import 'autocomplete.dart'; import 'internal_link.dart'; import 'narrow.dart'; import 'store.dart'; @@ -101,18 +102,33 @@ 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(Wildcard wildcard, { + required PerAccountStore store, +}) { + final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9) + assert(isChannelWildcardAvailable || wildcard != Wildcard.channel); + final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(sever-8) + assert(isTopicWildcardAvailable || wildcard != Wildcard.topic); + + final name = wildcard == Wildcard.stream && isChannelWildcardAvailable + ? Wildcard.channel.name + : wildcard.name; + return '@**$name**'; +} + /// https://spec.commonmark.org/0.30/#inline-link /// /// The "link text" is made by enclosing [visibleText] in square brackets. @@ -145,7 +161,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 +185,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 a1e5289b014..208c1a4c188 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'; @@ -197,7 +200,9 @@ class ComposeAutocomplete extends AutocompleteField _MentionAutocompleteItem(option: option), + MentionAutocompleteResult() => _MentionAutocompleteItem( + option: option, narrow: narrow), EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option), }; return InkWell( @@ -223,18 +229,23 @@ class ComposeAutocomplete extends AutocompleteField= 247; // TODO(server-9) + final localizations = ZulipLocalizations.of(context); + final description = switch (wildcard) { + Wildcard.all => isDmNarrow + ? localizations.notifyRecipients + : isChannelWildcardAvailable + ? localizations.notifyChannel + : localizations.notifyStream, + Wildcard.everyone => isDmNarrow + ? localizations.notifyRecipients + : isChannelWildcardAvailable + ? localizations.notifyChannel + : localizations.notifyStream, + Wildcard.channel => localizations.notifyChannel, + Wildcard.stream => isChannelWildcardAvailable + ? localizations.notifyChannel + : localizations.notifyStream, + Wildcard.topic => localizations.notifyTopic, + }; + return Text.rich(TextSpan(text: '${wildcard.name} ', children: [ + TextSpan(text: description, style: TextStyle(fontSize: 12, + color: Colors.black.withValues(alpha: 0.8)))])); + } } class _EmojiAutocompleteItem extends StatelessWidget { diff --git a/test/model/autocomplete_test.dart b/test/model/autocomplete_test.dart index 9d6667ca3f7..93e5eba3339 100644 --- a/test/model/autocomplete_test.dart +++ b/test/model/autocomplete_test.dart @@ -438,7 +438,7 @@ void main() { }); }); - group('MentionAutocompleteView sorting users results', () { + group('MentionAutocompleteView sorting user mention results', () { late PerAccountStore store; Future prepare({ @@ -773,8 +773,79 @@ void main() { }); }); + group('wildcardMentionResults', () { + Iterable getWildcardsFor(String rawQuery, { + 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)); + final results = view.wildcardMentionResults.map((e) => e.wildcard); + view.dispose(); + return results; + } + + const channelNarrow = ChannelNarrow(1); + const topicNarrow = TopicNarrow(1, 'topic'); + final dmNarrow = DmNarrow.withUser(10, selfUserId: 5); + + final testCases = [ + ('', channelNarrow, [Wildcard.all, Wildcard.topic]), + ('', topicNarrow, [Wildcard.all, Wildcard.topic]), + ('', dmNarrow, [Wildcard.all]), + + ('c', channelNarrow, [Wildcard.channel, Wildcard.topic]), + ('ch', topicNarrow, [Wildcard.channel]), + ('str', channelNarrow, [Wildcard.stream]), + ('e', topicNarrow, [Wildcard.everyone]), + ('everyone', channelNarrow, [Wildcard.everyone]), + ('t', topicNarrow, [Wildcard.stream, Wildcard.topic]), + ('topic', channelNarrow, [Wildcard.topic]), + ('topic etc', topicNarrow, []), + + ('a', dmNarrow, [Wildcard.all]), + ('every', dmNarrow, [Wildcard.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('${Wildcard.channel} is available FL-247 onwards', () { + check(getWildcardsFor('channel', + narrow: channelNarrow, zulipFeatureLevel: 247)) + .deepEquals([Wildcard.channel]); + }); + + test('${Wildcard.channel} is not available before FL-247', () { + check(getWildcardsFor('channel', + narrow: channelNarrow, zulipFeatureLevel: 246)) + .deepEquals([]); + }); + + test('${Wildcard.topic} is available FL-224 onwards', () { + check(getWildcardsFor('topic', + narrow: channelNarrow, zulipFeatureLevel: 224)) + .deepEquals([Wildcard.topic]); + }); + + test('${Wildcard.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, @@ -782,12 +853,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 = TopicNarrow(stream.streamId, topic); @@ -811,20 +887,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 user 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. Channel and/or topic 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('')); + check(getWildcardsFromResults(results1.take(2))) + .deepEquals([Wildcard.all, Wildcard.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(getWildcardsFromResults(results2.take(2))) + .deepEquals([Wildcard.stream, Wildcard.topic]); + check(getUsersFromResults(results2.skip(2))).deepEquals([2, 3]); + final results3 = await getResults(topicNarrow, MentionAutocompleteQuery('f')); + 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..d2f5dd23b02 100644 --- a/test/model/compose_test.dart +++ b/test/model/compose_test.dart @@ -1,6 +1,8 @@ import 'package:checks/checks.dart'; import 'package:test/scaffolding.dart'; +import 'package:zulip/model/autocomplete.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 +223,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(Wildcard.all, store: store())) + .equals('@**all**'); + check(wildcardMention(Wildcard.everyone, store: store())) + .equals('@**everyone**'); + check(wildcardMention(Wildcard.channel, store: store())) + .equals('@**channel**'); + check(wildcardMention(Wildcard.stream, + store: store(zulipFeatureLevel: 247))) + .equals('@**channel**'); + check(wildcardMention(Wildcard.stream, + store: store(zulipFeatureLevel: 246))) + .equals('@**stream**'); + check(wildcardMention(Wildcard.topic, store: store())) + .equals('@**topic**'); }); }); diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 69d7b8b4b1f..d50d815ac7a 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -7,12 +7,14 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/channels.dart'; import 'package:zulip/api/route/realm.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/model/typing_status.dart'; +import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/message_list.dart'; import '../api/fake_api.dart'; @@ -172,7 +174,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'); @@ -194,7 +196,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); @@ -216,6 +218,69 @@ void main() { debugNetworkImageHttpClientProvider = null; }); + + void checkWildcardShown(Wildcard wildcard, PerAccountStore store, { + required bool expected, + }) { + final richTextFinder = find.textContaining(wildcard.name, 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(Wildcard.channel, store, expected: true); + checkWildcardShown(Wildcard.topic, store, expected: true); + checkWildcardShown(Wildcard.all, store, expected: false); + checkWildcardShown(Wildcard.everyone, store, expected: false); + checkWildcardShown(Wildcard.stream, store, expected: false); + + // Finishing autocomplete updates compose box; causes options to disappear + await tester.tap(find.textContaining(Wildcard.channel.name, + findRichText: true)); + await tester.pump(); + check(tester.widget(composeInputFinder).controller!.text) + .contains(wildcardMention(Wildcard.channel, store: store)); + checkWildcardShown(Wildcard.channel, store, expected: false); + checkWildcardShown(Wildcard.topic, store, expected: false); + checkWildcardShown(Wildcard.all, store, expected: false); + checkWildcardShown(Wildcard.everyone, store, expected: false); + checkWildcardShown(Wildcard.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(Wildcard.channel, store, expected: true); + checkWildcardShown(Wildcard.topic, store, expected: false); + checkWildcardShown(Wildcard.all, store, expected: false); + checkWildcardShown(Wildcard.everyone, store, expected: false); + checkWildcardShown(Wildcard.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(Wildcard.channel, store, expected: false); + checkWildcardShown(Wildcard.topic, store, expected: false); + checkWildcardShown(Wildcard.all, store, expected: false); + checkWildcardShown(Wildcard.everyone, store, expected: false); + checkWildcardShown(Wildcard.stream, store, expected: false); + + debugNetworkImageHttpClientProvider = null; + }); }); group('emoji', () {