From 0536858747f63609ab05b59cb8876673629e6207 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 31 Aug 2026 17:01:11 -0400 Subject: [PATCH] feat(activity): badge person avatars with the token the row moved in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An activity row for a tip or a peer send draws the counterparty's face, so nothing in it names the token: a tip in dollars and a tip in a creator coin read identically. Draw the mint as a small coin over the avatar's bottom-right corner (node 9717:14138) — 40dp avatar centred in a 48dp slot, 20dp badge ringed in the page background. Only person-shaped avatars get one; a token or swap avatar already is the token. Rows reserve the full 48dp slot whether or not they badge, so titles stay aligned down the list. The badge needed a new resolution path. `observeTokenCache()` only ever holds mints the user has an account for, and a tip commonly arrives in a creator coin the recipient holds nothing of, so those rows would never resolve a token. `ensureBadgeToken` fetches such a mint once and memoizes it, following the existing `ensureProfile` shape: an in-flight key set collapses concurrent misses and the memo keeps the answer, so a mint costs at most one request. That matters here — the design this replaces called `getTokenMetadata` per item inside the paging transform, re-fetching never-cached mints on every emission until the wallet locked up. Held-token metadata is layered over the memo so balance-driven refreshes win, and the memo clears on loss of API access. Also collapses the coordinator's three duplicated message-to-row mapping blocks into one `resolveRow`. --- .../internal/WalletLoadingStateTest.kt | 2 +- .../ActivityFeedCoordinator.kt | 129 ++++++++++++------ .../transactionhistory/ActivityFeedRow.kt | 62 +++++++-- .../transactionhistory/TransactionListItem.kt | 21 ++- .../internal/TransactionItemMapper.kt | 7 +- .../TransactionItemMapperTest.kt | 59 +++++++- 6 files changed, 213 insertions(+), 67 deletions(-) diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt index c6a52c41d..e404126fa 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletLoadingStateTest.kt @@ -25,7 +25,7 @@ class WalletLoadingStateTest { id = "1", title = "Received", timestamp = Instant.fromEpochSeconds(0), - avatar = TransactionAvatar.Generic, + avatar = TransactionAvatar.Generic(), signedAmountPrefix = "+", amount = null, fee = null, diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt index fb949733d..30a37f81a 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedCoordinator.kt @@ -91,6 +91,19 @@ class ActivityFeedCoordinator @Inject internal constructor( // for the same user collapse to a single fetch. private val resolvingProfiles = ConcurrentHashMap.newKeySet() + // Same, for badge mints (see [ensureBadgeToken]). + private val resolvingMints = ConcurrentHashMap.newKeySet() + + /** + * Token metadata for mints the held-token cache will never carry, memoized for the lifetime of + * the process. + * + * [TokenMetadataProvider.observeTokenCache] only ever holds mints the user has an account for, + * but a person-shaped row badges the mint it moved in — and a tip commonly arrives in a creator + * coin the recipient holds nothing of. Without this those rows would sit un-badged forever. + */ + private val badgeTokens = MutableStateFlow>(emptyMap()) + private val _syncState = MutableStateFlow(FeedSyncState.Unknown) /** @@ -106,7 +119,10 @@ class ActivityFeedCoordinator @Inject internal constructor( .map { it.authState.canAccessAuthenticatedApis } .distinctUntilChanged() .filter { !it } - .onEach { _syncState.value = FeedSyncState.Unknown } + .onEach { + _syncState.value = FeedSyncState.Unknown + badgeTokens.value = emptyMap() + } .launchIn(scope) } @@ -164,8 +180,8 @@ class ActivityFeedCoordinator @Inject internal constructor( * such mints are never cached, every page emission re-fetched them, so the wallet feed churned * network calls and never settled ("locks up when a tip comes into view"). Resolving tokens * cache-only here removes the churn entirely; a not-yet-cached counterparty/token simply resolves - * later when it lands (or, for an unheld tip coin, stays absent — tips use the profile avatar, - * not the token icon). + * later when it lands. An unheld mint the row needs for its avatar badge is fetched exactly once + * and memoized — see [ensureBadgeToken], which is what keeps that fix from regressing. * * NB: we intentionally do NOT `.filter` the PagingData — filtering pages makes the presenter keep * requesting more pages to fill the viewport (a runaway-load stall). Unresolved rows render with a @@ -176,20 +192,64 @@ class ActivityFeedCoordinator @Inject internal constructor( messages .cachedIn(scope) .combine(resolvers) { paging, (profiles, tokens) -> - paging.map { msg -> - // Resolve-on-miss: a visible row whose counterparty isn't cached triggers a - // background fetch+store; when it lands, observeProfiles re-emits and this row - // re-maps with the resolved avatar + name (no reload). - counterpartyOf(msg.metadata) - ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } - ?.let(::ensureProfile) - val token = msg.amount?.mint?.let { tokens[it] } - val toToken = destinationTokenOf(msg.metadata, tokens) - transactionItemMapper.map( - ActivityFeedMessageWithToken(msg, token, toToken) to profiles - ) - } + paging.map { msg -> resolveRow(msg, profiles, tokens) } + } + + /** + * Maps one feed message to a presentation row against the observed [profiles] and [tokens] + * caches, and kicks off resolve-on-miss for whatever the row still needs. + * + * Both lookups are pure map reads, so this stays safe inside a paging transform; the fetches it + * triggers are fire-and-forget and de-duplicated, and when one lands the corresponding cache + * re-emits and the visible rows re-map in place (no reload). + */ + private fun resolveRow( + msg: ActivityFeedMessage, + profiles: Map, + tokens: Map, + ): TransactionListItem { + val counterparty = counterpartyOf(msg.metadata) + counterparty + ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } + ?.let(::ensureProfile) + + val mint = msg.amount?.mint + val token = mint?.let { tokens[it] } + // A row with a counterparty draws that person's face, so the mint only appears as the + // avatar's badge — worth a fetch even for a mint the user holds nothing of. + if (counterparty != null && mint != null && token == null) ensureBadgeToken(mint) + + return transactionItemMapper.map( + ActivityFeedMessageWithToken( + message = msg, + token = token, + toToken = destinationTokenOf(msg.metadata, tokens), + ) to profiles + ) + } + + /** + * Ensures [badgeTokens] holds metadata for [mint], fetching it once over the network. + * + * This is the one place the feed goes to the network for token metadata, and it is deliberately + * narrow. An earlier design called [TokenMetadataProvider.getTokenMetadata] per item *inside* + * the paging transform, with no de-duplication and nowhere to put the answer for an unheld mint: + * every page emission re-fetched the same mints, so the wallet churned network calls and never + * settled. Here the in-flight set collapses concurrent misses and [badgeTokens] keeps the + * result, so a mint costs at most one request. A failed fetch leaves the mint unresolved and is + * retried on a later emission — same policy as [ensureProfile]. + */ + private fun ensureBadgeToken(mint: Mint) { + if (!resolvingMints.add(mint)) return + scope.launch { + try { + val token = tokenProvider.getTokenMetadata(mint).getOrNull()?.token ?: return@launch + badgeTokens.update { it + (mint to token) } + } finally { + resolvingMints.remove(mint) } + } + } /** * Ensures a cached [UserProfile] exists for each of [userIds], fetching any miss over the network @@ -241,16 +301,7 @@ class ActivityFeedCoordinator @Inject internal constructor( */ fun recentTransactions(limit: Int): Flow> = combine(dataSource.observeRecent(limit), resolvers) { messages, (profiles, tokens) -> - messages.map { msg -> - counterpartyOf(msg.metadata) - ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } - ?.let(::ensureProfile) - val token = msg.amount?.mint?.let { tokens[it] } - val toToken = destinationTokenOf(msg.metadata, tokens) - transactionItemMapper.map( - ActivityFeedMessageWithToken(msg, token, toToken) to profiles - ) - } + messages.map { msg -> resolveRow(msg, profiles, tokens) } } /** @@ -261,26 +312,24 @@ class ActivityFeedCoordinator @Inject internal constructor( */ fun recentTransactions(mint: Mint, limit: Int): Flow> = combine(dataSource.observeRecent(mint, limit), resolvers) { messages, (profiles, tokens) -> - messages.map { msg -> - counterpartyOf(msg.metadata) - ?.takeUnless { profiles.containsKey(it.hexEncodedString()) } - ?.let(::ensureProfile) - val token = msg.amount?.mint?.let { tokens[it] } - val toToken = destinationTokenOf(msg.metadata, tokens) - transactionItemMapper.map( - ActivityFeedMessageWithToken(msg, token, toToken) to profiles - ) - } + messages.map { msg -> resolveRow(msg, profiles, tokens) } } /** - * Observed profile + token caches, paired for a single [combine] against the cached pages. Both - * are network-free reads that re-emit as their caches hydrate, so rows resolve reactively from + * Observed profile + token caches, paired for a single [combine] against the cached pages. All + * three are network-free reads that re-emit as they hydrate, so rows resolve reactively from * memory. Started eagerly-cold: it only collects while [recentTransactions] is subscribed. + * + * Held-token metadata is laid over [badgeTokens] rather than under it: the held cache is + * refreshed on every balance update, the badge memo is fetched once and never again. */ private val resolvers: Flow, Map>> = - combine(userProfiles.observeProfiles(), tokenProvider.observeTokenCache()) { profiles, tokens -> - profiles to tokens + combine( + userProfiles.observeProfiles(), + tokenProvider.observeTokenCache(), + badgeTokens, + ) { profiles, tokens, badges -> + profiles to (badges + tokens) } /** diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt index 380a861c3..55b35e8bb 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/ActivityFeedRow.kt @@ -31,10 +31,15 @@ import kotlinx.datetime.toLocalDateTime import kotlin.time.Clock import kotlin.time.Instant +/** Figma 9717:14138 — the avatar, the slot that leaves room for the badge's overhang, the badge. */ +private val AvatarSize = 40.dp +private val AvatarSlotSize = 48.dp +private val TokenBadgeSize = 20.dp + /** * A single row in the "Recent" activity list on the Wallet screen (Figma 8966:1910). * - * Layout: [40dp avatar] · [title + relative time (weight 1)] · [signed amount] + * Layout: [48dp avatar slot] · [title + relative time (weight 1)] · [signed amount] */ @Composable fun ActivityFeedRow( @@ -48,20 +53,7 @@ fun ActivityFeedRow( horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), verticalAlignment = Alignment.CenterVertically, ) { - val avatarModifier = Modifier - .requiredSize(CodeTheme.dimens.staticGrid.x8) - .clip(CircleShape) - - when (val a = item.avatar) { - is TransactionAvatar.Profile -> - ContactAvatar(userProfile = a.profile, modifier = avatarModifier) - is TransactionAvatar.TokenIcon -> - TokenIcon(token = a.token, modifier = avatarModifier) - is TransactionAvatar.SwapTokens -> - SwapAvatar(a, modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x8)) - TransactionAvatar.Generic -> - ContactAvatar(userProfile = UserProfile.Empty, modifier = avatarModifier) - } + AvatarSlot(item.avatar) Column( modifier = Modifier.weight(1f), @@ -115,6 +107,46 @@ fun ActivityFeedRow( } } +/** + * The leading slot: the avatar centred in a box wide enough for the token badge to overhang its + * bottom-right corner without pushing the title (Figma 9717:14138 — 40dp avatar in a 48dp slot, + * 20dp badge). Every row reserves the full slot, badge or not, so titles line up down the list. + */ +@Composable +private fun AvatarSlot(avatar: TransactionAvatar, modifier: Modifier = Modifier) { + Box( + modifier = modifier.requiredSize(AvatarSlotSize), + contentAlignment = Alignment.Center, + ) { + val avatarModifier = Modifier + .requiredSize(AvatarSize) + .clip(CircleShape) + + when (avatar) { + is TransactionAvatar.Profile -> + ContactAvatar(userProfile = avatar.profile, modifier = avatarModifier) + is TransactionAvatar.TokenIcon -> + TokenIcon(token = avatar.token, modifier = avatarModifier) + is TransactionAvatar.SwapTokens -> + SwapAvatar(avatar, modifier = Modifier.requiredSize(AvatarSize)) + is TransactionAvatar.Generic -> + ContactAvatar(userProfile = UserProfile.Empty, modifier = avatarModifier) + } + + // Ringed in the page background so the coin reads as sitting over the avatar rather than + // being part of it — the same treatment [SwapAvatar] gives its overlapping pair. + avatar.badgeToken?.let { token -> + TokenIcon( + token = token, + modifier = Modifier + .align(Alignment.BottomEnd) + .requiredSize(TokenBadgeSize) + .border(CodeTheme.dimens.thickBorder, CodeTheme.colors.background, CircleShape), + ) + } + } +} + /** * A convert's two tokens as overlapping coins — the destination sits over the source, ringed in the * page background so the overlap reads as depth. Mirrors iOS's `swapAvatar` in `ActivityRow.swift`. diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt index 6131b10ff..f64698214 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/TransactionListItem.kt @@ -15,7 +15,23 @@ import kotlin.time.Instant * - [Generic] — a counterparty that is named but not yet resolved, or unknown metadata. */ sealed interface TransactionAvatar { - data class Profile(val profile: UserProfile) : TransactionAvatar + /** + * The mint this entry moved in, drawn as a small coin over the avatar's bottom-right corner + * (Figma 9717:14138). + * + * Only person-shaped avatars carry one: a face says who, not what, so without the badge the row + * never names the token — every tip reads the same whether it was dollars or a creator coin. + * [TokenIcon] and [SwapTokens] already *are* the token, so badging them would just repeat it. + * + * Null until the mint's metadata resolves; the row draws the bare avatar until then. + */ + val badgeToken: Token? get() = null + + data class Profile( + val profile: UserProfile, + override val badgeToken: Token? = null, + ) : TransactionAvatar + data class TokenIcon(val token: Token) : TransactionAvatar /** @@ -23,7 +39,8 @@ sealed interface TransactionAvatar { * row draws a placeholder for a missing one rather than switching avatar shape mid-hydration. */ data class SwapTokens(val from: Token?, val to: Token?) : TransactionAvatar - data object Generic : TransactionAvatar + + data class Generic(override val badgeToken: Token? = null) : TransactionAvatar } data class TransactionListItem( diff --git a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt index 1370de371..dbac7b0ef 100644 --- a/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt +++ b/apps/flipcash/shared/transaction-history/src/main/kotlin/com/flipcash/shared/transactionhistory/internal/TransactionItemMapper.kt @@ -32,12 +32,15 @@ internal class TransactionItemMapper @Inject constructor( val counterparty = userIdOf(meta)?.let { profiles[it.hexEncodedString()] } val convert = convertOf(meta) val avatar: TransactionAvatar = when { - counterparty != null -> TransactionAvatar.Profile(counterparty) + // A face says who, not what, so the mint rides along as a badge (see [badgeToken]). + counterparty != null -> TransactionAvatar.Profile(counterparty, badgeToken = token) // A convert always draws both sides, even before both tokens have resolved. convert != null -> TransactionAvatar.SwapTokens(from = token, to = source.toToken) (hasNoCounterparty(meta) || isUnidentifiedBill(meta)) && token != null -> TransactionAvatar.TokenIcon(token) - else -> TransactionAvatar.Generic + // Also person-shaped — a named counterparty whose profile hasn't landed yet — so it + // carries the badge too, and the row gains only the face when the profile resolves. + else -> TransactionAvatar.Generic(badgeToken = token) } val prefix: String? = when { diff --git a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt index 54c7bfe02..94ca34ae5 100644 --- a/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt +++ b/apps/flipcash/shared/transaction-history/src/test/kotlin/com/flipcash/shared/transactionhistory/TransactionItemMapperTest.kt @@ -97,7 +97,7 @@ class TransactionItemMapperTest { val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to emptyMap()) - assertEquals(TransactionAvatar.Generic, item.avatar) + assertEquals(TransactionAvatar.Generic(), item.avatar) assertEquals("-", item.signedAmountPrefix) } @@ -221,6 +221,47 @@ class TransactionItemMapperTest { assertEquals("Tipped Server Sally", item.title) } + @Test + fun `sent tip badges the profile avatar with the token it moved in`() { + val token = usdfToken() + val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to cached) + + assertEquals(TransactionAvatar.Profile(knownProfile, badgeToken = token), item.avatar) + } + + @Test + fun `received tip badges the profile avatar with the token it moved in`() { + val token = usdfToken() + val msg = feedMessage(metadata = MessageMetadata.ReceivedCrypto(userId = knownUserId)) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to cached) + + assertEquals(TransactionAvatar.Profile(knownProfile, badgeToken = token), item.avatar) + } + + /** Token metadata arrives reactively; until it does the row draws the bare avatar. */ + @Test + fun `profile avatar carries no badge until the token resolves`() { + val msg = feedMessage(metadata = MessageMetadata.ReceivedCrypto(userId = knownUserId)) + val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) + + assertEquals(null, item.avatar.badgeToken) + } + + /** A token-shaped avatar already names the token, so a badge would only repeat it. */ + @Test + fun `token and swap avatars carry no badge`() { + val token = usdfToken() + val deposit = mapper.map( + ActivityFeedMessageWithToken( + feedMessage(metadata = MessageMetadata.DepositedCrypto), + token, + ) to emptyMap() + ) + + assertEquals(null, deposit.avatar.badgeToken) + } + @Test fun `deposit uses the token icon and a plus prefix`() { val token = usdfToken() @@ -264,14 +305,18 @@ class TransactionItemMapperTest { assertEquals("+", item.signedAmountPrefix) } - /** A named counterparty is still coming, so the row waits for it rather than showing the token. */ + /** + * A named counterparty is still coming, so the avatar stays a silhouette rather than becoming + * the token's icon — but the badge is already the token's, so the row gains only the face when + * the profile lands. + */ @Test - fun `send to a named but unresolved user keeps the generic avatar`() { + fun `send to a named but unresolved user keeps the generic avatar, badged with the token`() { val token = usdfToken() val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(userId = knownUserId)) val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap()) - assertEquals(TransactionAvatar.Generic, item.avatar) + assertEquals(TransactionAvatar.Generic(badgeToken = token), item.avatar) } @Test @@ -280,7 +325,7 @@ class TransactionItemMapperTest { val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto(phoneNumber = "+15555550123")) val item = mapper.map(ActivityFeedMessageWithToken(msg, token) to emptyMap()) - assertEquals(TransactionAvatar.Generic, item.avatar) + assertEquals(TransactionAvatar.Generic(badgeToken = token), item.avatar) } /** No token has resolved from the mint cache yet, so there is no icon to draw. */ @@ -289,7 +334,7 @@ class TransactionItemMapperTest { val msg = feedMessage(metadata = MessageMetadata.DirectlySentCrypto()) val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to cached) - assertEquals(TransactionAvatar.Generic, item.avatar) + assertEquals(TransactionAvatar.Generic(), item.avatar) } @Test @@ -309,7 +354,7 @@ class TransactionItemMapperTest { val item = mapper.map(ActivityFeedMessageWithToken(msg, token = null) to emptyMap()) assertEquals(null, item.signedAmountPrefix) - assertEquals(TransactionAvatar.Generic, item.avatar) + assertEquals(TransactionAvatar.Generic(), item.avatar) } @Test