Skip to content

Commit 8ca8bdb

Browse files
committed
autocomplete: Add user-group results to mention autocomplete
Fixes zulip#233.
1 parent 15be5b4 commit 8ca8bdb

File tree

7 files changed

+257
-20
lines changed

7 files changed

+257
-20
lines changed

lib/model/autocomplete.dart

Lines changed: 107 additions & 7 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);
@@ -728,7 +772,8 @@ abstract class AutocompleteQuery {
728772
}
729773
}
730774

731-
/// The match quality of a [User.fullName] to a mention autocomplete query.
775+
/// The match quality of a [User.fullName] or [UserGroup.name]
776+
/// to a mention autocomplete query.
732777
///
733778
/// All matches are case-insensitive.
734779
enum NameMatchQuality {
@@ -836,10 +881,24 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
836881
return lowercaseEmail.startsWith(_lowercase);
837882
}
838883

884+
MentionAutocompleteResult? testUserGroup(UserGroup userGroup, PerAccountStore store) {
885+
final cache = store.autocompleteViewManager.autocompleteDataCache;
886+
887+
final nameMatchQuality = _matchName(
888+
normalizedName: cache.lowercaseNameForUserGroup(userGroup),
889+
normalizedNameWords: cache.lowercaseNameWordsForUserGroup(userGroup));
890+
891+
if (nameMatchQuality == null) return null;
892+
893+
return UserGroupMentionAutocompleteResult(
894+
id: userGroup.id,
895+
rank: _rankUserGroupResult(userGroup, nameMatchQuality: nameMatchQuality));
896+
}
897+
839898
/// A measure of a wildcard result's quality in the context of the query,
840899
/// from 0 (best) to one less than [_numResultRanks].
841900
///
842-
/// See also [_rankUserResult].
901+
/// See also [_rankUserResult] and [_rankUserGroupResult].
843902
static const _rankWildcardResult = 0;
844903

845904
/// A measure of a user result's quality in the context of the query,
@@ -848,7 +907,7 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
848907
/// When [nameMatchQuality] is non-null (the name matches),
849908
/// callers should skip computing [matchesEmail] and pass null for that.
850909
///
851-
/// See also [_rankWildcardResult].
910+
/// See also [_rankWildcardResult] and [_rankUserGroupResult].
852911
static int _rankUserResult(User user, {
853912
required NameMatchQuality? nameMatchQuality,
854913
required bool? matchesEmail,
@@ -862,12 +921,26 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
862921
};
863922
}
864923
assert(matchesEmail == true);
865-
return 4;
924+
return 7;
925+
}
926+
927+
/// A measure of a user-group result's quality in the context of the query,
928+
/// from 0 (best) to one less than [_numResultRanks].
929+
///
930+
/// See also [_rankWildcardResult] and [_rankUserResult].
931+
static int _rankUserGroupResult(UserGroup userGroup, {
932+
required NameMatchQuality nameMatchQuality,
933+
}) {
934+
return switch (nameMatchQuality) {
935+
NameMatchQuality.exact => 4,
936+
NameMatchQuality.totalPrefix => 5,
937+
NameMatchQuality.wordPrefixes => 6,
938+
};
866939
}
867940

868941
/// The number of possible values returned by
869942
/// [_rankWildcardResult] and [_rankUserResult].
870-
static const _numResultRanks = 5;
943+
static const _numResultRanks = 8;
871944

872945
@override
873946
String toString() {
@@ -922,11 +995,30 @@ class AutocompleteDataCache {
922995
return _normalizedEmailsByUser[user.userId] ??= user.deliveryEmail?.toLowerCase();
923996
}
924997

998+
final Map<int, String> _lowercaseNamesByUserGroup = {};
999+
1000+
/// The normalized `name` of [userGroup].
1001+
String lowercaseNameForUserGroup(UserGroup userGroup) {
1002+
return _lowercaseNamesByUserGroup[userGroup.id] ??= userGroup.name.toLowerCase();
1003+
}
1004+
1005+
final Map<int, List<String>> _lowercaseNameWordsByUserGroup = {};
1006+
1007+
List<String> lowercaseNameWordsForUserGroup(UserGroup userGroup) {
1008+
return _lowercaseNameWordsByUserGroup[userGroup.id]
1009+
??= lowercaseNameForUserGroup(userGroup).split(' ');
1010+
}
1011+
9251012
void invalidateUser(int userId) {
9261013
_normalizedNamesByUser.remove(userId);
9271014
_normalizedNameWordsByUser.remove(userId);
9281015
_normalizedEmailsByUser.remove(userId);
9291016
}
1017+
1018+
void invalidateUserGroup(int id) {
1019+
_lowercaseNamesByUserGroup.remove(id);
1020+
_lowercaseNameWordsByUserGroup.remove(id);
1021+
}
9301022
}
9311023

9321024
/// A result the user chose, or might choose, from an autocomplete interaction.
@@ -976,7 +1068,7 @@ sealed class MentionAutocompleteResult extends ComposeAutocompleteResult {
9761068
// https://github.com/zulip/zulip/blob/afdf20c67/web/src/typeahead_helper.ts#L472
9771069
//
9781070
// Behavior we have that web doesn't and might like to follow:
979-
// - A "word-prefixes" match quality on user names:
1071+
// - A "word-prefixes" match quality on user and user-group names:
9801072
// see [NameMatchQuality.wordPrefixes], which we rank on.
9811073
//
9821074
// Behavior web has that seems undesired, which we don't plan to follow:
@@ -1021,7 +1113,15 @@ class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
10211113
final int rank;
10221114
}
10231115

1024-
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
1116+
/// An autocomplete result for an @-mention of a user group.
1117+
class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
1118+
UserGroupMentionAutocompleteResult({required this.id, required this.rank});
1119+
1120+
final int id;
1121+
1122+
@override
1123+
final int rank;
1124+
}
10251125

10261126
/// An autocomplete interaction for choosing a topic for a message.
10271127
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)