Skip to content

Commit 40a560e

Browse files
committed
feat(chat): suggest @mentions from the server's pool
Wire Chat.GetMentionSuggestions through ChatApi, ChatService, ChatRepository and ChatController. ServerRosterSearchSource fetches the pool once per composing session, filters it locally with the word-prefix rule while keeping the server's order, and moves each new message's sender to the front from the event stream. A failed fetch (DENIED, NOT_FOUND, transport) falls back to the roster held on the device.
1 parent 808a43d commit 40a560e

19 files changed

Lines changed: 444 additions & 101 deletions

File tree

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt‎

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2288,18 +2288,12 @@ internal class ChatViewModel @Inject constructor(
22882288
*/
22892289
@OptIn(ExperimentalCoroutinesApi::class)
22902290
private fun initMentionPicker() {
2291-
// Once per visit, the first time the picker opens: profile edits don't move the roster
2292-
// version, so the names held since the last full read can be stale.
2291+
// Once per visit, the first time the picker opens: that starts the composing session, which
2292+
// fetches the group's mention pool.
22932293
var refreshed = false
22942294
// Bumped when that refresh lands, so the open word searches again and new joiners show
22952295
// without another keystroke.
22962296
val refreshes = MutableStateFlow(0)
2297-
// So does the search source saying its answer changed, e.g. a member's handle arriving.
2298-
stateFlow.map { it.chatId }
2299-
.distinctUntilChanged()
2300-
.flatMapLatest { chatId -> chatId?.let(rosterSearch::invalidations) ?: emptyFlow() }
2301-
.onEach { refreshes.update { it + 1 } }
2302-
.launchIn(viewModelScope)
23032297
val eligible = stateFlow
23042298
.map { it.chatType == ChatType.GROUP && !it.isAnonymous && !it.replacesComposer }
23052299
.distinctUntilChanged()

‎apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt‎

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import io.mockk.every
3232
import io.mockk.mockk
3333
import kotlinx.coroutines.CompletableDeferred
3434
import kotlinx.coroutines.ExperimentalCoroutinesApi
35-
import kotlinx.coroutines.flow.MutableSharedFlow
3635
import kotlinx.coroutines.flow.MutableStateFlow
3736
import kotlinx.coroutines.flow.flowOf
3837
import kotlinx.coroutines.test.TestScope
@@ -65,7 +64,6 @@ class ChatMentionPickerTest {
6564
private val tokenCoordinator = mockk<TokenCoordinator>(relaxed = true)
6665
private val tipPaymentDelegate = mockk<TipPaymentDelegate>(relaxed = true)
6766
private val rosterSearch = mockk<RosterSearchSource>(relaxed = true)
68-
private val invalidations = MutableSharedFlow<Unit>()
6967

7068
private val chatId = ChatId(UUID.randomUUID().bytes)
7169
private val erica = match("Érica", "erica")
@@ -81,7 +79,6 @@ class ChatMentionPickerTest {
8179
every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null)
8280
coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException())
8381
coEvery { rosterSearch.search(any(), any(), any()) } returns listOf(erica, eric)
84-
every { rosterSearch.invalidations(any()) } returns invalidations
8582
coEvery { chatCoordinator.sendMessage(any(), any(), any()) } returns Result.success(mockk<ChatMessage>(relaxed = true))
8683
}
8784

@@ -271,23 +268,6 @@ class ChatMentionPickerTest {
271268
coVerify(exactly = 1) { rosterSearch.search(any(), any(), any()) }
272269
}
273270

274-
@Test
275-
fun `the open word searches again when the search source invalidates`() = runTest(mainCoroutineRule.dispatcher) {
276-
val vm = createViewModel()
277-
vm.openGroup()
278-
type(vm, "@er")
279-
// Eric has no stored handle yet, so only Erica can be mentioned.
280-
assertEquals(listOf(erica), vm.suggestions)
281-
282-
// His profile is fetched and stored with a handle; no keystroke follows.
283-
val ericWithHandle = match("Eric", "eric")
284-
coEvery { rosterSearch.search(any(), any(), any()) } returns listOf(erica, ericWithHandle)
285-
invalidations.emit(Unit)
286-
advanceUntilIdle()
287-
288-
assertEquals(listOf(erica, ericWithHandle), vm.suggestions)
289-
}
290-
291271
private fun match(name: String, username: String?) = MemberMatch(
292272
userId = name.encodeToByteArray().toList(),
293273
displayName = name,

‎apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/RosterSearchSource.kt‎

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,12 @@ package com.flipcash.shared.chat
33
import com.flipcash.services.models.chat.ChatId
44
import com.flipcash.services.models.chat.MediaItem
55
import com.getcode.opencode.model.core.ID
6-
import kotlinx.coroutines.flow.Flow
7-
import kotlinx.coroutines.flow.emptyFlow
86

97
/**
108
* Finds members of a chat by what the user has typed after `@`.
119
*
12-
* The seam between the mention picker and where members are searched. The only implementation
13-
* today searches the roster held on the device; a server-side search can replace or back it up
14-
* behind this interface without its callers changing.
10+
* The seam between the mention picker and where members are searched. The app binds the server's
11+
* mention pool, which falls back to the roster held on the device when the pool can't be fetched.
1512
*/
1613
interface RosterSearchSource {
1714

@@ -26,19 +23,11 @@ interface RosterSearchSource {
2623
suspend fun search(chatId: ChatId, query: String, limit: Int = DEFAULT_LIMIT): List<MemberMatch>
2724

2825
/**
29-
* Brings the members a search of [chatId] will read up to date. Called when the picker opens:
30-
* a profile change does not move the roster version, so held names can be stale without the
31-
* event stream saying so.
26+
* Begins a composing session for [chatId], bringing what its searches read up to date. Called
27+
* the first time the picker opens in a screen visit.
3228
*/
3329
suspend fun refresh(chatId: ChatId)
3430

35-
/**
36-
* Emits when a search of [chatId] may now answer differently, without the query changing: a
37-
* member [search] returned with no handle has had their profile fetched, for example. The
38-
* picker searches its open word again on each emission.
39-
*/
40-
fun invalidations(chatId: ChatId): Flow<Unit> = emptyFlow()
41-
4231
companion object {
4332
const val DEFAULT_LIMIT = 20
4433
}

‎apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import com.flipcash.shared.chat.ChatCoordinator
44
import com.flipcash.shared.chat.ChatDraftStore
55
import com.flipcash.shared.chat.RosterSearchSource
66
import com.flipcash.shared.chat.internal.DmOutgoingEncryption
7-
import com.flipcash.shared.chat.internal.LocalRosterSearchSource
7+
import com.flipcash.shared.chat.internal.MentionPoolUpdates
8+
import com.flipcash.shared.chat.internal.MentionSuggestionPool
9+
import com.flipcash.shared.chat.internal.ServerRosterSearchSource
810
import com.flipcash.shared.chat.internal.OutgoingEncryption
911
import com.flipcash.shared.chat.internal.RealChatCoordinator
1012
import com.flipcash.shared.chat.internal.RealChatDraftStore
@@ -43,9 +45,14 @@ abstract class ChatModule {
4345

4446
@Binds
4547
internal abstract fun bindRosterSearchSource(
46-
impl: LocalRosterSearchSource
48+
impl: ServerRosterSearchSource
4749
): RosterSearchSource
4850

51+
@Binds
52+
internal abstract fun bindMentionPoolUpdates(
53+
impl: MentionSuggestionPool
54+
): MentionPoolUpdates
55+
4956
@Binds
5057
abstract fun bindRosterReconcileScheduler(
5158
impl: WorkManagerRosterReconcileScheduler

‎apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/LocalRosterSearchSource.kt‎

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@ import com.flipcash.services.user.UserManager
88
import com.flipcash.shared.chat.MemberMatch
99
import com.flipcash.shared.chat.RosterSearchSource
1010
import com.getcode.utils.hexEncodedString
11-
import kotlinx.coroutines.flow.Flow
12-
import kotlinx.coroutines.flow.drop
13-
import kotlinx.coroutines.flow.map
1411
import javax.inject.Inject
1512

1613
/**
@@ -20,16 +17,12 @@ import javax.inject.Inject
2017
* answers the longest word with a range scan and the rest are checked against the few members it
2118
* returns.
2219
*
23-
* Handles come only from the stored profile, and a roster page can store a name with no handle. The
24-
* transcript fetches a sender's profile only when it has no name, so a member like that would stay
25-
* unmentionable. Any result with no handle has its profile asked for through [senderResolver], and
26-
* [invalidations] tells the picker to search again once it is stored.
20+
* The fallback behind [ServerRosterSearchSource], for when the group's mention pool can't be fetched.
2721
*/
2822
internal class LocalRosterSearchSource @Inject constructor(
2923
private val rosterDataSource: ChatRosterDataSource,
3024
private val rosterSync: RosterSync,
3125
private val userManager: UserManager,
32-
private val senderResolver: SenderResolver,
3326
) : RosterSearchSource {
3427

3528
override suspend fun search(chatId: ChatId, query: String, limit: Int): List<MemberMatch> {
@@ -45,15 +38,9 @@ internal class LocalRosterSearchSource @Inject constructor(
4538

4639
return rankRosterMatches(candidates, words)
4740
.take(limit)
48-
.onEach { if (it.username.isNullOrBlank()) senderResolver.request(it.userId) }
4941
.map { MemberMatch(it.userId, it.displayName, it.username, it.profilePicture) }
5042
}
5143

52-
// Any stored profile changing re-runs the open search. The search is local and the picker is
53-
// open only while an @ word is, so that is cheaper than working out whose profile it was.
54-
override fun invalidations(chatId: ChatId): Flow<Unit> =
55-
senderResolver.profiles.drop(1).map { }
56-
5744
override suspend fun refresh(chatId: ChatId) {
5845
rosterSync.refreshFirstPage(chatId)
5946
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.flipcash.shared.chat.internal
2+
3+
import com.flipcash.app.persistence.sources.UserProfileDataSource
4+
import com.flipcash.services.controllers.ChatController
5+
import com.flipcash.services.models.chat.ChatId
6+
import com.flipcash.services.models.chat.MentionSuggestion
7+
import com.getcode.opencode.model.core.ID
8+
import kotlinx.coroutines.CompletableDeferred
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.Deferred
11+
import kotlinx.coroutines.async
12+
import kotlinx.coroutines.sync.Mutex
13+
import kotlinx.coroutines.sync.withLock
14+
import kotlin.time.Instant
15+
import javax.inject.Inject
16+
import javax.inject.Singleton
17+
18+
/**
19+
* The pool of people a group offers for `@` mentions, as `Chat.GetMentionSuggestions` ranked it.
20+
*
21+
* Fetched once per composing session: [startSession] replaces whatever is held with a new fetch,
22+
* and [pool] waits on that fetch, starting one if no session has begun. Held in memory only; the
23+
* next session fetches again, which is how senders the pool has never seen get in.
24+
*
25+
* Between fetches, [onMessages] keeps the order fresh by moving a known sender to the front.
26+
*/
27+
@Singleton
28+
internal class MentionSuggestionPool @Inject constructor(
29+
private val chatController: ChatController,
30+
private val profiles: UserProfileDataSource,
31+
) : MentionPoolUpdates {
32+
33+
private val lock = Mutex()
34+
private val sessions = mutableMapOf<ChatId, Deferred<Result<List<MentionSuggestion>>>>()
35+
36+
/** Begins a composing session for [chatId] with a fresh fetch, on [scope]. */
37+
suspend fun startSession(scope: CoroutineScope, chatId: ChatId) {
38+
lock.withLock { sessions[chatId] = scope.fetchAsync(chatId) }
39+
}
40+
41+
/** [chatId]'s pool for the current session, most relevant first, or the failure that ended its fetch. */
42+
suspend fun pool(scope: CoroutineScope, chatId: ChatId): Result<List<MentionSuggestion>> =
43+
lock.withLock { sessions.getOrPut(chatId) { scope.fetchAsync(chatId) } }.await()
44+
45+
override suspend fun onMessages(chatId: ChatId, senders: List<Pair<ID, Instant>>) {
46+
lock.withLock {
47+
val session = sessions[chatId] ?: return
48+
if (!session.isCompleted) return
49+
val held = session.getCompleted().getOrNull() ?: return
50+
sessions[chatId] = CompletableDeferred(Result.success(movedToFront(held, senders)))
51+
}
52+
}
53+
54+
private fun CoroutineScope.fetchAsync(chatId: ChatId): Deferred<Result<List<MentionSuggestion>>> = async {
55+
chatController.getMentionSuggestions(chatId).onSuccess { pool ->
56+
pool.forEach { suggestion ->
57+
val userId = suggestion.userProfile.userId ?: return@forEach
58+
profiles.store(userId, suggestion.userProfile)
59+
}
60+
}
61+
}
62+
63+
}
64+
65+
/** Told about each message the event stream delivers, so the mention pool's order stays fresh. */
66+
interface MentionPoolUpdates {
67+
suspend fun onMessages(chatId: ChatId, senders: List<Pair<ID, Instant>>)
68+
69+
object None : MentionPoolUpdates {
70+
override suspend fun onMessages(chatId: ChatId, senders: List<Pair<ID, Instant>>) = Unit
71+
}
72+
}
73+
74+
/**
75+
* [pool] with each sender in [senders] that it already holds moved to the front, newest message
76+
* first, when that message is newer than the sender's last_sent_at. A sender the pool doesn't hold
77+
* is left out; the next session's fetch brings them in.
78+
*/
79+
internal fun movedToFront(
80+
pool: List<MentionSuggestion>,
81+
senders: List<Pair<ID, Instant>>,
82+
): List<MentionSuggestion> {
83+
val byUser = pool.associateBy { it.userProfile.userId }
84+
val latest = senders.groupBy({ it.first }, { it.second }).mapValues { (_, times) -> times.max() }
85+
val moved = latest
86+
.mapNotNull { (userId, sentAt) ->
87+
val held = byUser[userId] ?: return@mapNotNull null
88+
val last = held.lastSentAt
89+
if (last != null && last >= sentAt) null else held.copy(lastSentAt = sentAt)
90+
}
91+
.sortedByDescending { it.lastSentAt }
92+
if (moved.isEmpty()) return pool
93+
val movedIds = moved.map { it.userProfile.userId }.toSet()
94+
return moved + pool.filterNot { it.userProfile.userId in movedIds }
95+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.flipcash.shared.chat.internal
2+
3+
import com.flipcash.app.persistence.sources.search.MemberSearchText
4+
import com.flipcash.services.models.chat.ChatId
5+
import com.flipcash.services.models.chat.MentionSuggestion
6+
import com.flipcash.services.user.UserManager
7+
import com.flipcash.shared.chat.MemberMatch
8+
import com.flipcash.shared.chat.RosterSearchSource
9+
import kotlinx.coroutines.CoroutineScope
10+
import kotlinx.coroutines.Dispatchers
11+
import kotlinx.coroutines.SupervisorJob
12+
import javax.inject.Inject
13+
14+
/**
15+
* Searches the pool `Chat.GetMentionSuggestions` returns for a group, held by [pool].
16+
*
17+
* The pool is filtered locally with [MemberSearchText]'s word-prefix rule and keeps the server's
18+
* order: a bare `@` shows its head. When the fetch fails (DENIED, NOT_FOUND, or the call never
19+
* reached the server), the session searches the roster held on the device through [local] instead.
20+
*/
21+
internal class ServerRosterSearchSource(
22+
private val pool: MentionSuggestionPool,
23+
private val local: RosterSearchSource,
24+
private val userManager: UserManager,
25+
private val scope: CoroutineScope,
26+
) : RosterSearchSource {
27+
28+
@Inject constructor(
29+
pool: MentionSuggestionPool,
30+
local: LocalRosterSearchSource,
31+
userManager: UserManager,
32+
) : this(pool, local, userManager, CoroutineScope(SupervisorJob() + Dispatchers.IO))
33+
34+
override suspend fun search(chatId: ChatId, query: String, limit: Int): List<MemberMatch> {
35+
val held = pool.pool(scope, chatId).getOrElse { return local.search(chatId, query, limit) }
36+
val selfId = userManager.accountId
37+
val words = MemberSearchText.queryWords(query)
38+
return held.asSequence()
39+
.filter { it.userProfile.userId != null && it.userProfile.userId != selfId }
40+
.filter { words.isEmpty() || matchesEveryWord(it, words) }
41+
.take(limit)
42+
.map { it.toMatch() }
43+
.toList()
44+
}
45+
46+
// A new composing session: fetch the pool again. The local roster's first page is refreshed too,
47+
// so a fallback search reads current names.
48+
override suspend fun refresh(chatId: ChatId) {
49+
pool.startSession(scope, chatId)
50+
if (pool.pool(scope, chatId).isFailure) local.refresh(chatId)
51+
}
52+
53+
private fun matchesEveryWord(suggestion: MentionSuggestion, words: List<String>): Boolean {
54+
val tokens = MemberSearchText.tokens(suggestion.userProfile.displayName, suggestion.userProfile.username)
55+
return words.all { word -> tokens.any { it.startsWith(word) } }
56+
}
57+
58+
private fun MentionSuggestion.toMatch() = MemberMatch(
59+
userId = userProfile.userId!!,
60+
displayName = userProfile.displayName,
61+
username = userProfile.username,
62+
profilePicture = userProfile.profilePicture,
63+
)
64+
}

‎apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import com.flipcash.shared.chat.EventStreamOperations
2828
import com.flipcash.shared.chat.internal.ChatStateHolder
2929
import com.flipcash.services.user.UserManager
3030
import com.flipcash.shared.chat.MessageLinkPrefetch
31+
import com.flipcash.shared.chat.internal.MentionPoolUpdates
3132
import com.getcode.opencode.exchange.Exchange
3233
import com.getcode.opencode.model.core.ID
3334
import com.getcode.utils.TraceType
@@ -92,6 +93,7 @@ class EventStreamDelegate @Inject constructor(
9293
private val analytics: FlipcashAnalytics,
9394
private val exchange: Exchange,
9495
private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None,
96+
private val mentionPool: MentionPoolUpdates = MentionPoolUpdates.None,
9597
) : EventStreamOperations {
9698

9799
companion object {
@@ -363,6 +365,7 @@ class EventStreamDelegate @Inject constructor(
363365
linkPrefetch.prefetch(resolvedMessages, MessageLinkPrefetch.LIVE_WAIT)
364366
messageDataSource.upsert(chatId, resolvedMessages)
365367
typingTracker.messageArrived(chatId, resolvedMessages.mapNotNull { it.senderId })
368+
mentionPool.onMessages(chatId, resolvedMessages.mapNotNull { m -> m.senderId?.let { it to m.timestamp } })
366369
resolvedMessages.maxByOrNull { it.messageId }
367370
} else null
368371

0 commit comments

Comments
 (0)