Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .claude/plans/2026-10-07-group-balance-requirement-edit.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 19 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
}

Expand Down
8 changes: 7 additions & 1 deletion Flipcash/Core/Navigation/AppRouter+Destination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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):
Expand Down
39 changes: 39 additions & 0 deletions Flipcash/Core/Navigation/AppRouter+DestinationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down
12 changes: 7 additions & 5 deletions Flipcash/Core/Screens/Conversation/ConversationGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
14 changes: 11 additions & 3 deletions Flipcash/Core/Screens/Conversation/GroupLinkCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Conversation/LinkCardFeed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"
}
}

Expand All @@ -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"
}
}
}
Expand Down
Loading
Loading