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
11 changes: 3 additions & 8 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)))
}
Expand Down Expand Up @@ -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>) -> [MessageCapability] {
[.copy, .reply, .edit, .delete, .report].filter(capabilities.contains)
}
Expand Down
53 changes: 52 additions & 1 deletion Flipcash/Core/Screens/Conversation/ConversationGatePanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -83,7 +82,7 @@ extension MessageCapability {
now: Date
) -> Set<MessageCapability> {
var capabilities = memberCapabilities(for: message, as: selfUserID, canSpeak: canSpeak, policy: policy, now: now)
if !isMember {
if !isMember || !canSpeak {
capabilities.remove(.reply)
}
return capabilities
Expand Down Expand Up @@ -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
}
Expand Down
20 changes: 17 additions & 3 deletions FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 -
Expand Down
74 changes: 74 additions & 0 deletions FlipcashTests/Chat/NeverComposerRenderTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
}
Loading