Skip to content

Commit 1863ddd

Browse files
committed
autocomplete: Add user-group results to mention autocomplete
Fixes zulip#233.
1 parent 4a6b3d8 commit 1863ddd

File tree

7 files changed

+260
-23
lines changed

7 files changed

+260
-23
lines changed

lib/model/autocomplete.dart

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,14 @@ class AutocompleteViewManager {
249249
autocompleteDataCache.invalidateUser(event.userId);
250250
}
251251

252+
void handleUserGroupRemoveEvent(UserGroupRemoveEvent event) {
253+
autocompleteDataCache.invalidateUserGroup(event.groupId);
254+
}
255+
256+
void handleUserGroupUpdateEvent(UserGroupUpdateEvent event) {
257+
autocompleteDataCache.invalidateUserGroup(event.groupId);
258+
}
259+
252260
/// Called when the app is reassembled during debugging, e.g. for hot reload.
253261
///
254262
/// Calls [AutocompleteView.reassemble] for all that are registered.
@@ -423,6 +431,7 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
423431
required this.localizations,
424432
required this.narrow,
425433
required this.sortedUsers,
434+
required this.sortedUserGroups,
426435
});
427436

428437
factory MentionAutocompleteView.init({
@@ -437,13 +446,15 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
437446
localizations: localizations,
438447
narrow: narrow,
439448
sortedUsers: _usersByRelevance(store: store, narrow: narrow),
449+
sortedUserGroups: _userGroupsByRelevance(store: store),
440450
);
441451
store.autocompleteViewManager.registerMentionAutocomplete(view);
442452
return view;
443453
}
444454

445455
final Narrow narrow;
446456
final List<User> sortedUsers;
457+
final List<UserGroup> sortedUserGroups;
447458
final ZulipLocalizations localizations;
448459

449460
static List<User> _usersByRelevance({
@@ -611,6 +622,30 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
611622
return userAName.compareTo(userBName); // TODO(i18n): add locale-aware sorting
612623
}
613624

625+
static List<UserGroup> _userGroupsByRelevance({required PerAccountStore store}) {
626+
return store.activeGroups.where((userGroup) => !userGroup.isSystemGroup).toList()
627+
..sort(_userGroupComparator(store: store));
628+
}
629+
630+
static int Function(UserGroup, UserGroup) _userGroupComparator({
631+
required PerAccountStore store,
632+
}) {
633+
// See also [MentionAutocompleteQuery._rankUserGroupResult];
634+
// that ranking takes precedence over this.
635+
636+
return (userGroupA, userGroupB) =>
637+
compareGroupsByAlphabeticalOrder(userGroupA, userGroupB, store: store);
638+
}
639+
640+
static int compareGroupsByAlphabeticalOrder(UserGroup userGroupA, UserGroup userGroupB,
641+
{required PerAccountStore store}) {
642+
final groupAName = store.autocompleteViewManager.autocompleteDataCache
643+
.lowercaseNameForUserGroup(userGroupA);
644+
final groupBName = store.autocompleteViewManager.autocompleteDataCache
645+
.lowercaseNameForUserGroup(userGroupB);
646+
return groupAName.compareTo(groupBName); // TODO(i18n): add locale-aware sorting
647+
}
648+
614649
void computeWildcardMentionResults({
615650
required List<MentionAutocompleteResult> results,
616651
required bool isComposingChannelMessage,
@@ -654,6 +689,11 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
654689
return null;
655690
}
656691

692+
if (await filterCandidates(filter: _testUserGroup,
693+
candidates: sortedUserGroups, results: unsorted)) {
694+
return null;
695+
}
696+
657697
return bucketSort(unsorted,
658698
(r) => r.rank, numBuckets: MentionAutocompleteQuery._numResultRanks);
659699
}
@@ -662,6 +702,10 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
662702
return query.testUser(user, store);
663703
}
664704

705+
MentionAutocompleteResult? _testUserGroup(MentionAutocompleteQuery query, UserGroup userGroup) {
706+
return query.testUserGroup(userGroup, store);
707+
}
708+
665709
@override
666710
void dispose() {
667711
store.autocompleteViewManager.unregisterMentionAutocomplete(this);
@@ -727,7 +771,8 @@ abstract class AutocompleteQuery {
727771
}
728772
}
729773

730-
/// The match quality of a [User.fullName] to a mention autocomplete query.
774+
/// The match quality of a [User.fullName] or [UserGroup.name]
775+
/// to a mention autocomplete query.
731776
///
732777
/// All matches are case-insensitive.
733778
enum NameMatchQuality {
@@ -895,16 +940,30 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
895940
return EmailMatchQuality.localPartExactAndDomainPrefix;
896941
}
897942

943+
MentionAutocompleteResult? testUserGroup(UserGroup userGroup, PerAccountStore store) {
944+
final cache = store.autocompleteViewManager.autocompleteDataCache;
945+
946+
final nameMatchQuality = _matchName(
947+
lowercaseName: cache.lowercaseNameForUserGroup(userGroup),
948+
lowercaseNameWords: cache.lowercaseNameWordsForUserGroup(userGroup));
949+
950+
if (nameMatchQuality == null) return null;
951+
952+
return UserGroupMentionAutocompleteResult(
953+
id: userGroup.id,
954+
rank: _rankUserGroupResult(userGroup, nameMatchQuality: nameMatchQuality));
955+
}
956+
898957
/// A measure of a wildcard result's quality in the context of the query,
899958
/// from 0 (best) to one less than [_numResultRanks].
900959
///
901-
/// See also [_rankUserResult].
960+
/// See also [_rankUserResult] and [_rankUserGroupResult].
902961
static const _rankWildcardResult = 0;
903962

904963
/// A measure of a user result's quality in the context of the query,
905964
/// from 0 (best) to one less than [_numResultRanks].
906965
///
907-
/// See also [_rankWildcardResult].
966+
/// See also [_rankWildcardResult] and [_rankUserGroupResult].
908967
static int _rankUserResult(User user, {
909968
required NameMatchQuality? nameMatchQuality,
910969
required EmailMatchQuality? emailMatchQuality,
@@ -918,15 +977,29 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
918977
}
919978
assert(emailMatchQuality != null);
920979
return switch (emailMatchQuality!) {
921-
EmailMatchQuality.localPartExactAndDomainPrefix => 4,
922-
EmailMatchQuality.localPartExact => 5,
923-
EmailMatchQuality.prefix => 6,
980+
EmailMatchQuality.localPartExactAndDomainPrefix => 7,
981+
EmailMatchQuality.localPartExact => 8,
982+
EmailMatchQuality.prefix => 9,
983+
};
984+
}
985+
986+
/// A measure of a user-group result's quality in the context of the query,
987+
/// from 0 (best) to one less than [_numResultRanks].
988+
///
989+
/// See also [_rankWildcardResult] and [_rankUserResult].
990+
static int _rankUserGroupResult(UserGroup userGroup, {
991+
required NameMatchQuality nameMatchQuality,
992+
}) {
993+
return switch (nameMatchQuality) {
994+
NameMatchQuality.exact => 4,
995+
NameMatchQuality.totalPrefix => 5,
996+
NameMatchQuality.wordPrefixes => 6,
924997
};
925998
}
926999

9271000
/// The number of possible values returned by
9281001
/// [_rankWildcardResult] and [_rankUserResult].
929-
static const _numResultRanks = 7;
1002+
static const _numResultRanks = 10;
9301003

9311004
@override
9321005
String toString() {
@@ -1007,12 +1080,31 @@ class AutocompleteDataCache {
10071080
return (split[0], split[1]);
10081081
}
10091082

1083+
final Map<int, String> _lowercaseNamesByUserGroup = {};
1084+
1085+
/// The lowercase `name` of [userGroup].
1086+
String lowercaseNameForUserGroup(UserGroup userGroup) {
1087+
return _lowercaseNamesByUserGroup[userGroup.id] ??= userGroup.name.toLowerCase();
1088+
}
1089+
1090+
final Map<int, List<String>> _lowercaseNameWordsByUserGroup = {};
1091+
1092+
List<String> lowercaseNameWordsForUserGroup(UserGroup userGroup) {
1093+
return _lowercaseNameWordsByUserGroup[userGroup.id]
1094+
??= lowercaseNameForUserGroup(userGroup).split(' ');
1095+
}
1096+
10101097
void invalidateUser(int userId) {
10111098
_lowercaseNamesByUser.remove(userId);
10121099
_lowercaseNameWordsByUser.remove(userId);
10131100
_lowercaseEmailsByUser.remove(userId);
10141101
_lowercaseEmailPartsByUser.remove(userId);
10151102
}
1103+
1104+
void invalidateUserGroup(int id) {
1105+
_lowercaseNamesByUserGroup.remove(id);
1106+
_lowercaseNameWordsByUserGroup.remove(id);
1107+
}
10161108
}
10171109

10181110
/// A result the user chose, or might choose, from an autocomplete interaction.
@@ -1062,7 +1154,7 @@ sealed class MentionAutocompleteResult extends ComposeAutocompleteResult {
10621154
// https://github.com/zulip/zulip/blob/afdf20c67/web/src/typeahead_helper.ts#L472
10631155
//
10641156
// Behavior we have that web doesn't and might like to follow:
1065-
// - A "word-prefixes" match quality on user names:
1157+
// - A "word-prefixes" match quality on user and user-group names:
10661158
// see [NameMatchQuality.wordPrefixes], which we rank on.
10671159
// - Two email match qualities when the query matches the "local part";
10681160
// see [EmailMatchQuality.localPartExactAndDomainPrefix] and
@@ -1110,7 +1202,15 @@ class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
11101202
final int rank;
11111203
}
11121204

1113-
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
1205+
/// An autocomplete result for an @-mention of a user group.
1206+
class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
1207+
UserGroupMentionAutocompleteResult({required this.id, required this.rank});
1208+
1209+
final int id;
1210+
1211+
@override
1212+
final int rank;
1213+
}
11141214

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

lib/model/compose.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,10 @@ String wildcardMention(WildcardMentionOption wildcardOption, {
181181
return '@**$name**';
182182
}
183183

184+
/// An @-mention of a user group, like @*mobile*.
185+
String userGroupMention(String userGroupName, {bool silent = false}) =>
186+
'@${silent ? '_' : ''}*$userGroupName*';
187+
184188
/// https://spec.commonmark.org/0.30/#inline-link
185189
///
186190
/// The "link text" is made by enclosing [visibleText] in square brackets.

lib/widgets/autocomplete.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,19 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
208208
replacementString = '${userMention(user, silent: query.silent, users: store)} ';
209209
case WildcardMentionAutocompleteResult(:var wildcardOption):
210210
replacementString = '${wildcardMention(wildcardOption, store: store)} ';
211+
case UserGroupMentionAutocompleteResult(:final id):
212+
if (query is! MentionAutocompleteQuery) {
213+
return; // Shrug; similar to `intent == null` case above.
214+
}
215+
final userGroup = store.getGroup(id);
216+
if (userGroup == null) {
217+
// Don't crash on theoretical race between async results-filtering
218+
// and losing data for the group.
219+
return;
220+
}
221+
// TODO(i18n) language-appropriate space character; check active keyboard?
222+
// (maybe handle centrally in `controller`)
223+
replacementString = '${userGroupMention(userGroup.name, silent: query.silent)} ';
211224
}
212225

213226
controller.value = intent.textEditingValue.replaced(
@@ -286,6 +299,16 @@ class MentionAutocompleteItem extends StatelessWidget {
286299
emoji = UserStatusEmoji(userId: userId, size: 18,
287300
padding: const EdgeInsetsDirectional.only(start: 5.0));
288301
sublabel = store.getUser(userId)?.deliveryEmail;
302+
case UserGroupMentionAutocompleteResult(:final id):
303+
final group = store.getGroup(id);
304+
avatar = SizedBox.square(dimension: 36,
305+
child: const Icon(ZulipIcons.three_person, size: 24));
306+
label = group?.name
307+
// Don't crash on theoretical race between async results-filtering
308+
// and losing data for the group.
309+
?? '';
310+
emoji = null;
311+
sublabel = group?.description;
289312
case WildcardMentionAutocompleteResult(:var wildcardOption):
290313
avatar = SizedBox.square(dimension: 36,
291314
child: const Icon(ZulipIcons.three_person, size: 24));

test/model/autocomplete_checks.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ extension WildcardMentionAutocompleteResultChecks on Subject<WildcardMentionAuto
2525
Subject<WildcardMentionOption> get wildcardOption => has((x) => x.wildcardOption, 'wildcardOption');
2626
}
2727

28+
extension UserGroupMentionAutocompleteResultChecks on Subject<UserGroupMentionAutocompleteResult> {
29+
Subject<int> get id => has((r) => r.id, 'id');
30+
}
31+
2832
extension TopicAutocompleteResultChecks on Subject<TopicAutocompleteResult> {
2933
Subject<TopicName> get topic => has((r) => r.topic, 'topic');
3034
}

0 commit comments

Comments
 (0)