From cef6085da75b53affdbe18350200267ac6cff9ab Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 00:07:03 -0400 Subject: [PATCH 1/2] refactor(chat): resolve a link card in the card, not in the transcript Opening a chat painted nothing until every link in the first paging window had been round-tripped. The paging transform in ChatViewModel awaited LinkCardResolver for each classified card before it could emit the page, and it queried every link in every mapped message whether or not the reader ever scrolled to it. The classifier now emits the card in a Loading state and LinkCardView runs the lookup itself, through a LinkCardResolution seam that chat-ui can see and LinkCardResolver implements. Loading draws the unresolved card with the fields that are about to change shimmering rather than a skeleton of its own: the branded ticket and the abbreviated mint are already real information, and a grey rectangle would show the reader less than the raw link did. A failure is the shimmer stopping and nothing else moving, so a card still never shows an error over a link that opens fine. The resolver keeps its landed answers a second time, in a map readable without the mutex, so peek() can answer during composition -- without that, every card scrolled back into view shimmers for a hop on a link the reader already watched resolve. The claim invalidation and the 15s re-ask for claimable vouchers stay with the screen and reach drawn cards through a revision StateFlow, replacing the whole-window re-map they used to trigger. --- .../app/messenger/internal/ChatViewModel.kt | 44 +++--- .../internal/link/LinkCardClassifier.kt | 7 +- .../internal/link/LinkCardResolver.kt | 108 ++++++++----- .../internal/screens/MessengerScreen.kt | 1 + .../screens/components/MessageList.kt | 11 +- .../internal/link/LinkCardClassifierTest.kt | 8 +- .../internal/link/LinkCardResolverTest.kt | 84 +++++++++- .../flipcash/shared/chat/models/LinkCard.kt | 18 ++- .../shared/chat/models/LinkCardResolution.kt | 60 ++++++++ .../flipcash/shared/chat/ui/LinkCardView.kt | 145 +++++++++++++++++- .../flipcash/shared/chat/ui/MessageBubble.kt | 21 +++ 11 files changed, 431 insertions(+), 76 deletions(-) create mode 100644 apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/LinkCardResolution.kt 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..dba071eae0 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 @@ -11,10 +11,17 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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 +35,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 @@ -37,7 +45,10 @@ 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.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 +68,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 +79,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 +151,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 two slots the lookup fills standing in place and + // pulsing. It is not a third rendering: the ticket, its brand and its proportions are known + // without the network and are drawn the moment the message is, so what is left to say is which + // parts are still coming. A failure stops the pulse and the slots go 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 +174,10 @@ private fun CashLinkCard( torn = state?.claim == LinkCard.Cash.Claim.Claimed, onClick = onClick, ) { - state ?: return@CashVoucher + if (state == null) { + if (loading) StubPlaceholder() + return@CashVoucher + } when (state.claim) { LinkCard.Cash.Claim.Claimed -> // Off the paper: the stub this used to sit on left with whoever claimed it. @@ -150,9 +216,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 +248,7 @@ private fun TokenLinkCard( private fun UnresolvedTokenCard( mint: Mint, height: Dp, + loading: Boolean, onClick: () -> Unit, ) { val shape = CodeTheme.shapes.medium @@ -180,6 +258,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 +319,7 @@ private fun CashVoucher( amount: String?, spent: Boolean, torn: Boolean, + loading: Boolean, onClick: () -> Unit, stub: @Composable () -> Unit, ) { @@ -281,15 +363,20 @@ private fun CashVoucher( textStyle = CodeTheme.typography.textSmall, textColor = LinkCardDefaults.INK.copy(alpha = LinkCardDefaults.INK_MUTED), ) - amount?.let { + if (amount != null) { AnimatedNumberText( - value = it, + 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, + ) } } } @@ -469,6 +556,40 @@ private fun StubLabel(text: String, onPaper: Boolean = true) { ) } +/** + * Where the claim pill will be. Drawn at the pill's size rather than the label's, because the pill + * is what lands in almost every case and a placeholder that grows on arrival would be its own + * small movement inside a card whose whole point is that nothing moves. + */ +@Composable +private fun StubPlaceholder() { + InkPlaceholder( + width = LinkCardDefaults.STUB_PLACEHOLDER_WIDTH, + height = LinkCardDefaults.STUB_PLACEHOLDER_HEIGHT, + ) +} + +/** + * 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 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. @@ -521,6 +642,14 @@ private object LinkCardDefaults { /** Secondary ink: the token's name and a spent voucher's label. */ const val INK_MUTED = 0.55f + /** The amount's slot while it is coming — roughly what "$15.00" occupies at `displaySmall`. */ + val AMOUNT_PLACEHOLDER_WIDTH = 104.dp + val AMOUNT_PLACEHOLDER_HEIGHT = 28.dp + + /** The claim pill's slot while it is coming. */ + val STUB_PLACEHOLDER_WIDTH = 76.dp + val STUB_PLACEHOLDER_HEIGHT = 22.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 From fe65bc9e8da012db350d192d39ebb7eed2aa4ad6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 18 Sep 2026 09:07:54 -0400 Subject: [PATCH 2/2] fix(chat): hold the link card still while its lookup runs Three things the loading voucher got wrong, found by rendering the states rather than by reading them. The amount's slot was only present once the amount was. The column centres what it holds, so a failed lookup dropped the slot and slid the token name ~18dp down the card -- the one movement the card's own rule says a failure must not make. The slot is now held at displaySmall's line height in every state, as a minimum so a large font scale grows it instead of clipping the amount. The unresolved voucher drew a person's silhouette beside the word "Cash", because TokenIconWithName draws TokenIcon unconditionally and TokenIcon falls back to ic_placeholder_user for a null image. There is no honest icon for a token nobody has fetched, so nothing is drawn in its place; the row keeps the icon's height so the name does not move when one lands. The stub shimmered an empty slot over a control that already worked. The whole voucher is clickable in every state and opening the link is where claiming happens, so the claim pill is now drawn from the first frame. Claimable is the assumption until the lookup says otherwise, which trades an offer that is occasionally retracted for a claim that is never late; a voucher that comes back spent lands on a dimmed card with the label under the tear, so the withdrawal is unmistakable. --- .../flipcash/shared/chat/ui/LinkCardView.kt | 147 ++++++++++++------ 1 file changed, 96 insertions(+), 51 deletions(-) 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 dba071eae0..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,9 +7,11 @@ 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 @@ -44,7 +46,7 @@ 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 @@ -151,11 +153,11 @@ 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 two slots the lookup fills standing in place and - // pulsing. It is not a third rendering: the ticket, its brand and its proportions are known - // without the network and are drawn the moment the message is, so what is left to say is which - // parts are still coming. A failure stops the pulse and the slots go quiet and empty, which is - // the unresolved card, which is the one a reader can still tap. + // 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( @@ -175,7 +177,16 @@ private fun CashLinkCard( onClick = onClick, ) { if (state == null) { - if (loading) StubPlaceholder() + // 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) { @@ -187,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; @@ -355,28 +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), - ) - 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, - ) + 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, + ) + } } } } @@ -556,19 +606,6 @@ private fun StubLabel(text: String, onPaper: Boolean = true) { ) } -/** - * Where the claim pill will be. Drawn at the pill's size rather than the label's, because the pill - * is what lands in almost every case and a placeholder that grows on arrival would be its own - * small movement inside a card whose whole point is that nothing moves. - */ -@Composable -private fun StubPlaceholder() { - InkPlaceholder( - width = LinkCardDefaults.STUB_PLACEHOLDER_WIDTH, - height = LinkCardDefaults.STUB_PLACEHOLDER_HEIGHT, - ) -} - /** * A slot on the paper with nothing in it yet, pulsing. * @@ -591,11 +628,13 @@ private fun InkPlaceholder( } /** - * 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. + * 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)) @@ -642,14 +681,20 @@ private object LinkCardDefaults { /** Secondary ink: the token's name and a spent voucher's label. */ const val INK_MUTED = 0.55f - /** The amount's slot while it is coming — roughly what "$15.00" occupies at `displaySmall`. */ + /** 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 - /** The claim pill's slot while it is coming. */ - val STUB_PLACEHOLDER_WIDTH = 76.dp - val STUB_PLACEHOLDER_HEIGHT = 22.dp - val NOTCH_RADIUS = 9.dp val DASH = 4.dp }