diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt index 096c02e30b..dbd177e145 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/AppNavigationBar.kt @@ -10,13 +10,14 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.core.AppRoute @@ -24,6 +25,7 @@ import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.extensions.openAsSheet import dev.chrisbanes.haze.HazeState import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.core.navigation.TabBarVisibilityController import com.flipcash.app.core.navigation.asNavBarTab import com.flipcash.app.core.navigation.destinationRoute import com.flipcash.app.core.ui.NavigationBar @@ -33,18 +35,106 @@ import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.user.AuthState import com.flipcash.shared.common.ui.ContactAvatar import com.getcode.manager.BottomBarManager +import com.getcode.navigation.Sheet import com.getcode.navigation.core.CodeNavigator import com.getcode.theme.CodeTheme +import com.getcode.ui.components.glass.FloatingChrome import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +/** + * Whether the hoisted navigation bar is on screen, read by the bar itself and by the toast that rises + * out of it, so both answer the same way. + * + * Every value is derived lazily, so only the composables that read one recompose when it changes. + */ +@Stable +internal class AppNavigationBarVisibility( + private val navigator: CodeNavigator, + private val bottomBarMessages: State>, + private val billUp: State, + private val tabBarVisibility: TabBarVisibilityController, + private val cardExpansion: CardExpansionController?, +) { + /** + * The tab the bar highlights. Selection follows the base of the backstack (the tab "home"), so it + * stays correct while a sheet/modal sits on top and is right on launch. + */ + val selectedTab: NavBarButton? by derivedStateOf { + navigator.backStack.firstNotNullOfOrNull { (it as? AppRoute)?.asNavBarTab() } + } + + /** + * The route, modal and bill gate, which the bar slides in and out on. Only the top route gates + * visibility. A BottomBar modal (e.g. Add Money) renders in the nav content, above the bar, and a + * bill/tip card renders at the app root above everything, so the bar hides under either. A tab + * home can also hide it without leaving its route (the You tab's tip card expanding in place). + */ + val shown: Boolean by derivedStateOf { + (navigator.currentRouteKey as? AppRoute)?.asNavBarTab() != null && + bottomBarMessages.value.isEmpty() && + !billUp.value && + !tabBarVisibility.isHidden + } + + /** + * The wallet's card expansion, which fades the bar out as a card opens over the deck. The + * expansion is the wallet's, and only the wallet entry can collapse it (CardExpandHost), so the + * fade is scoped to that tab: a route change that leaves the wallet mid-expansion would otherwise + * strand the bar faded out with nothing left to bring it back. + */ + val fadeProgress: Float + get() = cardExpansion + ?.takeIf { selectedTab == NavBarButton.Wallet } + ?.progress?.value + ?: 0f + + /** + * Fully faded means fully off screen, and off screen must mean untappable: alpha alone leaves the + * bar hit-testable, so an invisible bar still took taps and switched tabs. Derived, so a reader + * recomposes on the two frames the boolean flips rather than once per frame of the expansion. + */ + val fadedOut: Boolean by derivedStateOf { fadeProgress >= 1f } + + /** + * A sheet, the bill or a bottom-bar prompt is up. Each renders inside the nav content, below the + * root toast host in z, so a toast is dismissed while one is up rather than drawn over it. An + * ordinary push hides the bar but covers nothing, so it is not counted. + */ + val coversToast: Boolean by derivedStateOf { + navigator.currentRouteKey is Sheet || + bottomBarMessages.value.isNotEmpty() || + billUp.value + } + + /** On screen and not faded out by a card expansion. */ + val isVisible: Boolean get() = shown && !fadedOut +} + +@Composable +internal fun rememberAppNavigationBarVisibility( + navigator: CodeNavigator, + tabBarVisibility: TabBarVisibilityController, + cardExpansion: CardExpansionController?, +): AppNavigationBarVisibility { + val bottomBarMessages = BottomBarManager.messages.collectAsStateWithLifecycle() + val session = LocalSessionController.current + val billUp = remember(session) { + session?.billState?.map { it.bill != null } ?: flowOf(false) + }.collectAsStateWithLifecycle(initialValue = false) + return remember(navigator, tabBarVisibility, cardExpansion, bottomBarMessages, billUp) { + AppNavigationBarVisibility(navigator, bottomBarMessages, billUp, tabBarVisibility, cardExpansion) + } +} + /** * The hoisted navigation bar — root chrome, not owned by any screen. It renders over whichever * top-level route is a tab home and switches tabs by **swapping the current screen** (single * backstack, like a tab bar — hence [CodeNavigator.replaceAll], not a sheet). * - * Only visible when the current route maps to a tab. + * Visible while [visibility] says so; it fades out with the wallet's card expansion, like iOS's tab + * bar, and drops out of the tree at the end of the fade. * * Self-positions as a full-size, touch-transparent overlay pinned to the bottom, so it can be * dropped into any container (it does not require a BoxScope from its caller). @@ -52,74 +142,40 @@ import kotlinx.coroutines.flow.map @Composable internal fun AppNavigationBar( navigator: CodeNavigator, + visibility: AppNavigationBarVisibility, modifier: Modifier = Modifier, hazeState: HazeState? = null, - // A tab home taking over the whole screen without leaving its route (the You tab's tip card - // expanding in place). See TabBarVisibilityController. - forceHidden: Boolean = false, - // The wallet's card expansion, which fades this bar out as a card opens over the deck. Passed as - // the controller rather than a Float so the progress is read inside a graphicsLayer and a frame of - // the expansion doesn't recompose the bar. - cardExpansion: CardExpansionController? = null, // The same gate as Switch Accounts in Advanced Features: beta flags unlocked, or staff. canSwitchAccounts: Boolean = false, ) { - // Selection follows the base of the backstack (the tab "home"), so it stays correct while a - // sheet/modal sits on top and is right on launch. The top route only gates visibility. - val selectedTab = navigator.backStack.firstNotNullOfOrNull { (it as? AppRoute)?.asNavBarTab() } - val topTab = (navigator.currentRouteKey as? AppRoute)?.asNavBarTab() - - // A BottomBar modal (e.g. Add Money) renders in the nav content, above this bar; hide the bar so - // it doesn't draw over the modal. Safe because the bar is a bottom overlay (not a scaffold - // bottomBar), so hiding it doesn't resize the content beneath (see AppContent). - val bottomBarMessages by BottomBarManager.messages.collectAsStateWithLifecycle() - - // A bill/tip card renders at the app root above everything; hide the bar so it doesn't show - // beneath the presented bill. - val session = LocalSessionController.current - val billUp by remember(session) { - session?.billState?.map { it.bill != null } ?: flowOf(false) - }.collectAsStateWithLifecycle(initialValue = false) + val selectedTab = visibility.selectedTab // Unread chats in the Chats list (tip DMs and groups), badged onto the Chats tab, so the badge // appears, updates and clears as conversations are read. + val session = LocalSessionController.current val chatListUnreadCount by remember(session) { session?.state?.map { it.chatListUnreadCount } ?: flowOf(0) }.collectAsStateWithLifecycle(initialValue = 0) val avatar = rememberProfileAvatar() - // The expansion is the wallet's, and only the wallet entry can collapse it (CardExpandHost), so - // scope the fade to that tab. A route change that leaves the wallet mid-expansion would otherwise - // strand the bar faded out with nothing left to bring it back. - val expansion = cardExpansion?.takeIf { selectedTab == NavBarButton.Wallet } - val fadeProgress = { expansion?.progress?.value ?: 0f } - - // Fully faded means fully off screen, and off screen must mean untappable: alpha alone leaves the - // bar hit-testable, so an invisible bar still took taps and switched tabs. Drop it from the tree at - // the end of the fade instead. derivedStateOf keeps that to the two frames the boolean flips on, - // rather than one recomposition per frame of the expansion. - val fadedOut by remember(expansion) { - derivedStateOf { fadeProgress() >= 1f } - } - Box( modifier = Modifier .then(modifier) - // Fades out with the expansion and back in with the collapse (no abrupt snap on return), - // like iOS's tab bar. At rest progress is 0, so it's fully shown. - .graphicsLayer { alpha = 1f - fadeProgress() }, + // Read in a graphicsLayer so a frame of the expansion doesn't recompose the bar. At rest + // progress is 0, so it's fully shown. + .graphicsLayer { alpha = 1f - visibility.fadeProgress }, contentAlignment = Alignment.BottomCenter, ) { AnimatedVisibility( - visible = topTab != null && bottomBarMessages.isEmpty() && !billUp && !forceHidden, + visible = visibility.shown, enter = slideInVertically { it } + fadeIn(), exit = slideOutVertically { it } + fadeOut(), ) { // Inside the AnimatedVisibility, not part of its `visible`: the bar is already at alpha 0 // by the time this drops it, so it must not also play the slide-out — and on the way back // it reappears where it stood and fades up, as before. - if (!fadedOut) { + if (!visibility.fadedOut) { val state = rememberNavigationBarState( selectedTab = selectedTab ?: NavBarButton.Wallet, chatListUnreadCount = chatListUnreadCount, @@ -127,9 +183,8 @@ internal fun AppNavigationBar( NavigationBar( modifier = Modifier .navigationBarsPadding() - // The design insets the bar 24pt from each edge (node 10642:1325), which - // keeps each tab's pill wider than tall; 25 is the nearest fixed step. - .padding(horizontal = CodeTheme.dimens.staticGrid.x5) + // Shared with the toast that rises out of the bar, so it is never wider. + .padding(horizontal = FloatingChrome.horizontalInset) .padding(bottom = CodeTheme.dimens.grid.x3), state = state, onButtonClick = { button -> diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index 9e3be28962..c7b28bb77e 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -1,5 +1,20 @@ package com.flipcash.app.internal.ui.navigation +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.getValue +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import com.flipcash.app.internal.ui.AppNavigationBarVisibility +import com.flipcash.app.internal.ui.rememberAppNavigationBarVisibility +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.toast.FloatingToastHost +import com.getcode.ui.components.toast.FloatingToastHostState +import com.getcode.ui.components.toast.LocalFloatingToastHost +import dev.chrisbanes.haze.HazeState import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween @@ -98,6 +113,17 @@ internal fun AppContent( // Lets a tab home hide the bar without leaving its route — the You tab's tip card expands to // full screen in place, so there's no route change for the visibility rule below to notice. val tabBarVisibility = remember { TabBarVisibilityController() } + val barVisibility = rememberAppNavigationBarVisibility(codeNavigator, tabBarVisibility, cardExpansion) + + // Toasts float with the bar, so they are hosted here rather than per screen: outside the haze + // source, so their glass has the nav content to blur, and under the bar in z, so a toast can grow + // out from behind it. + val toasts = remember { FloatingToastHostState() } + // The host draws above the nav content, so it must stand down while a sheet, the bill or a + // bottom-bar prompt is up there rather than float over it and take its taps. + LaunchedEffect(toasts, barVisibility) { + snapshotFlow { barVisibility.coversToast }.collect { toasts.isCovered = it } + } // A tab press replaces the whole back stack (tab-bar semantics — see AppNavigationBar), so every // tab home was destroyed and rebuilt on each switch: the wallet re-fetched its balances and the @@ -124,6 +150,7 @@ internal fun AppContent( CompositionLocalProvider( LocalCardExpansion provides cardExpansion, LocalTabBarVisibility provides tabBarVisibility, + LocalFloatingToastHost provides toasts, ) { Box(modifier = Modifier.fillMaxSize()) { // Mark the nav content as the haze source so the frosted bar blurs whatever scrolls beneath it. @@ -222,15 +249,23 @@ internal fun AppContent( ) } + // Composed before the bar, so the bar draws over a toast rising out of it. + AppToastHost( + hostState = toasts, + barVisibility = barVisibility, + barHeight = { tabBarHeight.value }, + hazeState = hazeState, + modifier = Modifier.align(Alignment.BottomCenter), + ) + // Single persistent bottom overlay. Latch the tallest measured height so the reserved inset // stays stable when the bar hides for a modal/bill. AppNavigationBar( navigator = codeNavigator, - hazeState = hazeState, - forceHidden = tabBarVisibility.isHidden, // The bar fades itself out with the wallet's card expansion — and drops out of the tree at // the end of the fade, so an invisible bar can't be tapped. See AppNavigationBar. - cardExpansion = cardExpansion, + visibility = barVisibility, + hazeState = hazeState, canSwitchAccounts = canSwitchAccounts, modifier = Modifier .align(Alignment.BottomCenter) @@ -243,4 +278,29 @@ internal fun AppContent( // wallet entry's own composition churn on push/pop. } } -} \ No newline at end of file +} + +/** + * The root toast host. Over a visible navigation bar a toast rests a little above it; with no bar it + * sits where the bar would, above the system navigation bar. + */ +@Composable +private fun AppToastHost( + hostState: FloatingToastHostState, + barVisibility: AppNavigationBarVisibility, + barHeight: () -> Dp, + hazeState: HazeState, + modifier: Modifier = Modifier, +) { + val gap = CodeTheme.dimens.grid.x2 + val systemBottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val bottom by animateDpAsState( + targetValue = if (barVisibility.isVisible) barHeight() + gap else systemBottom + CodeTheme.dimens.grid.x3, + label = "toastBottom", + ) + FloatingToastHost( + hostState = hostState, + hazeState = hazeState, + modifier = modifier.offset { IntOffset(0, -bottom.roundToPx()) }, + ) +} diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 74571d545b..04e242cc5b 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -26,6 +26,7 @@ import com.flipcash.app.cash.CashScreen import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen import com.flipcash.app.tipping.FindByUsernameScreen +import com.flipcash.app.tipping.ArchivedChatsScreen import com.flipcash.app.tipping.NewChatScreen import com.flipcash.app.tipping.NewGroupFlowScreen import com.flipcash.app.tipping.ChatsScreen @@ -121,6 +122,7 @@ fun appEntryProvider( } annotatedEntry { key -> ProfileScreen(key.address) } annotatedEntry { NewChatScreen() } + annotatedEntry { ArchivedChatsScreen() } annotatedEntry { FindByUsernameScreen() } annotatedEntry { key -> NewGroupFlowScreen(route = key, resultStateRegistry = resultStateRegistry) diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index 42448c39b7..e55d15636b 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -37,7 +37,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.lerp import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.testTag @@ -51,15 +50,12 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEach import com.flipcash.app.core.navigation.NavBarButton -import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState -import dev.chrisbanes.haze.blur.HazeBlurStyle -import dev.chrisbanes.haze.blur.HazeColorEffect -import dev.chrisbanes.haze.blur.hazeBlur import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R import com.getcode.theme.CodeTheme import com.getcode.ui.components.Badge +import com.getcode.ui.components.glass.floatingGlass data class NavigationBarState( // Route-driven: the caller derives this from the current backstack tab so the highlighted tab @@ -127,36 +123,10 @@ fun NavigationBar( .takeIf { it >= 0 && it <= order.lastIndex } ?: order.indexOf(NavBarButton.Wallet) - // Frost the pill over whatever content scrolls beneath it, iOS "liquid glass" style: a wide blur - // plus a strong tint toward the BACKGROUND colour (not black) at high alpha. Over empty/dark - // content the pill just reads as the background (a subtle glass, not a black blob); over the - // vibrant cards the high alpha mutes their colour toward that same neutral dark. A faint bright - // rim gives the glass edge. Haze can only blur Compose-layer pixels, so on the scanner tab (a - // camera SurfaceView) fall back to the opaque pill. `clip` must precede `hazeBlur` to bound the - // blur to the pill shape, not its bounding box. - // Tint toward a grey lifted off the (near-black) background so the pill reads as a light frosted - // glass sitting ABOVE the dark content, not the background tone itself. - val backdrop = CodeTheme.colors.background - val glassTint = lerp(backdrop, Color.White, 0.18f) - // The HazeBlurStyle builder is not a @Composable scope, so theme reads are hoisted above it. - val liquidGlass = HazeBlurStyle { - blurRadius(32.dp) - backgroundColor(backdrop) - colorEffects(listOf(HazeColorEffect.tint(glassTint.copy(alpha = 0.72f)))) - } - // Same clip + rim on every tab; only the fill differs. Haze frosts the content beneath — including - // the scanner's live camera, since its PreviewView runs in COMPATIBLE mode (a TextureView drawn in - // the Compose layer, not a SurfaceView hole). Fall back to a near-opaque fill of the same - // lifted-grey tint only when no HazeState is supplied. - val pillFill = if (hazeState != null) { - Modifier.hazeBlur(HazeInput.Sources(hazeState), liquidGlass) - } else { - Modifier.background(glassTint.copy(alpha = 0.9f), CircleShape) - } - val pillBackground = Modifier - .clip(CircleShape) - .then(pillFill) - .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), CircleShape) + // The shared floating glass. Haze frosts the content beneath, including the scanner's live + // camera, since its PreviewView runs in COMPATIBLE mode (a TextureView drawn in the Compose layer, + // not a SurfaceView hole). With no HazeState it falls back to a near-opaque fill. + val pillBackground = Modifier.floatingGlass(hazeState) BoxWithConstraints( modifier = Modifier 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 7150d54524..7d7c349b48 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 @@ -362,6 +362,16 @@ sealed interface AppRoute : NavKey, Parcelable { @Parcelize data object NewChat : Messaging + /** + * The chats the viewer archived, behind the Archived row on the Chats list. + * + * A pushed route rather than an in-place screen, for the reason [NewChat] is: the Chats + * list is a tab home, and anything drawn inside it would leave the tab bar over this one. + */ + @Serializable + @Parcelize + data object ArchivedChats : Messaging + /** * Another person's profile, opened on its own rather than from inside a chat — the * destination of a `flipcash.com/{username}` or `flipcash.com/{uuid}` link. diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 102267a2ad..ca157dad19 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1062,6 +1062,21 @@ No Chats Yet Start a new chat, or share your profile + + Archived + Archived, %1$d unread + All + Unread + Groups + No unread chats + No groups + No archived chats + All your chats are archived + Chat archived + Undo + Archive chat + Unarchive chat + Enter Flipcash username Enter the Flipcash username of the person you want to chat with diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt index 71cc14bd27..5793309ab6 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/MenuScreen.kt @@ -6,10 +6,12 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.menu.internal.MenuScreenContent import com.flipcash.app.menu.internal.MenuScreenViewModel import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.components.toast.LocalFloatingToastHost import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch @Composable fun MenuScreen() { @@ -17,6 +19,7 @@ fun MenuScreen() { MenuScreenContent(viewModel) val navigator = LocalCodeNavigator.current + val toasts = LocalFloatingToastHost.current LaunchedEffect(viewModel) { viewModel.eventFlow @@ -25,4 +28,24 @@ fun MenuScreen() { .onEach { navigator.push(it) } .launchIn(this) } + + // The menu is a tab home, so this rises out of the tab bar, over the version row the taps land + // on. It hugs the message and passes taps through, so each tap still reaches the row; each + // update swaps the text in place and restarts the timeout rather than replaying the entrance. + LaunchedEffect(viewModel, toasts) { + if (toasts == null) return@LaunchedEffect + viewModel.eventFlow + .filterIsInstance() + .onEach { event -> + launch { + toasts.show( + message = event.message, + hugContent = true, + passThrough = true, + inPlace = true, + ) + } + } + .launchIn(this) + } } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index f76bf29b7a..6e6177a377 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -160,6 +160,12 @@ internal class MenuScreenViewModel @Inject constructor( val source: AddMoneySource = AddMoneySource.MENU, ) : Event data class OpenScreen(val screen: AppRoute) : Event + + /** + * A developer-mode unlock message (the tap countdown, then unlocked). The screen floats it + * over the version row without taking taps, so the taps keep counting through it. + */ + data class ShowDevModeToast(val message: String) : Event data class OnTipCardStateChanged(val tipCardState: TipCardState) : Event data class OnUsernameProgressChanged( val progress: UsernameProgress?, @@ -220,7 +226,7 @@ internal class MenuScreenViewModel @Inject constructor( .onEach { if (stateFlow.value.unlockedBetaFeaturesManually) { if (stateFlow.value.logoTapCount - TAP_THRESHOLD > COUNTDOWN_START) { - toastController.showToast(R.string.toast_betaOverrideAlready, replacePrevious = true) + dispatchEvent(Event.ShowDevModeToast(resources.getString(R.string.toast_betaOverrideAlready))) } return@onEach } @@ -228,10 +234,14 @@ internal class MenuScreenViewModel @Inject constructor( when { remaining <= 0 -> { featureFlags.enableBetaFeatures() - toastController.showToast(R.string.toast_betaOverrideEnabled, replacePrevious = true) + dispatchEvent(Event.ShowDevModeToast(resources.getString(R.string.toast_betaOverrideEnabled))) } remaining <= COUNTDOWN_START -> { - toastController.showQuantityToast(R.plurals.toast_betaOverrideCountdown, remaining, remaining, replacePrevious = true) + dispatchEvent( + Event.ShowDevModeToast( + resources.getQuantityString(R.plurals.toast_betaOverrideCountdown, remaining, remaining) + ) + ) } } } @@ -580,6 +590,7 @@ internal class MenuScreenViewModel @Inject constructor( Event.CopyTipLink, Event.DownloadTipCard, is Event.ExportTipCard, + is Event.ShowDevModeToast, is Event.OpenScreen -> { state -> state } is Event.OnFeatureFlagsUpdated -> { state -> diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt index 50b2e545e0..16debf9042 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/MuteChatViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ViewerState import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.currentArchivedChatListFeed import com.flipcash.shared.chat.currentChatListFeed import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper @@ -40,10 +41,11 @@ internal class MuteChatViewModel @Inject constructor( * * Seeds [observeViewerState] so the sheet opens with its unmute row already decided. Starting * from `null` would open it without the row and then grow it by one as the store answered. + * An archived chat is only in the archived list, so that is searched too. */ fun currentViewerState(chatId: ChatId): ViewerState? = - chatCoordinator.currentChatListFeed() - ?.firstOrNull { it.metadata.chatId == chatId } + (chatCoordinator.currentChatListFeed().orEmpty() + chatCoordinator.currentArchivedChatListFeed().orEmpty()) + .firstOrNull { it.metadata.chatId == chatId } ?.metadata?.viewerState /** diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatViewerStateSeedTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatViewerStateSeedTest.kt new file mode 100644 index 0000000000..6c7c6d9990 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/MuteChatViewerStateSeedTest.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.ViewerState +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatSummary +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** + * The mute sheet opens with its unmute row already decided, so the seed must find the chat whether + * it sits in the main list or in the archived one. + */ +class MuteChatViewerStateSeedTest { + + private val chatCoordinator = mockk(relaxed = true) + private val chatId = ChatId(ByteArray(16) { 3 }.toList()) + private val muted = ViewerState(mute = MuteState.Forever, version = 1L) + + private fun summary() = ChatSummary( + metadata = ChatMetadata( + chatId = chatId, + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1), + viewerState = muted, + ), + unreadCount = 0, + ) + + private fun viewModel() = MuteChatViewModel(chatCoordinator, mockk(relaxed = true), RecordingAnalytics()) + + @Test + fun `a chat in the main list seeds from it`() { + every { chatCoordinator.currentFeed(*anyVararg()) } returns listOf(summary()) + every { chatCoordinator.currentArchivedFeed(*anyVararg()) } returns emptyList() + assertEquals(muted, viewModel().currentViewerState(chatId)) + } + + @Test + fun `an archived chat seeds from the archived list`() { + every { chatCoordinator.currentFeed(*anyVararg()) } returns emptyList() + every { chatCoordinator.currentArchivedFeed(*anyVararg()) } returns listOf(summary()) + assertEquals(muted, viewModel().currentViewerState(chatId)) + } + + @Test + fun `a chat in neither list has no seed`() { + every { chatCoordinator.currentFeed(*anyVararg()) } returns null + every { chatCoordinator.currentArchivedFeed(*anyVararg()) } returns null + assertNull(viewModel().currentViewerState(chatId)) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt new file mode 100644 index 0000000000..26439d64e6 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ArchivedChatsScreen.kt @@ -0,0 +1,157 @@ +package com.flipcash.app.tipping + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Unarchive +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.data.isLoaded +import com.flipcash.app.tipping.internal.ArchivedChatsViewModel +import com.flipcash.app.tipping.internal.components.TipChatRow +import com.flipcash.features.tipping.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.SwipeAction +import com.getcode.ui.components.SwipeActionRow +import com.getcode.ui.components.SwipeRevealGroup +import com.getcode.ui.components.rememberSwipeRevealGroup +import com.getcode.ui.theme.CodeScaffold + +/** + * The archived chats: the same row as the Chats list, sorted by last activity. A swipe on a row + * unarchives it. Opening a chat from here does not unarchive it. + */ +@Composable +fun ArchivedChatsScreen() { + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val navigator = LocalCodeNavigator.current + + ArchivedChatsContent( + chats = state.chats.dataOrNull.orEmpty(), + isLoaded = state.chats.isLoaded(), + onBack = { navigator.pop() }, + onOpen = { chatId -> navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chatId))) }, + onUnarchive = viewModel::unarchive, + ) +} + +@Composable +private fun ArchivedChatsContent( + chats: List, + isLoaded: Boolean, + onBack: () -> Unit, + onOpen: (ChatId) -> Unit, + onUnarchive: (ChatId) -> Unit, +) { + // One row open at a time, as on the Chats list. + val revealGroup = rememberSwipeRevealGroup() + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_archived), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = onBack, + ) + }, + ) { padding -> + // One list for the rows and the empty state alike, so unarchiving the last chat fades its + // row out and the message in, rather than swapping the whole list for the message at once. + LazyColumn( + modifier = Modifier.fillMaxSize().testTag("archived_list"), + contentPadding = PaddingValues(top = padding.calculateTopPadding()), + ) { + if (isLoaded && chats.isEmpty()) { + item(key = "empty") { + Box( + modifier = Modifier + .animateItem() + .fillParentMaxSize() + .padding(bottom = padding.calculateBottomPadding()), + contentAlignment = Alignment.Center, + ) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(R.string.title_noArchivedChats), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } + // Keyed by chat so a row's swipe state stays with its chat as activity reorders. + // animateItem fades an unarchived row out where it stands and slides the rest up. + itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> + UnarchiveSwipeRow( + modifier = Modifier.animateItem(), + onUnarchive = { onUnarchive(chat.chatId) }, + stateKey = chat.chatId, + revealGroup = revealGroup, + ) { + // The row draws its divider below itself, so none sits above the first row. + TipChatRow(chat = chat, showDivider = index < chats.lastIndex) { + onOpen(chat.chatId) + } + } + } + } + } +} + +@Composable +private fun UnarchiveSwipeRow( + onUnarchive: () -> Unit, + stateKey: Any, + revealGroup: SwipeRevealGroup, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val label = stringResource(R.string.content_description_unarchiveChat) + SwipeActionRow( + actions = listOf( + SwipeAction( + background = CodeTheme.colors.surfaceVariant, + onTriggered = onUnarchive, + ) { + Icon( + imageVector = Icons.Outlined.Unarchive, + contentDescription = label, + tint = CodeTheme.colors.textMain, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + }, + ), + // A swipe is out of reach with a screen reader, so the row offers the action there. + modifier = modifier.semantics { + customActions = listOf(CustomAccessibilityAction(label) { onUnarchive(); true }) + }, + stateKey = stateKey, + revealGroup = revealGroup, + content = content, + ) +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt index 75ff7ef199..ffb509f528 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/ChatsScreen.kt @@ -1,6 +1,40 @@ package com.flipcash.app.tipping +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.Image +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material.SnackbarResult +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import com.flipcash.app.tipping.internal.ChipRevealConnection +import com.flipcash.app.tipping.internal.ParkFiller +import com.flipcash.app.tipping.internal.blockTouchesWhile +import com.flipcash.app.tipping.internal.rememberParkFiller +import com.flipcash.app.tipping.internal.chipReveal +import com.flipcash.app.tipping.internal.chipVisibleFraction +import com.flipcash.shared.chat.ChatListFilter +import com.getcode.ui.components.FilterChip +import kotlinx.coroutines.launch import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -51,6 +85,7 @@ import com.flipcash.shared.chat.ui.ConversationReference import com.flipcash.shared.chat.ui.rememberIsMuted import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.theme.CodeTheme +import com.getcode.ui.components.toast.LocalFloatingToastHost import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.SwipeAction @@ -64,6 +99,10 @@ import com.getcode.ui.theme.ScaffoldBarPlacement /** * The "Chats" root tab: tip DMs and groups under the standard centred screen title, with no dismiss * affordance (the root nav bar is the chrome). The tip card lives on the "You" tab. + * + * The All / Unread / Groups chips are the list's first item. The list starts on item 1, so they sit + * just out of view (under the bar, at zero alpha) until a pull from the top brings them on; see + * [ChipRevealConnection] for how a pull settles. */ @Composable fun ChatsScreen() { @@ -72,9 +111,48 @@ fun ChatsScreen() { val navigator = LocalCodeNavigator.current val chats = state.chats - val listState = rememberLazyListState() + // Item 1 from the first frame, so the chips never flash on. Saveable, so the position (chips + // shown or not) survives opening a chat and coming back. + val listState = rememberLazyListState(initialFirstVisibleItemIndex = if (state.showsChips) 0 else 1) + val flingBehavior = ScrollableDefaults.flingBehavior() + val chipRevealConnection = remember(listState, flingBehavior, viewModel) { + ChipRevealConnection( + listState = listState, + flingBehavior = flingBehavior, + isShown = { viewModel.stateFlow.value.showsChips }, + // Nothing to filter while loading or with no chats at all. + canReveal = { viewModel.stateFlow.value.let { it.chats.isLoaded() && !it.hasNoChatsAtAll } }, + onRevealed = viewModel::revealChips, + ) + } + val visible = state.visibleChats + // Everything between the chips and the filler, in list order. + val parkKeys: List = remember(state.chats, state.hasNoChatsAtAll, visible) { + listOf("archived") + when { + !chats.isLoaded() -> listOf("loading") + state.hasNoChatsAtAll -> listOf("empty") + visible.isEmpty() -> listOf("empty_filter") + else -> visible.map { it.chatId } + } + } + val parkFiller = rememberParkFiller() + SideEffect { parkFiller.retain(parkKeys) } + // Restored on the chips (after process death, say) with the ViewModel starting over: they are + // on screen, so they count as revealed rather than being parked by the next scroll. + LaunchedEffect(listState) { + if (listState.firstVisibleItemIndex == 0 && !viewModel.stateFlow.value.showsChips) { + viewModel.revealChips() + } + } // One row open at a time: swiping another row closes the one left revealed. val revealGroup = rememberSwipeRevealGroup() + // The root toast host, so the undo rises out of the tab bar on its glass. + val toasts = LocalFloatingToastHost.current + val scope = rememberCoroutineScope() + val haptics = LocalHapticFeedback.current + val archivedMessage = stringResource(R.string.message_chatArchived) + val undoLabel = stringResource(R.string.action_undo) + val tabBarPadding = LocalTabBarPadding.current.calculateBottomPadding() CodeScaffold( // The list runs the full height and passes under the title bar, which fades it out against @@ -117,28 +195,97 @@ fun ChatsScreen() { // Scroll anchor for UI tests: `send_contact_row` addresses a single row, this // addresses the scrollable list itself. .testTag("chat_list") + // Holds the chips out of view until a pull from the top brings them on. + .chipReveal(chipRevealConnection) // End edge only — the start edge is the bar's scrim now, and a second fade there // would darken rows twice over as they pass under the title. .verticalScrollStateGradient(scrollState = listState, showAtStart = false), state = listState, + flingBehavior = flingBehavior, contentPadding = PaddingValues( // The bar's height as content padding rather than as a layout inset: the viewport // runs the full height and rows scroll under the bar, but at rest the first row // still sits clear of it. top = barPadding.calculateTopPadding(), // Clears the hoisted tab bar: keeps the last row reachable. Both paddings are - // measured out of `fillParentMaxSize`, so the empty state stays centered in the + // measured out of `fillParentMaxSize`, so the empty states stay centered in the // space the two bars leave visible. - bottom = LocalTabBarPadding.current.calculateBottomPadding(), + bottom = tabBarPadding, ), ) { - // Once the feed has loaded and there's nothing to show, the list is replaced by a - // centered prompt. - if (chats.isLoaded() && chats.data.isEmpty()) { - item { NoChatsYet(Modifier.fillParentMaxSize()) } - } else { - tipChatItems( - chats = chats.dataOrNull.orEmpty(), + item(key = "chips", contentType = "chips") { + ChatFilterRow( + modifier = Modifier + .blockTouchesWhile { !state.showsChips && listState.chipVisibleFraction() == 0f } + .graphicsLayer { + // Fades in with the pull. Read here, in the draw phase, so following + // the finger never recomposes the row. + alpha = if (state.showsChips) 1f else listState.chipVisibleFraction() + }, + selected = state.filter, + unreadCount = state.projection.unreadChipCount, + groupsCount = state.projection.groupsChipCount, + onSelect = { filter -> + if (!state.showsChips && listState.chipVisibleFraction() < 1f) { + // Hidden under the bar (a stray tap, or a screen reader reaching it): + // bring the row on rather than switching filters out of sight. + viewModel.revealChips() + scope.launch { listState.animateScrollToItem(0) } + } else if (filter != state.filter) { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + viewModel.selectFilter(filter) + } + }, + ) + } + + // Always emitted, so item 1 exists from the first composition (including while + // loading) and the parked position has a stable anchor; empty when there is no row. + // Its key never leaves the list, so animateItem only moves it; the row itself fades in + // and out inside the slot, while the rows below slide to make room or close the gap. + item(key = "archived", contentType = "archived") { + Box(Modifier.animateItem().then(parkFiller.tracked("archived"))) { + AnimatedVisibility( + visible = state.showsArchivedRow, + enter = fadeIn(), + exit = fadeOut(), + ) { + ArchivedRow( + count = state.projection.archivedRowCount, + onClick = { navigator.push(AppRoute.Messaging.ArchivedChats) }, + ) + } + } + } + + when { + // A full-height spacer: with only the chips and an empty Archived slot the content + // is shorter than the viewport, the list fills backward to index 0, and the chips + // would show once rows arrive. + !chats.isLoaded() -> item(key = "loading") { + Spacer(parkFiller.tracked("loading").fillParentMaxSize()) + } + // The empty states fade like the rows do, so a filter or archive that empties the + // list doesn't drop the message on top of rows still fading out. + state.hasNoChatsAtAll -> item(key = "empty") { + NoChatsYet( + Modifier.animateItem().then(parkFiller.tracked("empty")).fillParentMaxSize() + ) + } + visible.isEmpty() -> item(key = "empty_filter") { + EmptyFilterState( + modifier = Modifier.animateItem() + .then(parkFiller.tracked("empty_filter")) + .fillParentMaxSize(), + message = when (state.filter) { + ChatListFilter.Unread -> R.string.title_noUnreadChats + ChatListFilter.Groups -> R.string.title_noGroupChats + ChatListFilter.All -> R.string.title_allChatsArchived + }, + ) + } + else -> tipChatItems( + chats = visible, onClick = { chat -> navigator.push( AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)) @@ -147,9 +294,29 @@ fun ChatsScreen() { // The same sheet the chat and group profiles open, so the list offers exactly // the durations they do, and unmuting is its "Never" row rather than a toggle. onMute = { chat -> navigator.push(AppRoute.Messaging.MuteChat(chat.chatId, chat.chatType)) }, + onArchive = { chat -> + viewModel.archive(chat.chatId) + // The row leaves the list at once, so offer to put it back. + if (toasts != null) { + scope.launch { + val result = toasts.show( + message = archivedMessage, + icon = Icons.Outlined.Archive, + actionLabel = undoLabel, + ) + if (result == SnackbarResult.ActionPerformed) viewModel.unarchive(chat.chatId) + } + } + }, revealGroup = revealGroup, + parkFiller = parkFiller, ) } + + // Makes up a list shorter than the viewport, so it can still park; see [ParkFiller]. + item(key = "park_filler", contentType = "park_filler") { + Spacer(with(parkFiller) { filler(parkKeys) }) + } } } } @@ -206,13 +373,19 @@ private fun LazyListScope.tipChatItems( chats: List, onClick: (ConversationReference) -> Unit, onMute: (ConversationReference) -> Unit, + onArchive: (ConversationReference) -> Unit, revealGroup: SwipeRevealGroup, + parkFiller: ParkFiller, ) { // Keyed by chat so a row's swipe state stays with its chat when new activity reorders the list. + // animateItem fades a row out where it stands when it is archived (or filtered away), even + // swiped open, slides its neighbours into the gap, and fades it back in on Undo. itemsIndexed(chats, key = { _, chat -> chat.chatId }) { index, chat -> - MuteSwipeRow( + ChatSwipeRow( + modifier = Modifier.animateItem().then(parkFiller.tracked(chat.chatId)), isMuted = rememberIsMuted(chat.viewerState), onMute = { onMute(chat) }, + onArchive = { onArchive(chat) }, stateKey = chat.chatId, revealGroup = revealGroup, ) { @@ -227,29 +400,46 @@ private fun LazyListScope.tipChatItems( } /** - * A trailing swipe that opens the mute sheet, whichever way the chat is muted. + * Two trailing swipe actions: archive inside, mute outside. [SwipeActionRow] draws the last action + * outermost and fires it on a full swipe, so a full swipe still opens the mute sheet as before, and + * archive is a tap on the revealed action. * - * The icon names the state the chat would be moved out of: a crossed-out bell on an audible chat, - * and a plain one on a muted chat, where the sheet is also how the mute is cleared. It never mutes - * directly, because a mute always needs a duration. + * The mute icon names the state the chat would be moved out of: a crossed-out bell on an audible + * chat, a plain one on a muted chat, where the sheet is also how the mute is cleared. It never + * mutes directly, because a mute always needs a duration. It resets rather than settling open: a + * row left swiped under the sheet would still be sitting open once the sheet closed. * - * Resets rather than settling open: a full swipe opens the sheet, and a row left swiped under it - * would still be sitting open once the sheet closed. + * Archive removes the row, which disposes its swipe state, so Undo brings the chat back as a fresh, + * closed row. */ @Composable -private fun MuteSwipeRow( +private fun ChatSwipeRow( isMuted: Boolean, onMute: () -> Unit, + onArchive: () -> Unit, stateKey: Any, revealGroup: SwipeRevealGroup, + modifier: Modifier = Modifier, content: @Composable () -> Unit, ) { - val label = stringResource( + val muteLabel = stringResource( if (isMuted) R.string.content_description_changeMute else R.string.content_description_muteChat ) + val archiveLabel = stringResource(R.string.content_description_archiveChat) SwipeActionRow( actions = listOf( + SwipeAction( + background = CodeTheme.colors.surfaceVariant, + onTriggered = onArchive, + ) { + Icon( + imageVector = Icons.Outlined.Archive, + contentDescription = archiveLabel, + tint = CodeTheme.colors.textMain, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + }, SwipeAction( // The profile's unverified badge treatment rather than the delete red: nothing is // lost by it. @@ -263,15 +453,18 @@ private fun MuteSwipeRow( } else { Icons.Outlined.NotificationsOff }, - contentDescription = label, + contentDescription = muteLabel, tint = CodeTheme.colors.warning, modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), ) - } + }, ), - // A swipe is out of reach with a screen reader, so the row offers the same action there. - modifier = Modifier.semantics { - customActions = listOf(CustomAccessibilityAction(label) { onMute(); true }) + // A swipe is out of reach with a screen reader, so the row offers the same actions there. + modifier = modifier.semantics { + customActions = listOf( + CustomAccessibilityAction(muteLabel) { onMute(); true }, + CustomAccessibilityAction(archiveLabel) { onArchive(); true }, + ) }, stateKey = stateKey, revealGroup = revealGroup, @@ -279,6 +472,119 @@ private fun MuteSwipeRow( ) } +/** + * The filter chips. All carries no number: the tab badge already shows it. Unread and Groups show + * theirs only when non-zero. + */ +@Composable +private fun ChatFilterRow( + selected: ChatListFilter, + unreadCount: Int, + groupsCount: Int, + onSelect: (ChatListFilter) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .selectableGroup() + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + FilterChip( + label = stringResource(R.string.title_filterAll), + selected = selected == ChatListFilter.All, + onClick = { onSelect(ChatListFilter.All) }, + ) + FilterChip( + label = stringResource(R.string.title_filterUnread), + selected = selected == ChatListFilter.Unread, + onClick = { onSelect(ChatListFilter.Unread) }, + count = unreadCount, + ) + FilterChip( + label = stringResource(R.string.title_filterGroups), + selected = selected == ChatListFilter.Groups, + onClick = { onSelect(ChatListFilter.Groups) }, + count = groupsCount, + ) + } +} + +/** + * The way into the archived chats. Secondary throughout (icon, label, count, chevron), so it reads + * as a folder rather than one more chat; the count is the unread archived chats that aren't muted. + */ +@Composable +private fun ArchivedRow(count: Int, onClick: () -> Unit) { + val description = if (count > 0) { + stringResource(R.string.content_description_archivedUnread, count) + } else { + stringResource(R.string.title_archived) + } + val color = CodeTheme.colors.textSecondary + Row( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + .clickable(onClick = onClick) + .clearAndSetSemantics { + contentDescription = description + role = Role.Button + } + .testTag("archived_row") + .padding(vertical = CodeTheme.dimens.grid.x3) + .padding(start = CodeTheme.dimens.inset, end = CodeTheme.dimens.inset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + // Centred in the avatar column, so the label lines up with the chat names below. + Box( + modifier = Modifier.width(CodeTheme.dimens.staticGrid.x8), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Archive, + contentDescription = null, + tint = color, + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x5), + ) + } + Text( + modifier = Modifier.weight(1f), + text = stringResource(R.string.title_archived), + style = CodeTheme.typography.textMedium, + color = color, + ) + if (count > 0) { + Text( + text = count.toString(), + style = CodeTheme.typography.textSmall, + color = color, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = color, + ) + } +} + +/** A filter with nothing under it, centred in the space it is given (the list viewport). */ +@Composable +private fun EmptyFilterState(@StringRes message: Int, modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Text( + modifier = Modifier.padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(message), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} + @Composable @Preview @PreviewWrapper(FlipcashThemeWrapper::class) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt new file mode 100644 index 0000000000..41d7953a6d --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ArchivedChatsViewModel.kt @@ -0,0 +1,69 @@ +package com.flipcash.app.tipping.internal + +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.data.Loadable +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.archivedChatListFeed +import com.flipcash.shared.chat.ui.ConversationReference +import com.getcode.util.resources.ResourceHelper +import com.getcode.view.BaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Backs the Archived screen: the archived chats, newest first, and unarchive. */ +@HiltViewModel +internal class ArchivedChatsViewModel @Inject constructor( + private val chatCoordinator: ChatCoordinator, + private val archiveStore: ChatArchiveStore, + userManager: UserManager, + tokenCoordinator: TokenCoordinator, + resources: ResourceHelper, + dispatchers: DispatcherProvider, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + + data class State( + val chats: Loadable> = Loadable.Loading(), + ) + + sealed interface Event { + data class ChatsUpdated(val chats: Loadable>) : Event + } + + fun unarchive(chatId: ChatId) { + viewModelScope.launch { archiveStore.unarchive(chatId) } + } + + init { + combine( + chatCoordinator.archivedChatListFeed(), + tokenCoordinator.tokens, + chatCoordinator.observeSenderProfiles(), + ) { summaries, tokens, senderProfiles -> + mapConversations(summaries, tokens, senderProfiles, userManager.accountId, resources, chatCoordinator) + } + .flowOn(dispatchers.Default) + .onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) } + .launchIn(viewModelScope) + } + + internal companion object { + private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.ChatsUpdated -> { state -> state.copy(chats = event.chats) } + } + } + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt index be2f8eaa5b..45bfe79fcb 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChatsViewModel.kt @@ -6,14 +6,19 @@ import com.flipcash.app.core.data.Loadable import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.isMutedAt import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator -import com.flipcash.shared.chat.ChatSummary -import com.flipcash.shared.chat.chatListFeed -import com.flipcash.shared.chat.currentChatListFeed +import com.flipcash.shared.chat.ChatFeeds +import com.flipcash.shared.chat.ChatListEntry +import com.flipcash.shared.chat.ChatListFilter +import com.flipcash.shared.chat.ChatListProjection +import com.flipcash.shared.chat.chatListFeeds +import com.flipcash.shared.chat.currentChatListFeeds +import com.flipcash.shared.chat.projectChatList import com.flipcash.shared.chat.ui.ConversationReference -import com.flipcash.shared.chat.ui.toConversationReference -import com.flipcash.shared.chat.ui.unnamedGroupSender import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -21,84 +26,146 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject /** Backs the "Chats" tab: the user's tip DMs and groups. */ @HiltViewModel internal class ChatsViewModel @Inject constructor( - chatCoordinator: ChatCoordinator, + private val chatCoordinator: ChatCoordinator, userManager: UserManager, tokenCoordinator: TokenCoordinator, private val resources: ResourceHelper, dispatchers: DispatcherProvider, + private val archiveStore: ChatArchiveStore, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, ) { data class State( - // Loading until the chat feed emits — distinguishes "still loading" from "loaded, none", so - // the empty state doesn't flash on top of a list that's about to arrive. + // The main list (not archived, not hidden), in the feed's order. Loading until the feed + // emits, so the empty state doesn't flash on top of a list that's about to arrive. val chats: Loadable> = Loadable.Loading(), - ) + // The chip selected above the list. Not persisted: a cold start opens on All. + val filter: ChatListFilter = ChatListFilter.All, + // What each chip and the Archived row show, from the pure projection. + val projection: ChatListProjection = ChatListProjection.Empty, + // Whether a pull from the top has brought the chips on. Held here rather than in the + // screen so it survives opening a chat and coming back. + val chipsRevealed: Boolean = false, + ) { + /** The chips stay once revealed, and always show under a filter other than All. */ + val showsChips: Boolean get() = chipsRevealed || filter != ChatListFilter.All + + /** The Archived row: only under All, and only when something is archived. */ + val showsArchivedRow: Boolean + get() = filter == ChatListFilter.All && projection.archivedRowVisible + + /** Nothing in the main list and nothing archived: the full-screen prompt. */ + val hasNoChatsAtAll: Boolean + get() = chats.dataOrNull.orEmpty().isEmpty() && !projection.archivedRowVisible + + /** The main-list rows shown under the selected chip, in order. */ + val visibleChats: List + get() { + val loaded = chats.dataOrNull.orEmpty() + if (filter == ChatListFilter.All) return loaded + val wanted = projection.idsFor(filter).toSet() + return loaded.filter { it.chatId.toString() in wanted } + } + } sealed interface Event { - data class ChatsUpdated(val chats: Loadable>) : Event + data class ChatsUpdated( + val chats: Loadable>, + val projection: ChatListProjection, + ) : Event + + data class FilterSelected(val filter: ChatListFilter) : Event + + data object ChipsRevealed : Event + } + + fun selectFilter(filter: ChatListFilter) = dispatchEvent(Event.FilterSelected(filter)) + + fun revealChips() = dispatchEvent(Event.ChipsRevealed) + + fun archive(chatId: ChatId) { + viewModelScope.launch { archiveStore.archive(chatId) } + } + + fun unarchive(chatId: ChatId) { + viewModelScope.launch { archiveStore.unarchive(chatId) } } init { - fun conversations( - summaries: List, + val selfId = { userManager.accountId } + + fun content( + feeds: ChatFeeds, tokens: List, // Null on the first-frame draw if the table has not been read yet: asking then would // re-fetch every sender already on disk. senderProfiles: Map?, - ): List { - val selfId = userManager.accountId - val tokensByMint = tokens.associateBy { it.address } - return summaries.map { summary -> - // The feed's group roster is usually just the viewer, so a group's last sender is - // named from the profiles the transcript resolves. Anyone not there yet is asked for - // here; SenderResolver collapses repeats, and the row re-emits when the write lands. - if (senderProfiles != null) { - summary.unnamedGroupSender(selfId, senderProfiles)?.let(chatCoordinator::requestSenderProfile) - } - summary.toConversationReference(selfId, tokensByMint, resources, senderProfiles.orEmpty()) - } + ): Event.ChatsUpdated { + val mainRows = mapConversations(feeds.main, tokens, senderProfiles, selfId(), resources, chatCoordinator) + val archivedRows = mapConversations(feeds.archived, tokens, senderProfiles, selfId(), resources, chatCoordinator) + + // The projection sees every chat so the chips, the Archived row and the badge number + // come from the one function the fixture tests. Hidden chats never reach the feeds, so + // `hidden` is always false here. + fun entry(row: ConversationReference, isArchived: Boolean) = ChatListEntry( + id = row.chatId.toString(), + isGroup = row.isGroup, + lastActivityMs = row.lastActivity?.toEpochMilliseconds() ?: 0L, + archived = isArchived, + muted = row.viewerState.isMutedAt(), + hidden = false, + unread = row.unreadCount, + ) + val projection = projectChatList( + mainRows.map { entry(it, isArchived = false) } + archivedRows.map { entry(it, isArchived = true) } + ) + return Event.ChatsUpdated(Loadable.Loaded(mainRows), projection) } // 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.currentChatListFeed()?.let { summaries -> + chatCoordinator.currentChatListFeeds()?.let { feeds -> dispatchEvent( - Event.ChatsUpdated( - Loadable.Loaded( - conversations( - summaries, - tokenCoordinator.cachedTokens(), - chatCoordinator.currentSenderProfiles(), - ) - ) + content( + feeds = feeds, + tokens = tokenCoordinator.cachedTokens(), + senderProfiles = chatCoordinator.currentSenderProfiles(), ) ) } + // One flow for both lists: two would let a new main list pair with a stale archived one, + // and archiving the last main chat would flash the no-chats prompt before the Archived row. combine( - chatCoordinator.chatListFeed(), + chatCoordinator.chatListFeeds(), tokenCoordinator.tokens, chatCoordinator.observeSenderProfiles(), - ::conversations, + ::content, ) // 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) + .onEach { dispatchEvent(it) } + .launchIn(viewModelScope) } internal companion object { - private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + internal val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { - is Event.ChatsUpdated -> { state -> state.copy(chats = event.chats) } + is Event.ChatsUpdated -> { state -> + state.copy(chats = event.chats, projection = event.projection) + } + // A chip can only be tapped once the row is on screen, so choosing one reveals it. + is Event.FilterSelected -> { state -> state.copy(filter = event.filter, chipsRevealed = true) } + Event.ChipsRevealed -> { state -> state.copy(chipsRevealed = true) } } } } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt new file mode 100644 index 0000000000..dcc58f67c1 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ChipReveal.kt @@ -0,0 +1,311 @@ +package com.flipcash.app.tipping.internal + +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity +import kotlin.math.max +import kotlin.math.min + +/** What a released drag does to a chip row that has not been revealed yet. */ +internal enum class ChipSettle { + /** Leave the release to the list's own fling. */ + None, + + /** Settle fully on the chips, which then stay. */ + Reveal, + + /** Settle just past the chips, hiding them again. */ + Park, + + /** Fling toward the top, but stop where the chips begin. */ + ClampedFling, +} + +/** + * How far the list projects a release, in seconds of its velocity. iOS's value: a quick flick from + * the parked position reveals the chips even if the finger barely moved. + */ +private const val ProjectionSeconds = 0.1f + +/** + * Decides how a release settles while the chips are hidden. + * + * [pastPark] is how far the list sits beyond the parked position (item 1 at the top): negative + * while the chips are partly on screen, zero when parked, positive once scrolled into the list. + * [velocity] is the finger's velocity in px/s, positive when moving down (toward the top of the + * list). [pullStartedAtTop] is whether the gesture began at the parked position; only such a pull + * may reveal. + */ +internal fun chipSettle( + pastPark: Float, + chipHeight: Float, + velocity: Float, + pullStartedAtTop: Boolean, +): ChipSettle { + if (chipHeight <= 0f) return if (velocity > 0f) ChipSettle.ClampedFling else ChipSettle.None + val projected = pastPark - velocity * ProjectionSeconds + return when { + // More than half the chip row is (or would be) on screen. + pullStartedAtTop && projected < -chipHeight / 2 -> ChipSettle.Reveal + pastPark < 0f -> when { + // Flung back into the list: its own fling carries the chips away. + velocity < 0f && projected > 0f -> ChipSettle.None + else -> ChipSettle.Park + } + velocity > 0f -> ChipSettle.ClampedFling + else -> ChipSettle.None + } +} + +/** + * Keeps the chip row (item 0 of [listState]) out of view until a pull from the top reveals it. + * + * Every scroll toward the top that did not begin at the parked position stops there, in + * [onPreScroll] for drags and in a clamped fling for releases. The release of a pull settles in + * [onPreFling] with one animation that starts at the finger's velocity and then reports all of it + * consumed, so the list never runs a second fling after the settle. + */ +internal class ChipRevealConnection( + private val listState: LazyListState, + private val flingBehavior: FlingBehavior, + private val isShown: () -> Boolean, + private val canReveal: () -> Boolean, + private val onRevealed: () -> Unit, +) : NestedScrollConnection { + + /** Set on pointer down; cleared when the gesture's release is handled. */ + private var pullStartedAtTop = false + + fun onPointerDown() { + pullStartedAtTop = !isShown() && canReveal() && listState.pastPark() <= 1f + } + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (isShown()) return Offset.Zero + val dy = available.y + // Scrolling into the list, or a pull that began at the top: the list takes it all. + if (dy <= 0f) return Offset.Zero + if (source == NestedScrollSource.UserInput && pullStartedAtTop) return Offset.Zero + val room = max(listState.pastPark(), 0f) + return if (dy > room) Offset(0f, dy - room) else Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + val fromTop = pullStartedAtTop + pullStartedAtTop = false + if (isShown()) return Velocity.Zero + val pastPark = listState.pastPark() + val chipHeight = listState.chipHeight() + return when (chipSettle(pastPark, chipHeight, available.y, fromTop && canReveal())) { + ChipSettle.None -> Velocity.Zero + ChipSettle.Reveal -> { + var marked = false + val markRevealed = { + if (!marked) { + marked = true + onRevealed() + } + } + try { + // Revealed the moment the chips are fully on, not when the spring comes to + // rest: a touch in the spring's tail cancels it, and an unmarked row would be + // parked again by the next scroll toward the top. + settleBy(-(pastPark + chipHeight), velocity = -available.y, onReached = markRevealed) + } finally { + // Cancelled before getting there, but already mostly on: keep them. + if (listState.pastPark() <= -chipHeight / 2) markRevealed() + } + available + } + ChipSettle.Park -> { + settleBy(-pastPark, velocity = -available.y) + available + } + ChipSettle.ClampedFling -> { + clampedFling(velocity = -available.y) + available + } + } + } + + /** + * Scrolls by [delta] on a spring that starts at [velocity] (in scroll direction), so the + * release carries straight into the settle. Overshoot is held at the target rather than + * scrolled past and back. + */ + private suspend fun settleBy(delta: Float, velocity: Float, onReached: () -> Unit = {}) { + if (delta == 0f) { + onReached() + return + } + val low = min(0f, delta) + val high = max(0f, delta) + listState.scroll { + var applied = 0f + animate( + initialValue = 0f, + targetValue = delta, + initialVelocity = velocity, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + ) { value, _ -> + applied += scrollBy(value.coerceIn(low, high) - applied) + if (applied == delta) onReached() + } + } + } + + /** The list's own fling, stopped where the chips begin. */ + private suspend fun clampedFling(velocity: Float) { + listState.scroll { + val scope = this + val clamped = object : ScrollScope { + override fun scrollBy(pixels: Float): Float { + if (pixels >= 0f) return scope.scrollBy(pixels) + // Toward the top: never past the parked position. Steps, because far from the + // top [pastPark] is only a lower bound; each step lands closer and re-measures. + var consumed = 0f + while (consumed > pixels) { + val step = max(pixels - consumed, -max(listState.pastPark(), 0f)) + if (step == 0f) break + val moved = scope.scrollBy(step) + consumed += moved + if (moved != step) break + } + // A delta not fully consumed ends the fling. + return consumed + } + } + with(flingBehavior) { clamped.performFling(velocity) } + } + } +} + +/** + * How far the list sits beyond the parked position (item 1 at the top of the content area): + * negative while the chip row is partly on screen. Item offsets are relative to the content start, + * so a chip row lying in the top padding (under the bar) still counts as hidden. + * + * Exact while item 0 or 1 is laid out, which includes the parked position: the chip row then sits + * in the top padding, where the list still composes it. Further down it is a lower bound, the top + * padding: item 1 is dropped only once it has scrolled past all of it. + */ +internal fun LazyListState.pastPark(): Float { + val items = layoutInfo.visibleItemsInfo + items.firstOrNull { it.index == 0 }?.let { return -(it.offset + it.size).toFloat() } + items.firstOrNull { it.index == 1 }?.let { return -it.offset.toFloat() } + return layoutInfo.beforeContentPadding.toFloat() +} + +/** + * Lets no touch through to the chip row while [blocked]: hidden, it lies in the list's top padding + * under the title bar, and a tap on the bar's lower edge would otherwise land on an invisible chip. + * Only the pointer is stopped; the row's semantics stay, so a screen reader can still reach it. + */ +internal fun Modifier.blockTouchesWhile(blocked: () -> Boolean): Modifier = pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + // A consumed down never starts a click below. The list's own drag still starts from it. + if (blocked()) down.consume() + } +} + +/** + * Wires [connection] to the list it is placed on: records where each gesture starts, and takes + * part in the list's nested scroll. Placed before the list's own scrolling. + */ +internal fun Modifier.chipReveal(connection: ChipRevealConnection): Modifier = this + .pointerInput(connection) { + awaitEachGesture { + awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Initial) + connection.onPointerDown() + } + } + .nestedScroll(connection) + +/** The chip row's height, or 0 while it is not laid out. */ +internal fun LazyListState.chipHeight(): Float = + layoutInfo.visibleItemsInfo.firstOrNull { it.index == 0 }?.size?.toFloat() ?: 0f + +/** How much of the chip row is on screen, 0 to 1. */ +internal fun LazyListState.chipVisibleFraction(): Float { + val height = chipHeight() + if (height <= 0f) return 0f + return (-pastPark() / height).coerceIn(0f, 1f) +} + +/** + * Keeps the list long enough to park. Parked means item 1 at the top, which a list can only hold + * if everything from item 1 on is at least a viewport tall: shorter, and the list scrolls back to + * index 0 and the chips show, on the first frame included. iOS sets the scroll distance outright; + * here a last item makes up the difference, and nothing more, so the list never scrolls further + * past its last row than the chip row's height. + * + * Each item after the chips reports its height with [tracked] as it is measured. The list measures + * forward from item 1, so in a short list every row has reported by the time [filler] is measured + * in the same pass, and the first frame is already parked. + */ +@Stable +internal class ParkFiller { + private val heights = mutableStateMapOf() + + /** Reports the height of the item under [key]. On the root of each item between chips and filler. */ + fun tracked(key: Any): Modifier = Modifier.layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val known = Snapshot.withoutReadObservation { heights[key] } + if (known != placeable.height) heights[key] = placeable.height + layout(placeable.width, placeable.height) { placeable.place(0, 0) } + } + + /** + * The last item's modifier, from a [LazyItemScope]: as tall as the viewport less the items + * under [keys]. While any of them has not been measured yet the list is long enough already + * (it skipped them), so it takes no height. + */ + fun LazyItemScope.filler(keys: List): Modifier = Modifier + .layout { measurable, constraints -> + // Measured only to learn the viewport (less the content padding). + val viewport = measurable.measure(constraints).height + var taken = 0 + var known = true + for (key in keys) { + val height = heights[key] + if (height == null) { + known = false + break + } + taken += height + } + val height = if (known) (viewport - taken).coerceAtLeast(0) else 0 + layout(constraints.maxWidth.takeIf { it != Constraints.Infinity } ?: 0, height) {} + } + .fillParentMaxHeight() + + /** Forgets items no longer in the list. */ + fun retain(keys: List) { + heights.keys.retainAll(keys.toSet()) + } +} + +@Composable +internal fun rememberParkFiller(): ParkFiller = remember { ParkFiller() } diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt new file mode 100644 index 0000000000..66c32c4d7e --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/ConversationMapping.kt @@ -0,0 +1,37 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.services.models.UserProfile +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.chat.ui.unnamedGroupSender +import com.getcode.opencode.model.financial.Token +import com.getcode.util.resources.ResourceHelper +import com.getcode.opencode.model.core.ID + +/** + * Chat summaries as list rows. One definition for the Chats list and the Archived list, so a row + * looks the same in both. + */ +internal fun mapConversations( + summaries: List, + tokens: List, + // Null on the first-frame draw, before the table has been read: asking then would re-fetch + // every sender already on disk. + senderProfiles: Map?, + selfId: ID?, + resources: ResourceHelper, + chatCoordinator: ChatCoordinator, +): List { + val tokensByMint = tokens.associateBy { it.address } + return summaries.map { summary -> + // The feed's group roster is usually just the viewer, so a group's last sender is named + // from the profiles the transcript resolves. Anyone not there yet is asked for here; + // SenderResolver collapses repeats, and the row re-emits when the write lands. + if (senderProfiles != null) { + summary.unnamedGroupSender(selfId, senderProfiles)?.let(chatCoordinator::requestSenderProfile) + } + summary.toConversationReference(selfId, tokensByMint, resources, senderProfiles.orEmpty()) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt new file mode 100644 index 0000000000..aba5953887 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelArchiveTest.kt @@ -0,0 +1,91 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.features.tipping.R +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatFeeds +import com.flipcash.shared.chat.ChatSummary +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Instant + +/** + * Archiving the last chat in the main list moves it to the Archived row in one step. The screen + * reads both lists from one feed, so no state between the two shows the no-chats prompt. + * `ChatArchiveFeedTest` pins the feed side: the delegate emits both lists from one state. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatsViewModelArchiveTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chat = ChatSummary( + metadata = ChatMetadata( + chatId = ChatId(ByteArray(16) { 3 }.toList()), + type = ChatType.GROUP, + members = emptyList(), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(1_000), + title = "Ballers", + latestEventSequence = 0, + ), + unreadCount = 0, + ) + + @Test + fun `archiving the last main chat never shows the no-chats prompt`() = + runTest(mainCoroutineRule.dispatcher) { + val feeds = MutableStateFlow(ChatFeeds(main = listOf(chat), archived = emptyList())) + val chatCoordinator = mockk(relaxed = true).also { + every { it.feedWithArchived(*anyVararg()) } returns feeds + every { it.currentFeedWithArchived(*anyVararg()) } returns null + every { it.observeSenderProfiles() } returns flowOf(emptyMap()) + } + val tokenCoordinator = mockk(relaxed = true).also { + every { it.tokens } returns flowOf(emptyList()) + every { it.cachedTokens() } returns emptyList() + } + val vm = ChatsViewModel( + chatCoordinator = chatCoordinator, + userManager = mockk(relaxed = true), + tokenCoordinator = tokenCoordinator, + resources = FakeResourceHelper(R::class.java), + dispatchers = TestDispatchers(testScheduler), + archiveStore = ChatArchiveStore.None, + ) + + val states = mutableListOf() + backgroundScope.launch { vm.stateFlow.collect { states += it } } + advanceUntilIdle() + + feeds.value = ChatFeeds(main = emptyList(), archived = listOf(chat)) + advanceUntilIdle() + + val loaded = states.filter { it.chats.dataOrNull != null } + assertTrue(loaded.isNotEmpty()) + assertTrue(loaded.none { it.hasNoChatsAtAll }, "a loaded state showed the no-chats prompt") + val last = states.last() + assertEquals(emptyList(), last.chats.dataOrNull) + assertTrue(last.showsArchivedRow) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt new file mode 100644 index 0000000000..52a3198eb1 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChatsViewModelStateTest.kt @@ -0,0 +1,94 @@ +package com.flipcash.app.tipping.internal + +import com.flipcash.app.core.data.Loadable +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatListFilter +import com.flipcash.shared.chat.ChatListProjection +import com.flipcash.shared.chat.ui.ConversationReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatsViewModelStateTest { + + private fun reduce(state: ChatsViewModel.State, event: ChatsViewModel.Event) = + ChatsViewModel.updateStateForEvent(event)(state) + + private val dm = ConversationReference(chatId = ChatId(byteArrayOf(1))) + private val group = ConversationReference(chatId = ChatId(byteArrayOf(2)), isGroup = true) + + private val projection = ChatListProjection.Empty.copy( + main = listOf(dm.chatId.toString(), group.chatId.toString()), + unreadChip = listOf(dm.chatId.toString()), + groupsChip = listOf(group.chatId.toString()), + archivedRowVisible = true, + archivedRowCount = 2, + ) + + private val loaded = reduce( + ChatsViewModel.State(), + ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(listOf(dm, group)), projection), + ) + + @Test + fun `chips start hidden under All`() { + assertFalse(ChatsViewModel.State().showsChips) + assertFalse(loaded.showsChips) + } + + @Test + fun `a reveal holds across later feed updates`() { + val revealed = reduce(loaded, ChatsViewModel.Event.ChipsRevealed) + val updated = reduce( + revealed, + ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(listOf(group)), projection), + ) + assertTrue(updated.chipsRevealed) + assertTrue(updated.showsChips) + } + + @Test + fun `choosing a filter shows the chips and stays revealed back on All`() { + val unread = reduce(loaded, ChatsViewModel.Event.FilterSelected(ChatListFilter.Unread)) + assertTrue(unread.showsChips) + val all = reduce(unread, ChatsViewModel.Event.FilterSelected(ChatListFilter.All)) + assertTrue(all.showsChips) + } + + @Test + fun `each filter shows its own rows in feed order`() { + assertEquals(listOf(dm, group), loaded.visibleChats) + assertEquals( + listOf(dm), + reduce(loaded, ChatsViewModel.Event.FilterSelected(ChatListFilter.Unread)).visibleChats, + ) + assertEquals( + listOf(group), + reduce(loaded, ChatsViewModel.Event.FilterSelected(ChatListFilter.Groups)).visibleChats, + ) + } + + @Test + fun `the Archived row shows only under All`() { + assertTrue(loaded.showsArchivedRow) + assertFalse(reduce(loaded, ChatsViewModel.Event.FilterSelected(ChatListFilter.Unread)).showsArchivedRow) + assertFalse(reduce(loaded, ChatsViewModel.Event.FilterSelected(ChatListFilter.Groups)).showsArchivedRow) + } + + @Test + fun `an empty main list with archived chats is not the no-chats prompt`() { + val archivedOnly = reduce( + ChatsViewModel.State(), + ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(emptyList()), projection.copy(main = emptyList())), + ) + assertFalse(archivedOnly.hasNoChatsAtAll) + assertTrue(archivedOnly.visibleChats.isEmpty()) + + val nothing = reduce( + ChatsViewModel.State(), + ChatsViewModel.Event.ChatsUpdated(Loadable.Loaded(emptyList()), ChatListProjection.Empty), + ) + assertTrue(nothing.hasNoChatsAtAll) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt new file mode 100644 index 0000000000..916049bd39 --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipRevealTest.kt @@ -0,0 +1,302 @@ +package com.flipcash.app.tipping.internal + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.TouchInjectionScope +import androidx.compose.ui.test.click +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Drives [ChipRevealConnection] on a list laid out like the Chats tab: the chip row as item 0, an + * empty slot as item 1 (where the Archived row goes), rows after, and the list padded by a bar. + * Positions are read as [pastPark]: 0 is parked, minus the chip height is fully revealed. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class ChipRevealTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private lateinit var listState: LazyListState + private var revealed by mutableStateOf(false) + private var firstFrame: Float? = null + private var chipClicks = 0 + + // xhdpi: 2px per dp. + private val chipPx = 100f + + private fun setContent(rows: Int = 60) { + composeRule.setContent { + listState = rememberLazyListState(initialFirstVisibleItemIndex = 1) + val fling = ScrollableDefaults.flingBehavior() + val filler = rememberParkFiller() + val keys = remember(rows) { listOf("archived") + (0 until rows).toList() } + val connection = remember(listState, fling) { + ChipRevealConnection( + listState = listState, + flingBehavior = fling, + isShown = { revealed }, + canReveal = { true }, + onRevealed = { revealed = true }, + ) + } + LazyColumn( + modifier = Modifier.fillMaxSize().testTag("list").chipReveal(connection), + state = listState, + flingBehavior = fling, + contentPadding = PaddingValues(top = 56.dp), + ) { + item(key = "chips") { + Box( + Modifier + .fillMaxWidth() + .height(50.dp) + .blockTouchesWhile { !revealed && listState.chipVisibleFraction() == 0f } + .clickable { chipClicks++ } + ) + if (firstFrame == null) firstFrame = listState.chipVisibleFraction() + } + item(key = "archived") { Box(filler.tracked("archived")) } + items((0 until rows).toList(), key = { it }) { + Box(filler.tracked(it).fillMaxWidth().height(60.dp)) + } + item(key = "filler") { Box(with(filler) { filler(keys) }) } + } + } + composeRule.waitForIdle() + } + + /** A drag of [dy] px that comes to rest before lifting, so it releases with no velocity. */ + private fun TouchInjectionScope.slowDrag(dy: Float) { + down(Offset(centerX, 400f)) + repeat(20) { moveBy(Offset(0f, dy / 20), delayMillis = 16) } + repeat(10) { moveBy(Offset.Zero, delayMillis = 16) } + up() + } + + private val pastPark get() = listState.pastPark() + + @Test + fun `starts parked with the chips hidden from the first frame`() { + setContent() + assertEquals(0f, firstFrame!!, 0f) + assertEquals(0f, pastPark, 0.5f) + assertEquals(chipPx, listState.chipHeight(), 0.5f) + // The empty Archived slot is the anchor, not the first row, even at zero height. + assertEquals(1, listState.firstVisibleItemIndex) + assertEquals(0, listState.firstVisibleItemScrollOffset) + } + + @Test + fun `a pull past half the chips settles revealed`() { + setContent() + // Touch slop eats the start of the drag, so pull well past half. + composeRule.onNodeWithTag("list").performTouchInput { slowDrag(chipPx * 0.75f + 16f) } + composeRule.waitForIdle() + assertEquals(-chipPx, pastPark, 0.5f) + assertTrue(revealed) + } + + @Test + fun `a short pull settles back to parked`() { + setContent() + composeRule.onNodeWithTag("list").performTouchInput { slowDrag(chipPx * 0.3f + 16f) } + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + assertFalse(revealed) + } + + @Test + fun `a fling toward the top from mid-list stops parked`() { + setContent() + // Far enough down that the release comes well before the top: the fling has to stop. + runBlocking { listState.scrollToItem(30) } + composeRule.waitForIdle() + composeRule.mainClock.autoAdvance = false + composeRule.onNodeWithTag("list").performTouchInput { + swipeDown(startY = 200f, endY = 1_200f, durationMillis = 80) + } + var frames = 0 + while (pastPark > 0.5f && frames++ < 300) composeRule.mainClock.advanceTimeByFrame() + assertEquals(0f, pastPark, 0.5f) + // The fling ends where it stops, rather than running on with every delta held back: a + // fling still running would swallow the next tap on a row. + repeat(2) { composeRule.mainClock.advanceTimeByFrame() } + assertFalse("fling still running at park", listState.isScrollInProgress) + composeRule.mainClock.autoAdvance = true + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + assertFalse(revealed) + } + + @Test + fun `a drag from mid-list past the top stops parked while the finger is down`() { + setContent() + runBlocking { listState.scrollToItem(3) } + composeRule.waitForIdle() + val list = composeRule.onNodeWithTag("list") + list.performTouchInput { + down(Offset(centerX, 300f)) + repeat(20) { moveBy(Offset(0f, 50f), delayMillis = 16) } + } + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + list.performTouchInput { up() } + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + assertFalse(revealed) + } + + @Test + fun `a pull from the top follows the finger`() { + setContent() + val list = composeRule.onNodeWithTag("list") + list.performTouchInput { + down(Offset(centerX, 400f)) + // Past touch slop, then 40px more. + repeat(4) { moveBy(Offset(0f, 20f), delayMillis = 16) } + } + composeRule.waitForIdle() + val partway = pastPark + list.performTouchInput { moveBy(Offset(0f, 30f), delayMillis = 16) } + composeRule.waitForIdle() + assertTrue("chips partly on: $partway", partway < 0f && partway > -chipPx) + assertEquals(partway - 30f, pastPark, 0.5f) + list.performTouchInput { up() } + } + + @Test + fun `once revealed the list scrolls freely to the chips`() { + setContent() + revealed = true + runBlocking { listState.scrollToItem(8) } + composeRule.waitForIdle() + composeRule.onNodeWithTag("list").performTouchInput { + swipeDown(startY = 200f, endY = 1_200f, durationMillis = 80) + } + composeRule.waitForIdle() + assertEquals(-chipPx, pastPark, 0.5f) + } + + @Test + fun `a tap on the bar over hidden chips does not reach them`() { + setContent() + // Parked, the chip row lies in the top padding: 12px to 112px. + composeRule.onNodeWithTag("list").performTouchInput { click(Offset(centerX, 60f)) } + composeRule.waitForIdle() + assertEquals(0, chipClicks) + assertEquals(0f, pastPark, 0.5f) + + revealed = true + composeRule.waitForIdle() + composeRule.onNodeWithTag("list").performTouchInput { click(Offset(centerX, 60f)) } + composeRule.waitForIdle() + assertEquals(1, chipClicks) + } + + @Test + fun `a quick flick down from park reveals`() { + setContent() + composeRule.onNodeWithTag("list").performTouchInput { + down(Offset(centerX, 400f)) + // Barely past touch slop, but fast. + repeat(5) { moveBy(Offset(0f, 12f), delayMillis = 8) } + up() + } + composeRule.waitForIdle() + assertEquals(-chipPx, pastPark, 0.5f) + assertTrue(revealed) + } + + @Test + fun `a touch that cancels the settle partway still leaves the chips revealed`() { + setContent() + composeRule.mainClock.autoAdvance = false + val list = composeRule.onNodeWithTag("list") + list.performTouchInput { slowDrag(chipPx * 0.75f + 16f) } + repeat(2) { composeRule.mainClock.advanceTimeByFrame() } + assertFalse("settle already done", revealed) + list.performTouchInput { down(Offset(centerX, 600f)) } + repeat(3) { composeRule.mainClock.advanceTimeByFrame() } + val stoppedAt = pastPark + assertTrue("settle not cancelled: $stoppedAt", stoppedAt > -chipPx + 0.5f) + assertTrue(revealed) + list.performTouchInput { up() } + composeRule.mainClock.autoAdvance = true + composeRule.waitForIdle() + assertTrue(revealed) + } + + @Test + fun `a list shorter than the screen still parks, reveals and parks again`() { + // Three rows: 360px against a 1488px viewport (1600 less the bar's 112). + setContent(rows = 3) + assertEquals(0f, firstFrame!!, 0f) + assertEquals(1, listState.firstVisibleItemIndex) + assertEquals(0, listState.firstVisibleItemScrollOffset) + // The filler makes up the viewport and no more: parked is as far as the list goes. + assertFalse(listState.canScrollForward) + + composeRule.onNodeWithTag("list").performTouchInput { slowDrag(chipPx * 0.75f + 16f) } + composeRule.waitForIdle() + assertEquals(-chipPx, pastPark, 0.5f) + assertTrue(revealed) + } + + @Test + fun `a short pull on a list shorter than the screen parks`() { + setContent(rows = 3) + composeRule.onNodeWithTag("list").performTouchInput { slowDrag(chipPx * 0.3f + 16f) } + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + assertFalse(revealed) + // A swipe up from park goes nowhere: nothing below the last row to scroll into. + composeRule.onNodeWithTag("list").performTouchInput { slowDrag(-300f) } + composeRule.waitForIdle() + assertEquals(0f, pastPark, 0.5f) + } + + @Test + fun `a long list scrolled to its end leaves no filler`() { + setContent() + // Scrolled through on the way, so every row has reported its height. + runBlocking { repeat(20) { listState.scrollBy(500f) } } + composeRule.waitForIdle() + val info = listState.layoutInfo + val lastRow = info.visibleItemsInfo.first { it.key == 59 } + // The end of the list is the last row: the filler takes nothing. + assertEquals(info.viewportEndOffset - info.afterContentPadding, lastRow.offset + lastRow.size) + } +} diff --git a/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt new file mode 100644 index 0000000000..45a647863c --- /dev/null +++ b/apps/flipcash/features/tipping/src/test/kotlin/com/flipcash/app/tipping/internal/ChipSettleTest.kt @@ -0,0 +1,77 @@ +package com.flipcash.app.tipping.internal + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChipSettleTest { + + private val chip = 100f + + private fun settle(pastPark: Float, velocity: Float = 0f, fromTop: Boolean = true) = + chipSettle(pastPark = pastPark, chipHeight = chip, velocity = velocity, pullStartedAtTop = fromTop) + + @Test + fun `a slow release with more than half the chips on screen reveals them`() { + assertEquals(ChipSettle.Reveal, settle(pastPark = -60f)) + } + + @Test + fun `a slow release with half or less on screen parks`() { + assertEquals(ChipSettle.Park, settle(pastPark = -50f)) + assertEquals(ChipSettle.Park, settle(pastPark = -10f)) + } + + @Test + fun `a quick flick down from a short pull reveals`() { + // -10 - 500 * 0.1 = -60: past half once projected. + assertEquals(ChipSettle.Reveal, settle(pastPark = -10f, velocity = 500f)) + } + + @Test + fun `a flick back into the list from a partial reveal parks`() { + // -60 + 300 * 0.1 = -30: not past park, so the spring brings it to park. + assertEquals(ChipSettle.Park, settle(pastPark = -60f, velocity = -300f)) + } + + @Test + fun `a hard flick back into the list leaves the list's own fling`() { + assertEquals(ChipSettle.None, settle(pastPark = -20f, velocity = -2_000f)) + } + + @Test + fun `a gesture that began mid-list never reveals`() { + assertEquals(ChipSettle.ClampedFling, settle(pastPark = 400f, velocity = 8_000f, fromTop = false)) + assertEquals(ChipSettle.Park, settle(pastPark = -80f, fromTop = false)) + } + + @Test + fun `a fling toward the top from inside the list is clamped`() { + assertEquals(ChipSettle.ClampedFling, settle(pastPark = 400f, velocity = 300f)) + } + + @Test + fun `a release at rest or moving into the list is left alone`() { + assertEquals(ChipSettle.None, settle(pastPark = 0f)) + assertEquals(ChipSettle.None, settle(pastPark = 400f, velocity = -3_000f)) + } + + @Test + fun `with the chips not laid out a fling toward the top is still clamped`() { + assertEquals( + ChipSettle.ClampedFling, + chipSettle(pastPark = 2_000f, chipHeight = 0f, velocity = 5_000f, pullStartedAtTop = false), + ) + assertEquals( + ChipSettle.None, + chipSettle(pastPark = 2_000f, chipHeight = 0f, velocity = -5_000f, pullStartedAtTop = false), + ) + } + + @Test + fun `a quick flick down from park reveals`() { + // 0 - 600 * 0.1 = -60: past half once projected, with nothing pulled yet. + assertEquals(ChipSettle.Reveal, settle(pastPark = 0f, velocity = 600f)) + // Too slow to project past half: the list stays parked (a clamped fling stops at once). + assertEquals(ChipSettle.ClampedFling, settle(pastPark = 0f, velocity = 400f)) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt new file mode 100644 index 0000000000..54e0227547 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ArchiveNotifyRule.kt @@ -0,0 +1,31 @@ +package com.flipcash.shared.chat + +/** + * Whether a push for a chat may post a notification. + * + * - **Muted: never**, archived or not. Mute already suppresses every push and archiving must not + * add a way through it. + * - **Not archived and not muted: always.** Archive adds a filter; it never changes what an + * ordinary chat does. + * - **Archived, not muted: only a message addressed to the viewer**, which is an @mention of them + * or a reply to one of their messages. The message's kind does not matter: received + * cash, media and system messages do not break through on their own, because in a tip DM most + * messages may be cash and letting them through would make archive useless where people most + * want quiet. + * + * [mentionsViewer] and [repliesToViewer] are false when the device cannot tell: the message text + * is not available, a mention names a handle that is not the viewer's, or the replied-to message + * is not stored locally. Both platforms default the same way. + * + * Pure: no Android types. The fixture's `notify` cases drive this directly. + */ +fun shouldNotify( + archived: Boolean, + muted: Boolean, + mentionsViewer: Boolean, + repliesToViewer: Boolean, +): Boolean = when { + muted -> false + !archived -> true + else -> mentionsViewer || repliesToViewer +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatArchiveStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatArchiveStore.kt new file mode 100644 index 0000000000..928e66d3a7 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatArchiveStore.kt @@ -0,0 +1,44 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf + +/** + * The chats the viewer has archived, kept on this device. + * + * Archive changes only when the user changes it: nothing here is called from an incoming message, + * mention, payment or from opening the chat. The two writers outside the UI are leaving a group + * and account erasure. + * + * Nothing reaches the network; the set is local until the server stores it, which is why it lives + * in its own Room table. + */ +interface ChatArchiveStore { + + /** The archived chats, re-emitted on every change. Emits an empty set before sign-in. */ + fun observeArchived(): Flow> + + suspend fun isArchived(chatId: ChatId): Boolean + + /** Archives [chatId]. Archiving an archived chat does nothing. */ + suspend fun archive(chatId: ChatId) + + /** Un-archives [chatId]; also what leaving a group does. */ + suspend fun unarchive(chatId: ChatId) + + /** Drops every record. Account erasure. */ + suspend fun clearAll() + + /** + * Archives nothing and ignores writes. The default for delegates that take a store, so the + * dozens of tests that construct them directly keep compiling without a fake. + */ + object None : ChatArchiveStore { + override fun observeArchived(): Flow> = flowOf(emptySet()) + override suspend fun isArchived(chatId: ChatId): Boolean = false + override suspend fun archive(chatId: ChatId) = Unit + override suspend fun unarchive(chatId: ChatId) = Unit + override suspend fun clearAll() = Unit + } +} 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 d2e70089c6..db72ee3645 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 @@ -29,6 +29,9 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow +/** The main and archived lists from one read of the feed. See [FeedOperations.feedWithArchived]. */ +data class ChatFeeds(val main: List, val archived: List) + /** * Feed-level operations: observing the conversation list and its unread state. * @@ -52,6 +55,25 @@ interface FeedOperations { /** Emits the number of conversations of any of [chatTypes] that have unread messages. */ fun observeUnreadConversations(vararg chatTypes: ChatType): Flow + /** + * [feed]'s counterpart: the same rows for the chats the viewer archived, newest first. Emits + * nothing until the list is known, like [feed]. + */ + fun archivedFeed(vararg chatTypes: ChatType): Flow> + + /** [currentFeed]'s counterpart for archived chats. */ + fun currentArchivedFeed(vararg chatTypes: ChatType): List? + + /** + * [feed] and [archivedFeed] read from one state, for a screen that draws both. Collecting the + * two separately can pair a new main list with a stale archived one: archiving the last main + * chat would briefly show both empty. Emits only when either list changes. + */ + fun feedWithArchived(vararg chatTypes: ChatType): Flow + + /** [feedWithArchived]'s current value, or null while it would emit nothing yet. */ + fun currentFeedWithArchived(vararg chatTypes: ChatType): ChatFeeds? + /** * The same conversations as [feed], paged. * @@ -396,6 +418,15 @@ interface MessagingOperations { */ suspend fun openPushedMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): String? + /** + * The end-to-end encrypted message a push is for, opened on this device, with its content in + * the clear, so the push can be read for what it is (a reply, a mention) and not just shown. + * Resolves [message] or [messageId] the way [openPushedMessage] does. + * + * `null` when the message isn't encrypted or can't be opened or fetched. Nothing is stored. + */ + suspend fun openPushedChatMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): ChatMessage? + /** * Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt index 3402837b98..ab9e03048e 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatList.kt @@ -19,3 +19,15 @@ fun FeedOperations.currentChatListFeed(): List? = currentFeed(*chat /** How many of the chats the Chats list shows have unread messages; the Chats tab badge. */ fun FeedOperations.observeUnreadChatListCount(): Flow = observeUnreadConversations(*chatListTypes) + +/** [FeedOperations.archivedFeed] for the chats the Chats list shows. */ +fun FeedOperations.archivedChatListFeed(): Flow> = archivedFeed(*chatListTypes) + +/** [FeedOperations.currentArchivedFeed] for the chats the Chats list shows. */ +fun FeedOperations.currentArchivedChatListFeed(): List? = currentArchivedFeed(*chatListTypes) + +/** [FeedOperations.feedWithArchived] for the chats the Chats list shows. */ +fun FeedOperations.chatListFeeds(): Flow = feedWithArchived(*chatListTypes) + +/** [FeedOperations.currentFeedWithArchived] for the chats the Chats list shows. */ +fun FeedOperations.currentChatListFeeds(): ChatFeeds? = currentFeedWithArchived(*chatListTypes) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt new file mode 100644 index 0000000000..6abf462251 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatListProjection.kt @@ -0,0 +1,99 @@ +package com.flipcash.shared.chat + +/** The chip selected above the Chats list. Not persisted: every cold start opens on [All]. */ +enum class ChatListFilter { All, Unread, Groups } + +/** + * One chat as the list projection sees it. [id] is opaque here (the app passes the chat id's + * `toString()`, its base58 form; the fixture passes its own ids). [unread] is the chat's unread count, with null meaning "unread + * by an unknown count", which counts as unread. + */ +data class ChatListEntry( + val id: String, + val isGroup: Boolean, + val lastActivityMs: Long, + val archived: Boolean, + val muted: Boolean, + val hidden: Boolean, + val unread: Int?, +) { + val isUnread: Boolean get() = unread != 0 +} + +/** + * What the Chats list shows, derived from every chat the viewer has. + * + * Every id list is ordered by last activity, newest first. Hidden chats (blocked) appear nowhere, + * including [archived]; their archive record stays in storage in case they are unblocked. + * + * @property main every chat that is neither archived nor hidden: the "All" list. + * @property unreadChip [main] filtered to unread chats, unknown counts included. + * @property groupsChip [main] filtered to groups. The chip's *number* is [groupsChipCount], the + * unread groups among them. + * @property archived the chats under the Archived row, muted ones included. + * @property archivedRowVisible whether any chat is archived; the row is shown whenever it is, even + * when [main] is empty. + * @property archivedRowCount unread archived chats that are not muted. Muted archived chats count + * toward nothing, as muted chats count toward nothing there today. + * @property tabBadge unread chats in [main], muted ones included, as the Chats tab badge counts + * today. No Android screen reads it: the badge comes from + * [observeUnreadChatListCount], which counts the same chats. It stays because the shared + * fixture's `list` cases check it on both platforms, and iOS draws its badge from it. + */ +data class ChatListProjection( + val main: List, + val unreadChip: List, + val groupsChip: List, + val archived: List, + val unreadChipCount: Int, + val groupsChipCount: Int, + val archivedRowVisible: Boolean, + val archivedRowCount: Int, + val tabBadge: Int, +) { + fun idsFor(filter: ChatListFilter): List = when (filter) { + ChatListFilter.All -> main + ChatListFilter.Unread -> unreadChip + ChatListFilter.Groups -> groupsChip + } + + companion object { + val Empty = ChatListProjection( + main = emptyList(), + unreadChip = emptyList(), + groupsChip = emptyList(), + archived = emptyList(), + unreadChipCount = 0, + groupsChipCount = 0, + archivedRowVisible = false, + archivedRowCount = 0, + tabBadge = 0, + ) + } +} + +/** + * Splits chats into the main list and the Archived row (an archived chat leaves the list, every + * chip and the tab badge together), and counts the numbers the list draws. Pure, so the fixture's `list` cases drive it directly. + * + * Ties in last activity keep their input order (`sortedByDescending` is stable). + */ +fun projectChatList(entries: List): ChatListProjection { + val (archived, active) = entries.filterNot { it.hidden }.partition { it.archived } + val main = active.sortedByDescending { it.lastActivityMs } + val archivedSorted = archived.sortedByDescending { it.lastActivityMs } + val unread = main.filter { it.isUnread } + val groups = main.filter { it.isGroup } + + return ChatListProjection( + main = main.map { it.id }, + unreadChip = unread.map { it.id }, + groupsChip = groups.map { it.id }, + archived = archivedSorted.map { it.id }, + unreadChipCount = unread.size, + groupsChipCount = groups.count { it.isUnread }, + archivedRowVisible = archivedSorted.isNotEmpty(), + archivedRowCount = archivedSorted.count { it.isUnread && !it.muted }, + tabBadge = unread.size, + ) +} 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 42ee2647e5..92b5f65ab3 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 @@ -23,6 +23,9 @@ data class ChatState( val feedSyncState: FeedSyncState = FeedSyncState.Idle, val historyHydration: ChatHydrationState = ChatHydrationState.Unknown, val activeChat: ChatId? = null, + // The chats the viewer archived, read in the same combine that builds [feed] so the feed and + // the archive set always change together. `feed` and `archivedFeed` split on it. + val archived: Set = emptySet(), ) data class ChatSummary( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index 7629e5be2c..c3eddf96fe 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -1,5 +1,6 @@ package com.flipcash.shared.chat.inject +import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.RosterSearchSource @@ -8,6 +9,7 @@ import com.flipcash.shared.chat.internal.MentionPoolUpdates import com.flipcash.shared.chat.internal.MentionSuggestionPool import com.flipcash.shared.chat.internal.ServerRosterSearchSource import com.flipcash.shared.chat.internal.OutgoingEncryption +import com.flipcash.shared.chat.internal.RealChatArchiveStore import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.RealChatDraftStore import com.getcode.opencode.providers.SessionListener @@ -34,6 +36,12 @@ abstract class ChatModule { impl: RealChatDraftStore ): ChatDraftStore + @Binds + @Singleton + abstract fun bindChatArchiveStore( + impl: RealChatArchiveStore + ): ChatArchiveStore + @Binds internal abstract fun bindOutgoingEncryption( impl: DmOutgoingEncryption diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatArchiveStore.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatArchiveStore.kt new file mode 100644 index 0000000000..2a81e0f37a --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatArchiveStore.kt @@ -0,0 +1,36 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.ChatArchiveDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ChatArchiveStore +import com.getcode.utils.hexEncodedString +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** [ChatArchiveStore] over the `chat_archive` table. */ +@Singleton +class RealChatArchiveStore internal constructor( + private val dataSource: ChatArchiveDataSource, + private val now: () -> Long, +) : ChatArchiveStore { + + @Inject + constructor(dataSource: ChatArchiveDataSource) : this(dataSource, System::currentTimeMillis) + + override fun observeArchived(): Flow> = + dataSource.observeIds().map { hexes -> hexes.mapTo(HashSet()) { ChatId(it) } } + + override suspend fun isArchived(chatId: ChatId): Boolean = dataSource.isArchived(chatId.hex) + + override suspend fun archive(chatId: ChatId) = dataSource.archive(chatId.hex, now()) + + override suspend fun unarchive(chatId: ChatId) = dataSource.unarchive(chatId.hex) + + override suspend fun clearAll() = dataSource.clear() + + // Matches ChatEntityMapper.chatIdHex, so the record is keyed the same way as the chat's rows. + private val ChatId.hex: String + get() = bytes.toList().hexEncodedString() +} 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 13cd3e1664..d296780214 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 @@ -10,6 +10,7 @@ import androidx.lifecycle.ProcessLifecycleOwner import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore import com.flipcash.shared.chat.ChatState @@ -97,6 +98,7 @@ class RealChatCoordinator @Inject constructor( private val userManager: UserManager, private val networkObserver: NetworkConnectivityListener, private val dispatchers: DispatcherProvider, + private val archiveStore: ChatArchiveStore = ChatArchiveStore.None, ) : ChatCoordinator, SessionListener, DefaultLifecycleObserver, @@ -322,7 +324,14 @@ class RealChatCoordinator @Inject constructor( * the draft is what it was. */ override suspend fun leave(chatId: ChatId): Result = - groupFeedDelegate.leave(chatId).onSuccess { draftStore.clear(chatId) } + groupFeedDelegate.leave(chatId).onSuccess { + draftStore.clear(chatId) + // Leaving a group clears its archive the way it clears mute. A remote leave, from another + // device, arrives through GroupFeedDelegate's roster path, which has no store: the + // record is left behind, harmlessly, because the chat is no longer a member chat and + // so is in neither feed. + archiveStore.unarchive(chatId) + } /** * Fetches both halves of the conversation list. @@ -414,6 +423,7 @@ class RealChatCoordinator @Inject constructor( // same reason the transcript does — the database is per-account, so the next login reopens // the same file — and account deletion is the one path that is meant to erase them. draftStore.clearAll() + archiveStore.clearAll() trace(tag = TAG, message = "cache cleared", type = TraceType.Process) } 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 7b77ebeb61..2ee87c8b94 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,8 @@ import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.PointerType +import com.flipcash.shared.chat.ChatArchiveStore +import com.flipcash.shared.chat.ChatFeeds import com.flipcash.shared.chat.ChatHydrationState import com.flipcash.shared.chat.ChatSummary import com.flipcash.shared.chat.FeedOperations @@ -85,6 +87,7 @@ class FeedSyncDelegate @Inject constructor( private val userManager: UserManager, private val messagingController: ChatMessagingController, private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None, + private val archiveStore: ChatArchiveStore = ChatArchiveStore.None, private val feedWriter: ChatFeedWriter = ChatFeedWriter(metadataDataSource, memberDataSource, messageDataSource), ) : FeedOperations { @@ -160,14 +163,40 @@ class FeedSyncDelegate @Inject constructor( // Distinct: state also moves for reasons the list does not show (sync status, typing, // overlays), and an equal list repaints nothing but still re-runs every collector. return stateHolder.state - .mapNotNull { state -> summaries(state, requested) } + .mapNotNull { state -> summaries(state, requested, archived = false) } .distinctUntilChanged() } override fun currentFeed(vararg chatTypes: ChatType): List? = - summaries(stateHolder.state.value, chatTypes.toSet()) + summaries(stateHolder.state.value, chatTypes.toSet(), archived = false) - private fun summaries(state: ChatState, requested: Set): List? { + override fun archivedFeed(vararg chatTypes: ChatType): Flow> { + val requested = chatTypes.toSet() + return stateHolder.state + .mapNotNull { state -> summaries(state, requested, archived = true) } + .distinctUntilChanged() + } + + override fun currentArchivedFeed(vararg chatTypes: ChatType): List? = + summaries(stateHolder.state.value, chatTypes.toSet(), archived = true) + + override fun feedWithArchived(vararg chatTypes: ChatType): Flow { + val requested = chatTypes.toSet() + return stateHolder.state + .mapNotNull { state -> feeds(state, requested) } + .distinctUntilChanged() + } + + override fun currentFeedWithArchived(vararg chatTypes: ChatType): ChatFeeds? = + feeds(stateHolder.state.value, chatTypes.toSet()) + + private fun feeds(state: ChatState, requested: Set): ChatFeeds? { + val main = summaries(state, requested, archived = false) ?: return null + val archived = summaries(state, requested, archived = true) ?: return null + return ChatFeeds(main = main, archived = archived) + } + + private fun summaries(state: ChatState, requested: Set, archived: Boolean): 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 @@ -181,6 +210,9 @@ class FeedSyncDelegate @Inject constructor( val selfPhone = userManager.profile?.verifiedPhoneNumber return feed .filter { it.type in requested } + // The one place archive is applied. An archived chat leaves the list, every chip and the + // tab badge together: they all read `feed`, so none can disagree about what is archived. + .filter { (it.chatId in state.archived) == archived } .filter { isRenderable(it, selfId, selfPhone) } .map { metadata -> val count = unreadCount(metadata, selfId) { id -> state.readStampAt(metadata.chatId, id) } @@ -267,15 +299,18 @@ class FeedSyncDelegate @Inject constructor( messageDataSource.observeLatestVisibleChanges() .onStart { emit(emptyList()) } .distinctUntilChanged(), - ) { metadataEntities, membersByChat, _ -> - buildFeedFromDb(metadataEntities, membersByChat) - }.onEach { (feed, readStamps) -> + archiveStore.observeArchived(), + ) { metadataEntities, membersByChat, _, archived -> + buildFeedFromDb(metadataEntities, membersByChat) to archived + }.onEach { (built, archived) -> + val (feed, readStamps) = built val staged = drainStagedStamps() stateHolder.update { it.copy( feed = feed, readStamps = readStamps, fetchedReadStamps = if (staged.isEmpty()) it.fetchedReadStamps else it.fetchedReadStamps + staged, + archived = archived, ) } // Until the first sync settles, the reconcile looks the stamps up itself, bounded and 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 4c14928351..3a5f4077f5 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 @@ -289,7 +289,14 @@ class MessagingDelegate @Inject constructor( } } - override suspend fun openPushedMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): String? { + override suspend fun openPushedMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): String? = + openPushedChatMessage(chatId, message, messageId)?.content?.singleOrNull()?.pushText() + + override suspend fun openPushedChatMessage( + chatId: ChatId, + message: ChatMessage?, + messageId: Long?, + ): ChatMessage? { val opener = incoming ?: return null val selfId = userManager.accountId ?: return null val candidate = message @@ -309,8 +316,7 @@ class MessagingDelegate @Inject constructor( } else -> return null } - if (opened.encryption !is MessageEncryption.Decrypted) return null - return opened.content.singleOrNull()?.pushText() + return opened.takeIf { it.encryption is MessageEncryption.Decrypted } } /** Text and replies with text are what DMs encrypt; anything else keeps the server's body. */ diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt similarity index 100% rename from apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt rename to apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/LinkDetection.kt diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt similarity index 100% rename from apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt rename to apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ui/MentionDetection.kt diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt new file mode 100644 index 0000000000..79c8d7b0f9 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveFeedTest.kt @@ -0,0 +1,198 @@ +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.UserProfile +import com.flipcash.services.models.VerifiableContactMethod +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.PointerType +import com.flipcash.services.models.chat.RosterSummary +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +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.assertTrue +import kotlin.time.Instant + +/** + * An archived chat leaves the list, every chip and the tab badge together, because they + * all read the one feed the delegate filters. It appears only in the archived feed. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatArchiveFeedTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + private val tipHex = "aabbccdd" + private val groupHex = "11223344" + + private fun entity(hex: String, type: ChatType) = ChatMetadataEntity( + chatIdHex = hex, + chatType = type.name, + lastActivityEpochMs = 2_000, + lastMessageId = 2, + isMember = true, + ) + + private fun message(id: Long) = ChatMessage( + messageId = id, + senderId = otherId, + content = listOf(MessageContent.Text("hi")), + timestamp = Instant.fromEpochSeconds(id), + unreadSeq = id, + ) + + private val selfUnread = ChatMember( + userId = selfId, + userProfile = UserProfile.Empty, + pointers = listOf( + MessagePointer(PointerType.READ, selfId, value = 1, timestamp = Instant.fromEpochSeconds(1_000)) + ), + ) + + // A tip DM renders only when the other member is addressable, so give them a verified phone. + private val tipOther = ChatMember( + userId = otherId, + userProfile = UserProfile.Empty.copy( + displayName = "Ada", + phoneNumber = VerifiableContactMethod("+15551234567", verified = true), + ), + pointers = emptyList(), + ) + + private class Fixture(val delegate: FeedSyncDelegate, val archived: MutableStateFlow>) + + private fun TestScope.fixture(initiallyArchived: Set): Fixture { + val entities = listOf(entity(tipHex, ChatType.TIP_DM), entity(groupHex, ChatType.GROUP)) + val members = mapOf(tipHex to listOf(selfUnread, tipOther), groupHex to listOf(selfUnread)) + + val messageDataSource = mockk(relaxed = true).also { + coEvery { it.getLatestVisibleByChat() } returns entities.associate { e -> e.chatIdHex to message(2) } + coEvery { it.getUnreadSeq(any(), any()) } answers { secondArg() } + } + val metadataDataSource = mockk(relaxed = true).also { source -> + every { source.observeAll() } returns flowOf(entities) + every { source.toMetadata(any(), any(), any()) } answers { + val row = firstArg() + ChatMetadata( + chatId = ChatId(row.chatIdHex), + type = ChatType.valueOf(row.chatType), + members = secondArg(), + lastMessage = thirdArg(), + lastActivity = Instant.fromEpochMilliseconds(row.lastActivityEpochMs), + title = row.title, + rosterSummary = RosterSummary(memberCount = row.memberCount, version = row.rosterVersion), + ) + } + } + val memberDataSource = mockk(relaxed = true).also { source -> + every { source.observeAll() } returns flowOf(members) + } + + val archived = MutableStateFlow(initiallyArchived) + val archiveStore = object : ChatArchiveStore by ChatArchiveStore.None { + override fun observeArchived() = archived + } + + val delegate = FeedSyncDelegate( + messagingController = mockk(relaxed = true), + chatController = mockk(relaxed = true), + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = ChatStateHolder().apply { update { it.copy(feedSyncState = FeedSyncState.Synced) } }, + userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + every { it.profile } returns null + }, + archiveStore = archiveStore, + ) + delegate.initialize(backgroundScope) + delegate.observeFeedFromDb() + runCurrent() + return Fixture(delegate, archived) + } + + private val listTypes = arrayOf(ChatType.TIP_DM, ChatType.GROUP) + + @Test + fun `an archived chat is not in the feed, the current feed or the badge`() = runTest { + val f = fixture(setOf(ChatId(groupHex))) + assertEquals(listOf(ChatId(tipHex)), f.delegate.feed(*listTypes).first().map { it.metadata.chatId }) + assertEquals(listOf(ChatId(tipHex)), f.delegate.currentFeed(*listTypes)!!.map { it.metadata.chatId }) + assertEquals(1, f.delegate.observeUnreadChatListCount().first()) + } + + @Test + fun `an archived chat is in the archived feed and nowhere else`() = runTest { + val f = fixture(setOf(ChatId(groupHex))) + assertEquals(listOf(ChatId(groupHex)), f.delegate.archivedFeed(*listTypes).first().map { it.metadata.chatId }) + assertEquals(listOf(ChatId(groupHex)), f.delegate.currentArchivedFeed(*listTypes)!!.map { it.metadata.chatId }) + } + + @Test + fun `unarchiving puts the chat back in the main feed`() = runTest { + val f = fixture(setOf(ChatId(groupHex))) + f.archived.value = emptySet() + runCurrent() + assertEquals(2, f.delegate.currentFeed(*listTypes)!!.size) + assertEquals(emptyList(), f.delegate.currentArchivedFeed(*listTypes)!!) + } + + @Test + fun `with nothing archived the feed is unchanged`() = runTest { + val f = fixture(emptySet()) + assertEquals(2, f.delegate.currentFeed(*listTypes)!!.size) + assertEquals(2, f.delegate.observeUnreadChatListCount().first()) + } + + @Test + fun `archiving the last main chat moves it in one emission`() = runTest { + val f = fixture(setOf(ChatId(groupHex))) + val emissions = mutableListOf() + backgroundScope.launch { f.delegate.feedWithArchived(*listTypes).collect { emissions += it } } + runCurrent() + + f.archived.value = setOf(ChatId(tipHex), ChatId(groupHex)) + runCurrent() + + assertEquals(2, emissions.size, "one emission before, one after: $emissions") + assertTrue(emissions.none { it.main.isEmpty() && it.archived.isEmpty() }) + assertEquals(emptyList(), emissions.last().main) + assertEquals(2, emissions.last().archived.size) + } + + @Test + fun `an unrelated state change does not re-emit the lists`() = runTest { + val f = fixture(emptySet()) + val emissions = mutableListOf() + backgroundScope.launch { f.delegate.feedWithArchived(*listTypes).collect { emissions += it } } + runCurrent() + + f.archived.value = setOf(ChatId("deadbeef")) + runCurrent() + + assertEquals(1, emissions.size) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt new file mode 100644 index 0000000000..97f6fc40b1 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatArchiveVectorTest.kt @@ -0,0 +1,123 @@ +package com.flipcash.shared.chat + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * `test-vectors/chat_archive.json`. The canonical copy lives in the orchestrator repo; this one is + * synced. A failure here is a real regression or a cross-platform decision that has to be made in + * the canonical fixture and re-synced to both platforms, never a local edit. + * + * Mention and reply detection stay native, so the fixture hands them in as inputs: these tests pin + * the rules, not the parsers. `"unknown"` means the device could not tell, which both rules treat + * as false. + */ +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class ChatArchiveVectorTest { + + @Test + fun `notify decisions match the cross-platform vectors`() { + val cases = fixture().getJSONArray("notify").objects() + // A fixture that failed to load reads as an empty list, and a loop over one passes. + assertTrue(cases.isNotEmpty(), "chat_archive.json loaded no notify vectors") + + for (case in cases) { + val push = case.getJSONObject("push") + val expect = case.getJSONObject("expect") + val name = case.getString("name") + + val actual = shouldNotify( + archived = push.getBoolean("archived"), + muted = push.getBoolean("muted"), + mentionsViewer = push.triState("mentionsViewer"), + repliesToViewer = push.triState("repliesToViewer"), + ) + assertEquals(expect.getBoolean("notify"), actual, "$name: ${case.optString("note")}") + // Nothing a message does unarchives the chat. No Android code path can write + // archive from a push, so the fixture's `expect.archived` is checked against the input. + assertEquals(push.getBoolean("archived"), expect.getBoolean("archived"), "$name: a push never changes archive") + } + } + + @Test + fun `list projections match the cross-platform vectors`() { + val cases = fixture().getJSONArray("list").objects() + assertTrue(cases.isNotEmpty(), "chat_archive.json loaded no list vectors") + + for (case in cases) { + val name = case.getString("name") + val note = case.optString("note") + val expect = case.getJSONObject("expect") + val entries = case.getJSONArray("chats").objects().map { chat -> + ChatListEntry( + id = chat.getString("id"), + isGroup = chat.getString("type") == "group", + lastActivityMs = chat.getLong("lastActivity"), + archived = chat.getBoolean("archived"), + muted = chat.getBoolean("muted"), + hidden = chat.getBoolean("hidden"), + unread = chat.unread(), + ) + } + + val actual = projectChatList(entries) + + assertEquals(expect.strings("main"), actual.main, "$name: main. $note") + assertEquals(expect.strings("unreadChip"), actual.unreadChip, "$name: unreadChip. $note") + assertEquals(expect.strings("groupsChip"), actual.groupsChip, "$name: groupsChip. $note") + assertEquals(expect.strings("archived"), actual.archived, "$name: archived. $note") + val counts = expect.getJSONObject("chipCounts") + assertEquals(counts.getInt("unread"), actual.unreadChipCount, "$name: unread chip count. $note") + assertEquals(counts.getInt("groups"), actual.groupsChipCount, "$name: groups chip count. $note") + val row = expect.getJSONObject("archivedRow") + assertEquals(row.getBoolean("visible"), actual.archivedRowVisible, "$name: archived row visible. $note") + assertEquals(row.getInt("count"), actual.archivedRowCount, "$name: archived row count. $note") + assertEquals(expect.getInt("tabBadge"), actual.tabBadge, "$name: tab badge. $note") + } + } + + @Test + fun `a filter selects the ids its chip lists`() { + val projection = projectChatList( + listOf( + ChatListEntry("a", isGroup = false, lastActivityMs = 3, archived = false, muted = false, hidden = false, unread = 0), + ChatListEntry("b", isGroup = true, lastActivityMs = 2, archived = false, muted = false, hidden = false, unread = 4), + ) + ) + assertEquals(listOf("a", "b"), projection.idsFor(ChatListFilter.All)) + assertEquals(listOf("b"), projection.idsFor(ChatListFilter.Unread)) + assertEquals(listOf("b"), projection.idsFor(ChatListFilter.Groups)) + } + + private fun JSONObject.triState(key: String): Boolean { + val value = get(key) + // true / false / "unknown": anything else is a typo in the fixture, not a case to guess at. + assertTrue(value is Boolean || value == "unknown", "$key must be true, false or \"unknown\", was $value") + return value == true + } + + private fun JSONObject.unread(): Int? { + val value = get("unread") + assertTrue(value is Int || value == "unknown", "unread must be an int or \"unknown\", was $value") + return value as? Int + } + + private fun JSONObject.strings(key: String): List = + getJSONArray(key).let { array -> (0 until array.length()).map { array.getString(it) } } + + private fun JSONArray.objects(): List = (0 until length()).map { getJSONObject(it) } + + private fun fixture(): JSONObject { + val json = javaClass.classLoader!! + .getResourceAsStream("chat_archive.json")!! + .bufferedReader().use { it.readText() } + return JSONObject(json) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt index 290dcdf25a..179da505f6 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorTeardownTest.kt @@ -9,6 +9,7 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.internal.ChatIdGenerator @@ -17,6 +18,7 @@ import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.GroupFeedDelegate import com.flipcash.shared.chat.internal.delegates.MessagingDelegate import com.getcode.utils.network.NetworkConnectivityListener import io.mockk.coEvery @@ -50,6 +52,8 @@ class ChatCoordinatorTeardownTest { private lateinit var messageDataSource: ChatMessageDataSource private lateinit var memberDataSource: ChatMemberDataSource private lateinit var draftStore: ChatDraftStore + private lateinit var archiveStore: ChatArchiveStore + private lateinit var groupFeedDelegate: GroupFeedDelegate private lateinit var coordinator: RealChatCoordinator @Before @@ -69,6 +73,8 @@ class ChatCoordinatorTeardownTest { messageDataSource = mockk(relaxed = true) memberDataSource = mockk(relaxed = true) draftStore = mockk(relaxed = true) + archiveStore = mockk(relaxed = true) + groupFeedDelegate = mockk(relaxed = true) val messagingController = mockk(relaxed = true) val stateHolder = ChatStateHolder() @@ -117,8 +123,9 @@ class ChatCoordinatorTeardownTest { userManager = userManager, networkObserver = mockk(relaxed = true), dispatchers = TestDispatchers(TestCoroutineScheduler()), - groupFeedDelegate = mockk(relaxed = true), + groupFeedDelegate = groupFeedDelegate, reactionsDelegate = mockk(relaxed = true), + archiveStore = archiveStore, ) } @@ -159,4 +166,34 @@ class ChatCoordinatorTeardownTest { coVerify(exactly = 1) { draftStore.clearAll() } } + + /** Like drafts: logout keeps the set (the database is per-account), erasure wipes it. */ + @Test + fun `clearCache drops the archive set, teardown keeps it`() = runTest { + coordinator.teardown() + coVerify(exactly = 0) { archiveStore.clearAll() } + + coordinator.clearCache() + coVerify(exactly = 1) { archiveStore.clearAll() } + } + + @Test + fun `a successful leave unarchives the chat`() = runTest { + val chatId = ChatId("aabbccdd") + coEvery { groupFeedDelegate.leave(chatId) } returns Result.success(Unit) + + coordinator.leave(chatId) + + coVerify(exactly = 1) { archiveStore.unarchive(chatId) } + } + + @Test + fun `a failed leave keeps the archive record`() = runTest { + val chatId = ChatId("aabbccdd") + coEvery { groupFeedDelegate.leave(chatId) } returns Result.failure(RuntimeException("nope")) + + coordinator.leave(chatId) + + coVerify(exactly = 0) { archiveStore.unarchive(any()) } + } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt index e5e7c45b8f..d71a63ea67 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt @@ -89,6 +89,21 @@ class MessagingPushDecryptionTest { assertEquals("yes", delegate.openPushedMessage(chatId, pushed, messageId = null)) } + @Test + fun `an opened reply keeps the message it cites`() = runTest { + val reply = MessageContent.Reply(repliedMessageId = 2, content = listOf(MessageContent.Text("yes"))) + val pushed = message(sealedFromPeer(reply)) + + val opened = delegate.openPushedChatMessage(chatId, pushed, messageId = null) + + assertEquals(listOf(reply), opened?.content) + } + + @Test + fun `a message that isn't encrypted is not opened`() = runTest { + assertNull(delegate.openPushedChatMessage(chatId, message(MessageContent.Text("hi")), messageId = null)) + } + @Test fun `an id-only push fetches the message when it isn't stored`() = runTest { val fetched = message(sealedFromPeer(MessageContent.Text("long one")), id = 9) diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RealChatArchiveStoreTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RealChatArchiveStoreTest.kt new file mode 100644 index 0000000000..819673697c --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/RealChatArchiveStoreTest.kt @@ -0,0 +1,77 @@ +package com.flipcash.shared.chat + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.sources.ChatArchiveDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.internal.RealChatArchiveStore +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class RealChatArchiveStoreTest { + + private val a = ChatId("aabbccdd") + private val b = ChatId("11223344") + private val store: ChatArchiveStore = RealChatArchiveStore(ChatArchiveDataSource()) { 1_000L } + + @Before + fun setUp() { + FlipcashDatabase.init(ApplicationProvider.getApplicationContext(), "chat-archive-store-test") + runBlocking { store.clearAll() } + } + + @After + fun tearDown() { + FlipcashDatabase.closeDb() + } + + @Test + fun `archive marks the chat archived and observeArchived reports it`() = runBlocking { + store.archive(a) + assertTrue(store.isArchived(a)) + assertFalse(store.isArchived(b)) + assertEquals(setOf(a), store.observeArchived().first()) + } + + @Test + fun `archiving twice keeps one record`() = runBlocking { + store.archive(a) + store.archive(a) + assertEquals(setOf(a), store.observeArchived().first()) + } + + @Test + fun `unarchive clears only that chat`() = runBlocking { + store.archive(a) + store.archive(b) + store.unarchive(a) + assertFalse(store.isArchived(a)) + assertTrue(store.isArchived(b)) + assertEquals(setOf(b), store.observeArchived().first()) + } + + @Test + fun `clearAll empties the set`() = runBlocking { + store.archive(a) + store.archive(b) + store.clearAll() + assertFalse(store.isArchived(a)) + assertEquals(emptySet(), store.observeArchived().first()) + } + + @Test + fun `None archives nothing`() = runBlocking { + assertEquals(emptySet(), ChatArchiveStore.None.observeArchived().first()) + assertFalse(ChatArchiveStore.None.isArchived(a)) + } +} diff --git a/apps/flipcash/shared/chat/src/test/resources/chat_archive.json b/apps/flipcash/shared/chat/src/test/resources/chat_archive.json new file mode 100644 index 0000000000..5f18c077f0 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/resources/chat_archive.json @@ -0,0 +1,671 @@ +{ + "notify": [ + { + "name": "plain-notifies", + "push": { + "archived": false, + "muted": false, + "kind": "text", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": true, + "archived": false + }, + "note": "Not archived, not muted: today's behavior, unchanged." + }, + { + "name": "plain-muted-silent", + "push": { + "archived": false, + "muted": true, + "kind": "text", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": false + }, + "note": "Not archived, muted: today's behavior, unchanged." + }, + { + "name": "plain-muted-mention-silent", + "push": { + "archived": false, + "muted": true, + "kind": "text", + "mentionsViewer": true, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": false + }, + "note": "Mute already ignores mentions. Archive must not be the thing that changes that." + }, + { + "name": "archived-text-silent", + "push": { + "archived": true, + "muted": false, + "kind": "text", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. Ordinary traffic in an archived chat is stored silently." + }, + { + "name": "archived-cash-silent", + "push": { + "archived": true, + "muted": false, + "kind": "cash", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. Cash does not break through on its own: in a tip DM most messages may be cash, which would make archive useless there." + }, + { + "name": "archived-media-silent", + "push": { + "archived": true, + "muted": false, + "kind": "media", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3." + }, + { + "name": "archived-system-silent", + "push": { + "archived": true, + "muted": false, + "kind": "system", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. Joins, renames and the like never break through." + }, + { + "name": "archived-mention-notifies", + "push": { + "archived": true, + "muted": false, + "kind": "text", + "mentionsViewer": true, + "repliesToViewer": false + }, + "expect": { + "notify": true, + "archived": true + }, + "note": "Rule 3. An @mention of the viewer is addressed to them." + }, + { + "name": "archived-media-caption-mention-notifies", + "push": { + "archived": true, + "muted": false, + "kind": "media", + "mentionsViewer": true, + "repliesToViewer": false + }, + "expect": { + "notify": true, + "archived": true + }, + "note": "Rule 3. A mention counts wherever the text is, including a caption." + }, + { + "name": "archived-reply-to-viewer-notifies", + "push": { + "archived": true, + "muted": false, + "kind": "reply", + "mentionsViewer": false, + "repliesToViewer": true + }, + "expect": { + "notify": true, + "archived": true + }, + "note": "Rule 3. A reply to one of the viewer's messages is addressed to them." + }, + { + "name": "archived-reply-to-someone-else-silent", + "push": { + "archived": true, + "muted": false, + "kind": "reply", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. A reply is not addressed to you just because it is a reply." + }, + { + "name": "archived-reply-unknown-target-silent", + "push": { + "archived": true, + "muted": false, + "kind": "reply", + "mentionsViewer": false, + "repliesToViewer": "unknown" + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. The replied-to message is not stored on this device. Unknown counts as not addressed, on both platforms." + }, + { + "name": "archived-mention-unresolved-silent", + "push": { + "archived": true, + "muted": false, + "kind": "text", + "mentionsViewer": "unknown", + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 3. A mention that does not resolve to a user counts as not addressed." + }, + { + "name": "archived-reply-and-mention-notifies", + "push": { + "archived": true, + "muted": false, + "kind": "reply", + "mentionsViewer": true, + "repliesToViewer": false + }, + "expect": { + "notify": true, + "archived": true + }, + "note": "Rule 3. Either condition is enough." + }, + { + "name": "archived-muted-text-silent", + "push": { + "archived": true, + "muted": true, + "kind": "text", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 2." + }, + { + "name": "archived-muted-cash-silent", + "push": { + "archived": true, + "muted": true, + "kind": "cash", + "mentionsViewer": false, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 2." + }, + { + "name": "archived-muted-mention-silent", + "push": { + "archived": true, + "muted": true, + "kind": "text", + "mentionsViewer": true, + "repliesToViewer": false + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 2. The case most likely to be got wrong: a mention breaks through archive but never through mute." + }, + { + "name": "archived-muted-reply-to-viewer-silent", + "push": { + "archived": true, + "muted": true, + "kind": "reply", + "mentionsViewer": false, + "repliesToViewer": true + }, + "expect": { + "notify": false, + "archived": true + }, + "note": "Rule 2." + } + ], + "list": [ + { + "name": "nothing-archived", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 3, + "archived": false, + "muted": false, + "hidden": false, + "unread": 2 + }, + { + "id": "b", + "type": "group", + "lastActivity": 2, + "archived": false, + "muted": false, + "hidden": false, + "unread": 0 + }, + { + "id": "c", + "type": "group", + "lastActivity": 1, + "archived": false, + "muted": false, + "hidden": false, + "unread": 1 + } + ], + "expect": { + "main": [ + "a", + "b", + "c" + ], + "unreadChip": [ + "a", + "c" + ], + "groupsChip": [ + "b", + "c" + ], + "archived": [], + "chipCounts": { + "unread": 2, + "groups": 1 + }, + "archivedRow": { + "visible": false, + "count": 0 + }, + "tabBadge": 2 + }, + "note": "No archive row when nothing is archived. Groups counts unread groups (c), not all groups." + }, + { + "name": "archived-leaves-main-chips-and-badge", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 3, + "archived": false, + "muted": false, + "hidden": false, + "unread": 2 + }, + { + "id": "b", + "type": "group", + "lastActivity": 2, + "archived": true, + "muted": false, + "hidden": false, + "unread": 4 + } + ], + "expect": { + "main": [ + "a" + ], + "unreadChip": [ + "a" + ], + "groupsChip": [], + "archived": [ + "b" + ], + "chipCounts": { + "unread": 1, + "groups": 0 + }, + "archivedRow": { + "visible": true, + "count": 1 + }, + "tabBadge": 1 + }, + "note": "Rule 1. The archived group is in no chip and not in the badge; it shows only in the Archived row's count." + }, + { + "name": "archived-muted-not-counted", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 3, + "archived": true, + "muted": true, + "hidden": false, + "unread": 9 + }, + { + "id": "b", + "type": "tipDm", + "lastActivity": 2, + "archived": true, + "muted": false, + "hidden": false, + "unread": 1 + }, + { + "id": "c", + "type": "tipDm", + "lastActivity": 1, + "archived": false, + "muted": false, + "hidden": false, + "unread": 0 + } + ], + "expect": { + "main": [ + "c" + ], + "unreadChip": [], + "groupsChip": [], + "archived": [ + "a", + "b" + ], + "chipCounts": { + "unread": 0, + "groups": 0 + }, + "archivedRow": { + "visible": true, + "count": 1 + }, + "tabBadge": 0 + }, + "note": "The Archived row counts unread chats that are not muted. A muted archived chat stays listed under Archived but counts toward nothing." + }, + { + "name": "muted-in-main-still-in-badge", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 2, + "archived": false, + "muted": true, + "hidden": false, + "unread": 1 + }, + { + "id": "b", + "type": "tipDm", + "lastActivity": 1, + "archived": false, + "muted": false, + "hidden": false, + "unread": 1 + } + ], + "expect": { + "main": [ + "a", + "b" + ], + "unreadChip": [ + "a", + "b" + ], + "groupsChip": [], + "archived": [], + "chipCounts": { + "unread": 2, + "groups": 0 + }, + "archivedRow": { + "visible": false, + "count": 0 + }, + "tabBadge": 2 + }, + "note": "Archive does not change how mute affects the main list: both apps count muted unread chats in the badge today, and still do." + }, + { + "name": "unknown-unread-counts-as-unread", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 2, + "archived": false, + "muted": false, + "hidden": false, + "unread": "unknown" + }, + { + "id": "b", + "type": "tipDm", + "lastActivity": 1, + "archived": true, + "muted": false, + "hidden": false, + "unread": "unknown" + } + ], + "expect": { + "main": [ + "a" + ], + "unreadChip": [ + "a" + ], + "groupsChip": [], + "archived": [ + "b" + ], + "chipCounts": { + "unread": 1, + "groups": 0 + }, + "archivedRow": { + "visible": true, + "count": 1 + }, + "tabBadge": 1 + }, + "note": "Android shows a dot when the count is not known yet. It is unread for every count here." + }, + { + "name": "hidden-and-archived-appears-nowhere", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 2, + "archived": true, + "muted": false, + "hidden": true, + "unread": 3 + }, + { + "id": "b", + "type": "tipDm", + "lastActivity": 1, + "archived": false, + "muted": false, + "hidden": false, + "unread": 0 + } + ], + "expect": { + "main": [ + "b" + ], + "unreadChip": [], + "groupsChip": [], + "archived": [], + "chipCounts": { + "unread": 0, + "groups": 0 + }, + "archivedRow": { + "visible": false, + "count": 0 + }, + "tabBadge": 0 + }, + "note": "Rule 5. Hidden wins. The only archived chat is hidden, so the Archived row is not shown at all." + }, + { + "name": "everything-archived", + "chats": [ + { + "id": "a", + "type": "tipDm", + "lastActivity": 2, + "archived": true, + "muted": false, + "hidden": false, + "unread": 0 + }, + { + "id": "b", + "type": "group", + "lastActivity": 1, + "archived": true, + "muted": false, + "hidden": false, + "unread": 1 + } + ], + "expect": { + "main": [], + "unreadChip": [], + "groupsChip": [], + "archived": [ + "a", + "b" + ], + "chipCounts": { + "unread": 0, + "groups": 0 + }, + "archivedRow": { + "visible": true, + "count": 1 + }, + "tabBadge": 0 + }, + "note": "The main list is empty but the Archived row must still render. On iOS that means the empty state cannot replace the List." + }, + { + "name": "order-is-last-activity-everywhere", + "chats": [ + { + "id": "old", + "type": "tipDm", + "lastActivity": 1, + "archived": false, + "muted": false, + "hidden": false, + "unread": 1 + }, + { + "id": "new", + "type": "tipDm", + "lastActivity": 5, + "archived": false, + "muted": false, + "hidden": false, + "unread": 1 + }, + { + "id": "arch-old", + "type": "tipDm", + "lastActivity": 2, + "archived": true, + "muted": false, + "hidden": false, + "unread": 0 + }, + { + "id": "arch-new", + "type": "tipDm", + "lastActivity": 4, + "archived": true, + "muted": false, + "hidden": false, + "unread": 0 + } + ], + "expect": { + "main": [ + "new", + "old" + ], + "unreadChip": [ + "new", + "old" + ], + "groupsChip": [], + "archived": [ + "arch-new", + "arch-old" + ], + "chipCounts": { + "unread": 2, + "groups": 0 + }, + "archivedRow": { + "visible": true, + "count": 0 + }, + "tabBadge": 2 + }, + "note": "Every list sorts by last activity, newest first, Archived included." + } + ] +} diff --git a/apps/flipcash/shared/notifications/build.gradle.kts b/apps/flipcash/shared/notifications/build.gradle.kts index d16d684bd4..6ea8741810 100644 --- a/apps/flipcash/shared/notifications/build.gradle.kts +++ b/apps/flipcash/shared/notifications/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { implementation(libs.coil3.core) implementation(libs.androidx.datastore) + implementation(libs.androidx.lifecycle.process) testImplementation(kotlin("test")) testImplementation(libs.robolectric) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index f256e8e50b..5e8bde3782 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -17,6 +17,8 @@ import androidx.core.app.NotificationManagerCompat import androidx.core.app.Person import androidx.core.graphics.drawable.IconCompat import androidx.core.net.toUri +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import coil3.SingletonImageLoader import coil3.request.ImageRequest import coil3.request.SuccessResult @@ -27,6 +29,7 @@ import com.flipcash.app.auth.AuthManager import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.contacts.ContactResolver import com.flipcash.app.core.util.Linkify +import com.flipcash.shared.chat.ChatArchiveStore import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.persistence.sources.ChatMetadataDataSource @@ -127,6 +130,12 @@ class NotificationService : FirebaseMessagingService(), @Inject lateinit var chatMetadataDataSource: ChatMetadataDataSource + @Inject + lateinit var archiveStore: ChatArchiveStore + + @Inject + lateinit var pushMessageClassifier: PushMessageClassifier + // TODO(firebase-messaging): 25.1.0 deprecated onNewToken in favor of FID-based onRegistered(). // Migrate once Firebase ships a stable guide and the backend accepts FID registration. // Tracking: https://github.com/firebase/firebase-android-sdk/issues/8087 @@ -158,6 +167,8 @@ class NotificationService : FirebaseMessagingService(), .takeIf { it.isNotEmpty() } ?.let { NotificationPayload.fromEncoded(it) } + // Planned without the archive inputs: they come from the per-user database, which a cold + // start from a push has not opened yet. The post path re-decides once authenticated. val actions = planPushHandling( title = title, body = body, @@ -226,13 +237,14 @@ class NotificationService : FirebaseMessagingService(), launch { try { if (syncContacts) launch { contactCoordinator.sync() } - if (post != null) { + val decided = post?.let { applyArchiveOrPost(it) } + if (decided != null) { val resolvedTitle = - applySubstitutions(post.title, post.payload?.titleSubstitutions.orEmpty()) - val resolvedBody = post.body?.let { - applySubstitutions(it, post.payload?.bodySubstitutions.orEmpty()) + applySubstitutions(decided.title, decided.payload?.titleSubstitutions.orEmpty()) + val resolvedBody = decided.body?.let { + applySubstitutions(it, decided.payload?.bodySubstitutions.orEmpty()) } - postNotification(resolvedTitle, resolvedBody, post.payload) + postNotification(resolvedTitle, resolvedBody, decided.payload, decided.silent) } } catch (e: Exception) { trace(tag = "NotificationService", message = "Failed to handle push", error = e) @@ -241,7 +253,54 @@ class NotificationService : FirebaseMessagingService(), } } - private suspend fun postNotification(title: String, body: String?, payload: NotificationPayload?) { + /** + * [applyArchive], falling back to [post] unchanged if it fails: a broken archive read must not + * drop the notification. An archived chat may then notify loudly, which is the safer miss. + */ + private suspend fun applyArchiveOrPost(post: PushAction.PostNotification): PushAction.PostNotification? = + try { + applyArchive(post) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + trace(tag = "NotificationService", message = "Couldn't apply archive to a push", error = e) + post + } + + /** + * Re-decides [post] for an archived chat. Runs after authentication, because the archive and + * the viewer's username, account id and stored messages all live in the per-user database and + * user state that sign-in opens; read earlier, an archived chat would look unarchived and + * post loudly. A chat that is not archived costs one primary-key read and keeps [post] as is. + */ + private suspend fun applyArchive(post: PushAction.PostNotification): PushAction.PostNotification? { + val chatId = (post.payload?.navigation as? NavigationTrigger.Chat.ById)?.chatId ?: return post + if (!archiveStore.isArchived(chatId)) return post + val classification = pushMessageClassifier.classify(chatId, post.payload.chatMetadata) + return planNotification( + title = post.title, + body = post.body, + payload = post.payload, + archived = true, + mentionsViewer = classification.mentionsViewer, + repliesToViewer = classification.repliesToViewer, + ) + } + + /** Whether any of the app's activities is visible, as iOS's foreground delegate sees it. */ + private fun isAppInForeground(): Boolean = + ProcessLifecycleOwner.get().lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED) + + /** + * @param silent post without sound, vibration or heads-up, and not at all while the app is in + * the foreground: an archived chat's message that is not addressed to the viewer. + */ + private suspend fun postNotification( + title: String, + body: String?, + payload: NotificationPayload?, + silent: Boolean = false, + ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED @@ -260,6 +319,9 @@ class NotificationService : FirebaseMessagingService(), } if (chatId != null && chatCoordinator.isActiveChat(chatId)) return + // iOS suppresses a passive push entirely while the app is in front; the chat list already + // shows the message. + if (silent && isAppInForeground()) return // Resolved once, here, because the tap target and the message style both turn on what // kind of chat this is and neither can take the payload's word for it. @@ -276,8 +338,18 @@ class NotificationService : FirebaseMessagingService(), val group = planNotificationGroup(payloadGroupKey = groupKey, isChat = isChat) val builder = NotificationCompat.Builder(this, channel.id) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + .apply { + if (silent) { + // androidx implements this by giving the notification GROUP_ALERT_SUMMARY (and + // the GROUP_KEY_SILENT group when it has none), so it never alerts whatever the + // channel's importance: no sound, vibration or heads-up, the shade only. The + // group summary below is GROUP_ALERT_CHILDREN, so it stays quiet too. + setSilent(true) + } else { + setPriority(NotificationCompat.PRIORITY_HIGH) + setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) + } + } .setSmallIcon(R.drawable.flipcash_logo) .setColor(getColor(R.color.notification_color)) .setAutoCancel(true) diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushAction.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushAction.kt index 21f1084148..3ef4df3790 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushAction.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushAction.kt @@ -33,10 +33,17 @@ sealed interface PushAction { /** Refresh the contact list. */ data object SyncContacts : PushAction - /** Post a user-visible notification. Absent for a silent push. */ + /** + * Post a user-visible notification. Absent for a silent push. + * + * [silent] is set for an archived chat's message that is not addressed to the viewer: it is + * posted without sound, vibration or heads-up while the app is in the background, and not at + * all while it is in the foreground (iOS delivers these as passive). + */ data class PostNotification( val title: String, val body: String?, val payload: NotificationPayload?, + val silent: Boolean = false, ) : PushAction } diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt index fac6fb9ffc..d6e7d14072 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushHandlingPlanner.kt @@ -3,6 +3,7 @@ package com.flipcash.app.notifications import com.flipcash.services.models.NavigationTrigger import com.flipcash.services.models.NotificationCategory import com.flipcash.services.models.NotificationPayload +import com.flipcash.shared.chat.shouldNotify /** * Decides what a received push should cause the app to do. @@ -13,18 +14,60 @@ import com.flipcash.services.models.NotificationPayload * @param title resolved push title, null for a data-only push * @param body resolved push body, may be null even for a visible push * @param payload decoded [NotificationPayload], null when absent or undecodable + * @param archived whether the viewer has this chat archived, read from local storage: the push + * payload carries no archive flag yet + * @param mentionsViewer whether the message text @mentions the viewer; false when it cannot be told + * @param repliesToViewer whether the message replies to one of the viewer's messages; false when + * the cited message is not stored locally */ fun planPushHandling( title: String?, body: String?, payload: NotificationPayload?, + archived: Boolean = false, + mentionsViewer: Boolean = false, + repliesToViewer: Boolean = false, ): List { + // The sync half is the same either way; a title adds the notification on top. Archived chats + // still sync: the message must reach the database either way. val sync = syncActionsFor(payload) + val post = planNotification(title, body, payload, archived, mentionsViewer, repliesToViewer) + return if (post == null) sync else sync + post +} - // The sync half is the same either way; a title adds the notification on top, unless the - // chat was silenced when the push was sent. - if (title == null || isMuted(payload)) return sync - return sync + PushAction.PostNotification(title, body, payload) +/** + * The notification half of [planPushHandling] on its own, for the caller that can only learn + * [archived], [mentionsViewer] and [repliesToViewer] later: the per-user database they are read + * from opens with authentication, which a cold start from a push has not done yet. + * + * - Muted when the push was sent: nothing, archived or not. + * - Otherwise, when [shouldNotify] allows it: a normal notification. + * - An archived chat's message not addressed to the viewer: a [PushAction.PostNotification.silent] + * one rather than none, as iOS delivers it passive. + * + * `null` for a data-only push ([title] null). + */ +fun planNotification( + title: String?, + body: String?, + payload: NotificationPayload?, + archived: Boolean = false, + mentionsViewer: Boolean = false, + repliesToViewer: Boolean = false, +): PushAction.PostNotification? { + if (title == null) return null + val muted = isMuted(payload) + val notify = shouldNotify( + archived = archived, + muted = muted, + mentionsViewer = mentionsViewer, + repliesToViewer = repliesToViewer, + ) + return when { + notify -> PushAction.PostNotification(title, body, payload) + muted -> null + else -> PushAction.PostNotification(title, body, payload, silent = true) + } } /** diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt new file mode 100644 index 0000000000..b018326ecd --- /dev/null +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/PushMessageClassifier.kt @@ -0,0 +1,97 @@ +package com.flipcash.app.notifications + +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.models.PushChatMetadata +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ui.detectMentions +import com.flipcash.shared.chat.ui.detectUrls +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +/** + * Whether a received message is addressed to the viewer: the two ways a push breaks through an + * archived chat's silence. An archived chat notifies only for a message addressed to the viewer. + */ +data class PushClassification( + val mentionsViewer: Boolean, + val repliesToViewer: Boolean, +) { + companion object { + /** Not addressed to the viewer, or not knowable. The silent default. */ + val None = PushClassification(mentionsViewer = false, repliesToViewer = false) + } +} + +/** + * Reads a push's message against what this device knows. + * + * Both answers default to false when the device cannot tell, so an archived chat stays silent: + * - the message is not in the payload and not yet stored, or has no readable text (cash, media + * without a caption), so there is nothing to scan; + * - the message is end-to-end encrypted and cannot be opened on this device; + * - the viewer has no username, so no handle can be theirs; + * - the replied-to message is not stored locally, so its author is unknown. + * + * Mentions are `@handle` text found by [detectMentions], compared case-insensitively with the + * viewer's username. A reply is a [MessageContent.Reply] whose cited message was sent by the + * viewer. + * + * An encrypted DM message is opened first through [ChatCoordinator.openPushedChatMessage], so a + * mention or reply inside it counts; the push payload's copy and a stored row may both be sealed. + */ +class PushMessageClassifier @Inject constructor( + private val userManager: UserManager, + private val messages: ChatMessageDataSource, + private val chatCoordinator: ChatCoordinator, +) { + + suspend fun classify(chatId: ChatId, metadata: PushChatMetadata?): PushClassification { + val received = metadata?.message + ?: metadata?.messageId?.let { messages.getMessage(chatId, it) } + ?: return PushClassification.None + val message = if (received.content.singleOrNull() is MessageContent.Encrypted) { + val opened = try { + chatCoordinator.openPushedChatMessage(chatId, received, messageId = null) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } + opened ?: return PushClassification.None + } else { + received + } + + return PushClassification( + mentionsViewer = mentionsViewer(message), + repliesToViewer = repliesToViewer(chatId, message), + ) + } + + private fun mentionsViewer(message: ChatMessage): Boolean { + val handle = userManager.profile?.username?.lowercase()?.takeIf { it.isNotEmpty() } ?: return false + return message.content.texts().any { text -> + detectMentions(text, detectUrls(text)).any { it.username == handle } + } + } + + private suspend fun repliesToViewer(chatId: ChatId, message: ChatMessage): Boolean { + val selfId = userManager.accountId ?: return false + val cited = message.content.filterIsInstance().firstOrNull() ?: return false + return messages.getMessage(chatId, cited.repliedMessageId)?.senderId == selfId + } + + /** Every piece of plain text a message carries: its body, a reply's nested body, a caption. */ + private fun List.texts(): List = flatMap { content -> + when (content) { + is MessageContent.Text -> listOf(content.text) + is MessageContent.Reply -> content.content.filterIsInstance().map { it.text } + is MessageContent.Media -> listOfNotNull(content.caption?.text) + else -> emptyList() + } + } +} diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt index 3973600c4f..f921a3da8f 100644 --- a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushHandlingPlannerTest.kt @@ -12,6 +12,7 @@ import com.getcode.solana.keys.Mint import kotlin.time.Instant import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import kotlin.test.assertTrue class PushHandlingPlannerTest { @@ -260,6 +261,107 @@ class PushHandlingPlannerTest { // endregion + // region An archived chat + + private val chatNav = NavigationTrigger.Chat.ById(ChatId("aabbccdd")) + + private fun post(actions: List) = + actions.filterIsInstance().singleOrNull() + + @Test + fun `an archived chat posts an ordinary message silently`() { + val actions = planPushHandling( + title = "Ada", body = "hi", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + archived = true, + ) + assertEquals(true, post(actions)?.silent) + } + + @Test + fun `an archived chat still syncs the message it was sent`() { + val actions = planPushHandling( + title = "Ada", body = "hi", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + archived = true, + ) + assertTrue(actions.any { it is PushAction.ApplyMessage }) + } + + @Test + fun `an archived chat posts normally when the message mentions the viewer`() { + val actions = planPushHandling( + title = "Ada", body = "hi @me", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + archived = true, + mentionsViewer = true, + ) + assertEquals(false, post(actions)?.silent) + } + + @Test + fun `an archived chat posts normally when the message replies to the viewer`() { + val actions = planPushHandling( + title = "Ada", body = "yes", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + archived = true, + repliesToViewer = true, + ) + assertEquals(false, post(actions)?.silent) + } + + @Test + fun `a muted and archived chat posts nothing even for a mention`() { + val actions = planPushHandling( + title = "Ada", body = "hi @me", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage(), muted = true)), + archived = true, + mentionsViewer = true, + repliesToViewer = true, + ) + assertNull(post(actions)) + assertTrue(actions.any { it is PushAction.ApplyMessage }) + } + + @Test + fun `a muted and archived chat posts nothing for an ordinary message`() { + val actions = planPushHandling( + title = "Ada", body = "hi", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage(), muted = true)), + archived = true, + ) + assertNull(post(actions)) + } + + @Test + fun `a chat that is not archived posts normally as before`() { + val actions = planPushHandling( + title = "Ada", body = "hi", + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + ) + assertEquals(false, post(actions)?.silent) + } + + @Test + fun `the notification half re-decides an archived chat on its own`() { + val p = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())) + assertEquals(PushAction.PostNotification("Ada", "hi", p, silent = true), planNotification("Ada", "hi", p, archived = true)) + assertEquals(PushAction.PostNotification("Ada", "hi", p), planNotification("Ada", "hi", p, archived = true, mentionsViewer = true)) + assertEquals(post(planPushHandling("Ada", "hi", p)), planNotification("Ada", "hi", p)) + } + + @Test + fun `a data-only push for an archived chat posts nothing`() { + val actions = planPushHandling( + title = null, body = null, + payload = payload(navigation = chatNav, chatMetadata = chatMetadata(inlinedMessage())), + archived = true, + ) + assertNull(post(actions)) + } + + // endregion + companion object { private val TEST_MINT = Mint.usdc } diff --git a/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt new file mode 100644 index 0000000000..44c25f7bcb --- /dev/null +++ b/apps/flipcash/shared/notifications/src/test/kotlin/com/flipcash/app/notifications/PushMessageClassifierTest.kt @@ -0,0 +1,163 @@ +package com.flipcash.app.notifications + +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.models.PushChatMetadata +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.Mint +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.time.Instant + +/** + * The two ways a message breaks through an archived chat's silence, and the default when the device cannot tell. Runs under Robolectric because + * mention detection reads `android.util.Patterns` for the links it must skip. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36]) +class PushMessageClassifierTest { + + private val chatId = ChatId("aabbccdd") + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + + private val messages = mockk(relaxed = true) + private val userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + every { it.profile } returns UserProfile.Empty.copy(username = "Bmc") + } + private val chatCoordinator = mockk(relaxed = true) + private val classifier = PushMessageClassifier(userManager, messages, chatCoordinator) + + private val sealed = MessageContent.Encrypted(scheme = 1, nonce = byteArrayOf(1), ciphertext = byteArrayOf(2)) + + private fun message(vararg content: MessageContent, id: Long = 10) = ChatMessage( + messageId = id, + senderId = otherId, + content = content.toList(), + timestamp = Instant.fromEpochSeconds(1), + unreadSeq = 1, + ) + + private fun metadata(message: ChatMessage?, messageId: Long? = null) = PushChatMetadata( + sendingUserId = otherId, + chatType = ChatType.TIP_DM, + message = message, + messageId = messageId, + ) + + @Test + fun `a mention of the viewer is recognised whatever its case`() = runTest { + val result = classifier.classify(chatId, metadata(message(MessageContent.Text("hey @BMC look")))) + assertEquals(PushClassification(mentionsViewer = true, repliesToViewer = false), result) + } + + @Test + fun `a mention of someone else is not a mention of the viewer`() = runTest { + val result = classifier.classify(chatId, metadata(message(MessageContent.Text("hey @ada")))) + assertEquals(PushClassification.None, result) + } + + @Test + fun `an email address is not a mention`() = runTest { + val result = classifier.classify(chatId, metadata(message(MessageContent.Text("write bmc@bmc.dev")))) + assertEquals(PushClassification.None, result) + } + + @Test + fun `a mention in a media caption is recognised`() = runTest { + val media = MessageContent.Media(items = emptyList(), caption = MessageContent.Text("for @bmc")) + val result = classifier.classify(chatId, metadata(message(media))) + assertEquals(PushClassification(mentionsViewer = true, repliesToViewer = false), result) + } + + @Test + fun `a reply to the viewer's own message is a reply to the viewer`() = runTest { + coEvery { messages.getMessage(chatId, 7) } returns message(MessageContent.Text("mine"), id = 7).copy(senderId = selfId) + val reply = message(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("agreed")))) + val result = classifier.classify(chatId, metadata(reply)) + assertEquals(PushClassification(mentionsViewer = false, repliesToViewer = true), result) + } + + @Test + fun `a reply to someone else's message is not a reply to the viewer`() = runTest { + coEvery { messages.getMessage(chatId, 7) } returns message(MessageContent.Text("theirs"), id = 7) + val reply = message(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("agreed")))) + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(reply))) + } + + @Test + fun `a reply whose target is not stored locally counts as not addressed to the viewer`() = runTest { + coEvery { messages.getMessage(chatId, 7) } returns null + val reply = message(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("agreed")))) + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(reply))) + } + + @Test + fun `no message text at all is unknown and so false`() = runTest { + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(message = null, messageId = null))) + val cash = MessageContent.Cash(intentId = listOf(9), amount = Fiat(quarks = 100L), mint = Mint.usdc) + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(message(cash)))) + } + + @Test + fun `a message that is only stored, not inlined, is read from the database`() = runTest { + coEvery { messages.getMessage(chatId, 10) } returns message(MessageContent.Text("hi @bmc")) + val result = classifier.classify(chatId, metadata(message = null, messageId = 10)) + assertEquals(PushClassification(mentionsViewer = true, repliesToViewer = false), result) + } + + @Test + fun `an encrypted message is opened before it is read for a mention`() = runTest { + val pushed = message(sealed) + coEvery { chatCoordinator.openPushedChatMessage(chatId, pushed, null) } returns + message(MessageContent.Text("hey @bmc")) + val result = classifier.classify(chatId, metadata(pushed)) + assertEquals(PushClassification(mentionsViewer = true, repliesToViewer = false), result) + } + + @Test + fun `an encrypted reply to the viewer is a reply to the viewer once opened`() = runTest { + val pushed = message(sealed) + coEvery { chatCoordinator.openPushedChatMessage(chatId, pushed, null) } returns + message(MessageContent.Reply(repliedMessageId = 7, content = listOf(MessageContent.Text("agreed")))) + coEvery { messages.getMessage(chatId, 7) } returns message(MessageContent.Text("mine"), id = 7).copy(senderId = selfId) + val result = classifier.classify(chatId, metadata(pushed)) + assertEquals(PushClassification(mentionsViewer = false, repliesToViewer = true), result) + } + + @Test + fun `an encrypted message that cannot be opened is not addressed to the viewer`() = runTest { + val pushed = message(sealed) + coEvery { chatCoordinator.openPushedChatMessage(chatId, pushed, null) } returns null + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(pushed))) + } + + @Test + fun `an encrypted message whose opening throws is not addressed to the viewer`() = runTest { + val pushed = message(sealed) + coEvery { chatCoordinator.openPushedChatMessage(chatId, pushed, null) } throws + IllegalStateException("decryption failed") + assertEquals(PushClassification.None, classifier.classify(chatId, metadata(pushed))) + } + + @Test + fun `a viewer with no username cannot be mentioned`() = runTest { + every { userManager.profile } returns UserProfile.Empty + val result = classifier.classify(chatId, metadata(message(MessageContent.Text("hey @bmc")))) + assertEquals(PushClassification.None, result) + } +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/41.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/41.json new file mode 100644 index 0000000000..c95a41202c --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/41.json @@ -0,0 +1,960 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "84c3e15ca2c7f659186651cf26a0a0eb", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "chat_archive", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `archived_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "archivedAt", + "columnName": "archived_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '84c3e15ca2c7f659186651cf26a0a0eb')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index dc60110d2b..ce48e94ad3 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.asStateFlow import com.flipcash.app.persistence.converters.ChatTypeConverters import com.flipcash.app.persistence.converters.TokenTypeConverters import com.flipcash.app.persistence.dao.BlockedUserDao +import com.flipcash.app.persistence.dao.ChatArchiveDao import com.flipcash.app.persistence.dao.ChatDraftDao import com.flipcash.app.persistence.dao.ChatMemberDao import com.flipcash.app.persistence.dao.ChatMessageDao @@ -30,6 +31,7 @@ import com.flipcash.app.persistence.dao.MessageDao import com.flipcash.app.persistence.dao.TokenDao import com.flipcash.app.persistence.dao.UserProfileDao import com.flipcash.app.persistence.entities.BlockedUserEntity +import com.flipcash.app.persistence.entities.ChatArchiveEntity import com.flipcash.app.persistence.entities.ChatDraftEntity import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMessageEntity @@ -61,6 +63,7 @@ import com.getcode.utils.subByteArray ChatMessageEntity::class, ChatMemberEntity::class, ChatDraftEntity::class, + ChatArchiveEntity::class, BlockedUserEntity::class, UserProfileEntity::class, LinkPreviewEntity::class, @@ -111,8 +114,11 @@ import com.getcode.utils.subByteArray AutoMigration(from = 37, to = 38), // chat_metadata.creator_hex (nullable), use_e2ee (default 0) AutoMigration(from = 38, to = 39), // link_previews table AutoMigration(from = 39, to = 40, spec = FlipcashDatabase.Migration39To40::class), + // The chat_archive table. An AutoMigration rather than the fallbackToDestructiveMigration() + // below, for the same reason as chat_draft: an archive set cannot be re-fetched. + AutoMigration(from = 40, to = 41), // chat_archive table ], - version = 40, + version = 41, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -125,6 +131,7 @@ abstract class FlipcashDatabase : RoomDatabase() { abstract fun chatMessageDao(): ChatMessageDao abstract fun chatMemberDao(): ChatMemberDao abstract fun chatDraftDao(): ChatDraftDao + abstract fun chatArchiveDao(): ChatArchiveDao abstract fun blockedUserDao(): BlockedUserDao abstract fun userProfileDao(): UserProfileDao abstract fun linkPreviewDao(): LinkPreviewDao diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDao.kt new file mode 100644 index 0000000000..0fffcbe1e2 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDao.kt @@ -0,0 +1,31 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.flipcash.app.persistence.entities.ChatArchiveEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ChatArchiveDao { + + /** IGNORE, not REPLACE: archiving an archived chat is a no-op and keeps its timestamp. */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertIfAbsent(archive: ChatArchiveEntity) + + @Query("SELECT chat_id_hex FROM chat_archive") + fun observeIds(): Flow> + + @Query("SELECT chat_id_hex FROM chat_archive WHERE chat_id_hex = :chatIdHex") + suspend fun find(chatIdHex: String): String? + + @Query("SELECT archived_at FROM chat_archive WHERE chat_id_hex = :chatIdHex") + suspend fun archivedAt(chatIdHex: String): Long? + + @Query("DELETE FROM chat_archive WHERE chat_id_hex = :chatIdHex") + suspend fun delete(chatIdHex: String) + + @Query("DELETE FROM chat_archive") + suspend fun deleteAll() +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatArchiveEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatArchiveEntity.kt new file mode 100644 index 0000000000..7f94bc180b --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatArchiveEntity.kt @@ -0,0 +1,29 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.PrimaryKey + +/** + * A chat the viewer has archived. Presence of the row is the whole state. + * + * Its own table rather than a `chat_metadata` column, on purpose: `chat_metadata.is_hidden` is + * server-owned and `ChatMetadataDao.updateServerOwnedFields` rewrites it on every feed sync, so a + * client-owned flag stored there would be reset by the next sync. A row here is never touched by + * the metadata round-trip. + * + * A row is only ever written by the user's own archive action and removed by their unarchive, by + * leaving the group, or by account erasure; no incoming message, mention or payment clears it. + * + * Added by an `AutoMigration` rather than left to `fallbackToDestructiveMigration()`: an archive + * set cannot be re-fetched until the server stores it, so a version bump that dropped the file + * would lose it. + */ +@Entity(tableName = "chat_archive") +data class ChatArchiveEntity( + @PrimaryKey + @ColumnInfo(name = "chat_id_hex") + val chatIdHex: String, + @ColumnInfo(name = "archived_at") + val archivedAt: Long, +) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDaoTest.kt new file mode 100644 index 0000000000..a556c6fb07 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatArchiveDaoTest.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatArchiveEntity +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +class ChatArchiveDaoTest { + + private lateinit var db: FlipcashDatabase + private lateinit var dao: ChatArchiveDao + + @Before + fun setUp() { + db = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + FlipcashDatabase::class.java, + ).allowMainThreadQueries().build() + dao = db.chatArchiveDao() + } + + @After + fun tearDown() { + db.close() + } + + @Test + fun `insert then find returns the id`() = runTest { + dao.insertIfAbsent(ChatArchiveEntity("aa", archivedAt = 1_000L)) + assertEquals("aa", dao.find("aa")) + } + + @Test + fun `find is null for a chat that was never archived`() = runTest { + assertNull(dao.find("zz")) + } + + @Test + fun `archiving twice keeps the first timestamp`() = runTest { + dao.insertIfAbsent(ChatArchiveEntity("aa", archivedAt = 1_000L)) + dao.insertIfAbsent(ChatArchiveEntity("aa", archivedAt = 9_000L)) + assertEquals(1_000L, dao.archivedAt("aa")) + } + + @Test + fun `delete removes only the named chat`() = runTest { + dao.insertIfAbsent(ChatArchiveEntity("aa", 1_000L)) + dao.insertIfAbsent(ChatArchiveEntity("bb", 1_000L)) + dao.delete("bb") + assertEquals("aa", dao.find("aa")) + assertNull(dao.find("bb")) + } + + @Test + fun `deleteAll empties the table`() = runTest { + dao.insertIfAbsent(ChatArchiveEntity("aa", 1_000L)) + dao.insertIfAbsent(ChatArchiveEntity("bb", 1_000L)) + dao.deleteAll() + assertEquals(emptyList(), dao.observeIds().first()) + } + + @Test + fun `observeIds emits the current set and re-emits on change`() = runTest { + assertEquals(emptyList(), dao.observeIds().first()) + dao.insertIfAbsent(ChatArchiveEntity("aa", 1_000L)) + assertEquals(listOf("aa"), dao.observeIds().first()) + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSource.kt new file mode 100644 index 0000000000..b8d39c61f6 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSource.kt @@ -0,0 +1,43 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ChatArchiveEntity +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +/** + * The `chat_archive` table, keyed by chat id hex. + * + * No database means nothing archived rather than an error, like the other data sources here. One + * difference from [ChatMetadataDataSource.observeAll], which returns `emptyFlow()` in that case: + * [observeIds] returns a flow of an empty set. It is combined with the metadata and member flows + * in the feed, and `combine` emits nothing until every input has emitted once, so an empty flow + * would hold the whole feed back. + */ +@Singleton +class ChatArchiveDataSource @Inject constructor() { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + fun observeIds(): Flow> = + db?.chatArchiveDao()?.observeIds()?.map { it.toSet() } ?: flowOf(emptySet()) + + suspend fun isArchived(chatIdHex: String): Boolean = + db?.chatArchiveDao()?.find(chatIdHex) != null + + suspend fun archive(chatIdHex: String, now: Long) { + db?.chatArchiveDao()?.insertIfAbsent(ChatArchiveEntity(chatIdHex, archivedAt = now)) + } + + suspend fun unarchive(chatIdHex: String) { + db?.chatArchiveDao()?.delete(chatIdHex) + } + + suspend fun clear() { + db?.chatArchiveDao()?.deleteAll() + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSourceTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSourceTest.kt new file mode 100644 index 0000000000..2a6e9a1e98 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/ChatArchiveDataSourceTest.kt @@ -0,0 +1,30 @@ +package com.flipcash.app.persistence.sources + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +/** + * No Robolectric here, so only the no-database paths run; the read/write paths are covered over a + * real database by the archive store's test. + */ +class ChatArchiveDataSourceTest { + + private val source = ChatArchiveDataSource() + + @Test + fun `with no database observeIds is an empty set, not an empty flow`() = runTest { + // An empty *flow* would stall a combine() that includes it; an empty *set* lets it emit. + assertEquals(emptySet(), source.observeIds().first()) + } + + @Test + fun `with no database nothing is archived and writes are no-ops`() = runTest { + source.archive("aa", now = 1L) + source.unarchive("aa") + source.clear() + assertFalse(source.isArchived("aa")) + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt new file mode 100644 index 0000000000..ee51dff908 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/FilterChip.kt @@ -0,0 +1,78 @@ +package com.getcode.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.theme.White +import com.getcode.theme.White10 + +/** + * A chip that can be selected, for choosing one filter among a few. [CodeChip] is a label with no + * click or selected state, so this wraps it rather than changing what every other chip means. + * + * Selection is exposed to accessibility (`Role.RadioButton`, selected), because the colour change + * is the only visual cue. Selected is a white tint at 16% over whatever is behind the chip, with the + * label in the main text colour; unselected has no fill and uses the secondary text colour. Both + * carry a 1dp white outline at 10%, as iOS draws them. A + * solid white chip was the brightest thing on a dark screen. A [count] of null or 0 draws nothing: + * the chips show a number only when there is something in the filter. + */ +@Composable +fun FilterChip( + label: String, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + count: Int? = null, +) { + val backgroundColor by animateColorAsState( + targetValue = if (selected) SelectedChipTint else Color.Transparent, + label = "filterChipBackground", + ) + val contentColor by animateColorAsState( + targetValue = if (selected) CodeTheme.colors.textMain else CodeTheme.colors.textSecondary, + label = "filterChipContent", + ) + CodeChip( + modifier = modifier + .clip(CircleShape) + // Drawn rather than passed as CodeChip's background so the animated fill only redraws. + .drawBehind { drawRect(backgroundColor) } + .border(1.dp, White10, CircleShape) + .selectable(selected = selected, role = Role.RadioButton, onClick = onClick), + backgroundColor = Color.Transparent, + contentPadding = PaddingValues( + horizontal = CodeTheme.dimens.grid.x3, + // 7dp to match iOS's selectable chip; no grid step lands on it. + vertical = 7.dp, + ), + ) { + Text( + text = label, + style = CodeTheme.typography.textSmall, + color = contentColor, + ) + if (count != null && count > 0) { + Text( + text = count.toString(), + style = CodeTheme.typography.textSmall, + color = contentColor, + ) + } + } +} + +// No theme token sits at 16%; White10 and White20 bracket it. +private val SelectedChipTint = White.copy(alpha = 0.16f) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/glass/FloatingGlass.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/glass/FloatingGlass.kt new file mode 100644 index 0000000000..327e581d3e --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/glass/FloatingGlass.kt @@ -0,0 +1,69 @@ +package com.getcode.ui.components.glass + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.hazeBlur + +/** + * Layout shared by the chrome that floats over the bottom of the screen: the navigation bar and the + * toast that rises out of it. + */ +object FloatingChrome { + /** + * The inset from each screen edge. The design insets the bar 24pt (node 10642:1325), which keeps + * each tab's pill wider than tall; 25 is the nearest fixed step. A toast takes the same inset, so + * it is never wider than the bar. + */ + val horizontalInset: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x5 +} + +/** + * The frosted "liquid glass" surface of the floating chrome, clipped to [shape]. + * + * A wide blur of what lies beneath, plus a strong tint toward a grey lifted off the (near-black) + * background at high alpha. Over empty or dark content it reads as a light frosted glass sitting + * above the screen; over the vibrant cards the high alpha mutes their colour toward that same grey. + * A faint bright rim gives the glass its edge. + * + * [hazeState] must belong to a `hazeSource` that does not contain this surface, or there is nothing + * to blur. With no [hazeState] the surface is a near-opaque fill of the same tint. + */ +@Composable +fun Modifier.floatingGlass( + hazeState: HazeState?, + shape: Shape = CircleShape, +): Modifier { + val backdrop = CodeTheme.colors.background + val glassTint = lerp(backdrop, Color.White, 0.18f) + val fill = if (hazeState != null) { + // The HazeBlurStyle builder is not a @Composable scope, so theme reads are hoisted above it. + val liquidGlass = HazeBlurStyle { + blurRadius(32.dp) + backgroundColor(backdrop) + colorEffects(listOf(HazeColorEffect.tint(glassTint.copy(alpha = 0.72f)))) + } + Modifier.hazeBlur(HazeInput.Sources(hazeState), liquidGlass) + } else { + Modifier.background(glassTint.copy(alpha = 0.9f), shape) + } + // `clip` must precede `hazeBlur` to bound the blur to the shape, not its bounding box. + return this + .clip(shape) + .then(fill) + .border(CodeTheme.dimens.border, Color.White.copy(alpha = 0.08f), shape) +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt deleted file mode 100644 index 1f7f1e4f63..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/snack/SnackData.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.getcode.ui.components.snack - -import androidx.compose.material.SnackbarDuration -import androidx.compose.material.SnackbarHostState -import androidx.compose.material.SnackbarResult - -data class SnackData( - val message: String, - val actionLabel: String? = null, - val duration: SnackbarDuration = SnackbarDuration.Short -) - -suspend fun SnackbarHostState.showSnackbar(data: SnackData): SnackbarResult { - return showSnackbar( - message = data.message, - actionLabel = data.actionLabel, - duration = data.duration - ) -} \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt new file mode 100644 index 0000000000..f55564aa6e --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToast.kt @@ -0,0 +1,132 @@ +package com.getcode.ui.components.toast + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.getcode.theme.CodeTheme +import com.getcode.theme.R +import com.getcode.theme.White +import com.getcode.ui.components.glass.floatingGlass +import dev.chrisbanes.haze.HazeState + +/** + * The app's toast: a pill on the navigation bar's glass, holding an optional [icon], the [message], + * and an optional action in its own pill. iOS draws the same design. + * + * It fills the width it is given unless [hugContent], when it wraps the message instead. Give it + * [FloatingChrome.horizontalInset][com.getcode.ui.components.glass.FloatingChrome.horizontalInset] + * on each side so it is never wider than the bar. [hazeState] is the bar's; without one the glass + * falls back to the bar's near-opaque fill. + * + * With no [actionLabel] nothing here takes pointer input, so taps pass through to what is beneath. + * Show it through [FloatingToastHost], which owns its motion, lifetime and swipe to dismiss. + */ +@Composable +fun FloatingToast( + message: String, + modifier: Modifier = Modifier, + icon: ImageVector? = null, + actionLabel: String? = null, + onAction: () -> Unit = {}, + hugContent: Boolean = false, + hazeState: HazeState? = null, +) { + Row( + modifier = modifier + .then(if (hugContent) Modifier else Modifier.fillMaxWidth()) + .floatingGlass(hazeState) + .padding( + PaddingValues( + start = 16.dp, + // With an action, its 48dp touch area sets the height instead. + top = if (actionLabel != null) 0.dp else 8.dp, + bottom = if (actionLabel != null) 0.dp else 8.dp, + end = if (actionLabel != null) 8.dp else 16.dp, + ) + ), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + tint = CodeTheme.colors.textMain, + modifier = Modifier.size(18.dp), + ) + } + Text( + text = message, + style = CodeTheme.typography.textSmall.copy(fontFamily = AvenirMedium, fontWeight = FontWeight.Medium), + color = CodeTheme.colors.textMain, + modifier = Modifier + // Weighted children measure after the fixed ones, so a long message wraps rather + // than squeezing the action; without fill, a hugging toast stays the message's width. + .weight(1f, fill = !hugContent) + // The action pill's vertical padding, so a toast is the same height with or without one. + .padding(vertical = 7.dp) + .semantics { liveRegion = LiveRegionMode.Polite }, + ) + if (actionLabel != null) { + val interactionSource = remember { MutableInteractionSource() } + // The touch area is the toast's full height; the ripple stays on the visible pill. + Box( + modifier = Modifier + .heightIn(min = 48.dp) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onAction, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = actionLabel, + style = CodeTheme.typography.textSmall, + fontWeight = FontWeight.Bold, + color = CodeTheme.colors.textMain, + modifier = Modifier + .clip(CircleShape) + .background(ActionFill) + .indication(interactionSource, ripple()) + .padding(horizontal = 16.dp, vertical = 7.dp), + ) + } + } + } +} + +// No theme token sits at 12%; White10 is the nearest below it. +private val ActionFill = White.copy(alpha = 0.12f) + +// The theme maps Medium to the Demi file, so name the Medium cut directly: iOS sets this message in +// Avenir Next Medium. +private val AvenirMedium = FontFamily(Font(R.font.avenir_next_medium, FontWeight.Medium)) diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt new file mode 100644 index 0000000000..3e0a4fcaf8 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/toast/FloatingToastHost.kt @@ -0,0 +1,276 @@ +package com.getcode.ui.components.toast + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.EnterExitState +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.snap +import androidx.compose.animation.core.spring +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.SnackbarDuration +import androidx.compose.material.SnackbarResult +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalAccessibilityManager +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.dismiss +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.getcode.ui.components.glass.FloatingChrome +import dev.chrisbanes.haze.HazeState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** One toast on screen in a [FloatingToastHost]. */ +@Immutable +class FloatingToastData internal constructor( + val message: String, + val icon: ImageVector?, + val actionLabel: String?, + val hugContent: Boolean, + val passThrough: Boolean, + val duration: SnackbarDuration, + // Toasts that share a slot swap in place rather than replaying the entrance. + internal val slot: Long, + private val result: CompletableDeferred, +) { + fun performAction() { + result.complete(SnackbarResult.ActionPerformed) + } + + fun dismiss() { + result.complete(SnackbarResult.Dismissed) + } + + internal suspend fun await(): SnackbarResult = result.await() +} + +/** + * The toasts shown by a [FloatingToastHost], one at a time. A new toast replaces the one on screen + * rather than queueing behind it, and the replaced toast's [show] returns + * [SnackbarResult.Dismissed]. + */ +@Stable +class FloatingToastHostState { + var current: FloatingToastData? by mutableStateOf(null) + private set + + /** + * Whether something that must not be drawn over (a sheet, a modal, a prompt) is on screen above + * the host. Covering dismisses the toast on screen, and a toast shown while covered is dismissed + * without appearing: a toast left on top would take that layer's taps, and one hidden beneath it + * would time out unseen with its action unreachable. + */ + var isCovered: Boolean = false + set(value) { + field = value + if (value) current?.dismiss() + } + + private var nextSlot = 0L + + /** + * Shows [message] until it times out, is dismissed or replaced, or its action is performed, and + * returns which. Cancelling the caller takes the toast down. + * + * @param hugContent wrap the message rather than fill the bar's width. + * @param passThrough take no pointer input at all, so taps reach what is beneath; there is no + * swipe to dismiss and the toast should have no action. + * @param inPlace when a toast is already showing, swap its content without replaying the + * entrance, for a message that updates as the user acts (a countdown). + */ + suspend fun show( + message: String, + icon: ImageVector? = null, + actionLabel: String? = null, + hugContent: Boolean = false, + passThrough: Boolean = false, + inPlace: Boolean = false, + duration: SnackbarDuration = SnackbarDuration.Short, + ): SnackbarResult { + if (isCovered) return SnackbarResult.Dismissed + val previous = current + val data = FloatingToastData( + message = message, + icon = icon, + actionLabel = actionLabel, + hugContent = hugContent, + passThrough = passThrough, + duration = duration, + slot = if (inPlace && previous != null) previous.slot else nextSlot++, + result = CompletableDeferred(), + ) + // Set before the previous toast resolves, so the host never sees an empty frame between them. + current = data + previous?.dismiss() + try { + return data.await() + } finally { + if (current === data) current = null + } + } +} + +/** + * The app's toast host, provided at the root so a toast floats with the navigation bar. Null where + * no root host is installed (previews, tests). + */ +val LocalFloatingToastHost = staticCompositionLocalOf { null } + +/** + * Shows [hostState]'s current toast as a [FloatingToast], inset like the navigation bar so it is + * never wider than it. Place it at the bottom of the screen, above the bar, and compose it *before* + * the bar so the bar draws over it. + * + * A toast slides up by its own height while fading in, and leaves the same way in reverse. A + * [SnackbarDuration.Short] toast stays 4 seconds, or longer when the accessibility settings ask for + * more time, and a swipe down dismisses it unless it passes through. + * + * [hazeState] must not belong to a `hazeSource` that contains this host. + */ +@Composable +fun FloatingToastHost( + hostState: FloatingToastHostState, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, +) { + val current = hostState.current + val accessibilityManager = LocalAccessibilityManager.current + LaunchedEffect(current) { + if (current == null) return@LaunchedEffect + val millis = when (current.duration) { + SnackbarDuration.Short -> 4_000L + SnackbarDuration.Long -> 10_000L + SnackbarDuration.Indefinite -> return@LaunchedEffect + } + val timeout = accessibilityManager?.calculateRecommendedTimeoutMillis( + originalTimeoutMillis = millis, + containsIcons = current.icon != null, + containsText = true, + containsControls = current.actionLabel != null, + ) ?: millis + delay(timeout) + current.dismiss() + } + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = FloatingChrome.horizontalInset), + contentAlignment = Alignment.BottomCenter, + ) { + AnimatedContent( + targetState = current, + contentKey = { it?.slot }, + transitionSpec = { + // The motion lives on the progress below, so entering and leaving share one curve. + EnterTransition.None togetherWith ExitTransition.None using + // Content draws unclipped, so the container has nothing to animate. + SizeTransform(clip = false) { _, _ -> snap() } + }, + contentAlignment = Alignment.BottomCenter, + label = "floatingToast", + ) { data -> + if (data == null) { + // An empty slot, so the outgoing toast can animate away. + Box(Modifier.fillMaxWidth()) + return@AnimatedContent + } + // Driven by the enter/exit transition itself, so AnimatedContent keeps the outgoing toast + // until it has finished leaving. + val progress = transition.animateFloat( + transitionSpec = { + spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, + ) + }, + label = "floatingToastProgress", + ) { state -> if (state == EnterExitState.Visible) 1f else 0f } + + val motion = Modifier.graphicsLayer { + val p = progress.value + alpha = p + translationY = (1f - p) * size.height + } + SwipeToDismiss(data, motion) { + FloatingToast( + message = data.message, + icon = data.icon, + actionLabel = data.actionLabel, + onAction = data::performAction, + hugContent = data.hugContent, + hazeState = hazeState, + ) + } + } + } +} + +@Composable +private fun SwipeToDismiss( + data: FloatingToastData, + modifier: Modifier, + content: @Composable () -> Unit, +) { + if (data.passThrough) { + // No pointer or dismiss handlers at all: taps go to whatever is beneath. + Box(modifier) { content() } + return + } + val offset = remember(data.slot) { Animatable(0f) } + val scope = rememberCoroutineScope() + val threshold = with(LocalDensity.current) { 32.dp.toPx() } + Box( + modifier = modifier + .graphicsLayer { translationY = offset.value } + .semantics { + dismiss { + data.dismiss() + true + } + } + .pointerInput(data) { + detectVerticalDragGestures( + onDragEnd = { + if (offset.value > threshold) { + data.dismiss() + } else { + scope.launch { offset.animateTo(0f) } + } + }, + onDragCancel = { scope.launch { offset.animateTo(0f) } }, + ) { change, dragAmount -> + change.consume() + scope.launch { offset.snapTo((offset.value + dragAmount).coerceAtLeast(0f)) } + } + }, + ) { + content() + } +} + +// How far below its resting place a rising toast starts: enough to tuck its squashed bottom edge +// behind the bar it grows out of. diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt index dcc228544f..221e24d415 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeScaffold.kt @@ -6,8 +6,6 @@ import androidx.compose.material.DrawerDefaults import androidx.compose.material.FabPosition import androidx.compose.material.Scaffold import androidx.compose.material.ScaffoldState -import androidx.compose.material.SnackbarHost -import androidx.compose.material.SnackbarHostState import androidx.compose.material.rememberScaffoldState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -38,11 +36,6 @@ fun CodeScaffold( scaffoldState: ScaffoldState = rememberScaffoldState(), topBar: @Composable () -> Unit = {}, bottomBar: @Composable () -> Unit = {}, - snackbarHost: @Composable (SnackbarHostState) -> Unit = { - SnackbarHost(it) { data -> - CodeSnackbar(snackbarData = data) - } - }, floatingActionButton: @Composable () -> Unit = {}, floatingActionButtonPosition: FabPosition = FabPosition.End, isFloatingActionButtonDocked: Boolean = false, @@ -67,7 +60,8 @@ fun CodeScaffold( // exists to avoid. topBar = if (isOverlay) ({}) else topBar, bottomBar = if (isOverlay) ({}) else bottomBar, - snackbarHost = snackbarHost, + // Toasts go through the app's root FloatingToastHost (LocalFloatingToastHost). + snackbarHost = {}, floatingActionButton = floatingActionButton, floatingActionButtonPosition = floatingActionButtonPosition, isFloatingActionButtonDocked = isFloatingActionButtonDocked, diff --git a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt b/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt deleted file mode 100644 index bf6cd72e8f..0000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/theme/CodeSnackbar.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.getcode.ui.theme - -import androidx.compose.material.Snackbar -import androidx.compose.material.SnackbarData -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.getcode.theme.BrandMuted -import com.getcode.theme.CodeTheme -import com.getcode.theme.Success - -@Composable -fun CodeSnackbar( - snackbarData: SnackbarData, - modifier: Modifier = Modifier, - actionOnNewLine: Boolean = false, - shape: Shape = CodeTheme.shapes.small, - backgroundColor: Color = BrandMuted, - contentColor: Color = CodeTheme.colors.onBackground, - actionColor: Color = Success, - elevation: Dp = 6.dp -) { - Snackbar( - snackbarData = snackbarData, - modifier = modifier, - actionOnNewLine = actionOnNewLine, - shape = shape, - backgroundColor = backgroundColor, - contentColor = contentColor, - actionColor = actionColor, - elevation = elevation - ) -} \ No newline at end of file diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/FilterChipTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/FilterChipTest.kt new file mode 100644 index 0000000000..4573e02a42 --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/FilterChipTest.kt @@ -0,0 +1,64 @@ +package com.getcode.ui.components + +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.getcode.theme.DesignSystem +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +@RunWith(RobolectricTestRunner::class) +class FilterChipTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun `selected state is exposed to accessibility`() { + composeTestRule.setContent { + DesignSystem { + FilterChip(label = "Unread", selected = true, onClick = {}) + } + } + composeTestRule.onNodeWithText("Unread", useUnmergedTree = false).assertIsSelected() + } + + @Test + fun `an unselected chip is not selected`() { + composeTestRule.setContent { + DesignSystem { + FilterChip(label = "Groups", selected = false, onClick = {}) + } + } + composeTestRule.onNodeWithText("Groups").assertIsNotSelected() + } + + @Test + fun `clicking invokes onClick`() { + var clicks = 0 + composeTestRule.setContent { + DesignSystem { + FilterChip(label = "All", selected = false, onClick = { clicks++ }) + } + } + composeTestRule.onNodeWithText("All").performClick() + assertEquals(1, clicks) + } + + @Test + fun `a count is drawn after the label and a zero count is not drawn`() { + composeTestRule.setContent { + DesignSystem { + FilterChip(label = "Unread", selected = false, onClick = {}, count = 5) + FilterChip(label = "Groups", selected = false, onClick = {}, count = 0) + } + } + composeTestRule.onNodeWithText("5").assertExists() + composeTestRule.onNodeWithText("0").assertDoesNotExist() + } +} diff --git a/ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt b/ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt new file mode 100644 index 0000000000..d1995ca34f --- /dev/null +++ b/ui/components/src/test/kotlin/com/getcode/ui/components/toast/FloatingToastHostStateTest.kt @@ -0,0 +1,78 @@ +package com.getcode.ui.components.toast + +import androidx.compose.material.SnackbarResult +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The host draws above the nav content, so a sheet, the bill or a bottom-bar prompt covers it. A + * toast left showing over one of those takes its taps, so covering has to take the toast down and + * keep new ones from appearing until it is uncovered. + */ +class FloatingToastHostStateTest { + + @Test + fun `covering the host dismisses the toast on screen`() = runTest { + val host = FloatingToastHostState() + val shown = async(start = CoroutineStart.UNDISPATCHED) { host.show("Chat archived") } + assertEquals("Chat archived", host.current?.message) + + host.isCovered = true + + assertEquals(SnackbarResult.Dismissed, shown.await()) + assertNull(host.current) + } + + @Test + fun `a toast shown while covered never appears`() = runTest { + val host = FloatingToastHostState() + host.isCovered = true + + assertEquals(SnackbarResult.Dismissed, host.show("3 taps away")) + assertNull(host.current) + } + + @Test + fun `an in-place update while covered does not bring the toast back`() = runTest { + val host = FloatingToastHostState() + val first = async(start = CoroutineStart.UNDISPATCHED) { + host.show("4 taps away", inPlace = true) + } + host.isCovered = true + assertEquals(SnackbarResult.Dismissed, first.await()) + + assertEquals(SnackbarResult.Dismissed, host.show("3 taps away", inPlace = true)) + assertNull(host.current) + } + + @Test + fun `uncovering lets the next toast show`() = runTest { + val host = FloatingToastHostState() + host.isCovered = true + host.isCovered = false + + val shown = async(start = CoroutineStart.UNDISPATCHED) { host.show("Chat archived") } + assertEquals("Chat archived", host.current?.message) + host.current!!.performAction() + + assertEquals(SnackbarResult.ActionPerformed, shown.await()) + } + + @Test + fun `a new toast replaces the one on screen`() = runTest { + val host = FloatingToastHostState() + val first = async(start = CoroutineStart.UNDISPATCHED) { host.show("Chat archived") } + val second = async(start = CoroutineStart.UNDISPATCHED) { host.show("Chat archived again") } + + assertEquals(SnackbarResult.Dismissed, first.await()) + assertTrue(second.isActive) + assertEquals("Chat archived again", host.current?.message) + host.current!!.dismiss() + second.await() + } +}