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..b26b1c81b --- /dev/null +++ b/.claude/plans/2026-10-07-group-balance-requirement-edit.md @@ -0,0 +1,42 @@ +# 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 shows the Join minimum when the group sets no speaker minimum, matching the group + 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. + 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 899034cfe..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) { @@ -1298,6 +1313,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+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..ca5da9efe 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,44 @@ 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 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/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/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..2fb3c9b11 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementModel.swift @@ -0,0 +1,183 @@ +// +// 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 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 { + + /// 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 go ahead or didn't land, shown as a dialog. Set to nil once shown. + enum Failure: Equatable { + /// 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 + } + + 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 join: MinimumBalanceRequirement + @ObservationIgnored private let speaker: MinimumBalanceRequirement? + @ObservationIgnored private let rates: [CurrencyCode: Rate] + @ObservationIgnored private let validator = AmountValidator() + @ObservationIgnored private let satisfies: (ConversationRules) -> Bool? + @ObservationIgnored private let saving: (FiatAmount?) async throws -> Void + + /// - Parameters: + /// - role: the minimum being replaced. + /// - 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 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, + join: MinimumBalanceRequirement, + speaker: MinimumBalanceRequirement?, + currency: CurrencyCode, + rates: [CurrencyCode: Rate], + satisfies: @escaping (ConversationRules) -> Bool?, + saving: @escaping (FiatAmount?) async throws -> Void + ) { + self.role = role + 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 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 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 from the one set. + var canSave: Bool { + guard state == .normal, let amount else { return false } + let current = role == .join ? join : speaker + return amount != current?.amount.roundedToSmallestUnit() + } + + /// 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 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(amount) + state = .saved + + } 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") + 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 new file mode 100644 index 000000000..831bdaff9 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/Edit Group/EditGroupBalanceRequirementScreen.swift @@ -0,0 +1,138 @@ +// +// 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) + .toolbar { + if model.canRemove { + ToolbarItem(placement: .topBarTrailing) { + Button(action: proposeRemove) { + Image.system(.trash) + } + .accessibilityLabel("Remove") + .disabled(saveTask != nil) + } + } + } + .dialog(item: $dialog) + .onChange(of: model.failure) { _, failure in + guard let failure else { return } + model.failure = nil + 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 } + 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(_ operation: @escaping () async -> Void) { + guard saveTask == nil else { return } + saveTask = Task { + await operation() + 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..88455fa54 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 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 { @@ -168,10 +171,10 @@ struct EditGroupScreen: View { .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 +185,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..cce8ba965 100644 --- a/Flipcash/Core/Screens/Profile/Edit Group/GroupChatEditing.swift +++ b/Flipcash/Core/Screens/Profile/Edit Group/GroupChatEditing.swift @@ -34,6 +34,39 @@ protocol GroupChatEditing { pictureBlobID: BlobID?, coverPictureBlobID: BlobID? ) async throws -> Conversation + + /// 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, + amount: FiatAmount?, + in rules: ConversationRules + ) 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 +} + +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. @@ -66,4 +99,22 @@ struct SessionGroupChatEditor: GroupChatEditing { coverPictureBlobID: coverPictureBlobID ) } + + func setMinimumBalance( + conversationID: ConversationID, + role: GroupBalanceRole, + amount: FiatAmount?, + in rules: ConversationRules + ) async throws -> Conversation { + 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/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/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. /// 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 new file mode 100644 index 000000000..b91ad77ad --- /dev/null +++ b/FlipcashTests/Chat/EditGroupBalanceRequirementModelTests.swift @@ -0,0 +1,242 @@ +// +// 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 amounts: [FiatAmount?] = [] + } + + private let token = PublicKey.jeffy + + 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, + join: FiatAmount = .usd(5), + speaker: FiatAmount? = nil, + currency: CurrencyCode = .usd, + satisfies: @escaping (ConversationRules) -> Bool? = { _ in true }, + calls: Calls = Calls(), + saving: ((FiatAmount?) async throws -> Void)? = nil + ) -> EditGroupBalanceRequirementModel { + 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) + } + } + + // MARK: - Seeding - + + @Test("The keypad opens on the minimum it replaces, restated in the entry currency") + func seedsFromCurrent() { + #expect(makeModel().enteredAmount == "5") + #expect(makeModel(currency: .cad).enteredAmount == "6.25") + #expect(makeModel(role: .chat, speaker: .usd(8)).enteredAmount == "8") + } + + @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)) + } + + @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" + + await model.save() + + #expect(calls.amounts == [FiatAmount(value: 1500, currency: .jpy)]) + #expect(model.state == .saved) + } + + @Test("A chat minimum entered in another currency is saved in the join minimum's") + func chatSavesInJoinCurrency() async { + let calls = Calls() + let model = makeModel(role: .chat, currency: .cad, calls: calls) + model.enteredAmount = "12.50" + + await model.save() + + #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) + } + + // 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, satisfies: { rules in + checked.append(rules) + return false + }, calls: calls) + model.enteredAmount = "20" + + await model.save() + + #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("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(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(saving: { _ 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..cef27c1d2 100644 --- a/FlipcashTests/Chat/EditGroupModelTests.swift +++ b/FlipcashTests/Chat/EditGroupModelTests.swift @@ -69,6 +69,16 @@ struct EditGroupModelTests { title: title ) } + + func setMinimumBalance( + conversationID: ConversationID, + role: GroupBalanceRole, + amount: FiatAmount?, + in rules: ConversationRules + ) async throws -> Conversation { + Issue.record("The form never edits balance requirements") + throw ErrorEditChat.denied + } } private let conversationID = ConversationID.test(7) 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")