Skip to content

autocomplete: Support user-group mentions; match users by email; rank results by match quality #1775

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from

Conversation

chrisbobbe
Copy link
Collaborator

@chrisbobbe chrisbobbe commented Jul 31, 2025

Fixes #236.
Fixes #233.

Screenshots coming soon.

image image

@chrisbobbe chrisbobbe requested a review from gnprice July 31, 2025 00:32
@chrisbobbe chrisbobbe added the integration review Added by maintainers when PR may be ready for integration label Jul 31, 2025
Comment on lines 802 to 1036
final Map<int, String> _normalizedNamesByUser = {};
final Map<int, String> _lowercaseNamesByUser = {};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

autocomplete [nfc]: Renames/doc changes for explicitness about lowercase

I think the reason to call these fields and methods "normalize" rather than "lowercase" is to anticipate having them be normalized for diacritics as well as for case.

@chrisbobbe chrisbobbe force-pushed the pr-user-group-autocomplete branch from 1863ddd to 8ca8bdb Compare July 31, 2025 01:08
@chrisbobbe
Copy link
Collaborator Author

Thanks! Revision pushed, addressing that comment and also simplifying the email-match logic as discussed in the office.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for building this! Here's a review of everything but the next-to-last commit:
15be5b4 autocomplete: Match user results by email match (prefix match)

Comment on lines 883 to 889
if (matchLength == lowercaseName.length && matchLength == _lowercase.length) {
return NameMatchQuality.exact;
} else if (matchLength == _lowercase.length) {
return NameMatchQuality.totalPrefix;
}

if (_testContainsQueryWords(lowercaseNameWords)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these conditions are possible only if the query is no longer than the name, right?

So we could check _lowercase.length <= lowercaseName.length at the top, and then simplify by referring only to _lowercase.length in the loop.

Comment on lines 875 to 863
int matchLength = 0;
while (
matchLength < lowercaseName.length && matchLength < _lowercase.length
&& lowercaseName[matchLength] == _lowercase[matchLength]
) {
matchLength++;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or perhaps better: this is effectively checking lowercaseName.startswith(_lowercase), right?

I think we can first check that, using that method; then distinguish .exact by checking if the lengths are equal.

Comment on lines 856 to 858
final nameMatchQuality = _matchName(
lowercaseName: cache.lowercaseNameForUser(user),
lowercaseNameWords: cache.lowercaseNameWordsForUser(user));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems probably cleaner to read if _matchName handles looking these up from the cache, like _testName before:

Suggested change
final nameMatchQuality = _matchName(
lowercaseName: cache.lowercaseNameForUser(user),
lowercaseNameWords: cache.lowercaseNameWordsForUser(user));
final nameMatchQuality = _matchName(user, cache);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see, the point of this comes in that later commit where user groups get matched: that's a new call site which does different lookups on the cache. OK, sure.

Comment on lines +1126 to +1087
final user1 = eg.user(fullName: 'So Many Ideas');
final user2 = eg.user(fullName: 'Some Merry User');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙂

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was really struggling to come up with actual normal names to use 😅

Comment on lines 1000 to 1002
/// The number of possible values returned by
/// [_rankWildcardResult] and [_rankUserResult].
static const _numResultRanks = 10;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: also _rankUserGroupResult

Comment on lines 215 to 224
final userGroup = store.getGroup(id);
if (userGroup == null) {
// Don't crash on theoretical race between async results-filtering
// and losing data for the group.
return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm interesting. I guess if that is possible, it probably also applies to the user case above:

        final user = store.getUser(userId)!; // must exist because UserMentionAutocompleteResult

Comment on lines 837 to 838
// The bucket-sort-by-rank step has its own tests;
// see "MentionAutocompleteQuery ranking.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: missing close-quote?

.deepEquals([WildcardMentionOption.all, WildcardMentionOption.topic]);
check(getUsersFromResults(results1.skip(2)))
.deepEquals([1, 5, 4, 2, 7, 3, 6]);
// 6. User groups by name alphabetical order.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This item isn't like the others — this ordered list is of ranking criteria, not of classes of results.

Instead, change the first item to something like:

  1. Wildcards before individual users; user groups after.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Well, adjusted a bit for the fact that users matched by email come after groups.)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think email matching is short-circuited by the fact that this is an empty query, which always matches users by name.

Comment on lines 857 to 858
isUser(2), isUser(3),
isUserGroup(3), isUserGroup(2),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh interesting — why is the order reversed between these?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the users, 2 ("User Two") goes before 3 ("User Three") by DM recency. For the groups, 3 ("User Group Three") goes before 2 ("User Group Two") by alphabet.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, makes sense; and the comment to that effect is helpful, thanks.

@@ -316,6 +316,22 @@ hello
check(wildcardMention(WildcardMentionOption.topic, store: store()))
.equals('@**topic**');
});

group('user group', () {
final userGroup = eg.userGroup(id: 123, name: 'Group Name');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: ID isn't relevant to understanding these tests, so can omit

Suggested change
final userGroup = eg.userGroup(id: 123, name: 'Group Name');
final userGroup = eg.userGroup(name: 'Group Name');

(contrasts with the user case, where it can appear in the output)

@chrisbobbe chrisbobbe force-pushed the pr-user-group-autocomplete branch from 8ca8bdb to ae92100 Compare July 31, 2025 19:40
@chrisbobbe
Copy link
Collaborator Author

Thanks for the review! Revision pushed.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revision! All looks good now except a couple of nits; and that email commit, which I guess I'll read next.

Comment on lines -205 to +206
final user = store.getUser(userId)!; // must exist because UserMentionAutocompleteResult
final user = store.getUser(userId);
if (user == null) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

autocomplete: Remove a probably-wrong null check

I've been waffling on this and haven't managed to convince myself
that the null-check was correct.

nit: "null assertion"? This is still something I'd call a null check — it's checking whether this is null.

Comment on lines 857 to 858
isUser(2), isUser(3),
isUserGroup(3), isUserGroup(2),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, makes sense; and the comment to that effect is helpful, thanks.

// TODO(i18n) language-appropriate space character; check active keyboard?
// (maybe handle centrally in `controller`)
replacementString = '${userMention(user, silent: query.silent, users: store)} ';
case WildcardMentionAutocompleteResult(:var wildcardOption):
replacementString = '${wildcardMention(wildcardOption, store: store)} ';
case UserGroupMentionAutocompleteResult(groupId:final id):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: propagate name change (here and another case below)

Suggested change
case UserGroupMentionAutocompleteResult(groupId:final id):
case UserGroupMentionAutocompleteResult(:final groupId):

@chrisbobbe
Copy link
Collaborator Author

Thanks! Revision pushed.

@chrisbobbe chrisbobbe force-pushed the pr-user-group-autocomplete branch from ae92100 to 1f3d350 Compare July 31, 2025 19:57
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, and read the emails commit — just one comment, for a test I think will be quick to add.

checkPrecedes('so m', userGroup1, userGroup2);
});

test('email matched case-insensitively', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The one other test that'd be good to have here is that emails are matched by prefix only: so e.g. check that "[email protected]" doesn't match a query "mail" or "example" or "example.com".

chrisbobbe and others added 14 commits July 31, 2025 20:33
I've been waffling on this and haven't managed to convince myself
that the "!" was correct.
Since 9815022, this method already takes the whole store, which
it uses in order to identify muted users.  So it can get the
autocomplete-data cache from the store just as well as its
caller can.
Like we do in emoji autocomplete.

This commit doesn't make any changes to the results ordering;
bucketSort sorts stably, and the input is still just the wildcard
results followed by the user results.

Soon, though, we'd like to rank by match quality and add user-group
results (for zulip#233) interleaved with user results. Bucket sorting
will help us do this without making many intermediate copies of
lists of results; see discussion:
  https://chat.zulip.org/#narrow/channel/48-mobile/topic/user-group.20mentions.20.23F233/near/2216353

Co-authored-by: Greg Price <[email protected]>
… test

This will make it more smoothly generalize to situations where
different types of results are more intermingled.
This makes this function better match the end-to-end behavior of our
autocomplete ordering, now that that involves this ranking notion.

Co-authored-by: Chris Bobbe <[email protected]>
This group also checks that wildcard results come before user
results.
@chrisbobbe chrisbobbe force-pushed the pr-user-group-autocomplete branch from 1f3d350 to 7cfc4ac Compare August 1, 2025 03:37
@chrisbobbe
Copy link
Collaborator Author

Thanks, done! PTAL.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
integration review Added by maintainers when PR may be ready for integration
Projects
None yet
Development

Successfully merging this pull request may close these issues.

autocomplete: Filter user-autocomplete results according to email match, not just name autocomplete: Support user-group @-mentions
2 participants