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
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import com.flipcash.shared.chat.models.SenderIdentity
import com.flipcash.shared.chat.models.ChatQuote
import com.flipcash.shared.chat.models.ChatQuoteSnippet
import com.flipcash.shared.chat.models.LinkCard
import com.flipcash.shared.chat.models.LinkCardResolution
import com.flipcash.shared.chat.models.ReceiptStatus
import com.flipcash.shared.chat.models.SeparatorConfig
import com.flipcash.shared.chat.ui.detectUrls
Expand Down Expand Up @@ -459,14 +460,13 @@ internal class ChatViewModel @Inject constructor(
?.let { ChatParticipant.TipUser(userId, it) }

/**
* Bumped when a link card's answer is known to have moved, so the transcript maps again.
* How a drawn card reaches the lookup. Provided to the transcript, read by `LinkCardView`.
*
* The transform below only re-runs when one of the flows it combines emits, and none of them
* notices a claim: claiming a cash link refreshes the activity feed, not this chat's messages.
* Without this the reader taps a voucher, claims it, comes back to the transcript and finds a
* card that still says "Tap to claim".
* The resolver rather than the transcript is what a card asks, so the type narrows to the part
* of it `chat-ui` is allowed to see — the query and the signal that an answer moved, not the
* eviction that produces the signal, which stays this screen's to decide.
*/
private val cardRevision = MutableStateFlow(0)
val linkCardResolution: LinkCardResolution get() = linkCardResolver

/**
* Where a claim goes back to, for a voucher tapped in this transcript.
Expand All @@ -484,8 +484,7 @@ internal class ChatViewModel @Inject constructor(
pendingMutations,
messagePolicy,
senderProfiles,
cardRevision,
) { pagingData, mutations, policy, profiles, _ ->
) { pagingData, mutations, policy, profiles ->
pagingData.flatMap { stored ->
val message = stored.applying(mutations[stored.messageId])
message.content.mapIndexed { index, content ->
Expand All @@ -508,12 +507,14 @@ internal class ChatViewModel @Inject constructor(

// Built from the same detection pass the bubble underlines with, so the
// link that becomes a card is always one the reader can see is a link.
// Resolution is memoized per entropy: the first pass over a message pays the
// query, every re-map after it is free, and a message whose query has not
// returned renders unresolved and picks the amount up on the next pass.
//
// Classified only. The card comes out of here in its loading state and runs
// its own lookup -- see `LinkCardResolution`. This pass used to await that,
// which meant a chat painted nothing until every link in the first window had
// been round-tripped, and that every link in every mapped message was queried
// whether or not the reader ever scrolled to it.
val linkCard = enriched.linkableText()
?.let { text -> linkCardClassifier.firstCard(detectUrls(text)) }
?.let { card -> linkCardResolver.resolve(card) }

// Noted while the voucher and the message it came on are in the same hand;
// see [ClaimReplyTargets]. Skipped for the reader's own messages, which is what
Expand Down Expand Up @@ -766,10 +767,10 @@ internal class ChatViewModel @Inject constructor(
* come from.
*
* **This device.** The reader taps a voucher, the link goes back out through the URL handler,
* and the shell claims it over a bill drawn on top of this screen. The transcript is never
* told; it is still composed, still holding the answer it drew the voucher from, and the
* answer has just stopped being true. [CashLinkClaims] names the entropy, the resolver forgets
* it, and the re-map tears that one voucher in place. Every settled attempt, not only a
* and the shell claims it over a bill drawn on top of this screen. The card is never told; it
* is still composed, still showing the answer it was drawn from, and the answer has just
* stopped being true. [CashLinkClaims] names the entropy, the resolver forgets it and bumps its
* revision, and the card re-asks and tears in place. Every settled attempt, not only a
* successful one, because "already claimed" and "expired" are the same news arriving as an
* error.
*
Expand All @@ -789,7 +790,6 @@ internal class ChatViewModel @Inject constructor(
cashLinkClaims.settledClaims
.onEach { claim ->
linkCardResolver.invalidateCash(claim.entropy)
cardRevision.update { it + 1 }
thankForClaim(claim)
}
.launchIn(viewModelScope)
Expand All @@ -813,14 +813,12 @@ internal class ChatViewModel @Inject constructor(
* Re-asks about any voucher still drawn as claimable.
*
* Not a poll in the expensive sense: [LinkCardResolver.refreshClaimable] drops nothing unless
* the transcript is holding a card that says `Claimable`, and no drop means no re-map and so
* no query. A chat with no cash link in view — nearly all of them — costs a lock and a walk of
* an empty map per tick. A claimed or expired card is terminal and is never asked about again.
* an answer it is holding says `Claimable`, and no drop means no revision bump and so no
* query. A chat with no cash link in view — nearly all of them — costs a walk of an empty map
* per tick. A claimed or expired card is terminal and is never asked about again.
*/
private suspend fun refreshLinkCards() {
if (linkCardResolver.refreshClaimable()) {
cardRevision.update { it + 1 }
}
linkCardResolver.refreshClaimable()
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ internal class LinkCardClassifier @Inject constructor(
/**
* The first card-eligible link wins; at most one card per message.
*
* The card comes out loading, never resolved: classifying is all that can be done from the
* message text, and the lookup that fills the rest in belongs to the card that draws it.
*
* Takes the detected links rather than their URLs because the card carries the span it came
* from: the bubble draws the card in place of that text, and only the detection pass knows
* where it sat.
Expand All @@ -58,15 +61,15 @@ internal class LinkCardClassifier @Inject constructor(
start = link.start,
end = link.end,
entropy = it,
state = LinkCard.Cash.State.Unresolved,
state = LinkCard.Cash.State.Loading,
)
}
is DeeplinkType.TokenInfo -> LinkCard.TokenInfo(
url = target,
start = link.start,
end = link.end,
mint = type.mint,
state = LinkCard.TokenInfo.State.Unresolved,
state = LinkCard.TokenInfo.State.Loading,
)
else -> null
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
package com.flipcash.app.messenger.internal.link

import com.flipcash.shared.chat.models.LinkCard
import com.flipcash.shared.chat.models.LinkCardResolution
import com.getcode.opencode.model.financial.Token
import com.getcode.solana.keys.Mint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.ConcurrentHashMap

/**
* Fills a link card in, without acting on the link.
Expand All @@ -22,23 +27,22 @@ import kotlinx.coroutines.sync.withLock
*
* A token link needs only the mint's metadata, which the wallet already caches.
*
* The query runs in [scope] rather than in the caller's coroutine. The caller is a paging
* transform, which is re-run and cancelled every time anything upstream of the transcript emits —
* a delivery receipt, a sender profile, a policy change. A query awaited inline dies with that
* pass, and the cancellation arrives here as an ordinary failure, so the card renders unresolved
* for a reason that has nothing to do with the link. Owning the scope means a cancelled pass
* cancels only its own [Deferred.await]; the query finishes and the next pass reads the answer.
* The query runs in [scope] rather than in the caller's coroutine. The caller is a card that has
* just been composed, and it is cancelled by an ordinary scroll. A query awaited inline dies with
* the card, and the cancellation arrives here as an ordinary failure, so the next card to ask about
* the same link starts over. Owning the scope means a card leaving the screen cancels only its own
* [Deferred.await]; the query finishes and the answer is there for whoever asks next.
*
* Failure of any kind — offline, timeout, malformed entropy, an unknown mint, a kill switch —
* returns the card unchanged, in its unresolved state, and drops the query so a later pass asks
* returns the card unchanged, in its unresolved state, and drops the query so a later card asks
* again. The card has no error state by design: a link that has not resolved is one the reader
* can still open.
*/
internal class LinkCardResolver(
private val scope: CoroutineScope,
private val giftCard: suspend (entropy: String) -> Result<Snapshot>,
private val tokenMetadata: suspend (mint: Mint) -> Result<Token>,
) {
) : LinkCardResolution {

data class Snapshot(
val amount: String,
Expand All @@ -49,53 +53,84 @@ internal class LinkCardResolver(
private val mutex = Mutex()

/**
* The query per key, not the answer: memoizing the [Deferred] is what makes the several passes
* that map the same message at once share one query instead of racing each other to the same
* result. Kept per card kind so an entropy and a mint cannot collide on one key.
* The query per key, not the answer: memoizing the [Deferred] is what makes the several cards
* that come on screen holding the same link share one query instead of racing each other to the
* same result. Kept per card kind so an entropy and a mint cannot collide on one key.
*/
private val cashQueries = mutableMapOf<String, Deferred<LinkCard.Cash.State>>()
private val tokenQueries = mutableMapOf<Mint, Deferred<LinkCard.TokenInfo.State>>()

suspend fun resolve(card: LinkCard): LinkCard = when (card) {
/**
* The answers that have landed, readable without the lock and without suspending — which is the
* whole reason they are kept a second time rather than read back off [cashQueries].
*
* [peek] is called from composition, and a card that has already resolved has to be able to
* paint resolved on its first frame. Reaching a [Deferred] means taking [mutex], which means
* suspending, which means a frame of shimmer on a link the reader watched resolve a moment ago.
* So the query map stays the thing that dedupes concurrent askers, and this is the thing that
* answers instantly.
*
* Only successes are written. A failure is forgotten rather than remembered, so there is
* nothing here to stop the next card asking again.
*/
private val cashAnswers = ConcurrentHashMap<String, LinkCard.Cash.State.Resolved>()
private val tokenAnswers = ConcurrentHashMap<Mint, LinkCard.TokenInfo.State.Resolved>()

private val _revision = MutableStateFlow(0)
override val revision: StateFlow<Int> = _revision.asStateFlow()

override fun peek(card: LinkCard): LinkCard? = when (card) {
is LinkCard.Cash -> cashAnswers[card.entropy]?.let { card.copy(state = it) }
is LinkCard.TokenInfo -> tokenAnswers[card.mint]?.let { card.copy(state = it) }
}

override suspend fun resolve(card: LinkCard): LinkCard = when (card) {
is LinkCard.Cash -> card.copy(state = cashState(card.entropy))
is LinkCard.TokenInfo -> card.copy(state = tokenState(card.mint))
}

/**
* Drops what is held about [entropy], so the next pass over the transcript asks again.
* Drops what is held about [entropy] and tells the cards on screen to ask again.
*
* Claim state is the one thing a card draws that moves while the reader is looking at it, and
* the memo is what makes a scroll cheap. Both hold: the answer is kept until something says it
* has changed. Invalidating alone does not redraw anything — it makes the next mapping pass
* honest, and the caller is responsible for there being one.
* has changed. The [revision] bump is that something reaching the card — it used to be the
* transcript re-mapping its whole paging window to redraw one voucher.
*/
suspend fun invalidateCash(entropy: String) = forget(cashQueries, entropy)
suspend fun invalidateCash(entropy: String) {
cashAnswers.remove(entropy)
forget(cashQueries, entropy)
_revision.update { it + 1 }
}

/**
* Drops every cash link still sitting on [LinkCard.Cash.Claim.Claimable], and reports whether
* it dropped any.
*
* The blind spot [invalidateCash] cannot cover: a link claimed by someone else, which nothing
* tells this device about. Asking is the only way to find out, so the caller asks on a timer —
* and the return value is what keeps that from being a poll in the usual sense. A transcript
* with no claimable card drops nothing, the caller re-maps nothing, and the tick costs a lock
* and a walk of a map that is almost always empty. Only a chat that is actually showing an
* unclaimed voucher pays for a query, which is the only chat whose answer can still move.
* and dropping nothing is what keeps that from being a poll in the usual sense. A transcript
* with no claimable card drops nothing, bumps nothing, and so asks nothing; the tick costs a
* walk of a map that is almost always empty. Only a chat actually showing an unclaimed voucher
* pays for a query, which is the only chat whose answer can still move.
*
* [LinkCard.Cash.Claim.Claimed] and [LinkCard.Cash.Claim.Expired] are terminal and kept: a
* claimed link does not become claimable again. An in-flight query is kept too — it is already
* asking, and dropping it would only start the same question over.
* claimed link does not become claimable again. An in-flight query is kept too — it has no
* answer here yet, it is already asking, and dropping it would only start the same question
* over.
*/
@OptIn(ExperimentalCoroutinesApi::class)
suspend fun refreshClaimable(): Boolean = mutex.withLock {
val stale = cashQueries.filterValues { query ->
val state = query.takeIf { it.isCompleted }
?.runCatching { getCompleted() }
?.getOrNull()
(state as? LinkCard.Cash.State.Resolved)?.claim == LinkCard.Cash.Claim.Claimable
}.keys.toList()
stale.forEach { cashQueries.remove(it) }
stale.isNotEmpty()
suspend fun refreshClaimable(): Boolean {
val stale = cashAnswers
.filterValues { it.claim == LinkCard.Cash.Claim.Claimable }
.keys
if (stale.isEmpty()) return false

stale.forEach {
cashAnswers.remove(it)
forget(cashQueries, it)
}
_revision.update { it + 1 }
return true
}

/** Ends the queries with the screen that asked for them. */
Expand All @@ -111,7 +146,7 @@ internal class LinkCardResolver(
amount = it.amount,
claim = it.claim,
token = it.token,
)
).also { resolved -> cashAnswers[entropy] = resolved }
},
onFailure = { forget(cashQueries, entropy); LinkCard.Cash.State.Unresolved },
)
Expand All @@ -120,7 +155,10 @@ internal class LinkCardResolver(
private suspend fun tokenState(mint: Mint): LinkCard.TokenInfo.State =
memoized(tokenQueries, mint) {
tokenMetadata(mint).fold(
onSuccess = { LinkCard.TokenInfo.State.Resolved(token = it) },
onSuccess = {
LinkCard.TokenInfo.State.Resolved(token = it)
.also { resolved -> tokenAnswers[mint] = resolved }
},
onFailure = { forget(tokenQueries, mint); LinkCard.TokenInfo.State.Unresolved },
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
separatorConfig = state.separatorConfig,
otherReadPointer = otherReadPointer,
onAction = chatActionHandler,
linkCardResolution = viewModel.linkCardResolution,
canViewProfile = state.canViewProfile,
onJumpConsumed = { viewModel.dispatchEvent(ChatViewModel.Event.JumpConsumed) },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ import com.flipcash.services.models.chat.MessagePointer
import com.flipcash.shared.chat.models.ChatAction
import com.flipcash.shared.chat.models.ChatActionHandler
import com.flipcash.shared.chat.models.ChatListItem
import com.flipcash.shared.chat.models.LinkCardResolution
import com.flipcash.shared.chat.models.LocalChatActionHandler
import com.flipcash.shared.chat.models.LocalLinkCardResolution
import com.flipcash.shared.chat.models.SeparatorConfig
import com.getcode.theme.CodeTheme
import com.getcode.ui.utils.rememberKeyboardController
Expand All @@ -64,6 +66,7 @@ internal fun MessageList(
separatorConfig: SeparatorConfig,
otherReadPointer: MessagePointer? = null,
onAction: ChatActionHandler,
linkCardResolution: LinkCardResolution,
canViewProfile: Boolean,
onJumpConsumed: () -> Unit = {},
) {
Expand All @@ -74,7 +77,13 @@ internal fun MessageList(
// row: a DM needs no avatar column, a group reserves one on every incoming row.
val isGroup = state.chatType == ChatType.GROUP

CompositionLocalProvider(LocalChatActionHandler provides onAction) {
// Both provided here rather than passed down: a link card is drawn from several places inside
// the bubble, and threading either through every one of them would be a parameter on a lot of
// composables that have no other reason to know about links.
CompositionLocalProvider(
LocalChatActionHandler provides onAction,
LocalLinkCardResolution provides linkCardResolution,
) {
HandleMessageReads(listState, messages)

// Haptic feedback when a new incoming message arrives
Expand Down
Loading
Loading