Skip to content

Commit d5dddb3

Browse files
chrisbobbegnprice
authored andcommitted
autocomplete: Add user-group results to mention autocomplete
Fixes #233.
1 parent 7be6826 commit d5dddb3

File tree

7 files changed

+263
-23
lines changed

7 files changed

+263
-23
lines changed

lib/model/autocomplete.dart

Lines changed: 111 additions & 8 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,33 @@ 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
627+
// TODO(#1776) Follow new "Who can mention this group" setting instead
628+
.where((userGroup) => !userGroup.isSystemGroup)
629+
.toList()
630+
..sort(_userGroupComparator(store: store));
631+
}
632+
633+
static int Function(UserGroup, UserGroup) _userGroupComparator({
634+
required PerAccountStore store,
635+
}) {
636+
// See also [MentionAutocompleteQuery._rankUserGroupResult];
637+
// that ranking takes precedence over this.
638+
639+
return (userGroupA, userGroupB) =>
640+
compareGroupsByAlphabeticalOrder(userGroupA, userGroupB, store: store);
641+
}
642+
643+
static int compareGroupsByAlphabeticalOrder(UserGroup userGroupA, UserGroup userGroupB,
644+
{required PerAccountStore store}) {
645+
final groupAName = store.autocompleteViewManager.autocompleteDataCache
646+
.lowercaseNameForUserGroup(userGroupA);
647+
final groupBName = store.autocompleteViewManager.autocompleteDataCache
648+
.lowercaseNameForUserGroup(userGroupB);
649+
return groupAName.compareTo(groupBName); // TODO(i18n): add locale-aware sorting
650+
}
651+
614652
void computeWildcardMentionResults({
615653
required List<MentionAutocompleteResult> results,
616654
required bool isComposingChannelMessage,
@@ -654,6 +692,11 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
654692
return null;
655693
}
656694

695+
if (await filterCandidates(filter: _testUserGroup,
696+
candidates: sortedUserGroups, results: unsorted)) {
697+
return null;
698+
}
699+
657700
return bucketSort(unsorted,
658701
(r) => r.rank, numBuckets: MentionAutocompleteQuery._numResultRanks);
659702
}
@@ -662,6 +705,10 @@ class MentionAutocompleteView extends AutocompleteView<MentionAutocompleteQuery,
662705
return query.testUser(user, store);
663706
}
664707

708+
MentionAutocompleteResult? _testUserGroup(MentionAutocompleteQuery query, UserGroup userGroup) {
709+
return query.testUserGroup(userGroup, store);
710+
}
711+
665712
@override
666713
void dispose() {
667714
store.autocompleteViewManager.unregisterMentionAutocomplete(this);
@@ -728,7 +775,8 @@ abstract class AutocompleteQuery {
728775
}
729776
}
730777

731-
/// The match quality of a [User.fullName] to a mention autocomplete query.
778+
/// The match quality of a [User.fullName] or [UserGroup.name]
779+
/// to a mention autocomplete query.
732780
///
733781
/// All matches are case-insensitive.
734782
enum NameMatchQuality {
@@ -830,10 +878,24 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
830878
return lowercaseEmail.startsWith(_lowercase);
831879
}
832880

881+
MentionAutocompleteResult? testUserGroup(UserGroup userGroup, PerAccountStore store) {
882+
final cache = store.autocompleteViewManager.autocompleteDataCache;
883+
884+
final nameMatchQuality = _matchName(
885+
normalizedName: cache.lowercaseNameForUserGroup(userGroup),
886+
normalizedNameWords: cache.lowercaseNameWordsForUserGroup(userGroup));
887+
888+
if (nameMatchQuality == null) return null;
889+
890+
return UserGroupMentionAutocompleteResult(
891+
groupId: userGroup.id,
892+
rank: _rankUserGroupResult(userGroup, nameMatchQuality: nameMatchQuality));
893+
}
894+
833895
/// A measure of a wildcard result's quality in the context of the query,
834896
/// from 0 (best) to one less than [_numResultRanks].
835897
///
836-
/// See also [_rankUserResult].
898+
/// See also [_rankUserResult] and [_rankUserGroupResult].
837899
static const _rankWildcardResult = 0;
838900

839901
/// A measure of a user result's quality in the context of the query,
@@ -842,7 +904,7 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
842904
/// When [nameMatchQuality] is non-null (the name matches),
843905
/// callers should skip computing [matchesEmail] and pass null for that.
844906
///
845-
/// See also [_rankWildcardResult].
907+
/// See also [_rankWildcardResult] and [_rankUserGroupResult].
846908
static int _rankUserResult(User user, {
847909
required NameMatchQuality? nameMatchQuality,
848910
required bool? matchesEmail,
@@ -856,12 +918,26 @@ class MentionAutocompleteQuery extends ComposeAutocompleteQuery {
856918
};
857919
}
858920
assert(matchesEmail == true);
859-
return 4;
921+
return 7;
922+
}
923+
924+
/// A measure of a user-group result's quality in the context of the query,
925+
/// from 0 (best) to one less than [_numResultRanks].
926+
///
927+
/// See also [_rankWildcardResult] and [_rankUserResult].
928+
static int _rankUserGroupResult(UserGroup userGroup, {
929+
required NameMatchQuality nameMatchQuality,
930+
}) {
931+
return switch (nameMatchQuality) {
932+
NameMatchQuality.exact => 4,
933+
NameMatchQuality.totalPrefix => 5,
934+
NameMatchQuality.wordPrefixes => 6,
935+
};
860936
}
861937

862938
/// The number of possible values returned by
863-
/// [_rankWildcardResult] and [_rankUserResult].
864-
static const _numResultRanks = 5;
939+
/// [_rankWildcardResult], [_rankUserResult], and [_rankUserGroupResult]..
940+
static const _numResultRanks = 8;
865941

866942
@override
867943
String toString() {
@@ -916,11 +992,30 @@ class AutocompleteDataCache {
916992
return _normalizedEmailsByUser[user.userId] ??= user.deliveryEmail?.toLowerCase();
917993
}
918994

995+
final Map<int, String> _lowercaseNamesByUserGroup = {};
996+
997+
/// The normalized `name` of [userGroup].
998+
String lowercaseNameForUserGroup(UserGroup userGroup) {
999+
return _lowercaseNamesByUserGroup[userGroup.id] ??= userGroup.name.toLowerCase();
1000+
}
1001+
1002+
final Map<int, List<String>> _lowercaseNameWordsByUserGroup = {};
1003+
1004+
List<String> lowercaseNameWordsForUserGroup(UserGroup userGroup) {
1005+
return _lowercaseNameWordsByUserGroup[userGroup.id]
1006+
??= lowercaseNameForUserGroup(userGroup).split(' ');
1007+
}
1008+
9191009
void invalidateUser(int userId) {
9201010
_normalizedNamesByUser.remove(userId);
9211011
_normalizedNameWordsByUser.remove(userId);
9221012
_normalizedEmailsByUser.remove(userId);
9231013
}
1014+
1015+
void invalidateUserGroup(int id) {
1016+
_lowercaseNamesByUserGroup.remove(id);
1017+
_lowercaseNameWordsByUserGroup.remove(id);
1018+
}
9241019
}
9251020

9261021
/// A result the user chose, or might choose, from an autocomplete interaction.
@@ -970,7 +1065,7 @@ sealed class MentionAutocompleteResult extends ComposeAutocompleteResult {
9701065
// https://github.com/zulip/zulip/blob/afdf20c67/web/src/typeahead_helper.ts#L472
9711066
//
9721067
// Behavior we have that web doesn't and might like to follow:
973-
// - A "word-prefixes" match quality on user names:
1068+
// - A "word-prefixes" match quality on user and user-group names:
9741069
// see [NameMatchQuality.wordPrefixes], which we rank on.
9751070
//
9761071
// Behavior web has that seems undesired, which we don't plan to follow:
@@ -1015,7 +1110,15 @@ class WildcardMentionAutocompleteResult extends MentionAutocompleteResult {
10151110
final int rank;
10161111
}
10171112

1018-
// TODO(#233): // class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
1113+
/// An autocomplete result for an @-mention of a user group.
1114+
class UserGroupMentionAutocompleteResult extends MentionAutocompleteResult {
1115+
UserGroupMentionAutocompleteResult({required this.groupId, required this.rank});
1116+
1117+
final int groupId;
1118+
1119+
@override
1120+
final int rank;
1121+
}
10191122

10201123
/// An autocomplete interaction for choosing a topic for a message.
10211124
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
@@ -213,6 +213,19 @@ class ComposeAutocomplete extends AutocompleteField<ComposeAutocompleteQuery, Co
213213
replacementString = '${userMention(user, silent: query.silent, users: store)} ';
214214
case WildcardMentionAutocompleteResult(:var wildcardOption):
215215
replacementString = '${wildcardMention(wildcardOption, store: store)} ';
216+
case UserGroupMentionAutocompleteResult(:final groupId):
217+
if (query is! MentionAutocompleteQuery) {
218+
return; // Shrug; similar to `intent == null` case above.
219+
}
220+
final userGroup = store.getGroup(groupId);
221+
if (userGroup == null) {
222+
// Don't crash on theoretical race between async results-filtering
223+
// and losing data for the group.
224+
return;
225+
}
226+
// TODO(i18n) language-appropriate space character; check active keyboard?
227+
// (maybe handle centrally in `controller`)
228+
replacementString = '${userGroupMention(userGroup.name, silent: query.silent)} ';
216229
}
217230

218231
controller.value = intent.textEditingValue.replaced(
@@ -291,6 +304,16 @@ class MentionAutocompleteItem extends StatelessWidget {
291304
emoji = UserStatusEmoji(userId: userId, size: 18,
292305
padding: const EdgeInsetsDirectional.only(start: 5.0));
293306
sublabel = store.getUser(userId)?.deliveryEmail;
307+
case UserGroupMentionAutocompleteResult(:final groupId):
308+
final group = store.getGroup(groupId);
309+
avatar = SizedBox.square(dimension: 36,
310+
child: const Icon(ZulipIcons.three_person, size: 24));
311+
label = group?.name
312+
// Don't crash on theoretical race between async results-filtering
313+
// and losing data for the group.
314+
?? '';
315+
emoji = null;
316+
sublabel = group?.description;
294317
case WildcardMentionAutocompleteResult(:var wildcardOption):
295318
avatar = SizedBox.square(dimension: 36,
296319
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 groupId => has((r) => r.groupId, 'groupId');
30+
}
31+
2832
extension TopicAutocompleteResultChecks on Subject<TopicAutocompleteResult> {
2933
Subject<TopicName> get topic => has((r) => r.topic, 'topic');
3034
}

0 commit comments

Comments
 (0)