From 86f151addaa3367b3454eda6864779939b733928 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 13:59:59 -0400 Subject: [PATCH 1/2] chore(deps): bump flipcash2-client-protocol to 0.14.1 --- FlipcashAPI/Package.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index e185ac533..eaebb22f0 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable { var version: Version { switch self { case .ocp: return "0.6.0" - case .flipcash2: return "0.14.0" + case .flipcash2: return "0.14.1" } } From f61deb6f0d42fe71ad4487fb3d5777d58a421aac Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 14:21:32 -0400 Subject: [PATCH 2/2] feat(chat): gate posting on creator-only and unsupported speaker rules Add ConversationSpeakerRule.creator (chat.v1 SpeakerRules.creator = 4): only Metadata.creator may send. Staff get no bypass. The gate now takes the chat's creator and compares it with the viewer's user id; an unknown creator fails closed. A speaker rule with an unset or unrecognized kind now maps to .unsupported instead of being dropped (which opened the composer). It blocks posting for everyone and the panel asks the user to update. It has no wire form. A viewer who fails a speaker rule loses Reply; copy and report stay. Reactions are blocked only by a reaction-blocking rule (never, minimum balance, staff) or an unmet listener rule, so creator-only and unsupported leave them on; this replaces the all-or-nothing canSpeak flag from the speaker-rule UI. Bump Database.schemaVersion to 45: rule JSON gains cases, and rows stored by an earlier build dropped creator and unreadable rules, which read as an open composer. --- .../Conversation/ChatItem+Conversation.swift | 7 +- .../Conversation/ConversationGate.swift | 37 +++++- .../Conversation/ConversationGatePanel.swift | 52 +++++--- .../ConversationLoadCoordinator.swift | 29 +++-- .../Conversation/ConversationScreen.swift | 10 +- .../Screens/Conversation/GroupLinkCard.swift | 2 +- .../Core/Session/SessionAuthenticator.swift | 1 + .../Conversation/ConversationRules.swift | 33 ++++-- .../Conversation/MessageCapability.swift | 14 +-- .../Sources/FlipcashStore/Database.swift | 2 +- .../ConversationModelMappingTests.swift | 23 ++++ .../MessageCapabilityTests.swift | 30 +++-- .../Chat/GroupPushRoutingTests.swift | 1 + .../Chat/NeverComposerRenderTests.swift | 25 ++-- .../Chat/NewPublicGroupModelTests.swift | 1 + .../Chat/ShareProfileWidgetGatingTests.swift | 66 +++++++---- FlipcashTests/ConversationGateTests.swift | 112 +++++++++++++++++- 17 files changed, 344 insertions(+), 101 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index b4fe4a64c..ea9dbe9a2 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -80,11 +80,8 @@ extension ChatItem { author: (ConversationMessage) -> ChatAuthor? = { _ in nil }, namesAuthors: Bool = false, /// Whether the viewer may add or remove a reaction — false for someone previewing a group - /// they have not joined (see `ChatMessage.canReact`). + /// they have not joined, or who fails a reaction-blocking speaker rule (see `ChatMessage.canReact`). canReact: Bool = true, - /// Whether the chat's speaker rule lets the viewer speak. Reactions follow it in addition to - /// `canReact`; a DM always passes. - canSpeak: Bool = true, /// The card, if any, for a message's detected links. Injected like the other collaborators /// so the mapper stays pure; the screen supplies classification and nothing else, and it /// defaults to no card. Takes the detected links rather than the text so the detector runs @@ -355,7 +352,7 @@ extension ChatItem { // last row, with its receipt. reactions: isLast ? message.reactionState?.pills ?? [] : [], selfReactions: message.reactionState?.selfReactions ?? [], - canReact: canReact && canSpeak, + canReact: canReact, isUnsent: isUnsent ))) } diff --git a/Flipcash/Core/Screens/Conversation/ConversationGate.swift b/Flipcash/Core/Screens/Conversation/ConversationGate.swift index 511fd3958..063f84c41 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGate.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGate.swift @@ -14,6 +14,8 @@ import FlipcashStore @MainActor protocol ConversationGateReading: AnyObject { var isStaff: Bool { get } + /// The signed-in user, compared against a chat's creator by ``ConversationSpeakerRule/creator``. + var userID: UserID { get } var totalBalance: ExchangedFiat { get } func balance(for mint: PublicKey) -> StoredBalance? } @@ -30,6 +32,11 @@ enum ConversationGateRequirement: Equatable { case staff /// Nobody may send here. There is no action a user can take, so no CTA. case never + /// Only the chat's creator may send, and the viewer isn't it. There is no action a user can + /// take, so no CTA. + case creator + /// A speaker rule this build can't read. Blocks for everyone; the fix is updating the app. + case unsupported } /// Whether one class of rules is met, and if not, which of them aren't. @@ -72,6 +79,13 @@ struct ConversationGate: Equatable { /// it in USD. The verdict is then a guess, and a non-member's transcript waits for the real one. var isProvisional = false + /// Whether reactions are allowed, as far as the rules go: false while a rule that blocks them + /// (``ConversationSpeakerRule/never``, an unmet minimum balance, an unmet staff requirement, or + /// any unmet listener rule) is unmet. ``ConversationSpeakerRule/creator`` and + /// ``ConversationSpeakerRule/unsupported`` withhold posting only, so on their own they leave + /// this true. Membership is separate; see ``ConversationLoadCoordinator/access(isMember:gate:)``. + var allowsReactions = true + /// No rules to satisfy — every DM, and a group that doesn't gate anything. static let open = ConversationGate(listener: .satisfied, speaker: .satisfied, headline: nil) @@ -83,6 +97,10 @@ struct ConversationGate: Equatable { /// Evaluates a group chat's participation rules against what the user holds. /// +/// `creator` is the chat's `Metadata.creator`; a ``ConversationSpeakerRule/creator`` rule is unmet +/// unless it equals the viewer, so an unknown creator fails closed. Staff get no bypass: only an +/// explicit `.staff` rule looks at staff standing. +/// /// `rules` is nil for every non-group chat and for a group whose metadata hasn't /// hydrated yet; both are open, as is an empty rule list — the contract /// documents empty as "anyone can". `rates` is `RatesController.cachedRates`, @@ -115,6 +133,7 @@ struct ConversationGate: Equatable { func conversationGate( session: some ConversationGateReading, rules: ConversationRules?, + creator: UserID? = nil, rates: [CurrencyCode: Rate] ) -> ConversationGate { guard let rules else { return .open } @@ -134,11 +153,24 @@ func conversationGate( return session.isStaff ? nil : .staff case .never: return .never + case .creator: + return creator == session.userID ? nil : .creator + case .unsupported: + return .unsupported case .minimumBalance(let requirement): return unmetBalance(requirement, session: session, rates: rates) } } + // Creator and unsupported withhold posting, not reactions. Everything else the speaker list + // can fail (never, balance, staff) blocks both, and so does an unmet listener rule. + let blocksReactions = !listenerUnmet.isEmpty || speakerUnmet.contains { requirement in + switch requirement { + case .creator, .unsupported: return false + case .minimumBalance, .staff, .never: return true + } + } + let listener = verdict(for: listenerUnmet) // Speaking implies listening, so an unmet listener rule fails the speaker // verdict as well, carrying both sets. @@ -155,7 +187,8 @@ func conversationGate( listener: listener, speaker: speaker, headline: headline(for: rules.listener), - isProvisional: isProvisional + isProvisional: isProvisional, + allowsReactions: !blocksReactions ) } @@ -330,7 +363,7 @@ func groupRequirementLine(_ headline: ConversationGateRequirement?, mintName: St return "Balance Requirement:\n\(holding)" case .staff: return "This chat is for Flipcash staff" - case .never: + case .never, .creator, .unsupported: return nil case nil: return nil diff --git a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift index ba8cdba91..fa54c5bcc 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift @@ -37,40 +37,41 @@ struct ConversationGatePanel: View { let isJoining: Bool var body: some View { - if case .readOnly(.never) = presentation { - neverPill + if case .readOnly(let requirement) = presentation, requirement.isStaticReadOnly, let sentence { + readOnlyPill(sentence) } else { card } } - /// Nobody but Flipcash may send here (node 10588:1969): the composer's place is taken by a - /// disabled, full-width rounded rectangle of glass with one muted line — no field, no controls, no action. - /// The brand is named, not the chat, because this rule is what the welcome chat runs on. - private var neverPill: some View { - Text(Self.neverSentence) + /// A rule the viewer can do nothing about (`never`, `creator`, `unsupported`; node 10588:1969): the + /// composer's place is taken by a disabled, full-width rounded rectangle of glass with one muted + /// line — no field, no controls, no action. For `never` the brand is named, not the chat, because + /// that rule is what the welcome chat runs on. + private func readOnlyPill(_ sentence: String) -> some View { + Text(sentence) .font(.appTextMedium) - .foregroundStyle(Color.textMain.opacity(Layout.neverTextOpacity)) + .foregroundStyle(Color.textMain.opacity(Layout.pillTextOpacity)) .lineLimit(1) .minimumScaleFactor(0.8) .frame(maxWidth: .infinity) .frame(height: BarMetrics.contentHeight) .padding(.horizontal, BarMetrics.edgeInset) - .modifier(NeverPillGlass()) + .modifier(ReadOnlyPillGlass()) .padding(.horizontal, BarMetrics.compactInset) .padding(.vertical, BarMetrics.contentPadding) .accessibilityElement(children: .ignore) - .accessibilityLabel(Text(Self.neverSentence)) + .accessibilityLabel(Text(sentence)) .accessibilityAddTraits(.isStaticText) } /// The pill's surface: non-interactive glass at `Metrics.boxRadius`, darkened to the design's fill /// (node 10588:1969 draws 46% of a near-black; `backgroundMain` is the nearest token). Below iOS 26 /// the same fill lies over the standard ultra-thin material. - private struct NeverPillGlass: ViewModifier { + private struct ReadOnlyPillGlass: ViewModifier { func body(content: Content) -> some View { let shape = RoundedRectangle(cornerRadius: Metrics.boxRadius) - let fill = Color.backgroundMain.opacity(Layout.neverFillOpacity) + let fill = Color.backgroundMain.opacity(Layout.pillFillOpacity) if #available(iOS 26, *) { content.glassEffect(.regular.tint(fill), in: shape) } else { @@ -82,6 +83,8 @@ struct ConversationGatePanel: View { } static let neverSentence = "Only Flipcash can send messages" + static let creatorSentence = "Only the creator can send messages" + static let unsupportedSentence = "Update Flipcash to send messages" private var card: some View { VStack(spacing: Layout.gap) { @@ -134,8 +137,13 @@ struct ConversationGatePanel: View { case .open, .undetermined, .join, .blocked: return "This chat is for Flipcash staff" } case .never: - // Drawn as the composer-shaped pill instead of this card; see ``neverPill``. + // Drawn as the composer-shaped pill instead of this card; see ``readOnlyPill(_:)``. return Self.neverSentence + case .creator: + // Drawn as the pill too. + return Self.creatorSentence + case .unsupported: + return Self.unsupportedSentence } } @@ -171,8 +179,8 @@ struct ConversationGatePanel: View { case .minimumBalance(_, let mint): Button(addFundsTitle(mint: mint), action: onAddFunds) .buttonStyle(.filled) - case .staff, .never: - // Nothing the user can do about being staff or about a chat nobody may post in, + case .staff, .never, .creator, .unsupported: + // Nothing the user can do about being staff, about a chat nobody may post in, or about not being its creator, // so a button here would be a lie. EmptyView() } @@ -197,7 +205,17 @@ struct ConversationGatePanel: View { static let cardPadding: CGFloat = 6 static let gap: CGFloat = 12 /// The pill's line, muted as the design draws it (node 10588:1969). - static let neverTextOpacity: Double = 0.4 - static let neverFillOpacity: Double = 0.46 + static let pillTextOpacity: Double = 0.4 + static let pillFillOpacity: Double = 0.46 + } +} + +private extension ConversationGateRequirement { + /// Whether the read-only state is drawn as the composer-shaped pill: the ones with no action. + var isStaticReadOnly: Bool { + switch self { + case .never, .creator, .unsupported: true + case .minimumBalance, .staff: false + } } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift index ab0f9a0ab..16d7a438b 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationLoadCoordinator.swift @@ -47,9 +47,6 @@ final class ConversationLoadCoordinator { /// scope, so whatever it reads (the contact directory, the conversation) re-triggers mapping. /// Nil for a chat with no counterpart to card. private let profileCard: @MainActor () -> ChatProfileCard? - /// Whether the chat's speaker rule lets the viewer speak, evaluated inside the observation scope - /// so a balance or rule change re-maps. Gates Reply and reactions on a widget. - private let canSpeak: @MainActor () -> Bool /// Names senders the chat's own roster leaves out, from what the device already knows about /// them. Observed like every other input, so a reload re-attributes the window in place. private let knownAuthors: KnownAuthorDirectory @@ -90,10 +87,8 @@ final class ConversationLoadCoordinator { controller: ConversationController, session: Session, knownAuthors: KnownAuthorDirectory, - profileCard: @escaping @MainActor () -> ChatProfileCard?, - canSpeak: @escaping @MainActor () -> Bool = { true } + profileCard: @escaping @MainActor () -> ChatProfileCard? ) { - self.canSpeak = canSpeak self.conversationID = conversationID self.controller = controller self.session = session @@ -214,6 +209,14 @@ final class ConversationLoadCoordinator { } } + /// What a viewer may do in a chat. Posting (the composer and Reply) needs membership and every + /// speaker rule met. Reacting needs membership and no unmet reaction-blocking rule + /// (``ConversationGate/allowsReactions``): a creator-only or unsupported rule withholds posting + /// but not reactions, and neither does it affect copy or report. + static func access(isMember: Bool, gate: ConversationGate) -> (canReact: Bool, canPost: Bool) { + (canReact: isMember && gate.allowsReactions, canPost: isMember && gate.speaker.isSatisfied) + } + private func currentInputs() -> Inputs { let conversation = controller.conversation(withID: conversationID) // A group has no single counterpart to have read anything — `counterpartReadReceipt` picks @@ -229,6 +232,9 @@ final class ConversationLoadCoordinator { branding[fiat.mint] = .init(token: balance.name, iconURL: balance.imageURL) } } + // Read inside the observation scope, so a balance or rule change re-maps. A chat whose + // record hasn't landed yet has no gate to read, so it keeps the member menu. + let access = conversation.map { Self.access(isMember: controller.isMember(of: $0), gate: controller.gateConversation($0)) } let counterpartName = conversation?.counterpart(excluding: controller.selfUserID)?.displayName ?? "" // The head card belongs only above a short transcript — a long or paged history drops it, // and the nav title opens the same place it would. @@ -265,14 +271,14 @@ final class ConversationLoadCoordinator { conversation: conversation, // A chat whose record hasn't landed yet has no gate to read, so it keeps the member menu. isMember: conversation.map(controller.isMember(of:)) ?? true, + canPost: access?.canPost ?? true, counterpartName: counterpartName, quotedMessages: quotedMessages, // Read live, so the windows take effect on the same re-map that lands the flags fetch. policy: MessagePolicy(userFlags: session.userFlags), now: capabilityClock, namesAuthors: namesAuthors, - canReact: conversation.map { controller.isMember(of: $0) } ?? true, - canSpeak: canSpeak(), + canReact: access?.canReact ?? true, // Only a transcript that attributes its rows has anything to resolve, so a DM never // takes a dependency on the directory and never re-maps when it reloads. knownAuthors: namesAuthors ? knownAuthors.snapshot : .empty, @@ -322,7 +328,7 @@ final class ConversationLoadCoordinator { in: inputs.conversation, as: inputs.selfUserID, isMember: inputs.isMember, - canSpeak: inputs.canSpeak, + canPost: inputs.canPost, policy: inputs.policy, now: inputs.now ) @@ -334,7 +340,6 @@ final class ConversationLoadCoordinator { author: { message in message.senderID.flatMap { authors[$0] } }, namesAuthors: inputs.namesAuthors, canReact: inputs.canReact, - canSpeak: inputs.canSpeak, // Classification is pure and host-gated, and that is all mapping does with a link: the // card is the link's identity, and the card view looks it up for itself. So nothing // here touches the network, and an answer landing cannot re-diff this window. @@ -425,6 +430,8 @@ final class ConversationLoadCoordinator { var conversation: Conversation? /// False for a non-member reading a group they have not joined, who is offered no Reply. var isMember: Bool + /// False when the viewer fails a speaker rule, who is offered no Reply (reactions stay). + var canPost: Bool /// The counterpart's display name, for a quote whose original they wrote. var counterpartName: String /// Every message quoted by a reply in the window, pre-resolved so `map` stays pure. Keyed @@ -439,8 +446,6 @@ final class ConversationLoadCoordinator { /// Whether the viewer may add or remove a reaction — false for someone previewing a group /// they have not joined. True for a DM, where there is no conversation record to preview. var canReact: Bool - /// The chat's speaker rule as the viewer stands against it; see ``ConversationLoadCoordinator/canSpeak``. - var canSpeak: Bool /// Identities for senders the chat's own roster leaves out. Compared by identity — see /// ``KnownAuthorDirectory/Snapshot``. var knownAuthors: KnownAuthorDirectory.Snapshot diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 8e87d339c..6246c4c5d 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -305,6 +305,7 @@ struct ConversationScreen: View { return conversationGate( session: session, rules: conversation.rules, + creator: conversation.creator, rates: ratesController.cachedRates ) } @@ -448,7 +449,7 @@ struct ConversationScreen: View { private static func mint(of requirement: ConversationGateRequirement) -> PublicKey? { switch requirement { case .minimumBalance(_, let mint): mint - case .staff, .never: nil + case .staff, .never, .creator, .unsupported: nil } } @@ -1415,13 +1416,6 @@ struct ConversationScreen: View { // must pick up the conversation the first tip creates. fallbackCounterpart: Self.cachedCounterpart(counterpartUserID, session: session) ) - }, - // The chat's speaker rule, the same `conversationGate` verdict that decides whether - // the composer is live, so a widget's Reply and reactions open and close with it. - canSpeak: { [session, ratesController, conversationController] in - guard let chat = conversationController.conversation(withID: id), chat.type == .group else { return true } - guard conversationController.isMember(of: chat) else { return false } - return conversationGate(session: session, rules: chat.rules, rates: ratesController.cachedRates).isOpen } ) } diff --git a/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift b/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift index 4d8345685..67bff3d24 100644 --- a/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift +++ b/Flipcash/Core/Screens/Conversation/GroupLinkCard.swift @@ -91,7 +91,7 @@ nonisolated extension ConversationGateRequirement { var mint: PublicKey? { switch self { case .minimumBalance(_, let mint): mint - case .staff, .never: nil + case .staff, .never, .creator, .unsupported: nil } } } diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index 2c72c929d..7ef123ac0 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -681,6 +681,7 @@ final class SessionContainer { return conversationGate( session: session, rules: conversation.rules, + creator: conversation.creator, rates: ratesController?.cachedRates ?? [:] ) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index da709fcff..4ce76ce37 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -170,7 +170,7 @@ extension ConversationRules { public init(_ proto: Flipcash_Chat_V1_Rules) { self.init( listener: proto.listener.compactMap(ConversationListenerRule.init), - speaker: proto.speaker.compactMap(ConversationSpeakerRule.init) + speaker: proto.speaker.map(ConversationSpeakerRule.init) ) } } @@ -229,28 +229,43 @@ public enum ConversationSpeakerRule: Hashable, Codable, Sendable { case staff /// Nobody can send messages; the chat is read-only for everyone. case never + /// Only the chat's creator can send messages; see `chat.v1.CreatorRequirement`. + case creator + /// A rule this client can't read: the `kind` oneof is unset, or names a case from a newer + /// contract. Kept rather than dropped because a dropped rule reads as "no rule" and opens the + /// composer; it is unsatisfied for everyone, staff included. Never sent to the server. + case unsupported } extension ConversationSpeakerRule { - /// Returns nil when `proto` carries neither arm of the `kind` oneof, or a - /// `minimumBalance` requirement in a currency this client doesn't - /// recognize. - init?(_ proto: Flipcash_Chat_V1_SpeakerRules) { + /// Never fails: an unset `kind` (what a case added by a newer contract decodes to) and a + /// `minimumBalance` in a currency this client doesn't recognize both become ``unsupported``, + /// which keeps the composer closed instead of reading as "no rule". + init(_ proto: Flipcash_Chat_V1_SpeakerRules) { switch proto.kind { case .minimumBalance(let requirement): - guard let requirement = MinimumBalanceRequirement(requirement) else { return nil } + guard let requirement = MinimumBalanceRequirement(requirement) else { + self = .unsupported + return + } self = .minimumBalance(requirement) case .staff: self = .staff case .never: self = .never + case .creator: + self = .creator case nil: - return nil + self = .unsupported } } } extension ConversationSpeakerRule { + /// Encoding ``unsupported`` is a programmer error: it only ever comes from decoding, and there + /// is no wire form for a rule this client can't name. The outbound path (`startChat`) builds + /// rules from the picker, which can't produce one, so this traps in debug and, in release, yields + /// an empty `SpeakerRules` rather than a wrong rule. var proto: Flipcash_Chat_V1_SpeakerRules { .with { switch self { @@ -260,6 +275,10 @@ extension ConversationSpeakerRule { $0.staff = .init() case .never: $0.never = .init() + case .creator: + $0.creator = .init() + case .unsupported: + assertionFailure("ConversationSpeakerRule.unsupported has no wire form") } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 7c60d77b1..65116c33c 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -66,10 +66,9 @@ extension MessageCapability { /// `isMember` is false for a non-member reading a group they have not joined. They get no Reply: /// the Join panel stands where the composer would, so there is nothing to reply with. /// - /// `canSpeak` is the chat's speaker rule as the viewer stands against it (`ConversationGate`'s - /// speaker verdict, with membership). It withholds Reply from every message, matching Android: - /// Copy, Report and the sender's own Edit/Delete are unaffected. A `never` rule, an unmet balance or an unmet staff requirement all arrive here - /// as false. + /// `canPost` is false when the viewer fails a speaker rule (with membership). Reply goes with the + /// composer, since a reply is a post, matching Android. Copy, Report and the sender's own + /// Edit/Delete are not posting and stay. Reactions are not decided here. /// /// `now` is a parameter rather than `Date.now` so the result stays a function of its inputs. public static func resolve( @@ -77,12 +76,12 @@ extension MessageCapability { in conversation: Conversation?, as selfUserID: UserID, isMember: Bool, - canSpeak: Bool = true, + canPost: Bool = true, policy: MessagePolicy, now: Date ) -> Set { - var capabilities = memberCapabilities(for: message, as: selfUserID, canSpeak: canSpeak, policy: policy, now: now) - if !isMember || !canSpeak { + var capabilities = memberCapabilities(for: message, as: selfUserID, policy: policy, now: now) + if !isMember || !canPost { capabilities.remove(.reply) } return capabilities @@ -91,7 +90,6 @@ extension MessageCapability { private static func memberCapabilities( for message: ConversationMessage, as selfUserID: UserID, - canSpeak: Bool, policy: MessagePolicy, now: Date ) -> Set { diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 7c0ae939f..7d10fb5cb 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -197,7 +197,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 = 44 + public static let schemaVersion = 45 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 36a619624..9d206b8b5 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -713,6 +713,29 @@ struct ConversationMessageContentEncodingTests { #expect(ConversationSpeakerRule(proto) == .never) } + @Test("The creator speaker rule round-trips through the proto") + func creatorSpeakerRuleRoundTrips() { + let proto = ConversationSpeakerRule.creator.proto + #expect(proto.creator == .init()) + #expect(ConversationSpeakerRule(proto) == .creator) + } + + @Test("An unset speaker rule maps to unsupported instead of being dropped") + func unsetSpeakerRuleMapsToUnsupported() { + #expect(ConversationSpeakerRule(Flipcash_Chat_V1_SpeakerRules()) == .unsupported) + let rules = ConversationRules(Flipcash_Chat_V1_Rules.with { + $0.speaker = [.init(), .with { $0.creator = .init() }] + }) + #expect(rules.speaker == [.unsupported, .creator]) + } + + @Test("Unsupported speaker rules survive the store's JSON encoding") + func unsupportedSpeakerRuleRoundTripsThroughJSON() throws { + let rules = ConversationRules(speaker: [.unsupported, .creator]) + let data = try JSONEncoder().encode(rules) + #expect(try JSONDecoder().decode(ConversationRules.self, from: data) == rules) + } + @Test("A share-profile widget maps to its domain model") func shareProfileWidgetMaps() throws { let proto = Flipcash_Messaging_V1_WidgetContent.with { diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index f43d1aab8..c6e8b595d 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -24,8 +24,14 @@ struct MessageCapabilityTests { ) } - private func resolve(_ message: ConversationMessage, isMember: Bool = true, canSpeak: Bool = true, policy: MessagePolicy = .default) -> Set { - MessageCapability.resolve(for: message, in: nil, as: me, isMember: isMember, canSpeak: canSpeak, policy: policy, now: now) + private func resolve(_ message: ConversationMessage, isMember: Bool = true, canPost: Bool = true, policy: MessagePolicy = .default) -> Set { + MessageCapability.resolve(for: message, in: nil, as: me, isMember: isMember, canPost: canPost, policy: policy, now: now) + } + + @Test("A viewer who can't post loses Reply but keeps copy and report") + func cannotPost_losesReplyOnly() { + #expect(resolve(text("hi", from: them), canPost: false) == [.copy, .report]) + #expect(resolve(text("hi", from: them)) == [.copy, .reply, .report]) } @Test("My own confirmed text can be copied, edited, and deleted") @@ -100,26 +106,26 @@ struct MessageCapabilityTests { id: MessageID(value: 4), senderID: nil, content: widget, date: now, unreadSeq: 1, eventSequence: 2 ) - #expect(resolve(message, canSpeak: true) == [.reply]) - #expect(resolve(message, canSpeak: false) == []) + #expect(resolve(message, canPost: true) == [.reply]) + #expect(resolve(message, canPost: false) == []) // A non-member has no composer either, whatever the speaker rule says. - #expect(resolve(message, isMember: false, canSpeak: true) == []) + #expect(resolve(message, isMember: false, canPost: true) == []) } @Test("The speaker rule removes Reply from text and cash, and nothing else") func speakerRuleGatesReplyOnEveryMessage() { // Can speak: the usual menus. - #expect(resolve(text("hi", from: them), canSpeak: true) == [.copy, .reply, .report]) - #expect(resolve(cash(from: them), canSpeak: true) == [.reply, .report]) + #expect(resolve(text("hi", from: them), canPost: true) == [.copy, .reply, .report]) + #expect(resolve(cash(from: them), canPost: true) == [.reply, .report]) // Cannot speak: Reply goes; Copy and Report stay. - #expect(resolve(text("hi", from: them), canSpeak: false) == [.copy, .report]) - #expect(resolve(cash(from: them), canSpeak: false) == [.report]) + #expect(resolve(text("hi", from: them), canPost: false) == [.copy, .report]) + #expect(resolve(cash(from: them), canPost: false) == [.report]) // Own messages keep Copy, Edit and Delete. - #expect(resolve(text("hi", from: me), canSpeak: false) == [.copy, .edit, .delete]) - #expect(resolve(cash(from: me), canSpeak: false) == []) + #expect(resolve(text("hi", from: me), canPost: false) == [.copy, .edit, .delete]) + #expect(resolve(cash(from: me), canPost: false) == []) } - @Test("A DM is never gated: canSpeak defaults to true") + @Test("A DM is never gated: canPost defaults to true") func dmIsUngated() { #expect(resolve(text("hi", from: them)) == [.copy, .reply, .report]) #expect(resolve(cash(from: them)) == [.reply, .report]) diff --git a/FlipcashTests/Chat/GroupPushRoutingTests.swift b/FlipcashTests/Chat/GroupPushRoutingTests.swift index 20ce88704..70267ff6a 100644 --- a/FlipcashTests/Chat/GroupPushRoutingTests.swift +++ b/FlipcashTests/Chat/GroupPushRoutingTests.swift @@ -42,6 +42,7 @@ struct GroupPushRoutingTests { /// Stands in for `Session` on the gate's read side. private final class StubHoldings: ConversationGateReading { var isStaff = false + var userID = UUID() var totalBalance = ExchangedFiat(nativeAmount: .usd(0), rate: Rate(fx: 1, currency: .usd)) private let balances: [PublicKey: StoredBalance] diff --git a/FlipcashTests/Chat/NeverComposerRenderTests.swift b/FlipcashTests/Chat/NeverComposerRenderTests.swift index 0d9372b93..ceddc45e3 100644 --- a/FlipcashTests/Chat/NeverComposerRenderTests.swift +++ b/FlipcashTests/Chat/NeverComposerRenderTests.swift @@ -18,9 +18,9 @@ import FlipcashCore @Suite("Never-speaker composer rendering") struct NeverComposerRenderTests { - private func render(width: CGFloat = 402) throws -> (image: UIImage, pill: CGSize) { + private func render(_ requirement: ConversationGateRequirement = .never, width: CGFloat = 402) throws -> (image: UIImage, pill: CGSize) { let panel = ConversationGatePanel( - presentation: .readOnly(.never), mintName: nil, onAddFunds: {}, onJoin: {}, isJoining: false + presentation: .readOnly(requirement), mintName: nil, onAddFunds: {}, onJoin: {}, isJoining: false ) let host = UIHostingController(rootView: ZStack(alignment: .bottom) { Color.backgroundMain @@ -61,14 +61,25 @@ struct NeverComposerRenderTests { #expect(ConversationGatePanel.neverSentence == "Only Flipcash can send messages") } - @Test("The pill renders at the composer's height") - func renders() throws { - let rendered = try render() + @Test("The creator and unsupported rules draw their own copy") + func otherSentences() { + #expect(ConversationGatePanel.creatorSentence == "Only the creator can send messages") + #expect(ConversationGatePanel.unsupportedSentence == "Update Flipcash to send messages") + } + + @Test("Each static read-only rule renders a pill at the composer's height", arguments: [ + ("never", ConversationGateRequirement.never), + ("creator", .creator), + ("unsupported", .unsupported), + ]) + func renders(name: String, requirement: ConversationGateRequirement) throws { + let rendered = try render(requirement) #expect(rendered.pill.height == 50) #expect(rendered.image.size.width == 402) - if let path = ProcessInfo.processInfo.environment["NEVER_COMPOSER_PNG"], + // Set `TEST_RUNNER_READ_ONLY_PILL_DIR` to keep the images. + if let dir = ProcessInfo.processInfo.environment["READ_ONLY_PILL_DIR"], let data = rendered.image.pngData() { - try data.write(to: URL(fileURLWithPath: path)) + try data.write(to: URL(fileURLWithPath: dir).appendingPathComponent("ios-\(name)-pill.png")) } } } diff --git a/FlipcashTests/Chat/NewPublicGroupModelTests.swift b/FlipcashTests/Chat/NewPublicGroupModelTests.swift index bc3c66853..1f480791c 100644 --- a/FlipcashTests/Chat/NewPublicGroupModelTests.swift +++ b/FlipcashTests/Chat/NewPublicGroupModelTests.swift @@ -69,6 +69,7 @@ struct NewPublicGroupModelTests { /// counted only in the total. private final class StubHoldings: ConversationGateReading { var isStaff = false + var userID = UUID() var totalBalance: ExchangedFiat var holding: StoredBalance? diff --git a/FlipcashTests/Chat/ShareProfileWidgetGatingTests.swift b/FlipcashTests/Chat/ShareProfileWidgetGatingTests.swift index 8c972cdd0..190c60df7 100644 --- a/FlipcashTests/Chat/ShareProfileWidgetGatingTests.swift +++ b/FlipcashTests/Chat/ShareProfileWidgetGatingTests.swift @@ -20,8 +20,9 @@ struct ShareProfileWidgetGatingTests { private final class Holdings: ConversationGateReading { var isStaff: Bool + var userID: UserID var totalBalance = ExchangedFiat(nativeAmount: .usd(0), rate: Rate(fx: 1, currency: .usd)) - init(isStaff: Bool = false) { self.isStaff = isStaff } + init(isStaff: Bool = false, userID: UserID = UUID()) { self.isStaff = isStaff; self.userID = userID } func balance(for mint: PublicKey) -> StoredBalance? { nil } } @@ -36,20 +37,19 @@ struct ShareProfileWidgetGatingTests { ) } - /// What the screen hands the coordinator: whether the viewer, a member, may speak under `rules`. - private func canSpeak(_ rules: ConversationRules, holdings: Holdings = Holdings()) -> Bool { - conversationGate(session: holdings, rules: rules, rates: [:]).isOpen - } - - private func offered(canSpeak: Bool) throws -> (actions: Set, canReact: Bool) { + /// What the coordinator hands the mapper for a member under `rules`: the gate's verdict through + /// `access(isMember:gate:)`, with the widget's actions and reaction flag resolved from it. + private func offered(_ rules: ConversationRules, creator: UserID? = nil, holdings: Holdings = Holdings()) throws -> (actions: Set, canReact: Bool) { + let gate = conversationGate(session: holdings, rules: rules, creator: creator, rates: [:]) + let access = ConversationLoadCoordinator.access(isMember: true, gate: gate) let message = try widget() let items = ChatItem.from( [message], selfUserID: me, capabilities: { - MessageCapability.resolve(for: $0, in: nil, as: me, isMember: true, canSpeak: canSpeak, policy: .default, now: now) + MessageCapability.resolve(for: $0, in: nil, as: me, isMember: true, canPost: access.canPost, policy: .default, now: now) }, - canSpeak: canSpeak + canReact: access.canReact ) for item in items { if case .message(let row) = item { return (Set(row.actions), row.canReact) } @@ -60,17 +60,14 @@ struct ShareProfileWidgetGatingTests { @Test("An open chat offers Reply and reactions") func openChat() throws { - #expect(canSpeak(ConversationRules())) - let result = try offered(canSpeak: true) + let result = try offered(ConversationRules()) #expect(result.actions == [.reply]) #expect(result.canReact) } - @Test("A never speaker rule offers neither Reply nor reactions") + @Test("A never speaker rule offers neither Reply nor reactions, even to staff") func neverRule() throws { - let rules = ConversationRules(speaker: [.never]) - #expect(!canSpeak(rules, holdings: Holdings(isStaff: true))) - let result = try offered(canSpeak: canSpeak(rules)) + let result = try offered(ConversationRules(speaker: [.never]), holdings: Holdings(isStaff: true)) #expect(result.actions.isEmpty) #expect(!result.canReact) } @@ -78,8 +75,7 @@ struct ShareProfileWidgetGatingTests { @Test("An unmet balance requirement offers neither") func unmetBalance() throws { let rules = ConversationRules(speaker: [.minimumBalance(MinimumBalanceRequirement(amount: .usd(100), mints: []))]) - #expect(!canSpeak(rules)) - let result = try offered(canSpeak: canSpeak(rules)) + let result = try offered(rules) #expect(result.actions.isEmpty) #expect(!result.canReact) } @@ -87,9 +83,39 @@ struct ShareProfileWidgetGatingTests { @Test("An unmet staff requirement offers neither, and a staff member is offered both") func staffRule() throws { let rules = ConversationRules(speaker: [.staff]) - #expect(!canSpeak(rules)) - #expect(canSpeak(rules, holdings: Holdings(isStaff: true))) - let result = try offered(canSpeak: canSpeak(rules)) + let blocked = try offered(rules) + #expect(blocked.actions.isEmpty) + #expect(!blocked.canReact) + let allowed = try offered(rules, holdings: Holdings(isStaff: true)) + #expect(allowed.actions == [.reply]) + #expect(allowed.canReact) + } + + @Test("A creator rule offers a non-creator reactions but no Reply, and the creator both") + func creatorRule() throws { + let rules = ConversationRules(speaker: [.creator]) + let other = try offered(rules, creator: UUID()) + #expect(other.actions.isEmpty) + #expect(other.canReact) + let nilCreator = try offered(rules, creator: nil) + #expect(nilCreator.actions.isEmpty) + #expect(nilCreator.canReact) + let creator = try offered(rules, creator: me, holdings: Holdings(userID: me)) + #expect(creator.actions == [.reply]) + #expect(creator.canReact) + } + + @Test("An unsupported rule offers reactions but no Reply, staff included") + func unsupportedRule() throws { + let result = try offered(ConversationRules(speaker: [.unsupported]), holdings: Holdings(isStaff: true)) + #expect(result.actions.isEmpty) + #expect(result.canReact) + } + + @Test("Creator paired with an unmet staff rule keeps reactions off") + func creatorWithUnmetStaff() throws { + let rules = ConversationRules(speaker: [.creator, .staff]) + let result = try offered(rules, creator: me, holdings: Holdings(userID: me)) #expect(result.actions.isEmpty) #expect(!result.canReact) } diff --git a/FlipcashTests/ConversationGateTests.swift b/FlipcashTests/ConversationGateTests.swift index ff512449e..4bd91d22e 100644 --- a/FlipcashTests/ConversationGateTests.swift +++ b/FlipcashTests/ConversationGateTests.swift @@ -16,11 +16,13 @@ struct ConversationGateTests { /// Stands in for `Session`, which the gate reads three things from. private final class StubHoldings: ConversationGateReading { var isStaff: Bool + var userID: UserID var totalBalance: ExchangedFiat private var balances: [PublicKey: StoredBalance] - init(isStaff: Bool = false, totalUSD: Decimal = 0, balances: [PublicKey: StoredBalance] = [:]) { + init(isStaff: Bool = false, userID: UserID = UUID(), totalUSD: Decimal = 0, balances: [PublicKey: StoredBalance] = [:]) { self.isStaff = isStaff + self.userID = userID self.totalBalance = ExchangedFiat( nativeAmount: .usd(totalUSD), rate: Rate(fx: 1, currency: .usd) @@ -80,6 +82,114 @@ struct ConversationGateTests { #expect(gate.speaker == .unsatisfied(unmet: [.never], primary: .never)) } + @Test("A creator speaker rule is met by the chat's creator") + func creatorSpeakerRule_creatorViewer_open() { + let me = UUID() + let rules = ConversationRules(speaker: [.creator]) + let gate = conversationGate(session: StubHoldings(userID: me), rules: rules, creator: me, rates: noRates) + #expect(gate.isOpen) + #expect(conversationGatePresentation(gate, isMember: true) == .open) + } + + @Test("A creator speaker rule leaves a non-creator read-only") + func creatorSpeakerRule_otherViewer_readOnly() { + let rules = ConversationRules(speaker: [.creator]) + let gate = conversationGate(session: StubHoldings(), rules: rules, creator: UUID(), rates: noRates) + #expect(gate.listener == .satisfied) + #expect(gate.speaker == .unsatisfied(unmet: [.creator], primary: .creator)) + #expect(conversationGatePresentation(gate, isMember: true) == .readOnly(.creator)) + } + + @Test("Staff get no bypass of a creator speaker rule") + func creatorSpeakerRule_staffNonCreator_readOnly() { + let rules = ConversationRules(speaker: [.creator]) + let gate = conversationGate(session: StubHoldings(isStaff: true), rules: rules, creator: UUID(), rates: noRates) + #expect(gate.speaker == .unsatisfied(unmet: [.creator], primary: .creator)) + } + + @Test("A creator speaker rule on a chat with an unknown creator fails closed") + func creatorSpeakerRule_unknownCreator_readOnly() { + let rules = ConversationRules(speaker: [.creator]) + let gate = conversationGate(session: StubHoldings(), rules: rules, creator: nil, rates: noRates) + #expect(gate.speaker == .unsatisfied(unmet: [.creator], primary: .creator)) + } + + @Test("An unsupported speaker rule blocks posting for everyone, staff included") + func unsupportedSpeakerRule_staffViewer_readOnly() { + let rules = ConversationRules(speaker: [.unsupported]) + let gate = conversationGate(session: StubHoldings(isStaff: true), rules: rules, rates: noRates) + #expect(gate.listener == .satisfied) + #expect(gate.speaker == .unsatisfied(unmet: [.unsupported], primary: .unsupported)) + #expect(conversationGatePresentation(gate, isMember: true) == .readOnly(.unsupported)) + } + + @Test("A member failing only a creator or unsupported rule can react but cannot post", arguments: [ConversationSpeakerRule.creator, .unsupported]) + func postingOnlyRule_memberCanReactButNotPost(_ rule: ConversationSpeakerRule) { + let gate = conversationGate(session: StubHoldings(isStaff: true), rules: ConversationRules(speaker: [rule]), creator: UUID(), rates: noRates) + let access = ConversationLoadCoordinator.access(isMember: true, gate: gate) + #expect(access.canReact) + #expect(!access.canPost) + } + + @Test("A never rule blocks reactions as well as posting") + func neverRule_blocksReactionsAndPosting() { + let gate = conversationGate(session: StubHoldings(isStaff: true), rules: ConversationRules(speaker: [.never]), rates: noRates) + let access = ConversationLoadCoordinator.access(isMember: true, gate: gate) + #expect(!access.canReact) + #expect(!access.canPost) + } + + @Test("An unmet minimum balance or staff rule blocks reactions as well as posting") + func balanceAndStaffRules_blockReactionsAndPosting() { + for rule in [ConversationSpeakerRule.minimumBalance(minimumBalance(100)), .staff] { + let gate = conversationGate(session: StubHoldings(), rules: ConversationRules(speaker: [rule]), rates: noRates) + let access = ConversationLoadCoordinator.access(isMember: true, gate: gate) + #expect(!access.canReact) + #expect(!access.canPost) + } + } + + @Test("Creator paired with an unmet staff rule keeps reactions blocked") + func creatorWithUnmetStaff_blocksReactions() { + let me = UUID() + let rules = ConversationRules(speaker: [.creator, .staff]) + let gate = conversationGate(session: StubHoldings(userID: me), rules: rules, creator: me, rates: noRates) + let access = ConversationLoadCoordinator.access(isMember: true, gate: gate) + #expect(!access.canReact) + #expect(!access.canPost) + } + + @Test("Creator paired with an unmet staff rule still allows reactions once staff is met and the viewer is not the creator") + func creatorWithMetStaff_nonCreatorCanReact() { + let rules = ConversationRules(speaker: [.creator, .staff]) + let gate = conversationGate(session: StubHoldings(isStaff: true), rules: rules, creator: UUID(), rates: noRates) + let access = ConversationLoadCoordinator.access(isMember: true, gate: gate) + #expect(access.canReact) + #expect(!access.canPost) + } + + @Test("A non-member can neither react nor post, whatever the rules say") + func nonMember_hasNoAccess() { + let access = ConversationLoadCoordinator.access(isMember: false, gate: .open) + #expect(!access.canReact) + #expect(!access.canPost) + } + + @Test("The creator can react and post") + func creator_canReactAndPost() { + let me = UUID() + let gate = conversationGate(session: StubHoldings(userID: me), rules: ConversationRules(speaker: [.creator]), creator: me, rates: noRates) + let access = ConversationLoadCoordinator.access(isMember: true, gate: gate) + #expect(access.canReact) + #expect(access.canPost) + } + + @Test("A read-only presentation still leaves the transcript readable") + func readOnlyPresentation_doesNotObscureTranscript() { + #expect(ConversationGatePresentation.readOnly(.creator).obscuresTranscript == false) + #expect(ConversationGatePresentation.readOnly(.unsupported).obscuresTranscript == false) + } + @Test("A staff member satisfies a staff-only chat") func staffRule_staffUser_satisfied() { let rules = ConversationRules(listener: [.staff])