Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e8f25cc
feat(messenger): jump-to-bottom button with unread badge
Oct 14, 2026
c3b19da
fix(messenger): hide jump-to-bottom while a message is raised
Oct 14, 2026
19e6cae
fix(messenger): keep the jump-to-bottom button down while the list au…
Oct 14, 2026
8f23275
test(chat): assert the unread badge as a live emission sequence
Oct 14, 2026
14de3b4
fix(messenger): centre the unread pill on the button corner and annou…
Oct 14, 2026
b1fbdac
fix(messenger): let the unread badge wait for the account and database
Oct 14, 2026
df0f319
fix(messenger): hide jump-to-bottom while a message is selected
Oct 14, 2026
028d4b2
feat(messenger): hold the jump-to-bottom badge at the divider's count…
Oct 14, 2026
aadcaf0
test(chat): cover the badge freeze and what releases it
Oct 14, 2026
44053f3
test(persistence): cover the newest-inbound id query
Oct 14, 2026
a8b5721
fix(messenger): wait for the account and database before the open sna…
Oct 14, 2026
49b4af0
fix(messenger): count a thank-you reply as an own message sent
Oct 14, 2026
a13bba5
test(chat): update boundary resolution for the readiness wait
Oct 14, 2026
9955853
fix(messenger): cut off badge arrivals at the read pointer as well as…
bmc08gt Oct 14, 2026
30915d5
fix(messenger): update the badge's held count from an effect, not com…
bmc08gt Oct 14, 2026
9358e11
fix(messenger): record the badge arrival cutoff after the opening load
bmc08gt Oct 14, 2026
a845249
fix(messenger): wait for the first successful newest-page load before…
bmc08gt Oct 14, 2026
cf39594
test(chat): cover the badge cutoff waiting for the first newest-page …
bmc08gt Oct 14, 2026
3551228
fix(chat): drop the gradient behind the jump-to-bottom button
bmc08gt Oct 14, 2026
f2feda2
fix(chat): line the jump-to-bottom button up with the $ button
bmc08gt Oct 14, 2026
3f60911
test(chat): assert a system notice leaves the badge, cutoff and divid…
bmc08gt Oct 14, 2026
eead7a8
fix(chat): place the jump-to-bottom button and the composer row from …
bmc08gt Oct 14, 2026
ac98eeb
fix(chat): give the jump-to-bottom button the composer's glass material
bmc08gt Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1299,6 +1299,11 @@
<string name="title_shareNoChats">No chats yet</string>
<string name="subtitle_shareNoChats">Start a chat in Flipcash, then share again.</string>
<string name="label_shareAttachedCount">%1$d attached</string>
<string name="action_jumpToBottom">Jump to latest message</string>
<plurals name="action_jumpToBottomUnread">
<item quantity="one">Jump to latest message, %1$d unread</item>
<item quantity="other">Jump to latest message, %1$d unread</item>
</plurals>
<string name="hint_addAMessage">Add a message\u2026</string>
<string name="title_tipIntro">Receive Tips From Everyone</string>
<string name="subtitle_tipIntro">Add your name to receive tips</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
*
Expand Down Expand 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
}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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}") }
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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}") }
}
}
Expand Down Expand Up @@ -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)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ChatViewModel.State>,
live: Flow<Int>,
arrivalsAfter: (afterId: Long) -> Flow<Int>,
): Flow<Int> {
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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
}
}
Expand Down
Loading
Loading