Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,19 @@ class ActivityFeedCoordinator @Inject internal constructor(
// for the same user collapse to a single fetch.
private val resolvingProfiles = ConcurrentHashMap.newKeySet<String>()

// Same, for badge mints (see [ensureBadgeToken]).
private val resolvingMints = ConcurrentHashMap.newKeySet<Mint>()

/**
* 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<Map<Mint, Token>>(emptyMap())

private val _syncState = MutableStateFlow(FeedSyncState.Unknown)

/**
Expand All @@ -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)
}

Expand Down Expand Up @@ -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
Expand All @@ -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<String, UserProfile>,
tokens: Map<Mint, Token>,
): 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
Expand Down Expand Up @@ -241,16 +301,7 @@ class ActivityFeedCoordinator @Inject internal constructor(
*/
fun recentTransactions(limit: Int): Flow<List<TransactionListItem>> =
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) }
}

/**
Expand All @@ -261,26 +312,24 @@ class ActivityFeedCoordinator @Inject internal constructor(
*/
fun recentTransactions(mint: Mint, limit: Int): Flow<List<TransactionListItem>> =
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<Pair<Map<String, UserProfile>, Map<Mint, Token>>> =
combine(userProfiles.observeProfiles(), tokenProvider.observeTokenCache()) { profiles, tokens ->
profiles to tokens
combine(
userProfiles.observeProfiles(),
tokenProvider.observeTokenCache(),
badgeTokens,
) { profiles, tokens, badges ->
profiles to (badges + tokens)
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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),
Expand Down Expand Up @@ -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`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,32 @@ 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

/**
* Either side may still be unresolved (its metadata hasn't landed in the token cache yet); the
* 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading