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 50f63dab98..6b425d1fcd 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 @@ -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 @@ -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. @@ -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 -> @@ -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 @@ -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. * @@ -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) @@ -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() } /** diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt index 7762f90ad8..fbde976c25 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifier.kt @@ -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. @@ -58,7 +61,7 @@ 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( @@ -66,7 +69,7 @@ internal class LinkCardClassifier @Inject constructor( start = link.start, end = link.end, mint = type.mint, - state = LinkCard.TokenInfo.State.Unresolved, + state = LinkCard.TokenInfo.State.Loading, ) else -> null } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt index cee609981f..6d6f95e80a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolver.kt @@ -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. @@ -22,15 +27,14 @@ 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. */ @@ -38,7 +42,7 @@ internal class LinkCardResolver( private val scope: CoroutineScope, private val giftCard: suspend (entropy: String) -> Result, private val tokenMetadata: suspend (mint: Mint) -> Result, -) { +) : LinkCardResolution { data class Snapshot( val amount: String, @@ -49,27 +53,55 @@ 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>() private val tokenQueries = mutableMapOf>() - 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() + private val tokenAnswers = ConcurrentHashMap() + + private val _revision = MutableStateFlow(0) + override val revision: StateFlow = _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 @@ -77,25 +109,28 @@ internal class LinkCardResolver( * * 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. */ @@ -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 }, ) @@ -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 }, ) } 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 5cfe878e71..ae665eec09 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 @@ -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) }, ) 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 c52f4419f6..ed37703134 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 @@ -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 @@ -64,6 +66,7 @@ internal fun MessageList( separatorConfig: SeparatorConfig, otherReadPointer: MessagePointer? = null, onAction: ChatActionHandler, + linkCardResolution: LinkCardResolution, canViewProfile: Boolean, onJumpConsumed: () -> Unit = {}, ) { @@ -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 diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt index c1cd4c9eb5..d99d8e814b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardClassifierTest.kt @@ -84,9 +84,9 @@ class LinkCardClassifierTest { val card = actual as? LinkCard.Cash assertEquals(expectedCard.getString("url"), card?.url, "vector `$name`: $note") assertEquals( - LinkCard.Cash.State.Unresolved, + LinkCard.Cash.State.Loading, card?.state, - "vector `$name` must start unresolved", + "vector `$name` must start with its lookup still to do", ) } @@ -99,9 +99,9 @@ class LinkCardClassifierTest { "vector `$name` must carry the mint from the path", ) assertEquals( - LinkCard.TokenInfo.State.Unresolved, + LinkCard.TokenInfo.State.Loading, card?.state, - "vector `$name` must start unresolved", + "vector `$name` must start with its lookup still to do", ) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt index 1ebbf72fdc..ca4fa86926 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/link/LinkCardResolverTest.kt @@ -8,6 +8,7 @@ import org.mockito.kotlin.mock import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class LinkCardResolverTest { @@ -21,7 +22,7 @@ class LinkCardResolverTest { start = 0, end = 54, entropy = "KNi8pQr1n5hRU65vKJGge3", - state = LinkCard.Cash.State.Unresolved, + state = LinkCard.Cash.State.Loading, ) private val tokenCard = LinkCard.TokenInfo( @@ -29,7 +30,7 @@ class LinkCardResolverTest { start = 0, end = 74, mint = Mint(MINT), - state = LinkCard.TokenInfo.State.Unresolved, + state = LinkCard.TokenInfo.State.Loading, ) private fun snapshot() = LinkCardResolver.Snapshot( @@ -242,4 +243,83 @@ class LinkCardResolverTest { assertEquals(1, cashCalls) assertEquals(1, tokenCalls) } + + @Test + fun `peek answers nothing until the lookup has landed`() = runTest { + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.success(snapshot()) }, + tokenMetadata = { Result.success(mock()) }, + ) + + assertNull(resolver.peek(card), "nothing has been asked yet") + + resolver.resolve(card) + + // The point of the whole memo: a card scrolled back into view has its answer without + // suspending, so it paints resolved on its first frame instead of shimmering for a hop. + val peeked = resolver.peek(card) as? LinkCard.Cash + assertTrue(peeked?.state is LinkCard.Cash.State.Resolved) + } + + @Test + fun `peek answers nothing for a lookup that failed`() = runTest { + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.failure(IllegalStateException("offline")) }, + tokenMetadata = { Result.failure(IllegalStateException("offline")) }, + ) + resolver.resolve(card) + + // A failure is forgotten rather than remembered, so the next card to draw this link asks + // again -- and shimmers while it does, rather than peeking a stale "unavailable". + assertNull(resolver.peek(card)) + } + + @Test + fun `a claim bumps the revision so a drawn card asks again`() = runTest { + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.success(snapshot()) }, + tokenMetadata = { Result.success(mock()) }, + ) + resolver.resolve(card) + val before = resolver.revision.value + + resolver.invalidateCash(card.entropy) + + assertTrue(resolver.revision.value > before, "the card on screen has nothing else to hear") + assertNull(resolver.peek(card), "the answer it would have peeked is the stale one") + } + + @Test + fun `a tick that drops nothing bumps nothing`() = runTest { + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.success(snapshot().copy(claim = LinkCard.Cash.Claim.Claimed)) }, + tokenMetadata = { Result.success(mock()) }, + ) + resolver.resolve(card) + val before = resolver.revision.value + + assertFalse(resolver.refreshClaimable()) + + // What keeps the claim timer from being a poll: a transcript with nothing claimable in it + // re-asks nothing, because nothing told it to. + assertEquals(before, resolver.revision.value) + } + + @Test + fun `a claimable tick bumps the revision`() = runTest { + val resolver = LinkCardResolver( + scope = backgroundScope, + giftCard = { Result.success(snapshot()) }, + tokenMetadata = { Result.success(mock()) }, + ) + resolver.resolve(card) + val before = resolver.revision.value + + assertTrue(resolver.refreshClaimable()) + assertTrue(resolver.revision.value > before) + } } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt index 7344d2f0a7..6318d10a63 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCard.kt @@ -9,6 +9,10 @@ import com.getcode.solana.keys.Mint * Display-ready by construction: whoever builds it has already classified the URL and formatted * the amount, so `chat-ui` needs neither the router nor a currency formatter. See * `LinkCardClassifier` and `LinkCardResolver` in `:apps:flipcash:features:messenger`. + * + * The classifier builds the card in its [Cash.State.Loading] / [TokenInfo.State.Loading] state and + * the card view carries it the rest of the way through [LinkCardResolution], so a transcript is + * never held back by a lookup and only a link somebody is looking at costs a query. */ sealed interface LinkCard { @@ -33,10 +37,19 @@ sealed interface LinkCard { ) : LinkCard { sealed interface State { + /** + * The lookup is out. Drawn as [Unresolved] with the fields that are about to change + * shimmering, rather than as a skeleton of its own: the branded ticket is already + * real information, and replacing it with a grey rectangle would show the reader less + * than the raw link did while they waited. + */ + data object Loading : State + /** * Branded, no network. Also what a failed, timed-out, offline or switched-off * resolution renders as — a cash link card never shows an error, because the - * link is still perfectly openable and the card is decoration over it. + * link is still perfectly openable and the card is decoration over it. A failure is + * therefore the shimmer stopping, and nothing else moving. */ data object Unresolved : State @@ -69,6 +82,9 @@ sealed interface LinkCard { ) : LinkCard { sealed interface State { + /** The lookup is out. [Unresolved], shimmering; see [Cash.State.Loading]. */ + data object Loading : State + /** * No metadata yet, and what a failed, timed-out or offline lookup renders as. The mint * is all that is known, so the card names it and nothing else — a link to a token diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCardResolution.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCardResolution.kt new file mode 100644 index 0000000000..b5fd6d4ba7 --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCardResolution.kt @@ -0,0 +1,60 @@ +package com.flipcash.shared.chat.models + +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * How a card fills itself in. + * + * The lookup lives in `:apps:flipcash:features:messenger`, which `chat-ui` cannot see, and the + * suspension belongs to the card rather than to whoever built it — so this is the seam between + * them. `LinkCardResolver` on the far side is the implementation; nothing else implements it. + * + * The card view calls [peek] during composition and [resolve] off it. That split is the whole + * point of the interface: a card scrolled back into view has an answer already, and a shimmer for + * one frame on a link the reader has seen resolve is worse than the wait it stands in for. + */ +interface LinkCardResolution { + + /** + * Bumped when an answer this has already given has stopped being true, so a card on screen + * asks again. + * + * The transcript used to carry this: a claim evicted the answer and the whole paging window + * re-mapped to redraw one voucher. With the lookup inside the card there is nothing to re-map, + * and this is what reaches the card instead. It says only *that* something moved, not what — + * the cards re-ask and the memo answers every one whose answer did not change. + * + * A [StateFlow] rather than a plain flow so a card composed after a claim reads the count that + * is current at its first frame. Starting every card at zero would have each of them see one + * spurious change on its first collection, and re-ask for an answer it had just peeked. + */ + val revision: StateFlow + + /** + * The answer already held for [card], or null if none is — never a query, never a suspension. + * + * Read during composition, so a card whose link has already resolved paints resolved on its + * first frame. + */ + fun peek(card: LinkCard): LinkCard? + + /** [card] with its state filled in, or the card unchanged if the lookup could not answer. */ + suspend fun resolve(card: LinkCard): LinkCard +} + +/** + * Leaves every card exactly as it was handed in. + * + * What a preview gets, which is why it is identity rather than a stub that resolves or fails: a + * preview states the card it wants to draw and this hands it straight back, loading state included. + */ +private object NoLinkCardResolution : LinkCardResolution { + override val revision: StateFlow = MutableStateFlow(0) + override fun peek(card: LinkCard): LinkCard = card + override suspend fun resolve(card: LinkCard): LinkCard = card +} + +val LocalLinkCardResolution = + staticCompositionLocalOf { NoLinkCardResolution } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt index 2b5e1fadaf..239e4d6567 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/LinkCardView.kt @@ -7,14 +7,23 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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 @@ -28,6 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity @@ -36,8 +46,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.flipcash.app.core.ui.TokenCard -import com.flipcash.app.core.ui.TokenIconWithName +import com.flipcash.app.core.ui.TokenIcon +import com.flipcash.app.core.ui.rememberShimmerAlpha +import com.flipcash.app.core.ui.shimmer import com.flipcash.shared.chat.models.LinkCard +import com.flipcash.shared.chat.models.LocalLinkCardResolution import com.getcode.opencode.model.financial.Token import com.getcode.solana.keys.Mint import com.getcode.theme.CodeTheme @@ -57,6 +70,10 @@ import com.getcode.ui.utils.ConstraintMode * ornament above it, so [onClick] has to carry the tap — a link-only message would otherwise draw * something that opens nothing. The whole card is handed back rather than its URL, because where a * tap should land differs by kind and only the caller knows the transcript it is landing in. + * + * [card] arrives with its lookup still to do and the card runs it — see [rememberResolvedCard]. + * The transcript hands over what it can read off the message text, which is everything but the + * amount and the claim, and is not held up by the rest. */ @Composable internal fun LinkCardView( @@ -64,27 +81,67 @@ internal fun LinkCardView( onClick: (LinkCard) -> Unit, modifier: Modifier = Modifier, ) { + val live = rememberResolvedCard(card) + // The voucher's proportions, not its size: a bubble is a good deal narrower than the wallet's // card, and scaling the height with the width is what keeps it a card in chat instead of a // tall panel. BoxWithConstraints(modifier = modifier.fillMaxWidth()) { val height: Dp = maxWidth * LinkCardDefaults.CARD_ASPECT - when (card) { + when (live) { is LinkCard.Cash -> CashLinkCard( - card = card, + card = live, height = height, - onClick = { onClick(card) }, + onClick = { onClick(live) }, ) is LinkCard.TokenInfo -> TokenLinkCard( - card = card, + card = live, height = height, - onClick = { onClick(card) }, + onClick = { onClick(live) }, ) } } } +/** + * [card] with whatever the lookup has to say about it, asked for here rather than upstream. + * + * The transcript used to await this while mapping a page, which meant a chat painted nothing until + * every link in the first window had come back from the network, and that every link in it was + * queried whether or not it was ever scrolled to. Asking from the card inverts both: the transcript + * paints at once, and only a card someone is looking at costs a query. + * + * Three pieces, and each is load-bearing. + * + * [LinkCardResolution.peek] runs during composition, so a link that has already resolved — scrolled + * off and back, or drawn a second time — paints resolved on its first frame. Without it the memo is + * still a suspension away and every such card would shimmer for a hop, which is worse than the wait + * it stands for because the reader has already seen the answer. + * + * The held value is seeded once per card and written over, never reset, so a re-ask keeps the + * answer on screen while it runs. A voucher re-checked on the claim timer must not blink back to a + * shimmer to tell the reader nothing changed. + * + * [LinkCardResolution.revision] is what a claim reaches a drawn card through. It replaces a re-map + * of the whole paging window, which is what the transcript needed when it was the one holding the + * answer. + */ +@Composable +private fun rememberResolvedCard(card: LinkCard): LinkCard { + val resolution = LocalLinkCardResolution.current + val revision by resolution.revision.collectAsState() + var live by remember(card) { mutableStateOf(resolution.peek(card) ?: card) } + + // Keyed on the card, so a row recomposed onto a different message drops the previous link's + // query instead of finishing it into the new card. + LaunchedEffect(card, revision) { + live = resolution.resolve(card) + } + + return live +} + @Composable private fun CashLinkCard( card: LinkCard.Cash, @@ -96,8 +153,16 @@ private fun CashLinkCard( // moves or resizes when the amount lands. val state = card.state as? LinkCard.Cash.State.Resolved + // Loading is that same voucher with the amount standing in place and pulsing. It is not a third + // rendering: the ticket, its brand, its proportions and the fact that it opens are all known + // without the network and are drawn the moment the message is, so the amount is the only thing + // left to say is still coming. A failure stops the pulse and the slot goes quiet and empty, + // which is the unresolved card, which is the one a reader can still tap. + val loading = card.state is LinkCard.Cash.State.Loading + CashVoucher( height = height, + loading = loading, tokenName = state?.token?.let { displayNameOf(it) } ?: stringResource(R.string.label_linkCard_cash), tokenImage = state?.token?.imageUrl, @@ -111,7 +176,19 @@ private fun CashLinkCard( torn = state?.claim == LinkCard.Cash.Claim.Claimed, onClick = onClick, ) { - state ?: return@CashVoucher + if (state == null) { + // The tap is live before the lookup is — the whole voucher is clickable in every state, + // and opening the link is where claiming happens — so the stub carries the claim + // straight away rather than holding an empty slot over a control that already works. + // + // Claimable is the assumption until the lookup says otherwise, which means a voucher + // that turns out to be spent shows the offer for as long as the lookup takes and then + // withdraws it. That is the trade: an offer that is occasionally retracted, against a + // claim that is always late. Claimed and Expired both land on a dimmed card with the + // label under the tear, so the withdrawal is at least unmistakable when it happens. + StubPill(stringResource(R.string.label_linkCard_claim)) + return@CashVoucher + } when (state.claim) { LinkCard.Cash.Claim.Claimed -> // Off the paper: the stub this used to sit on left with whoever claimed it. @@ -121,11 +198,44 @@ private fun CashLinkCard( // link -- the bubble sits on the sender's side -- so a card that read differently for // the issuer would be saying it twice, and saying it in the one place both people are // looking at the same object. - LinkCard.Cash.Claim.Claimable -> ClaimPill(stringResource(R.string.label_linkCard_claim)) + LinkCard.Cash.Claim.Claimable -> StubPill(stringResource(R.string.label_linkCard_claim)) } } } +/** + * The voucher's top line: the token's icon and name, or just the name until there is a token. + * + * Not [TokenIconWithName], because that draws [TokenIcon] unconditionally and [TokenIcon] falls + * back to a generic user avatar for a null image — so an unresolved voucher was showing a person's + * silhouette beside the word "Cash", which is both the wrong glyph for money and a claim that an + * icon arrived when none had. There is no honest icon for a token nobody has fetched, so nothing is + * drawn in its place. + * + * The row is held at the icon's height whether or not an icon is in it, so the name does not move + * when one lands. + */ +@Composable +private fun TokenRow(tokenName: String, tokenImage: Any?) { + Row( + modifier = Modifier.height(LinkCardDefaults.TOKEN_ICON_SIZE), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), + ) { + if (tokenImage != null) { + TokenIcon( + image = tokenImage, + modifier = Modifier.size(LinkCardDefaults.TOKEN_ICON_SIZE), + ) + } + Text( + text = tokenName, + style = CodeTheme.typography.textSmall, + color = LinkCardDefaults.INK.copy(alpha = LinkCardDefaults.INK_MUTED), + ) + } +} + /** * A token link is the token's own bill — the card the link opens to, so the colours the creator * chose are what the reader recognises before reading a word. The wallet's card carries a balance; @@ -150,9 +260,20 @@ private fun TokenLinkCard( onClick = onClick, ) + // The address is the one thing known about the mint before the network answers, and it is + // what the raw link text showed. So the loading card is the unresolved card, shimmering — + // not a blank panel, which would leave the reader with less while they waited. + LinkCard.TokenInfo.State.Loading -> UnresolvedTokenCard( + mint = card.mint, + height = height, + loading = true, + onClick = onClick, + ) + LinkCard.TokenInfo.State.Unresolved -> UnresolvedTokenCard( mint = card.mint, height = height, + loading = false, onClick = onClick, ) } @@ -171,6 +292,7 @@ private fun TokenLinkCard( private fun UnresolvedTokenCard( mint: Mint, height: Dp, + loading: Boolean, onClick: () -> Unit, ) { val shape = CodeTheme.shapes.medium @@ -180,6 +302,9 @@ private fun UnresolvedTokenCard( .height(height) .clip(shape) .background(CodeTheme.colors.surfaceVariant) + // Behind the address rather than over it: the shimmer says the bill is still coming, + // and the one thing already known about the token stays readable while it does. + .then(if (loading) Modifier.shimmer(shape) else Modifier) .border(CodeTheme.dimens.border, CodeTheme.colors.surfaceVariant, shape) .clickable(onClick = onClick) .padding(CodeTheme.dimens.inset), @@ -238,6 +363,7 @@ private fun CashVoucher( amount: String?, spent: Boolean, torn: Boolean, + loading: Boolean, onClick: () -> Unit, stub: @Composable () -> Unit, ) { @@ -273,23 +399,34 @@ private fun CashVoucher( ), horizontalAlignment = Alignment.CenterHorizontally, ) { - TokenIconWithName( - tokenName = tokenName, - tokenImage = tokenImage, - imageSize = 20.dp, - spacing = CodeTheme.dimens.grid.x1, - textStyle = CodeTheme.typography.textSmall, - textColor = LinkCardDefaults.INK.copy(alpha = LinkCardDefaults.INK_MUTED), - ) - amount?.let { - AnimatedNumberText( - value = it, - style = CodeTheme.typography.displaySmall.copy(fontWeight = FontWeight.Bold), - color = LinkCardDefaults.INK, - constraintMode = ConstraintMode.AutoSize( - minimum = CodeTheme.typography.textMedium, - ), - ) + TokenRow(tokenName = tokenName, tokenImage = tokenImage) + // One slot, one height, whichever of the three things is in it: the amount once + // it lands, a placeholder while the lookup is out, nothing at all if it failed. + // The height is held because the column centres what it holds -- without it the + // empty unresolved card centres the token name on its own and a failed lookup + // slides the name down the card, which is the one thing a failure must not do. + // A minimum rather than a fixed height, so a large system font scale grows the + // slot instead of clipping the amount. + Box( + modifier = Modifier.heightIn(min = LinkCardDefaults.AMOUNT_SLOT_HEIGHT), + contentAlignment = Alignment.Center, + ) { + if (amount != null) { + AnimatedNumberText( + value = amount, + style = CodeTheme.typography.displaySmall + .copy(fontWeight = FontWeight.Bold), + color = LinkCardDefaults.INK, + constraintMode = ConstraintMode.AutoSize( + minimum = CodeTheme.typography.textMedium, + ), + ) + } else if (loading) { + InkPlaceholder( + width = LinkCardDefaults.AMOUNT_PLACEHOLDER_WIDTH, + height = LinkCardDefaults.AMOUNT_PLACEHOLDER_HEIGHT, + ) + } } } } @@ -470,11 +607,34 @@ private fun StubLabel(text: String, onPaper: Boolean = true) { } /** - * The claimable stub. A filled pill rather than a line of text, because this is the one state where - * the card is an offer — tapping it opens the link, which is where the claim happens. + * A slot on the paper with nothing in it yet, pulsing. + * + * Ink rather than the white of `Modifier.shimmer`: the voucher is the one light surface in the + * transcript, and a white shimmer on near-white paper is invisible. The animation is shared with + * every other skeleton in the app — only what it is painted in differs. + */ +@Composable +private fun InkPlaceholder( + width: Dp, + height: Dp, + shape: Shape = RoundedCornerShape(percent = 50), +) { + val alpha = rememberShimmerAlpha() + Box( + modifier = Modifier + .size(width, height) + .background(LinkCardDefaults.INK.copy(alpha = alpha), shape), + ) +} + +/** + * The stub of a voucher that can still be acted on. A filled pill rather than a line of text, + * because these are the states where the card is an offer — tapping it opens the link, which is + * where the claim happens. [StubLabel] is the other half of the pair: flat ink for a voucher that + * is only reporting what became of it. */ @Composable -private fun ClaimPill(text: String) { +private fun StubPill(text: String) { Text( modifier = Modifier .background(LinkCardDefaults.INK, RoundedCornerShape(percent = 50)) @@ -521,6 +681,20 @@ private object LinkCardDefaults { /** Secondary ink: the token's name and a spent voucher's label. */ const val INK_MUTED = 0.55f + /** The token's icon on the voucher, and the height its row keeps when there is no icon. */ + val TOKEN_ICON_SIZE = 20.dp + + /** + * The amount's slot, held at the same height in every state so neither resolving nor failing + * moves the token name above it. `displaySmall`'s own line height, which is what the amount + * occupies once it arrives. + */ + val AMOUNT_SLOT_HEIGHT = 36.dp + + /** The placeholder inside that slot — roughly what "$15.00" occupies at `displaySmall`. */ + val AMOUNT_PLACEHOLDER_WIDTH = 104.dp + val AMOUNT_PLACEHOLDER_HEIGHT = 28.dp + val NOTCH_RADIUS = 9.dp val DASH = 4.dp } diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index 2764c66b3f..60cee75f5d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -910,6 +910,27 @@ private fun previewCard( state = state, ) +/** + * What the card looks like while its lookup is out — and so what every cash link looks like for the + * moment after the transcript paints, since the transcript no longer waits for the answer. + * + * The default `LocalLinkCardResolution` hands a card back untouched, so a preview draws the state + * it names rather than resolving out from under itself. + */ +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_TextBubble_LinkCard_Loading() { + TextBubble( + text = PREVIEW_CASH_TEXT, + isFromSelf = false, + position = BubblePosition.Solo, + maxWidth = 300.dp, + linkCard = previewCard(state = LinkCard.Cash.State.Loading), + ) +} + +/** A lookup that failed: the loading card with the shimmer stopped, and nothing else moved. */ @Preview @PreviewWrapper(FlipcashThemeWrapper::class) @Composable