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/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" } } 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])