Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,10 @@
<string name="title_messageNotSent">Message Not Sent</string>
<string name="description_messageNotSent">Your message could not be delivered. Would you like to try again?</string>
<string name="label_chatSeparator_today">Today</string>
<plurals name="title_chatUnreadDivider">
<item quantity="one">%1$s Unread Message</item>
<item quantity="other">%1$s Unread Messages</item>
</plurals>

<string name="title_sendFeatureIntro">Send Money To Your Friends</string>
<string name="subtitle_sendFeatureIntro">Send money to friends as easily as a text. Connect your phone number to get started</string>
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/features/messenger/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ dependencies {
testImplementation(libs.bundles.unit.testing)
testImplementation(libs.mockito.kotlin)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.paging.testing)
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.flatMap
import androidx.paging.insertSeparators
import com.flipcash.app.analytics.Analytics
import com.flipcash.app.analytics.FlipcashAnalyticsService
import com.flipcash.app.contacts.ContactCoordinator
Expand All @@ -28,6 +27,7 @@ import com.flipcash.app.core.ui.ConfirmationStyle
import com.flipcash.app.core.util.Linkify
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.messenger.internal.link.CashCardTap
import com.flipcash.shared.chat.UnreadBoundary
import com.flipcash.app.messenger.internal.link.ClaimReplyTargets
import com.flipcash.app.messenger.internal.link.LinkCardClassifier
import com.flipcash.app.messenger.internal.link.LinkCardResolver
Expand Down Expand Up @@ -118,6 +118,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNot
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
Expand Down Expand Up @@ -264,6 +265,14 @@ internal class ChatViewModel @Inject constructor(
* it. Set alongside [jumpTarget] and cleared with it.
*/
val jumpBudget: Int? = null,
/**
* Where the "N Unread Messages" divider sits, read once per visit from the viewer's stored
* READ pointer. Never re-read: the list advances that pointer as the reader scrolls, and a
* boundary that followed it would slide the divider away from what was new at open.
*/
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,
/**
* True while the delete confirmation is up.
*
Expand Down Expand Up @@ -576,6 +585,9 @@ internal class ChatViewModel @Inject constructor(

/** The same request, once the walk's bound is known. */
data class JumpResolved(val messageId: Long, val budget: Int) : Event

/** The chat's unread boundary, read once when its id became known. */
data class UnreadBoundaryResolved(val boundary: UnreadBoundary, val walkBudget: Int) : Event
data object JumpConsumed : Event
}

Expand Down Expand Up @@ -664,8 +676,12 @@ internal class ChatViewModel @Inject constructor(
*/
private val claimReplyTargets = ClaimReplyTargets()

/**
* The transcript's rows before separators. Cached so a change to the unread boundary or the
* separator config re-runs only [messages]' separator pass, not the per-message lookups here.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val messages: Flow<PagingData<ChatListItem>> =
private val mappedMessages: Flow<PagingData<ChatListItem.ContentBubble>> =
combine(
messageStream,
pendingMutations,
Expand Down Expand Up @@ -771,13 +787,26 @@ internal class ChatViewModel @Inject constructor(
.splitAroundLinkCard()
.asReversed()
}
}.insertSeparators { before: ChatListItem.ContentBubble?, after: ChatListItem.ContentBubble? ->
if (before == null || after == null) return@insertSeparators null
if (stateFlow.value.separatorConfig.shouldSeparate(before.timestamp, after.timestamp)) {
ChatListItem.DateSeparator(before.timestamp)
} else null
}
}
}.cachedIn(viewModelScope)

/**
* The transcript as the list draws it: [mappedMessages] with date separators and the unread
* divider. The boundary and config are inputs rather than reads of the current state, so a
* page never takes its separators from one config and its neighbour's from another.
*
* Nothing is emitted while the boundary is still resolving. The list decides where to open from
* the first page it lays out, and a page drawn before the boundary landed has no divider to
* open at. Both wait on the same chat id, and the resolution is two local queries.
*/
val messages: Flow<PagingData<ChatListItem>> =
combine(
mappedMessages,
stateFlow.map { it.unreadBoundary }
.filterNot { it is UnreadBoundary.Resolving }
.distinctUntilChanged(),
stateFlow.map { it.separatorConfig }.distinctUntilChanged(),
) { paging, boundary, config -> paging.withSeparators(boundary, config) }

/**
* The citation shown for [this] message.
Expand Down Expand Up @@ -1741,6 +1770,19 @@ internal class ChatViewModel @Inject constructor(
dispatchEvent(Event.JumpResolved(event.messageId, distance))
}
.launchIn(viewModelScope)

// Read once per chat id. The list holds read reporting until this lands, so the pointer
// it reads is the one the viewer arrived with.
stateFlow.mapNotNull { it.chatId }
.distinctUntilChanged()
.onEach { chatId ->
val boundary = chatCoordinator.resolveUnreadBoundary(chatId)
val budget = (boundary as? UnreadBoundary.At)
?.let { chatCoordinator.countMessagesAfter(chatId, it.readThrough) }
?: 0
dispatchEvent(Event.UnreadBoundaryResolved(boundary, budget))
}
.launchIn(viewModelScope)
}

/** Leaves edit mode, restoring the draft the edit interrupted. */
Expand Down Expand Up @@ -2162,6 +2204,11 @@ internal class ChatViewModel @Inject constructor(
private val DRAFT_WRITE_DEBOUNCE = 300.milliseconds

val updateStateForEvent: (Event) -> ((State) -> State) = { event ->
stateReducer(event, UNREAD_DIVIDER_LIFETIME)
}

/** [updateStateForEvent] under an explicit [lifetime], so tests can cover both policies. */
internal fun stateReducer(event: Event, lifetime: UnreadDividerLifetime): (State) -> State =
when (event) {
is Event.OnChatOpened -> { state ->
when (val id = event.identifier) {
Expand Down Expand Up @@ -2274,7 +2321,11 @@ internal class ChatViewModel @Inject constructor(
is Event.ResolveFailed -> { state ->
state.copy(resolveState = ResolveState.Failed)
}
is Event.SendMessage -> { state -> state }
is Event.SendMessage -> { state ->
val clears = lifetime == UnreadDividerLifetime.UntilSend &&
state.unreadBoundary is UnreadBoundary.At
if (clears) state.copy(unreadBoundary = UnreadBoundary.None) else state
}
is Event.RetryMessage -> { state -> state }
Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
Event.NavigateToInitPayment -> { state -> state.copy(sendProgress = LoadingSuccessState()) }
Expand Down Expand Up @@ -2385,7 +2436,9 @@ internal class ChatViewModel @Inject constructor(
state.copy(jumpTarget = event.messageId, jumpBudget = event.budget)
}
Event.JumpConsumed -> { state -> state.copy(jumpTarget = null, jumpBudget = null) }
is Event.UnreadBoundaryResolved -> { state ->
state.copy(unreadBoundary = event.boundary, unreadWalkBudget = event.walkBudget)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.flipcash.app.messenger.internal

import androidx.paging.PagingData
import androidx.paging.insertSeparators
import com.flipcash.shared.chat.UnreadBoundary
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.models.SeparatorConfig

/** How long the unread divider stays once a chat is open. iOS carries the same switch. */
internal enum class UnreadDividerLifetime {
/** The whole visit. */
UntilClose,

/** Until the viewer sends a message. */
UntilSend,
}

internal val UNREAD_DIVIDER_LIFETIME = UnreadDividerLifetime.UntilClose

/**
* Whether the unread divider goes in the gap between [newer] and [older]. [older] is `null` at the
* end of pagination, when every stored message is unread.
*
* Compares ranges rather than matching an id, so a read-through message that was deleted or never
* stored does not matter. The rows of one message share a `messageId`, so the divider never lands
* inside a message.
*/
internal fun unreadDividerBetween(
newer: ChatListItem.ContentBubble,
older: ChatListItem.ContentBubble?,
boundary: UnreadBoundary,
): Boolean {
if (boundary !is UnreadBoundary.At || newer.isFromSelf) return false
val olderId = older?.messageId ?: Long.MIN_VALUE
return olderId <= boundary.readThrough && boundary.readThrough < newer.messageId
}

/** The one item that goes in the gap between [newer] and [older] in the newest-first list, if any. */
internal fun separatorBetween(
newer: ChatListItem.ContentBubble?,
older: ChatListItem.ContentBubble?,
boundary: UnreadBoundary,
config: SeparatorConfig,
): ChatListItem? {
newer ?: return null
if (boundary is UnreadBoundary.At && unreadDividerBetween(newer, older, boundary)) {
// The oldest stored message has no separator of its own; the list draws its date as a
// trailing header. The divider sits there instead, so it carries that date.
val dayChanges = older == null || config.shouldSeparate(newer.timestamp, older.timestamp)
return ChatListItem.UnreadDivider(boundary.count, date = newer.timestamp.takeIf { dayChanges })
}
older ?: return null
return if (config.shouldSeparate(newer.timestamp, older.timestamp)) {
ChatListItem.DateSeparator(newer.timestamp)
} else null
}

internal fun PagingData<ChatListItem.ContentBubble>.withSeparators(
boundary: UnreadBoundary,
config: SeparatorConfig,
): PagingData<ChatListItem> =
insertSeparators { newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble? ->
separatorBetween(newer, older, boundary, config)
}

/** The count the divider shows: past 99 it reads "99+". */
internal fun unreadCountLabel(count: Int): String = if (count > 99) "99+" else count.toString()
Loading
Loading