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 @@ -1319,27 +1319,27 @@ internal class ChatViewModel @Inject constructor(
.onEach { dispatchEvent(Event.OnViewerStateResolved(it)) }
.launchIn(viewModelScope)

// The cache starts empty and fills in, so this is observed rather than read once — a
// requirement resolved against an empty cache would render its amount with no token beside
// it and never correct itself.
combine(
stateFlow.map { (it.subject as? ChatSubject.Group)?.rules.balanceRequirement() }
.distinctUntilChanged(),
tokenCoordinator.observeTokenCache(),
) { requirement, tokens ->
// The name rather than the symbol, matching the create form's own mint row: the two
// screens name one holding, and the balance list a requirement is satisfied from
// renders the name as well (`TokenWithBalance.displayName`). `brandedName` is the same
// rule the link cards use, so the reserve reads "Dollars" here too rather than "USDF".
requirement?.mints?.firstOrNull()
?.let { tokens[Mint(it.bytes)] }
?.let { token ->
// Observed rather than read once: the rule can change under an open screen, and a buy
// hydrates a fresher copy of the token. See observeRuleToken for why it fetches as well.
stateFlow.map { state ->
(state.subject as? ChatSubject.Group)?.rules.balanceRequirement()
?.mints?.firstOrNull()?.let { Mint(it.bytes) }
}
.distinctUntilChanged()
.flatMapLatest { mint -> mint?.let { tokenCoordinator.observeRuleToken(it) } ?: flowOf(null) }
.map { token ->
// The name rather than the symbol, matching the create form's own mint row: the two
// screens name one holding, and the balance list a requirement is satisfied from
// renders the name as well (`TokenWithBalance.displayName`). `brandedName` is the
// same rule the link cards use, so the reserve reads "Dollars" here too rather than
// "USDF".
token?.let {
RuleCurrency(
name = token.brandedName(resources),
isReserve = token.isReserve,
name = it.brandedName(resources),
isReserve = it.isReserve,
)
}
}
}
.distinctUntilChanged()
.onEach { dispatchEvent(Event.OnRuleCurrencyResolved(it)) }
.launchIn(viewModelScope)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.flipcash.app.messenger.internal

import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.providers.TokenMetadataProvider
import com.getcode.solana.keys.Mint
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.mapNotNull

/**
* The token a balance rule names, for the gate bar's "Buy More <name>".
*
* Fetched first rather than only observed: the cache holds only mints the account has an account
* for, so a viewer who has never held the rule's token would wait on it forever and Buy More would
* never enable. The cache is followed after the fetch so the copy hydrated by a buy replaces it.
*/
internal fun TokenMetadataProvider.observeRuleToken(mint: Mint): Flow<Token> = flow {
getTokenMetadata(mint).getOrNull()?.token?.let { emit(it) }
emitAll(observeTokenCache().mapNotNull { it[mint] })
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,12 @@ internal fun MessengerScreen(viewModel: ChatViewModel) {
}
}

ChatAction.AddCash -> {
keyboard.hideIfVisible {
viewModel.dispatchEvent(ChatViewModel.Event.PresentDepositOptions)
}
}

is ChatAction.OpenGroup -> {
// An invite to the chat already on screen has nowhere to go.
if (action.chatId != state.chatId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,15 +113,23 @@ internal fun GroupGateBar(
}

if (unmetBalance != null) {
// Always enabled, as iOS's ConversationGatePanel is. A rule any holding satisfies, or
// one on the reserve, has no other token to buy, so it adds cash. A named mint buys
// that mint even before its name loads; the label just can't say which yet.
val mint = unmetBalance.mints.firstOrNull()?.let { Mint(it.bytes) }
val tokenToBuy = mint?.takeUnless { it == Mint.usdf }
CodeButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.action_buyMoreToken, currency?.name.orEmpty()),
// Nothing to buy until the currency resolves: the label would read "Buy More " and
// the token screen has no mint to open.
enabled = currency != null,
text = when {
tokenToBuy == null -> stringResource(R.string.action_addCash)
currency == null -> stringResource(R.string.action_buyMore)
else -> stringResource(R.string.action_buyMoreToken, currency.name)
},
onClick = {
unmetBalance.mints.firstOrNull()
?.let { onAction(ChatAction.ViewToken(Mint(it.bytes), returnAfterBuy = true)) }
onAction(
if (tokenToBuy == null) ChatAction.AddCash
else ChatAction.ViewToken(tokenToBuy, returnAfterBuy = true)
)
},
)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,28 +178,37 @@ class GroupChromeScreenshotTest {
staffOnly = false,
currency = badBoys,
)
// The same state before the currency resolves: the button is disabled and the
// caption falls back to the amount alone.
// The same state before the currency resolves: "Buy More", still enabled — the
// mint is the rule's, so the button buys the right thing before it can name it.
GroupGateBar(
access = GroupAccess.Blocked(
unmet = ChatRuleRequirement.MinimumBalance(
mints = emptyList(),
amount = Fiat(100.0),
),
),
requirement = ChatRuleRequirement.MinimumBalance(
mints = emptyList(),
amount = Fiat(100.0),
),
access = GroupAccess.Blocked(unmet = balanceRule),
requirement = balanceRule,
onAction = {},
staffOnly = false,
currency = null,
)
// Any holding counts: no token to buy, so "Add Cash", and the caption states
// the amount alone.
val anyMintRule = ChatRuleRequirement.MinimumBalance(
mints = emptyList(),
amount = Fiat(100.0),
)
GroupGateBar(
access = GroupAccess.Blocked(unmet = anyMintRule),
requirement = anyMintRule,
onAction = {},
staffOnly = false,
currency = null,
)
// Gated on the reserve: the caption drops "of Dollars" because the amount is
// already a dollar figure, while the button still names what to go and buy.
// already a dollar figure, and the button adds cash rather than buying.
val reserveRule = ChatRuleRequirement.MinimumBalance(
mints = listOf(Mint.usdf),
amount = Fiat(100.0),
)
GroupGateBar(
access = GroupAccess.Blocked(unmet = balanceRule),
requirement = balanceRule,
access = GroupAccess.Blocked(unmet = reserveRule),
requirement = reserveRule,
onAction = {},
staffOnly = false,
currency = RuleCurrency(name = "Dollars", isReserve = true),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package com.flipcash.app.messenger.internal

import com.getcode.opencode.model.financial.DataSource
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.HolderMetrics
import com.getcode.opencode.model.financial.LaunchpadMetadata
import com.getcode.opencode.model.financial.MintMetadata
import com.getcode.opencode.model.financial.Token
import com.getcode.opencode.model.financial.TokenResult
import com.getcode.opencode.model.financial.VmMetadata
import com.getcode.opencode.providers.TokenMetadataProvider
import com.getcode.solana.keys.Mint
import com.getcode.solana.keys.PublicKey
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.Test
import kotlin.test.assertEquals

class RuleTokenTest {

private val mint = Mint(List(32) { 1.toByte() })
private val dummyKey = PublicKey.fromBase58("11111111111111111111111111111111")

/** Mirrors TokenCoordinator: a network fetch of an unheld mint is not written to the cache. */
private class FakeProvider(private val network: Token?) : TokenMetadataProvider {
val cache = MutableStateFlow<Map<Mint, Token>>(emptyMap())

override suspend fun getTokenMetadata(mint: Mint): Result<TokenResult> =
cache.value[mint]?.let { Result.success(TokenResult(it, DataSource.Memory)) }
?: network?.let { Result.success(TokenResult(it, DataSource.Network)) }
?: Result.failure(IllegalStateException("not found"))

override fun observeTokenCache(): Flow<Map<Mint, Token>> = cache
}

@Test
fun `an unheld rule token resolves from the network`() = runTest {
val provider = FakeProvider(network = token(name = "OPOS"))

assertEquals("OPOS", provider.observeRuleToken(mint).first().name)
}

@Test
fun `a copy hydrated into the cache replaces the fetched one`() = runTest {
val provider = FakeProvider(network = token(name = "OPOS"))

val names = mutableListOf<String>()
val job = launch { provider.observeRuleToken(mint).take(2).toList().mapTo(names) { it.name } }
testScheduler.runCurrent()
provider.cache.value = mapOf(mint to token(name = "OPOS after buy"))
job.join()

assertEquals(listOf("OPOS", "OPOS after buy"), names)
}

@Test
fun `a failed fetch still resolves once the cache fills`() = runTest {
val provider = FakeProvider(network = null)

val job = launch { assertEquals("OPOS", provider.observeRuleToken(mint).first().name) }
testScheduler.runCurrent()
provider.cache.value = mapOf(mint to token(name = "OPOS"))
job.join()
}

private fun token(name: String): Token = MintMetadata(
address = mint,
decimals = 10,
name = name,
symbol = "OPOS",
createdAt = null,
description = "",
imageUrl = "",
vmMetadata = VmMetadata(vm = dummyKey, authority = dummyKey, lockDurationInDays = 21),
launchpadMetadata = LaunchpadMetadata(
currencyConfig = dummyKey,
liquidityPool = dummyKey,
seed = dummyKey,
authority = dummyKey,
mintVault = dummyKey,
coreMintVault = dummyKey,
currentCirculatingSupplyQuarks = 0,
sellFeeBps = 100,
price = Fiat(fiat = 0.01),
marketCap = Fiat(fiat = 10000.0),
),
billCustomizations = null,
socialLinks = emptyList(),
holderMetrics = HolderMetrics.None,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ sealed interface ChatAction {
*/
data class ViewToken(val mint: Mint, val returnAfterBuy: Boolean = false) : ChatAction

/**
* Opens the add-cash flow — the group gate's answer to a balance rule any holding satisfies,
* or one on the reserve, where there is no other token to buy.
*/
data object AddCash : ChatAction

/**
* Opens the group a link card names, pushed over this chat so Back returns here. The pushed
* screen gates itself; a card never joins or buys on the reader's behalf.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import com.flipcash.services.models.chat.ChatRuleRequirement
import com.flipcash.services.models.chat.ChatRules
import com.getcode.opencode.model.financial.Fiat
import com.getcode.opencode.model.financial.TokenWithBalance
import com.getcode.opencode.model.financial.sum
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
Expand Down Expand Up @@ -49,8 +50,9 @@ sealed interface GroupAccess {
*
* A [ChatRuleRequirement.MinimumBalance] is satisfied by the largest single balance among
* the mints it names, not by their sum: the requirement is "hold $100 of this", and two
* unrelated $60 positions are not that. An empty `mints` names no token in particular, so
* any one of them can satisfy it.
* unrelated $60 positions are not that. An empty `mints` names no token in particular and
* is measured against everything held, added up, as iOS's `ConversationGate.unmetBalance`
* does with `totalBalance`.
*/
fun evaluate(
isMember: Boolean,
Expand All @@ -71,13 +73,20 @@ sealed interface GroupAccess {
val unmet = listener.firstOrNull { requirement ->
when (requirement) {
is ChatRuleRequirement.MinimumBalance -> {
val candidates = if (requirement.mints.isEmpty()) {
byMint.values
val held = if (requirement.mints.isEmpty()) {
byMint.values.sum()
} else {
requirement.mints.mapNotNull { byMint[it.bytes] }
requirement.mints.mapNotNull { byMint[it.bytes] }.maxOrNull()
}
val best = candidates.maxOrNull()
best == null || best < requirement.amount
// Compared at display precision, the held side rounded half-up to cents
// (`Fiat.toDouble`), and a total rounded once, after adding: a balance the wallet shows as $5.00 meets a $5 bar even
// when its exact worth is $4.998, and $4.995 passes too. A launchpad
// holding's exact worth depends on the supply this client last saw, and one
// that lags a buy prices the new tokens a fraction below what was paid. The
// server enforces the rule against its own supply, so admitting half a cent
// too generously costs one denied join. iOS's `ConversationGate.unmetBalance`
// rounds the same way; keep the two in step.
held == null || held.toDouble() < requirement.amount.decimalValue
}
// `UserFlags.is_staff` is the same field the rule is written against, and the
// client already has it — so staff are eligible for a staff chat and can rejoin
Expand Down
Loading
Loading