From ec3ea26309b7ebfe15ff1b43c3fa34dd98b99a8c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 19:28:32 -0400 Subject: [PATCH 1/7] feat(nav): open on the Chats tab on cold launch A signed-in launch with no deeplink, or one the router resolves to nothing, now starts on the Chats tab instead of Wallet. The route comes from the Chats tab's own destination, so launching lands where a tap on the tab does. Deeplink launches keep the Wallet base, since token and cash-link actions open as the wallet's expanded card. Onboarding still releases to Wallet. --- .../flipcash/app/internal/ui/navigation/MainRoot.kt | 6 ++++-- .../ui/navigation/BuildNavGraphForLaunchTest.kt | 10 +++++----- .../com/flipcash/app/core/navigation/HomeRoute.kt | 10 ++++++++-- .../com/flipcash/app/core/navigation/HomeRouteTest.kt | 5 +++++ .../com/flipcash/app/login/OnboardingFlowScreen.kt | 4 ++-- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt index 72712c0604..1fbddb817b 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt @@ -28,6 +28,7 @@ import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.homeRoute +import com.flipcash.app.core.navigation.launchRoute import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.resolveBackStack import com.flipcash.app.router.LocalRouter @@ -234,10 +235,11 @@ internal fun buildNavGraphForLaunch( pendingAction = action, ) - else -> LaunchNavGraph(listOf(home)) + // A link that resolves to nothing is a plain launch. + else -> LaunchNavGraph(listOf(launchRoute)) } } else { - LaunchNavGraph(listOf(home)) + LaunchNavGraph(listOf(launchRoute)) } } diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt index b8c55865f8..f12598f2ba 100644 --- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -51,11 +51,11 @@ class BuildNavGraphForLaunchTest { // -- Ready -- @Test - fun `logged in without deeplink opens on the Wallet tab`() { + fun `logged in without deeplink opens on the Chats tab`() { val result = build(AuthState.Ready)!! - assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertEquals(listOf(AppRoute.Sheets.Tips()), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) - assertEquals(listOf(AppRoute.Sheets.Wallet), result.resolvedBackStack()) + assertEquals(listOf(AppRoute.Sheets.Tips()), result.resolvedBackStack()) } @Test @@ -99,9 +99,9 @@ class BuildNavGraphForLaunchTest { } @Test - fun `logged in with None action opens the Wallet tab without deeplink routes`() { + fun `logged in with None action opens the Chats tab without deeplink routes`() { val result = buildReady(DeeplinkAction.None) - assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes) + assertEquals(listOf(AppRoute.Sheets.Tips()), result.baseRoutes) assertTrue(result.deeplinkRoutes.isEmpty()) } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt index c92a853776..781973e19e 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt @@ -3,11 +3,17 @@ package com.flipcash.app.core.navigation import com.flipcash.app.core.AppRoute /** - * The top-level route the app releases to once there's a session — on launch and when onboarding - * finishes. + * The top-level route the app releases to once there's a session — when onboarding finishes, and + * under a launch deeplink (whose token and cash-link actions open on the wallet). * * The wallet is a flat tab home (not a sheet), so this route is applied with `replaceAll` / * [com.getcode.navigation.core.NavOptions.PopUpTo.ClearAll], exactly like a tab switch from the * nav bar. */ val homeRoute: AppRoute = AppRoute.Sheets.Wallet + +/** + * The tab a cold launch opens on when no deeplink directs it elsewhere: Chats. Built from the tab's + * own destination so launching lands on the same route a tap on the Chats tab does. + */ +val launchRoute: AppRoute = NavBarButton.Chats.destinationRoute() diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt index 66853ed931..a1c1e42283 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/HomeRouteTest.kt @@ -21,4 +21,9 @@ class HomeRouteTest { // hoisted nav bar would render with no selection. assertEquals(NavBarButton.Wallet, homeRoute.asNavBarTab()) } + + @Test + fun `cold launch opens on the chats tab`() { + assertEquals(NavBarButton.Chats, launchRoute.asNavBarTab()) + } } diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 7b3a34efd6..a0c6d1e1f5 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -113,7 +113,7 @@ import kotlin.time.Duration.Companion.milliseconds * [PermissionsPhaseFlowHost]. * ² Display-name entry is shown only when no display name is set. It reuses the * UpdateUserProfile subflow, whose `target` replaces the stack with the permissions phase. - * ³ Home is the same route the app launches on — the Wallet tab. See [homeRoute]. + * ³ Home is the Wallet tab, so a new user lands on their balance. See [homeRoute]. */ @Composable fun OnboardingFlowScreen( @@ -159,7 +159,7 @@ private fun PermissionsPhaseFlowHost( val userManager = LocalUserManager.current val contactPickerMode by featureFlags.observe(FeatureFlag.ContactPickerMode).collectAsStateWithLifecycle() - // Onboarding releases to the same home the app launches on: the Wallet tab. + // Onboarding releases to the Wallet tab, not the Chats tab a cold launch opens on. val home = homeRoute val permissionsSteps = buildList { From 9a8423930313c4f264b683b16081267947ae55d4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 20:09:06 -0400 Subject: [PATCH 2/7] fix(chat): hold the feed until the database has been read The chat list shows "No Chats Yet" as soon as the feed emits an empty list, and feed() emitted the state holder's initial empty list before observeFeedFromDb had read Room. Opening on the Chats tab at launch put the empty state up over chats that were about to load from disk. ChatState now carries feedLoaded, set by the first database read, and feed() filters on it. An empty database still emits an empty feed. --- .../com/flipcash/shared/chat/ChatState.kt | 3 + .../internal/delegates/FeedSyncDelegate.kt | 7 +- .../shared/chat/FeedDbHydrationTest.kt | 81 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt index f4a010780d..5956d5ec50 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -8,6 +8,9 @@ import kotlin.time.Instant data class ChatState( val feed: List = emptyList(), + // False until the feed has been read from the database once, so an empty [feed] before then + // means "not read yet", not "no chats". + val feedLoaded: Boolean = false, val typingIndicators: Map> = emptyMap(), val reactionOverlays: Map> = emptyMap(), val feedSyncState: FeedSyncState = FeedSyncState.Idle, diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 1c8d89df9a..771dc1b897 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -117,7 +118,9 @@ class FeedSyncDelegate @Inject constructor( override fun feed(vararg chatTypes: ChatType): Flow> { val requested = chatTypes.toSet() - return stateHolder.state.map { state -> + // Nothing until the database has been read: the chat list shows its empty state for an + // emitted empty list, and the state holder's initial empty feed isn't one. + return stateHolder.state.filter { it.feedLoaded }.map { state -> val selfId = userManager.accountId val selfPhone = userManager.profile?.verifiedPhoneNumber state.feed @@ -196,7 +199,7 @@ class FeedSyncDelegate @Inject constructor( ) { metadataEntities, membersByChat -> buildFeedFromDb(metadataEntities, membersByChat) }.onEach { feed -> - stateHolder.update { it.copy(feed = feed) } + stateHolder.update { it.copy(feed = feed, feedLoaded = true) } }.launchIn(scope) } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt new file mode 100644 index 0000000000..8146ff9eac --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt @@ -0,0 +1,81 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The chat list tells "loading" from "no chats" by whether the feed has emitted, so the feed must + * not emit until the database has been read. An empty list emitted before that would put the empty + * state up over chats that are about to load from disk. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class FeedDbHydrationTest { + + private val rows = MutableSharedFlow>() + + private val delegate = FeedSyncDelegate( + chatController = mockk(relaxed = true), + metadataDataSource = mockk(relaxed = true).also { + every { it.observeAll() } returns rows + }, + messageDataSource = mockk(relaxed = true), + memberDataSource = mockk(relaxed = true).also { + every { it.observeAll() } returns flowOf(emptyMap>()) + }, + stateHolder = ChatStateHolder(), + userManager = mockk(relaxed = true).also { + every { it.accountId } returns listOf(1, 2, 3) + every { it.profile } returns null + }, + ) + + @Test + fun `feed does not emit before the database has been read`() = runTest { + val emissions = mutableListOf>() + delegate.feed(ChatType.TIP_DM, ChatType.GROUP) + .onEach { emissions += it } + .launchIn(backgroundScope) + + delegate.initialize(backgroundScope) + delegate.observeFeedFromDb() + runCurrent() + + assertTrue(emissions.isEmpty(), "feed emitted $emissions before the database answered") + } + + @Test + fun `an empty database still emits an empty feed`() = runTest { + val emissions = mutableListOf>() + delegate.feed(ChatType.TIP_DM, ChatType.GROUP) + .onEach { emissions += it } + .launchIn(backgroundScope) + + delegate.initialize(backgroundScope) + delegate.observeFeedFromDb() + runCurrent() + rows.emit(emptyList()) + runCurrent() + + assertEquals(listOf(emptyList()), emissions) + } +} From 5060c5d5c437f485f1c3679881978e68c59739bf Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 21:03:30 -0400 Subject: [PATCH 3/7] fix(chat): shorten the blank Chats tab on cold launch The Chats tab showed blank for 200-350ms on a cold launch before its list drew. Two things held it up: the feed sync started alongside the first database read and competed with it for CPU, and TipFlowViewModel built the rows on the main thread while it was drawing the app's first screens. The sync now waits for the first read (bounded at 3s), and the row mapping runs on the Default dispatcher. Measured on a Seeker with the non-debuggable benchmark build, four cold launches each: the blank tab went from 207-353ms to 122-152ms, and the list now draws about 870ms after process start instead of about 1015ms. --- .../flipcash/app/tipping/internal/TipFlowViewModel.kt | 9 ++++++++- .../flipcash/shared/chat/internal/RealChatCoordinator.kt | 8 ++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt index 63455c5acf..420b0c0a92 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.data.Loadable +import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.tipping.TipStep import com.flipcash.app.shareable.ShareSheetController @@ -20,6 +21,7 @@ import com.flipcash.shared.tipping.TippingCoordinator import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn @@ -37,6 +39,7 @@ internal class TipFlowViewModel @Inject constructor( shareable: ShareSheetController, tipCodePreviewCache: TipCodePreviewCache, private val resources: ResourceHelper, + dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -93,7 +96,11 @@ internal class TipFlowViewModel @Inject constructor( val selfId = userManager.accountId val tokensByMint = tokens.associateBy { it.address } summaries.map { it.toConversationReference(selfId, tokensByMint, resources) } - }.onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) }.launchIn(viewModelScope) + } + // Off the main thread: on a cold launch the first mapping lands while the main thread + // is drawing the app's first screens, and waiting for it holds the Chats tab blank. + .flowOn(dispatchers.Default) + .onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) }.launchIn(viewModelScope) eventFlow .filterIsInstance() diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index 0b143b8060..e42cc6608d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -39,11 +39,13 @@ import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration.Companion.seconds @@ -100,6 +102,7 @@ class RealChatCoordinator @Inject constructor( companion object { private const val TAG = "ChatCoordinator" + private val FEED_READ_WAIT = 3.seconds } // Recreated on re-login: [reset] cancels [supervisorJob] on logout, which would @@ -141,6 +144,11 @@ class RealChatCoordinator @Inject constructor( // becoming retryable. See [MessagingDelegate.recoverInterruptedSends]. messagingDelegate.recoverInterruptedSends() feedDelegate.observeFeedFromDb() + // The list the Chats tab opens on comes from this read, so let it finish before the sync + // below starts competing with it for CPU (a cold-launch trace had the sync's gRPC work + // on every core while the Room reads waited to run). Bounded, so a read that never + // answers delays the sync rather than cancelling it. + withTimeoutOrNull(FEED_READ_WAIT) { stateHolder.state.first { it.feedLoaded } } syncFeeds() eventStreamDelegate.open() eventStreamDelegate.startHeartbeat { syncFeeds() } From e0d32693f2df57c24cf26670b080143ecc261726 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 22:54:47 -0400 Subject: [PATCH 4/7] refactor(chat): make ChatState.feed null until the database has been read Replaces the separate feedLoaded flag, which allowed a non-empty feed marked as not loaded. Null now means "not read yet" and an empty list means "no chats". --- .../main/kotlin/com/flipcash/shared/chat/ChatState.kt | 7 +++---- .../shared/chat/internal/RealChatCoordinator.kt | 2 +- .../shared/chat/internal/delegates/FeedSyncDelegate.kt | 10 +++++----- .../chat/internal/delegates/MessagingDelegate.kt | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt index 5956d5ec50..1d7e1da6fc 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt @@ -7,10 +7,9 @@ import com.getcode.opencode.model.core.ID import kotlin.time.Instant data class ChatState( - val feed: List = emptyList(), - // False until the feed has been read from the database once, so an empty [feed] before then - // means "not read yet", not "no chats". - val feedLoaded: Boolean = false, + // Null until the feed has been read from the database once, so an empty list means "no + // chats" and never "not read yet". + val feed: List? = null, val typingIndicators: Map> = emptyMap(), val reactionOverlays: Map> = emptyMap(), val feedSyncState: FeedSyncState = FeedSyncState.Idle, diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index e42cc6608d..6f2a37d78e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -148,7 +148,7 @@ class RealChatCoordinator @Inject constructor( // below starts competing with it for CPU (a cold-launch trace had the sync's gRPC work // on every core while the Room reads waited to run). Bounded, so a read that never // answers delays the sync rather than cancelling it. - withTimeoutOrNull(FEED_READ_WAIT) { stateHolder.state.first { it.feedLoaded } } + withTimeoutOrNull(FEED_READ_WAIT) { stateHolder.state.first { it.feed != null } } syncFeeds() eventStreamDelegate.open() eventStreamDelegate.startHeartbeat { syncFeeds() } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 771dc1b897..a472b9176d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -37,9 +37,9 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch @@ -119,11 +119,11 @@ class FeedSyncDelegate @Inject constructor( override fun feed(vararg chatTypes: ChatType): Flow> { val requested = chatTypes.toSet() // Nothing until the database has been read: the chat list shows its empty state for an - // emitted empty list, and the state holder's initial empty feed isn't one. - return stateHolder.state.filter { it.feedLoaded }.map { state -> + // emitted empty list, so it must not see one that only means "not read yet". + return stateHolder.state.mapNotNull { it.feed }.map { feed -> val selfId = userManager.accountId val selfPhone = userManager.profile?.verifiedPhoneNumber - state.feed + feed .filter { it.type in requested } .filter { isRenderable(it, selfId, selfPhone) } .map { metadata -> @@ -199,7 +199,7 @@ class FeedSyncDelegate @Inject constructor( ) { metadataEntities, membersByChat -> buildFeedFromDb(metadataEntities, membersByChat) }.onEach { feed -> - stateHolder.update { it.copy(feed = feed, feedLoaded = true) } + stateHolder.update { it.copy(feed = feed) } }.launchIn(scope) } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 83e02b5053..4e690fc6db 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -391,7 +391,7 @@ class MessagingDelegate @Inject constructor( // pointer below a deleted message and leave the chat permanently unread. val messageId = messageDataSource.getLatestMessageId(chatId) ?: stateHolder.current.feed - .firstOrNull { it.chatId == chatId } + ?.firstOrNull { it.chatId == chatId } ?.lastMessage?.messageId ?: return Result.success(Unit) return advanceReadPointer(chatId, messageId) From c8a2805ff65596add852e5bbf262e400755f60a5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 23:06:16 -0400 Subject: [PATCH 5/7] fix(chat): wait for the first sync before showing an empty chat list An empty database is only known to mean "no chats" once the server has answered: on a fresh sign-in the first sync is about to write the account's chats, and until then the Chats tab showed "No Chats Yet". feed() now emits an empty list only after the sync succeeds or fails. Chats already on disk still emit as soon as they are read. --- .../internal/delegates/FeedSyncDelegate.kt | 14 +++-- .../shared/chat/FeedDbHydrationTest.kt | 53 ++++++++++++++++--- .../shared/chat/FeedGroupProjectionTest.kt | 8 ++- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index a472b9176d..0520f71b6d 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -118,9 +118,17 @@ class FeedSyncDelegate @Inject constructor( override fun feed(vararg chatTypes: ChatType): Flow> { val requested = chatTypes.toSet() - // Nothing until the database has been read: the chat list shows its empty state for an - // emitted empty list, so it must not see one that only means "not read yet". - return stateHolder.state.mapNotNull { it.feed }.map { feed -> + // Nothing until the list is known: the chat list shows its empty state for an emitted empty + // list, so it must not see one that only means "not read yet". Chats on disk are known as + // soon as they are read. An empty database is not, because on a fresh sign-in it is empty + // only until the first sync writes the account's chats, so it waits for that sync to answer. + return stateHolder.state.mapNotNull { state -> + state.feed?.takeIf { feed -> + feed.isNotEmpty() || + state.feedSyncState == FeedSyncState.Synced || + state.feedSyncState == FeedSyncState.Error + } + }.map { feed -> val selfId = userManager.accountId val selfPhone = userManager.profile?.verifiedPhoneNumber feed diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt index 8146ff9eac..508fb3e705 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test @@ -25,13 +26,15 @@ import kotlin.test.assertTrue /** * The chat list tells "loading" from "no chats" by whether the feed has emitted, so the feed must - * not emit until the database has been read. An empty list emitted before that would put the empty - * state up over chats that are about to load from disk. + * not emit until it knows the answer: chats on disk, or the server's first reply. An empty list + * emitted before that would put the empty state up over chats that are about to load from disk, or + * on a fresh sign-in, over chats the first sync is about to write. */ @OptIn(ExperimentalCoroutinesApi::class) class FeedDbHydrationTest { private val rows = MutableSharedFlow>() + private val stateHolder = ChatStateHolder() private val delegate = FeedSyncDelegate( chatController = mockk(relaxed = true), @@ -42,7 +45,7 @@ class FeedDbHydrationTest { memberDataSource = mockk(relaxed = true).also { every { it.observeAll() } returns flowOf(emptyMap>()) }, - stateHolder = ChatStateHolder(), + stateHolder = stateHolder, userManager = mockk(relaxed = true).also { every { it.accountId } returns listOf(1, 2, 3) every { it.profile } returns null @@ -64,18 +67,52 @@ class FeedDbHydrationTest { } @Test - fun `an empty database still emits an empty feed`() = runTest { - val emissions = mutableListOf>() - delegate.feed(ChatType.TIP_DM, ChatType.GROUP) - .onEach { emissions += it } - .launchIn(backgroundScope) + fun `an empty database does not emit before the first sync answers`() = runTest { + val emissions = collectFeed() + + delegate.initialize(backgroundScope) + delegate.observeFeedFromDb() + runCurrent() + rows.emit(emptyList()) + stateHolder.update { it.copy(feedSyncState = FeedSyncState.Syncing) } + runCurrent() + + assertTrue(emissions.isEmpty(), "feed emitted $emissions before the sync answered") + } + + @Test + fun `an empty database emits an empty feed once the sync succeeds`() = runTest { + val emissions = collectFeed() delegate.initialize(backgroundScope) delegate.observeFeedFromDb() runCurrent() rows.emit(emptyList()) + stateHolder.update { it.copy(feedSyncState = FeedSyncState.Synced) } runCurrent() assertEquals(listOf(emptyList()), emissions) } + + @Test + fun `an empty database emits an empty feed once the sync fails`() = runTest { + val emissions = collectFeed() + + delegate.initialize(backgroundScope) + delegate.observeFeedFromDb() + runCurrent() + rows.emit(emptyList()) + stateHolder.update { it.copy(feedSyncState = FeedSyncState.Error) } + runCurrent() + + assertEquals(listOf(emptyList()), emissions) + } + + private fun TestScope.collectFeed(): List> { + val emissions = mutableListOf>() + delegate.feed(ChatType.TIP_DM, ChatType.GROUP) + .onEach { emissions += it } + .launchIn(backgroundScope) + return emissions + } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt index d013df1607..b6f62e0f03 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt @@ -114,12 +114,18 @@ class FeedGroupProjectionTest { every { source.observeAll() } returns flowOf(members) } + // Marked synced so an empty projection still emits: these tests are about what the feed + // holds, not about when it is first known. + private val stateHolder = ChatStateHolder().apply { + update { it.copy(feedSyncState = FeedSyncState.Synced) } + } + val delegate = FeedSyncDelegate( chatController = mockk(relaxed = true), metadataDataSource = metadataDataSource, messageDataSource = messageDataSource, memberDataSource = memberDataSource, - stateHolder = ChatStateHolder(), + stateHolder = stateHolder, userManager = mockk(relaxed = true).also { every { it.accountId } returns selfId every { it.profile } returns null From 21987ab6bbb426756f223a80da03c6512e938aeb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 23:08:46 -0400 Subject: [PATCH 6/7] feat(onboarding): land on Chats after signing in to an existing account Every onboarding ending released to the Wallet tab. Only an account that onboarding just created should: a restore or an app resume now lands on the Chats tab, where a cold launch opens. This matches iOS, which passes the Wallet tab only from completeOnboardingAndLogin. OnboardingFlow carries a newAccount flag, set on the paths that create the account: finishing the access key or purchase step, and resuming at display-name entry. onboardingLandingRoute picks the Wallet tab for it and launchRoute otherwise. A restored account that still owes the purchase goes through the purchase step, so it counts as new and lands on Wallet. --- .../app/internal/ui/navigation/MainRoot.kt | 1 + .../navigation/BuildNavGraphForLaunchTest.kt | 2 ++ .../kotlin/com/flipcash/app/core/AppRoute.kt | 3 +++ .../flipcash/app/core/navigation/HomeRoute.kt | 2 +- .../app/login/OnboardingFlowScreen.kt | 19 +++++++++++---- .../app/login/OnboardingRoutingTest.kt | 23 +++++++++++++++++++ 6 files changed, 45 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt index 1fbddb817b..3eec0c76d3 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt @@ -200,6 +200,7 @@ internal fun buildNavGraphForLaunch( target = AppRoute.OnboardingFlow( phase = AppRoute.OnboardingFlow.Phase.Permissions, skipContacts = true, + newAccount = true, ), allowBack = false, ) diff --git a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt index f12598f2ba..873fe5e601 100644 --- a/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt +++ b/apps/flipcash/app/src/test/kotlin/com/flipcash/app/internal/ui/navigation/BuildNavGraphForLaunchTest.kt @@ -175,6 +175,8 @@ class BuildNavGraphForLaunchTest { ) val target = assertIs(route.target) assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase) + // Display-name entry follows the access key, so this is an account still being created. + assertTrue(target.newAccount) } // -- Authenticating -- diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index a9488c8ad9..b8333ed820 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -80,6 +80,9 @@ sealed interface AppRoute : NavKey, Parcelable { val fromDeeplink: Boolean = false, val resumeAt: ResumePoint = ResumePoint.Login, val skipContacts: Boolean = true, + // Set when onboarding is creating the account rather than signing in to one; decides + // where the permissions phase releases to. + val newAccount: Boolean = false, ) : AppRoute, FlowRoute { enum class Phase { Account, Permissions } enum class ResumePoint { Login, AccessKey, AccessKeyThenPurchase, PostAccessKey } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt index 781973e19e..d11582d4fb 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/HomeRoute.kt @@ -3,7 +3,7 @@ package com.flipcash.app.core.navigation import com.flipcash.app.core.AppRoute /** - * The top-level route the app releases to once there's a session — when onboarding finishes, and + * The top-level route the app releases to when onboarding finishes creating a new account, and * under a launch deeplink (whose token and cash-link actions open on the wallet). * * The wallet is a flat tab home (not a sheet), so this route is applied with `replaceAll` / diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index a0c6d1e1f5..95b88ea4eb 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -30,6 +30,7 @@ import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.navigation.homeRoute +import com.flipcash.app.core.navigation.launchRoute import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.core.onboarding.OnboardingResult @@ -113,7 +114,9 @@ import kotlin.time.Duration.Companion.milliseconds * [PermissionsPhaseFlowHost]. * ² Display-name entry is shown only when no display name is set. It reuses the * UpdateUserProfile subflow, whose `target` replaces the stack with the permissions phase. - * ³ Home is the Wallet tab, so a new user lands on their balance. See [homeRoute]. + * ³ A new account (1, and 4 when it resumes account creation) lands on the Wallet tab, so a new + * user sees their balance. A restore (2) or an app resume (3) lands on Chats, where a cold launch + * opens. See [onboardingLandingRoute]. */ @Composable fun OnboardingFlowScreen( @@ -159,8 +162,7 @@ private fun PermissionsPhaseFlowHost( val userManager = LocalUserManager.current val contactPickerMode by featureFlags.observe(FeatureFlag.ContactPickerMode).collectAsStateWithLifecycle() - // Onboarding releases to the Wallet tab, not the Chats tab a cold launch opens on. - val home = homeRoute + val home = onboardingLandingRoute(route) val permissionsSteps = buildList { if (!route.skipContacts && !contactPickerMode) add(OnboardingStep.ContactPermission) @@ -244,6 +246,14 @@ private fun AccountPhaseFlowHost( ) } +/** + * Where the permissions phase releases to: the Wallet tab for an account onboarding just created, + * so a new user sees their balance, and the Chats tab a cold launch opens on for an account being + * signed in to. + */ +internal fun onboardingLandingRoute(route: AppRoute.OnboardingFlow): AppRoute = + if (route.newAccount) homeRoute else launchRoute + internal fun resolvePostAccountRoute( result: OnboardingResult, skipContacts: Boolean = true, @@ -253,7 +263,7 @@ internal fun resolvePostAccountRoute( skipContacts = skipContacts, ) return when (result) { - is OnboardingResult.ProceedToVerification -> permissionsRoute + is OnboardingResult.ProceedToVerification -> permissionsRoute.copy(newAccount = true) OnboardingResult.LoggedIn -> permissionsRoute OnboardingResult.Completed -> null } @@ -278,6 +288,7 @@ private fun FlowNavigator.proceedToNameOrPermi target = AppRoute.OnboardingFlow( phase = AppRoute.OnboardingFlow.Phase.Permissions, skipContacts = true, + newAccount = true, ), allowBack = false, ) diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt index abc5c3220f..58a6e2844c 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/OnboardingRoutingTest.kt @@ -1,11 +1,15 @@ package com.flipcash.app.login import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.navigation.homeRoute +import com.flipcash.app.core.navigation.launchRoute import com.flipcash.app.core.onboarding.OnboardingResult import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNull +import kotlin.test.assertTrue class OnboardingRoutingTest { @@ -19,6 +23,7 @@ class OnboardingRoutingTest { val flow = assertIs(route) assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase) assertEquals(true, flow.skipContacts) + assertTrue(flow.newAccount) } // -- LoggedIn routes to permissions -- @@ -31,6 +36,7 @@ class OnboardingRoutingTest { val flow = assertIs(route) assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase) assertEquals(true, flow.skipContacts) + assertFalse(flow.newAccount) } // -- skipContacts is forwarded -- @@ -52,4 +58,21 @@ class OnboardingRoutingTest { fun `Completed returns null`() { assertNull(resolvePostAccountRoute(OnboardingResult.Completed)) } + + // -- Where onboarding lands -- + + @Test + fun `a new account lands on the wallet`() { + val route = AppRoute.OnboardingFlow( + phase = AppRoute.OnboardingFlow.Phase.Permissions, + newAccount = true, + ) + assertEquals(homeRoute, onboardingLandingRoute(route)) + } + + @Test + fun `an existing account lands where a cold launch does`() { + val route = AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions) + assertEquals(launchRoute, onboardingLandingRoute(route)) + } } From 79caf67ff35bee346d9516a3dd03ad106eefd311 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 12:03:30 -0400 Subject: [PATCH 7/7] perf(chat): draw the chat list on the Chats tab's first frame The feed builder ran one getLatestVisible query per chat, which took 115-165ms for 65 chats on a Seeker (benchmark build). It now reads every chat's latest visible message in one query, which takes 13-38ms. Both queries now break a timestamp tie on message_id DESC, matching the paged message list; getLatestVisible previously left ties to SQLite. With the feed ready sooner, it is built before TipFlowViewModel in practice, so the view model seeds its first rows in init from the new ChatCoordinator.currentFeed() and TokenCoordinator.cachedTokens(). currentFeed() shares feed()'s gate, so it is null whenever feed() would stay silent. Over five cold launches the seed found the feed every time and the Chats screen's first composition already held the list, at 691-1007ms from process start. Before, the tab composed blank and the list followed at 826-981ms. --- .../app/tipping/internal/TipFlowViewModel.kt | 23 ++++++++--- .../flipcash/shared/chat/ChatCoordinator.kt | 6 +++ .../internal/delegates/FeedSyncDelegate.kt | 40 ++++++++++--------- .../shared/chat/FeedDbHydrationTest.kt | 17 ++++++++ .../shared/chat/FeedDeletedMessageTest.kt | 17 ++++---- .../shared/chat/FeedGroupProjectionTest.kt | 2 +- .../app/persistence/dao/ChatMessageDao.kt | 18 ++++++++- .../app/persistence/dao/ChatMessageDaoTest.kt | 28 +++++++++++++ .../sources/ChatMessageDataSource.kt | 6 +++ .../flipcash/app/tokens/TokenCoordinator.kt | 3 ++ 10 files changed, 128 insertions(+), 32 deletions(-) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt index 420b0c0a92..ea5c968248 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt @@ -15,9 +15,11 @@ import com.flipcash.features.tipping.R import com.flipcash.services.models.chat.ChatType import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.toConversationReference import com.flipcash.shared.tipping.TippingCoordinator +import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import kotlinx.coroutines.flow.combine @@ -89,14 +91,25 @@ internal class TipFlowViewModel @Inject constructor( }) .launchIn(viewModelScope) - combine( - chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP), - tokenCoordinator.tokens, - ) { summaries, tokens -> + fun conversations(summaries: List, tokens: List): List { val selfId = userManager.accountId val tokensByMint = tokens.associateBy { it.address } - summaries.map { it.toConversationReference(selfId, tokensByMint, resources) } + return summaries.map { it.toConversationReference(selfId, tokensByMint, resources) } + } + + // On a cold launch the feed is usually built before this screen is, so draw it on the first + // frame rather than waiting for the collector below to get a turn on the main thread. + chatCoordinator.currentFeed(ChatType.TIP_DM, ChatType.GROUP)?.let { summaries -> + dispatchEvent( + Event.ChatsUpdated(Loadable.Loaded(conversations(summaries, tokenCoordinator.cachedTokens()))) + ) } + + combine( + chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP), + tokenCoordinator.tokens, + ::conversations, + ) // Off the main thread: on a cold launch the first mapping lands while the main thread // is drawing the app's first screens, and waiting for it holds the Chats tab blank. .flowOn(dispatchers.Default) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index b79281d092..8b6ba954ba 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -37,6 +37,12 @@ interface FeedOperations { */ fun feed(vararg chatTypes: ChatType): Flow> + /** + * What [feed] would emit right now, or null while it would emit nothing yet. Lets a screen + * built after the feed is ready draw it on its first frame instead of waiting for a collector. + */ + fun currentFeed(vararg chatTypes: ChatType): List? + /** Emits the number of conversations of any of [chatTypes] that have unread messages. */ fun observeUnreadConversations(vararg chatTypes: ChatType): Flow diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt index 0520f71b6d..31cbab3f33 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/FeedSyncDelegate.kt @@ -23,6 +23,7 @@ import com.flipcash.shared.chat.ChatHydrationState import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.FeedOperations import com.flipcash.shared.chat.FeedSyncState +import com.flipcash.shared.chat.ChatState import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.shared.chat.internal.isRenderable import com.flipcash.shared.chat.internal.unreadCount @@ -118,26 +119,30 @@ class FeedSyncDelegate @Inject constructor( override fun feed(vararg chatTypes: ChatType): Flow> { val requested = chatTypes.toSet() + return stateHolder.state.mapNotNull { state -> summaries(state, requested) } + } + + override fun currentFeed(vararg chatTypes: ChatType): List? = + summaries(stateHolder.state.value, chatTypes.toSet()) + + private fun summaries(state: ChatState, requested: Set): List? { // Nothing until the list is known: the chat list shows its empty state for an emitted empty // list, so it must not see one that only means "not read yet". Chats on disk are known as // soon as they are read. An empty database is not, because on a fresh sign-in it is empty // only until the first sync writes the account's chats, so it waits for that sync to answer. - return stateHolder.state.mapNotNull { state -> - state.feed?.takeIf { feed -> - feed.isNotEmpty() || - state.feedSyncState == FeedSyncState.Synced || - state.feedSyncState == FeedSyncState.Error + val feed = state.feed?.takeIf { feed -> + feed.isNotEmpty() || + state.feedSyncState == FeedSyncState.Synced || + state.feedSyncState == FeedSyncState.Error + } ?: return null + val selfId = userManager.accountId + val selfPhone = userManager.profile?.verifiedPhoneNumber + return feed + .filter { it.type in requested } + .filter { isRenderable(it, selfId, selfPhone) } + .map { metadata -> + ChatSummary(metadata = metadata, unreadCount = unreadCount(metadata, selfId)) } - }.map { feed -> - val selfId = userManager.accountId - val selfPhone = userManager.profile?.verifiedPhoneNumber - feed - .filter { it.type in requested } - .filter { isRenderable(it, selfId, selfPhone) } - .map { metadata -> - ChatSummary(metadata = metadata, unreadCount = unreadCount(metadata, selfId)) - } - } } override fun observeUnreadConversations(vararg chatTypes: ChatType): Flow { @@ -266,15 +271,14 @@ class FeedSyncDelegate @Inject constructor( // `is_member` is a column rather than a field on ChatMetadata: a chat you have left is // still a chat you can be shown (Plan C's gate reads the same row), so the flag is dropped // here, at the edge of the list, rather than carried through the domain model. + val latestVisible = messageDataSource.getLatestVisibleByChat() return metadataEntities.filter { it.isMember }.map { entity -> val members = membersByChat[entity.chatIdHex] ?: emptyList() // Deliberately the newest *visible* message, not the newest row: deleting the newest // message drops the feed back to the one before it, so the preview reads that message // instead of "Message deleted" and its unread splat clears with it (the fallback sits // at or below the read pointer whenever the deleted message was the only unread one). - val lastMessage = entity.lastMessageId?.let { - messageDataSource.getLatestVisible(entity.chatIdHex) - } + val lastMessage = entity.lastMessageId?.let { latestVisible[entity.chatIdHex] } metadataDataSource.toMetadata(entity, members, lastMessage) } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt index 508fb3e705..3a3520663d 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue /** @@ -108,6 +109,22 @@ class FeedDbHydrationTest { assertEquals(listOf(emptyList()), emissions) } + @Test + fun `currentFeed is null while feed is silent and matches it once feed emits`() = runTest { + val emissions = collectFeed() + + delegate.initialize(backgroundScope) + delegate.observeFeedFromDb() + runCurrent() + rows.emit(emptyList()) + runCurrent() + assertNull(delegate.currentFeed(ChatType.TIP_DM, ChatType.GROUP)) + + stateHolder.update { it.copy(feedSyncState = FeedSyncState.Synced) } + runCurrent() + assertEquals(emissions.last(), delegate.currentFeed(ChatType.TIP_DM, ChatType.GROUP)) + } + private fun TestScope.collectFeed(): List> { val emissions = mutableListOf>() delegate.feed(ChatType.TIP_DM, ChatType.GROUP) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDeletedMessageTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDeletedMessageTest.kt index 613a83b587..2636253362 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDeletedMessageTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDeletedMessageTest.kt @@ -87,6 +87,12 @@ class FeedDeletedMessageTest { val messageDataSource = mockk(relaxed = true) + /** Stubs the chat's newest visible message, as the batched read returns it. */ + fun latestVisible(message: ChatMessage?) { + coEvery { messageDataSource.getLatestVisibleByChat() } returns + message?.let { mapOf(entity.chatIdHex to it) }.orEmpty() + } + private val metadataDataSource = mockk(relaxed = true).also { source -> every { source.observeAll() } returns flowOf(listOf(entity)) every { source.toMetadata(any(), any(), any()) } answers { @@ -128,8 +134,7 @@ class FeedDeletedMessageTest { fun `the preview falls back to the newest message that still has content`() = runTest { val harness = Harness(selfId, otherId, chatId, readPointer = 1) // Message 2 was deleted; the visible newest is message 1. - coEvery { harness.messageDataSource.getLatestVisible(any()) } returns - message(1, MessageContent.Text("still here")) + harness.latestVisible(message(1, MessageContent.Text("still here"))) val summary = harness.summary(this) @@ -144,8 +149,7 @@ class FeedDeletedMessageTest { fun `deleting the only unread message clears the splat`() = runTest { // The READ pointer sits at 1: message 2 arrived unread, then was deleted. val harness = Harness(selfId, otherId, chatId, readPointer = 1) - coEvery { harness.messageDataSource.getLatestVisible(any()) } returns - message(1, MessageContent.Text("read already")) + harness.latestVisible(message(1, MessageContent.Text("read already"))) assertEquals(0, harness.summary(this)?.unreadCount) } @@ -154,8 +158,7 @@ class FeedDeletedMessageTest { fun `an unread message older than the tombstone keeps the splat`() = runTest { // Nothing has been read, so the message the preview falls back to is itself unread. val harness = Harness(selfId, otherId, chatId, readPointer = 0) - coEvery { harness.messageDataSource.getLatestVisible(any()) } returns - message(1, MessageContent.Text("never read")) + harness.latestVisible(message(1, MessageContent.Text("never read"))) assertEquals(1, harness.summary(this)?.unreadCount) } @@ -163,7 +166,7 @@ class FeedDeletedMessageTest { @Test fun `a chat with nothing but tombstones has no preview`() = runTest { val harness = Harness(selfId, otherId, chatId, readPointer = 0) - coEvery { harness.messageDataSource.getLatestVisible(any()) } returns null + harness.latestVisible(null) val summary = harness.summary(this) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt index b6f62e0f03..6bfa17f346 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedGroupProjectionTest.kt @@ -91,7 +91,7 @@ class FeedGroupProjectionTest { members: Map>, ) { private val messageDataSource = mockk(relaxed = true).also { - coEvery { it.getLatestVisible(any()) } returns message(2) + coEvery { it.getLatestVisibleByChat() } returns entities.associate { e -> e.chatIdHex to message(2) } } private val metadataDataSource = mockk(relaxed = true).also { source -> diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 049ab513c6..b99fcd25e5 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -43,9 +43,25 @@ interface ChatMessageDao { * the newest id including tombstones, or a delete would regress the read pointer and leave the * chat unread forever. */ - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 ORDER BY timestamp_epoch_ms DESC LIMIT 1") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") suspend fun getLatestVisible(chatIdHex: String): ChatMessageEntity? + /** + * [getLatestVisible] for every chat at once: one row per chat that has a message with content. + * + * The conversation list needs this for every chat it builds, and one query per chat costs a + * round trip each. A correlated subquery rather than a window function, which needs SQLite 3.25 + * (API 30) and minSdk ships 3.22. + */ + @Query( + "SELECT * FROM chat_messages WHERE rowid IN (" + + "SELECT (SELECT m.rowid FROM chat_messages m " + + "WHERE m.chat_id_hex = c.chat_id_hex AND m.is_deleted = 0 " + + "ORDER BY m.timestamp_epoch_ms DESC, m.message_id DESC LIMIT 1) " + + "FROM (SELECT DISTINCT chat_id_hex FROM chat_messages) c)" + ) + suspend fun getLatestVisibleForAllChats(): List + @Query( "SELECT * FROM chat_messages " + "WHERE chat_id_hex = :chatIdHex " + diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 73dbe19eb9..a3a05ebc41 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -116,6 +116,34 @@ class ChatMessageDaoTest { assertEquals(2L, dao.getLatestVisible(CHAT_HEX)?.messageId) } + @Test + fun `getLatestVisibleForAllChats gives each chat its own latest visible message`() = runTest { + dao.upsert(listOf(text(1, "one"), text(2, "two"))) + dao.upsert(tombstone(2)) + dao.upsert(listOf(text(3, "three"), text(4, "four")).map { it.copy(chatIdHex = OTHER_HEX) }) + + val latest = dao.getLatestVisibleForAllChats().associate { it.chatIdHex to it.messageId } + + assertEquals(mapOf(CHAT_HEX to 1L, OTHER_HEX to 4L), latest) + } + + @Test + fun `getLatestVisibleForAllChats leaves out a chat whose messages are all deleted`() = runTest { + dao.upsert(tombstone(1)) + dao.upsert(text(2, "two").copy(chatIdHex = OTHER_HEX)) + + assertEquals(listOf(OTHER_HEX), dao.getLatestVisibleForAllChats().map { it.chatIdHex }) + } + + @Test + fun `on a shared timestamp both latest-visible reads pick the highest message id`() = runTest { + val sameMs = 5_000L + dao.upsert(listOf(3L, 1L, 2L).map { text(it, "m$it").copy(timestampEpochMs = sameMs) }) + + assertEquals(3L, dao.getLatestVisible(CHAT_HEX)?.messageId) + assertEquals(listOf(3L), dao.getLatestVisibleForAllChats().map { it.messageId }) + } + /** * The optimistic row is written with no event sequence, because the client has none to write: * the server stamps it. Confirming has to carry the echo's stamp onto the row, or the message diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index b5b4de0853..09597c3b39 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -123,6 +123,12 @@ class ChatMessageDataSource @Inject constructor( suspend fun getLatestVisible(chatIdHex: String): ChatMessage? = db?.chatMessageDao()?.getLatestVisible(chatIdHex)?.let { toChatMessage(it) } + /** [getLatestVisible] for every chat, keyed by chat id hex; chats with none are absent. */ + suspend fun getLatestVisibleByChat(): Map = + db?.chatMessageDao()?.getLatestVisibleForAllChats() + ?.associate { it.chatIdHex to toChatMessage(it) } + .orEmpty() + suspend fun hasMessages(chatId: ChatId): Boolean = db?.chatMessageDao()?.getLatest(mapper.chatIdHex(chatId)) != null diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt index 56d06a2d92..d3b1f0f2e9 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenCoordinator.kt @@ -183,6 +183,9 @@ class TokenCoordinator @Inject constructor( */ fun cachedToken(mint: Mint): Token? = _state.value.tokens[mint] + /** Synchronous read of every cached token: the current value of [tokens]. */ + fun cachedTokens(): List = _state.value.tokens.values.toList() + val tokenBalances: Flow> = _hydrated .filter { it } .flatMapLatest {