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..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 @@ -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 @@ -199,6 +200,7 @@ internal fun buildNavGraphForLaunch( target = AppRoute.OnboardingFlow( phase = AppRoute.OnboardingFlow.Phase.Permissions, skipContacts = true, + newAccount = true, ), allowBack = false, ) @@ -234,10 +236,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..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 @@ -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()) } @@ -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 c92a853776..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,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 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` / * [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..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 same route the app launches on — the Wallet tab. 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 same home the app launches on: the Wallet tab. - 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)) + } } 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..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 @@ -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 @@ -14,12 +15,15 @@ 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 +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn @@ -37,6 +41,7 @@ internal class TipFlowViewModel @Inject constructor( shareable: ShareSheetController, tipCodePreviewCache: TipCodePreviewCache, private val resources: ResourceHelper, + dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -86,14 +91,29 @@ internal class TipFlowViewModel @Inject constructor( }) .launchIn(viewModelScope) + fun conversations(summaries: List, tokens: List): List { + val selfId = userManager.accountId + val tokensByMint = tokens.associateBy { it.address } + 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, - ) { summaries, tokens -> - 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) + ::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) + .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/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/ChatState.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatState.kt index f4a010780d..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,7 +7,9 @@ import com.getcode.opencode.model.core.ID import kotlin.time.Instant data class ChatState( - val feed: List = emptyList(), + // 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 0b143b8060..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 @@ -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.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 1c8d89df9a..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 @@ -39,6 +40,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine 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 @@ -117,16 +119,30 @@ class FeedSyncDelegate @Inject constructor( override fun feed(vararg chatTypes: ChatType): Flow> { val requested = chatTypes.toSet() - return stateHolder.state.map { state -> - val selfId = userManager.accountId - val selfPhone = userManager.profile?.verifiedPhoneNumber - state.feed - .filter { it.type in requested } - .filter { isRenderable(it, selfId, selfPhone) } - .map { metadata -> - ChatSummary(metadata = metadata, unreadCount = unreadCount(metadata, selfId)) - } - } + 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. + 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)) + } } override fun observeUnreadConversations(vararg chatTypes: ChatType): Flow { @@ -255,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/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) 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..3a3520663d --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/FeedDbHydrationTest.kt @@ -0,0 +1,135 @@ +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.TestScope +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 + +/** + * The chat list tells "loading" from "no chats" by whether the feed has emitted, so the feed must + * 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), + 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 = stateHolder, + 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 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) + } + + @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) + .onEach { emissions += it } + .launchIn(backgroundScope) + return emissions + } +} 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 d013df1607..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 -> @@ -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 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 {