Skip to content

Commit 76fd1ac

Browse files
committed
autocomplete: Support @-wildcard in user-mention autocomplete
Fixes: #234
1 parent dde8747 commit 76fd1ac

File tree

7 files changed

+184
-60
lines changed

7 files changed

+184
-60
lines changed

lib/api/model/model.dart

+38-1
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,43 @@ enum Emojiset {
178178
.map((key, value) => MapEntry(value, key));
179179
}
180180

181+
sealed class MentionableUser {}
182+
183+
class Wildcard extends MentionableUser {
184+
Wildcard({
185+
required this.name,
186+
required this.value,
187+
required this.fullDisplayName,
188+
required this.type,
189+
required this.index,
190+
});
191+
192+
/// The name of the wildcard to be shown as part of [fullDisplayName] in autocomplete suggestions.
193+
///
194+
/// Ex: "channel", "stream", "topic", ...
195+
final String name;
196+
197+
/// The value to be put at the compose box after choosing an option from autocomplete.
198+
///
199+
/// The same as [name], except for "stream" it is "channel" in FL >= 247 (server-9).
200+
final String value; // TODO(sever-9): remove, instead use [name]
201+
202+
/// The full name of the wildcard to be shown in autocomplete suggestions.
203+
///
204+
/// Ex: "all (Notify channel)" or "everyone (Notify recipients)".
205+
final String fullDisplayName;
206+
207+
final WildcardType type;
208+
209+
/// An integer solely used for sorting purposes.
210+
final int index;
211+
}
212+
213+
enum WildcardType {
214+
channel,
215+
topic, // TODO(sever-8)
216+
}
217+
181218
/// As in [InitialSnapshot.realmUsers], [InitialSnapshot.realmNonActiveUsers], and [InitialSnapshot.crossRealmBots].
182219
///
183220
/// In the Zulip API, the items in realm_users, realm_non_active_users, and
@@ -187,7 +224,7 @@ enum Emojiset {
187224
/// For docs, search for "realm_users:"
188225
/// in <https://zulip.com/api/register-queue>.
189226
@JsonSerializable(fieldRename: FieldRename.snake)
190-
class User {
227+
class User extends MentionableUser {
191228
// When adding a field to this class:
192229
// * If a [RealmUserUpdateEvent] can update it, be sure to add
193230
// that case to [RealmUserUpdateEvent] and its handler.

lib/model/autocomplete.dart

+74-46
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ abstract class AutocompleteView<QueryT extends AutocompleteQuery, ResultT extend
256256
}
257257
}
258258

259-
class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery, MentionAutocompleteResult, User> {
259+
class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery, MentionAutocompleteResult, MentionableUser> {
260260
MentionAutocompleteView._({
261261
required super.store,
262262
required this.narrow,
@@ -277,13 +277,13 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
277277
}
278278

279279
final Narrow narrow;
280-
final List<User> sortedUsers;
280+
final List<MentionableUser> sortedUsers;
281281

282-
static List<User> _usersByRelevance({
282+
static List<MentionableUser> _usersByRelevance({
283283
required PerAccountStore store,
284284
required Narrow narrow,
285285
}) {
286-
return store.users.values.toList()
286+
return [...store.users.values, ...store.wildcardsForNarrow(narrow).values]
287287
..sort(_comparator(store: store, narrow: narrow));
288288
}
289289

@@ -303,7 +303,7 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
303303
return _comparator(store: store, narrow: narrow)(userA, userB);
304304
}
305305

306-
static int Function(User, User) _comparator({
306+
static int Function(MentionableUser, MentionableUser) _comparator({
307307
required PerAccountStore store,
308308
required Narrow narrow,
309309
}) {
@@ -326,29 +326,36 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
326326
store: store);
327327
}
328328

329-
static int _compareByRelevance(User userA, User userB, {
329+
static int _compareByRelevance(MentionableUser userA, MentionableUser userB, {
330330
required int? streamId,
331331
required String? topic,
332332
required PerAccountStore store,
333333
}) {
334-
// TODO(#234): give preference to "all", "everyone" or "stream"
334+
switch ((userA, userB)) {
335+
case (Wildcard _, User _):
336+
return -1;
337+
case (User _, Wildcard _):
338+
return 1;
339+
case (Wildcard wildcardA, Wildcard wildcardB):
340+
return wildcardA.index.compareTo(wildcardB.index);
341+
case (User userA, User userB):
342+
// TODO(#618): give preference to subscribed users first
343+
344+
if (streamId != null) {
345+
final recencyResult = compareByRecency(userA, userB,
346+
streamId: streamId,
347+
topic: topic,
348+
store: store);
349+
if (recencyResult != 0) return recencyResult;
350+
}
351+
final dmsResult = compareByDms(userA, userB, store: store);
352+
if (dmsResult != 0) return dmsResult;
335353

336-
// TODO(#618): give preference to subscribed users first
354+
final botStatusResult = compareByBotStatus(userA, userB);
355+
if (botStatusResult != 0) return botStatusResult;
337356

338-
if (streamId != null) {
339-
final recencyResult = compareByRecency(userA, userB,
340-
streamId: streamId,
341-
topic: topic,
342-
store: store);
343-
if (recencyResult != 0) return recencyResult;
357+
return compareByAlphabeticalOrder(userA, userB, store: store);
344358
}
345-
final dmsResult = compareByDms(userA, userB, store: store);
346-
if (dmsResult != 0) return dmsResult;
347-
348-
final botStatusResult = compareByBotStatus(userA, userB);
349-
if (botStatusResult != 0) return botStatusResult;
350-
351-
return compareByAlphabeticalOrder(userA, userB, store: store);
352359
}
353360

354361
/// Determines which of the two users has more recent activity (messages sent
@@ -385,19 +392,6 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
385392
streamId: streamId, senderId: userB.userId));
386393
}
387394

388-
@override
389-
Iterable<User> getSortedItemsToTest(MentionAutocompleteQuery query) {
390-
return sortedUsers;
391-
}
392-
393-
@override
394-
MentionAutocompleteResult? testItem(MentionAutocompleteQuery query, User item) {
395-
if (query.testUser(item, store.autocompleteViewManager.autocompleteDataCache)) {
396-
return UserMentionAutocompleteResult(userId: item.userId);
397-
}
398-
return null;
399-
}
400-
401395
/// Determines which of the two users is more recent in DM conversations.
402396
///
403397
/// Returns a negative number if [userA] is more recent than [userB],
@@ -450,6 +444,37 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
450444
return userAName.compareTo(userBName); // TODO(i18n): add locale-aware sorting
451445
}
452446

447+
@override
448+
Iterable<MentionableUser> getSortedItemsToTest(MentionAutocompleteQuery query) {
449+
return sortedUsers;
450+
}
451+
452+
bool _isChannelWildcardIncluded = false;
453+
454+
@override
455+
MentionAutocompleteResult? testItem(MentionAutocompleteQuery query, MentionableUser item) {
456+
if (query.testUser(item, store.autocompleteViewManager.autocompleteDataCache)) {
457+
switch (item) {
458+
case User user:
459+
return UserMentionAutocompleteResult(userId: user.userId);
460+
case Wildcard wildcard:
461+
final isChannelWildcard = wildcard.type == WildcardType.channel;
462+
if (isChannelWildcard && _isChannelWildcardIncluded) break;
463+
if (isChannelWildcard) {
464+
_isChannelWildcardIncluded = true;
465+
}
466+
return WildcardMentionAutocompleteResult(wildcardName: wildcard.name);
467+
}
468+
}
469+
return null;
470+
}
471+
472+
@override
473+
Future<void> _startSearch(MentionAutocompleteQuery query) async {
474+
_isChannelWildcardIncluded = false;
475+
return super._startSearch(query);
476+
}
477+
453478
@override
454479
void dispose() {
455480
store.autocompleteViewManager.unregisterMentionAutocomplete(this);
@@ -496,16 +521,15 @@ class MentionAutocompleteQuery extends AutocompleteQuery {
496521
/// Whether the user wants a silent mention (@_query, vs. @query).
497522
final bool silent;
498523

499-
bool testUser(User user, AutocompleteDataCache cache) {
500-
// TODO(#236) test email too, not just name
501-
502-
if (!user.isActive) return false;
503-
504-
return _testName(user, cache);
505-
}
506-
507-
bool _testName(User user, AutocompleteDataCache cache) {
508-
return _testContainsQueryWords(cache.nameWordsForUser(user));
524+
bool testUser(MentionableUser user, AutocompleteDataCache cache) {
525+
switch (user) {
526+
case User():
527+
if (!user.isActive) return false;
528+
// TODO(#236) test email too, not just name
529+
return _testContainsQueryWords(cache.nameWordsForUser(user));
530+
case Wildcard():
531+
return user.name.contains(raw.toLowerCase());
532+
}
509533
}
510534

511535
@override
@@ -552,9 +576,13 @@ class UserMentionAutocompleteResult extends MentionAutocompleteResult {
552576
final int userId;
553577
}
554578

555-
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
579+
class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
580+
WildcardMentionAutocompleteResult({required this.wildcardName});
581+
582+
final String wildcardName;
583+
}
556584

557-
// TODO(#234): // class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
585+
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
558586

559587
class TopicAutocompleteView extends AutocompleteView<TopicAutocompleteQuery, TopicAutocompleteResult, String> {
560588
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/model/store.dart

+49
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import 'autocomplete.dart';
2222
import 'database.dart';
2323
import 'message.dart';
2424
import 'message_list.dart';
25+
import 'narrow.dart';
2526
import 'recent_dm_conversations.dart';
2627
import 'recent_senders.dart';
2728
import 'channel.dart';
@@ -341,6 +342,54 @@ class PerAccountStore extends ChangeNotifier with ChannelStore, MessageStore {
341342

342343
final Map<int, User> users;
343344

345+
Map<String, Wildcard> wildcardsForNarrow(Narrow narrow) => Map.fromEntries(
346+
_wildcardsForNarrow(narrow).map((w) => MapEntry(w.name, w)));
347+
348+
List<Wildcard> _wildcardsForNarrow(Narrow narrow) {
349+
final isDmNarrow = narrow is DmNarrow;
350+
final isChannelWildcardAvailable = account.zulipFeatureLevel >= 247; // TODO(server-9)
351+
final isTopicWildcardAvailable = account.zulipFeatureLevel >= 188; // TODO(sever-8)
352+
return [
353+
Wildcard(
354+
name: 'all',
355+
value: 'all',
356+
fullDisplayName: 'all (Notify ${isDmNarrow ? 'recipients' : isChannelWildcardAvailable ? 'channel' : 'stream'})',
357+
type: WildcardType.channel,
358+
index: 0,
359+
),
360+
Wildcard(
361+
name: 'everyone',
362+
value: 'everyone',
363+
fullDisplayName: 'everyone (Notify ${isDmNarrow ? 'recipients' : isChannelWildcardAvailable ? 'channel' : 'stream'})',
364+
type: WildcardType.channel,
365+
index: 1,
366+
),
367+
if (!isDmNarrow) ...[
368+
if (isChannelWildcardAvailable) Wildcard(
369+
name: 'channel',
370+
value: 'channel',
371+
fullDisplayName: 'channel (Notify channel)',
372+
type: WildcardType.channel,
373+
index: 2,
374+
),
375+
Wildcard(
376+
name: 'stream',
377+
value: isChannelWildcardAvailable ? 'channel' : 'stream',
378+
fullDisplayName: 'stream (Notify ${isChannelWildcardAvailable ? 'channel' : 'stream'})',
379+
type: WildcardType.channel,
380+
index: 3,
381+
),
382+
if (isTopicWildcardAvailable) Wildcard(
383+
name: 'topic',
384+
value: 'topic',
385+
fullDisplayName: 'topic (Notify topic)',
386+
type: WildcardType.topic,
387+
index: 4,
388+
),
389+
],
390+
];
391+
}
392+
344393
final TypingStatus typingStatus;
345394

346395
////////////////////////////////

lib/widgets/autocomplete.dart

+10-3
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
22

33
import '../api/model/model.dart';
44
import 'content.dart';
5+
import 'icons.dart';
56
import 'store.dart';
67
import '../model/autocomplete.dart';
78
import '../model/compose.dart';
@@ -145,7 +146,7 @@ class _AutocompleteFieldState<QueryT extends AutocompleteQuery, ResultT extends
145146
}
146147
}
147148

148-
class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, MentionAutocompleteResult, User> {
149+
class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, MentionAutocompleteResult, MentionableUser> {
149150
const ComposeAutocomplete({
150151
super.key,
151152
required this.narrow,
@@ -183,7 +184,9 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
183184
case UserMentionAutocompleteResult(:var userId):
184185
// TODO(i18n) language-appropriate space character; check active keyboard?
185186
// (maybe handle centrally in `controller`)
186-
replacementString = '${mention(store.users[userId]!, silent: intent.query.silent, users: store.users)} ';
187+
replacementString = '${userMention(store.users[userId]!, silent: intent.query.silent, users: store.users)} ';
188+
case WildcardMentionAutocompleteResult(:var wildcardName):
189+
replacementString = '${wildcardMention(store.wildcardsForNarrow(narrow)[wildcardName]!.value)} ';
187190
}
188191

189192
controller.value = intent.textEditingValue.replaced(
@@ -196,12 +199,16 @@ class ComposeAutocomplete extends AutocompleteField<MentionAutocompleteQuery, Me
196199

197200
@override
198201
Widget buildItem(BuildContext context, int index, MentionAutocompleteResult option) {
202+
final store = PerAccountStoreWidget.of(context);
199203
Widget avatar;
200204
String label;
201205
switch (option) {
202206
case UserMentionAutocompleteResult(:var userId):
203207
avatar = Avatar(userId: userId, size: 32, borderRadius: 3);
204-
label = PerAccountStoreWidget.of(context).users[userId]!.fullName;
208+
label = store.users[userId]!.fullName;
209+
case WildcardMentionAutocompleteResult(:var wildcardName):
210+
avatar = const Icon(ZulipIcons.bullhorn, size: 32);
211+
label = store.wildcardsForNarrow(narrow)[wildcardName]!.fullDisplayName;
205212
}
206213
return InkWell(
207214
onTap: () {

0 commit comments

Comments
 (0)