From b89524dd401411713fc06ea5308f4ef06dfe7ef5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 7 Oct 2026 16:04:37 -0400 Subject: [PATCH 1/5] feat(group): edit join and chat balance requirements from Edit Group The Join and Chat rows in Edit Group now open the amount entry screen, and the card shows on groups with no requirement so one can be added. Saving is stubbed: flipcash2-client-protocol 0.18.0 has no RPC that changes a group's rules after StartChat (EditChatRequest carries only title, description and pictures). SessionGroupChatEditor.setMinimumBalance throws .unavailable, which shows "Not Available Yet" and isn't reported. That method body is the one place to wire the RPC when it lands. --- ...26-10-07-group-balance-requirement-edit.md | 35 +++++ .../Navigation/AppRouter+Destination.swift | 8 +- .../AppRouter+DestinationView.swift | 23 ++++ .../Edit Group/DialogItem+GroupChange.swift | 6 + .../EditGroupBalanceRequirementModel.swift | 120 ++++++++++++++++++ .../EditGroupBalanceRequirementScreen.swift | 109 ++++++++++++++++ .../Profile/Edit Group/EditGroupScreen.swift | 48 ++++--- .../Profile/Edit Group/GroupChatEditing.swift | 31 +++++ ...ditGroupBalanceRequirementModelTests.swift | 105 +++++++++++++++ FlipcashTests/Chat/EditGroupModelTests.swift | 9 ++ 10 files changed, 473 insertions(+), 21 deletions(-) create mode 100644 .claude/plans/2026-10-07-group-balance-requirement-edit.md create mode 100644 Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementModel.swift create mode 100644 Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift create mode 100644 FlipcashTests/Chat/EditGroupBalanceRequirementModelTests.swift diff --git a/.claude/plans/2026-10-07-group-balance-requirement-edit.md b/.claude/plans/2026-10-07-group-balance-requirement-edit.md new file mode 100644 index 000000000..8aa49812d --- /dev/null +++ b/.claude/plans/2026-10-07-group-balance-requirement-edit.md @@ -0,0 +1,35 @@ +# Editing a group's balance requirements (stubbed) + +## Contract gap + +`flipcash2-client-protocol` 0.18.0 (latest tag) has no RPC that changes a group's rules after +`StartChat`. `EditChatRequest` carries only title, profile picture, description, and cover +picture. No branch of `code-payments/flipcash2-protobuf-api` adds one as of 2026-10-07. Android has +no setter either. + +## What iOS built + +- Edit Group's Balance Requirements card always shows (rows read "None" on a group without one). + Each row (Join, Chat) pushes `.editGroupBalanceRequirement(id, role:)`. +- `EditGroupBalanceRequirementScreen` is `EnterAmountView(mode: .balanceRequirement)` with a + "Save" title, confirmed through `DialogItem.confirmGroupChange(.joinRequirement / .chatRequirement)`. +- `EditGroupBalanceRequirementModel`: entry in `balanceCurrency`, saved in USD (the creation + sheet's conversion), keeps the requirement's existing mint, empty mints for a new one. The Chat + row seeds from `GroupBalanceRequirements.chat`, which falls back to join; saving writes an + explicit speaker rule. +- The single stub: `SessionGroupChatEditor.setMinimumBalance(conversationID:role:requirement:)` + throws `ErrorSetGroupMinimumBalance.unavailable`. The model maps it to a "Not Available Yet" + dialog without reporting it. + +## When the RPC lands + +1. Bump the package; add a `FlipClient`/`ChatService` call. +2. Replace the stub body with it. +3. Seat the returned rules: `ConversationController.applyEdit` handles title, description, and + pictures only — add a rules branch (and a store mutation if none exists). +4. Drop the `.unavailable` case and its dialog, and the "stubbed" note in `EditGroupScreen`'s doc. + +## Open + +- No way to clear a requirement: the keypad can't express "None". +- No check that the chat minimum is at least the join minimum. diff --git a/Flipcash/Core/Navigation/AppRouter+Destination.swift b/Flipcash/Core/Navigation/AppRouter+Destination.swift index ed419f5f7..79be6f79c 100644 --- a/Flipcash/Core/Navigation/AppRouter+Destination.swift +++ b/Flipcash/Core/Navigation/AppRouter+Destination.swift @@ -144,6 +144,9 @@ extension AppRouter { case editGroupCover(ConversationID) /// Setting or clearing a group's description, pushed from the Description card of `editGroup`. case editGroupDescription(ConversationID) + /// Replacing a group's join or chat minimum balance, pushed from a Balance Requirements row + /// of `editGroup`. + case editGroupBalanceRequirement(ConversationID, role: GroupBalanceRole) /// The list of archived chats, pushed from the Archived row on the Chats tab. case archivedChats /// Chats Search, pushed from the Chats tab's search button. @@ -185,7 +188,7 @@ extension AppRouter { case .profileName, .profilePhoto, .tipcard, .usernameLookup, .newChat, .newPublicGroup, .newPrivateGroup, .tipConversation, .tipConversationWithKeyboard, .chatMessage, .userProfile, .chatProfile, .editGroup, .editGroupName, .editGroupPicture, .editGroupCover, - .editGroupDescription, .archivedChats, .chatSearch, .requests, .joinRequests, .inviteLinks, .groupMembers, .groupInvite: + .editGroupDescription, .editGroupBalanceRequirement, .archivedChats, .chatSearch, .requests, .joinRequests, .inviteLinks, .groupMembers, .groupInvite: return .tips } } @@ -251,6 +254,7 @@ extension AppRouter { case .editGroupPicture: "editGroupPicture" case .editGroupCover: "editGroupCover" case .editGroupDescription: "editGroupDescription" + case .editGroupBalanceRequirement: "editGroupBalanceRequirement" case .archivedChats: "archivedChats" case .chatSearch: "chatSearch" case .requests: "requests" @@ -295,6 +299,8 @@ extension AppRouter { .inviteLinks(let conversationID), .groupMembers(let conversationID): return conversationID.description + case .editGroupBalanceRequirement(let conversationID, let role): + return "\(conversationID.description) \(role)" case .userProfile(let userID, _): return userID.uuidString case .username(let username): diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 91f1000e5..1ca112a67 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -16,6 +16,7 @@ struct DestinationView: View { @Environment(Container.self) private var container @Environment(SessionContainer.self) private var sessionContainer @Environment(ConversationController.self) private var conversationController + @Environment(RatesController.self) private var ratesController let destination: AppRouter.Destination @@ -322,6 +323,28 @@ struct DestinationView: View { } )) .id(conversationID) + + case .editGroupBalanceRequirement(let conversationID, let role): + // Seeded here, like `.editGroupDescription`, so the keypad opens on the amount it is + // about to replace. + let editor = SessionGroupChatEditor(session: sessionContainer.session, flipClient: container.flipClient) + let conversationController = conversationController + let requirements = GroupBalanceRequirements(conversationController.conversation(withID: conversationID)?.rules) + EditGroupBalanceRequirementScreen(model: EditGroupBalanceRequirementModel( + role: role, + current: role == .join ? requirements?.join : requirements?.chat, + currency: ratesController.balanceCurrency, + rates: ratesController.cachedRates, + saving: { requirement in + let conversation = try await editor.setMinimumBalance( + conversationID: conversationID, + role: role, + requirement: requirement + ) + conversationController.applyEdit(conversation) + } + )) + .id(conversationID) } } } diff --git a/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupChange.swift b/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupChange.swift index f47c5eb79..9889c41c6 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupChange.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/DialogItem+GroupChange.swift @@ -19,6 +19,8 @@ extension DialogItem { case picture case cover case description + case joinRequirement + case chatRequirement /// Group-qualified rather than borrowing the card's bare "Name" / "Description", which would /// read as the user's own once the dialog covers the screen that gave them context. @@ -28,6 +30,8 @@ extension DialogItem { case .picture: "Group Picture" case .cover: "Group Cover" case .description: "Group Description" + case .joinRequirement: "Join Requirement" + case .chatRequirement: "Chat Requirement" } } @@ -37,6 +41,8 @@ extension DialogItem { case .picture: "This will change the group picture for everyone in it" case .cover: "This will change the group cover for everyone in it" case .description: "This will change the group description for everyone in it" + case .joinRequirement: "This will change who can join the group" + case .chatRequirement: "This will change who can send messages in the group" } } } diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementModel.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementModel.swift new file mode 100644 index 000000000..c96c50aa5 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementModel.swift @@ -0,0 +1,120 @@ +// +// EditGroupBalanceRequirementModel.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.edit-group-balance-requirement") + +/// The state behind replacing one of a group's minimum balances, and its one save. Modelled on +/// ``EditGroupDescriptionModel``. +/// +/// The entry is in the account's display currency and is saved in USD, the conversion +/// ``MinimumBalanceAmountSheet`` applies when a group is created: the gate weighs holdings by +/// their USD worth, so a USD requirement needs no rate to enforce. The requirement keeps the mint +/// it already names; a group with no requirement gets one across all holdings, as +/// ``NewPublicGroupModel`` builds it. +@MainActor +@Observable +final class EditGroupBalanceRequirementModel { + + /// Where the save is. `.saved` is held by the screen for its checkmark before it pops. + enum SaveState: Equatable { + case normal + case saving + case saved + } + + /// Why the save didn't land, shown as a dialog. Set to nil once shown. + enum Failure: Equatable { + /// The contract has no way to change a group's rules yet. + case unavailable + case failed + } + + let role: GroupBalanceRole + + /// The keypad's text, in ``currency``. + var enteredAmount: String + + private(set) var state: SaveState = .normal + var failure: Failure? + + /// The currency the amount is entered in. + let currency: CurrencyCode + + @ObservationIgnored private let current: MinimumBalanceRequirement? + @ObservationIgnored private let rates: [CurrencyCode: Rate] + @ObservationIgnored private let validator = AmountValidator() + @ObservationIgnored private let saving: (MinimumBalanceRequirement) async throws -> Void + + /// - Parameters: + /// - role: the minimum being replaced. + /// - current: the requirement the role shows now. For ``GroupBalanceRole/chat`` on a group + /// with no speaker rule, that is the join requirement, which the chat minimum falls back + /// to; saving writes an explicit speaker rule. + /// - currency: the currency the amount is entered in. + /// - rates: what restates the entry in USD, and the current requirement in ``currency``. + /// - saving: submits the requirement and seats the chat's post-edit metadata. + init( + role: GroupBalanceRole, + current: MinimumBalanceRequirement?, + currency: CurrencyCode, + rates: [CurrencyCode: Rate], + saving: @escaping (MinimumBalanceRequirement) async throws -> Void + ) { + self.role = role + self.current = current + self.currency = currency + self.rates = rates + self.saving = saving + self.enteredAmount = SetMinimumTipScreen.seed( + fee: current?.amount.converted(to: currency, rates: rates), + currency: currency + ) + } + + /// The entry as the requirement it would save, or nil while it holds nothing a requirement can + /// be set to: empty, zero, no rate to restate it in USD, or small enough to round away there. + var requirement: MinimumBalanceRequirement? { + guard let value = validator.validate(enteredAmount), value > 0, + let usd = FiatAmount(value: value, currency: currency).converted(to: .usd, rates: rates), + usd.isPositive else { return nil } + return MinimumBalanceRequirement(amount: usd, mints: current?.mints ?? []) + } + + /// Whether Save is enabled: a valid amount that differs, at display precision, from the one set. + var canSave: Bool { + guard state == .normal, let requirement else { return false } + guard let current = current?.amount.converted(to: .usd, rates: rates) else { return true } + return requirement.amount.roundedToSmallestUnit() != current.roundedToSmallestUnit() + } + + /// Submits the requirement. Failures land in ``failure``. + func save() async { + guard canSave, let requirement else { return } + + state = .saving + + do { + try await saving(requirement) + state = .saved + + } catch ErrorSetGroupMinimumBalance.unavailable { + // The stub throws this on every save, so it is expected rather than reported. + state = .normal + logger.info("Group minimum balance edit unavailable", metadata: ["role": "\(role)"]) + failure = .unavailable + + } catch { + state = .normal + guard !Task.isCancelled else { return } + logger.error("Failed to set group minimum balance", metadata: ["role": "\(role)", "error": "\(error)"]) + ErrorReporting.captureError(error, reason: "Failed to set group minimum balance") + failure = .failed + } + } +} diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift new file mode 100644 index 000000000..e3efc2d2b --- /dev/null +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift @@ -0,0 +1,109 @@ +// +// EditGroupBalanceRequirementScreen.swift +// Flipcash +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Replacing a group's join or chat minimum, pushed from a row of Edit Group's Balance +/// Requirements. Entered on ``EnterAmountView`` like Minimum To Chat and the creation form's +/// custom requirement. +struct EditGroupBalanceRequirementScreen: View { + + @Environment(AppRouter.self) private var router + + @State private var model: EditGroupBalanceRequirementModel + @State private var dialog: DialogItem? + @State private var saveTask: Task? + + /// Takes the model rather than building it, so the field opens on the amount it is about to + /// replace instead of animating it in over the push. + init(model: EditGroupBalanceRequirementModel) { + _model = State(initialValue: model) + } + + private var buttonState: ButtonState { + switch model.state { + case .normal: .normal + case .saving: .loading + case .saved: .success + } + } + + private var field: DialogItem.GroupField { + switch model.role { + case .join: .joinRequirement + case .chat: .chatRequirement + } + } + + private var hint: String { + switch model.role { + case .join: "People won't be able to join this group if their balance is less than this amount" + case .chat: "People won't be able to send messages in this group if their balance is less than this amount" + } + } + + var body: some View { + @Bindable var model = model + + Background(color: .backgroundMain) { + EnterAmountView( + mode: .balanceRequirement, + enteredAmount: $model.enteredAmount, + subtitle: .hidden, + actionState: .constant(buttonState), + actionEnabled: { _ in model.canSave && saveTask == nil }, + action: submit, + actionTitle: "Save", + header: AnyView(EnterAmountHeader( + enteredAmount: $model.enteredAmount, + hint: .description(hint) + )) + ) + .foregroundStyle(.textMain) + .padding(20) + } + .ignoresSafeArea(.keyboard) + .navigationTitle(field.title) + .toolbarTitleDisplayMode(.inline) + .dialog(item: $dialog) + .onChange(of: model.failure) { _, failure in + guard let failure else { return } + model.failure = nil + switch failure { + case .unavailable: + dialog = .info( + title: "Not Available Yet", + subtitle: "Balance requirements can't be changed yet" + ) + case .failed: + dialog = .error(title: "Couldn't Save the Requirement", subtitle: "Try again") + } + } + // Leaving the screen abandons the submission: its only continuation is a pop. + .onDisappear { saveTask?.cancel() } + } + + /// Save proposes; the dialog commits, since the requirement changes for everyone in the group. + private func submit() { + guard model.canSave, saveTask == nil else { return } + dialog = .confirmGroupChange(field) { save() } + } + + private func save() { + guard saveTask == nil else { return } + saveTask = Task { + await model.save() + if model.state == .saved { + // Same beat the other editors hold their checkmark for. + try? await Task.delay(milliseconds: 500) + guard !Task.isCancelled else { return } + router.popTopmost() + } + saveTask = nil + } + } +} diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift index d22d0d73b..10941886c 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift @@ -10,8 +10,8 @@ import FlipcashUI /// A group's editor, in Edit Profile's shape: the cover and photo at the top, then a card per field, /// each opening its own screen that saves on its own. /// -/// Balance Requirements is read-only: `EditChatRequest` carries no rules, and nothing changes a -/// group's rules after `StartChat`. +/// Balance Requirements shows even on a group with none, so one can be added. Its rows open the +/// amount editor, whose save is stubbed until the contract can change a group's rules. struct EditGroupScreen: View { let conversationID: ConversationID @@ -45,10 +45,8 @@ struct EditGroupScreen: View { photo fieldCards .padding(.top, 20) - if let requirements { - balanceRequirements(requirements) - .padding(.top, 28) - } + balanceRequirements(requirements) + .padding(.top, 28) } .padding(.horizontal, ProfileHeaderMetrics.inset) .padding(.vertical, 24) @@ -161,17 +159,17 @@ struct EditGroupScreen: View { // MARK: - Balance requirements - - private func balanceRequirements(_ requirements: GroupBalanceRequirements) -> some View { + private func balanceRequirements(_ requirements: GroupBalanceRequirements?) -> some View { VStack(alignment: .leading, spacing: 8) { Text("Balance Requirements") .font(.appTextMedium) .foregroundStyle(Color.textMain) VStack(spacing: 0) { - requirementRow("Join", requirements.join) + requirementRow("Join", requirements?.join, role: .join) Divider() .overlay(Color.rowSeparator) - requirementRow("Chat", requirements.chat) + requirementRow("Chat", requirements?.chat, role: .chat) } .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) @@ -182,19 +180,29 @@ struct EditGroupScreen: View { .accessibilityIdentifier("edit-group-balance-requirements") } - private func requirementRow(_ title: String, _ requirement: MinimumBalanceRequirement?) -> some View { - HStack { - Text(title) - .font(.appTextMedium) - .foregroundStyle(Color.textSecondary) - Spacer() - Text(requirement.map { GroupBalanceRequirements.formatted($0, mintName: $0.mints.first.flatMap { mintNames[$0] }, in: ratesController.displayCurrency) } ?? "None") - .font(.appTextMedium) - .foregroundStyle(Color.textMain) + private func requirementRow(_ title: String, _ requirement: MinimumBalanceRequirement?, role: GroupBalanceRole) -> some View { + Button { + router.push(.editGroupBalanceRequirement(conversationID, role: role)) + } label: { + HStack { + Text(title) + .font(.appTextMedium) + .foregroundStyle(Color.textSecondary) + Spacer() + Text(requirement.map { GroupBalanceRequirements.formatted($0, mintName: $0.mints.first.flatMap { mintNames[$0] }, in: ratesController.displayCurrency) } ?? "None") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + Image(systemName: "chevron.right") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + .contentShape(Rectangle()) } - .padding(.horizontal, 16) - .padding(.vertical, 14) + .buttonStyle(.plain) .accessibilityElement(children: .combine) + .accessibilityIdentifier(role == .join ? "edit-group-join-requirement" : "edit-group-chat-requirement") } private func nonEmpty(_ string: String?) -> String? { diff --git a/Flipcash/Core/Screens/Profile/Edit Group/GroupChatEditing.swift b/Flipcash/Core/Screens/Profile/Edit Group/GroupChatEditing.swift index 788f544b7..4a82aa12e 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/GroupChatEditing.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/GroupChatEditing.swift @@ -34,6 +34,26 @@ protocol GroupChatEditing { pictureBlobID: BlobID?, coverPictureBlobID: BlobID? ) async throws -> Conversation + + /// Replaces the minimum balance `role` requires and returns the chat's post-edit metadata. + func setMinimumBalance( + conversationID: ConversationID, + role: GroupBalanceRole, + requirement: MinimumBalanceRequirement + ) async throws -> Conversation +} + +/// Which of a group's minimum balances an edit replaces: the listener rule for ``join``, the +/// speaker rule for ``chat``. +nonisolated enum GroupBalanceRole: Hashable, Sendable { + case join + case chat +} + +/// Thrown by ``SessionGroupChatEditor/setMinimumBalance(conversationID:role:requirement:)`` until +/// the contract carries a way to change a group's rules. +enum ErrorSetGroupMinimumBalance: Error { + case unavailable } /// Edits on behalf of the signed-in owner. @@ -66,4 +86,15 @@ struct SessionGroupChatEditor: GroupChatEditing { coverPictureBlobID: coverPictureBlobID ) } + + // 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 FlipClient call + // once the contract adds one, and seat the returned rules in `ConversationController.applyEdit`. + func setMinimumBalance( + conversationID: ConversationID, + role: GroupBalanceRole, + requirement: MinimumBalanceRequirement + ) async throws -> Conversation { + throw ErrorSetGroupMinimumBalance.unavailable + } } diff --git a/FlipcashTests/Chat/EditGroupBalanceRequirementModelTests.swift b/FlipcashTests/Chat/EditGroupBalanceRequirementModelTests.swift new file mode 100644 index 000000000..dee99d6ea --- /dev/null +++ b/FlipcashTests/Chat/EditGroupBalanceRequirementModelTests.swift @@ -0,0 +1,105 @@ +// +// EditGroupBalanceRequirementModelTests.swift +// FlipcashTests +// + +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Group balance requirement editor") +struct EditGroupBalanceRequirementModelTests { + + private struct Boom: Error {} + + @MainActor + private final class Calls { + var requirements: [MinimumBalanceRequirement] = [] + } + + private let token = PublicKey.jeffy + + private let rates: [CurrencyCode: Rate] = [ + .usd: Rate(fx: 1, currency: .usd), + .cad: Rate(fx: 1.25, currency: .cad), + ] + + private func makeModel( + role: GroupBalanceRole = .join, + current: MinimumBalanceRequirement? = nil, + currency: CurrencyCode = .usd, + calls: Calls = Calls(), + saving: ((MinimumBalanceRequirement) async throws -> Void)? = nil + ) -> EditGroupBalanceRequirementModel { + EditGroupBalanceRequirementModel(role: role, current: current, currency: currency, rates: rates) { requirement in + calls.requirements.append(requirement) + try await saving?(requirement) + } + } + + @Test("The keypad opens on the current requirement, restated in the entry currency") + func seedsFromCurrent() { + #expect(makeModel(current: .init(amount: .usd(5))).enteredAmount == "5") + #expect(makeModel(current: .init(amount: .usd(5)), currency: .cad).enteredAmount == "6.25") + #expect(makeModel().enteredAmount == "") + } + + @Test("Save is shut while empty, zero, or unchanged") + func canSave() { + let model = makeModel(current: .init(amount: .usd(5))) + #expect(!model.canSave) + + model.enteredAmount = "0" + #expect(!model.canSave) + + model.enteredAmount = "10" + #expect(model.canSave) + } + + @Test("The entry is saved in USD and keeps the mint the requirement names") + func savesInUSDKeepingMint() async { + let calls = Calls() + let model = makeModel(current: .init(amount: .usd(5), mints: [token]), currency: .cad, calls: calls) + model.enteredAmount = "12.50" + + await model.save() + + #expect(calls.requirements == [.init(amount: .usd(10), mints: [token])]) + #expect(model.state == .saved) + } + + @Test("A group with no requirement gets one across all holdings") + func newRequirementAppliesToAllHoldings() async { + let calls = Calls() + let model = makeModel(role: .chat, calls: calls) + model.enteredAmount = "3" + + await model.save() + + #expect(calls.requirements == [.init(amount: .usd(3), mints: [])]) + } + + @Test("The stub's refusal surfaces as unavailable and leaves Save open") + func stubIsUnavailable() async { + let model = makeModel { _ in throw ErrorSetGroupMinimumBalance.unavailable } + model.enteredAmount = "3" + + await model.save() + + #expect(model.failure == .unavailable) + #expect(model.state == .normal) + #expect(model.canSave) + } + + @Test("Any other failure surfaces as failed") + func otherFailure() async { + let model = makeModel { _ in throw Boom() } + model.enteredAmount = "3" + + await model.save() + + #expect(model.failure == .failed) + #expect(model.state == .normal) + } +} diff --git a/FlipcashTests/Chat/EditGroupModelTests.swift b/FlipcashTests/Chat/EditGroupModelTests.swift index c75a6f831..1e444e45f 100644 --- a/FlipcashTests/Chat/EditGroupModelTests.swift +++ b/FlipcashTests/Chat/EditGroupModelTests.swift @@ -69,6 +69,15 @@ struct EditGroupModelTests { title: title ) } + + func setMinimumBalance( + conversationID: ConversationID, + role: GroupBalanceRole, + requirement: MinimumBalanceRequirement + ) async throws -> Conversation { + Issue.record("The form never edits balance requirements") + throw ErrorSetGroupMinimumBalance.unavailable + } } private let conversationID = ConversationID.test(7) From b645aac0185d37460ad75567737ba69f3bfb1169 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 07:07:05 -0400 Subject: [PATCH 2/5] feat(chat): save group balance requirements through EditChat --- .../Controllers/ConversationController.swift | 3 + .../AppRouter+DestinationView.swift | 48 ++++-- .../EditGroupBalanceRequirementModel.swift | 139 +++++++++++++----- .../EditGroupBalanceRequirementScreen.swift | 50 +++++-- .../Profile/Edit Group/EditGroupScreen.swift | 26 ++-- .../Profile/Edit Group/GroupChatEditing.swift | 42 ++++-- .../Screens/Profile/GroupProfileState.swift | 3 + .../Clients/Flip API/FlipClient+Chat.swift | 2 +- .../Flip API/Services/ChatService.swift | 6 +- .../Conversation/ConversationRules.swift | 97 +++++++++++- .../Conversation/ConversationStore.swift | 6 +- .../Sources/FlipcashStore/Database.swift | 2 +- 12 files changed, 327 insertions(+), 97 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 899034cfe..9cb813afd 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1298,6 +1298,9 @@ final class ConversationController { if let coverPicture = conversation.coverPicture { store.applyCoverPictureChanged(coverPicture, in: conversation.id) } + if let rules = conversation.rules { + store.applyRulesChanged(rules, in: conversation.id) + } persistConversation(conversation.id) } diff --git a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift index 1ca112a67..ca5da9efe 100644 --- a/Flipcash/Core/Navigation/AppRouter+DestinationView.swift +++ b/Flipcash/Core/Navigation/AppRouter+DestinationView.swift @@ -329,22 +329,38 @@ struct DestinationView: View { // about to replace. let editor = SessionGroupChatEditor(session: sessionContainer.session, flipClient: container.flipClient) let conversationController = conversationController - let requirements = GroupBalanceRequirements(conversationController.conversation(withID: conversationID)?.rules) - EditGroupBalanceRequirementScreen(model: EditGroupBalanceRequirementModel( - role: role, - current: role == .join ? requirements?.join : requirements?.chat, - currency: ratesController.balanceCurrency, - rates: ratesController.cachedRates, - saving: { requirement in - let conversation = try await editor.setMinimumBalance( - conversationID: conversationID, - role: role, - requirement: requirement - ) - conversationController.applyEdit(conversation) - } - )) - .id(conversationID) + let conversation = conversationController.conversation(withID: conversationID) + let session = sessionContainer.session + let rates = ratesController.cachedRates + if let requirements = GroupBalanceRequirements(conversation?.rules), let join = requirements.join { + EditGroupBalanceRequirementScreen(model: EditGroupBalanceRequirementModel( + role: role, + join: join, + speaker: requirements.speaker, + currency: ratesController.balanceCurrency, + rates: rates, + satisfies: { rules in + let gate = conversationGate(session: session, rules: rules, creator: conversation?.creator, rates: rates) + guard !gate.isProvisional else { return nil } + return gate.listener.isSatisfied && gate.speaker.isSatisfied + }, + saving: { amount in + // The rules as they stand at save time, so an edit that landed while the + // keypad was open isn't sent back over. + guard let rules = conversationController.conversation(withID: conversationID)?.rules else { + throw ErrorEditChat.invalidRules + } + let conversation = try await editor.setMinimumBalance( + conversationID: conversationID, + role: role, + amount: amount, + in: rules + ) + conversationController.applyEdit(conversation) + } + )) + .id(conversationID) + } } } } diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementModel.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementModel.swift index c96c50aa5..2fb3c9b11 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementModel.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementModel.swift @@ -12,11 +12,9 @@ private let logger = Logger(label: "flipcash.edit-group-balance-requirement") /// The state behind replacing one of a group's minimum balances, and its one save. Modelled on /// ``EditGroupDescriptionModel``. /// -/// The entry is in the account's display currency and is saved in USD, the conversion -/// ``MinimumBalanceAmountSheet`` applies when a group is created: the gate weighs holdings by -/// their USD worth, so a USD requirement needs no rate to enforce. The requirement keeps the mint -/// it already names; a group with no requirement gets one across all holdings, as -/// ``NewPublicGroupModel`` builds it. +/// The entry is in the account's display currency and is saved in the join minimum's currency, +/// which the server requires every minimum to share, rounded to that currency's smallest unit. The +/// chat minimum is held in the join minimum's mints for the same reason. @MainActor @Observable final class EditGroupBalanceRequirementModel { @@ -28,10 +26,16 @@ final class EditGroupBalanceRequirementModel { case saved } - /// Why the save didn't land, shown as a dialog. Set to nil once shown. + /// Why the save didn't go ahead or didn't land, shown as a dialog. Set to nil once shown. enum Failure: Equatable { - /// The contract has no way to change a group's rules yet. - case unavailable + /// The chat minimum isn't above the join minimum, given in the entry currency when a rate + /// can restate it. + case chatNotAboveJoin(join: FiatAmount) + /// The server refused the rules, or they break a rule it enforces (a join minimum at or + /// above the chat minimum). + case invalidRules + /// The creator doesn't meet the new rules. + case rulesNotSatisfied case failed } @@ -46,75 +50,134 @@ final class EditGroupBalanceRequirementModel { /// The currency the amount is entered in. let currency: CurrencyCode - @ObservationIgnored private let current: MinimumBalanceRequirement? + @ObservationIgnored private let join: MinimumBalanceRequirement + @ObservationIgnored private let speaker: MinimumBalanceRequirement? @ObservationIgnored private let rates: [CurrencyCode: Rate] @ObservationIgnored private let validator = AmountValidator() - @ObservationIgnored private let saving: (MinimumBalanceRequirement) async throws -> Void + @ObservationIgnored private let satisfies: (ConversationRules) -> Bool? + @ObservationIgnored private let saving: (FiatAmount?) async throws -> Void /// - Parameters: /// - role: the minimum being replaced. - /// - current: the requirement the role shows now. For ``GroupBalanceRole/chat`` on a group - /// with no speaker rule, that is the join requirement, which the chat minimum falls back - /// to; saving writes an explicit speaker rule. + /// - join: the group's listener minimum, whose currency and mints every minimum shares. + /// - speaker: the group's own speaker minimum, or nil when it sets none. /// - currency: the currency the amount is entered in. - /// - rates: what restates the entry in USD, and the current requirement in ``currency``. - /// - saving: submits the requirement and seats the chat's post-edit metadata. + /// - rates: what restates the entry in the join minimum's currency, and back. + /// - satisfies: whether the creator meets a set of minimums, or nil when their balance can't + /// be weighed against it. + /// - saving: submits the amount, in the join minimum's currency, or nil to remove the chat + /// minimum, and seats the chat's post-edit metadata. init( role: GroupBalanceRole, - current: MinimumBalanceRequirement?, + join: MinimumBalanceRequirement, + speaker: MinimumBalanceRequirement?, currency: CurrencyCode, rates: [CurrencyCode: Rate], - saving: @escaping (MinimumBalanceRequirement) async throws -> Void + satisfies: @escaping (ConversationRules) -> Bool?, + saving: @escaping (FiatAmount?) async throws -> Void ) { self.role = role - self.current = current + self.join = join + self.speaker = speaker self.currency = currency self.rates = rates + self.satisfies = satisfies self.saving = saving + let current = role == .join ? join : speaker self.enteredAmount = SetMinimumTipScreen.seed( fee: current?.amount.converted(to: currency, rates: rates), currency: currency ) } - /// The entry as the requirement it would save, or nil while it holds nothing a requirement can - /// be set to: empty, zero, no rate to restate it in USD, or small enough to round away there. - var requirement: MinimumBalanceRequirement? { + /// The entry restated in the join minimum's currency and rounded to its smallest unit, or nil + /// while it holds nothing a minimum can be set to: empty, zero, no rate to restate it, or below + /// the currency's minimum transfer value. + var amount: FiatAmount? { guard let value = validator.validate(enteredAmount), value > 0, - let usd = FiatAmount(value: value, currency: currency).converted(to: .usd, rates: rates), - usd.isPositive else { return nil } - return MinimumBalanceRequirement(amount: usd, mints: current?.mints ?? []) + let converted = FiatAmount(value: value, currency: currency).converted(to: join.amount.currency, rates: rates) + else { return nil } + let rounded = converted.roundedToSmallestUnit() + return rounded.isPositive ? rounded : nil } - /// Whether Save is enabled: a valid amount that differs, at display precision, from the one set. + /// Whether Save is enabled: a valid amount that differs from the one set. var canSave: Bool { - guard state == .normal, let requirement else { return false } - guard let current = current?.amount.converted(to: .usd, rates: rates) else { return true } - return requirement.amount.roundedToSmallestUnit() != current.roundedToSmallestUnit() + guard state == .normal, let amount else { return false } + let current = role == .join ? join : speaker + return amount != current?.amount.roundedToSmallestUnit() } - /// Submits the requirement. Failures land in ``failure``. + /// Whether the chat minimum can be removed, leaving none beyond joining. + var canRemove: Bool { + state == .normal && role == .chat && speaker != nil + } + + /// Why the entry can't be sent, checked before the change is confirmed; nil when it can. + var blocker: Failure? { + guard let amount else { return nil } + let joinAmount = join.amount.roundedToSmallestUnit() + switch role { + case .join: + if let speaker, amount >= speaker.amount.roundedToSmallestUnit() { + return .invalidRules + } + case .chat: + // Both sides sit on the currency's smallest unit, so exceeding the join minimum means + // exceeding it by at least the minimum transfer value. + if amount <= joinAmount { + return .chatNotAboveJoin(join: join.amount.converted(to: currency, rates: rates) ?? join.amount) + } + } + guard satisfies(proposedRules(amount)) != false else { return .rulesNotSatisfied } + return nil + } + + /// Submits the entry. Failures land in ``failure``. func save() async { - guard canSave, let requirement else { return } + guard canSave, let amount else { return } + if let blocker { + failure = blocker + return + } + await submit(amount) + } + /// Removes the chat minimum. Failures land in ``failure``. + func remove() async { + guard canRemove else { return } + await submit(nil) + } + + // MARK: - Private - + + /// The minimums the group would carry with `amount` saved for ``role``. + private func proposedRules(_ amount: FiatAmount) -> ConversationRules { + let listener = role == .join ? amount : join.amount + let speaker = role == .chat ? amount : self.speaker?.amount + return ConversationRules( + listener: [.minimumBalance(MinimumBalanceRequirement(amount: listener, mints: join.mints))], + speaker: speaker.map { [.minimumBalance(MinimumBalanceRequirement(amount: $0, mints: join.mints))] } ?? [] + ) + } + + private func submit(_ amount: FiatAmount?) async { state = .saving do { - try await saving(requirement) + try await saving(amount) state = .saved - } catch ErrorSetGroupMinimumBalance.unavailable { - // The stub throws this on every save, so it is expected rather than reported. - state = .normal - logger.info("Group minimum balance edit unavailable", metadata: ["role": "\(role)"]) - failure = .unavailable - } catch { state = .normal guard !Task.isCancelled else { return } logger.error("Failed to set group minimum balance", metadata: ["role": "\(role)", "error": "\(error)"]) ErrorReporting.captureError(error, reason: "Failed to set group minimum balance") - failure = .failed + switch error { + case ErrorEditChat.invalidRules: failure = .invalidRules + case ErrorEditChat.rulesNotSatisfied: failure = .rulesNotSatisfied + default: failure = .failed + } } } } diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift index e3efc2d2b..dfc7d2589 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift @@ -69,34 +69,60 @@ struct EditGroupBalanceRequirementScreen: View { .ignoresSafeArea(.keyboard) .navigationTitle(field.title) .toolbarTitleDisplayMode(.inline) + .toolbar { + if model.canRemove { + ToolbarItem(placement: .topBarTrailing) { + Button("Remove", action: proposeRemove) + .disabled(saveTask != nil) + } + } + } .dialog(item: $dialog) .onChange(of: model.failure) { _, failure in guard let failure else { return } model.failure = nil - switch failure { - case .unavailable: - dialog = .info( - title: "Not Available Yet", - subtitle: "Balance requirements can't be changed yet" - ) - case .failed: - dialog = .error(title: "Couldn't Save the Requirement", subtitle: "Try again") - } + dialog = Self.dialog(for: failure) } // Leaving the screen abandons the submission: its only continuation is a pop. .onDisappear { saveTask?.cancel() } } + static func dialog(for failure: EditGroupBalanceRequirementModel.Failure) -> DialogItem { + switch failure { + case .chatNotAboveJoin(let join): + .error( + title: "Chat Minimum Too Low", + subtitle: "The chat minimum must be more than the join minimum of \(join.formatted())" + ) + case .invalidRules: + .error(title: "This Requirement Isn't Valid", subtitle: "Try a different minimum balance") + case .rulesNotSatisfied: + .error(title: "You Don't Meet This Requirement", subtitle: "Lower the minimum balance or add funds") + case .failed: + .error(title: "Couldn't Save the Requirement", subtitle: "Try again") + } + } + /// Save proposes; the dialog commits, since the requirement changes for everyone in the group. + /// An entry the server would refuse is stopped here, before the confirmation. private func submit() { guard model.canSave, saveTask == nil else { return } - dialog = .confirmGroupChange(field) { save() } + if let blocker = model.blocker { + dialog = Self.dialog(for: blocker) + return + } + dialog = .confirmGroupChange(field) { save(model.save) } + } + + private func proposeRemove() { + guard model.canRemove, saveTask == nil else { return } + dialog = .confirmGroupChange(field) { save(model.remove) } } - private func save() { + private func save(_ operation: @escaping () async -> Void) { guard saveTask == nil else { return } saveTask = Task { - await model.save() + await operation() if model.state == .saved { // Same beat the other editors hold their checkmark for. try? await Task.delay(milliseconds: 500) diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift index 10941886c..369bdfac2 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift @@ -10,8 +10,8 @@ import FlipcashUI /// A group's editor, in Edit Profile's shape: the cover and photo at the top, then a card per field, /// each opening its own screen that saves on its own. /// -/// Balance Requirements shows even on a group with none, so one can be added. Its rows open the -/// amount editor, whose save is stubbed until the contract can change a group's rules. +/// Balance Requirements shows only on a public group with a join minimum, the one rule set the +/// server lets a group change. It reads the stored rules, so an edit from anywhere shows here at once. struct EditGroupScreen: View { let conversationID: ConversationID @@ -31,10 +31,13 @@ struct EditGroupScreen: View { conversationController.conversation(withID: conversationID) } - /// Nil for a private group, which has no rules and can't gain them. + /// Nil unless the group's rules can be edited: a public group with a join minimum. A join + /// minimum can't be added to a group without one, nor a private group given rules. private var requirements: GroupBalanceRequirements? { - guard conversation?.isPrivate != true else { return nil } - return GroupBalanceRequirements(conversation?.rules) + guard conversation?.isPrivate == false, + let requirements = GroupBalanceRequirements(conversation?.rules), + requirements.join != nil else { return nil } + return requirements } var body: some View { @@ -45,8 +48,10 @@ struct EditGroupScreen: View { photo fieldCards .padding(.top, 20) - balanceRequirements(requirements) - .padding(.top, 28) + if let requirements { + balanceRequirements(requirements) + .padding(.top, 28) + } } .padding(.horizontal, ProfileHeaderMetrics.inset) .padding(.vertical, 24) @@ -159,17 +164,18 @@ struct EditGroupScreen: View { // MARK: - Balance requirements - - private func balanceRequirements(_ requirements: GroupBalanceRequirements?) -> some View { + private func balanceRequirements(_ requirements: GroupBalanceRequirements) -> some View { VStack(alignment: .leading, spacing: 8) { Text("Balance Requirements") .font(.appTextMedium) .foregroundStyle(Color.textMain) VStack(spacing: 0) { - requirementRow("Join", requirements?.join, role: .join) + requirementRow("Join", requirements.join, role: .join) Divider() .overlay(Color.rowSeparator) - requirementRow("Chat", requirements?.chat, role: .chat) + // "None" when joining is enough to chat: the group sets no chat minimum of its own. + requirementRow("Chat", requirements.speaker, role: .chat) } .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) diff --git a/Flipcash/Core/Screens/Profile/Edit Group/GroupChatEditing.swift b/Flipcash/Core/Screens/Profile/Edit Group/GroupChatEditing.swift index 4a82aa12e..cce8ba965 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/GroupChatEditing.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/GroupChatEditing.swift @@ -35,11 +35,14 @@ protocol GroupChatEditing { coverPictureBlobID: BlobID? ) async throws -> Conversation - /// Replaces the minimum balance `role` requires and returns the chat's post-edit metadata. + /// Replaces the minimum balance `role` requires in `rules`, the chat's current rules, and returns + /// the chat's post-edit metadata. `amount` is in the listener minimum's currency; nil removes the + /// chat minimum, leaving no separate speaker rule. func setMinimumBalance( conversationID: ConversationID, role: GroupBalanceRole, - requirement: MinimumBalanceRequirement + amount: FiatAmount?, + in rules: ConversationRules ) async throws -> Conversation } @@ -50,10 +53,20 @@ nonisolated enum GroupBalanceRole: Hashable, Sendable { case chat } -/// Thrown by ``SessionGroupChatEditor/setMinimumBalance(conversationID:role:requirement:)`` until -/// the contract carries a way to change a group's rules. -enum ErrorSetGroupMinimumBalance: Error { - case unavailable +extension ConversationRulesEdit { + /// `rules` with `role`'s minimum set to `amount` and every other rule as the group carries it, + /// or nil when `rules` has no listener minimum, `amount` isn't in its currency, or `amount` is + /// nil for ``GroupBalanceRole/join``, which can't be removed. + init?(editing rules: ConversationRules, role: GroupBalanceRole, amount: FiatAmount?) { + guard let requirements = GroupBalanceRequirements(rules), let join = requirements.join else { return nil } + switch role { + case .join: + guard let amount else { return nil } + self.init(editing: rules, listener: amount, speaker: requirements.speaker?.amount) + case .chat: + self.init(editing: rules, listener: join.amount, speaker: amount) + } + } } /// Edits on behalf of the signed-in owner. @@ -87,14 +100,21 @@ struct SessionGroupChatEditor: GroupChatEditing { ) } - // 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 FlipClient call - // once the contract adds one, and seat the returned rules in `ConversationController.applyEdit`. func setMinimumBalance( conversationID: ConversationID, role: GroupBalanceRole, - requirement: MinimumBalanceRequirement + amount: FiatAmount?, + in rules: ConversationRules ) async throws -> Conversation { - throw ErrorSetGroupMinimumBalance.unavailable + guard let edit = ConversationRulesEdit(editing: rules, role: role, amount: amount) else { + throw ErrorEditChat.invalidRules + } + return try await flipClient.editChat( + owner: session.ownerKeyPair, + conversationID: conversationID, + title: nil, + pictureBlobID: nil, + rules: edit + ) } } diff --git a/Flipcash/Core/Screens/Profile/GroupProfileState.swift b/Flipcash/Core/Screens/Profile/GroupProfileState.swift index 0de2829c7..ebf2fca1d 100644 --- a/Flipcash/Core/Screens/Profile/GroupProfileState.swift +++ b/Flipcash/Core/Screens/Profile/GroupProfileState.swift @@ -68,6 +68,8 @@ nonisolated struct GroupBalanceRequirements: Equatable { /// The speaker minimum, falling back to ``join`` when the group sets none: speaker rules equal /// listener rules when none are set. let chat: MinimumBalanceRequirement? + /// The speaker minimum the group sets itself, or nil when it sets none and ``chat`` is the join's. + let speaker: MinimumBalanceRequirement? /// Nil when the group has no minimum balance rule, which hides the card. init?(_ rules: ConversationRules?) { @@ -85,6 +87,7 @@ nonisolated struct GroupBalanceRequirements: Equatable { }.first guard join != nil || speaker != nil else { return nil } self.join = join + self.speaker = speaker self.chat = speaker ?? join } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 28b0da594..bb4b7cf42 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -245,7 +245,7 @@ extension FlipClient { /// /// `rules`, when set, fully replaces the chat's rules (public groups with a listener minimum balance /// only); fails with `.invalidRules` or `.rulesNotSatisfied`. `nil` leaves the rules as they are. - public func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?, coverPictureBlobID: BlobID? = nil, rules: ConversationRules? = nil) async throws -> Conversation { + public func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?, coverPictureBlobID: BlobID? = nil, rules: ConversationRulesEdit? = nil) async throws -> Conversation { try await withCheckedThrowingContinuation { c in chatService.editChat(owner: owner, conversationID: conversationID, title: title, description: description, pictureBlobID: pictureBlobID, coverPictureBlobID: coverPictureBlobID, rules: rules) { c.resume(with: $0) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index 83cd8702c..aa5e9a57f 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -565,7 +565,7 @@ final class ChatService: Sendable { /// /// `description` is ``ConversationDescriptionEdit/unchanged`` by default; use `.clear` to remove /// an existing description. - func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?, coverPictureBlobID: BlobID? = nil, rules: ConversationRules? = nil, completion: @Sendable @escaping (Result) -> Void) { + func editChat(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit = .unchanged, pictureBlobID: BlobID?, coverPictureBlobID: BlobID? = nil, rules: ConversationRulesEdit? = nil, completion: @Sendable @escaping (Result) -> Void) { let request = Self.editChatRequest( owner: owner, conversationID: conversationID, @@ -600,8 +600,8 @@ final class ChatService: Sendable { /// The signed `EditChat` request; a nil title or blob and an `.unchanged` description leave that /// field unset, so the server keeps its current value. `rules`, when set, is the full replacement - /// rule set (public groups with a listener minimum balance only). - static func editChatRequest(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit, pictureBlobID: BlobID?, coverPictureBlobID: BlobID?, rules: ConversationRules? = nil) -> Flipcash_Chat_V1_EditChatRequest { + /// rule set (public groups with a listener minimum balance only), sent as built from the server's. + static func editChatRequest(owner: KeyPair, conversationID: ConversationID, title: String?, description: ConversationDescriptionEdit, pictureBlobID: BlobID?, coverPictureBlobID: BlobID?, rules: ConversationRulesEdit? = nil) -> Flipcash_Chat_V1_EditChatRequest { Flipcash_Chat_V1_EditChatRequest.with { $0.chatID = conversationID.proto if let title { diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index 23e380b41..131c2d69d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -263,18 +263,45 @@ public struct ConversationRules: Hashable, Codable, Sendable { /// Empty means any member can send. public var speaker: [ConversationSpeakerRule] + /// The rules exactly as the server last sent them, serialized, so ``ConversationRulesEdit`` can + /// send back the rules this client can't read. Nil for rules built locally. Left out of + /// equality: two rule sets that read the same are the same rules. + let wire: Data? + public init(listener: [ConversationListenerRule] = [], speaker: [ConversationSpeakerRule] = []) { self.listener = listener self.speaker = speaker + self.wire = nil + } + + public static func == (lhs: ConversationRules, rhs: ConversationRules) -> Bool { + lhs.listener == rhs.listener && lhs.speaker == rhs.speaker + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(listener) + hasher.combine(speaker) } } extension ConversationRules { public init(_ proto: Flipcash_Chat_V1_Rules) { - self.init( - listener: proto.listener.compactMap(ConversationListenerRule.init), - speaker: proto.speaker.map(ConversationSpeakerRule.init) - ) + self.listener = proto.listener.compactMap(ConversationListenerRule.init) + self.speaker = proto.speaker.map(ConversationSpeakerRule.init) + self.wire = try? proto.serializedData() + } + + /// Whether decoding dropped a listener rule this client can't read, such as a minimum balance + /// in a currency it doesn't recognize. Such a set reads as more open than the chat is. + public var dropsListenerRule: Bool { + guard let wire, let proto = try? Flipcash_Chat_V1_Rules(serializedBytes: wire) else { return false } + return proto.listener.count != listener.count + } + + /// The server's form of these rules: what it last sent when there is one, else the rules as built. + var wireProto: Flipcash_Chat_V1_Rules { + guard let wire, let proto = try? Flipcash_Chat_V1_Rules(serializedBytes: wire) else { return self.proto } + return proto } } @@ -431,3 +458,65 @@ extension MinimumBalanceRequirement { } } } + +/// A replacement rule set for `EditChatRequest.rules`, built from the rules the server last sent +/// rather than from the decoded ``ConversationRules``, so rules this client can't read go back as the +/// group carries them. +/// +/// Only the edits the server accepts can be expressed: the listener minimum balance's amount, and +/// adding, changing or removing a speaker minimum balance in the listener's currency and mints. +public struct ConversationRulesEdit: Equatable, Sendable { + + let proto: Flipcash_Chat_V1_Rules + + /// Returns nil when `rules` has no listener minimum balance to edit, or when an amount isn't in + /// that requirement's currency. + /// + /// - Parameters: + /// - rules: the chat's current rules. + /// - listener: the new listener minimum, in the listener requirement's currency. + /// - speaker: the new speaker minimum in that currency, or nil for no speaker minimum. + public init?(editing rules: ConversationRules, listener: FiatAmount, speaker: FiatAmount?) { + var proto = rules.wireProto + guard let index = proto.listener.firstIndex(where: { + guard case .minimumBalance = $0.kind else { return false } + return true + }), case .minimumBalance(let current)? = proto.listener[index].kind else { return nil } + + let currency = current.amount.currency + guard currency.lowercased() == listener.currency.rawValue, + speaker.map({ $0.currency == listener.currency }) ?? true else { return nil } + + proto.listener[index].minimumBalance.amount.nativeAmount = listener.doubleValue + + // The listener's mints are copied as sent, not as decoded, which drops a malformed one. + let speakerRule = speaker.map { amount in + Flipcash_Chat_V1_SpeakerRules.with { + $0.minimumBalance = .with { + $0.amount = .with { + $0.currency = currency + $0.nativeAmount = amount.doubleValue + } + $0.mints = current.mints + } + } + } + let speakerIndex = proto.speaker.firstIndex { + guard case .minimumBalance = $0.kind else { return false } + return true + } + switch (speakerIndex, speakerRule) { + case (let index?, let rule?): proto.speaker[index] = rule + case (let index?, nil): proto.speaker.remove(at: index) + case (nil, let rule?): proto.speaker.append(rule) + case (nil, nil): break + } + + self.proto = proto + } + + /// The edited rules as this client reads them. + public var rules: ConversationRules { + ConversationRules(proto) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift index bede89f01..27ce3fcc0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationStore.swift @@ -594,8 +594,12 @@ public struct ConversationStore: Sendable { /// Apply a rules change delivered via `MetadataUpdate.RulesChanged`. `rules` replaces the whole set. /// Same best-effort, no-version caveat as ``applyTitleChanged(_:in:)``. No-ops for a chat the /// store doesn't hold. + /// + /// Rules that drop a listener rule this client can't read are refused, keeping the ones held: the + /// dropped rule would leave a gated group reading as open, and a refetch decodes the same way. public mutating func applyRulesChanged(_ rules: ConversationRules, in conversationID: ConversationID) { - guard let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } + guard !rules.dropsListenerRule, + let index = conversations.firstIndex(where: { $0.id == conversationID }) else { return } conversations[index].rules = rules } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 74ad6307c..b1f2f1593 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 60 + public static let schemaVersion = 61 /// Removes the store and the write-ahead log files beside it. /// From 43a46517c9526f8cc7740ce2349460d2d0bf611f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 07:18:16 -0400 Subject: [PATCH 3/5] feat(chat): follow group rule changes live A member short of the listener minimum keeps reading and can't speak, as when their balance falls, instead of losing the transcript. Group link cards read the stored rules, and reopening a group the viewer has left refetches its rules so a rejoin is weighed against them. --- ...26-10-07-group-balance-requirement-edit.md | 77 ++++--- .../Controllers/ConversationController.swift | 17 +- .../Conversation/ConversationGate.swift | 12 +- .../Screens/Conversation/GroupLinkCard.swift | 14 +- .../Screens/Conversation/LinkCardFeed.swift | 4 +- .../Core/Session/SessionAuthenticator.swift | 4 +- .../ConversationRulesEditTests.swift | 127 +++++++++++ .../Chat/ConversationMembershipTests.swift | 22 ++ ...ditGroupBalanceRequirementModelTests.swift | 203 +++++++++++++++--- FlipcashTests/Chat/EditGroupModelTests.swift | 5 +- FlipcashTests/ConversationGateTests.swift | 17 +- 11 files changed, 417 insertions(+), 85 deletions(-) create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ConversationRulesEditTests.swift diff --git a/.claude/plans/2026-10-07-group-balance-requirement-edit.md b/.claude/plans/2026-10-07-group-balance-requirement-edit.md index 8aa49812d..ad9ee6191 100644 --- a/.claude/plans/2026-10-07-group-balance-requirement-edit.md +++ b/.claude/plans/2026-10-07-group-balance-requirement-edit.md @@ -1,35 +1,42 @@ -# Editing a group's balance requirements (stubbed) - -## Contract gap - -`flipcash2-client-protocol` 0.18.0 (latest tag) has no RPC that changes a group's rules after -`StartChat`. `EditChatRequest` carries only title, profile picture, description, and cover -picture. No branch of `code-payments/flipcash2-protobuf-api` adds one as of 2026-10-07. Android has -no setter either. - -## What iOS built - -- Edit Group's Balance Requirements card always shows (rows read "None" on a group without one). - Each row (Join, Chat) pushes `.editGroupBalanceRequirement(id, role:)`. -- `EditGroupBalanceRequirementScreen` is `EnterAmountView(mode: .balanceRequirement)` with a - "Save" title, confirmed through `DialogItem.confirmGroupChange(.joinRequirement / .chatRequirement)`. -- `EditGroupBalanceRequirementModel`: entry in `balanceCurrency`, saved in USD (the creation - sheet's conversion), keeps the requirement's existing mint, empty mints for a new one. The Chat - row seeds from `GroupBalanceRequirements.chat`, which falls back to join; saving writes an - explicit speaker rule. -- The single stub: `SessionGroupChatEditor.setMinimumBalance(conversationID:role:requirement:)` - throws `ErrorSetGroupMinimumBalance.unavailable`. The model maps it to a "Not Available Yet" - dialog without reporting it. - -## When the RPC lands - -1. Bump the package; add a `FlipClient`/`ChatService` call. -2. Replace the stub body with it. -3. Seat the returned rules: `ConversationController.applyEdit` handles title, description, and - pictures only — add a rules branch (and a store mutation if none exists). -4. Drop the `.unavailable` case and its dialog, and the "stubbed" note in `EditGroupScreen`'s doc. - -## Open - -- No way to clear a requirement: the keypad can't express "None". -- No check that the chat minimum is at least the join minimum. +# Editing a group's balance requirements + +Originally built against a stub (flipcash2-client-protocol 0.18.0 had no rules edit). Wired to +`EditChatRequest.rules` in 0.23.0 on 2026-10-29. + +## Contract (`chat/v1/chat_service.proto`, `EditChatRequest.rules`) + +- Full replacement rule set. Only a public group created with a listener minimum is editable; + private is DENIED, anything else INVALID_RULES. +- The listener minimum keeps its currency and mints; it can't be added or removed. +- A speaker minimum uses the listener's currency and mints, exceeds the listener minimum by at + least the currency's minimum transfer value, and can be added, changed, or removed. +- Every amount is at least the minimum transfer value (0.01 USD, 1 JPY). +- Other rules (staff, creator) go back as the group has them. +- The creator must satisfy the new rules, speaker included (RULES_NOT_SATISFIED otherwise). +- Rules never remove a member: one short of the listener rules keeps reading but can't speak, as + when their balance falls; after a leave they must satisfy the rules to rejoin. + +## How iOS does it + +- `ConversationRules` keeps the serialized proto the server last sent (`wire`, outside equality). + `ConversationRulesEdit` edits that proto, so unreadable rules and malformed mints go back + verbatim. `ConversationRules(proto)`'s `compactMap` would drop them. +- `EditGroupBalanceRequirementModel` converts the entry into the join minimum's currency, rounds + to its smallest unit, and blocks before sending: Chat ≤ Join ("Chat Minimum Too Low"), Join ≥ + Chat (INVALID_RULES copy), and the creator not meeting the rules (RULES_NOT_SATISFIED copy, + skipped when the gate is provisional). Unchanged amounts keep Save shut. +- The Chat row reads the group's own speaker minimum ("None" without one). "Remove" in the Chat + editor's toolbar clears it. +- Edit Group shows the card only on a public group with a join minimum. +- `applyEdit` seats the returned rules. `applyRulesChanged` ignores a set whose listener rule this + client can't read (`dropsListenerRule`), keeping the old gate rather than opening the chat. + A refetch would decode the same way. + +## Live rules + +- Gates read `conversationController.conversation(withID:)` in their bodies, so the stream's + `.rulesChanged` reaches them directly. A member failing the listener rules is `.readOnly`, not + `.blocked`. +- Group link cards overlay the stored rules on their fetched copy (`GroupLinkPresenter.storedRules`). +- A held group the viewer isn't in gets no `.rulesChanged`, so `hydratedConversation` refetches its + rules when it is reopened. diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 9cb813afd..32c2cd21d 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -210,9 +210,15 @@ final class ConversationController { /// doesn't hold it yet — the same fetch + apply + persist path stream /// events use, so the caller's screen finds the chat populated. Returns /// nil when the server doesn't know the chat either. + /// + /// A group held but not joined, such as one left, has its rules refreshed: the stream doesn't + /// carry a non-member's rule changes, and a rejoin has to be weighed against the rules as they + /// are now. func hydratedConversation(withID conversationID: ConversationID) async -> Conversation? { if let conversation = conversation(withID: conversationID) { - return conversation + guard !store.isMember(of: conversation) else { return conversation } + await refreshRules(of: conversationID) + return self.conversation(withID: conversationID) } do { let conversation = try await fetching.getChat(owner: owner, conversationID: conversationID) @@ -228,6 +234,15 @@ final class ConversationController { } } + /// Replaces a held chat's rules with the server's. Best-effort: on failure the held rules stand, + /// and the server weighs the join against its own. + private func refreshRules(of conversationID: ConversationID) async { + guard let fetched = try? await fetching.getChat(owner: owner, conversationID: conversationID), + let rules = fetched.rules else { return } + store.applyRulesChanged(rules, in: conversationID) + persistConversation(conversationID) + } + /// Holds a conversation fetched elsewhere, so a screen opened on it finds it populated. When the /// store already has the chat its copy is the fresher one, and only a cover it lacks is taken. func hold(_ conversation: Conversation) { diff --git a/Flipcash/Core/Screens/Conversation/ConversationGate.swift b/Flipcash/Core/Screens/Conversation/ConversationGate.swift index 4bcceedd3..dc8ce5618 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGate.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGate.swift @@ -285,11 +285,11 @@ enum ConversationGatePresentation: Equatable { /// rule, and the contract gives a non-member of such a group no read at all, /// so it stays blurred until the join. Android draws the same line. case join(ConversationGateRequirement?) - /// Listener rules unmet: blurred transcript, the requirement named, and a - /// CTA when the requirement has one (node 10125:19153). + /// Listener rules unmet by a non-member: blurred transcript, the requirement + /// named, and a CTA when the requirement has one (node 10125:19153). case blocked(ConversationGateRequirement) - /// A member who can read but not send: sharp transcript, the requirement in - /// place of the composer. + /// A member who can read but not send, including one short of the listener + /// rules: sharp transcript, the requirement in place of the composer. case readOnly(ConversationGateRequirement) /// Whether the transcript is blurred and its messages left unfetched. @@ -363,7 +363,9 @@ enum CashCollectionBlock: Equatable { func conversationGatePresentation(_ gate: ConversationGate, isMember: Bool) -> ConversationGatePresentation { switch gate.listener { case .unsatisfied(_, let primary): - return .blocked(primary) + // Rules never remove anyone: a member who no longer meets them, whether their balance fell + // or the rules rose, keeps reading but can't speak until they meet them again. + return isMember ? .readOnly(primary) : .blocked(primary) case .satisfied: // Satisfying the rules earns the transcript; joining earns the composer. A verdict that // guessed at a missing rate stays undetermined, so the transcript is never shown and then diff --git a/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift b/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift index 77cd44707..d7d22b408 100644 --- a/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift +++ b/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift @@ -13,7 +13,7 @@ import FlipcashCore /// count and rules. The roster is private to members whatever the group's mode, so `members` is /// never read, and the fetch asks for redacted content so no message body comes back either. nonisolated struct GroupLinkFacts: Sendable, Equatable { - let conversation: Conversation + var conversation: Conversation /// The name of the token ``headline(for:)`` names, or nil when the rule names none or names the /// dollar token, which the card states by amount alone. let headlineMintName: String? @@ -39,13 +39,21 @@ protocol GroupLinkPresenting: AnyObject { final class GroupLinkPresenter: GroupLinkPresenting { private let avatars: ProfileAvatarStore + private let storedRules: (ConversationID) -> ConversationRules? - init(avatars: ProfileAvatarStore) { + /// - Parameter storedRules: the rules the app holds for a chat, kept current by its stream, or + /// nil when it holds none. Read in place of the fetched rules so the card follows a rules change. + init(avatars: ProfileAvatarStore, storedRules: @escaping (ConversationID) -> ConversationRules? = { _ in nil }) { self.avatars = avatars + self.storedRules = storedRules } func present(_ facts: GroupLinkFacts) -> LinkCard.Group.Resolved { - groupLinkCard(facts, imageData: avatars.data(for: .chat(facts.conversation.id))) + var facts = facts + if let rules = storedRules(facts.conversation.id) { + facts.conversation.rules = rules + } + return groupLinkCard(facts, imageData: avatars.data(for: .chat(facts.conversation.id))) } func loadPicture(for facts: GroupLinkFacts) async { diff --git a/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift b/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift index 195544a44..c0ea250fc 100644 --- a/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift +++ b/Flipcash/Core/Screens/Conversation/LinkCardFeed.swift @@ -249,8 +249,8 @@ final class LinkCardFeed: LinkCardSource { } } - // Re-presents a group or person card when the picture's bytes land, and yields it to whoever is - // still listening. Re-arms once per change, and lapses once no row shows the link. + // Re-presents a group or person card when the picture's bytes land, or a group's stored rules + // change, and yields it to whoever is still listening. Re-arms once per change, and lapses once no row shows the link. private func observePicture(_ key: String) { guard !observedPictures.contains(key), let facts = fetched[key] else { return } observedPictures.insert(key) diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index f53429e13..0867594dc 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -635,7 +635,9 @@ final class SessionContainer { resolver: linkCardResolver, memo: linkCardMemo, claims: session.cashLinkClaims, - groups: GroupLinkPresenter(avatars: profileAvatars), + groups: GroupLinkPresenter(avatars: profileAvatars, storedRules: { [conversationController] id in + conversationController.conversation(withID: id)?.rules + }), users: UserLinkPresenter(avatars: profileAvatars), messagePhotos: { [flipClient, owner = session.ownerKeyPair, conversationController] photo, chatID in guard let blobID = photo.blobID, diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationRulesEditTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationRulesEditTests.swift new file mode 100644 index 000000000..37acd9b4b --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationRulesEditTests.swift @@ -0,0 +1,127 @@ +// +// ConversationRulesEditTests.swift +// FlipcashCoreTests +// + +import Testing +import Foundation +import FlipcashAPI +@testable import FlipcashCore + +@Suite("Conversation rules edit") +struct ConversationRulesEditTests { + + private let mint = Data(repeating: 7, count: 32) + + private func minimumBalance(_ value: Double, currency: String = "usd", mints: [Data]? = nil) -> Flipcash_Chat_V1_MinimumBalanceRequirement { + .with { + $0.amount = .with { + $0.currency = currency + $0.nativeAmount = value + } + $0.mints = (mints ?? [mint]).map { bytes in .with { $0.value = bytes } } + } + } + + /// A group as the server sends it: a listener minimum and a staff rule, a creator speaker rule, + /// and optionally a speaker minimum. + private func serverRules(speaker: Double? = nil, currency: String = "usd") -> Flipcash_Chat_V1_Rules { + .with { + $0.listener = [ + .with { $0.minimumBalance = minimumBalance(5, currency: currency) }, + .with { $0.staff = .init() }, + ] + $0.speaker = [.with { $0.creator = .init() }] + if let speaker { + $0.speaker.append(.with { $0.minimumBalance = minimumBalance(speaker, currency: currency) }) + } + } + } + + @Test("Rules the edit doesn't touch go back as the server sent them") + func keepsOtherRules() throws { + let rules = ConversationRules(serverRules()) + let edit = try #require(ConversationRulesEdit(editing: rules, listener: .usd(8), speaker: nil)) + + #expect(edit.proto.listener.count == 2) + #expect(edit.proto.listener[0].minimumBalance.amount.nativeAmount == 8) + #expect(edit.proto.listener[1].staff == .init()) + #expect(edit.proto.speaker == [.with { $0.creator = .init() }]) + } + + @Test("A new speaker minimum takes the listener's currency and mints as sent") + func addsSpeakerInListenerTerms() throws { + // A malformed mint, which decoding drops, still goes back so the server sees matching mints. + let malformed = Data([1, 2, 3]) + var proto = serverRules() + proto.listener[0].minimumBalance.mints.append(.with { $0.value = malformed }) + let rules = ConversationRules(proto) + + let edit = try #require(ConversationRulesEdit(editing: rules, listener: .usd(5), speaker: .usd(10))) + + let speaker = try #require(edit.proto.speaker.last) + #expect(speaker.minimumBalance.amount.currency == "usd") + #expect(speaker.minimumBalance.amount.nativeAmount == 10) + #expect(speaker.minimumBalance.mints.map(\.value) == [mint, malformed]) + #expect(edit.proto.speaker.first == .with { $0.creator = .init() }) + } + + @Test("A speaker minimum is replaced in place, or removed") + func replacesOrRemovesSpeaker() throws { + let rules = ConversationRules(serverRules(speaker: 10)) + + let changed = try #require(ConversationRulesEdit(editing: rules, listener: .usd(5), speaker: .usd(12))) + #expect(changed.proto.speaker.count == 2) + #expect(changed.proto.speaker[1].minimumBalance.amount.nativeAmount == 12) + + let removed = try #require(ConversationRulesEdit(editing: rules, listener: .usd(5), speaker: nil)) + #expect(removed.proto.speaker == [.with { $0.creator = .init() }]) + } + + @Test("An amount outside the listener's currency, or a group with no listener minimum, has no edit") + func refusesWhatTheServerWould() { + let jpy = ConversationRules(serverRules(currency: "jpy")) + #expect(ConversationRulesEdit(editing: jpy, listener: .usd(5), speaker: nil) == nil) + #expect(ConversationRulesEdit(editing: jpy, listener: FiatAmount(value: 500, currency: .jpy), speaker: .usd(1)) == nil) + #expect(ConversationRulesEdit(editing: jpy, listener: FiatAmount(value: 500, currency: .jpy), speaker: nil) != nil) + + let staffOnly = ConversationRules(.with { $0.listener = [.with { $0.staff = .init() }] }) + #expect(ConversationRulesEdit(editing: staffOnly, listener: .usd(5), speaker: nil) == nil) + } + + @Test("Rules built locally have no server form beyond what they state") + func localRulesEditFromTheirOwnProto() throws { + let rules = ConversationRules(listener: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(5)))]) + let edit = try #require(ConversationRulesEdit(editing: rules, listener: .usd(6), speaker: nil)) + #expect(edit.rules == ConversationRules(listener: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(6)))])) + } + + @Test("The server's rules don't change how two rule sets compare") + func wireLeftOutOfEquality() { + let decoded = ConversationRules(.with { $0.listener = [.with { $0.minimumBalance = minimumBalance(5, mints: []) }] }) + let local = ConversationRules(listener: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(5)))]) + #expect(decoded == local) + } + + @Test("A listener rule in an unknown currency is reported as dropped") + func dropsUnknownCurrency() { + #expect(ConversationRules(serverRules(currency: "zzz")).dropsListenerRule) + #expect(!ConversationRules(serverRules()).dropsListenerRule) + #expect(!ConversationRules().dropsListenerRule) + } + + @Test("A rules change the client can't fully read keeps the rules it had, rather than opening the chat") + func storeKeepsRulesOnUnknownCurrency() { + let id = ConversationID(data: Data(repeating: 1, count: 32)) + let held = ConversationRules(serverRules()) + var store = ConversationStore() + store.setFeed([Conversation(id: id, members: [], lastMessage: nil, lastActivity: .now, type: .group, rules: held)]) + + store.applyRulesChanged(ConversationRules(serverRules(currency: "zzz")), in: id) + #expect(store.conversations.first?.rules == held) + + let raised = ConversationRules(serverRules(speaker: 9)) + store.applyRulesChanged(raised, in: id) + #expect(store.conversations.first?.rules == raised) + } +} diff --git a/FlipcashTests/Chat/ConversationMembershipTests.swift b/FlipcashTests/Chat/ConversationMembershipTests.swift index d6b4da34d..6bcafff24 100644 --- a/FlipcashTests/Chat/ConversationMembershipTests.swift +++ b/FlipcashTests/Chat/ConversationMembershipTests.swift @@ -140,6 +140,28 @@ struct ConversationMembershipTests { #expect(try database.getGroupMemberships() == [.test(9)]) } + @Test("Reopening a group held but not joined weighs it against the server's current rules") + func reopeningUnjoinedGroupRefreshesRules() async throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let mock = MockConversations() + let minimum = { (value: Decimal) in + ConversationRules(listener: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(value)))]) + } + var held = group(9, lastActivity: 50) + held.rules = minimum(5) + mock.feed = [held] + let controller = makeController(mock, database: database) + _ = await controller.hydratedConversation(withID: .test(9)) + + // Raised while the viewer was outside the group, so no stream event carried it here. + held.rules = minimum(50) + mock.feed = [held] + let reopened = try #require(await controller.hydratedConversation(withID: .test(9))) + + #expect(reopened.rules == minimum(50)) + } + // MARK: - Reading from outside @Test("A non-member the listener rules admit reads the transcript without marking it read") diff --git a/FlipcashTests/Chat/EditGroupBalanceRequirementModelTests.swift b/FlipcashTests/Chat/EditGroupBalanceRequirementModelTests.swift index dee99d6ea..b91ad77ad 100644 --- a/FlipcashTests/Chat/EditGroupBalanceRequirementModelTests.swift +++ b/FlipcashTests/Chat/EditGroupBalanceRequirementModelTests.swift @@ -15,7 +15,7 @@ struct EditGroupBalanceRequirementModelTests { @MainActor private final class Calls { - var requirements: [MinimumBalanceRequirement] = [] + var amounts: [FiatAmount?] = [] } private let token = PublicKey.jeffy @@ -23,78 +23,215 @@ struct EditGroupBalanceRequirementModelTests { private let rates: [CurrencyCode: Rate] = [ .usd: Rate(fx: 1, currency: .usd), .cad: Rate(fx: 1.25, currency: .cad), + .jpy: Rate(fx: 150, currency: .jpy), ] private func makeModel( role: GroupBalanceRole = .join, - current: MinimumBalanceRequirement? = nil, + join: FiatAmount = .usd(5), + speaker: FiatAmount? = nil, currency: CurrencyCode = .usd, + satisfies: @escaping (ConversationRules) -> Bool? = { _ in true }, calls: Calls = Calls(), - saving: ((MinimumBalanceRequirement) async throws -> Void)? = nil + saving: ((FiatAmount?) async throws -> Void)? = nil ) -> EditGroupBalanceRequirementModel { - EditGroupBalanceRequirementModel(role: role, current: current, currency: currency, rates: rates) { requirement in - calls.requirements.append(requirement) - try await saving?(requirement) + EditGroupBalanceRequirementModel( + role: role, + join: MinimumBalanceRequirement(amount: join, mints: [token]), + speaker: speaker.map { MinimumBalanceRequirement(amount: $0, mints: [token]) }, + currency: currency, + rates: rates, + satisfies: satisfies + ) { amount in + calls.amounts.append(amount) + try await saving?(amount) } } - @Test("The keypad opens on the current requirement, restated in the entry currency") + // MARK: - Seeding - + + @Test("The keypad opens on the minimum it replaces, restated in the entry currency") func seedsFromCurrent() { - #expect(makeModel(current: .init(amount: .usd(5))).enteredAmount == "5") - #expect(makeModel(current: .init(amount: .usd(5)), currency: .cad).enteredAmount == "6.25") - #expect(makeModel().enteredAmount == "") + #expect(makeModel().enteredAmount == "5") + #expect(makeModel(currency: .cad).enteredAmount == "6.25") + #expect(makeModel(role: .chat, speaker: .usd(8)).enteredAmount == "8") } - @Test("Save is shut while empty, zero, or unchanged") - func canSave() { - let model = makeModel(current: .init(amount: .usd(5))) - #expect(!model.canSave) + @Test("With no chat minimum of its own, the Chat keypad opens empty, not on the join minimum") + func chatSeedsEmptyWithoutSpeaker() { + #expect(makeModel(role: .chat).enteredAmount == "") + } + + // MARK: - Save gating - + + @Test("Save is shut while empty, zero, or below the minimum transfer value") + func canSaveNeedsATransferableAmount() { + let model = makeModel() + for entry in ["", "0", "0.001"] { + model.enteredAmount = entry + #expect(!model.canSave, "\(entry)") + } + model.enteredAmount = "0.01" + #expect(model.canSave) + } + + @Test("An amount that rounds to nothing in the join minimum's currency can't be saved") + func belowOneYen() { + // 0.001 USD is 0.15 JPY, under the yen's minimum transfer value of 1. + let model = makeModel(join: FiatAmount(value: 500, currency: .jpy)) + model.enteredAmount = "0.001" + #expect(model.amount == nil) + model.enteredAmount = "0.01" + #expect(model.amount == FiatAmount(value: 2, currency: .jpy)) + } - model.enteredAmount = "0" + @Test("An entry that rounds to the current minimum is a no-op, so Save stays shut") + func unchangedIsNoOp() { + let model = makeModel(currency: .cad) + model.enteredAmount = "6.25" #expect(!model.canSave) + let chat = makeModel(role: .chat, speaker: .usd(8)) + chat.enteredAmount = "8" + #expect(!chat.canSave) + } + + // MARK: - Currency - + + @Test("The entry is saved in the join minimum's currency, which needn't be USD") + func savesInJoinCurrency() async { + let calls = Calls() + let model = makeModel(join: FiatAmount(value: 500, currency: .jpy), calls: calls) model.enteredAmount = "10" - #expect(model.canSave) + + await model.save() + + #expect(calls.amounts == [FiatAmount(value: 1500, currency: .jpy)]) + #expect(model.state == .saved) } - @Test("The entry is saved in USD and keeps the mint the requirement names") - func savesInUSDKeepingMint() async { + @Test("A chat minimum entered in another currency is saved in the join minimum's") + func chatSavesInJoinCurrency() async { let calls = Calls() - let model = makeModel(current: .init(amount: .usd(5), mints: [token]), currency: .cad, calls: calls) + let model = makeModel(role: .chat, currency: .cad, calls: calls) model.enteredAmount = "12.50" await model.save() - #expect(calls.requirements == [.init(amount: .usd(10), mints: [token])]) + #expect(calls.amounts == [.usd(10)]) + } + + // MARK: - Speaker above listener - + + @Test("A chat minimum at or below the join minimum is blocked before sending, naming the join minimum") + func chatAtOrBelowJoinIsBlocked() async { + let calls = Calls() + let model = makeModel(role: .chat, currency: .cad, calls: calls) + + model.enteredAmount = "6.25" + #expect(model.blocker == .chatNotAboveJoin(join: FiatAmount(value: 6.25, currency: .cad))) + model.enteredAmount = "5" + #expect(model.blocker == .chatNotAboveJoin(join: FiatAmount(value: 6.25, currency: .cad))) + + await model.save() + #expect(calls.amounts.isEmpty) + #expect(model.failure == .chatNotAboveJoin(join: FiatAmount(value: 6.25, currency: .cad))) + } + + @Test("A chat minimum one smallest unit above the join minimum goes through") + func chatOneCentAboveJoin() { + let model = makeModel(role: .chat) + model.enteredAmount = "5.01" + #expect(model.blocker == nil) + } + + @Test("A join minimum at or above the chat minimum is blocked before sending") + func joinAtOrAboveChatIsBlocked() async { + let calls = Calls() + let model = makeModel(speaker: .usd(8), calls: calls) + + model.enteredAmount = "8" + #expect(model.blocker == .invalidRules) + model.enteredAmount = "7.99" + #expect(model.blocker == nil) + + model.enteredAmount = "9" + await model.save() + #expect(calls.amounts.isEmpty) + #expect(model.failure == .invalidRules) + } + + // MARK: - Removing the chat minimum - + + @Test("The chat minimum can be removed, and only when the group sets one") + func removesChatMinimum() async { + #expect(!makeModel(role: .chat).canRemove) + #expect(!makeModel(role: .join, speaker: .usd(8)).canRemove) + + let calls = Calls() + let model = makeModel(role: .chat, speaker: .usd(8), calls: calls) + #expect(model.canRemove) + + await model.remove() + + #expect(calls.amounts == [nil]) #expect(model.state == .saved) } - @Test("A group with no requirement gets one across all holdings") - func newRequirementAppliesToAllHoldings() async { + // MARK: - Creator - + + @Test("A minimum the creator doesn't meet is blocked before sending, speaker rule included") + func creatorMustSatisfy() async { + var checked: [ConversationRules] = [] let calls = Calls() - let model = makeModel(role: .chat, calls: calls) - model.enteredAmount = "3" + let model = makeModel(role: .chat, satisfies: { rules in + checked.append(rules) + return false + }, calls: calls) + model.enteredAmount = "20" await model.save() - #expect(calls.requirements == [.init(amount: .usd(3), mints: [])]) + #expect(calls.amounts.isEmpty) + #expect(model.failure == .rulesNotSatisfied) + let speaker = MinimumBalanceRequirement(amount: .usd(20), mints: [token]) + #expect(checked.last?.speaker == [.minimumBalance(speaker)]) + #expect(checked.last?.listener == [.minimumBalance(MinimumBalanceRequirement(amount: .usd(5), mints: [token]))]) } - @Test("The stub's refusal surfaces as unavailable and leaves Save open") - func stubIsUnavailable() async { - let model = makeModel { _ in throw ErrorSetGroupMinimumBalance.unavailable } - model.enteredAmount = "3" + @Test("When the creator's balance can't be weighed, the server decides") + func unknownBalanceSends() async { + let calls = Calls() + let model = makeModel(satisfies: { _ in nil }, calls: calls) + model.enteredAmount = "20" await model.save() - #expect(model.failure == .unavailable) - #expect(model.state == .normal) - #expect(model.canSave) + #expect(calls.amounts == [.usd(20)]) + } + + // MARK: - Server refusals - + + @Test("The server's rule refusals surface as their own alerts and leave Save open") + func serverRefusals() async { + for (error, failure) in [ + (ErrorEditChat.invalidRules, EditGroupBalanceRequirementModel.Failure.invalidRules), + (ErrorEditChat.rulesNotSatisfied, .rulesNotSatisfied), + ] { + let model = makeModel(saving: { _ in throw error }) + model.enteredAmount = "3" + + await model.save() + + #expect(model.failure == failure) + #expect(model.state == .normal) + #expect(model.canSave) + } } @Test("Any other failure surfaces as failed") func otherFailure() async { - let model = makeModel { _ in throw Boom() } + let model = makeModel(saving: { _ in throw Boom() }) model.enteredAmount = "3" await model.save() diff --git a/FlipcashTests/Chat/EditGroupModelTests.swift b/FlipcashTests/Chat/EditGroupModelTests.swift index 1e444e45f..cef27c1d2 100644 --- a/FlipcashTests/Chat/EditGroupModelTests.swift +++ b/FlipcashTests/Chat/EditGroupModelTests.swift @@ -73,10 +73,11 @@ struct EditGroupModelTests { func setMinimumBalance( conversationID: ConversationID, role: GroupBalanceRole, - requirement: MinimumBalanceRequirement + amount: FiatAmount?, + in rules: ConversationRules ) async throws -> Conversation { Issue.record("The form never edits balance requirements") - throw ErrorSetGroupMinimumBalance.unavailable + throw ErrorEditChat.denied } } diff --git a/FlipcashTests/ConversationGateTests.swift b/FlipcashTests/ConversationGateTests.swift index 301fb08b6..d982264a4 100644 --- a/FlipcashTests/ConversationGateTests.swift +++ b/FlipcashTests/ConversationGateTests.swift @@ -389,15 +389,26 @@ struct ConversationGateTests { #expect(conversationGatePresentation(.open, isMember: false) == .join(nil)) } - @Test("A failing listener gate blocks the chat whether or not the user is a member") - func presentation_listenerFails_isBlockedEitherWay() { + @Test("A failing listener gate blocks a non-member but leaves a member reading, unable to speak") + func presentation_listenerFails_blocksNonMemberOnly() { let gate = ConversationGate( listener: .unsatisfied(unmet: [.staff], primary: .staff), speaker: .unsatisfied(unmet: [.staff], primary: .staff), headline: .staff ) #expect(conversationGatePresentation(gate, isMember: false) == .blocked(.staff)) - #expect(conversationGatePresentation(gate, isMember: true) == .blocked(.staff)) + #expect(conversationGatePresentation(gate, isMember: true) == .readOnly(.staff)) + } + + @Test("A member under a raised join minimum keeps reading, can't speak, and is shown the new minimum") + func presentation_memberUnderRaisedJoin_readsOnly() { + let rules = ConversationRules(listener: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(500)))]) + let gate = conversationGate(session: StubHoldings(), rules: rules, rates: [.usd: Rate(fx: 1, currency: .usd)]) + let presentation = conversationGatePresentation(gate, isMember: true) + + #expect(presentation == .readOnly(.minimumBalance(amount: .usd(500), mint: nil))) + #expect(!presentation.obscuresTranscript) + #expect(!ConversationLoadCoordinator.access(isMember: true, gate: gate).canPost) } @Test("A member who can read but not send gets a read-only chat, not a blurred one") From fcfd71d8348485fc1a07da08c2d5240894124dbb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 09:44:19 -0400 Subject: [PATCH 4/5] fix(chat): show the join minimum on Edit Group's Chat row when none is set --- .claude/plans/2026-10-07-group-balance-requirement-edit.md | 4 ++-- .../Core/Screens/Profile/Edit Group/EditGroupScreen.swift | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.claude/plans/2026-10-07-group-balance-requirement-edit.md b/.claude/plans/2026-10-07-group-balance-requirement-edit.md index ad9ee6191..0fe425406 100644 --- a/.claude/plans/2026-10-07-group-balance-requirement-edit.md +++ b/.claude/plans/2026-10-07-group-balance-requirement-edit.md @@ -25,8 +25,8 @@ Originally built against a stub (flipcash2-client-protocol 0.18.0 had no rules e to its smallest unit, and blocks before sending: Chat ≤ Join ("Chat Minimum Too Low"), Join ≥ Chat (INVALID_RULES copy), and the creator not meeting the rules (RULES_NOT_SATISFIED copy, skipped when the gate is provisional). Unchanged amounts keep Save shut. -- The Chat row reads the group's own speaker minimum ("None" without one). "Remove" in the Chat - editor's toolbar clears it. +- The Chat row shows the Join minimum when the group sets no speaker minimum, matching the group + profile. "Remove" in the Chat editor's toolbar clears a speaker minimum. - Edit Group shows the card only on a public group with a join minimum. - `applyEdit` seats the returned rules. `applyRulesChanged` ignores a set whose listener rule this client can't read (`dropsListenerRule`), keeping the old gate rather than opening the chat. diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift index 369bdfac2..88455fa54 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupScreen.swift @@ -174,8 +174,7 @@ struct EditGroupScreen: View { requirementRow("Join", requirements.join, role: .join) Divider() .overlay(Color.rowSeparator) - // "None" when joining is enough to chat: the group sets no chat minimum of its own. - requirementRow("Chat", requirements.speaker, role: .chat) + requirementRow("Chat", requirements.chat, role: .chat) } .background(Color.backgroundRow, in: RoundedRectangle(cornerRadius: Metrics.boxRadius, style: .continuous)) From b7ba37221e13ff466bc01263c00b9b3c937ecd6d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 29 Oct 2026 10:31:13 -0400 Subject: [PATCH 5/5] feat(chat): show a trash icon for removing the chat minimum --- .claude/plans/2026-10-07-group-balance-requirement-edit.md | 2 +- .../Edit Group/EditGroupBalanceRequirementScreen.swift | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.claude/plans/2026-10-07-group-balance-requirement-edit.md b/.claude/plans/2026-10-07-group-balance-requirement-edit.md index 0fe425406..b26b1c81b 100644 --- a/.claude/plans/2026-10-07-group-balance-requirement-edit.md +++ b/.claude/plans/2026-10-07-group-balance-requirement-edit.md @@ -26,7 +26,7 @@ Originally built against a stub (flipcash2-client-protocol 0.18.0 had no rules e Chat (INVALID_RULES copy), and the creator not meeting the rules (RULES_NOT_SATISFIED copy, skipped when the gate is provisional). Unchanged amounts keep Save shut. - The Chat row shows the Join minimum when the group sets no speaker minimum, matching the group - profile. "Remove" in the Chat editor's toolbar clears a speaker minimum. + profile. The trash button in the Chat editor's toolbar clears a speaker minimum. - Edit Group shows the card only on a public group with a join minimum. - `applyEdit` seats the returned rules. `applyRulesChanged` ignores a set whose listener rule this client can't read (`dropsListenerRule`), keeping the old gate rather than opening the chat. diff --git a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift index dfc7d2589..831bdaff9 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift @@ -72,8 +72,11 @@ struct EditGroupBalanceRequirementScreen: View { .toolbar { if model.canRemove { ToolbarItem(placement: .topBarTrailing) { - Button("Remove", action: proposeRemove) - .disabled(saveTask != nil) + Button(action: proposeRemove) { + Image.system(.trash) + } + .accessibilityLabel("Remove") + .disabled(saveTask != nil) } } }