From 9f4766e55a1f3cbb3807560183d0829786a9a5f1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 21 Sep 2026 15:42:54 -0400 Subject: [PATCH 1/5] chore(spm): add the local shared-core override the docs already describe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrator's `docs/shared-core-local-development.md` documents `FLIPCASH_SHARED_CORE_LOCAL` as swapping the pinned `flipcash-shared-core-spm` for a path dependency into a `code-android-app` checkout, so a `:kmp:shared-core` change can be tried from an iOS build without publishing a version. It was never implemented here: `git grep` found nothing, and all three manifests that consume `SharedCoreKit` carried an unconditional `.upToNextMinor(from: "0.7.0")`. All three gain the branch together — SPM resolves one version for the whole graph, so a half-converted graph fails to resolve with a message about conflicting requirements that does not name the cause. The shape follows `FlipcashAPI/Package.swift`'s `FLIPCASH_PROTO_LOCAL` rather than inventing a second one. A path dependency takes its identity from the directory basename, so under the override shared core resolves as `spm` rather than `flipcash-shared-core-spm`. Product references switch with it, and the app target's own `XCRemoteSwiftPackageReference` had to go: holding both identities in one graph fails with `multiple similar targets 'SharedCore', 'SharedCoreKit' appear in package 'spm' and 'flipcash-shared-core-spm'`. That reference existed for one call, `KikCode.svg` in `TipCardExport`, which now goes through a `CodeView.svg` declared in FlipcashUI beside the `KikCode.figure` wrapper it already had. The orchestrator doc describes this removal as done; it was not. Both branches build the `Flipcash` scheme for an iPhone 17 Pro simulator: `ProcessXCFramework` runs against the local XCFramework with the variable set, and `SharedCore` resolves from GitHub at `0.7.0` with it unset. The pin stays at `0.7.0` — bumping it to an unpublished `0.8.0` here would make a standalone infrastructure commit that nobody can build without the override. --- Code.xcodeproj/project.pbxproj | 17 ------ CrossPlatformVectors/Package.swift | 24 ++++++++- .../Core/Screens/Main/You/TipCardExport.swift | 3 +- FlipcashCore/Package.swift | 53 ++++++++++++++++++- FlipcashUI/Package.swift | 24 ++++++++- .../FlipcashUI/Views/Bill/CodeView.swift | 16 ++++++ 6 files changed, 112 insertions(+), 25 deletions(-) diff --git a/Code.xcodeproj/project.pbxproj b/Code.xcodeproj/project.pbxproj index fd264529f..f84dc3a3e 100644 --- a/Code.xcodeproj/project.pbxproj +++ b/Code.xcodeproj/project.pbxproj @@ -27,7 +27,6 @@ 9A017D692EAC1DE200216925 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 9A017D682EAC1DE200216925 /* Kingfisher */; }; 9A444EE72E8C414D002B1E39 /* BigDecimal in Frameworks */ = {isa = PBXBuildFile; productRef = 9A444EE62E8C414D002B1E39 /* BigDecimal */; }; 9A780F972EDF817D009BC4D9 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 9A780F962EDF817D009BC4D9 /* CodeScanner */; }; - 9AE5C0122FA10003004C0DE0 /* SharedCoreKit in Frameworks */ = {isa = PBXBuildFile; productRef = 9AE5C0112FA10002004C0DE0 /* SharedCoreKit */; }; 9ABDD1952D9D7B61006B6CDA /* FlipcashCore in Frameworks */ = {isa = PBXBuildFile; productRef = 9ABDD1942D9D7B61006B6CDA /* FlipcashCore */; }; 9AC011182DA4320F0030298E /* FlipcashUI in Frameworks */ = {isa = PBXBuildFile; productRef = 9AC011172DA4320F0030298E /* FlipcashUI */; }; 9AC0156C2DA576FA0030298E /* opencv2.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9AD5E64B25F2BF3E007388AE /* opencv2.framework */; }; @@ -181,7 +180,6 @@ 9ABDD1952D9D7B61006B6CDA /* FlipcashCore in Frameworks */, 4CA1B0032FD00003000AA003 /* FlipcashStore in Frameworks */, 9ADEF1D92DD627C6001B260A /* Mixpanel in Frameworks */, - 9AE5C0122FA10003004C0DE0 /* SharedCoreKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -387,7 +385,6 @@ 9A780F962EDF817D009BC4D9 /* CodeScanner */, 4CB225752F77260D0075874E /* FirebaseInstallations */, 4CB225772F7726500075874E /* FirebaseMessaging */, - 9AE5C0112FA10002004C0DE0 /* SharedCoreKit */, ); productName = Flipcash; productReference = 9A516F7A2D9B12E900AF478B /* Flipcash.app */; @@ -516,7 +513,6 @@ 10A6C0E8F8974C1887219D41 /* XCRemoteSwiftPackageReference "tweetnacl-swiftwrap" */, 9A444EE52E8C414D002B1E39 /* XCRemoteSwiftPackageReference "BigDecimal" */, 9A780F952EDF817D009BC4D9 /* XCLocalSwiftPackageReference "CodeScanner" */, - 9AE5C0102FA10001004C0DE0 /* XCRemoteSwiftPackageReference "flipcash-shared-core-spm" */, ); productRefGroup = 9AED1101258BE1310088D902 /* Products */; projectDirPath = ""; @@ -1770,14 +1766,6 @@ kind = branch; }; }; - 9AE5C0102FA10001004C0DE0 /* XCRemoteSwiftPackageReference "flipcash-shared-core-spm" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/code-payments/flipcash-shared-core-spm"; - requirement = { - kind = upToNextMinorVersion; - minimumVersion = 0.7.0; - }; - }; 9ACE172927E6287C00ACA047 /* XCRemoteSwiftPackageReference "mixpanel-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mixpanel/mixpanel-swift"; @@ -1839,11 +1827,6 @@ package = 9A9499D62CFB829300DC219B /* XCRemoteSwiftPackageReference "SQLite" */; productName = SQLite; }; - 9AE5C0112FA10002004C0DE0 /* SharedCoreKit */ = { - isa = XCSwiftPackageProductDependency; - package = 9AE5C0102FA10001004C0DE0 /* XCRemoteSwiftPackageReference "flipcash-shared-core-spm" */; - productName = SharedCoreKit; - }; 9ADEF1D62DD627C0001B260A /* Bugsnag */ = { isa = XCSwiftPackageProductDependency; package = 9A476D482978884700C5B5CE /* XCRemoteSwiftPackageReference "bugsnag-cocoa" */; diff --git a/CrossPlatformVectors/Package.swift b/CrossPlatformVectors/Package.swift index 8932fdebf..c2c6a1fa7 100644 --- a/CrossPlatformVectors/Package.swift +++ b/CrossPlatformVectors/Package.swift @@ -1,5 +1,25 @@ // swift-tools-version: 5.9 import PackageDescription +import Foundation + +// `FLIPCASH_SHARED_CORE_LOCAL` swaps the pinned package for a path dependency into a +// `code-android-app` checkout. `FlipcashCore/Package.swift` carries the full rationale and the +// loop; the one thing to know here is that all three manifests consuming `SharedCoreKit` have to +// move together, because SPM resolves one version for the whole graph. +let sharedCoreLocalRoot = ProcessInfo.processInfo.environment["FLIPCASH_SHARED_CORE_LOCAL"] + .map { ($0 as NSString).expandingTildeInPath } + .flatMap { $0.isEmpty ? nil : $0 } + +// A path dependency takes its identity from the directory's basename rather than the `Package(name:)` +// inside it, so under the override the package is `spm`. Product references have to follow. +let sharedCorePackage = sharedCoreLocalRoot == nil ? "flipcash-shared-core-spm" : "spm" + +let sharedCore: Package.Dependency = { + if let sharedCoreLocalRoot { + return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") + } + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")) +}() // Cross-platform test-vector GATE (Track-B/C safety net). Asserts the ed25519 this app signs // with reproduces the canonical fixtures in `code/test-vectors/` — the SAME fixtures the Android @@ -21,12 +41,12 @@ let package = Package( .iOS(.v15), ], dependencies: [ - .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")), + sharedCore, ], targets: [ .testTarget( name: "CrossPlatformVectorsTests", - dependencies: [.product(name: "SharedCoreKit", package: "flipcash-shared-core-spm")], + dependencies: [.product(name: "SharedCoreKit", package: sharedCorePackage)], resources: [.copy("Fixtures")] ), ] diff --git a/Flipcash/Core/Screens/Main/You/TipCardExport.swift b/Flipcash/Core/Screens/Main/You/TipCardExport.swift index 9cb897b2b..84a52dcd2 100644 --- a/Flipcash/Core/Screens/Main/You/TipCardExport.swift +++ b/Flipcash/Core/Screens/Main/You/TipCardExport.swift @@ -6,7 +6,6 @@ import SwiftUI import UIKit import FlipcashUI -import SharedCoreKit /// Writes a tip code out as a file for the You tab's "Download As" sheet. /// @@ -73,7 +72,7 @@ enum TipCardExport { // MARK: - Rendering - private static func svg(codeData: Data) -> String { - KikCode.svg(payload: codeData, dimension: dimension, background: backgroundHex) + CodeView.svg(data: codeData, dimension: dimension, background: backgroundHex) } @MainActor diff --git a/FlipcashCore/Package.swift b/FlipcashCore/Package.swift index 99291ce2e..42dc35040 100644 --- a/FlipcashCore/Package.swift +++ b/FlipcashCore/Package.swift @@ -2,6 +2,55 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription +import Foundation + +// `FLIPCASH_SHARED_CORE_LOCAL` points at a `code-android-app` checkout and swaps the pinned +// package below for a path dependency into that checkout's `kmp/shared-core/spm`, so a change to +// `:kmp:shared-core` — including the Swift facade under `Sources/SharedCoreKit` — can be tried +// from an iOS build without publishing a version to consume it: +// +// cd code-android-app +// ./gradlew :kmp:shared-core:assembleSharedCoreReleaseXCFramework +// export FLIPCASH_SHARED_CORE_LOCAL=~/dev/bmcreations/code/code-android-app +// xed ../code-ios-app +// +// Xcode inherits the environment of whatever launched it, so it has to be started from a shell +// that exported the variable rather than from the Dock. SwiftPM re-evaluates this manifest when +// the variable changes, so switching back needs no clean — but the assemble task has to be re-run +// after every Kotlin edit, because the override points at a file on disk and SwiftPM has no way to +// know it went stale. The full loop is the orchestrator's `docs/shared-core-local-development.md`. +// +// All three manifests that consume `SharedCoreKit` read this — `FlipcashCore`, `FlipcashUI` and +// `CrossPlatformVectors` — and they have to move together. SPM resolves one version for the whole +// graph, so a graph with a path dependency in one manifest and the remote in another fails to +// resolve, with a message about conflicting requirements that does not name the cause. +// +// For the same reason, `SharedCoreKit` must not be added to a target's package dependencies inside +// `Code.xcodeproj`: that reference resolves under identity `flipcash-shared-core-spm` and nothing +// an environment variable can reach rewrites it, so the two identities collide on duplicate +// targets. An app-target import belongs behind a `FlipcashCore` or `FlipcashUI` declaration. +// +// Local mode drops the pinned entry from the tracked workspace `Package.resolved` and changes its +// `originHash`, because a path dependency has no revision to pin. Restore it before committing: +// +// git checkout -- Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +// +// Unset is the committed state, and CI never sets it, so release builds always resolve the pinned +// version below. +let sharedCoreLocalRoot = ProcessInfo.processInfo.environment["FLIPCASH_SHARED_CORE_LOCAL"] + .map { ($0 as NSString).expandingTildeInPath } + .flatMap { $0.isEmpty ? nil : $0 } + +// A path dependency takes its identity from the directory's basename rather than the `Package(name:)` +// inside it, so under the override the package is `spm`. Product references have to follow. +let sharedCorePackage = sharedCoreLocalRoot == nil ? "flipcash-shared-core-spm" : "spm" + +let sharedCore: Package.Dependency = { + if let sharedCoreLocalRoot { + return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") + } + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")) +}() let package = Package( name: "FlipcashCore", @@ -27,7 +76,7 @@ let package = Package( .package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", from: "2.0.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.81.0"), .package(path: "../FlipcashAPI"), - .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")), + sharedCore, // Branch-pinned to match the app project's own reference to the same fork. SPM resolves one // version of it for the whole graph, so the two have to agree. .package(url: "https://github.com/dbart01/SQLite.swift", branch: "master"), @@ -43,7 +92,7 @@ let package = Package( .product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "FlipcashAPI", package: "FlipcashAPI"), - .product(name: "SharedCoreKit", package: "flipcash-shared-core-spm"), + .product(name: "SharedCoreKit", package: sharedCorePackage), ], resources: [ .copy("Resources/discrete_pricing_table.bin"), diff --git a/FlipcashUI/Package.swift b/FlipcashUI/Package.swift index 5012d0fee..9ba67b984 100644 --- a/FlipcashUI/Package.swift +++ b/FlipcashUI/Package.swift @@ -2,6 +2,26 @@ // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription +import Foundation + +// `FLIPCASH_SHARED_CORE_LOCAL` swaps the pinned package for a path dependency into a +// `code-android-app` checkout. `FlipcashCore/Package.swift` carries the full rationale and the +// loop; the one thing to know here is that all three manifests consuming `SharedCoreKit` have to +// move together, because SPM resolves one version for the whole graph. +let sharedCoreLocalRoot = ProcessInfo.processInfo.environment["FLIPCASH_SHARED_CORE_LOCAL"] + .map { ($0 as NSString).expandingTildeInPath } + .flatMap { $0.isEmpty ? nil : $0 } + +// A path dependency takes its identity from the directory's basename rather than the `Package(name:)` +// inside it, so under the override the package is `spm`. Product references have to follow. +let sharedCorePackage = sharedCoreLocalRoot == nil ? "flipcash-shared-core-spm" : "spm" + +let sharedCore: Package.Dependency = { + if let sharedCoreLocalRoot { + return .package(path: "\(sharedCoreLocalRoot)/kmp/shared-core/spm") + } + return .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")) +}() let package = Package( name: "FlipcashUI", @@ -19,7 +39,7 @@ let package = Package( .package(url: "https://github.com/ekazaev/ChatLayout", from: "2.4.2"), .package(url: "https://github.com/ra1028/DifferenceKit", from: "1.3.0"), .package(url: "https://github.com/onevcat/Kingfisher", from: "8.3.0"), - .package(url: "https://github.com/code-payments/flipcash-shared-core-spm", .upToNextMinor(from: "0.7.0")), + sharedCore, ], targets: [ .target( @@ -29,7 +49,7 @@ let package = Package( .product(name: "ChatLayout", package: "ChatLayout", condition: .when(platforms: [.iOS])), .product(name: "DifferenceKit", package: "DifferenceKit"), .product(name: "Kingfisher", package: "Kingfisher"), - .product(name: "SharedCoreKit", package: "flipcash-shared-core-spm"), + .product(name: "SharedCoreKit", package: sharedCorePackage), ], resources: [ .process("Assets") diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Bill/CodeView.swift b/FlipcashUI/Sources/FlipcashUI/Views/Bill/CodeView.swift index 71fbf6949..be5146380 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Bill/CodeView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Bill/CodeView.swift @@ -36,6 +36,22 @@ public struct CodeView: View { } } +extension CodeView { + + /// The same figure as a standalone SVG document, for export. + /// + /// Here rather than in the app target so that nothing outside this package reaches + /// `SharedCoreKit` directly. An app-target package reference resolves shared core under + /// identity `flipcash-shared-core-spm`, while `FLIPCASH_SHARED_CORE_LOCAL` resolves it under + /// `spm`, and a graph holding both fails to resolve at all. See `FlipcashCore/Package.swift`. + /// + /// - Parameter background: the surface color the code sits on, or `nil` for a transparent + /// document. Codes are light-on-dark, so a transparent export is invisible on light surfaces. + public static func svg(data: Data, dimension: CGFloat, background: String?) -> String { + KikCode.svg(payload: data, dimension: Double(dimension), background: background) + } +} + // MARK: - Code Figure - /// One half of a code, laid out square and centred in whatever rect it is handed. From f98a2d5ef945e57bfc94bb190bff41c19446954c Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 21 Sep 2026 16:33:58 -0400 Subject: [PATCH 2/5] feat(chat): give a message a report capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MessageCapability` gains `.report`, resolved by one rule held in step with Android's `resolveCapabilities`: anything a participant sent can be reported; anything that no longer exists, or that you sent, cannot. So another person's text and their cash both carry it, your own messages and tombstones do not. Reporting has no window. The edit and delete windows exist because the server enforces them, and nothing in the contract caps how old a reportable message may be — so `MessagePolicy.window(for:)` returns nil for it. That is load- bearing rather than tidy: `nextExpiry` walks every resolved capability and skips the ones with no window, so a windowed `.report` would arm a timer on every incoming message in the transcript. A test pins it. It is also not destructive. Nothing is destroyed and nothing is irreversible from the reporter's side; the red row stays delete's. The menu row is held back. `orderedActions` still stops at `.delete`, because the reason sheet needs a `ReportReason` vocabulary that `SharedCoreKit` does not ship until 0.8.0 — a tag that does not exist, and which the Android task producing it has not started. Offering a row that opens nothing is worse than offering none, so the plumbing around it lands now and the row follows with the sheet: one line here and the `.report` arm of `handleMessageAction`, both commented where a reader will look. The exhaustive switches the new case broke are all resolved rather than defaulted, which is what they are for. Two were not in the plan: a mutation alert cannot arise from reporting, since it files straight from its own sheet, and the keyboard comes down for it as it does for delete, because a sheet is about to cover the transcript. `MessageCapabilityTests` asserted exact set equality throughout, so every assertion about another person's message moved. The cash test was only ever covering my own; it now covers both directions. --- .../Controllers/ConversationController.swift | 8 ++-- .../Conversation/ChatItem+Conversation.swift | 4 ++ .../ChatScreenRepresentable.swift | 5 ++- .../Conversation/ConversationScreen.swift | 6 +++ .../Conversation/MessageCapability.swift | 26 ++++++++++--- .../Models/Conversation/MessagePolicy.swift | 12 ++++-- .../MessageCapabilityTests.swift | 37 +++++++++++++++---- .../FlipcashUI/Chat/ChatViewController.swift | 3 +- .../FlipcashUI/Theme/Image+Symbols.swift | 1 + 9 files changed, 80 insertions(+), 22 deletions(-) diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 93947a280..fe80e0994 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1149,9 +1149,11 @@ final class ConversationController { case .conflict: "Message Changed" case .failure: switch action { - case .edit: "Couldn't Edit Message" - case .delete: "Couldn't Delete Message" - case .copy, .reply: "Something Went Wrong" + case .edit: "Couldn't Edit Message" + case .delete: "Couldn't Delete Message" + // Neither of these mutates the transcript, so neither can raise a mutation alert. + // Report files straight from its sheet and reports its own outcome there. + case .copy, .reply, .report: "Something Went Wrong" } } } diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index fc90857f4..0026ff196 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -300,7 +300,11 @@ extension ChatItem { /// Menu order is fixed here, not at the call site — a `Set` has no order, and the context menu /// must not shuffle its rows between renders of the same message. + /// + /// 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 orderedActions(_ capabilities: Set) -> [MessageCapability] { + // `.report` is deliberately absent until the reason sheet lands — see `handleMessageAction`. [.copy, .reply, .edit, .delete].filter(capabilities.contains) } diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index f435ab322..5080693d4 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -189,8 +189,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen?.focusComposer() case .reply: screen?.focusComposer() - case .delete: screen?.dismissKeyboard() - case .copy: break + // Both raise a sheet over the transcript, so the keyboard goes down first. + case .delete, .report: screen?.dismissKeyboard() + case .copy: break } } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 52b470588..267a276bf 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -835,6 +835,12 @@ struct ConversationScreen: View { composer.beginEditing(messageID: message.id, stableID: stableID, currentText: text) case .delete: confirmDelete(message.id) + case .report: + // Unreachable today: `orderedActions` does not offer Report yet, because the reason + // sheet needs a vocabulary that `SharedCoreKit` does not ship until 0.8.0. The + // capability, its window and its menu placement are all in place; the row and this + // arm land together with the sheet. + break } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 415f18f8e..70deaa4b3 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -14,6 +14,7 @@ public enum MessageCapability: String, Hashable, Sendable, Codable, CaseIterable case reply case edit case delete + case report } extension MessageCapability { @@ -25,14 +26,17 @@ extension MessageCapability { case .reply: "Reply" case .edit: "Edit" case .delete: "Delete" + case .report: "Report" } } /// Whether the menu should render this row in its destructive style. public var isDestructive: Bool { switch self { - case .copy, .reply, .edit: false - case .delete: true + // Report is not destructive, though a reviewer will expect it to be: nothing is destroyed + // and nothing is irreversible from the reporter's side. The red row belongs to delete. + case .copy, .reply, .edit, .report: false + case .delete: true } } } @@ -42,6 +46,17 @@ extension MessageCapability { /// The capabilities `selfUserID` has over `message`. Pure — the same inputs always give the same /// answer, which is what lets the transcript mapper run off the main actor. /// + /// Reporting follows one rule, held in step with Android's `resolveCapabilities`: anything a + /// participant sent can be reported; anything that no longer exists, or that you sent, cannot. + /// + /// | Message | Report? | + /// |---|---| + /// | Another participant's text | yes | + /// | Another participant's cash | yes | + /// | Own message, confirmed | no | + /// | Own message, unconfirmed | no | + /// | A tombstone | no | + /// /// `conversation` is accepted but unread today: it is the seam a group-chat permissions model /// plugs into (an admin deleting another member's message, a read-only channel), and taking it /// now means adding that model does not change every call site. It is optional because the @@ -63,14 +78,15 @@ extension MessageCapability { case .cash: // Reply is a cash message's only capability: there is no text to copy, the server // authored it so there is nothing to edit, and delete is deliberately withheld from - // a payment record. - return [.reply] + // a payment record. Someone else's payment is still reportable — a payment is a thing + // a person did to you. + return message.isFromSelf(selfUserID) ? [.reply] : [.reply, .report] case .text: break } guard message.isFromSelf(selfUserID) else { - return [.copy, .reply] + return [.copy, .reply, .report] } // An unconfirmed message has no `eventSequence` to send as `expected_event_sequence`, so no diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift index 9dd260aef..4a2f8ca24 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessagePolicy.swift @@ -83,11 +83,17 @@ public struct MessagePolicy: Hashable, Sendable { } /// The window governing `capability`, or `nil` when it never lapses. + /// + /// Reporting has no window on purpose. The edit and delete windows exist because the server + /// enforces them; nothing in the contract limits how old a reportable message may be. It also + /// matters mechanically: ``MessageCapability/nextExpiry(among:in:as:policy:now:)`` walks every + /// resolved capability and skips the ones with no window, so a windowed `.report` would arm a + /// timer on every incoming message in the transcript. public func window(for capability: MessageCapability) -> TimeInterval? { switch capability { - case .edit: editWindow - case .delete: deleteWindow - case .copy, .reply: nil + case .edit: editWindow + case .delete: deleteWindow + case .copy, .reply, .report: nil } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index 9d9677ccd..804cfd638 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -38,9 +38,9 @@ struct MessageCapabilityTests { #expect(resolve(text("hi", from: me, eventSequence: 0)).isEmpty) } - @Test("Someone else's text can only be copied") - func otherPersonsTextIsCopyOnly() { - #expect(resolve(text("hi", from: them)) == [.copy, .reply]) + @Test("Someone else's text can be copied, replied to, and reported") + func otherPersonsTextIsReportable() { + #expect(resolve(text("hi", from: them)) == [.copy, .reply, .report]) } @Test("A tombstone offers nothing") @@ -53,14 +53,22 @@ struct MessageCapabilityTests { #expect(resolve(tombstone).isEmpty) } - @Test("A cash message offers Reply and nothing else") - func cashMessageOffersReplyOnly() { - let cash = ConversationMessage( - id: MessageID(value: 3), senderID: me, + @Test("My own cash message offers Reply and nothing else") + func ownCashMessageOffersReplyOnly() { + #expect(resolve(cash(from: me)) == [.reply]) + } + + @Test("Someone else's cash message can be reported — a payment is a thing a person did to you") + func otherPersonsCashIsReportable() { + #expect(resolve(cash(from: them)) == [.reply, .report]) + } + + private func cash(from sender: UUID) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: 3), senderID: sender, content: .cash(ExchangedFiat(nativeAmount: .usd(20), rate: .oneToOne)), cashAction: .sent, date: now, unreadSeq: 1, eventSequence: 2 ) - #expect(resolve(cash) == [.reply]) } // MARK: - Windows - @@ -109,6 +117,18 @@ struct MessageCapabilityTests { #expect(resolve(text("hi", from: me, sentAgo: 172_800.001), policy: policy) == [.copy, .reply]) } + @Test("Report has no window — it is a limit on mutating a message, and reporting mutates nothing") + func reportIsNeverWindowed() { + #expect(MessagePolicy.default.window(for: .report) == nil) + #expect(windows(edit: 60, delete: 60).window(for: .report) == nil) + // The consequence that matters: a windowed `.report` would arm a timer on every incoming + // message in the transcript, since every one of them resolves it. + #expect(MessageCapability.nextExpiry( + among: [text("hi", from: them, sentAgo: 10_000_000)], + in: nil, as: me, policy: windows(edit: 60, delete: 60), now: now + ) == nil) + } + @Test("A nil window never lapses") func nilWindowNeverLapses() { let policy = windows(edit: nil, delete: nil) @@ -211,6 +231,7 @@ struct MessageCapabilityTests { @Test("Messages with nothing to lose contribute no deadline") func nonExpiringMessagesScheduleNothing() { let policy = windows(edit: 900, delete: 172_800) + // `theirs` resolves `.report`, which has no window, so it still contributes nothing. let theirs = text("hi", from: them) let unconfirmed = text("hi", from: me, eventSequence: 0) #expect(MessageCapability.nextExpiry( diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index b91f0ff23..607736d3d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -837,7 +837,7 @@ extension ChatViewController { switch action { case .copy: if let body { UIPasteboard.general.string = body } - case .reply, .edit, .delete: + case .reply, .edit, .delete, .report: handler?(rowID, action) } } @@ -1025,6 +1025,7 @@ private extension MessageCapability { case .reply: .replyArrow case .edit: .pencil case .delete: .trash + case .report: .flag } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift index c7891d5e0..296fba368 100644 --- a/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift +++ b/FlipcashUI/Sources/FlipcashUI/Theme/Image+Symbols.swift @@ -84,6 +84,7 @@ public enum SystemSymbol: String { case pencil = "pencil" case trash = "trash" + case flag = "flag" /// The bare glyph, for a confirm affordance that carries its own backing shape. case checkmark = "checkmark" From 595d8156866d9e5557401a07401660e06498d2ba Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 11:46:35 -0400 Subject: [PATCH 3/5] feat(reporting): give a report a reason, and three places to file one A row on a user's profile, a row on a group's, and the context menu action that has been resolving but going nowhere since f98a2d5e. All three open the same two-step flow and file through `FlipClient.report`. Picking a reason selects it; the button files it. Filing is not undoable from here and the six labels sit close together, so a mis-tap is a plausible way to report someone for the wrong thing. A second, deliberate press costs one tap and removes that whole class of mistake. `Something Else` is the only reason that cannot stand on its own, so it is the only one whose button reads `Next`. Each reason carries a line of scope under it. Six words of label leave people guessing which bucket their situation belongs in, and a guess lands in the wrong one. Going back from the details step keeps both what was typed and what was picked, so a second thought about the reason does not cost the sentence already written. The tokens and the `description` they assemble come from shared Kotlin, never from here, so both platforms put the same bytes on the wire for the same choice. Only the labels are this app's. Three tests fail if iOS starts formatting the string itself. The app target reaches the vocabulary through a new `@_exported` in FlipcashCore, mirroring `Logging`. It must not depend on `SharedCoreKit` directly: that gives the package a second identity and the build fails with `multiple similar targets 'SharedCore', 'SharedCoreKit'`. Report survives leaving a group, so the `isMember` gate in `ChatProfileScreen` now wraps invite, mute and leave rather than the whole block. The success copy is Android's placeholder, carried over deliberately so that one moderation answer fixes both platforms rather than two different ones. This does not build against the pinned `0.7.0`, which has no report symbols. Until `flipcash-shared-core-spm` publishes `0.8.0` it needs `FLIPCASH_SHARED_CORE_LOCAL` pointed at an Android checkout. --- .../Conversation/ChatItem+Conversation.swift | 3 +- .../Conversation/ConversationScreen.swift | 27 +- .../Conversation/ReportFlowScreen.swift | 322 ++++++++++++++++++ .../Conversation/ReportFlowState.swift | 54 +++ .../ReportReason+Presentation.swift | 81 +++++ .../Screens/Profile/ChatProfileScreen.swift | 16 +- Flipcash/Core/Screens/Profile/ReportRow.swift | 52 +++ .../Screens/Profile/UserProfileScreen.swift | 8 +- .../FlipcashCore/SharedCoreExports.swift | 7 + .../Chat/MessageCapabilityMenuTests.swift | 34 ++ FlipcashTests/Chat/ReportFlowTests.swift | 97 ++++++ 11 files changed, 688 insertions(+), 13 deletions(-) create mode 100644 Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift create mode 100644 Flipcash/Core/Screens/Conversation/ReportFlowState.swift create mode 100644 Flipcash/Core/Screens/Conversation/ReportReason+Presentation.swift create mode 100644 Flipcash/Core/Screens/Profile/ReportRow.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/SharedCoreExports.swift create mode 100644 FlipcashTests/Chat/ReportFlowTests.swift diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index 0026ff196..2033adce1 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -304,8 +304,7 @@ 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 orderedActions(_ capabilities: Set) -> [MessageCapability] { - // `.report` is deliberately absent until the reason sheet lands — see `handleMessageAction`. - [.copy, .reply, .edit, .delete].filter(capabilities.contains) + [.copy, .reply, .edit, .delete, .report].filter(capabilities.contains) } /// "Read 3:42 PM" / "Read Yesterday" / "Read Monday" / "Read Tue, Jun 17" once the counterpart's diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 267a276bf..996941c45 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -83,6 +83,7 @@ struct ConversationScreen: View { /// Whether the invite sheet is up. A link is the only way into a group, so the empty group's /// card hands one out (node 10127:118280). @State private var isInviting = false + @State private var messageReport: MessageReportRequest? /// Horizontal space the back button (leading) reserves on each side of the /// centered title item, so the avatar + name can left-align inside a @@ -647,6 +648,13 @@ struct ConversationScreen: View { GroupInviteSheet(conversationID: conversationID, isPresented: $isInviting) } } + .fullScreenCover(item: $messageReport) { report in + NavigationStack { + ReportFlowScreen( + target: .message(chatID: report.chatID, messageID: report.messageID) + ) + } + } .background { // Measure the bar width so the centered title item can be sized to // (almost) fill it — the system toolbar won't honor maxWidth on a @@ -836,14 +844,23 @@ struct ConversationScreen: View { case .delete: confirmDelete(message.id) case .report: - // Unreachable today: `orderedActions` does not offer Report yet, because the reason - // sheet needs a vocabulary that `SharedCoreKit` does not ship until 0.8.0. The - // capability, its window and its menu placement are all in place; the row and this - // arm land together with the sheet. - break + // A report names the message, not the chat and not the writer: a chat-level report from + // here would name a DM id this client derived, and the server has never been told about + // that one. + guard let conversationID else { return } + messageReport = MessageReportRequest(id: stableID, chatID: conversationID, messageID: message.id) } } + /// The message a report is being filed against, so the sheet can be presented by item rather + /// than from a loose boolean and a second piece of state that could disagree with it. + private struct MessageReportRequest: Identifiable { + /// The transcript row's stable id — already unique per row, and already a `String`. + let id: String + let chatID: ConversationID + let messageID: MessageID + } + /// The composer strip's preview of the message being answered — the same three-way split the /// transcript's quote panel uses, so the strip and the sent bubble read alike. /// diff --git a/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift new file mode 100644 index 000000000..2ea9aefaf --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift @@ -0,0 +1,322 @@ +// +// ReportFlowScreen.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// Reporting a person, a group, or a message. +/// +/// Two steps, and most reports only see the first: picking a reason and confirming it files the +/// report. ``ReportReason/other`` is the exception, because only it has nothing to go on without a +/// second screen. +/// +/// Steps rather than one sheet that swaps its own contents. A sheet sized to its content is the +/// wrong shape for a step that grows a text field and a keyboard, and the choice also buys a real +/// back: returning to the reasons keeps both the pick and the words, because leaving the details by +/// the back arrow is usually a second thought about the reason rather than about what happened. +/// +/// What goes on the wire is assembled by ``ReportDescription/build(reason:details:)`` in shared +/// code, so this screen and Android's produce the same `description` for the same choice. Nothing +/// here formats anything. +struct ReportFlowScreen: View { + + let target: ReportTarget + + @State private var state = ReportFlowState() + @State private var dialog: DialogItem? + @State private var direction: Direction = .forward + + @Environment(\.dismiss) private var dismiss + @Environment(SessionContainer.self) private var sessionContainer + + private enum Direction { + case forward, backward + + var slide: AnyTransition { + switch self { + case .forward: + .asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)) + case .backward: + .asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing)) + } + } + } + + var body: some View { + Background(color: .backgroundMain) { + ZStack { + switch state.step { + case .reason: + ReasonStep(state: state, onContinue: advance) + .transition(direction.slide) + case .details: + DetailsStep(state: state, onSubmit: submitDetails) + .transition(direction.slide) + } + } + } + .navigationTitle("Report") + .toolbarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + // A half-written report is not something to lose to a stray downward drag. + .interactiveDismissDisabled() + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button(action: goBack) { + // At the first step there is nothing to go back to, so the control leaves. + Image(systemName: state.step == .reason ? "xmark" : "chevron.backward") + .foregroundStyle(Color.textMain) + } + .accessibilityIdentifier("report-nav-leading") + .accessibilityLabel(state.step == .reason ? "Close" : "Back") + } + } + .dialog(item: $dialog) + } + + private func goBack() { + switch state.step { + case .reason: + dismiss() + case .details: + direction = .backward + withAnimation { state.step = .reason } + } + } + + /// The button at the bottom of the reasons: a second, deliberate press on a pick already made. + /// + /// Tapping a row only selects it. Filing a report cannot be undone from here and the labels sit + /// close enough together that a mis-tap is a plausible way to reach the wrong one, so the extra + /// press buys back a whole class of mistake for one tap. + private func advance() { + guard let reason = state.selectedReason else { return } + if ReportFlowState.needsDetails(reason) { + direction = .forward + withAnimation { state.step = .details } + } else { + Task { await submit(reason, details: nil) } + } + } + + private func submitDetails() { + guard let reason = state.selectedReason else { return } + Task { await submit(reason, details: state.details) } + } + + /// Files the report, then waits to be acknowledged. + /// + /// What closes this flow is the confirmation being dismissed, not the send returning. A report + /// answers inside a frame, so a flow that vanished on the call would leave its confirmation to + /// land on whatever happened to be behind it — and say nothing at all when the send had failed. + /// + /// The contract makes a duplicate report a no-op that answers OK, so the acknowledgement reads + /// the same whether or not this was the first one. Telling someone they had already reported + /// this would be answering a question they did not ask. + private func submit(_ reason: ReportReason, details: String?) async { + state.buttonState = .loading + + // A floor under the spinner, run alongside the send rather than before it: the call + // answers inside a frame, so without one the spinner never renders, and with a blocking + // delay a slow send would be held back by its own progress indicator. + let floor = Task { try? await Task.sleep(for: .milliseconds(400)) } + + do { + try await sessionContainer.flipClient.report( + owner: sessionContainer.session.ownerKeyPair, + target: target, + description: ReportDescription.build(reason: reason, details: details) + ) + await floor.value + state.buttonState = .success + // The checkmark is drawn on the frame after the state flips, so raising the dialog + // immediately would drop a scrim over one nobody saw. + try? await Task.sleep(for: .milliseconds(500)) + dialog = DialogItem.success( + title: "Report Sent", + subtitle: "Thanks. We'll take a look" + ).onDismiss { dismiss() } + } catch { + floor.cancel() + // Left where it was, with the reason still picked: a retry is one press. + state.buttonState = .normal + dialog = .error( + title: "Something Went Wrong", + subtitle: "We were unable to send your report. Please try again" + ) + ErrorReporting.captureError(error, reason: "Failed to file report") + } + } +} + +// MARK: - ReasonStep + +private struct ReasonStep: View { + + @Bindable var state: ReportFlowState + let onContinue: () -> Void + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { + Text("Tell us what's wrong. Your report is private — we won't tell them who reported") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 20) + + VStack(spacing: 8) { + ForEach(ReportReason.displayOrder, id: \.self) { reason in + ReportReasonRow( + reason: reason, + isSelected: state.selectedReason == reason + ) { + state.selectedReason = reason + } + } + } + .padding(.top, 24) + } + } + .scrollIndicators(.hidden) + + CodeButton( + state: state.buttonState, + style: .filled, + title: ReportFlowState.buttonTitle(for: state.selectedReason), + disabled: state.selectedReason == nil || state.buttonState != .normal, + action: onContinue + ) + .accessibilityIdentifier("report-reason-continue") + .padding(.top, 12) + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + } +} + +// MARK: - ReportReasonRow + +/// One choice: an indicator, its label, and the line of scope under it. +/// +/// The whole row is the target and the indicator only reports state, so there is no second, +/// smaller thing to hit inside it. +private struct ReportReasonRow: View { + + let reason: ReportReason + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(alignment: .top, spacing: 12) { + Image(systemName: isSelected ? "largecircle.fill.circle" : "circle") + .font(.system(size: 20)) + .foregroundStyle(isSelected ? Color.textMain : Color.textSecondary) + + VStack(alignment: .leading, spacing: 4) { + Text(reason.title) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + + Text(reason.summary) + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.backgroundRow, in: .rect(cornerRadius: 12)) + .contentShape(.rect) + } + .buttonStyle(.plain) + .accessibilityIdentifier("report-reason-\(reason.identifier)") + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } +} + +// MARK: - DetailsStep + +private struct DetailsStep: View { + + @Bindable var state: ReportFlowState + let onSubmit: () -> Void + + @FocusState private var isFocused: Bool + + /// Counts down rather than up, matching the currency wizard's fields. + private var remaining: Int { + ReportDescription.maxDetailsLength - state.details.count + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + // Two blocks rather than one sentence: the ask is what the screen wants, the + // privacy line is what it offers in return. Run together at one weight they + // compete, and the part that reassures is the part that gets skipped. + Text("Tell us what's wrong") + .font(.appTextLarge) + .foregroundStyle(Color.textMain) + + Text("Your report is private — we won't tell them who reported") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .fixedSize(horizontal: false, vertical: true) + + TextField("Tell us more", text: $state.details, axis: .vertical) + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .focused($isFocused) + .padding(.top, 16) + .accessibilityIdentifier("report-details") + .onChange(of: state.details) { _, new in + // Clipped rather than refused: the cap is the contract's, not a rule + // anyone broke, and the counter above has been saying so all along. + if new.count > ReportDescription.maxDetailsLength { + state.details = String(new.prefix(ReportDescription.maxDetailsLength)) + } + } + + Color.clear.frame(height: 60) + } + .padding(.top, 20) + } + .scrollDismissesKeyboard(.interactively) + .scrollIndicators(.hidden) + + Text("\(remaining) characters") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("report-details-counter") + .padding(.bottom, 12) + + // Picking "Something Else" and then saying nothing leaves the report with less to go + // on than any other row would have carried, so the button waits. + CodeButton( + state: state.buttonState, + style: .filled, + title: "Submit Report", + disabled: !ReportFlowState.canSubmitDetails(state.details) || state.buttonState != .normal, + action: onSubmit + ) + .accessibilityIdentifier("report-submit") + .padding(.bottom, 20) + } + .padding(.horizontal, 20) + .onAppear { isFocused = true } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ReportFlowState.swift b/Flipcash/Core/Screens/Conversation/ReportFlowState.swift new file mode 100644 index 000000000..b765c08d9 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ReportFlowState.swift @@ -0,0 +1,54 @@ +// +// ReportFlowState.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import FlipcashUI + +/// What has been chosen so far, and how far through the report we are. +/// +/// Held apart from ``ReportFlowScreen`` because stepping to the details and back must not lose the +/// pick that got you there, nor the words already typed — going back is usually a second thought +/// about the reason, not about what happened. A `@State` on the step's own view dies with the step. +@Observable +final class ReportFlowState { + + /// Picking a reason, and — for ``ReportReason/other`` alone — saying what happened. + enum Step: Equatable { + case reason + case details + } + + var step: Step = .reason + + /// Nil until a row is picked. The button waits on it. + var selectedReason: ReportReason? + + var details: String = "" + + /// Carries the send: the wait, then the checkmark, on whichever step submitted. + var buttonState: ButtonState = .normal + + /// Whether picking this reason needs a second step before there is a report worth filing. + /// + /// `nonisolated` and static so the branch is reachable from a test without standing up a view. + nonisolated static func needsDetails(_ reason: ReportReason) -> Bool { + reason == .other + } + + /// What the button will do, spelled on the button, because it differs by row. + nonisolated static func buttonTitle(for reason: ReportReason?) -> String { + reason.map { needsDetails($0) ? "Next" : "Submit Report" } ?? "Submit Report" + } + + /// Whitespace alone is not an answer, and this is the same trim + /// ``ReportDescription/build(reason:details:)`` applies — so the button and the wire agree on + /// what "blank" means. + nonisolated static func canSubmitDetails(_ details: String) -> Bool { + !details.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} diff --git a/Flipcash/Core/Screens/Conversation/ReportReason+Presentation.swift b/Flipcash/Core/Screens/Conversation/ReportReason+Presentation.swift new file mode 100644 index 000000000..b3a61da25 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ReportReason+Presentation.swift @@ -0,0 +1,81 @@ +// +// ReportReason+Presentation.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// The words each reason wears, and the slug it answers to in a test. +/// +/// Kept here rather than on the shared enum: ``ReportReason`` is compiled for both platforms, and +/// each app writes its own copy against it. What must not differ is the token +/// ``ReportDescription/build(reason:details:)`` puts on the wire, and that never comes from here. +/// +/// A table rather than a `switch`, because a Kotlin enum arrives in Swift as a class and the +/// compiler cannot check a `switch` over one for exhaustiveness. `ReportReasonPresentationTests` +/// does that check instead, walking ``ReportReason/displayOrder``, so a reason added upstream fails +/// a test here instead of shipping a row with no words on it. +extension ReportReason { + + /// The row's label. + var title: String { + Self.presentation[self]?.title ?? "" + } + + /// One line of scope under the label. + /// + /// Six words of label leave people guessing which bucket their situation belongs in, and a + /// guess resolves as the nearest wrong one — which costs both the reporter and whoever reads + /// it. These say what the row collects, and promise nothing about what happens next. + var summary: String { + Self.presentation[self]?.summary ?? "" + } + + /// Stable slug for accessibility identifiers. Not the wire token — that is the shared + /// builder's to decide. + var identifier: String { + Self.presentation[self]?.identifier ?? "" + } + + private struct Presentation { + let title: String + let summary: String + let identifier: String + } + + private static let presentation: [ReportReason: Presentation] = [ + .spam: Presentation( + title: "Spam", + summary: "Repeated unwanted messages, promotions, or links", + identifier: "spam" + ), + .scamorfraud: Presentation( + title: "Scam or Fraud", + summary: "Attempts to take money or account details by deception", + identifier: "scam-or-fraud" + ), + .harassment: Presentation( + title: "Harassment or Bullying", + summary: "Abuse, insults, or repeated unwanted contact", + identifier: "harassment" + ), + .sexualcontent: Presentation( + title: "Sexual Content", + summary: "Explicit content, or unwanted sexual messages", + identifier: "sexual-content" + ), + .violence: Presentation( + title: "Violence or Threats", + summary: "Threats of harm, or content showing violence", + identifier: "violence" + ), + .other: Presentation( + title: "Something Else", + summary: "None of these fit — tell us in your own words", + identifier: "other" + ), + ] +} diff --git a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift index 78248ce89..7859281f3 100644 --- a/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/ChatProfileScreen.swift @@ -79,8 +79,8 @@ struct ChatProfileScreen: View { ChatMuteStatusLabel(conversationID: conversationID) } - if isMember { - VStack(spacing: 0) { + VStack(spacing: 0) { + if isMember { Row(insets: rowInsets, accessory: .chevron) { Image(systemName: "person.badge.plus") .frame(minWidth: 45) @@ -92,7 +92,15 @@ struct ChatProfileScreen: View { .accessibilityIdentifier("chat-profile-invite") ChatMuteRow(conversationID: conversationID, insets: rowInsets) + } + + // Outside the membership check, unlike every other row here. A non-member has + // no link to hand out, nothing to leave, and no viewer state on a chat they are + // not in — but a group you have already left is the one you are most likely to + // report. + ReportRow(target: .chat(conversationID), insets: rowInsets) + if isMember { Row( insets: rowInsets, disabled: isLeaving, @@ -107,9 +115,9 @@ struct ChatProfileScreen: View { } .accessibilityIdentifier("chat-profile-leave") } - .font(.appDisplayXS) - .padding(.top, 24) } + .font(.appDisplayXS) + .padding(.top, 24) Spacer() } diff --git a/Flipcash/Core/Screens/Profile/ReportRow.swift b/Flipcash/Core/Screens/Profile/ReportRow.swift new file mode 100644 index 000000000..c49c26527 --- /dev/null +++ b/Flipcash/Core/Screens/Profile/ReportRow.swift @@ -0,0 +1,52 @@ +// +// ReportRow.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashCore +import FlipcashUI + +/// The report control as both profile surfaces draw it: one row that opens the reason sheet. +/// +/// A named unit rather than a copy per screen, for the same reason ``ChatMuteRow`` is one — except +/// that here the two screens report *different things*, so the target is the parameter. A user's +/// profile names the person; a group's names the chat. Never the other way around: a tip DM's id is +/// derived on this client, so a report naming that chat would name something the server has never +/// been told about. +struct ReportRow: View { + + let target: ReportTarget + let insets: EdgeInsets + var chevron: ChatMuteRow.Chevron = .standard + + @State private var isReporting = false + + var body: some View { + Row(insets: insets, accessory: chevron == .standard ? .chevron : nil) { + Image(systemName: "flag") + .frame(minWidth: 45) + Text("Report") + .foregroundStyle(.textMain) + + if chevron == .secondary { + // Nothing has claimed the slack, so the chevron needs the spacer the accessory + // would have brought. + Spacer() + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.textSecondary) + } + } action: { + isReporting = true + } + .accessibilityIdentifier("chat-report") + .fullScreenCover(isPresented: $isReporting) { + NavigationStack { + ReportFlowScreen(target: target) + } + } + } +} diff --git a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift index ab72b33bb..140d0ded8 100644 --- a/Flipcash/Core/Screens/Profile/UserProfileScreen.swift +++ b/Flipcash/Core/Screens/Profile/UserProfileScreen.swift @@ -92,12 +92,15 @@ private struct UserProfileContent: View { } VStack(spacing: 0) { - // Mute sits above Block the way it sits above Leave on a group: the reversible - // action first, the one that ends the relationship last. + // Mute, then report, then block: the reversible and routine first, then the + // one that asks someone else to look, then the one that ends the relationship. + // Same shape as a group's profile, where leaving holds the last place. if let conversationID { ChatMuteRow(conversationID: conversationID, insets: rowInsets, chevron: .secondary) } + ReportRow(target: .user(model.userID), insets: rowInsets, chevron: .secondary) + Row(insets: rowInsets) { Image(systemName: "nosign") .frame(minWidth: 45) @@ -111,6 +114,7 @@ private struct UserProfileContent: View { } action: { dialogItem = blockDialog() } + .accessibilityIdentifier("chat-block") } .font(.appDisplayXS) .padding(.top, 24) diff --git a/FlipcashCore/Sources/FlipcashCore/SharedCoreExports.swift b/FlipcashCore/Sources/FlipcashCore/SharedCoreExports.swift new file mode 100644 index 000000000..e62d7b1a7 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/SharedCoreExports.swift @@ -0,0 +1,7 @@ +// Re-exported so the app target can name the shared vocabulary -- `ReportReason` and +// `ReportDescription` -- through `import FlipcashCore`, the way it already reaches `Logging`. +// +// The app target must NOT depend on `SharedCoreKit` directly: adding it to an Xcode target's +// package dependencies gives the package a second identity, and the build fails with +// `multiple similar targets 'SharedCore', 'SharedCoreKit'`. See FlipcashCore/Package.swift. +@_exported import SharedCoreKit diff --git a/FlipcashTests/Chat/MessageCapabilityMenuTests.swift b/FlipcashTests/Chat/MessageCapabilityMenuTests.swift index 84872065a..a18e787d5 100644 --- a/FlipcashTests/Chat/MessageCapabilityMenuTests.swift +++ b/FlipcashTests/Chat/MessageCapabilityMenuTests.swift @@ -105,6 +105,40 @@ struct MessageCapabilityMenuTests { #expect(notified.map(\.1) == [.edit, .delete]) } + @Test("Report renders last, and is not marked destructive") + func reportIsOfferedButNotDestructive() { + let controller = loadedController([ + .message(ChatMessage(id: "1", text: "hi", sender: .other, actions: [.copy, .reply, .report])) + ]) + #expect(titles(menu(controller, at: 0)) == ["Copy", "Reply", "Report"]) + + guard let report = menu(controller, at: 0)?.children + .compactMap({ $0 as? UIAction }) + .first(where: { $0.title == "Report" }) else { + Issue.record("expected a Report action") + return + } + #expect(!report.attributes.contains(.destructive)) + } + + @Test("Report reports the row's id to the screen") + func reportNotifies() { + let controller = loadedController([ + .message(ChatMessage(id: "row-9", text: "hi", sender: .other, actions: [.report])) + ]) + var notified: [(String, MessageCapability)] = [] + controller.onMessageAction = { notified.append(($0, $1)) } + + guard let report = menu(controller, at: 0)?.children.first as? UIAction else { + Issue.record("expected a Report action") + return + } + report.performWithSender(nil, target: nil) + + #expect(notified.map(\.0) == ["row-9"]) + #expect(notified.map(\.1) == [.report]) + } + @Test("A deleted placeholder offers no menu") func tombstoneOffersNoMenu() { let controller = loadedController([ diff --git a/FlipcashTests/Chat/ReportFlowTests.swift b/FlipcashTests/Chat/ReportFlowTests.swift new file mode 100644 index 000000000..7b2c6b57d --- /dev/null +++ b/FlipcashTests/Chat/ReportFlowTests.swift @@ -0,0 +1,97 @@ +// +// ReportFlowTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import FlipcashCore +@testable import Flipcash + +// `@MainActor` for the same reason `MessageCapabilityMenuTests` is: the labels are an app-target +// extension, so their isolation is the app's default. +@MainActor +@Suite("Report flow") +struct ReportFlowTests { + + // MARK: - Which step a reason leads to + + @Test("Something Else collects details; every other reason files on confirm") + func onlyOtherNeedsDetails() { + for reason in ReportReason.displayOrder where reason != .other { + #expect( + ReportFlowState.needsDetails(reason) == false, + "\(reason.title) should file without a second step" + ) + } + #expect(ReportFlowState.needsDetails(.other)) + } + + @Test("The button says which of the two it will do") + func buttonTitleNamesTheOutcome() { + #expect(ReportFlowState.buttonTitle(for: .other) == "Next") + for reason in ReportReason.displayOrder where reason != .other { + #expect(ReportFlowState.buttonTitle(for: reason) == "Submit Report") + } + // Nothing picked yet: the button is disabled, but it should not be blank while it waits. + #expect(ReportFlowState.buttonTitle(for: nil) == "Submit Report") + } + + @Test("Whitespace is not an answer") + func detailsMustSaySomething() { + #expect(ReportFlowState.canSubmitDetails("") == false) + #expect(ReportFlowState.canSubmitDetails(" \n\t ") == false) + #expect(ReportFlowState.canSubmitDetails(" they kept messaging me ")) + } + + // MARK: - Every reason has words on it + + // The compiler cannot check a `switch` over a Kotlin enum for exhaustiveness, so this stands in + // for one: a reason added upstream arrives in `displayOrder` and fails here. + @Test("Every reason carries a label, a line of scope, and a slug") + func presentationIsTotal() { + for reason in ReportReason.displayOrder { + #expect(reason.title.isEmpty == false, "a reason is showing no label") + #expect(reason.summary.isEmpty == false, "\(reason.title) has no line of scope") + #expect(reason.identifier.isEmpty == false, "\(reason.title) has no slug") + } + + let identifiers = Set(ReportReason.displayOrder.map(\.identifier)) + #expect( + identifiers.count == ReportReason.displayOrder.count, + "two reasons share an accessibility identifier" + ) + } + + // MARK: - What reaches the wire + + // These read the shared builder rather than anything here: they fail if iOS ever starts + // assembling the `description` itself, which is the drift the shared module exists to prevent. + + @Test("No details is the bare token, with nothing trailing it") + func blankDetailsGiveABareToken() { + let built = ReportDescription.build(reason: .spam, details: nil) + #expect(built.contains("\n") == false) + #expect(built == ReportDescription.build(reason: .spam, details: " ")) + } + + @Test("Details follow the token on the next line, trimmed") + func detailsFollowTheToken() { + let built = ReportDescription.build(reason: .other, details: " they kept messaging me ") + let lines = built.components(separatedBy: "\n") + #expect(lines.count == 2) + #expect(lines[1] == "they kept messaging me") + // The first line is the token alone, so a server-side reader can split on the newline. + #expect(lines[0] == ReportDescription.build(reason: .other, details: nil)) + } + + @Test("Over-long details are truncated, not refused") + func longDetailsAreTruncated() { + let cap = Int(ReportDescription.maxDetailsLength) + let built = ReportDescription.build(reason: .other, details: String(repeating: "a", count: cap + 50)) + let lines = built.components(separatedBy: "\n") + #expect(lines.count == 2) + #expect(lines[1].count == cap) + } +} From 86c6a1a78fc8bc2999041d96e65a363d448d3d70 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 12:07:09 -0400 Subject: [PATCH 4/5] fix(reporting): put the close where every other screen puts it The flow carried Compose's arrangement, where one leading slot holds either a close or a back arrow. Every dismissal control in this app is trailing -- eight of them, no exceptions, including the two sheets this flow is closest to. Splitting them across the bar also stops the two controls sharing a slot, so back stays leading where iOS expects it, and close no longer disappears on the details step. --- .../Conversation/ReportFlowScreen.swift | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift index 2ea9aefaf..50ef977d5 100644 --- a/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift @@ -66,27 +66,30 @@ struct ReportFlowScreen: View { // A half-written report is not something to lose to a stray downward drag. .interactiveDismissDisabled() .toolbar { - ToolbarItem(placement: .topBarLeading) { - Button(action: goBack) { - // At the first step there is nothing to go back to, so the control leaves. - Image(systemName: state.step == .reason ? "xmark" : "chevron.backward") - .foregroundStyle(Color.textMain) + // Leaving and stepping back are different questions, so they get different corners. + // Every dismissal in this app is trailing; back is the only thing iOS puts leading. + if state.step == .details { + ToolbarItem(placement: .topBarLeading) { + Button(action: goBackToReasons) { + Image(systemName: "chevron.backward") + .foregroundStyle(Color.textMain) + } + .accessibilityIdentifier("report-nav-back") + .accessibilityLabel("Back") } - .accessibilityIdentifier("report-nav-leading") - .accessibilityLabel(state.step == .reason ? "Close" : "Back") + } + + ToolbarItem(placement: .topBarTrailing) { + CloseButton { dismiss() } + .accessibilityIdentifier("report-nav-close") } } .dialog(item: $dialog) } - private func goBack() { - switch state.step { - case .reason: - dismiss() - case .details: - direction = .backward - withAnimation { state.step = .reason } - } + private func goBackToReasons() { + direction = .backward + withAnimation { state.step = .reason } } /// The button at the bottom of the reasons: a second, deliberate press on a pick already made. From c748f9510be8dfe6edffd2122f55949a111b3c19 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 22 Sep 2026 12:17:08 -0400 Subject: [PATCH 5/5] fix(reporting): leave one way out of the details step The close sat beside the back arrow on both steps, so a half-written report had two exits and the one nearer the keyboard discarded it without asking. Past the reasons the only way out is back through them. One control per step, chosen by a switch, so a third step has to answer the question rather than inherit an answer. --- .../Conversation/ReportFlowScreen.swift | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift index 50ef977d5..253899773 100644 --- a/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ReportFlowScreen.swift @@ -66,9 +66,17 @@ struct ReportFlowScreen: View { // A half-written report is not something to lose to a stray downward drag. .interactiveDismissDisabled() .toolbar { - // Leaving and stepping back are different questions, so they get different corners. - // Every dismissal in this app is trailing; back is the only thing iOS puts leading. - if state.step == .details { + // One control per step, in the corner iOS puts it: dismissal trailing, back leading. + // Past the reasons the only way out is back through them, so the details a person has + // typed cannot be dropped by a control sitting next to the keyboard. + switch state.step { + case .reason: + ToolbarItem(placement: .topBarTrailing) { + CloseButton { dismiss() } + .accessibilityIdentifier("report-nav-close") + } + + case .details: ToolbarItem(placement: .topBarLeading) { Button(action: goBackToReasons) { Image(systemName: "chevron.backward") @@ -78,11 +86,6 @@ struct ReportFlowScreen: View { .accessibilityLabel("Back") } } - - ToolbarItem(placement: .topBarTrailing) { - CloseButton { dismiss() } - .accessibilityIdentifier("report-nav-close") - } } .dialog(item: $dialog) }