Skip to content

Commit a0ab0ed

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: #234
1 parent d8f6961 commit a0ab0ed

12 files changed

+410
-54
lines changed

assets/l10n/app_ar.arb

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

assets/l10n/app_en.arb

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

lib/generated/l10n/zulip_localizations.dart

+24
Original file line numberDiff line numberDiff line change
@@ -859,6 +859,30 @@ 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 channel'**
866+
String get notifyChannel;
867+
868+
/// Description for "@all", "@everyone", and "@stream" wildcard mentions in a stream or topic narrow.
869+
///
870+
/// In en, this message translates to:
871+
/// **'Notify stream'**
872+
String get notifyStream;
873+
874+
/// Description for "@all" and "@everyone" wildcard mentions in a DM narrow.
875+
///
876+
/// In en, this message translates to:
877+
/// **'Notify recipients'**
878+
String get notifyRecipients;
879+
880+
/// Description for "@topic" wildcard mention in a channel or topic narrow.
881+
///
882+
/// In en, this message translates to:
883+
/// **'Notify topic'**
884+
String get notifyTopic;
885+
862886
/// Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)
863887
///
864888
/// In en, this message translates to:

lib/generated/l10n/zulip_localizations_ar.dart

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

464+
@override
465+
String get notifyChannel => 'إخطار القناة';
466+
467+
@override
468+
String get notifyStream => 'إخطار الدفق';
469+
470+
@override
471+
String get notifyRecipients => 'إخطار المستلمين';
472+
473+
@override
474+
String get notifyTopic => 'إخطار الموضوع';
475+
464476
@override
465477
String get messageIsEditedLabel => 'EDITED';
466478

lib/generated/l10n/zulip_localizations_en.dart

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

464+
@override
465+
String get notifyChannel => 'Notify channel';
466+
467+
@override
468+
String get notifyStream => 'Notify stream';
469+
470+
@override
471+
String get notifyRecipients => 'Notify recipients';
472+
473+
@override
474+
String get notifyTopic => 'Notify topic';
475+
464476
@override
465477
String get messageIsEditedLabel => 'EDITED';
466478

lib/generated/l10n/zulip_localizations_ja.dart

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

464+
@override
465+
String get notifyChannel => 'Notify channel';
466+
467+
@override
468+
String get notifyStream => 'Notify stream';
469+
470+
@override
471+
String get notifyRecipients => 'Notify recipients';
472+
473+
@override
474+
String get notifyTopic => 'Notify topic';
475+
464476
@override
465477
String get messageIsEditedLabel => 'EDITED';
466478

lib/model/autocomplete.dart

+50-6
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) {
@@ -598,9 +596,45 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
598596
return userAName.compareTo(userBName); // TODO(i18n): add locale-aware sorting
599597
}
600598

599+
List<WildcardMentionAutocompleteResult> get wildcardMentionResults {
600+
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
601+
final isChannelOrTopicNarrow = narrow is ChannelNarrow || narrow is TopicNarrow;
602+
603+
final wildcardMentions = <WildcardMentionAutocompleteResult>[];
604+
// Only one of the (all, everyone, channel, stream) channel wildcards are
605+
// shown.
606+
if (query.testWildcard(Wildcard.all)) {
607+
wildcardMentions.add(WildcardMentionAutocompleteResult(
608+
wildcard: Wildcard.all));
609+
} else if (query.testWildcard(Wildcard.everyone)) {
610+
wildcardMentions.add(WildcardMentionAutocompleteResult(
611+
wildcard: Wildcard.everyone));
612+
} else if (isChannelOrTopicNarrow) {
613+
if (query.testWildcard(Wildcard.channel) && isChannelWildcardAvailable) {
614+
wildcardMentions.add(WildcardMentionAutocompleteResult(
615+
wildcard: Wildcard.channel));
616+
} else if (query.testWildcard(Wildcard.stream)) {
617+
wildcardMentions.add(WildcardMentionAutocompleteResult(
618+
wildcard: Wildcard.stream));
619+
}
620+
}
621+
622+
final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(sever-8)
623+
if (isChannelOrTopicNarrow
624+
&& isTopicWildcardAvailable
625+
&& query.testWildcard(Wildcard.topic)) {
626+
wildcardMentions.add(WildcardMentionAutocompleteResult(
627+
wildcard: Wildcard.topic));
628+
}
629+
return wildcardMentions;
630+
}
631+
601632
@override
602633
Future<List<MentionAutocompleteResult>?> computeResults() async {
603634
final results = <MentionAutocompleteResult>[];
635+
// Give priority to wildcard mentions.
636+
results.addAll(wildcardMentionResults);
637+
604638
if (await filterCandidates(filter: _testUser,
605639
candidates: sortedUsers, results: results)) {
606640
return null;
@@ -625,6 +659,9 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
625659
}
626660
}
627661

662+
// The available user wildcard mention options.
663+
enum Wildcard { all, everyone, channel, stream, topic }
664+
628665
/// A query the user has entered into some form of autocomplete.
629666
///
630667
/// Subclasses correspond to different types of autocomplete interaction
@@ -694,9 +731,12 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
694731
return MentionAutocompleteView.init(store: store, narrow: narrow, query: this);
695732
}
696733

734+
bool testWildcard(Wildcard wildcard) {
735+
return wildcard.name.contains(raw.toLowerCase());
736+
}
737+
697738
bool testUser(User user, AutocompleteDataCache cache) {
698739
// TODO(#236) test email too, not just name
699-
700740
if (!user.isActive) return false;
701741

702742
return _testName(user, cache);
@@ -778,9 +818,13 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
778818
final int userId;
779819
}
780820

781-
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
821+
class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
822+
WildcardMentionAutocompleteResult({required this.wildcard});
823+
824+
final Wildcard wildcard;
825+
}
782826

783-
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
827+
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
784828

785829
/// An autocomplete interaction for choosing a topic for a message.
786830
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult> {

lib/model/compose.dart

+20-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,33 @@ 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+
/// An @wildcard-mention, like @**channel**.
118+
String wildcardMention(Wildcard wildcard, {
119+
required PerAccountStore store,
120+
}) {
121+
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
122+
assert(isChannelWildcardAvailable || wildcard != Wildcard.channel);
123+
final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 224; // TODO(sever-8)
124+
assert(isTopicWildcardAvailable || wildcard != Wildcard.topic);
125+
126+
final name = wildcard == Wildcard.stream && isChannelWildcardAvailable
127+
? Wildcard.channel.name
128+
: wildcard.name;
129+
return '@**$name**';
130+
}
131+
116132
/// https://spec.commonmark.org/0.30/#inline-link
117133
///
118134
/// The "link text" is made by enclosing [visibleText] in square brackets.
@@ -145,7 +161,7 @@ String quoteAndReplyPlaceholder(PerAccountStore store, {
145161
SendableNarrow.ofMessage(message, selfUserId: store.selfUserId),
146162
nearMessageId: message.id);
147163
// See note in [quoteAndReply] about asking `mention` to omit the |<id> part.
148-
return '${mention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
164+
return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
149165
'*(loading message ${message.id})*\n'; // TODO(i18n) ?
150166
}
151167

@@ -169,6 +185,6 @@ String quoteAndReply(PerAccountStore store, {
169185
// Could ask `mention` to omit the |<id> part unless the mention is ambiguous…
170186
// but that would mean a linear scan through all users, and the extra noise
171187
// 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) ?
188+
return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}:\n' // TODO(i18n) ?
173189
'${wrapWithBacktickFence(content: rawContent, infoString: 'quote')}';
174190
}

lib/widgets/autocomplete.dart

+47-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, store: store)} ';
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,62 @@ 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+
: isChannelWildcardAvailable
271+
? localizations.notifyChannel
272+
: localizations.notifyStream,
273+
Wildcard.everyone => isDmNarrow
274+
? localizations.notifyRecipients
275+
: isChannelWildcardAvailable
276+
? localizations.notifyChannel
277+
: localizations.notifyStream,
278+
Wildcard.channel => localizations.notifyChannel,
279+
Wildcard.stream => isChannelWildcardAvailable
280+
? localizations.notifyChannel
281+
: localizations.notifyStream,
282+
Wildcard.topic => localizations.notifyTopic,
283+
};
284+
return Text.rich(TextSpan(text: '${wildcard.name} ', children: [
285+
TextSpan(text: description, style: TextStyle(fontSize: 12,
286+
color: Colors.black.withValues(alpha: 0.8)))]));
287+
}
248288
}
249289

250290
class _EmojiAutocompleteItem extends StatelessWidget {

0 commit comments

Comments
 (0)