diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 046bb46b4..7d3613b46 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1299,6 +1299,11 @@ No chats yet Start a chat in Flipcash, then share again. %1$d attached + Jump to latest message + + Jump to latest message, %1$d unread + Jump to latest message, %1$d unread + Add a message\u2026 Receive Tips From Everyone Add your name to receive tips diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 0793cd452..eeb9b3219 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -185,6 +185,7 @@ import kotlin.time.Instant import java.util.Optional import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -424,6 +425,25 @@ internal class ChatViewModel @AssistedInject constructor( val unreadBoundary: UnreadBoundary = UnreadBoundary.Resolving, /** How many stored messages sit past the boundary, which bounds the walk that opens at it. */ val unreadWalkBudget: Int = 0, + /** + * Whether the jump-to-bottom badge is still holding the number the chat opened with (see + * [unreadBadgeCount]). Starts true; released by the first drag or fling the viewer makes + * on the transcript after it has settled ([Event.UserScrolledTranscript]), or by their own + * message being confirmed ([Event.OwnMessageSent]). Programmatic scrolls never release it. + */ + val unreadBadgeFrozen: Boolean = true, + /** + * The divider's count as it stood at open, kept apart from [unreadBoundary] so the badge + * holds it when the boundary is later cleared (sending under [UnreadDividerLifetime.UntilSend]). + */ + val unreadBadgeOpenCount: Int = 0, + /** + * The [arrivalCutoff] recorded once the opening load has landed: the later of the read + * pointer and the newest inbound id stored, or [NO_INBOUND_AT_OPEN]; null until then, while + * nothing counts as an arrival. Inbound messages past it are the arrivals the frozen badge + * adds to the divider's count. + */ + val newestInboundIdAtOpen: Long? = null, /** * True while the delete confirmation is up. * @@ -996,6 +1016,18 @@ internal class ChatViewModel @AssistedInject constructor( /** The chat's unread boundary, read once when its id became known. */ data class UnreadBoundaryResolved(val boundary: UnreadBoundary, val walkBudget: Int) : Event + + /** + * The opening load has landed and the arrival cutoff ([arrivalCutoff]) was read after it. + * Inbound messages past it are the arrivals the frozen badge adds. + */ + data class ArrivalCutoffRecorded(val cutoff: Long) : Event + + /** The viewer dragged or flung the settled transcript; releases the badge's freeze. */ + data object UserScrolledTranscript : Event + + /** The server confirmed a message the viewer sent; the read pointer has moved past everything. */ + data object OwnMessageSent : Event data object JumpConsumed : Event } @@ -1047,6 +1079,23 @@ internal class ChatViewModel @AssistedInject constructor( .flatMapLatest { chatCoordinator.observeOtherReadPointer(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + /** + * The jump-to-bottom badge: see [unreadBadgeCount]. Its live term counts inbound messages past + * the viewer's READ pointer, the one the read reporter advances, so once released the badge + * falls as rows are read and cannot disagree with it. + */ + @OptIn(ExperimentalCoroutinesApi::class) + val unreadBelowCount = stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatId -> + unreadBadgeCounts( + state = stateFlow, + live = chatCoordinator.observeUnreadBelow(chatId), + arrivalsAfter = { afterId -> chatCoordinator.observeInboundAfter(chatId, afterId) }, + ) + } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 0) + /** * Re-runs the transcript mapping when the windows change, so a message resolved under the * defaults (both open, before the flags arrive) narrows as soon as the server's answer lands. @@ -1763,7 +1812,8 @@ internal class ChatViewModel @AssistedInject constructor( chatId, resources.getString(R.string.message_cash_link_thanks), messageId, - ).onFailure { trace("claim reply failed to send - ${it.localizedMessage}") } + ).onSuccess { dispatchEvent(Event.OwnMessageSent) } + .onFailure { trace("claim reply failed to send - ${it.localizedMessage}") } } } @@ -1791,6 +1841,23 @@ internal class ChatViewModel @AssistedInject constructor( * the gate says so, off [State.readsFromOutside], rather than here, where the balance that * decides it has not arrived yet. */ + /** + * Reads the badge's arrival cutoff once the first successful load of the newest page has + * landed, so history that load stores is never an arrival. A message that arrives during the + * load is not counted either. + * The reads wait for the account and database, so -1 only ever means "ready, with no read + * pointer and no inbound stored". + */ + private suspend fun recordArrivalCutoff(chatId: ChatId) { + val cutoff = withContext(dispatchers.IO) { + arrivalCutoff( + readPointer = chatCoordinator.selfReadPointer(chatId), + newestInbound = chatCoordinator.newestInboundId(chatId), + ) + } + dispatchEvent(Event.ArrivalCutoffRecorded(cutoff)) + } + private suspend fun openTranscript(chatId: ChatId) { when (val hydration = chatCoordinator.hydrateChat(chatId)) { ChatHydration.Stored, @@ -1859,6 +1926,13 @@ internal class ChatViewModel @AssistedInject constructor( featuredGroups.peek(chatId) ?.takeIf { chatCoordinator.observeMetadata(chatId).first() == null } ?.let { dispatchEvent(Event.OnGroupResolved(ChatMembership(metadata = it, isMember = null))) } + // Subscribed before any load can start, so the first one to land is not missed. + // Whichever path loads the newest page (this open, the feed sync, an outside + // reader's load) ends the wait; a load that fails or is skipped does not. + viewModelScope.launch(start = CoroutineStart.UNDISPATCHED) { + chatCoordinator.newestPageLoads().first { it == chatId } + recordArrivalCutoff(chatId) + } viewModelScope.launch { openTranscript(chatId) } // A feed row has no cover, so fetch it now rather than when the group's // profile opens: the profile would otherwise draw an empty cover through GetChat. @@ -3056,6 +3130,7 @@ internal class ChatViewModel @AssistedInject constructor( } sent .onSuccess { + dispatchEvent(Event.OwnMessageSent) trace("message sent successfully") analytics.track(ChatEvents.sentMessage(chatType.analytics, null)) } @@ -3084,11 +3159,17 @@ internal class ChatViewModel @AssistedInject constructor( viewModelScope.launch { if (content.photoBody() != null) { chatCoordinator.retryMedia(chatId, pendingId) - .onSuccess { trace("retry photo sent successfully") } + .onSuccess { + dispatchEvent(Event.OwnMessageSent) + trace("retry photo sent successfully") + } .onFailure { trace("retry photo failed - ${it.localizedMessage}") } } else { chatCoordinator.retryMessage(chatId, pendingId, listOf(content)) - .onSuccess { trace("retry message sent successfully") } + .onSuccess { + dispatchEvent(Event.OwnMessageSent) + trace("retry message sent successfully") + } .onFailure { trace("retry message failed - ${it.localizedMessage}") } } } @@ -3701,7 +3782,15 @@ internal class ChatViewModel @AssistedInject constructor( } Event.JumpConsumed -> { state -> state.copy(jumpTarget = null, jumpBudget = null) } is Event.UnreadBoundaryResolved -> { state -> - state.copy(unreadBoundary = event.boundary, unreadWalkBudget = event.walkBudget) + state.copy( + unreadBoundary = event.boundary, + unreadWalkBudget = event.walkBudget, + unreadBadgeOpenCount = (event.boundary as? UnreadBoundary.At)?.count ?: 0, + ) + } + is Event.ArrivalCutoffRecorded -> { state -> state.copy(newestInboundIdAtOpen = event.cutoff) } + Event.UserScrolledTranscript, Event.OwnMessageSent -> { state -> + state.copy(unreadBadgeFrozen = false) } } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt new file mode 100644 index 000000000..8b5b4c6c3 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadBadge.kt @@ -0,0 +1,67 @@ +package com.flipcash.app.messenger.internal + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map + +/** + * [ChatViewModel.State.newestInboundIdAtOpen] for a chat with neither an inbound message nor a read + * pointer at open: below any real message id, so every inbound message that comes later counts as + * an arrival. + */ +internal const val NO_INBOUND_AT_OPEN = -1L + +/** + * The id past which an inbound message counts as an arrival after the open: the later of the + * viewer's [readPointer] and the [newestInbound] id stored, each null when absent. The pointer + * matters when the cache is empty at open: history that loads afterwards sits at or below it and + * is not new, where [newestInbound] alone would count all of it. + */ +internal fun arrivalCutoff(readPointer: Long?, newestInbound: Long?): Long = + maxOf(readPointer ?: NO_INBOUND_AT_OPEN, newestInbound ?: NO_INBOUND_AT_OPEN) + +/** + * What the jump-to-bottom badge shows. + * + * While [frozen] it opens at the unread divider's own count and holds there, whatever the read + * pointer does underneath, rising by one for each inbound message that arrives after the open. + * Once released it follows [live], the inbound messages past the viewer's read pointer. A viewer + * outside the group has no read pointer to follow and sees nothing. + */ +internal fun unreadBadgeCount( + frozen: Boolean, + openCount: Int, + arrivalsSinceOpen: Int, + live: Int, + outsideGroup: Boolean, +): Int = when { + outsideGroup -> 0 + frozen -> openCount + arrivalsSinceOpen + else -> live +} + +/** + * The badge over a chat's [state]. [live] is the inbound count past the read pointer; + * [arrivalsAfter] counts inbound messages past an id, and is asked for the id that was newest at + * open. + */ +@OptIn(ExperimentalCoroutinesApi::class) +internal fun unreadBadgeCounts( + state: Flow, + live: Flow, + arrivalsAfter: (afterId: Long) -> Flow, +): Flow { + val arrivals = state.map { it.newestInboundIdAtOpen } + .distinctUntilChanged() + .flatMapLatest { openedAt -> if (openedAt == null) flowOf(0) else arrivalsAfter(openedAt) } + val inputs = state + .map { Triple(it.unreadBadgeFrozen, it.unreadBadgeOpenCount, it.isOutsideGroup) } + .distinctUntilChanged() + return combine(inputs, arrivals, live) { (frozen, openCount, outside), arrived, liveCount -> + unreadBadgeCount(frozen, openCount, arrived, liveCount, outside) + }.distinctUntilChanged() +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 2dcf2624c..cc699102b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -96,6 +96,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val mediaProgress = viewModel.mediaSendProgress.collectAsStateWithLifecycle() val mediaProgressOf = remember(mediaProgress) { { mediaProgress.value } } val otherReadPointer by viewModel.otherReadPointer.collectAsStateWithLifecycle(null) + val unreadBelowCount by viewModel.unreadBelowCount.collectAsStateWithLifecycle() val navigator = LocalCodeNavigator.current val uriHandler = LocalUriHandler.current val context = LocalContext.current @@ -515,6 +516,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { webLinkPreviewsEnabled = webLinkPreviewsEnabled, onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) }, topBarBottom = barHeight, + unreadBelowCount = unreadBelowCount, + onUserScrolled = { viewModel.dispatchEvent(ChatViewModel.Event.UserScrolledTranscript) }, + hazeState = hazeState, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt index fbfada687..069f7ee9c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatBottomBar.kt @@ -79,6 +79,10 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.offset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Density +import androidx.compose.runtime.Stable import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.util.lerp @@ -105,7 +109,6 @@ import com.getcode.ui.utils.rememberKeyboardController import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.hazeBlur -import dev.chrisbanes.haze.blur.materials.HazeMaterials /** The ramp's shape: clear for most of its height, then a late rise into the background. */ private val ComposerFadeCurve = CubicBezierEasing(0.42f, 0.18f, 0.17f, 1.04f) @@ -197,7 +200,7 @@ internal fun UserControlBottomBar( val focusRequester = remember { FocusRequester() } var buttonHeight by remember { mutableStateOf(0.dp) } var mentionListHeight by remember { mutableStateOf(0.dp) } - val material = HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) + val material = chatGlassMaterial() // The attach menu and the camera are one surface over the keyboard, which stays up behind it. var attachPhase by remember { mutableStateOf(AttachPhase.Collapsed) } @@ -281,8 +284,8 @@ internal fun UserControlBottomBar( // doesn't move anything, since nothing here animates on its own. val keyboardOpen = keyboardOpenFraction() val hasComposer = state.typingConstraints.enabled - val compactInset = CodeTheme.dimens.staticGrid.x6.coerceAtLeast(CodeTheme.dimens.inset) - val sideInset = if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset + val sideInset = composerSideInset(hasComposer, keyboardOpen) + val composerInsets = rememberComposerInsets() val restingDrop = if (hasComposer) lerp(CodeTheme.dimens.staticGrid.x2, 0.dp, keyboardOpen) else 0.dp // The part of the navigation bar's inset the keyboard isn't covering, so the bar only sinks // into it once the keyboard has cleared it. @@ -412,7 +415,7 @@ internal fun UserControlBottomBar( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = sideInset), + .composerSidePadding(composerInsets, hasComposer), ) { if (canType) { val chips = remember(state.composerPhotos, state.uploadStates) { @@ -649,6 +652,84 @@ private fun DeactivatedChatBottomBar() { } } +/** + * The margin either side of the bar. Compact at rest once there is a composer, opening out to the + * normal inset as the keyboard ([keyboardOpen], 0 to 1) comes up. The `$` button's trailing edge sits + * this far from the screen edge. Composition-phase: the accessory cards use it; the input row and the + * jump-to-bottom button use [ComposerInsets] instead, which is read while laying out. + */ +@Composable +internal fun composerSideInset(hasComposer: Boolean, keyboardOpen: Float): Dp { + val compactInset = CodeTheme.dimens.staticGrid.x6.coerceAtLeast(CodeTheme.dimens.inset) + return if (hasComposer) lerp(compactInset, CodeTheme.dimens.inset, keyboardOpen) else CodeTheme.dimens.inset +} + +/** + * The composer's side margin, readable in the layout phase. The bar's input row and the + * jump-to-bottom button both place from this one value, so a keyboard animation frame moves them + * together instead of each picking it up through its own recomposition. + */ +@Stable +internal class ComposerInsets( + private val ime: WindowInsets, + private val imeSource: WindowInsets, + private val imeTarget: WindowInsets, + private val compact: Dp, + private val normal: Dp, +) { + /** The margin in px, with the keyboard's animation state read at the call site's phase. */ + fun Density.sidePx(hasComposer: Boolean): Int { + if (!hasComposer) return normal.roundToPx() + val now = ime.getBottom(this) + val full = maxOf(imeSource.getBottom(this), imeTarget.getBottom(this), now) + val open = if (full <= 0) 0f else now.toFloat() / full + return lerp(compact, normal, open).roundToPx() + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun rememberComposerInsets(): ComposerInsets { + val ime = WindowInsets.ime + val source = WindowInsets.imeAnimationSource + val target = WindowInsets.imeAnimationTarget + val compact = CodeTheme.dimens.staticGrid.x6.coerceAtLeast(CodeTheme.dimens.inset) + val normal = CodeTheme.dimens.inset + return remember(ime, source, target, compact, normal) { ComposerInsets(ime, source, target, compact, normal) } +} + +/** Horizontal padding of [ComposerInsets.sidePx], resolved while laying out. */ +internal fun Modifier.composerSidePadding(insets: ComposerInsets, hasComposer: Boolean): Modifier = + layout { measurable, constraints -> + val side = with(insets) { sidePx(hasComposer) } + val placeable = measurable.measure(constraints.offset(horizontal = -2 * side)) + layout((placeable.width + 2 * side).coerceIn(constraints.minWidth, constraints.maxWidth), placeable.height) { + placeable.place(side, 0) + } + } + +/** + * Moves a bottom-end aligned child so it is centred over the `$` button's column: the bar's margin + * plus half the difference between the column and the child, or just + * [plainInset] when no `$` sits beside a composer. + */ +internal fun Modifier.alignedToDollarColumn( + insets: ComposerInsets, + dollarColumn: Boolean, + columnWidth: Dp, + plainInset: Dp, +): Modifier = layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val end = if (dollarColumn) { + with(insets) { sidePx(hasComposer = true) } + (columnWidth.roundToPx() - placeable.width) / 2 + } else { + plainInset.roundToPx() + } + layout(placeable.width, placeable.height) { + placeable.place(if (layoutDirection == LayoutDirection.Rtl) end else -end, 0) + } +} + /** * How far open the keyboard is, from 0 (down) to 1 (fully up), following its show and hide * animations frame by frame. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatGlass.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatGlass.kt new file mode 100644 index 000000000..8b03b40c0 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ChatGlass.kt @@ -0,0 +1,15 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.runtime.Composable +import com.getcode.theme.CodeTheme +import dev.chrisbanes.haze.blur.HazeBlurStyle +import dev.chrisbanes.haze.blur.materials.HazeMaterials + +/** + * The one glass material for the chat's floating surfaces: the composer's message bar, the `$` button, + * the join-requests card and the jump-to-bottom button. Layer `ChatInputDefaults.ContainerColor` and + * `ChatInputDefaults.RimBrush` over it as the composer does. + */ +@Composable +internal fun chatGlassMaterial(): HazeBlurStyle = + HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt index dfd58a3f8..9cf96d486 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JoinRequestsAnchor.kt @@ -41,7 +41,6 @@ import com.getcode.ui.theme.CodeButton import dev.chrisbanes.haze.HazeInput import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.hazeBlur -import dev.chrisbanes.haze.blur.materials.HazeMaterials import kotlinx.coroutines.delay import kotlin.time.Clock import kotlin.time.Duration.Companion.minutes @@ -65,10 +64,7 @@ internal fun JoinRequestsAnchor( val shape = CodeTheme.shapes.medium val ground = if (hazeState != null) { Modifier - .hazeBlur( - HazeInput.Sources(hazeState), - HazeMaterials.ultraThin(containerColor = CodeTheme.colors.background), - ) + .hazeBlur(HazeInput.Sources(hazeState), chatGlassMaterial()) .background(ChatInputDefaults.ContainerColor) } else { Modifier.background(Color.White.copy(alpha = 0.1f)) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt new file mode 100644 index 000000000..becc2843c --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottom.kt @@ -0,0 +1,149 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.layout +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.features.messenger.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.Badge +import com.getcode.ui.components.chat.ChatInputDefaults +import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.blur.hazeBlur + +/** Past this many rows from the newest, a tap jumps rather than animates (from the old Flipchat). */ +internal const val JumpToBottomAnimateMaxRows = 100 + +internal val JumpToBottomButtonSize = 48.dp +private val BadgeOutline = 2.dp + +/** Whether a tap at [firstVisibleIndex] rows from the newest jumps instead of animating. */ +internal fun jumpsToBottom(firstVisibleIndex: Int): Boolean = firstVisibleIndex > JumpToBottomAnimateMaxRows + +/** Scrolls [this] to the newest row: a jump from far away, an animation from near. */ +internal suspend fun LazyListState.scrollToNewest() { + if (jumpsToBottom(firstVisibleItemIndex)) scrollToItem(0) else animateScrollToItem(0) +} + +/** + * A 48dp glass circle with a down chevron, and the unread count at its top-trailing corner. + * + * Hidden by [AnimatedVisibility] rather than alpha, so it is not tappable while it is away. + * [hazeState] must belong to a `hazeSource` that does not contain this button. + */ +@Composable +internal fun JumpToBottomButton( + visible: Boolean, + unreadCount: Int, + hazeState: HazeState?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val background = CodeTheme.colors.background + val glass = chatGlassMaterial() + // Held so the pill doesn't read "0" while it scales out. + var lastCount by remember { mutableIntStateOf(unreadCount) } + LaunchedEffect(unreadCount) { if (unreadCount > 0) lastCount = unreadCount } + // The effect runs a frame late; show the new count straight away rather than the old one. + val shownCount = if (unreadCount > 0) unreadCount else lastCount + + AnimatedVisibility( + modifier = modifier, + visible = visible, + enter = fadeIn(tween(200)) + scaleIn(tween(200), initialScale = 0.8f), + exit = fadeOut(tween(150)) + scaleOut(tween(150), targetScale = 0.8f), + ) { + Box { + Box( + modifier = Modifier + .size(JumpToBottomButtonSize) + .clip(CircleShape) + .then( + if (hazeState != null) { + Modifier + .hazeBlur(HazeInput.Sources(hazeState), glass) + .background(ChatInputDefaults.ContainerColor) + } else { + Modifier.background(Color.White.copy(alpha = 0.1f)) + } + ) + .border(CodeTheme.dimens.border, ChatInputDefaults.RimBrush, CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = if (unreadCount > 0) { + pluralStringResource(R.plurals.action_jumpToBottomUnread, unreadCount, unreadCount) + } else { + stringResource(R.string.action_jumpToBottom) + }, + tint = Color.White, + modifier = Modifier.size(28.dp), + ) + } + AnimatedVisibility( + modifier = Modifier + .align(Alignment.TopEnd) + // The pill, outline included, is centred on the top-trailing corner of the button's + // bounds: placed half its own size out, without changing the size it reports. + .layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val dx = if (layoutDirection == LayoutDirection.Rtl) -placeable.width / 2 else placeable.width / 2 + layout(placeable.width, placeable.height) { placeable.place(dx, -placeable.height / 2) } + }, + visible = unreadCount > 0, + enter = fadeIn(tween(200)) + scaleIn(tween(200)), + exit = fadeOut(tween(150)) + scaleOut(tween(150)), + ) { + // The outline is the chat background, so the pill reads as cut out of the button. + Box( + Modifier + .background(background, CircleShape) + .padding(BadgeOutline), + ) { + Badge( + count = shownCount, + color = CodeTheme.colors.badgeFocus, + textStyle = CodeTheme.typography.caption.copy( + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + ), + ) + } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index c68cbc400..c4680007a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -13,6 +13,7 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints @@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding @@ -66,6 +68,7 @@ import androidx.paging.compose.itemKey import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel import com.flipcash.features.messenger.R +import dev.chrisbanes.haze.HazeState import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer import com.flipcash.shared.chat.UnreadBoundary @@ -88,6 +91,7 @@ import com.flipcash.shared.chat.ui.GroupInviteLinkCard import com.flipcash.shared.chat.ui.QuickReactionStripPlacement import com.flipcash.shared.chat.ui.transcriptBubbleMaxWidth import com.getcode.theme.CodeTheme +import com.getcode.ui.components.chat.ChatInputDefaults import com.getcode.ui.utils.rememberKeyboardController import com.getcode.util.vibration.LocalVibrator import kotlinx.coroutines.delay @@ -96,6 +100,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.withTimeoutOrNull @@ -115,6 +120,9 @@ internal fun MessageList( webLinkPreviewsEnabled: Boolean = true, onJumpConsumed: () -> Unit = {}, topBarBottom: Dp = 0.dp, + unreadBelowCount: Int = 0, + onUserScrolled: () -> Unit = {}, + hazeState: HazeState? = null, ) { val keyboard = rememberKeyboardController() val listState = rememberLazyListState() @@ -143,7 +151,19 @@ internal fun MessageList( // rebuilt under the amount-entry step, and this visit's opening must not replay on the way // back. var hasPositioned by rememberSaveable { mutableStateOf(false) } + // True while the list is being carried to a new newest row, so the jump button stays down. + var autoScrolling by remember { mutableStateOf(false) } HandleMessageReads(listState, messages, enabled = hasPositioned) + // The first drag or fling after the list has settled. Not isScrollInProgress, which is + // also true through the animated and instant scrolls the list makes itself (opening, + // auto-scroll, the jump button): only a finger starts a DragInteraction, and a fling + // starts from one. + val currentOnUserScrolled by rememberUpdatedState(onUserScrolled) + LaunchedEffect(listState, hasPositioned) { + if (!hasPositioned) return@LaunchedEffect + listState.interactionSource.interactions.filterIsInstance().first() + currentOnUserScrolled() + } // Haptic feedback when a new incoming message arrives // Track the newest message key — only fire when it changes to an incoming message @@ -440,240 +460,272 @@ internal fun MessageList( } } - LazyColumn( - // NB: no sheetResignmentBehavior, unlike every other scrolling list in the app. The - // conversation is a full-screen destination, not a sheet, so there is no dismiss drag - // for the guard to hand the gesture back to — back is the only way out. It would also - // read this list wrong: the guard expects a top-anchored list, where index0/offset0 is - // the edge a downward drag overscrolls past, and this one is reverseLayout, where - // index0/offset0 is the resting position and a downward drag scrolls into history. - modifier = modifier - // The backdrop is modal. While a message is selected or being edited the rest of - // the transcript sits behind it, so a tap there dismisses what is up rather than - // reaching the message it landed on — the exit iOS gives its held blur. - .pointerInput(state.selection != null, state.editing != null) { - detectTapGestures { - when { - state.editing != null -> onAction(ChatAction.CancelEdit) - state.selection != null -> onAction(ChatAction.ClearSelection) - else -> keyboard.hide() + Box(Modifier.fillMaxSize()) { + LazyColumn( + // NB: no sheetResignmentBehavior, unlike every other scrolling list in the app. The + // conversation is a full-screen destination, not a sheet, so there is no dismiss drag + // for the guard to hand the gesture back to — back is the only way out. It would also + // read this list wrong: the guard expects a top-anchored list, where index0/offset0 is + // the edge a downward drag overscrolls past, and this one is reverseLayout, where + // index0/offset0 is the resting position and a downward drag scrolls into history. + modifier = modifier + // The backdrop is modal. While a message is selected or being edited the rest of + // the transcript sits behind it, so a tap there dismisses what is up rather than + // reaching the message it landed on — the exit iOS gives its held blur. + .pointerInput(state.selection != null, state.editing != null) { + detectTapGestures { + when { + state.editing != null -> onAction(ChatAction.CancelEdit) + state.selection != null -> onAction(ChatAction.ClearSelection) + else -> keyboard.hide() + } } } - } - .holdFocusedMessageInPlace(focusPin, imeInsets, listBottomPadPx), - state = listState, - reverseLayout = true, - // The transcript can't be dragged for as long as the backdrop is up — selection and - // edit alike. It is behind the same scrim the taps above dismiss, so a drag there has - // no more business reaching it than a tap does. Arriving messages still move it — see - // the auto-scroll effect below, which caps how far. - userScrollEnabled = !selecting, - contentPadding = PaddingValues( - top = CodeTheme.dimens.inset + contentPadding.calculateTopPadding(), - bottom = listBottomPad, - start = CodeTheme.dimens.inset, - end = CodeTheme.dimens.inset, - ), - verticalArrangement = Arrangement.Top, - ) { - items( - count = messages.itemCount, - key = messages.itemKey { it.itemKey } - ) { index -> - val item = messages[index] ?: return@items - - // Cross-item bookkeeping, so it stays with the list rather than the row: a message - // animates in once, the first time it is laid out after the initial page. - // The newest row opening from under the typing dots: see TypingRowModel. - // Decided here too: the list can lay this row out before the composition above - // has seen it arrive. See TypingRowModel.arrive. - val grow = if (index == 0) { - arriveUnderDots() - if (typingRow.takeAnchor()) SideEffect { listState.requestScrollToItem(0, 0) } - typingRow.growFor(item.itemKey) - } else { - null - } - val animateInsertion = index == 0 && hasLoaded && item.itemKey !in animatedKeys && - // A message growing out of the dots doesn't rise in as well. - grow?.handoff == null - if (animateInsertion) animatedKeys.add(item.itemKey) - - val bubble = item as? ChatListItem.ContentBubble - // Selecting or editing a message pushes the rest of the transcript behind a blur, - // leaving the one message sharp — the same treatment iOS holds from its context - // menu through the edit that can follow it. - val focused = when { - // The delete confirmation is modal, so nothing behind it is the focus: the - // selected message drops back with the rest rather than sitting sharp and - // half-clipped where the sheet cuts across it. - state.confirmingDelete -> false - state.editing != null -> bubble?.messageId == state.editing.messageId - // Every row of the message stays sharp, so a split message is focused whole. - state.selection != null -> bubble?.messageKey == state.selection.messageKey - else -> true - } + .holdFocusedMessageInPlace(focusPin, imeInsets, listBottomPadPx), + state = listState, + reverseLayout = true, + // The transcript can't be dragged for as long as the backdrop is up — selection and + // edit alike. It is behind the same scrim the taps above dismiss, so a drag there has + // no more business reaching it than a tap does. Arriving messages still move it — see + // the auto-scroll effect below, which caps how far. + userScrollEnabled = !selecting, + contentPadding = PaddingValues( + top = CodeTheme.dimens.inset + contentPadding.calculateTopPadding(), + bottom = listBottomPad, + start = CodeTheme.dimens.inset, + end = CodeTheme.dimens.inset, + ), + verticalArrangement = Arrangement.Top, + ) { + items( + count = messages.itemCount, + key = messages.itemKey { it.itemKey } + ) { index -> + val item = messages[index] ?: return@items - // Every row of a message split around its card is focused, but only the pressed - // one hosts the quick strip — one strip per message, as iOS gives one per context - // menu — and it spans the rows the others measure. - val inSelection = focused && bubble != null && - bubble.messageKey == state.selection?.messageKey - if (inSelection) { - DisposableEffect(selectedRowBounds, item.itemKey) { - onDispose { selectedRowBounds.remove(item.itemKey) } + // Cross-item bookkeeping, so it stays with the list rather than the row: a message + // animates in once, the first time it is laid out after the initial page. + // The newest row opening from under the typing dots: see TypingRowModel. + // Decided here too: the list can lay this row out before the composition above + // has seen it arrive. See TypingRowModel.arrive. + val grow = if (index == 0) { + arriveUnderDots() + if (typingRow.takeAnchor()) SideEffect { listState.requestScrollToItem(0, 0) } + typingRow.growFor(item.itemKey) + } else { + null } - } - val hostsStrip = inSelection && item.itemKey == state.selection?.itemKey - - var growing by remember { mutableStateOf(grow != null) } - val growProgress = remember { Animatable(if (grow != null) 0f else 1f) } - val contentAlpha = remember { Animatable(if (grow?.handoff != null) 0f else 1f) } - val dotsAlpha = remember { Animatable(if (grow?.handoff != null) 1f else 0f) } - LaunchedEffect(grow) { - grow ?: return@LaunchedEffect - coroutineScope { - val spec = if (grow.handoff != null) { - ChatAnimations.fromTyping - } else { - ChatAnimations.insertionPush + val animateInsertion = index == 0 && hasLoaded && item.itemKey !in animatedKeys && + // A message growing out of the dots doesn't rise in as well. + grow?.handoff == null + if (animateInsertion) animatedKeys.add(item.itemKey) + + val bubble = item as? ChatListItem.ContentBubble + // Selecting or editing a message pushes the rest of the transcript behind a blur, + // leaving the one message sharp — the same treatment iOS holds from its context + // menu through the edit that can follow it. + val focused = when { + // The delete confirmation is modal, so nothing behind it is the focus: the + // selected message drops back with the rest rather than sitting sharp and + // half-clipped where the sheet cuts across it. + state.confirmingDelete -> false + state.editing != null -> bubble?.messageId == state.editing.messageId + // Every row of the message stays sharp, so a split message is focused whole. + state.selection != null -> bubble?.messageKey == state.selection.messageKey + else -> true + } + + // Every row of a message split around its card is focused, but only the pressed + // one hosts the quick strip — one strip per message, as iOS gives one per context + // menu — and it spans the rows the others measure. + val inSelection = focused && bubble != null && + bubble.messageKey == state.selection?.messageKey + if (inSelection) { + DisposableEffect(selectedRowBounds, item.itemKey) { + onDispose { selectedRowBounds.remove(item.itemKey) } } - launch { growProgress.animateTo(1f, spec) } - if (grow.handoff != null) { - launch { - contentAlpha.animateTo( - 1f, - tween( - durationMillis = ChatAnimations.fromTypingTextFadeMs, - delayMillis = ChatAnimations.fromTypingTextDelayMs, - ), - ) + } + val hostsStrip = inSelection && item.itemKey == state.selection?.itemKey + + var growing by remember { mutableStateOf(grow != null) } + val growProgress = remember { Animatable(if (grow != null) 0f else 1f) } + val contentAlpha = remember { Animatable(if (grow?.handoff != null) 0f else 1f) } + val dotsAlpha = remember { Animatable(if (grow?.handoff != null) 1f else 0f) } + LaunchedEffect(grow) { + grow ?: return@LaunchedEffect + coroutineScope { + val spec = if (grow.handoff != null) { + ChatAnimations.fromTyping + } else { + ChatAnimations.insertionPush + } + launch { growProgress.animateTo(1f, spec) } + if (grow.handoff != null) { + launch { + contentAlpha.animateTo( + 1f, + tween( + durationMillis = ChatAnimations.fromTypingTextFadeMs, + delayMillis = ChatAnimations.fromTypingTextDelayMs, + ), + ) + } + launch { dotsAlpha.animateTo(0f, tween(ChatAnimations.fromTypingDotsFadeMs)) } } - launch { dotsAlpha.animateTo(0f, tween(ChatAnimations.fromTypingDotsFadeMs)) } } + growing = false + typingRow.finished(grow.key) } - growing = false - typingRow.finished(grow.key) - } - val handoff = grow?.handoff?.takeIf { growing }?.let { from -> - remember(from) { - TypingHandoff( - from = from, - progress = { growProgress.value }, - contentAlpha = { contentAlpha.value }, - dotsAlpha = { dotsAlpha.value }, - ) + val handoff = grow?.handoff?.takeIf { growing }?.let { from -> + remember(from) { + TypingHandoff( + from = from, + progress = { growProgress.value }, + contentAlpha = { contentAlpha.value }, + dotsAlpha = { dotsAlpha.value }, + ) + } } - } - // Every item is wrapped alike, so a row keeps its state as it moves off index 0. - Column( - modifier = Modifier.addIf(grow != null && growing) { - Modifier.openFromTypingRow(grow?.fromPx ?: 0) { growProgress.value } - }, - ) { - MessageRow( - index = index, - item = item, - messages = messages, - separatorConfig = separatorConfig, - otherReadPointer = otherReadPointer, - settlingKey = settlingKey, - selecting = selecting, - focused = focused, - // The pressed row is also the only one that lifts, as iOS lifts only the held bubble. - raised = hostsStrip, - animateInsertion = animateInsertion, - showsSenderGutter = isGroup, - topBarBottom = topBarBottom, - quickReactionStrip = if (hostsStrip) state.quickReactionStrip else emptyList(), - stripAnchor = if (hostsStrip) { - QuickReactionStripPlacement.messageBounds(selectedRowBounds.values) - } else { - null - }, - onBubbleBounds = if (inSelection) { - { bounds -> selectedRowBounds[item.itemKey] = bounds } - } else { - null - }, - attention = if (bubble != null && bubble.messageId == attentionId) { - readAttention - } else { - NoAttention - }, - typingHandoff = handoff, - ) - - // Under the newest row, not in place of it. A message growing out of the dots - // draws what is left of them itself. - if (index == 0 && (showDots || dotsVisible) && !typingFrame.handedOff && handoff == null) { - TypingRow( - avatars = dotsAvatars, - // The newest row already keeps the tight gap under it; the dots add the rest. - gapAbove = when (gapAboveTypingRow(item, typingFrame.lastTypists)) { - RowGap.Tight -> 0.dp - RowGap.Normal -> CodeTheme.dimens.grid.x2 - CodeTheme.dimens.grid.x1 - RowGap.Wide -> CodeTheme.dimens.grid.x3 - CodeTheme.dimens.grid.x1 + // Every item is wrapped alike, so a row keeps its state as it moves off index 0. + Column( + modifier = Modifier.addIf(grow != null && growing) { + Modifier.openFromTypingRow(grow?.fromPx ?: 0) { growProgress.value } }, - model = typingRow, - presence = { dotsPresence.value }, - // Dots carried under a newer message keep their place. - enters = Snapshot.withoutReadObservation { dotsPresence.value == 0f }, + ) { + MessageRow( + index = index, + item = item, + messages = messages, + separatorConfig = separatorConfig, + otherReadPointer = otherReadPointer, + settlingKey = settlingKey, + selecting = selecting, + focused = focused, + // The pressed row is also the only one that lifts, as iOS lifts only the held bubble. + raised = hostsStrip, + animateInsertion = animateInsertion, + showsSenderGutter = isGroup, + topBarBottom = topBarBottom, + quickReactionStrip = if (hostsStrip) state.quickReactionStrip else emptyList(), + stripAnchor = if (hostsStrip) { + QuickReactionStripPlacement.messageBounds(selectedRowBounds.values) + } else { + null + }, + onBubbleBounds = if (inSelection) { + { bounds -> selectedRowBounds[item.itemKey] = bounds } + } else { + null + }, + attention = if (bubble != null && bubble.messageId == attentionId) { + readAttention + } else { + NoAttention + }, + typingHandoff = handoff, ) - } - } - } - // Show trailing separator and contact info once messages are available - // (from Room cache) or after the refresh settles (for empty conversations). - // This prevents these items from being the only content before messages - // load, which would cause the list to start at the wrong scroll position. - if (messages.itemCount > 0 || refreshSettled) { - // Node 10127:117171. Emitted before the info card, and so drawn below it: this list - // is reverseLayout, where the last item is the topmost one. The blur over this list - // is what makes it a preview; see GatedTranscriptPlaceholder for why it is drawn - // rather than fetched. - if (messages.itemCount == 0 && state.obscuresTranscript) { - item(key = "gated-transcript-placeholder") { - GatedTranscriptPlaceholder( - modifier = Modifier.fillParentMaxWidth().then(behindBackdrop), + // Under the newest row, not in place of it. A message growing out of the dots + // draws what is left of them itself. + if (index == 0 && (showDots || dotsVisible) && !typingFrame.handedOff && handoff == null) { + TypingRow( + avatars = dotsAvatars, + // The newest row already keeps the tight gap under it; the dots add the rest. + gapAbove = when (gapAboveTypingRow(item, typingFrame.lastTypists)) { + RowGap.Tight -> 0.dp + RowGap.Normal -> CodeTheme.dimens.grid.x2 - CodeTheme.dimens.grid.x1 + RowGap.Wide -> CodeTheme.dimens.grid.x3 - CodeTheme.dimens.grid.x1 + }, + model = typingRow, + presence = { dotsPresence.value }, + // Dots carried under a newer message keep their place. + enters = Snapshot.withoutReadObservation { dotsPresence.value == 0f }, ) } + } } - // Trailing date separator for the oldest loaded message. - // This replaces the `after == null` boundary from insertSeparators - // which Paging 3 defers until endOfPaginationReached, causing a - // visible frame delay where the message appears without its separator. - if (messages.itemCount > 0) { - val oldest = messages.peek(messages.itemCount - 1) - val oldestTimestamp = when (oldest) { - is ChatListItem.TranscriptMessage -> oldest.timestamp - is ChatListItem.DateSeparator -> null // already a separator - is ChatListItem.UnreadDivider -> null // carries the oldest message's date - is ChatListItem.EncryptedMarker -> null // carries it too, as `above` - null -> null + // Show trailing separator and contact info once messages are available + // (from Room cache) or after the refresh settles (for empty conversations). + // This prevents these items from being the only content before messages + // load, which would cause the list to start at the wrong scroll position. + if (messages.itemCount > 0 || refreshSettled) { + // Node 10127:117171. Emitted before the info card, and so drawn below it: this list + // is reverseLayout, where the last item is the topmost one. The blur over this list + // is what makes it a preview; see GatedTranscriptPlaceholder for why it is drawn + // rather than fetched. + if (messages.itemCount == 0 && state.obscuresTranscript) { + item(key = "gated-transcript-placeholder") { + GatedTranscriptPlaceholder( + modifier = Modifier.fillParentMaxWidth().then(behindBackdrop), + ) + } } - if (oldestTimestamp != null) { - item(key = "trailing-date-${oldestTimestamp.epochSeconds}") { - Box( - modifier = Modifier - .padding(bottom = CodeTheme.dimens.grid.x2) - .then(behindBackdrop), - ) { - DateSeparatorRow(oldestTimestamp) + + // Trailing date separator for the oldest loaded message. + // This replaces the `after == null` boundary from insertSeparators + // which Paging 3 defers until endOfPaginationReached, causing a + // visible frame delay where the message appears without its separator. + if (messages.itemCount > 0) { + val oldest = messages.peek(messages.itemCount - 1) + val oldestTimestamp = when (oldest) { + is ChatListItem.TranscriptMessage -> oldest.timestamp + is ChatListItem.DateSeparator -> null // already a separator + is ChatListItem.UnreadDivider -> null // carries the oldest message's date + is ChatListItem.EncryptedMarker -> null // carries it too, as `above` + null -> null + } + if (oldestTimestamp != null) { + item(key = "trailing-date-${oldestTimestamp.epochSeconds}") { + Box( + modifier = Modifier + .padding(bottom = CodeTheme.dimens.grid.x2) + .then(behindBackdrop), + ) { + DateSeparatorRow(oldestTimestamp) + } } } } - } - // Room at the start of history for the quick reaction strip, made only while a - // message near the start needs it: the strip sits above the message it acts on. - item(key = "history-start") { - Spacer(Modifier.height(with(LocalDensity.current) { historyStartRoom.toDp() })) + // Room at the start of history for the quick reaction strip, made only while a + // message near the start needs it: the strip sits above the message it acts on. + item(key = "history-start") { + Spacer(Modifier.height(with(LocalDensity.current) { historyStartRoom.toDp() })) + } } } + // Not while a message is raised: the backdrop is modal and the list can't be dragged, so + // the button must not scroll it either. + // Keyed on selecting: it is a plain Boolean, not state, so the derivation would keep the + // value it first read. + val jumpVisible by remember(selecting) { + derivedStateOf { hasPositioned && !listState.restsOnNewest() && !selecting && !autoScrolling } + } + // Centred on the `$` button's column, placed from the same inset value the bar's input row + // uses and in the same layout pass (see ComposerInsets). With no `$` beside a composer, + // the plain inset. + val dollarColumn = state.typingConstraints.enabled && state.editing == null + val composerInsets = rememberComposerInsets() + val jumpScope = rememberCoroutineScope() + JumpToBottomButton( + visible = jumpVisible, + // No count until the list has settled: reads are gated on it, and so is the badge. + unreadCount = if (hasPositioned) unreadBelowCount else 0, + hazeState = hazeState, + onClick = { jumpScope.launch { listState.scrollToNewest() } }, + modifier = Modifier + .align(Alignment.BottomEnd) + // The scaffold already rides the keyboard, so the bar-height padding does too. + .padding(bottom = contentPadding.calculateBottomPadding() + 20.dp) + .alignedToDollarColumn( + insets = composerInsets, + dollarColumn = dollarColumn, + columnWidth = ChatInputDefaults.OutsideSize, + plainInset = CodeTheme.dimens.inset, + ), + ) } // Scroll to bottom when the newest message changes (sent or received) @@ -685,66 +737,78 @@ internal fun MessageList( .filterNotNull() .distinctUntilChanged() .collectLatest { newestKey -> - // The paged snapshot changes before the list lays it out, so wait for the next - // layout pass; until then the scroll position describes the old list. - withTimeoutOrNull(NextLayoutTimeoutMs) { - snapshotFlow { listState.layoutInfo }.drop(1).first() - } - // Opening from under the typing dots already moves the transcript. Checked after - // the layout, by which point the composition that decided it has run. - if (typingRow.grows(newestKey)) return@collectLatest - // The list holds its position by key, so a message arriving at index 0 leaves - // the one that was newest (now index 1) where it sat. Sitting at offset 0 means - // the transcript was resting on the newest message. - val atBottom = listState.firstVisibleItemIndex == 1 && - listState.firstVisibleItemScrollOffset == 0 - // Always scroll for own messages; only near-bottom for incoming - val nearBottom = listState.firstVisibleItemIndex <= 5 - val newest = messages.peek(0) as? ChatListItem.TranscriptMessage - val editing = editingMessageId - if (editing != null) { - // A message arriving mid-edit still moves the transcript, or it would land - // off the bottom edge with nothing to say it had arrived. What it can't do - // is carry the row being edited away: the scroll stops at the point that - // row would pass under the top bar, and the pin holds it there. - if (newest?.isFromSelf == true || nearBottom) { - val room = listState.layoutInfo.headroomAbove(messages, editing) - // Negative is toward index 0. The list settles on the newest message by - // itself if that comes before the cap. - if (room > 0) listState.animateScrollBy(-room.toFloat()) + // The state still describes the pre-insert layout here. A list resting on the + // newest row, or near it, or a send, is about to be carried to the new row; the + // button stays down for that instead of flashing for the insertion push. Scrolled + // far up nothing moves, so the button must stay. + val carried = listState.restsOnNewest() || + listState.firstVisibleItemIndex <= 4 || + (messages.peek(0) as? ChatListItem.TranscriptMessage)?.isFromSelf == true + if (carried) autoScrolling = true + try { + // The paged snapshot changes before the list lays it out, so wait for the next + // layout pass; until then the scroll position describes the old list. + withTimeoutOrNull(NextLayoutTimeoutMs) { + snapshotFlow { listState.layoutInfo }.drop(1).first() } - return@collectLatest - } - // Resting on the newest message, the arrival pushes the transcript up as iOS - // does: every row rides up by the new row's height on the insertion spring, - // while the new row rises into its slot. Placement animations can't do this — - // the move is a scroll, and a jump in scroll position resets them. - if (atBottom && (newest?.isFromSelf == true || nearBottom)) { - val height = listState.measuredHeight(0) - if (height != null) { - // Negative is toward index 0. - listState.animateScrollBy(-height.toFloat(), ChatAnimations.insertionPush) - // The row above can resize under the scroll (its receipt moving on), so - // settle exactly on the newest message rather than a few pixels short. - if (listState.firstVisibleItemIndex != 0 || - listState.firstVisibleItemScrollOffset != 0 - ) { - listState.requestScrollToItem(0, 0) + // Opening from under the typing dots already moves the transcript. Checked after + // the layout, by which point the composition that decided it has run. + if (typingRow.grows(newestKey)) return@collectLatest + // The list holds its position by key, so a message arriving at index 0 leaves + // the one that was newest (now index 1) where it sat. Sitting at offset 0 means + // the transcript was resting on the newest message. + val atBottom = listState.firstVisibleItemIndex == 1 && + listState.firstVisibleItemScrollOffset == 0 + // Always scroll for own messages; only near-bottom for incoming + val nearBottom = listState.firstVisibleItemIndex <= 5 + val newest = messages.peek(0) as? ChatListItem.TranscriptMessage + val editing = editingMessageId + if (editing != null) { + // A message arriving mid-edit still moves the transcript, or it would land + // off the bottom edge with nothing to say it had arrived. What it can't do + // is carry the row being edited away: the scroll stops at the point that + // row would pass under the top bar, and the pin holds it there. + if (newest?.isFromSelf == true || nearBottom) { + val room = listState.layoutInfo.headroomAbove(messages, editing) + // Negative is toward index 0. The list settles on the newest message by + // itself if that comes before the cap. + if (room > 0) listState.animateScrollBy(-room.toFloat()) } return@collectLatest } - } - when { - // Own message: anchor the new bubble during the next measure pass - // rather than animating to it. An animated scroll resolves its target - // offset up front, so the previous message's receipt collapsing out - // from under it moves the target mid-flight and the list overshoots - // and corrects — read as the bubble bouncing. iOS never hand-rolls - // this scroll at all: ChatLayout's keepContentOffsetAtBottomOnBatchUpdates - // pins the bottom edge across the one batch that both inserts the new - // cell and reconfigures the old one's receipt away. - newest?.isFromSelf == true -> listState.requestScrollToItem(0, 0) - nearBottom -> listState.animateScrollToItem(0) + // Resting on the newest message, the arrival pushes the transcript up as iOS + // does: every row rides up by the new row's height on the insertion spring, + // while the new row rises into its slot. Placement animations can't do this — + // the move is a scroll, and a jump in scroll position resets them. + if (atBottom && (newest?.isFromSelf == true || nearBottom)) { + val height = listState.measuredHeight(0) + if (height != null) { + // Negative is toward index 0. + listState.animateScrollBy(-height.toFloat(), ChatAnimations.insertionPush) + // The row above can resize under the scroll (its receipt moving on), so + // settle exactly on the newest message rather than a few pixels short. + if (listState.firstVisibleItemIndex != 0 || + listState.firstVisibleItemScrollOffset != 0 + ) { + listState.requestScrollToItem(0, 0) + } + return@collectLatest + } + } + when { + // Own message: anchor the new bubble during the next measure pass + // rather than animating to it. An animated scroll resolves its target + // offset up front, so the previous message's receipt collapsing out + // from under it moves the target mid-flight and the list overshoots + // and corrects — read as the bubble bouncing. iOS never hand-rolls + // this scroll at all: ChatLayout's keepContentOffsetAtBottomOnBatchUpdates + // pins the bottom edge across the one batch that both inserts the new + // cell and reconfigures the old one's receipt away. + newest?.isFromSelf == true -> listState.requestScrollToItem(0, 0) + nearBottom -> listState.animateScrollToItem(0) + } + } finally { + autoScrolling = false } } } @@ -885,7 +949,7 @@ private suspend fun LazyListState.alignBelowTopBar(index: Int) { } /** Whether the transcript rests on its newest row, with nothing of it scrolled out of view. */ -private fun LazyListState.restsOnNewest(): Boolean = +internal fun LazyListState.restsOnNewest(): Boolean = firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 /** diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index 91ae4f79b..66074f0e2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -30,6 +30,7 @@ import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -62,6 +63,8 @@ class ChatOpenTranscriptTest { private val chatCoordinator = mockk(relaxed = true) { every { observeMediaSendProgress() } returns kotlinx.coroutines.flow.emptyFlow() + // No load ever lands unless a test emits one. + every { newestPageLoads() } returns MutableSharedFlow() } private val tipPaymentDelegate = mockk(relaxed = true) private val transactionController = mockk(relaxed = true) @@ -267,4 +270,37 @@ class ChatOpenTranscriptTest { assertNull(viewModel.stateFlow.value.subject) } + @Test + fun `the badge cutoff is recorded after the first successful newest-page load, not on open`() = runTest { + val loads = MutableSharedFlow(extraBufferCapacity = 8) + var newestInbound: Long? = null + every { chatCoordinator.newestPageLoads() } returns loads + coEvery { chatCoordinator.hydrateChat(chatId) } returns ChatHydration.Stored + coEvery { chatCoordinator.selfReadPointer(chatId) } returns null + coEvery { chatCoordinator.newestInboundId(chatId) } coAnswers { newestInbound } + + val viewModel = createViewModel() + viewModel.dispatchEvent(openChat) + advanceUntilIdle() + // Opened, nothing loaded yet: no cutoff, so nothing counts as an arrival. + assertNull(viewModel.stateFlow.value.newestInboundIdAtOpen) + + // A backfill page stores history, and a load for another chat lands. Neither is the + // newest page of this chat, so the cutoff still waits. + newestInbound = 12 + loads.emit(ChatId(UUID.randomUUID().bytes)) + advanceUntilIdle() + assertNull(viewModel.stateFlow.value.newestInboundIdAtOpen) + + // The newest page lands: the history it stored is behind the cutoff. + loads.emit(chatId) + advanceUntilIdle() + assertEquals(12L, viewModel.stateFlow.value.newestInboundIdAtOpen) + + // Later loads do not move it. + newestInbound = 15 + loads.emit(chatId) + advanceUntilIdle() + assertEquals(12L, viewModel.stateFlow.value.newestInboundIdAtOpen) + } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt new file mode 100644 index 000000000..587621cd5 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/JumpToBottomTest.kt @@ -0,0 +1,27 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.app.messenger.internal.screens.components.jumpsToBottom +import com.getcode.ui.components.unreadCountLabel +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** What the jump-to-bottom button says and how a tap travels. */ +class JumpToBottomTest { + + @Test + fun `badge caps at 99+`() { + assertEquals("99+", unreadCountLabel(150)) + assertEquals("99+", unreadCountLabel(100)) + assertEquals("99", unreadCountLabel(99)) + assertEquals("1", unreadCountLabel(1)) + } + + @Test + fun `a tap animates up to 100 rows away and jumps beyond`() { + assertFalse(jumpsToBottom(1)) + assertFalse(jumpsToBottom(100)) + assertTrue(jumpsToBottom(101)) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt new file mode 100644 index 000000000..38af7d08b --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/UnreadBadgeTest.kt @@ -0,0 +1,221 @@ +package com.flipcash.app.messenger.internal + +import app.cash.turbine.test +import com.flipcash.shared.chat.UnreadBoundary +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals + +/** + * The jump-to-bottom badge opens at the unread divider's count, holds there until the viewer + * drags the transcript, and follows the live count from then on. + */ +class UnreadBadgeTest { + + private val divider = UnreadBoundary.At(readThrough = 10, count = 5) + + private val state = MutableStateFlow(ChatViewModel.State()) + private val live = MutableStateFlow(5) + private val arrivals = MutableStateFlow(0) + private val askedAfter = mutableListOf() + + private fun badge() = unreadBadgeCounts( + state = state, + live = live, + arrivalsAfter = { afterId -> askedAfter += afterId; arrivals }, + ) + + private fun dispatch(event: ChatViewModel.Event) = + state.update { ChatViewModel.stateReducer(event, UnreadDividerLifetime.UntilClose)(it) } + + private fun open(boundary: UnreadBoundary, newestInbound: Long = 15) { + dispatch(ChatViewModel.Event.UnreadBoundaryResolved(boundary, walkBudget = 5)) + dispatch(ChatViewModel.Event.ArrivalCutoffRecorded(newestInbound)) + } + + @Test + fun `opens at the divider's count even with unread rows already read on screen`() = runTest { + open(divider) + // Three of the five unread rows were on screen at open and the read pointer has moved past them. + live.value = 2 + + badge().test { + assertEquals(5, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `after a user drag the badge follows the live count`() = runTest { + open(divider) + live.value = 2 + + badge().test { + assertEquals(5, awaitItem()) + + dispatch(ChatViewModel.Event.UserScrolledTranscript) + assertEquals(2, awaitItem()) + + live.value = 1 + assertEquals(1, awaitItem()) + } + } + + @Test + fun `reading rows does not move a frozen badge`() = runTest { + open(divider) + + badge().test { + assertEquals(5, awaitItem()) + + live.value = 3 + live.value = 0 + expectNoEvents() + } + } + + @Test + fun `no divider means no badge`() = runTest { + open(UnreadBoundary.None, newestInbound = NO_INBOUND_AT_OPEN) + live.value = 0 + + badge().test { + assertEquals(0, awaitItem()) + expectNoEvents() + } + } + + @Test + fun `an inbound arrival while frozen adds to the divider's count`() = runTest { + open(divider) + + badge().test { + assertEquals(5, awaitItem()) + + arrivals.value = 1 + live.value = 6 + assertEquals(6, awaitItem()) + + arrivals.value = 2 + live.value = 7 + assertEquals(7, awaitItem()) + } + assertEquals(listOf(15L), askedAfter.distinct()) + } + + @Test + fun `history loaded after open below the read pointer is not an arrival`() = runTest { + // Empty cache at open: no inbound stored, but the member row says the viewer read through 40. + open(UnreadBoundary.None, newestInbound = arrivalCutoff(readPointer = 40, newestInbound = null)) + live.value = 0 + + badge().test { + assertEquals(0, awaitItem()) + + // The history fetch lands rows at or below 40; the DB counts only ids past the cutoff. + arrivals.value = 0 + expectNoEvents() + + arrivals.value = 1 + live.value = 1 + assertEquals(1, awaitItem()) + } + assertEquals(listOf(40L), askedAfter.distinct()) + } + + @Test + fun `the cutoff is the later of the read pointer and the newest inbound`() { + assertEquals(40L, arrivalCutoff(readPointer = 40, newestInbound = null)) + assertEquals(40L, arrivalCutoff(readPointer = 40, newestInbound = 25)) + assertEquals(55L, arrivalCutoff(readPointer = 40, newestInbound = 55)) + assertEquals(55L, arrivalCutoff(readPointer = null, newestInbound = 55)) + } + + @Test + fun `with neither a pointer nor inbound at open every later inbound counts as an arrival`() = runTest { + open(UnreadBoundary.None, newestInbound = arrivalCutoff(readPointer = null, newestInbound = null)) + live.value = 0 + + badge().test { + assertEquals(0, awaitItem()) + + arrivals.value = 1 + live.value = 1 + assertEquals(1, awaitItem()) + } + // Asked for everything past an id below any real one. + assertEquals(listOf(NO_INBOUND_AT_OPEN), askedAfter.distinct()) + } + + @Test + fun `an own send that the server confirmed clears the badge`() = runTest { + open(divider) + + badge().test { + assertEquals(5, awaitItem()) + + // The delegate advances the read pointer to the sent message before it reports success. + live.value = 0 + dispatch(ChatViewModel.Event.OwnMessageSent) + assertEquals(0, awaitItem()) + } + } + + @Test + fun `the id newest at open is read once, from the resolved open`() = runTest { + // Before the open resolves there is nothing to count arrivals past. + badge().test { + assertEquals(0, awaitItem()) + assertEquals(emptyList(), askedAfter) + + open(divider, newestInbound = 15) + assertEquals(5, awaitItem()) + } + assertEquals(listOf(15L), askedAfter.distinct()) + } + + @Test + fun `history the opening load stores is not an arrival, whatever it brings`() = runTest { + // Empty cache and no read pointer: the opening sync is what fills the chat. + val stored = MutableStateFlow(emptyList()) + val countingBadge = unreadBadgeCounts( + state = state, + live = live, + arrivalsAfter = { afterId -> askedAfter += afterId; stored.map { ids -> ids.count { it > afterId } } }, + ) + dispatch(ChatViewModel.Event.UnreadBoundaryResolved(UnreadBoundary.None, walkBudget = 0)) + live.value = 0 + + countingBadge.test { + assertEquals(0, awaitItem()) + + // The sync lands 12 inbound rows before the cutoff is read; none counts. + stored.value = (1L..12L).toList() + expectNoEvents() + assertEquals(emptyList(), askedAfter) + + dispatch(ChatViewModel.Event.ArrivalCutoffRecorded(arrivalCutoff(readPointer = null, newestInbound = 12))) + expectNoEvents() + + stored.value = stored.value + 13L + live.value = 1 + assertEquals(1, awaitItem()) + } + } + + @Test + fun `a send that clears the boundary does not shrink the frozen badge`() { + val opened = ChatViewModel.stateReducer( + ChatViewModel.Event.UnreadBoundaryResolved(divider, walkBudget = 5), + UnreadDividerLifetime.UntilSend, + )(ChatViewModel.State()) + val sent = ChatViewModel.stateReducer(ChatViewModel.Event.SendMessage, UnreadDividerLifetime.UntilSend)(opened) + + assertEquals(UnreadBoundary.None, sent.unreadBoundary) + assertEquals(5, sent.unreadBadgeOpenCount) + assertEquals(true, sent.unreadBadgeFrozen) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerInsetsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerInsetsTest.kt new file mode 100644 index 000000000..dce9ac66c --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/ComposerInsetsTest.kt @@ -0,0 +1,50 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * The bar's input row and the jump-to-bottom button both place from [ComposerInsets.sidePx], so the + * value is what keeps the jump button over the `$` button while the keyboard moves. + */ +class ComposerInsetsTest { + + private val density = Density(1f) + + private class FakeInsets(var bottom: Int) : WindowInsets { + override fun getLeft(density: Density, layoutDirection: LayoutDirection) = 0 + override fun getTop(density: Density) = 0 + override fun getRight(density: Density, layoutDirection: LayoutDirection) = 0 + override fun getBottom(density: Density) = bottom + } + + private fun insets(now: Int, source: Int = now, target: Int = now, compact: Dp = 24.dp, normal: Dp = 16.dp) = + ComposerInsets(FakeInsets(now), FakeInsets(source), FakeInsets(target), compact, normal) + + private fun ComposerInsets.px(hasComposer: Boolean) = with(density) { with(this@px) { sidePx(hasComposer) } } + + @Test + fun `keyboard down leaves the compact margin`() { + assertEquals(24, insets(now = 0, source = 0, target = 800).px(hasComposer = true)) + } + + @Test + fun `keyboard fully up opens out to the normal margin`() { + assertEquals(16, insets(now = 800, source = 0, target = 800).px(hasComposer = true)) + } + + @Test + fun `halfway through the animation is halfway between the margins`() { + assertEquals(20, insets(now = 400, source = 0, target = 800).px(hasComposer = true)) + } + + @Test + fun `no composer is always the normal margin`() { + assertEquals(16, insets(now = 0, source = 0, target = 800).px(hasComposer = false)) + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt new file mode 100644 index 000000000..e2ac66e24 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/JumpToBottomVisibilityTest.kt @@ -0,0 +1,106 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.withSeparators +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.UnreadBoundary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.shared.chat.models.SeparatorConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** + * The jump-to-bottom button is down while a message is raised: the backdrop is modal and the list + * can't be dragged, so the button must not scroll it either. Opened at an unread divider well off + * the bottom, where the button is up. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class JumpToBottomVisibilityTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val start = Instant.fromEpochMilliseconds(1_700_000_000_000) + private val button = "Jump to latest message" + + private fun bubble(id: Long) = ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("m$id"), + isFromSelf = false, + timestamp = start + id.minutes, + ) + + @Test + fun `the button goes away while a message is selected and returns after`() { + val boundary = UnreadBoundary.At(readThrough = 10, count = 30) + val newestFirst = (40L downTo 1L).map(::bubble) + val loaded = LoadState.NotLoading(endOfPaginationReached = true) + val page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded)) + val paged = flowOf(page.withSeparators(boundary, SeparatorConfig.DayOnly)) + var selected by mutableStateOf(null) + + composeRule.setContent { + FlipcashThemeWrapper().Wrap { + MessageList( + contentPadding = PaddingValues(), + state = ChatViewModel.State( + unreadBoundary = boundary, + unreadWalkBudget = 30, + selection = selected, + ), + messages = paged.collectAsLazyPagingItems(), + separatorConfig = SeparatorConfig.DayOnly, + onAction = {}, + linkCardResolution = Unresolved, + ) + } + } + composeRule.waitUntil(5_000) { + (1..40).any { composeRule.onAllNodesWithText("m$it").fetchSemanticsNodes().isNotEmpty() } + } + composeRule.waitForIdle() + composeRule.onNodeWithContentDescription(button).assertIsDisplayed() + + selected = newestFirst[20] + composeRule.waitForIdle() + composeRule.mainClock.advanceTimeBy(500) + composeRule.onNodeWithContentDescription(button).assertDoesNotExist() + + selected = null + composeRule.waitForIdle() + composeRule.mainClock.advanceTimeBy(500) + composeRule.onNodeWithContentDescription(button).assertIsDisplayed() + } + + private object Unresolved : LinkCardResolution { + override val revision: StateFlow = MutableStateFlow(0) + override fun peek(card: LinkCard): LinkCard = card + override suspend fun resolve(card: LinkCard): LinkCard = card + } +} diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt new file mode 100644 index 000000000..fe546ab8c --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/components/UserScrollReleaseTest.kt @@ -0,0 +1,105 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeDown +import androidx.paging.LoadState +import androidx.paging.LoadStates +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.withSeparators +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.UnreadBoundary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LinkCardResolution +import com.flipcash.shared.chat.models.SeparatorConfig +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import org.junit.Rule +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.Duration.Companion.minutes +import kotlin.time.Instant + +/** + * What releases the jump-to-bottom badge's freeze: a finger on the transcript after it has + * settled, and nothing the list does by itself. isScrollInProgress can't tell them apart, so the + * list listens for the drag instead. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], qualifiers = "w400dp-h800dp-xhdpi") +class UserScrollReleaseTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private val start = Instant.fromEpochMilliseconds(1_700_000_000_000) + private val button = "Jump to latest message" + + private fun bubble(id: Long) = ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("m$id"), + isFromSelf = false, + timestamp = start + id.minutes, + ) + + @Test + fun `the jump button's own scroll does not release the badge, a drag does`() { + val boundary = UnreadBoundary.At(readThrough = 10, count = 30) + val newestFirst = (40L downTo 1L).map(::bubble) + val loaded = LoadState.NotLoading(endOfPaginationReached = true) + val page = PagingData.from(newestFirst, LoadStates(loaded, loaded, loaded)) + val paged = flowOf(page.withSeparators(boundary, SeparatorConfig.DayOnly)) + var released = 0 + + composeRule.setContent { + FlipcashThemeWrapper().Wrap { + MessageList( + contentPadding = PaddingValues(), + state = ChatViewModel.State(unreadBoundary = boundary, unreadWalkBudget = 30), + messages = paged.collectAsLazyPagingItems(), + separatorConfig = SeparatorConfig.DayOnly, + onAction = {}, + linkCardResolution = Unresolved, + onUserScrolled = { released++ }, + ) + } + } + composeRule.waitUntil(5_000) { + (1..40).any { composeRule.onAllNodesWithText("m$it").fetchSemanticsNodes().isNotEmpty() } + } + composeRule.waitForIdle() + // The opening walk to the divider was a programmatic scroll. + assertEquals(0, released, "the opening walk released the badge") + + composeRule.onNodeWithContentDescription(button).performClick() + composeRule.waitForIdle() + composeRule.onNodeWithText("m40").assertIsDisplayed() + assertEquals(0, released, "the jump button's animated scroll released the badge") + + composeRule.onNodeWithText("m40").performTouchInput { swipeDown() } + composeRule.waitForIdle() + assertEquals(1, released) + } + + private object Unresolved : LinkCardResolution { + override val revision: StateFlow = MutableStateFlow(0) + override fun peek(card: LinkCard): LinkCard = card + override suspend fun resolve(card: LinkCard): LinkCard = card + } +} 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 a5424d74f..99ca1bb6e 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 @@ -334,6 +334,34 @@ interface MessagingOperations { */ suspend fun resolveUnreadBoundary(chatId: ChatId): UnreadBoundary + /** + * How many inbound messages in [chatId] sit past the viewer's stored READ pointer, live — the + * jump-to-bottom badge. Read from the same pointer the read reporter advances and counted with + * the divider's own query, so the two cannot disagree. 0 when the divider would resolve to + * [UnreadBoundary.None] for want of a pointer, which includes a viewer outside the group. + */ + fun observeUnreadBelow(chatId: ChatId): Flow + + /** + * The newest inbound, non-deleted message id stored in [chatId], or `null` when there is none. + * Read when a chat opens: inbound messages past it are the ones that arrived after the open. + * Suspends until the account and the database are ready, so `null` only ever means "ready, and none". + */ + suspend fun newestInboundId(chatId: ChatId): Long? + + /** + * The viewer's stored READ pointer in [chatId], or `null` when their member row is not stored. + * Read with [newestInboundId] when a chat opens: history older than the pointer is not an + * arrival even when it loads after the open. Suspends until the account and database are ready. + */ + suspend fun selfReadPointer(chatId: ChatId): Long? + + /** + * How many inbound, non-deleted messages in [chatId] sit past [afterId], live, whatever the + * read pointer says. [afterId] below every stored id counts them all. + */ + fun observeInboundAfter(chatId: ChatId, afterId: Long): Flow + /** How many stored messages in [chatId] sit past [messageId]. Bounds the walk to the unread divider. */ suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int @@ -401,6 +429,14 @@ interface MessagingOperations { /** Fetches the full message history for [chatId] from the server and persists locally. */ suspend fun loadMessages(chatId: ChatId) + /** + * Emits a chat's id each time a load of its newest page succeeds and has been stored, whichever + * path performed it (the open, the feed sync, a push, an outside reader). Never replays and + * emits nothing for a failed load, so a collector started before the load sees only the loads + * that happen after it. + */ + fun newestPageLoads(): Flow + /** * Persists a message the server delivered inside a push payload, without an RPC. * 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 06223cac5..8d67ab2b6 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 @@ -54,10 +54,14 @@ import com.getcode.utils.TraceType import com.getcode.utils.trace import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import javax.inject.Inject @@ -204,8 +208,19 @@ class MessagingDelegate @Inject constructor( override suspend fun distanceFromNewest(chatId: ChatId, messageId: Long): Int? = messageDataSource.distanceFromNewest(chatId, messageId) + /** + * Suspends until there is an account and a per-user database. The open snapshot reads once, so + * "not ready yet" must not look like "nothing stored": a null here would become "no inbound + * messages at open" and every stored row would later count as an arrival. + */ + private suspend fun awaitReady(): ID { + val selfId = userManager.state.map { it.accountId }.filterNotNull().first() + messageDataSource.awaitDatabase() + return selfId + } + override suspend fun resolveUnreadBoundary(chatId: ChatId): UnreadBoundary { - val selfId = userManager.accountId ?: return UnreadBoundary.None + val selfId = awaitReady() val readThrough = memberDataSource.getSelfReadPointerOrNull(chatId, selfId) ?: return UnreadBoundary.None val count = messageDataSource.countInboundAfter(chatId, selfId, readThrough) @@ -221,6 +236,43 @@ class MessagingDelegate @Inject constructor( return UnreadBoundary.At(readThrough = boundary, count = count) } + @OptIn(ExperimentalCoroutinesApi::class) + override fun observeUnreadBelow(chatId: ChatId): Flow = + // The account id is set during login, and the per-user DB is made then too (the data + // sources wait on it): a screen that subscribes before either is ready counts 0 until they + // are, and starts over if the account changes. + userManager.state.map { it.accountId } + .distinctUntilChanged() + .flatMapLatest { selfId -> + if (selfId == null) flowOf(0) + else memberDataSource.observeSelfReadPointer(chatId, selfId) + .flatMapLatest { readThrough -> + if (readThrough == null) flowOf(0) + else messageDataSource.observeInboundCountAfter(chatId, selfId, readThrough) + } + } + .distinctUntilChanged() + + override suspend fun newestInboundId(chatId: ChatId): Long? { + val selfId = awaitReady() + return messageDataSource.newestInboundId(chatId, selfId) + } + + override suspend fun selfReadPointer(chatId: ChatId): Long? { + val selfId = awaitReady() + return memberDataSource.getSelfReadPointerOrNull(chatId, selfId) + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun observeInboundAfter(chatId: ChatId, afterId: Long): Flow = + userManager.state.map { it.accountId } + .distinctUntilChanged() + .flatMapLatest { selfId -> + if (selfId == null) flowOf(0) + else messageDataSource.observeInboundCountAfter(chatId, selfId, afterId) + } + .distinctUntilChanged() + override suspend fun countMessagesAfter(chatId: ChatId, messageId: Long): Int = messageDataSource.countAfter(chatId, messageId) @@ -262,6 +314,10 @@ class MessagingDelegate @Inject constructor( .distinctUntilChanged() } + private val newestPageLoaded = MutableSharedFlow(extraBufferCapacity = 16) + + override fun newestPageLoads(): Flow = newestPageLoaded + override suspend fun loadMessages(chatId: ChatId) { messagingController.getMessages(chatId) .onSuccess { messages -> @@ -277,6 +333,7 @@ class MessagingDelegate @Inject constructor( // what the feed already stored: the list rebuilds only for a real change. val head = messages.maxOfOrNull { it.eventSequence } ?: 0L metadataDataSource.applyCatchUp(chatId, head, messages.maxByOrNull { it.messageId }) + newestPageLoaded.tryEmit(chatId) } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt new file mode 100644 index 000000000..23f52e898 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBelowObservationTest.kt @@ -0,0 +1,173 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import app.cash.turbine.test +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * The jump-to-bottom badge opens at the unread divider's number: [MessagingDelegate.observeUnreadBelow] + * and [MessagingDelegate.resolveUnreadBoundary] read the same self READ pointer and the same + * inbound count, so seeded identically they agree. + */ +class UnreadBelowObservationTest { + + private val chatId = ChatId("aabbccdd") + private val selfId = listOf(1, 2, 3) + + private val messagingController = mockk(relaxed = true) + private val memberDataSource = mockk(relaxed = true) + private val messageDataSource = mockk(relaxed = true) + private val account = MutableStateFlow(UserManager.State(accountId = selfId)) + private val userManager = mockk(relaxed = true).also { + every { it.accountId } answers { account.value.accountId } + every { it.state } returns account + } + + init { + coEvery { messageDataSource.hasAtOrBelow(chatId, any()) } returns true + } + + private val delegate = MessagingDelegate( + chatController = mockk(relaxed = true), + messagingController = messagingController, + metadataDataSource = mockk(relaxed = true), + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = mockk(relaxed = true), + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + ) + + @Test + fun `the badge opens at the divider's count`() = runTest { + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 2 + every { memberDataSource.observeSelfReadPointer(chatId, selfId) } returns flowOf(2L) + coEvery { messageDataSource.countInboundAfter(chatId, selfId, 2) } returns 3 + every { messageDataSource.observeInboundCountAfter(chatId, selfId, 2) } returns flowOf(3) + coEvery { messageDataSource.firstNotSentByAfter(chatId, selfId, 2) } returns 3 + + val boundary = delegate.resolveUnreadBoundary(chatId) as UnreadBoundary.At + + assertEquals(3, boundary.count) + assertEquals(boundary.count, delegate.observeUnreadBelow(chatId).first()) + } + + @Test + fun `no self row counts nothing`() = runTest { + every { memberDataSource.observeSelfReadPointer(chatId, selfId) } returns flowOf(null) + + assertEquals(0, delegate.observeUnreadBelow(chatId).first()) + } + + @Test + fun `subscribing before the account is ready counts 0, then the count once it is`() = runTest { + account.value = UserManager.State(accountId = null) + every { memberDataSource.observeSelfReadPointer(chatId, selfId) } returns flowOf(2L) + every { messageDataSource.observeInboundCountAfter(chatId, selfId, 2) } returns flowOf(3) + + delegate.observeUnreadBelow(chatId).test { + assertEquals(0, awaitItem()) + + account.value = UserManager.State(accountId = selfId) + assertEquals(3, awaitItem()) + + // Ready -> not ready -> ready resubscribes rather than keeping the old stream. + account.value = UserManager.State(accountId = null) + assertEquals(0, awaitItem()) + account.value = UserManager.State(accountId = selfId) + assertEquals(3, awaitItem()) + } + } + + @Test + fun `a failed load of the newest page does not announce a landing, a later success does`() = runTest { + delegate.newestPageLoads().test { + coEvery { messagingController.getMessages(chatId) } returns Result.failure(IllegalStateException("offline")) + delegate.loadMessages(chatId) + expectNoEvents() + + coEvery { messagingController.getMessages(chatId) } returns Result.success(emptyList()) + delegate.loadMessages(chatId) + assertEquals(chatId, awaitItem()) + } + } + + @Test + fun `the read pointer for the open waits for the account and the database`() = runTest { + account.value = UserManager.State(accountId = null) + val databaseUp = CompletableDeferred() + coEvery { messageDataSource.awaitDatabase() } coAnswers { databaseUp.await() } + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 40 + + val pointer = async { delegate.selfReadPointer(chatId) } + advanceUntilIdle() + assertFalse(pointer.isCompleted, "no account yet") + + account.value = UserManager.State(accountId = selfId) + advanceUntilIdle() + assertFalse(pointer.isCompleted, "no database yet") + + databaseUp.complete(Unit) + advanceUntilIdle() + assertEquals(40L, pointer.await()) + } + + @Test + fun `the open snapshot waits for the account and the database instead of reporting no inbound`() = runTest { + account.value = UserManager.State(accountId = null) + val databaseUp = CompletableDeferred() + coEvery { messageDataSource.awaitDatabase() } coAnswers { databaseUp.await() } + coEvery { messageDataSource.newestInboundId(chatId, selfId) } returns 15 + + val newest = async { delegate.newestInboundId(chatId) } + advanceUntilIdle() + assertFalse(newest.isCompleted, "no account yet") + + account.value = UserManager.State(accountId = selfId) + advanceUntilIdle() + assertFalse(newest.isCompleted, "no database yet") + + databaseUp.complete(Unit) + advanceUntilIdle() + assertTrue(newest.isCompleted) + assertEquals(15L, newest.await()) + } + + @Test + fun `the unread boundary waits for the account and the database`() = runTest { + account.value = UserManager.State(accountId = null) + val databaseUp = CompletableDeferred() + coEvery { messageDataSource.awaitDatabase() } coAnswers { databaseUp.await() } + coEvery { memberDataSource.getSelfReadPointerOrNull(chatId, selfId) } returns 2 + coEvery { messageDataSource.countInboundAfter(chatId, selfId, 2) } returns 3 + coEvery { messageDataSource.firstNotSentByAfter(chatId, selfId, 2) } returns 3 + + val boundary = async { delegate.resolveUnreadBoundary(chatId) } + advanceUntilIdle() + assertFalse(boundary.isCompleted) + + account.value = UserManager.State(accountId = selfId) + databaseUp.complete(Unit) + advanceUntilIdle() + assertEquals(3, (boundary.await() as UnreadBoundary.At).count) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt index 098e05273..7e5411d0f 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/UnreadBoundaryResolutionTest.kt @@ -9,6 +9,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.Test import kotlin.test.assertEquals @@ -28,6 +29,7 @@ class UnreadBoundaryResolutionTest { private val messageDataSource = mockk(relaxed = true) private val userManager = mockk(relaxed = true).also { every { it.accountId } returns selfId + every { it.state } returns MutableStateFlow(UserManager.State(accountId = selfId)) } init { @@ -99,13 +101,6 @@ class UnreadBoundaryResolutionTest { assertEquals(UnreadBoundary.None, delegate.resolveUnreadBoundary(chatId)) } - @Test - fun `signed out is no divider`() = runTest { - every { userManager.accountId } returns null - - assertEquals(UnreadBoundary.None, delegate.resolveUnreadBoundary(chatId)) - } - @Test fun `the walk budget counts every stored row past the id`() = runTest { coEvery { messageDataSource.countAfter(chatId, 10) } returns 30 diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt index a2ca50471..dc00aea3f 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMemberDao.kt @@ -56,6 +56,10 @@ interface ChatMemberDao { @Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex LIMIT 1") suspend fun getMember(chatIdHex: String, userIdHex: String): ChatMemberEntity? + /** [getMember] as a stream: one row, so a large group's roster is not loaded to watch the viewer's pointer. */ + @Query("SELECT * FROM chat_members WHERE chat_id_hex = :chatIdHex AND user_id_hex = :userIdHex LIMIT 1") + fun observeMember(chatIdHex: String, userIdHex: String): Flow + /** * Resolves the chat id of a DM that [userIdHex] is a member of, of the given [chatType] * (e.g. `"TIP_DM"`). Reuses the already-synced `chat_members` ↔ `chat_metadata` data — no diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 017d28d3b..cbab3c6ac 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -111,6 +111,30 @@ interface ChatMessageDao { ) suspend fun countInboundAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Int + /** + * [countInboundAfter] as a stream, for the jump-to-bottom badge. The WHERE clause is the same + * on purpose: the badge opens at the divider's count and must never drift from it. + */ + @Query( + "SELECT COUNT(*) FROM chat_messages " + + "WHERE chat_id_hex = :chatIdHex " + + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + + "AND is_deleted = 0 AND message_id > :afterId" + ) + fun observeInboundCountAfter(chatIdHex: String, selfIdHex: String, afterId: Long): Flow + + /** + * The newest inbound, non-deleted message id in [chatIdHex], or `null` when there is none. Same + * WHERE clause as [countInboundAfter]: "an arrival" and "unread" must mean the same rows. + */ + @Query( + "SELECT MAX(message_id) FROM chat_messages " + + "WHERE chat_id_hex = :chatIdHex " + + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + + "AND is_deleted = 0" + ) + suspend fun newestInboundId(chatIdHex: String, selfIdHex: String): Long? + /** * The first stored message in [chatIdHex] after [afterId] that [selfIdHex] did not send, * tombstones included, or `null` when only the viewer's own messages follow. The unread divider diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index 57397b557..94c520e0b 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -228,6 +228,28 @@ class ChatMessageDaoTest { private suspend fun unread(pointer: Long) = dao.countInboundAfter(CHAT_HEX, SELF_HEX, pointer) + @Test + fun `newestInboundId is the highest id among others' messages`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 3), from(SENDER_HEX, 15), from(SENDER_HEX, 7))) + + assertEquals(15L, dao.newestInboundId(CHAT_HEX, SELF_HEX)) + } + + @Test + fun `newestInboundId ignores the viewer's own and deleted rows`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 3), from(SELF_HEX, 20), tombstone(30).copy(senderIdHex = SENDER_HEX))) + + assertEquals(3L, dao.newestInboundId(CHAT_HEX, SELF_HEX)) + } + + @Test + fun `newestInboundId is null for an empty chat`() = runTest { + dao.upsert(listOf(from(SELF_HEX, 1))) + + assertNull(dao.newestInboundId(CHAT_HEX, SELF_HEX)) + assertNull(dao.newestInboundId(OTHER_HEX, SELF_HEX)) + } + @Test fun `countInboundAfter counts only others' messages past the pointer`() = runTest { dao.upsert(listOf(from(SENDER_HEX, 1), from(SELF_HEX, 2), from(SENDER_HEX, 3), from(SENDER_HEX, 4))) @@ -279,6 +301,35 @@ class ChatMessageDaoTest { assertEquals(0, unread(pointer = 1)) } + @Test + fun `a system notice leaves the badge count, arrival cutoff, divider and own-send range unchanged`() = runTest { + dao.upsert(listOf(from(SENDER_HEX, 1), from(SENDER_HEX, 2))) + val before = listOf( + unread(pointer = 1), + dao.observeInboundCountAfter(CHAT_HEX, SELF_HEX, 1).first(), + dao.newestInboundId(CHAT_HEX, SELF_HEX), + dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, 1), + dao.getInboundMessagesInRange(CHAT_HEX, SELF_HEX, 0, 10).map { it.messageId }, + ) + + dao.upsert( + text(5, "").copy( + senderIdHex = null, + contentJson = listOf(MessageContentSerialized.System("Alex joined the group")), + ), + ) + + val after = listOf( + unread(pointer = 1), + dao.observeInboundCountAfter(CHAT_HEX, SELF_HEX, 1).first(), + dao.newestInboundId(CHAT_HEX, SELF_HEX), + dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, 1), + dao.getInboundMessagesInRange(CHAT_HEX, SELF_HEX, 0, 10).map { it.messageId }, + ) + assertEquals(before, after) + assertEquals(listOf(1, 1, 2L, 2L, listOf(1L, 2L)), after) + } + private suspend fun firstNotOwn(pointer: Long) = dao.firstNotSentByAfter(CHAT_HEX, SELF_HEX, pointer) @Test diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt new file mode 100644 index 000000000..7fce28e15 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/UnreadBelowBadgeTest.kt @@ -0,0 +1,164 @@ +package com.flipcash.app.persistence.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.converters.MessageContentSerialized +import com.flipcash.app.persistence.converters.MessagePointerSerialized +import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.ChatMessageEntity +import app.cash.turbine.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +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 + +/** + * The jump-to-bottom badge: the viewer's stored READ pointer feeding the divider's own inbound + * count, live. The pointer is the one the read reporter advances, so the badge can't disagree + * with it, and the count is the divider's query, so it opens at the divider's number. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class UnreadBelowBadgeTest { + + private lateinit var db: FlipcashDatabase + private lateinit var messages: ChatMessageDao + private lateinit var members: ChatMemberDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java) + .allowMainThreadQueries() + .build() + messages = db.chatMessageDao() + members = db.chatMemberDao() + } + + @After + fun tearDown() { + db.close() + } + + /** The same composition MessagingDelegate.observeUnreadBelow runs over the data sources. */ + private fun badge(): Flow = + members.observeMember(CHAT_HEX, SELF_HEX) + .map { self -> + self?.let { it.pointersJson.orEmpty().firstOrNull { p -> p.type == "READ" }?.value ?: 0L } + } + .distinctUntilChanged() + .flatMapLatest { readThrough -> + if (readThrough == null) flowOf(0) + else messages.observeInboundCountAfter(CHAT_HEX, SELF_HEX, readThrough) + } + .distinctUntilChanged() + + private fun message(sender: String, id: Long) = ChatMessageEntity( + chatIdHex = CHAT_HEX, + messageId = id, + senderIdHex = sender, + contentJson = listOf(MessageContentSerialized.Text("m$id")), + timestampEpochMs = id * 1_000, + unreadSeq = id, + ) + + private fun pointer(value: Long) = MessagePointerSerialized(type = "READ", userIdHex = SELF_HEX, value = value) + + private suspend fun open(readThrough: Long) { + members.upsert(ChatMemberEntity(chatIdHex = CHAT_HEX, userIdHex = SELF_HEX, pointersJson = listOf(pointer(readThrough)))) + } + + @Test + fun `opens at the unread divider's count`() = runTest { + messages.upsert( + listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2), message(SELF_HEX, 3), message(OTHER_HEX, 4), message(OTHER_HEX, 5)), + ) + open(readThrough = 2) + val dividerCount = messages.countInboundAfter(CHAT_HEX, SELF_HEX, 2) + + badge().test { + assertEquals(dividerCount, awaitItem()) + assertEquals(2, dividerCount) + expectNoEvents() + } + } + + @Test + fun `advancing the read pointer emits 3 then 1 and ignores a late lower advance`() = runTest { + messages.upsert(listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2), message(OTHER_HEX, 3), message(OTHER_HEX, 4))) + open(readThrough = 1) + + badge().test { + assertEquals(3, awaitItem()) + + members.advancePointer(CHAT_HEX, SELF_HEX, pointer(3)) + assertEquals(1, awaitItem()) + + // The pointer keeps its max: a late, lower advance doesn't bring the count back. + members.advancePointer(CHAT_HEX, SELF_HEX, pointer(2)) + expectNoEvents() + } + } + + @Test + fun `an inbound arrival below emits n then n plus 1`() = runTest { + messages.upsert(listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2))) + open(readThrough = 2) + + badge().test { + assertEquals(0, awaitItem()) + + messages.upsert(listOf(message(OTHER_HEX, 3))) + assertEquals(1, awaitItem()) + + messages.upsert(listOf(message(OTHER_HEX, 4))) + assertEquals(2, awaitItem()) + } + } + + @Test + fun `own messages are never counted and the pointer reaching an own send clears the badge`() = runTest { + messages.upsert(listOf(message(OTHER_HEX, 1), message(OTHER_HEX, 2), message(OTHER_HEX, 3))) + open(readThrough = 0) + + badge().test { + assertEquals(3, awaitItem()) + + // An own row changes nothing. + messages.upsert(listOf(message(SELF_HEX, 4))) + expectNoEvents() + + // MessagingDelegate.send advances the pointer to the id the server gives the sent + // message on success, which is above every inbound id; that is what clears the badge. + members.advancePointer(CHAT_HEX, SELF_HEX, pointer(4)) + assertEquals(0, awaitItem()) + } + } + + @Test + fun `no self row emits 0`() = runTest { + messages.upsert(listOf(message(OTHER_HEX, 1))) + + badge().test { + assertEquals(0, awaitItem()) + expectNoEvents() + } + } + + private companion object { + const val CHAT_HEX = "c1" + const val SELF_HEX = "aa" + const val OTHER_HEX = "bb" + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt index ec926802d..e11f9c44c 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt @@ -9,8 +9,12 @@ import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import javax.inject.Inject import javax.inject.Singleton @@ -64,6 +68,24 @@ class ChatMemberDataSource @Inject constructor( ?.getMember(mapper.chatIdHex(chatId), mapper.userIdHex(selfId)) ?.let { self -> mapper.pointers(self).firstOrNull { it.type == PointerType.READ }?.value ?: 0L } + /** + * [getSelfReadPointerOrNull] as a stream, so the pointer the read reporter advances is the one + * observers see. Pointer values only: a profile or roster write touches the self row without + * moving the pointer, and must not wake anyone downstream. Resolved through + * [FlipcashDatabase.observeInstance], so a subscriber that starts before the per-user DB exists + * sees `null` until it does, and follows an account switch onto the new DB. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun observeSelfReadPointer(chatId: ChatId, selfId: ID): Flow = + FlipcashDatabase.observeInstance().flatMapLatest { database -> + database?.chatMemberDao() + ?.observeMember(mapper.chatIdHex(chatId), mapper.userIdHex(selfId)) + ?.map { self -> + self?.let { mapper.pointers(it).firstOrNull { p -> p.type == PointerType.READ }?.value ?: 0L } + } + ?: flowOf(null) + }.distinctUntilChanged() + suspend fun upsert(chatId: ChatId, members: List) { val database = db ?: return val hex = mapper.chatIdHex(chatId) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 57cac1de0..ac5a77788 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -24,6 +24,8 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf @@ -191,6 +193,33 @@ class ChatMessageDataSource @Inject constructor( afterId = afterId, ) ?: 0 + /** Returns once the per-user database exists; reads that must not mistake "not open yet" for "empty" call this first. */ + suspend fun awaitDatabase() { + FlipcashDatabase.observeInstance().filterNotNull().first() + } + + /** The newest stored, non-deleted message in [chatId] that someone other than [selfId] sent, or `null`. */ + suspend fun newestInboundId(chatId: ChatId, selfId: ID): Long? = + db?.chatMessageDao()?.newestInboundId( + chatIdHex = mapper.chatIdHex(chatId), + selfIdHex = mapper.userIdHex(selfId), + ) + + /** + * [countInboundAfter] as a stream, so arrivals and pointer moves both update it. Resolved through + * [FlipcashDatabase.observeInstance] like [hasEverTipped]: a subscriber that starts before the + * per-user DB exists counts 0 until it does, rather than completing empty for good. + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun observeInboundCountAfter(chatId: ChatId, selfId: ID, afterId: Long): Flow = + FlipcashDatabase.observeInstance().flatMapLatest { database -> + database?.chatMessageDao()?.observeInboundCountAfter( + chatIdHex = mapper.chatIdHex(chatId), + selfIdHex = mapper.userIdHex(selfId), + afterId = afterId, + ) ?: flowOf(0) + }.distinctUntilChanged() + /** The first stored message in [chatId] past [afterId] that [selfId] did not send, or `null`. */ suspend fun firstNotSentByAfter(chatId: ChatId, selfId: ID, afterId: Long): Long? = db?.chatMessageDao()?.firstNotSentByAfter( diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/UnreadStreamReadinessTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/UnreadStreamReadinessTest.kt new file mode 100644 index 000000000..931396eff --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/UnreadStreamReadinessTest.kt @@ -0,0 +1,91 @@ +package com.flipcash.app.persistence.sources + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.converters.MessageContentSerialized +import com.flipcash.app.persistence.converters.MessagePointerSerialized +import com.flipcash.app.persistence.entities.ChatMemberEntity +import com.flipcash.app.persistence.entities.ChatMessageEntity +import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper +import com.flipcash.services.models.chat.ChatId +import com.getcode.utils.hexEncodedString +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals + +/** + * The per-user database is created at login, so the jump-to-bottom badge's streams can be + * subscribed before it exists. They must wait for it, not complete empty for good. + */ +@RunWith(RobolectricTestRunner::class) +class UnreadStreamReadinessTest { + + private val mapper = ChatEntityMapper() + private val members = ChatMemberDataSource(mapper) + private val messages = ChatMessageDataSource(mapper, mockk(relaxed = true), mockk(relaxed = true)) + + private val chatId = ChatId(listOf(0xAB.toByte())) + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + + @After + fun tearDown() { + FlipcashDatabase.closeDb() + } + + private fun openDatabase() { + FlipcashDatabase.init(ApplicationProvider.getApplicationContext(), "entropy-a") + } + + @Test + fun `the read pointer is null until the database exists and then follows it`() = runTest { + FlipcashDatabase.closeDb() + + members.observeSelfReadPointer(chatId, selfId).test { + assertEquals(null, awaitItem()) + + openDatabase() + val db = FlipcashDatabase.requireInstance() + db.chatMemberDao().upsert( + ChatMemberEntity( + chatIdHex = mapper.chatIdHex(chatId), + userIdHex = selfId.hexEncodedString(), + pointersJson = listOf( + MessagePointerSerialized(type = "READ", userIdHex = selfId.hexEncodedString(), value = 2), + ), + ), + ) + assertEquals(2L, awaitItem()) + } + } + + @Test + fun `the inbound count is 0 until the database exists and then counts`() = runTest { + FlipcashDatabase.closeDb() + + messages.observeInboundCountAfter(chatId, selfId, 1).test { + assertEquals(0, awaitItem()) + + openDatabase() + FlipcashDatabase.requireInstance().chatMessageDao().upsert( + listOf(2L, 3L).map { id -> + ChatMessageEntity( + chatIdHex = mapper.chatIdHex(chatId), + messageId = id, + senderIdHex = otherId.hexEncodedString(), + contentJson = listOf(MessageContentSerialized.Text("m$id")), + timestampEpochMs = id * 1_000, + unreadSeq = id, + ) + }, + ) + assertEquals(2, awaitItem()) + } + } +} diff --git a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt index 92c644b9f..af9731560 100644 --- a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt +++ b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt @@ -3,6 +3,7 @@ package com.flipcash.app.theme.internal import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import com.getcode.theme.BadgeFocus import com.getcode.theme.BetaIndicator import com.getcode.theme.Black40 import com.getcode.theme.BrandAccent @@ -95,6 +96,7 @@ private val colors = with(Flipcash2ColorSpec) { secondary = secondary, tertiary = BrandAccent, indicator = notification, + badgeFocus = BadgeFocus, action = Gray50, onAction = White, background = primary, diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt index 7fbd83e06..5276f7edf 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Color.kt @@ -15,6 +15,7 @@ val BrandAccent = Color(0xFF443091) val BrandIndicator = Color(0xFF31BB00) // iOS unreadIndicator: the fill of a notification dot. val UnreadDot = Color(0xFF0A84FF) +val BadgeFocus = Color(0xFF0A84FF) val BrandSlideToConfirm = Color(0xFF11142A) val BrandToggleUncheckedTrackColor = Color(0xFF201D2F) diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt index a7845e549..64b1cdbed 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt @@ -29,6 +29,7 @@ internal val CodeDefaultColorScheme = ColorScheme( secondary = BrandAccent, tertiary = BrandAccent, indicator = BrandIndicator, + badgeFocus = BadgeFocus, action = Gray50, onAction = White, background = Brand, @@ -132,6 +133,8 @@ class ColorScheme( secondary: Color, tertiary: Color, indicator: Color, + /** Fill of a count badge that asks for attention, e.g. the unread count on jump-to-bottom (Figma badge/focus). */ + badgeFocus: Color, action: Color, onAction: Color, background: Color, @@ -218,6 +221,8 @@ class ColorScheme( private set var indicator by mutableStateOf(indicator) private set + var badgeFocus by mutableStateOf(badgeFocus) + private set var action by mutableStateOf(action) private set var onAction by mutableStateOf(onAction) @@ -286,6 +291,7 @@ class ColorScheme( secondary = other.secondary tertiary = other.tertiary indicator = other.indicator + badgeFocus = other.badgeFocus action = other.action onAction = other.onAction border = other.border @@ -333,6 +339,7 @@ class ColorScheme( secondary = secondary, tertiary = tertiary, indicator = indicator, + badgeFocus = badgeFocus, action = action, onAction = onAction, border = border,