Rank the mention suggestions by how well they match what was typed

The member suggestions were emitted in whatever order the room member list
happened to be in, so a member who only matches somewhere in the middle of
their name could be offered above one whose name starts with the query.

Matches are now ordered by how well they match: display name prefix first,
then user ID localpart prefix, then any other match. The sort is stable, so
members of equal rank keep the order the member list gave them.

The @room suggestion was also matched case sensitively, so typing @Ro or
@ROOM dropped it while @ro kept it. It now matches the query the same way
the members do.
This commit is contained in:
hayaksi1
2026-08-25 14:30:09 +03:00
parent 23437e4427
commit 88b3559c6b
2 changed files with 56 additions and 1 deletions
@@ -115,9 +115,10 @@ class SuggestionsProcessor(
.filterUpTo(MAX_BATCH_ITEMS) { member ->
isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
}
.sortedBy { member -> matchRank(member, query) }
.map(ResolvedSuggestion::Member)
if ("room".contains(query) && canSendRoomMention) {
if ("room".startsWith(query, ignoreCase = true) && canSendRoomMention) {
listOf(ResolvedSuggestion.AtRoom) + matchingMembers
} else {
matchingMembers
@@ -125,6 +126,18 @@ class SuggestionsProcessor(
}
}
/**
* Members whose display name starts with the query come first, then members whose user id
* localpart starts with it, then the remaining matches on any other part of the name or id.
*/
private fun matchRank(member: RoomMember, query: String): Int {
return when {
member.displayName?.startsWith(query, ignoreCase = true) == true -> 0
member.userId.extractedDisplayName.startsWith(query, ignoreCase = true) -> 1
else -> 2
}
}
companion object {
// We don't want to retrieve thousands of members
private const val MAX_BATCH_ITEMS = 100
@@ -383,6 +383,48 @@ class SuggestionsProcessorTest {
)
}
@Test
fun `processing Mention suggestion returns the members matching the start of the query first`() = runTest {
val matchingOnAnyPart = aRoomMember(userId = UserId("@carol:server.org"), displayName = "Not Alice")
val matchingOnUserIdStart = aRoomMember(userId = UserId("@alicia:server.org"), displayName = null)
val matchingOnDisplayNameStart = aRoomMember(userId = UserId("@zoe:server.org"), displayName = "Alice")
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("ali"),
roomMembersState = RoomMembersState.Ready(
persistentListOf(matchingOnAnyPart, matchingOnUserIdStart, matchingOnDisplayNameStart)
),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { false },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.Member(matchingOnDisplayNameStart),
ResolvedSuggestion.Member(matchingOnUserIdStart),
ResolvedSuggestion.Member(matchingOnAnyPart),
)
)
}
@Test
fun `processing Mention suggestion returns room ignoring the case of the query`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "alice")
val result = suggestionsProcessor.process(
suggestion = aMentionSuggestion("RO"),
roomMembersState = RoomMembersState.Ready(persistentListOf(aRoomMember)),
roomAliasSuggestions = emptyList(),
currentUserId = A_USER_ID_2,
canSendRoomMention = { true },
isInThread = false,
)
assertThat(result).isEqualTo(
listOf(
ResolvedSuggestion.AtRoom,
)
)
}
@Test
fun `processing Mention suggestion with return matching display name but not room if not allowed`() = runTest {
val aRoomMember = aRoomMember(userId = UserId("@alice:server.org"), displayName = "ro")