From 88b3559c6b8e1b6b0ef096abd7e810d9fb3c0eeb Mon Sep 17 00:00:00 2001 From: hayaksi1 <193020925+hayaksi1@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:30:09 +0300 Subject: [PATCH] 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. --- .../suggestions/SuggestionsProcessor.kt | 15 ++++++- .../suggestions/SuggestionsProcessorTest.kt | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt index 010aff5d4b..8f29693e9a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessor.kt @@ -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 diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt index 6283d7236a..71a9ada1a8 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/suggestions/SuggestionsProcessorTest.kt @@ -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")