Skip to content

Commit a24bddb

Browse files
sm-sayedignprice
authored andcommitted
autocomplete: Support @-wildcard in user-mention autocomplete
Fixes: #234
1 parent 97ed6d5 commit a24bddb

File tree

6 files changed

+191
-41
lines changed

6 files changed

+191
-41
lines changed

assets/l10n/app_en.arb

+35
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,41 @@
543543
"@manyPeopleTyping": {
544544
"description": "Text to display when there are multiple users typing."
545545
},
546+
"all": "all",
547+
"@all": {
548+
"description": "Text for \"@all\" wildcard mention."
549+
},
550+
"everyone": "everyone",
551+
"@everyone": {
552+
"description": "Text for \"@everyone\" wildcard mention."
553+
},
554+
"channel": "channel",
555+
"@channel": {
556+
"description": "Text for \"@channel\" wildcard mention."
557+
},
558+
"stream": "stream",
559+
"@stream": {
560+
"description": "Text for \"@stream\" wildcard mention."
561+
},
562+
"topic": "topic",
563+
"@topic": {
564+
"description": "Text for \"@topic\" wildcard mention."
565+
},
566+
"notifyChannel": "Notify {value, select, channel{channel} other{stream}}",
567+
"@notifyChannel": {
568+
"description": "Description for \"@all\", \"@everyone\", \"@channel\", and \"@stream\" wildcard mentions in a channel or topic narrow.",
569+
"placeholders": {
570+
"value": {"type": "String"}
571+
}
572+
},
573+
"notifyRecipients": "Notify recipients",
574+
"@notifyRecipients": {
575+
"description": "Description for \"@all\" and \"@everyone\" wildcard mentions in a DM narrow."
576+
},
577+
"notifyTopic": "Notify topic",
578+
"@notifyTopic": {
579+
"description": "Description for \"@topic\" wildcard mention in a channel or topic narrow."
580+
},
546581
"messageIsEditedLabel": "EDITED",
547582
"@messageIsEditedLabel": {
548583
"description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"

lib/model/autocomplete.dart

+82-27
Original file line numberDiff line numberDiff line change
@@ -288,42 +288,29 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
288288
MentionAutocompleteView._({
289289
required super.store,
290290
required this.narrow,
291+
required this.wildcards,
291292
required this.sortedUsers,
292293
});
293294

294295
factory MentionAutocompleteView.init({
295296
required PerAccountStore store,
296297
required Narrow narrow,
298+
required List<Wildcard> wildcards,
297299
}) {
298300
final view = MentionAutocompleteView._(
299301
store: store,
300302
narrow: narrow,
303+
wildcards: wildcards,
301304
sortedUsers: _usersByRelevance(store: store, narrow: narrow),
302305
);
303306
store.autocompleteViewManager.registerMentionAutocomplete(view);
304307
return view;
305308
}
306309

307310
final Narrow narrow;
311+
final List<Wildcard> wildcards;
308312
final List<User> sortedUsers;
309313

310-
@override
311-
Future<List<MentionAutocompleteResult>?> computeResults() async {
312-
final results = <MentionAutocompleteResult>[];
313-
if (await filterCandidates(filter: _testUser,
314-
candidates: sortedUsers, results: results)) {
315-
return null;
316-
}
317-
return results;
318-
}
319-
320-
MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) {
321-
if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
322-
return UserMentionAutocompleteResult(userId: user.userId);
323-
}
324-
return null;
325-
}
326-
327314
static List<User> _usersByRelevance({
328315
required PerAccountStore store,
329316
required Narrow narrow,
@@ -377,8 +364,6 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
377364
required String? topic,
378365
required PerAccountStore store,
379366
}) {
380-
// TODO(#234): give preference to "all", "everyone" or "stream"
381-
382367
// TODO(#618): give preference to subscribed users first
383368

384369
if (streamId != null) {
@@ -483,6 +468,42 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
483468
return userAName.compareTo(userBName); // TODO(i18n): add locale-aware sorting
484469
}
485470

471+
bool _isChannelWildcardIncluded = false;
472+
473+
@override
474+
Future<List<MentionAutocompleteResult>?> computeResults() async {
475+
_isChannelWildcardIncluded = false;
476+
final results = <MentionAutocompleteResult>[];
477+
// give priority to wildcard mentions
478+
if (await filterCandidates(filter: _testWildcard,
479+
candidates: wildcards, results: results)) {
480+
return null;
481+
}
482+
if (await filterCandidates(filter: _testUser,
483+
candidates: sortedUsers, results: results)) {
484+
return null;
485+
}
486+
return results;
487+
}
488+
489+
MentionAutocompleteResult? _testWildcard(MentionAutocompleteQuery query, Wildcard wildcard) {
490+
if (query.testWildcard(wildcard)) {
491+
if (wildcard.type == WildcardType.channel) {
492+
if (_isChannelWildcardIncluded) return null;
493+
_isChannelWildcardIncluded = true;
494+
}
495+
return WildcardMentionAutocompleteResult(wildcardName: wildcard.name);
496+
}
497+
return null;
498+
}
499+
500+
MentionAutocompleteResult? _testUser(MentionAutocompleteQuery query, User user) {
501+
if (query.testUser(user, store.autocompleteViewManager.autocompleteDataCache)) {
502+
return UserMentionAutocompleteResult(userId: user.userId);
503+
}
504+
return null;
505+
}
506+
486507
@override
487508
void dispose() {
488509
store.autocompleteViewManager.unregisterMentionAutocomplete(this);
@@ -493,6 +514,37 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
493514
}
494515
}
495516

517+
class Wildcard {
518+
Wildcard({
519+
required this.name,
520+
required this.value,
521+
required this.fullDisplayName,
522+
required this.type,
523+
});
524+
525+
/// The name of the wildcard to be shown as part of [fullDisplayName] in autocomplete suggestions.
526+
///
527+
/// Ex: "channel", "stream", "topic", ...
528+
final String name;
529+
530+
/// The value to be put at the compose box after choosing an option from autocomplete.
531+
///
532+
/// Same as the [name], except for "stream" it is "channel" in FL >= 247 (server-9).
533+
final String value; // TODO(sever-9): remove, instead use [name]
534+
535+
/// The full name of the wildcard to be shown in autocomplete suggestions.
536+
///
537+
/// Ex: "all (Notify channel)" or "everyone (Notify recipients)".
538+
final String fullDisplayName;
539+
540+
final WildcardType type;
541+
}
542+
543+
enum WildcardType {
544+
channel,
545+
topic, // TODO(sever-8)
546+
}
547+
496548
abstract class AutocompleteQuery {
497549
AutocompleteQuery(this.raw)
498550
: _lowercaseWords = raw.toLowerCase().split(' ');
@@ -529,15 +581,14 @@ class MentionAutocompleteQuery extends AutocompleteQuery {
529581
/// Whether the user wants a silent mention (@_query, vs. @query).
530582
final bool silent;
531583

532-
bool testUser(User user, AutocompleteDataCache cache) {
533-
// TODO(#236) test email too, not just name
584+
bool testWildcard(Wildcard wildcard) {
585+
return wildcard.name.contains(raw.toLowerCase());
586+
}
534587

588+
bool testUser(User user, AutocompleteDataCache cache) {
535589
if (!user.isActive) return false;
536590

537-
return _testName(user, cache);
538-
}
539-
540-
bool _testName(User user, AutocompleteDataCache cache) {
591+
// TODO(#236) test email too, not just name
541592
return _testContainsQueryWords(cache.nameWordsForUser(user));
542593
}
543594

@@ -585,9 +636,13 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
585636
final int userId;
586637
}
587638

588-
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
639+
class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
640+
WildcardMentionAutocompleteResult({required this.wildcardName});
641+
642+
final String wildcardName;
643+
}
589644

590-
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
645+
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
591646

592647
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult> {
593648
TopicAutocompleteView._({required super.store, required this.streamId});

lib/model/compose.dart

+7-4
Original file line numberDiff line numberDiff line change
@@ -101,18 +101,21 @@ String wrapWithBacktickFence({required String content, String? infoString}) {
101101
return resultBuffer.toString();
102102
}
103103

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

113113
return '@${silent ? '_' : ''}**${user.fullName}${includeUserId ? '|${user.userId}' : ''}**';
114114
}
115115

116+
/// An @wildcard-mention, like @**channel**.
117+
String wildcardMention(String wildcard) => '@**$wildcard**';
118+
116119
/// https://spec.commonmark.org/0.30/#inline-link
117120
///
118121
/// The "link text" is made by enclosing [visibleText] in square brackets.
@@ -145,7 +148,7 @@ String quoteAndReplyPlaceholder(PerAccountStore store, {
145148
SendableNarrow.ofMessage(message, selfUserId: store.selfUserId),
146149
nearMessageId: message.id);
147150
// See note in [quoteAndReply] about asking `mention` to omit the |<id> part.
148-
return '${mention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
151+
return '${userMention(sender!, silent: true)} ${inlineLink('said', url)}: ' // TODO(i18n) ?
149152
'*(loading message ${message.id})*\n'; // TODO(i18n) ?
150153
}
151154

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

lib/widgets/autocomplete.dart

+61-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import 'package:flutter/material.dart';
2+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
23

4+
import '../model/store.dart';
35
import 'content.dart';
6+
import 'icons.dart';
47
import 'store.dart';
58
import '../model/autocomplete.dart';
69
import '../model/compose.dart';
@@ -164,7 +167,54 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
164167
@override
165168
MentionAutocompleteView initViewModel(BuildContext context) {
166169
final store = PerAccountStoreWidget.of(context);
167-
return MentionAutocompleteView.init(store: store, narrow: narrow);
170+
return MentionAutocompleteView.init(store: store, narrow: narrow, wildcards: _wildcards(context, store));
171+
}
172+
173+
List<Wildcard> _wildcards(BuildContext context, PerAccountStore store) {
174+
final isDmNarrow = narrow is DmNarrow;
175+
final isChannelWildcardAvailable = store.account.zulipFeatureLevel >= 247; // TODO(server-9)
176+
final isTopicWildcardAvailable = store.account.zulipFeatureLevel >= 188; // TODO(sever-8)
177+
final zulipLocalizations = ZulipLocalizations.of(context);
178+
return [
179+
Wildcard(
180+
name: zulipLocalizations.all,
181+
value: 'all',
182+
fullDisplayName: 'all (${isDmNarrow
183+
? zulipLocalizations.notifyRecipients
184+
: zulipLocalizations.notifyChannel(isChannelWildcardAvailable
185+
? "channel" : "stream")})',
186+
type: WildcardType.channel,
187+
),
188+
Wildcard(
189+
name: zulipLocalizations.everyone,
190+
value: 'everyone',
191+
fullDisplayName: 'everyone (${isDmNarrow
192+
? zulipLocalizations.notifyRecipients
193+
: zulipLocalizations.notifyChannel(isChannelWildcardAvailable
194+
? "channel" : "stream")})',
195+
type: WildcardType.channel,
196+
),
197+
if (!isDmNarrow) ...[
198+
if (isChannelWildcardAvailable) Wildcard(
199+
name: zulipLocalizations.channel,
200+
value: 'channel',
201+
fullDisplayName: 'channel (${zulipLocalizations.notifyChannel('channel')})',
202+
type: WildcardType.channel,
203+
),
204+
Wildcard(
205+
name: zulipLocalizations.stream,
206+
value: isChannelWildcardAvailable ? 'channel' : 'stream',
207+
fullDisplayName: 'stream (${zulipLocalizations.notifyChannel(isChannelWildcardAvailable ? 'channel' : 'stream')})',
208+
type: WildcardType.channel,
209+
),
210+
if (isTopicWildcardAvailable) Wildcard(
211+
name: zulipLocalizations.topic,
212+
value: 'topic',
213+
fullDisplayName: 'topic (${zulipLocalizations.notifyTopic})',
214+
type: WildcardType.topic,
215+
),
216+
],
217+
];
168218
}
169219

170220
void _onTapOption(BuildContext context, MentionAutocompleteResult option) {
@@ -182,7 +232,9 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
182232
case UserMentionAutocompleteResult(:var userId):
183233
// TODO(i18n) language-appropriate space character; check active keyboard?
184234
// (maybe handle centrally in `controller`)
185-
replacementString = '${mention(store.users[userId]!, silent: intent.query.silent, users: store.users)} ';
235+
replacementString = '${userMention(store.users[userId]!, silent: intent.query.silent, users: store.users)} ';
236+
case WildcardMentionAutocompleteResult(:var wildcardName):
237+
replacementString = '${wildcardMention(_wildcards(context, store).singleWhere((w) => w.name == wildcardName).value)} ';
186238
}
187239

188240
controller.value = intent.textEditingValue.replaced(
@@ -195,12 +247,17 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
195247

196248
@override
197249
Widget buildItem(BuildContext context, int index, MentionAutocompleteResult option) {
250+
final store = PerAccountStoreWidget.of(context);
198251
Widget avatar;
199252
String label;
200253
switch (option) {
201254
case UserMentionAutocompleteResult(:var userId):
202-
avatar = Avatar(userId: userId, size: 32, borderRadius: 3);
203-
label = PerAccountStoreWidget.of(context).users[userId]!.fullName;
255+
avatar = Avatar(userId: userId, size: 32, borderRadius: 3); // web uses 21px
256+
label = store.users[userId]!.fullName;
257+
case WildcardMentionAutocompleteResult(:var wildcardName):
258+
avatar = const Icon(ZulipIcons.bullhorn, size: 29); // web uses 19px
259+
print('wildcard name: $wildcardName');
260+
label = _wildcards(context, store).singleWhere((w) => w.name == wildcardName).fullDisplayName;
204261
}
205262
return InkWell(
206263
onTap: () {

test/model/compose_test.dart

+5-5
Original file line numberDiff line numberDiff line change
@@ -319,25 +319,25 @@ hello
319319
group('mention', () {
320320
final user = eg.user(userId: 123, fullName: 'Full Name');
321321
test('not silent', () {
322-
check(mention(user, silent: false)).equals('@**Full Name|123**');
322+
check(userMention(user, silent: false)).equals('@**Full Name|123**');
323323
});
324324
test('silent', () {
325-
check(mention(user, silent: true)).equals('@_**Full Name|123**');
325+
check(userMention(user, silent: true)).equals('@_**Full Name|123**');
326326
});
327327
test('`users` passed; has two users with same fullName', () async {
328328
final store = eg.store();
329329
await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName)]);
330-
check(mention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
330+
check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
331331
});
332332
test('`users` passed; has two same-name users but one of them is deactivated', () async {
333333
final store = eg.store();
334334
await store.addUsers([user, eg.user(userId: 5), eg.user(userId: 234, fullName: user.fullName, isActive: false)]);
335-
check(mention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
335+
check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name|123**');
336336
});
337337
test('`users` passed; user has unique fullName', () async {
338338
final store = eg.store();
339339
await store.addUsers([user, eg.user(userId: 234, fullName: 'Another Name')]);
340-
check(mention(user, silent: true, users: store.users)).equals('@_**Full Name**');
340+
check(userMention(user, silent: true, users: store.users)).equals('@_**Full Name**');
341341
});
342342
});
343343

test/widgets/autocomplete_test.dart

+1-1
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ void main() {
148148
await tester.tap(find.text('User Three'));
149149
await tester.pump();
150150
check(tester.widget<TextField>(composeInputFinder).controller!.text)
151-
.contains(mention(user3, users: store.users));
151+
.contains(userMention(user3, users: store.users));
152152
checkUserShown(user1, store, expected: false);
153153
checkUserShown(user2, store, expected: false);
154154
checkUserShown(user3, store, expected: false);

0 commit comments

Comments
 (0)