diff --git a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt index a13c470cf5..7a8de96a19 100644 --- a/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt +++ b/apps/flipcash/features/search/src/main/kotlin/com/flipcash/app/search/internal/SearchViewModel.kt @@ -77,7 +77,9 @@ internal class SearchViewModel @Inject constructor( chatCoordinator.chatListFeeds(), tokenCoordinator.tokens, chatCoordinator.observeSenderProfiles(), - ) { feeds, tokens, profiles -> + // Payment previews read at the display and sent currencies' rates, so a rate landing redraws them. + combine(exchange.observePreferredRate(), exchange.observeRates(), ::Pair), + ) { feeds, tokens, profiles, _ -> val tokensByMint = tokens.associateBy { it.address } val selfId = userManager.accountId Directory( diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt index 84308978a9..7db4036db4 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt @@ -57,9 +57,11 @@ internal class ArchivedChatsViewModel @Inject constructor( chatCoordinator.archivedChatListFeed(), tokenCoordinator.tokens, chatCoordinator.observeSenderProfiles(), - paymentRequests.state, - hostLobbies.state, - ) { summaries, tokens, senderProfiles, requests, lobbies -> + // A payment preview reads at the display currency's rate and the sent currency's, so + // either one landing redraws the rows. Paired to stay at five typed flows. + combine(exchange.observePreferredRate(), exchange.observeRates(), ::Pair), + combine(paymentRequests.state, hostLobbies.state, ::Pair), + ) { summaries, tokens, senderProfiles, _, (requests, lobbies) -> mapConversations( summaries, tokens, senderProfiles, userManager.accountId, resources, chatCoordinator, exchange, requests, requestPaymentRequest = { id -> viewModelScope.launch { paymentRequests.fetchIfAbsent(id) } }, diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index 14b71af54f..cef14e4572 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -170,13 +170,14 @@ internal class ChatsViewModel @Inject constructor( // One flow for both lists: two would let a new main list pair with a stale archived one, // and archiving the last main chat would flash the no-chats prompt before the Archived row. - // A payment preview reads at the display currency's rate, so a new currency or rate redraws the rows. + // A payment preview reads at the display currency's rate and the sent currency's, so a new + // currency or any rate arriving redraws the rows; the preferred rate alone misses a late sent-currency rate. combine( chatCoordinator.chatListFeeds(), tokenCoordinator.tokens, chatCoordinator.observeSenderProfiles(), - exchange.observePreferredRate(), // Paired so the combine stays at five typed flows. + combine(exchange.observePreferredRate(), exchange.observeRates(), ::Pair), combine(paymentRequests.state, hostLobbies.state, ::Pair), ) { feeds, tokens, senderProfiles, _, (requests, lobbies) -> content(feeds, tokens, senderProfiles, requests, lobbies) diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt index d14c551737..6b1e64517f 100644 --- a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -70,6 +70,7 @@ class ChatsViewModelArchiveTest { val vm = ChatsViewModel( exchange = mockk(relaxed = true).also { every { it.observePreferredRate() } returns flowOf(Rate.oneToOne) + every { it.observeRates() } returns flowOf(emptyMap()) }, chatCoordinator = chatCoordinator, userManager = mockk(relaxed = true), diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt new file mode 100644 index 0000000000..7fa71bced8 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelRatesTest.kt @@ -0,0 +1,125 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.tipping.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatFeeds +import com.flipcash.shared.chat.ChatSummary +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.solana.keys.Mint +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * A payment preview restates the sent amount in the display currency, which needs both currencies' + * rates. Rates stream in batches, so the display currency's can land first; the preview must redraw + * when the sent currency's follows, though the preferred rate has not changed. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatsViewModelRatesTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val cad = Rate(fx = 1.4, currency = CurrencyCode.CAD) + private val gbp = Rate(fx = 0.8, currency = CurrencyCode.GBP) + + private val chat = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(ByteArray(16) { 3 }.toList()), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = ChatMessage( + messageId = 1, + senderId = null, + content = listOf( + MessageContent.Cash( + intentId = ByteArray(32) { 1 }.toList(), + amount = Fiat(5.0, CurrencyCode.GBP), + mint = Mint.usdf, + ) + ), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 0, + ), + lastActivity = Instant.fromEpochSeconds(1_000), + title = "Ballers", + latestEventSequence = 0, + ), + unreadCount = 0, + ) + + @Test + fun `a payment preview redraws when the sent currency's rate lands after the display rate`() = + runTest(mainCoroutineRule.dispatcher) { + val preferred = MutableStateFlow(cad) + val rates = MutableStateFlow(mapOf(CurrencyCode.CAD to cad)) + val exchange = mockk(relaxed = true).also { + every { it.preferredRate } answers { preferred.value } + every { it.rates() } answers { rates.value } + every { it.observePreferredRate() } returns preferred + every { it.observeRates() } returns rates + } + val feeds = MutableStateFlow(ChatFeeds(main = listOf(chat), archived = emptyList())) + val chatCoordinator = mockk(relaxed = true).also { + every { it.feedWithArchived(*anyVararg()) } returns feeds + every { it.currentFeedWithArchived(*anyVararg()) } returns null + every { it.observeSenderProfiles() } returns flowOf(emptyMap()) + } + val tokenCoordinator = mockk(relaxed = true).also { + every { it.tokens } returns flowOf(emptyList()) + every { it.cachedTokens() } returns emptyList() + } + val vm = ChatsViewModel( + exchange = exchange, + chatCoordinator = chatCoordinator, + userManager = mockk(relaxed = true), + tokenCoordinator = tokenCoordinator, + resources = FakeResourceHelper(R::class.java), + dispatchers = TestDispatchers(testScheduler), + archiveStore = ChatArchiveStore.None, + lobbyRequests = mockk(relaxed = true), + hostLobbies = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + paymentRequests = mockk(relaxed = true) { every { state } returns MutableStateFlow(emptyMap()) }, + ) + + val states = mutableListOf() + backgroundScope.launch { vm.stateFlow.collect { states += it } } + advanceUntilIdle() + + fun preview() = states.last().chats.dataOrNull?.single()?.lastMessagePreview + + // GBP has no rate yet, so the amount can only read as it was sent. + assertEquals(Fiat(5.0, CurrencyCode.GBP).formatted(), preview()) + + rates.value = rates.value + (CurrencyCode.GBP to gbp) + advanceUntilIdle() + + // 5 GBP / 0.8 = 6.25 USD, * 1.4 = 8.75 CAD. + assertEquals(Fiat(8.75, CurrencyCode.CAD).formatted(), preview()) + } +}