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..92aee63651 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1495,6 +1495,28 @@ 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 + + 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 + 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/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index db91f4cd26..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 @@ -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 @@ -2480,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) @@ -3023,6 +3034,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/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..a4d1c58bde --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementScreen.kt @@ -0,0 +1,120 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +import android.os.Parcelable +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.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 +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.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.components.CircularIconButton +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 + * 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( + 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 + viewModel.dispatchEvent( + EditGroupBalanceRequirementViewModel.Event.Initialize( + chatId = resolved.chatId, + role = role, + rules = resolved.rules, + ) + ) + } + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + 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() }, + endContent = { + if (state.canRemove) { + 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, + ) + } + } + }, + ) + }, + 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..8e4b47c761 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModel.kt @@ -0,0 +1,445 @@ +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.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.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 +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 +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 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. + * + * 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(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + data class State( + val chatId: ChatId? = null, + val role: GroupBalanceRole = GroupBalanceRole.Join, + /** 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 the group's rules. */ + data class Initialize( + val chatId: ChatId, + val role: GroupBalanceRole, + 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. Checks the entry and proposes the change; [Submit] is dispatched by the prompt. */ + data object SaveClicked : 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, + 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, + ) + ), + loadingState = stateFlow.map { it.processingState } + .stateIn(viewModelScope, SharingStarted.Eagerly, LoadingSuccessState()), + confirmEnabled = canSave, + ) + + // 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() + .onEach { rate -> + exchange.getCurrency(rate.currency.name)?.let(amountDelegate::onCurrencyChanged) + dispatchEvent(Event.RateChanged(rate)) + 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() } + .launchIn(viewModelScope) + + combine( + amountDelegate.state.map { it.enteredAmount }.distinctUntilChanged(), + stateFlow, + ) { entered, state -> + state.processingState.isIdle && state.chatId != null && proposedAmount(entered, state) != null + }.onEach { canSave.value = it }.launchIn(viewModelScope) + + eventFlow + .filterIsInstance() + .onEach { + val state = stateFlow.value + if (!state.processingState.isIdle) return@onEach + 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 + .filterIsInstance() + .onEach { event -> + val state = stateFlow.value + val chatId = state.chatId ?: return@onEach + if (!state.processingState.isIdle) return@onEach + + dispatchEvent(Event.UpdateProcessingState(loading = true)) + // 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 { + delay(SuccessHoldDuration) + dispatchEvent(Event.OnRequirementAccepted) + dispatchEvent(Event.UpdateProcessingState()) + } + } + .onFailure { cause -> + dispatchEvent(Event.UpdateProcessingState()) + announceFailure(cause) + } + }.launchIn(viewModelScope) + } + + /** + * 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 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 } + } + + private sealed interface Check { + data class Blocked(val title: Int, val message: String) : Check + data class Ready(val rules: ChatRules) : Check + } + + /** + * 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 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 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( + 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 { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.Initialize -> { state -> + 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( + loading = event.loading, + success = event.success, + ) + ) + } + + 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 c2b19fd8df..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 @@ -22,6 +22,8 @@ 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.chat.isBalanceEditable 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 +34,12 @@ 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. 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 @@ -52,6 +59,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 +117,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..458679a543 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/screens/profile/edit/EditGroupBalanceRequirementViewModelTest.kt @@ -0,0 +1,448 @@ +package com.flipcash.app.messenger.internal.screens.profile.edit + +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 +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 +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.emptyFlow +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 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) +@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 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() + + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher(scheduler)) + BottomBarManager.clear() + coEvery { chatCoordinator.editChat(any(), any()) } returns Result.success(mockk(relaxed = true)) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + BottomBarManager.clear() + } + + private fun model( + 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, rules)) + } + } + + 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) + } + + private fun EditGroupBalanceRequirementViewModel.clearEntry() { + 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() + 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(rules()) + 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 `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(rules()) + advanceUntilIdle() + + model.retype(0) + assertFalse("zero", model.canSave.value) + + 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(0) + model.amountDelegate.onDecimal() + model.type(0, 1) + advanceUntilIdle() + + assertFalse(model.canSave.value) + } + + @Test + fun `save stays disabled without a usable rate`() = runTest(scheduler) { + val model = model(rules(), 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 `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.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() + + 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 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(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(listOf(minimum(10.01)), sentRules().speaker) + } + + @Test + 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.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) + assertNothingSent() + } + + @Test + fun `a save leaves once the server takes it`() = runTest(scheduler) { + val model = model(rules()) + advanceUntilIdle() + + 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.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.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/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..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/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/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/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/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..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 @@ -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,21 @@ 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( + modifier = Modifier.padding(start = 8.dp), + painter = painterResource(R.drawable.ic_chevron_right), + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + } } } @@ -203,5 +221,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/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()) 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, +}