From 1bcf144a052242a40ec3752d395905e50781a2e6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 16:20:49 -0400 Subject: [PATCH 1/6] feat(chat): edit a group's balance requirements from Edit Group Edit Group now shows the Balance Requirements card under its rows. The Join row reads the listener rule and the Chat row reads the speaker rule, falling back to Join's, as the group profile does. A row the group has no rule for reads "None". Tapping a row opens the shared amount keypad for that rule. The keypad enters in the preferred currency and saves in USD, through the same conversion group creation uses. That conversion moves to shared/chat as minimumBalanceFor. The keypad is seeded with the current rule. A replaced rule keeps its mints; a new rule names none. Save stays disabled for an empty or zero entry, without a usable rate, or when the entry matches the current rule to the cent. Saving asks for confirmation first. flipcash2-client-protocol 0.18.0 has no RPC that changes a group's rules after StartChat. GroupOperations.setMinimumBalance therefore fails with SetGroupMinimumBalanceError.Unavailable for now. The screen shows that as "Not Available Yet" and does not report it; any other failure is reported. Swap in the real call inside GroupFeedDelegate.setMinimumBalance. --- .../com/flipcash/app/core/chat/ChatStep.kt | 12 +- .../core/src/main/res/values/strings.xml | 18 + .../flipcash/app/messenger/ChatFlowScreen.kt | 13 +- .../edit/EditGroupBalanceRequirementScreen.kt | 100 ++++++ .../EditGroupBalanceRequirementViewModel.kt | 307 ++++++++++++++++++ .../screens/profile/edit/EditGroupScreen.kt | 14 +- ...ditGroupBalanceRequirementViewModelTest.kt | 245 ++++++++++++++ .../profile/edit/EditGroupConfirmationTest.kt | 2 +- .../flipcash/shared/chat/ChatCoordinator.kt | 16 + .../shared/chat/MinimumBalanceAmount.kt | 24 ++ .../internal/delegates/GroupFeedDelegate.kt | 12 + .../ui/profile/BalanceRequirementsCard.kt | 41 ++- .../com/flipcash/services/models/Errors.kt | 15 + .../services/models/chat/ChatRules.kt | 9 + 14 files changed, 816 insertions(+), 12 deletions(-) create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt create mode 100644 apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MinimumBalanceAmount.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 917cada750..491fe1bd80 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 @@ -2,6 +2,7 @@ package com.flipcash.app.core.chat import android.os.Parcelable import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.GroupBalanceRole import com.getcode.solana.keys.Mint import com.getcode.navigation.FullscreenSheet import com.getcode.navigation.HalfSheet @@ -192,7 +193,7 @@ sealed interface ChatStep : FlowStep, Parcelable { * view model. * * Name, description, picture and cover each open their own editor and save on their own; - * the balance requirements are shown read-only, since nothing on `EditChatRequest` writes them. + * the Balance Requirements card's rows open [EditGroupBalanceRequirement]. */ @Parcelize @Serializable @@ -235,6 +236,15 @@ sealed interface ChatStep : FlowStep, Parcelable { @Serializable data object EditGroupDescription : ChatStep + /** + * One of the group's minimum balances, on the shared amount keypad — pushed from a row of + * [EditGroup]'s Balance Requirements card. [role] picks the listener (Join) or speaker (Chat) + * rule. + */ + @Parcelize + @Serializable + data class EditGroupBalanceRequirement(val role: GroupBalanceRole) : ChatStep + /** * The full emoji picker for a message's reaction, opened from the pill row's "+" or the * quick strip's "+". A plain [Sheet] rather than [WrapContentSheet]: the picker's catalog diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index be9f6e2903..c3a447146f 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1495,6 +1495,24 @@ Couldn\'t Save the Description Try again + + Join Requirement + Chat Requirement + People won\'t be able to join this group if their balance is less than this amount + People won\'t be able to send messages in this group if their balance is less than this amount + Change Join Requirement? + This will change who can join the group + Change Join Requirement + Change Chat Requirement? + This will change who can send messages in the group + Change Chat Requirement + + Not Available Yet + Balance requirements can\'t be changed yet + Couldn\'t Save the Requirement + Try again + Invite to Join Group Send Invite Link diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index ad25d04d63..49f4c8169f 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -75,6 +75,7 @@ import com.flipcash.app.messenger.internal.screens.profile.JoinRequestsScreen import com.flipcash.app.messenger.internal.screens.profile.MembersScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupCoverScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupDescriptionScreen +import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupBalanceRequirementScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupNameScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupPictureScreen import com.flipcash.app.messenger.internal.screens.profile.edit.EditGroupScreen @@ -83,6 +84,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.shared.amountentry.AmountEntryScreen import com.getcode.manager.BottomBarManager import com.getcode.navigation.annotatedEntry +import com.flipcash.services.models.chat.GroupBalanceRole import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowHost import com.getcode.navigation.flow.flowSharedViewModel @@ -297,6 +299,10 @@ private fun chatEntryProvider( ) } + annotatedEntry { step -> + FlowEditGroupBalanceRequirementScreen(step.role) + } + annotatedEntry { step -> FlowReactionPickerScreen(step.messageId) } @@ -729,7 +735,7 @@ private fun FlowGroupProfileScreen() { // The edit steps read the chat they are editing off the flow's shared ChatViewModel, the same way // the profile above them does, and keep the edit itself in their own nav-entry-scoped view model. -// The list itself holds no edit, but reads the group and its permissions the same way. +// The list itself holds no edit, but reads the group, its permissions and its rules the same way. @Composable private fun FlowEditGroupScreen() { EditGroupScreen(flowSharedViewModel()) @@ -755,6 +761,11 @@ private fun FlowEditGroupPictureScreen() { EditGroupPictureScreen(flowSharedViewModel()) } +@Composable +private fun FlowEditGroupBalanceRequirementScreen(role: GroupBalanceRole) { + EditGroupBalanceRequirementScreen(flowSharedViewModel(), role) +} + /** * The full emoji picker for [messageId] — a tap on any emoji toggles it and dismisses. The search * lives in the entry's own [EmojiPickerViewModel]; only the toggle goes to the chat. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt new file mode 100644 index 0000000000..077f9f014a --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt @@ -0,0 +1,100 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.os.Parcelable +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.chat.ChatStep +import com.flipcash.app.messenger.internal.ChatSubject +import com.flipcash.app.messenger.internal.ChatViewModel +import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequirements +import com.flipcash.features.messenger.R +import com.flipcash.services.models.chat.GroupBalanceRole +import com.flipcash.shared.amountentry.AmountEntryScreen +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * One of the group's minimum balances on the shared amount keypad, opened from a row of Edit + * Group's Balance Requirements card. [role] picks the rule: Join is the listener rule, Chat the + * speaker rule, falling back to Join's when the group has no speaker rule of its own — the same + * reading the card shows. + */ +@Composable +internal fun EditGroupBalanceRequirementScreen( + chatViewModel: ChatViewModel, + role: GroupBalanceRole, +) { + val flowNavigator = rememberFlowNavigator() + val chatState by chatViewModel.stateFlow.collectAsStateWithLifecycle() + val group = chatState.subject as? ChatSubject.Group + + val viewModel = hiltViewModel() + + // Keyed on the chat: the seed waits for the group to resolve and must not run again, or a rule + // arriving from the stream mid-edit would reset the baseline under the user. + LaunchedEffect(group?.chatId) { + val resolved = group ?: return@LaunchedEffect + val requirements = GroupBalanceRequirements.from(resolved.rules) + viewModel.dispatchEvent( + EditGroupBalanceRequirementViewModel.Event.Initialize( + chatId = resolved.chatId, + role = role, + current = when (role) { + GroupBalanceRole.Join -> requirements?.join + GroupBalanceRole.Chat -> requirements?.chat + }, + ) + ) + } + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .onEach { flowNavigator.back() } + .launchIn(this) + } + + AmountEntryScreen( + controller = viewModel.amountDelegate, + onConfirm = { + viewModel.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked) + }, + largeHeader = true, + appBar = { + AppBarWithTitle( + title = stringResource( + when (role) { + GroupBalanceRole.Join -> R.string.title_groupJoinRequirement + GroupBalanceRole.Chat -> R.string.title_groupChatRequirement + } + ), + titleAlignment = Alignment.CenterHorizontally, + onBackIconClicked = { flowNavigator.back() }, + ) + }, + headerCaption = { + Text( + text = stringResource( + when (role) { + GroupBalanceRole.Join -> R.string.description_groupJoinRequirement + GroupBalanceRole.Chat -> R.string.description_groupChatRequirement + } + ), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Start, + ) + }, + ) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt new file mode 100644 index 0000000000..ad43cb39b8 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt @@ -0,0 +1,307 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import androidx.annotation.VisibleForTesting +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.features.messenger.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.models.SetGroupMinimumBalanceError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.GroupBalanceRole +import com.flipcash.shared.amountentry.AmountEntryDelegate +import com.flipcash.shared.amountentry.AmountEntryLabel +import com.flipcash.shared.amountentry.AmountEntryStyle +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.minimumBalanceFor +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +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.util.resources.ResourceHelper +import com.getcode.utils.ErrorUtils +import com.getcode.view.BaseViewModel +import com.getcode.view.LoadingSuccessState +import com.getcode.view.SuccessHoldDuration +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlin.time.Duration.Companion.milliseconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * One of a group's minimum balances, behind a row of Edit Group's Balance Requirements card. + * + * Entry is in the preferred currency and stored in USD, through the same [minimumBalanceFor] group + * creation uses, so the same keypad entry writes the same rule from either screen. The rule's + * mints carry over from the requirement being replaced; a group with none gets an empty list. + * + * The write goes through [ChatCoordinator.setMinimumBalance], which fails with + * [SetGroupMinimumBalanceError.Unavailable] until the contract can change a group's rules. That + * failure is expected on every save, so it is announced but not reported. + */ +@HiltViewModel +class EditGroupBalanceRequirementViewModel @Inject constructor( + dispatchers: DispatcherProvider, + private val exchange: Exchange, + private val chatCoordinator: ChatCoordinator, + private val resources: ResourceHelper, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + data class State( + val chatId: ChatId? = null, + val role: GroupBalanceRole = GroupBalanceRole.Join, + /** The rule being replaced, or null when the group has none for [role]. */ + val current: ChatRuleRequirement.MinimumBalance? = null, + /** The preferred rate the keypad enters in; null until it resolves. */ + val rate: Rate? = null, + val processingState: LoadingSuccessState = LoadingSuccessState(), + ) + + sealed interface Event { + /** The screen handing over the group, the rule being edited, and its current value. */ + data class Initialize( + val chatId: ChatId, + val role: GroupBalanceRole, + val current: ChatRuleRequirement.MinimumBalance?, + ) : Event + + data class RateChanged(val rate: Rate) : Event + + /** Save, pressed. Only proposes the change; [Submit] is dispatched by the prompt. */ + data object SaveClicked : Event + + /** The confirmed change. */ + data class Submit(val requirement: ChatRuleRequirement.MinimumBalance) : Event + + data class UpdateProcessingState( + val loading: Boolean = false, + val success: Boolean = false, + ) : Event + + /** The change was stored; the screen can leave. */ + data object OnRequirementAccepted : Event + } + + /** Hilt cannot inject a defaulted lambda, so tests swap this to observe what was reported. */ + @VisibleForTesting + internal var reportError: (Throwable) -> Unit = ErrorUtils::handleError + + /** Whether Save is live; the keypad's confirm gate. Exposed for tests. */ + @VisibleForTesting + internal val canSave = MutableStateFlow(false) + + val amountDelegate = AmountEntryDelegate( + exchange = exchange, + scope = viewModelScope, + style = MutableStateFlow( + AmountEntryStyle( + actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_save)), + actionStyle = ConfirmationStyle.Button, + canChangeCurrency = false, + ) + ), + loadingState = stateFlow.map { it.processingState } + .stateIn(viewModelScope, SharingStarted.Eagerly, LoadingSuccessState()), + confirmEnabled = canSave, + ) + + // Seeds once. `prefill` types on top of the entry, so a second pass would append to whatever + // the user has typed since. + private var seeded = false + + init { + exchange.observePreferredRate() + .onEach { rate -> + exchange.getCurrency(rate.currency.name)?.let(amountDelegate::onCurrencyChanged) + dispatchEvent(Event.RateChanged(rate)) + seedIfReady() + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { seedIfReady() } + .launchIn(viewModelScope) + + combine( + amountDelegate.state.map { it.enteredAmount }.distinctUntilChanged(), + stateFlow, + ) { entered, state -> + state.processingState.isIdle && state.chatId != null && + requirementFor(entered, state.rate, state.current, usdOf(state.current)) != null + }.onEach { canSave.value = it }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + val state = stateFlow.value + if (!state.processingState.isIdle) return@onEach + val requirement = requirementFor( + entered = amountDelegate.state.value.enteredAmount, + rate = state.rate, + current = state.current, + currentUsd = usdOf(state.current), + ) ?: return@onEach + + val copy = promptCopy(state.role) + BottomBarManager.showAlert( + title = resources.getString(copy.title), + message = resources.getString(copy.message), + actions = listOf( + BottomBarAction(resources.getString(copy.action)) { + viewModelScope.launch { + // The bar dismisses on an animation; the spinner belongs to the + // screen behind it and would otherwise start underneath. + delay(150.milliseconds) + dispatchEvent(Event.Submit(requirement)) + } + } + ), + showCancel = true, + ) + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { event -> + val state = stateFlow.value + val chatId = state.chatId ?: return@onEach + if (!state.processingState.isIdle) return@onEach + + dispatchEvent(Event.UpdateProcessingState(loading = true)) + chatCoordinator.setMinimumBalance(chatId, state.role, event.requirement) + .onSuccess { + dispatchEvent(Event.UpdateProcessingState(success = true)) + viewModelScope.launch { + delay(SuccessHoldDuration) + dispatchEvent(Event.OnRequirementAccepted) + dispatchEvent(Event.UpdateProcessingState()) + } + } + .onFailure { cause -> + dispatchEvent(Event.UpdateProcessingState()) + announceFailure(cause) + } + }.launchIn(viewModelScope) + } + + /** + * Types the current rule into the keypad, restated in the entry currency, once both the rule + * and a usable rate are known. A group with no rule leaves the keypad empty. + */ + private fun seedIfReady() { + if (seeded) return + val state = stateFlow.value + if (state.chatId == null) return + val rate = state.rate?.takeIf { it.isUsable() } ?: return + seeded = true + + val currentUsd = usdOf(state.current) ?: return + // An untouched keypad reads "0" rather than empty. + if (amountDelegate.state.value.enteredAmount != 0.0) return + val inEntryCurrency = currentUsd.convertingTo(rate).rounded(rate.currency.fractionDigits) + if (inEntryCurrency.decimalValue > 0.0) amountDelegate.prefill(inEntryCurrency.decimalValue) + } + + /** + * [requirement]'s amount in USD. Rules are written in USD — group creation and this screen + * both store them that way — but the proto carries a currency, so one that arrives in another + * is restated rather than read as dollars. + */ + private fun usdOf(requirement: ChatRuleRequirement.MinimumBalance?): Fiat? { + val amount = requirement?.amount ?: return null + if (amount.currencyCode == CurrencyCode.USD) return amount + val rate = exchange.rateFor(amount.currencyCode)?.takeIf { it.isUsable() } ?: return null + return amount.convertingToUsdIfNeeded(rate) + } + + private fun announceFailure(cause: Throwable) { + when (cause) { + is SetGroupMinimumBalanceError.Unavailable -> BottomBarManager.showInfo( + title = resources.getString(R.string.error_title_groupRequirementUnavailable), + message = resources.getString(R.string.error_description_groupRequirementUnavailable), + ) + + else -> { + reportError(cause) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_groupRequirementFailed), + message = resources.getString(R.string.error_description_groupRequirementFailed), + ) + } + } + } + + private data class PromptCopy(val title: Int, val message: Int, val action: Int) + + private fun promptCopy(role: GroupBalanceRole) = when (role) { + GroupBalanceRole.Join -> PromptCopy( + title = R.string.prompt_title_changeGroupJoinRequirement, + message = R.string.prompt_description_changeGroupJoinRequirement, + action = R.string.action_changeGroupJoinRequirement, + ) + + GroupBalanceRole.Chat -> PromptCopy( + title = R.string.prompt_title_changeGroupChatRequirement, + message = R.string.prompt_description_changeGroupChatRequirement, + action = R.string.action_changeGroupChatRequirement, + ) + } + + internal companion object { + /** + * The rule [entered] would store, or null when there is nothing to save: no usable rate, + * an entry that rounds to nothing in USD, or one equal to [currentUsd] in cents. The mints + * carry over from [current]. + */ + fun requirementFor( + entered: Double, + rate: Rate?, + current: ChatRuleRequirement.MinimumBalance?, + currentUsd: Fiat?, + ): ChatRuleRequirement.MinimumBalance? { + val usd = minimumBalanceFor(entered, rate ?: return null) ?: return null + if (currentUsd?.rounded(CurrencyCode.USD.fractionDigits)?.decimalValue == usd.decimalValue) { + return null + } + return ChatRuleRequirement.MinimumBalance(amount = usd, mints = current?.mints.orEmpty()) + } + + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.Initialize -> { state -> + state.copy(chatId = event.chatId, role = event.role, current = event.current) + } + + is Event.RateChanged -> { state -> state.copy(rate = event.rate) } + is Event.UpdateProcessingState -> { state -> + state.copy( + processingState = LoadingSuccessState( + loading = event.loading, + success = event.success, + ) + ) + } + + Event.SaveClicked -> { state -> state } + is Event.Submit -> { state -> state } + Event.OnRequirementAccepted -> { state -> state } + } + } + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt index c2b19fd8df..0fa36ad5d0 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt @@ -22,6 +22,7 @@ import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequireme import com.flipcash.app.messenger.internal.screens.profile.holdingLabel import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.GroupBalanceRole import com.flipcash.shared.common.ui.profile.BalanceRequirementsCard import com.flipcash.shared.common.ui.profile.CoverAndPhoto import com.flipcash.shared.common.ui.profile.FieldCard @@ -32,7 +33,11 @@ import com.getcode.ui.theme.CodeScaffold /** * The group's edit screen: its cover and picture, then a card for each text field, then the - * balance requirements, which are shown and not editable. + * balance requirements. + * + * The requirements' Join and Chat rows open [EditGroupBalanceRequirementScreen] only where + * `EditChatRequest.rules` would be accepted: a public group created with a listener minimum + * ([isBalanceEditable]). Anywhere else the card is shown and not editable. * * Every field opens its own editor and saves on its own, so nothing is held here. The screen is * the door to those editors and leaves the moment the viewer stops being allowed through it: if @@ -52,6 +57,7 @@ internal fun EditGroupScreen(chatViewModel: ChatViewModel) { val requirements = remember(group?.rules) { GroupBalanceRequirements.from(group?.rules) } val tokens = state.ruleTokens + val balanceEditable = group != null && isBalanceEditable(group.rules, group.isPrivate) val grid = CodeTheme.dimens.staticGrid CodeScaffold( @@ -109,6 +115,12 @@ internal fun EditGroupScreen(chatViewModel: ChatViewModel) { yourBalance = null, compact = true, footnote = stringResource(R.string.footer_groupBalanceRequirements), + onJoinClick = if (balanceEditable) { + { flowNavigator.navigateTo(ChatStep.EditGroupBalanceRequirement(GroupBalanceRole.Join)) } + } else null, + onChatClick = if (balanceEditable) { + { flowNavigator.navigateTo(ChatStep.EditGroupBalanceRequirement(GroupBalanceRole.Chat)) } + } else null, ) } } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt new file mode 100644 index 0000000000..c8795702f7 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt @@ -0,0 +1,245 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import com.flipcash.services.models.SetGroupMinimumBalanceError +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.GroupBalanceRole +import com.flipcash.shared.chat.ChatCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.model.financial.Currency +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.solana.keys.PublicKey +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * The requirement editor enters in the preferred currency and stores USD, keeping the replaced + * rule's mints. Until the contract can change rules every save fails as unavailable, which is + * announced and not reported; any other failure is both. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class EditGroupBalanceRequirementViewModelTest { + + private val chatCoordinator = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val chatId = ChatId(ByteArray(32) { 9 }.toList()) + private val mint = PublicKey(ByteArray(32) { 4 }.toList()) + + // Two euros to the dollar keeps the arithmetic readable: $10 seeds as 20, 30 saves as $15. + private val euroRate = Rate(fx = 2.0, currency = CurrencyCode.EUR) + + private val scheduler = TestCoroutineScheduler() + private val reported = mutableListOf() + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher(scheduler)) + BottomBarManager.clear() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + BottomBarManager.clear() + } + + private fun model( + current: ChatRuleRequirement.MinimumBalance?, + rate: Rate = euroRate, + role: GroupBalanceRole = GroupBalanceRole.Join, + ): EditGroupBalanceRequirementViewModel { + val exchange = mockk(relaxed = true) { + every { preferredRate } returns rate + every { observePreferredRate() } returns flowOf(rate) + every { getCurrency(any()) } returns Currency(code = rate.currency.name, name = "", fractionUnits = 2) + every { rateFor(any()) } returns null + } + return EditGroupBalanceRequirementViewModel( + dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler)), + exchange = exchange, + chatCoordinator = chatCoordinator, + resources = resources, + ).apply { + reportError = { reported += it } + dispatchEvent(EditGroupBalanceRequirementViewModel.Event.Initialize(chatId, role, current)) + } + } + + private fun usd(amount: Double, mints: List = listOf(mint)) = + ChatRuleRequirement.MinimumBalance(Fiat(amount, CurrencyCode.USD), mints) + + private fun EditGroupBalanceRequirementViewModel.type(vararg digits: Int) { + digits.forEach(amountDelegate::onNumber) + } + + private fun EditGroupBalanceRequirementViewModel.clearEntry() { + repeat(10) { amountDelegate.onBackspace() } + } + + /** Save, then the prompt's confirm — the only path that writes. */ + private fun EditGroupBalanceRequirementViewModel.saveAndConfirm() { + dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked) + scheduler.advanceUntilIdle() + val prompt = BottomBarManager.messages.value.last() + prompt.actions.first().onClick() + scheduler.advanceUntilIdle() + } + + @Test + fun `seeds the current rule in the entry currency`() = runTest(scheduler) { + val model = model(current = usd(10.0)) + advanceUntilIdle() + + assertEquals(20.0, model.amountDelegate.state.value.enteredAmount, 0.0001) + // Unchanged from what is stored, so there is nothing to save yet. + assertFalse(model.canSave.value) + } + + @Test + fun `a group without the rule starts empty and cannot save`() = runTest(scheduler) { + val model = model(current = null) + advanceUntilIdle() + + assertEquals(0.0, model.amountDelegate.state.value.enteredAmount, 0.0) + assertFalse(model.canSave.value) + } + + @Test + fun `save is enabled only for a positive amount that differs from the rule`() = runTest(scheduler) { + val model = model(current = usd(10.0)) + advanceUntilIdle() + + model.clearEntry() + model.type(0) + advanceUntilIdle() + assertFalse("zero", model.canSave.value) + + model.clearEntry() + model.type(2, 0) + advanceUntilIdle() + assertFalse("same as the rule", model.canSave.value) + + model.clearEntry() + model.type(3, 0) + advanceUntilIdle() + assertTrue("changed", model.canSave.value) + } + + @Test + fun `save stays disabled without a usable rate`() = runTest(scheduler) { + val model = model(current = usd(10.0), rate = Rate.ignore) + advanceUntilIdle() + + assertEquals("nothing to seed from", 0.0, model.amountDelegate.state.value.enteredAmount, 0.0) + model.type(5) + advanceUntilIdle() + assertFalse(model.canSave.value) + } + + @Test + fun `saving converts to USD and keeps the rule's mints`() = runTest(scheduler) { + val sent = slot() + coEvery { chatCoordinator.setMinimumBalance(chatId, GroupBalanceRole.Chat, capture(sent)) } returns + Result.failure(SetGroupMinimumBalanceError.Unavailable()) + val model = model(current = usd(10.0), role = GroupBalanceRole.Chat) + advanceUntilIdle() + + model.clearEntry() + model.type(3, 0) + advanceUntilIdle() + model.saveAndConfirm() + + assertEquals(CurrencyCode.USD, sent.captured.amount.currencyCode) + assertEquals(15.0, sent.captured.amount.decimalValue, 0.0001) + assertEquals(listOf(mint), sent.captured.mints) + } + + @Test + fun `a new rule names no mint`() = runTest(scheduler) { + val sent = slot() + coEvery { chatCoordinator.setMinimumBalance(chatId, GroupBalanceRole.Join, capture(sent)) } returns + Result.failure(SetGroupMinimumBalanceError.Unavailable()) + val model = model(current = null) + advanceUntilIdle() + + model.type(5) + advanceUntilIdle() + model.saveAndConfirm() + + assertEquals(2.5, sent.captured.amount.decimalValue, 0.0001) + assertEquals(emptyList(), sent.captured.mints) + } + + @Test + fun `save asks before it sends`() = runTest(scheduler) { + val model = model(current = null) + advanceUntilIdle() + model.type(5) + advanceUntilIdle() + + model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked) + advanceUntilIdle() + + assertEquals(BottomBarManager.BottomBarMessageType.DESTRUCTIVE, BottomBarManager.messages.value.last().type) + coVerify(exactly = 0) { chatCoordinator.setMinimumBalance(any(), any(), any()) } + } + + @Test + fun `an unavailable save is announced but not reported`() = runTest(scheduler) { + coEvery { chatCoordinator.setMinimumBalance(any(), any(), any()) } returns + Result.failure(SetGroupMinimumBalanceError.Unavailable()) + val model = model(current = null) + advanceUntilIdle() + model.type(5) + advanceUntilIdle() + + model.saveAndConfirm() + + assertEquals(BottomBarManager.BottomBarMessageType.INFO, BottomBarManager.messages.value.last().type) + assertTrue(reported.isEmpty()) + assertTrue(model.stateFlow.value.processingState.isIdle) + } + + @Test + fun `any other failure is reported and shown as an error`() = runTest(scheduler) { + val failure = IllegalStateException("boom") + coEvery { chatCoordinator.setMinimumBalance(any(), any(), any()) } returns Result.failure(failure) + val model = model(current = null) + advanceUntilIdle() + model.type(5) + advanceUntilIdle() + + model.saveAndConfirm() + + assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type) + assertEquals(listOf(failure), reported) + } +} + diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt index 39b62b1b10..b625ed4db8 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupConfirmationTest.kt @@ -176,7 +176,7 @@ class EditGroupConfirmationTest { } } -private class TestDispatchers(private val dispatcher: CoroutineDispatcher) : DispatcherProvider { +internal class TestDispatchers(private val dispatcher: CoroutineDispatcher) : DispatcherProvider { override val Default: CoroutineDispatcher get() = dispatcher override val Main: CoroutineDispatcher get() = dispatcher override val IO: CoroutineDispatcher get() = dispatcher 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 c873b03142..b8122609dc 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 @@ -11,9 +11,11 @@ import com.flipcash.services.models.chat.InviteParameters 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.ChatRuleRequirement 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.GroupBalanceRole import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer @@ -613,6 +615,20 @@ interface GroupOperations { parameters: EditChatParameters, ): Result + /** + * Replaces the minimum balance [role] requires in [chatId] with [requirement] and returns the + * post-edit metadata: the listener rule for [GroupBalanceRole.Join], the speaker rule for + * [GroupBalanceRole.Chat]. + * + * Fails with [com.flipcash.services.models.SetGroupMinimumBalanceError.Unavailable] until the + * contract can change a group's rules after `StartChat`. + */ + suspend fun setMinimumBalance( + chatId: ChatId, + role: GroupBalanceRole, + requirement: ChatRuleRequirement.MinimumBalance, + ): Result + /** Joins [chatId], caching the chat so it is in the list before the next sync. */ suspend fun join(chatId: ChatId): Result diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MinimumBalanceAmount.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MinimumBalanceAmount.kt new file mode 100644 index 0000000000..04ec5af7c9 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MinimumBalanceAmount.kt @@ -0,0 +1,24 @@ +package com.flipcash.shared.chat + +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 + +/** + * The stored minimum balance for a keypad [entered] amount in [rate]'s currency, or null when it + * falls below the smallest USD unit or there is no usable rate to restate it with. + * + * The keypad enters in the preferred currency; the rule is compared against a USD balance, so it + * is stored in USD rather than in whatever was typed, rounded to USD's decimals (cents) so the + * server never sees a fraction of one. Shared by group creation and the Edit Group requirement + * editor so both write the same amount for the same entry. + */ +fun minimumBalanceFor(entered: Double, rate: Rate): Fiat? { + if (entered <= 0.0) return null + // `Rate.ignore` carries a near-zero fx, which would restate any entry as an enormous USD sum. + if (!rate.isUsable() || rate.fx <= 0.0) return null + val usd = Fiat(entered, rate.currency).convertingToUsdIfNeeded(rate) + val rounded = usd.rounded(CurrencyCode.USD.fractionDigits) + return rounded.takeIf { it.decimalValue > 0.0 } +} 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 c3c5496294..81c2001b1b 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 @@ -5,11 +5,14 @@ import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.SetGroupMinimumBalanceError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.InviteParameters import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.EditChatParameters +import com.flipcash.services.models.chat.GroupBalanceRole import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.RosterChange import com.flipcash.services.models.chat.StartChatParameters @@ -167,6 +170,15 @@ class GroupFeedDelegate @Inject constructor( } } + // Stubbed: flipcash2-client-protocol 0.18.0 has no RPC that changes a group's rules after + // `StartChat`, and `EditChatRequest` carries none. Replace this body with the ChatController + // call once the contract adds one, and persist the returned metadata the way [editChat] does. + override suspend fun setMinimumBalance( + chatId: ChatId, + role: GroupBalanceRole, + requirement: ChatRuleRequirement.MinimumBalance, + ): Result = Result.failure(SetGroupMinimumBalanceError.Unavailable()) + /** * Joins [chatId], caching the chat the server returns so the list has it before any sync runs. */ 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 index 7efce6668d..15b24fd26d 100644 --- 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 @@ -50,8 +50,11 @@ private val RowInset = 16.dp * token's icon and name) and a chevron, and tapping it calls [onCommunityCurrencyClick]. The caller * then passes bare amounts for [join] and [chat], since the token is already named above them. * - * [compact] is the read-only form on Edit Group: a 16 header, shorter rows with their labels in - * the same 16 as the values, and the [footnote] beneath. The profile's own is the default. + * [compact] is the form on Edit Group: a 16 header, shorter rows with their labels in the same 16 + * as the values, and the [footnote] beneath. The profile's own is the default. + * + * [onJoinClick] and [onChatClick] make their row tappable and give it a trailing chevron — Edit + * Group passes them to open the requirement's editor; the read-only profile passes neither. */ @Composable fun BalanceRequirementsCard( @@ -63,6 +66,8 @@ fun BalanceRequirementsCard( onCommunityCurrencyClick: () -> Unit = {}, compact: Boolean = false, footnote: String? = null, + onJoinClick: (() -> Unit)? = null, + onChatClick: (() -> Unit)? = null, ) { Column( modifier = modifier.fillMaxWidth(), @@ -87,12 +92,14 @@ fun BalanceRequirementsCard( label = stringResource(R.string.label_balanceRequirementJoin), value = join ?: stringResource(R.string.label_balanceRequirementNone), compact = compact, + onClick = onJoinClick, ) RowSeparator() RequirementRow( label = stringResource(R.string.label_balanceRequirementChat), value = chat ?: stringResource(R.string.label_balanceRequirementNone), compact = compact, + onClick = onChatClick, ) } if (footnote != null) { @@ -168,11 +175,12 @@ private fun CommunityCurrencyRow(onClick: () -> Unit, content: @Composable RowSc } @Composable -private fun RequirementRow(label: String, value: String, compact: Boolean) { +private fun RequirementRow(label: String, value: String, compact: Boolean, onClick: (() -> Unit)?) { Row( modifier = Modifier .fillMaxWidth() .height(if (compact) CompactRowHeight else RowHeight) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) .padding(horizontal = RowInset), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, @@ -182,11 +190,20 @@ private fun RequirementRow(label: String, value: String, compact: Boolean) { style = if (compact) CodeTheme.typography.textMedium else CodeTheme.typography.textSmall, color = CodeTheme.colors.textSecondary, ) - Text( - text = value, - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = value, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + if (onClick != null) { + Icon( + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + } } } @@ -203,5 +220,13 @@ private fun Preview_BalanceRequirementsCard() { yourBalance = "$4.50 of Moony", communityCurrency = { Text(text = "Moony", style = CodeTheme.typography.textMedium, color = CodeTheme.colors.textMain) }, ) + BalanceRequirementsCard( + join = "$10", + chat = null, + yourBalance = null, + compact = true, + onJoinClick = {}, + onChatClick = {}, + ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index 7c305b767a..18bc23f339 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -538,6 +538,21 @@ sealed class EditChatError( data class Other(override val cause: Throwable? = null) : EditChatError(message = cause?.message, cause = cause), NotifiableError } +/** + * Why a group's minimum balance could not be replaced. + * + * Client-side only for now: no RPC changes a group's rules after `StartChat`, so [Unavailable] is + * what every attempt fails with. An [UnreportedError] rather than a [NotifiableError]: it is + * expected on every save. + */ +sealed class SetGroupMinimumBalanceError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + /** The contract has no way to change a group's rules yet. */ + class Unavailable : SetGroupMinimumBalanceError("Changing a group's rules is not supported"), UnreportedError +} + sealed class GetMessageError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt index 10e3c410d2..7ffaf28287 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatRules.kt @@ -73,3 +73,12 @@ val ChatRuleRequirement.blocksReactions: Boolean ChatRuleRequirement.Creator, ChatRuleRequirement.UnsupportedSpeakerRule -> false } + +/** + * Which of a group's minimum balances an edit replaces: the listener rule for [Join], the speaker + * rule for [Chat]. Mirrors iOS's `GroupBalanceRole`. + */ +enum class GroupBalanceRole { + Join, + Chat, +} From d76ac5bbe436bf0c85a2a29d57cfaa6920662487 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 07:16:42 -0400 Subject: [PATCH 2/6] feat(chat): save group balance requirements through EditChat rules Send the group's whole rule set back with only the edited minimum changed, in the listener rule's currency, and refuse locally what the server would: amounts under the minimum transfer value, a Chat minimum not above Join, a Join minimum not below Chat, and minimums the creator doesn't meet. The Chat minimum can be removed. Replaces the Unavailable stub. --- .../core/src/main/res/values/strings.xml | 10 +- .../edit/EditGroupBalanceRequirementScreen.kt | 29 +- .../EditGroupBalanceRequirementViewModel.kt | 316 +++++++++++++----- .../screens/profile/edit/EditGroupScreen.kt | 9 +- ...ditGroupBalanceRequirementViewModelTest.kt | 315 +++++++++++++---- .../flipcash/shared/chat/ChatCoordinator.kt | 16 - .../flipcash/shared/chat/GroupRulesEdit.kt | 101 ++++++ .../shared/chat/MinimumBalanceAmount.kt | 24 -- .../internal/delegates/GroupFeedDelegate.kt | 12 - .../shared/chat/GroupRulesEditTest.kt | 98 ++++++ .../com/flipcash/services/models/Errors.kt | 15 - 11 files changed, 720 insertions(+), 225 deletions(-) create mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRulesEdit.kt delete mode 100644 apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MinimumBalanceAmount.kt create mode 100644 apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRulesEditTest.kt diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index c3a447146f..92aee63651 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1507,9 +1507,13 @@ Change Chat Requirement? This will change who can send messages in the group Change Chat Requirement - - Not Available Yet - Balance requirements can\'t be changed yet + + This Requirement Isn\'t Valid + Try a different minimum balance + You Don\'t Meet This Requirement + Lower the minimum balance or add funds + Chat Minimum Too Low + The chat minimum must be more than the join minimum of %1$s Couldn\'t Save the Requirement Try again diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt index 077f9f014a..5cb4051c09 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt @@ -5,7 +5,9 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.foundation.layout.padding import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel @@ -13,13 +15,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel -import com.flipcash.app.messenger.internal.screens.profile.GroupBalanceRequirements import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.GroupBalanceRole import com.flipcash.shared.amountentry.AmountEntryScreen import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.core.unboundedClickable import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -27,8 +29,8 @@ import kotlinx.coroutines.flow.onEach /** * One of the group's minimum balances on the shared amount keypad, opened from a row of Edit * Group's Balance Requirements card. [role] picks the rule: Join is the listener rule, Chat the - * speaker rule, falling back to Join's when the group has no speaker rule of its own — the same - * reading the card shows. + * group's own speaker rule, empty when it has none. A Chat minimum the group has can be removed + * from the bar, which returns the row to "None". */ @Composable internal fun EditGroupBalanceRequirementScreen( @@ -45,18 +47,15 @@ internal fun EditGroupBalanceRequirementScreen( // arriving from the stream mid-edit would reset the baseline under the user. LaunchedEffect(group?.chatId) { val resolved = group ?: return@LaunchedEffect - val requirements = GroupBalanceRequirements.from(resolved.rules) viewModel.dispatchEvent( EditGroupBalanceRequirementViewModel.Event.Initialize( chatId = resolved.chatId, role = role, - current = when (role) { - GroupBalanceRole.Join -> requirements?.join - GroupBalanceRole.Chat -> requirements?.chat - }, + rules = resolved.rules, ) ) } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() LaunchedEffect(viewModel) { viewModel.eventFlow @@ -81,6 +80,20 @@ internal fun EditGroupBalanceRequirementScreen( ), titleAlignment = Alignment.CenterHorizontally, onBackIconClicked = { flowNavigator.back() }, + endContent = { + if (state.canRemove) { + Text( + modifier = Modifier + .unboundedClickable(enabled = state.processingState.isIdle) { + viewModel.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.RemoveClicked) + } + .padding(horizontal = CodeTheme.dimens.grid.x2), + text = stringResource(R.string.action_remove), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } + }, ) }, headerCaption = { diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt index ad43cb39b8..8e4b47c761 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt @@ -3,17 +3,26 @@ package com.flipcash.app.messenger.internal.screens.profile.edit import androidx.annotation.VisibleForTesting import androidx.lifecycle.viewModelScope import com.flipcash.app.core.ui.ConfirmationStyle +import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.features.messenger.R import com.flipcash.libs.coroutines.DispatcherProvider -import com.flipcash.services.models.SetGroupMinimumBalanceError +import com.flipcash.services.models.EditChatError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.GroupBalanceRole import com.flipcash.shared.amountentry.AmountEntryDelegate import com.flipcash.shared.amountentry.AmountEntryLabel import com.flipcash.shared.amountentry.AmountEntryStyle import com.flipcash.shared.chat.ChatCoordinator -import com.flipcash.shared.chat.minimumBalanceFor +import com.flipcash.shared.chat.listenerMinimum +import com.flipcash.shared.chat.minimumBalanceIn +import com.flipcash.shared.chat.restatedIn +import com.flipcash.shared.chat.speakerMinimum +import com.flipcash.shared.chat.unmetRequirements +import com.flipcash.shared.chat.withMinimum +import com.flipcash.shared.chat.withoutSpeakerMinimum import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager import com.getcode.opencode.exchange.Exchange @@ -21,6 +30,7 @@ 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 com.getcode.util.resources.ResourceHelper import com.getcode.utils.ErrorUtils import com.getcode.view.BaseViewModel @@ -44,19 +54,22 @@ import kotlinx.coroutines.launch /** * One of a group's minimum balances, behind a row of Edit Group's Balance Requirements card. * - * Entry is in the preferred currency and stored in USD, through the same [minimumBalanceFor] group - * creation uses, so the same keypad entry writes the same rule from either screen. The rule's - * mints carry over from the requirement being replaced; a group with none gets an empty list. + * Entry is in the preferred currency and stored in the listener rule's currency, which the + * speaker rule must share; a speaker rule also takes the listener's mints. Saving sends the whole + * rule set back through `EditChatRequest.rules` with only this minimum changed, so staff, creator + * and any other rule the group has go back as they came. * - * The write goes through [ChatCoordinator.setMinimumBalance], which fails with - * [SetGroupMinimumBalanceError.Unavailable] until the contract can change a group's rules. That - * failure is expected on every save, so it is announced but not reported. + * What the server would refuse is stopped here first where it can be known: an amount below the + * currency's minimum transfer value or equal to the current one leaves Save disabled, and a Chat + * minimum not above Join, a Join minimum not below Chat, or new minimums the caller's own balance + * does not meet each raise an alert instead of a request. */ @HiltViewModel class EditGroupBalanceRequirementViewModel @Inject constructor( dispatchers: DispatcherProvider, private val exchange: Exchange, private val chatCoordinator: ChatCoordinator, + tokenCoordinator: TokenCoordinator, private val resources: ResourceHelper, ) : BaseViewModel( initialState = State(), @@ -66,28 +79,47 @@ class EditGroupBalanceRequirementViewModel @Inject constructor( data class State( val chatId: ChatId? = null, val role: GroupBalanceRole = GroupBalanceRole.Join, - /** The rule being replaced, or null when the group has none for [role]. */ - val current: ChatRuleRequirement.MinimumBalance? = null, + /** The group's rules as the editor opened them; the baseline every save is built from. */ + val rules: ChatRules? = null, /** The preferred rate the keypad enters in; null until it resolves. */ val rate: Rate? = null, + val rates: Map = emptyMap(), + /** The caller's balances, or null until they load; the self-check is skipped without them. */ + val balances: List? = null, val processingState: LoadingSuccessState = LoadingSuccessState(), - ) + ) { + /** The rule being replaced, or null when the group has none for [role]. */ + val current: ChatRuleRequirement.MinimumBalance? + get() = when (role) { + GroupBalanceRole.Join -> rules?.listenerMinimum + GroupBalanceRole.Chat -> rules?.speakerMinimum + } + + /** Whether the Chat minimum can go back to "None": only one the group has can be removed. */ + val canRemove: Boolean + get() = role == GroupBalanceRole.Chat && rules?.speakerMinimum != null + } sealed interface Event { - /** The screen handing over the group, the rule being edited, and its current value. */ + /** The screen handing over the group, the rule being edited, and the group's rules. */ data class Initialize( val chatId: ChatId, val role: GroupBalanceRole, - val current: ChatRuleRequirement.MinimumBalance?, + val rules: ChatRules?, ) : Event data class RateChanged(val rate: Rate) : Event + data class RatesChanged(val rates: Map) : Event + data class BalancesChanged(val balances: List) : Event - /** Save, pressed. Only proposes the change; [Submit] is dispatched by the prompt. */ + /** Save, pressed. Checks the entry and proposes the change; [Submit] is dispatched by the prompt. */ data object SaveClicked : Event - /** The confirmed change. */ - data class Submit(val requirement: ChatRuleRequirement.MinimumBalance) : Event + /** Remove, pressed on the Chat minimum. [Submit] is dispatched by the prompt. */ + data object RemoveClicked : Event + + /** The confirmed rule set. */ + data class Submit(val rules: ChatRules) : Event data class UpdateProcessingState( val loading: Boolean = false, @@ -113,7 +145,6 @@ class EditGroupBalanceRequirementViewModel @Inject constructor( AmountEntryStyle( actionLabel = AmountEntryLabel.Plain(resources.getString(R.string.action_save)), actionStyle = ConfirmationStyle.Button, - canChangeCurrency = false, ) ), loadingState = stateFlow.map { it.processingState } @@ -121,9 +152,10 @@ class EditGroupBalanceRequirementViewModel @Inject constructor( confirmEnabled = canSave, ) - // Seeds once. `prefill` types on top of the entry, so a second pass would append to whatever - // the user has typed since. - private var seeded = false + // What the seed typed, or null before it runs. `prefill` types on top of the entry, so it + // runs once; and the restated rule can land a unit off the stored one, so the seeded entry + // itself is what reads as unchanged. + private var seededEntry: Double? = null init { exchange.observePreferredRate() @@ -133,6 +165,16 @@ class EditGroupBalanceRequirementViewModel @Inject constructor( seedIfReady() }.launchIn(viewModelScope) + exchange.observeRates() + .onEach { rates -> + dispatchEvent(Event.RatesChanged(rates)) + seedIfReady() + }.launchIn(viewModelScope) + + tokenCoordinator.tokenBalances + .onEach { dispatchEvent(Event.BalancesChanged(it)) } + .launchIn(viewModelScope) + eventFlow .filterIsInstance() .onEach { seedIfReady() } @@ -142,8 +184,7 @@ class EditGroupBalanceRequirementViewModel @Inject constructor( amountDelegate.state.map { it.enteredAmount }.distinctUntilChanged(), stateFlow, ) { entered, state -> - state.processingState.isIdle && state.chatId != null && - requirementFor(entered, state.rate, state.current, usdOf(state.current)) != null + state.processingState.isIdle && state.chatId != null && proposedAmount(entered, state) != null }.onEach { canSave.value = it }.launchIn(viewModelScope) eventFlow @@ -151,29 +192,25 @@ class EditGroupBalanceRequirementViewModel @Inject constructor( .onEach { val state = stateFlow.value if (!state.processingState.isIdle) return@onEach - val requirement = requirementFor( - entered = amountDelegate.state.value.enteredAmount, - rate = state.rate, - current = state.current, - currentUsd = usdOf(state.current), - ) ?: return@onEach - - val copy = promptCopy(state.role) - BottomBarManager.showAlert( - title = resources.getString(copy.title), - message = resources.getString(copy.message), - actions = listOf( - BottomBarAction(resources.getString(copy.action)) { - viewModelScope.launch { - // The bar dismisses on an animation; the spinner belongs to the - // screen behind it and would otherwise start underneath. - delay(150.milliseconds) - dispatchEvent(Event.Submit(requirement)) - } - } - ), - showCancel = true, - ) + val rules = state.rules ?: return@onEach + val amount = proposedAmount(amountDelegate.state.value.enteredAmount, state) ?: return@onEach + when (val check = check(state, rules, amount)) { + is Check.Blocked -> BottomBarManager.showError( + title = resources.getString(check.title), + message = check.message, + ) + + is Check.Ready -> confirm(state.role, check.rules) + } + }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + val state = stateFlow.value + if (!state.processingState.isIdle || !state.canRemove) return@onEach + val rules = state.rules ?: return@onEach + confirm(GroupBalanceRole.Chat, rules.withoutSpeakerMinimum()) }.launchIn(viewModelScope) eventFlow @@ -184,7 +221,9 @@ class EditGroupBalanceRequirementViewModel @Inject constructor( if (!state.processingState.isIdle) return@onEach dispatchEvent(Event.UpdateProcessingState(loading = true)) - chatCoordinator.setMinimumBalance(chatId, state.role, event.requirement) + // The coordinator stores the metadata the response carries, so every screen + // reading the chat moves to the new rules without waiting for RulesChanged. + chatCoordinator.editChat(chatId, EditChatParameters(rules = event.rules)) .onSuccess { dispatchEvent(Event.UpdateProcessingState(success = true)) viewModelScope.launch { @@ -201,42 +240,156 @@ class EditGroupBalanceRequirementViewModel @Inject constructor( } /** - * Types the current rule into the keypad, restated in the entry currency, once both the rule - * and a usable rate are known. A group with no rule leaves the keypad empty. + * The amount [entered] would store, in the listener rule's currency, or null when there is + * nothing to save: no listener rule or usable rate, an entry below the currency's minimum + * transfer value, or one that leaves the current rule as it is. */ - private fun seedIfReady() { - if (seeded) return - val state = stateFlow.value - if (state.chatId == null) return - val rate = state.rate?.takeIf { it.isUsable() } ?: return - seeded = true + private fun proposedAmount(entered: Double, state: State): Fiat? { + val listener = state.rules?.listenerMinimum ?: return null + // `Rate.ignore` is in USD, so an unresolved rate would read the entry as dollars. + val rate = state.rate?.takeIf { it.isUsable() } ?: return null + if (seededEntry != null && entered == seededEntry) return null + val target = listener.amount.currencyCode + val amount = minimumBalanceIn(entered, rate, target, state.rates[target]) ?: return null + val current = state.current?.amount?.rounded(target.fractionDigits) + return amount.takeUnless { it.quarks == current?.quarks } + } - val currentUsd = usdOf(state.current) ?: return - // An untouched keypad reads "0" rather than empty. - if (amountDelegate.state.value.enteredAmount != 0.0) return - val inEntryCurrency = currentUsd.convertingTo(rate).rounded(rate.currency.fractionDigits) - if (inEntryCurrency.decimalValue > 0.0) amountDelegate.prefill(inEntryCurrency.decimalValue) + private sealed interface Check { + data class Blocked(val title: Int, val message: String) : Check + data class Ready(val rules: ChatRules) : Check } /** - * [requirement]'s amount in USD. Rules are written in USD — group creation and this screen - * both store them that way — but the proto carries a currency, so one that arrives in another - * is restated rather than read as dollars. + * Whether the server would take [amount] for the state's role, checked where the answer is + * already known, and the rule set to send when it would. */ - private fun usdOf(requirement: ChatRuleRequirement.MinimumBalance?): Fiat? { - val amount = requirement?.amount ?: return null - if (amount.currencyCode == CurrencyCode.USD) return amount - val rate = exchange.rateFor(amount.currencyCode)?.takeIf { it.isUsable() } ?: return null - return amount.convertingToUsdIfNeeded(rate) + private fun check(state: State, rules: ChatRules, amount: Fiat): Check { + val listener = rules.listenerMinimum ?: return Check.Blocked( + R.string.error_title_groupRequirementInvalid, + resources.getString(R.string.error_description_groupRequirementInvalid), + ) + val step = amount.smallestUnit.quarks + val joinAmount = listener.amount.rounded(listener.amount.currencyCode.fractionDigits) + when (state.role) { + // A speaker minimum has to clear the listener's by at least the minimum transfer value. + GroupBalanceRole.Chat -> if (amount.quarks < joinAmount.quarks + step) { + return Check.Blocked( + R.string.error_title_groupChatMinimumTooLow, + resources.getString( + R.string.error_description_groupChatMinimumTooLow, + displayed(joinAmount, state).formatted(), + ), + ) + } + + GroupBalanceRole.Join -> { + val speaker = rules.speakerMinimum?.amount?.rounded(listener.amount.currencyCode.fractionDigits) + if (speaker != null && amount.quarks + step > speaker.quarks) { + return Check.Blocked( + R.string.error_title_groupRequirementInvalid, + resources.getString(R.string.error_description_groupRequirementInvalid), + ) + } + } + } + + val updated = rules.withMinimum(state.role, amount) + if (!callerMeets(updated, state)) { + return Check.Blocked( + R.string.error_title_groupRequirementNotSatisfied, + resources.getString(R.string.error_description_groupRequirementNotSatisfied), + ) + } + return Check.Ready(updated) + } + + /** + * Whether the caller's own balance meets every minimum in [rules], listener and speaker alike, + * which the server requires of whoever sets them. Only the minimums are weighed here: the + * rest of the set is unchanged and stays the server's to judge. With no balances yet, or no + * rate for the rule's currency, it reads as met and the server decides. + */ + private fun callerMeets(rules: ChatRules, state: State): Boolean { + val balances = state.balances ?: return true + val minimums = (rules.listener + rules.speaker).filterIsInstance() + return unmetRequirements( + rules = ChatRules(listener = minimums, speaker = emptyList()), + balances = balances, + isStaff = false, + rates = state.rates, + ).isEmpty() + } + + /** [amount] restated in the entry currency, or as it is when no rate can restate it. */ + private fun displayed(amount: Fiat, state: State): Fiat { + val rate = state.rate ?: return amount + return amount.restatedIn(rate.currency, state.rates[amount.currencyCode], rate) + ?.rounded(rate.currency.fractionDigits) + ?: amount + } + + private fun confirm(role: GroupBalanceRole, rules: ChatRules) { + val copy = promptCopy(role) + BottomBarManager.showAlert( + title = resources.getString(copy.title), + message = resources.getString(copy.message), + actions = listOf( + BottomBarAction(resources.getString(copy.action)) { + viewModelScope.launch { + // The bar dismisses on an animation; the spinner belongs to the + // screen behind it and would otherwise start underneath. + delay(150.milliseconds) + dispatchEvent(Event.Submit(rules)) + } + } + ), + showCancel = true, + ) + } + + /** + * Types the current rule into the keypad, restated in the entry currency, once the rule and + * the rates to restate it are known. A group with no rule for the role leaves the keypad empty. + */ + private fun seedIfReady() { + if (seededEntry != null) return + val state = stateFlow.value + if (state.chatId == null) return + val rate = state.rate?.takeIf { it.isUsable() } ?: return + val current = state.current + if (current == null) { + seededEntry = 0.0 + return + } + val inEntryCurrency = current.amount + .restatedIn(rate.currency, state.rates[current.amount.currencyCode], rate) + ?.rounded(rate.currency.fractionDigits) + ?: return + // An untouched keypad reads "0" rather than empty; anything else was typed by the user. + if (amountDelegate.state.value.enteredAmount != 0.0) { + seededEntry = Double.NaN + return + } + if (inEntryCurrency.decimalValue > 0.0) amountDelegate.prefill(inEntryCurrency.decimalValue) + seededEntry = amountDelegate.state.value.enteredAmount } private fun announceFailure(cause: Throwable) { when (cause) { - is SetGroupMinimumBalanceError.Unavailable -> BottomBarManager.showInfo( - title = resources.getString(R.string.error_title_groupRequirementUnavailable), - message = resources.getString(R.string.error_description_groupRequirementUnavailable), + is EditChatError.RulesNotSatisfied -> BottomBarManager.showError( + title = resources.getString(R.string.error_title_groupRequirementNotSatisfied), + message = resources.getString(R.string.error_description_groupRequirementNotSatisfied), ) + is EditChatError.InvalidRules -> { + reportError(cause) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_groupRequirementInvalid), + message = resources.getString(R.string.error_description_groupRequirementInvalid), + ) + } + else -> { reportError(cause) BottomBarManager.showError( @@ -264,31 +417,15 @@ class EditGroupBalanceRequirementViewModel @Inject constructor( } internal companion object { - /** - * The rule [entered] would store, or null when there is nothing to save: no usable rate, - * an entry that rounds to nothing in USD, or one equal to [currentUsd] in cents. The mints - * carry over from [current]. - */ - fun requirementFor( - entered: Double, - rate: Rate?, - current: ChatRuleRequirement.MinimumBalance?, - currentUsd: Fiat?, - ): ChatRuleRequirement.MinimumBalance? { - val usd = minimumBalanceFor(entered, rate ?: return null) ?: return null - if (currentUsd?.rounded(CurrencyCode.USD.fractionDigits)?.decimalValue == usd.decimalValue) { - return null - } - return ChatRuleRequirement.MinimumBalance(amount = usd, mints = current?.mints.orEmpty()) - } - val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { is Event.Initialize -> { state -> - state.copy(chatId = event.chatId, role = event.role, current = event.current) + state.copy(chatId = event.chatId, role = event.role, rules = event.rules) } is Event.RateChanged -> { state -> state.copy(rate = event.rate) } + is Event.RatesChanged -> { state -> state.copy(rates = event.rates) } + is Event.BalancesChanged -> { state -> state.copy(balances = event.balances) } is Event.UpdateProcessingState -> { state -> state.copy( processingState = LoadingSuccessState( @@ -299,6 +436,7 @@ class EditGroupBalanceRequirementViewModel @Inject constructor( } Event.SaveClicked -> { state -> state } + Event.RemoveClicked -> { state -> state } is Event.Submit -> { state -> state } Event.OnRequirementAccepted -> { state -> state } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt index 0fa36ad5d0..59dcadd113 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt @@ -23,6 +23,8 @@ import com.flipcash.app.messenger.internal.screens.profile.holdingLabel import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.GroupBalanceRole +import com.flipcash.shared.chat.isBalanceEditable +import com.flipcash.shared.chat.speakerMinimum import com.flipcash.shared.common.ui.profile.BalanceRequirementsCard import com.flipcash.shared.common.ui.profile.CoverAndPhoto import com.flipcash.shared.common.ui.profile.FieldCard @@ -37,7 +39,8 @@ import com.getcode.ui.theme.CodeScaffold * * The requirements' Join and Chat rows open [EditGroupBalanceRequirementScreen] only where * `EditChatRequest.rules` would be accepted: a public group created with a listener minimum - * ([isBalanceEditable]). Anywhere else the card is shown and not editable. + * ([isBalanceEditable]). Anywhere else the card is shown and not editable. The Chat row reads the + * group's own speaker rule, "None" without one. * * Every field opens its own editor and saves on its own, so nothing is held here. The screen is * the door to those editors and leaves the moment the viewer stops being allowed through it: if @@ -111,7 +114,9 @@ internal fun EditGroupScreen(chatViewModel: ChatViewModel) { BalanceRequirementsCard( modifier = Modifier.padding(top = grid.x6), join = requirements.join?.let { holdingLabel(it, tokens) }, - chat = requirements.chat?.let { holdingLabel(it, tokens) }, + // The group's own speaker rule, not the profile's fallback to Join: this row + // edits that rule, so "None" has to mean there isn't one. + chat = group?.rules?.speakerMinimum?.let { holdingLabel(it, tokens) }, yourBalance = null, compact = true, footnote = stringResource(R.string.footer_groupBalanceRequirements), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt index c8795702f7..458679a543 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt @@ -1,8 +1,12 @@ package com.flipcash.app.messenger.internal.screens.profile.edit -import com.flipcash.services.models.SetGroupMinimumBalanceError +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.models.EditChatError import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.GroupBalanceRole import com.flipcash.shared.chat.ChatCoordinator import com.getcode.manager.BottomBarManager @@ -10,7 +14,12 @@ import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.model.financial.Currency 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 com.getcode.util.resources.ResourceHelper import io.mockk.coEvery @@ -20,6 +29,7 @@ import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.TestCoroutineScheduler import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -38,9 +48,9 @@ import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config /** - * The requirement editor enters in the preferred currency and stores USD, keeping the replaced - * rule's mints. Until the contract can change rules every save fails as unavailable, which is - * announced and not reported; any other failure is both. + * The requirement editor enters in the preferred currency and saves the group's whole rule set + * through `EditChatRequest.rules`, with only the edited minimum changed and stored in the listener + * rule's currency. What the server would refuse is stopped before a request where it can be known. */ @OptIn(ExperimentalCoroutinesApi::class) @RunWith(RobolectricTestRunner::class) @@ -54,6 +64,8 @@ class EditGroupBalanceRequirementViewModelTest { // Two euros to the dollar keeps the arithmetic readable: $10 seeds as 20, 30 saves as $15. private val euroRate = Rate(fx = 2.0, currency = CurrencyCode.EUR) + private val usdRate = Rate(fx = 1.0, currency = CurrencyCode.USD) + private val yenRate = Rate(fx = 150.0, currency = CurrencyCode.JPY) private val scheduler = TestCoroutineScheduler() private val reported = mutableListOf() @@ -62,6 +74,7 @@ class EditGroupBalanceRequirementViewModelTest { fun setUp() { Dispatchers.setMain(UnconfinedTestDispatcher(scheduler)) BottomBarManager.clear() + coEvery { chatCoordinator.editChat(any(), any()) } returns Result.success(mockk(relaxed = true)) } @After @@ -71,29 +84,68 @@ class EditGroupBalanceRequirementViewModelTest { } private fun model( - current: ChatRuleRequirement.MinimumBalance?, - rate: Rate = euroRate, + rules: ChatRules?, role: GroupBalanceRole = GroupBalanceRole.Join, + rate: Rate = euroRate, + rates: Map = mapOf(CurrencyCode.EUR to euroRate, CurrencyCode.JPY to yenRate), + balances: List? = null, ): EditGroupBalanceRequirementViewModel { val exchange = mockk(relaxed = true) { every { preferredRate } returns rate every { observePreferredRate() } returns flowOf(rate) + every { observeRates() } returns flowOf(rates) every { getCurrency(any()) } returns Currency(code = rate.currency.name, name = "", fractionUnits = 2) every { rateFor(any()) } returns null } + val tokenCoordinator = mockk(relaxed = true) { + every { tokenBalances } returns (balances?.let { flowOf(it) } ?: emptyFlow()) + } return EditGroupBalanceRequirementViewModel( dispatchers = TestDispatchers(UnconfinedTestDispatcher(scheduler)), exchange = exchange, chatCoordinator = chatCoordinator, + tokenCoordinator = tokenCoordinator, resources = resources, ).apply { reportError = { reported += it } - dispatchEvent(EditGroupBalanceRequirementViewModel.Event.Initialize(chatId, role, current)) + dispatchEvent(EditGroupBalanceRequirementViewModel.Event.Initialize(chatId, role, rules)) } } - private fun usd(amount: Double, mints: List = listOf(mint)) = - ChatRuleRequirement.MinimumBalance(Fiat(amount, CurrencyCode.USD), mints) + private fun minimum(amount: Double, currency: CurrencyCode = CurrencyCode.USD, mints: List = listOf(mint)) = + ChatRuleRequirement.MinimumBalance(Fiat(amount, currency), mints) + + private fun rules( + join: ChatRuleRequirement.MinimumBalance = minimum(10.0), + chat: ChatRuleRequirement.MinimumBalance? = null, + listenerExtra: List = emptyList(), + speakerExtra: List = emptyList(), + ) = ChatRules( + listener = listOf(join) + listenerExtra, + speaker = listOfNotNull(chat) + speakerExtra, + ) + + private fun held(dollars: Double) = TokenWithBalance( + token = MintMetadata( + address = Mint(mint.bytes), + decimals = 6, + name = "T", + symbol = "T", + 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, + ), + balance = Fiat(dollars), + ) private fun EditGroupBalanceRequirementViewModel.type(vararg digits: Int) { digits.forEach(amountDelegate::onNumber) @@ -103,18 +155,33 @@ class EditGroupBalanceRequirementViewModelTest { repeat(10) { amountDelegate.onBackspace() } } + private fun EditGroupBalanceRequirementViewModel.retype(vararg digits: Int) { + clearEntry() + type(*digits) + scheduler.advanceUntilIdle() + } + /** Save, then the prompt's confirm — the only path that writes. */ private fun EditGroupBalanceRequirementViewModel.saveAndConfirm() { dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked) scheduler.advanceUntilIdle() - val prompt = BottomBarManager.messages.value.last() - prompt.actions.first().onClick() + BottomBarManager.messages.value.last().actions.first().onClick() scheduler.advanceUntilIdle() } + private fun sentRules(): ChatRules { + val sent = slot() + coVerify { chatCoordinator.editChat(chatId, capture(sent)) } + return checkNotNull(sent.captured.rules) + } + + private fun assertNothingSent() { + coVerify(exactly = 0) { chatCoordinator.editChat(any(), any()) } + } + @Test fun `seeds the current rule in the entry currency`() = runTest(scheduler) { - val model = model(current = usd(10.0)) + val model = model(rules()) advanceUntilIdle() assertEquals(20.0, model.amountDelegate.state.value.enteredAmount, 0.0001) @@ -123,38 +190,62 @@ class EditGroupBalanceRequirementViewModelTest { } @Test - fun `a group without the rule starts empty and cannot save`() = runTest(scheduler) { - val model = model(current = null) + fun `an untouched rule in another currency still reads as unchanged`() = runTest(scheduler) { + // ¥1000 seeds as €13.33, which restates as ¥999.75 and rounds back to ¥1000 only by luck; + // the seeded entry itself is what has to read as unchanged. + val model = model(rules(join = minimum(1000.0, CurrencyCode.JPY))) + advanceUntilIdle() + + assertEquals(13.33, model.amountDelegate.state.value.enteredAmount, 0.0001) + assertFalse(model.canSave.value) + } + + @Test + fun `a group without a Chat minimum starts empty and has nothing to remove`() = runTest(scheduler) { + val model = model(rules(), role = GroupBalanceRole.Chat) advanceUntilIdle() assertEquals(0.0, model.amountDelegate.state.value.enteredAmount, 0.0) assertFalse(model.canSave.value) + assertFalse(model.stateFlow.value.canRemove) } @Test fun `save is enabled only for a positive amount that differs from the rule`() = runTest(scheduler) { - val model = model(current = usd(10.0)) + val model = model(rules()) advanceUntilIdle() - model.clearEntry() - model.type(0) - advanceUntilIdle() + model.retype(0) assertFalse("zero", model.canSave.value) - model.clearEntry() - model.type(2, 0) - advanceUntilIdle() + model.retype(2, 0) assertFalse("same as the rule", model.canSave.value) + model.retype(3, 0) + assertTrue("changed", model.canSave.value) + } + + @Test + fun `an amount below the minimum transfer value cannot be saved`() = runTest(scheduler) { + // €0.01 is half a cent, a quarter of a yen at ¥50 to the dollar: less than ¥1. + val model = model( + rules(join = minimum(1000.0, CurrencyCode.JPY)), + rates = mapOf(CurrencyCode.EUR to euroRate, CurrencyCode.JPY to Rate(fx = 50.0, currency = CurrencyCode.JPY)), + ) + advanceUntilIdle() + model.clearEntry() - model.type(3, 0) + model.type(0) + model.amountDelegate.onDecimal() + model.type(0, 1) advanceUntilIdle() - assertTrue("changed", model.canSave.value) + + assertFalse(model.canSave.value) } @Test fun `save stays disabled without a usable rate`() = runTest(scheduler) { - val model = model(current = usd(10.0), rate = Rate.ignore) + val model = model(rules(), rate = Rate.ignore) advanceUntilIdle() assertEquals("nothing to seed from", 0.0, model.amountDelegate.state.value.enteredAmount, 0.0) @@ -164,82 +255,194 @@ class EditGroupBalanceRequirementViewModelTest { } @Test - fun `saving converts to USD and keeps the rule's mints`() = runTest(scheduler) { - val sent = slot() - coEvery { chatCoordinator.setMinimumBalance(chatId, GroupBalanceRole.Chat, capture(sent)) } returns - Result.failure(SetGroupMinimumBalanceError.Unavailable()) - val model = model(current = usd(10.0), role = GroupBalanceRole.Chat) + fun `a Join change keeps its currency, its mints and every other rule`() = runTest(scheduler) { + val speaker = minimum(40.0) + val model = model( + rules( + chat = speaker, + listenerExtra = listOf(ChatRuleRequirement.Staff), + speakerExtra = listOf(ChatRuleRequirement.Creator), + ) + ) advanceUntilIdle() - model.clearEntry() + model.retype(3, 0) + model.saveAndConfirm() + + val sent = sentRules() + assertEquals(listOf(minimum(15.0), ChatRuleRequirement.Staff), sent.listener) + assertEquals(listOf(speaker, ChatRuleRequirement.Creator), sent.speaker) + } + + @Test + fun `a new Chat minimum takes the listener's currency and mints`() = runTest(scheduler) { + val listenerMints = listOf(mint, PublicKey(ByteArray(32) { 5 }.toList())) + val model = model( + rules(join = minimum(1000.0, CurrencyCode.JPY, listenerMints), speakerExtra = listOf(ChatRuleRequirement.Staff)), + role = GroupBalanceRole.Chat, + ) + advanceUntilIdle() + + // €30 is $15, which is ¥2250. model.type(3, 0) advanceUntilIdle() model.saveAndConfirm() - assertEquals(CurrencyCode.USD, sent.captured.amount.currencyCode) - assertEquals(15.0, sent.captured.amount.decimalValue, 0.0001) - assertEquals(listOf(mint), sent.captured.mints) + val sent = sentRules() + assertEquals(listOf(minimum(1000.0, CurrencyCode.JPY, listenerMints)), sent.listener) + assertEquals(listOf(ChatRuleRequirement.Staff, minimum(2250.0, CurrencyCode.JPY, listenerMints)), sent.speaker) } @Test - fun `a new rule names no mint`() = runTest(scheduler) { - val sent = slot() - coEvery { chatCoordinator.setMinimumBalance(chatId, GroupBalanceRole.Join, capture(sent)) } returns - Result.failure(SetGroupMinimumBalanceError.Unavailable()) - val model = model(current = null) + fun `a Chat minimum at or below Join is blocked before sending`() = runTest(scheduler) { + val model = model(rules(), role = GroupBalanceRole.Chat, rate = usdRate, rates = emptyMap()) advanceUntilIdle() - model.type(5) + model.type(1, 0) + advanceUntilIdle() + model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked) + advanceUntilIdle() + + assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type) + assertNothingSent() + } + + @Test + fun `a Chat minimum one cent above Join is accepted`() = runTest(scheduler) { + val model = model(rules(), role = GroupBalanceRole.Chat, rate = usdRate, rates = emptyMap()) + advanceUntilIdle() + + model.type(1, 0) + model.amountDelegate.onDecimal() + model.type(0, 1) advanceUntilIdle() model.saveAndConfirm() - assertEquals(2.5, sent.captured.amount.decimalValue, 0.0001) - assertEquals(emptyList(), sent.captured.mints) + assertEquals(listOf(minimum(10.01)), sentRules().speaker) } @Test - fun `save asks before it sends`() = runTest(scheduler) { - val model = model(current = null) + fun `a Join minimum at or above Chat is blocked before sending`() = runTest(scheduler) { + val model = model(rules(chat = minimum(20.0)), rate = usdRate, rates = emptyMap()) advanceUntilIdle() - model.type(5) + + model.retype(2, 0) + model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked) + advanceUntilIdle() + + assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type) + assertNothingSent() + } + + @Test + fun `removing the Chat minimum sends the rules without it`() = runTest(scheduler) { + val model = model(rules(chat = minimum(20.0), speakerExtra = listOf(ChatRuleRequirement.Creator)), role = GroupBalanceRole.Chat) + advanceUntilIdle() + assertTrue(model.stateFlow.value.canRemove) + + model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.RemoveClicked) + advanceUntilIdle() + BottomBarManager.messages.value.last().actions.first().onClick() + advanceUntilIdle() + + val sent = sentRules() + assertEquals(listOf(minimum(10.0)), sent.listener) + assertEquals(listOf(ChatRuleRequirement.Creator), sent.speaker) + } + + @Test + fun `new minimums the caller does not meet are blocked before sending`() = runTest(scheduler) { + val model = model(rules(), balances = listOf(held(12.0))) + advanceUntilIdle() + + // €30 is a $15 Join minimum; the caller holds $12. + model.retype(3, 0) + model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked) + advanceUntilIdle() + + assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type) + assertNothingSent() + } + + @Test + fun `a Chat minimum the caller does not meet is blocked too`() = runTest(scheduler) { + val model = model(rules(), role = GroupBalanceRole.Chat, balances = listOf(held(12.0))) advanceUntilIdle() + model.type(3, 0) + advanceUntilIdle() + model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked) + advanceUntilIdle() + + assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type) + assertNothingSent() + } + + @Test + fun `a caller who meets the new minimums is asked before it sends`() = runTest(scheduler) { + val model = model(rules(), balances = listOf(held(20.0))) + advanceUntilIdle() + + model.retype(3, 0) model.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.SaveClicked) advanceUntilIdle() assertEquals(BottomBarManager.BottomBarMessageType.DESTRUCTIVE, BottomBarManager.messages.value.last().type) - coVerify(exactly = 0) { chatCoordinator.setMinimumBalance(any(), any(), any()) } + assertNothingSent() } @Test - fun `an unavailable save is announced but not reported`() = runTest(scheduler) { - coEvery { chatCoordinator.setMinimumBalance(any(), any(), any()) } returns - Result.failure(SetGroupMinimumBalanceError.Unavailable()) - val model = model(current = null) + fun `a save leaves once the server takes it`() = runTest(scheduler) { + val model = model(rules()) advanceUntilIdle() - model.type(5) + + model.retype(3, 0) + model.saveAndConfirm() + + coVerify(exactly = 1) { chatCoordinator.editChat(chatId, any()) } + assertTrue(model.stateFlow.value.processingState.isIdle) + assertTrue(reported.isEmpty()) + } + + @Test + fun `rules the server will not satisfy are announced but not reported`() = runTest(scheduler) { + coEvery { chatCoordinator.editChat(any(), any()) } returns Result.failure(EditChatError.RulesNotSatisfied()) + val model = model(rules()) advanceUntilIdle() + model.retype(3, 0) model.saveAndConfirm() - assertEquals(BottomBarManager.BottomBarMessageType.INFO, BottomBarManager.messages.value.last().type) + assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type) assertTrue(reported.isEmpty()) assertTrue(model.stateFlow.value.processingState.isIdle) } + @Test + fun `invalid rules are announced and reported`() = runTest(scheduler) { + val failure = EditChatError.InvalidRules() + coEvery { chatCoordinator.editChat(any(), any()) } returns Result.failure(failure) + val model = model(rules()) + advanceUntilIdle() + + model.retype(3, 0) + model.saveAndConfirm() + + assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type) + assertEquals(listOf(failure), reported) + } + @Test fun `any other failure is reported and shown as an error`() = runTest(scheduler) { val failure = IllegalStateException("boom") - coEvery { chatCoordinator.setMinimumBalance(any(), any(), any()) } returns Result.failure(failure) - val model = model(current = null) - advanceUntilIdle() - model.type(5) + coEvery { chatCoordinator.editChat(any(), any()) } returns Result.failure(failure) + val model = model(rules()) advanceUntilIdle() + model.retype(3, 0) model.saveAndConfirm() assertEquals(BottomBarManager.BottomBarMessageType.ERROR, BottomBarManager.messages.value.last().type) assertEquals(listOf(failure), reported) } } - 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 b8122609dc..c873b03142 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 @@ -11,11 +11,9 @@ import com.flipcash.services.models.chat.InviteParameters 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.ChatRuleRequirement 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.GroupBalanceRole import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer @@ -615,20 +613,6 @@ interface GroupOperations { parameters: EditChatParameters, ): Result - /** - * Replaces the minimum balance [role] requires in [chatId] with [requirement] and returns the - * post-edit metadata: the listener rule for [GroupBalanceRole.Join], the speaker rule for - * [GroupBalanceRole.Chat]. - * - * Fails with [com.flipcash.services.models.SetGroupMinimumBalanceError.Unavailable] until the - * contract can change a group's rules after `StartChat`. - */ - suspend fun setMinimumBalance( - chatId: ChatId, - role: GroupBalanceRole, - requirement: ChatRuleRequirement.MinimumBalance, - ): Result - /** Joins [chatId], caching the chat so it is in the list before the next sync. */ suspend fun join(chatId: ChatId): Result diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRulesEdit.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRulesEdit.kt new file mode 100644 index 0000000000..0eba64918a --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/GroupRulesEdit.kt @@ -0,0 +1,101 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.GroupBalanceRole +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 + +/** The group's listener minimum balance, the rule behind the Join row. */ +val ChatRules.listenerMinimum: ChatRuleRequirement.MinimumBalance? + get() = listener.filterIsInstance().firstOrNull() + +/** + * The group's own speaker minimum balance, the rule behind the Chat row, or null when speaking + * needs nothing beyond joining. + */ +val ChatRules.speakerMinimum: ChatRuleRequirement.MinimumBalance? + get() = speaker.filterIsInstance().firstOrNull() + +/** + * Whether `EditChatRequest.rules` would accept a change to these rules: a public group created + * with a listener minimum. A private group is DENIED and any other group INVALID_RULES. + * + * A rule this build cannot decode also rules it out. The edit sends the whole set back, and + * [ChatRuleRequirement.UnsupportedSpeakerRule] has no wire form, so it could only be dropped, + * which would open the chat it was keeping closed. + */ +fun isBalanceEditable(rules: ChatRules?, isPrivate: Boolean): Boolean { + if (isPrivate || rules == null) return false + if (rules.listenerMinimum == null) return false + return ChatRuleRequirement.UnsupportedSpeakerRule !in rules.listener + rules.speaker +} + +/** + * These rules with [role]'s minimum balance set to [amount], and everything else as it was. + * + * The listener rule keeps its mints. A speaker rule always takes the listener's mints, the + * server's condition for accepting one, and replaces the group's own speaker minimum in place, or + * is appended when there is none. [amount] is expected in the listener rule's currency. + */ +fun ChatRules.withMinimum(role: GroupBalanceRole, amount: Fiat): ChatRules { + val listenerRule = listenerMinimum ?: return this + return when (role) { + GroupBalanceRole.Join -> copy( + listener = listener.map { if (it === listenerRule) listenerRule.copy(amount = amount) else it }, + ) + + GroupBalanceRole.Chat -> { + val replacement = ChatRuleRequirement.MinimumBalance(amount = amount, mints = listenerRule.mints) + val existing = speakerMinimum + copy( + speaker = if (existing == null) speaker + replacement + else speaker.map { if (it === existing) replacement else it }, + ) + } + } +} + +/** These rules without the group's own speaker minimum: Chat reads "None" and speaking needs only joining. */ +fun ChatRules.withoutSpeakerMinimum(): ChatRules { + val existing = speakerMinimum ?: return this + return copy(speaker = speaker.filterNot { it === existing }) +} + +/** + * The minimum balance a keypad [entered] amount in [entryRate]'s currency stores, restated in + * [target] (the listener rule's currency) and rounded to its decimals, or null when it falls below + * [target]'s minimum transfer value (one unit of its last decimal place, $0.01 or ¥1) or no usable + * rate can restate it. [targetRate] is only read when [target] differs from both the entry + * currency and USD. + */ +fun minimumBalanceIn( + entered: Double, + entryRate: Rate, + target: CurrencyCode, + targetRate: Rate?, +): Fiat? { + if (entered <= 0.0) return null + val inTarget = Fiat(entered, entryRate.currency).restatedIn(target, entryRate, targetRate) ?: return null + val rounded = inTarget.rounded(target.fractionDigits) + return rounded.takeIf { it >= rounded.smallestUnit } +} + +/** + * This amount in [target], through USD, or null when a rate it needs is missing or unusable. + * [ownRate] is the rate for this amount's currency, [targetRate] the one for [target]; neither is + * read when its currency is [target] or USD. + */ +fun Fiat.restatedIn(target: CurrencyCode, ownRate: Rate?, targetRate: Rate?): Fiat? { + if (currencyCode == target) return this + val usd = if (currencyCode == CurrencyCode.USD) this else { + // `Rate.ignore` carries a near-zero fx, which would restate any amount as an enormous USD sum. + val rate = ownRate?.takeIf { it.isUsable() && it.fx > 0.0 && it.currency == currencyCode } ?: return null + convertingToUsdIfNeeded(rate) + } + if (target == CurrencyCode.USD) return usd + val rate = targetRate?.takeIf { it.isUsable() && it.fx > 0.0 && it.currency == target } ?: return null + return usd.convertingTo(rate) +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MinimumBalanceAmount.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MinimumBalanceAmount.kt deleted file mode 100644 index 04ec5af7c9..0000000000 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/MinimumBalanceAmount.kt +++ /dev/null @@ -1,24 +0,0 @@ -package com.flipcash.shared.chat - -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 - -/** - * The stored minimum balance for a keypad [entered] amount in [rate]'s currency, or null when it - * falls below the smallest USD unit or there is no usable rate to restate it with. - * - * The keypad enters in the preferred currency; the rule is compared against a USD balance, so it - * is stored in USD rather than in whatever was typed, rounded to USD's decimals (cents) so the - * server never sees a fraction of one. Shared by group creation and the Edit Group requirement - * editor so both write the same amount for the same entry. - */ -fun minimumBalanceFor(entered: Double, rate: Rate): Fiat? { - if (entered <= 0.0) return null - // `Rate.ignore` carries a near-zero fx, which would restate any entry as an enormous USD sum. - if (!rate.isUsable() || rate.fx <= 0.0) return null - val usd = Fiat(entered, rate.currency).convertingToUsdIfNeeded(rate) - val rounded = usd.rounded(CurrencyCode.USD.fractionDigits) - return rounded.takeIf { it.decimalValue > 0.0 } -} 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 81c2001b1b..c3c5496294 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 @@ -5,14 +5,11 @@ import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.controllers.ChatController -import com.flipcash.services.models.SetGroupMinimumBalanceError import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.InviteParameters import com.flipcash.services.models.chat.ChatMetadata -import com.flipcash.services.models.chat.ChatRuleRequirement import com.flipcash.services.models.chat.ChatterSample import com.flipcash.services.models.chat.EditChatParameters -import com.flipcash.services.models.chat.GroupBalanceRole import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.RosterChange import com.flipcash.services.models.chat.StartChatParameters @@ -170,15 +167,6 @@ class GroupFeedDelegate @Inject constructor( } } - // Stubbed: flipcash2-client-protocol 0.18.0 has no RPC that changes a group's rules after - // `StartChat`, and `EditChatRequest` carries none. Replace this body with the ChatController - // call once the contract adds one, and persist the returned metadata the way [editChat] does. - override suspend fun setMinimumBalance( - chatId: ChatId, - role: GroupBalanceRole, - requirement: ChatRuleRequirement.MinimumBalance, - ): Result = Result.failure(SetGroupMinimumBalanceError.Unavailable()) - /** * Joins [chatId], caching the chat the server returns so the list has it before any sync runs. */ diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRulesEditTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRulesEditTest.kt new file mode 100644 index 0000000000..b0e9c985ff --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupRulesEditTest.kt @@ -0,0 +1,98 @@ +package com.flipcash.shared.chat + +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules +import com.flipcash.services.models.chat.GroupBalanceRole +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +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.assertSame +import kotlin.test.assertTrue + +class GroupRulesEditTest { + + private val mint = PublicKey(ByteArray(32) { 4 }.toList()) + private val listenerRule = ChatRuleRequirement.MinimumBalance(Fiat(1000.0, CurrencyCode.JPY), listOf(mint)) + + @Test + fun `only a public group with a listener minimum is editable`() { + val rules = ChatRules(listener = listOf(listenerRule), speaker = emptyList()) + + assertTrue(isBalanceEditable(rules, isPrivate = false)) + assertFalse(isBalanceEditable(rules, isPrivate = true), "private") + assertFalse(isBalanceEditable(null, isPrivate = false), "no rules") + assertFalse( + isBalanceEditable(ChatRules(listener = listOf(ChatRuleRequirement.Staff), speaker = emptyList()), isPrivate = false), + "no listener minimum", + ) + } + + @Test + fun `a rule this build cannot send back rules out the edit`() { + val rules = ChatRules(listener = listOf(listenerRule), speaker = listOf(ChatRuleRequirement.UnsupportedSpeakerRule)) + + assertFalse(isBalanceEditable(rules, isPrivate = false)) + } + + @Test + fun `a speaker minimum replaces the group's own in place`() { + val rules = ChatRules( + listener = listOf(listenerRule), + speaker = listOf( + ChatRuleRequirement.Staff, + ChatRuleRequirement.MinimumBalance(Fiat(2000.0, CurrencyCode.JPY), emptyList()), + ChatRuleRequirement.Creator, + ), + ) + + val updated = rules.withMinimum(GroupBalanceRole.Chat, Fiat(3000.0, CurrencyCode.JPY)) + + assertEquals( + listOf( + ChatRuleRequirement.Staff, + ChatRuleRequirement.MinimumBalance(Fiat(3000.0, CurrencyCode.JPY), listOf(mint)), + ChatRuleRequirement.Creator, + ), + updated.speaker, + ) + assertEquals(rules.listener, updated.listener) + } + + @Test + fun `removing the speaker minimum leaves every other speaker rule`() { + val rules = ChatRules( + listener = listOf(listenerRule), + speaker = listOf(ChatRuleRequirement.MinimumBalance(Fiat(2000.0, CurrencyCode.JPY), listOf(mint)), ChatRuleRequirement.Creator), + ) + + assertEquals(listOf(ChatRuleRequirement.Creator), rules.withoutSpeakerMinimum().speaker) + val none = ChatRules(listener = listOf(listenerRule), speaker = emptyList()) + assertSame(none, none.withoutSpeakerMinimum()) + } + + @Test + fun `an entry is restated in the listener's currency through USD`() { + val euro = Rate(fx = 2.0, currency = CurrencyCode.EUR) + val yen = Rate(fx = 150.0, currency = CurrencyCode.JPY) + + assertEquals(Fiat(2250.0, CurrencyCode.JPY), minimumBalanceIn(30.0, euro, CurrencyCode.JPY, yen)) + assertEquals(Fiat(15.0, CurrencyCode.USD), minimumBalanceIn(30.0, euro, CurrencyCode.USD, null)) + assertNull(minimumBalanceIn(30.0, euro, CurrencyCode.JPY, null), "no rate for the listener's currency") + } + + @Test + fun `an entry below the currency's minimum transfer value stores nothing`() { + val usd = Rate(fx = 1.0, currency = CurrencyCode.USD) + val yen = Rate(fx = 100.0, currency = CurrencyCode.JPY) + + // $0.003 is ¥0.3, under ¥1. + assertNull(minimumBalanceIn(0.003, usd, CurrencyCode.JPY, yen)) + assertNull(minimumBalanceIn(0.004, usd, CurrencyCode.USD, null)) + assertEquals(Fiat(1.0, CurrencyCode.JPY), minimumBalanceIn(0.01, usd, CurrencyCode.JPY, yen)) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index 18bc23f339..7c305b767a 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -538,21 +538,6 @@ sealed class EditChatError( data class Other(override val cause: Throwable? = null) : EditChatError(message = cause?.message, cause = cause), NotifiableError } -/** - * Why a group's minimum balance could not be replaced. - * - * Client-side only for now: no RPC changes a group's rules after `StartChat`, so [Unavailable] is - * what every attempt fails with. An [UnreportedError] rather than a [NotifiableError]: it is - * expected on every save. - */ -sealed class SetGroupMinimumBalanceError( - override val message: String? = null, - override val cause: Throwable? = null -): CodeServerError(message, cause) { - /** The contract has no way to change a group's rules yet. */ - class Unavailable : SetGroupMinimumBalanceError("Changing a group's rules is not supported"), UnreportedError -} - sealed class GetMessageError( override val message: String? = null, override val cause: Throwable? = null From 0eb328f21173a88697e04601be7dcade8aaf24b2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 07:22:18 -0400 Subject: [PATCH 3/6] feat(chat): refresh stored rules where RulesChanged doesn't reach Members follow a rules change through RulesChanged into rules_json, and every in-chat consumer reads that row. A viewer outside the group, as after a leave, gets no events for it, so the gate before Join could show an old minimum. ChatViewModel now fetches the rules once each time the viewer is found outside the group, and again when a join fails with RULES_NOT_SATISFIED, through ChatCoordinator.refreshRules. Tests cover rules_json round-tripping a speaker rule added and removed, and the stored chat row re-emitting on each updateRules. --- .../app/messenger/internal/ChatViewModel.kt | 12 +++++++++ .../flipcash/shared/chat/ChatCoordinator.kt | 10 +++++++ .../internal/delegates/GroupFeedDelegate.kt | 5 ++++ .../shared/chat/GroupFeedDelegateTest.kt | 27 +++++++++++++++++++ .../persistence/dao/ChatMetadataDaoTest.kt | 27 +++++++++++++++++++ .../mapper/chat/ChatEntityMapperTest.kt | 24 +++++++++++++++++ 6 files changed, 105 insertions(+) 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 db91f4cd26..04fe8381b5 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 @@ -2350,6 +2350,15 @@ internal class ChatViewModel @AssistedInject constructor( .onEach { dispatchEvent(Event.OnGroupResolved(it)) } .launchIn(viewModelScope) + // RulesChanged reaches members only, so a stored group the viewer is outside of, as after + // a leave, can hold rules older than the ones a join is judged against. Fetched once each + // time the viewer is found outside, so the gate shows the current minimum before Join. + stateFlow.map { state -> (state.subject as? ChatSubject.Group)?.takeIf { it.isMember == false }?.chatId } + .distinctUntilChanged() + .filterNotNull() + .onEach { chatCoordinator.refreshRules(it) } + .launchIn(viewModelScope) + // The same metadata, minus the type filter: every chat has viewer state and every chat can // be muted, so this one is not the group chrome's business. Observed rather than read with // the chat because a mute made on another device arrives on the stream, and because leaving @@ -3023,6 +3032,9 @@ internal class ChatViewModel @AssistedInject constructor( ), ) dispatchEvent(Event.JoinStateUpdated()) + // The server judged the join against rules this device may not hold yet; + // storing them moves the gate to the minimum that actually refused it. + if (cause is JoinChatError.RulesNotSatisfied) chatCoordinator.refreshRules(chatId) } } .launchIn(viewModelScope) 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 c873b03142..88b0ae0976 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 @@ -644,6 +644,16 @@ interface GroupOperations { * or cover-less answer leaves whatever is stored alone. */ suspend fun refreshCover(chatId: ChatId) + + /** + * Stores the rules `GetChat` holds for [chatId]. + * + * A member follows rules changes through `RulesChanged`, but a viewer outside the group, as + * after a leave, receives no events for it, so the stored rules can be older than the ones a + * join is judged against. Writes only rules that came back, so a failed answer leaves the + * stored ones alone. + */ + suspend fun refreshRules(chatId: ChatId) } interface ChatCoordinator : 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 c3c5496294..cb1c1ce47a 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 @@ -215,6 +215,11 @@ class GroupFeedDelegate @Inject constructor( metadataDataSource.updateCoverPicture(chatId, cover) } + override suspend fun refreshRules(chatId: ChatId) { + val rules = chatController.getChat(chatId).getOrNull()?.rules ?: return + metadataDataSource.updateRules(chatId, rules) + } + /** * 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/GroupFeedDelegateTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt index 08fa68b97e..279a762741 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/GroupFeedDelegateTest.kt @@ -4,11 +4,14 @@ import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.GetChatError import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatFeedPage import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatRuleRequirement +import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.RosterChange @@ -17,6 +20,7 @@ import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.internal.RosterStateHolder import com.flipcash.shared.chat.internal.delegates.GroupFeedDelegate +import com.getcode.opencode.model.financial.Fiat import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -254,6 +258,29 @@ class GroupFeedDelegateTest { coVerify { metadataDataSource.setMembership(chatId, isMember = true) } } + /** A viewer outside the group gets no `RulesChanged`, so its rules are fetched to stand in. */ + @Test + fun `refreshing the rules stores the ones GetChat holds`() = runTest { + val rules = ChatRules( + listener = listOf(ChatRuleRequirement.MinimumBalance(Fiat(25.0), emptyList())), + speaker = emptyList(), + ) + coEvery { controller.getChat(chatId) } returns Result.success(group().copy(rules = rules)) + + subject.refreshRules(chatId) + + coVerify(exactly = 1) { metadataDataSource.updateRules(chatId, rules) } + } + + @Test + fun `a failed rules refresh leaves the stored rules alone`() = runTest { + coEvery { controller.getChat(chatId) } returns Result.failure(GetChatError.Denied()) + + subject.refreshRules(chatId) + + coVerify(exactly = 0) { metadataDataSource.updateRules(any(), any()) } + } + /** `metadata` is set only on your own join, and is the whole chat to put in the list. */ @Test fun `your own join adds the chat to the list`() = runTest { diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index 782befc9e6..292425aa36 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -6,6 +6,7 @@ import androidx.room.Room import androidx.test.core.app.ApplicationProvider import app.cash.turbine.test import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.converters.ChatRuleRequirementSerialized import com.flipcash.app.persistence.converters.ChatRulesSerialized import com.flipcash.app.persistence.entities.ChatMetadataEntity import com.flipcash.services.models.chat.MediaItem @@ -673,6 +674,32 @@ class ChatMetadataDaoTest { } } + /** + * `MetadataUpdate.RulesChanged` writes through `updateRules`, and every gate reads the rules + * off this observation, so each change has to re-emit the row: a speaker minimum added, then + * removed, with the listener rule and the creator rule kept as they were. + */ + @Test + fun `observeById re-emits each rules change`() = runTest { + val join = ChatRuleRequirementSerialized.MinimumBalance(quarks = 10_000_000, currencyCode = "USD", mints = listOf("mint")) + val chat = ChatRuleRequirementSerialized.MinimumBalance(quarks = 20_000_000, currencyCode = "USD", mints = listOf("mint")) + val joinOnly = ChatRulesSerialized(listener = listOf(join), speaker = listOf(ChatRuleRequirementSerialized.Creator)) + val withChat = joinOnly.copy(speaker = listOf(chat, ChatRuleRequirementSerialized.Creator)) + dao.upsert(entity(chatType = "GROUP", rulesJson = joinOnly)) + + dao.observeById(CHAT_HEX).test { + assertEquals(joinOnly, awaitItem()?.rulesJson) + + dao.updateRules(CHAT_HEX, withChat) + assertEquals(withChat, awaitItem()?.rulesJson) + + dao.updateRules(CHAT_HEX, joinOnly) + assertEquals(joinOnly, awaitItem()?.rulesJson) + + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `observeById emits null for a chat this device has never stored`() = runTest { dao.observeById(CHAT_HEX).test { diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index ae23378044..80a8dd623c 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -21,6 +21,7 @@ import com.flipcash.services.models.chat.RosterSummary import com.flipcash.services.models.chat.ViewerState import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat +import com.getcode.solana.keys.PublicKey import com.flipcash.app.persistence.entities.EncryptionState import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.chat.UndecryptableReason @@ -391,6 +392,29 @@ class ChatEntityMapperTest { ) } + /** + * The write `MetadataUpdate.RulesChanged` and a rules edit make: [ChatEntityMapper.rulesSerialized] + * into `rules_json`, read back with the row. A speaker minimum added and then removed has to + * come back each time as it was sent, with the rules around it untouched. + */ + @Test + fun `a speaker minimum added and removed through a rules change survives the round trip`() { + val mint = PublicKey(ByteArray(32) { 4 }.toList()) + val join = ChatRuleRequirement.MinimumBalance(Fiat(quarks = 1_000_000_000, currencyCode = CurrencyCode.JPY), listOf(mint)) + val chat = ChatRuleRequirement.MinimumBalance(Fiat(quarks = 2_500_000_000, currencyCode = CurrencyCode.JPY), listOf(mint)) + val withSpeaker = ChatRules( + listener = listOf(join, ChatRuleRequirement.Staff), + speaker = listOf(chat, ChatRuleRequirement.Creator), + ) + val withoutSpeaker = withSpeaker.copy(speaker = listOf(ChatRuleRequirement.Creator)) + val entity = mapper.toEntity(groupMetadata()) + + for (rules in listOf(withSpeaker, withoutSpeaker)) { + val stored = entity.copy(rulesJson = mapper.rulesSerialized(rules)) + assertEquals(rules, mapper.toMetadata(stored, members = emptyList(), lastMessage = null).rules) + } + } + @Test fun `group identity and roster survive the round trip`() { val entity = mapper.toEntity(groupMetadata()) From d9ae2b6ee7658227ffca6c69d8f3f8f63373f5ba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 10:05:12 -0400 Subject: [PATCH 4/6] fix(chat): refresh a non-member's stored rules when Group Info opens --- .../com/flipcash/app/messenger/internal/ChatViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 04fe8381b5..12186684e0 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 @@ -2489,11 +2489,13 @@ internal class ChatViewModel @AssistedInject constructor( }.launchIn(viewModelScope) // A row that came from the feed can lack the cover the profile shows; GetChat has it. The - // stored metadata updates and OnGroupResolved carries it into the subject. + // stored metadata updates and OnGroupResolved carries it into the subject. A viewer outside + // the group gets no RulesChanged, so opening the profile is also when its gate catches up. eventFlow.filterIsInstance() .onEach { val chatId = stateFlow.value.chatId ?: return@onEach val group = stateFlow.value.subject as? ChatSubject.Group ?: return@onEach + if (group.isMember == false) chatCoordinator.refreshRules(chatId) if (group.coverPicture == null) chatCoordinator.refreshCover(chatId) } .launchIn(viewModelScope) From 35bb3a02b510a599a479cc661feca5d465f48146 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 10:19:09 -0400 Subject: [PATCH 5/6] fix(messenger): show Join on an unset Chat row; pill Remove button --- .../edit/EditGroupBalanceRequirementScreen.kt | 28 ++++++++++++------- .../screens/profile/edit/EditGroupScreen.kt | 9 ++---- .../ui/profile/BalanceRequirementsCard.kt | 1 + 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt index 5cb4051c09..349396a2d2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt @@ -1,6 +1,8 @@ package com.flipcash.app.messenger.internal.screens.profile.edit import android.os.Parcelable +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -21,7 +23,9 @@ import com.flipcash.shared.amountentry.AmountEntryScreen import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.core.unboundedClickable +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -82,16 +86,18 @@ internal fun EditGroupBalanceRequirementScreen( onBackIconClicked = { flowNavigator.back() }, endContent = { if (state.canRemove) { - Text( - modifier = Modifier - .unboundedClickable(enabled = state.processingState.isIdle) { - viewModel.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.RemoveClicked) - } - .padding(horizontal = CodeTheme.dimens.grid.x2), + CodeButton( + modifier = Modifier.padding(end = CodeTheme.dimens.grid.x2), text = stringResource(R.string.action_remove), - style = CodeTheme.typography.textMedium, - color = CodeTheme.colors.textMain, - ) + buttonState = ButtonState.Filled10, + enabled = state.processingState.isIdle, + shape = CircleShape, + style = CodeTheme.typography.textSmall, + contentPadding = PillPadding, + overrideContentPadding = true, + ) { + viewModel.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.RemoveClicked) + } } }, ) @@ -111,3 +117,5 @@ internal fun EditGroupBalanceRequirementScreen( }, ) } + +private val PillPadding = PaddingValues(horizontal = 14.dp, vertical = 7.dp) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt index 59dcadd113..6692cd6307 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupScreen.kt @@ -24,7 +24,6 @@ import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.BlobAccessContext import com.flipcash.services.models.chat.GroupBalanceRole import com.flipcash.shared.chat.isBalanceEditable -import com.flipcash.shared.chat.speakerMinimum import com.flipcash.shared.common.ui.profile.BalanceRequirementsCard import com.flipcash.shared.common.ui.profile.CoverAndPhoto import com.flipcash.shared.common.ui.profile.FieldCard @@ -39,8 +38,8 @@ import com.getcode.ui.theme.CodeScaffold * * The requirements' Join and Chat rows open [EditGroupBalanceRequirementScreen] only where * `EditChatRequest.rules` would be accepted: a public group created with a listener minimum - * ([isBalanceEditable]). Anywhere else the card is shown and not editable. The Chat row reads the - * group's own speaker rule, "None" without one. + * ([isBalanceEditable]). Anywhere else the card is shown and not editable. Without a speaker rule, + * the Chat row shows the Join minimum, as the profile does. * * Every field opens its own editor and saves on its own, so nothing is held here. The screen is * the door to those editors and leaves the moment the viewer stops being allowed through it: if @@ -114,9 +113,7 @@ internal fun EditGroupScreen(chatViewModel: ChatViewModel) { BalanceRequirementsCard( modifier = Modifier.padding(top = grid.x6), join = requirements.join?.let { holdingLabel(it, tokens) }, - // The group's own speaker rule, not the profile's fallback to Join: this row - // edits that rule, so "None" has to mean there isn't one. - chat = group?.rules?.speakerMinimum?.let { holdingLabel(it, tokens) }, + chat = requirements.chat?.let { holdingLabel(it, tokens) }, yourBalance = null, compact = true, footnote = stringResource(R.string.footer_groupBalanceRequirements), 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 index 15b24fd26d..f20fdcf262 100644 --- 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 @@ -198,6 +198,7 @@ private fun RequirementRow(label: String, value: String, compact: Boolean, onCli ) if (onClick != null) { Icon( + modifier = Modifier.padding(start = 8.dp), painter = painterResource(R.drawable.ic_chevron_right), contentDescription = null, tint = CodeTheme.colors.textSecondary, From 7f8b9ceff07677f225c9b5fbf50d7311b2bf4913 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 10:24:59 -0400 Subject: [PATCH 6/6] fix(messenger): draw Remove as an app bar icon button --- .../edit/EditGroupBalanceRequirementScreen.kt | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt index 349396a2d2..a4d1c58bde 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt @@ -1,13 +1,14 @@ package com.flipcash.app.messenger.internal.screens.profile.edit import android.os.Parcelable -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material3.Icon import androidx.compose.material3.Text +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.foundation.layout.padding import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -23,9 +24,7 @@ import com.flipcash.shared.amountentry.AmountEntryScreen import com.getcode.navigation.flow.rememberFlowNavigator import com.getcode.theme.CodeTheme import com.getcode.ui.components.AppBarWithTitle -import com.getcode.ui.theme.ButtonState -import com.getcode.ui.theme.CodeButton -import androidx.compose.ui.unit.dp +import com.getcode.ui.components.CircularIconButton import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -86,17 +85,19 @@ internal fun EditGroupBalanceRequirementScreen( onBackIconClicked = { flowNavigator.back() }, endContent = { if (state.canRemove) { - CodeButton( - modifier = Modifier.padding(end = CodeTheme.dimens.grid.x2), - text = stringResource(R.string.action_remove), - buttonState = ButtonState.Filled10, - enabled = state.processingState.isIdle, - shape = CircleShape, - style = CodeTheme.typography.textSmall, - contentPadding = PillPadding, - overrideContentPadding = true, - ) { - viewModel.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.RemoveClicked) + CircularIconButton( + onClick = { + if (state.processingState.isIdle) { + viewModel.dispatchEvent(EditGroupBalanceRequirementViewModel.Event.RemoveClicked) + } + }, + ) { size -> + Icon( + modifier = Modifier.requiredSize(size), + painter = painterResource(com.getcode.util.resources.R.drawable.ic_delete), + contentDescription = stringResource(R.string.action_remove), + tint = Color.White, + ) } } }, @@ -117,5 +118,3 @@ internal fun EditGroupBalanceRequirementScreen( }, ) } - -private val PillPadding = PaddingValues(horizontal = 14.dp, vertical = 7.dp)