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/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift index dc0e3d5a3..ba8cdba91 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift @@ -37,6 +37,53 @@ 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 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) + .font(.appTextMedium) + .foregroundStyle(Color.textMain.opacity(Layout.neverTextOpacity)) + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity) + .frame(height: BarMetrics.contentHeight) + .padding(.horizontal, BarMetrics.edgeInset) + .modifier(NeverPillGlass()) + .padding(.horizontal, BarMetrics.compactInset) + .padding(.vertical, BarMetrics.contentPadding) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(Self.neverSentence)) + .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 { VStack(spacing: Layout.gap) { if let sentence { Text(sentence) @@ -87,7 +134,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 +196,8 @@ 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 + static let neverFillOpacity: Double = 0.46 } } 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 - 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)) + } + } +}