Skip to content

Commit d278e34

Browse files
committed
autocomplete: Support @-wildcard in user-mention autocomplete
The implementation logic is similar to the zulip-mobile implementation: https://github.com/zulip/zulip-mobile/blob/a115df1f71c9dc31e9b41060a8d57b51c017d786/src/autocomplete/WildcardMentionItem.js Fixes: zulip#234
1 parent aa92362 commit d278e34

11 files changed

+199
-22
lines changed

assets/l10n/app_ar.arb

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
{
2-
2+
"notifyChannel": "إخطار {value, select, channel{القناة} other{الدفق}}",
3+
"notifyRecipients": "إخطار المستلمين",
4+
"notifyTopic": "إخطار الموضوع"
35
}

assets/l10n/app_en.arb

+15
Original file line numberDiff line numberDiff line change
@@ -580,6 +580,21 @@
580580
"@manyPeopleTyping": {
581581
"description": "Text to display when there are multiple users typing."
582582
},
583+
"notifyChannel": "Notify {value, select, channel{channel} other{stream}}",
584+
"@notifyChannel": {
585+
"description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard mentions in a channel or topic narrow.",
586+
"placeholders": {
587+
"value": {"type": "String"}
588+
}
589+
},
590+
"notifyRecipients": "Notify recipients",
591+
"@notifyRecipients": {
592+
"description": "Description for \"@all\" and \"@everyone\" wildcard mentions in a DM narrow."
593+
},
594+
"notifyTopic": "Notify topic",
595+
"@notifyTopic": {
596+
"description": "Description for \"@topic\" wildcard mention in a channel or topic narrow."
597+
},
583598
"messageIsEditedLabel": "EDITED",
584599
"@messageIsEditedLabel": {
585600
"description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"

lib/generated/l10n/zulip_localizations.dart

+18
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,24 @@ abstract class ZulipLocalizations {
859859
/// **'Several people are typing…'**
860860
String get manyPeopleTyping;
861861

862+
/// Description for "@all", "@everyone", "@channel", and "@stream" wildcard mentions in a channel or topic narrow.
863+
///
864+
/// In en, this message translates to:
865+
/// **'Notify {value, select, channel{channel} other{stream}}'**
866+
String notifyChannel(String value);
867+
868+
/// Description for "@all" and "@everyone" wildcard mentions in a DM narrow.
869+
///
870+
/// In en, this message translates to:
871+
/// **'Notify recipients'**
872+
String get notifyRecipients;
873+
874+
/// Description for "@topic" wildcard mention in a channel or topic narrow.
875+
///
876+
/// In en, this message translates to:
877+
/// **'Notify topic'**
878+
String get notifyTopic;
879+
862880
/// Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)
863881
///
864882
/// In en, this message translates to:

lib/generated/l10n/zulip_localizations_ar.dart

+18
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,24 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
461461
@override
462462
String get manyPeopleTyping => 'Several people are typing…';
463463

464+
@override
465+
String notifyChannel(String value) {
466+
String _temp0 = intl.Intl.selectLogic(
467+
value,
468+
{
469+
'channel': 'القناة',
470+
'other': 'الدفق',
471+
},
472+
);
473+
return 'إخطار $_temp0';
474+
}
475+
476+
@override
477+
String get notifyRecipients => 'إخطار المستلمين';
478+
479+
@override
480+
String get notifyTopic => 'إخطار الموضوع';
481+
464482
@override
465483
String get messageIsEditedLabel => 'EDITED';
466484

lib/generated/l10n/zulip_localizations_en.dart

+18
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,24 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
461461
@override
462462
String get manyPeopleTyping => 'Several people are typing…';
463463

464+
@override
465+
String notifyChannel(String value) {
466+
String _temp0 = intl.Intl.selectLogic(
467+
value,
468+
{
469+
'channel': 'channel',
470+
'other': 'stream',
471+
},
472+
);
473+
return 'Notify $_temp0';
474+
}
475+
476+
@override
477+
String get notifyRecipients => 'Notify recipients';
478+
479+
@override
480+
String get notifyTopic => 'Notify topic';
481+
464482
@override
465483
String get messageIsEditedLabel => 'EDITED';
466484

lib/generated/l10n/zulip_localizations_ja.dart

+18
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,24 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
461461
@override
462462
String get manyPeopleTyping => 'Several people are typing…';
463463

464+
@override
465+
String notifyChannel(String value) {
466+
String _temp0 = intl.Intl.selectLogic(
467+
value,
468+
{
469+
'channel': 'channel',
470+
'other': 'stream',
471+
},
472+
);
473+
return 'Notify $_temp0';
474+
}
475+
476+
@override
477+
String get notifyRecipients => 'Notify recipients';
478+
479+
@override
480+
String get notifyTopic => 'Notify topic';
481+
464482
@override
465483
String get messageIsEditedLabel => 'EDITED';
466484

lib/model/autocomplete.dart

+47-4
Original file line numberDiff line numberDiff line change
@@ -423,8 +423,8 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
423423

424424
factory MentionAutocompleteView.init({
425425
required PerAccountStore store,
426-
required Narrow narrow,
427426
required MentionAutocompleteQuery query,
427+
required Narrow narrow,
428428
}) {
429429
final view = MentionAutocompleteView._(
430430
store: store,
@@ -492,8 +492,6 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
492492
required String? topic,
493493
required PerAccountStore store,
494494
}) {
495-
// TODO(#234): give preference to "all", "everyone" or "stream"
496-
497495
// TODO(#618): give preference to subscribed users first
498496

499497
if (streamId != null) {
@@ -601,13 +599,47 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
601599
@override
602600
Future<List<MentionAutocompleteResult>?> computeResults() async {
603601
final results = <MentionAutocompleteResult>[];
602+
// Give priority to wildcard mentions.
603+
results.addAll(wildcardMentionResults);
604+
604605
if (await filterCandidates(filter: _testUser,
605606
candidates: sortedUsers, results: results)) {
606607
return null;
607608
}
608609
return results;
609610
}
610611

612+
List<WildcardMentionAutocompleteResult> get wildcardMentionResults {
613+
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
614+
final isChannelOrTopicNarrow = narrow is ChannelNarrow || narrow is TopicNarrow;
615+
616+
final wildcardMentions = <WildcardMentionAutocompleteResult>[];
617+
if (query.testWildcard(Wildcard.all)) {
618+
wildcardMentions.add(WildcardMentionAutocompleteResult(
619+
wildcard: Wildcard.all));
620+
} else if (query.testWildcard(Wildcard.everyone)) {
621+
wildcardMentions.add(WildcardMentionAutocompleteResult(
622+
wildcard: Wildcard.everyone));
623+
} else if (isChannelOrTopicNarrow) {
624+
if (query.testWildcard(Wildcard.channel) && isChannelWildcardAvailable) {
625+
wildcardMentions.add(WildcardMentionAutocompleteResult(
626+
wildcard: Wildcard.channel));
627+
} else if (query.testWildcard(Wildcard.stream)) {
628+
wildcardMentions.add(WildcardMentionAutocompleteResult(
629+
wildcard: Wildcard.stream));
630+
}
631+
}
632+
633+
final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 188; // TODO(sever-8)
634+
if (isChannelOrTopicNarrow
635+
&& isTopicWildcardAvailable
636+
&& query.testWildcard(Wildcard.topic)) {
637+
wildcardMentions.add(WildcardMentionAutocompleteResult(
638+
wildcard: Wildcard.topic));
639+
}
640+
return wildcardMentions;
641+
}
642+
611643
MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) {
612644
if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
613645
return UserMentionAutocompleteResult(userId: user.userId);
@@ -625,6 +657,8 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
625657
}
626658
}
627659

660+
enum Wildcard { all, everyone, channel, stream, topic }
661+
628662
/// A query the user has entered into some form of autocomplete.
629663
///
630664
/// Subclasses correspond to different types of autocomplete interaction
@@ -694,9 +728,12 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
694728
return MentionAutocompleteView.init(store: store, narrow: narrow, query: this);
695729
}
696730

731+
bool testWildcard(Wildcard wildcard) {
732+
return wildcard.name.contains(raw.toLowerCase());
733+
}
734+
697735
bool testUser(User user, AutocompleteDataCache cache) {
698736
// TODO(#236) test email too, not just name
699-
700737
if (!user.isActive) return false;
701738

702739
return _testName(user, cache);
@@ -778,6 +815,12 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
778815
final int userId;
779816
}
780817

818+
class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
819+
WildcardMentionAutocompleteResult({required this.wildcard});
820+
821+
final Wildcard wildcard;
822+
}
823+
781824
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
782825

783826
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {

lib/model/compose.dart

+14-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:math';
22

33
import '../api/model/model.dart';
4+
import 'autocomplete.dart';
45
import 'internal_link.dart';
56
import 'narrow.dart';
67
import 'store.dart';
@@ -101,18 +102,27 @@ String wrapWithBacktickFence({required String content, String? infoString}) {
101102
return resultBuffer.toString();
102103
}
103104

104-
/// An @-mention, like @**Chris Bobbe|13313**.
105+
/// An @user-mention, like @**Chris Bobbe|13313**.
105106
///
106107
/// To omit the user ID part ("|13313") whenever the name part is unambiguous,
107108
/// pass a Map of all users we know about. This means accepting a linear scan
108109
/// through all users; avoid it in performance-sensitive codepaths.
109-
String mention(User user, {bool silent = false, Map<int, User>? users}) {
110+
String userMention(User user, {bool silent = false, Map<int, User>? users}) {
110111
bool includeUserId = users == null
111112
|| users.values.where((u) => u.fullName == user.fullName).take(2).length == 2;
112113

113114
return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**';
114115
}
115116

117+
String wildcardMention(Wildcard wildcard, {
118+
required bool isChannelWildcardAvailable, // TODO(server-9)
119+
}) {
120+
final name = wildcard == Wildcard.stream && isChannelWildcardAvailable
121+
? Wildcard.channel.name
122+
: wildcard.name;
123+
return '@**$name**';
124+
}
125+
116126
/// https://spec.commonmark.org/0.30/#inline-link
117127
///
118128
/// The "link text" is made by enclosing [visibleText] in square brackets.
@@ -145,7 +155,7 @@ String quoteAndReplyPlaceholder(PerAccountStore store, {
145155
SendableNarrow.ofMessage(message, selfUserId: store.selfUserId),
146156
nearMessageId: message.id);
147157
// See note in [quoteAndReply] about asking `mention` to omit the |<id> part.
148-
return '${mention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
158+
return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
149159
'*(loading message ${message.id})*\n'; // TODO(i18n) ?
150160
}
151161

@@ -169,6 +179,6 @@ String quoteAndReply(PerAccountStore store, {
169179
// Could ask `mention` to omit the |<id> part unless the mention is ambiguous…
170180
// but that would mean a linear scan through all users, and the extra noise
171181
// won't much matter with the already probably-long message link in there too.
172-
return '${mention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(i18n) ?
182+
return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(i18n) ?
173183
'${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}';
174184
}

lib/widgets/autocomplete.dart

+42-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import 'package:flutter/material.dart';
22

3+
import '../generated/l10n/zulip_localizations.dart';
34
import '../model/emoji.dart';
5+
import '../model/store.dart';
46
import 'content.dart';
57
import 'emoji.dart';
8+
import 'icons.dart';
69
import 'store.dart';
710
import '../model/autocomplete.dart';
811
import '../model/compose.dart';
@@ -197,7 +200,9 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
197200
}
198201
// TODO(i18n) language-appropriate space character; check active keyboard?
199202
// (maybe handle centrally in `controller`)
200-
replacementString = '${mention(store.users[userId]!, silent: query.silent, users: store.users)} ';
203+
replacementString = '${userMention(store.users[userId]!, silent: query.silent, users: store.users)} ';
204+
case WildcardMentionAutocompleteResult(:var wildcard):
205+
replacementString = '${wildcardMention(wildcard, isChannelWildcardAvailable: store.account.zulipFeatureLevel >= 247)} ';
201206
}
202207

203208
controller.value = intent.textEditingValue.replaced(
@@ -211,7 +216,8 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
211216
@override
212217
Widget buildItem(BuildContext context, int index, ComposeAutocompleteResult option) {
213218
final child = switch (option) {
214-
MentionAutocompleteResult() => _MentionAutocompleteItem(option: option),
219+
MentionAutocompleteResult() => _MentionAutocompleteItem(
220+
option: option, narrow: narrow),
215221
EmojiAutocompleteResult() => _EmojiAutocompleteItem(option: option),
216222
};
217223
return InkWell(
@@ -223,28 +229,57 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
223229
}
224230

225231
class _MentionAutocompleteItem extends StatelessWidget {
226-
const _MentionAutocompleteItem({required this.option});
232+
const _MentionAutocompleteItem({required this.option, required this.narrow});
227233

228234
final MentionAutocompleteResult option;
235+
final Narrow narrow;
229236

230237
@override
231238
Widget build(BuildContext context) {
239+
final store = PerAccountStoreWidget.of(context);
232240
Widget avatar;
233-
String label;
241+
Widget label;
234242
switch (option) {
235243
case UserMentionAutocompleteResult(:var userId):
236-
avatar = Avatar(userId: userId, size: 32, borderRadius: 3);
237-
label = PerAccountStoreWidget.of(context).users[userId]!.fullName;
244+
avatar = Avatar(userId: userId, size: 32, borderRadius: 3); // web uses 21px
245+
label = Text(store.users[userId]!.fullName);
246+
case WildcardMentionAutocompleteResult(:var wildcard):
247+
avatar = const Icon(ZulipIcons.three_person, size: 29); // web uses 19px
248+
label = wildcardLabel(wildcard, context: context, store: store);
238249
}
239250

240251
return Padding(
241252
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
242253
child: Row(children: [
243254
avatar,
244255
const SizedBox(width: 8),
245-
Text(label),
256+
label,
246257
]));
247258
}
259+
260+
Widget wildcardLabel(Wildcard wildcard, {
261+
required BuildContext context,
262+
required PerAccountStore store,
263+
}) {
264+
final isDmNarrow = narrow is DmNarrow;
265+
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
266+
final localizations = ZulipLocalizations.of(context);
267+
final description = switch (wildcard) {
268+
Wildcard.all => isDmNarrow
269+
? localizations.notifyRecipients
270+
: localizations.notifyChannel(isChannelWildcardAvailable ? "channel" : "stream"),
271+
Wildcard.everyone => isDmNarrow
272+
? localizations.notifyRecipients
273+
: localizations.notifyChannel(isChannelWildcardAvailable ? "channel" : "stream"),
274+
Wildcard.channel => localizations.notifyChannel('channel'),
275+
Wildcard.stream => localizations.notifyChannel(
276+
isChannelWildcardAvailable ? 'channel' : 'stream'),
277+
Wildcard.topic => localizations.notifyTopic,
278+
};
279+
return Text.rich(TextSpan(text: '${wildcard.name} ', children: [
280+
TextSpan(text: description, style: TextStyle(fontSize: 12,
281+
color: Colors.black.withValues(alpha: 0.8)))]));
282+
}
248283
}
249284

250285
class _EmojiAutocompleteItem extends StatelessWidget {

0 commit comments

Comments
 (0)