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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)))
}
Expand Down
37 changes: 35 additions & 2 deletions Flipcash/Core/Screens/Conversation/ConversationGate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
Expand All @@ -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.
Expand Down Expand Up @@ -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)

Expand All @@ -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`,
Expand Down Expand Up @@ -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 }
Expand All @@ -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.
Expand All @@ -155,7 +187,8 @@ func conversationGate(
listener: listener,
speaker: speaker,
headline: headline(for: rules.listener),
isProvisional: isProvisional
isProvisional: isProvisional,
allowsReactions: !blocksReactions
)
}

Expand Down Expand Up @@ -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
Expand Down
52 changes: 35 additions & 17 deletions Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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()
}
Expand All @@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
)
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 2 additions & 8 deletions Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ struct ConversationScreen: View {
return conversationGate(
session: session,
rules: conversation.rules,
creator: conversation.creator,
rates: ratesController.cachedRates
)
}
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
}
)
}
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Screens/Conversation/GroupLinkCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
1 change: 1 addition & 0 deletions Flipcash/Core/Session/SessionAuthenticator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,7 @@ final class SessionContainer {
return conversationGate(
session: session,
rules: conversation.rules,
creator: conversation.creator,
rates: ratesController?.cachedRates ?? [:]
)
}
Expand Down
2 changes: 1 addition & 1 deletion FlipcashAPI/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down
Loading
Loading