From 5fe6b7dafbfaa9b17135891d6ea4a7e94d19ff70 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 10:22:26 -0400 Subject: [PATCH 01/10] feat(groups): resolve a group profile's action, requirements and shortfall ChatSubject.Group now carries the description, cover picture and privacy flag from ChatMetadata. The profile's pinned action, join/chat balance rows and shortfall are pure functions, tested against iOS's GroupProfileStateTests cases. GroupAccess gains unmetRequirements and heldAgainst, the measurement the gate already used. GroupOperations gains sampleChatters and refreshCover (a GetChat for feed rows with no cover). --- .../app/messenger/internal/ChatSubject.kt | 9 + .../app/messenger/internal/ChatViewModel.kt | 3 + .../screens/profile/GroupProfileState.kt | 190 +++++++++++ .../screens/profile/GroupProfileStateTest.kt | 297 ++++++++++++++++++ .../flipcash/shared/chat/ChatCoordinator.kt | 17 + .../com/flipcash/shared/chat/GroupAccess.kt | 57 +++- .../internal/delegates/GroupFeedDelegate.kt | 9 + .../flipcash/shared/chat/GroupAccessTest.kt | 22 ++ 8 files changed, 592 insertions(+), 12 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileStateTest.kt diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt index 28bd1c5fa..4765c1a8b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatSubject.kt @@ -86,6 +86,15 @@ sealed interface ChatSubject { val isMember: Boolean?, /** The group's creator (`ChatMetadata.creator`); null when the metadata did not carry one. */ val creator: ID? = null, + /** The group's description (`ChatMetadata.description`); null when unset. */ + val description: String? = null, + /** + * The banner above the group's profile. A feed row may carry none even when the group has + * one; the profile asks `GetChat` once when it opens on a group without one. + */ + val coverPicture: MediaItem? = null, + /** A private group's roster is not sampled for the profile, which the server denies. */ + val isPrivate: Boolean = false, ) : ChatSubject { override val title: String get() = groupTitle.orEmpty() override val subtitle: String? get() = null 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 945a08cee..a76040ce8 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 @@ -2834,6 +2834,9 @@ internal class ChatViewModel @Inject constructor( rules = metadata.rules, isMember = event.membership.isMember, creator = metadata.creator, + description = metadata.description, + coverPicture = metadata.coverPicture, + isPrivate = metadata.isPrivate, ), chatType = ChatType.GROUP, resolveState = ResolveState.Resolved, diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt new file mode 100644 index 000000000..2a26104fd --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileState.kt @@ -0,0 +1,190 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.SampledChatter +import com.flipcash.shared.chat.GroupAccess +import com.flipcash.shared.chat.heldAgainst +import com.flipcash.shared.chat.unmetRequirements +import com.flipcash.shared.chat.usdValue +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.internal.extensions.fractionDigits +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.TokenWithBalance +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * The one action pinned to the bottom of a group's profile. + * + * Mirrors iOS's `GroupProfileCTA`. Resolved from membership and the unmet rules rather than from + * the transcript's gate, because the two answer different questions: the gate decides what the + * transcript may show, this decides what the viewer can do next from here. + */ +internal sealed interface GroupProfileCta { + /** Nothing to offer: the answer is not known yet, or the bar is not one a viewer can clear. */ + data object None : GroupProfileCta + data object Join : GroupProfileCta + data object OpenChat : GroupProfileCta + + /** Not a member, and short of the join minimum. */ + data class BuyToJoin(val requirement: ChatRuleRequirement.MinimumBalance) : GroupProfileCta + + /** A member, and short of the minimum that lets them speak. */ + data class BuyToChat(val requirement: ChatRuleRequirement.MinimumBalance) : GroupProfileCta + + companion object { + /** + * @param access what the listener gate says; [GroupAccess.Undetermined] is iOS's + * `isProvisional`, a guess that offers nothing until a rate arrives. + * @param unmet every unmet rule in the order [unmetRequirements] lists them: listener rules + * first, then speaker rules. + */ + fun resolve( + isMember: Boolean, + access: GroupAccess, + unmet: List, + ): GroupProfileCta { + if (isMember) { + // The last minimum, not the first: the speaker rules come after the listener ones, + // so it is the chat minimum, and it is the larger bar when both are unmet. A + // member under only the join minimum is told to buy to chat, as on iOS. A chat + // that is read-only, staff-only or creator-only has nothing to buy, so it opens. + val minimum = unmet.filterIsInstance().lastOrNull() + return if (minimum != null) BuyToChat(minimum) else OpenChat + } + return when (access) { + GroupAccess.Undetermined -> None + GroupAccess.Eligible, GroupAccess.Membered -> Join + is GroupAccess.Blocked -> { + val minimum = access.unmet as? ChatRuleRequirement.MinimumBalance + if (minimum != null) BuyToJoin(minimum) else None + } + } + } + } +} + +/** + * The balance a group asks for, as the two rows the profile shows. Null when there is no minimum + * to show on either row. + * + * Only the first minimum of each list counts; staff, never and creator rules have no amount. + * [chat] falls back to [join] when no speaker minimum is set, because the server treats the + * listener rules as the speaker rules then. A chat-only minimum leaves [join] null. + */ +internal data class GroupBalanceRequirements( + val join: ChatRuleRequirement.MinimumBalance?, + val chat: ChatRuleRequirement.MinimumBalance?, +) { + companion object { + fun from(rules: ChatRules?): GroupBalanceRequirements? { + val join = rules?.listener.orEmpty() + .filterIsInstance().firstOrNull() + val speaker = rules?.speaker.orEmpty() + .filterIsInstance().firstOrNull() + if (join == null && speaker == null) return null + return GroupBalanceRequirements(join = join, chat = speaker ?: join) + } + } +} + +/** + * What the viewer still needs to buy to meet [requirement], in the requirement's own currency, or + * null when they already meet it or when the requirement cannot be restated (no rate for its + * currency). A null falls back to showing the full requirement. + * + * Compared in USD at display precision, as the gate does: the held side is rounded half-up to + * cents. A holding of the named mint counts alone; a requirement naming no mint is measured + * against everything held. The result is rounded up to the currency's smallest unit, so a buy of + * exactly this much clears the bar. + */ +internal fun balanceShortfall( + requirement: ChatRuleRequirement.MinimumBalance, + balances: List, + rates: Map, +): Fiat? { + val required = requirement.amount.usdValue(rates) ?: return null + val held = heldAgainst(requirement.mints, balances)?.toDouble() ?: 0.0 + if (held >= required) return null + val shortUsd = BigDecimal.valueOf(required).subtract(BigDecimal.valueOf(held)) + .setScale(MICRO_SCALE, RoundingMode.HALF_UP) + val currency = requirement.amount.currencyCode + val short = if (currency == CurrencyCode.USD) { + shortUsd + } else { + val fx = rates[currency]?.takeIf { it.isUsable() }?.fx?.takeIf { it > 0.0 } ?: return null + shortUsd.multiply(BigDecimal.valueOf(fx)).setScale(MICRO_SCALE, RoundingMode.HALF_UP) + } + return Fiat(fiat = short.setScale(currency.fractionDigits, RoundingMode.CEILING).toDouble(), currencyCode = currency) +} + +/** + * The "Your Balance" line: what the viewer holds toward the join minimum (the chat minimum when + * there is none), in that requirement's currency. Null when it cannot be stated, which is a + * requirement in a currency with no rate. + * + * Measured by [heldAgainst], the same measurement the gate uses. + */ +internal fun yourBalance( + requirements: GroupBalanceRequirements, + balances: List, + rates: Map, +): Fiat? { + val rule = requirements.join ?: requirements.chat ?: return null + val heldUsd = (heldAgainst(rule.mints, balances) ?: Fiat.Zero).toDouble() + val currency = rule.amount.currencyCode + val value = if (currency == CurrencyCode.USD) { + heldUsd + } else { + val fx = rates[currency]?.takeIf { it.isUsable() }?.fx?.takeIf { it > 0.0 } ?: return null + heldUsd * fx + } + return Fiat(fiat = value, currencyCode = currency).rounded(currency.fractionDigits) +} + +/** The Chatting section: only for a public group with someone to show. */ +internal fun isChattingGridVisible(isPrivate: Boolean, chatters: List): Boolean = + !isPrivate && chatters.isNotEmpty() + +/** + * The profile's pinned action and the figures behind it, decided together so they are re-decided + * together when a balance or rate moves. + */ +internal data class GroupProfileStanding( + val cta: GroupProfileCta, + /** The shortfall a buy CTA names; null falls back to the requirement's full amount. */ + val shortfall: Fiat?, + /** The "Your Balance" line, when it can be stated. */ + val yourBalance: Fiat?, +) { + companion object { + fun of( + isMember: Boolean, + rules: ChatRules?, + balances: List, + isStaff: Boolean, + rates: Map, + viewerId: ID?, + creatorId: ID?, + ): GroupProfileStanding { + val access = GroupAccess.evaluate(isMember, rules, balances, isStaff, rates) + val unmet = unmetRequirements(rules, balances, isStaff, viewerId, creatorId, rates) + val cta = GroupProfileCta.resolve(isMember, access, unmet) + val bar = when (cta) { + is GroupProfileCta.BuyToJoin -> cta.requirement + is GroupProfileCta.BuyToChat -> cta.requirement + else -> null + } + return GroupProfileStanding( + cta = cta, + shortfall = bar?.let { balanceShortfall(it, balances, rates) }, + yourBalance = GroupBalanceRequirements.from(rules)?.let { yourBalance(it, balances, rates) }, + ) + } + } +} + +private const val MICRO_SCALE = 6 diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileStateTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileStateTest.kt new file mode 100644 index 000000000..2fb266d23 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/GroupProfileStateTest.kt @@ -0,0 +1,297 @@ +package com.flipcash.app.messenger.internal.screens.profile + +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.SampledChatter +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.HolderMetrics +import com.getcode.opencode.model.financial.MintMetadata +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.TokenWithBalance +import com.getcode.opencode.model.financial.VmMetadata +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.PublicKey +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Port of iOS's `GroupProfileStateTests`. */ +class GroupProfileStateTest { + + private fun mint(seed: Byte) = Mint(ByteArray(32) { seed }.toList()) + + private fun token(seed: Byte) = MintMetadata( + address = mint(seed), + decimals = 6, + name = "T$seed", + symbol = "T$seed", + createdAt = null, + description = "", + imageUrl = "", + vmMetadata = VmMetadata( + vm = PublicKey.fromBase58("11111111111111111111111111111111"), + authority = PublicKey.fromBase58("11111111111111111111111111111111"), + lockDurationInDays = 21, + ), + launchpadMetadata = null, + billCustomizations = null, + socialLinks = emptyList(), + holderMetrics = HolderMetrics.None, + ) + + private fun held(seed: Byte, dollars: Double) = + TokenWithBalance(token = token(seed), balance = Fiat(dollars)) + + private fun minimum(amount: Fiat, mints: List = emptyList()) = + ChatRuleRequirement.MinimumBalance(amount, mints) + + private fun minimum(dollars: Double, mints: List = emptyList()) = + minimum(Fiat(dollars), mints) + + private fun rules( + listener: List = emptyList(), + speaker: List = emptyList(), + ) = ChatRules(listener = listener, speaker = speaker) + + private fun standing( + isMember: Boolean, + rules: ChatRules?, + balances: List = emptyList(), + rates: Map = emptyMap(), + ) = GroupProfileStanding.of( + isMember = isMember, + rules = rules, + balances = balances, + isStaff = false, + rates = rates, + viewerId = null, + creatorId = null, + ) + + // CTA + + @Test + fun `non-member short of the join minimum buys to join`() { + val join = minimum(10.0) + assertEquals( + GroupProfileCta.BuyToJoin(join), + standing(false, rules(listener = listOf(join))).cta, + ) + } + + @Test + fun `non-member who meets the join minimum joins`() { + assertEquals( + GroupProfileCta.Join, + standing(false, rules(listener = listOf(minimum(10.0))), listOf(held(1, 20.0))).cta, + ) + } + + @Test + fun `non-member of a group with no rules joins`() { + assertEquals(GroupProfileCta.Join, standing(false, null).cta) + assertEquals(GroupProfileCta.Join, standing(false, rules()).cta) + } + + @Test + fun `non-member of a staff-only group has nothing to do`() { + assertEquals( + GroupProfileCta.None, + standing(false, rules(listener = listOf(ChatRuleRequirement.Staff))).cta, + ) + } + + @Test + fun `non-member with a minimum in a currency with no rate has nothing to do yet`() { + assertEquals( + GroupProfileCta.None, + standing(false, rules(listener = listOf(minimum(Fiat(10.0, CurrencyCode.CAD))))).cta, + ) + } + + @Test + fun `member who may speak opens the chat`() { + assertEquals( + GroupProfileCta.OpenChat, + standing( + true, + rules(listener = listOf(minimum(10.0)), speaker = listOf(minimum(100.0))), + listOf(held(1, 500.0)), + ).cta, + ) + } + + @Test + fun `member short of the chat minimum only buys to chat`() { + val chat = minimum(100.0) + assertEquals( + GroupProfileCta.BuyToChat(chat), + standing( + true, + rules(listener = listOf(minimum(10.0)), speaker = listOf(chat)), + listOf(held(1, 12.0)), + ).cta, + ) + } + + @Test + fun `member short of both minimums buys to chat for the larger one`() { + val chat = minimum(100.0) + assertEquals( + GroupProfileCta.BuyToChat(chat), + standing(true, rules(listener = listOf(minimum(10.0)), speaker = listOf(chat))).cta, + ) + } + + @Test + fun `member under the join minimum alone buys to chat`() { + val join = minimum(10.0) + assertEquals( + GroupProfileCta.BuyToChat(join), + standing(true, rules(listener = listOf(join))).cta, + ) + } + + @Test + fun `member of a read-only group opens the chat`() { + assertEquals( + GroupProfileCta.OpenChat, + standing(true, rules(speaker = listOf(ChatRuleRequirement.Never))).cta, + ) + } + + // Requirements + + @Test + fun `requirements carry a join and a chat minimum`() { + val join = minimum(10.0) + val chat = minimum(100.0) + assertEquals( + GroupBalanceRequirements(join, chat), + GroupBalanceRequirements.from(rules(listener = listOf(join), speaker = listOf(chat))), + ) + } + + @Test + fun `chat falls back to the join minimum`() { + val join = minimum(10.0) + assertEquals( + GroupBalanceRequirements(join, join), + GroupBalanceRequirements.from( + rules(listener = listOf(join), speaker = listOf(ChatRuleRequirement.Staff)) + ), + ) + } + + @Test + fun `a chat-only minimum leaves join empty`() { + val chat = minimum(100.0) + assertEquals( + GroupBalanceRequirements(null, chat), + GroupBalanceRequirements.from(rules(speaker = listOf(chat))), + ) + } + + @Test + fun `requirements are hidden without a minimum`() { + assertNull(GroupBalanceRequirements.from(null)) + assertNull(GroupBalanceRequirements.from(rules())) + assertNull( + GroupBalanceRequirements.from( + rules( + listener = listOf(ChatRuleRequirement.Staff), + speaker = listOf(ChatRuleRequirement.Never), + ) + ) + ) + } + + // Shortfall + + @Test + fun `shortfall counts every holding when no mint is named`() { + assertEquals( + Fiat(3.67), + balanceShortfall(minimum(10.0), listOf(held(1, 3.0), held(2, 3.33)), emptyMap()), + ) + } + + @Test + fun `shortfall counts only the named mint`() { + assertEquals( + Fiat(6.0), + balanceShortfall( + minimum(10.0, listOf(mint(1))), + listOf(held(1, 4.0), held(2, 100.0)), + emptyMap(), + ), + ) + } + + @Test + fun `shortfall is restated in the requirement's currency and rounded up`() { + // 10 CAD at 1.25 per dollar is $8; holding $2.40 leaves $5.60, which is 7 CAD. + val rates = mapOf(CurrencyCode.CAD to Rate(1.25, CurrencyCode.CAD)) + assertEquals( + Fiat(7.0, CurrencyCode.CAD), + balanceShortfall(minimum(Fiat(10.0, CurrencyCode.CAD)), listOf(held(1, 2.4)), rates), + ) + } + + @Test + fun `shortfall is null once the requirement is met`() { + assertNull(balanceShortfall(minimum(10.0), listOf(held(1, 10.0)), emptyMap())) + } + + // Chatting grid + + private fun chatter() = SampledChatter( + userProfile = UserProfile( + displayName = "Ted", + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ), + lastSentAt = null, + isCreator = false, + ) + + @Test + fun `a private group hides the grid even with chatters`() { + assertFalse(isChattingGridVisible(isPrivate = true, chatters = listOf(chatter()))) + } + + @Test + fun `no chatters hides the grid`() { + assertFalse(isChattingGridVisible(isPrivate = false, chatters = emptyList())) + } + + @Test + fun `a public group with chatters shows the grid`() { + assertTrue(isChattingGridVisible(isPrivate = false, chatters = listOf(chatter()))) + } + + // Your Balance + + @Test + fun `your balance reads the join minimum's mint`() { + val requirements = GroupBalanceRequirements( + join = minimum(10.0, listOf(mint(1))), + chat = minimum(100.0, listOf(mint(2))), + ) + assertEquals( + Fiat(4.0), + yourBalance(requirements, listOf(held(1, 4.0), held(2, 50.0)), emptyMap()), + ) + } + + @Test + fun `your balance is hidden when its currency has no rate`() { + val requirements = GroupBalanceRequirements(minimum(Fiat(10.0, CurrencyCode.CAD)), null) + assertNull(yourBalance(requirements, listOf(held(1, 4.0)), emptyMap())) + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 326e5d2fa..a5424d74f 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -10,6 +10,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey @@ -552,6 +553,22 @@ interface GroupOperations { * fails. */ suspend fun leave(chatId: ChatId): Result + + /** + * A sample of [chatId]'s recent chatters, in the order the server returns them. Denied for a + * private group, which is the caller's to skip. Nothing is stored: the profile that asks + * reads it fresh each time it opens. + */ + suspend fun sampleChatters(chatId: ChatId): Result + + /** + * Stores the cover picture `GetChat` holds for [chatId], when it holds one. + * + * The feed RPCs may leave a cover unset even when one exists, so a row that arrived through + * them can lack the cover its profile shows. Writes only a cover that came back, so a failed + * or cover-less answer leaves whatever is stored alone. + */ + suspend fun refreshCover(chatId: ChatId) } interface ChatCoordinator : diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt index 891feb10a..a2faae7d5 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupAccess.kt @@ -10,6 +10,7 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.opencode.model.financial.sum +import com.getcode.solana.keys.PublicKey import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -246,12 +247,6 @@ private fun ChatRuleRequirement.isUnmet( viewerId: ID? = null, creatorId: ID? = null, ): Boolean { - // Keyed by bytes, not by the key object: `class Mint(bytes) : PublicKey(bytes)` - // (libs/encryption/keys/.../Mint.kt), so the `PublicKey`s in `mints` are not `Mint`s and - // a map keyed by `Mint` would miss every one of them. - val byMint: Map, Fiat> = - balances.associate { it.token.address.bytes to it.balance } - return when (this) { is ChatRuleRequirement.MinimumBalance -> { // Balances are held in USD; a requirement in another currency is restated through @@ -261,11 +256,7 @@ private fun ChatRuleRequirement.isUnmet( // qualifies for as long as the rate table lacks that currency. // [GroupAccess.Undetermined] is how the listener gate admits it is guessing. val required = amount.usdValue(rates) ?: return false - val held = if (mints.isEmpty()) { - byMint.values.sum() - } else { - mints.mapNotNull { byMint[it.bytes] }.maxOrNull() - } + val held = heldAgainst(mints, balances) // 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 @@ -293,11 +284,53 @@ private fun ChatRuleRequirement.isUnmet( } } +/** + * What the viewer holds toward a minimum balance naming [mints], in USD, or null when [mints] is + * non-empty and none of them is held. + * + * The largest single balance among the named mints, not 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 and is measured against everything held, added up. This is the one measurement the + * gate ([isUnmet]) and a screen that shows the viewer's own balance both read. + */ +fun heldAgainst(mints: List, balances: List): Fiat? { + // Keyed by bytes, not by the key object: `class Mint(bytes) : PublicKey(bytes)` + // (libs/encryption/keys/.../Mint.kt), so the `PublicKey`s in `mints` are not `Mint`s and + // a map keyed by `Mint` would miss every one of them. + val byMint: Map, Fiat> = + balances.associate { it.token.address.bytes to it.balance } + return if (mints.isEmpty()) { + byMint.values.sum() + } else { + mints.mapNotNull { byMint[it.bytes] }.maxOrNull() + } +} + +/** + * Every unmet rule in the order iOS's speaker verdict lists them: the listener rules first, then + * the speaker rules. Empty when the viewer may speak. + * + * [resolveSpeakerBlock] names the first minimum balance for the composer; a caller that needs the + * last one (the chat's own bar, which also covers the join bar) reads it from here. + */ +fun unmetRequirements( + rules: ChatRules?, + balances: List, + isStaff: Boolean, + viewerId: ID? = null, + creatorId: ID? = null, + rates: Map = emptyMap(), +): List { + val listenerUnmet = rules?.listener.orEmpty().filter { it.isUnmet(balances, isStaff, rates) } + val speakerUnmet = rules?.speaker.orEmpty().filter { it.isUnmet(balances, isStaff, rates, viewerId, creatorId) } + return listenerUnmet + speakerUnmet +} + /** * This amount in USD, or null when it is in another currency with no usable rate. A [Rate]'s `fx` * is units of its currency per US dollar, so the USD worth is the amount divided by it. */ -private fun Fiat.usdValue(rates: Map): Double? { +fun Fiat.usdValue(rates: Map): Double? { if (currencyCode == CurrencyCode.USD) return decimalValue val fx = rates[currencyCode]?.takeIf { it.isUsable() }?.fx?.takeIf { it > 0.0 } ?: return null return decimalValue / fx diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt index 4792e6c82..1609bbff4 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/GroupFeedDelegate.kt @@ -8,6 +8,7 @@ import com.flipcash.app.persistence.sources.lastMessagesByChat import com.flipcash.services.controllers.ChatController import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.RosterChange @@ -203,6 +204,14 @@ class GroupFeedDelegate @Inject constructor( } } + override suspend fun sampleChatters(chatId: ChatId): Result = + chatController.sampleChatters(chatId) + + override suspend fun refreshCover(chatId: ChatId) { + val cover = chatController.getChat(chatId).getOrNull()?.coverPicture ?: return + metadataDataSource.updateCoverPicture(chatId, cover) + } + /** * Handles the list-level meaning of [changes], then hands them to [RosterStateHolder] for the * roster itself. diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt index a2f43f10a..f44e2bff5 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupAccessTest.kt @@ -362,6 +362,28 @@ class GroupAccessTest { ) } + @Test + fun `unmet requirements list the listener rules before the speaker rules`() { + val listenerBar = ChatRuleRequirement.MinimumBalance(Fiat(100.0), listOf(badBoys)) + val speakerBar = ChatRuleRequirement.MinimumBalance(Fiat(500.0), listOf(badBoys)) + val both = ChatRules(listener = listOf(listenerBar), speaker = listOf(speakerBar)) + + assertEquals(listOf(listenerBar, speakerBar), unmetRequirements(both, emptyList(), isStaff = false)) + assertEquals(listOf(speakerBar), unmetRequirements(both, listOf(held(1, "BadBoys", 200.0)), isStaff = false)) + assertEquals(emptyList(), unmetRequirements(both, listOf(held(1, "BadBoys", 600.0)), isStaff = false)) + // The composer still names the first one. + assertEquals(listenerBar, unmetSpeakerRequirement(both, emptyList(), isStaff = false)) + } + + @Test + fun `held against named mints is the largest, against none the total`() { + val balances = listOf(held(1, "BadBoys", 60.0), held(2, "Other", 60.0)) + + assertEquals(60.0, heldAgainst(listOf(badBoys), balances)?.toDouble()) + assertEquals(120.0, heldAgainst(emptyList(), balances)?.toDouble()) + assertEquals(null, heldAgainst(listOf(badBoys), emptyList())) + } + @Test fun `with no speaker rules a met listener rule lets the viewer speak`() { val listenerOnly = rules(ChatRuleRequirement.MinimumBalance(Fiat(500.0), listOf(badBoys))) From 3d54fdc23416d36fcead06a95fefb43eabd8c65a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 10:29:02 -0400 Subject: [PATCH 02/10] feat(common-ui): chatting grid and balance requirements card --- .../ui/profile/BalanceRequirementsCard.kt | 127 ++++++++++++ .../common/ui/profile/GroupChattingGrid.kt | 186 ++++++++++++++++++ .../common-ui/src/main/res/values/strings.xml | 7 + 3 files changed, 320 insertions(+) create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt create mode 100644 apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt new file mode 100644 index 000000000..649168af7 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/BalanceRequirementsCard.kt @@ -0,0 +1,127 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.shared.common.ui.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 + +private val RowHeight = 56.dp +private val CardRadius = 12.dp +private val RowInset = 16.dp + +/** + * The balance a group asks for, as a card of two rows, Join and Chat, with the viewer's own + * balance under it when [yourBalance] is known. + * + * Values arrive formatted, since the caller owns currency and token naming: "$10", "$10 of NYC". + * A null [join] or [chat] reads "None", so a group that only gates speaking still shows a Join row. + */ +@Composable +fun BalanceRequirementsCard( + join: String?, + chat: String?, + yourBalance: String?, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.title_balanceRequirements), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(CardRadius)) + .background(White05), + ) { + RequirementRow( + label = stringResource(R.string.label_balanceRequirementJoin), + value = join ?: stringResource(R.string.label_balanceRequirementNone), + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(CodeTheme.dimens.border) + .background(CodeTheme.colors.divider) + ) + RequirementRow( + label = stringResource(R.string.label_balanceRequirementChat), + value = chat ?: stringResource(R.string.label_balanceRequirementNone), + ) + } + if (yourBalance != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = RowInset), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.label_yourBalance), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = yourBalance, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } + } +} + +@Composable +private fun RequirementRow(label: String, value: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(RowHeight) + .padding(horizontal = RowInset), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = value, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_BalanceRequirementsCard() { + Column(verticalArrangement = Arrangement.spacedBy(24.dp)) { + BalanceRequirementsCard(join = "$10 of NYC", chat = "$25 of NYC", yourBalance = "$4.50 of NYC") + BalanceRequirementsCard(join = null, chat = "$2.50", yourBalance = null) + } +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt new file mode 100644 index 000000000..46eb7b002 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/GroupChattingGrid.kt @@ -0,0 +1,186 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.WavingHand +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.R +import com.getcode.opencode.model.core.ID +import com.getcode.theme.CodeTheme + +private const val Columns = 4 +private val SectionSpacing = 16.dp +private val ColumnSpacing = 8.dp +private val RowSpacing = 16.dp +private val PortraitSize = 60.dp +private val HostBadgeSize = 22.dp +private val HostBadgeRing = 2.dp +private val HostBadgeIconSize = 12.dp +private val HostBadgeOffset = 2.dp + +/** One chatter as the grid draws it, already resolved to what is shown. */ +data class ChattingGridItem( + val userId: ID, + val name: String, + val picture: MediaItem?, + val isHost: Boolean, +) + +/** + * What a chatter is called under their portrait: their display name, else their handle, else + * [fallback]. Blank counts as missing, since the server sends an empty display name for a profile + * that has none. + */ +fun chatterName(displayName: String?, username: String?, fallback: String): String = + displayName?.takeIf { it.isNotBlank() } + ?: username?.takeIf { it.isNotBlank() }?.let { "@$it" } + ?: fallback + +/** + * Who has been talking in a public group: a "Chatting" heading over portraits four to a row, the + * host badged. Draws nothing for an empty list, heading included. + * + * Rows are built by hand rather than as a lazy grid so it can sit inside the profile's own scroll. + * A short last row leaves its empty cells blank to keep the columns aligned. The order is the + * caller's; the grid does not sort. + */ +@Composable +fun GroupChattingGrid( + chatters: List, + onOpen: (ChattingGridItem) -> Unit, + modifier: Modifier = Modifier, +) { + if (chatters.isEmpty()) return + + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(SectionSpacing), + ) { + Text( + text = stringResource(R.string.title_chatting), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Column(verticalArrangement = Arrangement.spacedBy(RowSpacing)) { + chatters.chunked(Columns).forEach { row -> + Row(horizontalArrangement = Arrangement.spacedBy(ColumnSpacing)) { + row.forEach { chatter -> + ChatterCell( + modifier = Modifier.weight(1f), + chatter = chatter, + onClick = { onOpen(chatter) }, + ) + } + repeat(Columns - row.size) { Box(modifier = Modifier.weight(1f)) } + } + } + } + } +} + +@Composable +private fun ChatterCell( + chatter: ChattingGridItem, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val hostLabel = stringResource(R.string.label_chatterHost, chatter.name) + Column( + modifier = modifier + .clickable(role = Role.Button, onClick = onClick) + .then(if (chatter.isHost) Modifier.semantics { contentDescription = hostLabel } else Modifier), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box { + ContactAvatar( + image = chatter.picture, + displayName = chatter.name, + access = BlobAccessContext.profile(chatter.userId), + modifier = Modifier + .size(PortraitSize) + .clip(CircleShape), + ) + if (chatter.isHost) { + HostBadge( + modifier = Modifier + .align(Alignment.BottomEnd) + .offset(x = HostBadgeOffset, y = HostBadgeOffset), + ) + } + } + Text( + modifier = Modifier.fillMaxWidth(), + text = chatter.name, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textMain, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** A white disc with a wave, ringed in the page's background so it reads as cut out of the portrait. */ +@Composable +private fun HostBadge(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .size(HostBadgeSize) + .background(CodeTheme.colors.textMain, CircleShape) + .border(HostBadgeRing, CodeTheme.colors.background, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(HostBadgeIconSize), + imageVector = Icons.Outlined.WavingHand, + contentDescription = null, + tint = CodeTheme.colors.background, + ) + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_GroupChattingGrid() { + GroupChattingGrid( + chatters = List(6) { index -> + ChattingGridItem( + userId = listOf(index.toByte()), + name = if (index == 0) "Ada Lovelace" else "@user$index", + picture = null, + isHost = index == 0, + ) + }, + onOpen = {}, + ) +} diff --git a/apps/flipcash/shared/common-ui/src/main/res/values/strings.xml b/apps/flipcash/shared/common-ui/src/main/res/values/strings.xml index 51a0a846a..e7825830b 100644 --- a/apps/flipcash/shared/common-ui/src/main/res/values/strings.xml +++ b/apps/flipcash/shared/common-ui/src/main/res/values/strings.xml @@ -3,4 +3,11 @@ Minimum to Chat Date Joined — + Chatting + %1$s, host + Balance Requirements + Join + Chat + None + Your Balance From 120449c649f80600726aa541e9cf881682c3ecc3 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 10:36:32 -0400 Subject: [PATCH 03/10] feat(groups): drive the group profile from the chat view model Sample chatters each time the profile opens, name the tokens behind the join and chat rows, and keep the pinned action's standing current as balances and rates move. Share builds its link without the membership check so a non-member can hand the group out. --- .../com/flipcash/app/core/chat/ChatStep.kt | 6 + .../core/src/main/res/values/strings.xml | 18 ++- .../app/core/chat/ProfileOriginTest.kt | 2 +- .../flipcash/app/messenger/ChatFlowScreen.kt | 4 +- .../app/messenger/internal/ChatViewModel.kt | 138 +++++++++++++++++- .../internal/screens/profile/MuteMenuItems.kt | 38 ----- .../ui/profile/ProfilePinnedActionBar.kt | 3 + 7 files changed, 157 insertions(+), 52 deletions(-) delete mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/MuteMenuItems.kt diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 0c505a126..458dc3a23 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -151,6 +151,12 @@ enum class ProfileOrigin { */ Mention, + /** + * A portrait in a public group's Chatting grid. Like [Mention], the person is not who the chat + * is with, so Mute would act on the wrong chat and opening a chat has somewhere new to go. + */ + GroupMember, + /** A `flipcash.com/...` link. The default for `AppRoute.Messaging.Profile`. Blocking pops back. */ Link, diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 8c8497f25..ff2f1f41f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -915,13 +915,19 @@ Only the creator can send messages Update Flipcash to send messages Buy More %1$s + Join Join Chat Leave Chat - Leave + Encryption + %1$s Required to Join + %1$s Required to Chat + Buy %1$s to Join + Buy %1$s to Chat + Leave Chat Leave %1$s? - You won’t receive new messages from this chat. You can join it again if it still lets you in + You won’t receive messages from this group any more. You can join again with an invite link Something Went Wrong - We were unable to leave the chat. Please try again + We were unable to leave this group. Please try again