Skip to content

Commit f7679e1

Browse files
committed
chore: improve initial chat load speeds wrt contact resolving
Signed-off-by: Brandon McAnsh <git@bmcreations.dev>
1 parent 6e49a91 commit f7679e1

16 files changed

Lines changed: 153 additions & 124 deletions

File tree

apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatIdentifier.kt

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.flipcash.app.core.chat
22

33
import android.os.Parcelable
4+
import com.flipcash.app.core.contacts.DeviceContact
45
import com.flipcash.services.models.chat.ChatId
56
import kotlinx.parcelize.Parcelize
67
import kotlinx.serialization.Serializable
@@ -18,7 +19,11 @@ sealed interface ChatIdentifier : Parcelable {
1819

1920
@Serializable
2021
@Parcelize
21-
data class ByContact(val e164: String, val displayName: String, val chatId: ChatId? = null) : ChatIdentifier {
22-
override val key: String get() = e164
22+
// TODO: move DeviceContact someone shared and pass it directly
23+
data class ByContact(
24+
val contact: DeviceContact,
25+
val chatId: ChatId? = null
26+
) : ChatIdentifier {
27+
override val key: String get() = contact.e164
2328
}
2429
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.flipcash.app.core.contacts
2+
3+
import android.os.Parcelable
4+
import kotlinx.parcelize.Parcelize
5+
import kotlinx.serialization.Serializable
6+
7+
@Serializable
8+
@Parcelize
9+
data class DeviceContact(
10+
val e164: String,
11+
val androidContactId: Long = -1L,
12+
val displayName: String,
13+
val photoUri: String?,
14+
val displayNumber: String = "",
15+
): Parcelable {
16+
var isUnknown: Boolean = false
17+
private set
18+
19+
companion object {
20+
fun unknownContact(
21+
e164: String = "",
22+
displayName: String? = null,
23+
displayNumber: String? = null,
24+
) = DeviceContact(
25+
e164 = e164,
26+
androidContactId = -1,
27+
displayName = displayName ?: e164,
28+
displayNumber = displayNumber ?: e164,
29+
photoUri = null,
30+
).apply { isUnknown = true }
31+
}
32+
}

apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/ContactListItem.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.flipcash.app.directsend.internal
22

3-
import com.flipcash.app.contacts.device.DeviceContact
3+
import com.flipcash.app.core.contacts.DeviceContact
44
import com.flipcash.services.models.chat.ChatId
55
import kotlin.time.Instant
66

apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,29 @@
11
package com.flipcash.app.directsend.internal
2-
32
import androidx.compose.foundation.text.input.TextFieldState
43
import androidx.compose.material3.ExperimentalMaterial3Api
54
import androidx.compose.runtime.snapshotFlow
65
import androidx.lifecycle.viewModelScope
76
import com.flipcash.app.contacts.ContactCoordinator
87
import com.flipcash.app.contacts.ContactCoordinator.ContactState
9-
import com.flipcash.app.contacts.device.DeviceContact
108
import com.flipcash.app.contacts.device.PickedContactData
11-
import com.flipcash.app.core.AppRoute
129
import com.flipcash.app.core.chat.ChatIdentifier
10+
import com.flipcash.app.core.contacts.DeviceContact
1311
import com.flipcash.app.core.send.SendStep
1412
import com.flipcash.app.featureflags.FeatureFlag
1513
import com.flipcash.app.featureflags.FeatureFlagController
16-
import com.flipcash.app.payments.PurchaseMethodController
17-
import com.flipcash.app.phone.PhoneUtils
1814
import com.flipcash.app.permissions.PickedContact
15+
import com.flipcash.app.phone.PhoneUtils
1916
import com.flipcash.app.tokens.TokenCoordinator
2017
import com.flipcash.features.directsend.R
21-
import com.flipcash.services.models.chat.ChatId
2218
import com.flipcash.services.models.chat.ChatType
2319
import com.flipcash.services.models.chat.MessageContent
2420
import com.flipcash.services.user.UserManager
2521
import com.flipcash.shared.chat.ChatCoordinator
2622
import com.flipcash.shared.chat.ChatSummary
27-
import com.getcode.opencode.model.financial.Token
28-
import com.getcode.solana.keys.Mint
29-
import com.getcode.manager.BottomBarAction
3023
import com.getcode.manager.BottomBarManager
3124
import com.getcode.opencode.model.core.ID
25+
import com.getcode.opencode.model.financial.Token
26+
import com.getcode.solana.keys.Mint
3227
import com.getcode.util.resources.ResourceHelper
3328
import com.getcode.view.BaseViewModel
3429
import com.getcode.view.LoadingSuccessState
@@ -175,7 +170,10 @@ internal class SendFlowViewModel @Inject constructor(
175170
val (contact, isOnFlipcash) = row
176171
if (isOnFlipcash) {
177172
val identifier = if (contact.e164.isNotEmpty()) {
178-
ChatIdentifier.ByContact(contact.e164, contact.displayName, row.chatId)
173+
ChatIdentifier.ByContact(
174+
contact = contact,
175+
chatId = row.chatId
176+
)
179177
} else {
180178
ChatIdentifier.ByChatId(row.chatId!!)
181179
}

apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ import androidx.compose.animation.fadeIn
55
import androidx.compose.animation.fadeOut
66
import androidx.compose.animation.togetherWith
77
import androidx.compose.foundation.background
8+
import androidx.compose.foundation.border
89
import androidx.compose.foundation.clickable
910
import androidx.compose.foundation.layout.Arrangement
1011
import androidx.compose.foundation.layout.Box
1112
import androidx.compose.foundation.layout.Column
1213
import androidx.compose.foundation.layout.PaddingValues
1314
import androidx.compose.foundation.layout.Row
14-
import androidx.compose.foundation.layout.Spacer
1515
import androidx.compose.foundation.layout.fillMaxSize
1616
import androidx.compose.foundation.layout.fillMaxWidth
1717
import androidx.compose.foundation.layout.height
@@ -24,8 +24,7 @@ import androidx.compose.foundation.lazy.itemsIndexed
2424
import androidx.compose.foundation.lazy.rememberLazyListState
2525
import androidx.compose.foundation.shape.CircleShape
2626
import androidx.compose.material.icons.Icons
27-
import androidx.compose.material.icons.filled.GroupAdd
28-
import androidx.compose.material.icons.filled.Person
27+
import androidx.compose.material.icons.filled.Add
2928
import androidx.compose.material3.HorizontalDivider
3029
import androidx.compose.material3.Icon
3130
import androidx.compose.material3.Text
@@ -41,14 +40,12 @@ import androidx.compose.ui.res.painterResource
4140
import androidx.compose.ui.res.stringResource
4241
import androidx.compose.ui.tooling.preview.Preview
4342
import androidx.compose.ui.tooling.preview.PreviewWrapper
44-
import androidx.compose.foundation.border
45-
import androidx.compose.material.icons.filled.Add
4643
import androidx.compose.ui.unit.dp
4744
import androidx.compose.ui.zIndex
4845
import androidx.lifecycle.compose.collectAsStateWithLifecycle
49-
import com.flipcash.app.contacts.device.DeviceContact
5046
import com.flipcash.app.contacts.ui.ContactAvatar
5147
import com.flipcash.app.core.AppRoute
48+
import com.flipcash.app.core.contacts.DeviceContact
5249
import com.flipcash.app.core.send.SendResult
5350
import com.flipcash.app.core.send.SendStep
5451
import com.flipcash.app.directsend.internal.ContactListItem
@@ -65,7 +62,6 @@ import com.getcode.theme.White10
6562
import com.getcode.theme.extraSmall
6663
import com.getcode.ui.components.AppBarDefaults
6764
import com.getcode.ui.components.AppBarWithTitle
68-
import com.getcode.ui.components.CircularIconButton
6965
import com.getcode.ui.components.SearchInput
7066
import com.getcode.ui.core.verticalScrollStateGradient
7167
import com.getcode.ui.theme.CodeScaffold

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

Lines changed: 80 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@ package com.flipcash.app.messenger.internal
22

33
import androidx.compose.foundation.text.input.TextFieldState
44
import androidx.compose.foundation.text.input.clearText
5+
import androidx.compose.runtime.snapshotFlow
56
import androidx.lifecycle.viewModelScope
67
import androidx.paging.PagingData
78
import androidx.paging.cachedIn
89
import androidx.paging.flatMap
910
import androidx.paging.insertSeparators
1011
import com.flipcash.app.contacts.ContactCoordinator
11-
import com.flipcash.app.contacts.device.DeviceContact
12-
import androidx.compose.runtime.snapshotFlow
1312
import com.flipcash.app.core.AppRoute
1413
import com.flipcash.app.core.chat.ChatIdentifier
14+
import com.flipcash.app.core.contacts.DeviceContact
1515
import com.flipcash.app.core.extensions.onResult
1616
import com.flipcash.app.core.ui.ConfirmationStyle
1717
import com.flipcash.app.featureflags.FeatureFlag
@@ -26,7 +26,6 @@ import com.flipcash.services.models.buildDmPaymentMetadata
2626
import com.flipcash.services.models.chat.ChatId
2727
import com.flipcash.services.models.chat.DeliveryStatus
2828
import com.flipcash.services.models.chat.MessageContent
29-
import com.flipcash.services.models.chat.MessagePointer
3029
import com.flipcash.services.models.chat.TypingState
3130
import com.flipcash.services.user.UserManager
3231
import com.flipcash.shared.amountentry.AmountEntryDelegate
@@ -210,56 +209,48 @@ internal class ChatViewModel @Inject constructor(
210209
}
211210
}.cachedIn(viewModelScope)
212211

213-
private val maxAmountFlow = combine(
214-
transactionController.limits,
215-
tokenCoordinator.observeSelectedTokenMint()
216-
.flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) },
217-
exchange.observePreferredRate(),
218-
) { limits, balance, rate ->
219-
val balanceInLocal = balance.convertingTo(rate)
220-
val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero
221-
Fiat(min(sendLimit.nextTransaction, balanceInLocal.toDouble()), rate.currency)
222-
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
223-
224-
val amountDelegate = AmountEntryDelegate(
225-
exchange = exchange,
226-
scope = viewModelScope,
227-
style = AmountEntryStyle(
228-
actionLabel = resources.getString(R.string.action_swipeToSend),
229-
actionStyle = ConfirmationStyle.Slide,
230-
infoHint = { resources.getString(R.string.subtitle_sendHint, it) },
231-
overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) },
232-
),
233-
loadingState = stateFlow.map { it.sendProgress }
234-
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LoadingSuccessState()),
235-
maxAmount = maxAmountFlow,
236-
)
237-
238-
init {
239-
// Token observation
240-
tokenCoordinator.observeSelectedTokenMint()
241-
.flatMapLatest { mint ->
242-
tokenCoordinator.tokenBalances.map { tokens ->
243-
tokens.find { it.token.address == mint }
244-
}
245-
}
246-
.filterNotNull()
247-
.onEach { tokenWithBalance ->
248-
dispatchEvent(Event.TokenUpdated(tokenWithBalance.token))
249-
}.launchIn(viewModelScope)
212+
private val maxAmountFlow by lazy {
213+
combine(
214+
transactionController.limits,
215+
tokenCoordinator.observeSelectedTokenMint()
216+
.flatMapLatest { mint -> tokenCoordinator.balanceForToken(mint) },
217+
exchange.observePreferredRate(),
218+
) { limits, balance, rate ->
219+
val balanceInLocal = balance.convertingTo(rate)
220+
val sendLimit = limits?.sendLimitFor(rate.currency) ?: SendLimit.Zero
221+
Fiat(min(sendLimit.nextTransaction, balanceInLocal.toDouble()), rate.currency)
222+
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
223+
}
250224

251-
exchange.observePreferredRate()
252-
.onEach { rate ->
253-
val currency = exchange.getCurrency(rate.currency.name)
254-
if (currency != null) {
255-
amountDelegate.onCurrencyChanged(currency)
256-
}
257-
}.launchIn(viewModelScope)
225+
val amountDelegate by lazy {
226+
AmountEntryDelegate(
227+
exchange = exchange,
228+
scope = viewModelScope,
229+
style = AmountEntryStyle(
230+
actionLabel = resources.getString(R.string.action_swipeToSend),
231+
actionStyle = ConfirmationStyle.Slide,
232+
infoHint = { resources.getString(R.string.subtitle_sendHint, it) },
233+
overMaxHint = { resources.getString(R.string.subtitle_sendHintLimitExceeded, it) },
234+
),
235+
loadingState = stateFlow.map { it.sendProgress }
236+
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), LoadingSuccessState()),
237+
maxAmount = maxAmountFlow,
238+
)
239+
}
258240

259-
transactionController.limits
260-
.onEach { dispatchEvent(Event.LimitsChanged(it)) }
261-
.launchIn(viewModelScope)
241+
init {
242+
// Essential — needed immediately for chat display
243+
initChatHandlers()
244+
245+
viewModelScope.launch {
246+
// Yield to let the first frame render before setting up remaining collectors
247+
initTokenAndExchangeObservers()
248+
initTypingHandlers()
249+
initSendHandlers()
250+
}
251+
}
262252

253+
private fun initChatHandlers() {
263254
// Unified chat open handler — resolves chatId and contact from the identifier
264255
eventFlow
265256
.filterIsInstance<Event.OnChatOpened>()
@@ -269,9 +260,7 @@ internal class ChatViewModel @Inject constructor(
269260
// 1. Resolve chatId
270261
val chatId = when (identifier) {
271262
is ChatIdentifier.ByContact -> identifier.chatId
272-
?: chatCoordinator.getChatId(
273-
DeviceContact.unknownContact(identifier.e164)
274-
).getOrNull()
263+
?: chatCoordinator.getChatId(identifier.contact).getOrNull()
275264
is ChatIdentifier.ByChatId -> identifier.chatId
276265
}
277266

@@ -285,13 +274,7 @@ internal class ChatViewModel @Inject constructor(
285274
// 2. Resolve contact
286275
when (identifier) {
287276
is ChatIdentifier.ByContact -> {
288-
val contact = contactCoordinator.lookupContact(identifier.e164).getOrElse {
289-
DeviceContact.unknownContact(
290-
e164 = identifier.e164,
291-
displayName = identifier.displayName.takeIf { it.isNotBlank() },
292-
)
293-
}
294-
dispatchEvent(Event.OnContactFound(contact))
277+
dispatchEvent(Event.OnContactFound(identifier.contact))
295278
}
296279
is ChatIdentifier.ByChatId -> {
297280
val contact = contactCoordinator.lookupContactByDmChatId(
@@ -328,11 +311,39 @@ internal class ChatViewModel @Inject constructor(
328311
chatCoordinator.advanceReadPointer(chatId, event.messageId)
329312
}
330313
.launchIn(viewModelScope)
314+
}
315+
316+
private fun initTokenAndExchangeObservers() {
317+
// Token observation
318+
tokenCoordinator.observeSelectedTokenMint()
319+
.flatMapLatest { mint ->
320+
tokenCoordinator.tokenBalances.map { tokens ->
321+
tokens.find { it.token.address == mint }
322+
}
323+
}
324+
.filterNotNull()
325+
.onEach { tokenWithBalance ->
326+
dispatchEvent(Event.TokenUpdated(tokenWithBalance.token))
327+
}.launchIn(viewModelScope)
328+
329+
exchange.observePreferredRate()
330+
.onEach { rate ->
331+
val currency = exchange.getCurrency(rate.currency.name)
332+
if (currency != null) {
333+
amountDelegate.onCurrencyChanged(currency)
334+
}
335+
}.launchIn(viewModelScope)
336+
337+
transactionController.limits
338+
.onEach { dispatchEvent(Event.LimitsChanged(it)) }
339+
.launchIn(viewModelScope)
340+
}
331341

342+
@OptIn(ExperimentalCoroutinesApi::class)
343+
private fun initTypingHandlers() {
332344
// Dispatch typing notifications based on text changes.
333345
// transformLatest auto-cancels the previous block on each new emission,
334346
// replacing manual Job tracking for idle timeout and heartbeats.
335-
@OptIn(ExperimentalCoroutinesApi::class)
336347
snapshotFlow { stateFlow.value.chatInputState.text.toString() }
337348
.drop(1)
338349
.distinctUntilChanged()
@@ -410,7 +421,9 @@ internal class ChatViewModel @Inject constructor(
410421
}
411422
.onEach { dispatchEvent(Event.TypingEnabled(it)) }
412423
.launchIn(viewModelScope)
424+
}
413425

426+
private fun initSendHandlers() {
414427
// Send text message
415428
eventFlow.filterIsInstance<Event.SendMessage>()
416429
.map { stateFlow.value.chatInputState }
@@ -610,7 +623,12 @@ internal class ChatViewModel @Inject constructor(
610623
companion object {
611624
val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
612625
when (event) {
613-
is Event.OnChatOpened -> { state -> state }
626+
is Event.OnChatOpened -> { state ->
627+
when (val id = event.identifier) {
628+
is ChatIdentifier.ByContact -> state.copy(chattingWith = id.contact)
629+
is ChatIdentifier.ByChatId -> state
630+
}
631+
}
614632
is Event.OnContactFound -> { state ->
615633
state.copy(
616634
chattingWith = event.contact

0 commit comments

Comments
 (0)