Skip to content

Commit 2f7b083

Browse files
committed
Fix mention suggestion filter to use substring matching instead of fuzzy Levenshtein filtering. The previous implementation filtered by Levenshtein distance < threshold, causing short queries like @jc to match unrelated names (e.g. "Aleksandar") because the distance was below the cutoff. Now filters by substring containment and sorts by Levenshtein distance, aligning with the Swift SDK.
1 parent a71f41d commit 2f7b083

2 files changed

Lines changed: 101 additions & 77 deletions

File tree

  • stream-chat-android-ui-common/src

stream-chat-android-ui-common/src/main/kotlin/io/getstream/chat/android/ui/common/feature/messages/composer/query/filter/DefaultQueryFilter.kt

Lines changed: 16 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,19 @@ import kotlin.math.min
2929
/**
3030
* Default implementation of [QueryFilter].
3131
*
32-
* This implementation of [QueryFilter] ignores upper case, diacritics
33-
* It uses levenshtein approximation so typos are included in the search.
32+
* Keeps only items whose normalized target contains the normalized query as a substring, then
33+
* sorts results by Levenshtein distance so the closest matches appear first. Normalization
34+
* applies lowercasing, diacritics removal, and optional transliteration.
3435
*
35-
* It is possible to choose a transliteration by providing a [transliterator].
36-
*
37-
* @param transliterator The transliterator to use for transliterating the query string.
38-
* @param target The function to extract the target string from the item.
36+
* @param transliterator The transliterator to use for normalizing strings.
37+
* @param target The function to extract the searchable string from an item.
3938
*/
4039
public class DefaultQueryFilter<T>(
4140
private val transliterator: StreamTransliterator = DefaultStreamTransliterator(),
4241
private val target: (T) -> String,
4342
) : QueryFilter<T> {
4443

45-
private val logger by taggedLogger("Chat:InputQueryFilter")
44+
private val logger by taggedLogger("Chat:QueryFilter")
4645

4746
private val queryFormatter: QueryFormatter = Combine(
4847
Lowercase(),
@@ -53,73 +52,18 @@ public class DefaultQueryFilter<T>(
5352
override fun filter(items: List<T>, query: String): List<T> {
5453
logger.d { "[filter] query: \"$query\", items.size: ${items.size}" }
5554
val formattedQuery = queryFormatter.format(query)
56-
return items.asSequence()
57-
.map { it.measureDistance(formattedQuery) }
58-
.filter { it.distance < MAX_DISTANCE }
59-
.sorted()
60-
.onEach { logger.v { "[filter] target: \"${target(it.item)}\", distance: ${it.distance}" } }
61-
.map { it.item }
62-
.toList()
63-
}
64-
65-
private fun T.measureDistance(formattedQuery: String): MeasuredItem<T> {
66-
val target = target(this)
67-
if (target.isEmpty() || formattedQuery.length > target.length) {
68-
logger.v { "[measureDistance] #skip; target: \"$target\", formattedQuery: \"$formattedQuery\"" }
69-
return MeasuredItem(this, Int.MAX_VALUE)
70-
}
71-
val formattedTarget = queryFormatter.format(target)
72-
val distance = when (formattedTarget.contains(formattedQuery, ignoreCase = true)) {
73-
true -> 0
74-
else -> {
75-
val finalTarget = when (formattedTarget.length > formattedQuery.length) {
76-
true -> formattedTarget.substring(0, formattedQuery.length)
77-
else -> formattedTarget
78-
}
79-
levenshteinDistance(formattedQuery, finalTarget)
80-
}
81-
}
82-
return MeasuredItem(this, distance)
83-
}
84-
85-
private data class MeasuredItem<T>(val item: T, val distance: Int) : Comparable<MeasuredItem<T>> {
86-
override fun compareTo(other: MeasuredItem<T>): Int {
87-
return distance.compareTo(other.distance)
88-
}
89-
}
90-
91-
private fun minLevenshteinDistance(search: String, target: String): Int {
92-
val totalDistance = levenshteinDistance(search, target)
93-
val wordDistance = wordLevenshteinDistance(search, target)
94-
return minOf(totalDistance, wordDistance)
95-
}
96-
97-
private fun wordLevenshteinDistance(search: String, target: String): Int {
98-
try {
99-
if (search.isEmpty() || target.isEmpty()) {
100-
return Int.MAX_VALUE
101-
}
102-
var distance = Int.MAX_VALUE
103-
var sStartIndex = 0
104-
var tStartIndex = 0
105-
while (true) {
106-
val sEndIndex = search.indexOf(startIndex = sStartIndex, char = SPACE)
107-
val tEndIndex = target.indexOf(startIndex = tStartIndex, char = SPACE)
108-
if (tEndIndex == -1) {
109-
break
55+
if (formattedQuery.isEmpty()) return items
56+
return items
57+
.mapNotNull { item ->
58+
val formattedTarget = queryFormatter.format(target(item))
59+
if (formattedTarget.contains(formattedQuery)) {
60+
item to levenshteinDistance(formattedQuery, formattedTarget)
61+
} else {
62+
null
11063
}
111-
val subSearch = if (sEndIndex == -1) search else search.substring(sStartIndex, sEndIndex)
112-
val subTarget = target.substring(tStartIndex, tEndIndex)
113-
val subDistance = levenshteinDistance(subSearch, subTarget)
114-
distance = minOf(distance, subDistance)
115-
sStartIndex = sEndIndex + 1
116-
tStartIndex = tEndIndex + 1
11764
}
118-
return distance
119-
} catch (e: Throwable) {
120-
logger.e(e) { "[wordLevenshteinDistance] failed: $e" }
121-
return Int.MAX_VALUE
122-
}
65+
.sortedBy { (_, distance) -> distance }
66+
.map { (item, _) -> item }
12367
}
12468

12569
private fun levenshteinDistance(search: String, target: String): Int {
@@ -155,9 +99,4 @@ public class DefaultQueryFilter<T>(
15599

156100
return cost[searchLength - 1]
157101
}
158-
159-
private companion object {
160-
private const val MAX_DISTANCE = 3
161-
private const val SPACE = ' '
162-
}
163102
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
3+
*
4+
* Licensed under the Stream License;
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://github.com/GetStream/stream-chat-android/blob/main/LICENSE
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.getstream.chat.android.ui.common.feature.messages.composer.query.filter
18+
19+
import io.getstream.chat.android.ui.common.feature.messages.composer.transliteration.DefaultStreamTransliterator
20+
import org.junit.jupiter.api.Assertions.assertEquals
21+
import org.junit.jupiter.api.Test
22+
23+
internal class DefaultQueryFilterTest {
24+
25+
private val filter = DefaultQueryFilter<String>(
26+
transliterator = DefaultStreamTransliterator(),
27+
target = { it },
28+
)
29+
30+
@Test
31+
fun `empty query returns all items`() {
32+
val names = listOf("Alice", "Bob")
33+
34+
assertEquals(listOf("Alice", "Bob"), filter.filter(names, ""))
35+
}
36+
37+
@Test
38+
fun `no match returns empty list`() {
39+
val names = listOf("Alice", "Bob")
40+
41+
assertEquals(emptyList<String>(), filter.filter(names, "xyz"))
42+
}
43+
44+
@Test
45+
fun `match is case insensitive`() {
46+
val names = listOf("Aleksandar Apostolov", "Jc Minarro")
47+
48+
assertEquals(listOf("Jc Minarro"), filter.filter(names, "JC"))
49+
}
50+
51+
@Test
52+
fun `match ignores diacritics`() {
53+
val names = listOf("José", "Bob")
54+
55+
assertEquals(listOf("José"), filter.filter(names, "jose"))
56+
}
57+
58+
@Test
59+
fun `short query only matches names containing that substring`() {
60+
val names = listOf("Aleksandar Apostolov", "Jc Minarro")
61+
62+
assertEquals(listOf("Jc Minarro"), filter.filter(names, "jc"))
63+
}
64+
65+
@Test
66+
fun `query does not fuzzy match unrelated names`() {
67+
val names = listOf("Aleksandar Apostolov", "Ara", "AA BB CC", "Abel")
68+
69+
assertEquals(listOf("Aleksandar Apostolov"), filter.filter(names, "ale"))
70+
}
71+
72+
@Test
73+
fun `query matches substring in any word`() {
74+
val names = listOf("Alice Smith", "Bob Jones", "Charlie Smith")
75+
76+
assertEquals(listOf("Alice Smith", "Charlie Smith"), filter.filter(names, "smith"))
77+
}
78+
79+
@Test
80+
fun `results are sorted by levenshtein distance`() {
81+
val names = listOf("Charlie Alice", "Alice", "Bob Alice Smith")
82+
83+
assertEquals(listOf("Alice", "Charlie Alice", "Bob Alice Smith"), filter.filter(names, "alice"))
84+
}
85+
}

0 commit comments

Comments
 (0)