From 36ff7053215da8ab13f51648d55e16e934b31bb5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 12:42:25 -0400 Subject: [PATCH 1/3] fix(chat): show a glass 'Only Flipcash can send messages' pill under a never speaker rule The never rule replaces the composer with a disabled, non-interactive Liquid Glass capsule at the composer's height carrying one muted line, instead of the gate card that said messages can't be sent. Balance and staff panels are unchanged. capsuleGlassBackground gains an interactive flag (default unchanged); iOS below 26 keeps its ultra-thin material fallback. --- .../Conversation/ConversationGatePanel.swift | 35 ++++++++- .../Chat/NeverComposerRenderTests.swift | 74 +++++++++++++++++++ .../Views/Containers/GlassBackground.swift | 7 +- 3 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 FlipcashTests/Chat/NeverComposerRenderTests.swift diff --git a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift index dc0e3d5a3..4167ff21e 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift @@ -37,6 +37,36 @@ struct ConversationGatePanel: View { let isJoining: Bool var body: some View { + if case .readOnly(.never) = presentation { + neverPill + } else { + card + } + } + + /// Nobody but Flipcash may send here (node 10588:1969): the composer's place is taken by a + /// disabled, full-width capsule 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) + .font(.appTextMedium) + .foregroundStyle(Color.textMain.opacity(Layout.neverTextOpacity)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity) + .frame(height: BarMetrics.contentHeight) + .padding(.horizontal, BarMetrics.edgeInset) + .capsuleGlassBackground(interactive: false) + .padding(.horizontal, BarMetrics.compactInset) + .padding(.vertical, BarMetrics.contentPadding) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(Self.neverSentence)) + .accessibilityAddTraits(.isStaticText) + } + + static let neverSentence = "Only Flipcash can send messages" + + private var card: some View { VStack(spacing: Layout.gap) { if let sentence { Text(sentence) @@ -87,7 +117,8 @@ struct ConversationGatePanel: View { case .open, .undetermined, .join, .blocked: return "This chat is for Flipcash staff" } case .never: - return "Messages can't be sent in this chat" + // Drawn as the composer-shaped pill instead of this card; see ``neverPill``. + return Self.neverSentence } } @@ -148,5 +179,7 @@ struct ConversationGatePanel: View { static let cardTopPadding: CGFloat = 12 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 } } diff --git a/FlipcashTests/Chat/NeverComposerRenderTests.swift b/FlipcashTests/Chat/NeverComposerRenderTests.swift new file mode 100644 index 000000000..0d9372b93 --- /dev/null +++ b/FlipcashTests/Chat/NeverComposerRenderTests.swift @@ -0,0 +1,74 @@ +// +// NeverComposerRenderTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import SwiftUI +import FlipcashCore +@testable import Flipcash +@testable import FlipcashUI + +/// The composer's replacement under a `never` speaker rule (node 10588:1969): the real gate panel, +/// hosted in a window over stand-in transcript content. +@MainActor +@Suite("Never-speaker composer rendering") +struct NeverComposerRenderTests { + + private func render(width: CGFloat = 402) throws -> (image: UIImage, pill: CGSize) { + let panel = ConversationGatePanel( + presentation: .readOnly(.never), mintName: nil, onAddFunds: {}, onJoin: {}, isJoining: false + ) + let host = UIHostingController(rootView: ZStack(alignment: .bottom) { + Color.backgroundMain + VStack(alignment: .leading, spacing: 8) { + ForEach(["Happy chatting!", "Welcome to Flipcash!"], id: \.self) { line in + Text(line).font(.appTextMedium).foregroundStyle(Color.textMain) + .padding(.horizontal, 12).padding(.vertical, 9) + .background(Color.textMain.opacity(0.06), in: RoundedRectangle(cornerRadius: 12)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) + .padding(12).padding(.bottom, 20) + panel + }) + let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first + let window = scene.map(UIWindow.init(windowScene:)) ?? UIWindow() + window.frame = CGRect(x: 0, y: 0, width: width, height: 160) + window.overrideUserInterfaceStyle = .dark + window.rootViewController = host + window.makeKeyAndVisible() + let inset = window.safeAreaInsets + host.additionalSafeAreaInsets = UIEdgeInsets(top: -inset.top, left: -inset.left, bottom: -inset.bottom, right: -inset.right) + host.view.frame = window.bounds + RunLoop.main.run(until: Date().addingTimeInterval(0.5)) + host.view.layoutIfNeeded() + + let format = UIGraphicsImageRendererFormat() + format.scale = 3 + let image = UIGraphicsImageRenderer(size: window.bounds.size, format: format).image { _ in + host.view.drawHierarchy(in: host.view.bounds, afterScreenUpdates: true) + } + window.isHidden = true + return (image, CGSize(width: width - 2 * BarMetrics.compactInset, height: BarMetrics.contentHeight)) + } + + @Test("The never rule draws the brand sentence, not the chat's or the old copy") + func sentence() { + #expect(ConversationGatePanel.neverSentence == "Only Flipcash can send messages") + } + + @Test("The pill renders at the composer's height") + func renders() throws { + let rendered = try render() + #expect(rendered.pill.height == 50) + #expect(rendered.image.size.width == 402) + if let path = ProcessInfo.processInfo.environment["NEVER_COMPOSER_PNG"], + let data = rendered.image.pngData() { + try data.write(to: URL(fileURLWithPath: path)) + } + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index 494dc60fc..8d4db710a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -36,10 +36,13 @@ extension View { /// The app's glass surface clipped to a capsule, for a control whose shape is /// fully rounded — the floating tab bar and the Scan tab's gallery button. + /// + /// Pass `interactive: false` for a surface that takes no touch, such as a disabled field, so the + /// glass does not shimmer under a finger that does nothing. @ViewBuilder - public func capsuleGlassBackground() -> some View { + public func capsuleGlassBackground(interactive: Bool = true) -> some View { if #available(iOS 26, *) { - glassEffect(.regular.interactive(), in: Capsule()) + glassEffect(interactive ? .regular.interactive() : .regular, in: Capsule()) } else { background(.ultraThinMaterial, in: Capsule()) } From 35d7bbab9c67cc33881fa661d502af849afd7bb2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 12:45:00 -0400 Subject: [PATCH 2/3] fix(chat): gate Reply and reactions on the speaker rule for every message MessageCapability.resolve now drops Reply from any message when the viewer cannot speak, and ChatItem.from withholds reactions the same way, not only for widgets. Copy, Report and the sender's own Edit/Delete are unchanged; a DM always passes. --- .../Conversation/ChatItem+Conversation.swift | 11 +++------- .../Conversation/MessageCapability.swift | 9 ++++----- .../MessageCapabilityTests.swift | 20 ++++++++++++++++--- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 7b636cd0e..b4fe4a64c 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -82,8 +82,8 @@ extension ChatItem { /// Whether the viewer may add or remove a reaction — false for someone previewing a group /// they have not joined (see `ChatMessage.canReact`). canReact: Bool = true, - /// Whether the chat's speaker rule lets the viewer speak. A widget has no author to gate on, - /// so its reactions follow this in addition to `canReact`. + /// 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 @@ -355,7 +355,7 @@ extension ChatItem { // last row, with its receipt. reactions: isLast ? message.reactionState?.pills ?? [] : [], selfReactions: message.reactionState?.selfReactions ?? [], - canReact: canReact && (isWidget(message) ? canSpeak : true), + canReact: canReact && canSpeak, isUnsent: isUnsent ))) } @@ -546,11 +546,6 @@ extension ChatItem { /// /// Report goes last: it is the rarest row and the only one that never appears on your own /// message, so the menu reads "yours ends with delete, theirs ends with report". - nonisolated private static func isWidget(_ message: ConversationMessage) -> Bool { - if case .widget = message.content { return true } - return false - } - nonisolated private static func orderedActions(_ capabilities: Set) -> [MessageCapability] { [.copy, .reply, .edit, .delete, .report].filter(capabilities.contains) } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 717f039b3..7c60d77b1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -67,9 +67,8 @@ extension MessageCapability { /// 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 gates only the server-authored widget, whose Reply and - /// reactions have no other reason to be withheld: a person's message keeps the composer-based - /// gating above. A `never` rule, an unmet balance or an unmet staff requirement all arrive here + /// 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. /// /// `now` is a parameter rather than `Date.now` so the result stays a function of its inputs. @@ -83,7 +82,7 @@ extension MessageCapability { now: Date ) -> Set { var capabilities = memberCapabilities(for: message, as: selfUserID, canSpeak: canSpeak, policy: policy, now: now) - if !isMember { + if !isMember || !canSpeak { capabilities.remove(.reply) } return capabilities @@ -114,7 +113,7 @@ extension MessageCapability { // Server-authored, like cash: no text to copy or edit, and no user sender to report, // so the only capability is Reply, which the quote panel supports. Reply follows the // chat's speaker rule like any message. Sharing is the card's own button, not a menu action. - return canSpeak ? [.reply] : [] + return [.reply] case .text: break } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index 3fec13426..f43d1aab8 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -106,9 +106,23 @@ struct MessageCapabilityTests { #expect(resolve(message, isMember: false, canSpeak: true) == []) } - @Test("The speaker rule gates only widgets: a person's text keeps its composer-based menu") - func speakerRuleDoesNotGateText() { - #expect(resolve(text("hi", from: them), canSpeak: false) == [.copy, .reply, .report]) + @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]) + // 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]) + // 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) == []) + } + + @Test("A DM is never gated: canSpeak defaults to true") + func dmIsUngated() { + #expect(resolve(text("hi", from: them)) == [.copy, .reply, .report]) + #expect(resolve(cash(from: them)) == [.reply, .report]) } // MARK: - Windows - From 0c30670151079b0e89f6a29d448a1390525128bb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 30 Sep 2026 12:54:31 -0400 Subject: [PATCH 3/3] fix(chat): draw the never pill as tinted glass at the box radius The pill was a capsule of plain glass, lighter than the design. It is now non-interactive glass in a RoundedRectangle at Metrics.boxRadius, tinted with backgroundMain at 0.46 (node 10588:1969). Below iOS 26 the same fill lies over the ultra-thin material. --- .../Conversation/ConversationGatePanel.swift | 22 +++++++++++++++++-- .../Views/Containers/GlassBackground.swift | 7 ++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift index 4167ff21e..ba8cdba91 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift @@ -45,7 +45,7 @@ struct ConversationGatePanel: View { } /// Nobody but Flipcash may send here (node 10588:1969): the composer's place is taken by a - /// disabled, full-width capsule of glass with one muted line — no field, no controls, no action. + /// 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) @@ -56,7 +56,7 @@ struct ConversationGatePanel: View { .frame(maxWidth: .infinity) .frame(height: BarMetrics.contentHeight) .padding(.horizontal, BarMetrics.edgeInset) - .capsuleGlassBackground(interactive: false) + .modifier(NeverPillGlass()) .padding(.horizontal, BarMetrics.compactInset) .padding(.vertical, BarMetrics.contentPadding) .accessibilityElement(children: .ignore) @@ -64,6 +64,23 @@ struct ConversationGatePanel: View { .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 { + func body(content: Content) -> some View { + let shape = RoundedRectangle(cornerRadius: Metrics.boxRadius) + let fill = Color.backgroundMain.opacity(Layout.neverFillOpacity) + if #available(iOS 26, *) { + content.glassEffect(.regular.tint(fill), in: shape) + } else { + content + .background(fill, in: shape) + .background(.ultraThinMaterial, in: shape) + } + } + } + static let neverSentence = "Only Flipcash can send messages" private var card: some View { @@ -181,5 +198,6 @@ struct ConversationGatePanel: View { 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 } } diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index 8d4db710a..494dc60fc 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -36,13 +36,10 @@ extension View { /// The app's glass surface clipped to a capsule, for a control whose shape is /// fully rounded — the floating tab bar and the Scan tab's gallery button. - /// - /// Pass `interactive: false` for a surface that takes no touch, such as a disabled field, so the - /// glass does not shimmer under a finger that does nothing. @ViewBuilder - public func capsuleGlassBackground(interactive: Bool = true) -> some View { + public func capsuleGlassBackground() -> some View { if #available(iOS 26, *) { - glassEffect(interactive ? .regular.interactive() : .regular, in: Capsule()) + glassEffect(.regular.interactive(), in: Capsule()) } else { background(.ultraThinMaterial, in: Capsule()) }