From 14247011dd427c80b61a71fcb5e7cb12ff336ec1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 13:19:04 -0400 Subject: [PATCH 1/8] feat(blob): encode chat photos and pick an upload policy Adds the chat-media downscale target, the JPEG quality ladder that walks down until a photo fits the policy's size cap, and the mime selection that follows the policy's own order. GetUploadPolicy is wrapped with a chatMessage access context. The shared chat_media.json vectors are synced and gated in CI. --- .../Blob/ChatMediaConstraints.swift | 30 + .../Blob/ChatMediaDownscale.swift | 48 ++ .../FlipcashCore/Blob/ChatMediaEncoder.swift | 151 +++++ .../FlipcashCore/Blob/UploadPolicyCache.swift | 63 ++ .../Clients/Flip API/FlipClient+Blob.swift | 5 + .../Flip API/Services/BlobService.swift | 42 ++ .../Sources/FlipcashCore/Models/Blob.swift | 72 ++ .../ChatMediaEncoderTests.swift | 171 +++++ .../FlipcashCoreTests/UploadPolicyTests.swift | 202 ++++++ .../ChatMediaConstraintVectorTests.swift | 45 ++ .../ChatMediaDownscaleVectorTests.swift | 56 ++ .../Fixtures/chat_media.json | 639 ++++++++++++++++++ FlipcashTests/Chat/Fixtures/chat_media.json | 639 ++++++++++++++++++ 13 files changed, 2163 insertions(+) create mode 100644 FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaConstraints.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaDownscale.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaEncoder.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Blob/UploadPolicyCache.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ChatMediaEncoderTests.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaConstraintVectorTests.swift create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaDownscaleVectorTests.swift create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_media.json create mode 100644 FlipcashTests/Chat/Fixtures/chat_media.json diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaConstraints.swift b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaConstraints.swift new file mode 100644 index 000000000..68c2a8d4e --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaConstraints.swift @@ -0,0 +1,30 @@ +// +// ChatMediaConstraints.swift +// FlipcashCore +// + +import Foundation + +/// Which `UploadPolicy.mimeTypeConstraints` entry governs an upload — the first one, in policy +/// order, whose pattern matches. Pure string matching; the caller owns fetching the policy +/// (`BlobService.getUploadPolicy`). +public enum ChatMediaConstraints { + + /// Returns the index of the first pattern in `patterns` (already in policy order) that + /// matches `mimeType`, or nil if none does. A pattern is `"type/subtype"`, `"type/*"`, or + /// `"*/*"`. + public static func firstMatchIndex(patterns: [String], mimeType: String) -> Int? { + let mimeParts = mimeType.split(separator: "/", maxSplits: 1) + guard mimeParts.count == 2 else { return nil } + let (mimeType0, mimeType1) = (mimeParts[0], mimeParts[1]) + + return patterns.firstIndex { pattern in + if pattern == "*/*" { return true } + let patternParts = pattern.split(separator: "/", maxSplits: 1) + guard patternParts.count == 2 else { return false } + let (patternType, patternSubtype) = (patternParts[0], patternParts[1]) + guard patternType == mimeType0 else { return false } + return patternSubtype == "*" || patternSubtype == mimeType1 + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaDownscale.swift b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaDownscale.swift new file mode 100644 index 000000000..a6a0372ab --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaDownscale.swift @@ -0,0 +1,48 @@ +// +// ChatMediaDownscale.swift +// FlipcashCore +// + +import Foundation + +/// The single scale factor a chat photo is downscaled by before upload — pure math, no image +/// codec, so it can be pinned by `chat_media.json`'s `downscale` section without a UIKit +/// dependency. `ChatMediaEncoder` is what actually re-encodes at this target. +public enum ChatMediaDownscale { + + public struct Target: Equatable, Sendable { + public let width: Int + public let height: Int + } + + /// `maxWidth`/`maxHeight`/`maxPixels` of `0` mean unbounded on that axis. Never upscales — + /// the resulting edges are always `<= source`. + public static func target(sourceWidth w: Int, sourceHeight h: Int, maxWidth: Int, maxHeight: Int, maxPixels: Int) -> Target { + guard w > 0, h > 0 else { return Target(width: max(w, 1), height: max(h, 1)) } + + var scale = 1.0 + if maxWidth > 0 { scale = min(scale, Double(maxWidth) / Double(w)) } + if maxHeight > 0 { scale = min(scale, Double(maxHeight) / Double(h)) } + if maxPixels > 0 { + let pixelScale = (Double(maxPixels) / (Double(w) * Double(h))).squareRoot() + scale = min(scale, pixelScale) + } + + var targetWidth = max(Int(Double(w) * scale), 1) + var targetHeight = max(Int(Double(h) * scale), 1) + + // Flooring both edges independently can still leave the product over maxPixels by a + // pixel or two; shave the longer edge until it fits rather than re-deriving scale. + if maxPixels > 0 { + while targetWidth * targetHeight > maxPixels, targetWidth > 1 || targetHeight > 1 { + if targetWidth >= targetHeight { + targetWidth -= 1 + } else { + targetHeight -= 1 + } + } + } + + return Target(width: targetWidth, height: targetHeight) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaEncoder.swift b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaEncoder.swift new file mode 100644 index 000000000..3774c9124 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaEncoder.swift @@ -0,0 +1,151 @@ +// +// ChatMediaEncoder.swift +// FlipcashCore +// + +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// Re-encodes a chat photo as an upright JPEG at its `ChatMediaDownscale` target, stepping +/// quality down the ladder until the bytes fit the policy's size ceiling. +/// +/// The fixture pins the ladder, not the encoded bytes — encoders differ across OS versions. +public struct ChatMediaEncoder: Sendable { + + public enum Error: Swift.Error, Equatable { + /// The image could not be drawn or encoded at all. + case encodingFailed + /// Every quality on the ladder produced more bytes than the ceiling. + case tooLarge + } + + /// The MIME type every encoded photo is uploaded as. + public static let mimeType = "image/jpeg" + + /// JPEG qualities tried in order, highest first. + public static let qualityLadder: [Double] = [0.9, 0.8, 0.7, 0.6] + + private let encodeJPEG: @Sendable (CGImage, Double) -> Data? + + /// Creates an encoder backed by ImageIO. + public init() { + self.init(encodeJPEG: Self.jpeg) + } + + init(encodeJPEG: @escaping @Sendable (CGImage, Double) -> Data?) { + self.encodeJPEG = encodeJPEG + } + + /// Returns `image` drawn upright at `target` and encoded at the highest ladder quality + /// whose stripped bytes fit `maxSizeBytes`. + /// + /// `orientation` is the source's display orientation; `target` is in display space, so + /// its edges are swapped relative to `image` for a sideways capture. + public func encode( + _ image: CGImage, + orientation: CGImagePropertyOrientation = .up, + target: ChatMediaDownscale.Target, + maxSizeBytes: Int + ) throws -> Data { + guard let upright = Self.render(image, orientation: orientation, target: target) else { + throw Error.encodingFailed + } + + var encodedAny = false + for quality in Self.qualityLadder { + guard let data = encodeJPEG(upright, quality) else { continue } + encodedAny = true + + // Measured after stripping, since the stripped bytes are what the reservation signs. + let stripped = JPEGMetadata.stripped(data) + if stripped.count <= maxSizeBytes { + return stripped + } + } + + throw encodedAny ? Error.tooLarge : Error.encodingFailed + } + + // MARK: - Drawing - + + /// Returns `image` redrawn at `target` with `orientation` baked into the pixels, on an + /// opaque white background since JPEG carries no alpha. + static func render(_ image: CGImage, orientation: CGImagePropertyOrientation, target: ChatMediaDownscale.Target) -> CGImage? { + let width = CGFloat(target.width) + let height = CGFloat(target.height) + + let colorSpace = image.colorSpace.flatMap { $0.model == .rgb ? $0 : nil } + ?? CGColorSpace(name: CGColorSpace.sRGB)! + + guard let context = CGContext( + data: nil, + width: target.width, + height: target.height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { + return nil + } + + context.setFillColor(CGColor(gray: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.interpolationQuality = .high + + var transform = CGAffineTransform.identity + var drawSize = CGSize(width: width, height: height) + + switch orientation { + case .up, .upMirrored: + break + case .down, .downMirrored: + transform = transform.translatedBy(x: width, y: height).rotated(by: .pi) + case .left, .leftMirrored: + transform = transform.translatedBy(x: width, y: 0).rotated(by: .pi / 2) + drawSize = CGSize(width: height, height: width) + case .right, .rightMirrored: + transform = transform.translatedBy(x: 0, y: height).rotated(by: -.pi / 2) + drawSize = CGSize(width: height, height: width) + } + + switch orientation { + case .upMirrored, .downMirrored, .leftMirrored, .rightMirrored: + transform = transform.translatedBy(x: drawSize.width, y: 0).scaledBy(x: -1, y: 1) + case .up, .down, .left, .right: + break + } + + context.concatenate(transform) + context.draw(image, in: CGRect(origin: .zero, size: drawSize)) + + return context.makeImage() + } + + // MARK: - Encoding - + + /// Encodes `image` as a JPEG at `quality`, carrying no source metadata. + static func jpeg(_ image: CGImage, quality: Double) -> Data? { + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + output, + UTType.jpeg.identifier as CFString, + 1, + nil + ) else { + return nil + } + + CGImageDestinationAddImage(destination, image, [ + kCGImageDestinationLossyCompressionQuality: quality, + ] as CFDictionary) + + guard CGImageDestinationFinalize(destination) else { + return nil + } + + return output as Data + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/UploadPolicyCache.swift b/FlipcashCore/Sources/FlipcashCore/Blob/UploadPolicyCache.swift new file mode 100644 index 000000000..fff413347 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Blob/UploadPolicyCache.swift @@ -0,0 +1,63 @@ +// +// UploadPolicyCache.swift +// FlipcashCore +// + +import Foundation + +/// Holds the last fetched `UploadPolicy` until its TTL lapses or the server +/// reports a different version. +/// +/// In memory only, so a cold launch fetches once. +actor UploadPolicyCache { + + private struct Entry { + let owner: PublicKey + let policy: UploadPolicy + let expiresAt: ContinuousClock.Instant? + } + + private let now: @Sendable () -> ContinuousClock.Instant + private var entry: Entry? + private var inFlight: (owner: PublicKey, task: Task)? + + init(now: @escaping @Sendable () -> ContinuousClock.Instant = { .now }) { + self.now = now + } + + /// Returns the cached policy for `owner`, or the result of `fetch` when + /// there is none still valid. + /// + /// Concurrent callers share one fetch, and a failed fetch is not cached. + func policy(for owner: KeyPair, fetch: @escaping @Sendable () async throws -> UploadPolicy) async throws -> UploadPolicy { + let key = owner.publicKey + + if let entry, entry.owner == key, entry.expiresAt.map({ now() < $0 }) ?? true { + return entry.policy + } + + if let inFlight, inFlight.owner == key { + return try await inFlight.task.value + } + + let task = Task { try await fetch() } + inFlight = (key, task) + defer { + if inFlight?.owner == key { + inFlight = nil + } + } + + let policy = try await task.value + entry = Entry(owner: key, policy: policy, expiresAt: policy.ttl.map { now().advanced(by: $0) }) + return policy + } + + /// Drops the cached policy when `version` differs from it — the server + /// echoes the version in force on a policy-driven denial. + func observe(version: String) { + if let entry, entry.policy.version != version { + self.entry = nil + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift index 9b5474f82..f1a7bb29a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift @@ -16,6 +16,11 @@ extension FlipClient { try await blobUploader.store(data, mimeType: mimeType, owner: owner) } + /// Returns the upload constraints in force for `owner`. + public func uploadPolicy(owner: KeyPair) async throws -> UploadPolicy { + try await blobService.uploadPolicy(owner: owner) + } + /// Returns a freshly minted download URL for a blob the caller owns, or — /// with an access context — one it can read through that surface. public func blobDownloadURL(blobID: BlobID, owner: KeyPair, accessContext: BlobAccessContext? = nil) async throws -> URL? { diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift index f592f9985..e4b8dc606 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift @@ -10,6 +10,7 @@ import GRPCCore final class BlobService: Sendable { private let service: Flipcash_Blob_V1_BlobStorage.Client + private let policyCache = UploadPolicyCache() init(client: GRPCClient) { self.service = Flipcash_Blob_V1_BlobStorage.Client(wrapping: client) @@ -43,8 +44,10 @@ extension BlobService: BlobReserving { case .denied: throw ErrorBlob.uploadDenied case .unsupportedType: + await observePolicyVersion(of: response) throw ErrorBlob.unsupportedType case .tooLarge: + await observePolicyVersion(of: response) throw ErrorBlob.tooLarge case .quotaExceeded: throw ErrorBlob.quotaExceeded @@ -83,6 +86,39 @@ extension BlobService: BlobReserving { } } + /// Returns the upload policy in force for `owner`, from the cache while it + /// is still valid. + func uploadPolicy(owner: KeyPair) async throws -> UploadPolicy { + try await policyCache.policy(for: owner) { [service] in + var request = Flipcash_Blob_V1_GetUploadPolicyRequest() + request.auth = owner.authFor(message: request) + + do { + let response = try await service.getUploadPolicy(request, options: .unaryDefault) + + switch response.result { + case .ok: + return UploadPolicy(response.policy) + case .denied: + throw ErrorBlob.uploadDenied + case .UNRECOGNIZED: + throw ErrorBlob.unknown + } + } catch let error as ErrorBlob { + throw error + } catch { + throw ErrorBlob.network(error) + } + } + } + + /// A policy-driven denial echoes the version in force, which retires a + /// stale cached policy. + private func observePolicyVersion(of response: Flipcash_Blob_V1_InitiateExternalUploadResponse) async { + guard response.hasPolicyVersion else { return } + await policyCache.observe(version: response.policyVersion.value) + } + /// Returns a freshly minted download URL for a blob the caller owns, or — /// with an access context — one it can read through that surface. /// @@ -185,12 +221,18 @@ public enum BlobAccessContext: Sendable { /// through it. case chatProfile(ConversationID) + /// Reading media shared into `conversationID` as a message. Authorized + /// while the caller is a member of the chat. + case chatMessage(ConversationID) + var proto: Flipcash_Blob_V1_AccessContext { switch self { case .userProfile(let userID): return .with { $0.userProfile = .with { $0.value = userID.data } } case .chatProfile(let conversationID): return .with { $0.chatProfile = conversationID.proto } + case .chatMessage(let conversationID): + return .with { $0.chat = conversationID.proto } } } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift b/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift index 6faa1e93c..048fcbd27 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift @@ -51,6 +51,46 @@ struct UploadTarget: Sendable, Equatable { } } +/// The upload constraints in force for a caller, fetched with `GetUploadPolicy`. +public struct UploadPolicy: Sendable, Equatable { + + /// The bounds one MIME-type pattern imposes on a matching upload. + public struct MimeConstraint: Sendable, Equatable { + public let pattern: String + public let maxSizeBytes: Int + public let image: ImageConstraints? + } + + /// Pixel bounds on an image upload; `0` on an axis means unbounded. + public struct ImageConstraints: Sendable, Equatable { + public let maxWidth: Int + public let maxHeight: Int + public let maxPixels: Int + } + + /// Opaque generation token, compared by equality only. + public let version: String + + /// How long the policy may be relied on, or nil when the server sent none. + public let ttl: Duration? + + /// Constraints ordered most specific first. + public let constraints: [MimeConstraint] + + init(version: String, ttl: Duration?, constraints: [MimeConstraint]) { + self.version = version + self.ttl = ttl + self.constraints = constraints + } + + /// Returns the constraint governing `mimeType` — the first match in policy + /// order — or nil when the policy accepts no upload of that type. + public func constraint(for mimeType: String) -> MimeConstraint? { + ChatMediaConstraints.firstMatchIndex(patterns: constraints.map(\.pattern), mimeType: mimeType) + .map { constraints[$0] } + } +} + // MARK: - Proto - extension BlobState { @@ -98,3 +138,35 @@ extension UploadTarget { ) } } + +extension UploadPolicy { + init(_ proto: Flipcash_Blob_V1_UploadPolicy) { + self.init( + version: proto.version.value, + ttl: proto.hasTtl ? .seconds(proto.ttl.seconds) + .nanoseconds(proto.ttl.nanos) : nil, + constraints: proto.mimeTypeConstraints.map(MimeConstraint.init) + ) + } +} + +extension UploadPolicy.MimeConstraint { + init(_ proto: Flipcash_Blob_V1_MimeTypeConstraints) { + let image: UploadPolicy.ImageConstraints? + switch proto.kind { + case .image(let bounds): + image = UploadPolicy.ImageConstraints( + maxWidth: Int(bounds.maxWidth), + maxHeight: Int(bounds.maxHeight), + maxPixels: Int(clamping: bounds.maxPixels) + ) + case nil: + image = nil + } + + self.init( + pattern: proto.mimeTypePattern, + maxSizeBytes: Int(clamping: proto.maxSizeBytes), + image: image + ) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatMediaEncoderTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatMediaEncoderTests.swift new file mode 100644 index 000000000..2f643a74e --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatMediaEncoderTests.swift @@ -0,0 +1,171 @@ +// +// ChatMediaEncoderTests.swift +// FlipcashCoreTests +// + +import CoreGraphics +import Foundation +import ImageIO +import Synchronization +import Testing +@testable import FlipcashCore + +@Suite("Chat media encoder") +struct ChatMediaEncoderTests { + + // MARK: - Quality ladder - + + @Test("Walks the ladder highest first and stops at the first quality that fits") + func stopsAtFirstFit() throws { + let attempts = Mutex<[Double]>([]) + let encoder = ChatMediaEncoder { _, quality in + attempts.withLock { $0.append(quality) } + return Data(count: quality > 0.75 ? 200 : 100) + } + + let data = try encoder.encode(Self.image(width: 4, height: 4), target: .init(width: 4, height: 4), maxSizeBytes: 100) + + #expect(data.count == 100) + #expect(attempts.withLock { $0 } == [0.9, 0.8, 0.7]) + } + + @Test("Throws tooLarge when no quality on the ladder fits") + func givesUpAfterLastStep() throws { + let attempts = Mutex<[Double]>([]) + let encoder = ChatMediaEncoder { _, quality in + attempts.withLock { $0.append(quality) } + return Data(count: 200) + } + + #expect(throws: ChatMediaEncoder.Error.tooLarge) { + try encoder.encode(Self.image(width: 4, height: 4), target: .init(width: 4, height: 4), maxSizeBytes: 100) + } + #expect(attempts.withLock { $0 } == ChatMediaEncoder.qualityLadder) + } + + @Test("Throws encodingFailed when no quality encodes at all") + func encodeFailure() throws { + let encoder = ChatMediaEncoder { _, _ in nil } + + #expect(throws: ChatMediaEncoder.Error.encodingFailed) { + try encoder.encode(Self.image(width: 4, height: 4), target: .init(width: 4, height: 4), maxSizeBytes: 100) + } + } + + /// The reservation signs the stripped bytes, so a JPEG that fits only once + /// its EXIF is gone must be accepted — and handed back stripped. + @Test("Measures the size after stripping metadata") + func measuresStrippedSize() throws { + let exif = Data([0xFF, 0xE1, 0x00, 0x0A] + Array("Exif\0\0GPS".utf8).prefix(8)) + let bare = Data([0xFF, 0xD8, 0xFF, 0xDA, 0x00, 0x02, 0x11, 0xFF, 0xD9]) + let withExif = bare.prefix(2) + exif + bare.dropFirst(2) + let encoder = ChatMediaEncoder { _, _ in withExif } + + let data = try encoder.encode(Self.image(width: 4, height: 4), target: .init(width: 4, height: 4), maxSizeBytes: bare.count) + + #expect(data == bare) + } + + // MARK: - Real encode - + + @Test("Encodes a real JPEG at the target size") + func encodesAtTarget() throws { + let data = try ChatMediaEncoder().encode( + Self.image(width: 40, height: 20), + target: .init(width: 20, height: 10), + maxSizeBytes: 1_000_000 + ) + + let (width, height) = try Self.pixelSize(of: data) + #expect(width == 20) + #expect(height == 10) + #expect(data.prefix(2) == Data([0xFF, 0xD8])) + } + + /// A portrait capture arrives as landscape pixels tagged `.right`; the + /// target is in display space, and the output must decode upright without + /// an orientation tag. + @Test("Bakes a sideways orientation into upright pixels") + func bakesOrientation() throws { + let data = try ChatMediaEncoder().encode( + Self.image(width: 40, height: 20), + orientation: .right, + target: .init(width: 10, height: 20), + maxSizeBytes: 1_000_000 + ) + + let (width, height) = try Self.pixelSize(of: data) + #expect(width == 10) + #expect(height == 20) + + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + let orientation = properties?[kCGImagePropertyOrientation] as? UInt32 + #expect(orientation == nil || orientation == CGImagePropertyOrientation.up.rawValue) + } + + /// `.right` stores the displayed top along the pixels' left edge, so a + /// source red on its left half must display red on top. + @Test("Rotates a .right capture the way a viewer displays it") + func rotatesInDisplayDirection() throws { + let data = try ChatMediaEncoder().encode( + Self.image(width: 40, height: 20, leftHalf: CGColor(red: 1, green: 0, blue: 0, alpha: 1)), + orientation: .right, + target: .init(width: 10, height: 20), + maxSizeBytes: 1_000_000 + ) + + let top = try Self.pixel(of: data, x: 5, y: 2) + let bottom = try Self.pixel(of: data, x: 5, y: 17) + #expect(top.red > 200 && top.blue < 60) + #expect(bottom.blue > 200 && bottom.red < 60) + } + + // MARK: - Fixtures - + + private static func image(width: Int, height: Int, leftHalf: CGColor? = nil) throws -> CGImage { + let context = try #require(CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )) + context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + if let leftHalf { + context.setFillColor(CGColor(red: 0, green: 0, blue: 1, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + context.setFillColor(leftHalf) + context.fill(CGRect(x: 0, y: 0, width: width / 2, height: height)) + } + return try #require(context.makeImage()) + } + + /// Returns the decoded RGB at `(x, y)`, with `y` counted from the top. + private static func pixel(of data: Data, x: Int, y: Int) throws -> (red: UInt8, green: UInt8, blue: UInt8) { + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + var buffer = [UInt8](repeating: 0, count: image.width * image.height * 4) + let context = try #require(CGContext( + data: &buffer, + width: image.width, + height: image.height, + bitsPerComponent: 8, + bytesPerRow: image.width * 4, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + )) + context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height)) + let offset = (y * image.width + x) * 4 + return (buffer[offset], buffer[offset + 1], buffer[offset + 2]) + } + + private static func pixelSize(of data: Data) throws -> (Int, Int) { + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + let image = try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + return (image.width, image.height) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift new file mode 100644 index 000000000..5c6644374 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift @@ -0,0 +1,202 @@ +// +// UploadPolicyTests.swift +// FlipcashCoreTests +// + +import Foundation +import Synchronization +import Testing +import FlipcashAPI +@testable import FlipcashCore + +@Suite("Upload policy") +struct UploadPolicyTests { + + // MARK: - Proto mapping - + + @Test("Maps version, TTL, and constraints in policy order") + func mapsProto() { + let policy = UploadPolicy(Self.proto(version: "v7", ttlSeconds: 300)) + + #expect(policy.version == "v7") + #expect(policy.ttl == .seconds(300)) + #expect(policy.constraints.map(\.pattern) == ["image/jpeg", "image/*", "*/*"]) + #expect(policy.constraints[0].maxSizeBytes == 5_000_000) + #expect(policy.constraints[0].image == .init(maxWidth: 2048, maxHeight: 2048, maxPixels: 4_000_000)) + } + + /// An opaque-blob entry sets no `kind`, and reading `.image` off it would + /// hand back zeroes that read as "unbounded" rather than "not an image". + @Test("An entry without image bounds maps to no image constraints") + func opaqueEntryHasNoImageConstraints() { + let policy = UploadPolicy(Self.proto(version: "v7", ttlSeconds: 300)) + + #expect(policy.constraints[2].image == nil) + } + + @Test("An unset TTL maps to nil, not zero") + func unsetTTLIsNil() { + var proto = Self.proto(version: "v7", ttlSeconds: 300) + proto.clearTtl() + + #expect(UploadPolicy(proto).ttl == nil) + } + + @Test("Picks the first constraint whose pattern matches, in policy order") + func constraintForMimeType() { + let policy = UploadPolicy(Self.proto(version: "v7", ttlSeconds: 300)) + + #expect(policy.constraint(for: "image/jpeg")?.pattern == "image/jpeg") + #expect(policy.constraint(for: "image/png")?.pattern == "image/*") + #expect(policy.constraint(for: "video/mp4")?.pattern == "*/*") + } + + // MARK: - Access context - + + /// `chatProfile` authorizes only a chat's current picture; message media + /// must go through the general chat scope or every read is denied. + @Test("Chat message media reads through the chat scope, not the chat profile") + func chatMessageUsesChatScope() { + let conversationID = ConversationID(data: Data(repeating: 7, count: 32)) + + #expect(BlobAccessContext.chatMessage(conversationID).proto.scope == .chat(conversationID.proto)) + #expect(BlobAccessContext.chatProfile(conversationID).proto.scope == .chatProfile(conversationID.proto)) + } + + // MARK: - Cache - + + @Test("A second request is served from the cache") + func cachesAcrossCalls() async throws { + let cache = UploadPolicyCache() + let fetches = Mutex(0) + let owner = try Self.owner() + + _ = try await cache.policy(for: owner) { fetches.withLock { $0 += 1 }; return Self.policy("v1") } + let second = try await cache.policy(for: owner) { fetches.withLock { $0 += 1 }; return Self.policy("v2") } + + #expect(second.version == "v1") + #expect(fetches.withLock { $0 } == 1) + } + + @Test("Concurrent requests share one fetch") + func coalescesConcurrentFetches() async throws { + let cache = UploadPolicyCache() + let fetches = Mutex(0) + let owner = try Self.owner() + + let versions = try await withThrowingTaskGroup(of: String.self) { group in + for _ in 0..<5 { + group.addTask { + try await cache.policy(for: owner) { + fetches.withLock { $0 += 1 } + try await Task.sleep(for: .milliseconds(20)) + return Self.policy("v1") + }.version + } + } + return try await group.reduce(into: []) { $0.append($1) } + } + + #expect(versions == Array(repeating: "v1", count: 5)) + #expect(fetches.withLock { $0 } == 1) + } + + @Test("A different version echoed by the server forces a re-fetch") + func differentVersionInvalidates() async throws { + let cache = UploadPolicyCache() + let owner = try Self.owner() + + _ = try await cache.policy(for: owner) { Self.policy("v1") } + await cache.observe(version: "v2") + let refreshed = try await cache.policy(for: owner) { Self.policy("v2") } + + #expect(refreshed.version == "v2") + } + + @Test("The cached version echoed back keeps the cache") + func sameVersionKeepsCache() async throws { + let cache = UploadPolicyCache() + let owner = try Self.owner() + + _ = try await cache.policy(for: owner) { Self.policy("v1") } + await cache.observe(version: "v1") + let kept = try await cache.policy(for: owner) { Self.policy("v2") } + + #expect(kept.version == "v1") + } + + @Test("An expired TTL forces a re-fetch") + func expiredTTLRefetches() async throws { + let now = Mutex(ContinuousClock.now) + let cache = UploadPolicyCache(now: { now.withLock { $0 } }) + let owner = try Self.owner() + + _ = try await cache.policy(for: owner) { Self.policy("v1", ttl: .seconds(60)) } + + now.withLock { $0 = $0.advanced(by: .seconds(59)) } + #expect(try await cache.policy(for: owner) { Self.policy("v2") }.version == "v1") + + now.withLock { $0 = $0.advanced(by: .seconds(1)) } + #expect(try await cache.policy(for: owner) { Self.policy("v2") }.version == "v2") + } + + /// The policy is "the constraints in force for the caller", and the client + /// outlives a logout. + @Test("A different owner does not see another owner's policy") + func keyedByOwner() async throws { + let cache = UploadPolicyCache() + + _ = try await cache.policy(for: try Self.owner()) { Self.policy("v1") } + let other = try await cache.policy(for: try Self.owner()) { Self.policy("v2") } + + #expect(other.version == "v2") + } + + @Test("A failed fetch is not cached") + func failureIsNotCached() async throws { + let cache = UploadPolicyCache() + let owner = try Self.owner() + + await #expect(throws: ErrorBlob.self) { + _ = try await cache.policy(for: owner) { throw ErrorBlob.uploadDenied } + } + #expect(try await cache.policy(for: owner) { Self.policy("v1") }.version == "v1") + } + + // MARK: - Fixtures - + + private static func owner() throws -> KeyPair { + try #require(KeyPair.generate()) + } + + private static func policy(_ version: String, ttl: Duration? = nil) -> UploadPolicy { + UploadPolicy(version: version, ttl: ttl, constraints: []) + } + + private static func proto(version: String, ttlSeconds: Int64) -> Flipcash_Blob_V1_UploadPolicy { + .with { + $0.version = .with { $0.value = version } + $0.ttl = .with { $0.seconds = ttlSeconds } + $0.mimeTypeConstraints = [ + .with { + $0.mimeTypePattern = "image/jpeg" + $0.maxSizeBytes = 5_000_000 + $0.image = .with { + $0.maxWidth = 2048 + $0.maxHeight = 2048 + $0.maxPixels = 4_000_000 + } + }, + .with { + $0.mimeTypePattern = "image/*" + $0.maxSizeBytes = 10_000_000 + $0.image = .with { $0.maxWidth = 4096 } + }, + .with { + $0.mimeTypePattern = "*/*" + $0.maxSizeBytes = 1_000_000 + }, + ] + } + } +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaConstraintVectorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaConstraintVectorTests.swift new file mode 100644 index 000000000..c4cb531fc --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaConstraintVectorTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import FlipcashCore + +/// Constraint-selection half of `test-vectors/chat_media.json`. The canonical copy lives in the +/// orchestrator repo; this one is synced. A failure here is either a real regression or a +/// deliberate cross-platform decision that has to be made in the canonical fixture and +/// re-synced to both platforms — never a local edit. +@Suite struct ChatMediaConstraintVectorTests { + + struct Vector: Decodable, Sendable, CustomTestStringConvertible { + let name: String + let patterns: [String] + let mimeType: String + let expectedIndex: Int? + let note: String + + var testDescription: String { name } + } + + struct Fixture: Decodable { + let constraintSelection: [Vector] + } + + /// Loaded when the suite is built, so each vector runs, and fails, as its own case. A fixture + /// that is missing or doesn't decode fails `fixtureLoads` instead of every vector at once. + static let vectors: [Vector] = (try? loadFixture().constraintSelection) ?? [] + + private static func loadFixture() throws -> Fixture { + let url = try #require( + Bundle.module.url(forResource: "chat_media", withExtension: "json", subdirectory: "Fixtures") + ) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + @Test func fixtureLoads() throws { + #expect(try !Self.loadFixture().constraintSelection.isEmpty) + } + + @Test(arguments: vectors) + func firstMatchIndexMatchesTheCrossPlatformVector(_ vector: Vector) { + let result = ChatMediaConstraints.firstMatchIndex(patterns: vector.patterns, mimeType: vector.mimeType) + #expect(result == vector.expectedIndex, "\(vector.note)") + } +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaDownscaleVectorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaDownscaleVectorTests.swift new file mode 100644 index 000000000..47a16e8c5 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatMediaDownscaleVectorTests.swift @@ -0,0 +1,56 @@ +import Foundation +import Testing +@testable import FlipcashCore + +/// Downscale half of `test-vectors/chat_media.json`. The canonical copy lives in the +/// orchestrator repo; this one is synced. A failure here is either a real regression or a +/// deliberate cross-platform decision that has to be made in the canonical fixture and +/// re-synced to both platforms — never a local edit. +@Suite struct ChatMediaDownscaleVectorTests { + + struct Vector: Decodable, Sendable, CustomTestStringConvertible { + struct Expected: Decodable, Sendable { let width: Int; let height: Int } + let name: String + let sourceWidth: Int + let sourceHeight: Int + let maxWidth: Int + let maxHeight: Int + let maxPixels: Int + let expected: Expected + let note: String + + var testDescription: String { name } + } + + struct Fixture: Decodable { + let downscale: [Vector] + } + + /// Loaded when the suite is built, so each vector runs, and fails, as its own case. A fixture + /// that is missing or doesn't decode fails `fixtureLoads` instead of every vector at once. + static let vectors: [Vector] = (try? loadFixture().downscale) ?? [] + + private static func loadFixture() throws -> Fixture { + let url = try #require( + Bundle.module.url(forResource: "chat_media", withExtension: "json", subdirectory: "Fixtures") + ) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + @Test func fixtureLoads() throws { + #expect(try !Self.loadFixture().downscale.isEmpty) + } + + @Test(arguments: vectors) + func targetMatchesTheCrossPlatformVector(_ vector: Vector) { + let result = ChatMediaDownscale.target( + sourceWidth: vector.sourceWidth, + sourceHeight: vector.sourceHeight, + maxWidth: vector.maxWidth, + maxHeight: vector.maxHeight, + maxPixels: vector.maxPixels + ) + #expect(result.width == vector.expected.width, "\(vector.note)") + #expect(result.height == vector.expected.height, "\(vector.note)") + } +} diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_media.json b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_media.json new file mode 100644 index 000000000..54f976f27 --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_media.json @@ -0,0 +1,639 @@ +{ + "algorithm": "chat-media-photos", + "note": "Behavior fixture for docs/superpowers/specs/2026-09-28-chat-media-photos-design.md. Heights are points or dp as floats; compare with a 0.001 tolerance.", + "maxAttachments": 10, + "minAspect": 0.5, + "maxAspect": 2.0, + "jpegQualityLadder": [ + 0.9, + 0.8, + 0.7, + 0.6 + ], + "uploadMimeType": "image/jpeg", + "fanOut": [ + { + "name": "text-only", + "chips": [], + "text": "hello", + "replyTo": null, + "messages": [ + { + "kind": "text", + "chip": null, + "caption": null, + "text": "hello", + "replyTo": null + } + ], + "note": "No chips: a text message, exactly as today." + }, + { + "name": "text-only-reply", + "chips": [], + "text": "hello", + "replyTo": "m1", + "messages": [ + { + "kind": "text", + "chip": null, + "caption": null, + "text": "hello", + "replyTo": "m1" + } + ], + "note": "No chips, with a reply: a text reply, exactly as today." + }, + { + "name": "empty", + "chips": [], + "text": "", + "replyTo": null, + "messages": [], + "note": "Nothing to send. Send is disabled, so this pins that the builder agrees." + }, + { + "name": "one-photo", + "chips": [ + "a" + ], + "text": "", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "One chip, no text: one media message with no caption." + }, + { + "name": "one-photo-caption", + "chips": [ + "a" + ], + "text": "look", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": "look", + "text": null, + "replyTo": null + } + ], + "note": "One chip with text: the text is the caption." + }, + { + "name": "three-photos-caption", + "chips": [ + "a", + "b", + "c" + ], + "text": "from today", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": "from today", + "text": null, + "replyTo": null + } + ], + "note": "The caption rides on the last message, so it lands under the last image." + }, + { + "name": "three-photos-reply", + "chips": [ + "a", + "b", + "c" + ], + "text": "", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": "m1" + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "The reply attaches to the first message only." + }, + { + "name": "three-photos-caption-reply", + "chips": [ + "a", + "b", + "c" + ], + "text": "these?", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": "m1" + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": "these?", + "text": null, + "replyTo": null + } + ], + "note": "Reply on the first, caption on the last." + }, + { + "name": "one-photo-caption-reply", + "chips": [ + "a" + ], + "text": "this one", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": "this one", + "text": null, + "replyTo": "m1" + } + ], + "note": "With one chip, the same message carries both reply and caption." + }, + { + "name": "ten-photos", + "chips": [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j" + ], + "text": "", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "d", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "e", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "f", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "g", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "h", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "i", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "j", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "The composer maximum. Order is chip order." + } + ], + "bubble": [ + { + "name": "square", + "imageWidth": 1000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 240.0, + "cropped": false + }, + "note": "1:1 fills the width, same height." + }, + { + "name": "landscape-4-3", + "imageWidth": 4032, + "imageHeight": 3024, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 180.0, + "cropped": false + }, + "note": "Landscape phone photo." + }, + { + "name": "portrait-3-4", + "imageWidth": 3024, + "imageHeight": 4032, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 320.0, + "cropped": false + }, + "note": "Portrait phone photo." + }, + { + "name": "wide-at-limit", + "imageWidth": 2000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 120.0, + "cropped": false + }, + "note": "Exactly 1:2 is not cropped." + }, + { + "name": "panorama", + "imageWidth": 8000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 120.0, + "cropped": true + }, + "note": "Wider than 1:2 clamps to 1:2 and crops." + }, + { + "name": "tall-at-limit", + "imageWidth": 1000, + "imageHeight": 2000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 480.0, + "cropped": false + }, + "note": "Exactly 2:1 is not cropped." + }, + { + "name": "screenshot", + "imageWidth": 1179, + "imageHeight": 2556, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 480.0, + "cropped": true + }, + "note": "An iPhone screenshot is taller than 2:1 and crops." + }, + { + "name": "tiny-upscaled", + "imageWidth": 40, + "imageHeight": 30, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 180.0, + "cropped": false + }, + "note": "Small images still take the full width. The bubble never shrinks to the image." + }, + { + "name": "missing-metadata", + "imageWidth": 0, + "imageHeight": 0, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 240.0, + "cropped": false + }, + "note": "No dimensions (should not happen after READY): a square placeholder." + }, + { + "name": "android-dp", + "imageWidth": 4032, + "imageHeight": 3024, + "maxWidth": 264.5, + "expected": { + "width": 264.5, + "height": 198.375, + "cropped": false + }, + "note": "Fractional widths are fine; compare with 0.001 tolerance." + } + ], + "downscale": [ + { + "name": "within-bounds", + "sourceWidth": 1200, + "sourceHeight": 900, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 1200, + "height": 900 + }, + "note": "Already fits: unchanged." + }, + { + "name": "edge-bound-landscape", + "sourceWidth": 4032, + "sourceHeight": 3024, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 2048, + "height": 1536 + }, + "note": "Long edge binds." + }, + { + "name": "edge-bound-portrait", + "sourceWidth": 3024, + "sourceHeight": 4032, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 1536, + "height": 2048 + }, + "note": "Long edge binds, portrait." + }, + { + "name": "asymmetric-bounds", + "sourceWidth": 4000, + "sourceHeight": 4000, + "maxWidth": 3000, + "maxHeight": 1000, + "maxPixels": 0, + "expected": { + "width": 1000, + "height": 1000 + }, + "note": "The tighter of width and height binds." + }, + { + "name": "pixels-bind-first", + "sourceWidth": 4032, + "sourceHeight": 3024, + "maxWidth": 4096, + "maxHeight": 4096, + "maxPixels": 4000000, + "expected": { + "width": 2309, + "height": 1732 + }, + "note": "Edges would allow the original; max_pixels binds instead." + }, + { + "name": "pixels-and-edges", + "sourceWidth": 8000, + "sourceHeight": 6000, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 2000000, + "expected": { + "width": 1632, + "height": 1224 + }, + "note": "max_pixels is tighter than the edge limit here." + }, + { + "name": "unbounded", + "sourceWidth": 5000, + "sourceHeight": 3000, + "maxWidth": 0, + "maxHeight": 0, + "maxPixels": 0, + "expected": { + "width": 5000, + "height": 3000 + }, + "note": "No constraints: unchanged." + }, + { + "name": "never-upscale", + "sourceWidth": 300, + "sourceHeight": 200, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 16000000, + "expected": { + "width": 300, + "height": 200 + }, + "note": "Small source: unchanged." + }, + { + "name": "panorama-min-edge", + "sourceWidth": 20000, + "sourceHeight": 10, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 2048, + "height": 1 + }, + "note": "An edge never goes below 1." + }, + { + "name": "odd-pixels", + "sourceWidth": 3001, + "sourceHeight": 2999, + "maxWidth": 0, + "maxHeight": 0, + "maxPixels": 1000000, + "expected": { + "width": 1000, + "height": 999 + }, + "note": "Floor rounding still fits max_pixels." + } + ], + "constraintSelection": [ + { + "name": "exact-first", + "patterns": [ + "image/jpeg", + "image/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 0, + "note": "Exact match wins when listed first." + }, + { + "name": "wildcard-image", + "patterns": [ + "image/png", + "image/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 1, + "note": "image/png does not match; image/* does." + }, + { + "name": "catch-all", + "patterns": [ + "video/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 1, + "note": "Only */* matches." + }, + { + "name": "none", + "patterns": [ + "video/*", + "application/pdf" + ], + "mimeType": "image/jpeg", + "expectedIndex": null, + "note": "No entry: do not upload." + }, + { + "name": "policy-order-wins", + "patterns": [ + "*/*", + "image/jpeg" + ], + "mimeType": "image/jpeg", + "expectedIndex": 0, + "note": "Policy order is authoritative, even if a later entry is more specific." + } + ], + "strings": [ + { + "name": "no-caption", + "caption": null, + "expected": { + "snippet": "Photo", + "preview": "📷 Photo" + }, + "note": "No caption: the word Photo." + }, + { + "name": "caption", + "caption": "from today", + "expected": { + "snippet": "from today", + "preview": "📷 from today" + }, + "note": "The caption replaces the word Photo." + }, + { + "name": "emoji-caption", + "caption": "🎉 done", + "expected": { + "snippet": "🎉 done", + "preview": "📷 🎉 done" + }, + "note": "Captions are used as written." + } + ] +} diff --git a/FlipcashTests/Chat/Fixtures/chat_media.json b/FlipcashTests/Chat/Fixtures/chat_media.json new file mode 100644 index 000000000..54f976f27 --- /dev/null +++ b/FlipcashTests/Chat/Fixtures/chat_media.json @@ -0,0 +1,639 @@ +{ + "algorithm": "chat-media-photos", + "note": "Behavior fixture for docs/superpowers/specs/2026-09-28-chat-media-photos-design.md. Heights are points or dp as floats; compare with a 0.001 tolerance.", + "maxAttachments": 10, + "minAspect": 0.5, + "maxAspect": 2.0, + "jpegQualityLadder": [ + 0.9, + 0.8, + 0.7, + 0.6 + ], + "uploadMimeType": "image/jpeg", + "fanOut": [ + { + "name": "text-only", + "chips": [], + "text": "hello", + "replyTo": null, + "messages": [ + { + "kind": "text", + "chip": null, + "caption": null, + "text": "hello", + "replyTo": null + } + ], + "note": "No chips: a text message, exactly as today." + }, + { + "name": "text-only-reply", + "chips": [], + "text": "hello", + "replyTo": "m1", + "messages": [ + { + "kind": "text", + "chip": null, + "caption": null, + "text": "hello", + "replyTo": "m1" + } + ], + "note": "No chips, with a reply: a text reply, exactly as today." + }, + { + "name": "empty", + "chips": [], + "text": "", + "replyTo": null, + "messages": [], + "note": "Nothing to send. Send is disabled, so this pins that the builder agrees." + }, + { + "name": "one-photo", + "chips": [ + "a" + ], + "text": "", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "One chip, no text: one media message with no caption." + }, + { + "name": "one-photo-caption", + "chips": [ + "a" + ], + "text": "look", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": "look", + "text": null, + "replyTo": null + } + ], + "note": "One chip with text: the text is the caption." + }, + { + "name": "three-photos-caption", + "chips": [ + "a", + "b", + "c" + ], + "text": "from today", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": "from today", + "text": null, + "replyTo": null + } + ], + "note": "The caption rides on the last message, so it lands under the last image." + }, + { + "name": "three-photos-reply", + "chips": [ + "a", + "b", + "c" + ], + "text": "", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": "m1" + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "The reply attaches to the first message only." + }, + { + "name": "three-photos-caption-reply", + "chips": [ + "a", + "b", + "c" + ], + "text": "these?", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": "m1" + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": "these?", + "text": null, + "replyTo": null + } + ], + "note": "Reply on the first, caption on the last." + }, + { + "name": "one-photo-caption-reply", + "chips": [ + "a" + ], + "text": "this one", + "replyTo": "m1", + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": "this one", + "text": null, + "replyTo": "m1" + } + ], + "note": "With one chip, the same message carries both reply and caption." + }, + { + "name": "ten-photos", + "chips": [ + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j" + ], + "text": "", + "replyTo": null, + "messages": [ + { + "kind": "media", + "chip": "a", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "b", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "c", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "d", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "e", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "f", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "g", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "h", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "i", + "caption": null, + "text": null, + "replyTo": null + }, + { + "kind": "media", + "chip": "j", + "caption": null, + "text": null, + "replyTo": null + } + ], + "note": "The composer maximum. Order is chip order." + } + ], + "bubble": [ + { + "name": "square", + "imageWidth": 1000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 240.0, + "cropped": false + }, + "note": "1:1 fills the width, same height." + }, + { + "name": "landscape-4-3", + "imageWidth": 4032, + "imageHeight": 3024, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 180.0, + "cropped": false + }, + "note": "Landscape phone photo." + }, + { + "name": "portrait-3-4", + "imageWidth": 3024, + "imageHeight": 4032, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 320.0, + "cropped": false + }, + "note": "Portrait phone photo." + }, + { + "name": "wide-at-limit", + "imageWidth": 2000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 120.0, + "cropped": false + }, + "note": "Exactly 1:2 is not cropped." + }, + { + "name": "panorama", + "imageWidth": 8000, + "imageHeight": 1000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 120.0, + "cropped": true + }, + "note": "Wider than 1:2 clamps to 1:2 and crops." + }, + { + "name": "tall-at-limit", + "imageWidth": 1000, + "imageHeight": 2000, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 480.0, + "cropped": false + }, + "note": "Exactly 2:1 is not cropped." + }, + { + "name": "screenshot", + "imageWidth": 1179, + "imageHeight": 2556, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 480.0, + "cropped": true + }, + "note": "An iPhone screenshot is taller than 2:1 and crops." + }, + { + "name": "tiny-upscaled", + "imageWidth": 40, + "imageHeight": 30, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 180.0, + "cropped": false + }, + "note": "Small images still take the full width. The bubble never shrinks to the image." + }, + { + "name": "missing-metadata", + "imageWidth": 0, + "imageHeight": 0, + "maxWidth": 240, + "expected": { + "width": 240, + "height": 240.0, + "cropped": false + }, + "note": "No dimensions (should not happen after READY): a square placeholder." + }, + { + "name": "android-dp", + "imageWidth": 4032, + "imageHeight": 3024, + "maxWidth": 264.5, + "expected": { + "width": 264.5, + "height": 198.375, + "cropped": false + }, + "note": "Fractional widths are fine; compare with 0.001 tolerance." + } + ], + "downscale": [ + { + "name": "within-bounds", + "sourceWidth": 1200, + "sourceHeight": 900, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 1200, + "height": 900 + }, + "note": "Already fits: unchanged." + }, + { + "name": "edge-bound-landscape", + "sourceWidth": 4032, + "sourceHeight": 3024, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 2048, + "height": 1536 + }, + "note": "Long edge binds." + }, + { + "name": "edge-bound-portrait", + "sourceWidth": 3024, + "sourceHeight": 4032, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 1536, + "height": 2048 + }, + "note": "Long edge binds, portrait." + }, + { + "name": "asymmetric-bounds", + "sourceWidth": 4000, + "sourceHeight": 4000, + "maxWidth": 3000, + "maxHeight": 1000, + "maxPixels": 0, + "expected": { + "width": 1000, + "height": 1000 + }, + "note": "The tighter of width and height binds." + }, + { + "name": "pixels-bind-first", + "sourceWidth": 4032, + "sourceHeight": 3024, + "maxWidth": 4096, + "maxHeight": 4096, + "maxPixels": 4000000, + "expected": { + "width": 2309, + "height": 1732 + }, + "note": "Edges would allow the original; max_pixels binds instead." + }, + { + "name": "pixels-and-edges", + "sourceWidth": 8000, + "sourceHeight": 6000, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 2000000, + "expected": { + "width": 1632, + "height": 1224 + }, + "note": "max_pixels is tighter than the edge limit here." + }, + { + "name": "unbounded", + "sourceWidth": 5000, + "sourceHeight": 3000, + "maxWidth": 0, + "maxHeight": 0, + "maxPixels": 0, + "expected": { + "width": 5000, + "height": 3000 + }, + "note": "No constraints: unchanged." + }, + { + "name": "never-upscale", + "sourceWidth": 300, + "sourceHeight": 200, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 16000000, + "expected": { + "width": 300, + "height": 200 + }, + "note": "Small source: unchanged." + }, + { + "name": "panorama-min-edge", + "sourceWidth": 20000, + "sourceHeight": 10, + "maxWidth": 2048, + "maxHeight": 2048, + "maxPixels": 0, + "expected": { + "width": 2048, + "height": 1 + }, + "note": "An edge never goes below 1." + }, + { + "name": "odd-pixels", + "sourceWidth": 3001, + "sourceHeight": 2999, + "maxWidth": 0, + "maxHeight": 0, + "maxPixels": 1000000, + "expected": { + "width": 1000, + "height": 999 + }, + "note": "Floor rounding still fits max_pixels." + } + ], + "constraintSelection": [ + { + "name": "exact-first", + "patterns": [ + "image/jpeg", + "image/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 0, + "note": "Exact match wins when listed first." + }, + { + "name": "wildcard-image", + "patterns": [ + "image/png", + "image/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 1, + "note": "image/png does not match; image/* does." + }, + { + "name": "catch-all", + "patterns": [ + "video/*", + "*/*" + ], + "mimeType": "image/jpeg", + "expectedIndex": 1, + "note": "Only */* matches." + }, + { + "name": "none", + "patterns": [ + "video/*", + "application/pdf" + ], + "mimeType": "image/jpeg", + "expectedIndex": null, + "note": "No entry: do not upload." + }, + { + "name": "policy-order-wins", + "patterns": [ + "*/*", + "image/jpeg" + ], + "mimeType": "image/jpeg", + "expectedIndex": 0, + "note": "Policy order is authoritative, even if a later entry is more specific." + } + ], + "strings": [ + { + "name": "no-caption", + "caption": null, + "expected": { + "snippet": "Photo", + "preview": "📷 Photo" + }, + "note": "No caption: the word Photo." + }, + { + "name": "caption", + "caption": "from today", + "expected": { + "snippet": "from today", + "preview": "📷 from today" + }, + "note": "The caption replaces the word Photo." + }, + { + "name": "emoji-caption", + "caption": "🎉 done", + "expected": { + "snippet": "🎉 done", + "preview": "📷 🎉 done" + }, + "note": "Captions are used as written." + } + ] +} From 8e52ba9f5409124bee366af6493ddb557b5e942e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 14:26:43 -0400 Subject: [PATCH 2/8] feat(core): model photo messages and persist them Adds Content.media with MediaAttachment and a media quote kind, and stores media messages locally. Database.schemaVersion is bumped because the store now writes media content. --- .../Controllers/ConversationController.swift | 8 +- .../Conversation/ChatItem+Conversation.swift | 17 +- .../Conversation/ComposerReplyStrip.swift | 4 +- .../Conversation/ConversationScreen.swift | 7 +- .../Core/Spotlight/ChatSpotlightItem.swift | 1 + .../Models/Chat/ChatPreviewMapping.swift | 5 +- .../FlipcashCore/Models/Chat/ChatQuote.swift | 3 + .../Conversation/ConversationMessage.swift | 51 +++++- .../Models/Conversation/MediaAttachment.swift | 67 ++++++++ .../Conversation/MessageCapability.swift | 18 +- .../Push/NotificationPayload.swift | 2 +- .../Database+Conversations.swift | 27 ++- .../Sources/FlipcashStore/Database.swift | 2 +- .../Sources/FlipcashStore/Schema.swift | 3 + .../ConversationModelMappingTests.swift | 156 ++++++++++++++++++ .../ConversationStoreMutationTests.swift | 1 + .../MessageCapabilityTests.swift | 18 ++ .../Database+ConversationsTests.swift | 44 ++++- .../FlipcashUI/Chat/ChatQuotePanelView.swift | 4 +- 19 files changed, 413 insertions(+), 25 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index cd73bdbd3..3ed8eddf2 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1527,6 +1527,12 @@ final class ConversationController { // the chat and the viewer may have got none of it, so the amount stands on its own. return conversation.type == .group ? label : "You received \(label)" + case .media(_, let caption): + let body = "📷 " + (caption ?? "Photo") + if isFromSelf { return "You: \(body)" } + guard let senderName else { return body } + return "\(senderName): \(body)" + case .deleted: return nil @@ -1958,7 +1964,7 @@ final class ConversationController { case .text(let text): store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) _ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID) - case .encrypted, .cash, .deleted, .widget: + case .encrypted, .cash, .deleted, .widget, .media: if case .failure(let error) = Result(catching: { try pending.content.asProto() }) { logger.error("Cannot retry a send this client has no path to re-send", metadata: [ "conversationID": "\(conversationID)", diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index ea9dbe9a2..ba11482f5 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -151,7 +151,7 @@ extension ChatItem { func isEmojiOnlyBody(_ message: ConversationMessage) -> Bool { switch message.content { case .text(let text): EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted, .encrypted, .widget: false + case .cash, .deleted, .encrypted, .widget, .media: false } } func rendersBare(_ message: ConversationMessage) -> Bool { @@ -164,7 +164,7 @@ extension ChatItem { let layouts = messages.map { message in switch message.content { case .text(let text): Self.rows(for: text, preview: detectedLink(in: text, card: linkCard)) - case .cash, .deleted, .encrypted, .widget: [RowLayout(part: nil, text: nil, preview: nil)] + case .cash, .deleted, .encrypted, .widget, .media: [RowLayout(part: nil, text: nil, preview: nil)] } } // A status line under a message sits between it and the next bubble, so it ends the bubble @@ -263,6 +263,10 @@ extension ChatItem { isFromSelf: isFromSelf, senderName: counterpartName )) + case .media(_, let caption): + // A text stand-in until the transcript has a media cell; a redacted photo stays a + // photo row rather than a tombstone. + content = .text("📷 " + (caption ?? "Photo")) } // The status line rides on the bubble itself (not a separate row, so a send is a clean @@ -428,6 +432,15 @@ extension ChatItem { kind: .unavailable, authorID: original.senderID ) + case .media(let attachments, let caption): + // A redacted original's bytes may not be fetched, so its quote carries no thumbnail. + return ChatQuote( + stableID: original.stableID, + authorName: authorName, + snippet: ChatQuote.snippet(forText: caption ?? "Photo"), + kind: .media(thumbnailBlobID: original.redacted ? nil : attachments.first?.blobID), + authorID: original.senderID + ) case .encrypted: // No plaintext to preview -- same unavailable treatment as a quote whose original the // local database never saw. diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index 1a60664c9..c84f2697a 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -137,7 +137,7 @@ struct ComposerReplyStrip: View { .foregroundStyle(Color.textSecondary) } .lineLimit(1) - case .text, .unavailable: + case .text, .media, .unavailable: Text(target.snippet) .font(.default(size: 14, weight: .medium)) .foregroundStyle(Color.textMain) @@ -150,7 +150,7 @@ struct ComposerReplyStrip: View { private var spokenSnippet: String { switch target.kind { case .cash(let token, _): "\(target.snippet) \(token)" - case .text, .unavailable: target.snippet + case .text, .media, .unavailable: target.snippet } } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index b406fd7e7..c206635cc 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1096,6 +1096,11 @@ struct ConversationScreen: View { fiat.nativeAmount.formatted(), .cash(token: token(fiat), flagImageName: ChatItem.flagImageName(for: fiat)) ) + case .media(let attachments, let caption): + ( + ChatQuote.snippet(forText: caption ?? "Photo"), + .media(thumbnailBlobID: message.redacted ? nil : attachments.first?.blobID) + ) case .deleted: (ChatQuote.deletedSnippet, .unavailable) case .widget(.shareProfile): @@ -1187,7 +1192,7 @@ struct ConversationScreen: View { case .cash(let fiat): Analytics.tokenInfoOpened(from: .openedFromChat, mint: fiat.mint) router.push(.currencyInfo(fiat.mint)) - case .text, .deleted, .encrypted, .widget: + case .text, .deleted, .encrypted, .widget, .media: break } } diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 2c9f080cf..188d53a12 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -60,6 +60,7 @@ nonisolated struct ChatSpotlightItem { case .text(let text): text case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())" case .widget(.shareProfile): "Shared a profile" + case .media(_, let caption): "📷 " + (caption ?? "Photo") case .deleted, .encrypted, .widget(.unrecognized), nil: nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index 04c2bc5ba..e27dedd18 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -91,6 +91,9 @@ extension ChatItem { content = .shareProfile(LinkCard.User(profileOf: share.username)) case .widget(.unrecognized): content = .unavailable(.updateApp) + case .media(_, let caption): + // A text stand-in until the preview has a media row to draw. + content = .text("📷 " + (caption ?? "Photo")) case .deleted: continue // filtered out above; unreachable, kept for switch exhaustiveness } @@ -101,7 +104,7 @@ extension ChatItem { let isEmojiOnly: Bool switch message.content { case .text(let text): isEmojiOnly = EmojiOnlyDetector.isEmojiOnly(text) - case .cash, .deleted, .encrypted, .widget: isEmojiOnly = false + case .cash, .deleted, .encrypted, .widget, .media: isEmojiOnly = false } items.append(.message(ChatMessage( diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift index 50908edf1..2fce0bb69 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift @@ -20,6 +20,9 @@ public struct ChatQuote: Hashable, Sendable, Codable { /// as a bare number — `token` is the mint's name ("Cash" for USDF) and `flagImageName` is /// the currency's asset name, `nil` when the currency has no flag. case cash(token: String, flagImageName: String?) + /// A photo. The snippet is its caption, or "Photo" when it has none; `thumbnailBlobID` is the + /// blob the panel draws beside it, `nil` when there is none to fetch. + case media(thumbnailBlobID: BlobID?) /// The original is not in the local database, or it has been deleted. The panel renders /// the placeholder copy and the row is not tappable. case unavailable diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 30861a627..08865d8f4 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -55,6 +55,10 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { case encrypted(scheme: Int, nonce: Data, ciphertext: Data) /// A widget: structured content the sender's client drew as a card rather than text. case widget(Widget) + /// Photos with an optional caption. A wire message carries exactly one attachment; a + /// redacted copy keeps its dimensions and blurhash and stays `.media`, with + /// ``ConversationMessage/redacted`` saying its bytes may not be fetched. + case media([MediaAttachment], caption: String?) } /// The widget a `.widget` message carries. Sent in the clear only: the E2EE allow-list is text, @@ -104,7 +108,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// When the sender last edited this message, or `nil` if it has never been edited. public let lastEditedTs: Date? /// The message this one replies to, or `nil` when it replies to nothing. A reply is a - /// decoration on a text message rather than a content kind of its own: the wire nests the + /// decoration on a text or media message rather than a content kind of its own: the wire nests the /// body inside `ReplyContent`, and the initializer below unwraps it so every `case .text` /// path — link detection, the transcript mapper, the bubble, edit — sees the shape it /// always saw. @@ -290,17 +294,27 @@ extension ConversationMessage { self.cashAction = nil repliedTo = nil case .reply(let replyContent): - // The wire nests the body one level down; unwrap it so the message is a text message - // that happens to point at another, not a second shape every `case .text` must learn. + // The wire nests the body one level down; unwrap it so the message is a text or media + // message that happens to point at another, not a second shape every case must learn. // `content` is repeated on the wire but carries exactly one entry in practice — a // reply with nothing inside has no body to draw, so it is dropped like any other // content the client cannot represent. - guard case .text(let textContent)? = replyContent.content.first?.type else { + switch replyContent.content.first?.type { + case .text(let textContent): + self.content = .text(textContent.text) + case .media(let mediaContent): + guard let content = Content(mediaContent) else { return nil } + self.content = content + case .cash, .deleted, .reply, .encrypted, .system, .widget, .none: return nil } - self.content = .text(textContent.text) self.cashAction = nil repliedTo = replyContent.hasRepliedMessageID ? MessageID(replyContent.repliedMessageID) : nil + case .media(let mediaContent): + guard let content = Content(mediaContent) else { return nil } + self.content = content + self.cashAction = nil + repliedTo = nil case .encrypted(let encryptedContent): // Kept undecrypted here: decryption needs the chat's keys, which `ChatSeal.open` applies. self.content = .encrypted( @@ -316,8 +330,8 @@ extension ConversationMessage { self.content = .widget(widgetContent.shareProfile.map(Widget.shareProfile) ?? .unrecognized) self.cashAction = nil repliedTo = nil - // `.media`/`.system` are dropped by design: the message is not stored and not shown. - case .media, .system, .none: + // `.system` is dropped by design: the message is not stored and not shown. + case .system, .none: return nil } @@ -366,8 +380,31 @@ extension ConversationMessage.Content { $0.ciphertext = ciphertext }) } + case .media(let attachments, let caption): + // Only a single uploaded attachment has a wire shape; staged or still-uploading + // attachments are fanned out and resolved before a send reaches this call. + guard attachments.count == 1, let media = attachments[0].proto else { + throw ConversationMessageContentEncodingError.unsupported(self) + } + return .with { + $0.type = .media(.with { + $0.items = [media] + if let caption { + $0.caption = .with { $0.text = caption } + } + }) + } case .cash, .deleted, .widget: throw ConversationMessageContentEncodingError.unsupported(self) } } + + /// The `.media` content `proto` describes, or `nil` when its first item has no ORIGINAL rendition. + init?(_ proto: Flipcash_Messaging_V1_MediaContent) { + guard let attachment = proto.items.first.flatMap(MediaAttachment.init) else { + return nil + } + let caption = proto.caption.text + self = .media([attachment], caption: caption.isEmpty ? nil : caption) + } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift new file mode 100644 index 000000000..301e667a7 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift @@ -0,0 +1,67 @@ +// +// MediaAttachment.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// One photo attached to a chat message: the ORIGINAL rendition's blob and the intrinsic metadata +/// needed to lay it out before its bytes arrive. +public struct MediaAttachment: Hashable, Sendable, Codable { + + /// The ORIGINAL rendition's blob; `nil` only for an optimistic row whose upload has not + /// produced one yet. + public let blobID: BlobID? + + /// Pixel width of the ORIGINAL. + public let width: Int + + /// Pixel height of the ORIGINAL. + public let height: Int + + /// The ORIGINAL's BlurHash preview, or `nil` when the server carried none. + public let blurhash: String? + + public init(blobID: BlobID?, width: Int, height: Int, blurhash: String?) { + self.blobID = blobID + self.width = width + self.height = height + self.blurhash = blurhash + } +} + +// MARK: - Proto - + +extension MediaAttachment { + + /// Returns the attachment described by `proto`'s ORIGINAL rendition, or `nil` when it carries none. + /// + /// A redacted copy still maps: the server keeps its dimensions and blurhash and drops only the + /// download URL, which this type never carries. + init?(_ proto: Flipcash_Blob_V1_Media) { + guard let original = proto.renditions.first(where: { $0.role == .original }) else { + return nil + } + let image = original.blob.image + self.init( + blobID: original.hasBlobID ? BlobID(data: original.blobID.value) : nil, + width: Int(image.width), + height: Int(image.height), + blurhash: image.blurhash.isEmpty ? nil : image.blurhash + ) + } + + /// The single-ORIGINAL `Media` a client attaches on send; `nil` when there is no blob to attach. + var proto: Flipcash_Blob_V1_Media? { + guard let blobID else { return nil } + return .with { + $0.renditions = [.with { + $0.role = .original + $0.blobID = .with { $0.value = blobID.data } + }] + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift index 65116c33c..bfb97ea25 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MessageCapability.swift @@ -53,6 +53,7 @@ extension MessageCapability { /// |---|---| /// | Another participant's text | yes | /// | Another participant's cash | yes | + /// | Another participant's photo | yes | /// | Own message, confirmed | no | /// | Own message, unconfirmed | no | /// | A tombstone | no | @@ -93,6 +94,8 @@ extension MessageCapability { policy: MessagePolicy, now: Date ) -> Set { + // Media carries no text, so it offers neither copy nor edit — matching Android's resolver. + let hasText: Bool switch message.content { case .deleted, .encrypted: // Nothing is left to act on: a tombstone must not be re-deleted, and this client has no @@ -113,11 +116,13 @@ extension MessageCapability { // chat's speaker rule like any message. Sharing is the card's own button, not a menu action. return [.reply] case .text: - break + hasText = true + case .media: + hasText = false } guard message.isFromSelf(selfUserID) else { - return [.copy, .reply, .report] + return hasText ? [.copy, .reply, .report] : [.reply, .report] } // An unconfirmed message has no `eventSequence` to send as `expected_event_sequence`, so no @@ -127,9 +132,12 @@ extension MessageCapability { return [] } - var capabilities: Set = [.copy, .reply] - if isWithin(policy.editWindow, of: message, at: now) { - capabilities.insert(.edit) + var capabilities: Set = [.reply] + if hasText { + capabilities.insert(.copy) + if isWithin(policy.editWindow, of: message, at: now) { + capabilities.insert(.edit) + } } if isWithin(policy.deleteWindow, of: message, at: now) { capabilities.insert(.delete) diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index 6d8562487..17d2ae10a 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -94,7 +94,7 @@ public enum NotificationPayload { switch message.content { case .text(let text): return text - case .cash, .deleted, .encrypted, .widget: + case .cash, .deleted, .encrypted, .widget, .media: return nil } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift index 28af2ab91..9b2b47e14 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database+Conversations.swift @@ -550,6 +550,7 @@ nonisolated extension Database { var encryptedScheme: Int? var encryptedNonce: Data? var encryptedCiphertext: Data? + var mediaJson: Data? if let sealed = message.sealed { encryptedScheme = sealed.scheme encryptedNonce = sealed.nonce @@ -582,6 +583,11 @@ nonisolated extension Database { case .shareProfile(let share): text = share.username.value case .unrecognized: text = nil } + case .media(let attachments, let caption): + kind = 5 + mediaJson = try JSONEncoder().encode( + StoredMedia(attachments: attachments, caption: caption, redacted: message.redacted) + ) } let cashAction: Int? = switch message.cashAction { @@ -615,7 +621,8 @@ nonisolated extension Database { m.encryptedNonce <- encryptedNonce, m.encryptedCiphertext <- encryptedCiphertext, m.decryptFailure <- message.decryptFailure?.rawValue, - m.reactionsJson <- Self.encodeReactions(message.reactionState) + m.reactionsJson <- Self.encodeReactions(message.reactionState), + m.mediaJson <- mediaJson ) ) } @@ -690,6 +697,15 @@ nonisolated extension Database { data.flatMap { try? JSONDecoder().decode(ReactionState.self, from: $0) } } + /// The `mediaJson` payload of a `.media` row. `redacted` rides here rather than in a column of + /// its own because only media acts on it: a redacted photo must reload redacted, or its bytes + /// would be fetched after a relaunch. + nonisolated private struct StoredMedia: Codable { + let attachments: [MediaAttachment] + let caption: String? + let redacted: Bool + } + // MARK: - Decode - /// Returns nil unless both rendition columns are present — the pair is @@ -758,6 +774,7 @@ nonisolated extension Database { let date = Date(timeIntervalSinceReferenceDate: row[m.date]) let content: ConversationMessage.Content + var redacted = false switch row[m.kind] { case 0: guard let text = row[m.text] else { return nil } @@ -800,6 +817,13 @@ nonisolated extension Database { content = .widget( row[m.text].flatMap(Username.init).map { .shareProfile(ShareProfileWidget(username: $0)) } ?? .unrecognized ) + case 5: + guard let data = row[m.mediaJson], + let media = try? JSONDecoder().decode(StoredMedia.self, from: data) else { + return nil + } + content = .media(media.attachments, caption: media.caption) + redacted = media.redacted default: return nil } @@ -820,6 +844,7 @@ nonisolated extension Database { lastEditedTs: row[m.lastEditedTs].map(Date.init(timeIntervalSinceReferenceDate:)), repliedTo: row[m.repliedToId].map(MessageID.init(value:)), clientMessageID: row[m.clientMessageID], + redacted: redacted, reactionState: Self.decodeReactions(row[m.reactionsJson]), sealed: sealed, decryptFailure: row[m.decryptFailure].flatMap(ConversationMessage.DecryptFailure.init(rawValue:)) diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index af9ab48c8..2e97d2ca8 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 45 + public static let schemaVersion = 46 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Sources/FlipcashStore/Schema.swift b/FlipcashCore/Sources/FlipcashStore/Schema.swift index f199c0a1c..deb3eadc0 100644 --- a/FlipcashCore/Sources/FlipcashStore/Schema.swift +++ b/FlipcashCore/Sources/FlipcashStore/Schema.swift @@ -372,6 +372,8 @@ nonisolated public struct ConversationMessageTable: Sendable { public let deletedAt = Expression ("deletedAt") // JSON-encoded `ReactionState` (its confirmed server state only); nil when none is known. public let reactionsJson = Expression ("reactionsJson") + // JSON-encoded `.media` payload (attachments, caption, redaction); nil for a non-media row. + public let mediaJson = Expression ("mediaJson") } @@ -648,6 +650,7 @@ nonisolated extension Database { t.column(conversationMessageTable.encryptedCiphertext) t.column(conversationMessageTable.decryptFailure) t.column(conversationMessageTable.reactionsJson) + t.column(conversationMessageTable.mediaJson) t.primaryKey(conversationMessageTable.conversationId, conversationMessageTable.id) }) } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 9d206b8b5..b154b3341 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -773,3 +773,159 @@ struct ConversationMessageContentEncodingTests { #expect(throws: ConversationMessageContentEncodingError.self) { try widget.asProto() } } } + +@Suite("Media proto mapping") +struct ConversationMessageMediaMappingTests { + + private let blobID = BlobID(data: Data([1, 2, 3])) + private let blurhash = "L6PZfSi_.AyE_3t7t7R**0o#DgR4" + + private func media(withDownloadURL: Bool = true) -> Flipcash_Blob_V1_Media { + .with { + $0.renditions = [ + .with { + $0.role = .thumbnail + $0.blobID = .with { $0.value = Data([9]) } + $0.blob.image.width = 50 + $0.blob.image.height = 100 + }, + .with { + $0.role = .original + $0.blobID = .with { $0.value = blobID.data } + $0.blob.image.width = 100 + $0.blob.image.height = 200 + $0.blob.image.blurhash = blurhash + if withDownloadURL { + $0.blob.downloadURL = .with { $0.url = "https://example.com/blob" } + } + }, + ] + } + } + + private func mediaProto(caption: String?, redacted: Bool = false) -> Flipcash_Messaging_V1_Message { + .with { + $0.messageID = .with { $0.value = 50 } + $0.redacted = redacted + $0.content = [.with { + $0.media = .with { + $0.items = [media(withDownloadURL: !redacted)] + if let caption { + $0.caption = .with { $0.text = caption } + } + } + }] + } + } + + @Test("A media message maps its ORIGINAL rendition and caption") + func mediaMapsOriginalAndCaption() throws { + let message = try #require(ConversationMessage(mediaProto(caption: "from today"))) + + let expected = MediaAttachment(blobID: blobID, width: 100, height: 200, blurhash: blurhash) + #expect(message.content == .media([expected], caption: "from today")) + #expect(message.repliedTo == nil) + #expect(message.redacted == false) + } + + @Test("A media message without a caption, or with an empty one, has a nil caption") + func mediaWithoutCaption() throws { + for caption in [nil, ""] as [String?] { + let message = try #require(ConversationMessage(mediaProto(caption: caption))) + guard case .media(_, let mapped) = message.content else { + Issue.record("expected .media content") + return + } + #expect(mapped == nil) + } + } + + @Test("A redacted media message stays .media with its blurhash, flagged redacted — never a tombstone") + func redactedMediaStaysMedia() throws { + let message = try #require(ConversationMessage(mediaProto(caption: "hidden", redacted: true))) + + guard case .media(let attachments, _) = message.content else { + Issue.record("expected .media content, got \(message.content)") + return + } + #expect(attachments.first?.blurhash == blurhash) + #expect(attachments.first?.width == 100) + #expect(message.redacted) + #expect(!message.isDeleted) + } + + @Test("A media item with no ORIGINAL rendition is dropped") + func mediaWithoutOriginalIsDropped() { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 51 } + $0.content = [.with { + $0.media = .with { + $0.items = [.with { $0.renditions = [.with { $0.role = .thumbnail }] }] + } + }] + } + #expect(ConversationMessage(proto) == nil) + } + + @Test("A reply carrying media unwraps to .media with the replied-to id") + func replyWithMediaUnwraps() throws { + let inner = media() + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 52 } + $0.content = [.with { content in + content.reply = .with { reply in + reply.repliedMessageID = .with { $0.value = 7 } + reply.content = [.with { $0.media = .with { $0.items = [inner] } }] + } + }] + } + + let message = try #require(ConversationMessage(proto)) + let expected = MediaAttachment(blobID: blobID, width: 100, height: 200, blurhash: blurhash) + #expect(message.content == .media([expected], caption: nil)) + #expect(message.repliedTo == MessageID(value: 7)) + } + + @Test("Media content encodes to a single ORIGINAL rendition with its caption") + func mediaEncodes() throws { + let attachment = MediaAttachment(blobID: blobID, width: 100, height: 200, blurhash: blurhash) + let proto = try ConversationMessage.Content.media([attachment], caption: "from today").asProto() + + guard case .media(let media) = proto.type else { + Issue.record("Expected media content") + return + } + #expect(media.items.count == 1) + #expect(media.items.first?.renditions.map(\.role) == [.original]) + #expect(media.items.first?.renditions.first?.blobID.value == blobID.data) + #expect(media.caption.text == "from today") + } + + @Test("Media without a caption encodes no caption") + func mediaEncodesWithoutCaption() throws { + let attachment = MediaAttachment(blobID: blobID, width: 100, height: 200, blurhash: nil) + let proto = try ConversationMessage.Content.media([attachment], caption: nil).asProto() + + guard case .media(let media) = proto.type else { + Issue.record("Expected media content") + return + } + #expect(!media.hasCaption) + } + + @Test("Media with no blob, or more than one attachment, throws rather than sending a partial message") + func stagedMediaThrows() { + let uploaded = MediaAttachment(blobID: blobID, width: 1, height: 1, blurhash: nil) + let pending = MediaAttachment(blobID: nil, width: 1, height: 1, blurhash: nil) + + #expect(throws: ConversationMessageContentEncodingError.self) { + try ConversationMessage.Content.media([pending], caption: nil).asProto() + } + #expect(throws: ConversationMessageContentEncodingError.self) { + try ConversationMessage.Content.media([uploaded, uploaded], caption: nil).asProto() + } + #expect(throws: ConversationMessageContentEncodingError.self) { + try ConversationMessage.Content.media([], caption: nil).asProto() + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift index 5ba21c365..7f3376a1f 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationStoreMutationTests.swift @@ -33,6 +33,7 @@ struct ConversationStoreMutationTests { case .cash: "" case .encrypted: "" case .widget: "" + case .media: "" } } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift index c6e8b595d..86bafecac 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MessageCapabilityTests.swift @@ -79,6 +79,24 @@ struct MessageCapabilityTests { #expect(resolve(cash(from: them), isMember: false) == [.report]) } + @Test("My own photo can be replied to and deleted, but has no text to copy or edit") + func ownMediaOffersReplyAndDelete() { + #expect(resolve(media(from: me)) == [.reply, .delete]) + } + + @Test("Someone else's photo can be replied to and reported, but not copied") + func otherPersonsMediaIsReportable() { + #expect(resolve(media(from: them)) == [.reply, .report]) + } + + private func media(from sender: UUID) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: 4), senderID: sender, + content: .media([MediaAttachment(blobID: BlobID(data: Data([1])), width: 1, height: 1, blurhash: nil)], caption: "hi"), + date: now, unreadSeq: 1, eventSequence: 2 + ) + } + private func cash(from sender: UUID) -> ConversationMessage { ConversationMessage( id: MessageID(value: 3), senderID: sender, diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index 5834ce577..f614bfbf0 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -103,7 +103,7 @@ struct DatabaseConversationsTests { switch loadedMessage.content { case .cash(let loadedExchanged): #expect(loadedExchanged.nativeAmount.value == amount) - case .text, .deleted, .encrypted, .widget: + case .text, .deleted, .encrypted, .widget, .media: Issue.record("Expected cash message content") } } @@ -478,6 +478,48 @@ struct DatabaseConversationsTests { #expect(ciphertext == Data([0x01, 0x02, 0x03, 0x04])) } + @Test("A media message round-trips its attachment, caption, and reply target") + func mediaMessageRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let attachment = MediaAttachment(blobID: BlobID(data: Data([9, 8, 7])), width: 300, height: 400, blurhash: "LEHV6nWB2yk8") + let captioned = ConversationMessage( + id: MessageID(value: 1), senderID: otherID, + content: .media([attachment], caption: "hi"), + date: Date(timeIntervalSince1970: 10), unreadSeq: 1, eventSequence: 3, + repliedTo: MessageID(value: 7) + ) + let bare = ConversationMessage( + id: MessageID(value: 2), senderID: selfID, + content: .media([MediaAttachment(blobID: BlobID(data: Data([1])), width: 1, height: 2, blurhash: nil)], caption: nil), + date: Date(timeIntervalSince1970: 20), unreadSeq: 2, eventSequence: 4 + ) + + try database.upsertConversationMessages([captioned, bare], conversationID: id) + + #expect(try database.getConversationMessages(conversationID: id) == [captioned, bare]) + } + + @Test("A redacted media message reloads still redacted, so its bytes stay unfetched after a relaunch") + func redactedMediaRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let message = ConversationMessage( + id: MessageID(value: 1), senderID: otherID, + content: .media([MediaAttachment(blobID: BlobID(data: Data([9])), width: 300, height: 400, blurhash: "LEHV6nWB2yk8")], caption: nil), + date: Date(timeIntervalSince1970: 10), unreadSeq: 1, eventSequence: 3, + redacted: true + ) + + try database.upsertConversationMessages([message], conversationID: id) + + let loaded = try #require(try database.getConversationMessages(conversationID: id).first) + #expect(loaded == message) + #expect(loaded.redacted) + } + @Test("the catch-up cursor round-trips and survives a feed replace") func catchupCursorRoundTrip() throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index 885905a8b..74b193da5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -170,14 +170,14 @@ final class ChatQuotePanelView: UIView { // A payment's amount is the whole of what was said, so it is read rather than glanced // at — a step brighter than the preview grey a quoted sentence gets. snippetLabel.textColor = UIColor.white.withAlphaComponent(0.75) - case .text, .unavailable: + case .text, .media, .unavailable: flagView.isHidden = true tokenLabel.isHidden = true snippetLabel.textColor = Self.snippetColor } let spoken = switch quote.kind { case .cash(let token, _): "\(quote.snippet) \(token)" - case .text, .unavailable: quote.snippet + case .text, .media, .unavailable: quote.snippet } isUserInteractionEnabled = quote.isJumpable accessibilityLabel = quote.authorName.isEmpty From f97caba64962f5bd17d8dd48dd4967e7ca751f9f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 14:42:14 -0400 Subject: [PATCH 3/8] feat(chat): send and show photo messages Photos are staged as composer chips, uploaded through the downscale and quality ladder, and sent one message each with retry. The transcript draws photo bubbles, quotes and previews describe them, and tapping one opens a full-screen viewer. An inline camera captures straight into the composer. Only the downsampled copy is cached, not the original. --- .github/workflows/vectors.yml | 9 +- .../Controllers/ConversationController.swift | 174 +++++++++- .../Controllers/EncryptedChatClient.swift | 5 + .../Controllers/FlipClient+Protocols.swift | 2 + .../Screens/Conversation/AttachMenu.swift | 100 ++++++ .../Conversation/ChatCameraSheet.swift | 198 +++++++++++ .../Screens/Conversation/ChatCameraSlot.swift | 72 ++++ .../Conversation/ChatItem+Conversation.swift | 18 +- .../Conversation/ChatMediaSendPlan.swift | 54 +++ .../Conversation/ChatMediaUploader.swift | 219 ++++++++++++ .../Conversation/ChatPhotoCamera.swift | 186 ++++++++++ .../Conversation/ChatPhotoStaging.swift | 62 ++++ .../ChatScreenRepresentable.swift | 52 ++- .../Screens/Conversation/ComposerChip.swift | 90 +++++ .../Conversation/ComposerChipStrip.swift | 152 +++++++++ .../Screens/Conversation/ComposerModel.swift | 74 +++- .../Conversation/ComposerReplyStrip.swift | 35 ++ .../Conversation/ConversationBottomBar.swift | 179 +++++++--- .../Conversation/ConversationScreen.swift | 59 +++- .../Core/Screens/Main/CameraPromptView.swift | 4 +- .../Core/Spotlight/ChatSpotlightItem.swift | 2 +- .../Clients/Flip API/FlipClient+Chat.swift | 8 + .../Services/ChatMessagingService.swift | 33 +- .../Models/Chat/ChatMediaStrings.swift | 29 ++ .../Models/Chat/ChatMessage.swift | 41 ++- .../Models/Chat/ChatPreviewMapping.swift | 14 +- FlipcashTests/Chat/AttachMenuTests.swift | 64 ++++ FlipcashTests/Chat/ChatCameraSheetTests.swift | 48 +++ .../Chat/ChatMediaBubbleVectorTests.swift | 64 ++++ FlipcashTests/Chat/ChatMediaCellTests.swift | 112 ++++++ .../Chat/ChatMediaFanOutVectorTests.swift | 76 +++++ .../Chat/ChatMediaStringVectorTests.swift | 123 +++++++ .../Chat/ChatMediaURLResolverTests.swift | 144 ++++++++ .../Chat/ChatMediaUploaderTests.swift | 246 ++++++++++++++ FlipcashTests/Chat/ChatMediaViewerTests.swift | 178 ++++++++++ .../Chat/ChatMessageMappingTests.swift | 26 ++ .../Chat/ChatPhotoStagingTests.swift | 214 ++++++++++++ FlipcashTests/Chat/ChatQuoteBubbleTests.swift | 19 ++ .../Chat/ComposerChipStripTests.swift | 49 +++ FlipcashTests/Chat/ComposerChipTests.swift | 118 +++++++ .../ConversationBarLeadingControlTests.swift | 65 ++++ .../Chat/ConversationMediaSendTests.swift | 321 ++++++++++++++++++ FlipcashTests/Chat/ReactionUITests.swift | 7 + FlipcashTests/ChatPreviewMappingTests.swift | 27 ++ .../TestSupport/MockChatMediaBlobStore.swift | 53 +++ .../TestSupport/MockConversations.swift | 30 ++ .../FlipcashUI/Chat/ChatBubbleView.swift | 13 +- .../Chat/ChatItem+Differentiable.swift | 2 + .../FlipcashUI/Chat/ChatLinkMessageCell.swift | 13 +- .../Chat/ChatMediaBubbleSizing.swift | 35 ++ .../FlipcashUI/Chat/ChatMediaCell.swift | 255 ++++++++++++++ .../Chat/ChatMediaImageSource.swift | 20 ++ .../Chat/ChatMediaURLResolver.swift | 101 ++++++ .../Chat/ChatMediaViewerController.swift | 211 ++++++++++++ .../FlipcashUI/Chat/ChatMessageCell.swift | 15 +- .../FlipcashUI/Chat/ChatQuotePanelView.swift | 71 +++- .../Chat/ChatScreenViewController.swift | 18 + .../FlipcashUI/Chat/ChatViewController.swift | 87 ++++- .../FlipcashUI/Chat/LinkableBubbleView.swift | 5 +- .../Smoke/BlockUnblockSmokeTests.swift | 8 +- .../Screens/ConversationUIScreen.swift | 13 +- .../NotificationTranscriptView.swift | 3 + 62 files changed, 4615 insertions(+), 110 deletions(-) create mode 100644 Flipcash/Core/Screens/Conversation/AttachMenu.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatMediaSendPlan.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift create mode 100644 Flipcash/Core/Screens/Conversation/ComposerChip.swift create mode 100644 Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift create mode 100644 FlipcashTests/Chat/AttachMenuTests.swift create mode 100644 FlipcashTests/Chat/ChatCameraSheetTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaBubbleVectorTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaCellTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaFanOutVectorTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaStringVectorTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaURLResolverTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaUploaderTests.swift create mode 100644 FlipcashTests/Chat/ChatMediaViewerTests.swift create mode 100644 FlipcashTests/Chat/ChatPhotoStagingTests.swift create mode 100644 FlipcashTests/Chat/ComposerChipStripTests.swift create mode 100644 FlipcashTests/Chat/ComposerChipTests.swift create mode 100644 FlipcashTests/Chat/ConversationBarLeadingControlTests.swift create mode 100644 FlipcashTests/Chat/ConversationMediaSendTests.swift create mode 100644 FlipcashTests/TestSupport/MockChatMediaBlobStore.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaBubbleSizing.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift diff --git a/.github/workflows/vectors.yml b/.github/workflows/vectors.yml index 5b72532c6..3abb61788 100644 --- a/.github/workflows/vectors.yml +++ b/.github/workflows/vectors.yml @@ -65,7 +65,7 @@ jobs: -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ -quiet - # The app target's own vector suites -- chat drafts and link detection -- assert against types + # The app target's own vector suites -- chat drafts, link detection, and chat media -- assert against types # only the app has, so they cannot move into either package above. Heavier than the two # package schemes because it builds the app. # Split so the two failure classes are separable and each stays legible: @@ -82,8 +82,8 @@ jobs: # Two runs of the one built target, because they answer different questions. # - # This is the gate: the two suites that read the synced copies of - # `chat_draft.json` and `link_detection.json`. Those fixtures are canonical and + # This is the gate: the suites that read the synced copies of `chat_draft.json`, + # `link_detection.json`, and `chat_media.json`. Those fixtures are canonical and # code-android-app asserts against the same ones, so a failure here is contract # drift between the platforms -- exactly what this job exists to stop. - name: App vectors @@ -92,6 +92,9 @@ jobs: -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ -only-testing:FlipcashTests/ChatDraftVectorTests \ -only-testing:FlipcashTests/LinkCardClassifierTests \ + -only-testing:FlipcashTests/ChatMediaFanOutVectorTests \ + -only-testing:FlipcashTests/ChatMediaBubbleVectorTests \ + -only-testing:FlipcashTests/ChatMediaStringVectorTests \ -resultBundlePath "${{ runner.temp }}/AppVectors.xcresult" # The rest of the target reports but does not gate. It holds timing-sensitive diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 3ed8eddf2..a56b3603c 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -312,6 +312,9 @@ final class ConversationController { /// Keeps the words of a send that failed — see ``FailedSendDrafts``. @ObservationIgnored private var failedSends: FailedSendDrafts? + /// The chip behind each photo send not yet confirmed, keyed by its client id: its local image + /// draws the pending bubble, and a failed send uploads or posts again through it. + @ObservationIgnored private var pendingMediaChips: [UUID: ComposerChip] = [:] /// The receive-side analytics concern (cumulative counters + received events), /// owned by its own unit. Exposed so `SessionContainer` can wire its rate lookup. @ObservationIgnored let receipts: ConversationReceiptReporter @@ -1528,7 +1531,7 @@ final class ConversationController { return conversation.type == .group ? label : "You received \(label)" case .media(_, let caption): - let body = "📷 " + (caption ?? "Photo") + let body = ChatMediaStrings.listPreview(caption: caption) if isFromSelf { return "You: \(body)" } guard let senderName else { return body } return "\(senderName): \(body)" @@ -1954,25 +1957,25 @@ final class ConversationController { func retry(clientMessageID: UUID, in conversationID: ConversationID) async { guard let pending = store.pendingMessage(clientMessageID: clientMessageID, in: conversationID), pending.status == .failed else { return } - // Only `.text` is ever sent by this client today -- `deliver(text:)` is the only send path, - // and `send(_:to:)` only ever creates a `.text` pending row -- so this is unreachable in - // practice. It's a `switch` rather than the narrow `guard case .text` it replaces so a future - // pending shape (e.g. an outbox that can hold `.encrypted`) fails loudly via the log below - // instead of silently never retrying, and so `Content.asProto()`'s own crash-free contract - // (no fatalError/force-unwrap for `.encrypted`/`.cash`/`.deleted`) is exercised here too. + // A `switch` rather than a `guard case` so a pending shape with no send path (an outbox that + // could hold `.encrypted`, say) fails loudly via the log below instead of silently never + // retrying, and so `Content.asProto()`'s crash-free contract for `.encrypted`/`.cash`/ + // `.deleted` is exercised here too. switch pending.content { case .text(let text): store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) _ = await deliver(clientMessageID: clientMessageID, text: text, repliedTo: pending.repliedTo, to: conversationID) - case .encrypted, .cash, .deleted, .widget, .media: + case .media(_, let caption): + await retryMedia(clientMessageID: clientMessageID, caption: caption, repliedTo: pending.repliedTo, in: conversationID) + case .encrypted, .cash, .deleted, .widget: if case .failure(let error) = Result(catching: { try pending.content.asProto() }) { logger.error("Cannot retry a send this client has no path to re-send", metadata: [ "conversationID": "\(conversationID)", "error": "\(error)", ]) } - // Nothing to resend over the existing text-only send RPC; leave it `.failed` rather than - // looping forever or crashing. + // Nothing to resend over the send RPCs; leave it `.failed` rather than looping forever + // or crashing. } } @@ -2023,6 +2026,157 @@ final class ConversationController { } } + /// Sends each staged chip as its own message, in chip order, with `caption` under the last and + /// `repliedTo` on the first; returns whether every one was delivered. + /// + /// Every pending bubble appears at once, even for a chip still uploading. Each message is then + /// posted only once its own chip's upload settles, so a later photo never lands before an + /// earlier one. A chip that fails marks only its own bubble `.failed`, and the next chip still + /// gets its turn. + @discardableResult + func sendMedia( + _ chips: [ComposerChip], + caption: String?, + to conversationID: ConversationID, + repliedTo: MessageID? = nil + ) async -> Bool { + let sends = ChatMediaSendPlan.mediaMessages(chips: chips, caption: caption, replyTo: repliedTo).map { message in + (message: message, clientMessageID: insertPendingMedia(message, into: conversationID)) + } + + var deliveredAll = true + for send in sends { + let delivered = await deliverMedia( + clientMessageID: send.clientMessageID, + chip: send.message.chip, + caption: send.message.caption, + repliedTo: send.message.replyTo, + to: conversationID + ) + deliveredAll = deliveredAll && delivered + } + return deliveredAll + } + + /// The local image drawing the pending photo bubble whose transcript id is `messageID`, or `nil` + /// once the send is confirmed or when the row is not a photo this device is sending. + func pendingMediaImage(forMessageID messageID: String) -> UIImage? { + UUID(uuidString: messageID).flatMap { pendingMediaChips[$0]?.image } + } + + private func insertPendingMedia(_ message: ChatMediaSendPlan.MediaMessage, into conversationID: ConversationID) -> UUID { + let clientMessageID = UUID() + let chip = message.chip + // A chip still preparing has no upload size yet; the source's own pixels carry the same + // aspect ratio, which is all the bubble's clamp reads. + let pixels = CGSize(width: chip.image.size.width * chip.image.scale, height: chip.image.size.height * chip.image.scale) + let attachment = MediaAttachment( + blobID: nil, + width: chip.preparedWidth ?? Int(pixels.width.rounded()), + height: chip.preparedHeight ?? Int(pixels.height.rounded()), + blurhash: nil + ) + let pending = ConversationMessage( + id: .unassigned, + senderID: selfUserID, + content: .media([attachment], caption: message.caption), + date: .now, + unreadSeq: 0, + repliedTo: message.replyTo, + status: .sending, + clientMessageID: clientMessageID + ) + let anchor = (try? database.newestMessageID(conversationID: conversationID)).flatMap { $0 }?.value ?? 0 + store.insertPending(pending, anchoredTo: anchor, into: conversationID) + receiptSettle.hold(clientMessageID.uuidString) + pendingMediaChips[clientMessageID] = chip + return clientMessageID + } + + /// Re-sends a failed photo: posts again when its blob is finalized, uploads again first when the + /// upload is what failed, and leaves a photo the server refused for good as it is. + private func retryMedia(clientMessageID: UUID, caption: String?, repliedTo: MessageID?, in conversationID: ConversationID) async { + guard let chip = pendingMediaChips[clientMessageID] else { + logger.error("Cannot retry a photo send with no staged image", metadata: [ + "conversationID": "\(conversationID)", + ]) + return + } + switch chip.state { + case .preparing, .uploading, .uploaded: + break + case .failed(.retryable): + chip.retryUpload() + case .failed(.notRetryable): + return + } + store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) + _ = await deliverMedia(clientMessageID: clientMessageID, chip: chip, caption: caption, repliedTo: repliedTo, to: conversationID) + } + + /// Awaits `chip`'s upload, then posts its message; any failure marks the pending row `.failed`. + private func deliverMedia( + clientMessageID: UUID, + chip: ComposerChip, + caption: String?, + repliedTo: MessageID?, + to conversationID: ConversationID + ) async -> Bool { + // Read at await time: a retry replaces the chip's task. + guard let upload = chip.uploadTask else { + store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) + logger.error("Photo send has no upload to await", metadata: ["conversationID": "\(conversationID)"]) + return false + } + let blobID: BlobID + do { + blobID = try await upload.value + } catch { + store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) + logger.error("Failed to upload conversation photo", metadata: [ + "conversationID": "\(conversationID)", + "error": "\(error)", + ]) + ErrorReporting.captureError(error, reason: "Failed to upload conversation photo") + return false + } + + let chatType = conversation(withID: conversationID)?.type + do { + let message = try await messaging.sendMediaMessage( + owner: owner, + conversationID: conversationID, + blobID: blobID, + caption: caption, + repliedTo: repliedTo, + clientMessageID: clientMessageID + ) + var confirmed = message + confirmed.clientMessageID = clientMessageID + let ok = persist(operation: "send-media-message") { try database.upsertConversationMessages([confirmed], conversationID: conversationID) } + if ok { + store.dropPending(clientMessageID: clientMessageID, confirmedAt: message.id, in: conversationID) + pendingMediaChips[clientMessageID] = nil + } else { + scheduleGapCatchUp(conversationID) + } + store.advanceLastActivity(to: message.date, in: conversationID) + refreshFeedPreview(for: conversationID) + persistConversation(conversationID) + Analytics.sentMessage(chatType: chatType) + return true + } catch { + store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) + logger.error("Failed to send conversation photo", metadata: [ + "conversationID": "\(conversationID)", + "error": "\(error)", + ]) + ErrorReporting.captureError(error, reason: "Failed to send conversation photo") + Analytics.sentMessage(chatType: chatType, error: error) + return false + } + } + /// Moves the viewer's READ pointer to `messageID`, the newest message someone else sent that has /// been on screen. Never moves it backward. /// diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index c60ea7db5..9078a42e9 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -135,6 +135,11 @@ extension EncryptedChatClient: ConversationMessaging { } } + /// Media has no sealed form, so a photo goes out in plaintext whatever the chat's encryption. + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + try await client.sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID) + } + func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, repliedTo: MessageID?, expectedEventSequence: UInt64) async throws -> MessageMutation { try await refetchingOnRefusal(conversationID) { let seal = try await sealForSending(conversationID, owner: owner) diff --git a/Flipcash/Core/Controllers/FlipClient+Protocols.swift b/Flipcash/Core/Controllers/FlipClient+Protocols.swift index e8092e597..cdf13d4d8 100644 --- a/Flipcash/Core/Controllers/FlipClient+Protocols.swift +++ b/Flipcash/Core/Controllers/FlipClient+Protocols.swift @@ -112,6 +112,8 @@ protocol ConversationMessaging: AnyObject, Sendable { onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void ) async throws -> UInt64 func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage + /// Sends the finalized photo `blobID` as one media message, with `caption` under it. + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage /// Replaces a message's text, keeping it a reply to `repliedTo` when set. `expectedEventSequence` is the optimistic-concurrency guard: the /// server applies the edit only if the message still carries that sequence, and reports a /// conflict with the winning state otherwise. diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift new file mode 100644 index 000000000..cdc7c0080 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -0,0 +1,100 @@ +// +// AttachMenu.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import PhotosUI +import SwiftUI +import FlipcashUI + +/// A row of the composer's attach menu. +enum AttachMenuItem: Equatable { + case cash + case camera + case photos + + /// Returns the menu's rows, top to bottom: Cash wherever Send Cash is offered, then Camera and + /// Photos while the chat accepts media and the composer has room for another photo. + static func items(showsCash: Bool, acceptsMedia: Bool, attachedCount: Int) -> [AttachMenuItem] { + var items: [AttachMenuItem] = [] + if showsCash { + items.append(.cash) + } + if acceptsMedia, attachedCount < ComposerModel.maxAttachments { + items.append(.camera) + items.append(.photos) + } + return items + } + + /// Returns how many photos the picker may add to the `attachedCount` already staged. + static func photosSelectionLimit(attachedCount: Int) -> Int { + max(0, ComposerModel.maxAttachments - attachedCount) + } +} + +/// The composer's leading `+` control: a menu of Cash, Camera, and Photos, in the slot Send Cash +/// takes before the chat exists. +/// +/// Photos opens the system picker, which runs out of process and needs no library permission — +/// the same reason ``GalleryScanButton`` uses it. +struct AttachMenu: View { + + let items: [AttachMenuItem] + /// The most photos one pick may return, so the strip never passes `ComposerModel.maxAttachments`. + let photosSelectionLimit: Int + let onCash: () -> Void + let onCamera: () -> Void + /// Receives the picked photos in the order they were selected. + let onPhotos: ([PhotosPickerItem]) -> Void + + @State private var isPickingPhotos = false + @State private var pickedPhotos: [PhotosPickerItem] = [] + + var body: some View { + Menu { + ForEach(items, id: \.self) { item in + switch item { + case .cash: + Button("Cash", systemImage: "banknote", action: onCash) + case .camera: + Button("Camera", systemImage: "camera", action: onCamera) + case .photos: + // Presented from the modifier below rather than as a `PhotosPicker` row: a + // picker nested in a `Menu` is torn down with the menu before it can present. + Button("Photos", systemImage: "photo.on.rectangle") { isPickingPhotos = true } + } + } + } label: { + Image(systemName: SystemSymbol.plus.rawValue) + .font(.default(size: 20, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + .contentShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + } + // Declared order, top to bottom. The system otherwise reverses a menu that opens upward, + // which is every menu raised from the bottom bar. + .menuOrder(.fixed) + .buttonStyle(.plain) + .glassBackground(cornerRadius: BarMetrics.cornerRadius) + .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + .accessibilityLabel("Attach") + .accessibilityIdentifier("attach-menu-button") + .photosPicker( + isPresented: $isPickingPhotos, + selection: $pickedPhotos, + maxSelectionCount: photosSelectionLimit, + selectionBehavior: .ordered, + matching: .images, + photoLibrary: .shared() + ) + .onChange(of: pickedPhotos) { _, picked in + guard !picked.isEmpty else { return } + // Cleared so the next pick starts empty instead of preselecting what was just staged. + pickedPhotos = [] + onPhotos(picked) + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift new file mode 100644 index 000000000..388167b66 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift @@ -0,0 +1,198 @@ +// +// ChatCameraSheet.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import AVFoundation +import FlipcashUI + +/// What the chat camera shows for a camera authorization status. +nonisolated enum ChatCameraPhase: Equatable { + + /// Access hasn't been asked for yet; the system prompt is up. + case requestingAccess + + /// Access is granted; the viewfinder runs. + case live + + /// Access is denied or restricted; the panel points to Settings. + case denied + + init(status: AVAuthorizationStatus) { + switch status { + case .notDetermined: + self = .requestingAccess + case .authorized: + self = .live + case .denied, .restricted: + self = .denied + @unknown default: + self = .denied + } + } +} + +/// The attach menu's Camera: a rounded viewfinder with a shutter, a lens flip, and a back chevron, +/// sized by its host to the keyboard's frame. +/// +/// Asks for camera access on first use, since opening the camera is the request. A capture hands +/// the photo to `onCapture`; the host stages it and collapses the camera. +struct ChatCameraSheet: View { + + let onCapture: (UIImage) -> Void + let onCancel: () -> Void + + @State private var authorizer = CameraAuthorizer() + @State private var camera = ChatPhotoCamera() + @State private var isUnavailable = false + + private static let cornerRadius: CGFloat = 24 + + var body: some View { + ZStack { + switch ChatCameraPhase(status: authorizer.status) { + case .requestingAccess: + ProgressView() + .task { _ = try? await authorizer.authorize() } + case .denied: + CameraPromptView( + prompt: .openSettings, + message: "Turn on Camera in Settings to send photos" + ) { + URL.openSettings() + } + case .live: + if isUnavailable { + Text("Camera unavailable") + .font(.appTextSmall) + .foregroundStyle(Color.textSecondary) + } else { + viewfinder + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.backgroundMain) + .overlay(alignment: .topLeading) { + backButton + } + .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) + } + + private var viewfinder: some View { + ChatCameraPreview(session: camera.session) + .overlay(alignment: .bottom) { + controls + } + .task { + do { + try camera.configure() + camera.start() + } catch { + isUnavailable = true + } + } + .onDisappear { + camera.stop() + } + } + + private var controls: some View { + HStack { + // Balances the flip button so the shutter sits on the centre line. + Color.clear + .frame(width: 44, height: 44) + Spacer() + Button { + Task { + if let image = await camera.capture() { + onCapture(image) + } + } + } label: { + Circle() + .strokeBorder(Color.white, lineWidth: 4) + .padding(-2) + .overlay { + Circle() + .fill(Color.white) + .padding(6) + } + .frame(width: 68, height: 68) + } + .buttonStyle(.plain) + .disabled(camera.isCapturing) + .accessibilityLabel("Take photo") + .accessibilityIdentifier("chat-camera-shutter") + Spacer() + Button { + camera.flip() + } label: { + Image(systemName: "arrow.triangle.2.circlepath") + .font(.default(size: 17, weight: .semibold)) + .foregroundStyle(Color.white) + .frame(width: 44, height: 44) + .background(Circle().fill(Color.black.opacity(0.35))) + } + .buttonStyle(.plain) + .accessibilityLabel(camera.position == .front ? "Use back camera" : "Use front camera") + .accessibilityIdentifier("chat-camera-flip") + } + .padding(.horizontal, 24) + .padding(.bottom, 16) + } + + private var backButton: some View { + Button(action: onCancel) { + Image(systemName: "chevron.left") + .font(.default(size: 17, weight: .semibold)) + .foregroundStyle(Color.white) + .frame(width: 44, height: 44) + .background(Circle().fill(Color.black.opacity(0.35))) + } + .buttonStyle(.plain) + .padding(12) + .accessibilityLabel("Back to keyboard") + .accessibilityIdentifier("chat-camera-back") + } +} + +// MARK: - ChatCameraPreview - + +/// The live feed of a capture session, filling its frame. +/// +/// Its own layer rather than FlipcashUI's `CameraViewport`: that view hands out one shared +/// `_CameraPreviewView`, and mounting it here would pull the scanner's preview out of its tab. +private struct ChatCameraPreview: UIViewRepresentable { + + let session: AVCaptureSession + + func makeUIView(context: Context) -> PreviewView { + let view = PreviewView() + view.backgroundColor = UIColor(Color.backgroundMain) + view.previewLayer.videoGravity = .resizeAspectFill + view.previewLayer.session = session + return view + } + + func updateUIView(_ uiView: PreviewView, context: Context) { + if uiView.previewLayer.session !== session { + uiView.previewLayer.session = session + } + } + + final class PreviewView: UIView { + + override class var layerClass: AnyClass { + AVCaptureVideoPreviewLayer.self + } + + var previewLayer: AVCaptureVideoPreviewLayer { + // `layerClass` guarantees the type. + layer as! AVCaptureVideoPreviewLayer + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift new file mode 100644 index 000000000..c99740585 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift @@ -0,0 +1,72 @@ +// +// ChatCameraSlot.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import Observation + +/// Whether the inline camera holds the keyboard's place under the composer, and how tall that +/// place is. +@MainActor +@Observable +final class ChatCameraSlot { + + /// The height the camera takes before a keyboard has risen on this screen. + static let fallbackHeight: CGFloat = 300 + + /// Whether the camera is up in the keyboard's place. + private(set) var isOpen = false + + /// The keyboard's height above the bottom safe area, the last time it rose. + private(set) var height: CGFloat = fallbackHeight + + @ObservationIgnored private var observer: (any NSObjectProtocol)? + + init() { + observer = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: nil + ) { [weak self] notification in + let info = notification.userInfo + let endFrame = info?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect + let isLocal = info?[UIResponder.keyboardIsLocalUserInfoKey] as? Bool ?? true + MainActor.assumeIsolated { + guard isLocal, let endFrame, !endFrame.isEmpty else { return } + let safeAreaBottom = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 + self?.keyboardWillShow(height: endFrame.height, safeAreaBottom: safeAreaBottom) + } + } + } + + isolated deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + /// Puts the camera up in the keyboard's place. + func open() { + guard !isOpen else { return } + isOpen = true + } + + /// Takes the camera down. + func close() { + guard isOpen else { return } + isOpen = false + } + + /// Records a keyboard `height` tall rising over a window whose bottom safe area is + /// `safeAreaBottom`, and closes the camera, since the keyboard is taking its place back. + func keyboardWillShow(height keyboardHeight: CGFloat, safeAreaBottom: CGFloat) { + let slotHeight = keyboardHeight - safeAreaBottom + if slotHeight > 0, slotHeight != height { + height = slotHeight + } + close() + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index ba11482f5..a7d58db36 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -263,10 +263,18 @@ extension ChatItem { isFromSelf: isFromSelf, senderName: counterpartName )) - case .media(_, let caption): - // A text stand-in until the transcript has a media cell; a redacted photo stays a - // photo row rather than a tombstone. - content = .text("📷 " + (caption ?? "Photo")) + case .media(let attachments, let caption): + // A redacted photo stays a photo row, drawn from its BlurHash, rather than a tombstone. + // A send carries one attachment (see `ChatMediaSendPlan`); the row draws the first. + let attachment = attachments.first + content = .media(ChatMediaContent( + blobID: attachment?.blobID, + width: attachment?.width ?? 0, + height: attachment?.height ?? 0, + blurhash: attachment?.blurhash, + caption: caption, + isRedacted: message.redacted + )) } // The status line rides on the bubble itself (not a separate row, so a send is a clean @@ -437,7 +445,7 @@ extension ChatItem { return ChatQuote( stableID: original.stableID, authorName: authorName, - snippet: ChatQuote.snippet(forText: caption ?? "Photo"), + snippet: ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption)), kind: .media(thumbnailBlobID: original.redacted ? nil : attachments.first?.blobID), authorID: original.senderID ) diff --git a/Flipcash/Core/Screens/Conversation/ChatMediaSendPlan.swift b/Flipcash/Core/Screens/Conversation/ChatMediaSendPlan.swift new file mode 100644 index 000000000..43e507ae0 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatMediaSendPlan.swift @@ -0,0 +1,54 @@ +// +// ChatMediaSendPlan.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import FlipcashCore + +/// The messages one composer send fans out to, pinned by `chat_media.json`'s `fanOut` section. +/// +/// Generic over the chip so the fixture can name chips with strings while the send path hands in +/// `ComposerChip`s. +enum ChatMediaSendPlan { + + /// One photo's message. + struct MediaMessage { + let chip: Chip + let caption: String? + let replyTo: MessageID? + } + + /// One message a send becomes. + enum Message { + case text(String, replyTo: MessageID?) + case media(MediaMessage) + } + + /// Returns a text message when there are no chips, otherwise one media message per chip. + static func messages(chips: [Chip], text: String?, replyTo: MessageID?) -> [Message] { + guard chips.isEmpty else { + return mediaMessages(chips: chips, caption: text, replyTo: replyTo).map(Message.media) + } + guard let text = nonEmpty(text) else { return [] } + return [.text(text, replyTo: replyTo)] + } + + /// Returns one message per chip in chip order, with `caption` on the last and `replyTo` on the first. + static func mediaMessages(chips: [Chip], caption: String?, replyTo: MessageID?) -> [MediaMessage] { + let caption = nonEmpty(caption) + return chips.enumerated().map { index, chip in + MediaMessage( + chip: chip, + caption: index == chips.count - 1 ? caption : nil, + replyTo: index == 0 ? replyTo : nil + ) + } + } + + private static func nonEmpty(_ text: String?) -> String? { + guard let text, !text.isEmpty else { return nil } + return text + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift b/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift new file mode 100644 index 000000000..7b3338dec --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift @@ -0,0 +1,219 @@ +// +// ChatMediaUploader.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import ImageIO +import FlipcashCore + +private let logger = Logger(label: "flipcash.chat-media-upload") + +/// The blob calls a chat photo upload makes, in the order it makes them. +/// +/// The owner is bound by the conformer, so the upload never handles keys. +protocol ChatMediaBlobStoring { + + /// Returns the upload constraints in force for the owner. + func uploadPolicy() async throws -> UploadPolicy + + /// Stores `data` and returns its blob, before the server has finalized it. + func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID + + /// Returns once the blob is servable, throwing `ErrorBlob.rejected` when it is refused and + /// `ErrorBlob.timedOut` when it is still processing. + func awaitBlobFinalization(blobID: BlobID) async throws +} + +/// Stores chat photos on behalf of the signed-in owner. +struct SessionChatMediaBlobStore: ChatMediaBlobStoring { + + let session: Session + let flipClient: FlipClient + + func uploadPolicy() async throws -> UploadPolicy { + try await flipClient.uploadPolicy(owner: session.ownerKeyPair) + } + + func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID { + try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair) + } + + func awaitBlobFinalization(blobID: BlobID) async throws { + try await flipClient.awaitBlobFinalization(blobID: blobID, owner: session.ownerKeyPair) + } +} + +/// Why a chat photo did not upload. +enum ChatMediaUploadError: Error { + /// The upload policy accepts no JPEG. + case noMatchingConstraint + /// The photo could not be encoded within the policy's size ceiling. + case encodingFailed(ChatMediaEncoder.Error) + /// The server refused the stored bytes. + case rejected(BlobRejectionReason) + /// The upload failed for a reason other than the bytes themselves, after any automatic retries. + case failed(Error) + + /// Whether uploading the same photo again could succeed. + var isRetryable: Bool { + switch self { + case .noMatchingConstraint, .encodingFailed: + false + case .rejected(let reason): + switch reason { + case .moderation: + false + case .unsupportedType, .mismatchedType, .tooLarge, .corrupt, .privacyMetadata, .unknown: + true + } + case .failed: + true + } + } +} + +extension ChatMediaUploadError: ServerError { + var reportingLevel: ErrorReportingLevel { + switch self { + case .noMatchingConstraint, .encodingFailed: + .error + case .rejected: + .info + case .failed(let error): + (error as? ServerError)?.reportingLevel ?? .error + } + } +} + +/// Turns a staged photo into a finalized blob: downscaled to the upload policy's bounds, encoded +/// down the JPEG quality ladder, stored, and awaited until the server has finalized it. +struct ChatMediaUploader { + + let blob: any ChatMediaBlobStoring + + /// The wait before each automatic retry of a store that failed in transit; one entry per retry. + var backoff: [Duration] = [.seconds(1), .seconds(2), .seconds(4)] + + /// Returns the finalized blob for `image`, throwing `ChatMediaUploadError`. + /// + /// `onPrepared` receives the uploaded pixel width and height once, before any bytes are stored. + func upload(_ image: UIImage, onPrepared: (Int, Int) -> Void) async throws -> BlobID { + let policy: UploadPolicy + do { + policy = try await blob.uploadPolicy() + } catch { + try Task.checkCancellation() + throw ChatMediaUploadError.failed(error) + } + + guard let constraint = policy.constraint(for: ChatMediaEncoder.mimeType) else { + logger.warning("Upload policy accepts no chat photo", metadata: ["policyVersion": "\(policy.version)"]) + throw ChatMediaUploadError.noMatchingConstraint + } + guard let cgImage = image.cgImage else { + throw ChatMediaUploadError.encodingFailed(.encodingFailed) + } + + let orientation = CGImagePropertyOrientation(image.imageOrientation) + let isSideways: Bool + switch orientation { + case .left, .leftMirrored, .right, .rightMirrored: + isSideways = true + case .up, .upMirrored, .down, .downMirrored: + isSideways = false + } + + let target = ChatMediaDownscale.target( + sourceWidth: isSideways ? cgImage.height : cgImage.width, + sourceHeight: isSideways ? cgImage.width : cgImage.height, + maxWidth: constraint.image?.maxWidth ?? 0, + maxHeight: constraint.image?.maxHeight ?? 0, + maxPixels: constraint.image?.maxPixels ?? 0 + ) + onPrepared(target.width, target.height) + + let data: Data + do { + data = try await Self.encode(cgImage, orientation: orientation, target: target, maxSizeBytes: constraint.maxSizeBytes) + } catch let error as ChatMediaEncoder.Error { + throw ChatMediaUploadError.encodingFailed(error) + } + + let blobID = try await store(data) + + do { + try await blob.awaitBlobFinalization(blobID: blobID) + } catch ErrorBlob.rejected(let reason) { + throw ChatMediaUploadError.rejected(reason) + } catch { + try Task.checkCancellation() + throw ChatMediaUploadError.failed(error) + } + + return blobID + } + + /// Stores `data`, retrying through `backoff` while the failure is in transit. + private func store(_ data: Data) async throws -> BlobID { + var attempt = 0 + while true { + do { + return try await blob.storeBlob(data, mimeType: ChatMediaEncoder.mimeType) + } catch { + try Task.checkCancellation() + + if let error = error as? ErrorBlob { + switch error { + case .rejected(let reason): + throw ChatMediaUploadError.rejected(reason) + case .network, .uploadFailed, .timedOut, .unknown: + break + case .uploadDenied, .unsupportedType, .tooLarge, .quotaExceeded, .notFound, .notUploaded: + throw ChatMediaUploadError.failed(error) + } + } + + guard attempt < backoff.count else { + throw ChatMediaUploadError.failed(error) + } + + logger.info("Retrying chat photo store", metadata: [ + "attempt": "\(attempt + 1)", + "error": "\(error)", + ]) + try await Task.sleep(for: backoff[attempt]) + attempt += 1 + } + } + } + + /// Encodes off the main actor, since drawing and compressing a full-size photo takes a while. + @concurrent + private nonisolated static func encode( + _ image: CGImage, + orientation: CGImagePropertyOrientation, + target: ChatMediaDownscale.Target, + maxSizeBytes: Int + ) async throws -> Data { + try ChatMediaEncoder().encode(image, orientation: orientation, target: target, maxSizeBytes: maxSizeBytes) + } +} + +private extension CGImagePropertyOrientation { + init(_ orientation: UIImage.Orientation) { + switch orientation { + case .up: self = .up + case .upMirrored: self = .upMirrored + case .down: self = .down + case .downMirrored: self = .downMirrored + case .left: self = .left + case .leftMirrored: self = .leftMirrored + case .right: self = .right + case .rightMirrored: self = .rightMirrored + @unknown default: self = .up + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift new file mode 100644 index 000000000..e3c273f78 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift @@ -0,0 +1,186 @@ +// +// ChatPhotoCamera.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import Observation +// SAFETY: AVCaptureSession and AVCapturePhotoOutput are documented thread-safe, but AVFoundation +// has not adopted Sendable, so handing the session to the session queue trips compile-time +// warnings. Same exemption `CameraSession` takes. +// FOLLOW-UP: Remove once AVFoundation annotates the capture pipeline as Sendable. +@preconcurrency import AVFoundation + +/// A still-photo camera for the chat composer: one capture session with a photo output, flippable +/// between the back and front lenses. +/// +/// Separate from FlipcashUI's `CameraSession`, which wires only video-data and metadata outputs for +/// scanning and has no still-photo output. +@Observable +final class ChatPhotoCamera { + + /// Why the camera could not be set up. + enum SetupError: Error { + /// The device has no camera at the requested position, as on the simulator. + case deviceUnavailable + /// The session refused the camera's input or the photo output. + case configurationRejected + } + + /// The lens the session is capturing from. + private(set) var position: AVCaptureDevice.Position = .back + + /// Whether a shutter press is still being processed. + private(set) var isCapturing = false + + @ObservationIgnored let session = AVCaptureSession() + + @ObservationIgnored private let output = AVCapturePhotoOutput() + @ObservationIgnored private let sessionQueue = DispatchQueue(label: "com.flipcash.chat-camera.session") + @ObservationIgnored private var input: AVCaptureDeviceInput? + @ObservationIgnored private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? + // The output holds its delegate weakly; each capture keeps its own alive until it finishes. + @ObservationIgnored private var pendingCaptures: [Int64: PhotoCaptureDelegate] = [:] + + // Same Swift 6.3 EarlyPerfInliner workaround `CameraSession` carries for @MainActor deinits. + nonisolated deinit {} + + /// Returns the lens a flip moves to from `position`. + nonisolated static func flipped(_ position: AVCaptureDevice.Position) -> AVCaptureDevice.Position { + switch position { + case .back: + .front + case .front, .unspecified: + .back + @unknown default: + .back + } + } + + // MARK: - Session - + + /// Adds the photo output and the current lens to the session; a no-op once configured. + func configure() throws { + guard input == nil else { return } + + session.beginConfiguration() + // An uncommitted session crashes on stopRunning(), so commit on every exit. + defer { session.commitConfiguration() } + + session.sessionPreset = .photo + guard session.canAddOutput(output) else { + throw SetupError.configurationRejected + } + session.addOutput(output) + try attachInput(for: position) + } + + /// Starts the session off the main thread; `startRunning()` blocks until frames flow. + func start() { + let session = session + sessionQueue.async { + guard !session.isRunning else { return } + session.startRunning() + } + } + + /// Stops the session off the main thread. + func stop() { + let session = session + sessionQueue.async { + guard session.isRunning else { return } + session.stopRunning() + } + } + + /// Swaps to the other lens, keeping the current one if the other is missing. + func flip() { + guard input != nil else { return } + let target = Self.flipped(position) + + session.beginConfiguration() + defer { session.commitConfiguration() } + + let previous = input + if let previous { + session.removeInput(previous) + } + do { + try attachInput(for: target) + } catch { + if let previous, session.canAddInput(previous) { + session.addInput(previous) + input = previous + } + } + } + + private func attachInput(for position: AVCaptureDevice.Position) throws { + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) else { + throw SetupError.deviceUnavailable + } + guard + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) + else { + throw SetupError.configurationRejected + } + session.addInput(input) + self.input = input + self.position = position + // The app is portrait-locked, so the interface can't say how the phone is held; the + // coordinator reads the device's physical orientation for the photo's EXIF. + rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: nil) + } + + // MARK: - Capture - + + /// Returns the next photo from the running session, or `nil` if the session isn't running or + /// the capture failed. + func capture() async -> UIImage? { + guard session.isRunning, !isCapturing else { return nil } + isCapturing = true + defer { isCapturing = false } + + let settings = AVCapturePhotoSettings() + if let connection = output.connection(with: .video), let rotationCoordinator { + let angle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture + if connection.isVideoRotationAngleSupported(angle) { + connection.videoRotationAngle = angle + } + } + + let id = settings.uniqueID + let data: Data? = await withCheckedContinuation { continuation in + let delegate = PhotoCaptureDelegate { continuation.resume(returning: $0) } + pendingCaptures[id] = delegate + output.capturePhoto(with: settings, delegate: delegate) + } + pendingCaptures[id] = nil + + // `UIImage(data:)` carries the EXIF orientation into `imageOrientation`, which the uploader + // bakes into the pixels before encoding. + return data.flatMap(UIImage.init(data:)) + } +} + +// MARK: - PhotoCaptureDelegate - + +// AVFoundation calls back on its own queue, so the delegate stays off the main actor, as +// `CameraSession`'s delegates do. +// +// SAFETY (`@unchecked Sendable`): the only stored property is an immutable `@Sendable` closure. +private nonisolated final class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate, @unchecked Sendable { + + private let completion: @Sendable (Data?) -> Void + + init(completion: @escaping @Sendable (Data?) -> Void) { + self.completion = completion + } + + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + completion(error == nil ? photo.fileDataRepresentation() : nil) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift new file mode 100644 index 000000000..40ab29583 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift @@ -0,0 +1,62 @@ +// +// ChatPhotoStaging.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import PhotosUI +import SwiftUI +import UIKit +import FlipcashCore + +private let logger = Logger(label: "flipcash.chat-photo-staging") + +/// Turns photos picked from the library into composer chips. +enum ChatPhotoStaging { + + /// Loads `items` one at a time in pick order and stages each photo that loads, stopping at the + /// first one the composer refuses for being full; returns the chips staged. + @discardableResult + static func stage( + _ items: [Item], + into composer: ComposerModel, + uploader: ChatMediaUploader, + load: (Item) async -> UIImage? + ) async -> [ComposerChip] { + var staged: [ComposerChip] = [] + for item in items { + guard let image = await load(item) else { continue } + guard let chip = composer.stageChip(image: image, uploader: uploader) else { break } + staged.append(chip) + } + return staged + } + + /// Returns the photo `item` holds, or `nil` when it cannot be loaded or decoded. + static func loadImage(_ item: PhotosPickerItem) async -> UIImage? { + let data: Data + do { + guard let loaded = try await item.loadTransferable(type: Data.self) else { + logger.warning("Picked photo has no data") + return nil + } + data = loaded + } catch { + // Usually an iCloud original that failed to download; nothing a developer can act on. + logger.warning("Picked photo failed to load", metadata: ["error": "\(error)"]) + return nil + } + guard let image = await decode(data) else { + logger.warning("Picked photo failed to decode", metadata: ["bytes": "\(data.count)"]) + return nil + } + return image + } + + /// Decodes off the main actor, since a full-size photo takes a while to parse. + @concurrent + private nonisolated static func decode(_ data: Data) async -> UIImage? { + UIImage(data: data) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index aed97cd68..59774e657 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -5,6 +5,7 @@ // Copyright © 2026 Code Inc. All rights reserved. // +import PhotosUI import SwiftUI import UIKit import FlipcashCore @@ -117,6 +118,18 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Avatar bytes for the group's members, keyed by user id. Empty in a DM, and empty for a group /// until the pictures download — the rows fall back to a BlurHash, then a monogram. let authorAvatars: [UserID: Data] + /// Whether the chat takes photos; false for an E2EE DM. + var acceptsMedia: Bool = false + /// Receives a photo taken with the inline camera, which the representable opens in the + /// keyboard's place and closes again once the photo is handed over. + var onCameraCapture: (UIImage) -> Void = { _ in } + /// Receives the photos picked from the attach menu, in the order they were selected. + var onPhotosPicked: ([PhotosPickerItem]) -> Void = { _ in } + /// Mints a signed download URL for a photo in this chat. The transcript's resolver caches what it + /// returns and never asks for a photo drawn only from its BlurHash. + var mintMediaURL: (BlobID) async throws -> URL? = { _ in nil } + /// Fired when the user taps a photo the viewer may see. The owner opens the full-screen viewer. + var onMediaTap: (ChatMediaViewerRequest) -> Void = { _ in } func makeUIViewController(context: Context) -> ChatScreenViewController { let barHost = UIHostingController(rootView: bar(coordinator: context.coordinator)) @@ -143,6 +156,12 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.ownProfile = ownProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource + screen.onMediaTap = onMediaTap + context.coordinator.mintMediaURL = mintMediaURL + screen.mediaURLResolver = context.coordinator.mediaURLResolver + screen.pendingMediaImage = { [conversationController] id in + conversationController.pendingMediaImage(forMessageID: id) + } screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onGroupInvite = onGroupInvite @@ -189,6 +208,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.ownProfile = ownProfile screen.onLinkCardTap = onLinkCardTap screen.linkCardSource = linkCardSource + screen.onMediaTap = onMediaTap + context.coordinator.mintMediaURL = mintMediaURL screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onGroupInvite = onGroupInvite @@ -285,7 +306,27 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { onGateAddFunds: onGateAddFunds, onGateJoin: onGateJoin, isJoiningChat: isJoiningChat, - mentions: mentions + mentions: mentions, + acceptsMedia: acceptsMedia, + onCamera: { [weak coordinator, camera = barModel.camera] in + coordinator?.screen?.dismissKeyboard() + camera.open() + }, + onCameraCapture: { [weak coordinator, camera = barModel.camera] image in + onCameraCapture(image) + camera.close() + // Back to the keyboard, so the photo can be captioned. + coordinator?.screen?.focusComposer() + }, + onCameraCancel: { [weak coordinator, camera = barModel.camera] in + camera.close() + coordinator?.screen?.focusComposer() + }, + onPhotosPicked: onPhotosPicked, + // Only a member can aim a reply, so the viewer may see what it quotes. + quoteThumbnailURL: { [resolver = coordinator.mediaURLResolver] kind in + await resolver.thumbnailURL(for: kind, canReact: true) + } ) .environment(conversationController) .modifier( @@ -307,6 +348,15 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { var barHost: UIHostingController? weak var screen: ChatScreenViewController? var lastMessageID: String? + /// The latest ``ChatScreenRepresentable/mintMediaURL``, read by the resolver. + var mintMediaURL: (BlobID) async throws -> URL? = { _ in nil } + /// The one resolver the transcript and the composer's reply strip share, so a photo is minted + /// once however many places draw it. Reads ``mintMediaURL`` at fetch time, so a chat created + /// after this screen opened mints against its real id. + lazy var mediaURLResolver = ChatMediaURLResolver { [weak self] blobID in + guard let self else { return nil } + return try await self.mintMediaURL(blobID) + } } } diff --git a/Flipcash/Core/Screens/Conversation/ComposerChip.swift b/Flipcash/Core/Screens/Conversation/ComposerChip.swift new file mode 100644 index 000000000..7deb02c7c --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerChip.swift @@ -0,0 +1,90 @@ +// +// ComposerChip.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit +import Observation +import FlipcashCore + +/// Whether a failed chip can be uploaded again, or the server refused its bytes for good. +enum ChatMediaChipFailure: Equatable { + case notRetryable + case retryable +} + +/// A photo staged in the composer, and how far its upload has got. +/// +/// A reference type so the send path can hold the chip it was handed and await its upload later; +/// a chip lives only as long as the composer, never in the persisted draft. +@MainActor +@Observable +final class ComposerChip: Identifiable { + + enum State: Equatable { + case preparing + case uploading + case uploaded(BlobID) + case failed(ChatMediaChipFailure) + } + + let id = UUID() + let image: UIImage + var state: State = .preparing + + /// Pixel width of the image as it will be uploaded, known before the upload starts. + var preparedWidth: Int? + + /// Pixel height of the image as it will be uploaded, known before the upload starts. + var preparedHeight: Int? + + /// The upload in flight or finished for this chip, which the send path awaits rather than + /// starting its own. + @ObservationIgnored var uploadTask: Task? + + /// The uploader the last attempt went through, kept so a failed send can upload again after the + /// composer has let go of the chip. + @ObservationIgnored private var uploader: ChatMediaUploader? + + init(image: UIImage) { + self.image = image + } + + /// Uploads the image through `uploader`, replacing any earlier attempt; calling it again after + /// a retryable failure is how the chip is retried. + func startUpload(using uploader: ChatMediaUploader) { + self.uploader = uploader + uploadTask?.cancel() + state = .preparing + + uploadTask = Task { + do { + let blobID = try await uploader.upload(image) { width, height in + preparedWidth = width + preparedHeight = height + state = .uploading + } + if !Task.isCancelled { + state = .uploaded(blobID) + } + return blobID + } catch let error as ChatMediaUploadError { + if !Task.isCancelled { + state = .failed(error.isRetryable ? .retryable : .notRetryable) + } + throw error + } + } + } + + /// Uploads again through the uploader the last attempt used, returning `false` when no upload + /// was ever started. + @discardableResult + func retryUpload() -> Bool { + guard let uploader else { return false } + startUpload(using: uploader) + return true + } +} diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift new file mode 100644 index 000000000..476bce9b4 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -0,0 +1,152 @@ +// +// ComposerChipStrip.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI + +/// What a staged chip draws over its thumbnail. +enum ComposerChipBadge: Equatable { + /// The photo is still being prepared or uploaded. + case progress + /// The upload failed and can be tried again. + case retry + /// The server refused the photo; the only way forward is to remove it. + case error + /// The photo is uploaded, so the thumbnail stands alone. + case none + + /// Returns the badge for a chip in `state`. + init(_ state: ComposerChip.State) { + switch state { + case .preparing, .uploading: self = .progress + case .uploaded: self = .none + case .failed(.retryable): self = .retry + case .failed(.notRetryable): self = .error + } + } +} + +/// The photos staged for the next send, as a row of thumbnails above the message field. +/// +/// Part of the bar's own content rather than a reveal like the reply strip: its arrival changes the +/// bar's height the way a draft wrapping to a second line does, which the screen takes in one frame. +struct ComposerChipStrip: View { + + /// Side of a chip's square thumbnail, per the spec's 56pt chips. + static let chipSize: CGFloat = 56 + + let chips: [ComposerChip] + let onRemove: (ComposerChip.ID) -> Void + let onRetry: (ComposerChip) -> Void + + /// Returns whether the strip is drawn: while chips are staged, and not during an edit, which + /// takes the bar for itself and hands the chips back when it ends. + static func isShown(chipCount: Int, isEditing: Bool) -> Bool { + chipCount > 0 && !isEditing + } + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(chips) { chip in + ComposerChipView( + chip: chip, + onRemove: { onRemove(chip.id) }, + onRetry: { onRetry(chip) } + ) + } + } + } + .scrollIndicators(.hidden) + .frame(height: Self.chipSize) + .accessibilityIdentifier("composer-chip-strip") + } +} + +/// One staged photo: its thumbnail, a remove button, and the upload's badge. +private struct ComposerChipView: View { + + let chip: ComposerChip + let onRemove: () -> Void + let onRetry: () -> Void + + private static let cornerRadius: CGFloat = 10 + + /// A thumbnail sized for the chip, decoded off the main thread. The staged image is the full + /// camera or library photo, which is far too large to redraw at 56pt on every pass. + @State private var thumbnail: UIImage? + + var body: some View { + let size = ComposerChipStrip.chipSize + ZStack { + Color.backgroundSecondary + if let thumbnail { + Image(uiImage: thumbnail) + .resizable() + .scaledToFill() + } + badge + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) + .overlay(alignment: .topTrailing) { + Button(action: onRemove) { + Image(systemName: SystemSymbol.closeCircle.rawValue) + .font(.default(size: 18, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(Color.white, Color.black.opacity(0.55)) + .padding(3) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove photo") + .accessibilityIdentifier("composer-chip-remove") + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("composer-chip") + .task(id: chip.id) { + let scale = UITraitCollection.current.displayScale + let pixels = CGSize(width: size * scale, height: size * scale) + thumbnail = await chip.image.byPreparingThumbnail(ofSize: pixels) ?? chip.image + } + } + + @ViewBuilder + private var badge: some View { + switch ComposerChipBadge(chip.state) { + case .progress: + ZStack { + Color.black.opacity(0.35) + ProgressView() + .tint(.white) + } + .accessibilityLabel("Uploading photo") + case .retry: + Button(action: onRetry) { + ZStack { + Color.black.opacity(0.45) + Image(systemName: "arrow.clockwise") + .font(.default(size: 18, weight: .semibold)) + .foregroundStyle(Color.white) + } + } + .buttonStyle(.plain) + .accessibilityLabel("Retry photo upload") + .accessibilityIdentifier("composer-chip-retry") + case .error: + ZStack { + Color.black.opacity(0.45) + Image(systemName: "exclamationmark.triangle.fill") + .font(.default(size: 18, weight: .semibold)) + .foregroundStyle(Color.textError) + } + .accessibilityLabel("Photo can't be sent") + case .none: + EmptyView() + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index 1f75f8e53..108d94bbc 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -5,7 +5,7 @@ // Copyright © 2026 Code Inc. All rights reserved. // -import Foundation +import UIKit import Observation import SwiftUI import FlipcashCore @@ -40,6 +40,9 @@ final class ComposerModel { case replying(to: ReplyTarget) } + /// The most photos one send can carry, matching `chat_media.json`'s `maxAttachments`. + static let maxAttachments = 10 + private(set) var mode: Mode = .new var draft = "" /// The field's cursor or selection, while it has focus. @@ -48,6 +51,9 @@ final class ComposerModel { /// taken from. var selection: TextSelection? + /// Photos staged for the next send, in the order they were added. + private(set) var chips: [ComposerChip] = [] + /// The unsent new-message draft, held while an edit occupies the field. @ObservationIgnored private var stashedDraft = "" /// The text the message had when the edit began, so an unchanged edit can be refused. @@ -66,7 +72,47 @@ final class ComposerModel { } } - var canSubmit: Bool { submission != nil } + /// Whether the send button can fire: text or at least one chip, and no chip that failed. Chips + /// still uploading do not hold the send back, and an edit is text only. + var canSubmit: Bool { + switch mode { + case .new, .replying: + !hasFailedChip && (submission != nil || !chips.isEmpty) + case .editing: + submission != nil + } + } + + /// What the send button posts for a new message or a reply. + enum Outgoing { + case text(String) + /// The staged photos in order, with the trimmed text as the caption. + case media([ComposerChip], caption: String?) + } + + /// What the send button would post now, or `nil` when it cannot fire or the field holds an edit. + var outgoing: Outgoing? { + switch mode { + case .editing: + return nil + case .new, .replying: + guard canSubmit else { return nil } + if chips.isEmpty { + return submission.map(Outgoing.text) + } + return .media(chips, caption: submission) + } + } + + /// Whether any staged chip failed to upload, which holds the send until it is retried or removed. + var hasFailedChip: Bool { + chips.contains { chip in + switch chip.state { + case .failed: true + case .preparing, .uploading, .uploaded: false + } + } + } /// The transcript row an edit is open on, if any. The chat screen keys its edit backdrop off /// this, so reading it is what ties the backdrop's lifetime to the composer's mode. @@ -163,7 +209,26 @@ final class ComposerModel { mode = .new } - /// Empties the field after a successful send. + /// Stages `image` as a new chip and starts its upload through `uploader`, returning the chip, or + /// returns `nil` when the composer already holds `maxAttachments` chips. + @discardableResult + func stageChip(image: UIImage, uploader: ChatMediaUploader) -> ComposerChip? { + guard chips.count < Self.maxAttachments else { return nil } + let chip = ComposerChip(image: image) + chips.append(chip) + chip.startUpload(using: uploader) + return chip + } + + /// Drops the chip with `id` and cancels its upload. + func removeChip(_ id: ComposerChip.ID) { + guard let index = chips.firstIndex(where: { $0.id == id }) else { return } + chips[index].uploadTask?.cancel() + chips.remove(at: index) + } + + /// Empties the field and the chip strip once a send has taken them. The chips' uploads keep + /// running, since the send awaits them. func clear() { // Only when it actually changes. `@Observable` fires on assignment without comparing, and // the chat screen's body reads `isEditing` and `editingStableID` — both derived from this — @@ -176,6 +241,9 @@ final class ComposerModel { stashedDraft = "" draft = "" selection = nil + if !chips.isEmpty { + chips = [] + } } /// The `@word` at the cursor that the mention picker searches for, or `nil` when it is closed. diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index c84f2697a..f4766486e 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -8,6 +8,7 @@ import SwiftUI import FlipcashCore import FlipcashUI +import Kingfisher /// The quoted original above the composer while a reply is being written: a rule in the author's own /// colour, the author's name over one or two lines of what they said, and the way out on the @@ -22,9 +23,13 @@ import FlipcashUI struct ComposerReplyStrip: View { let target: ComposerModel.ReplyTarget + /// Where a quoted photo's thumbnail loads from; nil for anything else, or a photo with none to fetch. + var thumbnailURL: (ChatQuote.Kind) async -> URL? = { _ in nil } let onDismiss: () -> Void @Environment(\.barCardCollapsed) private var collapsed + @State private var resolvedThumbnail: URL? + /// Wider than the 4pt a blockquote rule usually takes, because the quote's corner radius is the /// bar's 14: the leading edge is straight for only `contentHeight - 14 * 2` of its run, and the /// corners taper the rest away. Thickness can't lengthen that straight core — it only gives the @@ -43,6 +48,9 @@ struct ComposerReplyStrip: View { /// rather than as a second element the line has to make room for. private static let flagDiameter: CGFloat = 16 + /// The quote's two text lines' height, so the photo sits beside them without growing the strip. + private static let thumbnailSide: CGFloat = 38 + var body: some View { // One colour for the rule and the name, so the rule keeps the name's contrast on the surface. let name = ComplementaryPalette.nameColor(for: target.authorID) @@ -63,6 +71,8 @@ struct ComposerReplyStrip: View { .accessibilityLabel("Replying to \(target.authorName): \(spokenSnippet)") .accessibilityIdentifier("composer-reply-quote") + thumbnail + // A disc rather than a bare ✕, because the ground behind it is glass sampling the // transcript: a hairline glyph's contrast changed with whatever message scrolled past. // The fill gives it its own ground and takes that variable out. @@ -145,6 +155,31 @@ struct ComposerReplyStrip: View { } } + /// A quoted photo's thumbnail; nothing for any other quote, or a redacted photo. + @ViewBuilder + private var thumbnail: some View { + switch target.kind { + case .media(let thumbnailBlobID?): + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(0.08)) + .overlay { + if let resolvedThumbnail { + KFImage(source: .network(ChatMediaImageSource.resource(blobID: thumbnailBlobID, url: resolvedThumbnail))) + .resizable() + .scaledToFill() + } + } + .frame(width: Self.thumbnailSide, height: Self.thumbnailSide) + .clipShape(.rect(cornerRadius: 6)) + .accessibilityHidden(true) + .task(id: thumbnailBlobID) { + resolvedThumbnail = await thumbnailURL(target.kind) + } + case .media(nil), .text, .cash, .unavailable: + EmptyView() + } + } + /// What VoiceOver reads for the quote — the mint's name is part of a payment's identity, and it /// is drawn as a separate run that the combined element would otherwise drop. private var spokenSnippet: String { diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 4d5c6a6ad..9d8725ccf 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -5,6 +5,7 @@ // Copyright © 2026 Code Inc. All rights reserved. // +import PhotosUI import SwiftUI import FlipcashCore import FlipcashUI @@ -14,6 +15,8 @@ import FlipcashUI /// `ComposerModel`, which also knows whether it is a new message or an edit. @MainActor @Observable final class ConversationBarModel { var isComposing = false + /// The inline camera, which takes the keyboard's place under the composer. + let camera = ChatCameraSlot() } /// Single spring driving the whole bar: the button morph, the composer's @@ -47,7 +50,36 @@ enum BarMetrics { static let compactInset: CGFloat = 32 } -/// The unified bottom bar: Send Cash (morphing) beside the message field. +/// What stands in the bar's leading slot, beside the message field. +enum ConversationBarLeadingControl: Equatable { + /// The way out of an edit. + case cancelEdit + /// The full-width Send Cash call to action, alone in the bar before the chat exists. + case sendCash + /// The `+` menu with these rows. + case attach([AttachMenuItem]) + /// Nothing: the chat takes neither cash nor media from this user. + case none + + /// Returns the control for the bar's state. An edit wins; before the chat exists the only + /// control is the Send Cash call to action; after, it is the attach menu, when it has a row. + init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool, acceptsMedia: Bool, attachedCount: Int) { + if isEditing { + self = .cancelEdit + } else if !chatExists { + self = showsSendCash ? .sendCash : .none + } else { + let items = AttachMenuItem.items( + showsCash: showsSendCash, + acceptsMedia: acceptsMedia, + attachedCount: attachedCount + ) + self = items.isEmpty ? .none : .attach(items) + } + } +} + +/// The unified bottom bar: the attach menu beside the message field. /// A standard-size filled Send Cash alone until the chat exists server-side. struct ConversationBottomBar: View { @@ -88,6 +120,18 @@ struct ConversationBottomBar: View { /// The composer row's measured height, reported as part of what the mention list's room is /// measured without. @State private var composerRowHeight: CGFloat = 0 + /// Whether the chat takes photos; false for an E2EE DM, whose encryption does not cover media. + var acceptsMedia: Bool = false + /// Opens the camera from the attach menu. + var onCamera: () -> Void = {} + /// Receives a photo the inline camera took. + var onCameraCapture: (UIImage) -> Void = { _ in } + /// Fired by the inline camera's back chevron. + var onCameraCancel: () -> Void = {} + /// Receives the photos picked from the attach menu, in the order they were selected. + var onPhotosPicked: ([PhotosPickerItem]) -> Void = { _ in } + /// Where the reply strip's quoted photo loads its thumbnail from. + var quoteThumbnailURL: (ChatQuote.Kind) async -> URL? = { _ in nil } /// The curve the bar narrows and widens on as the keyboard goes and comes. private static let widthSpring = Animation.spring(duration: 0.22, bounce: 0.14) @@ -130,33 +174,58 @@ struct ConversationBottomBar: View { // gained or lost. Nothing animates that travel — the bar's springs key on `chatExists` and // `isEditing`, neither of which moves during a send — so it snapped while the bar's height // sprang underneath it. - let content = HStack(alignment: .bottom, spacing: 10) { - // An edit takes over the bar: the leading control becomes the way out of it and Send - // Cash steps aside until it resolves, the way WhatsApp hides its accessory controls. - if composer.isEditing { - CancelEditButton { composer.endEditing() } - } else if showsSendCash { - SendCashMorphButton( - symbol: symbol, - // Minimized by a reply as well as by focus. Starting a reply from the context - // menu raises the keyboard, and focus — and so `isComposing` — arrives a - // transaction later than the reply target, on its own bouncy spring: the button - // collapsed after the bar had already grown, jolting the field beside it. - // Reading the target directly puts the morph in the reply's own transaction, so - // the two move together and the later focus change finds nothing left to do. - composing: model.isComposing || composer.replyTarget != nil, - standalone: !chatExists, - // Every chat sits minimized beside its composer, but before - // the first tip there is no composer to sit beside: the - // design draws the full-width "Start Chatting" CTA - // (node 10074:18891). - alwaysMinimized: chatExists, - expandedTitle: isTipDm ? startChattingTitle : nil, - action: onSendCash + let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { + // Below the reply strip and above the field, as the spec orders them. + if ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing) { + ComposerChipStrip( + chips: composer.chips, + onRemove: { composer.removeChip($0) }, + onRetry: { $0.retryUpload() } ) } - if chatExists { - ConversationComposer(conversationID: conversationID, model: model, composer: composer) + HStack(alignment: .bottom, spacing: 10) { + // An edit takes over the bar: the leading control becomes the way out of it and the + // attach menu steps aside until it resolves, the way WhatsApp hides its accessory controls. + switch leadingControl { + case .cancelEdit: + CancelEditButton { composer.endEditing() } + case .sendCash: + SendCashMorphButton( + symbol: symbol, + // Minimized by a reply as well as by focus. Starting a reply from the context + // menu raises the keyboard, and focus — and so `isComposing` — arrives a + // transaction later than the reply target, on its own bouncy spring: the button + // collapsed after the bar had already grown, jolting the field beside it. + // Reading the target directly puts the morph in the reply's own transaction, so + // the two move together and the later focus change finds nothing left to do. + composing: model.isComposing || composer.replyTarget != nil, + // Before the first tip there is no composer to sit beside: the design draws + // the full-width "Start Chatting" CTA (node 10074:18891). + standalone: true, + expandedTitle: isTipDm ? startChattingTitle : nil, + action: onSendCash + ) + case .attach(let items): + AttachMenu( + items: items, + photosSelectionLimit: AttachMenuItem.photosSelectionLimit(attachedCount: composer.chips.count), + onCash: onSendCash, + onCamera: onCamera, + onPhotos: onPhotosPicked + ) + case .none: + EmptyView() + } + if chatExists { + ConversationComposer(conversationID: conversationID, model: model, composer: composer) + .transition(.opacity) + } + } + // In the keyboard's place: the screen lowers the keyboard as this opens, and the bar + // grows by what the keyboard held, so the field stays where it was. + if model.camera.isOpen { + ChatCameraSheet(onCapture: onCameraCapture, onCancel: onCameraCancel) + .frame(height: max(0, model.camera.height - Self.rowSpacing)) .transition(.opacity) } } @@ -171,6 +240,7 @@ struct ConversationBottomBar: View { .padding(.bottom, BarMetrics.contentPadding) .animation(barMorphSpring, value: chatExists) .animation(barMorphSpring, value: composer.isEditing) + .animation(barMorphSpring, value: model.camera.isOpen) // No shared GlassEffectContainer: the composer's glass is a background // layer behind an editable text field, and a container composites its @@ -203,7 +273,7 @@ struct ConversationBottomBar: View { // already drives this state in both directions, and wrapping the dismissal in a second // transaction gave the exit a curve the entry never had. AccessoryReveal(kind: .reply, item: barReply, collapsesInPlace: mentionCandidates != nil) { target in - ComposerReplyStrip(target: target) { composer.endReplying() } + ComposerReplyStrip(target: target, thumbnailURL: quoteThumbnailURL) { composer.endReplying() } } // The quote narrows with the row below it, so the two keep one margin. .padding(.horizontal, compactExtraInset) @@ -258,6 +328,20 @@ struct ConversationBottomBar: View { return listedCandidates } + /// The gap between the bar's rows. The camera's height gives it back, so the bar grows by + /// exactly the keyboard's height. + private static let rowSpacing: CGFloat = 8 + + private var leadingControl: ConversationBarLeadingControl { + ConversationBarLeadingControl( + isEditing: composer.isEditing, + chatExists: chatExists, + showsSendCash: showsSendCash, + acceptsMedia: acceptsMedia, + attachedCount: composer.chips.count + ) + } + /// The tip CTA's title. Names the amount that opens the chat when a floor /// has resolved; the fee is what the recipient charges for the /// conversation, so stating it is the whole point of the button. @@ -724,28 +808,32 @@ struct ConversationComposer: View { // so the composer stays ready immediately. Emptying the field up front makes a double-tap a // no-op, because there is then nothing to submit. switch composer.mode { - case .new: - guard let text = composer.submission else { return } + case .new, .replying: + guard let outgoing = composer.outgoing else { return } + let repliedTo = composer.replyTarget?.messageID // Snapshotted before the field is emptied: a send that fails has no persisted record on - // this platform, so this is the only copy of the words left to put back. + // this platform, so this is the only copy of the words left to put back. A reply's strip + // travels with the text, since restoring the words alone would downgrade it to a loose + // message. let draft = composer.persistableDraft composer.clear() isFocused = true - Task { await conversationController.send(text, to: conversationID, restoringOnFailure: draft) } - case .replying(let target): - guard let text = composer.submission else { return } - // The strip travels with the text. Restoring the words alone would downgrade a reply to - // a loose message, which is the wrong-context send this is here to prevent. - let draft = composer.persistableDraft - composer.clear() - isFocused = true - Task { - await conversationController.send( - text, - to: conversationID, - repliedTo: target.messageID, - restoringOnFailure: draft - ) + switch outgoing { + case .text(let text): + Task { + await conversationController.send( + text, + to: conversationID, + repliedTo: repliedTo, + restoringOnFailure: draft + ) + } + case .media(let chips, let caption): + // A photo that fails stays in the transcript to be retried, so nothing goes back + // into the field. + Task { + await conversationController.sendMedia(chips, caption: caption, to: conversationID, repliedTo: repliedTo) + } } case .editing(let messageID, _): // Confirming an edit that changed nothing leaves edit mode rather than round-tripping @@ -800,7 +888,6 @@ struct SendCashMorphButton: View { /// the bar at the standard filled-button size instead of field-sized. let standalone: Bool /// Forces the compact symbol-only presentation regardless of composing. - /// The bar sets it once a chat exists; only the pre-chat CTA expands. var alwaysMinimized: Bool = false /// Replaces "Send " while expanded. A tip chat names the tip /// instead of the currency, because the amount is chosen on the next screen. diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index c206635cc..c693a4b17 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -5,6 +5,7 @@ // Copyright © 2026 Code Inc. All rights reserved. // +import PhotosUI import SwiftUI import UIKit import Combine @@ -536,10 +537,64 @@ struct ConversationScreen: View { onGateJoin: joinChat, isJoiningChat: isJoiningChat, mentions: mentions, - authorAvatars: authorAvatars + authorAvatars: authorAvatars, + acceptsMedia: acceptsMedia, + onCameraCapture: stageCapturedPhoto, + onPhotosPicked: stagePickedPhotos, + mintMediaURL: mintMediaURL, + onMediaTap: openMediaViewer ) } + /// Whether the chat takes photos: any chat this device has a record of, except an E2EE DM, whose + /// encryption does not cover media. False until the record loads, so the menu never offers a + /// photo the chat may refuse. + private var acceptsMedia: Bool { + guard let conversationID, + let conversation = conversationController.conversation(withID: conversationID) else { return false } + return !conversation.useE2Ee + } + + /// Uploads chat photos on behalf of the signed-in owner. + private var mediaUploader: ChatMediaUploader { + ChatMediaUploader(blob: SessionChatMediaBlobStore(session: session, flipClient: container.flipClient)) + } + + /// Stages photos picked from the library, in the order they were picked. + private func stagePickedPhotos(_ items: [PhotosPickerItem]) { + let uploader = mediaUploader + Task { + await ChatPhotoStaging.stage(items, into: composer, uploader: uploader, load: ChatPhotoStaging.loadImage) + } + } + + /// Stages a photo taken with the inline camera. + private func stageCapturedPhoto(_ image: UIImage) { + composer.stageChip(image: image, uploader: mediaUploader) + } + + /// Opens a tapped photo full screen, zooming out of its row, with share through the system sheet. + /// The transcript only hands over a request for a photo the viewer may see, so nothing here can + /// open or download a BlurHash-only one. + private func openMediaViewer(_ request: ChatMediaViewerRequest) { + guard var presenter = UIApplication.shared.currentKeyWindow?.rootViewController else { return } + while let presented = presenter.presentedViewController { + presenter = presented + } + let viewer = ChatMediaViewerController(request: request) { image in + ShareSheet.present(activityItems: [image]) { _ in } + } + presenter.present(viewer, animated: true) + } + + /// Mints a download URL for a photo in this chat, read through the chat's access context. + private var mintMediaURL: (BlobID) async throws -> URL? { + { [flipClient = container.flipClient, owner = session.ownerKeyPair, conversationID] blobID in + guard let conversationID else { return nil } + return try await flipClient.blobDownloadURL(blobID: blobID, owner: owner, accessContext: .chatMessage(conversationID)) + } + } + var body: some View { // The UIKit transcript hosts the bar internally and owns all keyboard handling, so there's // no SwiftUI `.safeAreaInset` bar here. @@ -1098,7 +1153,7 @@ struct ConversationScreen: View { ) case .media(let attachments, let caption): ( - ChatQuote.snippet(forText: caption ?? "Photo"), + ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption)), .media(thumbnailBlobID: message.redacted ? nil : attachments.first?.blobID) ) case .deleted: diff --git a/Flipcash/Core/Screens/Main/CameraPromptView.swift b/Flipcash/Core/Screens/Main/CameraPromptView.swift index 45a14e3a3..0a013823a 100644 --- a/Flipcash/Core/Screens/Main/CameraPromptView.swift +++ b/Flipcash/Core/Screens/Main/CameraPromptView.swift @@ -80,11 +80,13 @@ struct CameraPromptView: View { let prompt: CameraPrompt /// Whether shown inside the v2 tab-bar UI (affects the permission copy). var embedded: Bool = false + /// Replaces the scanner wording, for a camera used for something other than scanning. + var message: String? = nil let action: () -> Void var body: some View { VStack(spacing: 40) { - Text(prompt.message(embedded: embedded)) + Text(message ?? prompt.message(embedded: embedded)) .frame(maxWidth: 260) .multilineTextAlignment(.center) diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 188d53a12..1167e363e 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -60,7 +60,7 @@ nonisolated struct ChatSpotlightItem { case .text(let text): text case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())" case .widget(.shareProfile): "Shared a profile" - case .media(_, let caption): "📷 " + (caption ?? "Photo") + case .media(_, let caption): ChatMediaStrings.listPreview(caption: caption) case .deleted, .encrypted, .widget(.unrecognized), nil: nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index ad8e5ee08..5ef389854 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -190,6 +190,14 @@ extension FlipClient { } } + /// Sends the finalized photo `blobID` as one media message, with `caption` under it. + @discardableResult + public func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + try await withCheckedThrowingContinuation { c in + chatMessagingService.sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID) { c.resume(with: $0) } + } + } + public func editMessage( owner: KeyPair, conversationID: ConversationID, diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index 0436a3914..ce55e90b2 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -168,10 +168,39 @@ final class ChatMessagingService: Sendable { completion(.failure(.encryptionFailed)) return } + send(content, owner: owner, conversationID: conversationID, seal: seal, clientMessageID: clientMessageID, completion: completion) + } + + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { + let media = Flipcash_Messaging_V1_Content.with { + $0.media = .with { + $0.items = [.with { + $0.renditions = [.with { + $0.role = .original + $0.blobID = .with { $0.value = blobID.data } + }] + }] + if let caption { + $0.caption = .with { $0.text = caption } + } + } + } + // A reply wraps the media one level deeper on the wire. The domain model keeps it flat — + // see `ConversationMessage.init?(_:)`, which unwraps it back. + let content = repliedTo.map { repliedTo in + Flipcash_Messaging_V1_Content.with { + $0.reply = .with { + $0.repliedMessageID = repliedTo.proto + $0.content = [media] + } + } + } ?? media + send(content, owner: owner, conversationID: conversationID, seal: nil, clientMessageID: clientMessageID, completion: completion) + } + + private func send(_ content: Flipcash_Messaging_V1_Content, owner: KeyPair, conversationID: ConversationID, seal: ChatSeal?, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Messaging_V1_SendMessageRequest.with { $0.chatID = conversationID.proto - // A reply wraps the text one level deeper on the wire. The domain model keeps it flat — - // see `ConversationMessage.init?(_:)`, which unwraps it back. $0.content = [content] $0.clientMessageID = .with { $0.value = clientMessageID.data } $0.auth = owner.authFor(message: $0) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift new file mode 100644 index 000000000..b2909853a --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift @@ -0,0 +1,29 @@ +// +// ChatMediaStrings.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The words a photo message is summarised in wherever it cannot be drawn: a reply's quote, the chat +/// list, Spotlight, and notifications. The copy is the fixture's `strings` section, shared with Android. +public enum ChatMediaStrings { + + /// A quote's snippet for a photo: its caption, or "Photo" when it has none. + public static func quoteSnippet(caption: String?) -> String { + nonEmpty(caption) ?? "Photo" + } + + /// A one-line preview of a photo: the camera glyph ahead of its caption, or ahead of "Photo". + public static func listPreview(caption: String?) -> String { + "📷 " + quoteSnippet(caption: caption) + } + + // The transcript draws no caption bubble for an empty caption, so neither does a preview. + private static func nonEmpty(_ caption: String?) -> String? { + guard let caption, !caption.isEmpty else { return nil } + return caption + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index ee4e2ba59..deb0d19a5 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -32,6 +32,8 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { /// A shared profile, drawn as a card with a Share button. The card carries only the /// handle's link; the name and picture are looked up by the view, as a link card's are. case shareProfile(LinkCard.User) + /// A photo, drawn from its blob once a download URL is resolved and from its BlurHash until then. + case media(ChatMediaContent) } /// The line under an unavailable message's bubble, chosen by why it can't be shown. @@ -147,7 +149,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { guard isEmojiOnly, quote == nil, linkPreview == nil else { return false } switch content { case .text: return true - case .cash, .deleted, .unavailable, .shareProfile: return false + case .cash, .deleted, .unavailable, .shareProfile, .media: return false } } @@ -166,10 +168,15 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { /// left to add a new one to) and for the user's own message still in flight — the server has /// nothing to record a reaction against until the send lands. A row with no context menu at all /// never reaches this check, since the menu itself is what hosts the strip. + /// A redacted photo keeps its reactions hidden, so it offers no strip to add one either. public var offersReactionStrip: Bool { switch content { - case .deleted, .unavailable: return false - case .text, .cash, .shareProfile: return !isUnsent + case .deleted, .unavailable: + return false + case .media(let media) where media.isRedacted: + return false + case .text, .cash, .shareProfile, .media: + return !isUnsent } } @@ -324,6 +331,34 @@ public struct ChatAuthor: Hashable, Sendable, Codable { } } +/// A photo row's display data: what the bubble needs to size itself and draw a placeholder before +/// any bytes arrive. The download URL and a pending row's local image stay outside this value — a +/// URL is signed and minted per fetch, and an image is not `Codable` — and reach the cell at +/// configure time instead. +public struct ChatMediaContent: Hashable, Sendable, Codable { + /// The photo's blob, or nil for a pending row whose upload has not produced one yet. + public let blobID: BlobID? + /// Pixel width of the photo, or 0 when the server carried none. + public let width: Int + /// Pixel height of the photo, or 0 when the server carried none. + public let height: Int + /// The photo's BlurHash, drawn until the image loads. + public let blurhash: String? + /// The text sent with the photo, drawn as its own bubble under the image. + public let caption: String? + /// Whether the server withheld the photo's download URL; the bubble then only ever draws its BlurHash. + public let isRedacted: Bool + + public init(blobID: BlobID?, width: Int, height: Int, blurhash: String?, caption: String?, isRedacted: Bool) { + self.blobID = blobID + self.width = width + self.height = height + self.blurhash = blurhash + self.caption = caption + self.isRedacted = isRedacted + } +} + /// A cash payment row's display data — already formatted, so the cell renders strings + images. public struct ChatCashContent: Hashable, Sendable, Codable { /// The amount in the user's currency, formatted for display (e.g. "$5.00"). diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index e27dedd18..807d00b82 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -91,9 +91,17 @@ extension ChatItem { content = .shareProfile(LinkCard.User(profileOf: share.username)) case .widget(.unrecognized): content = .unavailable(.updateApp) - case .media(_, let caption): - // A text stand-in until the preview has a media row to draw. - content = .text("📷 " + (caption ?? "Photo")) + case .media(let attachments, let caption): + // The same row the transcript maps to; the extension decides how to draw it. + let attachment = attachments.first + content = .media(ChatMediaContent( + blobID: attachment?.blobID, + width: attachment?.width ?? 0, + height: attachment?.height ?? 0, + blurhash: attachment?.blurhash, + caption: caption, + isRedacted: message.redacted + )) case .deleted: continue // filtered out above; unreachable, kept for switch exhaustiveness } diff --git a/FlipcashTests/Chat/AttachMenuTests.swift b/FlipcashTests/Chat/AttachMenuTests.swift new file mode 100644 index 000000000..eac7dc910 --- /dev/null +++ b/FlipcashTests/Chat/AttachMenuTests.swift @@ -0,0 +1,64 @@ +// +// AttachMenuTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +@testable import Flipcash + +@MainActor +@Suite("Attach menu") +struct AttachMenuTests { + + @Test("A chat that takes cash and media lists Cash, Camera, Photos, top to bottom") + func allRowsInOrder() { + let items = AttachMenuItem.items(showsCash: true, acceptsMedia: true, attachedCount: 0) + #expect(items == [.cash, .camera, .photos]) + } + + @Test("An E2EE chat keeps Cash and drops Camera and Photos") + func mediaRowsHiddenWithoutMedia() { + let items = AttachMenuItem.items(showsCash: true, acceptsMedia: false, attachedCount: 0) + #expect(items == [.cash]) + } + + @Test("A chat with no Send Cash target lists only the media rows") + func cashRowHiddenWithoutCash() { + let items = AttachMenuItem.items(showsCash: false, acceptsMedia: true, attachedCount: 0) + #expect(items == [.camera, .photos]) + } + + @Test("A chat that takes neither cash nor media has no rows") + func noRows() { + let items = AttachMenuItem.items(showsCash: false, acceptsMedia: false, attachedCount: 0) + #expect(items.isEmpty) + } + + @Test("Camera and Photos stay until the composer is full", arguments: [1, 5, 9]) + func mediaRowsBelowLimit(attachedCount: Int) { + let items = AttachMenuItem.items(showsCash: true, acceptsMedia: true, attachedCount: attachedCount) + #expect(items == [.cash, .camera, .photos]) + } + + @Test("Camera and Photos leave once the composer holds the maximum") + func mediaRowsHiddenAtLimit() { + let items = AttachMenuItem.items( + showsCash: true, + acceptsMedia: true, + attachedCount: ComposerModel.maxAttachments + ) + #expect(items == [.cash]) + } + + @Test("The picker may add only what is left of the maximum", arguments: [ + (0, 10), + (3, 7), + (9, 1), + (10, 0), + ]) + func selectionLimit(attachedCount: Int, expected: Int) { + #expect(AttachMenuItem.photosSelectionLimit(attachedCount: attachedCount) == expected) + } +} diff --git a/FlipcashTests/Chat/ChatCameraSheetTests.swift b/FlipcashTests/Chat/ChatCameraSheetTests.swift new file mode 100644 index 000000000..9e9d66d26 --- /dev/null +++ b/FlipcashTests/Chat/ChatCameraSheetTests.swift @@ -0,0 +1,48 @@ +// +// ChatCameraSheetTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import AVFoundation +import Testing +@testable import Flipcash + +@MainActor +@Suite("Chat camera sheet") +struct ChatCameraSheetTests { + + @Test("First use asks for camera access") + func notDeterminedRequestsAccess() { + #expect(ChatCameraPhase(status: .notDetermined) == .requestingAccess) + } + + @Test("Granted access shows the live camera") + func authorizedIsLive() { + #expect(ChatCameraPhase(status: .authorized) == .live) + } + + @Test("Denied or restricted access shows the Settings prompt", arguments: [ + AVAuthorizationStatus.denied, + .restricted, + ]) + func refusedShowsSettingsPrompt(status: AVAuthorizationStatus) { + #expect(ChatCameraPhase(status: status) == .denied) + } + + @Test("A flip alternates between the back and front lenses", arguments: [ + (AVCaptureDevice.Position.back, AVCaptureDevice.Position.front), + (.front, .back), + (.unspecified, .back), + ]) + func flipAlternatesLenses(from: AVCaptureDevice.Position, to: AVCaptureDevice.Position) { + #expect(ChatPhotoCamera.flipped(from) == to) + } + + @Test("The shutter on a camera that never started returns no photo") + func captureBeforeStartReturnsNil() async { + let camera = ChatPhotoCamera() + #expect(await camera.capture() == nil) + } +} diff --git a/FlipcashTests/Chat/ChatMediaBubbleVectorTests.swift b/FlipcashTests/Chat/ChatMediaBubbleVectorTests.swift new file mode 100644 index 000000000..402264a2b --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaBubbleVectorTests.swift @@ -0,0 +1,64 @@ +// +// ChatMediaBubbleVectorTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +@testable import FlipcashUI + +@Suite("Chat media bubble vectors") +struct ChatMediaBubbleVectorTests { + + private final class BundleToken {} + + struct Vector: Decodable { + struct Expected: Decodable { + let width: Double + let height: Double + let cropped: Bool + } + let name: String + let imageWidth: Double + let imageHeight: Double + let maxWidth: Double + let expected: Expected + } + + private struct Fixture: Decodable { + let minAspect: Double + let maxAspect: Double + let bubble: [Vector] + } + + private static func loadFixture() throws -> Fixture { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "chat_media", withExtension: "json")) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + @Test("The clamp bounds match the fixture") + func clampBoundsMatchFixture() throws { + let fixture = try Self.loadFixture() + #expect(ChatMediaBubbleSizing.minAspect == fixture.minAspect) + #expect(ChatMediaBubbleSizing.maxAspect == fixture.maxAspect) + } + + @Test("The bubble size matches every bubble vector") + func matchesFixture() throws { + let vectors = try Self.loadFixture().bubble + #expect(!vectors.isEmpty) + + for vector in vectors { + let size = ChatMediaBubbleSizing.size( + imageWidth: vector.imageWidth, + imageHeight: vector.imageHeight, + maxWidth: vector.maxWidth + ) + #expect(abs(size.width - vector.expected.width) < 0.001, "\(vector.name)") + #expect(abs(size.height - vector.expected.height) < 0.001, "\(vector.name)") + #expect(size.cropped == vector.expected.cropped, "\(vector.name)") + } + } +} diff --git a/FlipcashTests/Chat/ChatMediaCellTests.swift b/FlipcashTests/Chat/ChatMediaCellTests.swift new file mode 100644 index 000000000..f9259ba50 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaCellTests.swift @@ -0,0 +1,112 @@ +// +// ChatMediaCellTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import FlipcashUI + +@Suite("Chat photo cell") +@MainActor +struct ChatMediaCellTests { + + private static let blurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj" + private static let remoteURL = URL(string: "https://example.com/a.jpg")! + private static let pill = ReactionPill(emoji: "🔥", count: 1, selfReacted: false, pending: false) + + private func media(caption: String? = nil, isRedacted: Bool = false, blurhash: String? = Self.blurhash) -> ChatMediaContent { + ChatMediaContent( + blobID: BlobID(data: Data([1])), + width: 1179, + height: 2556, + blurhash: blurhash, + caption: caption, + isRedacted: isRedacted + ) + } + + private func configuredCell( + _ media: ChatMediaContent, + canReact: Bool = true, + localImage: UIImage? = nil, + remoteURL: URL? = nil + ) -> ChatMediaCell { + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure( + with: ChatMessage(id: "1", content: .media(media), sender: .other, reactions: [Self.pill], canReact: canReact), + maxWidth: 240, + localImage: localImage, + remoteURL: remoteURL + ) + return cell + } + + @Test("A row with no image yet draws its BlurHash") + func drawsBlurhashBeforeImageLoads() { + let cell = configuredCell(media()) + #expect(cell.imageView.image != nil) + } + + @Test("A pending row draws the staged local image") + func drawsLocalImage() { + let image = UIImage(systemName: "photo")! + let cell = configuredCell(media(), localImage: image) + #expect(cell.imageView.image === image) + } + + @Test("A caption shows as its own bubble") + func showsCaption() { + let cell = configuredCell(media(caption: "hi")) + #expect(cell.captionBubble.isHidden == false) + #expect(cell.captionLabel.text == "hi") + } + + @Test("A nil or empty caption hides the caption bubble", arguments: [nil, ""] as [String?]) + func hidesEmptyCaption(caption: String?) { + let cell = configuredCell(media(caption: caption)) + #expect(cell.captionBubble.isHidden) + } + + @Test("A viewable row shows its reactions and takes a tap") + func viewableRow() { + let cell = configuredCell(media(), remoteURL: Self.remoteURL) + #expect(!cell.blurhashOnly) + #expect(!cell.reactionRow.isHidden) + #expect(cell.imageTap.isEnabled) + } + + @Test("A redacted row draws only its BlurHash, even handed a URL") + func redactedRowIsBlurhashOnly() { + let local = UIImage(systemName: "photo")! + let cell = configuredCell(media(isRedacted: true), localImage: local, remoteURL: Self.remoteURL) + #expect(cell.blurhashOnly) + #expect(cell.reactionRow.isHidden) + #expect(!cell.imageTap.isEnabled) + #expect(cell.imageView.image != nil) + #expect(cell.imageView.image !== local) + } + + @Test("A previewer's row draws only its BlurHash") + func previewingRowIsBlurhashOnly() { + let cell = configuredCell(media(), canReact: false) + #expect(cell.blurhashOnly) + #expect(cell.reactionRow.isHidden) + #expect(!cell.imageTap.isEnabled) + } + + @Test("A photo caches under its blob, so a freshly signed URL reuses the download") + func cachesUnderBlob() { + let blobID = BlobID(data: Data([1])) + let first = ChatMediaImageSource.resource(blobID: blobID, url: URL(string: "https://cdn.example.com/a.jpg?sig=1")!) + let second = ChatMediaImageSource.resource(blobID: blobID, url: URL(string: "https://cdn.example.com/a.jpg?sig=2")!) + let other = ChatMediaImageSource.resource(blobID: BlobID(data: Data([2])), url: URL(string: "https://cdn.example.com/a.jpg?sig=1")!) + + #expect(first.cacheKey == second.cacheKey) + #expect(first.cacheKey != other.cacheKey) + #expect(second.downloadURL.absoluteString.hasSuffix("sig=2")) + } +} diff --git a/FlipcashTests/Chat/ChatMediaFanOutVectorTests.swift b/FlipcashTests/Chat/ChatMediaFanOutVectorTests.swift new file mode 100644 index 000000000..b1cf6fb52 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaFanOutVectorTests.swift @@ -0,0 +1,76 @@ +// +// ChatMediaFanOutVectorTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Chat media fan-out vectors") +struct ChatMediaFanOutVectorTests { + + private final class BundleToken {} + + struct ExpectedMessage: Decodable { + let kind: String + let chip: String? + let caption: String? + let text: String? + let replyTo: String? + } + + struct Vector: Decodable { + let name: String + let chips: [String] + let text: String? + let replyTo: String? + let messages: [ExpectedMessage] + } + + private struct Fixture: Decodable { + let fanOut: [Vector] + } + + /// The fixture's message names mapped onto ids; only the identity matters. + static let replyIDs: [String: MessageID] = ["m1": MessageID(value: 7)] + + static func loadVectors() throws -> [Vector] { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "chat_media", withExtension: "json")) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)).fanOut + } + + @Test("The send plan matches every fanOut vector") + func matchesFixture() throws { + let vectors = try Self.loadVectors() + #expect(!vectors.isEmpty) + + for vector in vectors { + let plan = ChatMediaSendPlan.messages( + chips: vector.chips, + text: vector.text, + replyTo: vector.replyTo.flatMap { Self.replyIDs[$0] } + ) + #expect(plan.count == vector.messages.count, "\(vector.name)") + + for (built, expected) in zip(plan, vector.messages) { + let expectedReply = expected.replyTo.flatMap { Self.replyIDs[$0] } + switch built { + case .text(let text, let replyTo): + #expect(expected.kind == "text", "\(vector.name)") + #expect(text == expected.text, "\(vector.name)") + #expect(replyTo == expectedReply, "\(vector.name)") + case .media(let message): + #expect(expected.kind == "media", "\(vector.name)") + #expect(message.chip == expected.chip, "\(vector.name)") + #expect(message.caption == expected.caption, "\(vector.name)") + #expect(message.replyTo == expectedReply, "\(vector.name)") + } + } + } + } +} diff --git a/FlipcashTests/Chat/ChatMediaStringVectorTests.swift b/FlipcashTests/Chat/ChatMediaStringVectorTests.swift new file mode 100644 index 000000000..c313024d0 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaStringVectorTests.swift @@ -0,0 +1,123 @@ +// +// ChatMediaStringVectorTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@Suite("Chat media string vectors") +@MainActor +struct ChatMediaStringVectorTests { + + private final class BundleToken {} + + struct Vector: Decodable { + struct Expected: Decodable { + let snippet: String + let preview: String + } + let name: String + let caption: String? + let expected: Expected + } + + private struct Fixture: Decodable { + let strings: [Vector] + } + + private static func vectors() throws -> [Vector] { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "chat_media", withExtension: "json")) + let vectors = try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)).strings + #expect(!vectors.isEmpty) + return vectors + } + + private func photoMessage(caption: String?) -> ConversationMessage { + ConversationMessage( + id: MessageID(value: 1), + senderID: nil, + content: .media([MediaAttachment(blobID: BlobID(data: Data([1])), width: 100, height: 100, blurhash: nil)], caption: caption), + date: Date(timeIntervalSince1970: 1), + unreadSeq: 1 + ) + } + + private func conversation(lastMessage: ConversationMessage) -> Conversation { + Conversation( + id: ConversationID.test(1), + members: [], + lastMessage: lastMessage, + lastActivity: Date(timeIntervalSince1970: 1) + ) + } + + @Test("The quote snippet and list preview match every strings vector") + func helperMatchesFixture() throws { + for vector in try Self.vectors() { + #expect(ChatMediaStrings.quoteSnippet(caption: vector.caption) == vector.expected.snippet, "\(vector.name)") + #expect(ChatMediaStrings.listPreview(caption: vector.caption) == vector.expected.preview, "\(vector.name)") + } + } + + @Test("The conversation list previews a photo with the fixture's preview") + func conversationListMatchesFixture() throws { + let mock = MockConversations() + let controller = ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: try Database.makeTemp().database, + owner: .generate()!, selfUserID: UUID(), + typingHeartbeatInterval: .seconds(3), incomingTypingExpiry: .seconds(10) + ) + + for vector in try Self.vectors() { + let preview = controller.lastMessagePreview(for: conversation(lastMessage: photoMessage(caption: vector.caption))) { _ -> String? in nil } + #expect(preview == vector.expected.preview, "\(vector.name)") + } + } + + @Test("Spotlight describes a photo with the fixture's preview") + func spotlightMatchesFixture() throws { + for vector in try Self.vectors() { + let item = ChatSpotlightItem( + conversation: conversation(lastMessage: photoMessage(caption: vector.caption)), + displayName: "Anna", + counterpartPhoneE164: nil, + thumbnailData: nil + ) + #expect(item.contentDescription == vector.expected.preview, "\(vector.name)") + } + } + + @Test("A reply to a photo quotes it with the fixture's snippet") + func quoteMatchesFixture() throws { + for vector in try Self.vectors() { + let original = photoMessage(caption: vector.caption) + let reply = ConversationMessage( + id: MessageID(value: 2), + senderID: nil, + content: .text("nice"), + date: Date(timeIntervalSince1970: 2), + unreadSeq: 2, + repliedTo: original.id + ) + let quotes = ChatItem.from( + [original, reply], + selfUserID: UUID(), + cashBranding: { _ in ("Cash", nil) }, + counterpartName: "Ada", + quotedMessage: { $0 == original.id ? original : nil } + ).compactMap { item in + if case .message(let message) = item { return message.quote } else { return nil } + } + let quote = try #require(quotes.first) + #expect(quote.snippet == vector.expected.snippet, "\(vector.name)") + } + } +} diff --git a/FlipcashTests/Chat/ChatMediaURLResolverTests.swift b/FlipcashTests/Chat/ChatMediaURLResolverTests.swift new file mode 100644 index 000000000..9b51bd4c3 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaURLResolverTests.swift @@ -0,0 +1,144 @@ +// +// ChatMediaURLResolverTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashCore +@testable import FlipcashUI + +@Suite("Chat photo URL resolver") +@MainActor +struct ChatMediaURLResolverTests { + + private let blobID = BlobID(data: Data([1])) + private let url = URL(string: "https://example.com/a.jpg")! + + /// Records every blob the resolver asks for. + @MainActor + private final class FetchSpy { + var calls: [BlobID] = [] + } + + private func photo(isRedacted: Bool) -> ChatMediaContent { + ChatMediaContent(blobID: blobID, width: 100, height: 100, blurhash: nil, caption: nil, isRedacted: isRedacted) + } + + @Test("A BlurHash-only row never fetches") + func blurhashOnlyRowNeverFetches() async { + let spy = FetchSpy() + let url = url + let resolver = ChatMediaURLResolver { id in + spy.calls.append(id) + return url + } + + #expect(resolver.url(for: photo(isRedacted: true), canReact: true) { _ in } == nil) + #expect(resolver.url(for: photo(isRedacted: false), canReact: false) { _ in } == nil) + + // Give any stray fetch task a chance to run before asserting none did. + await Task.yield() + #expect(spy.calls.isEmpty) + } + + @Test("Joining fetches once, then serves from the cache") + func joiningFetchesOnceThenServesFromCache() async { + let spy = FetchSpy() + let url = url + let resolver = ChatMediaURLResolver { id in + spy.calls.append(id) + return url + } + let photo = photo(isRedacted: false) + + #expect(resolver.url(for: photo, canReact: false) { _ in } == nil) + + let resolved = await withCheckedContinuation { continuation in + let immediate = resolver.url(for: photo, canReact: true) { continuation.resume(returning: $0) } + #expect(immediate == nil) + // A second dequeue while the first fetch is in flight does not start another. + #expect(resolver.url(for: photo, canReact: true) { _ in } == nil) + } + #expect(resolved == url) + + #expect(resolver.url(for: photo, canReact: true) { _ in } == url) + #expect(spy.calls == [blobID]) + } + + @Test("A quote's thumbnail never fetches for a redacted original, a previewer, or a non-photo") + func quoteThumbnailNeverFetchesWhenBlurhashOnly() async { + let spy = FetchSpy() + let url = url + let resolver = ChatMediaURLResolver { id in + spy.calls.append(id) + return url + } + + #expect(resolver.thumbnailURL(for: .media(thumbnailBlobID: nil), canReact: true) { _ in } == nil) + #expect(resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: false) { _ in } == nil) + #expect(resolver.thumbnailURL(for: .text, canReact: true) { _ in } == nil) + #expect(resolver.thumbnailURL(for: .unavailable, canReact: true) { _ in } == nil) + + await Task.yield() + #expect(spy.calls.isEmpty) + } + + @Test("A quote's thumbnail shares the photo row's cached URL") + func quoteThumbnailSharesTheRowCache() async { + let spy = FetchSpy() + let url = url + let resolver = ChatMediaURLResolver { id in + spy.calls.append(id) + return url + } + + let resolved = await withCheckedContinuation { continuation in + let immediate = resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) { + continuation.resume(returning: $0) + } + #expect(immediate == nil) + } + #expect(resolved == url) + + #expect(resolver.url(for: photo(isRedacted: false), canReact: true) { _ in } == url) + #expect(spy.calls == [blobID]) + } + + @Test("The composer's reply strip waits for the thumbnail, and never fetches a redacted one") + func awaitedThumbnail() async { + let spy = FetchSpy() + let url = url + let resolver = ChatMediaURLResolver { id in + spy.calls.append(id) + return url + } + + #expect(await resolver.thumbnailURL(for: .media(thumbnailBlobID: nil), canReact: true) == nil) + #expect(spy.calls.isEmpty) + + #expect(await resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) == url) + #expect(await resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) == url) + #expect(spy.calls == [blobID]) + } + + @Test("Every caller waiting on one fetch hears when it lands") + func everyWaiterIsCalledBack() async { + let url = url + let resolver = ChatMediaURLResolver { _ in url } + let photo = photo(isRedacted: false) + + let (row, quote) = await withCheckedContinuation { continuation in + var row: URL? + let immediate = resolver.url(for: photo, canReact: true) { row = $0 } + #expect(immediate == nil) + _ = resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) { quote in + continuation.resume(returning: (row, quote)) + } + } + #expect(row == url) + #expect(quote == url) + } +} diff --git a/FlipcashTests/Chat/ChatMediaUploaderTests.swift b/FlipcashTests/Chat/ChatMediaUploaderTests.swift new file mode 100644 index 000000000..27e9722ea --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaUploaderTests.swift @@ -0,0 +1,246 @@ +// +// ChatMediaUploaderTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +@testable import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Chat media upload") +struct ChatMediaUploaderTests { + + private final class BundleToken {} + + private struct Fixture: Decodable { + let uploadMimeType: String + let jpegQualityLadder: [Double] + } + + private func loadFixture() throws -> Fixture { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "chat_media", withExtension: "json")) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + // MARK: - Uploader - + + @Test("Uploads as the fixture's MIME type through the fixture's quality ladder") + func uploadsFixtureMimeType() async throws { + let fixture = try loadFixture() + #expect(ChatMediaEncoder.qualityLadder == fixture.jpegQualityLadder) + + let blob = MockChatMediaBlobStore() + _ = try await ChatMediaUploader(blob: blob).upload(Self.image(width: 40, height: 30)) { _, _ in } + + #expect(blob.storedMimeTypes == [fixture.uploadMimeType]) + } + + @Test("Reports the downscale target before storing, then awaits finalization") + func reportsDimensionsBeforeStoring() async throws { + let blob = MockChatMediaBlobStore(policy: Self.policy(maxSizeBytes: 5_000_000, maxEdge: 2048)) + var prepared: (width: Int, height: Int, storesSoFar: Int)? + + let blobID = try await ChatMediaUploader(blob: blob).upload(Self.image(width: 3000, height: 2000)) { width, height in + prepared = (width, height, blob.storedData.count) + } + + #expect(prepared?.width == 2048) + #expect(prepared?.height == 1365) + #expect(prepared?.storesSoFar == 0) + #expect(blobID == MockChatMediaBlobStore.blobID) + #expect(blob.finalizedBlobIDs == [blobID]) + + let stored = try #require(blob.storedData.first) + #expect(stored.count <= 5_000_000) + let decoded = try #require(UIImage(data: stored)?.cgImage) + #expect(decoded.width == 2048) + #expect(decoded.height == 1365) + } + + @Test("A sideways capture is downscaled in display space") + func sidewaysCaptureUsesDisplaySpace() async throws { + let landscape = try #require(Self.image(width: 300, height: 200).cgImage) + let portrait = UIImage(cgImage: landscape, scale: 1, orientation: .right) + let blob = MockChatMediaBlobStore(policy: Self.policy(maxSizeBytes: 5_000_000, maxEdge: 150)) + var prepared: (width: Int, height: Int)? + + _ = try await ChatMediaUploader(blob: blob).upload(portrait) { prepared = ($0, $1) } + + #expect(prepared?.width == 100) + #expect(prepared?.height == 150) + let stored = try #require(blob.storedData.first) + let decoded = try #require(UIImage(data: stored)?.cgImage) + #expect(decoded.width == 100) + #expect(decoded.height == 150) + } + + @Test("A moderation rejection at finalization is not retryable") + func moderationRejectionIsTerminal() async { + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.rejected(.moderation)) + + let error = await Self.uploadError(ChatMediaUploader(blob: blob)) + + #expect(error?.isRetryable == false) + #expect(blob.storedData.count == 1) + } + + @Test("A rejection when storing is not retried automatically") + func storeRejectionIsNotRetried() async { + let blob = MockChatMediaBlobStore() + blob.storeResults = [.failure(ErrorBlob.rejected(.moderation))] + + let error = await Self.uploadError(ChatMediaUploader(blob: blob, backoff: [.zero, .zero])) + + #expect(error?.isRetryable == false) + #expect(blob.storeAttempts == 1) + #expect(blob.finalizedBlobIDs.isEmpty) + } + + @Test("A network failure is retried through the backoff, then surfaces as retryable") + func networkFailureRetriesThenIsRetryable() async { + let blob = MockChatMediaBlobStore() + blob.storeResults = Array(repeating: .failure(ErrorBlob.network(URLError(.networkConnectionLost))), count: 3) + + let error = await Self.uploadError(ChatMediaUploader(blob: blob, backoff: [.zero, .zero])) + + #expect(error?.isRetryable == true) + #expect(blob.storeAttempts == 3) + } + + @Test("A network failure that clears within the backoff uploads normally") + func networkFailureRecovers() async throws { + let blob = MockChatMediaBlobStore() + blob.storeResults = [.failure(ErrorBlob.network(URLError(.timedOut)))] + + let blobID = try await ChatMediaUploader(blob: blob, backoff: [.zero]).upload(Self.image(width: 40, height: 30)) { _, _ in } + + #expect(blobID == MockChatMediaBlobStore.blobID) + #expect(blob.storeAttempts == 2) + } + + @Test("A finalization timeout is retryable") + func finalizationTimeoutIsRetryable() async { + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.timedOut) + + let error = await Self.uploadError(ChatMediaUploader(blob: blob)) + + #expect(error?.isRetryable == true) + } + + @Test("A policy with no constraint for JPEG uploads nothing") + func noMatchingConstraintUploadsNothing() async { + let policy = UploadPolicy(version: "v1", ttl: nil, constraints: [ + .init(pattern: "video/*", maxSizeBytes: 5_000_000, image: nil), + ]) + let blob = MockChatMediaBlobStore(policy: policy) + + let error = await Self.uploadError(ChatMediaUploader(blob: blob)) + + #expect(error?.isRetryable == false) + #expect(blob.storeAttempts == 0) + } + + // MARK: - Chip - + + @Test("A staged chip uploads, with its dimensions set before the bytes are stored") + func stagedChipUploads() async throws { + let blob = MockChatMediaBlobStore(policy: Self.policy(maxSizeBytes: 5_000_000, maxEdge: 20)) + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: ChatMediaUploader(blob: blob))) + var dimensionsAtStore: (Int?, Int?)? + blob.onStore = { dimensionsAtStore = (chip.preparedWidth, chip.preparedHeight) } + + #expect(chip.state == .preparing) + + let blobID = try await #require(chip.uploadTask).value + + #expect(dimensionsAtStore?.0 == 20) + #expect(dimensionsAtStore?.1 == 15) + #expect(chip.state == .uploaded(blobID)) + } + + @Test("A moderated chip fails for good") + func moderatedChipFailsForGood() async throws { + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.rejected(.moderation)) + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: ChatMediaUploader(blob: blob))) + + _ = await chip.uploadTask?.result + + #expect(chip.state == .failed(.notRetryable)) + } + + @Test("Retrying a failed chip uploads it again") + func retryingFailedChipUploadsAgain() async throws { + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.timedOut) + let uploader = ChatMediaUploader(blob: blob) + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: uploader)) + _ = await chip.uploadTask?.result + #expect(chip.state == .failed(.retryable)) + + blob.finalization = .success(()) + chip.startUpload(using: uploader) + let blobID = try await #require(chip.uploadTask).value + + #expect(chip.state == .uploaded(blobID)) + } + + @Test("Removing a chip cancels its upload") + func removingChipCancelsUpload() throws { + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: ChatMediaUploader(blob: MockChatMediaBlobStore()))) + + composer.removeChip(chip.id) + + #expect(composer.chips.isEmpty) + #expect(chip.uploadTask?.isCancelled == true) + } + + // MARK: - Helpers - + + private static func image(width: Int, height: Int) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let size = CGSize(width: width, height: height) + return UIGraphicsImageRenderer(size: size, format: format).image { context in + UIColor.systemTeal.setFill() + context.fill(CGRect(origin: .zero, size: size)) + } + } + + private static func policy(maxSizeBytes: Int, maxEdge: Int) -> UploadPolicy { + UploadPolicy(version: "v1", ttl: nil, constraints: [ + .init( + pattern: "image/*", + maxSizeBytes: maxSizeBytes, + image: .init(maxWidth: maxEdge, maxHeight: maxEdge, maxPixels: 0) + ), + ]) + } + + private static func uploadError(_ uploader: ChatMediaUploader) async -> ChatMediaUploadError? { + do { + _ = try await uploader.upload(image(width: 40, height: 30)) { _, _ in } + return nil + } catch { + return error as? ChatMediaUploadError + } + } + + @Test("A refused photo reports as an expected outcome, a client defect as an error") + func reportingLevels() { + #expect(ChatMediaUploadError.rejected(.moderation).reportingLevel == .info) + #expect(ChatMediaUploadError.noMatchingConstraint.reportingLevel == .error) + #expect(ChatMediaUploadError.failed(ErrorBlob.quotaExceeded).reportingLevel == .info) + #expect(ChatMediaUploadError.failed(ErrorBlob.unknown).reportingLevel == .error) + } +} diff --git a/FlipcashTests/Chat/ChatMediaViewerTests.swift b/FlipcashTests/Chat/ChatMediaViewerTests.swift new file mode 100644 index 000000000..53da3ca17 --- /dev/null +++ b/FlipcashTests/Chat/ChatMediaViewerTests.swift @@ -0,0 +1,178 @@ +// +// ChatMediaViewerTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import FlipcashUI + +@Suite("Chat photo viewer") +@MainActor +struct ChatMediaViewerTests { + + private static let blobID = BlobID(data: Data([1])) + private static let remoteURL = URL(string: "https://example.com/a.jpg")! + private static let localImage = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 30)).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 40, height: 30)) + } + + private func media(isRedacted: Bool = false) -> ChatMediaContent { + ChatMediaContent(blobID: Self.blobID, width: 40, height: 30, blurhash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", caption: nil, isRedacted: isRedacted) + } + + private func message( + _ media: ChatMediaContent, + canReact: Bool = true, + receipt: ChatReceipt? = nil + ) -> ChatMessage { + ChatMessage(id: "photo", content: .media(media), sender: .other, receipt: receipt, canReact: canReact) + } + + private func request( + _ message: ChatMessage, + localImage: UIImage? = nil, + remoteURL: URL? = ChatMediaViewerTests.remoteURL + ) -> ChatMediaViewerRequest? { + ChatMediaViewerRequest(message: message, localImage: localImage, remoteURL: remoteURL, placeholder: nil) { nil } + } + + // MARK: - Request + + @Test("A viewable photo opens with its blob and URL") + func viewablePhotoOpens() throws { + let request = try #require(request(message(media()))) + #expect(request.blobID == Self.blobID) + #expect(request.remoteURL == Self.remoteURL) + } + + @Test("A redacted photo, or one seen while previewing a group, never opens") + func blurhashOnlyNeverOpens() { + #expect(request(message(media(isRedacted: true))) == nil) + #expect(request(message(media(), canReact: false)) == nil) + #expect(request(message(media(isRedacted: true)), localImage: Self.localImage) == nil) + } + + @Test("A failed send opens nothing: its tap is the retry") + func failedRowNeverOpens() { + #expect(request(message(media(), receipt: .failed("Not delivered")), localImage: Self.localImage) == nil) + } + + @Test("A photo with nothing to show yet opens nothing") + func unresolvedPhotoNeverOpens() { + #expect(request(message(media()), remoteURL: nil) == nil) + } + + @Test("A pending send opens on its staged image") + func pendingSendOpens() throws { + let request = try #require(request(message(media()), localImage: Self.localImage, remoteURL: nil)) + #expect(request.localImage === Self.localImage) + } + + // MARK: - Viewer + + @Test("The viewer zooms in from the tapped photo") + func zoomsFromSource() throws { + let source = UIView() + let request = try #require(ChatMediaViewerRequest( + message: message(media()), localImage: Self.localImage, remoteURL: nil, placeholder: nil + ) { source }) + let viewer = ChatMediaViewerController(request: request) { _ in } + + #expect(viewer.preferredTransition != nil) + #expect(request.sourceView() === source) + } + + @Test("Pinch zooms the photo itself, past its fitted size") + func pinchZoomsThePhoto() throws { + let viewer = ChatMediaViewerController(request: try #require(request(message(media()), localImage: Self.localImage))) { _ in } + viewer.loadViewIfNeeded() + + #expect(viewer.scrollView.maximumZoomScale > viewer.scrollView.minimumZoomScale) + #expect(viewer.viewForZooming(in: viewer.scrollView) === viewer.imageView) + } + + @Test("Double-tap zooms in, and a second double-tap zooms back out") + func doubleTapTogglesZoom() { + let scale = ChatMediaViewerController.doubleTapTargetScale + #expect(scale(1, 1) > 1) + #expect(scale(2.5, 1) == 1) + } + + @Test("Share hands the loaded photo to the share sheet") + func sharesLoadedPhoto() throws { + var shared: UIImage? + let viewer = ChatMediaViewerController(request: try #require(request(message(media()), localImage: Self.localImage))) { shared = $0 } + viewer.loadViewIfNeeded() + + #expect(viewer.shareButton.isEnabled) + viewer.shareButton.sendActions(for: .touchUpInside) + #expect(shared === Self.localImage) + } + + @Test("Share waits until the photo itself has loaded") + func shareWaitsForPhoto() throws { + let placeholder = UIImage(systemName: "photo")! + let request = try #require(ChatMediaViewerRequest( + message: message(media()), localImage: nil, remoteURL: URL(string: "https://example.invalid/never.jpg")!, placeholder: placeholder + ) { nil }) + var shared: UIImage? + let viewer = ChatMediaViewerController(request: request) { shared = $0 } + viewer.loadViewIfNeeded() + + #expect(viewer.imageView.image === placeholder) + #expect(!viewer.shareButton.isEnabled) + viewer.shareButton.sendActions(for: .touchUpInside) + #expect(shared == nil) + } + + // MARK: - Transcript wiring + + private func transcript(_ message: ChatMessage, localImage: UIImage? = nil) -> (ChatViewController, ChatMediaCell) { + let controller = ChatViewController() + controller.pendingMediaImage = { _ in localImage } + controller.loadViewIfNeeded() + controller.update(items: [.message(message)]) + let cell = controller.collectionView(controller.collectionView, cellForItemAt: IndexPath(item: 0, section: 0)) + return (controller, cell as! ChatMediaCell) + } + + @Test("Tapping a viewable photo asks the owner to open it") + func tapOpensViewer() { + let (controller, cell) = transcript(message(media()), localImage: Self.localImage) + var opened: ChatMediaViewerRequest? + controller.onMediaTap = { opened = $0 } + + cell.onImageTap?() + + #expect(opened?.blobID == Self.blobID) + #expect(opened?.localImage === Self.localImage) + } + + @Test("A redacted photo's tap, even forced, opens nothing") + func blurhashOnlyTapOpensNothing() { + let (controller, cell) = transcript(message(media(isRedacted: true)), localImage: Self.localImage) + var opened = false + controller.onMediaTap = { _ in opened = true } + + #expect(!cell.imageTap.isEnabled) + cell.onImageTap?() + + #expect(!opened) + } + + @Test("The photo's tap leaves the transcript's keyboard-dismiss tap free to fire") + func photoTapDoesNotSwallowKeyboardDismiss() throws { + let (controller, cell) = transcript(message(media()), localImage: Self.localImage) + let dismissTap = try #require(controller.collectionView.gestureRecognizers?.first { + $0 is UITapGestureRecognizer && $0.delegate === controller + } as? UITapGestureRecognizer) + + #expect(!dismissTap.cancelsTouchesInView) + #expect(controller.gestureRecognizer(dismissTap, shouldRecognizeSimultaneouslyWith: cell.imageTap)) + } +} diff --git a/FlipcashTests/Chat/ChatMessageMappingTests.swift b/FlipcashTests/Chat/ChatMessageMappingTests.swift index cefc0b4ed..6a7e63259 100644 --- a/FlipcashTests/Chat/ChatMessageMappingTests.swift +++ b/FlipcashTests/Chat/ChatMessageMappingTests.swift @@ -258,6 +258,32 @@ struct ChatMessageMappingTests { #expect(ChatCashContent.caption(isFromSelf: false, isTip: cash.isTip) == "You received") } + @Test("Photo messages map to media content, a redacted one staying a photo", arguments: [false, true]) + func media(redacted: Bool) { + let attachment = MediaAttachment(blobID: BlobID(data: Data([9, 8, 7])), width: 300, height: 400, blurhash: "LEHV6nWB2yk8") + let messages = [ + ConversationMessage( + id: MessageID(value: 1), + senderID: them, + content: .media([attachment], caption: "hi"), + date: base, + unreadSeq: 1, + redacted: redacted + ), + ] + + let rows = messageRows(ChatItem.from(messages, selfUserID: me)) + + #expect(rows.map(\.content) == [.media(ChatMediaContent( + blobID: attachment.blobID, + width: 300, + height: 400, + blurhash: "LEHV6nWB2yk8", + caption: "hi", + isRedacted: redacted + ))]) + } + @Test("Tipped cash messages carry the tip flag and caption") func tippedCash() { let fiat = ExchangedFiat( diff --git a/FlipcashTests/Chat/ChatPhotoStagingTests.swift b/FlipcashTests/Chat/ChatPhotoStagingTests.swift new file mode 100644 index 000000000..2e5a171ba --- /dev/null +++ b/FlipcashTests/Chat/ChatPhotoStagingTests.swift @@ -0,0 +1,214 @@ +// +// ChatPhotoStagingTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Chat photo staging") +struct ChatPhotoStagingTests { + + private let uploader = ChatMediaUploader(blob: MockChatMediaBlobStore()) + + private func images(_ count: Int) -> [UIImage] { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return (0.. UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: CGSize(width: 4, height: 3), format: format).image { _ in } + } + + @Test("Text alone sends as text") + func textAloneSendsText() { + let composer = ComposerModel() + composer.draft = " hello " + + switch composer.outgoing { + case .text(let text): + #expect(text == "hello") + case .media, .none: + Issue.record("Expected a text send") + } + } + + @Test("Staged photos send as media, captioned with the text") + func chipsSendAsMediaWithCaption() throws { + let composer = ComposerModel() + let first = try #require(composer.stageChip(image: image(), uploader: uploader)) + let second = try #require(composer.stageChip(image: image(), uploader: uploader)) + composer.draft = " look " + + switch composer.outgoing { + case .media(let chips, let caption): + #expect(chips.map(\.id) == [first.id, second.id]) + #expect(caption == "look") + case .text, .none: + Issue.record("Expected a media send") + } + } + + @Test("Staged photos with no text send without a caption") + func chipsWithoutTextSendWithoutCaption() throws { + let composer = ComposerModel() + try #require(composer.stageChip(image: image(), uploader: uploader) != nil) + + switch composer.outgoing { + case .media(let chips, let caption): + #expect(chips.count == 1) + #expect(caption == nil) + case .text, .none: + Issue.record("Expected a media send") + } + } + + @Test("A reply with staged photos still sends as media") + func replyWithChipsSendsMedia() throws { + let composer = ComposerModel() + composer.beginReplying(to: ComposerModel.ReplyTarget( + messageID: MessageID(value: 7), + stableID: "7", + authorName: "Ana", + authorID: nil, + snippet: "hi" + )) + try #require(composer.stageChip(image: image(), uploader: uploader) != nil) + + switch composer.outgoing { + case .media: + break + case .text, .none: + Issue.record("Expected a media send") + } + } + + @Test("A failed chip holds the send") + func failedChipHoldsSend() throws { + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: image(), uploader: uploader)) + chip.uploadTask?.cancel() + chip.state = .failed(.retryable) + composer.draft = "caption" + + #expect(composer.outgoing == nil) + } + + @Test("An empty composer and an edit send nothing") + func emptyAndEditingSendNothing() { + let composer = ComposerModel() + #expect(composer.outgoing == nil) + + composer.beginEditing(messageID: MessageID(value: 3), stableID: "3", currentText: "original") + composer.draft = "changed" + #expect(composer.outgoing == nil) + } +} + +@MainActor +@Suite("Chat camera slot") +struct ChatCameraSlotTests { + + @Test("A fresh slot is closed at the fallback height") + func freshSlotIsClosed() { + let slot = ChatCameraSlot() + #expect(!slot.isOpen) + #expect(slot.height == ChatCameraSlot.fallbackHeight) + } + + @Test("The slot takes the keyboard's height above the bottom safe area") + func takesKeyboardHeight() { + let slot = ChatCameraSlot() + slot.keyboardWillShow(height: 336, safeAreaBottom: 34) + #expect(slot.height == 302) + } + + @Test("A keyboard rising closes the camera") + func keyboardClosesCamera() { + let slot = ChatCameraSlot() + slot.open() + #expect(slot.isOpen) + + slot.keyboardWillShow(height: 336, safeAreaBottom: 34) + #expect(!slot.isOpen) + } + + @Test("A keyboard no taller than the safe area leaves the height alone") + func ignoresDegenerateKeyboard() { + let slot = ChatCameraSlot() + slot.keyboardWillShow(height: 20, safeAreaBottom: 34) + #expect(slot.height == ChatCameraSlot.fallbackHeight) + } +} diff --git a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift index d4d0ceb33..3d4037b4a 100644 --- a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift +++ b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift @@ -139,6 +139,25 @@ struct ChatQuoteBubbleTests { cell.bubbleView.quotePanel.simulateTap() #expect(tapped == nil) } + + @Test("A quoted photo draws its thumbnail; a redacted one draws none") + func mediaQuote_drawsThumbnailOnlyWhenFetchable() { + let photo = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: BlobID(data: Data([1])))) + let redacted = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: nil)) + + #expect(laidOutCell(quote: photo).bubbleView.quotePanel.thumbnailView.isHidden == false) + #expect(laidOutCell(quote: redacted).bubbleView.quotePanel.thumbnailView.isHidden == true) + } + + @Test("A recycled panel drops the photo it quoted before") + func reuse_dropsThumbnail() { + let photo = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: BlobID(data: Data([1])))) + let cell = laidOutCell(quote: photo) + cell.configure(with: ChatMessage(id: "2", content: .text("hi"), sender: .me, quote: quote), maxWidth: Self.maxWidth) + + #expect(cell.bubbleView.quotePanel.thumbnailView.isHidden == true) + #expect(cell.bubbleView.quotePanel.thumbnailView.image == nil) + } } @Suite("Reply bubble geometry") diff --git a/FlipcashTests/Chat/ComposerChipStripTests.swift b/FlipcashTests/Chat/ComposerChipStripTests.swift new file mode 100644 index 000000000..0b74624bf --- /dev/null +++ b/FlipcashTests/Chat/ComposerChipStripTests.swift @@ -0,0 +1,49 @@ +// +// ComposerChipStripTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Composer chip strip") +struct ComposerChipStripTests { + + @Test("A chip still preparing or uploading shows the progress ring", arguments: [ + ComposerChip.State.preparing, + .uploading, + ]) + func inFlightShowsProgress(state: ComposerChip.State) { + #expect(ComposerChipBadge(state) == .progress) + } + + @Test("An uploaded chip shows only its thumbnail") + func uploadedShowsNothing() { + #expect(ComposerChipBadge(.uploaded(BlobID(data: Data(repeating: 1, count: 32)))) == .none) + } + + @Test("A chip that can upload again offers Retry") + func retryableOffersRetry() { + #expect(ComposerChipBadge(.failed(.retryable)) == .retry) + } + + @Test("A chip the server refused shows the error without Retry") + func notRetryableShowsError() { + #expect(ComposerChipBadge(.failed(.notRetryable)) == .error) + } + + @Test("The strip shows for staged chips outside an edit", arguments: [ + (0, false, false), + (1, false, true), + (10, false, true), + (1, true, false), + ]) + func stripVisibility(chipCount: Int, isEditing: Bool, expected: Bool) { + #expect(ComposerChipStrip.isShown(chipCount: chipCount, isEditing: isEditing) == expected) + } +} diff --git a/FlipcashTests/Chat/ComposerChipTests.swift b/FlipcashTests/Chat/ComposerChipTests.swift new file mode 100644 index 000000000..3adb8f1b6 --- /dev/null +++ b/FlipcashTests/Chat/ComposerChipTests.swift @@ -0,0 +1,118 @@ +// +// ComposerChipTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import Flipcash + +@MainActor +@Suite("Composer chips") +struct ComposerChipTests { + + private final class BundleToken {} + + private struct Fixture: Decodable { + let maxAttachments: Int + } + + private func loadFixture() throws -> Fixture { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "chat_media", withExtension: "json")) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + private let uploader = ChatMediaUploader(blob: MockChatMediaBlobStore()) + + private func testImage() -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: CGSize(width: 8, height: 6), format: format).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 8, height: 6)) + } + } + + @Test("Staging a photo adds a chip that is still preparing") + func stagingAddsPreparingChip() throws { + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + + #expect(composer.chips.map(\.id) == [chip.id]) + #expect(chip.state == .preparing) + #expect(chip.preparedWidth == nil) + #expect(chip.preparedHeight == nil) + } + + @Test("Chips keep the order they were staged in") + func chipsKeepStagingOrder() throws { + let composer = ComposerModel() + let first = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + let second = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + let third = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + + #expect(composer.chips.map(\.id) == [first.id, second.id, third.id]) + } + + @Test("Removing a chip drops only that chip") + func removingDropsOnlyThatChip() throws { + let composer = ComposerModel() + let first = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + let second = try #require(composer.stageChip(image: testImage(), uploader: uploader)) + + composer.removeChip(first.id) + + #expect(composer.chips.map(\.id) == [second.id]) + } + + @Test("The composer refuses a chip past the fixture's maxAttachments") + func refusesChipPastMaxAttachments() throws { + let fixture = try loadFixture() + #expect(ComposerModel.maxAttachments == fixture.maxAttachments) + + let composer = ComposerModel() + for _ in 0.. ConversationBarLeadingControl { + ConversationBarLeadingControl( + isEditing: isEditing, + chatExists: chatExists, + showsSendCash: showsSendCash, + acceptsMedia: acceptsMedia, + attachedCount: attachedCount + ) + } + + @Test("Before the chat exists, Send Cash stays the full-width call to action", arguments: [true, false]) + func callToActionBeforeChatExists(acceptsMedia: Bool) { + #expect(control(chatExists: false, acceptsMedia: acceptsMedia) == .sendCash) + } + + @Test("Before the chat exists with nothing to send, the slot is empty") + func emptyBeforeChatExistsWithoutCash() { + #expect(control(chatExists: false, showsSendCash: false) == .none) + } + + @Test("Once the chat exists, the slot is the attach menu with every row") + func attachMenuOnceChatExists() { + #expect(control() == .attach([.cash, .camera, .photos])) + } + + @Test("An E2EE chat keeps the attach menu with Cash alone") + func attachMenuCashOnlyWithoutMedia() { + #expect(control(acceptsMedia: false) == .attach([.cash])) + } + + @Test("A chat that takes neither cash nor media leaves the slot empty") + func emptyWithoutCashOrMedia() { + #expect(control(showsSendCash: false, acceptsMedia: false) == .none) + } + + @Test("A full composer in a chat without cash leaves the slot empty") + func emptyWhenFullWithoutCash() { + #expect(control(showsSendCash: false, attachedCount: ComposerModel.maxAttachments) == .none) + } + + @Test("An edit takes the slot whatever else is true", arguments: [true, false]) + func cancelWhileEditing(chatExists: Bool) { + #expect(control(isEditing: true, chatExists: chatExists) == .cancelEdit) + } +} diff --git a/FlipcashTests/Chat/ConversationMediaSendTests.swift b/FlipcashTests/Chat/ConversationMediaSendTests.swift new file mode 100644 index 000000000..b6a298de3 --- /dev/null +++ b/FlipcashTests/Chat/ConversationMediaSendTests.swift @@ -0,0 +1,321 @@ +// +// ConversationMediaSendTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@MainActor +@Suite("Sending photos") +struct ConversationMediaSendTests { + + private let conversationID = ConversationID.test(1) + + /// Mirrors `ConversationReplySendTests.makeController`. + private func makeController(_ mock: MockConversations) -> ConversationController { + ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: try! Database.makeTemp().database, + owner: .generate()!, selfUserID: UUID(), + typingHeartbeatInterval: .seconds(3), incomingTypingExpiry: .seconds(10) + ) + } + + private func testImage(width: CGFloat = 8, height: CGFloat = 6, scale: CGFloat = 1) -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = scale + return UIGraphicsImageRenderer(size: CGSize(width: width, height: height), format: format).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + } + } + + private func blobID(_ name: String) -> BlobID { + BlobID(data: Data(name.utf8)) + } + + /// A chip whose upload has already finished with `name`'s blob. + private func uploadedChip(_ name: String) -> ComposerChip { + let chip = ComposerChip(image: testImage()) + let blobID = blobID(name) + chip.uploadTask = Task { blobID } + return chip + } + + /// A chip whose upload ends only when the test opens `gate`. + private func gatedChip(_ gate: UploadGate, image: UIImage? = nil) -> ComposerChip { + let chip = ComposerChip(image: image ?? testImage()) + chip.uploadTask = Task { try await gate.wait() } + return chip + } + + /// Yields the main actor until `condition` holds, so a send started in a `Task` can reach its + /// first suspension. + private func yield(until condition: () -> Bool) async { + for _ in 0..<200 where !condition() { + await Task.yield() + } + } + + private func pendingMedia(_ controller: ConversationController) -> [ConversationMessage] { + controller.messages(for: conversationID).filter { + switch $0.content { + case .media: $0.clientMessageID != nil && $0.id == .unassigned + case .text, .cash, .deleted, .encrypted, .widget: false + } + } + } + + private func attachment(of message: ConversationMessage) -> MediaAttachment? { + switch message.content { + case .media(let attachments, _): attachments.first + case .text, .cash, .deleted, .encrypted, .widget: nil + } + } + + // MARK: - Fan-out + + @Test("Each fanOut vector with chips posts one message per chip, caption last, reply first") + func fanOutMatchesFixture() async throws { + for vector in try ChatMediaFanOutVectorTests.loadVectors() where !vector.chips.isEmpty { + let mock = MockConversations() + let controller = makeController(mock) + + let sent = await controller.sendMedia( + vector.chips.map(uploadedChip), + caption: vector.text, + to: conversationID, + repliedTo: vector.replyTo.flatMap { ChatMediaFanOutVectorTests.replyIDs[$0] } + ) + + #expect(sent, "\(vector.name)") + #expect(mock.sentMedia.count == vector.messages.count, "\(vector.name)") + for (call, expected) in zip(mock.sentMedia, vector.messages) { + #expect(expected.kind == "media", "\(vector.name)") + #expect(call.blobID == blobID(try #require(expected.chip)), "\(vector.name)") + #expect(call.caption == expected.caption, "\(vector.name)") + #expect(call.repliedTo == expected.replyTo.flatMap { ChatMediaFanOutVectorTests.replyIDs[$0] }, "\(vector.name)") + } + #expect(pendingMedia(controller).isEmpty, "\(vector.name): every row confirmed") + } + } + + @Test("Every pending bubble appears before any upload has finished") + func insertsAllPendingRowsUpFront() async { + let mock = MockConversations() + let controller = makeController(mock) + let first = UploadGate(), second = UploadGate() + + let send = Task { await controller.sendMedia([gatedChip(first), gatedChip(second)], caption: "hi", to: conversationID) } + await yield { pendingMedia(controller).count == 2 } + + let pending = pendingMedia(controller) + #expect(pending.count == 2) + #expect(pending.allSatisfy { $0.status == .sending }) + #expect(pending.map(\.content.mediaCaption) == [nil, "hi"]) + #expect(mock.sentMedia.isEmpty) + + first.open(.success(blobID("a"))) + second.open(.success(blobID("b"))) + #expect(await send.value) + } + + @Test("A later photo that finishes uploading first still posts second") + func postsStrictlyInChipOrder() async { + let mock = MockConversations() + let controller = makeController(mock) + let slow = UploadGate() + + let send = Task { await controller.sendMedia([gatedChip(slow), uploadedChip("fast")], caption: nil, to: conversationID) } + await yield { pendingMedia(controller).count == 2 } + for _ in 0..<20 { await Task.yield() } + #expect(mock.sentMedia.isEmpty, "the second photo waits for the first") + + slow.open(.success(blobID("slow"))) + #expect(await send.value) + #expect(mock.sentMedia.map(\.blobID) == [blobID("slow"), blobID("fast")]) + } + + @Test("A photo that fails to upload fails only its own bubble") + func failedUploadFailsOnlyItsRow() async throws { + let mock = MockConversations() + let controller = makeController(mock) + let rejected = ComposerChip(image: testImage()) + rejected.uploadTask = Task { throw ChatMediaUploadError.rejected(.moderation) } + + let sent = await controller.sendMedia([rejected, uploadedChip("ok")], caption: nil, to: conversationID) + + #expect(!sent) + #expect(mock.sentMedia.map(\.blobID) == [blobID("ok")]) + let pending = pendingMedia(controller) + #expect(pending.count == 1) + #expect(pending.first?.status == .failed) + #expect(controller.pendingMediaImage(forMessageID: try #require(pending.first?.stableID)) === rejected.image) + } + + @Test("A photo the server refuses to post leaves a failed bubble") + func failedPostFailsTheRow() async { + let mock = MockConversations() + mock.mediaSendError = ErrorSendMessage.transportFailure + let controller = makeController(mock) + + #expect(!(await controller.sendMedia([uploadedChip("a")], caption: "hi", to: conversationID))) + + #expect(pendingMedia(controller).map(\.status) == [.failed]) + } + + // MARK: - Pending bubble + + @Test("The pending bubble carries the upload size once the chip knows it") + func pendingRowUsesPreparedSize() async { + let controller = makeController(MockConversations()) + let gate = UploadGate() + let chip = gatedChip(gate) + chip.preparedWidth = 2048 + chip.preparedHeight = 1536 + + let send = Task { await controller.sendMedia([chip], caption: nil, to: conversationID) } + await yield { pendingMedia(controller).count == 1 } + + let attachment = pendingMedia(controller).first.flatMap(attachment(of:)) + #expect(attachment?.width == 2048) + #expect(attachment?.height == 1536) + #expect(attachment?.blobID == nil) + + gate.open(.success(blobID("a"))) + _ = await send.value + } + + @Test("A chip still preparing sizes its bubble from the source image's pixels") + func pendingRowFallsBackToSourcePixels() async { + let controller = makeController(MockConversations()) + let gate = UploadGate() + let chip = gatedChip(gate, image: testImage(width: 8, height: 6, scale: 2)) + + let send = Task { await controller.sendMedia([chip], caption: nil, to: conversationID) } + await yield { pendingMedia(controller).count == 1 } + + let attachment = pendingMedia(controller).first.flatMap(attachment(of:)) + #expect(attachment?.width == 16) + #expect(attachment?.height == 12) + + gate.open(.success(blobID("a"))) + _ = await send.value + } + + @Test("The local image is served for a pending photo and dropped once it is confirmed") + func localImageLivesUntilConfirmed() async throws { + let controller = makeController(MockConversations()) + let gate = UploadGate() + let chip = gatedChip(gate) + + let send = Task { await controller.sendMedia([chip], caption: nil, to: conversationID) } + await yield { pendingMedia(controller).count == 1 } + let messageID = try #require(pendingMedia(controller).first?.stableID) + #expect(controller.pendingMediaImage(forMessageID: messageID) === chip.image) + + gate.open(.success(blobID("a"))) + _ = await send.value + #expect(controller.pendingMediaImage(forMessageID: messageID) == nil) + } + + // MARK: - Retry + + @Test("Retrying a photo whose post failed posts the same blob under the same client id") + func retryReposts() async throws { + let mock = MockConversations() + mock.mediaSendError = ErrorSendMessage.transportFailure + let controller = makeController(mock) + _ = await controller.sendMedia([uploadedChip("a")], caption: "hi", to: conversationID, repliedTo: MessageID(value: 7)) + let failed = try #require(pendingMedia(controller).first) + let clientMessageID = try #require(failed.clientMessageID) + + mock.mediaSendError = nil + await controller.retry(clientMessageID: clientMessageID, in: conversationID) + + #expect(mock.sentMedia.count == 2) + let retried = try #require(mock.sentMedia.last) + #expect(retried.clientMessageID == clientMessageID) + #expect(retried.blobID == blobID("a")) + #expect(retried.caption == "hi") + #expect(retried.repliedTo == MessageID(value: 7)) + #expect(pendingMedia(controller).isEmpty) + } + + @Test("Retrying a photo whose upload failed uploads it again before posting") + func retryReuploads() async throws { + let mock = MockConversations() + let controller = makeController(mock) + let blob = MockChatMediaBlobStore() + blob.storeResults = [.failure(ErrorBlob.uploadDenied)] + let chip = ComposerChip(image: testImage()) + chip.startUpload(using: ChatMediaUploader(blob: blob, backoff: [])) + + #expect(!(await controller.sendMedia([chip], caption: nil, to: conversationID))) + #expect(chip.state == .failed(.retryable)) + let clientMessageID = try #require(pendingMedia(controller).first?.clientMessageID) + + await controller.retry(clientMessageID: clientMessageID, in: conversationID) + + #expect(blob.storeAttempts == 2) + #expect(mock.sentMedia.map(\.blobID) == [MockChatMediaBlobStore.blobID]) + #expect(pendingMedia(controller).isEmpty) + } + + @Test("A photo refused by moderation is not retried") + func moderationIsNotRetried() async throws { + let mock = MockConversations() + let controller = makeController(mock) + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.rejected(.moderation)) + let chip = ComposerChip(image: testImage()) + chip.startUpload(using: ChatMediaUploader(blob: blob, backoff: [])) + + _ = await controller.sendMedia([chip], caption: nil, to: conversationID) + let clientMessageID = try #require(pendingMedia(controller).first?.clientMessageID) + + await controller.retry(clientMessageID: clientMessageID, in: conversationID) + + #expect(blob.storeAttempts == 1) + #expect(mock.sentMedia.isEmpty) + #expect(pendingMedia(controller).map(\.status) == [.failed]) + } +} + +/// An upload the test finishes by hand. +@MainActor +private final class UploadGate { + + private var continuation: CheckedContinuation? + private var result: Result? + + func wait() async throws -> BlobID { + if let result { return try result.get() } + return try await withCheckedThrowingContinuation { continuation = $0 } + } + + func open(_ result: Result) { + if let continuation { + continuation.resume(with: result) + self.continuation = nil + } else { + self.result = result + } + } +} + +private extension ConversationMessage.Content { + var mediaCaption: String? { + switch self { + case .media(_, let caption): caption + case .text, .cash, .deleted, .encrypted, .widget: nil + } + } +} diff --git a/FlipcashTests/Chat/ReactionUITests.swift b/FlipcashTests/Chat/ReactionUITests.swift index 43a11021b..dd21fd467 100644 --- a/FlipcashTests/Chat/ReactionUITests.swift +++ b/FlipcashTests/Chat/ReactionUITests.swift @@ -58,6 +58,13 @@ struct ReactionUITests { #expect(message.offersReactionStrip == false) } + @Test("A redacted photo never offers the strip; a viewable one does", arguments: [true, false]) + func redactedPhotoNoStrip(isRedacted: Bool) { + let media = ChatMediaContent(blobID: nil, width: 100, height: 100, blurhash: nil, caption: nil, isRedacted: isRedacted) + let message = Self.makeMessage(content: .media(media), sender: .other) + #expect(message.offersReactionStrip == !isRedacted) + } + @Test("The viewer's own message still in flight never offers the strip") func inFlightSendNoStrip() { let message = Self.makeMessage(content: .text("hi"), sender: .me, receipt: nil, isUnsent: true) diff --git a/FlipcashTests/ChatPreviewMappingTests.swift b/FlipcashTests/ChatPreviewMappingTests.swift index f17d6a06f..73568565e 100644 --- a/FlipcashTests/ChatPreviewMappingTests.swift +++ b/FlipcashTests/ChatPreviewMappingTests.swift @@ -285,4 +285,31 @@ struct ChatPreviewMappingTests { #expect(decoded == items) } + + // MARK: - Photos + + @Test("A photo maps to the transcript's photo row, redaction included") + func photoMapsToMediaRow() { + let blobID = BlobID(data: Data([7])) + let attachment = MediaAttachment(blobID: blobID, width: 30, height: 20, blurhash: "LEHV6nWB2yk8") + let messages = [ + ConversationMessage( + id: MessageID(value: 1), senderID: otherID, + content: .media([attachment], caption: "from today"), + date: Date(timeIntervalSince1970: 1), unreadSeq: 0 + ), + ConversationMessage( + id: MessageID(value: 2), senderID: otherID, + content: .media([attachment], caption: nil), + date: Date(timeIntervalSince1970: 2), unreadSeq: 0, redacted: true + ), + ] + + let rows = messageRows(ChatItem.preview(from: messages, selfUserID: meID)) + + #expect(rows.map(\.content) == [ + .media(ChatMediaContent(blobID: blobID, width: 30, height: 20, blurhash: "LEHV6nWB2yk8", caption: "from today", isRedacted: false)), + .media(ChatMediaContent(blobID: blobID, width: 30, height: 20, blurhash: "LEHV6nWB2yk8", caption: nil, isRedacted: true)), + ]) + } } diff --git a/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift b/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift new file mode 100644 index 000000000..e21dcef49 --- /dev/null +++ b/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift @@ -0,0 +1,53 @@ +// +// MockChatMediaBlobStore.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +@testable import FlipcashCore +@testable import Flipcash + +/// Records each blob call and lets a test choose how storing and finalization end. +@MainActor +final class MockChatMediaBlobStore: ChatMediaBlobStoring { + + static let blobID = BlobID(data: Data(repeating: 4, count: 16)) + + var policy: UploadPolicy + /// Consumed one per `storeBlob` call; once empty, every call succeeds. + var storeResults: [Result] = [] + var finalization: Result = .success(()) + var onStore: (() -> Void)? + + private(set) var storeAttempts = 0 + private(set) var storedData: [Data] = [] + private(set) var storedMimeTypes: [String] = [] + private(set) var finalizedBlobIDs: [BlobID] = [] + + init(policy: UploadPolicy = UploadPolicy(version: "v1", ttl: nil, constraints: [ + .init(pattern: "image/*", maxSizeBytes: 5_000_000, image: .init(maxWidth: 2048, maxHeight: 2048, maxPixels: 0)), + ])) { + self.policy = policy + } + + func uploadPolicy() async throws -> UploadPolicy { + policy + } + + func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID { + storeAttempts += 1 + onStore?() + let result = storeResults.isEmpty ? .success(Self.blobID) : storeResults.removeFirst() + let blobID = try result.get() + storedData.append(data) + storedMimeTypes.append(mimeType) + return blobID + } + + func awaitBlobFinalization(blobID: BlobID) async throws { + try finalization.get() + finalizedBlobIDs.append(blobID) + } +} diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index fa1e3ec67..1e000493b 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -16,6 +16,14 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con let text: String let repliedTo: MessageID? } + /// One `sendMediaMessage` call. + struct SentMedia: Sendable, Equatable { + let conversationID: ConversationID + let blobID: BlobID + let caption: String? + let repliedTo: MessageID? + let clientMessageID: UUID + } struct TypingCall: Sendable { let conversationID: ConversationID; let state: TypingState } /// A scripted `GetDelta` batch: one `onBatch` call with these messages + checkpoint. struct DeltaBatch: Sendable { let messages: [ConversationMessage]; let checkpoint: UInt64? } @@ -64,6 +72,8 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con private var _sendError: Error? private var _sentClientIDs: [UUID] = [] private var _sent: [Sent] = [] + private var _sentMedia: [SentMedia] = [] + private var _mediaSendError: Error? private var _edited: [Edited] = [] private var _reacted: [Reacted] = [] private var _reactionHandler: (@Sendable (Reacted) async throws -> EmojiReaction)? @@ -164,6 +174,13 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con /// The client message ids `sendMessage` was called with, in order. var sentClientIDs: [UUID] { lock.withLock { _sentClientIDs } } var sent: [Sent] { lock.withLock { _sent } } + /// Every `sendMediaMessage` call, in order, including the ones that threw. + var sentMedia: [SentMedia] { lock.withLock { _sentMedia } } + /// Thrown by `sendMediaMessage` while set. + var mediaSendError: Error? { + get { lock.withLock { _mediaSendError } } + set { lock.withLock { _mediaSendError = newValue } } + } /// The edits `editMessage` was called with, in order. var edited: [Edited] { lock.withLock { _edited } } /// The add/remove reaction calls, in order. @@ -392,6 +409,19 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con ) } + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + let (count, error) = lock.withLock { + _sentMedia.append(SentMedia(conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID)) + return (_sentMedia.count, _mediaSendError) + } + if let error { throw error } + return ConversationMessage( + id: MessageID(value: 100 + UInt64(count)), senderID: nil, + content: .media([MediaAttachment(blobID: blobID, width: 4, height: 3, blurhash: nil)], caption: caption), + date: Date(timeIntervalSince1970: 0), unreadSeq: 0, repliedTo: repliedTo + ) + } + func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, repliedTo: MessageID?, expectedEventSequence: UInt64) async throws -> MessageMutation { lock.withLock { _edited.append(Edited(conversationID: conversationID, messageID: messageID, text: text, repliedTo: repliedTo, expectedEventSequence: expectedEventSequence)) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index b238004c5..82406c59b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -148,7 +148,8 @@ public final class ChatBubbleView: UIView { /// Whether this bubble is currently flashing. var isFlashingAttention: Bool { background.isFlashingAttention } - public func configure(with message: ChatMessage) { + /// Fills the bubble; `quoteThumbnailURL` is where a quoted photo's thumbnail loads from. + public func configure(with message: ChatMessage, quoteThumbnailURL: URL? = nil) { label.attributedText = Self.displayText(for: message) editedLabel.isHidden = !Self.showsEditedMarker(for: message) || message.rendersAsLargeEmoji isBare = message.rendersAsLargeEmoji @@ -157,7 +158,7 @@ public final class ChatBubbleView: UIView { let hint: String? switch message.content { case .unavailable(let unavailableHint): hint = unavailableHint.text - case .text, .cash, .deleted, .shareProfile: hint = nil + case .text, .cash, .deleted, .shareProfile, .media: hint = nil } hintLabel.text = hint hintLabel.isHidden = hint == nil @@ -181,7 +182,7 @@ public final class ChatBubbleView: UIView { // unsatisfiable, and UIKit resolves that by breaking one at random. if let quote = message.quote { quotePanel.isHidden = false - quotePanel.configure(with: quote) + quotePanel.configure(with: quote, thumbnailURL: quoteThumbnailURL) NSLayoutConstraint.deactivate(quoteCollapse) quoteTrailing.isActive = true labelTopToBubble.isActive = false @@ -215,14 +216,14 @@ public final class ChatBubbleView: UIView { case .text: message.isEdited case .deleted: false case .cash: false - case .unavailable, .shareProfile: false + case .unavailable, .shareProfile, .media: false } } private static func isUnavailable(_ message: ChatMessage) -> Bool { switch message.content { case .unavailable: true - case .text, .cash, .deleted, .shareProfile: false + case .text, .cash, .deleted, .shareProfile, .media: false } } @@ -265,7 +266,7 @@ public final class ChatBubbleView: UIView { isPlaceholder = true case .unavailable: return unavailableText() - case .cash, .shareProfile: + case .cash, .shareProfile, .media: return nil } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift index ff7e237c8..dfb246104 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift @@ -49,6 +49,8 @@ extension ChatItem { ChatCashCardCell.reuseIdentifier case .shareProfile: ChatShareProfileCell.reuseIdentifier + case .media: + ChatMediaCell.reuseIdentifier } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift index b424a0ca1..88d18f5f8 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift @@ -95,12 +95,19 @@ public final class ChatLinkMessageCell: ChatColumnCell { reactionRow.prepareForReuse() } - /// - Parameter maxWidth: the widest the bubble may grow before its text wraps. - public func configure(with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil) { + /// - Parameters: + /// - maxWidth: the widest the bubble may grow before its text wraps. + /// - quoteThumbnailURL: where a quoted photo's thumbnail loads from, or nil until it resolves. + public func configure( + with message: ChatMessage, + maxWidth: CGFloat, + authorImageData: Data? = nil, + quoteThumbnailURL: URL? = nil + ) { bubbleMaxWidthConstraint.constant = maxWidth bubbleCardWidthConstraint.constant = maxWidth bubbleCardWidthConstraint.isActive = Self.cardFillsWidth(message.linkPreview?.card) - bubble.configure(with: message) + bubble.configure(with: message, quoteThumbnailURL: quoteThumbnailURL) reactionRowWidthConstraint.constant = maxWidth reactionRow.layoutWidth = maxWidth reactionRow.hugsTrailingEdge = message.sender == .me diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaBubbleSizing.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaBubbleSizing.swift new file mode 100644 index 000000000..bb405fe2f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaBubbleSizing.swift @@ -0,0 +1,35 @@ +// +// ChatMediaBubbleSizing.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// The size a photo bubble draws at, known from the photo's dimensions before any bytes arrive. +nonisolated enum ChatMediaBubbleSizing { + + /// The widest a photo may draw: 1:2, height over width. + static let minAspect: Double = 0.5 + /// The tallest a photo may draw: 2:1, height over width. + static let maxAspect: Double = 2.0 + + /// A bubble's footprint, and whether the photo is centre-cropped to fit it. + struct Size: Equatable { + let width: Double + let height: Double + let cropped: Bool + } + + /// The bubble for a `imageWidth` × `imageHeight` photo: always `maxWidth` wide, its height following + /// the photo's aspect clamped to 1:2…2:1. A photo missing either dimension draws square. + static func size(imageWidth: Double, imageHeight: Double, maxWidth: Double) -> Size { + guard imageWidth > 0, imageHeight > 0 else { + return Size(width: maxWidth, height: maxWidth, cropped: false) + } + let aspect = imageHeight / imageWidth + let clamped = min(max(aspect, minAspect), maxAspect) + return Size(width: maxWidth, height: maxWidth * clamped, cropped: clamped != aspect) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift new file mode 100644 index 000000000..17d39cf6c --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift @@ -0,0 +1,255 @@ +// +// ChatMediaCell.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import FlipcashCore +import Kingfisher + +/// A recycled cell for a photo row: the photo at the transcript's bubble width and a clamped aspect, +/// its caption as a text bubble directly under it, and the reaction pills, in a `ChatColumnCell`. +/// +/// The photo draws from, in order: the local image of a pending send this device staged, the +/// resolved download URL (fading in over the BlurHash), or the BlurHash alone. A BlurHash-only row — +/// see ``isBlurhashOnly(_:canReact:)`` — draws nothing else, takes no tap, and hides its reactions. +public final class ChatMediaCell: ChatColumnCell { + + public static let reuseIdentifier = "ChatMediaCell" + + /// The gap between the photo and its caption bubble: the transcript's tight row gap, since the + /// two read as one run. + static let captionGap: CGFloat = 5 + private static let captionInset: CGFloat = 12 + private static let captionPadding: CGFloat = 9 + private static let fadeDuration: TimeInterval = 0.25 + + private let stack = UIStackView() + private let imageBubble = BubbleBackgroundView() + let imageView = UIImageView() + let captionBubble = BubbleBackgroundView() + let captionLabel = UILabel() + let reactionRow = ReactionPillRowView() + let imageTap = UITapGestureRecognizer() + + private var imageWidthConstraint: NSLayoutConstraint! + private var imageHeightConstraint: NSLayoutConstraint! + private var captionMaxWidthConstraint: NSLayoutConstraint! + private var reactionRowWidthConstraint: NSLayoutConstraint! + + /// The row the image view is currently drawing, so a pending row that confirms keeps its local + /// image under the download instead of dropping back to the BlurHash. + private var drawnRowID: String? + + /// Whether the row `configure` last ran with draws only its BlurHash. + private(set) var blurhashOnly = false + + /// Fired when the viewer taps the photo. Never fires for a BlurHash-only or a failed row. + var onImageTap: (() -> Void)? + /// Fired when the viewer taps a reaction pill — the argument is the toggled emoji. + var onReactionTap: ((String) -> Void)? + /// Fired on a long-press of a reaction pill, to open the reactors sheet scoped to that emoji. + var onReactionLongPress: ((String) -> Void)? + /// Fired when the trailing "+" is tapped, to open the picker. + var onReactionAdd: (() -> Void)? + + public override init(frame: CGRect) { + super.init(frame: frame) + + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false + imageBubble.addSubview(imageView) + imageBubble.isAccessibilityElement = true + imageBubble.accessibilityLabel = "Photo" + imageTap.addTarget(self, action: #selector(imageTapped)) + imageBubble.addGestureRecognizer(imageTap) + + captionLabel.font = .default(size: 16, weight: .medium) + captionLabel.textColor = .white + captionLabel.numberOfLines = 0 + captionLabel.translatesAutoresizingMaskIntoConstraints = false + captionBubble.addSubview(captionLabel) + captionBubble.isHidden = true + + stack.axis = .vertical + stack.spacing = Self.captionGap + stack.addArrangedSubview(imageBubble) + stack.addArrangedSubview(captionBubble) + + installColumn(content: stack, accessory: reactionRow) + reactionRow.onToggle = { [weak self] emoji in self?.onReactionTap?(emoji) } + reactionRow.onLongPress = { [weak self] emoji in self?.onReactionLongPress?(emoji) } + reactionRow.onAdd = { [weak self] in self?.onReactionAdd?() } + + imageWidthConstraint = imageBubble.widthAnchor.constraint(equalToConstant: 240) + // Below required so the photo yields to the cell's self-sizing height instead of fighting it. + imageHeightConstraint = imageBubble.heightAnchor.constraint(equalToConstant: 240) + imageHeightConstraint.priority = UILayoutPriority(999) + captionMaxWidthConstraint = captionBubble.widthAnchor.constraint(lessThanOrEqualToConstant: 240) + reactionRowWidthConstraint = reactionRow.widthAnchor.constraint(equalToConstant: 240) + + NSLayoutConstraint.activate([ + imageWidthConstraint, + imageHeightConstraint, + captionMaxWidthConstraint, + reactionRowWidthConstraint, + + imageView.topAnchor.constraint(equalTo: imageBubble.topAnchor), + imageView.leadingAnchor.constraint(equalTo: imageBubble.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: imageBubble.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: imageBubble.bottomAnchor), + + captionLabel.topAnchor.constraint(equalTo: captionBubble.topAnchor, constant: Self.captionPadding), + captionLabel.bottomAnchor.constraint(equalTo: captionBubble.bottomAnchor, constant: -Self.captionPadding), + captionLabel.leadingAnchor.constraint(equalTo: captionBubble.leadingAnchor, constant: Self.captionInset), + captionLabel.trailingAnchor.constraint(equalTo: captionBubble.trailingAnchor, constant: -Self.captionInset), + ]) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + /// Scopes the row's touch target to the photo, caption, and pills, like the cash card: the cell + /// spans the full transcript width, and a tap beside the photo should not land on it. + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + stack.convert(stack.bounds, to: self).contains(point) + || (!reactionRow.isHidden && reactionRow.convert(reactionRow.bounds, to: self).contains(point)) + } + + public override func prepareForReuse() { + super.prepareForReuse() + imageView.kf.cancelDownloadTask() + imageView.image = nil + drawnRowID = nil + reactionRow.prepareForReuse() + } + + /// Whether a photo draws only its BlurHash: the server withheld it, or the viewer is previewing a + /// group they have not joined — the one reason `canReact` is false. Such a row is never fetched. + static func isBlurhashOnly(_ media: ChatMediaContent, canReact: Bool) -> Bool { + media.isRedacted || !canReact + } + + /// - Parameters: + /// - maxWidth: the transcript's widest bubble, which the photo always spans. + /// - localImage: the picked image of a pending send this device staged, or nil. + /// - remoteURL: the photo's resolved download URL, or nil until it is resolved. + public func configure( + with message: ChatMessage, + maxWidth: CGFloat, + localImage: UIImage?, + remoteURL: URL?, + authorImageData: Data? = nil + ) { + guard case .media(let media) = message.content else { return } + let blurhashOnly = Self.isBlurhashOnly(media, canReact: message.canReact) + self.blurhashOnly = blurhashOnly + + let size = ChatMediaBubbleSizing.size( + imageWidth: Double(media.width), + imageHeight: Double(media.height), + maxWidth: Double(maxWidth) + ) + imageWidthConstraint.constant = size.width + imageHeightConstraint.constant = size.height + drawImage( + for: message.id, + blobID: media.blobID, + blurhash: media.blurhash, + localImage: blurhashOnly ? nil : localImage, + remoteURL: blurhashOnly ? nil : remoteURL, + size: CGSize(width: size.width, height: size.height) + ) + imageTap.isEnabled = !blurhashOnly && !message.isFailed + + let caption = media.caption.flatMap { $0.isEmpty ? nil : $0 } + captionLabel.text = caption + captionBubble.isHidden = caption == nil + captionMaxWidthConstraint.constant = maxWidth + + let isFromSelf = message.sender == .me + let fill = BubbleBackgroundView.fill(isFromSelf: isFromSelf) + imageBubble.apply( + fill: fill, + radii: BubbleBackgroundView.radii( + isFromSelf: isFromSelf, + groupedAbove: message.joinsBubbleAbove, + groupedBelow: caption != nil || message.joinsBubbleBelow + ), + identity: message.id + ) + captionBubble.apply( + fill: fill, + radii: BubbleBackgroundView.radii(isFromSelf: isFromSelf, groupedAbove: true, groupedBelow: message.joinsBubbleBelow), + identity: message.id + ) + stack.alignment = isFromSelf ? .trailing : .leading + + reactionRowWidthConstraint.constant = maxWidth + reactionRow.layoutWidth = maxWidth + reactionRow.hugsTrailingEdge = isFromSelf + reactionRow.configure(pills: blurhashOnly ? [] : message.reactions, canReact: message.canReact && !blurhashOnly) + reactionRow.isHidden = blurhashOnly + updateColumn(for: message, authorImageData: authorImageData) + } + + private func drawImage(for rowID: String, blobID: BlobID?, blurhash: String?, localImage: UIImage?, remoteURL: URL?, size: CGSize) { + let isSameRow = drawnRowID == rowID + drawnRowID = rowID + + if let localImage { + imageView.kf.cancelDownloadTask() + imageView.image = localImage + return + } + + let preview = BlurHashCache.shared.image(for: blurhash) + guard let remoteURL else { + imageView.kf.cancelDownloadTask() + imageView.image = preview + return + } + + // Whatever this row already shows — its local image, or the photo itself on a reconfigure — + // stays under the load, so only a BlurHash is ever faded over. + let placeholder = isSameRow ? (imageView.image ?? preview) : preview + imageView.kf.setImage( + with: ChatMediaImageSource.resource(blobID: blobID, url: remoteURL), + placeholder: placeholder, + options: [ + .processor(DownsamplingImageProcessor(size: size)), + .scaleFactor(traitCollection.displayScale), + .transition(.fade(Self.fadeDuration)), + ] + ) + } + + @objc private func imageTapped() { + onImageTap?() + } +} + +extension ChatMediaCell: BubbleCarrying { + /// The photo and its caption, not the cell: the cell spans the full row. + var liftPreviewView: UIView { stack } + + var liftPreviewMaskingPath: UIBezierPath? { + let path = UIBezierPath() + for bubble in [imageBubble, captionBubble] where !bubble.isHidden { + let shape = bubble.maskingPath + shape.apply(CGAffineTransform(translationX: bubble.frame.minX, y: bubble.frame.minY)) + path.append(shape) + } + return path + } + + func flashAttention(startedAt start: CFTimeInterval) { + imageBubble.flashAttention(startedAt: start) + if !captionBubble.isHidden { captionBubble.flashAttention(startedAt: start) } + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift new file mode 100644 index 000000000..b18a3e5fc --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift @@ -0,0 +1,20 @@ +// +// ChatMediaImageSource.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore +import Kingfisher + +/// Where a chat photo's bytes load from, for every view that draws one. +public enum ChatMediaImageSource { + + /// The photo at `url`, cached under its blob rather than the URL. The URL is signed and minted + /// per fetch, so keying on it would re-download the same photo every session. + public static func resource(blobID: BlobID?, url: URL) -> KF.ImageResource { + KF.ImageResource(downloadURL: url, cacheKey: blobID.map { "chat-media-\($0.description)" } ?? url.absoluteString) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift new file mode 100644 index 000000000..0815161d8 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift @@ -0,0 +1,101 @@ +// +// ChatMediaURLResolver.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +/// Resolves and caches the signed download URL for each photo in one conversation. +/// +/// It is the only path from a photo row to the network, so a row that draws only its BlurHash — a +/// redacted photo, or any photo while the viewer previews a group they have not joined — never +/// fetches. Not fetching is what keeps that state from being a blur over bytes already on the device. +public final class ChatMediaURLResolver { + + /// Mints a download URL for a blob, or returns nil when the server has none to give. + public typealias Fetch = @MainActor (BlobID) async throws -> URL? + + private let fetch: Fetch + private var cache: [BlobID: URL] = [:] + /// Everyone waiting on a blob's fetch, called with its URL, or nil when the fetch failed. + private var waiters: [BlobID: [(URL?) -> Void]] = [:] + + public init(fetch: @escaping Fetch) { + self.fetch = fetch + } + + /// The URL to draw `media` from, or nil for now. + /// + /// A BlurHash-only row returns nil without fetching. A cache miss starts at most one fetch per + /// blob and calls `onResolved` when it lands so the caller can redraw the row; a failed fetch + /// leaves the row on its BlurHash, and the next call tries again. + public func url( + for media: ChatMediaContent, + canReact: Bool, + onResolved: @escaping (URL) -> Void + ) -> URL? { + guard !ChatMediaCell.isBlurhashOnly(media, canReact: canReact), + let blobID = media.blobID else { return nil } + return resolve(blobID) { $0.map(onResolved) } + } + + /// The URL to draw a quoted photo's thumbnail from, or nil for now, sharing the photo row's cache. + /// + /// Nil without fetching for anything but a photo whose bytes the viewer may see: a quote of text, + /// cash, or an unavailable original, a redacted photo (``ChatQuote/Kind/media(thumbnailBlobID:)`` + /// carries no blob), or any quote while the viewer previews a group they have not joined. + public func thumbnailURL( + for kind: ChatQuote.Kind, + canReact: Bool, + onResolved: @escaping (URL) -> Void + ) -> URL? { + guard let blobID = Self.thumbnailBlobID(for: kind, canReact: canReact) else { return nil } + return resolve(blobID) { $0.map(onResolved) } + } + + /// The URL to draw a quoted photo's thumbnail from, waiting for the fetch; nil when there is none + /// to draw or the fetch failed. Follows the same rules as the callback form. + public func thumbnailURL(for kind: ChatQuote.Kind, canReact: Bool) async -> URL? { + guard let blobID = Self.thumbnailBlobID(for: kind, canReact: canReact) else { return nil } + return await withCheckedContinuation { continuation in + var resumed = false + let immediate = resolve(blobID) { url in + guard !resumed else { return } + resumed = true + continuation.resume(returning: url) + } + if let immediate, !resumed { + resumed = true + continuation.resume(returning: immediate) + } + } + } + + private static func thumbnailBlobID(for kind: ChatQuote.Kind, canReact: Bool) -> BlobID? { + switch kind { + case .media(let thumbnailBlobID): + canReact ? thumbnailBlobID : nil + case .text, .cash, .unavailable: + nil + } + } + + /// The cached URL for `blobID`, or nil after queueing `completion` on the one fetch for it. + private func resolve(_ blobID: BlobID, completion: @escaping (URL?) -> Void) -> URL? { + if let cached = cache[blobID] { return cached } + let isFirst = waiters[blobID] == nil + waiters[blobID, default: []].append(completion) + guard isFirst else { return nil } + Task { + // Best-effort: the row keeps its BlurHash, and the next dequeue retries. + let resolved = try? await fetch(blobID) + if let resolved { cache[blobID] = resolved } + let completions = waiters.removeValue(forKey: blobID) ?? [] + for completion in completions { completion(resolved) } + } + return nil + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift new file mode 100644 index 000000000..07ccf6f8a --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift @@ -0,0 +1,211 @@ +// +// ChatMediaViewerController.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import FlipcashCore +import Kingfisher + +/// What the transcript hands its owner when a photo is tapped: the photo to show and the view it +/// zooms out of. +public struct ChatMediaViewerRequest { + + /// The tapped row's stable id. + public let messageID: String + /// The photo's blob, which keys its download in the image cache. + public let blobID: BlobID? + /// The staged image of a send this device has not confirmed yet, drawn instead of downloading. + public let localImage: UIImage? + /// The photo's resolved download URL, or nil for a pending send. + public let remoteURL: URL? + /// Whatever the row drew when it was tapped, shown while the full photo loads. + public let placeholder: UIImage? + /// The on-screen view the photo zooms out of and back into, looked up on each use because the + /// row's cell can be recycled while the viewer is up. + public let sourceView: () -> UIView? + + /// Nil when the row may not open: not a photo, BlurHash-only (see + /// ``ChatMediaCell/isBlurhashOnly(_:canReact:)``), a failed send whose tap is the retry, or a + /// photo with neither a staged image nor a resolved URL yet. + public init?( + message: ChatMessage, + localImage: UIImage?, + remoteURL: URL?, + placeholder: UIImage?, + sourceView: @escaping () -> UIView? + ) { + guard case .media(let media) = message.content, + !ChatMediaCell.isBlurhashOnly(media, canReact: message.canReact), + !message.isFailed, + localImage != nil || remoteURL != nil else { return nil } + self.messageID = message.id + self.blobID = media.blobID + self.localImage = localImage + self.remoteURL = remoteURL + self.placeholder = placeholder + self.sourceView = sourceView + } +} + +/// A full-screen photo, zoomed in from the tapped transcript row: pinch or double-tap to zoom, swipe +/// down or close to put it back, and share once the photo itself has loaded. +public final class ChatMediaViewerController: UIViewController, UIScrollViewDelegate { + + private static let maximumZoomScale: CGFloat = 4 + private static let doubleTapZoomScale: CGFloat = 2.5 + + private let request: ChatMediaViewerRequest + private let onShare: (UIImage) -> Void + + let scrollView = UIScrollView() + let imageView = UIImageView() + let shareButton = UIButton(configuration: .plain()) + private let closeButton = UIButton(configuration: .plain()) + + /// The photo itself, once drawn — never the placeholder, so share never hands out a BlurHash. + private var loadedImage: UIImage? { + didSet { shareButton.isEnabled = loadedImage != nil } + } + + /// - Parameter onShare: presents the system share sheet for the photo. + public init(request: ChatMediaViewerRequest, onShare: @escaping (UIImage) -> Void) { + self.request = request + self.onShare = onShare + super.init(nibName: nil, bundle: nil) + + let zoom = UIViewController.Transition.ZoomOptions() + // A zoomed-in photo pans instead; the swipe down only dismisses at the fitted size. + zoom.interactiveDismissShouldBegin = { [weak self] _ in + guard let self else { return true } + return scrollView.zoomScale <= scrollView.minimumZoomScale + } + preferredTransition = .zoom(options: zoom) { [sourceView = request.sourceView] _ in sourceView() } + modalPresentationStyle = .fullScreen + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + public override func viewDidLoad() { + super.viewDidLoad() + view.backgroundColor = .black + + scrollView.delegate = self + scrollView.minimumZoomScale = 1 + scrollView.maximumZoomScale = Self.maximumZoomScale + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(scrollView) + + imageView.contentMode = .scaleAspectFit + imageView.isAccessibilityElement = true + imageView.accessibilityLabel = "Photo" + imageView.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(imageView) + + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(doubleTapped(_:))) + doubleTap.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTap) + + configure(closeButton, symbol: "xmark", label: "Close", action: #selector(closeTapped)) + configure(shareButton, symbol: "square.and.arrow.up", label: "Share", action: #selector(shareTapped)) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + // Pinned to the frame, so at scale 1 the photo fits the screen and zooming grows it. + imageView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + imageView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + imageView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + imageView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + imageView.heightAnchor.constraint(equalTo: scrollView.frameLayoutGuide.heightAnchor), + + closeButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + closeButton.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 8), + shareButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + shareButton.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -8), + ]) + + loadImage() + } + + private func configure(_ button: UIButton, symbol: String, label: String, action: Selector) { + button.configuration?.image = UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: 17, weight: .semibold)) + button.configuration?.baseForegroundColor = .white + button.configuration?.contentInsets = NSDirectionalEdgeInsets(top: 12, leading: 12, bottom: 12, trailing: 12) + button.accessibilityLabel = label + button.addTarget(self, action: action, for: .touchUpInside) + button.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(button) + } + + /// Draws the staged image, or the placeholder under the download. The transcript caches only its + /// downsampled copy, so the full-size photo downloads here, over the copy the cell already drew. + private func loadImage() { + if let localImage = request.localImage { + imageView.image = localImage + loadedImage = localImage + return + } + imageView.image = request.placeholder + loadedImage = nil + guard let remoteURL = request.remoteURL else { return } + imageView.kf.setImage( + with: ChatMediaImageSource.resource(blobID: request.blobID, url: remoteURL), + placeholder: request.placeholder + ) { [weak self] result in + switch result { + case .success(let value): + self?.loadedImage = value.image + case .failure: + // Best-effort: the placeholder stays up and share stays off. + break + } + } + } + + // MARK: - Zoom + + /// The scale a double-tap lands on from `current`: in to the double-tap scale from the fitted + /// size, and back out to fitted from anywhere else. + static func doubleTapTargetScale(_ current: CGFloat, _ minimum: CGFloat) -> CGFloat { + current > minimum ? minimum : doubleTapZoomScale + } + + public func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView + } + + @objc private func doubleTapped(_ recognizer: UITapGestureRecognizer) { + let target = Self.doubleTapTargetScale(scrollView.zoomScale, scrollView.minimumZoomScale) + guard target > scrollView.minimumZoomScale else { + scrollView.setZoomScale(target, animated: true) + return + } + let point = recognizer.location(in: imageView) + let size = CGSize(width: scrollView.bounds.width / target, height: scrollView.bounds.height / target) + scrollView.zoom(to: CGRect(x: point.x - size.width / 2, y: point.y - size.height / 2, width: size.width, height: size.height), animated: true) + } + + // MARK: - Actions + + @objc private func closeTapped() { + dismiss(animated: true) + } + + @objc private func shareTapped() { + guard let loadedImage else { return } + onShare(loadedImage) + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift index 5906384d4..3ac34f097 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift @@ -63,10 +63,17 @@ public final class ChatMessageCell: ChatColumnCell { reactionRow.prepareForReuse() } - /// - Parameter maxWidth: the widest the bubble may grow before its text wraps, in points. - /// The owner derives it from the collection view's width. - public func configure(with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil) { - bubble.configure(with: message) + /// - Parameters: + /// - maxWidth: the widest the bubble may grow before its text wraps, in points. The owner + /// derives it from the collection view's width. + /// - quoteThumbnailURL: where a quoted photo's thumbnail loads from, or nil until it resolves. + public func configure( + with message: ChatMessage, + maxWidth: CGFloat, + authorImageData: Data? = nil, + quoteThumbnailURL: URL? = nil + ) { + bubble.configure(with: message, quoteThumbnailURL: quoteThumbnailURL) maxWidthConstraint.constant = maxWidth reactionRowWidthConstraint.constant = maxWidth reactionRow.layoutWidth = maxWidth diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index 74b193da5..87235c503 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -8,6 +8,7 @@ #if canImport(UIKit) import UIKit import FlipcashCore +import Kingfisher /// The quoted original drawn inside a reply's bubble, above the body: a leading rule, the author, /// and up to two lines of the original. Tapping it asks to jump to that message — but only when @@ -32,6 +33,17 @@ final class ChatQuotePanelView: UIView { /// Takes the slack so the flag, amount and token stay clustered at the leading edge instead of /// the amount stretching and pushing the token to the far side of the panel. private let detailSpacer = UIView() + /// Drawn only for a quoted photo the viewer may see: the photo itself at the trailing edge. + private(set) var thumbnailView = UIImageView() + /// The text's trailing edge with no thumbnail, and with one; exactly one is active. + private lazy var textTrailingToEdge = authorLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8) + private lazy var textTrailingToThumbnail = authorLabel.trailingAnchor.constraint(equalTo: thumbnailView.leadingAnchor, constant: -8) + private lazy var thumbnailConstraints = [ + thumbnailView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -6), + thumbnailView.centerYAnchor.constraint(equalTo: centerYAnchor), + thumbnailView.widthAnchor.constraint(equalToConstant: Self.thumbnailSide), + thumbnailView.heightAnchor.constraint(equalToConstant: Self.thumbnailSide), + ] /// The gap between the panel and the bubble's edges, the same on the top, leading and trailing /// sides — the bubble's own vertical margin. One gap rather than three, so ``cornerRadius`` @@ -52,6 +64,9 @@ final class ChatQuotePanelView: UIView { /// rather than as a second element the line has to make room for. private static let flagDiameter: CGFloat = 14 + /// The two text lines' height, so the thumbnail sits in the panel without growing it. + private static let thumbnailSide: CGFloat = 34 + /// The preview grey a quoted sentence is drawn in. private static let snippetColor = UIColor.white.withAlphaComponent(0.55) @@ -108,6 +123,16 @@ final class ChatQuotePanelView: UIView { detailRow.addArrangedSubview(detailSpacer) addSubview(detailRow) + thumbnailView.contentMode = .scaleAspectFill + thumbnailView.clipsToBounds = true + thumbnailView.layer.cornerRadius = 6 + thumbnailView.layer.cornerCurve = .continuous + // The ground a photo loads onto, so the slot reads as a photo before any bytes arrive. + thumbnailView.backgroundColor = UIColor.white.withAlphaComponent(0.08) + thumbnailView.isHidden = true + thumbnailView.translatesAutoresizingMaskIntoConstraints = false + addSubview(thumbnailView) + // The rule, the two gutters around the text and the detail row's spacing add up to a width // the panel demands even when it holds nothing, and a bubble with no quote would pay for it: // the host pins the panel to both of the bubble's sides, so the panel's floor becomes the @@ -116,13 +141,13 @@ final class ChatQuotePanelView: UIView { let horizontal = [ rule.widthAnchor.constraint(equalToConstant: Self.ruleWidth), authorLabel.leadingAnchor.constraint(equalTo: rule.trailingAnchor, constant: 8), - authorLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), detailRow.leadingAnchor.constraint(equalTo: authorLabel.leadingAnchor), detailRow.trailingAnchor.constraint(equalTo: authorLabel.trailingAnchor), ] - for constraint in horizontal { + for constraint in horizontal + [textTrailingToEdge, textTrailingToThumbnail] + thumbnailConstraints { constraint.priority = .required - 1 } + textTrailingToEdge.isActive = true NSLayoutConstraint.activate(horizontal + [ // Flush against the cell's leading edge and the full height of it, so the cell reads as @@ -146,7 +171,8 @@ final class ChatQuotePanelView: UIView { accessibilityIdentifier = "chat-quote-panel" } - func configure(with quote: ChatQuote) { + /// - Parameter thumbnailURL: where a quoted photo's thumbnail loads from, or nil until it resolves. + func configure(with quote: ChatQuote, thumbnailURL: URL? = nil) { targetStableID = quote.stableID // The author's own colour, derived from their user id — the same colour the composer's strip // draws them in, and the same one Android does. An original with no known author falls back @@ -170,10 +196,17 @@ final class ChatQuotePanelView: UIView { // A payment's amount is the whole of what was said, so it is read rather than glanced // at — a step brighter than the preview grey a quoted sentence gets. snippetLabel.textColor = UIColor.white.withAlphaComponent(0.75) - case .text, .media, .unavailable: + showThumbnail(blobID: nil, url: nil) + case .media(let thumbnailBlobID): + flagView.isHidden = true + tokenLabel.isHidden = true + snippetLabel.textColor = Self.snippetColor + showThumbnail(blobID: thumbnailBlobID, url: thumbnailURL) + case .text, .unavailable: flagView.isHidden = true tokenLabel.isHidden = true snippetLabel.textColor = Self.snippetColor + showThumbnail(blobID: nil, url: nil) } let spoken = switch quote.kind { case .cash(let token, _): "\(quote.snippet) \(token)" @@ -197,10 +230,40 @@ final class ChatQuotePanelView: UIView { flagView.image = nil flagView.isHidden = true tokenLabel.isHidden = true + showThumbnail(blobID: nil, url: nil) isUserInteractionEnabled = false accessibilityLabel = nil } + /// Shows the thumbnail slot for a photo with a blob, loading it once `url` resolves; hides and + /// empties it otherwise. A redacted photo has no blob, so it never reaches the network from here. + private func showThumbnail(blobID: BlobID?, url: URL?) { + guard let blobID else { + thumbnailView.kf.cancelDownloadTask() + thumbnailView.image = nil + thumbnailView.isHidden = true + NSLayoutConstraint.deactivate(thumbnailConstraints + [textTrailingToThumbnail]) + textTrailingToEdge.isActive = true + return + } + thumbnailView.isHidden = false + textTrailingToEdge.isActive = false + NSLayoutConstraint.activate(thumbnailConstraints + [textTrailingToThumbnail]) + guard let url else { + thumbnailView.kf.cancelDownloadTask() + thumbnailView.image = nil + return + } + let side = Self.thumbnailSide + thumbnailView.kf.setImage( + with: ChatMediaImageSource.resource(blobID: blobID, url: url), + options: [ + .processor(DownsamplingImageProcessor(size: CGSize(width: side, height: side))), + .scaleFactor(traitCollection.displayScale), + ] + ) + } + @objc private func handleTap() { guard let targetStableID else { return } onTap?(targetStableID) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index a7565d986..16c4ec1ec 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -185,6 +185,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.onLinkCardTap = newValue } } + /// Fired when a photo in the transcript is tapped — see ``ChatViewController/onMediaTap``. + public var onMediaTap: ((ChatMediaViewerRequest) -> Void)? { + get { transcript.onMediaTap } + set { transcript.onMediaTap = newValue } + } + /// Where a link card in the transcript looks its link up — see /// ``ChatViewController/linkCardSource``. public weak var linkCardSource: (any LinkCardSource)? { @@ -192,6 +198,18 @@ public final class ChatScreenViewController: UIViewController { set { transcript.linkCardSource = newValue } } + /// Where a photo row gets its download URL — see ``ChatViewController/mediaURLResolver``. + public var mediaURLResolver: ChatMediaURLResolver? { + get { transcript.mediaURLResolver } + set { transcript.mediaURLResolver = newValue } + } + + /// A pending photo's local image — see ``ChatViewController/pendingMediaImage``. + public var pendingMediaImage: ((String) -> UIImage?)? { + get { transcript.pendingMediaImage } + set { transcript.pendingMediaImage = newValue } + } + public var onContactAction: (() -> Void)? { get { transcript.onContactAction } set { transcript.onContactAction = newValue } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 7cfe38c5c..010afb4e3 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -118,6 +118,16 @@ public final class ChatViewController: UICollectionViewController { /// Fired when a message row's trailing "+" reaction pill is tapped, to open the picker for that /// row's stable id. public var onReactionAdd: ((String) -> Void)? + /// Fired when a photo row's image is tapped, to open the full-screen viewer. Never fires for a + /// BlurHash-only or failed row, or one with nothing to show yet. + public var onMediaTap: ((ChatMediaViewerRequest) -> Void)? + + /// Where a photo row gets its download URL. Nil draws every photo from its BlurHash. + public var mediaURLResolver: ChatMediaURLResolver? + + /// The local image of a photo this device is still sending, by the pending row's id; the row draws + /// it until the send confirms and the download takes over. + public var pendingMediaImage: ((String) -> UIImage?)? /// The widest a bubble may grow, as a share of the collection view's width. private static let maxBubbleWidthFraction: CGFloat = 0.78 @@ -325,6 +335,7 @@ public final class ChatViewController: UICollectionViewController { collectionView.register(ChatLinkMessageCell.self, forCellWithReuseIdentifier: ChatLinkMessageCell.reuseIdentifier) collectionView.register(ChatCashCardCell.self, forCellWithReuseIdentifier: ChatCashCardCell.reuseIdentifier) collectionView.register(ChatShareProfileCell.self, forCellWithReuseIdentifier: ChatShareProfileCell.reuseIdentifier) + collectionView.register(ChatMediaCell.self, forCellWithReuseIdentifier: ChatMediaCell.reuseIdentifier) collectionView.register(ChatDateSeparatorCell.self, forCellWithReuseIdentifier: ChatDateSeparatorCell.reuseIdentifier) collectionView.register(ChatUnreadDividerCell.self, forCellWithReuseIdentifier: ChatUnreadDividerCell.reuseIdentifier) collectionView.register(ChatEncryptionMarkerCell.self, forCellWithReuseIdentifier: ChatEncryptionMarkerCell.reuseIdentifier) @@ -637,12 +648,17 @@ public final class ChatViewController: UICollectionViewController { let authorImageData = message.author.flatMap { authorAvatars[$0.id] } (cell as? ChatColumnCell)?.onAuthorTap = { [weak self] userID in self?.onAuthorTap?(userID) } switch cell { - // Only text messages are sent optimistically, so only they can reach the failed state - // that arms retry (wired on both text cells). Cash messages are always server-confirmed. + // Text and photo messages are sent optimistically, so only they can reach the failed state + // that arms retry. Cash messages are always server-confirmed. case let cell as ChatLinkMessageCell: // Before `configure`, which is where the card subscribes. cell.linkCardSource = linkCardSource - cell.configure(with: message, maxWidth: maxWidth, authorImageData: authorImageData) + cell.configure( + with: message, + maxWidth: maxWidth, + authorImageData: authorImageData, + quoteThumbnailURL: quoteThumbnailURL(for: message) + ) cell.onRetry = { [weak self] id in self?.onRetry?(id) } cell.onOpenURL = { [weak self] url in self?.onOpenURL?(url) } cell.onMentionTap = { [weak self] username in self?.onMentionTap?(username) } @@ -652,7 +668,12 @@ public final class ChatViewController: UICollectionViewController { cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } case let cell as ChatMessageCell: - cell.configure(with: message, maxWidth: maxWidth, authorImageData: authorImageData) + cell.configure( + with: message, + maxWidth: maxWidth, + authorImageData: authorImageData, + quoteThumbnailURL: quoteThumbnailURL(for: message) + ) cell.onRetry = { [weak self] id in self?.onRetry?(id) } cell.onQuoteTap = { [weak self] id in self?.onQuoteTap?(id) } cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } @@ -673,11 +694,67 @@ public final class ChatViewController: UICollectionViewController { cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } + case let cell as ChatMediaCell: + guard case .media(let media) = message.content else { return } + let remoteURL = mediaURLResolver?.url(for: media, canReact: message.canReact) { [weak self] _ in + self?.reconfigureVisibleMessage(id: message.id) + } + let localImage = pendingMediaImage?(message.id) + cell.configure( + with: message, + maxWidth: maxWidth, + localImage: localImage, + remoteURL: remoteURL, + authorImageData: authorImageData + ) + cell.onImageTap = { [weak self, weak cell] in + guard let self, let request = ChatMediaViewerRequest( + message: message, + localImage: localImage, + remoteURL: remoteURL, + placeholder: cell?.imageView.image, + sourceView: { [weak self] in self?.mediaSourceView(forMessageID: message.id) } + ) else { return } + onMediaTap?(request) + } + cell.onRetry = { [weak self] id in self?.onRetry?(id) } + cell.onReactionTap = { [weak self] emoji in self?.onReactionTap?(message.messageID, emoji) } + cell.onReactionLongPress = { [weak self] emoji in self?.onReactionLongPress?(message.messageID, emoji) } + cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } default: assertionFailure("Unhandled chat cell class for message row") } } + /// Where a reply's quoted photo draws its thumbnail from, or nil when it has none or is not yet + /// resolved; a resolution redraws the reply. + private func quoteThumbnailURL(for message: ChatMessage) -> URL? { + guard let quote = message.quote else { return nil } + return mediaURLResolver?.thumbnailURL(for: quote.kind, canReact: message.canReact) { [weak self] _ in + self?.reconfigureVisibleMessage(id: message.id) + } + } + + /// Re-runs `configure` on the on-screen row for `id`, if there is one. A row off screen picks the + /// change up on its next dequeue. + private func reconfigureVisibleMessage(id: String) { + for cell in collectionView.visibleCells { + guard let indexPath = collectionView.indexPath(for: cell), + items.indices.contains(indexPath.item), + case .message(let message) = items[indexPath.item], + message.id == id else { continue } + configure(cell, with: message) + } + } + + /// The on-screen photo of the row for `id`, or nil when that row is scrolled away. + private func mediaSourceView(forMessageID id: String) -> UIView? { + guard let index = items.firstIndex(where: { item in + if case .message(let message) = item { message.id == id } else { false } + }) else { return nil } + return (collectionView.cellForItem(at: IndexPath(item: index, section: 0)) as? ChatMediaCell)?.imageView + } + // MARK: - Selection /// Only cash cards are tappable — they open the token's currency info. Text bubbles and date @@ -768,7 +845,7 @@ public final class ChatViewController: UICollectionViewController { let cell = collectionView.cellForItem(at: indexPath) as? BubbleCarrying else { return nil } switch message.content { case .text: break - case .cash, .deleted, .unavailable, .shareProfile: return nil + case .cash, .deleted, .unavailable, .shareProfile, .media: return nil } let bubble = cell.liftPreviewView guard bubble.bounds.contains(bubble.convert(point, from: collectionView)) else { return nil } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index b7beb45e0..4d21ed0ea 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -248,7 +248,8 @@ public final class LinkableBubbleView: UIView { cardView.prepareForReuse() } - public func configure(with message: ChatMessage) { + /// Fills the bubble; `quoteThumbnailURL` is where a quoted photo's thumbnail loads from. + public func configure(with message: ChatMessage, quoteThumbnailURL: URL? = nil) { // Shares the plain bubble's text builder so a link message gets the same body styling, the // same tombstone copy, and the same "Edited" reservation, with the link spans laid over it // from the preview the mapper already detected. @@ -295,7 +296,7 @@ public final class LinkableBubbleView: UIView { // and UIKit resolves that by breaking one at random. if let quote = message.quote { quotePanel.isHidden = false - quotePanel.configure(with: quote) + quotePanel.configure(with: quote, thumbnailURL: quoteThumbnailURL) NSLayoutConstraint.deactivate(quoteCollapse + [textTopToBubble, cardTopToBubble]) quoteTrailing.isActive = true textTopToQuote.isActive = !bare diff --git a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift index 869e25e0d..c12eadd01 100644 --- a/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift +++ b/FlipcashUITests/Smoke/BlockUnblockSmokeTests.swift @@ -61,11 +61,11 @@ final class BlockUnblockSmokeTests: BaseUITestCase { row.tap() // MARK: Open the counterpart's profile from the conversation. - // The tip DM shows Send Cash — wait for it so the transcript has loaded - // before reaching for the title. + // The tip DM already exists, so its bar leads with the attach menu — wait + // for it so the transcript has loaded before reaching for the title. XCTAssertTrue( - app.buttons["send-cash-button"].waitForExistence(timeout: 30), - "Expected the tip DM conversation to open with Send Cash" + app.buttons["attach-menu-button"].waitForExistence(timeout: 30), + "Expected the tip DM conversation to open with the attach menu" ) // The nav-title item is a button (label = the name) only when blocking is // enabled and the tip counterpart resolves — both hold here. diff --git a/FlipcashUITests/Support/Screens/ConversationUIScreen.swift b/FlipcashUITests/Support/Screens/ConversationUIScreen.swift index 0147318e1..fc7eeaea0 100644 --- a/FlipcashUITests/Support/Screens/ConversationUIScreen.swift +++ b/FlipcashUITests/Support/Screens/ConversationUIScreen.swift @@ -18,7 +18,10 @@ struct ConversationUIScreen { // MARK: - Elements + /// The full-width Send Cash call to action, shown only before the chat exists. var sendCashButton: XCUIElement { app.buttons["send-cash-button"] } + /// The composer's `+`, which holds Cash once the chat exists. + var attachMenuButton: XCUIElement { app.buttons["attach-menu-button"] } /// The composer's text field, found by identifier rather than by placeholder — the placeholder /// disappears as soon as there is a draft, and a multiline `TextField(axis:)` reports a text-view /// base type, so neither `textFields["Message"]` nor a type-scoped query survives typing. @@ -62,9 +65,15 @@ struct ConversationUIScreen { // MARK: - Actions - /// Opens the Send amount sheet on top of the conversation. + /// Opens the Send amount sheet on top of the conversation: from the call to action before the + /// chat exists, and from the attach menu's Cash row after. func tapSendCash(from testCase: BaseUITestCase) { - testCase.waitAndTap(sendCashButton) + if attachMenuButton.exists { + testCase.waitAndTap(attachMenuButton) + testCase.waitAndTap(app.buttons["Cash"].firstMatch) + } else { + testCase.waitAndTap(sendCashButton) + } } /// Focuses the always-visible composer, types `text`, and sends it. diff --git a/NotificationContent/NotificationTranscriptView.swift b/NotificationContent/NotificationTranscriptView.swift index 956e204c4..7cd916360 100644 --- a/NotificationContent/NotificationTranscriptView.swift +++ b/NotificationContent/NotificationTranscriptView.swift @@ -87,6 +87,9 @@ private struct NotificationMessageRow: View { case .shareProfile(let profile): // The extension has no profile lookup; the handle is what the widget names. NotificationTextBubble(text: profile.linkedHandle ?? "Shared a profile", isFromSelf: message.sender == .me) + case .media(let media): + // The extension draws no photos; a text line stands in, like the chat list's preview. + NotificationTextBubble(text: ChatMediaStrings.listPreview(caption: media.caption), isFromSelf: message.sender == .me) } if message.sender == .other { Spacer(minLength: 44) } } From 387e054f0e69bc139b7f53484b362be1dddc7ee4 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 18:06:16 -0400 Subject: [PATCH 4/8] feat(chat): open the attach menu and photo cards over the keyboard The attach menu grows out of +, and the menu, camera card and photo card are one surface that morphs between states. The surface draws over the live keyboard by joining the keyboard's window, falling back to an input view swap and then to dismissing the keyboard. The camera and picker start while the menu is up, so a card has content when it opens. A tap anywhere outside the open menu only closes it. Staged photos sit inside the message field and fade at its edges. The keyboard overlay decisions are recorded in .claude/plans/2026-10-02-attach-photo-card.md. --- .claude/plans/2026-10-02-attach-photo-card.md | 144 +++++++ .../Screens/Conversation/AttachCard.swift | 159 ++++++++ .../Conversation/AttachKeyboardOverlay.swift | 364 ++++++++++++++++++ .../Screens/Conversation/AttachMenu.swift | 196 +++++++--- .../Conversation/AttachOverKeyboard.swift | 129 +++++++ .../Screens/Conversation/AttachPanel.swift | 117 ++++++ .../Screens/Conversation/AttachSurface.swift | 298 ++++++++++++++ .../Screens/Conversation/AttachWarmUp.swift | 121 ++++++ .../Conversation/ChatCameraSheet.swift | 216 ++++++++--- .../Screens/Conversation/ChatCameraSlot.swift | 72 ---- .../Conversation/ChatPhotoCamera.swift | 164 ++++++-- .../Conversation/ChatPhotoPreloader.swift | 67 ++++ .../Conversation/ChatPhotoStaging.swift | 53 ++- .../Screens/Conversation/ChatPhotosCard.swift | 184 +++++++++ .../ChatScreenRepresentable.swift | 122 +++++- .../Screens/Conversation/ComposerChip.swift | 6 +- .../Conversation/ComposerChipStrip.swift | 61 ++- .../Screens/Conversation/ComposerModel.swift | 9 +- .../Conversation/ConversationBottomBar.swift | 317 +++++++++++++-- .../Conversation/ConversationScreen.swift | 35 +- .../Conversation/KeyboardOverlayHost.swift | 49 +++ .../Chat/AttachOverlayModeTests.swift | 179 +++++++++ .../Chat/AttachPanelPlacementTests.swift | 233 +++++++++++ FlipcashTests/Chat/AttachPanelTests.swift | 282 ++++++++++++++ FlipcashTests/Chat/AttachWarmUpTests.swift | 81 ++++ FlipcashTests/Chat/ChatCameraSheetTests.swift | 31 ++ .../Chat/ChatPhotoStagingTests.swift | 284 ++++++++++++-- FlipcashTests/Chat/ChatPhotosCardTests.swift | 29 ++ .../Chat/ComposerChipLayoutTests.swift | 79 ++++ .../ConversationControllerTests.swift | 37 ++ .../Sources/FlipcashUI/Chat/ChatMotion.swift | 25 ++ .../Chat/ChatScreenViewController.swift | 110 +++++- .../Views/Containers/GlassBackground.swift | 12 + 33 files changed, 3954 insertions(+), 311 deletions(-) create mode 100644 .claude/plans/2026-10-02-attach-photo-card.md create mode 100644 Flipcash/Core/Screens/Conversation/AttachCard.swift create mode 100644 Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift create mode 100644 Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift create mode 100644 Flipcash/Core/Screens/Conversation/AttachPanel.swift create mode 100644 Flipcash/Core/Screens/Conversation/AttachSurface.swift create mode 100644 Flipcash/Core/Screens/Conversation/AttachWarmUp.swift delete mode 100644 Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatPhotoPreloader.swift create mode 100644 Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift create mode 100644 Flipcash/Core/Screens/Conversation/KeyboardOverlayHost.swift create mode 100644 FlipcashTests/Chat/AttachOverlayModeTests.swift create mode 100644 FlipcashTests/Chat/AttachPanelPlacementTests.swift create mode 100644 FlipcashTests/Chat/AttachPanelTests.swift create mode 100644 FlipcashTests/Chat/AttachWarmUpTests.swift create mode 100644 FlipcashTests/Chat/ChatPhotosCardTests.swift create mode 100644 FlipcashTests/Chat/ComposerChipLayoutTests.swift diff --git a/.claude/plans/2026-10-02-attach-photo-card.md b/.claude/plans/2026-10-02-attach-photo-card.md new file mode 100644 index 000000000..6be0b5e3b --- /dev/null +++ b/.claude/plans/2026-10-02-attach-photo-card.md @@ -0,0 +1,144 @@ +# Attach flow: inline photo card and Add → chip shrink + +Target: a screen recording of ChatGPT's iOS attach flow (2026-10-02). A second pass the same day +(`6413eefd`, `1da4c9a6`, `ea099222`) matched the card geometry, panel anchoring, back-to-menu, +camera controls, and All Photos to the recording. + +## Decisions + +- **With the keyboard up, the panel and cards draw over the keys; the composer keeps focus.** An + earlier note here said the keyboard is out of process and nothing can draw over it. That was wrong: + the spike's public window was clamped to 10,000,000 (the cap on app window levels), one below the + keyboard's. The keyboard is in-process, in a `UIRemoteKeyboardWindow` at level 10,000,001 inside a + private keyboard scene, left out of `UIApplication.windows` and every scene's `windows`. A second + spike (iOS 27.2 device, iOS 26.6 device, iOS 27.0 simulator) reached it through the private + `+[UIWindow allWindowsIncludingInternalWindows:onlyVisibleWindows:]`: a subview there draws over + the keys, a `UIGlassEffect` blurs them, and the field stays first responder. A full-screen + container there whose `hitTest` returns nil outside one rect passed a real-touch probe (iOS 27.0 + simulator, 2026-10-02): the rect takes taps above and over the keys, the app window takes taps + outside it with focus kept, and uncovered keys still type. `keyboardWindow.convert(_:from: + appWindow)` returns an infinite rect, since the windows are in different scenes; convert through + the screen's coordinate space. + - **Mechanism.** `KeyboardOverlayHost` is the only code touching private API: it resolves the + selector with `NSSelectorFromString` after `responds(to:)`, matches the window by a class-name + fragment, and returns nil on any miss. `AttachKeyboardOverlay` hosts the same `AttachSurface` + the bar uses in a full-screen `AttachOverlayContainer` in that window, in the app window's + coordinates (both windows sit at the screen origin), taking touches only on the panel and card + frames. The menu is centred on the composer's bottom edge so its lower half sits over the keys + (sheet_02, `.straddlesPlus`), growing out of `+`; cards stand on the screen's bottom. + - **Rebuilds and teardown.** UIKit rebuilds the keyboard tree on focus or keyboard-type changes. + The container re-finds its window on `didMoveToWindow(nil)` and on keyboard did-show / + did-change-frame. It comes down when nothing is left in it, and at once on keyboard will-hide, + focus loss, or backgrounding. The app is portrait-only, so rotation needs nothing. + - **Cross-window hand-off.** The landing chip lives in the app window, so no shared namespace. On + Add/capture the chip is staged hidden, reports its window frame, and the card shrinks onto a + clear stand-in at that frame in the overlay's namespace; then the chip fades in. All Photos + can't present from the keyboard's window, so it hands the card to the keyboard-down flow, which + opens the full picker. + - **Risk.** Private API: Apple can rename the class or drop the selector in any release, and App + Review may flag the selector string (it's assembled from parts, never one literal). Every miss + falls back, never traps. + - **Fallback.** `AttachOverlayMode.select` picks at runtime and logs the mode: (a) keyboard window; + (b) a `UIInputView(.keyboard)` of the keyboard's height swapped in as the field's `inputView`, + with the overlay laid in that input view's window — the swap is not animated by the system and a + live inputView ignores height changes, so it matches the height exactly; (c) take the keyboard + down first, as before. A keyboard shorter than 150pt (hardware keyboard's shortcut bar) goes to + (c). `AttachCard.closesOnKeyboardShow` is off while over the keyboard, since the (b) swap posts + keyboard-show. `begin()` turns it off before the swap, so the gate holds by construction rather + than because no card happens to be open yet. +- **Cards are a fixed-proportion overlay, not a keyboard-height slot.** `AttachCard` (was + `ChatKeyboardSlot`) sizes the card to 58% of the screen height when it opens. It is an `.overlay` + on the composer row, bottom-aligned to the row's bottom edge, out to the keyboard-up margins + (`-compactExtraInset`). The bar's measured height stays the composer's, so the transcript doesn't + move. The overlay is attached after the row's `opacity`/`allowsHitTesting`, or the card would + inherit them. +- **The card rides the bar-overflow path with a touch band.** `ConversationBarModel.overflow` is + `.none`, `.everywhere` (panel: every touch above the bar goes to the bar, so an outside tap + dismisses), or `.band(top:)` (card: touches from the card's window-space top edge down). + `BarOverflowReporting` hands it to `ChatScreenViewController.barOverflowTouchTop` / + `barOverflowsTop`; `BarClipView.point(inside:)` limits the overflow region to the band. The card's + top is measured with `onGeometryChange(.global)`, not computed from bar padding. + `AttachPanelPlacementTests` uses the same modifier. +- **One surface, not a panel and a card.** (2026-10-03, after a device recording showed a + card-sized glass rectangle with the menu rows fading over it on Back: the panel and the card were + two views joined by `matchedGeometryEffect`, and both were on screen mid-morph.) `AttachSurface` + draws one glass shape. `AttachSurfaceFrame` animates its rect and corner radius as one + `Animatable` value, so the clip and the glass can't drift apart. The phase + (`ConversationBarModel.attachSurfacePhase`: collapsed / menu / card / landing) picks the shape + through `AttachSurfaceLayout.shape(for:…)`; the host passes `+`'s, the card's, and the landing + chip's rects in its own coordinates. The bar and the keyboard overlay draw the same view, with + `.standsOnPlus` / `.straddlesPlus` as the only difference in where the menu sits. Content layers + sit at their resting frames inside the moving clip and cross-fade: the leaving side on + `attachContentOut` (fast, early), the arriving side on `attachContentIn` (late). +- **Menu stands on the composer's bottom edge, keyboard down.** It is 240pt wide from `+`'s left + edge, so it grows up and right over the field and leaves the send button clear. The card stands on + the same edge. +- **Back is the forward morph reversed.** `returnToMenu()` closes the card and opens the panel in + one `withAnimation`; the phase goes card → menu, the shape springs back, and the photo selection + resets. Only Add and the shutter call `focusComposer()`. +- **Add and the shutter land the surface on the chip.** `closeCard` stages the chip and calls + `AttachCard.beginLanding(chipID:)`; the strip reports the chip's window frame through + `landingChipDidLayout`, and the surface springs onto it in the `.landing` phase, staying drawn the + whole way. `endLanding()` runs without animation, so the chip replaces the surface in one frame + (with a fade, the chip showed ~350ms after the card went). `matchedGeometryEffect` is gone from + the strip; the chip only carries `landingChipID`. +- **Camera row: back, shutter, "…".** "…" (with a blue dot) unfolds flash and flip above itself and + turns into ×. The flash is off/on, resolved per shot against `output.supportedFlashModes`. + Setting a mode the output lacks raises an exception, and a flip changes what it supports. + ChatGPT's "Scan" is out of scope: there is no document-scan feature. +- **All Photos opens the system sheet.** With `.photosPickerAccessoryVisibility(.visible, edges: + .top)` the inline picker shows a prompt, a Photos/Collections switch, and the privacy banner, about + 40% of the card. Nothing outside the picker can switch it to Collections. So the accessory stays + hidden, and All Photos (shown while nothing is selected, cross-fading with Add) presents + `.photosPicker(isPresented:)` bound to the same selection. +- **Photos opens an inline card, not the system sheet.** `PhotosPicker` with + `.photosPickerStyle(.inline)` (iOS 17+, target is 18.0) is out of process and needs no photo + library permission, so the spec's reason for rejecting an inline grid (full library access) + doesn't apply on iOS. The "Private Access to Photos" notice in the recording is the picker's own. + This is an iOS-only departure from the cross-platform spec; Android keeps the system Photo Picker. + +## What the recording shows + +1. Tapping Photos: the panel grows into a rounded card while its rows blur out and the picker fades + in. The card spans from just under the transcript to the bottom of the screen, covering both the + composer row and the keyboard. The transcript stays visible above it. +2. In the card: the picker grid, a back chevron bottom-leading, and — once something is selected — + a blue "Add N photo" pill bottom-trailing. System chrome is hidden. +3. Tapping Add: the card collapses, the keyboard rises, and the photo shrinks into a chip at the top + of the composer, which grows to hold it. + +## Constraints + +- The picker is a remote view: no `matchedGeometryEffect` on it, and no way to find the selected + cell's frame. Morph a card container; the shrink starts from the card's bounds. +- Inline selection updates live, so staging moves from "selection changed" to the Add tap. +- The shrink draws the real image, so load thumbnails as the selection changes, not at Add. +- One card mechanism for camera and photos: `AttachCard`. +- With N > 1 photos the recording shows nothing; the first chip takes the shrink and the rest use the + existing chip insert transition. +- Reduce Motion: with one surface there are not two views to cross-fade, so the shape's change + runs without animation (`AttachMotion.animatesGeometry` is false) and only the content and the + surface's opacity fade. + +## Content mounted with the surface + +- **Photos:** the surface's one inline `PhotosPicker` mounts, hidden and at the card's size, from the + surface's first frame, so the morph never relays it out and warming is the real instance. + `PhotosPickerWarmer` (a second hidden picker) and the 0.5s `Task.sleep` before mounting are gone. + Selection, preloader, and the All Photos flag live in `AttachPhotosPick` on the model, reset on + Back and when the surface unmounts without a choice. Add hands the preloader to staging and swaps + in a fresh one, so the in-flight loads aren't cancelled. +- **Camera:** the preview mounts with the surface only when access is already `.authorized`; + otherwise it mounts when its card opens, which asks. `AttachWarmUp` starts the session while the + open panel offers Camera or the camera card shows, and stops it when the panel closes. + `ChatPhotoCamera.isRunning` is observable. +- **Readiness:** `AttachPanel.select(_:warmUp:…)` opens a card through `AttachWarmUp.whenReady`, which + waits at most 150ms. The camera is ready once running. The remote picker has no ready signal, so + Photos counts as ready once the picker has been mounted for `pickerLead` (300ms). +- **Measured (iOS 27 simulator, 2026-10-03, `simctl recordVideo`, 60fps frame scan for a coloured + morph marker and the grid's saturated pixels):** the morph runs 0.317s. With the extension already + started in the process, the grid is in the card ~0.1s before the morph ends (three opens: two with + the keyboard up, one down). The first open in a fresh process shows the grid 0.63s after the + morph ends, ~2.2s after the hidden mount: the out-of-process extension's cold start, which no + readiness wait inside 150ms can cover. The simulator has no camera ("Camera unavailable" is in the + card at morph end), so camera first-frame time is unmeasured. diff --git a/Flipcash/Core/Screens/Conversation/AttachCard.swift b/Flipcash/Core/Screens/Conversation/AttachCard.swift new file mode 100644 index 000000000..3ccfec088 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachCard.swift @@ -0,0 +1,159 @@ +// +// AttachCard.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import Observation +import FlipcashUI + +/// The camera or photo card the attach menu expands into: a fixed share of the screen, standing on +/// the composer's bottom edge over the composer row and the transcript. +/// +/// The card floats over the transcript, outside the bar's own frame, so the screen has to let the +/// bar draw above itself for as long as the card is on screen — which outlasts ``isOpen`` by the +/// length of the exit. ``holdsOverflow`` is that longer span. +@MainActor +@Observable +final class AttachCard { + + /// What the attach menu can expand into. + nonisolated enum Content: Equatable { + case camera + case photos + } + + /// The share of the screen's height the card takes, measured off the reference recording. + nonisolated static let screenHeightFraction: CGFloat = 0.58 + + /// The card's height before a screen has been measured. + nonisolated static let fallbackHeight: CGFloat = 480 + + /// Returns the card's height on a screen `screenHeight` points tall. + nonisolated static func height(forScreenHeight screenHeight: CGFloat) -> CGFloat { + guard screenHeight > 0 else { return fallbackHeight } + return (screenHeight * screenHeightFraction).rounded() + } + + /// What is up, if anything. + private(set) var content: Content? + + /// Whether a card is up. + var isOpen: Bool { content != nil } + + /// The card's height, fixed when it opens. + private(set) var height: CGFloat = fallbackHeight + + /// Whether the bar needs room above itself: from opening until the exit has finished. + private(set) var holdsOverflow = false + + /// The chip the last capture or added photo was staged as, held hidden in the strip until the + /// surface has shrunk onto it. + private(set) var landingChipID: ComposerChip.ID? + + /// That chip's frame in window coordinates, once the bar has laid it out. + private(set) var landingChipFrame: CGRect? + + /// Whether the keyboard coming up takes the card down. Off while the card is drawn over the + /// keyboard, where the keyboard never left, and an input view swap announces it again. + @ObservationIgnored var closesOnKeyboardShow = true + + @ObservationIgnored private var observer: (any NSObjectProtocol)? + + init() { + observer = NotificationCenter.default.addObserver( + forName: UIResponder.keyboardWillShowNotification, + object: nil, + queue: nil + ) { [weak self] notification in + let isLocal = notification.userInfo?[UIResponder.keyboardIsLocalUserInfoKey] as? Bool ?? true + MainActor.assumeIsolated { + guard isLocal else { return } + self?.keyboardWillShow() + } + } + } + + isolated deinit { + if let observer { + NotificationCenter.default.removeObserver(observer) + } + } + + /// Sizes the card for a screen `screenHeight` points tall ahead of opening it, so its content can + /// be laid out at its final size while the panel is still up. + func measure(screenHeight: CGFloat) { + guard content == nil else { return } + height = Self.height(forScreenHeight: screenHeight) + } + + /// Puts `content` up on a screen `screenHeight` points tall, unless a card is already up. + func open(_ content: Content, screenHeight: CGFloat) { + guard self.content == nil else { return } + height = Self.height(forScreenHeight: screenHeight) + self.content = content + holdsOverflow = true + } + + /// Takes the card down, leaving the room above the bar until ``exitDidFinish()``. + func close() { + guard content != nil else { return } + content = nil + } + + /// Marks `chipID` as the chip the card will shrink onto, keeping the card up until the bar has + /// laid that chip out. + func beginLanding(on chipID: ComposerChip.ID) { + landingChipID = chipID + landingChipFrame = nil + } + + /// Records where the landing chip was laid out. Returns whether the card is still up and can now + /// shrink onto it. + @discardableResult + func landingChipDidLayout(_ frame: CGRect) -> Bool { + guard landingChipID != nil, frame.width > 0 else { return false } + landingChipFrame = frame + return content != nil + } + + /// Ends the landing once the surface has shrunk onto the chip: without animation, so the chip + /// takes the surface's place in one frame. + func endLanding() { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + landingChipID = nil + landingChipFrame = nil + } + } + + /// Releases the room above the bar once the exit's animation has run, unless a card came back up + /// in the meantime. + func exitDidFinish() { + guard content == nil else { return } + holdsOverflow = false + } + + /// Runs `change` on the card's curve, and calls ``exitDidFinish()`` once the motion it started has + /// logically finished. `then` runs after that, for whatever else the motion moved. + func animate(_ change: () -> Void, then: @escaping () -> Void = {}) { + withAnimation(ChatMotion.attachCard.animation, completionCriteria: .logicallyComplete) { + change() + } completion: { [weak self] in + self?.exitDidFinish() + then() + } + } + + /// The keyboard is taking the screen back from a card still up: it goes at once, with nothing to + /// animate out. A card landing on its chip — Add raises the keyboard as it starts — or already + /// leaving finishes on its own. + func keyboardWillShow() { + guard closesOnKeyboardShow, landingChipID == nil, content != nil else { return } + close() + exitDidFinish() + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift b/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift new file mode 100644 index 000000000..f36afa06a --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachKeyboardOverlay.swift @@ -0,0 +1,364 @@ +// +// AttachKeyboardOverlay.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import UIKit +import PhotosUI +import FlipcashCore +import FlipcashUI + +private let logger = Logger(label: "flipcash.attach-overlay") + +/// What the panel and cards drawn over the keyboard hand back to the screen. +struct AttachOverlayActions { + var onCash: () -> Void + var onCamera: () -> Void + var onPhotos: () -> Void + var onCameraCapture: (ChatCameraCapture) -> Void + var onCameraCancel: () -> Void + var onPhotosAdd: ([PhotosPickerItem], ChatPhotoPreloader) -> Void + var onPhotosBack: () -> Void + var onAllPhotos: (() -> Void)? = nil +} + +/// Draws the attach panel and its cards over the keyboard while the composer keeps focus, as +/// ChatGPT does: in the keyboard's own window when ``KeyboardOverlayHost`` finds it, else over a +/// keyboard-coloured input view swapped in under the field. +/// +/// The SwiftUI is hosted in a full-screen container laid over the app's window, so it shares the +/// bar's window coordinates. The container takes touches only on the panel and the card; the keys +/// they leave uncovered and the app beside them keep theirs. +@MainActor +final class AttachKeyboardOverlay { + + private let model: ConversationBarModel + private let locator: any KeyboardWindowLocating + + /// How the overlay is drawn, while it is. + private(set) var mode: AttachOverlayMode? + + private weak var screen: ChatScreenViewController? + private var container: AttachOverlayContainer? + private var host: UIHostingController? + private var backdrop: AttachInputBackdrop? + private var observers: [any NSObjectProtocol] = [] + + init(model: ConversationBarModel, locator: any KeyboardWindowLocating = KeyboardOverlayHost()) { + self.model = model + self.locator = locator + } + + /// Plans how `+` opens the panel and, when it goes over the keyboard, puts the overlay up with + /// `items` as its rows. Returns the mode chosen; ``AttachOverlayMode/dismissKeyboard`` leaves the + /// caller to take the keyboard down. + func begin( + screen: ChatScreenViewController, + items: [AttachMenuItem], + composer: ComposerModel, + actions: AttachOverlayActions + ) -> AttachOverlayMode { + if let mode { return mode } + let plan = AttachOverlayPlan( + locator: locator, + keyboardHeight: screen.keyboardOverlap, + canReplaceInputView: screen.canReplaceComposerInputView + ) + logger.info("Attach panel opening", metadata: [ + "mode": "\(plan.mode.rawValue)", + "keyboardHeight": "\(screen.keyboardOverlap)", + "keyboardWindowFound": "\(plan.keyboardWindow != nil)", + "canReplaceInputView": "\(screen.canReplaceComposerInputView)", + ]) + guard plan.mode.drawsOverKeyboard, let appWindow = screen.view.window else { return .dismissKeyboard } + + self.screen = screen + // Before the switch: swapping the input view in announces the keyboard showing again, which + // must not read as the keyboard taking a card's place. + model.attachCard.closesOnKeyboardShow = false + let container = makeContainer(appWindow: appWindow, composer: composer, actions: actions) + switch plan.mode { + case .keyboardWindow: + guard let window = plan.keyboardWindow else { return refuse() } + install(container, in: window) + case .inputView: + let backdrop = AttachInputBackdrop(height: screen.keyboardOverlap) + backdrop.onWindow = { [weak self] window in + guard let self, let container = self.container else { return } + self.install(container, in: window) + } + guard screen.setComposerInputView(backdrop) else { + logger.info("Attach panel input view refused", metadata: [:]) + return refuse() + } + self.backdrop = backdrop + case .dismissKeyboard: + return .dismissKeyboard + } + + mode = plan.mode + model.overKeyboard.activate(items: items) + // A tap on the transcript reaches the app's window, not the overlay, and would lower the + // keyboard; while the menu is up it only closes the menu. + screen.setTranscriptShield( + isActive: { [weak self] in self?.container?.dismissesOnOutsideTouch() ?? false }, + onTap: { [weak self] in self?.container?.onOutsideTouch() } + ) + observe() + return plan.mode + } + + /// Undoes a begin that can't draw over the keyboard after all, leaving the bar to draw the panel. + private func refuse() -> AttachOverlayMode { + model.attachCard.closesOnKeyboardShow = true + container = nil + host = nil + return .dismissKeyboard + } + + /// Takes the overlay down once nothing is left in it, putting the system keyboard back under the + /// field if an input view stood in for it. + func end() { + guard mode != nil else { return } + mode = nil + observers.forEach(NotificationCenter.default.removeObserver) + observers = [] + screen?.setTranscriptShield(isActive: { false }, onTap: nil) + container?.isEnding = true + container?.removeFromSuperview() + container = nil + host = nil + if backdrop != nil { + screen?.setComposerInputView(nil) + backdrop = nil + } + model.attachCard.closesOnKeyboardShow = true + model.overKeyboard.deactivate() + } + + /// Takes the panel and card down at once: the keyboard went, or the app left the foreground. + func abort() { + guard mode != nil else { return } + logger.info("Attach overlay aborted", metadata: [:]) + model.resetAttach() + end() + } + + /// Hands the photo card to the bar, with the keyboard down and the full picker opening: a sheet + /// can't be presented from the keyboard's window. + func handOffToBar() { + guard mode != nil else { return } + model.overKeyboard.opensLibraryInBar = true + end() + screen?.dismissKeyboard() + } + + private func makeContainer( + appWindow: UIWindow, + composer: ComposerModel, + actions: AttachOverlayActions + ) -> AttachOverlayContainer { + let container = AttachOverlayContainer() + container.appWindow = appWindow + // The keyboard's window does not carry the app's appearance. + container.overrideUserInterfaceStyle = appWindow.traitCollection.userInterfaceStyle + container.onWindowLost = { [weak self] in self?.reattach() } + container.dismissesOnOutsideTouch = { [weak self] in + guard let model = self?.model else { return false } + return model.attachPanel.isOpen && !model.attachCard.isOpen + } + container.onOutsideTouch = { [weak self] in + self?.model.attachPanel.animate { $0.dismiss() } + } + + let root = AttachOverlayRoot( + model: model, + composer: composer, + actions: actions, + report: { [weak container] region, rect in container?.hitRects[region] = rect }, + onFinished: { [weak self] in self?.end() }, + onAbort: { [weak self] in self?.abort() } + ) + let host = UIHostingController(rootView: AnyView(root)) + host.view.backgroundColor = .clear + host.safeAreaRegions = [] + host.view.frame = container.bounds + host.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + container.addSubview(host.view) + self.host = host + self.container = container + return container + } + + private func install(_ container: AttachOverlayContainer, in window: UIWindow) { + guard let appWindow = container.appWindow else { return } + // Through the screen: the keyboard's window is in another scene, and converting straight + // from the app's window yields an infinite rect. + let onScreen = appWindow.convert(appWindow.bounds, to: appWindow.screen.coordinateSpace) + let frame = window.convert(onScreen, from: window.screen.coordinateSpace) + container.frame = frame.origin.x.isFinite && frame.origin.y.isFinite ? frame : window.bounds + if container.superview === window { + window.bringSubviewToFront(container) + } else { + window.addSubview(container) + } + } + + /// Puts the overlay back on top of a keyboard whose view tree was rebuilt, or takes it down when + /// the keyboard's window has gone. + private func reattach() { + // A turn later, so UIKit has finished swapping the tree out. + DispatchQueue.main.async { [weak self] in + guard let self, let mode = self.mode, let container = self.container, !container.isEnding else { return } + let window: UIWindow? = switch mode { + case .keyboardWindow: self.locator.keyboardWindow() + case .inputView: self.backdrop?.window + case .dismissKeyboard: nil + } + guard let window else { + self.abort() + return + } + self.install(container, in: window) + } + } + + private func observe() { + let center = NotificationCenter.default + let refresh: [Notification.Name] = [UIResponder.keyboardDidShowNotification, UIResponder.keyboardDidChangeFrameNotification] + for name in refresh { + observers.append(center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.reattach() } + }) + } + observers.append(center.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.abort() } + }) + // The input view swap announces the keyboard going and coming, so only the keyboard's own + // window takes a hide as the keyboard leaving. Focus loss covers the swap. + observers.append(center.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard self?.mode == .keyboardWindow else { return } + self?.abort() + } + }) + } +} + +/// The parts of the overlay that take touches. +enum AttachOverlayRegion: Hashable { + case panel + case card +} + +/// A full-screen layer over the keyboard that takes touches only on the panel and the card. +final class AttachOverlayContainer: UIView { + + /// The app window the overlay is laid over, whose coordinates the SwiftUI inside uses. + weak var appWindow: UIWindow? + /// The panel's and card's frames, in this view's coordinates. + var hitRects: [AttachOverlayRegion: CGRect] = [:] + /// Set as the overlay is taken down, so leaving the window is not taken for a rebuilt keyboard. + var isEnding = false + /// Fired when the keyboard's view tree is rebuilt out from under the overlay. + var onWindowLost: () -> Void = {} + /// Whether a touch outside the panel and card is taken to dismiss, instead of passing through. + var dismissesOnOutsideTouch: () -> Bool = { false } + /// Fired by a touch outside the panel and card while `dismissesOnOutsideTouch` holds. + var onOutsideTouch: () -> Void = {} + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + guard hitRects.values.contains(where: { $0.contains(point) }) else { + // The touch is swallowed, not typed: it only closes the menu, as with the keyboard down. + return dismissesOnOutsideTouch() ? self : nil + } + return super.hitTest(point, with: event) + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + onOutsideTouch() + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window == nil, !isEnding { + onWindowLost() + } + } +} + +/// The input view that stands in for the keyboard when its window can't be reached: the keyboard's +/// own backdrop at the keyboard's height, so the composer stays where it was. +final class AttachInputBackdrop: UIInputView { + + /// Fired with the window the backdrop lands in, which the overlay is laid over. + var onWindow: (UIWindow) -> Void = { _ in } + + init(height: CGFloat) { + super.init(frame: CGRect(x: 0, y: 0, width: 0, height: height), inputViewStyle: .keyboard) + allowsSelfSizing = false + autoresizingMask = [.flexibleWidth] + } + + required init?(coder: NSCoder) { + nil + } + + override func didMoveToWindow() { + super.didMoveToWindow() + if let window { + onWindow(window) + } + } +} + +/// The overlay's content: the attach surface in window coordinates, the menu straddling the +/// composer's bottom edge and the cards standing on the screen's bottom over the keys. +private struct AttachOverlayRoot: View { + + let model: ConversationBarModel + let composer: ComposerModel + let actions: AttachOverlayActions + let report: (AttachOverlayRegion, CGRect?) -> Void + let onFinished: () -> Void + let onAbort: () -> Void + + @State private var bounds: CGSize = .zero + + var body: some View { + ZStack(alignment: .topLeading) { + Color.clear + if model.attachSurfaceIsMounted { + AttachSurface( + model: model, + items: model.overKeyboard.items, + plus: model.overKeyboard.plusFrame, + card: AttachOverlayLayout.cardFrame(in: CGRect(origin: .zero, size: bounds), height: model.attachCard.height), + landing: model.attachCard.landingChipFrame, + menuPlacement: .straddlesPlus, + selectionLimit: AttachMenuItem.photosSelectionLimit(attachedCount: composer.chips.count), + actions: actions, + report: report + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .ignoresSafeArea() + .onGeometryChange(for: CGSize.self, of: { $0.size }) { bounds = $0 } + // Everything has gone: the overlay comes down and the bar takes over again. + .onChange(of: model.attachSurfaceIsMounted) { _, isMounted in + if !isMounted { + onFinished() + } + } + // The field losing focus takes the keyboard, and with it what was drawn over it. + .onChange(of: model.isComposing) { _, isComposing in + if !isComposing { + onAbort() + } + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift index cdc7c0080..1c676bf0c 100644 --- a/Flipcash/Core/Screens/Conversation/AttachMenu.swift +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -5,7 +5,6 @@ // Copyright © 2026 Code Inc. All rights reserved. // -import PhotosUI import SwiftUI import FlipcashUI @@ -35,38 +34,30 @@ enum AttachMenuItem: Equatable { } } -/// The composer's leading `+` control: a menu of Cash, Camera, and Photos, in the slot Send Cash -/// takes before the chat exists. -/// -/// Photos opens the system picker, which runs out of process and needs no library permission — -/// the same reason ``GalleryScanButton`` uses it. +/// The composer's leading `+` control, in the slot Send Cash takes before the chat exists. Opens the +/// attach surface — the floating glass menu of Cash, Camera, and Photos, which grows out of `+` over +/// the field and collapses back into it — and reports where it is laid out, for the surface to grow +/// from. The surface itself is ``AttachSurface``, drawn by the bar or over the keyboard. struct AttachMenu: View { let items: [AttachMenuItem] - /// The most photos one pick may return, so the strip never passes `ComposerModel.maxAttachments`. - let photosSelectionLimit: Int - let onCash: () -> Void - let onCamera: () -> Void - /// Receives the picked photos in the order they were selected. - let onPhotos: ([PhotosPickerItem]) -> Void - - @State private var isPickingPhotos = false - @State private var pickedPhotos: [PhotosPickerItem] = [] + let panel: AttachPanel + /// Whether `+` is hidden under the camera or photo card. + let hidesButton: Bool + /// Whether the attach surface is drawn in `+`'s place, which hides `+`. + var isStoodInFor = false + /// Fired as the panel opens, before it is drawn: the keyboard goes down and the panel rides the bar + /// down with it, or the panel goes up over the keyboard. + let onOpen: ([AttachMenuItem]) -> Void + /// Receives `+`'s frame in window coordinates as it is laid out. + var onPlusFrame: (CGRect) -> Void = { _ in } var body: some View { - Menu { - ForEach(items, id: \.self) { item in - switch item { - case .cash: - Button("Cash", systemImage: "banknote", action: onCash) - case .camera: - Button("Camera", systemImage: "camera", action: onCamera) - case .photos: - // Presented from the modifier below rather than as a `PhotosPicker` row: a - // picker nested in a `Menu` is torn down with the menu before it can present. - Button("Photos", systemImage: "photo.on.rectangle") { isPickingPhotos = true } - } + Button { + if !panel.isOpen { + onOpen(items) } + panel.animate { $0.toggle() } } label: { Image(systemName: SystemSymbol.plus.rawValue) .font(.default(size: 20, weight: .semibold)) @@ -74,27 +65,146 @@ struct AttachMenu: View { .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) .contentShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) } - // Declared order, top to bottom. The system otherwise reverses a menu that opens upward, - // which is every menu raised from the bottom bar. - .menuOrder(.fixed) .buttonStyle(.plain) .glassBackground(cornerRadius: BarMetrics.cornerRadius) .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) .accessibilityLabel("Attach") + .accessibilityValue(panel.isOpen ? "Expanded" : "Collapsed") .accessibilityIdentifier("attach-menu-button") - .photosPicker( - isPresented: $isPickingPhotos, - selection: $pickedPhotos, - maxSelectionCount: photosSelectionLimit, - selectionBehavior: .ordered, - matching: .images, - photoLibrary: .shared() - ) - .onChange(of: pickedPhotos) { _, picked in - guard !picked.isEmpty else { return } - // Cleared so the next pick starts empty instead of preselecting what was just staged. - pickedPhotos = [] - onPhotos(picked) + .hiddenUnderAttachCard(hidesButton) + // Fades under the surface growing over it, whose glass can land a frame after `+` would + // vanish; comes back at once under the surface that has shrunk into it. + .opacity(isStoodInFor ? 0 : 1) + .animation(isStoodInFor ? ChatMotion.attachContentOut : nil, value: isStoodInFor) + .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { onPlusFrame($0) } + // An edit or a gate taking the slot takes the panel with it. + .onDisappear { panel.dismiss() } + } +} + +extension AttachPanel { + + /// Acts on the row `item`: Cash collapses the panel, and Camera and Photos expand it into their + /// card once `warmUp` has the card's content ready, or has waited as long as it will. + func select( + _ item: AttachMenuItem, + warmUp: AttachWarmUp, + onCash: () -> Void, + onCamera: @escaping () -> Void, + onPhotos: @escaping () -> Void + ) { + switch item { + case .cash: + animate { $0.dismiss() } + onCash() + case .camera: + warmUp.whenReady(for: .camera) { [weak self] in self?.expand(into: onCamera) } + case .photos: + warmUp.whenReady(for: .photos) { [weak self] in self?.expand(into: onPhotos) } + } + } + + /// Expands the panel into the card `open` puts up: one transaction, so the surface springs from + /// the menu's frame to the card's while the rows fade out and the card's content in. + private func expand(into open: () -> Void) { + guard isOpen else { return } + withAnimation(ChatMotion.attachCard.animation, completionCriteria: .logicallyComplete) { + dismiss() + open() + } completion: { [weak self] in + self?.exitDidFinish() + } + } +} + +/// Takes the attach panel down at the first touch outside it: over the transcript, and over the +/// field beside `+`. Hidden from VoiceOver, which dismisses with the escape gesture instead. +struct AttachPanelDismissArea: View { + + let panel: AttachPanel + + var body: some View { + Color.clear + .contentShape(Rectangle()) + // A drag from zero rather than a tap, so the touch that starts a scroll dismisses too + // instead of being swallowed. + .gesture(DragGesture(minimumDistance: 0).onChanged { _ in + panel.animate { $0.dismiss() } + }) + .accessibilityHidden(true) + } +} + +/// The attach menu's rows, top to bottom, laid out at the menu's width. The surface they sit on is +/// ``AttachSurface``'s. +struct AttachMenuRows: View { + + let items: [AttachMenuItem] + let onSelect: (AttachMenuItem) -> Void + /// Fired by the escape gesture. + let onDismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + ForEach(items, id: \.self) { item in + Button { + onSelect(item) + } label: { + Label(item.title, systemImage: item.systemImage) + .labelStyle(AttachMenuRowLabelStyle()) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, AttachSurfaceLayout.menuVerticalPadding) + .frame(width: AttachSurfaceLayout.menuWidth, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) + .accessibilityElement(children: .contain) + .accessibilityAddTraits(.isModal) + .accessibilityAction(.escape, onDismiss) + .accessibilityIdentifier("attach-menu-panel") + } +} + +/// A panel row: the glyph in a tinted disc so the titles line up, and the whole row the target. +private struct AttachMenuRowLabelStyle: LabelStyle { + + private static let iconDiameter: CGFloat = 42 + + func makeBody(configuration: Configuration) -> some View { + HStack(spacing: 14) { + configuration.icon + .font(.default(size: 19, weight: .medium)) + .frame(width: Self.iconDiameter, height: Self.iconDiameter) + .background(Color.white.opacity(0.08), in: Circle()) + configuration.title + .font(.default(size: 18, weight: .regular)) + } + .foregroundStyle(Color.textMain) + .padding(.horizontal, 18) + .frame(minHeight: AttachSurfaceLayout.menuRowHeight, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } +} + +extension AttachMenuItem { + + /// The row's title, which is also its VoiceOver label. + var title: String { + switch self { + case .cash: "Cash" + case .camera: "Camera" + case .photos: "Photos" + } + } + + /// The row's SF Symbol. + var systemImage: String { + switch self { + case .cash: "banknote" + case .camera: "camera" + case .photos: "photo.on.rectangle" } } } diff --git a/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift new file mode 100644 index 000000000..3598fa809 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift @@ -0,0 +1,129 @@ +// +// AttachOverKeyboard.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import Observation +import FlipcashCore + +/// How `+` puts the attach panel up while the keyboard is up. +nonisolated enum AttachOverlayMode: String, Equatable { + /// Over the live keys, in the keyboard's own window, which ``KeyboardOverlayHost`` finds. + case keyboardWindow + /// Over a plain keyboard-coloured backdrop swapped in as the field's input view, in the window + /// that input view lands in. + case inputView + /// The keyboard goes down first and the panel rides the bar down with it. + case dismissKeyboard + + /// The least keyboard height that counts as a software keyboard. A hardware keyboard leaves only + /// the shortcut bar, which is no keyboard to draw over. + static let minimumKeyboardHeight: CGFloat = 150 + + /// Picks the mode for a keyboard `keyboardHeight` points tall, given whether its window was found + /// and whether the field can take a replacement input view. + static func select(keyboardHeight: CGFloat, hasKeyboardWindow: Bool, canReplaceInputView: Bool) -> Self { + guard keyboardHeight >= minimumKeyboardHeight else { return .dismissKeyboard } + if hasKeyboardWindow { return .keyboardWindow } + if canReplaceInputView { return .inputView } + return .dismissKeyboard + } + + /// Whether the panel and its cards are drawn over the keyboard rather than in the bar. + var drawsOverKeyboard: Bool { + switch self { + case .keyboardWindow, .inputView: true + case .dismissKeyboard: false + } + } +} + +/// The attach mode chosen for one tap of `+`, with the keyboard window when that is where it goes. +@MainActor +struct AttachOverlayPlan { + let mode: AttachOverlayMode + let keyboardWindow: UIWindow? + + /// Plans the mode with `locator` asked for the keyboard's window, which it may not find. + init(locator: any KeyboardWindowLocating, keyboardHeight: CGFloat, canReplaceInputView: Bool) { + // Asked only for a keyboard tall enough to draw over, so the private lookup runs no more + // than it has to. + let window = keyboardHeight >= AttachOverlayMode.minimumKeyboardHeight ? locator.keyboardWindow() : nil + mode = AttachOverlayMode.select( + keyboardHeight: keyboardHeight, + hasKeyboardWindow: window != nil, + canReplaceInputView: canReplaceInputView + ) + keyboardWindow = mode == .keyboardWindow ? window : nil + } +} + +/// Whether the attach panel and its cards are drawn over the keyboard, and what that drawing needs +/// from the bar underneath it: where `+` and the field are, and the chip a photo lands as. +/// +/// The bar lives in the app's window and the overlay in the keyboard's, so they can't share a +/// `matchedGeometryEffect` namespace. The card shrinks onto a stand-in at the landing chip's frame +/// instead, which the bar measures and hands over here. +@MainActor +@Observable +final class AttachOverKeyboard { + + /// Whether the panel and cards are drawn over the keyboard rather than in the bar. + private(set) var isActive = false + + /// The menu rows the panel showed as it opened. + private(set) var items: [AttachMenuItem] = [] + + /// `+`'s frame in window coordinates, as last laid out. + var plusFrame: CGRect = .zero + + /// Whether the photo card opens the full library as it next mounts in the bar. Set when All + /// Photos hands the card over to the keyboard-down flow, whose sheet the keyboard would cover. + var opensLibraryInBar = false + + /// Starts drawing over the keyboard, with `items` as the panel's rows. + func activate(items: [AttachMenuItem]) { + self.items = items + isActive = true + } + + /// Hands drawing back to the bar. + func deactivate() { + isActive = false + } +} + +nonisolated enum AttachOverlayLayout { + + /// The margin between a card and the screen's sides and bottom: the bar's keyboard-up edge inset. + static let cardInset: CGFloat = 12 + + /// The panel's frame for a panel `size` big: its leading edge on `+`'s, and centred on `+`'s + /// bottom, the composer's bottom edge. It straddles that edge, as ChatGPT's does, so its lower + /// half lies over the keys that blur through it and its upper half over the field. + static func panelFrame(plusFrame: CGRect, size: CGSize) -> CGRect { + CGRect(x: plusFrame.minX, y: plusFrame.maxY - size.height / 2, width: size.width, height: size.height) + } + + /// The point in a panel `size` big that `+`'s centre sits on, for the panel to grow out of and + /// collapse back into. + static func panelAnchor(plusFrame: CGRect, size: CGSize) -> UnitPoint { + guard size.width > 0, size.height > 0 else { return .leading } + let frame = panelFrame(plusFrame: plusFrame, size: size) + return UnitPoint( + x: min(1, max(0, (plusFrame.midX - frame.minX) / size.width)), + y: min(1, max(0, (plusFrame.midY - frame.minY) / size.height)) + ) + } + + /// The card's frame in a window `bounds` big: `height` tall, standing on the screen's bottom + /// over the keyboard. + static func cardFrame(in bounds: CGRect, height: CGFloat) -> CGRect { + let width = max(0, bounds.width - cardInset * 2) + let height = min(height, max(0, bounds.height - cardInset)) + return CGRect(x: bounds.minX + cardInset, y: bounds.maxY - cardInset - height, width: width, height: height) + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachPanel.swift b/Flipcash/Core/Screens/Conversation/AttachPanel.swift new file mode 100644 index 000000000..369aca1bc --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachPanel.swift @@ -0,0 +1,117 @@ +// +// AttachPanel.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import Observation +import FlipcashUI + +/// Whether the attach menu's floating panel is up over the composer row, and how long it holds room +/// above the bar. +/// +/// The panel floats over the transcript, outside the bar's own frame, so the screen has to let the +/// bar draw and take touches above itself for as long as the panel is on screen — which outlasts +/// ``isOpen`` by the length of the exit. ``holdsOverflow`` is that longer span. +@MainActor +@Observable +final class AttachPanel { + + /// Whether the panel is up. + private(set) var isOpen = false + + /// Whether the bar needs room above itself: from opening until the exit has finished. + private(set) var holdsOverflow = false + + /// Opens the panel when closed and collapses it when open, as `+` does. + func toggle() { + if isOpen { + dismiss() + } else { + open() + } + } + + /// Puts the panel up: out of `+`, or back out of a card. + func open() { + guard !isOpen else { return } + isOpen = true + holdsOverflow = true + } + + /// Takes the panel down: into `+`, or into the card it expands into. + func dismiss() { + guard isOpen else { return } + isOpen = false + } + + /// Runs `change` on the panel's spring, and releases the room above the bar once the motion it + /// started has logically finished. + func animate(_ change: (AttachPanel) -> Void) { + withAnimation(ChatMotion.attachPanel.animation, completionCriteria: .logicallyComplete) { + change(self) + } completion: { [weak self] in + self?.exitDidFinish() + } + } + + /// Releases the room above the bar once the exit's animation has run, unless the panel came back + /// in the meantime. + func exitDidFinish() { + guard !isOpen else { return } + holdsOverflow = false + } +} + +/// How the attach flow moves: the one surface springing between `+`, the panel, a card, and a chip, +/// or — under Reduce Motion — standing still at each while its content cross-fades. +nonisolated enum AttachMotion: Equatable { + case morph + case crossFade + + init(reduceMotion: Bool) { + self = reduceMotion ? .crossFade : .morph + } + + /// Whether the surface's frame and corner radius animate, rather than jump. + var animatesGeometry: Bool { + switch self { + case .morph: true + case .crossFade: false + } + } + + /// A chip's scale as it enters the strip and leaves it. + var chipEnterScale: CGFloat { + switch self { + case .morph: ChatMotion.composerChipEnterScale + case .crossFade: 1 + } + } + + /// A chip's transition in and out of the strip, for a chip that is or isn't the one the surface is + /// landing on — that one arrives hidden, where it lies. + func chipTransition(isLanding: Bool) -> AnyTransition { + guard !isLanding else { return .identity } + return .scale(scale: chipEnterScale).combined(with: .opacity) + } + + /// The strip's transition as its first chip arrives and its last one leaves: grown from the + /// leading edge, where that chip sits. + func stripTransition(isLanding: Bool) -> AnyTransition { + guard !isLanding else { return .identity } + return .scale(scale: chipEnterScale, anchor: .leading).combined(with: .opacity) + } +} + +extension View { + /// Fades this view under the camera or photo card: out before the card has grown over it, and back + /// in once the card has shrunk away. Scoped, so nothing else inside rides these curves. + func hiddenUnderAttachCard(_ hidden: Bool) -> some View { + animation(hidden ? ChatMotion.attachContentOut : ChatMotion.attachContentIn) { + $0.opacity(hidden ? 0 : 1) + } + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachSurface.swift b/Flipcash/Core/Screens/Conversation/AttachSurface.swift new file mode 100644 index 000000000..9862f055f --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachSurface.swift @@ -0,0 +1,298 @@ +// +// AttachSurface.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import SwiftUI +import FlipcashUI + +/// What the attach surface is showing. +nonisolated enum AttachSurfacePhase: Equatable { + /// Shrunk into `+` and faded out: on its way in or out. + case collapsed + /// The menu's rows. + case menu + /// The camera or photo card. + case card(AttachCard.Content) + /// Shrinking onto the chip a capture or added photo was staged as. + case landing + + /// The phase for a panel that `isMenuOpen`, the card `card` up if any, and whether a landing is + /// under way. A card wins over the panel, which closes in the transaction the card opens in. + init(isMenuOpen: Bool, card: AttachCard.Content?, isLanding: Bool) { + if let card { + self = .card(card) + } else if isMenuOpen { + self = .menu + } else if isLanding { + self = .landing + } else { + self = .collapsed + } + } + + /// Whether the menu's rows are shown. + var showsRows: Bool { + switch self { + case .menu: true + case .collapsed, .card, .landing: false + } + } + + /// The card whose content is shown, if any. + var shownCard: AttachCard.Content? { + switch self { + case .card(let content): content + case .collapsed, .menu, .landing: nil + } + } +} + +/// Where the menu stands against `+`. +nonisolated enum AttachMenuPlacement: Equatable { + /// On the composer's bottom edge, growing up out of `+`: in the bar, with the keyboard down. + case standsOnPlus + /// Centred on the composer's bottom edge, over the keys: drawn over the keyboard. + case straddlesPlus +} + +/// The surface's frame and corner radius, which spring together. +nonisolated struct AttachSurfaceShape: Equatable { + var rect: CGRect + var cornerRadius: CGFloat +} + +/// Where the attach surface stands in each phase, in its host's coordinates. +enum AttachSurfaceLayout { + + /// `+`'s radius, which the surface grows out of and collapses back into. + static let plusCornerRadius: CGFloat = BarMetrics.cornerRadius + static let menuCornerRadius: CGFloat = 40 + static let cardCornerRadius: CGFloat = 24 + /// A staged chip's radius, which a landing ends on. + static let chipCornerRadius: CGFloat = 10 + /// The menu's width: enough to cover the field from `+`, short of the send button on a 375pt screen. + static let menuWidth: CGFloat = 280 + /// A menu row's height, sized to the system's own attachment menus. + static let menuRowHeight: CGFloat = 62 + static let menuVerticalPadding: CGFloat = 10 + + /// The menu's size before its rows are measured. + static func estimatedMenuSize(rowCount: Int) -> CGSize { + CGSize(width: menuWidth, height: CGFloat(rowCount) * menuRowHeight + menuVerticalPadding * 2) + } + + /// The menu's frame for a menu `size` big, placed against `plus` by `placement`. + static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect { + switch placement { + case .standsOnPlus: + CGRect(x: plus.minX, y: plus.maxY - size.height, width: size.width, height: size.height) + case .straddlesPlus: + AttachOverlayLayout.panelFrame(plusFrame: plus, size: size) + } + } + + /// The card's frame in the bar, in the composer row's coordinates: standing on the row's bottom + /// edge, `height` tall, and `outset` wider than the row on each side. + static func barCardRect(row: CGSize, outset: CGFloat, height: CGFloat) -> CGRect { + CGRect(x: -outset, y: row.height - height, width: row.width + outset * 2, height: height) + } + + /// The surface's shape in `phase`. A landing whose chip hasn't been laid out yet holds the card's. + static func shape(for phase: AttachSurfacePhase, plus: CGRect, menu: CGRect, card: CGRect, landing: CGRect?) -> AttachSurfaceShape { + switch phase { + case .collapsed: + AttachSurfaceShape(rect: plus, cornerRadius: plusCornerRadius) + case .menu: + AttachSurfaceShape(rect: menu, cornerRadius: menuCornerRadius) + case .card: + AttachSurfaceShape(rect: card, cornerRadius: cardCornerRadius) + case .landing: + if let landing { + AttachSurfaceShape(rect: landing, cornerRadius: chipCornerRadius) + } else { + AttachSurfaceShape(rect: card, cornerRadius: cardCornerRadius) + } + } + } + + /// The regions that take touches in `phase`: the menu while it shows, the card while it is up. + static func regions(for phase: AttachSurfacePhase, menu: CGRect, card: CGRect) -> [AttachOverlayRegion: CGRect] { + switch phase { + case .menu: [.panel: menu] + case .card: [.card: card] + case .collapsed, .landing: [:] + } + } +} + +/// The attach flow's one surface: a single glass shape that grows out of `+` into the menu, springs +/// between the menu and the camera or photo card, and shrinks onto the chip a photo is staged as. +/// +/// Only its frame and corner radius move. Its content is laid out at its final size from the moment +/// the surface appears — the camera's viewfinder and the inline photo picker at the card's, the rows +/// at the menu's — and cross-fades inside the clip: the leaving side out early and fast, the arriving +/// side in late. Drawn in the bar with the keyboard down, and over the keyboard with it up; the host +/// passes `+`'s frame, the card's, and the landing chip's in its own coordinates. +struct AttachSurface: View { + + let model: ConversationBarModel + let items: [AttachMenuItem] + let plus: CGRect + let card: CGRect + let landing: CGRect? + let menuPlacement: AttachMenuPlacement + let selectionLimit: Int + let actions: AttachOverlayActions + /// Whether the photo card opens the full picker as it mounts, for one All Photos was handed to. + var opensLibraryOnAppear = false + /// Receives the regions that take touches as they change, and none once the surface is gone. + var report: (AttachOverlayRegion, CGRect?) -> Void = { _, _ in } + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + /// Off for the first render, so the surface starts in `+` and grows out of it. + @State private var appeared = false + @State private var menuSize: CGSize? + + var body: some View { + let motion = AttachMotion(reduceMotion: reduceMotion) + let phase = model.attachSurfacePhase + let menu = AttachSurfaceLayout.menuRect( + plus: plus, + size: menuSize ?? AttachSurfaceLayout.estimatedMenuSize(rowCount: items.count), + placement: menuPlacement + ) + let shape = AttachSurfaceLayout.shape(for: appeared ? phase : .collapsed, plus: plus, menu: menu, card: card, landing: landing) + // Morphing, the surface is `+` at either end and stays opaque; a cross-fade fades it instead. + let isVisible = motion.animatesGeometry || (appeared && phase != .collapsed) + let showsPlusGlyph = !appeared || phase == .collapsed + AttachSurfaceFrame(shape: shape) { rect in + ZStack(alignment: .topLeading) { + plusGlyph + .offset(x: plus.minX - rect.minX, y: plus.minY - rect.minY) + .attachLayer(isShown: showsPlusGlyph) + cardLayer(phase: phase) + .frame(width: card.width, height: card.height) + .offset(x: card.minX - rect.minX, y: card.minY - rect.minY) + rows(phase: phase) + .frame(width: menu.width, height: menu.height, alignment: .topLeading) + .offset(x: menu.minX - rect.minX, y: menu.minY - rect.minY) + } + } + // Under Reduce Motion the shape jumps; only the content and the surface's own opacity fade. + .transaction(value: shape) { transaction in + if !motion.animatesGeometry { + transaction.animation = nil + } + } + .opacity(isVisible ? 1 : 0) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onAppear { + withAnimation(ChatMotion.attachPanel.animation) { appeared = true } + } + .onChange(of: AttachSurfaceLayout.regions(for: phase, menu: menu, card: card), initial: true) { _, regions in + report(.panel, regions[.panel]) + report(.card, regions[.card]) + } + .onDisappear { + report(.panel, nil) + report(.card, nil) + } + } + + // MARK: - Content - + + /// `+`'s glyph where `+` stands, so the surface reads as `+` itself as it grows and collapses. + private var plusGlyph: some View { + Image(systemName: SystemSymbol.plus.rawValue) + .font(.default(size: 20, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: plus.width, height: plus.height) + .accessibilityHidden(true) + } + + private func rows(phase: AttachSurfacePhase) -> some View { + let panel = model.attachPanel + return AttachMenuRows(items: items, onSelect: { item in + panel.select(item, warmUp: model.attachWarmUp, onCash: actions.onCash, onCamera: actions.onCamera, onPhotos: actions.onPhotos) + }) { + panel.animate { $0.dismiss() } + } + .onGeometryChange(for: CGSize.self, of: { $0.size }) { menuSize = $0 } + .attachLayer(isShown: phase.showsRows) + } + + /// The camera and the photo picker, each mounted from the surface's first frame at the card's + /// size, so neither is laid out again as the surface grows into it. The camera only once access + /// is granted: mounted earlier, it would ask while the menu is up. + private func cardLayer(phase: AttachSurfacePhase) -> some View { + let shown = phase.shownCard + let mountsCamera = items.contains(.camera) && (model.attachWarmUp.cameraIsAuthorized || shown == .camera) + return ZStack { + if mountsCamera { + ChatCameraSheet(camera: model.attachWarmUp.camera, onCapture: actions.onCameraCapture, onCancel: actions.onCameraCancel) + .attachLayer(isShown: shown == .camera) + } + if items.contains(.photos) { + ChatPhotosCard( + pick: model.photosPick, + selectionLimit: selectionLimit, + onAdd: actions.onPhotosAdd, + onBack: actions.onPhotosBack, + onAllPhotos: actions.onAllPhotos, + opensLibraryOnAppear: opensLibraryOnAppear + ) + .onAppear { model.attachWarmUp.pickerDidMount() } + .onDisappear { model.attachWarmUp.pickerDidUnmount() } + .attachLayer(isShown: shown == .photos) + } + } + } +} + +private extension View { + + /// Shows this layer of the surface's content, or keeps it mounted but hidden and out of reach: + /// fading in late as the surface arrives at it, and out early as the surface leaves. + func attachLayer(isShown: Bool) -> some View { + animation(isShown ? ChatMotion.attachContentIn : ChatMotion.attachContentOut) { + $0.opacity(isShown ? 1 : 0) + } + .allowsHitTesting(isShown) + .accessibilityHidden(!isShown) + } +} + +/// The surface's glass and clip at one frame and corner radius, which animate as one value so the +/// two never drift apart mid-spring. `content` is handed the frame of each animated step, to keep +/// its layers where they rest while the clip moves over them. +private struct AttachSurfaceFrame: View, Animatable { + + var shape: AttachSurfaceShape + let content: (CGRect) -> Content + + var animatableData: AnimatablePair { + get { AnimatablePair(shape.rect.animatableData, shape.cornerRadius) } + set { + shape.rect.animatableData = newValue.first + shape.cornerRadius = newValue.second + } + } + + var body: some View { + let rect = shape.rect + let clip = RoundedRectangle(cornerRadius: shape.cornerRadius, style: .continuous) + ZStack(alignment: .topLeading) { + Color.clear + .frame(width: rect.width, height: rect.height) + .panelGlassBackground(cornerRadius: shape.cornerRadius) + content(rect) + } + .frame(width: rect.width, height: rect.height, alignment: .topLeading) + .clipShape(clip) + .contentShape(clip) + .offset(x: rect.minX, y: rect.minY) + } +} diff --git a/Flipcash/Core/Screens/Conversation/AttachWarmUp.swift b/Flipcash/Core/Screens/Conversation/AttachWarmUp.swift new file mode 100644 index 000000000..bcd12c4d0 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/AttachWarmUp.swift @@ -0,0 +1,121 @@ +// +// AttachWarmUp.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import AVFoundation +import Observation + +/// Gets the camera running while the attach panel is up, so the camera card opens on a live frame +/// instead of waiting for the session to configure and start. +/// +/// Warm while the panel is up or the camera card is; cold otherwise, so no capture session runs, and +/// the camera light stays off, while the menu is closed. Never asks for camera access: a camera that +/// hasn't been granted is left for the card to ask about. +@MainActor +@Observable +final class AttachWarmUp { + + /// Whether the panel or a card is up, and the camera with it if access allows. + private(set) var isWarm = false + + @ObservationIgnored private var warmCamera: ChatPhotoCamera? + @ObservationIgnored private let cameraAccess: () -> AVAuthorizationStatus + + init(cameraAccess: @escaping () -> AVAuthorizationStatus = { AVCaptureDevice.authorizationStatus(for: .video) }) { + self.cameraAccess = cameraAccess + } + + /// The camera the card shows, made on first use and kept for the screen's lifetime. + var camera: ChatPhotoCamera { + if let warmCamera { + return warmCamera + } + let camera = ChatPhotoCamera() + warmCamera = camera + return camera + } + + /// Whether the camera has been made. A chat whose attach menu is never opened never makes one. + var hasCamera: Bool { warmCamera != nil } + + /// Whether camera access is granted, so the camera can be mounted without asking for it. + var cameraIsAuthorized: Bool { cameraAccess() == .authorized } + + /// The longest a card waits for its content before opening anyway. + static let readinessBudget: Duration = .milliseconds(150) + + /// How long the inline picker has to have been mounted before its grid counts as drawn. The picker + /// is remote and reports nothing, so this stands in for a signal. + static let pickerLead: Duration = .milliseconds(300) + + /// When the surface's inline photo picker was mounted, while it is. + @ObservationIgnored private(set) var pickerMountedAt: ContinuousClock.Instant? + + /// Records that the surface mounted its inline photo picker. + func pickerDidMount() { + pickerMountedAt = .now + } + + /// Records that the surface took its inline photo picker down. + func pickerDidUnmount() { + pickerMountedAt = nil + } + + /// Whether `item`'s card content is ready to be revealed at `now`: the camera running, or the + /// picker mounted for ``pickerLead``. A camera without access has nothing to wait for, since its + /// card asks for access instead. + func isReady(for item: AttachMenuItem, at now: ContinuousClock.Instant = .now) -> Bool { + switch item { + case .cash: + return true + case .camera: + return !cameraIsAuthorized || (warmCamera?.isRunning ?? false) + case .photos: + guard let pickerMountedAt else { return false } + return now - pickerMountedAt >= Self.pickerLead + } + } + + /// Runs `body` once `item`'s card content is ready, or after ``readinessBudget`` at the most, so + /// the card never opens empty for want of a few frames and never waits long for content that is + /// slow to come. + func whenReady(for item: AttachMenuItem, _ body: @escaping @MainActor () -> Void) { + guard !isReady(for: item) else { + body() + return + } + let deadline = ContinuousClock.now + Self.readinessBudget + Task { @MainActor [weak self] in + while let self, !self.isReady(for: item), ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(16)) + } + body() + } + } + + /// Starts warming when `wanted` and stops when not, as the panel and the cards come and go. + func update(wanted: Bool) { + if wanted { + warm() + } else { + cool() + } + } + + private func warm() { + guard !isWarm else { return } + isWarm = true + guard cameraAccess() == .authorized else { return } + let camera = camera + Task { try? await camera.prepare() } + } + + private func cool() { + guard isWarm else { return } + isWarm = false + warmCamera?.stop() + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift index 388167b66..95a4e1124 100644 --- a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift @@ -35,25 +35,43 @@ nonisolated enum ChatCameraPhase: Equatable { } } -/// The attach menu's Camera: a rounded viewfinder with a shutter, a lens flip, and a back chevron, -/// sized by its host to the keyboard's frame. +/// A photo the chat camera took, with a copy decoded at the viewfinder's size for the chip it +/// shrinks into. +struct ChatCameraCapture { + let image: UIImage + let preview: UIImage? +} + +/// The attach menu's Camera: a rounded viewfinder with a back chevron, a shutter, and a "…" button +/// that unfolds the flash and lens flip above itself, sized by its host as an ``AttachCard``. /// /// Asks for camera access on first use, since opening the camera is the request. A capture hands /// the photo to `onCapture`; the host stages it and collapses the camera. struct ChatCameraSheet: View { - let onCapture: (UIImage) -> Void + /// The camera, owned by ``AttachWarmUp`` so it can already be running when this appears. + let camera: ChatPhotoCamera + let onCapture: (ChatCameraCapture) -> Void let onCancel: () -> Void + /// The viewfinder's size in points, for decoding the capture's preview at a size that stays + /// sharp for the whole shrink into its chip. + @State private var viewfinderSize: CGSize = .zero + @State private var authorizer = CameraAuthorizer() - @State private var camera = ChatPhotoCamera() @State private var isUnavailable = false + /// Whether the flash and flip buttons are unfolded above "…". + @State private var showsMoreControls = false - private static let cornerRadius: CGFloat = 24 + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private static let controlSize: CGFloat = 44 var body: some View { + let phase = ChatCameraPhase(status: authorizer.status) + let isLive = phase == .live && !isUnavailable ZStack { - switch ChatCameraPhase(status: authorizer.status) { + switch phase { case .requestingAccess: ProgressView() .task { _ = try? await authorizer.authorize() } @@ -75,91 +93,169 @@ struct ChatCameraSheet: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + .onGeometryChange(for: CGSize.self, of: { $0.size }) { viewfinderSize = $0 } .background(Color.backgroundMain) - .overlay(alignment: .topLeading) { - backButton + // On every phase, so the way back is there while access is asked for or denied too. + .overlay(alignment: .bottom) { + controls(isLive: isLive) } - .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) } private var viewfinder: some View { ChatCameraPreview(session: camera.session) - .overlay(alignment: .bottom) { - controls - } + // Already running when the panel warmed it; this only catches a camera granted just now. + // The warm-up stops it once the card is gone. .task { do { - try camera.configure() - camera.start() + try await camera.prepare() } catch { isUnavailable = true } } - .onDisappear { - camera.stop() - } } - private var controls: some View { + /// Back on the left, the shutter on the centre line, and "…" on the right; the shutter and "…" + /// only while the viewfinder runs. + private func controls(isLive: Bool) -> some View { HStack { - // Balances the flip button so the shutter sits on the centre line. - Color.clear - .frame(width: 44, height: 44) + backButton Spacer() - Button { - Task { - if let image = await camera.capture() { - onCapture(image) - } - } - } label: { - Circle() - .strokeBorder(Color.white, lineWidth: 4) - .padding(-2) - .overlay { - Circle() - .fill(Color.white) - .padding(6) - } - .frame(width: 68, height: 68) + if isLive { + shutter } - .buttonStyle(.plain) - .disabled(camera.isCapturing) - .accessibilityLabel("Take photo") - .accessibilityIdentifier("chat-camera-shutter") Spacer() - Button { - camera.flip() - } label: { - Image(systemName: "arrow.triangle.2.circlepath") - .font(.default(size: 17, weight: .semibold)) - .foregroundStyle(Color.white) - .frame(width: 44, height: 44) - .background(Circle().fill(Color.black.opacity(0.35))) + if isLive { + moreButton + } else { + // Balances the back button, so nothing shifts as the viewfinder comes up. + Color.clear + .frame(width: Self.controlSize, height: Self.controlSize) } - .buttonStyle(.plain) - .accessibilityLabel(camera.position == .front ? "Use back camera" : "Use front camera") - .accessibilityIdentifier("chat-camera-flip") } - .padding(.horizontal, 24) + .padding(.horizontal, 20) .padding(.bottom, 16) } + private var shutter: some View { + Button { + Task { + guard let image = await camera.capture() else { return } + // Decoded off the main thread before the hand-off, so the chip the capture + // shrinks into has pixels on its first frame. + let scale = UITraitCollection.current.displayScale + // Square on the longer side: the thumbnail keeps the photo's aspect, and a + // portrait capture fitted into the wide viewfinder would come out small. + let side = max(viewfinderSize.width, viewfinderSize.height) * scale + let preview = side > 0 ? await image.byPreparingThumbnail(ofSize: CGSize(width: side, height: side)) : nil + onCapture(ChatCameraCapture(image: image, preview: preview)) + } + } label: { + Circle() + .strokeBorder(Color.white, lineWidth: 4) + .padding(-2) + .overlay { + Circle() + .fill(Color.white) + .padding(6) + } + .frame(width: 68, height: 68) + } + .buttonStyle(.plain) + .disabled(camera.isCapturing) + .accessibilityLabel("Take photo") + .accessibilityIdentifier("chat-camera-shutter") + } + + /// "…", turning into × while the flash and flip stand unfolded in a column above it. + private var moreButton: some View { + Button { + withAnimation(ChatMotion.attachPanel.animation) { + showsMoreControls.toggle() + } + } label: { + ChatCameraControlLabel(systemImage: showsMoreControls ? "xmark" : "ellipsis") + .overlay(alignment: .topTrailing) { + if !showsMoreControls { + Circle() + .fill(Color.blue) + .frame(width: 8, height: 8) + .offset(x: -2, y: 2) + .transition(.opacity) + } + } + } + .buttonStyle(.plain) + .accessibilityLabel(showsMoreControls ? "Hide camera controls" : "More camera controls") + .accessibilityIdentifier("chat-camera-more") + // An overlay, so unfolding the column leaves the row where it is. + .overlay(alignment: .bottom) { + if showsMoreControls { + VStack(spacing: 12) { + if camera.isFlashAvailable { + flashButton + } + flipButton + } + .fixedSize() + .padding(.bottom, Self.controlSize + 12) + .transition( + reduceMotion + ? .opacity + : .scale(scale: ChatMotion.attachPanelEnterScale, anchor: .bottom).combined(with: .opacity) + ) + } + } + } + + private var flashButton: some View { + let isOn = camera.flashMode == .on + return Button { + camera.toggleFlash() + } label: { + ChatCameraControlLabel(systemImage: isOn ? "bolt.fill" : "bolt.slash.fill") + } + .buttonStyle(.plain) + .accessibilityLabel("Flash") + .accessibilityValue(isOn ? "On" : "Off") + .accessibilityIdentifier("chat-camera-flash") + } + + private var flipButton: some View { + Button { + camera.flip() + } label: { + ChatCameraControlLabel(systemImage: "arrow.triangle.2.circlepath") + } + .buttonStyle(.plain) + .accessibilityLabel(camera.position == .front ? "Use back camera" : "Use front camera") + .accessibilityIdentifier("chat-camera-flip") + } + private var backButton: some View { Button(action: onCancel) { - Image(systemName: "chevron.left") - .font(.default(size: 17, weight: .semibold)) - .foregroundStyle(Color.white) - .frame(width: 44, height: 44) - .background(Circle().fill(Color.black.opacity(0.35))) + ChatCameraControlLabel(systemImage: "chevron.left") } .buttonStyle(.plain) - .padding(12) - .accessibilityLabel("Back to keyboard") + .accessibilityLabel("Back to attach menu") .accessibilityIdentifier("chat-camera-back") } } +/// A round control over the viewfinder: a white glyph on a dimmed disc. +private struct ChatCameraControlLabel: View { + + let systemImage: String + + var body: some View { + Image(systemName: systemImage) + .font(.default(size: 17, weight: .semibold)) + .foregroundStyle(Color.white) + .frame(width: 44, height: 44) + .background(Circle().fill(Color.black.opacity(0.35))) + .contentShape(Circle()) + } +} + // MARK: - ChatCameraPreview - /// The live feed of a capture session, filling its frame. diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift deleted file mode 100644 index c99740585..000000000 --- a/Flipcash/Core/Screens/Conversation/ChatCameraSlot.swift +++ /dev/null @@ -1,72 +0,0 @@ -// -// ChatCameraSlot.swift -// Flipcash -// -// Copyright © 2026 Code Inc. All rights reserved. -// - -import UIKit -import Observation - -/// Whether the inline camera holds the keyboard's place under the composer, and how tall that -/// place is. -@MainActor -@Observable -final class ChatCameraSlot { - - /// The height the camera takes before a keyboard has risen on this screen. - static let fallbackHeight: CGFloat = 300 - - /// Whether the camera is up in the keyboard's place. - private(set) var isOpen = false - - /// The keyboard's height above the bottom safe area, the last time it rose. - private(set) var height: CGFloat = fallbackHeight - - @ObservationIgnored private var observer: (any NSObjectProtocol)? - - init() { - observer = NotificationCenter.default.addObserver( - forName: UIResponder.keyboardWillShowNotification, - object: nil, - queue: nil - ) { [weak self] notification in - let info = notification.userInfo - let endFrame = info?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect - let isLocal = info?[UIResponder.keyboardIsLocalUserInfoKey] as? Bool ?? true - MainActor.assumeIsolated { - guard isLocal, let endFrame, !endFrame.isEmpty else { return } - let safeAreaBottom = UIApplication.shared.currentKeyWindow?.safeAreaInsets.bottom ?? 0 - self?.keyboardWillShow(height: endFrame.height, safeAreaBottom: safeAreaBottom) - } - } - } - - isolated deinit { - if let observer { - NotificationCenter.default.removeObserver(observer) - } - } - - /// Puts the camera up in the keyboard's place. - func open() { - guard !isOpen else { return } - isOpen = true - } - - /// Takes the camera down. - func close() { - guard isOpen else { return } - isOpen = false - } - - /// Records a keyboard `height` tall rising over a window whose bottom safe area is - /// `safeAreaBottom`, and closes the camera, since the keyboard is taking its place back. - func keyboardWillShow(height keyboardHeight: CGFloat, safeAreaBottom: CGFloat) { - let slotHeight = keyboardHeight - safeAreaBottom - if slotHeight > 0, slotHeight != height { - height = slotHeight - } - close() - } -} diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift index e3c273f78..2b48a3ce6 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoCamera.swift @@ -14,7 +14,7 @@ import Observation @preconcurrency import AVFoundation /// A still-photo camera for the chat composer: one capture session with a photo output, flippable -/// between the back and front lenses. +/// between the back and front lenses, with a flash the shutter fires when the lens supports it. /// /// Separate from FlipcashUI's `CameraSession`, which wires only video-data and metadata outputs for /// scanning and has no still-photo output. @@ -22,7 +22,7 @@ import Observation final class ChatPhotoCamera { /// Why the camera could not be set up. - enum SetupError: Error { + nonisolated enum SetupError: Error { /// The device has no camera at the requested position, as on the simulator. case deviceUnavailable /// The session refused the camera's input or the photo output. @@ -35,6 +35,15 @@ final class ChatPhotoCamera { /// Whether a shutter press is still being processed. private(set) var isCapturing = false + /// The flash the next shot asks for. A lens that can't fire it shoots without. + private(set) var flashMode: AVCaptureDevice.FlashMode = .off + + /// Whether the current lens can fire a flash. + private(set) var isFlashAvailable = false + + /// Whether the session is delivering frames: from its start until ``stop()``. + private(set) var isRunning = false + @ObservationIgnored let session = AVCaptureSession() @ObservationIgnored private let output = AVCapturePhotoOutput() @@ -43,6 +52,11 @@ final class ChatPhotoCamera { @ObservationIgnored private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? // The output holds its delegate weakly; each capture keeps its own alive until it finishes. @ObservationIgnored private var pendingCaptures: [Int64: PhotoCaptureDelegate] = [:] + /// The configuration in flight or done, shared by every caller of ``prepare()``. + @ObservationIgnored private var configuring: Task? + /// Whether the session should be running. A ``stop()`` while ``prepare()`` is still configuring + /// keeps the session from starting once it finishes. + @ObservationIgnored private var wantsRunning = false // Same Swift 6.3 EarlyPerfInliner workaround `CameraSession` carries for @MainActor deinits. nonisolated deinit {} @@ -59,12 +73,71 @@ final class ChatPhotoCamera { } } + /// Returns the flash after `mode` in the toggle: off and on, nothing automatic. + nonisolated static func toggled(_ mode: AVCaptureDevice.FlashMode) -> AVCaptureDevice.FlashMode { + switch mode { + case .off: + .on + case .on, .auto: + .off + @unknown default: + .off + } + } + + /// Returns the flash a shot can ask for given what the output `supported`: `requested` if the + /// output fires it, off otherwise. Asking an output for a mode it lacks raises an exception. + nonisolated static func resolvedFlashMode( + _ requested: AVCaptureDevice.FlashMode, + supported: [AVCaptureDevice.FlashMode] + ) -> AVCaptureDevice.FlashMode { + supported.contains(requested) ? requested : .off + } + + /// Switches the flash between off and on. + func toggleFlash() { + flashMode = Self.toggled(flashMode) + } + // MARK: - Session - - /// Adds the photo output and the current lens to the session; a no-op once configured. - func configure() throws { - guard input == nil else { return } + /// Configures the session with the photo output and the current lens, then starts it, all off the + /// main thread so the first frame can be live before the viewfinder is on screen. Configures once; + /// later calls only start it again. Does not ask for camera access. + func prepare() async throws { + wantsRunning = true + let configuring = configuring ?? Task { try await configureOffMain() } + self.configuring = configuring + do { + try await configuring.value + } catch { + // So a later call, after access is granted, can try again. + self.configuring = nil + throw error + } + guard wantsRunning else { return } + start() + } + + private func configureOffMain() async throws { + let session = session + let output = output + let position = position + let configured = try await withCheckedThrowingContinuation { continuation in + sessionQueue.async { + continuation.resume(with: Result { try Self.configure(session, output: output, position: position) }) + } + } + adopt(configured, at: position) + } + /// Adds `output` and the `position` lens to `session`, returning the lens's input and whether it + /// can fire a flash. Blocking; runs on the session queue. + private nonisolated static func configure( + _ session: AVCaptureSession, + output: AVCapturePhotoOutput, + position: AVCaptureDevice.Position + ) throws -> ConfiguredInput { session.beginConfiguration() // An uncommitted session crashes on stopRunning(), so commit on every exit. defer { session.commitConfiguration() } @@ -74,20 +147,59 @@ final class ChatPhotoCamera { throw SetupError.configurationRejected } session.addOutput(output) - try attachInput(for: position) + return try attach(position, to: session, output: output) + } + + /// Adds the `position` lens to `session`, returning its input and whether `output` can fire a + /// flash through it. Inside a configuration block. + private nonisolated static func attach( + _ position: AVCaptureDevice.Position, + to session: AVCaptureSession, + output: AVCapturePhotoOutput + ) throws -> ConfiguredInput { + guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) else { + throw SetupError.deviceUnavailable + } + guard + let input = try? AVCaptureDeviceInput(device: device), + session.canAddInput(input) + else { + throw SetupError.configurationRejected + } + session.addInput(input) + // Read after the input is attached: the output's supported modes follow the lens. + return ConfiguredInput(input: input, isFlashAvailable: output.supportedFlashModes.contains(.on)) + } + + /// Records the lens `configured` attached at `position`. + private func adopt(_ configured: ConfiguredInput, at position: AVCaptureDevice.Position) { + input = configured.input + self.position = position + isFlashAvailable = configured.isFlashAvailable + // The app is portrait-locked, so the interface can't say how the phone is held; the + // coordinator reads the device's physical orientation for the photo's EXIF. + rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: configured.input.device, previewLayer: nil) } /// Starts the session off the main thread; `startRunning()` blocks until frames flow. - func start() { + private func start() { let session = session - sessionQueue.async { - guard !session.isRunning else { return } - session.startRunning() + sessionQueue.async { [weak self] in + if !session.isRunning { + session.startRunning() + } + let isRunning = session.isRunning + Task { @MainActor [weak self] in + guard let self, self.wantsRunning else { return } + self.isRunning = isRunning + } } } - /// Stops the session off the main thread. + /// Stops the session off the main thread, or keeps one still being prepared from starting. func stop() { + wantsRunning = false + isRunning = false let session = session sessionQueue.async { guard session.isRunning else { return } @@ -118,21 +230,7 @@ final class ChatPhotoCamera { } private func attachInput(for position: AVCaptureDevice.Position) throws { - guard let device = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) else { - throw SetupError.deviceUnavailable - } - guard - let input = try? AVCaptureDeviceInput(device: device), - session.canAddInput(input) - else { - throw SetupError.configurationRejected - } - session.addInput(input) - self.input = input - self.position = position - // The app is portrait-locked, so the interface can't say how the phone is held; the - // coordinator reads the device's physical orientation for the photo's EXIF. - rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: nil) + adopt(try Self.attach(position, to: session, output: output), at: position) } // MARK: - Capture - @@ -145,6 +243,9 @@ final class ChatPhotoCamera { defer { isCapturing = false } let settings = AVCapturePhotoSettings() + // Resolved at the shot, not when the flash was toggled: a flip since then changes what the + // output supports. + settings.flashMode = Self.resolvedFlashMode(flashMode, supported: output.supportedFlashModes) if let connection = output.connection(with: .video), let rotationCoordinator { let angle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture if connection.isVideoRotationAngleSupported(angle) { @@ -160,12 +261,17 @@ final class ChatPhotoCamera { } pendingCaptures[id] = nil - // `UIImage(data:)` carries the EXIF orientation into `imageOrientation`, which the uploader - // bakes into the pixels before encoding. - return data.flatMap(UIImage.init(data:)) + guard let data else { return nil } + return await ChatPhotoStaging.decode(data) } } +/// The lens a configuration attached, carried back from the session queue. +private nonisolated struct ConfiguredInput { + let input: AVCaptureDeviceInput + let isFlashAvailable: Bool +} + // MARK: - PhotoCaptureDelegate - // AVFoundation calls back on its own queue, so the delegate stays off the main actor, as diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoPreloader.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoPreloader.swift new file mode 100644 index 000000000..fb5e01187 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoPreloader.swift @@ -0,0 +1,67 @@ +// +// ChatPhotoPreloader.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit + +/// Loads the photos picked in the photo card while they are being picked, so each one is in hand by +/// the time Add stages it. +@MainActor +@Observable +final class ChatPhotoPreloader { + + /// The images that have finished loading, by the item that holds them. + private(set) var images: [Item: UIImage] = [:] + + @ObservationIgnored private var tasks: [Item: Task] = [:] + @ObservationIgnored private let load: (Item) async -> UIImage? + + /// Creates a preloader that reads each item's image through `load`. + init(load: @escaping (Item) async -> UIImage?) { + self.load = load + } + + /// The items being loaded or already loaded. + var trackedItems: Set { Set(tasks.keys) } + + /// Starts loading each item in `selection` not yet started, and drops the ones no longer in it. + func update(selection: [Item]) { + let selected = Set(selection) + for item in tasks.keys where !selected.contains(item) { + tasks.removeValue(forKey: item)?.cancel() + images.removeValue(forKey: item) + } + for item in selection where tasks[item] == nil { + let task = Task { [load] in await load(item) } + tasks[item] = task + Task { [weak self] in + let image = await task.value + // Deselected while it loaded, or loaded again under a new task. + guard let self, self.tasks[item] == task, let image else { return } + self.images[item] = image + } + } + } + + /// Returns `item`'s image if it has already loaded. + func loadedImage(for item: Item) -> UIImage? { + images[item] + } + + /// Returns `item`'s image, waiting for a load in flight or starting one if none was. + func image(for item: Item) async -> UIImage? { + if let image = images[item] { return image } + if let task = tasks[item] { return await task.value } + return await load(item) + } + + /// Cancels every load and lets go of every image. + func reset() { + for task in tasks.values { task.cancel() } + tasks = [:] + images = [:] + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift index 40ab29583..afe0f5979 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotoStaging.swift @@ -33,6 +33,37 @@ enum ChatPhotoStaging { return staged } + /// Stages photos added from the photo card in selection order: the leading run whose images + /// `loaded` already holds at once, and the rest, from the first one still loading, through + /// `load` without blocking the caller. + /// + /// Returns the first chip when it could be staged at once, for the card to shrink into, and the + /// task staging the rest. + static func stageAdded( + _ items: [Item], + into composer: ComposerModel, + uploader: ChatMediaUploader, + loaded: (Item) -> UIImage?, + load: @escaping (Item) async -> UIImage? + ) -> (handOff: ComposerChip.ID?, remainder: Task<[ComposerChip], Never>?) { + var handOff: ComposerChip.ID? + var index = items.startIndex + while index < items.endIndex, let image = loaded(items[index]) { + // The image is its own preview, so the chip has pixels on the hand-off's first frame. + guard let chip = composer.stageChip(image: image, preview: image, uploader: uploader) else { + return (handOff, nil) + } + if handOff == nil { handOff = chip.id } + index += 1 + } + let rest = Array(items[index...]) + guard !rest.isEmpty else { return (handOff, nil) } + let remainder = Task { + await stage(rest, into: composer, uploader: uploader, load: load) + } + return (handOff, remainder) + } + /// Returns the photo `item` holds, or `nil` when it cannot be loaded or decoded. static func loadImage(_ item: PhotosPickerItem) async -> UIImage? { let data: Data @@ -54,9 +85,25 @@ enum ChatPhotoStaging { return image } - /// Decodes off the main actor, since a full-size photo takes a while to parse. + /// Returns the photo in `data` decoded at full size with its EXIF orientation drawn into the + /// pixels, or `nil` when it cannot be decoded. Off the main actor, since a full-size photo takes + /// a while to parse. + /// + /// Upright pixels, not an `imageOrientation`: `UIImageView` draws a turned image by rotating its + /// layer's contents, and that rotation animates with the cell when a sent photo's bubble lands. @concurrent - private nonisolated static func decode(_ data: Data) async -> UIImage? { - UIImage(data: data) + nonisolated static func decode(_ data: Data) async -> UIImage? { + guard let source = CGImageSourceCreateWithData(data as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int, + let upright = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: max(width, height), + ] as CFDictionary) else { + return nil + } + return UIImage(cgImage: upright) } } diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift new file mode 100644 index 000000000..3f14e2245 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift @@ -0,0 +1,184 @@ +// +// ChatPhotosCard.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import PhotosUI +import SwiftUI +import FlipcashUI + +/// A photo pick in progress: the selection, in the order it was made, the loader already reading +/// it, and whether the full system picker is up over the card. +/// +/// Held by the bar's model rather than the card, so the inline picker can be mounted ahead of the card +/// and a Back or a close without a choice can clear the pick wherever it is drawn. +@MainActor +@Observable +final class AttachPhotosPick { + + var selection: [PhotosPickerItem] = [] { + didSet { preloader.update(selection: selection) } + } + + /// Whether the full system picker is up as a sheet over the card. + var showsLibrary = false + + private(set) var preloader = AttachPhotosPick.makePreloader() + + /// Discards the selection and what was loaded for it. + func reset() { + selection = [] + showsLibrary = false + preloader.reset() + } + + /// Hands the selection and the loader reading it to `add`, and starts a fresh pick without + /// cancelling what that loader is still reading. + func handOff(to add: ([PhotosPickerItem], ChatPhotoPreloader) -> Void) { + let selection = selection + let preloader = preloader + self.preloader = Self.makePreloader() + self.selection = [] + showsLibrary = false + add(selection, preloader) + } + + private static func makePreloader() -> ChatPhotoPreloader { + ChatPhotoPreloader(load: ChatPhotoStaging.loadImage) + } +} + +/// The attach menu's Photos: the system photo picker inline, with a back chevron and, bottom right, +/// All Photos — the full system picker, for albums — until something is selected, then an Add pill. +/// Content of ``AttachSurface``, which draws the card's glass and clip. +/// +/// The picker runs out of process and needs no photo library permission. Selecting stages nothing, +/// but starts loading each photo, so it is in hand at Add; the selection is handed over then, in the +/// order it was made. +struct ChatPhotosCard: View { + + let pick: AttachPhotosPick + /// The most photos one pick may hold, so the strip never passes `ComposerModel.maxAttachments`. + let selectionLimit: Int + /// Receives the selection, in the order it was made, with the loader already reading it. + let onAdd: ([PhotosPickerItem], ChatPhotoPreloader) -> Void + /// Fired by the back chevron and the escape gesture; the host discards the selection. + let onBack: () -> Void + /// Fired by All Photos in place of putting the full picker up here, for a card that can't present + /// a sheet. All Photos only shows with nothing selected, so nothing is lost. + var onAllPhotos: (() -> Void)? = nil + /// Whether the full picker comes up as the card appears, for a card All Photos was handed to. + var opensLibraryOnAppear = false + + /// Reports each tap to the selection as it is made, in order. The picker's own Add is disabled, + /// and the non-continuous behaviours report only through it. + static let selectionBehavior: PhotosPickerSelectionBehavior = .continuousAndOrdered + + /// Returns the Add pill's title for `count` selected photos. + static func addTitle(count: Int) -> String { + count == 1 ? "Add 1 photo" : "Add \(count) photos" + } + + var body: some View { + @Bindable var pick = pick + PhotosPicker( + selection: $pick.selection, + maxSelectionCount: selectionLimit, + selectionBehavior: Self.selectionBehavior, + matching: .images, + photoLibrary: .shared() + ) { + Text("Photos") + } + .photosPickerStyle(.inline) + // Hidden at the top too: there it shows a prompt, a Photos/Collections switch, and a privacy + // banner that take most of the card, and nothing can switch it to Collections from outside. + // All Photos opens the full picker for albums instead. + .photosPickerAccessoryVisibility(.hidden, edges: .all) + .photosPickerDisabledCapabilities(.selectionActions) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .overlay(alignment: .bottom) { + controls + } + .accessibilityElement(children: .contain) + .accessibilityAction(.escape, onBack) + .accessibilityIdentifier("attach-photos-card") + // Bound to the same selection, so picks made in either picker show in both. + .photosPicker( + isPresented: $pick.showsLibrary, + selection: $pick.selection, + maxSelectionCount: selectionLimit, + selectionBehavior: Self.selectionBehavior, + matching: .images, + photoLibrary: .shared() + ) + .onAppear { + if opensLibraryOnAppear { + pick.showsLibrary = true + } + } + } + + private var controls: some View { + HStack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.default(size: 17, weight: .semibold)) + .foregroundStyle(Color.textMain) + .frame(width: 44, height: 44) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .glassBackground(cornerRadius: 22) + .clipShape(Circle()) + .accessibilityLabel("Back to attach menu") + .accessibilityIdentifier("attach-photos-back") + + Spacer() + + // One or the other, cross-faded in place as the first pick arrives and the last leaves. + if pick.selection.isEmpty { + Button { + if let onAllPhotos { + onAllPhotos() + } else { + pick.showsLibrary = true + } + } label: { + Text("All Photos") + .font(.appTextMedium) + .foregroundStyle(Color.textMain) + .padding(.horizontal, 18) + .frame(height: 44) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .glassBackground(cornerRadius: 22) + .clipShape(Capsule()) + .accessibilityIdentifier("attach-photos-all") + .transition(.opacity) + } else { + Button { + pick.handOff(to: onAdd) + } label: { + Text(Self.addTitle(count: pick.selection.count)) + .font(.appTextMedium) + .foregroundStyle(Color.textAction) + .padding(.horizontal, 18) + .frame(height: 44) + .background(Color.action, in: Capsule()) + .contentShape(Capsule()) + .contentTransition(.numericText()) + } + .buttonStyle(.plain) + .accessibilityIdentifier("attach-photos-add") + .transition(.opacity) + } + } + .padding(12) + .animation(ChatMotion.composerChip.animation, value: pick.selection.isEmpty) + .animation(ChatMotion.composerChip.animation, value: pick.selection.count) + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 59774e657..754140835 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -120,11 +120,14 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { let authorAvatars: [UserID: Data] /// Whether the chat takes photos; false for an E2EE DM. var acceptsMedia: Bool = false - /// Receives a photo taken with the inline camera, which the representable opens in the - /// keyboard's place and closes again once the photo is handed over. - var onCameraCapture: (UIImage) -> Void = { _ in } - /// Receives the photos picked from the attach menu, in the order they were selected. - var onPhotosPicked: ([PhotosPickerItem]) -> Void = { _ in } + /// Receives a photo taken with the camera card, which the representable opens over the composer + /// and closes again once the photo is handed over. + /// Returns the chip the photo was staged as, which the capture shrinks into. + var onCameraCapture: (ChatCameraCapture) -> ComposerChip.ID? = { _ in nil } + /// Receives the photos added from the attach menu's photo card, in the order they were + /// selected, with the loader already reading them. Returns the chip the first was staged as, + /// which the card shrinks into, when it could be staged at once. + var onPhotosAdded: ([PhotosPickerItem], ChatPhotoPreloader) -> ComposerChip.ID? = { _, _ in nil } /// Mints a signed download URL for a photo in this chat. The transcript's resolver caches what it /// returns and never asks for a photo drawn only from its BlurHash. var mintMediaURL: (BlobID) async throws -> URL? = { _ in nil } @@ -290,7 +293,17 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { } private func bar(coordinator: Coordinator) -> AnyView { - AnyView( + coordinator.overlayActions = AttachOverlayActions( + onCash: onSendCash, + onCamera: { [weak coordinator] in coordinator?.openCard(.camera) }, + onPhotos: { [weak coordinator] in coordinator?.openCard(.photos) }, + onCameraCapture: { [weak coordinator] capture in coordinator?.closeCard { onCameraCapture(capture) } }, + onCameraCancel: { [model = barModel] in model.returnToMenu() }, + onPhotosAdd: { [weak coordinator] items, preloader in coordinator?.closeCard { onPhotosAdded(items, preloader) } }, + onPhotosBack: { [model = barModel] in model.returnToMenu() }, + onAllPhotos: { [weak coordinator] in coordinator?.overlay.handOffToBar() } + ) + return AnyView( ConversationBottomBar( showsSendCash: showsSendCash, chatExists: chatExists, @@ -308,21 +321,29 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { isJoiningChat: isJoiningChat, mentions: mentions, acceptsMedia: acceptsMedia, - onCamera: { [weak coordinator, camera = barModel.camera] in - coordinator?.screen?.dismissKeyboard() - camera.open() + onAttachOpen: { [weak coordinator] items in + coordinator?.openAttach(items: items) + }, + onCamera: { [weak coordinator] in + coordinator?.openCard(.camera) + }, + onCameraCapture: { [weak coordinator] capture in + coordinator?.closeCard { onCameraCapture(capture) } }, - onCameraCapture: { [weak coordinator, camera = barModel.camera] image in - onCameraCapture(image) - camera.close() - // Back to the keyboard, so the photo can be captioned. - coordinator?.screen?.focusComposer() + onCameraCancel: { [model = barModel] in + // Back into the panel, with the keyboard left down. + model.returnToMenu() }, - onCameraCancel: { [weak coordinator, camera = barModel.camera] in - camera.close() - coordinator?.screen?.focusComposer() + onPhotos: { [weak coordinator] in + coordinator?.openCard(.photos) + }, + onPhotosAdd: { [weak coordinator] items, preloader in + coordinator?.closeCard { onPhotosAdded(items, preloader) } + }, + onPhotosBack: { [model = barModel] in + // Back into the panel, with the keyboard left down. + model.returnToMenu() }, - onPhotosPicked: onPhotosPicked, // Only a member can aim a reply, so the viewer may see what it quotes. quoteThumbnailURL: { [resolver = coordinator.mediaURLResolver] kind in await resolver.thumbnailURL(for: kind, canReact: true) @@ -339,14 +360,77 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { } } ) + // Behind the bar and over the transcript, which the bar's host covers while the panel is + // up: a touch anywhere outside the panel takes it down. + .background { + if barModel.attachPanel.isOpen { + AttachPanelDismissArea(panel: barModel.attachPanel) + } + } + .modifier(BarOverflowReporting(model: barModel) { [weak coordinator] in coordinator?.screen }) ) } - func makeCoordinator() -> Coordinator { Coordinator() } + func makeCoordinator() -> Coordinator { Coordinator(model: barModel, composer: composer) } @MainActor final class Coordinator { var barHost: UIHostingController? weak var screen: ChatScreenViewController? + let model: ConversationBarModel + let composer: ComposerModel + /// Draws the attach panel and cards over the keyboard while the composer keeps focus. + let overlay: AttachKeyboardOverlay + /// What the panel and cards drawn over the keyboard hand back, as of the latest render. + var overlayActions: AttachOverlayActions? + + init(model: ConversationBarModel, composer: ComposerModel) { + self.model = model + self.composer = composer + self.overlay = AttachKeyboardOverlay(model: model) + } + + /// `+` is opening the panel with `items` as its rows: over the keyboard while it is up and its + /// window or the field's input view can carry the panel, else with the keyboard taken down. + func openAttach(items: [AttachMenuItem]) { + guard let screen, let overlayActions else { return } + // Sized now, so the card's content is laid out at its final size while the menu is up. + model.attachCard.measure(screenHeight: screen.view.bounds.height) + let mode = overlay.begin(screen: screen, items: items, composer: composer, actions: overlayActions) + switch mode { + case .keyboardWindow, .inputView: + break + case .dismissKeyboard: + screen.dismissKeyboard() + } + } + + /// Puts the camera or photo card up from the panel, over the keyboard if the panel is. + func openCard(_ content: AttachCard.Content) { + if overlay.mode == nil { + screen?.dismissKeyboard() + } + model.attachCard.open(content, screenHeight: screen?.view.bounds.height ?? 0) + } + + /// Takes the card down into the chip `stage` returns, and puts focus back in the field so the + /// photo can be captioned. The chip waits hidden while the bar lays it out, and the surface + /// shrinks onto its frame once it has. + func closeCard(handingOffTo stage: () -> ComposerChip.ID?) { + let card = model.attachCard + guard let chipID = stage() else { + card.animate { card.close() } + screen?.focusComposer() + return + } + card.beginLanding(on: chipID) + screen?.focusComposer() + // A chip the bar never lays out leaves nothing to land on, so the card goes anyway. + DispatchQueue.main.asyncAfter(deadline: .now() + ChatMotion.attachCard.duration) { + guard card.isOpen, card.landingChipID == chipID else { return } + card.animate({ card.close() }, then: { card.endLanding() }) + } + } + var lastMessageID: String? /// The latest ``ChatScreenRepresentable/mintMediaURL``, read by the resolver. var mintMediaURL: (BlobID) async throws -> URL? = { _ in nil } diff --git a/Flipcash/Core/Screens/Conversation/ComposerChip.swift b/Flipcash/Core/Screens/Conversation/ComposerChip.swift index 7deb02c7c..763083366 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChip.swift @@ -32,6 +32,9 @@ final class ComposerChip: Identifiable { let id = UUID() let image: UIImage + /// A display-ready copy decoded ahead of staging, so the chip can draw on its first frame — the + /// camera's capture shrinks into the chip before its own thumbnail could be decoded. + let preview: UIImage? var state: State = .preparing /// Pixel width of the image as it will be uploaded, known before the upload starts. @@ -48,8 +51,9 @@ final class ComposerChip: Identifiable { /// composer has let go of the chip. @ObservationIgnored private var uploader: ChatMediaUploader? - init(image: UIImage) { + init(image: UIImage, preview: UIImage? = nil) { self.image = image + self.preview = preview } /// Uploads the image through `uploader`, replacing any earlier attempt; calling it again after diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift index 476bce9b4..af495806c 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -30,7 +30,7 @@ enum ComposerChipBadge: Equatable { } } -/// The photos staged for the next send, as a row of thumbnails above the message field. +/// The photos staged for the next send, as a row of thumbnails inside the message field, above its text. /// /// Part of the bar's own content rather than a reveal like the reply strip: its arrival changes the /// bar's height the way a draft wrapping to a second line does, which the screen takes in one frame. @@ -40,8 +40,15 @@ struct ComposerChipStrip: View { static let chipSize: CGFloat = 56 let chips: [ComposerChip] + /// The chip the attach surface is shrinking onto, held hidden until it has. + var landingChipID: ComposerChip.ID? = nil + /// Receives the landing chip's frame in window coordinates once it is laid out. + var onLandingChipFrame: (CGRect) -> Void = { _ in } let onRemove: (ComposerChip.ID) -> Void let onRetry: (ComposerChip) -> Void + /// How far the first and last chips sit in from the strip's edges, where the chips scrolling + /// past fade out instead of being cut off. + var edgeInset: CGFloat = 0 /// Returns whether the strip is drawn: while chips are staged, and not during an edit, which /// takes the bar for itself and hands the chips back when it ends. @@ -49,20 +56,50 @@ struct ComposerChipStrip: View { chipCount > 0 && !isEditing } + @Environment(\.accessibilityReduceMotion) private var reduceMotion + var body: some View { + let motion = AttachMotion(reduceMotion: reduceMotion) ScrollView(.horizontal) { HStack(spacing: 8) { ForEach(chips) { chip in + let isLanding = chip.id == landingChipID ComposerChipView( chip: chip, onRemove: { onRemove(chip.id) }, onRetry: { onRetry(chip) } ) + .onGeometryChange(for: CGRect.self, of: { $0.frame(in: .global) }) { frame in + if isLanding { + onLandingChipFrame(frame) + } + } + .opacity(isLanding ? 0 : 1) + .animation(ChatMotion.attachContentIn, value: isLanding) + // Arrives where it lies, so the frame it reports is where the card lands. + .transition(motion.chipTransition(isLanding: isLanding)) + } + } + // A chip arriving or leaving slides its neighbours over on the chip spring, unless the + // change came with an animation of its own. + .transaction(value: chips.map(\.id)) { transaction in + if transaction.animation == nil, !transaction.disablesAnimations { + transaction.animation = ChatMotion.composerChip.animation } } } .scrollIndicators(.hidden) + .contentMargins(.horizontal, edgeInset, for: .scrollContent) .frame(height: Self.chipSize) + .mask { + HStack(spacing: 0) { + LinearGradient(colors: [.clear, .black], startPoint: .leading, endPoint: .trailing) + .frame(width: edgeInset) + Color.black + LinearGradient(colors: [.black, .clear], startPoint: .leading, endPoint: .trailing) + .frame(width: edgeInset) + } + } .accessibilityIdentifier("composer-chip-strip") } } @@ -82,17 +119,21 @@ private struct ComposerChipView: View { var body: some View { let size = ComposerChipStrip.chipSize - ZStack { - Color.backgroundSecondary - if let thumbnail { - Image(uiImage: thumbnail) - .resizable() - .scaledToFill() + // Sized by the colour alone: a filled photo reports its own aspect's height, and laid out + // beside the colour it stretched the chip past its square and over the row below. + Color.backgroundSecondary + .overlay { + // The capture's preview until the thumbnail is decoded, so a chip shrinking out of the + // camera has its photo from the first frame. + if let image = thumbnail ?? chip.preview { + Image(uiImage: image) + .resizable() + .scaledToFill() + } } - badge - } + .overlay { badge } + .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) .frame(width: size, height: size) - .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius)) .overlay(alignment: .topTrailing) { Button(action: onRemove) { Image(systemName: SystemSymbol.closeCircle.rawValue) diff --git a/Flipcash/Core/Screens/Conversation/ComposerModel.swift b/Flipcash/Core/Screens/Conversation/ComposerModel.swift index 108d94bbc..a9494076a 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerModel.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerModel.swift @@ -209,12 +209,13 @@ final class ComposerModel { mode = .new } - /// Stages `image` as a new chip and starts its upload through `uploader`, returning the chip, or - /// returns `nil` when the composer already holds `maxAttachments` chips. + /// Stages `image` as a new chip, drawn from `preview` until its thumbnail is ready, and starts its + /// upload through `uploader`, returning the chip, or returns `nil` when the composer already + /// holds `maxAttachments` chips. @discardableResult - func stageChip(image: UIImage, uploader: ChatMediaUploader) -> ComposerChip? { + func stageChip(image: UIImage, preview: UIImage? = nil, uploader: ChatMediaUploader) -> ComposerChip? { guard chips.count < Self.maxAttachments else { return nil } - let chip = ComposerChip(image: image) + let chip = ComposerChip(image: image, preview: preview) chips.append(chip) chip.startUpload(using: uploader) return chip diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 9d8725ccf..ef3b64b3a 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -15,8 +15,129 @@ import FlipcashUI /// `ComposerModel`, which also knows whether it is a new message or an edit. @MainActor @Observable final class ConversationBarModel { var isComposing = false - /// The inline camera, which takes the keyboard's place under the composer. - let camera = ChatCameraSlot() + /// The camera or photo card the attach panel expands into. + let attachCard = AttachCard() + /// The attach menu's floating panel, grown out of `+`. + let attachPanel = AttachPanel() + /// Gets the camera running ahead of the camera card. + let attachWarmUp = AttachWarmUp() + /// The photo card's pick, kept across Back so its picker is mounted once per panel. + let photosPick = AttachPhotosPick() + /// Whether the panel and cards are drawn over the keyboard instead of in the bar. + let overKeyboard = AttachOverKeyboard() + + /// The card's top edge in window coordinates, as last laid out. + var cardTop: CGFloat = 0 + + /// What the attach surface is showing. + var attachSurfacePhase: AttachSurfacePhase { + AttachSurfacePhase( + isMenuOpen: attachPanel.isOpen, + card: attachCard.content, + isLanding: attachCard.landingChipID != nil + ) + } + + /// Whether the attach surface is on screen: from the panel opening until the last exit, a card's + /// or a landing's, has run. + var attachSurfaceIsMounted: Bool { + attachPanel.holdsOverflow || attachCard.holdsOverflow || attachCard.landingChipID != nil + } + + /// Whether the surface is drawn where `+` stands, as `+`: from the panel opening until it has + /// collapsed back, except while it shrinks onto a chip and leaves `+` to show again. + var surfaceStandsInForPlus: Bool { + attachSurfaceIsMounted && attachSurfacePhase != .landing + } + + /// What the screen has to give the bar above its own frame for the panel and the card. + var overflow: BarOverflow { + guard attachPanel.holdsOverflow || attachCard.holdsOverflow else { return .none } + // A panel or card over the keyboard draws in the keyboard's window and takes its own + // touches there. + if overKeyboard.isActive { return .none } + if attachPanel.isOpen { return .everywhere } + // A leaving card takes no touches: the band closes to the bar's own frame. + return .band(top: attachCard.isOpen ? cardTop : .greatestFiniteMagnitude) + } + + /// Takes the camera or photo card back into the attach panel, discarding any pick: one + /// transaction, so the surface springs from the card's frame to the menu's. + func returnToMenu() { + guard attachCard.isOpen else { return } + photosPick.reset() + withAnimation(ChatMotion.attachCard.animation, completionCriteria: .logicallyComplete) { + attachCard.close() + attachPanel.open() + } completion: { [weak self] in + self?.attachCard.exitDidFinish() + } + } + + /// Takes the panel and the card down at once, with nothing to animate out: the keyboard they were + /// drawn over went, or the app left the foreground. + func resetAttach() { + attachPanel.dismiss() + attachPanel.exitDidFinish() + attachCard.close() + attachCard.exitDidFinish() + attachCard.endLanding() + photosPick.reset() + } + + /// The landing chip was laid out at `frame`, in window coordinates: the card shrinks onto it the + /// first time, and follows it on the card's spring if it moves while the card is shrinking. + func landingChipDidLayout(_ frame: CGRect) { + let card = attachCard + let wasPlaced = card.landingChipFrame != nil + guard !wasPlaced else { + withAnimation(ChatMotion.attachCard.animation) { _ = card.landingChipDidLayout(frame) } + return + } + guard card.landingChipDidLayout(frame) else { return } + card.animate({ card.close() }, then: { card.endLanding() }) + } +} + +/// How the bar reaches above its own frame: not at all, everywhere for a panel that dismisses on any +/// outside touch, or from a window-space `top` down for a card that lets touches above it through to +/// the transcript. +enum BarOverflow: Equatable { + case none + case everywhere + case band(top: CGFloat) + + /// Whether the bar draws above its own frame. + var overflows: Bool { + switch self { + case .none: false + case .everywhere, .band: true + } + } + + /// Where, in window coordinates, the bar starts taking touches, or `nil` for everywhere it reaches. + var touchTop: CGFloat? { + switch self { + case .none: .greatestFiniteMagnitude + case .everywhere: nil + case .band(let top): top + } + } +} + +/// Hands the bar's ``BarOverflow`` to the screen hosting it whenever it changes. +struct BarOverflowReporting: ViewModifier { + let model: ConversationBarModel + let screen: () -> ChatScreenViewController? + + func body(content: Content) -> some View { + content.onChange(of: model.overflow) { _, overflow in + guard let screen = screen() else { return } + // The band first, so the bar never overflows with the last one's touches. + screen.barOverflowTouchTop = overflow.touchTop + screen.barOverflowsTop = overflow.overflows + } + } } /// Single spring driving the whole bar: the button morph, the composer's @@ -122,14 +243,22 @@ struct ConversationBottomBar: View { @State private var composerRowHeight: CGFloat = 0 /// Whether the chat takes photos; false for an E2EE DM, whose encryption does not cover media. var acceptsMedia: Bool = false + /// Fired as `+` opens the attach panel with the given rows: the panel goes up over the keyboard, + /// or the keyboard goes down under it. + var onAttachOpen: ([AttachMenuItem]) -> Void = { _ in } /// Opens the camera from the attach menu. var onCamera: () -> Void = {} /// Receives a photo the inline camera took. - var onCameraCapture: (UIImage) -> Void = { _ in } - /// Fired by the inline camera's back chevron. + var onCameraCapture: (ChatCameraCapture) -> Void = { _ in } + /// Fired by the camera card's back chevron. var onCameraCancel: () -> Void = {} - /// Receives the photos picked from the attach menu, in the order they were selected. - var onPhotosPicked: ([PhotosPickerItem]) -> Void = { _ in } + /// Opens the photo card from the attach menu. + var onPhotos: () -> Void = {} + /// Receives the photos added from the photo card, in the order they were selected, with the + /// loader already reading them. + var onPhotosAdd: ([PhotosPickerItem], ChatPhotoPreloader) -> Void = { _, _ in } + /// Fired by the photo card's back chevron and escape gesture. + var onPhotosBack: () -> Void = {} /// Where the reply strip's quoted photo loads its thumbnail from. var quoteThumbnailURL: (ChatQuote.Kind) async -> URL? = { _ in nil } @@ -144,6 +273,8 @@ struct ConversationBottomBar: View { /// How much further in than ``BarMetrics/edgeInset`` the controls and the reply quote sit. private var compactExtraInset: CGFloat { isCompact ? BarMetrics.compactInset - BarMetrics.edgeInset : 0 } + @Environment(\.accessibilityReduceMotion) private var reduceMotion + var body: some View { // The gate panel takes the bar whole rather than sitting inside it: none of the composer's // springs key on state a gated user can change. @@ -174,21 +305,15 @@ struct ConversationBottomBar: View { // gained or lost. Nothing animates that travel — the bar's springs key on `chatExists` and // `isEditing`, neither of which moves during a send — so it snapped while the bar's height // sprang underneath it. + let motion = AttachMotion(reduceMotion: reduceMotion) let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { - // Below the reply strip and above the field, as the spec orders them. - if ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing) { - ComposerChipStrip( - chips: composer.chips, - onRemove: { composer.removeChip($0) }, - onRetry: { $0.retryUpload() } - ) - } HStack(alignment: .bottom, spacing: 10) { // An edit takes over the bar: the leading control becomes the way out of it and the // attach menu steps aside until it resolves, the way WhatsApp hides its accessory controls. switch leadingControl { case .cancelEdit: CancelEditButton { composer.endEditing() } + .hiddenUnderAttachCard(showsCard) case .sendCash: SendCashMorphButton( symbol: symbol, @@ -205,28 +330,39 @@ struct ConversationBottomBar: View { expandedTitle: isTipDm ? startChattingTitle : nil, action: onSendCash ) + .hiddenUnderAttachCard(showsCard) case .attach(let items): AttachMenu( items: items, - photosSelectionLimit: AttachMenuItem.photosSelectionLimit(attachedCount: composer.chips.count), - onCash: onSendCash, - onCamera: onCamera, - onPhotos: onPhotosPicked + panel: model.attachPanel, + hidesButton: showsCard, + // Swapped out in one frame for the surface, which is drawn as `+` where it stands. + isStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, + onOpen: onAttachOpen, + onPlusFrame: { model.overKeyboard.plusFrame = $0 } ) + // Over the field beside it, which its panel floats across. + .zIndex(1) case .none: EmptyView() } if chatExists { ConversationComposer(conversationID: conversationID, model: model, composer: composer) + .hiddenUnderAttachCard(showsCard) .transition(.opacity) } } - // In the keyboard's place: the screen lowers the keyboard as this opens, and the bar - // grows by what the keyboard held, so the field stays where it was. - if model.camera.isOpen { - ChatCameraSheet(onCapture: onCameraCapture, onCancel: onCameraCancel) - .frame(height: max(0, model.camera.height - Self.rowSpacing)) - .transition(.opacity) + // Kept mounted under the card, so `+` is there for the surface to shrink back into and the + // field is there to take focus the moment the card closes. Each control fades on its own + // rather than the row, which holds the surface. An open panel lets a touch on the row — + // send included — fall through to the dismiss area behind the bar, so it only closes the panel. + .allowsHitTesting(!showsCard && !model.attachPanel.isOpen) + .accessibilityHidden(showsCard) + // After the row's own opacity, so the surface does not fade with it. + .overlay(alignment: .topLeading) { + if model.attachSurfaceIsMounted, !model.overKeyboard.isActive, case .attach(let items) = leadingControl { + attachSurface(items: items) + } } } // The Send Cash button morphs on the reply's spring. Inside the row's padding, so it covers @@ -240,7 +376,46 @@ struct ConversationBottomBar: View { .padding(.bottom, BarMetrics.contentPadding) .animation(barMorphSpring, value: chatExists) .animation(barMorphSpring, value: composer.isEditing) - .animation(barMorphSpring, value: model.camera.isOpen) + // The strip arriving with its first chip and leaving with its last, on the chip spring unless + // a capture's animation is already carrying it. + .transaction(value: ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing)) { transaction in + if transaction.animation == nil, !transaction.disablesAnimations { + transaction.animation = ChatMotion.composerChip.animation + } + } + .onChange(of: wantsCameraWarm) { _, wanted in + model.attachWarmUp.update(wanted: wanted) + } + // A panel closed without a choice leaves no pick behind for the next one. + .onChange(of: model.attachSurfaceIsMounted) { _, isMounted in + if !isMounted { + model.photosPick.reset() + } + } + .onDisappear { + model.attachWarmUp.update(wanted: false) + } + // Typing takes the attach panel down, as a tap outside it does. + .onChange(of: composer.draft) { + guard model.attachPanel.isOpen else { return } + model.attachPanel.animate { $0.dismiss() } + } + // An edit takes `+`'s slot, and the panel or card with it, so Back has no panel to return to. + .onChange(of: composer.isEditing) { _, isEditing in + guard isEditing else { return } + if model.attachCard.isOpen { + model.attachCard.close() + model.attachCard.exitDidFinish() + } + if model.attachPanel.isOpen { + model.attachPanel.animate { $0.dismiss() } + } + } + // So does focusing the field, whose send-button end the panel leaves uncovered. + .onChange(of: model.isComposing) { _, isComposing in + guard isComposing, model.attachPanel.isOpen else { return } + model.attachPanel.animate { $0.dismiss() } + } // No shared GlassEffectContainer: the composer's glass is a background // layer behind an editable text field, and a container composites its @@ -328,10 +503,63 @@ struct ConversationBottomBar: View { return listedCandidates } - /// The gap between the bar's rows. The camera's height gives it back, so the bar grows by - /// exactly the keyboard's height. + /// The gap between the bar's rows. private static let rowSpacing: CGFloat = 8 + /// Whether the camera or photo card is up over the composer row. + private var showsCard: Bool { + model.attachCard.isOpen + } + + /// The attach surface in the bar: the menu standing on `+`, and the card standing on the composer + /// row's bottom edge, a fixed share of the screen tall and as wide as the bar's controls are with + /// the keyboard up. An overlay, so the bar's measured height — and with it the transcript's inset + /// — stays the composer's. + private func attachSurface(items: [AttachMenuItem]) -> some View { + GeometryReader { proxy in + let origin = proxy.frame(in: .global).origin + let toRow: (CGRect) -> CGRect = { $0.offsetBy(dx: -origin.x, dy: -origin.y) } + AttachSurface( + model: model, + items: items, + plus: toRow(model.overKeyboard.plusFrame), + card: AttachSurfaceLayout.barCardRect(row: proxy.size, outset: compactExtraInset, height: model.attachCard.height), + landing: model.attachCard.landingChipFrame.map(toRow), + menuPlacement: .standsOnPlus, + selectionLimit: AttachMenuItem.photosSelectionLimit(attachedCount: composer.chips.count), + actions: AttachOverlayActions( + onCash: onSendCash, + onCamera: onCamera, + onPhotos: onPhotos, + onCameraCapture: onCameraCapture, + onCameraCancel: onCameraCancel, + onPhotosAdd: onPhotosAdd, + onPhotosBack: onPhotosBack + ), + opensLibraryOnAppear: model.overKeyboard.opensLibraryInBar + ) { region, rect in + if region == .card, let rect { + model.cardTop = rect.minY + origin.y + } + } + .onAppear { + // A turn later, so the card has read it first. + DispatchQueue.main.async { model.overKeyboard.opensLibraryInBar = false } + } + } + } + + /// Whether the camera should be warm: while the open panel offers it, or its card shows it. + private var wantsCameraWarm: Bool { + let showsCamera = model.attachCard.content == .camera + switch leadingControl { + case .attach(let items): + return showsCamera || (model.attachPanel.isOpen && items.contains(.camera)) + case .cancelEdit, .sendCash, .none: + return showsCamera + } + } + private var leadingControl: ConversationBarLeadingControl { ConversationBarLeadingControl( isEditing: composer.isEditing, @@ -716,9 +944,14 @@ struct ConversationComposer: View { /// Send button scale-in/out as text appears/clears. private static let sendButtonSpring = ChatMotion.sendButton.animation + private static let leadingInset: CGFloat = 14 + private static let trailingInset: CGFloat = 8 + + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { - let field = HStack(alignment: .bottom, spacing: 10) { + let motion = AttachMotion(reduceMotion: reduceMotion) + let textRow = HStack(alignment: .bottom, spacing: 10) { TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) .font(.appTextMessage) .foregroundStyle(Color.textMain) @@ -761,9 +994,29 @@ struct ConversationComposer: View { .animation(Self.sendButtonSpring, value: showsSubmit) } + // The staged photos ride inside the field, above the text, so the field reads as one message. + let field = VStack(alignment: .leading, spacing: BarMetrics.fieldVerticalPadding) { + if ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing) { + ComposerChipStrip( + chips: composer.chips, + landingChipID: model.attachCard.landingChipID, + onLandingChipFrame: { model.landingChipDidLayout($0) }, + onRemove: { composer.removeChip($0) }, + onRetry: { $0.retryUpload() }, + edgeInset: Self.leadingInset + ) + // Out to the field's own edges, so chips scroll under a fade rather than a hard margin. + .padding(.leading, -Self.leadingInset) + .padding(.trailing, -Self.trailingInset) + .padding(.top, BarMetrics.fieldVerticalPadding / 2) + .transition(motion.stripTransition(isLanding: model.attachCard.landingChipID != nil)) + } + textRow + } + return field - .padding(.leading, 14) - .padding(.trailing, 8) + .padding(.leading, Self.leadingInset) + .padding(.trailing, Self.trailingInset) .padding(.vertical, BarMetrics.fieldVerticalPadding) // Glass *behind* the field, not wrapping it: wrapping an editable // TextField in `glassEffect` reparents its text view into the glass @@ -803,6 +1056,10 @@ struct ConversationComposer: View { private func submit() { guard let conversationID else { return } + // A photo-only send leaves the draft as it was, so the draft's own dismissal never fires. + if model.attachPanel.isOpen { + model.attachPanel.animate { $0.dismiss() } + } // Fire-and-forget in both branches: the change applies optimistically and resolves on its own, // so the composer stays ready immediately. Emptying the field up front makes a double-tap a diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index c693a4b17..9d3ee13c3 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -540,19 +540,19 @@ struct ConversationScreen: View { authorAvatars: authorAvatars, acceptsMedia: acceptsMedia, onCameraCapture: stageCapturedPhoto, - onPhotosPicked: stagePickedPhotos, + onPhotosAdded: stageAddedPhotos, mintMediaURL: mintMediaURL, onMediaTap: openMediaViewer ) } - /// Whether the chat takes photos: any chat this device has a record of, except an E2EE DM, whose + /// Whether the chat takes photos: any chat this device has a record of, except one it encrypts, whose /// encryption does not cover media. False until the record loads, so the menu never offers a /// photo the chat may refuse. private var acceptsMedia: Bool { guard let conversationID, let conversation = conversationController.conversation(withID: conversationID) else { return false } - return !conversation.useE2Ee + return !E2eePolicy.shouldEncrypt(conversation) } /// Uploads chat photos on behalf of the signed-in owner. @@ -560,17 +560,24 @@ struct ConversationScreen: View { ChatMediaUploader(blob: SessionChatMediaBlobStore(session: session, flipClient: container.flipClient)) } - /// Stages photos picked from the library, in the order they were picked. - private func stagePickedPhotos(_ items: [PhotosPickerItem]) { - let uploader = mediaUploader - Task { - await ChatPhotoStaging.stage(items, into: composer, uploader: uploader, load: ChatPhotoStaging.loadImage) - } - } - - /// Stages a photo taken with the inline camera. - private func stageCapturedPhoto(_ image: UIImage) { - composer.stageChip(image: image, uploader: mediaUploader) + /// Stages photos added from the photo card in the order they were selected, returning the chip + /// the first became when its image was already loaded. The rest stage as they finish loading. + private func stageAddedPhotos( + _ items: [PhotosPickerItem], + preloader: ChatPhotoPreloader + ) -> ComposerChip.ID? { + ChatPhotoStaging.stageAdded( + items, + into: composer, + uploader: mediaUploader, + loaded: preloader.loadedImage(for:), + load: preloader.image(for:) + ).handOff + } + + /// Stages a photo taken with the inline camera, returning the chip it became. + private func stageCapturedPhoto(_ capture: ChatCameraCapture) -> ComposerChip.ID? { + composer.stageChip(image: capture.image, preview: capture.preview, uploader: mediaUploader)?.id } /// Opens a tapped photo full screen, zooming out of its row, with share through the system sheet. diff --git a/Flipcash/Core/Screens/Conversation/KeyboardOverlayHost.swift b/Flipcash/Core/Screens/Conversation/KeyboardOverlayHost.swift new file mode 100644 index 000000000..e55d66e9a --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/KeyboardOverlayHost.swift @@ -0,0 +1,49 @@ +// +// KeyboardOverlayHost.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import UIKit + +/// Finds the window the system keyboard is drawn in, so a view can be laid over the live keys. +@MainActor +protocol KeyboardWindowLocating { + /// Returns the window holding the on-screen keyboard, or `nil` when there is none to be found. + func keyboardWindow() -> UIWindow? +} + +/// Finds the system keyboard's window through private UIKit API. The only place in the app that does. +/// +/// Private-API reliant: the software keyboard is drawn in-process, in a window UIKit keeps at a level +/// above every window an app may create (app windows are capped at 10,000,000) and leaves out of +/// `UIApplication.windows` and every scene's `windows`. The one way to reach it is the private class +/// method `+[UIWindow allWindowsIncludingInternalWindows:onlyVisibleWindows:]`, called here through +/// `NSSelectorFromString` after a `responds(to:)` check. A subview added to that window draws over +/// the keys and a glass effect in it blurs them, while the composer stays first responder. +/// +/// Every step is checked: a missing selector, an unexpected return type, or no keyboard window +/// returns `nil`, and the caller falls back to public API. It never traps. +@MainActor +struct KeyboardOverlayHost: KeyboardWindowLocating { + + /// The private selector's name, assembled so the whole name never appears as one literal. + private static let selectorName = ["allWindows", "Including", "Internal", "Windows:", "only", "Visible", "Windows:"].joined() + + /// A fragment of the keyboard window's class name, which no app window carries. + private static let windowClassFragment = ["Remote", "Keyboard", "Window"].joined() + + func keyboardWindow() -> UIWindow? { + let selector = NSSelectorFromString(Self.selectorName) + guard UIWindow.responds(to: selector) else { return nil } + typealias AllWindows = @convention(c) (AnyObject, Selector, Bool, Bool) -> NSArray? + let implementation = UIWindow.method(for: selector) + guard implementation != nil else { return nil } + let allWindows = unsafeBitCast(implementation, to: AllWindows.self) + guard let windows = allWindows(UIWindow.self, selector, true, false) as? [UIWindow] else { return nil } + return windows + .filter { !$0.isHidden && NSStringFromClass(type(of: $0)).contains(Self.windowClassFragment) } + .max { $0.windowLevel.rawValue < $1.windowLevel.rawValue } + } +} diff --git a/FlipcashTests/Chat/AttachOverlayModeTests.swift b/FlipcashTests/Chat/AttachOverlayModeTests.swift new file mode 100644 index 000000000..61012b968 --- /dev/null +++ b/FlipcashTests/Chat/AttachOverlayModeTests.swift @@ -0,0 +1,179 @@ +// +// AttachOverlayModeTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +@testable import Flipcash + +@MainActor +@Suite("Attach overlay mode") +struct AttachOverlayModeTests { + + /// A locator that finds `window`, or nothing, and counts how often it was asked. + private final class StubLocator: KeyboardWindowLocating { + let window: UIWindow? + private(set) var lookups = 0 + + init(window: UIWindow?) { + self.window = window + } + + func keyboardWindow() -> UIWindow? { + lookups += 1 + return window + } + } + + private static let keyboardHeight: CGFloat = 336 + + @Test("A found keyboard window draws the panel over the live keys") + func keyboardWindowWins() { + let window = UIWindow(frame: .zero) + let plan = AttachOverlayPlan( + locator: StubLocator(window: window), + keyboardHeight: Self.keyboardHeight, + canReplaceInputView: true + ) + #expect(plan.mode == .keyboardWindow) + #expect(plan.keyboardWindow === window) + } + + @Test("No keyboard window falls back to swapping the field's input view") + func missingWindowFallsBackToInputView() { + let plan = AttachOverlayPlan( + locator: StubLocator(window: nil), + keyboardHeight: Self.keyboardHeight, + canReplaceInputView: true + ) + #expect(plan.mode == .inputView) + #expect(plan.keyboardWindow == nil) + } + + @Test("No keyboard window and no swappable field takes the keyboard down, as before") + func nothingLeftDismissesTheKeyboard() { + let plan = AttachOverlayPlan( + locator: StubLocator(window: nil), + keyboardHeight: Self.keyboardHeight, + canReplaceInputView: false + ) + #expect(plan.mode == .dismissKeyboard) + #expect(!plan.mode.drawsOverKeyboard) + } + + @Test("With the keyboard down the private lookup is never made", arguments: [CGFloat(0), 44]) + func keyboardDownSkipsTheLookup(height: CGFloat) { + let locator = StubLocator(window: UIWindow(frame: .zero)) + let plan = AttachOverlayPlan(locator: locator, keyboardHeight: height, canReplaceInputView: true) + #expect(plan.mode == .dismissKeyboard) + #expect(plan.keyboardWindow == nil) + #expect(locator.lookups == 0) + } + + @Test("Only the software keyboard's height counts as a keyboard to draw over") + func selectionThresholds() { + let minimum = AttachOverlayMode.minimumKeyboardHeight + #expect(AttachOverlayMode.select(keyboardHeight: minimum - 1, hasKeyboardWindow: true, canReplaceInputView: true) == .dismissKeyboard) + #expect(AttachOverlayMode.select(keyboardHeight: minimum, hasKeyboardWindow: true, canReplaceInputView: false) == .keyboardWindow) + #expect(AttachOverlayMode.select(keyboardHeight: minimum, hasKeyboardWindow: false, canReplaceInputView: true) == .inputView) + } + + @Test("The panel straddles the composer's bottom edge and grows out of +") + func panelStraddlesTheComposerEdge() { + let plus = CGRect(x: 12, y: 500, width: 50, height: 50) + let size = CGSize(width: 240, height: 144) + let frame = AttachOverlayLayout.panelFrame(plusFrame: plus, size: size) + #expect(frame.minX == plus.minX) + #expect(frame.midY == plus.maxY) + + let anchor = AttachOverlayLayout.panelAnchor(plusFrame: plus, size: size) + #expect(frame.minX + anchor.x * size.width == plus.midX) + #expect(frame.minY + anchor.y * size.height == plus.midY) + } + + @Test("The card stands on the screen's bottom, inset from its sides") + func cardStandsOnTheScreenBottom() { + let bounds = CGRect(x: 0, y: 0, width: 402, height: 874) + let frame = AttachOverlayLayout.cardFrame(in: bounds, height: 507) + #expect(frame.maxY == bounds.maxY - AttachOverlayLayout.cardInset) + #expect(frame.minX == AttachOverlayLayout.cardInset) + #expect(frame.width == bounds.width - AttachOverlayLayout.cardInset * 2) + #expect(frame.height == 507) + } + + @Test("The live host returns a window or nil, and never traps") + func liveHostIsSafe() { + // No keyboard is up in the test host, so there is nothing to find; the call itself must hold. + _ = KeyboardOverlayHost().keyboardWindow() + } + + @Test("A card drawn over the keyboard stays up as the keyboard announces itself") + func cardIgnoresKeyboardShowOverKeyboard() { + let card = AttachCard() + card.open(.photos, screenHeight: 800) + card.closesOnKeyboardShow = false + card.keyboardWillShow() + #expect(card.isOpen) + + card.closesOnKeyboardShow = true + card.keyboardWillShow() + #expect(!card.isOpen) + } + + @Test("A card over the keyboard asks the bar for no room, since it takes its own touches") + func overflowStaysOffForCardOverKeyboard() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screenHeight: 800) + #expect(model.overflow != .none) + + model.overKeyboard.activate(items: [.camera, .photos]) + #expect(model.overflow == .none) + } + + @Test("The landing chip's frame is kept only while a landing is under way") + func landingTracksOnlyItsChip() { + let card = AttachCard() + let frame = CGRect(x: 20, y: 600, width: 64, height: 64) + + card.landingChipDidLayout(frame) + #expect(card.landingChipFrame == nil) + + card.beginLanding(on: UUID()) + card.landingChipDidLayout(.zero) + #expect(card.landingChipFrame == nil, "An unlaid-out chip has no frame to land on") + card.landingChipDidLayout(frame) + #expect(card.landingChipFrame == frame) + + card.endLanding() + #expect(card.landingChipID == nil && card.landingChipFrame == nil) + } + + // MARK: - Touches outside the panel + + @Test("A touch outside the panel is taken while the menu dismisses on it") + func outsideTouch_takenWhenDismissing() { + let container = AttachOverlayContainer(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + container.hitRects[.panel] = CGRect(x: 10, y: 500, width: 200, height: 200) + var dismisses = true + container.dismissesOnOutsideTouch = { dismisses } + + #expect(container.hitTest(CGPoint(x: 300, y: 750), with: nil) === container) + + dismisses = false + #expect(container.hitTest(CGPoint(x: 300, y: 750), with: nil) == nil) + } + + @Test("A touch outside the panel fires the dismiss") + func outsideTouch_firesDismiss() { + let container = AttachOverlayContainer(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + var fired = 0 + container.onOutsideTouch = { fired += 1 } + + container.touchesBegan([], with: nil) + + #expect(fired == 1) + } +} diff --git a/FlipcashTests/Chat/AttachPanelPlacementTests.swift b/FlipcashTests/Chat/AttachPanelPlacementTests.swift new file mode 100644 index 000000000..ab69196ff --- /dev/null +++ b/FlipcashTests/Chat/AttachPanelPlacementTests.swift @@ -0,0 +1,233 @@ +// +// AttachPanelPlacementTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import SwiftUI +import UIKit +import FlipcashCore +import FlipcashStore +import FlipcashUI +@testable import Flipcash + +/// The real bar, hosted the way the chat screen hosts it, on a live window. +@MainActor +struct AttachPanelHost { + + let window: UIWindow + let screen: ChatScreenViewController + let barHost: UIHostingController + let model: ConversationBarModel + let composer: ComposerModel + private let screenBox: ScreenBox + private let databaseURL: URL + + init(windowSize: CGSize = CGSize(width: 402, height: 874)) throws { + let (database, url) = try Database.makeTemp() + databaseURL = url + let controller = ConversationController( + fetching: MockConversations(), membership: MockConversations(), viewerSettings: MockConversations(), + messaging: MockConversations(), streaming: MockConversations(), + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: UUID(), + typingHeartbeatInterval: .seconds(3), incomingTypingExpiry: .seconds(10) + ) + let model = ConversationBarModel() + let composer = ComposerModel() + self.model = model + self.composer = composer + + let screenBox = ScreenBox() + self.screenBox = screenBox + let root = HostedBar(controller: controller, model: model, composer: composer, screenBox: screenBox) + + barHost = UIHostingController(rootView: AnyView(root)) + barHost.view.backgroundColor = .clear + barHost.view.clipsToBounds = false + screen = ChatScreenViewController(bar: barHost.view, barController: barHost) + screenBox.screen = screen + + // On the host app's scene, so the window is actually on screen and SwiftUI lays the bar out. + if let scene = UIApplication.shared.connectedScenes.compactMap({ $0 as? UIWindowScene }).first { + window = UIWindow(windowScene: scene) + window.frame = CGRect(origin: .zero, size: windowSize) + } else { + window = UIWindow(frame: CGRect(origin: .zero, size: windowSize)) + } + window.windowLevel = .alert + 1 + window.rootViewController = screen + window.makeKeyAndVisible() + screen.loadViewIfNeeded() + window.layoutIfNeeded() + } + + /// Lets SwiftUI and UIKit settle a few turns. + func settle() async { + for _ in 0..<8 { + window.layoutIfNeeded() + try? await Task.sleep(for: .milliseconds(60)) + } + } + + /// The bar's measured height, as last reported to the screen. + var barHeight: CGFloat { screenBox.barHeight } + + /// The window-space point `distance` above the bar's top edge, over `+`'s column. + func pointAboveBar(by distance: CGFloat) -> CGPoint { + CGPoint(x: 40, y: window.bounds.height - barHeight - distance) + } + + /// The window's rendered colour at `point`. + func renderedColor(at point: CGPoint, backdrop: UIColor) -> UIColor { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { ctx in + backdrop.setFill() + ctx.fill(window.bounds) + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let cg = image.cgImage! + var pixel = [UInt8](repeating: 0, count: 4) + let context = CGContext( + data: &pixel, width: 1, height: 1, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.draw(cg, in: CGRect(x: -point.x, y: point.y - CGFloat(cg.height) + 1, width: CGFloat(cg.width), height: CGFloat(cg.height))) + return UIColor(red: CGFloat(pixel[0]) / 255, green: CGFloat(pixel[1]) / 255, blue: CGFloat(pixel[2]) / 255, alpha: 1) + } + + func tearDown() { + window.isHidden = true + Database.removeTemp(at: databaseURL) + } + + fileprivate final class ScreenBox { + weak var screen: ChatScreenViewController? + var barHeight: CGFloat = 0 + } +} + +/// The bar as `ChatScreenRepresentable` hosts it: measured, bottom-pinned, and lifting the screen's +/// clip while the panel holds the room above it. +private struct HostedBar: View { + + let controller: ConversationController + let model: ConversationBarModel + let composer: ComposerModel + let screenBox: AttachPanelHost.ScreenBox + + var body: some View { + ConversationBottomBar( + showsSendCash: true, + chatExists: true, + conversationID: .test(1), + symbol: "$", + onSendCash: {}, + model: model, + composer: composer, + acceptsMedia: true + ) + .environment(controller) + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self, of: { $0.size.height }) { height in + screenBox.barHeight = height + screenBox.screen?.setBarHeight(height, accessories: BarAccessories()) + } + .frame(maxHeight: .infinity, alignment: .bottom) + .modifier(BarOverflowReporting(model: model) { [screenBox] in screenBox.screen }) + } +} + +@MainActor +@Suite("Attach panel placement") +struct AttachPanelPlacementTests { + + @Test("The open panel floats above the bar, over the transcript, and takes taps there") + func openPanel_sitsAbovePlus() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + host.model.attachPanel.open() + await host.settle() + + // Inside the panel's rows, above the bar: before the fix the panel hung down from `+`'s top + // and nothing of the bar reached here. + let point = host.pointAboveBar(by: 40) + let transcript = host.renderedColor(at: CGPoint(x: 40, y: 100), backdrop: .black) + let probe = host.renderedColor(at: point, backdrop: .black) + #expect(!probe.isClose(to: transcript), "Panel is not drawn above the bar") + + // The screen lifts its clip for the panel, so taps there reach the bar rather than the transcript. + let hit = try #require(host.window.hitTest(point, with: nil)) + #expect(hit.isDescendant(of: host.barHost.view), "Taps above the bar miss the panel") + } + + @Test("The card stands on the composer over the transcript, takes taps on itself, and lets taps above it through") + func card_overlaysTranscript() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + let barHeight = host.barHeight + + // Photos, not the camera, which would put up the system's permission prompt in the test host. + host.model.attachCard.open(.photos, screenHeight: host.window.bounds.height) + await host.settle() + + // The card is an overlay: the bar, and so the transcript's inset, keeps the composer's height. + #expect(host.barHeight == barHeight, "The card resized the bar") + + let cardTop = host.model.cardTop + let cardBottom = cardTop + host.model.attachCard.height + let barTop = host.window.bounds.height - barHeight + #expect(cardBottom > barTop + 20 && cardBottom <= host.window.bounds.height, "The card does not stand on the composer row") + + let onCard = CGPoint(x: host.window.bounds.midX, y: cardTop + 60) + let onCardHit = try #require(host.window.hitTest(onCard, with: nil)) + #expect(onCardHit.isDescendant(of: host.barHost.view), "Taps on the card miss it") + + let aboveCard = CGPoint(x: host.window.bounds.midX, y: cardTop - 40) + let aboveHit = host.window.hitTest(aboveCard, with: nil) + #expect(aboveHit.map { !$0.isDescendant(of: host.barHost.view) } ?? true, "The bar swallows taps above the card") + } + + @Test("Adding from the card shrinks the surface onto the staged chip, which shows once it has landed") + func card_landsOnChip() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + host.model.attachCard.open(.photos, screenHeight: host.window.bounds.height) + await host.settle() + + let red = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 40)).image { ctx in + UIColor.red.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 40, height: 40)) + } + let chipID = try #require( + host.composer.stageChip(image: red, preview: red, uploader: ChatMediaUploader(blob: MockChatMediaBlobStore()))?.id + ) + host.model.attachCard.beginLanding(on: chipID) + + // The strip lays the chip out, the bar reports it, and the card shrinks onto it. + await host.settle() + #expect(!host.model.attachCard.isOpen, "The card never shrank onto the chip") + await host.settle() + #expect(host.model.attachCard.landingChipID == nil, "The landing never finished") + #expect(host.composer.chips.count == 1) + } +} + +private extension UIColor { + + func isClose(to other: UIColor, tolerance: CGFloat = 0.02) -> Bool { + var (r1, g1, b1, a1): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + var (r2, g2, b2, a2): (CGFloat, CGFloat, CGFloat, CGFloat) = (0, 0, 0, 0) + getRed(&r1, green: &g1, blue: &b1, alpha: &a1) + other.getRed(&r2, green: &g2, blue: &b2, alpha: &a2) + return abs(r1 - r2) < tolerance && abs(g1 - g2) < tolerance && abs(b1 - b2) < tolerance + } +} diff --git a/FlipcashTests/Chat/AttachPanelTests.swift b/FlipcashTests/Chat/AttachPanelTests.swift new file mode 100644 index 000000000..72c8bf029 --- /dev/null +++ b/FlipcashTests/Chat/AttachPanelTests.swift @@ -0,0 +1,282 @@ +// +// AttachPanelTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import Foundation +import FlipcashUI +@testable import Flipcash + +@MainActor +@Suite("Attach panel") +struct AttachPanelTests { + + @Test("A fresh panel is closed and asks the bar for no room above it") + func freshPanelIsClosed() { + let panel = AttachPanel() + #expect(!panel.isOpen) + #expect(!panel.holdsOverflow) + } + + @Test("Tapping + opens the panel and a second tap collapses it back") + func toggleOpensAndCollapses() { + let panel = AttachPanel() + panel.toggle() + #expect(panel.isOpen) + #expect(panel.holdsOverflow) + + panel.toggle() + #expect(!panel.isOpen) + } + + @Test("Dismissing keeps the room above the bar until the collapse has finished") + func overflowOutlivesTheCollapse() { + let panel = AttachPanel() + panel.open() + panel.dismiss() + #expect(!panel.isOpen) + #expect(panel.holdsOverflow) + + panel.exitDidFinish() + #expect(!panel.holdsOverflow) + } + + @Test("A collapse that finishes after the panel reopened keeps the room") + func staleExitLeavesOpenPanelAlone() { + let panel = AttachPanel() + panel.open() + panel.dismiss() + panel.open() + + panel.exitDidFinish() + #expect(panel.isOpen) + #expect(panel.holdsOverflow) + } + + @Test("Dismissing a closed panel changes nothing") + func dismissWhenClosedIsNoOp() { + let panel = AttachPanel() + panel.dismiss() + #expect(!panel.isOpen) + #expect(!panel.holdsOverflow) + } + + @Test("Back from a card takes it down and puts the panel back up, in one change") + func barReturnsToMenu() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screenHeight: 874) + + model.returnToMenu() + #expect(!model.attachCard.isOpen) + #expect(model.attachPanel.isOpen) + #expect(model.attachPanel.holdsOverflow) + #expect(model.attachSurfacePhase == .menu) + } + + @Test("Back with no card up leaves the panel down") + func returnToMenuWithoutCardIsNoOp() { + let model = ConversationBarModel() + model.returnToMenu() + #expect(!model.attachPanel.isOpen) + } + + @Test("Back from the photo card discards the pick") + func backResetsPick() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screenHeight: 874) + model.photosPick.showsLibrary = true + + model.returnToMenu() + #expect(model.photosPick.selection.isEmpty) + #expect(!model.photosPick.showsLibrary) + } + + @Test("Adding hands the loader on and starts a fresh one, so its reads are not cancelled") + func addHandsOffPreloader() { + let pick = AttachPhotosPick() + let before = pick.preloader + var received: AnyObject? + pick.handOff { _, preloader in received = preloader } + #expect(received === before) + #expect(pick.preloader !== before) + #expect(pick.selection.isEmpty) + } + + @Test("The bar asks for room above itself everywhere for the panel, and only over the card for a card") + func barOverflow() { + let model = ConversationBarModel() + #expect(model.overflow == .none) + + model.attachPanel.open() + #expect(model.overflow == .everywhere) + #expect(model.overflow.touchTop == nil) + + model.attachPanel.dismiss() + model.attachPanel.exitDidFinish() + model.attachCard.open(.camera, screenHeight: 874) + model.cardTop = 300 + #expect(model.overflow == .band(top: 300)) + + // Leaving, the card takes no touches; once it has gone, the bar stops overflowing. + model.attachCard.close() + #expect(model.overflow.overflows) + #expect(model.overflow.touchTop == .greatestFiniteMagnitude) + model.attachCard.exitDidFinish() + #expect(model.overflow == .none) + } + + @Test("Over the keyboard the bar claims no room, even with the panel up: the overlay takes its touches") + func overKeyboardClaimsNoRoom() { + let model = ConversationBarModel() + model.overKeyboard.activate(items: [.camera, .photos]) + model.attachPanel.open() + #expect(model.overflow == .none) + } +} + +@MainActor +@Suite("Attach surface") +struct AttachSurfaceTests { + + @Test("The surface shows the menu, then the card in its place, then the landing, then nothing") + func phases() { + let model = ConversationBarModel() + #expect(model.attachSurfacePhase == .collapsed) + #expect(!model.attachSurfaceIsMounted) + + model.attachPanel.open() + #expect(model.attachSurfacePhase == .menu) + #expect(model.attachSurfaceIsMounted) + + // One transaction in the app: the panel closes as the card opens, and the card wins. + model.attachPanel.dismiss() + model.attachCard.open(.camera, screenHeight: 874) + #expect(model.attachSurfacePhase == .card(.camera)) + + let chip = UUID() + model.attachCard.beginLanding(on: chip) + #expect(model.attachSurfacePhase == .card(.camera), "The card stays up until the chip is laid out") + model.attachCard.close() + #expect(model.attachSurfacePhase == .landing) + + model.attachPanel.exitDidFinish() + model.attachCard.exitDidFinish() + #expect(model.attachSurfaceIsMounted, "The surface stays until it has landed") + model.attachCard.endLanding() + #expect(model.attachSurfacePhase == .collapsed) + #expect(!model.attachSurfaceIsMounted) + } + + @Test("Only the menu shows rows, and only a card shows card content") + func contentPerPhase() { + #expect(AttachSurfacePhase.menu.showsRows) + #expect(AttachSurfacePhase.menu.shownCard == nil) + #expect(!AttachSurfacePhase.card(.photos).showsRows) + #expect(AttachSurfacePhase.card(.photos).shownCard == .photos) + #expect(!AttachSurfacePhase.collapsed.showsRows && AttachSurfacePhase.collapsed.shownCard == nil) + #expect(!AttachSurfacePhase.landing.showsRows && AttachSurfacePhase.landing.shownCard == nil) + } + + @Test("One shape per phase: +, the menu, the card, and the chip, each with its own radius") + func shapes() { + let plus = CGRect(x: 16, y: 700, width: 44, height: 44) + let menu = CGRect(x: 16, y: 600, width: 240, height: 144) + let card = CGRect(x: 12, y: 300, width: 378, height: 500) + let chip = CGRect(x: 20, y: 640, width: 56, height: 56) + + let collapsed = AttachSurfaceLayout.shape(for: .collapsed, plus: plus, menu: menu, card: card, landing: chip) + #expect(collapsed == AttachSurfaceShape(rect: plus, cornerRadius: AttachSurfaceLayout.plusCornerRadius)) + let open = AttachSurfaceLayout.shape(for: .menu, plus: plus, menu: menu, card: card, landing: nil) + #expect(open == AttachSurfaceShape(rect: menu, cornerRadius: AttachSurfaceLayout.menuCornerRadius)) + let carded = AttachSurfaceLayout.shape(for: .card(.camera), plus: plus, menu: menu, card: card, landing: nil) + #expect(carded == AttachSurfaceShape(rect: card, cornerRadius: AttachSurfaceLayout.cardCornerRadius)) + let landed = AttachSurfaceLayout.shape(for: .landing, plus: plus, menu: menu, card: card, landing: chip) + #expect(landed == AttachSurfaceShape(rect: chip, cornerRadius: AttachSurfaceLayout.chipCornerRadius)) + let waiting = AttachSurfaceLayout.shape(for: .landing, plus: plus, menu: menu, card: card, landing: nil) + #expect(waiting == carded, "Without the chip's frame the surface holds the card's") + } + + @Test("In the bar the menu stands on +; over the keyboard it straddles the composer's bottom edge") + func menuPlacement() { + let plus = CGRect(x: 16, y: 700, width: 44, height: 44) + let size = CGSize(width: 240, height: 144) + let standing = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .standsOnPlus) + #expect(standing == CGRect(x: 16, y: 600, width: 240, height: 144)) + let straddling = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .straddlesPlus) + #expect(straddling == AttachOverlayLayout.panelFrame(plusFrame: plus, size: size)) + } + + @Test("The bar's card stands on the row's bottom edge, out to the keyboard-up margin") + func barCard() { + let rect = AttachSurfaceLayout.barCardRect(row: CGSize(width: 340, height: 44), outset: 12, height: 500) + #expect(rect == CGRect(x: -12, y: -456, width: 364, height: 500)) + } + + @Test("Only the menu takes touches while it shows, and only the card while it is up") + func regions() { + let menu = CGRect(x: 0, y: 0, width: 10, height: 10) + let card = CGRect(x: 0, y: 0, width: 20, height: 20) + #expect(AttachSurfaceLayout.regions(for: .menu, menu: menu, card: card) == [.panel: menu]) + #expect(AttachSurfaceLayout.regions(for: .card(.photos), menu: menu, card: card) == [.card: card]) + #expect(AttachSurfaceLayout.regions(for: .collapsed, menu: menu, card: card).isEmpty) + #expect(AttachSurfaceLayout.regions(for: .landing, menu: menu, card: card).isEmpty) + } + + @Test("A landing waits for the chip's frame, then shrinks the card onto it") + func landingWaitsForChip() { + let model = ConversationBarModel() + model.attachCard.open(.photos, screenHeight: 874) + let chip = UUID() + model.attachCard.beginLanding(on: chip) + + model.landingChipDidLayout(.zero) + #expect(model.attachCard.isOpen, "An unlaid-out chip has no frame to land on") + let frame = CGRect(x: 20, y: 600, width: 56, height: 56) + model.landingChipDidLayout(frame) + #expect(!model.attachCard.isOpen) + } + + @Test("A chip laid out with no landing under way is ignored") + func landingIgnoresOtherChips() { + let card = AttachCard() + #expect(!card.landingChipDidLayout(CGRect(x: 0, y: 0, width: 56, height: 56))) + #expect(card.landingChipFrame == nil) + } +} + +@Suite("Attach motion") +struct AttachMotionTests { + + @Test("Reduce Motion cross-fades instead of morphing") + func reduceMotionCrossFades() { + #expect(AttachMotion(reduceMotion: true) == .crossFade) + #expect(AttachMotion(reduceMotion: false) == .morph) + } + + @Test("Only the morph moves the surface; under Reduce Motion it stands still and its content fades") + func onlyMorphAnimatesGeometry() { + #expect(AttachMotion.morph.animatesGeometry) + #expect(!AttachMotion.crossFade.animatesGeometry) + } + + @Test("The morph grows chips from the chat's tuned scale; the cross-fade holds full size") + func enterScales() { + #expect(AttachMotion.morph.chipEnterScale == ChatMotion.composerChipEnterScale) + #expect(AttachMotion.crossFade.chipEnterScale == 1) + } + + @Test("The attach flow reuses tuned springs rather than numbers of its own") + func tokensReuseTunedSprings() { + #expect(ChatMotion.attachPanel == ChatMotion.swap) + #expect(ChatMotion.composerChip == ChatMotion.reaction) + } + + @Test("The card opens and closes without overshoot") + func attachCardDoesNotOvershoot() { + #expect(ChatMotion.attachCard == ChatMotion.keyboardScroll) + #expect(ChatMotion.attachCard.bounce == 0) + } +} diff --git a/FlipcashTests/Chat/AttachWarmUpTests.swift b/FlipcashTests/Chat/AttachWarmUpTests.swift new file mode 100644 index 000000000..63fda095d --- /dev/null +++ b/FlipcashTests/Chat/AttachWarmUpTests.swift @@ -0,0 +1,81 @@ +// +// AttachWarmUpTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import AVFoundation +@testable import Flipcash + +@MainActor +@Suite("Attach warm-up") +struct AttachWarmUpTests { + + @Test("Without camera access the warm-up makes no camera, so nothing can prompt", arguments: [ + AVAuthorizationStatus.notDetermined, .denied, .restricted, + ]) + func noAccessMakesNoCamera(status: AVAuthorizationStatus) { + let warmUp = AttachWarmUp(cameraAccess: { status }) + warmUp.update(wanted: true) + #expect(warmUp.isWarm) + #expect(!warmUp.hasCamera) + } + + @Test("With access the panel opening makes the camera and starts preparing it") + func accessWarmsCamera() { + let warmUp = AttachWarmUp(cameraAccess: { .authorized }) + warmUp.update(wanted: true) + #expect(warmUp.hasCamera) + } + + @Test("Closing the panel without a choice cools the warm-up") + func closingCools() { + let warmUp = AttachWarmUp(cameraAccess: { .authorized }) + warmUp.update(wanted: true) + warmUp.update(wanted: false) + #expect(!warmUp.isWarm) + } + + @Test("A chat whose menu never opens never makes a camera") + func idleMakesNoCamera() { + let warmUp = AttachWarmUp(cameraAccess: { .authorized }) + warmUp.update(wanted: false) + #expect(!warmUp.hasCamera) + #expect(!warmUp.isWarm) + } + + @Test("Photos is ready once the picker has been mounted for its lead, and not before") + func photosReadyAfterLead() { + let warmUp = AttachWarmUp(cameraAccess: { .denied }) + #expect(!warmUp.isReady(for: .photos)) + + warmUp.pickerDidMount() + let mounted = try! #require(warmUp.pickerMountedAt) + #expect(!warmUp.isReady(for: .photos, at: mounted)) + #expect(warmUp.isReady(for: .photos, at: mounted + AttachWarmUp.pickerLead)) + + warmUp.pickerDidUnmount() + #expect(!warmUp.isReady(for: .photos)) + } + + @Test("A camera without access is ready at once: its card asks, nothing is mounted to wait on") + func cameraWithoutAccessIsReady() { + let warmUp = AttachWarmUp(cameraAccess: { .notDetermined }) + #expect(warmUp.isReady(for: .camera)) + #expect(warmUp.isReady(for: .cash)) + } + + @Test("A card whose content is late opens within the readiness budget anyway") + func whenReadyGivesUpAtBudget() async { + let warmUp = AttachWarmUp(cameraAccess: { .denied }) + let start = ContinuousClock.now + await withCheckedContinuation { continuation in + warmUp.whenReady(for: .photos) { continuation.resume() } + } + let waited = ContinuousClock.now - start + #expect(waited >= AttachWarmUp.readinessBudget) + #expect(waited < .seconds(1)) + } +} diff --git a/FlipcashTests/Chat/ChatCameraSheetTests.swift b/FlipcashTests/Chat/ChatCameraSheetTests.swift index 9e9d66d26..f2ebe2fd5 100644 --- a/FlipcashTests/Chat/ChatCameraSheetTests.swift +++ b/FlipcashTests/Chat/ChatCameraSheetTests.swift @@ -40,6 +40,37 @@ struct ChatCameraSheetTests { #expect(ChatPhotoCamera.flipped(from) == to) } + @Test("The flash toggles between off and on", arguments: [ + (AVCaptureDevice.FlashMode.off, AVCaptureDevice.FlashMode.on), + (.on, .off), + (.auto, .off), + ]) + func flashToggles(from: AVCaptureDevice.FlashMode, to: AVCaptureDevice.FlashMode) { + #expect(ChatPhotoCamera.toggled(from) == to) + } + + @Test("A shot asks for the flash only when the lens can fire it", arguments: [ + (AVCaptureDevice.FlashMode.on, [AVCaptureDevice.FlashMode.off, .on, .auto], AVCaptureDevice.FlashMode.on), + (.on, [.off], .off), + (.on, [], .off), + (.off, [.off, .on], .off), + ]) + func flashResolvesAgainstSupport( + requested: AVCaptureDevice.FlashMode, + supported: [AVCaptureDevice.FlashMode], + expected: AVCaptureDevice.FlashMode + ) { + #expect(ChatPhotoCamera.resolvedFlashMode(requested, supported: supported) == expected) + } + + @Test("A fresh camera starts with the flash off and toggles it on") + func freshCameraFlashOff() { + let camera = ChatPhotoCamera() + #expect(camera.flashMode == .off) + camera.toggleFlash() + #expect(camera.flashMode == .on) + } + @Test("The shutter on a camera that never started returns no photo") func captureBeforeStartReturnsNil() async { let camera = ChatPhotoCamera() diff --git a/FlipcashTests/Chat/ChatPhotoStagingTests.swift b/FlipcashTests/Chat/ChatPhotoStagingTests.swift index 2e5a171ba..5ff29af90 100644 --- a/FlipcashTests/Chat/ChatPhotoStagingTests.swift +++ b/FlipcashTests/Chat/ChatPhotoStagingTests.swift @@ -5,8 +5,10 @@ // Copyright © 2026 Code Inc. All rights reserved. // +import ImageIO import Testing import UIKit +import UniformTypeIdentifiers import FlipcashCore @testable import Flipcash @@ -78,6 +80,154 @@ struct ChatPhotoStagingTests { #expect(staged.map(\.image) == [picked[0]]) #expect(composer.chips.count == ComposerModel.maxAttachments) } + + @Test("Added photos that are already loaded stage at once, in selection order, handing off the first") + func addedLoadedStageAtOnce() async { + let composer = ComposerModel() + let picked = images(3) + + let added = ChatPhotoStaging.stageAdded( + Array(picked.indices), into: composer, uploader: uploader, + loaded: { picked[$0] }, load: { picked[$0] } + ) + + #expect(added.remainder == nil) + #expect(composer.chips.map(\.image) == picked) + #expect(added.handOff == composer.chips.first?.id) + } + + @Test("Added photos still loading stage after the loaded ones without blocking, keeping selection order") + func addedUnloadedStageLater() async { + let composer = ComposerModel() + let picked = images(3) + + let added = ChatPhotoStaging.stageAdded( + Array(picked.indices), into: composer, uploader: uploader, + loaded: { $0 == 0 ? picked[0] : nil }, load: { picked[$0] } + ) + + #expect(composer.chips.map(\.image) == [picked[0]]) + #expect(added.handOff == composer.chips.first?.id) + await added.remainder?.value + #expect(composer.chips.map(\.image) == picked) + } + + @Test("When the first added photo is still loading nothing is handed off and nothing waits for it") + func addedFirstUnloadedHandsOffNothing() async { + let composer = ComposerModel() + let picked = images(2) + + let added = ChatPhotoStaging.stageAdded( + Array(picked.indices), into: composer, uploader: uploader, + loaded: { $0 == 1 ? picked[1] : nil }, load: { picked[$0] } + ) + + #expect(added.handOff == nil) + #expect(composer.chips.isEmpty) + await added.remainder?.value + // The second, though loaded first, still lands after the first. + #expect(composer.chips.map(\.image) == picked) + } + + @Test("Adding stops at a full composer") + func addedStopsWhenFull() throws { + let composer = ComposerModel() + for image in images(ComposerModel.maxAttachments - 1) { + try #require(composer.stageChip(image: image, uploader: uploader) != nil) + } + let picked = images(3) + + let added = ChatPhotoStaging.stageAdded( + Array(picked.indices), into: composer, uploader: uploader, + loaded: { picked[$0] }, load: { picked[$0] } + ) + + #expect(added.remainder == nil) + #expect(composer.chips.count == ComposerModel.maxAttachments) + #expect(composer.chips.last?.image == picked[0]) + } + + @Test("A photo with a sideways EXIF orientation decodes with the turn drawn into its pixels") + func decodeBakesOrientation() async throws { + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData(data, UTType.jpeg.identifier as CFString, 1, nil)) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let pixels = try #require(UIGraphicsImageRenderer(size: CGSize(width: 4, height: 3), format: format).image { context in + UIColor.blue.setFill() + context.fill(CGRect(x: 0, y: 0, width: 4, height: 3)) + }.cgImage) + CGImageDestinationAddImage(destination, pixels, [kCGImagePropertyOrientation: CGImagePropertyOrientation.right.rawValue] as CFDictionary) + #expect(CGImageDestinationFinalize(destination)) + + let decoded = try #require(await ChatPhotoStaging.decode(data as Data)) + + #expect(decoded.imageOrientation == .up) + #expect(decoded.size == CGSize(width: 3, height: 4)) + } +} + +@MainActor +@Suite("Chat photo preloader") +struct ChatPhotoPreloaderTests { + + private static func image() -> UIImage { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2), format: format).image { _ in } + } + + @Test("Selecting starts a load for each new item and keeps what it loads") + func selectingLoads() async { + let image = Self.image() + let preloader = ChatPhotoPreloader { _ in image } + + preloader.update(selection: [1, 2]) + #expect(preloader.trackedItems == [1, 2]) + + #expect(await preloader.image(for: 1) === image) + #expect(await preloader.image(for: 2) === image) + } + + @Test("An item already loading is not loaded again") + func noDuplicateLoads() async { + var loads: [Int] = [] + let preloader = ChatPhotoPreloader { item in + loads.append(item) + return Self.image() + } + + preloader.update(selection: [1]) + preloader.update(selection: [1, 2]) + _ = await preloader.image(for: 1) + _ = await preloader.image(for: 2) + + #expect(loads.sorted() == [1, 2]) + } + + @Test("Deselecting drops the item's load and image") + func deselectingDrops() async { + let preloader = ChatPhotoPreloader { _ in Self.image() } + preloader.update(selection: [1, 2]) + _ = await preloader.image(for: 1) + + preloader.update(selection: [2]) + + #expect(preloader.trackedItems == [2]) + #expect(preloader.loadedImage(for: 1) == nil) + } + + @Test("Reset cancels every load and lets go of every image") + func resetClears() async { + let preloader = ChatPhotoPreloader { _ in Self.image() } + preloader.update(selection: [1, 2]) + _ = await preloader.image(for: 1) + + preloader.reset() + + #expect(preloader.trackedItems.isEmpty) + #expect(preloader.images.isEmpty) + } } @MainActor @@ -178,37 +328,123 @@ struct ComposerOutgoingTests { } @MainActor -@Suite("Chat camera slot") -struct ChatCameraSlotTests { +@Suite("Attach card") +struct AttachCardTests { + + @Test("A fresh card is down, at the fallback height, and holds no room above the bar") + func freshCardIsClosed() { + let card = AttachCard() + #expect(!card.isOpen) + #expect(!card.holdsOverflow) + #expect(card.height == AttachCard.fallbackHeight) + } + + @Test("The card takes a fixed share of the screen's height", arguments: [ + (CGFloat(874), CGFloat(507)), + (CGFloat(667), CGFloat(387)), + ]) + func takesShareOfScreen(screenHeight: CGFloat, expected: CGFloat) { + let card = AttachCard() + card.open(.camera, screenHeight: screenHeight) + #expect(card.height == expected) + } + + @Test("An unmeasured screen falls back to the fixed height") + func unmeasuredScreenFallsBack() { + #expect(AttachCard.height(forScreenHeight: 0) == AttachCard.fallbackHeight) + } + + @Test("A keyboard rising takes down the card that is up, and its room above the bar", arguments: [ + AttachCard.Content.camera, + .photos, + ]) + func keyboardClosesCard(content: AttachCard.Content) { + let card = AttachCard() + card.open(content, screenHeight: 874) + #expect(card.content == content) + #expect(card.holdsOverflow) + + card.keyboardWillShow() + #expect(card.content == nil) + #expect(!card.holdsOverflow) + } + + @Test("A keyboard rising as a card starts landing on its chip leaves the card up to land") + func keyboardLeavesLandingAlone() { + let card = AttachCard() + let chipID = UUID() + card.open(.photos, screenHeight: 874) + card.beginLanding(on: chipID) - @Test("A fresh slot is closed at the fallback height") - func freshSlotIsClosed() { - let slot = ChatCameraSlot() - #expect(!slot.isOpen) - #expect(slot.height == ChatCameraSlot.fallbackHeight) + card.keyboardWillShow() + #expect(card.content == .photos) + #expect(card.landingChipID == chipID) } - @Test("The slot takes the keyboard's height above the bottom safe area") - func takesKeyboardHeight() { - let slot = ChatCameraSlot() - slot.keyboardWillShow(height: 336, safeAreaBottom: 34) - #expect(slot.height == 302) + @Test("Over the keyboard, an input view swap announcing the keyboard leaves an opening card alone") + func keyboardGateHoldsOverKeyboard() { + let card = AttachCard() + card.closesOnKeyboardShow = false + card.open(.camera, screenHeight: 874) + card.keyboardWillShow() + #expect(card.content == .camera) } - @Test("A keyboard rising closes the camera") - func keyboardClosesCamera() { - let slot = ChatCameraSlot() - slot.open() - #expect(slot.isOpen) + @Test("The card holds the camera or the photo card, not both") + func holdsOneContent() { + let card = AttachCard() + card.open(.photos, screenHeight: 874) + card.open(.camera, screenHeight: 874) + #expect(card.content == .photos) - slot.keyboardWillShow(height: 336, safeAreaBottom: 34) - #expect(!slot.isOpen) + card.close() + card.open(.camera, screenHeight: 874) + #expect(card.content == .camera) } - @Test("A keyboard no taller than the safe area leaves the height alone") - func ignoresDegenerateKeyboard() { - let slot = ChatCameraSlot() - slot.keyboardWillShow(height: 20, safeAreaBottom: 34) - #expect(slot.height == ChatCameraSlot.fallbackHeight) + @Test("A card back up before the last exit finished keeps its room above the bar") + func reopenKeepsOverflow() { + let card = AttachCard() + card.open(.camera, screenHeight: 874) + card.close() + card.open(.photos, screenHeight: 874) + card.exitDidFinish() + #expect(card.holdsOverflow) } } + +@MainActor +@Suite("Chat camera capture hand-off") +struct ChatCameraCaptureHandOffTests { + + @Test("A capture waits for its chip's frame, then the card shrinks onto it and the chip shows") + func captureLandsOnChip() { + let card = AttachCard() + let chipID = UUID() + card.open(.camera, screenHeight: 874) + + card.beginLanding(on: chipID) + #expect(card.isOpen, "The card holds until the chip is laid out") + #expect(card.landingChipID == chipID) + + let frame = CGRect(x: 20, y: 700, width: 64, height: 64) + #expect(card.landingChipDidLayout(frame)) + #expect(card.landingChipFrame == frame) + + card.close() + card.endLanding() + card.exitDidFinish() + #expect(card.landingChipID == nil) + #expect(!card.holdsOverflow) + } + + @Test("A chip laid out after the card has gone has nothing to land") + func lateChipLandsNothing() { + let card = AttachCard() + card.open(.camera, screenHeight: 874) + card.beginLanding(on: UUID()) + card.close() + #expect(!card.landingChipDidLayout(CGRect(x: 0, y: 0, width: 64, height: 64))) + } + +} diff --git a/FlipcashTests/Chat/ChatPhotosCardTests.swift b/FlipcashTests/Chat/ChatPhotosCardTests.swift new file mode 100644 index 000000000..1d0952b6d --- /dev/null +++ b/FlipcashTests/Chat/ChatPhotosCardTests.swift @@ -0,0 +1,29 @@ +// +// ChatPhotosCardTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import PhotosUI +import Testing +@testable import Flipcash + +@MainActor +@Suite("Chat photos card") +struct ChatPhotosCardTests { + + // The picker runs out of process, so a tap can't be driven from here; this pins the behaviour + // that makes one reach the selection. With `.ordered`, a tapped photo was marked in the picker + // but the binding never changed, so the Add pill never appeared. + @Test("The picker reports each tap to the selection as it happens, in order") + func selectionBehavior_isContinuousAndOrdered() { + #expect(ChatPhotosCard.selectionBehavior == .continuousAndOrdered) + } + + @Test("The Add pill counts the selection") + func addTitle_countsSelection() { + #expect(ChatPhotosCard.addTitle(count: 1) == "Add 1 photo") + #expect(ChatPhotosCard.addTitle(count: 3) == "Add 3 photos") + } +} diff --git a/FlipcashTests/Chat/ComposerChipLayoutTests.swift b/FlipcashTests/Chat/ComposerChipLayoutTests.swift new file mode 100644 index 000000000..74b7be06b --- /dev/null +++ b/FlipcashTests/Chat/ComposerChipLayoutTests.swift @@ -0,0 +1,79 @@ +// +// ComposerChipLayoutTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +@testable import Flipcash + +@MainActor +@Suite("Composer chip layout") +struct ComposerChipLayoutTests { + + @Test("A portrait photo's chip stays inside its square, clear of the row below") + func portraitChip_staysInsideItsSquare() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + let portrait = UIGraphicsImageRenderer(size: CGSize(width: 200, height: 400)).image { ctx in + UIColor.green.setFill() + ctx.fill(CGRect(x: 0, y: 0, width: 200, height: 400)) + } + host.composer.stageChip(image: portrait, preview: portrait, uploader: ChatMediaUploader(blob: MockChatMediaBlobStore())) + await host.settle() + + // Down the chip's leading quarter, clear of the centred badge and the trailing remove button. + // The strip sits inside the field, which starts past `+`. Before the fix the photo filled at + // its own aspect and spilled past the 56pt square, over the text row below it. + let chipLeading = try #require( + stride(from: CGFloat(0), to: host.window.bounds.width, by: 2).first { x in + host.renderedColumn(atX: x, backdrop: .black).contains { $0.isGreenDominant } + }, + "The chip was not drawn" + ) + let column = host.renderedColumn(atX: chipLeading + 12, backdrop: .black) + let greenRows = column.filter { $0.isGreenDominant }.count + #expect(greenRows > 0, "The chip was not drawn") + #expect(greenRows <= Int(ComposerChipStrip.chipSize) + 1, "The chip is \(greenRows)pt tall") + } +} + +private extension AttachPanelHost { + + /// The window's rendered colours down the column at `x`, one per point. + func renderedColumn(atX x: CGFloat, backdrop: UIColor) -> [UIColor] { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { ctx in + backdrop.setFill() + ctx.fill(window.bounds) + window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) + } + let cg = image.cgImage! + let height = cg.height + var pixels = [UInt8](repeating: 0, count: height * 4) + let context = CGContext( + data: &pixels, width: 1, height: height, bitsPerComponent: 8, bytesPerRow: 4, + space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + )! + context.draw(cg, in: CGRect(x: -x, y: 0, width: CGFloat(cg.width), height: CGFloat(height))) + return (0.. 0.35 && g > r * 2 && g > b * 2 + } +} diff --git a/FlipcashTests/ConversationControllerTests.swift b/FlipcashTests/ConversationControllerTests.swift index 59a858961..8e6008901 100644 --- a/FlipcashTests/ConversationControllerTests.swift +++ b/FlipcashTests/ConversationControllerTests.swift @@ -1609,6 +1609,43 @@ struct ConversationControllerTests { #expect(preview == "You received $1.00 of Jeffy") } + /// The Chats row for an end-to-end-encrypted chat whose newest message is `content`. + private func encryptedConversation( + _ content: ConversationMessage.Content, + from sender: UserID?, + failure: ConversationMessage.DecryptFailure? = nil + ) -> Conversation { + let sealed = ConversationMessage.Sealed(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + return Conversation( + id: ConversationID.test(1), + members: [], + lastMessage: ConversationMessage( + id: MessageID(value: 1), + senderID: sender, + content: content, + date: Date(timeIntervalSince1970: 1), + unreadSeq: 1, + sealed: sealed, + decryptFailure: failure + ), + lastActivity: Date(timeIntervalSince1970: 1), + useE2Ee: true + ) + } + + @Test("an encrypted chat previews its decrypted text, and nothing while it cannot be read") + func encryptedPreview() { + let me = UUID() + let them = UUID() + let controller = makeController(MockConversations(), selfUserID: me) + let ciphertext = ConversationMessage.Content.encrypted(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + + #expect(controller.lastMessagePreview(for: encryptedConversation(.text("gm"), from: them)) { _ in nil } == "gm") + #expect(controller.lastMessagePreview(for: encryptedConversation(.text("gm"), from: me)) { _ in nil } == "You: gm") + #expect(controller.lastMessagePreview(for: encryptedConversation(ciphertext, from: them, failure: .authentication)) { _ in nil } == nil) + #expect(controller.lastMessagePreview(for: encryptedConversation(ciphertext, from: them)) { _ in nil } == nil) + } + @Test("the viewer's own message is prefixed with You, in a DM as in a group") func textPreviewPrefixesSelf() { let me = UUID() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift index 37c45e657..b84749603 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMotion.swift @@ -160,6 +160,25 @@ public nonisolated enum ChatMotion { public static let reactionReflow = reflow /// A pill's count or selected state changing in place. public static let reactionChange = ChatSpring(duration: 0.24, bounce: 0) + /// The attach panel growing out of `+` and collapsing back. The bar's own spring, since it moves + /// a piece of the bar; no reference frames were measured for it. A morph that also opens or + /// closes the camera or photo card rides `attachCard` instead. + public static let attachPanel = swap + /// The camera or photo card growing out of the panel, and closing into a chip or back into the + /// panel. Zero bounce, from `keyboardScroll`: an overshoot on a card half the screen tall carries + /// its edge well past where it rests. + public static let attachCard = keyboardScroll + /// The leaving side's content fading out while the panel and a card morph into each other: done in + /// the first third of `attachCard`, so only the shared surface is seen changing shape. + public static let attachContentOut = Animation.easeOut(duration: attachCard.duration * 0.35) + /// The arriving side's content fading in once the shared surface has nearly landed, so the two + /// directions of the morph are one motion played either way. Eased out: an ease-in ends at full + /// speed, and the content popped the last of the way in. + public static let attachContentIn = Animation.easeOut(duration: attachCard.duration * 0.55) + .delay(attachCard.duration * 0.45) + /// A chip arriving in or leaving the composer's strip, and its neighbours sliding to make room. + /// Small and tapped-for like a reaction pill, so it travels on the pill's spring. + public static let composerChip = reaction // MARK: - Scales @@ -181,6 +200,12 @@ public nonisolated enum ChatMotion { /// How far below its slot a row appended at the bottom starts, as a share of the room its arrival /// makes. At 1 it rides up in step with the rows it pushes, so it never overlaps the one above. public static let insertionRise: CGFloat = 0.35 + /// The attach panel's starting scale as it grows out of `+`, and its ending scale as it + /// collapses back. The pill's figure: small enough that the panel reads as coming out of the + /// button rather than fading in beside it. + public static let attachPanelEnterScale: CGFloat = reactionEnterScale + /// A composer chip's starting scale as it arrives, and its ending scale as it is removed. + public static let composerChipEnterScale: CGFloat = reactionEnterScale // MARK: - Timing diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 16c4ec1ec..6e44d29f5 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -35,7 +35,7 @@ public final class ChatScreenViewController: UIViewController { /// capture, that put the composer row 24pt — half the strip's 48 — from where it belongs. Pinned /// to the bottom of a box that clips it, the bar's frame can jump to the strip's full height /// while the box uncovers it, and the composer row does not move at all. - private let barClip = UIView() + private let barClip = BarClipView() /// Carries the chat background on below the fade, so the keyboard has that behind it rather /// than the transcript. /// @@ -59,6 +59,26 @@ public final class ChatScreenViewController: UIViewController { /// ramp's dark end never lands on the bubble the transcript just moved down. private var fadeTopConstraint: NSLayoutConstraint! private var barClipHeightConstraint: NSLayoutConstraint! + + /// Whether the bar draws and takes touches above its own frame, for a panel it floats over the + /// transcript. While set, the bar's view reaches the top of the screen with its content still on + /// its bottom edge, the clip stops cutting it, and the transcript's inset is left alone. + public var barOverflowsTop = false { + didSet { + guard barOverflowsTop != oldValue, barClipHeightConstraint != nil else { return } + // The bar is already as tall as the screen and pinned to the clip's bottom, so letting + // it draw and take touches above the clip is all the overflow needs. + barClip.clipsToBounds = !barOverflowsTop + barClip.overflowTarget = barOverflowsTop ? bar : nil + } + } + /// Where, in window coordinates, the bar starts taking touches while it overflows, or `nil` for + /// everywhere it reaches. A card standing over the transcript takes touches on itself and lets the + /// rest through; a panel that dismisses on any outside touch takes them all. + public var barOverflowTouchTop: CGFloat? { + didSet { barClip.overflowTouchTop = barOverflowTouchTop } + } + /// Keeps the bar above the keyboard. Not `view.keyboardLayoutGuide`: see `KeyboardFloor`. private var keyboardFloor: KeyboardFloor! @@ -310,6 +330,8 @@ public final class ChatScreenViewController: UIViewController { /// The lift on screen, if any: its overlay and the message it raised. private var lift: (overlay: MessageLiftOverlay, message: ChatMessage)? + /// The layer over the transcript that takes its touches, if one is up. + private var transcriptShield: TranscriptShield? public override func viewDidLoad() { super.viewDidLoad() @@ -661,6 +683,53 @@ public final class ChatScreenViewController: UIViewController { /// the mention list, sent whenever it changes. public var onMentionRoomChange: ((CGFloat) -> Void)? + /// How far the keyboard reaches up into the screen, in points; zero while it is down. + public var keyboardOverlap: CGFloat { + keyboardFloor?.overlap ?? 0 + } + + /// Whether the composer's field holds the keyboard and can take a replacement input view. + public var canReplaceComposerInputView: Bool { + guard let responder = bar.firstTextInputResponder, responder.isFirstResponder else { return false } + return responder is UITextView || responder is UITextField + } + + /// Shows `inputView` in the keyboard's place under the composer's field, or the system keyboard + /// again for `nil`, without the field losing focus. Returns whether the field took it. + @discardableResult + public func setComposerInputView(_ inputView: UIView?) -> Bool { + switch bar.firstTextInputResponder { + case let textView as UITextView: + guard textView.inputView !== inputView else { return true } + textView.inputView = inputView + textView.reloadInputViews() + return true + case let textField as UITextField: + guard textField.inputView !== inputView else { return true } + textField.inputView = inputView + textField.reloadInputViews() + return true + default: + return false + } + } + + /// Lays a clear layer over the transcript that takes every touch and fires `onTap` instead, + /// while `isActive` holds; `nil` takes it down. Nothing under it sees the touch, so the keyboard + /// stays up and no message reacts. + public func setTranscriptShield(isActive: @escaping () -> Bool, onTap: (() -> Void)?) { + transcriptShield?.removeFromSuperview() + transcriptShield = nil + guard let onTap else { return } + let shield = TranscriptShield() + shield.isActive = isActive + shield.onTap = onTap + shield.frame = transcript.view.frame + shield.autoresizingMask = [.flexibleWidth, .flexibleHeight] + view.insertSubview(shield, aboveSubview: transcript.view) + transcriptShield = shield + } + /// Set the bar's height to its measured SwiftUI content height. `accessories` says which cards /// stand above the composer row, which decides what the clip around the bar does with that height. /// @@ -849,7 +918,7 @@ private final class KeyboardFloor { private var observer: (any NSObjectProtocol)? /// The keyboard's current overlap of the view, in points; zero when it is down. - private var overlap: CGFloat = 0 + private(set) var overlap: CGFloat = 0 init(view: UIView, bottomConstraint: NSLayoutConstraint, keyboardEdgeConstraint: NSLayoutConstraint) { self.view = view @@ -960,3 +1029,40 @@ private extension UIView { } #endif + +/// The box the bar is seen through, which also lets touches reach the bar where it reaches above the +/// box — see ``ChatScreenViewController/barOverflowsTop``. +private final class BarClipView: UIView { + + /// The subview allowed to take touches outside the box's bounds, while the bar overflows. + weak var overflowTarget: UIView? + /// Where, in window coordinates, the target starts taking those touches; `nil` for all of it. + var overflowTouchTop: CGFloat? + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + if let overflowTarget { + var region = overflowTarget.frame + if let overflowTouchTop { + let top = max(region.minY, convert(CGPoint(x: 0, y: overflowTouchTop), from: nil).y) + region = CGRect(x: region.minX, y: top, width: region.width, height: max(0, region.maxY - top)) + } + if region.contains(point) { return true } + } + return super.point(inside: point, with: event) + } +} + +/// A clear layer that swallows touches while its predicate holds and passes them through otherwise. +private final class TranscriptShield: UIView { + + var isActive: () -> Bool = { false } + var onTap: () -> Void = {} + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + isActive() && bounds.contains(point) ? self : nil + } + + override func touchesBegan(_ touches: Set, with event: UIEvent?) { + onTap() + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index 494dc60fc..8ccb29c49 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -34,6 +34,18 @@ extension View { } } + /// The app's glass surface for a panel that holds its own buttons: Liquid Glass on iOS 26, an + /// ultra-thin material below. Non-interactive, because `.interactive()` on the container competes + /// with its buttons for the tap. + @ViewBuilder + public func panelGlassBackground(cornerRadius: CGFloat) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular, in: .rect(cornerRadius: cornerRadius)) + } else { + background(.ultraThinMaterial, in: .rect(cornerRadius: cornerRadius)) + } + } + /// 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. @ViewBuilder From 2a95bb4f865e678ab35ee99212efa86d5b931f7d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 13:15:23 -0400 Subject: [PATCH 5/8] feat(chat): cache photos on disk and encrypt them in encrypted DMs Downloaded photos go to a dedicated Kingfisher disk cache keyed by blob id, so they no longer fall back to the blur hash after five minutes. In an encrypted DM a photo is sealed with the shared-core chat cipher, stored with endToEndEncryptedFor set to the chat, and sent as EncryptedContent. Incoming encrypted photos are decrypted before they are drawn, and one that can't be opened shows as unavailable. Blob rejections now log the server's reason code instead of "unknown". --- .../Controllers/ConversationController.swift | 30 +- .../Controllers/EncryptedChatClient.swift | 66 ++- .../Controllers/FlipClient+Protocols.swift | 11 +- .../Screens/Conversation/AttachMenu.swift | 11 + .../Conversation/ChatItem+Conversation.swift | 5 +- .../Conversation/ChatMediaUploader.swift | 131 +++++- .../ChatScreenRepresentable.swift | 23 +- .../Screens/Conversation/ComposerChip.swift | 8 +- .../Conversation/ComposerReplyStrip.swift | 14 +- .../Conversation/ConversationBottomBar.swift | 4 +- .../Conversation/ConversationScreen.swift | 31 +- .../Profile/DialogItem+ProfilePicture.swift | 2 +- .../FlipcashCore/Blob/BlobUploader.swift | 74 ++- .../Clients/Flip API/FlipClient+Blob.swift | 8 + .../Clients/Flip API/FlipClient+Chat.swift | 9 + .../Flip API/Services/BlobService.swift | 19 +- .../Services/ChatMessagingService.swift | 13 + .../Sources/FlipcashCore/Models/Blob.swift | 57 ++- .../Models/Chat/ChatMediaStrings.swift | 4 + .../Models/Chat/ChatMessage.swift | 5 +- .../Models/Chat/ChatPreviewMapping.swift | 3 +- .../FlipcashCore/Models/Chat/ChatQuote.swift | 5 +- .../Models/Conversation/ChatSeal.swift | 209 ++++++++- .../Conversation/ConversationMessage.swift | 10 +- .../Models/Conversation/MediaAttachment.swift | 21 +- .../Sources/FlipcashStore/Database.swift | 2 +- .../BlobRejectionReasonTests.swift | 31 ++ .../FlipcashCoreTests/BlobUploaderTests.swift | 75 ++- .../ChatPreviewDecryptionTests.swift | 19 + .../FlipcashCoreTests/ChatSealTests.swift | 170 +++++++ .../FlipcashCoreTests/UploadPolicyTests.swift | 16 + .../ChatCipherBlobVectorTests.swift | 121 +++++ .../Fixtures/chat_cipher.json | 438 ++++++++++++++++++ FlipcashTests/Chat/ChatMediaCellTests.swift | 116 ++++- .../Chat/ChatMediaURLResolverTests.swift | 79 +++- .../Chat/ChatMediaUploaderTests.swift | 114 ++++- FlipcashTests/Chat/ChatMediaViewerTests.swift | 8 +- FlipcashTests/Chat/ChatQuoteBubbleTests.swift | 6 +- .../Chat/ConversationMediaSendTests.swift | 4 +- .../Database+ConversationsTests.swift | 21 + .../TestSupport/MockChatMediaBlobStore.swift | 11 + .../TestSupport/MockConversations.swift | 20 +- .../FlipcashUI/Chat/ChatBubbleView.swift | 6 +- .../FlipcashUI/Chat/ChatLinkMessageCell.swift | 6 +- .../FlipcashUI/Chat/ChatMediaCell.swift | 47 +- .../Chat/ChatMediaImageSource.swift | 110 ++++- .../Chat/ChatMediaURLResolver.swift | 113 +++-- .../Chat/ChatMediaViewerController.swift | 18 +- .../FlipcashUI/Chat/ChatMessageCell.swift | 6 +- .../FlipcashUI/Chat/ChatQuotePanelView.swift | 32 +- .../FlipcashUI/Chat/ChatViewController.swift | 14 +- .../FlipcashUI/Chat/LinkableBubbleView.swift | 6 +- .../Sources/FlipcashUI/Images/BlurHash.swift | 101 ++++ 53 files changed, 2265 insertions(+), 218 deletions(-) create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/BlobRejectionReasonTests.swift create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatCipherBlobVectorTests.swift create mode 100644 FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/Fixtures/chat_cipher.json diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index a56b3603c..2eae378c8 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -2033,6 +2033,30 @@ final class ConversationController { /// posted only once its own chip's upload settles, so a later photo never lands before an /// earlier one. A chip that fails marks only its own bubble `.failed`, and the next chip still /// gets its turn. + /// The seal a photo staged for `conversationID` is encrypted with, nil when the chat takes + /// plaintext photos; throws when the chat encrypts but its seal can't be built. + func photoSeal(for conversationID: ConversationID) async throws -> ChatSeal? { + try await messaging.photoSeal(owner: owner, conversationID: conversationID) + } + + /// Decrypts `conversationID`'s end-to-end encrypted photo blobs, or nil while the chat's key is + /// unknown or the chat doesn't encrypt. + func mediaBlobDecrypt(for conversationID: ConversationID) async -> (@Sendable (Data, BlobID, SealedBlob) throws -> Data)? { + guard let seal = await messaging.openingSeal(owner: owner, conversationID: conversationID) else { return nil } + return { blob, blobID, sealed in + do { + return try seal.decryptBlob(blob, blobID: blobID, sealed: sealed) + } catch { + logger.warning("Encrypted chat photo failed to open", metadata: [ + "conversationID": "\(conversationID)", + "blobID": "\(blobID)", + "error": "\(error)", + ]) + throw error + } + } + } + @discardableResult func sendMedia( _ chips: [ComposerChip], @@ -2128,9 +2152,9 @@ final class ConversationController { logger.error("Photo send has no upload to await", metadata: ["conversationID": "\(conversationID)"]) return false } - let blobID: BlobID + let photo: UploadedPhoto do { - blobID = try await upload.value + photo = try await upload.value } catch { store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) logger.error("Failed to upload conversation photo", metadata: [ @@ -2146,7 +2170,7 @@ final class ConversationController { let message = try await messaging.sendMediaMessage( owner: owner, conversationID: conversationID, - blobID: blobID, + photo: photo, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID diff --git a/Flipcash/Core/Controllers/EncryptedChatClient.swift b/Flipcash/Core/Controllers/EncryptedChatClient.swift index 9078a42e9..4837f2694 100644 --- a/Flipcash/Core/Controllers/EncryptedChatClient.swift +++ b/Flipcash/Core/Controllers/EncryptedChatClient.swift @@ -9,6 +9,8 @@ import Foundation import os import FlipcashCore +private let logger = Logger(label: "flipcash.encrypted-chat") + /// The chat surface `ConversationController` talks to, with DM end-to-end encryption applied at /// the edge: every message read from the server comes back decrypted (or marked with why it /// couldn't be), and every text sent into an encrypting chat goes out sealed. @@ -135,9 +137,67 @@ extension EncryptedChatClient: ConversationMessaging { } } - /// Media has no sealed form, so a photo goes out in plaintext whatever the chat's encryption. - func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { - try await client.sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID) + /// Sends a photo the way it was uploaded, after checking the chat still decides the same way: a + /// plaintext blob never goes into a chat that encrypts, nor a sealed one into a chat that doesn't. + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + try await refetchingOnRefusal(conversationID) { + let seal = try await sealForSending(conversationID, owner: owner) + switch Self.photoSend(photo, seal: seal, conversationID: conversationID) { + case .plain(let blobID): + return try await client.sendMediaMessage(owner: owner, conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID) + case .sealed(let sealed, let seal): + return try await client.sendSealedMediaMessage(owner: owner, conversationID: conversationID, photo: sealed, caption: caption, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID) + case .mismatch: + logger.error("Photo was uploaded for a different encryption choice", metadata: [ + "conversationID": "\(conversationID)", + "sealed": "\(seal != nil)", + ]) + throw ErrorSendMessage.encryptionFailed + } + } + } + + /// How an uploaded photo goes out given the chat's current seal. + enum PhotoSend: Equatable { + /// Plaintext, into a chat that doesn't encrypt. + case plain(BlobID) + /// Sealed, into the chat it was encrypted for. + case sealed(SealedPhoto, ChatSeal) + /// The photo was uploaded for a different encryption choice than the chat now makes. + case mismatch + + static func == (lhs: PhotoSend, rhs: PhotoSend) -> Bool { + switch (lhs, rhs) { + case (.plain(let a), .plain(let b)): a == b + case (.sealed(let a, _), .sealed(let b, _)): a == b + case (.mismatch, .mismatch): true + case (.plain, _), (.sealed, _), (.mismatch, _): false + } + } + } + + /// Matches `photo` to `seal`: a plaintext blob never goes into a chat that encrypts, nor a + /// sealed one into a chat that doesn't or a different chat. + static func photoSend(_ photo: UploadedPhoto, seal: ChatSeal?, conversationID: ConversationID) -> PhotoSend { + switch (photo, seal) { + case (.plain(let blobID), nil): + .plain(blobID) + case (.sealed(let sealed), let seal?) where seal.conversationID == conversationID: + .sealed(sealed, seal) + case (.plain, _?), (.sealed, _): + .mismatch + } + } + + func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? { + try await sealForSending(conversationID, owner: owner) + } + + func openingSeal(owner: KeyPair, conversationID: ConversationID) async -> ChatSeal? { + switch await opener(conversationID, owner: owner) { + case .seal(let seal): seal + case .awaitingKey, .unsupported: nil + } } func editMessage(owner: KeyPair, conversationID: ConversationID, messageID: MessageID, text: String, repliedTo: MessageID?, expectedEventSequence: UInt64) async throws -> MessageMutation { diff --git a/Flipcash/Core/Controllers/FlipClient+Protocols.swift b/Flipcash/Core/Controllers/FlipClient+Protocols.swift index cdf13d4d8..8946dd06a 100644 --- a/Flipcash/Core/Controllers/FlipClient+Protocols.swift +++ b/Flipcash/Core/Controllers/FlipClient+Protocols.swift @@ -112,8 +112,15 @@ protocol ConversationMessaging: AnyObject, Sendable { onBatch: @MainActor @Sendable @escaping (_ messages: [ConversationMessage], _ checkpoint: UInt64?) -> Void ) async throws -> UInt64 func sendMessage(owner: KeyPair, conversationID: ConversationID, text: String, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage - /// Sends the finalized photo `blobID` as one media message, with `caption` under it. - func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage + /// Sends the finalized `photo` as one media message, with `caption` under it; a sealed photo + /// goes out inside `EncryptedContent`. + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage + /// The seal a photo sent into `conversationID` is encrypted with, nil when it goes out in + /// plaintext. Throws when the chat encrypts but its seal can't be built. + func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? + /// The seal `conversationID`'s messages open with, nil when it can't be built yet or the chat + /// doesn't encrypt. + func openingSeal(owner: KeyPair, conversationID: ConversationID) async -> ChatSeal? /// Replaces a message's text, keeping it a reply to `repliedTo` when set. `expectedEventSequence` is the optimistic-concurrency guard: the /// server applies the edit only if the message still carries that sequence, and reports a /// conflict with the winning state otherwise. diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift index 1c676bf0c..2afa5bfe1 100644 --- a/Flipcash/Core/Screens/Conversation/AttachMenu.swift +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -7,6 +7,7 @@ import SwiftUI import FlipcashUI +import FlipcashCore /// A row of the composer's attach menu. enum AttachMenuItem: Equatable { @@ -208,3 +209,13 @@ extension AttachMenuItem { } } } + +/// Decides whether a chat offers Camera and Photos. +enum ChatMediaGate { + + /// True for any chat this device has a record of: an encrypted DM's photos are encrypted for it, + /// a plaintext chat's go up in plaintext. False while the record is still loading. + static func acceptsMedia(_ conversation: Conversation?) -> Bool { + conversation != nil + } +} diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index a7d58db36..16d4ce9e3 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -273,7 +273,8 @@ extension ChatItem { height: attachment?.height ?? 0, blurhash: attachment?.blurhash, caption: caption, - isRedacted: message.redacted + isRedacted: message.redacted, + sealed: attachment?.sealed )) } @@ -446,7 +447,7 @@ extension ChatItem { stableID: original.stableID, authorName: authorName, snippet: ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption)), - kind: .media(thumbnailBlobID: original.redacted ? nil : attachments.first?.blobID), + kind: .media(thumbnailBlobID: original.redacted ? nil : attachments.first?.blobID, sealed: attachments.first?.sealed), authorID: original.senderID ) case .encrypted: diff --git a/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift b/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift index 7b3338dec..c2c6beb8d 100644 --- a/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift +++ b/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift @@ -8,6 +8,7 @@ import UIKit import ImageIO import FlipcashCore +import FlipcashUI private let logger = Logger(label: "flipcash.chat-media-upload") @@ -22,6 +23,10 @@ protocol ChatMediaBlobStoring { /// Stores `data` and returns its blob, before the server has finalized it. func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID + /// Stores `image` end-to-end encrypted with `seal` for its DM and returns the blob, before the + /// server has finalized it. + func storeEncryptedBlob(_ image: Data, seal: ChatSeal) async throws -> EncryptedBlobUpload + /// Returns once the blob is servable, throwing `ErrorBlob.rejected` when it is refused and /// `ErrorBlob.timedOut` when it is still processing. func awaitBlobFinalization(blobID: BlobID) async throws @@ -41,15 +46,39 @@ struct SessionChatMediaBlobStore: ChatMediaBlobStoring { try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair) } + func storeEncryptedBlob(_ image: Data, seal: ChatSeal) async throws -> EncryptedBlobUpload { + try await flipClient.storeEncryptedBlob(image, seal: seal, owner: session.ownerKeyPair) + } + func awaitBlobFinalization(blobID: BlobID) async throws { try await flipClient.awaitBlobFinalization(blobID: blobID, owner: session.ownerKeyPair) } } +/// A chat photo uploaded and finalized, in the form its message references it. +enum UploadedPhoto: Hashable, Sendable { + /// A plaintext blob, whose metadata the server derives. + case plain(BlobID) + /// A blob end-to-end encrypted for the chat, with the metadata its sealed message carries. + case sealed(SealedPhoto) + + /// The finalized blob. + var blobID: BlobID { + switch self { + case .plain(let blobID): blobID + case .sealed(let photo): photo.blobID + } + } +} + /// Why a chat photo did not upload. enum ChatMediaUploadError: Error { /// The upload policy accepts no JPEG. case noMatchingConstraint + /// The upload policy allows the owner no end-to-end encrypted upload. + case encryptionNotAllowed + /// The chat's seal could not be built, so the photo cannot be encrypted for it. + case sealUnavailable(Error) /// The photo could not be encoded within the policy's size ceiling. case encodingFailed(ChatMediaEncoder.Error) /// The server refused the stored bytes. @@ -60,13 +89,16 @@ enum ChatMediaUploadError: Error { /// Whether uploading the same photo again could succeed. var isRetryable: Bool { switch self { - case .noMatchingConstraint, .encodingFailed: + case .noMatchingConstraint, .encryptionNotAllowed, .encodingFailed: false + case .sealUnavailable(let error): + // A peer key that couldn't be fetched may arrive; one shared-core refuses never will. + error is PeerKeyUnavailable case .rejected(let reason): switch reason { case .moderation: false - case .unsupportedType, .mismatchedType, .tooLarge, .corrupt, .privacyMetadata, .unknown: + case .unsupportedType, .mismatchedType, .tooLarge, .corrupt, .privacyMetadata, .internal, .unknown, .unrecognized: true } case .failed: @@ -78,8 +110,10 @@ enum ChatMediaUploadError: Error { extension ChatMediaUploadError: ServerError { var reportingLevel: ErrorReportingLevel { switch self { - case .noMatchingConstraint, .encodingFailed: + case .noMatchingConstraint, .encryptionNotAllowed, .encodingFailed: .error + case .sealUnavailable(let error): + error is PeerKeyUnavailable ? .info : .error case .rejected: .info case .failed(let error): @@ -89,18 +123,34 @@ extension ChatMediaUploadError: ServerError { } /// Turns a staged photo into a finalized blob: downscaled to the upload policy's bounds, encoded -/// down the JPEG quality ladder, stored, and awaited until the server has finalized it. +/// down the JPEG quality ladder, stored, and awaited until the server has finalized it. In a chat +/// that encrypts, the bytes are encrypted for it before they leave the device. struct ChatMediaUploader { + /// The seal to encrypt a photo with, or nil when the chat takes plaintext photos. + typealias SealProvider = @MainActor () async throws -> ChatSeal? + let blob: any ChatMediaBlobStoring + /// Decides at upload time whether the photo is encrypted, the same decision text sends make. + var seal: SealProvider = { nil } + /// The wait before each automatic retry of a store that failed in transit; one entry per retry. var backoff: [Duration] = [.seconds(1), .seconds(2), .seconds(4)] - /// Returns the finalized blob for `image`, throwing `ChatMediaUploadError`. + /// Returns the finalized photo for `image`, throwing `ChatMediaUploadError`. /// /// `onPrepared` receives the uploaded pixel width and height once, before any bytes are stored. - func upload(_ image: UIImage, onPrepared: (Int, Int) -> Void) async throws -> BlobID { + func upload(_ image: UIImage, onPrepared: (Int, Int) -> Void) async throws -> UploadedPhoto { + let chatSeal: ChatSeal? + do { + chatSeal = try await seal() + } catch { + try Task.checkCancellation() + logger.info("No seal for chat photo", metadata: ["error": "\(error)"]) + throw ChatMediaUploadError.sealUnavailable(error) + } + let policy: UploadPolicy do { policy = try await blob.uploadPolicy() @@ -109,11 +159,25 @@ struct ChatMediaUploader { throw ChatMediaUploadError.failed(error) } - guard let constraint = policy.constraint(for: ChatMediaEncoder.mimeType) else { - logger.warning("Upload policy accepts no chat photo", metadata: ["policyVersion": "\(policy.version)"]) - throw ChatMediaUploadError.noMatchingConstraint + let bounds: UploadPolicy.ImageConstraints? + let maxSizeBytes: Int + if chatSeal != nil { + guard let encrypted = policy.encrypted else { + logger.warning("Upload policy allows no encrypted chat photo", metadata: ["policyVersion": "\(policy.version)"]) + throw ChatMediaUploadError.encryptionNotAllowed + } + bounds = encrypted.image + // The ceiling covers the sealed blob, so the image must leave room for nonce and tag. + maxSizeBytes = encrypted.maxSizeBytes - EncryptedBlobUpload.overhead + } else { + guard let constraint = policy.constraint(for: ChatMediaEncoder.mimeType) else { + logger.warning("Upload policy accepts no chat photo", metadata: ["policyVersion": "\(policy.version)"]) + throw ChatMediaUploadError.noMatchingConstraint + } + bounds = constraint.image + maxSizeBytes = constraint.maxSizeBytes } - guard let cgImage = image.cgImage else { + guard let cgImage = image.cgImage, maxSizeBytes > 0 else { throw ChatMediaUploadError.encodingFailed(.encodingFailed) } @@ -129,23 +193,37 @@ struct ChatMediaUploader { let target = ChatMediaDownscale.target( sourceWidth: isSideways ? cgImage.height : cgImage.width, sourceHeight: isSideways ? cgImage.width : cgImage.height, - maxWidth: constraint.image?.maxWidth ?? 0, - maxHeight: constraint.image?.maxHeight ?? 0, - maxPixels: constraint.image?.maxPixels ?? 0 + maxWidth: bounds?.maxWidth ?? 0, + maxHeight: bounds?.maxHeight ?? 0, + maxPixels: bounds?.maxPixels ?? 0 ) onPrepared(target.width, target.height) let data: Data do { - data = try await Self.encode(cgImage, orientation: orientation, target: target, maxSizeBytes: constraint.maxSizeBytes) + data = try await Self.encode(cgImage, orientation: orientation, target: target, maxSizeBytes: maxSizeBytes) } catch let error as ChatMediaEncoder.Error { throw ChatMediaUploadError.encodingFailed(error) } - let blobID = try await store(data) + let uploaded: UploadedPhoto + if let chatSeal { + let blurhash = await Self.blurhash(of: data) + let stored = try await store { try await blob.storeEncryptedBlob(data, seal: chatSeal) } + uploaded = .sealed(SealedPhoto( + blobID: stored.blobID, + mimeType: ChatMediaEncoder.mimeType, + sizeBytes: stored.plaintextSize, + width: target.width, + height: target.height, + blurhash: blurhash + )) + } else { + uploaded = .plain(try await store { try await blob.storeBlob(data, mimeType: ChatMediaEncoder.mimeType) }) + } do { - try await blob.awaitBlobFinalization(blobID: blobID) + try await blob.awaitBlobFinalization(blobID: uploaded.blobID) } catch ErrorBlob.rejected(let reason) { throw ChatMediaUploadError.rejected(reason) } catch { @@ -153,15 +231,15 @@ struct ChatMediaUploader { throw ChatMediaUploadError.failed(error) } - return blobID + return uploaded } /// Stores `data`, retrying through `backoff` while the failure is in transit. - private func store(_ data: Data) async throws -> BlobID { + private func store(_ attemptStore: () async throws -> Stored) async throws -> Stored { var attempt = 0 while true { do { - return try await blob.storeBlob(data, mimeType: ChatMediaEncoder.mimeType) + return try await attemptStore() } catch { try Task.checkCancellation() @@ -200,6 +278,21 @@ struct ChatMediaUploader { ) async throws -> Data { try ChatMediaEncoder().encode(image, orientation: orientation, target: target, maxSizeBytes: maxSizeBytes) } + + /// The BlurHash of the encoded photo, which a recipient of an encrypted photo draws until it + /// decrypts, since the server cannot derive one. Empty when the bytes don't decode, which the + /// contract allows. + @concurrent + nonisolated static func blurhash(of jpeg: Data) async -> String { + guard let source = CGImageSourceCreateWithData(jpeg as CFData, nil), + let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceThumbnailMaxPixelSize: 64, + ] as CFDictionary) else { return "" } + let landscape = thumbnail.width >= thumbnail.height + return BlurHash.encode(thumbnail, componentsX: landscape ? 4 : 3, componentsY: landscape ? 3 : 4) ?? "" + } } private extension CGImagePropertyOrientation { diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 754140835..374aa3a51 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -131,6 +131,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// Mints a signed download URL for a photo in this chat. The transcript's resolver caches what it /// returns and never asks for a photo drawn only from its BlurHash. var mintMediaURL: (BlobID) async throws -> URL? = { _ in nil } + /// Decrypts the chat's end-to-end encrypted photo blobs, or nil while its key is unknown. + var mediaBlobDecrypt: () async -> ChatMediaURLResolver.BlobDecrypt? = { nil } /// Fired when the user taps a photo the viewer may see. The owner opens the full-screen viewer. var onMediaTap: (ChatMediaViewerRequest) -> Void = { _ in } @@ -161,6 +163,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.linkCardSource = linkCardSource screen.onMediaTap = onMediaTap context.coordinator.mintMediaURL = mintMediaURL + context.coordinator.mediaBlobDecrypt = mediaBlobDecrypt screen.mediaURLResolver = context.coordinator.mediaURLResolver screen.pendingMediaImage = { [conversationController] id in conversationController.pendingMediaImage(forMessageID: id) @@ -213,6 +216,7 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.linkCardSource = linkCardSource screen.onMediaTap = onMediaTap context.coordinator.mintMediaURL = mintMediaURL + context.coordinator.mediaBlobDecrypt = mediaBlobDecrypt screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onGroupInvite = onGroupInvite @@ -345,8 +349,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { model.returnToMenu() }, // Only a member can aim a reply, so the viewer may see what it quotes. - quoteThumbnailURL: { [resolver = coordinator.mediaURLResolver] kind in - await resolver.thumbnailURL(for: kind, canReact: true) + quoteThumbnailLocation: { [resolver = coordinator.mediaURLResolver] kind in + await resolver.thumbnailLocation(for: kind, canReact: true) } ) .environment(conversationController) @@ -437,10 +441,17 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { /// The one resolver the transcript and the composer's reply strip share, so a photo is minted /// once however many places draw it. Reads ``mintMediaURL`` at fetch time, so a chat created /// after this screen opened mints against its real id. - lazy var mediaURLResolver = ChatMediaURLResolver { [weak self] blobID in - guard let self else { return nil } - return try await self.mintMediaURL(blobID) - } + /// The latest ``ChatScreenRepresentable/mediaBlobDecrypt``, read by the resolver. + var mediaBlobDecrypt: () async -> ChatMediaURLResolver.BlobDecrypt? = { nil } + lazy var mediaURLResolver = ChatMediaURLResolver( + fetch: { [weak self] blobID in + guard let self else { return nil } + return try await self.mintMediaURL(blobID) + }, + decrypt: { [weak self] in + await self?.mediaBlobDecrypt() + } + ) } } diff --git a/Flipcash/Core/Screens/Conversation/ComposerChip.swift b/Flipcash/Core/Screens/Conversation/ComposerChip.swift index 763083366..be698189f 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChip.swift @@ -45,7 +45,7 @@ final class ComposerChip: Identifiable { /// The upload in flight or finished for this chip, which the send path awaits rather than /// starting its own. - @ObservationIgnored var uploadTask: Task? + @ObservationIgnored var uploadTask: Task? /// The uploader the last attempt went through, kept so a failed send can upload again after the /// composer has let go of the chip. @@ -65,15 +65,15 @@ final class ComposerChip: Identifiable { uploadTask = Task { do { - let blobID = try await uploader.upload(image) { width, height in + let photo = try await uploader.upload(image) { width, height in preparedWidth = width preparedHeight = height state = .uploading } if !Task.isCancelled { - state = .uploaded(blobID) + state = .uploaded(photo.blobID) } - return blobID + return photo } catch let error as ChatMediaUploadError { if !Task.isCancelled { state = .failed(error.isRetryable ? .retryable : .notRetryable) diff --git a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift index f4766486e..2b0034e58 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift @@ -24,11 +24,11 @@ struct ComposerReplyStrip: View { let target: ComposerModel.ReplyTarget /// Where a quoted photo's thumbnail loads from; nil for anything else, or a photo with none to fetch. - var thumbnailURL: (ChatQuote.Kind) async -> URL? = { _ in nil } + var thumbnailLocation: (ChatQuote.Kind) async -> ChatMediaLocation? = { _ in nil } let onDismiss: () -> Void @Environment(\.barCardCollapsed) private var collapsed - @State private var resolvedThumbnail: URL? + @State private var resolvedThumbnail: ChatMediaLocation? /// Wider than the 4pt a blockquote rule usually takes, because the quote's corner radius is the /// bar's 14: the leading edge is straight for only `contentHeight - 14 * 2` of its run, and the @@ -159,12 +159,14 @@ struct ComposerReplyStrip: View { @ViewBuilder private var thumbnail: some View { switch target.kind { - case .media(let thumbnailBlobID?): + case .media(let thumbnailBlobID?, _): RoundedRectangle(cornerRadius: 6) .fill(Color.white.opacity(0.08)) .overlay { if let resolvedThumbnail { - KFImage(source: .network(ChatMediaImageSource.resource(blobID: thumbnailBlobID, url: resolvedThumbnail))) + KFImage(source: ChatMediaImageSource.source(blobID: thumbnailBlobID, location: resolvedThumbnail)) + .targetCache(ChatMediaImageSource.cache) + .onSuccess { ChatMediaImageSource.persist(.success($0)) } .resizable() .scaledToFill() } @@ -173,9 +175,9 @@ struct ComposerReplyStrip: View { .clipShape(.rect(cornerRadius: 6)) .accessibilityHidden(true) .task(id: thumbnailBlobID) { - resolvedThumbnail = await thumbnailURL(target.kind) + resolvedThumbnail = await thumbnailLocation(target.kind) } - case .media(nil), .text, .cash, .unavailable: + case .media(nil, _), .text, .cash, .unavailable: EmptyView() } } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index ef3b64b3a..6a75e2bcb 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -260,7 +260,7 @@ struct ConversationBottomBar: View { /// Fired by the photo card's back chevron and escape gesture. var onPhotosBack: () -> Void = {} /// Where the reply strip's quoted photo loads its thumbnail from. - var quoteThumbnailURL: (ChatQuote.Kind) async -> URL? = { _ in nil } + var quoteThumbnailLocation: (ChatQuote.Kind) async -> ChatMediaLocation? = { _ in nil } /// The curve the bar narrows and widens on as the keyboard goes and comes. private static let widthSpring = Animation.spring(duration: 0.22, bounce: 0.14) @@ -448,7 +448,7 @@ struct ConversationBottomBar: View { // already drives this state in both directions, and wrapping the dismissal in a second // transaction gave the exit a curve the entry never had. AccessoryReveal(kind: .reply, item: barReply, collapsesInPlace: mentionCandidates != nil) { target in - ComposerReplyStrip(target: target, thumbnailURL: quoteThumbnailURL) { composer.endReplying() } + ComposerReplyStrip(target: target, thumbnailLocation: quoteThumbnailLocation) { composer.endReplying() } } // The quote narrows with the row below it, so the two keep one margin. .padding(.horizontal, compactExtraInset) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 9d3ee13c3..dcbc59cec 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -542,22 +542,27 @@ struct ConversationScreen: View { onCameraCapture: stageCapturedPhoto, onPhotosAdded: stageAddedPhotos, mintMediaURL: mintMediaURL, + mediaBlobDecrypt: mediaBlobDecrypt, onMediaTap: openMediaViewer ) } - /// Whether the chat takes photos: any chat this device has a record of, except one it encrypts, whose - /// encryption does not cover media. False until the record loads, so the menu never offers a - /// photo the chat may refuse. + /// Whether the chat takes photos: any chat this device has a record of, encrypted or not. False + /// until the record loads, so the menu never offers a photo before the chat is known. private var acceptsMedia: Bool { - guard let conversationID, - let conversation = conversationController.conversation(withID: conversationID) else { return false } - return !E2eePolicy.shouldEncrypt(conversation) + ChatMediaGate.acceptsMedia(conversationID.flatMap(conversationController.conversation(withID:))) } - /// Uploads chat photos on behalf of the signed-in owner. + /// Uploads chat photos on behalf of the signed-in owner, encrypted for the chat when it encrypts. private var mediaUploader: ChatMediaUploader { - ChatMediaUploader(blob: SessionChatMediaBlobStore(session: session, flipClient: container.flipClient)) + let controller = conversationController + let conversationID = conversationID + var uploader = ChatMediaUploader(blob: SessionChatMediaBlobStore(session: session, flipClient: container.flipClient)) + uploader.seal = { + guard let conversationID else { return nil } + return try await controller.photoSeal(for: conversationID) + } + return uploader } /// Stages photos added from the photo card in the order they were selected, returning the chip @@ -595,6 +600,14 @@ struct ConversationScreen: View { } /// Mints a download URL for a photo in this chat, read through the chat's access context. + /// Decrypts this chat's end-to-end encrypted photos with the key its messages open with. + private var mediaBlobDecrypt: () async -> ChatMediaURLResolver.BlobDecrypt? { + { [controller = conversationController, conversationID] in + guard let conversationID else { return nil } + return await controller.mediaBlobDecrypt(for: conversationID) + } + } + private var mintMediaURL: (BlobID) async throws -> URL? { { [flipClient = container.flipClient, owner = session.ownerKeyPair, conversationID] blobID in guard let conversationID else { return nil } @@ -1161,7 +1174,7 @@ struct ConversationScreen: View { case .media(let attachments, let caption): ( ChatQuote.snippet(forText: ChatMediaStrings.quoteSnippet(caption: caption)), - .media(thumbnailBlobID: message.redacted ? nil : attachments.first?.blobID) + .media(thumbnailBlobID: message.redacted ? nil : attachments.first?.blobID, sealed: attachments.first?.sealed) ) case .deleted: (ChatQuote.deletedSnippet, .unavailable) diff --git a/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift b/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift index a63d3c362..026a649d8 100644 --- a/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift +++ b/Flipcash/Core/Screens/Main/Profile/DialogItem+ProfilePicture.swift @@ -58,7 +58,7 @@ extension DialogItem { // Privacy metadata is stripped from every upload, so a rejection for it // is a defect on our side rather than something a different photo fixes. - case .rejected(.privacyMetadata), .rejected(.unknown), .timedOut, + case .rejected(.privacyMetadata), .rejected(.internal), .rejected(.unknown), .rejected(.unrecognized), .timedOut, .uploadFailed, .notFound, .notUploaded, .unknown, .network: .error( title: "Couldn't Upload Your Photo", diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift b/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift index f84e13418..999d1c4c0 100644 --- a/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift +++ b/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift @@ -9,7 +9,8 @@ private let logger = Logger(label: "flipcash.blob-uploader") /// The blob RPCs an upload depends on. protocol BlobReserving: Sendable { - func initiateExternalUpload(mimeType: String, sizeBytes: Int, owner: KeyPair) async throws -> ReservedUpload + /// Reserves an upload of `sizeBytes`, end-to-end encrypted for the DM `encryptedFor` when set. + func initiateExternalUpload(mimeType: String, sizeBytes: Int, encryptedFor: ConversationID?, owner: KeyPair) async throws -> ReservedUpload func completeExternalUpload(blobID: BlobID, owner: KeyPair) async throws -> BlobState func blobState(blobID: BlobID, owner: KeyPair) async throws -> BlobState } @@ -49,6 +50,7 @@ final class BlobUploader: Sendable { let reserved = try await reserving.initiateExternalUpload( mimeType: mimeType, sizeBytes: data.count, + encryptedFor: nil, owner: owner ) @@ -62,12 +64,70 @@ final class BlobUploader: Sendable { switch try await reserving.completeExternalUpload(blobID: reserved.blobID, owner: owner) { case .rejected(let reason): + logger.info("Blob rejected on completion", metadata: [ + "blobId": "\(reserved.blobID)", + "reason": "\(reason)", + ]) throw ErrorBlob.rejected(reason) case .ready, .pending, .processing: return reserved.blobID } } + /// Stores `image` encrypted for the DM `conversationID` and returns its blob, leaving + /// finalization to the caller. + /// + /// `encrypt` seals the plaintext under the blob id the reservation assigns, which is part of + /// its aad, so the reservation declares the sealed size before the blob exists. + func storeEncrypted( + _ image: Data, + for conversationID: ConversationID, + owner: KeyPair, + encrypt: @Sendable (Data, BlobID) throws -> Data + ) async throws -> EncryptedBlobUpload { + // The server never reads these bytes, so stripping location and camera metadata is on us. + let plaintext = JPEGMetadata.stripped(image) + let sizeBytes = plaintext.count + EncryptedBlobUpload.overhead + + let reserved = try await reserving.initiateExternalUpload( + mimeType: Self.encryptedMimeType, + sizeBytes: sizeBytes, + encryptedFor: conversationID, + owner: owner + ) + + logger.info("Reserved encrypted blob upload", metadata: [ + "blobId": "\(reserved.blobID)", + "sizeBytes": "\(sizeBytes)", + ]) + + let blob = try encrypt(plaintext, reserved.blobID) + guard blob.count == sizeBytes else { + logger.error("Encrypted blob is not the size reserved", metadata: [ + "blobId": "\(reserved.blobID)", + "reserved": "\(sizeBytes)", + "actual": "\(blob.count)", + ]) + throw ErrorBlob.unknown + } + + try await store(blob, mimeType: Self.encryptedMimeType, to: reserved.target) + + switch try await reserving.completeExternalUpload(blobID: reserved.blobID, owner: owner) { + case .rejected(let reason): + logger.info("Blob rejected on completion", metadata: [ + "blobId": "\(reserved.blobID)", + "reason": "\(reason)", + ]) + throw ErrorBlob.rejected(reason) + case .ready, .pending, .processing: + return EncryptedBlobUpload(blobID: reserved.blobID, plaintextSize: plaintext.count) + } + } + + /// The MIME type every end-to-end encrypted upload declares; the server refuses any other. + static let encryptedMimeType = "application/octet-stream" + /// Polls until the blob is finalized. /// /// Returns immediately when it is already ready; a rejection is terminal. @@ -163,6 +223,18 @@ final class BlobUploader: Sendable { } } +/// An encrypted blob stored but not yet finalized, with the length of the plaintext it seals. +public struct EncryptedBlobUpload: Hashable, Sendable { + /// The blob the reservation assigned. + public let blobID: BlobID + /// The plaintext image's length after metadata stripping, the length the recipient checks. + public let plaintextSize: Int + + /// What encryption adds to an image: the 24-byte nonce ahead of the ciphertext and the 16-byte + /// tag after it. + public static let overhead = 40 +} + private extension Data { mutating func append(_ string: String) { append(Data(string.utf8)) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift index f1a7bb29a..2e18231b1 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift @@ -16,6 +16,14 @@ extension FlipClient { try await blobUploader.store(data, mimeType: mimeType, owner: owner) } + /// Stores `image` end-to-end encrypted with `seal` for its DM and returns the blob, before the + /// server has finalized it. Pair with `awaitBlobFinalization(blobID:owner:)` as for `storeBlob`. + public func storeEncryptedBlob(_ image: Data, seal: ChatSeal, owner: KeyPair) async throws -> EncryptedBlobUpload { + try await blobUploader.storeEncrypted(image, for: seal.conversationID, owner: owner) { plaintext, blobID in + try seal.encryptBlob(plaintext, blobID: blobID) + } + } + /// Returns the upload constraints in force for `owner`. public func uploadPolicy(owner: KeyPair) async throws -> UploadPolicy { try await blobService.uploadPolicy(owner: owner) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index 5ef389854..d35ca880e 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -198,6 +198,15 @@ extension FlipClient { } } + /// Sends the finalized encrypted `photo` sealed with `seal`, with `caption` under it, and returns + /// the server's copy (decrypted). + @discardableResult + public func sendSealedMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: SealedPhoto, caption: String?, repliedTo: MessageID?, seal: ChatSeal, clientMessageID: UUID) async throws -> ConversationMessage { + try await withCheckedThrowingContinuation { c in + chatMessagingService.sendSealedMediaMessage(owner: owner, conversationID: conversationID, photo: photo, caption: caption, repliedTo: repliedTo, seal: seal, clientMessageID: clientMessageID) { c.resume(with: $0) } + } + } + public func editMessage( owner: KeyPair, conversationID: ConversationID, diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift index e4b8dc606..9bba3e255 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/BlobService.swift @@ -21,11 +21,8 @@ final class BlobService: Sendable { extension BlobService: BlobReserving { - func initiateExternalUpload(mimeType: String, sizeBytes: Int, owner: KeyPair) async throws -> ReservedUpload { - var request = Flipcash_Blob_V1_InitiateExternalUploadRequest() - request.mimeType = mimeType - request.sizeBytes = UInt64(sizeBytes) - request.auth = owner.authFor(message: request) + func initiateExternalUpload(mimeType: String, sizeBytes: Int, encryptedFor: ConversationID?, owner: KeyPair) async throws -> ReservedUpload { + let request = Self.initiateRequest(mimeType: mimeType, sizeBytes: sizeBytes, encryptedFor: encryptedFor, owner: owner) do { let response = try await service.initiateExternalUpload(request, options: .unaryDefault) @@ -114,6 +111,18 @@ extension BlobService: BlobReserving { /// A policy-driven denial echoes the version in force, which retires a /// stale cached policy. + /// The signed reservation request, naming the DM the bytes are encrypted for when set. + static func initiateRequest(mimeType: String, sizeBytes: Int, encryptedFor: ConversationID?, owner: KeyPair) -> Flipcash_Blob_V1_InitiateExternalUploadRequest { + var request = Flipcash_Blob_V1_InitiateExternalUploadRequest() + request.mimeType = mimeType + request.sizeBytes = UInt64(sizeBytes) + if let encryptedFor { + request.chat = encryptedFor.proto + } + request.auth = owner.authFor(message: request) + return request + } + private func observePolicyVersion(of response: Flipcash_Blob_V1_InitiateExternalUploadResponse) async { guard response.hasPolicyVersion else { return } await policyCache.observe(version: response.policyVersion.value) diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift index ce55e90b2..537292d17 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatMessagingService.swift @@ -198,6 +198,19 @@ final class ChatMessagingService: Sendable { send(content, owner: owner, conversationID: conversationID, seal: nil, clientMessageID: clientMessageID, completion: completion) } + /// Sends `photo`, its caption and reply inside the sealed plaintext, as `EncryptedContent`. + func sendSealedMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: SealedPhoto, caption: String?, repliedTo: MessageID?, seal: ChatSeal, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { + let content: Flipcash_Messaging_V1_Content + do { + content = try seal.seal(photo: photo, caption: caption, repliedTo: repliedTo) + } catch { + logger.error("Failed to encrypt photo message") + completion(.failure(.encryptionFailed)) + return + } + send(content, owner: owner, conversationID: conversationID, seal: seal, clientMessageID: clientMessageID, completion: completion) + } + private func send(_ content: Flipcash_Messaging_V1_Content, owner: KeyPair, conversationID: ConversationID, seal: ChatSeal?, clientMessageID: UUID, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Messaging_V1_SendMessageRequest.with { $0.chatID = conversationID.proto diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift b/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift index 048fcbd27..39dbcd649 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Blob.swift @@ -25,7 +25,12 @@ public enum BlobRejectionReason: Sendable, Equatable { case tooLarge case corrupt case privacyMetadata + /// The server failed to process the blob. + case `internal` + /// The server sent no reason. case unknown + /// The server sent a reason this build doesn't know, by its raw proto value. + case unrecognized(Int) } /// A reserved upload: the blob it will become, and the request that stores its @@ -77,10 +82,27 @@ public struct UploadPolicy: Sendable, Equatable { /// Constraints ordered most specific first. public let constraints: [MimeConstraint] - init(version: String, ttl: Duration?, constraints: [MimeConstraint]) { + /// The constraints on an end-to-end encrypted upload, or nil when the caller may not make one. + public let encrypted: EncryptedConstraints? + + /// The bounds on an end-to-end encrypted upload, which the server checks by size alone. + public struct EncryptedConstraints: Sendable, Equatable { + /// The ceiling on the whole encrypted blob, nonce and tag included. + public let maxSizeBytes: Int + /// Advisory bounds to downscale an image to before encrypting it, or nil when there are none. + public let image: ImageConstraints? + + public init(maxSizeBytes: Int, image: ImageConstraints?) { + self.maxSizeBytes = maxSizeBytes + self.image = image + } + } + + init(version: String, ttl: Duration?, constraints: [MimeConstraint], encrypted: EncryptedConstraints? = nil) { self.version = version self.ttl = ttl self.constraints = constraints + self.encrypted = encrypted } /// Returns the constraint governing `mimeType` — the first match in policy @@ -119,8 +141,9 @@ extension BlobRejectionReason { case .tooLarge: self = .tooLarge case .corrupt: self = .corrupt case .privacyMetadata: self = .privacyMetadata - case .internal: self = .unknown - case .unknown, .UNRECOGNIZED: self = .unknown + case .internal: self = .internal + case .unknown: self = .unknown + case .UNRECOGNIZED(let value): self = .unrecognized(value) } } } @@ -144,7 +167,27 @@ extension UploadPolicy { self.init( version: proto.version.value, ttl: proto.hasTtl ? .seconds(proto.ttl.seconds) + .nanoseconds(proto.ttl.nanos) : nil, - constraints: proto.mimeTypeConstraints.map(MimeConstraint.init) + constraints: proto.mimeTypeConstraints.map(MimeConstraint.init), + encrypted: proto.hasEncrypted ? EncryptedConstraints(proto.encrypted) : nil + ) + } +} + +extension UploadPolicy.EncryptedConstraints { + init(_ proto: Flipcash_Blob_V1_EncryptedConstraints) { + self.init( + maxSizeBytes: Int(clamping: proto.maxSizeBytes), + image: proto.hasImage ? UploadPolicy.ImageConstraints(proto.image) : nil + ) + } +} + +extension UploadPolicy.ImageConstraints { + init(_ proto: Flipcash_Blob_V1_ImageConstraints) { + self.init( + maxWidth: Int(proto.maxWidth), + maxHeight: Int(proto.maxHeight), + maxPixels: Int(clamping: proto.maxPixels) ) } } @@ -154,11 +197,7 @@ extension UploadPolicy.MimeConstraint { let image: UploadPolicy.ImageConstraints? switch proto.kind { case .image(let bounds): - image = UploadPolicy.ImageConstraints( - maxWidth: Int(bounds.maxWidth), - maxHeight: Int(bounds.maxHeight), - maxPixels: Int(clamping: bounds.maxPixels) - ) + image = UploadPolicy.ImageConstraints(bounds) case nil: image = nil } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift index b2909853a..7ddc36651 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMediaStrings.swift @@ -11,6 +11,10 @@ import Foundation /// list, Spotlight, and notifications. The copy is the fixture's `strings` section, shared with Android. public enum ChatMediaStrings { + /// Drawn over an encrypted photo whose bytes failed to decrypt. Not in the shared fixture yet: + /// Android doesn't decrypt photos. + public static let undecryptable = "This photo can\u{2019}t be displayed" + /// A quote's snippet for a photo: its caption, or "Photo" when it has none. public static func quoteSnippet(caption: String?) -> String { nonEmpty(caption) ?? "Photo" diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index deb0d19a5..f4d8b8cd6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -348,14 +348,17 @@ public struct ChatMediaContent: Hashable, Sendable, Codable { public let caption: String? /// Whether the server withheld the photo's download URL; the bubble then only ever draws its BlurHash. public let isRedacted: Bool + /// What decrypting the blob needs when it is end-to-end encrypted, nil for a plaintext blob. + public let sealed: SealedBlob? - public init(blobID: BlobID?, width: Int, height: Int, blurhash: String?, caption: String?, isRedacted: Bool) { + public init(blobID: BlobID?, width: Int, height: Int, blurhash: String?, caption: String?, isRedacted: Bool, sealed: SealedBlob? = nil) { self.blobID = blobID self.width = width self.height = height self.blurhash = blurhash self.caption = caption self.isRedacted = isRedacted + self.sealed = sealed } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index 807d00b82..c8180173d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -100,7 +100,8 @@ extension ChatItem { height: attachment?.height ?? 0, blurhash: attachment?.blurhash, caption: caption, - isRedacted: message.redacted + isRedacted: message.redacted, + sealed: attachment?.sealed )) case .deleted: continue // filtered out above; unreachable, kept for switch exhaustiveness diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift index 2fce0bb69..b780f56dd 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatQuote.swift @@ -21,8 +21,9 @@ public struct ChatQuote: Hashable, Sendable, Codable { /// the currency's asset name, `nil` when the currency has no flag. case cash(token: String, flagImageName: String?) /// A photo. The snippet is its caption, or "Photo" when it has none; `thumbnailBlobID` is the - /// blob the panel draws beside it, `nil` when there is none to fetch. - case media(thumbnailBlobID: BlobID?) + /// blob the panel draws beside it, `nil` when there is none to fetch; `sealed` is what + /// decrypting it needs when it is end-to-end encrypted. + case media(thumbnailBlobID: BlobID?, sealed: SealedBlob?) /// The original is not in the local database, or it has been deleted. The panel renders /// the placeholder copy and the row is not tappable. case unavailable diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift index 9a975cd71..f28798686 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift @@ -10,7 +10,8 @@ import FlipcashAPI import SharedCore /// One DM's end-to-end encryption: the chat key both members derive, bound to the chat and both -/// public keys. Encrypts outgoing text and decrypts incoming `EncryptedContent`. +/// public keys. Encrypts outgoing text and photos, and decrypts incoming `EncryptedContent` and the +/// photo blobs it references. /// /// The cipher is shared-core's `ChatCipher` (scheme `X25519_XCHACHA20POLY1305`), so both apps agree /// byte for byte. Stateless after construction. @@ -26,6 +27,9 @@ public struct ChatSeal: @unchecked Sendable { private let chatID: KotlinByteArray private let selfUserID: UserID + /// The chat this seal encrypts for. + public let conversationID: ConversationID + /// Derives the chat key for `owner` and `peerPublicKey` in `conversationID`. /// /// Throws when shared-core rejects the peer key (not a valid point, low-order). @@ -47,11 +51,22 @@ public struct ChatSeal: @unchecked Sendable { self.peerPublicKey = peer self.chatID = chatID self.selfUserID = selfUserID + self.conversationID = conversationID } /// Encrypts `text`, as a reply to `repliedTo` when set, into the content to send in its place. public func seal(text: String, repliedTo: MessageID?) throws -> Flipcash_Messaging_V1_Content { - let plaintext = Flipcash_Messaging_V1_Content.plaintext(text: text, repliedTo: repliedTo) + try seal(.plaintext(text: text, repliedTo: repliedTo)) + } + + /// Encrypts a message carrying `photo`, with `caption` under it and as a reply to `repliedTo` + /// when set, into the content to send in its place. `photo` must already be uploaded through + /// ``encryptBlob(_:blobID:)`` and READY. + public func seal(photo: SealedPhoto, caption: String?, repliedTo: MessageID?) throws -> Flipcash_Messaging_V1_Content { + try seal(.plaintext(media: photo.media, caption: caption, repliedTo: repliedTo)) + } + + private func seal(_ plaintext: Flipcash_Messaging_V1_Content) throws -> Flipcash_Messaging_V1_Content { let payload = try cipher.encrypt( content: SharedBytes.shared.byteArray(data: try plaintext.serializedData()), chatKey: chatKey, @@ -68,6 +83,46 @@ public struct ChatSeal: @unchecked Sendable { } } + /// The blob to upload for `image`, the plaintext bytes of a photo this user sends as `blobID`: + /// a fresh 24-byte nonce followed by the ciphertext and its tag. + public func encryptBlob(_ image: Data, blobID: BlobID) throws -> Data { + let bytes = SharedBytes.shared + let blob = try cipher.encryptBlob( + image: bytes.byteArray(data: image), + chatKey: chatKey, + senderPk: ownPublicKey, + recipientPk: peerPublicKey, + chatId: chatID, + blobId: bytes.byteArray(data: blobID.data) + ) + return bytes.data(bytes: blob) + } + + /// The plaintext image in `blob`, the downloaded bytes of `blobID` that `sealed` describes. + /// + /// Throws ``BlobOpenFailure`` when the blob fails to authenticate or its plaintext is not the + /// length the sender declared. + public func decryptBlob(_ blob: Data, blobID: BlobID, sealed: SealedBlob) throws -> Data { + let bytes = SharedBytes.shared + let isFromSelf = sealed.senderID == selfUserID + let image: KotlinByteArray + do { + image = try cipher.decryptBlob( + blob: bytes.byteArray(data: blob), + chatKey: chatKey, + senderPk: isFromSelf ? ownPublicKey : peerPublicKey, + recipientPk: isFromSelf ? peerPublicKey : ownPublicKey, + chatId: chatID, + blobId: bytes.byteArray(data: blobID.data) + ) + } catch { + throw BlobOpenFailure.authentication + } + let data = bytes.data(bytes: image) + guard data.count == sealed.plaintextSize else { throw BlobOpenFailure.length } + return data + } + /// `message` with its content decrypted, or carrying why it could not be. A message that isn't /// `.encrypted` comes back unchanged. public func open(_ message: ConversationMessage) -> ConversationMessage { @@ -89,15 +144,72 @@ public struct ChatSeal: @unchecked Sendable { return message.opened(.failure(.authentication)) } - // Authenticated, so anything this client can't read is a newer client's content. + // Authenticated, so anything this client can't read, or a photo whose metadata breaks the + // contract, is a newer or misbehaving client's content. guard let content = try? Flipcash_Messaging_V1_Content(serializedBytes: bytes.data(bytes: plaintext)), - let readable = content.readableText else { + let senderID = message.senderID, + let readable = content.readable(senderID: senderID) else { return message.opened(.failure(.unsupported)) } return message.opened(.success(readable)) } } +/// Why a photo blob could not be decrypted; either way the photo draws as unsupported. +public enum BlobOpenFailure: Error, Hashable, Sendable { + /// The blob failed to authenticate under the chat key and its aad. + case authentication + /// The decrypted image is not the length the sender declared. + case length + /// The decrypted bytes don't decode as an image. + case undecodable +} + +/// A photo uploaded encrypted for a chat, with the plaintext metadata its sealed message carries. +public struct SealedPhoto: Hashable, Sendable { + /// The encrypted blob, READY before the message is sent. + public let blobID: BlobID + /// The plaintext image's MIME type, always an `image/` type. + public let mimeType: String + /// The plaintext image's length in bytes. + public let sizeBytes: Int + /// Pixel width of the plaintext image. + public let width: Int + /// Pixel height of the plaintext image. + public let height: Int + /// The plaintext image's BlurHash, at most 64 characters. + public let blurhash: String + + public init(blobID: BlobID, mimeType: String, sizeBytes: Int, width: Int, height: Int, blurhash: String) { + self.blobID = blobID + self.mimeType = mimeType + self.sizeBytes = sizeBytes + self.width = width + self.height = height + self.blurhash = blurhash + } + + /// The single-ORIGINAL `Media` the sealed message carries, with the sender-set metadata the + /// server never sees and no download URL. + var media: Flipcash_Blob_V1_Media { + .with { + $0.renditions = [.with { + $0.role = .original + $0.blobID = .with { $0.value = blobID.data } + $0.blob = .with { + $0.mimeType = mimeType + $0.sizeBytes = UInt64(sizeBytes) + $0.image = .with { + $0.width = UInt32(width) + $0.height = UInt32(height) + $0.blurhash = blurhash + } + } + }] + } + } +} + extension Flipcash_Messaging_V1_Content { /// The plaintext content for `text`, wrapped as a reply to `repliedTo` when set. @@ -112,15 +224,92 @@ extension Flipcash_Messaging_V1_Content { } } - /// The text and replied-to message of decrypted Text or Reply(Text) content; nil for any other. - fileprivate var readableText: (text: String, repliedTo: MessageID?)? { + /// The media content for `media`, with `caption` under it, wrapped as a reply to `repliedTo` + /// when set. A reply nests the media one level deeper on the wire. + static func plaintext(media: Flipcash_Blob_V1_Media, caption: String?, repliedTo: MessageID?) -> Self { + let body = Self.with { + $0.media = .with { + $0.items = [media] + if let caption { + $0.caption = .with { $0.text = caption } + } + } + } + guard let repliedTo else { return body } + return .with { + $0.reply = .with { + $0.repliedMessageID = repliedTo.proto + $0.content = [body] + } + } + } + + /// The content and replied-to message of decrypted Text, Media, or a Reply of either sent by + /// `senderID`; nil for any other type, or for media whose metadata breaks the contract. + fileprivate func readable(senderID: UserID) -> (content: ConversationMessage.Content, repliedTo: MessageID?)? { switch type { case .text(let text): - return (text.text, nil) + return (.text(text.text), nil) + case .media(let media): + return ConversationMessage.Content(sealed: media, senderID: senderID).map { ($0, nil) } case .reply(let reply): - guard reply.content.count == 1, case .text(let text) = reply.content[0].type else { return nil } - return (text.text, MessageID(reply.repliedMessageID)) - case .cash, .media, .system, .widget, .deleted, .encrypted, nil: + guard reply.content.count == 1 else { return nil } + let repliedTo = MessageID(reply.repliedMessageID) + switch reply.content[0].type { + case .text(let text): + return (.text(text.text), repliedTo) + case .media(let media): + return ConversationMessage.Content(sealed: media, senderID: senderID).map { ($0, repliedTo) } + case .cash, .system, .widget, .deleted, .encrypted, .reply, nil: + return nil + } + case .cash, .system, .widget, .deleted, .encrypted, nil: + return nil + } + } +} + +extension ConversationMessage.Content { + + /// The `.media` content of a decrypted `MediaContent` sent by `senderID`, or nil when it breaks + /// the encrypted-media contract: exactly one item with exactly one ORIGINAL rendition, a blob id, + /// and valid `BlobMetadata` describing an image. + /// + /// The dimensions are the sender's claim; the decoded image's own are authoritative once it loads. + init?(sealed proto: Flipcash_Messaging_V1_MediaContent, senderID: UserID) { + guard proto.items.count == 1, + proto.items[0].renditions.count == 1 else { return nil } + let rendition = proto.items[0].renditions[0] + guard rendition.role == .original, + rendition.hasBlobID, + rendition.blobID.value.count == 16, + rendition.hasBlob, + let image = Self.validImage(rendition.blob) else { return nil } + let caption = proto.caption.text + self = .media( + [MediaAttachment( + blobID: BlobID(data: rendition.blobID.value), + width: Int(image.width), + height: Int(image.height), + blurhash: image.blurhash.isEmpty ? nil : image.blurhash, + sealed: SealedBlob(senderID: senderID, plaintextSize: Int(rendition.blob.sizeBytes)) + )], + caption: caption.isEmpty ? nil : caption + ) + } + + /// `metadata`'s image description when it passes the `blob.v1.BlobMetadata` rules for an image. + private static func validImage(_ metadata: Flipcash_Blob_V1_BlobMetadata) -> Flipcash_Blob_V1_ImageMetadata? { + let mimeType = metadata.mimeType + guard (1...255).contains(mimeType.unicodeScalars.count), + mimeType.lowercased().hasPrefix("image/"), + metadata.sizeBytes >= 1, + metadata.sizeBytes <= UInt64(Int.max) else { return nil } + switch metadata.kind { + case .image(let image): + guard image.width >= 1, image.height >= 1, image.blurhash.unicodeScalars.count <= 64 else { return nil } + return image + case .encrypted, nil: return nil } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 08865d8f4..1bf6f51ab 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -51,7 +51,7 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// End-to-end-encrypted content not yet decrypted: either decryption failed (see /// ``ConversationMessage/decryptFailure``) or the peer's key hasn't been fetched yet. `scheme` /// is the wire `EncryptedContent.Scheme` raw value, kept as `Int` so this model doesn't depend - /// on the generated proto enum. A decrypted message carries `.text` instead. + /// on the generated proto enum. A decrypted message carries `.text` or `.media` instead. case encrypted(scheme: Int, nonce: Data, ciphertext: Data) /// A widget: structured content the sender's client drew as a card rather than text. case widget(Widget) @@ -220,15 +220,15 @@ extension ConversationMessage { if case .encrypted = content { decryptFailure == nil } else { false } } - /// A copy carrying the outcome of decrypting it: the plaintext and the message it replies to, - /// or the reason it failed. Everything else is kept, including the ciphertext. - public func opened(_ outcome: Result<(text: String, repliedTo: MessageID?), DecryptFailure>) -> ConversationMessage { + /// A copy carrying the outcome of decrypting it: the decrypted content and the message it + /// replies to, or the reason it failed. Everything else is kept, including the ciphertext. + public func opened(_ outcome: Result<(content: Content, repliedTo: MessageID?), DecryptFailure>) -> ConversationMessage { let content: Content let repliedTo: MessageID? let failure: DecryptFailure? switch outcome { case .success(let plaintext): - content = .text(plaintext.text) + content = plaintext.content repliedTo = plaintext.repliedTo failure = nil case .failure(let reason): diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift index 301e667a7..1c72a3bec 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/MediaAttachment.swift @@ -25,11 +25,30 @@ public struct MediaAttachment: Hashable, Sendable, Codable { /// The ORIGINAL's BlurHash preview, or `nil` when the server carried none. public let blurhash: String? - public init(blobID: BlobID?, width: Int, height: Int, blurhash: String?) { + /// How the blob decrypts when it is end-to-end encrypted for the chat; nil for a plaintext blob. + public let sealed: SealedBlob? + + public init(blobID: BlobID?, width: Int, height: Int, blurhash: String?, sealed: SealedBlob? = nil) { self.blobID = blobID self.width = width self.height = height self.blurhash = blurhash + self.sealed = sealed + } +} + +/// What an end-to-end encrypted photo's blob needs, besides the chat key, to be decrypted and +/// checked: who uploaded it, which orders the public keys in its aad, and the plaintext length the +/// decrypted bytes must match. +public struct SealedBlob: Hashable, Sendable, Codable { + /// The member who uploaded the blob, always the sender of the message that references it. + public let senderID: UserID + /// The plaintext image's length in bytes, from the sender's sealed metadata. + public let plaintextSize: Int + + public init(senderID: UserID, plaintextSize: Int) { + self.senderID = senderID + self.plaintextSize = plaintextSize } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 2e97d2ca8..d194b4d9e 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 46 + public static let schemaVersion = 47 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Tests/FlipcashCoreTests/BlobRejectionReasonTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/BlobRejectionReasonTests.swift new file mode 100644 index 000000000..5068b45de --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/BlobRejectionReasonTests.swift @@ -0,0 +1,31 @@ +// +// BlobRejectionReasonTests.swift +// FlipcashCoreTests +// + +import Testing +import FlipcashAPI +@testable import FlipcashCore + +@Suite("Blob rejection reason") +struct BlobRejectionReasonTests { + + @Test("Keeps an internal rejection distinct from an unset reason") + func internal_isNotUnknown() { + #expect(BlobRejectionReason(.internal) == .internal) + #expect(BlobRejectionReason(.unknown) == .unknown) + } + + @Test("Keeps the raw value of a reason this build doesn't know") + func unrecognized_keepsRawValue() { + let reason = BlobRejectionReason(.UNRECOGNIZED(42)) + #expect(reason == .unrecognized(42)) + #expect("\(reason)" == "unrecognized(42)") + } + + @Test("A rejected status carries the mapped reason") + func rejectedState_carriesReason() { + let rejection = Flipcash_Blob_V1_RejectionMetadata.with { $0.reason = .internal } + #expect(BlobState(status: .rejected, rejection: rejection) == .rejected(.internal)) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift index 6fc90c087..8c52e4507 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift @@ -10,6 +10,70 @@ import Testing @Suite("Blob upload") struct BlobUploaderTests { + // MARK: - End-to-end encrypted - + + @Test("An encrypted upload reserves for its DM, as an opaque blob the size of the sealed bytes") + func encryptedUploadReservesForChat() async throws { + let transport = RecordingTransport() + let reserving = StubReserving(states: [.ready]) + let uploader = BlobUploader(reserving: reserving, transport: transport, pollInterval: .milliseconds(1), timeout: .seconds(5)) + let conversationID = ConversationID(data: Data(repeating: 7, count: 32)) + let sealed = Data(repeating: 0xCD, count: Self.fileBytes.count + EncryptedBlobUpload.overhead) + + let upload = try await uploader.storeEncrypted(Self.fileBytes, for: conversationID, owner: try Self.owner()) { plaintext, blobID in + #expect(blobID == StubReserving.blobID) + #expect(plaintext == Self.fileBytes) + return sealed + } + + #expect(upload == EncryptedBlobUpload(blobID: StubReserving.blobID, plaintextSize: Self.fileBytes.count)) + #expect(await reserving.encryptedFor == conversationID) + #expect(await reserving.declaredMimeType == "application/octet-stream") + #expect(await reserving.declaredSizeBytes == sealed.count) + let parsed = try await Self.parse(transport) + #expect(parsed.file == sealed) + } + + @Test("Metadata is stripped before encrypting, and the plaintext size is the stripped length") + func encryptedUploadStripsBeforeSealing() async throws { + let uploader = makeUploader(transport: RecordingTransport(), states: [.ready]) + let jpeg = Self.jpegCarryingComment() + let stripped = JPEGMetadata.stripped(jpeg) + #expect(stripped.count < jpeg.count) + + let upload = try await uploader.storeEncrypted(jpeg, for: ConversationID(data: Data(repeating: 7, count: 32)), owner: try Self.owner()) { plaintext, _ in + #expect(plaintext == stripped) + return Data(count: plaintext.count + EncryptedBlobUpload.overhead) + } + + #expect(upload.plaintextSize == stripped.count) + } + + @Test("A sealed blob of a size other than the one reserved is never uploaded") + func encryptedUploadRefusesWrongSize() async throws { + let transport = RecordingTransport() + let uploader = makeUploader(transport: transport, states: [.ready]) + + await #expect(throws: ErrorBlob.self) { + _ = try await uploader.storeEncrypted(Self.fileBytes, for: ConversationID(data: Data(repeating: 7, count: 32)), owner: try Self.owner()) { plaintext, _ in + plaintext + } + } + #expect(await transport.body == nil) + } + + @Test("The reservation names the chat only for an encrypted upload") + func reservationRequestNamesChat() throws { + let owner = try Self.owner() + let conversationID = ConversationID(data: Data(repeating: 7, count: 32)) + + let encrypted = BlobService.initiateRequest(mimeType: "application/octet-stream", sizeBytes: 10, encryptedFor: conversationID, owner: owner) + let plain = BlobService.initiateRequest(mimeType: "image/jpeg", sizeBytes: 10, encryptedFor: nil, owner: owner) + + #expect(encrypted.endToEndEncryptedFor == .chat(conversationID.proto)) + #expect(plain.endToEndEncryptedFor == nil) + } + // MARK: - Multipart body - @Test("Signed policy fields come first, the file last") @@ -305,6 +369,7 @@ struct BlobUploaderTests { let names: [String] let values: [String: String] let fileByteCount: Int + let file: Data } private static func parse(_ transport: RecordingTransport) async throws -> ParsedBody { @@ -320,6 +385,7 @@ struct BlobUploaderTests { var names: [String] = [] var values: [String: String] = [:] var fileByteCount = 0 + var file = Data() for segment in Self.segments(of: body, delimitedBy: "--\(boundary)\r\n") { guard let terminator = segment.range(of: Data("\r\n\r\n".utf8)) else { continue } @@ -334,12 +400,13 @@ struct BlobUploaderTests { if name == "file" { fileByteCount = payload.count + file = payload } else { values[name] = String(decoding: payload, as: UTF8.self) } } - return ParsedBody(names: names, values: values, fileByteCount: fileByteCount) + return ParsedBody(names: names, values: values, fileByteCount: fileByteCount, file: file) } private static func segments(of body: Data, delimitedBy delimiter: String) -> [Data] { @@ -409,15 +476,19 @@ private actor StubReserving: BlobReserving { private(set) var pollCount = 0 private(set) var reserveCount = 0 private(set) var declaredSizeBytes: Int? + private(set) var declaredMimeType: String? + private(set) var encryptedFor: ConversationID? init(states: [BlobState], completion: BlobState = .processing) { self.states = states self.completion = completion } - func initiateExternalUpload(mimeType: String, sizeBytes: Int, owner: KeyPair) async throws -> ReservedUpload { + func initiateExternalUpload(mimeType: String, sizeBytes: Int, encryptedFor: ConversationID?, owner: KeyPair) async throws -> ReservedUpload { reserveCount += 1 declaredSizeBytes = sizeBytes + declaredMimeType = mimeType + self.encryptedFor = encryptedFor return ReservedUpload( blobID: Self.blobID, diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift index bf2d53444..e0ec458c6 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatPreviewDecryptionTests.swift @@ -38,6 +38,25 @@ struct ChatPreviewDecryptionTests { #expect(contents(items) == [.text("hello")]) } + @Test("A decrypted photo previews as a photo that still knows it is encrypted") + func decryptedPhotoPreviewsAsPhoto() throws { + let blobID = BlobID(data: Data([7])) + let seal = SealedBlob(senderID: them, plaintextSize: 42) + let attachment = MediaAttachment(blobID: blobID, width: 4, height: 3, blurhash: "LKO2?U%2Tw=w", sealed: seal) + var message = encrypted(1, from: them) + message = message.opened(.success((content: .media([attachment], caption: "beach"), repliedTo: nil))) + + let items = ChatItem.preview(from: [message], selfUserID: me) + let content = try #require(contents(items).first) + guard case .media(let media) = content else { + Issue.record("Expected a photo, got \(content)") + return + } + #expect(media.blobID == blobID) + #expect(media.caption == "beach") + #expect(media.sealed == seal) + } + @Test("A message waiting on the peer's key is left out of the preview") func awaitingIsLeftOut() { let items = ChatItem.preview(from: [encrypted(1, from: them, text: "hi"), encrypted(2, from: them)], selfUserID: me) diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift index 8f2256b5c..a95ec7e2a 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatSealTests.swift @@ -127,4 +127,174 @@ struct ChatSealTests { #expect(encrypted.scheme == .x25519Xchacha20Poly1305) #expect(encrypted.nonce.count == 24) } + + // MARK: - Photos - + + private let blobID = BlobID(data: Data(repeating: 0xA5, count: 16)) + + private func photo(sizeBytes: Int = 1234) -> SealedPhoto { + SealedPhoto(blobID: blobID, mimeType: "image/jpeg", sizeBytes: sizeBytes, width: 640, height: 480, blurhash: "LEHV6nWB2yk8") + } + + /// The decrypted `.media` attachment `content` carries. + private func attachment(_ content: ConversationMessage.Content) throws -> (MediaAttachment, String?) { + guard case .media(let attachments, let caption) = content, attachments.count == 1 else { + Issue.record("Expected one media attachment, got \(content)") + throw CancellationError() + } + return (attachments[0], caption) + } + + @Test("The peer decrypts a sealed photo to its blob and sender-set metadata") + func photoRoundTrip() throws { + let sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(photo: photo(), caption: nil, repliedTo: nil) + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(sealed, from: aliceID)) + + let (media, caption) = try attachment(opened.content) + #expect(media == MediaAttachment( + blobID: blobID, width: 640, height: 480, blurhash: "LEHV6nWB2yk8", + sealed: SealedBlob(senderID: aliceID, plaintextSize: 1234) + )) + #expect(caption == nil) + #expect(opened.repliedTo == nil) + #expect(opened.decryptFailure == nil) + } + + @Test("A sealed photo carries its caption and the message it replies to inside the ciphertext") + func photoCaptionAndReply() throws { + let sealed = try seal(owner: alice, peer: bob, selfID: aliceID).seal(photo: photo(), caption: "sunset", repliedTo: MessageID(value: 9)) + guard case .encrypted = sealed.type else { + Issue.record("Expected encrypted content") + return + } + let opened = try seal(owner: bob, peer: alice, selfID: bobID).open(message(sealed, from: aliceID)) + + let (_, caption) = try attachment(opened.content) + #expect(caption == "sunset") + #expect(opened.repliedTo == MessageID(value: 9)) + } + + @Test("The sender's other device opens its own sealed photo") + func photoOwnMessage() throws { + let aliceSeal = try seal(owner: alice, peer: bob, selfID: aliceID) + let opened = aliceSeal.open(try message(aliceSeal.seal(photo: photo(), caption: nil, repliedTo: nil), from: aliceID)) + + let (media, _) = try attachment(opened.content) + #expect(media.sealed?.senderID == aliceID) + } + + @Test("Both members decrypt the blob the sender encrypted") + func blobRoundTrip() throws { + let image = Data((0..<4096).map { UInt8(truncatingIfNeeded: $0) }) + let sealedBlob = SealedBlob(senderID: aliceID, plaintextSize: image.count) + let blob = try seal(owner: alice, peer: bob, selfID: aliceID).encryptBlob(image, blobID: blobID) + + #expect(blob.count == image.count + 24 + 16) + #expect(try seal(owner: bob, peer: alice, selfID: bobID).decryptBlob(blob, blobID: blobID, sealed: sealedBlob) == image) + #expect(try seal(owner: alice, peer: bob, selfID: aliceID).decryptBlob(blob, blobID: blobID, sealed: sealedBlob) == image) + } + + @Test("A blob read under another blob id fails authentication") + func blobWrongID() throws { + let image = Data(repeating: 1, count: 100) + let blob = try seal(owner: alice, peer: bob, selfID: aliceID).encryptBlob(image, blobID: blobID) + let other = BlobID(data: Data(repeating: 0, count: 16)) + + #expect(throws: BlobOpenFailure.authentication) { + try seal(owner: bob, peer: alice, selfID: bobID).decryptBlob(blob, blobID: other, sealed: SealedBlob(senderID: aliceID, plaintextSize: 100)) + } + } + + @Test("A blob attributed to the wrong sender fails authentication") + func blobSwappedKeys() throws { + let image = Data(repeating: 1, count: 100) + let blob = try seal(owner: alice, peer: bob, selfID: aliceID).encryptBlob(image, blobID: blobID) + + // Bob reading it as his own upload puts the public keys in the aad the wrong way round. + #expect(throws: BlobOpenFailure.authentication) { + try seal(owner: bob, peer: alice, selfID: bobID).decryptBlob(blob, blobID: blobID, sealed: SealedBlob(senderID: bobID, plaintextSize: 100)) + } + } + + @Test("A blob whose plaintext is not the declared size is rejected") + func blobLengthMismatch() throws { + let image = Data(repeating: 1, count: 100) + let blob = try seal(owner: alice, peer: bob, selfID: aliceID).encryptBlob(image, blobID: blobID) + + #expect(throws: BlobOpenFailure.length) { + try seal(owner: bob, peer: alice, selfID: bobID).decryptBlob(blob, blobID: blobID, sealed: SealedBlob(senderID: aliceID, plaintextSize: 99)) + } + } + + /// The opened result of `media`, sealed by Alice by hand so it can break the contract. + private func openHandSealed(_ media: Flipcash_Messaging_V1_MediaContent) throws -> ConversationMessage { + let bytes = SharedBytes.shared + let plaintext = Flipcash_Messaging_V1_Content.with { $0.media = media } + let owner = SharedCore.KeyPair(publicKey: bytes.byteArray(data: alice.publicKey.data), privateKey: bytes.byteArray(data: alice.privateKey.data)) + let chatKey = try DefaultChatCipher.shared.chatKey(ownKeyPair: owner, peerPublicKey: bytes.byteArray(data: bob.publicKey.data), chatId: bytes.byteArray(data: chatID.data)) + let payload = try DefaultChatCipher.shared.encrypt( + content: bytes.byteArray(data: try plaintext.serializedData()), + chatKey: chatKey, + senderPk: bytes.byteArray(data: alice.publicKey.data), + recipientPk: bytes.byteArray(data: bob.publicKey.data), + chatId: bytes.byteArray(data: chatID.data) + ) + let content = Flipcash_Messaging_V1_Content.with { + $0.encrypted = .with { + $0.scheme = ChatSeal.scheme + $0.nonce = bytes.data(bytes: payload.nonce) + $0.ciphertext = bytes.data(bytes: payload.ciphertext) + } + } + return try seal(owner: bob, peer: alice, selfID: bobID).open(message(content, from: aliceID)) + } + + /// A valid sealed photo's `MediaContent`, for the validation cases to break one field of. + private var validMedia: Flipcash_Messaging_V1_MediaContent { + .with { $0.items = [photo().media] } + } + + @Test("Hand-sealed valid media opens, so the cases below fail for their one broken field") + func handSealedValid() throws { + #expect(try openHandSealed(validMedia).decryptFailure == nil) + } + + @Test( + "Media whose metadata breaks the contract is unsupported", + arguments: [ + "zero size", "non-image mime", "empty mime", "long mime", "zero width", "zero height", + "long blurhash", "no metadata", "no blob id", "short blob id", "not original", + "two renditions", "two items", "no items", "no image metadata", + ] + ) + func invalidMediaIsUnsupported(_ breakage: String) throws { + var media = validMedia + switch breakage { + case "zero size": media.items[0].renditions[0].blob.sizeBytes = 0 + case "non-image mime": media.items[0].renditions[0].blob.mimeType = "video/mp4" + case "empty mime": media.items[0].renditions[0].blob.mimeType = "" + case "long mime": media.items[0].renditions[0].blob.mimeType = "image/" + String(repeating: "x", count: 250) + case "zero width": media.items[0].renditions[0].blob.image.width = 0 + case "zero height": media.items[0].renditions[0].blob.image.height = 0 + case "long blurhash": media.items[0].renditions[0].blob.image.blurhash = String(repeating: "L", count: 65) + case "no metadata": media.items[0].renditions[0].clearBlob() + case "no blob id": media.items[0].renditions[0].clearBlobID() + case "short blob id": media.items[0].renditions[0].blobID.value = Data([1, 2, 3]) + case "not original": media.items[0].renditions[0].role = .unknown + case "two renditions": media.items[0].renditions.append(media.items[0].renditions[0]) + case "two items": media.items.append(media.items[0]) + case "no items": media.items = [] + case "no image metadata": media.items[0].renditions[0].blob.kind = nil + default: Issue.record("Unknown breakage \(breakage)") + } + + #expect(try openHandSealed(media).decryptFailure == .unsupported) + } + + @Test("A download URL inside sealed media is ignored") + func downloadURLIgnored() throws { + var media = validMedia + media.items[0].renditions[0].blob.downloadURL = .with { $0.url = "https://example.com/x" } + #expect(try openHandSealed(media).decryptFailure == nil) + } } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift index 5c6644374..849f69b06 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/UploadPolicyTests.swift @@ -34,6 +34,22 @@ struct UploadPolicyTests { #expect(policy.constraints[2].image == nil) } + @Test("Maps the encrypted-upload constraints, and their absence to nil") + func mapsEncryptedConstraints() { + var proto = Flipcash_Blob_V1_UploadPolicy() + #expect(UploadPolicy(proto).encrypted == nil) + + proto.encrypted = .with { + $0.maxSizeBytes = 5_000_040 + $0.image = .with { $0.maxWidth = 2048; $0.maxHeight = 1536; $0.maxPixels = 3_000_000 } + } + + #expect(UploadPolicy(proto).encrypted == UploadPolicy.EncryptedConstraints( + maxSizeBytes: 5_000_040, + image: UploadPolicy.ImageConstraints(maxWidth: 2048, maxHeight: 1536, maxPixels: 3_000_000) + )) + } + @Test("An unset TTL maps to nil, not zero") func unsetTTLIsNil() { var proto = Self.proto(version: "v7", ttlSeconds: 300) diff --git a/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatCipherBlobVectorTests.swift b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatCipherBlobVectorTests.swift new file mode 100644 index 000000000..06872e97b --- /dev/null +++ b/FlipcashCoreVectors/Tests/FlipcashCoreVectorsTests/ChatCipherBlobVectorTests.swift @@ -0,0 +1,121 @@ +import Foundation +import Testing +import SharedCore +@testable import FlipcashCore + +/// Photo-blob half of `test-vectors/chat_cipher.json`. The canonical copy lives in the orchestrator +/// repo; this one is synced. The vectors fix the nonce, which production draws at random, so only +/// the decrypt direction is reproducible: each blob must open to its image, and each reject must not. +@Suite struct ChatCipherBlobVectorTests { + + struct BlobVector: Decodable, Sendable, CustomTestStringConvertible { + let name: String + let chatId: String + let chatKey: String + let senderPublicKey: String + let recipientPublicKey: String + let blobId: String + let image: String? + let blob: String + + var testDescription: String { name } + } + + struct Member: Decodable, Sendable { + let edSeed: String + let edPublicKey: String + } + + struct ChatKeyVector: Decodable, Sendable { + let name: String + let chatId: String + let memberA: Member + let memberB: Member + let chatKey: String + } + + struct Rejects: Decodable { + let decryptBlob: [BlobVector] + } + + struct Fixture: Decodable { + let chatKey: [ChatKeyVector] + let blobs: [BlobVector] + let rejects: Rejects + } + + static let fixture: Fixture? = try? loadFixture() + static let blobs: [BlobVector] = fixture?.blobs ?? [] + static let rejects: [BlobVector] = fixture?.rejects.decryptBlob ?? [] + + private static func loadFixture() throws -> Fixture { + let url = try #require( + Bundle.module.url(forResource: "chat_cipher", withExtension: "json", subdirectory: "Fixtures") + ) + return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: url)) + } + + @Test func fixtureLoads() throws { + let fixture = try Self.loadFixture() + #expect(!fixture.blobs.isEmpty) + #expect(!fixture.rejects.decryptBlob.isEmpty) + } + + private func decrypt(_ vector: BlobVector) throws -> Data { + let bytes = SharedBytes.shared + let image = try DefaultChatCipher.shared.decryptBlob( + blob: bytes.byteArray(data: Data(hex: vector.blob)), + chatKey: bytes.byteArray(data: Data(hex: vector.chatKey)), + senderPk: bytes.byteArray(data: Data(hex: vector.senderPublicKey)), + recipientPk: bytes.byteArray(data: Data(hex: vector.recipientPublicKey)), + chatId: bytes.byteArray(data: Data(hex: vector.chatId)), + blobId: bytes.byteArray(data: Data(hex: vector.blobId)) + ) + return bytes.data(bytes: image) + } + + @Test(arguments: blobs) + func blobDecryptsToItsImage(_ vector: BlobVector) throws { + #expect(try decrypt(vector) == Data(hex: vector.image ?? "")) + } + + @Test(arguments: rejects) + func rejectedBlobFailsToDecrypt(_ vector: BlobVector) { + #expect(throws: (any Error).self) { try decrypt(vector) } + } + + /// `ChatSeal`, built from the members' seeds the way the app builds it, opens the same blobs: + /// the recipient through the peer's key and the sender's other device through its own. + @Test(arguments: blobs) + func chatSealOpensBlobForBothMembers(_ vector: BlobVector) throws { + let pair = try #require(Self.fixture?.chatKey.first { + $0.chatId == vector.chatId && $0.memberA.edPublicKey == vector.senderPublicKey + }) + let alice = try KeyPair(seed: Seed32(Data(hex: pair.memberA.edSeed))) + let bob = try KeyPair(seed: Seed32(Data(hex: pair.memberB.edSeed))) + let aliceID = UUID() + let conversationID = ConversationID(data: Data(hex: vector.chatId)) + let image = Data(hex: vector.image ?? "") + let sealed = SealedBlob(senderID: aliceID, plaintextSize: image.count) + let blobID = BlobID(data: Data(hex: vector.blobId)) + + let asBob = try ChatSeal(cipher: DefaultChatCipher.shared, owner: bob, peerPublicKey: alice.publicKey, conversationID: conversationID, selfUserID: UUID()) + let asAlice = try ChatSeal(cipher: DefaultChatCipher.shared, owner: alice, peerPublicKey: bob.publicKey, conversationID: conversationID, selfUserID: aliceID) + + #expect(try asBob.decryptBlob(Data(hex: vector.blob), blobID: blobID, sealed: sealed) == image) + #expect(try asAlice.decryptBlob(Data(hex: vector.blob), blobID: blobID, sealed: sealed) == image) + } +} + +private extension Data { + init(hex: String) { + var data = Data(capacity: hex.count / 2) + var index = hex.startIndex + while index < hex.endIndex { + let next = hex.index(index, offsetBy: 2) + data.append(UInt8(hex[index.. Data) throws -> ChatMediaCell { + let file = FileManager.default.temporaryDirectory.appendingPathComponent("chat-media-\(UUID().uuidString)") + try Data([1, 2, 3]).write(to: file) + let media = ChatMediaContent( + blobID: BlobID(data: Data(UUID().uuidString.utf8)), + width: 100, + height: 100, + blurhash: Self.blurhash, + caption: nil, + isRedacted: false, + sealed: SealedBlob(senderID: UUID(), plaintextSize: 3) + ) + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure( + with: ChatMessage(id: "1", content: .media(media), sender: .other, reactions: [], canReact: true), + maxWidth: 240, + localImage: nil, + remote: ChatMediaLocation(url: file, decrypt: decrypt) + ) + return cell + } + + private func waitForUnavailable(_ cell: ChatMediaCell) async { + for _ in 0..<200 where cell.unavailableLabel.isHidden { + try? await Task.sleep(for: .milliseconds(10)) + } + } + + @Test("An encrypted photo that fails to authenticate keeps its BlurHash, says it can't be shown, and takes no tap") + func undecryptablePhotoShowsUnavailable() async throws { + let cell = try encryptedCell { _ in throw BlobOpenFailure.authentication } + #expect(cell.unavailableLabel.isHidden) + + await waitForUnavailable(cell) + + #expect(!cell.unavailableLabel.isHidden) + #expect(!cell.imageTap.isEnabled) + #expect(cell.imageView.image != nil) + } + + @Test("An encrypted photo whose plaintext doesn't decode as an image says it can't be shown") + func undecodablePhotoShowsUnavailable() async throws { + let cell = try encryptedCell { $0 } + + await waitForUnavailable(cell) + + #expect(!cell.unavailableLabel.isHidden) + } + + @Test("Only a blob that will never open counts as undecryptable; a failed fetch stays retryable") + func undecryptableClassification() { + func providerError(_ underlying: any Error) -> KingfisherError { + .imageSettingError(reason: .dataProviderError(provider: RawImageDataProvider(data: Data(), cacheKey: "k"), error: underlying)) + } + #expect(ChatMediaImageSource.isUndecryptable(providerError(BlobOpenFailure.authentication))) + #expect(ChatMediaImageSource.isUndecryptable(providerError(BlobOpenFailure.length))) + #expect(ChatMediaImageSource.isUndecryptable(providerError(BlobOpenFailure.undecodable))) + #expect(!ChatMediaImageSource.isUndecryptable(providerError(URLError(.notConnectedToInternet)))) } } diff --git a/FlipcashTests/Chat/ChatMediaURLResolverTests.swift b/FlipcashTests/Chat/ChatMediaURLResolverTests.swift index 9b51bd4c3..bff8027e5 100644 --- a/FlipcashTests/Chat/ChatMediaURLResolverTests.swift +++ b/FlipcashTests/Chat/ChatMediaURLResolverTests.swift @@ -36,8 +36,8 @@ struct ChatMediaURLResolverTests { return url } - #expect(resolver.url(for: photo(isRedacted: true), canReact: true) { _ in } == nil) - #expect(resolver.url(for: photo(isRedacted: false), canReact: false) { _ in } == nil) + #expect(resolver.location(for: photo(isRedacted: true), canReact: true) { _ in }?.url == nil) + #expect(resolver.location(for: photo(isRedacted: false), canReact: false) { _ in }?.url == nil) // Give any stray fetch task a chance to run before asserting none did. await Task.yield() @@ -54,17 +54,17 @@ struct ChatMediaURLResolverTests { } let photo = photo(isRedacted: false) - #expect(resolver.url(for: photo, canReact: false) { _ in } == nil) + #expect(resolver.location(for: photo, canReact: false) { _ in }?.url == nil) let resolved = await withCheckedContinuation { continuation in - let immediate = resolver.url(for: photo, canReact: true) { continuation.resume(returning: $0) } + let immediate = resolver.location(for: photo, canReact: true) { continuation.resume(returning: $0.url) } #expect(immediate == nil) // A second dequeue while the first fetch is in flight does not start another. - #expect(resolver.url(for: photo, canReact: true) { _ in } == nil) + #expect(resolver.location(for: photo, canReact: true) { _ in }?.url == nil) } #expect(resolved == url) - #expect(resolver.url(for: photo, canReact: true) { _ in } == url) + #expect(resolver.location(for: photo, canReact: true) { _ in }?.url == url) #expect(spy.calls == [blobID]) } @@ -77,10 +77,10 @@ struct ChatMediaURLResolverTests { return url } - #expect(resolver.thumbnailURL(for: .media(thumbnailBlobID: nil), canReact: true) { _ in } == nil) - #expect(resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: false) { _ in } == nil) - #expect(resolver.thumbnailURL(for: .text, canReact: true) { _ in } == nil) - #expect(resolver.thumbnailURL(for: .unavailable, canReact: true) { _ in } == nil) + #expect(resolver.thumbnailLocation(for: .media(thumbnailBlobID: nil, sealed: nil), canReact: true) { _ in }?.url == nil) + #expect(resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: nil), canReact: false) { _ in }?.url == nil) + #expect(resolver.thumbnailLocation(for: .text, canReact: true) { _ in }?.url == nil) + #expect(resolver.thumbnailLocation(for: .unavailable, canReact: true) { _ in }?.url == nil) await Task.yield() #expect(spy.calls.isEmpty) @@ -96,14 +96,14 @@ struct ChatMediaURLResolverTests { } let resolved = await withCheckedContinuation { continuation in - let immediate = resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) { - continuation.resume(returning: $0) + let immediate = resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: nil), canReact: true) { + continuation.resume(returning: $0.url) } #expect(immediate == nil) } #expect(resolved == url) - #expect(resolver.url(for: photo(isRedacted: false), canReact: true) { _ in } == url) + #expect(resolver.location(for: photo(isRedacted: false), canReact: true) { _ in }?.url == url) #expect(spy.calls == [blobID]) } @@ -116,11 +116,11 @@ struct ChatMediaURLResolverTests { return url } - #expect(await resolver.thumbnailURL(for: .media(thumbnailBlobID: nil), canReact: true) == nil) + #expect(await resolver.thumbnailLocation(for: .media(thumbnailBlobID: nil, sealed: nil), canReact: true)?.url == nil) #expect(spy.calls.isEmpty) - #expect(await resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) == url) - #expect(await resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) == url) + #expect(await resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: nil), canReact: true)?.url == url) + #expect(await resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: nil), canReact: true)?.url == url) #expect(spy.calls == [blobID]) } @@ -132,13 +132,54 @@ struct ChatMediaURLResolverTests { let (row, quote) = await withCheckedContinuation { continuation in var row: URL? - let immediate = resolver.url(for: photo, canReact: true) { row = $0 } + let immediate = resolver.location(for: photo, canReact: true) { row = $0.url } #expect(immediate == nil) - _ = resolver.thumbnailURL(for: .media(thumbnailBlobID: blobID), canReact: true) { quote in - continuation.resume(returning: (row, quote)) + _ = resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: nil), canReact: true) { quote in + continuation.resume(returning: (row, quote.url)) } } #expect(row == url) #expect(quote == url) } + + @Test("An encrypted photo waits on the chat's decryption, fetches it once, and decrypts with its blob") + func encryptedPhotoDecrypts() async throws { + let url = url + let sealed = SealedBlob(senderID: UserID(), plaintextSize: 3) + let decryptCalls = FetchSpy() + let resolver = ChatMediaURLResolver( + fetch: { _ in url }, + decrypt: { + decryptCalls.calls.append(BlobID(data: Data())) + return { blob, blobID, sealed in Data(blob.reversed()) + blobID.data + Data([UInt8(sealed.plaintextSize)]) } + } + ) + let sealedPhoto = ChatMediaContent(blobID: blobID, width: 1, height: 1, blurhash: nil, caption: nil, isRedacted: false, sealed: sealed) + + let location = await withCheckedContinuation { continuation in + let immediate = resolver.location(for: sealedPhoto, canReact: true) { continuation.resume(returning: $0) } + #expect(immediate == nil) + } + #expect(location.url == url) + let decrypt = try #require(location.decrypt) + #expect(try decrypt(Data([1, 2])) == Data([2, 1, 1, 3])) + + let cached = try #require(resolver.location(for: sealedPhoto, canReact: true) { _ in }) + #expect(cached.decrypt != nil) + #expect(await resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: sealed), canReact: true)?.decrypt != nil) + #expect(decryptCalls.calls.count == 1) + #expect(resolver.location(for: photo(isRedacted: false), canReact: true) { _ in }?.decrypt == nil) + } + + @Test("An encrypted photo stays on its BlurHash while the chat's key is unknown") + func encryptedPhotoWithoutKey() async { + let url = url + let resolver = ChatMediaURLResolver(fetch: { _ in url }, decrypt: { nil }) + let sealed = SealedBlob(senderID: UserID(), plaintextSize: 3) + let photo = ChatMediaContent(blobID: blobID, width: 1, height: 1, blurhash: nil, caption: nil, isRedacted: false, sealed: sealed) + + let location = await resolver.thumbnailLocation(for: .media(thumbnailBlobID: blobID, sealed: sealed), canReact: true) + #expect(location == nil) + #expect(resolver.location(for: photo, canReact: true) { _ in } == nil) + } } diff --git a/FlipcashTests/Chat/ChatMediaUploaderTests.swift b/FlipcashTests/Chat/ChatMediaUploaderTests.swift index 27e9722ea..e82923ee5 100644 --- a/FlipcashTests/Chat/ChatMediaUploaderTests.swift +++ b/FlipcashTests/Chat/ChatMediaUploaderTests.swift @@ -7,6 +7,7 @@ import Testing import UIKit +import SharedCore @testable import FlipcashCore @testable import Flipcash @@ -46,7 +47,7 @@ struct ChatMediaUploaderTests { let blobID = try await ChatMediaUploader(blob: blob).upload(Self.image(width: 3000, height: 2000)) { width, height in prepared = (width, height, blob.storedData.count) - } + }.blobID #expect(prepared?.width == 2048) #expect(prepared?.height == 1365) @@ -117,9 +118,9 @@ struct ChatMediaUploaderTests { let blob = MockChatMediaBlobStore() blob.storeResults = [.failure(ErrorBlob.network(URLError(.timedOut)))] - let blobID = try await ChatMediaUploader(blob: blob, backoff: [.zero]).upload(Self.image(width: 40, height: 30)) { _, _ in } + let photo = try await ChatMediaUploader(blob: blob, backoff: [.zero]).upload(Self.image(width: 40, height: 30)) { _, _ in } - #expect(blobID == MockChatMediaBlobStore.blobID) + #expect(photo == .plain(MockChatMediaBlobStore.blobID)) #expect(blob.storeAttempts == 2) } @@ -146,6 +147,91 @@ struct ChatMediaUploaderTests { #expect(blob.storeAttempts == 0) } + // MARK: - Encrypted - + + private static func seal() throws -> ChatSeal { + try ChatSeal( + cipher: DefaultChatCipher.shared, + owner: KeyPair.generate()!, + peerPublicKey: KeyPair.generate()!.publicKey, + conversationID: ConversationID(data: Data(repeating: 0x07, count: 32)), + selfUserID: UUID() + ) + } + + @Test("In a chat that encrypts, the photo is sealed for it within the encrypted ceiling, with its blurhash") + func encryptedChatSealsPhoto() async throws { + let policy = UploadPolicy(version: "v1", ttl: nil, constraints: [ + .init(pattern: "image/*", maxSizeBytes: 5_000_000, image: .init(maxWidth: 4096, maxHeight: 4096, maxPixels: 0)), + ], encrypted: .init(maxSizeBytes: 2_000_000, image: .init(maxWidth: 1000, maxHeight: 1000, maxPixels: 0))) + let blob = MockChatMediaBlobStore(policy: policy) + let seal = try Self.seal() + var uploader = ChatMediaUploader(blob: blob) + uploader.seal = { seal } + + let photo = try await uploader.upload(Self.image(width: 3000, height: 2000)) { _, _ in } + + guard case .sealed(let sealed) = photo else { + Issue.record("Expected a sealed photo") + return + } + #expect(blob.storedData.isEmpty) + #expect(blob.encryptedStores.count == 1) + #expect(blob.encryptedStores.first?.conversationID == seal.conversationID) + #expect(sealed.width == 1000) + #expect((666...667).contains(sealed.height)) + #expect(sealed.mimeType == "image/jpeg") + #expect(sealed.sizeBytes == blob.encryptedStores.first?.image.count) + #expect(!sealed.blurhash.isEmpty && sealed.blurhash.count <= 64) + #expect(blob.finalizedBlobIDs == [sealed.blobID]) + } + + @Test("A chat that encrypts under a policy with no encrypted constraints uploads nothing, for good") + func encryptedChatWithoutPolicyUploadsNothing() async throws { + let blob = MockChatMediaBlobStore() + let seal = try Self.seal() + var uploader = ChatMediaUploader(blob: blob) + uploader.seal = { seal } + + let error = await Self.uploadError(uploader) + + #expect(error?.isRetryable == false) + #expect(blob.storeAttempts == 0) + } + + @Test("A missing peer key uploads nothing and stays retryable; it never falls back to plaintext") + func missingPeerKeyUploadsNothing() async { + let blob = MockChatMediaBlobStore() + var uploader = ChatMediaUploader(blob: blob) + uploader.seal = { throw PeerKeyUnavailable(userID: UUID()) } + + let error = await Self.uploadError(uploader) + + #expect(error?.isRetryable == true) + #expect(blob.storeAttempts == 0) + } + + // MARK: - Gate - + + @Test("Camera and Photos show in an encrypted DM, a plaintext chat, and not before the chat loads") + func gateShowsPhotosInEncryptedDMs() { + let encrypted = Conversation( + id: ConversationID(data: Data(repeating: 0x01, count: 32)), + members: [ConversationMember(userID: UUID(), displayName: "A"), ConversationMember(userID: UUID(), displayName: "B")], + lastMessage: nil, + lastActivity: Date(timeIntervalSince1970: 0), + type: .contactDm, + useE2Ee: true + ) + #expect(E2eePolicy.shouldEncrypt(encrypted)) + #expect(ChatMediaGate.acceptsMedia(encrypted)) + + var plain = encrypted + plain.useE2Ee = false + #expect(ChatMediaGate.acceptsMedia(plain)) + #expect(!ChatMediaGate.acceptsMedia(nil)) + } + // MARK: - Chip - @Test("A staged chip uploads, with its dimensions set before the bytes are stored") @@ -158,7 +244,7 @@ struct ChatMediaUploaderTests { #expect(chip.state == .preparing) - let blobID = try await #require(chip.uploadTask).value + let blobID = try await #require(chip.uploadTask).value.blobID #expect(dimensionsAtStore?.0 == 20) #expect(dimensionsAtStore?.1 == 15) @@ -189,7 +275,7 @@ struct ChatMediaUploaderTests { blob.finalization = .success(()) chip.startUpload(using: uploader) - let blobID = try await #require(chip.uploadTask).value + let blobID = try await #require(chip.uploadTask).value.blobID #expect(chip.state == .uploaded(blobID)) } @@ -243,4 +329,22 @@ struct ChatMediaUploaderTests { #expect(ChatMediaUploadError.failed(ErrorBlob.quotaExceeded).reportingLevel == .info) #expect(ChatMediaUploadError.failed(ErrorBlob.unknown).reportingLevel == .error) } + + @Test("A photo goes out only under the encryption choice it was uploaded for") + func photoSendMatchesSeal() throws { + let seal = try Self.seal() + let chat = seal.conversationID + let other = ConversationID(data: Data(repeating: 0x08, count: 32)) + let blobID = BlobID(data: Data([1])) + let sealed = SealedPhoto(blobID: blobID, mimeType: "image/jpeg", sizeBytes: 10, width: 1, height: 1, blurhash: "00") + + #expect(EncryptedChatClient.photoSend(.plain(blobID), seal: nil, conversationID: chat) == .plain(blobID)) + #expect(EncryptedChatClient.photoSend(.sealed(sealed), seal: seal, conversationID: chat) == .sealed(sealed, seal)) + // The chat started encrypting after the photo uploaded in plaintext. + #expect(EncryptedChatClient.photoSend(.plain(blobID), seal: seal, conversationID: chat) == .mismatch) + // The chat stopped encrypting after the photo uploaded sealed. + #expect(EncryptedChatClient.photoSend(.sealed(sealed), seal: nil, conversationID: chat) == .mismatch) + // A seal for a different chat. + #expect(EncryptedChatClient.photoSend(.sealed(sealed), seal: seal, conversationID: other) == .mismatch) + } } diff --git a/FlipcashTests/Chat/ChatMediaViewerTests.swift b/FlipcashTests/Chat/ChatMediaViewerTests.swift index 53da3ca17..bc9cbcca7 100644 --- a/FlipcashTests/Chat/ChatMediaViewerTests.swift +++ b/FlipcashTests/Chat/ChatMediaViewerTests.swift @@ -38,7 +38,7 @@ struct ChatMediaViewerTests { localImage: UIImage? = nil, remoteURL: URL? = ChatMediaViewerTests.remoteURL ) -> ChatMediaViewerRequest? { - ChatMediaViewerRequest(message: message, localImage: localImage, remoteURL: remoteURL, placeholder: nil) { nil } + ChatMediaViewerRequest(message: message, localImage: localImage, remote: remoteURL.map { ChatMediaLocation(url: $0) }, placeholder: nil) { nil } } // MARK: - Request @@ -47,7 +47,7 @@ struct ChatMediaViewerTests { func viewablePhotoOpens() throws { let request = try #require(request(message(media()))) #expect(request.blobID == Self.blobID) - #expect(request.remoteURL == Self.remoteURL) + #expect(request.remote?.url == Self.remoteURL) } @Test("A redacted photo, or one seen while previewing a group, never opens") @@ -79,7 +79,7 @@ struct ChatMediaViewerTests { func zoomsFromSource() throws { let source = UIView() let request = try #require(ChatMediaViewerRequest( - message: message(media()), localImage: Self.localImage, remoteURL: nil, placeholder: nil + message: message(media()), localImage: Self.localImage, remote: nil, placeholder: nil ) { source }) let viewer = ChatMediaViewerController(request: request) { _ in } @@ -118,7 +118,7 @@ struct ChatMediaViewerTests { func shareWaitsForPhoto() throws { let placeholder = UIImage(systemName: "photo")! let request = try #require(ChatMediaViewerRequest( - message: message(media()), localImage: nil, remoteURL: URL(string: "https://example.invalid/never.jpg")!, placeholder: placeholder + message: message(media()), localImage: nil, remote: ChatMediaLocation(url: URL(string: "https://example.invalid/never.jpg")!), placeholder: placeholder ) { nil }) var shared: UIImage? let viewer = ChatMediaViewerController(request: request) { shared = $0 } diff --git a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift index 3d4037b4a..8785bb956 100644 --- a/FlipcashTests/Chat/ChatQuoteBubbleTests.swift +++ b/FlipcashTests/Chat/ChatQuoteBubbleTests.swift @@ -142,8 +142,8 @@ struct ChatQuoteBubbleTests { @Test("A quoted photo draws its thumbnail; a redacted one draws none") func mediaQuote_drawsThumbnailOnlyWhenFetchable() { - let photo = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: BlobID(data: Data([1])))) - let redacted = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: nil)) + let photo = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: BlobID(data: Data([1])), sealed: nil)) + let redacted = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: nil, sealed: nil)) #expect(laidOutCell(quote: photo).bubbleView.quotePanel.thumbnailView.isHidden == false) #expect(laidOutCell(quote: redacted).bubbleView.quotePanel.thumbnailView.isHidden == true) @@ -151,7 +151,7 @@ struct ChatQuoteBubbleTests { @Test("A recycled panel drops the photo it quoted before") func reuse_dropsThumbnail() { - let photo = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: BlobID(data: Data([1])))) + let photo = ChatQuote(stableID: "7", authorName: "Ada", snippet: "Photo", kind: .media(thumbnailBlobID: BlobID(data: Data([1])), sealed: nil)) let cell = laidOutCell(quote: photo) cell.configure(with: ChatMessage(id: "2", content: .text("hi"), sender: .me, quote: quote), maxWidth: Self.maxWidth) diff --git a/FlipcashTests/Chat/ConversationMediaSendTests.swift b/FlipcashTests/Chat/ConversationMediaSendTests.swift index b6a298de3..47c9ad38b 100644 --- a/FlipcashTests/Chat/ConversationMediaSendTests.swift +++ b/FlipcashTests/Chat/ConversationMediaSendTests.swift @@ -45,14 +45,14 @@ struct ConversationMediaSendTests { private func uploadedChip(_ name: String) -> ComposerChip { let chip = ComposerChip(image: testImage()) let blobID = blobID(name) - chip.uploadTask = Task { blobID } + chip.uploadTask = Task { .plain(blobID) } return chip } /// A chip whose upload ends only when the test opens `gate`. private func gatedChip(_ gate: UploadGate, image: UIImage? = nil) -> ComposerChip { let chip = ComposerChip(image: image ?? testImage()) - chip.uploadTask = Task { try await gate.wait() } + chip.uploadTask = Task { .plain(try await gate.wait()) } return chip } diff --git a/FlipcashTests/Database/Database+ConversationsTests.swift b/FlipcashTests/Database/Database+ConversationsTests.swift index f614bfbf0..76593f653 100644 --- a/FlipcashTests/Database/Database+ConversationsTests.swift +++ b/FlipcashTests/Database/Database+ConversationsTests.swift @@ -501,6 +501,27 @@ struct DatabaseConversationsTests { #expect(try database.getConversationMessages(conversationID: id) == [captioned, bare]) } + @Test("A decrypted photo reloads with what it needs to decrypt its blob again") + func sealedMediaRoundTrip() throws { + let (database, url) = try Database.makeTemp() + defer { Database.removeTemp(at: url) } + let id = ConversationID.test(1) + let attachment = MediaAttachment( + blobID: BlobID(data: Data(repeating: 9, count: 16)), width: 300, height: 400, blurhash: "LEHV6nWB2yk8", + sealed: SealedBlob(senderID: otherID, plaintextSize: 4321) + ) + let message = ConversationMessage( + id: MessageID(value: 1), senderID: otherID, + content: .media([attachment], caption: "secret"), + date: Date(timeIntervalSince1970: 10), unreadSeq: 1, eventSequence: 3, + sealed: ConversationMessage.Sealed(scheme: 1, nonce: Data([1]), ciphertext: Data([2])) + ) + + try database.upsertConversationMessages([message], conversationID: id) + + #expect(try database.getConversationMessages(conversationID: id) == [message]) + } + @Test("A redacted media message reloads still redacted, so its bytes stay unfetched after a relaunch") func redactedMediaRoundTrip() throws { let (database, url) = try Database.makeTemp() diff --git a/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift b/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift index e21dcef49..92a8464b5 100644 --- a/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift +++ b/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift @@ -25,6 +25,8 @@ final class MockChatMediaBlobStore: ChatMediaBlobStoring { private(set) var storedData: [Data] = [] private(set) var storedMimeTypes: [String] = [] private(set) var finalizedBlobIDs: [BlobID] = [] + /// The plaintext images `storeEncryptedBlob` was handed, with the chat each was sealed for. + private(set) var encryptedStores: [(image: Data, conversationID: ConversationID)] = [] init(policy: UploadPolicy = UploadPolicy(version: "v1", ttl: nil, constraints: [ .init(pattern: "image/*", maxSizeBytes: 5_000_000, image: .init(maxWidth: 2048, maxHeight: 2048, maxPixels: 0)), @@ -46,6 +48,15 @@ final class MockChatMediaBlobStore: ChatMediaBlobStoring { return blobID } + func storeEncryptedBlob(_ image: Data, seal: ChatSeal) async throws -> EncryptedBlobUpload { + storeAttempts += 1 + onStore?() + let result = storeResults.isEmpty ? .success(Self.blobID) : storeResults.removeFirst() + let blobID = try result.get() + encryptedStores.append((image, seal.conversationID)) + return EncryptedBlobUpload(blobID: blobID, plaintextSize: image.count) + } + func awaitBlobFinalization(blobID: BlobID) async throws { try finalization.get() finalizedBlobIDs.append(blobID) diff --git a/FlipcashTests/TestSupport/MockConversations.swift b/FlipcashTests/TestSupport/MockConversations.swift index 1e000493b..0dfbcd2a5 100644 --- a/FlipcashTests/TestSupport/MockConversations.swift +++ b/FlipcashTests/TestSupport/MockConversations.swift @@ -23,6 +23,8 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con let caption: String? let repliedTo: MessageID? let clientMessageID: UUID + /// The sealed metadata the photo went out with, nil for a plaintext photo. + var sealed: SealedPhoto? = nil } struct TypingCall: Sendable { let conversationID: ConversationID; let state: TypingState } /// A scripted `GetDelta` batch: one `onBatch` call with these messages + checkpoint. @@ -409,9 +411,23 @@ final class MockConversations: ConversationFetching, ConversationMembership, Con ) } - func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, blobID: BlobID, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + func openingSeal(owner: KeyPair, conversationID: ConversationID) async -> ChatSeal? { + nil + } + + func photoSeal(owner: KeyPair, conversationID: ConversationID) async throws -> ChatSeal? { + nil + } + + func sendMediaMessage(owner: KeyPair, conversationID: ConversationID, photo: UploadedPhoto, caption: String?, repliedTo: MessageID?, clientMessageID: UUID) async throws -> ConversationMessage { + let blobID = photo.blobID + let sealed: SealedPhoto? + switch photo { + case .plain: sealed = nil + case .sealed(let photo): sealed = photo + } let (count, error) = lock.withLock { - _sentMedia.append(SentMedia(conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID)) + _sentMedia.append(SentMedia(conversationID: conversationID, blobID: blobID, caption: caption, repliedTo: repliedTo, clientMessageID: clientMessageID, sealed: sealed)) return (_sentMedia.count, _mediaSendError) } if let error { throw error } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index 82406c59b..265dfc244 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -148,8 +148,8 @@ public final class ChatBubbleView: UIView { /// Whether this bubble is currently flashing. var isFlashingAttention: Bool { background.isFlashingAttention } - /// Fills the bubble; `quoteThumbnailURL` is where a quoted photo's thumbnail loads from. - public func configure(with message: ChatMessage, quoteThumbnailURL: URL? = nil) { + /// Fills the bubble; `quoteThumbnail` is where a quoted photo's thumbnail loads from. + public func configure(with message: ChatMessage, quoteThumbnail: ChatMediaLocation? = nil) { label.attributedText = Self.displayText(for: message) editedLabel.isHidden = !Self.showsEditedMarker(for: message) || message.rendersAsLargeEmoji isBare = message.rendersAsLargeEmoji @@ -182,7 +182,7 @@ public final class ChatBubbleView: UIView { // unsatisfiable, and UIKit resolves that by breaking one at random. if let quote = message.quote { quotePanel.isHidden = false - quotePanel.configure(with: quote, thumbnailURL: quoteThumbnailURL) + quotePanel.configure(with: quote, thumbnail: quoteThumbnail) NSLayoutConstraint.deactivate(quoteCollapse) quoteTrailing.isActive = true labelTopToBubble.isActive = false diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift index 88d18f5f8..e25f43e95 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatLinkMessageCell.swift @@ -97,17 +97,17 @@ public final class ChatLinkMessageCell: ChatColumnCell { /// - Parameters: /// - maxWidth: the widest the bubble may grow before its text wraps. - /// - quoteThumbnailURL: where a quoted photo's thumbnail loads from, or nil until it resolves. + /// - quoteThumbnail: where a quoted photo's thumbnail loads from, or nil until it resolves. public func configure( with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil, - quoteThumbnailURL: URL? = nil + quoteThumbnail: ChatMediaLocation? = nil ) { bubbleMaxWidthConstraint.constant = maxWidth bubbleCardWidthConstraint.constant = maxWidth bubbleCardWidthConstraint.isActive = Self.cardFillsWidth(message.linkPreview?.card) - bubble.configure(with: message, quoteThumbnailURL: quoteThumbnailURL) + bubble.configure(with: message, quoteThumbnail: quoteThumbnail) reactionRowWidthConstraint.constant = maxWidth reactionRow.layoutWidth = maxWidth reactionRow.hugsTrailingEdge = message.sender == .me diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift index 17d39cf6c..e4f301146 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift @@ -30,6 +30,8 @@ public final class ChatMediaCell: ChatColumnCell { private let stack = UIStackView() private let imageBubble = BubbleBackgroundView() let imageView = UIImageView() + /// Shown over the BlurHash when an encrypted photo's bytes fail to decrypt or check out. + let unavailableLabel = UILabel() let captionBubble = BubbleBackgroundView() let captionLabel = UILabel() let reactionRow = ReactionPillRowView() @@ -65,6 +67,14 @@ public final class ChatMediaCell: ChatColumnCell { imageBubble.addSubview(imageView) imageBubble.isAccessibilityElement = true imageBubble.accessibilityLabel = "Photo" + unavailableLabel.text = ChatMediaStrings.undecryptable + unavailableLabel.font = .default(size: 14, weight: .medium) + unavailableLabel.textColor = .white + unavailableLabel.textAlignment = .center + unavailableLabel.numberOfLines = 0 + unavailableLabel.isHidden = true + unavailableLabel.translatesAutoresizingMaskIntoConstraints = false + imageBubble.addSubview(unavailableLabel) imageTap.addTarget(self, action: #selector(imageTapped)) imageBubble.addGestureRecognizer(imageTap) @@ -103,6 +113,10 @@ public final class ChatMediaCell: ChatColumnCell { imageView.trailingAnchor.constraint(equalTo: imageBubble.trailingAnchor), imageView.bottomAnchor.constraint(equalTo: imageBubble.bottomAnchor), + unavailableLabel.centerYAnchor.constraint(equalTo: imageBubble.centerYAnchor), + unavailableLabel.leadingAnchor.constraint(equalTo: imageBubble.leadingAnchor, constant: Self.captionInset), + unavailableLabel.trailingAnchor.constraint(equalTo: imageBubble.trailingAnchor, constant: -Self.captionInset), + captionLabel.topAnchor.constraint(equalTo: captionBubble.topAnchor, constant: Self.captionPadding), captionLabel.bottomAnchor.constraint(equalTo: captionBubble.bottomAnchor, constant: -Self.captionPadding), captionLabel.leadingAnchor.constraint(equalTo: captionBubble.leadingAnchor, constant: Self.captionInset), @@ -124,6 +138,7 @@ public final class ChatMediaCell: ChatColumnCell { super.prepareForReuse() imageView.kf.cancelDownloadTask() imageView.image = nil + showUnavailable(false) drawnRowID = nil reactionRow.prepareForReuse() } @@ -137,12 +152,12 @@ public final class ChatMediaCell: ChatColumnCell { /// - Parameters: /// - maxWidth: the transcript's widest bubble, which the photo always spans. /// - localImage: the picked image of a pending send this device staged, or nil. - /// - remoteURL: the photo's resolved download URL, or nil until it is resolved. + /// - remote: where the photo downloads from, or nil until it is resolved. public func configure( with message: ChatMessage, maxWidth: CGFloat, localImage: UIImage?, - remoteURL: URL?, + remote: ChatMediaLocation?, authorImageData: Data? = nil ) { guard case .media(let media) = message.content else { return } @@ -161,7 +176,7 @@ public final class ChatMediaCell: ChatColumnCell { blobID: media.blobID, blurhash: media.blurhash, localImage: blurhashOnly ? nil : localImage, - remoteURL: blurhashOnly ? nil : remoteURL, + remote: blurhashOnly ? nil : remote, size: CGSize(width: size.width, height: size.height) ) imageTap.isEnabled = !blurhashOnly && !message.isFailed @@ -197,9 +212,10 @@ public final class ChatMediaCell: ChatColumnCell { updateColumn(for: message, authorImageData: authorImageData) } - private func drawImage(for rowID: String, blobID: BlobID?, blurhash: String?, localImage: UIImage?, remoteURL: URL?, size: CGSize) { + private func drawImage(for rowID: String, blobID: BlobID?, blurhash: String?, localImage: UIImage?, remote: ChatMediaLocation?, size: CGSize) { let isSameRow = drawnRowID == rowID drawnRowID = rowID + if !isSameRow { showUnavailable(false) } if let localImage { imageView.kf.cancelDownloadTask() @@ -208,7 +224,7 @@ public final class ChatMediaCell: ChatColumnCell { } let preview = BlurHashCache.shared.image(for: blurhash) - guard let remoteURL else { + guard let remote else { imageView.kf.cancelDownloadTask() imageView.image = preview return @@ -217,15 +233,28 @@ public final class ChatMediaCell: ChatColumnCell { // Whatever this row already shows — its local image, or the photo itself on a reconfigure — // stays under the load, so only a BlurHash is ever faded over. let placeholder = isSameRow ? (imageView.image ?? preview) : preview + let processor = DownsamplingImageProcessor(size: size) imageView.kf.setImage( - with: ChatMediaImageSource.resource(blobID: blobID, url: remoteURL), + with: ChatMediaImageSource.source(blobID: blobID, location: remote), placeholder: placeholder, - options: [ - .processor(DownsamplingImageProcessor(size: size)), + options: ChatMediaImageSource.options(processor: processor) + [ .scaleFactor(traitCollection.displayScale), .transition(.fade(Self.fadeDuration)), ] - ) + ) { [weak self] result in + ChatMediaImageSource.persist(result, processor: processor) + if case .failure(let error) = result, ChatMediaImageSource.isUndecryptable(error) { + self?.showUnavailable(true) + } + } + } + + /// Marks the photo as one that can't be shown: an encrypted blob that failed to authenticate or + /// wasn't the length its sender declared. It keeps its BlurHash and takes no tap. + private func showUnavailable(_ unavailable: Bool) { + unavailableLabel.isHidden = !unavailable + imageBubble.accessibilityLabel = unavailable ? ChatMediaStrings.undecryptable : "Photo" + if unavailable { imageTap.isEnabled = false } } @objc private func imageTapped() { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift index b18a3e5fc..32dfa42ce 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaImageSource.swift @@ -6,15 +6,117 @@ // import Foundation +import UIKit import FlipcashCore import Kingfisher +/// Where one chat photo downloads from, and how its bytes become an image. +public struct ChatMediaLocation: Sendable { + /// The photo's signed download URL. + public let url: URL + /// Turns the downloaded blob into the image's plaintext bytes; nil for a plaintext blob. + public let decrypt: (@Sendable (Data) throws -> Data)? + + public init(url: URL, decrypt: (@Sendable (Data) throws -> Data)? = nil) { + self.url = url + self.decrypt = decrypt + } +} + /// Where a chat photo's bytes load from, for every view that draws one. public enum ChatMediaImageSource { - /// The photo at `url`, cached under its blob rather than the URL. The URL is signed and minted - /// per fetch, so keying on it would re-download the same photo every session. - public static func resource(blobID: BlobID?, url: URL) -> KF.ImageResource { - KF.ImageResource(downloadURL: url, cacheKey: blobID.map { "chat-media-\($0.description)" } ?? url.absoluteString) + /// The photo at `location`, cached under its blob rather than the URL. The URL is signed and + /// minted per fetch, so keying on it would re-download the same photo every session. An + /// encrypted blob is decrypted before Kingfisher decodes it. + public static func source(blobID: BlobID?, location: ChatMediaLocation) -> Source { + let cacheKey = blobID.map { "chat-media-\($0.description)" } ?? location.url.absoluteString + guard let decrypt = location.decrypt else { + return .network(KF.ImageResource(downloadURL: location.url, cacheKey: cacheKey)) + } + return .provider(DecryptingProvider(cacheKey: cacheKey, url: location.url, decrypt: decrypt)) + } + + /// Whether a failed load was an encrypted blob that will never decrypt, rather than a fetch + /// that may succeed later. + public static func isUndecryptable(_ error: KingfisherError) -> Bool { + switch error { + case .imageSettingError(reason: .dataProviderError(_, let underlying)): + underlying is BlobOpenFailure + default: + false + } + } + + /// The cache chat photos read and write. A blob's bytes never change, so unlike the app's other + /// remote images they persist to disk and never expire from memory. + public static let cache: ImageCache = { + let cache = ImageCache(name: "chat-media") + cache.memoryStorage.config.expiration = .never + cache.diskStorage.config.sizeLimit = 500 * 1024 * 1024 + cache.diskStorage.config.expiration = .days(30) + return cache + }() + + /// The options every chat photo load passes, `processor` included when it draws a downsampled copy. + public static func options(processor: ImageProcessor? = nil) -> KingfisherOptionsInfo { + var options: KingfisherOptionsInfo = [.targetCache(cache)] + if let processor { options.append(.processor(processor)) } + return options + } + + /// Writes a freshly downloaded photo to disk under `resource`'s key. + /// + /// The app-wide `.cacheMemoryOnly` default stops Kingfisher writing it there itself, and the + /// signed URL changes per fetch, so without this every relaunch or memory trim re-downloads it. + public static func persist( + _ result: Result, + processor: ImageProcessor? = nil + ) { + guard case .success(let value) = result else { return } + persist(value.image, cacheType: value.cacheType, forKey: value.source.cacheKey, processor: processor) + } + + static func persist( + _ image: UIImage, + cacheType: CacheType, + forKey key: String, + processor: ImageProcessor?, + completion: (@Sendable () -> Void)? = nil + ) { + guard cacheType == .none else { return } + cache.store( + image, + forKey: key, + processorIdentifier: processor?.identifier ?? DefaultImageProcessor.default.identifier, + cacheSerializer: FormatIndicatedCacheSerializer.jpeg, + toDisk: true + ) { _ in completion?() } + } +} + +/// Downloads an end-to-end encrypted chat blob and hands Kingfisher its decrypted bytes. +private struct DecryptingProvider: ImageDataProvider { + let cacheKey: String + let url: URL + let decrypt: @Sendable (Data) throws -> Data + + var contentURL: URL? { url } + + func data(handler: @escaping @Sendable (Result) -> Void) { + Task { + do { + let (blob, response) = try await URLSession.shared.data(from: url) + if let status = (response as? HTTPURLResponse)?.statusCode, !(200..<300).contains(status) { + throw URLError(.badServerResponse) + } + let image = try decrypt(blob) + // The decoded image is authoritative; bytes that don't decode render as unsupported. + guard UIImage(data: image) != nil else { throw BlobOpenFailure.undecodable } + handler(.success(image)) + } catch { + handler(.failure(error)) + } + } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift index 0815161d8..09d93989b 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaURLResolver.swift @@ -17,55 +17,67 @@ public final class ChatMediaURLResolver { /// Mints a download URL for a blob, or returns nil when the server has none to give. public typealias Fetch = @MainActor (BlobID) async throws -> URL? + /// Decrypts an end-to-end encrypted blob into its image bytes, throwing when it fails to + /// authenticate or is not the declared length. + public typealias BlobDecrypt = @Sendable (Data, BlobID, SealedBlob) throws -> Data + /// Builds the conversation's blob decryption, or returns nil while it can't be (its key is not + /// known yet). + public typealias FetchDecrypt = @MainActor () async -> BlobDecrypt? private let fetch: Fetch + private let fetchDecrypt: FetchDecrypt private var cache: [BlobID: URL] = [:] /// Everyone waiting on a blob's fetch, called with its URL, or nil when the fetch failed. private var waiters: [BlobID: [(URL?) -> Void]] = [:] + private var decrypt: BlobDecrypt? + /// Everyone waiting on the decryption, or nil when no fetch of it is in flight. + private var decryptWaiters: [(BlobDecrypt?) -> Void]? - public init(fetch: @escaping Fetch) { + public init(fetch: @escaping Fetch, decrypt: @escaping FetchDecrypt = { nil }) { self.fetch = fetch + self.fetchDecrypt = decrypt } - /// The URL to draw `media` from, or nil for now. + /// Where to draw `media` from, or nil for now. /// /// A BlurHash-only row returns nil without fetching. A cache miss starts at most one fetch per /// blob and calls `onResolved` when it lands so the caller can redraw the row; a failed fetch - /// leaves the row on its BlurHash, and the next call tries again. - public func url( + /// leaves the row on its BlurHash, and the next call tries again. An encrypted photo also waits + /// on the chat's decryption. + public func location( for media: ChatMediaContent, canReact: Bool, - onResolved: @escaping (URL) -> Void - ) -> URL? { + onResolved: @escaping (ChatMediaLocation) -> Void + ) -> ChatMediaLocation? { guard !ChatMediaCell.isBlurhashOnly(media, canReact: canReact), let blobID = media.blobID else { return nil } - return resolve(blobID) { $0.map(onResolved) } + return resolve(blobID, sealed: media.sealed) { $0.map(onResolved) } } - /// The URL to draw a quoted photo's thumbnail from, or nil for now, sharing the photo row's cache. + /// Where to draw a quoted photo's thumbnail from, or nil for now, sharing the photo row's cache. /// /// Nil without fetching for anything but a photo whose bytes the viewer may see: a quote of text, - /// cash, or an unavailable original, a redacted photo (``ChatQuote/Kind/media(thumbnailBlobID:)`` + /// cash, or an unavailable original, a redacted photo (``ChatQuote/Kind/media(thumbnailBlobID:sealed:)`` /// carries no blob), or any quote while the viewer previews a group they have not joined. - public func thumbnailURL( + public func thumbnailLocation( for kind: ChatQuote.Kind, canReact: Bool, - onResolved: @escaping (URL) -> Void - ) -> URL? { - guard let blobID = Self.thumbnailBlobID(for: kind, canReact: canReact) else { return nil } - return resolve(blobID) { $0.map(onResolved) } + onResolved: @escaping (ChatMediaLocation) -> Void + ) -> ChatMediaLocation? { + guard let (blobID, sealed) = Self.thumbnailBlob(for: kind, canReact: canReact) else { return nil } + return resolve(blobID, sealed: sealed) { $0.map(onResolved) } } - /// The URL to draw a quoted photo's thumbnail from, waiting for the fetch; nil when there is none + /// Where to draw a quoted photo's thumbnail from, waiting for the fetch; nil when there is none /// to draw or the fetch failed. Follows the same rules as the callback form. - public func thumbnailURL(for kind: ChatQuote.Kind, canReact: Bool) async -> URL? { - guard let blobID = Self.thumbnailBlobID(for: kind, canReact: canReact) else { return nil } + public func thumbnailLocation(for kind: ChatQuote.Kind, canReact: Bool) async -> ChatMediaLocation? { + guard let (blobID, sealed) = Self.thumbnailBlob(for: kind, canReact: canReact) else { return nil } return await withCheckedContinuation { continuation in var resumed = false - let immediate = resolve(blobID) { url in + let immediate = resolve(blobID, sealed: sealed) { location in guard !resumed else { return } resumed = true - continuation.resume(returning: url) + continuation.resume(returning: location) } if let immediate, !resumed { resumed = true @@ -74,21 +86,49 @@ public final class ChatMediaURLResolver { } } - private static func thumbnailBlobID(for kind: ChatQuote.Kind, canReact: Bool) -> BlobID? { + private static func thumbnailBlob(for kind: ChatQuote.Kind, canReact: Bool) -> (BlobID, SealedBlob?)? { switch kind { - case .media(let thumbnailBlobID): - canReact ? thumbnailBlobID : nil + case .media(let thumbnailBlobID, let sealed): + guard canReact, let thumbnailBlobID else { return nil } + return (thumbnailBlobID, sealed) case .text, .cash, .unavailable: - nil + return nil } } - /// The cached URL for `blobID`, or nil after queueing `completion` on the one fetch for it. - private func resolve(_ blobID: BlobID, completion: @escaping (URL?) -> Void) -> URL? { - if let cached = cache[blobID] { return cached } + /// The location for `blobID` when everything it needs is cached, or nil after queueing + /// `completion` on the fetches it still waits on. + private func resolve(_ blobID: BlobID, sealed: SealedBlob?, completion: @escaping (ChatMediaLocation?) -> Void) -> ChatMediaLocation? { + if let url = cache[blobID] { + guard let sealed else { return ChatMediaLocation(url: url) } + if let decrypt { return Self.location(url: url, blobID: blobID, sealed: sealed, decrypt: decrypt) } + awaitDecrypt { decrypt in + completion(decrypt.map { Self.location(url: url, blobID: blobID, sealed: sealed, decrypt: $0) }) + } + return nil + } + fetchURL(blobID) { [weak self] url in + guard let url else { return completion(nil) } + guard let sealed else { return completion(ChatMediaLocation(url: url)) } + if let decrypt = self?.decrypt { + return completion(Self.location(url: url, blobID: blobID, sealed: sealed, decrypt: decrypt)) + } + self?.awaitDecrypt { decrypt in + completion(decrypt.map { Self.location(url: url, blobID: blobID, sealed: sealed, decrypt: $0) }) + } + } + return nil + } + + private static func location(url: URL, blobID: BlobID, sealed: SealedBlob, decrypt: @escaping BlobDecrypt) -> ChatMediaLocation { + ChatMediaLocation(url: url) { try decrypt($0, blobID, sealed) } + } + + /// Queues `completion` on the one fetch of `blobID`'s URL, starting it if none is in flight. + private func fetchURL(_ blobID: BlobID, completion: @escaping (URL?) -> Void) { let isFirst = waiters[blobID] == nil waiters[blobID, default: []].append(completion) - guard isFirst else { return nil } + guard isFirst else { return } Task { // Best-effort: the row keeps its BlurHash, and the next dequeue retries. let resolved = try? await fetch(blobID) @@ -96,6 +136,23 @@ public final class ChatMediaURLResolver { let completions = waiters.removeValue(forKey: blobID) ?? [] for completion in completions { completion(resolved) } } - return nil + } + + /// Queues `completion` on the one fetch of the chat's decryption, starting it if none is in flight. + private func awaitDecrypt(_ completion: @escaping (BlobDecrypt?) -> Void) { + guard decryptWaiters == nil else { + decryptWaiters?.append(completion) + return + } + decryptWaiters = [completion] + Task { + // A chat whose key is not known yet leaves its photos on their BlurHash; the next + // dequeue tries again. + let resolved = await fetchDecrypt() + if let resolved { decrypt = resolved } + let completions = decryptWaiters ?? [] + decryptWaiters = nil + for completion in completions { completion(resolved) } + } } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift index 07ccf6f8a..c78363446 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaViewerController.swift @@ -20,8 +20,8 @@ public struct ChatMediaViewerRequest { public let blobID: BlobID? /// The staged image of a send this device has not confirmed yet, drawn instead of downloading. public let localImage: UIImage? - /// The photo's resolved download URL, or nil for a pending send. - public let remoteURL: URL? + /// Where the photo downloads from, or nil for a pending send. + public let remote: ChatMediaLocation? /// Whatever the row drew when it was tapped, shown while the full photo loads. public let placeholder: UIImage? /// The on-screen view the photo zooms out of and back into, looked up on each use because the @@ -34,18 +34,18 @@ public struct ChatMediaViewerRequest { public init?( message: ChatMessage, localImage: UIImage?, - remoteURL: URL?, + remote: ChatMediaLocation?, placeholder: UIImage?, sourceView: @escaping () -> UIView? ) { guard case .media(let media) = message.content, !ChatMediaCell.isBlurhashOnly(media, canReact: message.canReact), !message.isFailed, - localImage != nil || remoteURL != nil else { return nil } + localImage != nil || remote != nil else { return nil } self.messageID = message.id self.blobID = media.blobID self.localImage = localImage - self.remoteURL = remoteURL + self.remote = remote self.placeholder = placeholder self.sourceView = sourceView } @@ -159,11 +159,13 @@ public final class ChatMediaViewerController: UIViewController, UIScrollViewDele } imageView.image = request.placeholder loadedImage = nil - guard let remoteURL = request.remoteURL else { return } + guard let remote = request.remote else { return } imageView.kf.setImage( - with: ChatMediaImageSource.resource(blobID: request.blobID, url: remoteURL), - placeholder: request.placeholder + with: ChatMediaImageSource.source(blobID: request.blobID, location: remote), + placeholder: request.placeholder, + options: ChatMediaImageSource.options() ) { [weak self] result in + ChatMediaImageSource.persist(result) switch result { case .success(let value): self?.loadedImage = value.image diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift index 3ac34f097..d02896b06 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMessageCell.swift @@ -66,14 +66,14 @@ public final class ChatMessageCell: ChatColumnCell { /// - Parameters: /// - maxWidth: the widest the bubble may grow before its text wraps, in points. The owner /// derives it from the collection view's width. - /// - quoteThumbnailURL: where a quoted photo's thumbnail loads from, or nil until it resolves. + /// - quoteThumbnail: where a quoted photo's thumbnail loads from, or nil until it resolves. public func configure( with message: ChatMessage, maxWidth: CGFloat, authorImageData: Data? = nil, - quoteThumbnailURL: URL? = nil + quoteThumbnail: ChatMediaLocation? = nil ) { - bubble.configure(with: message, quoteThumbnailURL: quoteThumbnailURL) + bubble.configure(with: message, quoteThumbnail: quoteThumbnail) maxWidthConstraint.constant = maxWidth reactionRowWidthConstraint.constant = maxWidth reactionRow.layoutWidth = maxWidth diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift index 87235c503..17bda467e 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatQuotePanelView.swift @@ -171,8 +171,8 @@ final class ChatQuotePanelView: UIView { accessibilityIdentifier = "chat-quote-panel" } - /// - Parameter thumbnailURL: where a quoted photo's thumbnail loads from, or nil until it resolves. - func configure(with quote: ChatQuote, thumbnailURL: URL? = nil) { + /// - Parameter thumbnail: where a quoted photo's thumbnail loads from, or nil until it resolves. + func configure(with quote: ChatQuote, thumbnail: ChatMediaLocation? = nil) { targetStableID = quote.stableID // The author's own colour, derived from their user id — the same colour the composer's strip // draws them in, and the same one Android does. An original with no known author falls back @@ -196,17 +196,17 @@ final class ChatQuotePanelView: UIView { // A payment's amount is the whole of what was said, so it is read rather than glanced // at — a step brighter than the preview grey a quoted sentence gets. snippetLabel.textColor = UIColor.white.withAlphaComponent(0.75) - showThumbnail(blobID: nil, url: nil) - case .media(let thumbnailBlobID): + showThumbnail(blobID: nil, location: nil) + case .media(let thumbnailBlobID, _): flagView.isHidden = true tokenLabel.isHidden = true snippetLabel.textColor = Self.snippetColor - showThumbnail(blobID: thumbnailBlobID, url: thumbnailURL) + showThumbnail(blobID: thumbnailBlobID, location: thumbnail) case .text, .unavailable: flagView.isHidden = true tokenLabel.isHidden = true snippetLabel.textColor = Self.snippetColor - showThumbnail(blobID: nil, url: nil) + showThumbnail(blobID: nil, location: nil) } let spoken = switch quote.kind { case .cash(let token, _): "\(quote.snippet) \(token)" @@ -230,14 +230,14 @@ final class ChatQuotePanelView: UIView { flagView.image = nil flagView.isHidden = true tokenLabel.isHidden = true - showThumbnail(blobID: nil, url: nil) + showThumbnail(blobID: nil, location: nil) isUserInteractionEnabled = false accessibilityLabel = nil } - /// Shows the thumbnail slot for a photo with a blob, loading it once `url` resolves; hides and - /// empties it otherwise. A redacted photo has no blob, so it never reaches the network from here. - private func showThumbnail(blobID: BlobID?, url: URL?) { + /// Shows the thumbnail slot for a photo with a blob, loading it once `location` resolves; hides + /// and empties it otherwise. A redacted photo has no blob, so it never reaches the network from here. + private func showThumbnail(blobID: BlobID?, location: ChatMediaLocation?) { guard let blobID else { thumbnailView.kf.cancelDownloadTask() thumbnailView.image = nil @@ -249,19 +249,21 @@ final class ChatQuotePanelView: UIView { thumbnailView.isHidden = false textTrailingToEdge.isActive = false NSLayoutConstraint.activate(thumbnailConstraints + [textTrailingToThumbnail]) - guard let url else { + guard let location else { thumbnailView.kf.cancelDownloadTask() thumbnailView.image = nil return } let side = Self.thumbnailSide + let processor = DownsamplingImageProcessor(size: CGSize(width: side, height: side)) thumbnailView.kf.setImage( - with: ChatMediaImageSource.resource(blobID: blobID, url: url), - options: [ - .processor(DownsamplingImageProcessor(size: CGSize(width: side, height: side))), + with: ChatMediaImageSource.source(blobID: blobID, location: location), + options: ChatMediaImageSource.options(processor: processor) + [ .scaleFactor(traitCollection.displayScale), ] - ) + ) { result in + ChatMediaImageSource.persist(result, processor: processor) + } } @objc private func handleTap() { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 010afb4e3..2b87c5462 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -657,7 +657,7 @@ public final class ChatViewController: UICollectionViewController { with: message, maxWidth: maxWidth, authorImageData: authorImageData, - quoteThumbnailURL: quoteThumbnailURL(for: message) + quoteThumbnail: quoteThumbnail(for: message) ) cell.onRetry = { [weak self] id in self?.onRetry?(id) } cell.onOpenURL = { [weak self] url in self?.onOpenURL?(url) } @@ -672,7 +672,7 @@ public final class ChatViewController: UICollectionViewController { with: message, maxWidth: maxWidth, authorImageData: authorImageData, - quoteThumbnailURL: quoteThumbnailURL(for: message) + quoteThumbnail: quoteThumbnail(for: message) ) cell.onRetry = { [weak self] id in self?.onRetry?(id) } cell.onQuoteTap = { [weak self] id in self?.onQuoteTap?(id) } @@ -696,7 +696,7 @@ public final class ChatViewController: UICollectionViewController { cell.onReactionAdd = { [weak self] in self?.onReactionAdd?(message.messageID) } case let cell as ChatMediaCell: guard case .media(let media) = message.content else { return } - let remoteURL = mediaURLResolver?.url(for: media, canReact: message.canReact) { [weak self] _ in + let remote = mediaURLResolver?.location(for: media, canReact: message.canReact) { [weak self] _ in self?.reconfigureVisibleMessage(id: message.id) } let localImage = pendingMediaImage?(message.id) @@ -704,14 +704,14 @@ public final class ChatViewController: UICollectionViewController { with: message, maxWidth: maxWidth, localImage: localImage, - remoteURL: remoteURL, + remote: remote, authorImageData: authorImageData ) cell.onImageTap = { [weak self, weak cell] in guard let self, let request = ChatMediaViewerRequest( message: message, localImage: localImage, - remoteURL: remoteURL, + remote: remote, placeholder: cell?.imageView.image, sourceView: { [weak self] in self?.mediaSourceView(forMessageID: message.id) } ) else { return } @@ -728,9 +728,9 @@ public final class ChatViewController: UICollectionViewController { /// Where a reply's quoted photo draws its thumbnail from, or nil when it has none or is not yet /// resolved; a resolution redraws the reply. - private func quoteThumbnailURL(for message: ChatMessage) -> URL? { + private func quoteThumbnail(for message: ChatMessage) -> ChatMediaLocation? { guard let quote = message.quote else { return nil } - return mediaURLResolver?.thumbnailURL(for: quote.kind, canReact: message.canReact) { [weak self] _ in + return mediaURLResolver?.thumbnailLocation(for: quote.kind, canReact: message.canReact) { [weak self] _ in self?.reconfigureVisibleMessage(id: message.id) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index 4d21ed0ea..705746424 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -248,8 +248,8 @@ public final class LinkableBubbleView: UIView { cardView.prepareForReuse() } - /// Fills the bubble; `quoteThumbnailURL` is where a quoted photo's thumbnail loads from. - public func configure(with message: ChatMessage, quoteThumbnailURL: URL? = nil) { + /// Fills the bubble; `quoteThumbnail` is where a quoted photo's thumbnail loads from. + public func configure(with message: ChatMessage, quoteThumbnail: ChatMediaLocation? = nil) { // Shares the plain bubble's text builder so a link message gets the same body styling, the // same tombstone copy, and the same "Edited" reservation, with the link spans laid over it // from the preview the mapper already detected. @@ -296,7 +296,7 @@ public final class LinkableBubbleView: UIView { // and UIKit resolves that by breaking one at random. if let quote = message.quote { quotePanel.isHidden = false - quotePanel.configure(with: quote, thumbnailURL: quoteThumbnailURL) + quotePanel.configure(with: quote, thumbnail: quoteThumbnail) NSLayoutConstraint.deactivate(quoteCollapse + [textTopToBubble, cardTopToBubble]) quoteTrailing.isActive = true textTopToQuote.isActive = !bare diff --git a/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift b/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift index d0371e6df..d8982d591 100644 --- a/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift +++ b/FlipcashUI/Sources/FlipcashUI/Images/BlurHash.swift @@ -182,6 +182,107 @@ nonisolated public enum BlurHash { return UIImage(cgImage: cgImage) } + // MARK: - Encode - + + /// Encodes `image` into a hash with `componentsX` × `componentsY` components (each 1…9), or nil + /// when the image cannot be drawn. + /// + /// The image is sampled at a small fixed size first: a hash keeps only the lowest frequencies, so + /// more pixels change nothing but the cost. Squashing it to a square is harmless for the same + /// reason; the decoder stretches back to whatever aspect it is drawn at. + public static func encode(_ image: CGImage, componentsX: Int, componentsY: Int) -> String? { + guard (1...9).contains(componentsX), (1...9).contains(componentsY) else { return nil } + let side = 32 + let bytesPerRow = side * 4 + var pixels = [UInt8](repeating: 0, count: side * bytesPerRow) + let drawn = pixels.withUnsafeMutableBytes { buffer -> Bool in + guard let context = CGContext( + data: buffer.baseAddress, + width: side, + height: side, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue + ) else { return false } + context.interpolationQuality = .medium + context.draw(image, in: CGRect(x: 0, y: 0, width: side, height: side)) + return true + } + guard drawn else { return nil } + return encode(rgb: pixels, width: side, height: side, bytesPerRow: bytesPerRow, componentsX: componentsX, componentsY: componentsY) + } + + /// Encodes 8-bit sRGB pixels laid out as RGBx rows, the reference implementation's algorithm. + static func encode(rgb pixels: [UInt8], width: Int, height: Int, bytesPerRow: Int, componentsX: Int, componentsY: Int) -> String { + var linear = [SIMD3](repeating: .zero, count: width * height) + for y in 0..] = [] + for j in 0...zero + for y in 0.., _ maxValue: Float) -> Int { + func quantise(_ component: Float) -> Int { + let scaled = component / maxValue + let root = scaled < 0 ? -sqrtf(-scaled) : sqrtf(scaled) + return max(0, min(18, Int(floorf(root * 9 + 9.5)))) + } + return quantise(value.x) * 19 * 19 + quantise(value.y) * 19 + quantise(value.z) + } + + private static func encode83(_ value: Int, length: Int) -> String { + var result = "" + for i in 1...length { + var divisor = 1 + for _ in 0..<(length - i) { divisor *= 83 } + result.append(alphabet[(value / divisor) % 83]) + } + return result + } + private static let alphabet = Array( "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" ) From 6688e2d52d583cb500f28a9df6ee80f5b66d69e1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 3 Oct 2026 15:00:46 -0400 Subject: [PATCH 6/8] feat(chat): show photo send progress and survive backgrounding Outgoing photo bubbles show a thin progress bar in the corner: the upload's byte fraction, then a sliding segment while the server processes the blob, which has no progress signal. A send no longer fails when the app is backgrounded mid-send. The READY poll retries through a dropped stream, its deadline only counts time the app is awake, and the send holds a background task assertion. A retry after the bytes are stored polls again instead of uploading again. Pending photos are written to disk at send, so a relaunch restores them: one whose bytes were stored resumes, one that never reached the server comes back failed with retry, and one that already sent is dropped. --- .../2026-10-03-pending-photo-persistence.md | 70 +++++ .../Controllers/ConversationController.swift | 187 +++++++++++- .../Conversation/ChatMediaUploader.swift | 169 ++++++++--- .../ChatScreenRepresentable.swift | 3 + .../Screens/Conversation/ComposerChip.swift | 71 ++++- .../Conversation/ComposerChipStrip.swift | 16 +- .../Conversation/PendingMediaStore.swift | 143 +++++++++ .../Session/BackgroundStoreWriteGuard.swift | 37 ++- .../Core/Session/SessionAuthenticator.swift | 11 + .../FlipcashCore/Blob/BlobUploader.swift | 65 ++-- .../FlipcashCore/Blob/BlobUploading.swift | 51 +++- .../Clients/Flip API/FlipClient+Blob.swift | 20 +- .../Models/Conversation/ChatSeal.swift | 2 +- .../FlipcashCoreTests/BlobUploaderTests.swift | 110 ++++++- FlipcashTests/Chat/ChatMediaCellTests.swift | 91 ++++++ .../Chat/ChatMediaUploaderTests.swift | 45 ++- .../Chat/ChatPhotoSendProgressTests.swift | 113 +++++++ .../Chat/ComposerChipStripTests.swift | 6 +- .../Chat/ConversationMediaSendTests.swift | 31 +- .../Chat/PendingMediaPersistenceTests.swift | 281 ++++++++++++++++++ .../TestSupport/MockChatMediaBlobStore.swift | 8 +- .../FlipcashUI/Chat/ChatMediaCell.swift | 16 + .../Chat/ChatPhotoProgressOverlay.swift | 200 +++++++++++++ .../Chat/ChatPhotoSendProgress.swift | 88 ++++++ .../Chat/ChatScreenViewController.swift | 6 + .../FlipcashUI/Chat/ChatViewController.swift | 5 + 26 files changed, 1724 insertions(+), 121 deletions(-) create mode 100644 .claude/plans/2026-10-03-pending-photo-persistence.md create mode 100644 Flipcash/Core/Screens/Conversation/PendingMediaStore.swift create mode 100644 FlipcashTests/Chat/ChatPhotoSendProgressTests.swift create mode 100644 FlipcashTests/Chat/PendingMediaPersistenceTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoProgressOverlay.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift diff --git a/.claude/plans/2026-10-03-pending-photo-persistence.md b/.claude/plans/2026-10-03-pending-photo-persistence.md new file mode 100644 index 000000000..21af559fb --- /dev/null +++ b/.claude/plans/2026-10-03-pending-photo-persistence.md @@ -0,0 +1,70 @@ +# Persist pending chat photo sends + +## Problem + +A pending photo send lives only in memory. That's `ConversationController.pendingMediaChips` (`ComposerChip`, holding a `UIImage`) plus the in-memory `ConversationStore.pendingByConversation` row. If iOS kills the app in the background, the photo and its bubble are both lost. + +On-device evidence, 2026-10-03 15:39–15:45: +- blob `51993f1f…` was reserved, +- the app was backgrounded 3 s later, +- the next log line is a cold launch at 15:45:07, +- the chat showed no photo and no failed bubble. + +## What Android does (text only — Android has no media send) + +- Pending rows are stored in the same Room table as messages, with status `SENDING`/`SENT`/`FAILED`. +- At login, every `SENDING` row is marked `FAILED`, and the user taps retry. +- A retry reuses the same client id, and the server dedupes on `client_message_id`. + +## Design + +### Storage +- Use a JSON manifest plus one JPEG file per photo, following `ChatDraftStore`'s precedent and reasoning. +- `FlipcashStore` (SQLite) is the wrong home because its versioning is destructive, and an outbox can't be re-fetched. +- Location: Application Support, scoped to the owner: + - manifest `flipcash--pending-media.json`, + - photos in `flipcash--pending-media/.jpg`. +- Leave the files at the default protection class, `completeUntilFirstUserAuthentication`. + +### Entry +Each entry holds: +- `clientMessageID` +- `conversationID` +- `createdAt` +- the image file name +- `caption?` and `replyTo?`, exactly as `ChatMediaSendPlan` assigned them to this message +- `stored: UploadedPhoto?` + +`stored` is set the moment `store` returns. `SealedPhoto` is metadata only (blob id, MIME type, size, dimensions, blurhash), so it's safe to persist, and it needs `Codable`. + +### Encode once +- Encode the JPEG before the upload starts and write it to disk then. A kill mid-upload must not lose the photo. +- The upload then sends those same bytes. The retry path never re-encodes a re-decoded image. + +### Lifecycle +- Write the entry when `sendMedia` runs. Attached-but-unsent composer chips are not persisted. +- Update `stored` when the bytes land. +- Delete the entry and its file at the same point `dropPending` runs: after `upsertConversationMessages` succeeds, never before. +- Also delete when the user discards a failed row, and when the failure can't be retried (`.rejected`). + +### Launch +In `ConversationController.start()`, after hydrate: +- Load the manifest. +- Sweep orphans: files without an entry, and entries without a file. +- Re-insert each entry as a pending row, anchored by its `createdAt` rather than `newestMessageID`, and rebuild its chip from the file. +- `stored == nil`: show the row as failed with retry, as Android does. Retry uploads the file. +- `stored != nil`: resume the READY poll automatically, then send. + +### Dedupe +- The risk is a kill after `sendMediaMessage` succeeds but before the entry is deleted. +- Before re-inserting an entry that has `stored`, look for a self-authored media message with that blob id in the loaded messages. If one exists, the photo already sent, so drop the entry. +- An entry with no blob id can't exist on the server, so it's always safe to re-insert. + +## Tests +- The store round-trips its entries and sweeps orphans. +- Reconciliation drops an entry whose blob id is in the loaded messages and keeps one whose blob id isn't. +- At launch, `stored == nil` comes back as failed and `stored != nil` resumes. + +## Known gaps +- An end-to-end encrypted photo sits as a plaintext JPEG in the app's sandbox until it sends. +- `pendingMatch`'s date window: a resumed send's row keeps its old `createdAt`, so the stream echo may not match it. The send RPC's own confirm still removes the row. diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 2eae378c8..afc29cc6e 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -10,6 +10,7 @@ import os import SwiftUI import FlipcashCore import FlipcashStore +import FlipcashUI nonisolated private let logger = Logger(label: "flipcash.conversation-controller") @@ -312,9 +313,21 @@ final class ConversationController { /// Keeps the words of a send that failed — see ``FailedSendDrafts``. @ObservationIgnored private var failedSends: FailedSendDrafts? + /// The session's pending photo sends, wired by `SessionContainer` like ``chatDrafts``; a controller + /// without one keeps photo sends in memory only. + @ObservationIgnored var pendingMedia: PendingMediaStore? + + /// Builds the uploader a photo send restored from disk finishes through, wired with + /// ``pendingMedia``. + @ObservationIgnored var restoredPhotoUploader: ((ConversationID) -> ChatMediaUploader)? + /// The chip behind each photo send not yet confirmed, keyed by its client id: its local image /// draws the pending bubble, and a failed send uploads or posts again through it. @ObservationIgnored private var pendingMediaChips: [UUID: ComposerChip] = [:] + /// The local image of each photo this session sent, newest last and capped, so its bubble keeps + /// drawing it after confirm instead of falling back to the BlurHash while the server copy loads. + @ObservationIgnored private var sentMediaImages: [(clientMessageID: UUID, image: UIImage)] = [] + private static let sentMediaImageLimit = 20 /// The receive-side analytics concern (cumulative counters + received events), /// owned by its own unit. Exposed so `SessionContainer` can wire its rate lookup. @ObservationIgnored let receipts: ConversationReceiptReporter @@ -330,6 +343,8 @@ final class ConversationController { /// Moves new senders to the front of the mention picker's held suggestions. Set by the session /// after init. @ObservationIgnored var mentionSearch: ServerMentionSearch? + /// Keeps a photo send running for the time iOS allows after the app leaves the screen. + private let backgroundTasks: any BackgroundTaskAsserting init( fetching: any ConversationFetching, @@ -347,9 +362,11 @@ final class ConversationController { typingStoppedLinger: Duration = ConversationTyping.defaultStoppedLinger, typingExpiryClock: TypingExpiryClock = .continuous, reconcileTiming: FeedReconcileTiming = .launch, - receipts: ConversationReceiptReporter? = nil + receipts: ConversationReceiptReporter? = nil, + backgroundTasks: any BackgroundTaskAsserting = UIApplicationBackgroundTasks(name: "chat.photo.send") ) { self.reconcileTiming = reconcileTiming + self.backgroundTasks = backgroundTasks self.fetching = fetching self.membership = membership self.viewerSettings = viewerSettings @@ -453,6 +470,7 @@ final class ConversationController { } startTask = Task { await hydrateFromDatabase(loading: cache, unlessApplied: true) + restorePendingMedia() await openStream() await loadFeed() } @@ -2064,6 +2082,10 @@ final class ConversationController { to conversationID: ConversationID, repliedTo: MessageID? = nil ) async -> Bool { + let assertion = BackgroundTimeAssertion(assertions: backgroundTasks) + assertion.begin() + defer { assertion.end() } + let sends = ChatMediaSendPlan.mediaMessages(chips: chips, caption: caption, replyTo: repliedTo).map { message in (message: message, clientMessageID: insertPendingMedia(message, into: conversationID)) } @@ -2082,15 +2104,50 @@ final class ConversationController { return deliveredAll } - /// The local image drawing the pending photo bubble whose transcript id is `messageID`, or `nil` - /// once the send is confirmed or when the row is not a photo this device is sending. + /// The local image drawing the photo bubble whose transcript id is `messageID` — pending, or sent + /// from this device this session — or `nil` when the row is not one of those. func pendingMediaImage(forMessageID messageID: String) -> UIImage? { - UUID(uuidString: messageID).flatMap { pendingMediaChips[$0]?.image } + guard let id = UUID(uuidString: messageID) else { return nil } + return pendingMediaChips[id]?.image ?? sentMediaImages.last { $0.clientMessageID == id }?.image + } + + private func keepSentImage(_ image: UIImage, clientMessageID: UUID) { + sentMediaImages.append((clientMessageID, image)) + if sentMediaImages.count > Self.sentMediaImageLimit { + sentMediaImages.removeFirst(sentMediaImages.count - Self.sentMediaImageLimit) + } + } + + /// The send progress of the pending photo bubble whose transcript id is `messageID`, or `nil` + /// once the send is confirmed or when the row is not a photo this device is sending. + func pendingMediaProgress(forMessageID messageID: String) -> ChatPhotoSendProgress? { + UUID(uuidString: messageID).flatMap { pendingMediaChips[$0]?.progress } } private func insertPendingMedia(_ message: ChatMediaSendPlan.MediaMessage, into conversationID: ConversationID) -> UUID { let clientMessageID = UUID() let chip = message.chip + let createdAt = Date.now + let pending = pendingMediaRow( + chip: chip, caption: message.caption, repliedTo: message.replyTo, + clientMessageID: clientMessageID, date: createdAt, status: .sending + ) + let anchor = (try? database.newestMessageID(conversationID: conversationID)).flatMap { $0 }?.value ?? 0 + store.insertPending(pending, anchoredTo: anchor, into: conversationID) + receiptSettle.hold(clientMessageID.uuidString) + pendingMediaChips[clientMessageID] = chip + keepOnDisk(chip, clientMessageID: clientMessageID, conversationID: conversationID, createdAt: createdAt, caption: message.caption, repliedTo: message.replyTo) + return clientMessageID + } + + private func pendingMediaRow( + chip: ComposerChip, + caption: String?, + repliedTo: MessageID?, + clientMessageID: UUID, + date: Date, + status: SendStatus + ) -> ConversationMessage { // A chip still preparing has no upload size yet; the source's own pixels carry the same // aspect ratio, which is all the bubble's clamp reads. let pixels = CGSize(width: chip.image.size.width * chip.image.scale, height: chip.image.size.height * chip.image.scale) @@ -2100,23 +2157,111 @@ final class ConversationController { height: chip.preparedHeight ?? Int(pixels.height.rounded()), blurhash: nil ) - let pending = ConversationMessage( + return ConversationMessage( id: .unassigned, senderID: selfUserID, - content: .media([attachment], caption: message.caption), - date: .now, + content: .media([attachment], caption: caption), + date: date, unreadSeq: 0, - repliedTo: message.replyTo, - status: .sending, + repliedTo: repliedTo, + status: status, clientMessageID: clientMessageID ) - let anchor = (try? database.newestMessageID(conversationID: conversationID)).flatMap { $0 }?.value ?? 0 - store.insertPending(pending, anchoredTo: anchor, into: conversationID) - receiptSettle.hold(clientMessageID.uuidString) - pendingMediaChips[clientMessageID] = chip - return clientMessageID } + /// Records the send in ``pendingMedia`` and keeps its JPEG and stored photo current there as the + /// chip produces them. + private func keepOnDisk( + _ chip: ComposerChip, + clientMessageID: UUID, + conversationID: ConversationID, + createdAt: Date, + caption: String?, + repliedTo: MessageID? + ) { + guard let pendingMedia else { return } + pendingMedia.add(.init(clientMessageID: clientMessageID, conversationID: conversationID, createdAt: createdAt, caption: caption, replyTo: repliedTo)) + chip.persist( + onEncoded: { [weak pendingMedia] data in pendingMedia?.writeImage(data, for: clientMessageID) }, + onStored: { [weak pendingMedia] photo in pendingMedia?.setStored(photo, for: clientMessageID) } + ) + } + + /// Puts the photo sends a killed app left behind back in their transcripts: a send whose bytes + /// never landed as a failed row to retry, one whose bytes had landed resumed. + func restorePendingMedia() { + guard let pendingMedia, let uploaderFor = restoredPhotoUploader else { return } + pendingMedia.sweepOrphans() + + var resuming: [(clientMessageID: UUID, chip: ComposerChip, entry: PendingMediaStore.Entry)] = [] + for entry in pendingMedia.entries { + let conversationID = entry.chatID + let recent = (try? database.messagesWindow(conversationID: conversationID, limit: Self.restoreWindow)) ?? [] + guard let jpeg = pendingMedia.imageData(for: entry), let image = UIImage(data: jpeg) else { + pendingMedia.remove(clientMessageID: entry.clientMessageID) + continue + } + if let stored = entry.stored, alreadySent(stored.blobID, in: recent) { + pendingMedia.remove(clientMessageID: entry.clientMessageID) + continue + } + + let chip = ComposerChip(image: image) + chip.restore(encoded: jpeg, stored: entry.stored, uploader: uploaderFor(conversationID)) + let status: SendStatus = entry.stored == nil ? .failed : .sending + let row = pendingMediaRow( + chip: chip, caption: entry.caption, repliedTo: entry.replyTo, + clientMessageID: entry.clientMessageID, date: entry.createdAt, status: status + ) + let anchor = recent.filter { $0.date <= entry.createdAt }.map(\.id.value).max() ?? 0 + store.insertPending(row, anchoredTo: anchor, into: conversationID) + receiptSettle.hold(entry.clientMessageID.uuidString) + pendingMediaChips[entry.clientMessageID] = chip + chip.persist( + onEncoded: { _ in }, + onStored: { [weak pendingMedia] photo in pendingMedia?.setStored(photo, for: entry.clientMessageID) } + ) + if entry.stored != nil { + resuming.append((entry.clientMessageID, chip, entry)) + } + } + + guard !resuming.isEmpty else { return } + Task { [weak self] in + for send in resuming { + guard let self, let uploader = self.restoredPhotoUploader?(send.entry.chatID) else { return } + let assertion = BackgroundTimeAssertion(assertions: self.backgroundTasks) + assertion.begin() + send.chip.startUpload(using: uploader) + _ = await self.deliverMedia( + clientMessageID: send.clientMessageID, + chip: send.chip, + caption: send.entry.caption, + repliedTo: send.entry.replyTo, + to: send.entry.chatID + ) + assertion.end() + } + } + } + + /// Whether `recent` holds a message this user sent that references `blobID`, so its photo already + /// reached the server. + private func alreadySent(_ blobID: BlobID, in recent: [ConversationMessage]) -> Bool { + recent.contains { message in + guard message.senderID == selfUserID else { return false } + switch message.content { + case .media(let attachments, _): + return attachments.contains { $0.blobID == blobID } + case .text, .cash, .deleted, .encrypted, .widget: + return false + } + } + } + + /// How many of a chat's newest messages ``restorePendingMedia()`` searches for an already-sent photo. + private static let restoreWindow = 100 + /// Re-sends a failed photo: posts again when its blob is finalized, uploads again first when the /// upload is what failed, and leaves a photo the server refused for good as it is. private func retryMedia(clientMessageID: UUID, caption: String?, repliedTo: MessageID?, in conversationID: ConversationID) async { @@ -2135,6 +2280,9 @@ final class ConversationController { return } store.markPending(clientMessageID: clientMessageID, status: .sending, in: conversationID) + let assertion = BackgroundTimeAssertion(assertions: backgroundTasks) + assertion.begin() + defer { assertion.end() } _ = await deliverMedia(clientMessageID: clientMessageID, chip: chip, caption: caption, repliedTo: repliedTo, to: conversationID) } @@ -2148,6 +2296,7 @@ final class ConversationController { ) async -> Bool { // Read at await time: a retry replaces the chip's task. guard let upload = chip.uploadTask else { + chip.progress.fail() store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) logger.error("Photo send has no upload to await", metadata: ["conversationID": "\(conversationID)"]) return false @@ -2156,7 +2305,11 @@ final class ConversationController { do { photo = try await upload.value } catch { + chip.progress.fail() store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) + if let error = error as? ChatMediaUploadError, error.isTerminal { + pendingMedia?.remove(clientMessageID: clientMessageID) + } logger.error("Failed to upload conversation photo", metadata: [ "conversationID": "\(conversationID)", "error": "\(error)", @@ -2166,6 +2319,7 @@ final class ConversationController { } let chatType = conversation(withID: conversationID)?.type + chip.progress.beginSending() do { let message = try await messaging.sendMediaMessage( owner: owner, @@ -2175,12 +2329,16 @@ final class ConversationController { repliedTo: repliedTo, clientMessageID: clientMessageID ) + // Before the chip is dropped, so the bubble still holding it fades its overlay out. + chip.progress.finish() var confirmed = message confirmed.clientMessageID = clientMessageID let ok = persist(operation: "send-media-message") { try database.upsertConversationMessages([confirmed], conversationID: conversationID) } if ok { + keepSentImage(chip.image, clientMessageID: clientMessageID) store.dropPending(clientMessageID: clientMessageID, confirmedAt: message.id, in: conversationID) pendingMediaChips[clientMessageID] = nil + pendingMedia?.remove(clientMessageID: clientMessageID) } else { scheduleGapCatchUp(conversationID) } @@ -2190,6 +2348,7 @@ final class ConversationController { Analytics.sentMessage(chatType: chatType) return true } catch { + chip.progress.fail() store.markPending(clientMessageID: clientMessageID, status: .failed, in: conversationID) logger.error("Failed to send conversation photo", metadata: [ "conversationID": "\(conversationID)", diff --git a/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift b/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift index c2c6beb8d..f0770f1db 100644 --- a/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift +++ b/Flipcash/Core/Screens/Conversation/ChatMediaUploader.swift @@ -20,12 +20,13 @@ protocol ChatMediaBlobStoring { /// Returns the upload constraints in force for the owner. func uploadPolicy() async throws -> UploadPolicy - /// Stores `data` and returns its blob, before the server has finalized it. - func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID + /// Stores `data` and returns its blob, before the server has finalized it. `onProgress` hears + /// the bytes going out, from any thread. + func storeBlob(_ data: Data, mimeType: String, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> BlobID /// Stores `image` end-to-end encrypted with `seal` for its DM and returns the blob, before the - /// server has finalized it. - func storeEncryptedBlob(_ image: Data, seal: ChatSeal) async throws -> EncryptedBlobUpload + /// server has finalized it. `onProgress` hears the sealed bytes going out, from any thread. + func storeEncryptedBlob(_ image: Data, seal: ChatSeal, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> EncryptedBlobUpload /// Returns once the blob is servable, throwing `ErrorBlob.rejected` when it is refused and /// `ErrorBlob.timedOut` when it is still processing. @@ -42,12 +43,12 @@ struct SessionChatMediaBlobStore: ChatMediaBlobStoring { try await flipClient.uploadPolicy(owner: session.ownerKeyPair) } - func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID { - try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair) + func storeBlob(_ data: Data, mimeType: String, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> BlobID { + try await flipClient.storeBlob(data, mimeType: mimeType, owner: session.ownerKeyPair, onProgress: onProgress) } - func storeEncryptedBlob(_ image: Data, seal: ChatSeal) async throws -> EncryptedBlobUpload { - try await flipClient.storeEncryptedBlob(image, seal: seal, owner: session.ownerKeyPair) + func storeEncryptedBlob(_ image: Data, seal: ChatSeal, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> EncryptedBlobUpload { + try await flipClient.storeEncryptedBlob(image, seal: seal, owner: session.ownerKeyPair, onProgress: onProgress) } func awaitBlobFinalization(blobID: BlobID) async throws { @@ -56,7 +57,7 @@ struct SessionChatMediaBlobStore: ChatMediaBlobStoring { } /// A chat photo uploaded and finalized, in the form its message references it. -enum UploadedPhoto: Hashable, Sendable { +enum UploadedPhoto: Hashable, Sendable, Codable { /// A plaintext blob, whose metadata the server derives. case plain(BlobID) /// A blob end-to-end encrypted for the chat, with the metadata its sealed message carries. @@ -86,6 +87,17 @@ enum ChatMediaUploadError: Error { /// The upload failed for a reason other than the bytes themselves, after any automatic retries. case failed(Error) + /// Whether the send can never succeed, so nothing about it is worth keeping: the server refused + /// the bytes, or the photo can't be uploaded again. + var isTerminal: Bool { + switch self { + case .rejected: + true + case .noMatchingConstraint, .encryptionNotAllowed, .sealUnavailable, .encodingFailed, .failed: + !isRetryable + } + } + /// Whether uploading the same photo again could succeed. var isRetryable: Bool { switch self { @@ -141,7 +153,73 @@ struct ChatMediaUploader { /// Returns the finalized photo for `image`, throwing `ChatMediaUploadError`. /// /// `onPrepared` receives the uploaded pixel width and height once, before any bytes are stored. - func upload(_ image: UIImage, onPrepared: (Int, Int) -> Void) async throws -> UploadedPhoto { + /// `onEncoded` receives the JPEG that will be stored, before it is, so the caller can keep the + /// exact bytes. `onStored` receives the photo once its bytes are stored, before the server has + /// finalized it, so a failed wait can resume through ``finalize(_:progress:)`` instead of + /// storing again. `progress`, when given, follows each store attempt's bytes and then the + /// server's processing. + func upload( + _ image: UIImage, + progress: ChatPhotoSendProgress? = nil, + onStored: (UploadedPhoto) -> Void = { _ in }, + onPrepared: (Int, Int) -> Void, + onEncoded: (Data) -> Void = { _ in } + ) async throws -> UploadedPhoto { + let (chatSeal, bounds, maxSizeBytes) = try await resolveConstraints() + guard let cgImage = image.cgImage, maxSizeBytes > 0 else { + throw ChatMediaUploadError.encodingFailed(.encodingFailed) + } + + let orientation = CGImagePropertyOrientation(image.imageOrientation) + let isSideways: Bool + switch orientation { + case .left, .leftMirrored, .right, .rightMirrored: + isSideways = true + case .up, .upMirrored, .down, .downMirrored: + isSideways = false + } + + let target = ChatMediaDownscale.target( + sourceWidth: isSideways ? cgImage.height : cgImage.width, + sourceHeight: isSideways ? cgImage.width : cgImage.height, + maxWidth: bounds?.maxWidth ?? 0, + maxHeight: bounds?.maxHeight ?? 0, + maxPixels: bounds?.maxPixels ?? 0 + ) + onPrepared(target.width, target.height) + + let data: Data + do { + data = try await Self.encode(cgImage, orientation: orientation, target: target, maxSizeBytes: maxSizeBytes) + } catch let error as ChatMediaEncoder.Error { + throw ChatMediaUploadError.encodingFailed(error) + } + onEncoded(data) + + return try await storeAndFinalize(data, width: target.width, height: target.height, chatSeal: chatSeal, progress: progress, onStored: onStored) + } + + /// Returns the finalized photo for `jpeg`, bytes an earlier ``upload(_:progress:onStored:onPrepared:onEncoded:)`` + /// produced, stored as they are rather than encoded again; throws `ChatMediaUploadError`. + func upload( + jpeg: Data, + progress: ChatPhotoSendProgress? = nil, + onStored: (UploadedPhoto) -> Void = { _ in }, + onPrepared: (Int, Int) -> Void + ) async throws -> UploadedPhoto { + let (chatSeal, _, _) = try await resolveConstraints() + guard let source = CGImageSourceCreateWithData(jpeg as CFData, nil), + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any], + let width = properties[kCGImagePropertyPixelWidth] as? Int, + let height = properties[kCGImagePropertyPixelHeight] as? Int else { + throw ChatMediaUploadError.encodingFailed(.encodingFailed) + } + onPrepared(width, height) + return try await storeAndFinalize(jpeg, width: width, height: height, chatSeal: chatSeal, progress: progress, onStored: onStored) + } + + /// The seal for this photo and the policy's bounds and size ceiling for the form it goes out in. + private func resolveConstraints() async throws -> (ChatSeal?, UploadPolicy.ImageConstraints?, Int) { let chatSeal: ChatSeal? do { chatSeal = try await seal() @@ -159,71 +237,62 @@ struct ChatMediaUploader { throw ChatMediaUploadError.failed(error) } - let bounds: UploadPolicy.ImageConstraints? - let maxSizeBytes: Int if chatSeal != nil { guard let encrypted = policy.encrypted else { logger.warning("Upload policy allows no encrypted chat photo", metadata: ["policyVersion": "\(policy.version)"]) throw ChatMediaUploadError.encryptionNotAllowed } - bounds = encrypted.image // The ceiling covers the sealed blob, so the image must leave room for nonce and tag. - maxSizeBytes = encrypted.maxSizeBytes - EncryptedBlobUpload.overhead + return (chatSeal, encrypted.image, encrypted.maxSizeBytes - EncryptedBlobUpload.overhead) } else { guard let constraint = policy.constraint(for: ChatMediaEncoder.mimeType) else { logger.warning("Upload policy accepts no chat photo", metadata: ["policyVersion": "\(policy.version)"]) throw ChatMediaUploadError.noMatchingConstraint } - bounds = constraint.image - maxSizeBytes = constraint.maxSizeBytes - } - guard let cgImage = image.cgImage, maxSizeBytes > 0 else { - throw ChatMediaUploadError.encodingFailed(.encodingFailed) - } - - let orientation = CGImagePropertyOrientation(image.imageOrientation) - let isSideways: Bool - switch orientation { - case .left, .leftMirrored, .right, .rightMirrored: - isSideways = true - case .up, .upMirrored, .down, .downMirrored: - isSideways = false + return (nil, constraint.image, constraint.maxSizeBytes) } + } - let target = ChatMediaDownscale.target( - sourceWidth: isSideways ? cgImage.height : cgImage.width, - sourceHeight: isSideways ? cgImage.width : cgImage.height, - maxWidth: bounds?.maxWidth ?? 0, - maxHeight: bounds?.maxHeight ?? 0, - maxPixels: bounds?.maxPixels ?? 0 - ) - onPrepared(target.width, target.height) - - let data: Data - do { - data = try await Self.encode(cgImage, orientation: orientation, target: target, maxSizeBytes: maxSizeBytes) - } catch let error as ChatMediaEncoder.Error { - throw ChatMediaUploadError.encodingFailed(error) + private func storeAndFinalize( + _ data: Data, + width: Int, + height: Int, + chatSeal: ChatSeal?, + progress: ChatPhotoSendProgress?, + onStored: (UploadedPhoto) -> Void + ) async throws -> UploadedPhoto { + // Byte counts arrive on URLSession's delegate queue. + let onBytes: @Sendable (BlobUploadProgress) -> Void = { [weak progress] bytes in + Task { @MainActor in progress?.didUpload(bytes) } } let uploaded: UploadedPhoto if let chatSeal { let blurhash = await Self.blurhash(of: data) - let stored = try await store { try await blob.storeEncryptedBlob(data, seal: chatSeal) } + let stored = try await store(progress: progress) { try await blob.storeEncryptedBlob(data, seal: chatSeal, onProgress: onBytes) } uploaded = .sealed(SealedPhoto( blobID: stored.blobID, mimeType: ChatMediaEncoder.mimeType, sizeBytes: stored.plaintextSize, - width: target.width, - height: target.height, + width: width, + height: height, blurhash: blurhash )) } else { - uploaded = .plain(try await store { try await blob.storeBlob(data, mimeType: ChatMediaEncoder.mimeType) }) + uploaded = .plain(try await store(progress: progress) { try await blob.storeBlob(data, mimeType: ChatMediaEncoder.mimeType, onProgress: onBytes) }) } + onStored(uploaded) + return try await finalize(uploaded, progress: progress) + } + + /// Returns `photo` once the server has finalized its stored bytes, throwing + /// `ChatMediaUploadError`. + func finalize(_ photo: UploadedPhoto, progress: ChatPhotoSendProgress? = nil) async throws -> UploadedPhoto { + progress?.beginProcessing() + do { - try await blob.awaitBlobFinalization(blobID: uploaded.blobID) + try await blob.awaitBlobFinalization(blobID: photo.blobID) } catch ErrorBlob.rejected(let reason) { throw ChatMediaUploadError.rejected(reason) } catch { @@ -231,13 +300,15 @@ struct ChatMediaUploader { throw ChatMediaUploadError.failed(error) } - return uploaded + return photo } /// Stores `data`, retrying through `backoff` while the failure is in transit. - private func store(_ attemptStore: () async throws -> Stored) async throws -> Stored { + private func store(progress: ChatPhotoSendProgress?, _ attemptStore: () async throws -> Stored) async throws -> Stored { var attempt = 0 while true { + // A retried store sends every byte again. + progress?.beginAttempt() do { return try await attemptStore() } catch { diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 374aa3a51..30ee0d332 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -168,6 +168,9 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen.pendingMediaImage = { [conversationController] id in conversationController.pendingMediaImage(forMessageID: id) } + screen.pendingMediaProgress = { [conversationController] id in + conversationController.pendingMediaProgress(forMessageID: id) + } screen.onContactAction = onContactAction screen.onProfileTap = onProfileTap screen.onGroupInvite = onGroupInvite diff --git a/Flipcash/Core/Screens/Conversation/ComposerChip.swift b/Flipcash/Core/Screens/Conversation/ComposerChip.swift index be698189f..16cc726a3 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChip.swift @@ -8,6 +8,7 @@ import UIKit import Observation import FlipcashCore +import FlipcashUI /// Whether a failed chip can be uploaded again, or the server refused its bytes for good. enum ChatMediaChipFailure: Equatable { @@ -37,6 +38,9 @@ final class ComposerChip: Identifiable { let preview: UIImage? var state: State = .preparing + /// How far this photo's send has got, which its transcript bubble draws. + let progress = ChatPhotoSendProgress() + /// Pixel width of the image as it will be uploaded, known before the upload starts. var preparedWidth: Int? @@ -51,32 +55,93 @@ final class ComposerChip: Identifiable { /// composer has let go of the chip. @ObservationIgnored private var uploader: ChatMediaUploader? + /// The photo whose bytes storage already holds, so a retry waits on the server again instead of + /// storing a second copy. Nil until a store succeeds, and again once the server rejects it. + @ObservationIgnored private var stored: UploadedPhoto? + + /// The JPEG the upload encoded, kept so a retry stores the same bytes instead of encoding again. + @ObservationIgnored private var encoded: Data? + + /// Hears the encoded JPEG as soon as it exists; see ``persist(onEncoded:onStored:)``. + @ObservationIgnored private var onEncoded: ((Data) -> Void)? + + /// Hears the stored photo as soon as its bytes land; see ``persist(onEncoded:onStored:)``. + @ObservationIgnored private var onStored: ((UploadedPhoto) -> Void)? + init(image: UIImage, preview: UIImage? = nil) { self.image = image self.preview = preview } + /// Hands the encoded JPEG and the stored photo to the callbacks, now if the chip already has + /// them and otherwise when they arrive, so a send can keep them on disk. + func persist(onEncoded: @escaping (Data) -> Void, onStored: @escaping (UploadedPhoto) -> Void) { + self.onEncoded = onEncoded + self.onStored = onStored + if let encoded { onEncoded(encoded) } + if let stored { onStored(stored) } + } + + /// Rebuilds a chip for a send read back from disk: `encoded` is the JPEG that was held and + /// `stored` the photo whose bytes had landed, if they had. With nothing stored the chip starts + /// failed and retryable; otherwise ``startUpload(using:)`` resumes by waiting on the server. + func restore(encoded: Data, stored: UploadedPhoto?, uploader: ChatMediaUploader) { + self.encoded = encoded + self.stored = stored + self.uploader = uploader + if stored == nil { + state = .failed(.retryable) + progress.fail() + } + } + /// Uploads the image through `uploader`, replacing any earlier attempt; calling it again after /// a retryable failure is how the chip is retried. func startUpload(using uploader: ChatMediaUploader) { self.uploader = uploader uploadTask?.cancel() state = .preparing + progress.beginAttempt() uploadTask = Task { do { - let photo = try await uploader.upload(image) { width, height in - preparedWidth = width - preparedHeight = height + let photo: UploadedPhoto + if let stored { state = .uploading + photo = try await uploader.finalize(stored, progress: progress) + } else if let encoded { + photo = try await uploader.upload(jpeg: encoded, progress: progress, onStored: { photo in + stored = photo + onStored?(photo) + }, onPrepared: { width, height in + preparedWidth = width + preparedHeight = height + state = .uploading + }) + } else { + photo = try await uploader.upload(image, progress: progress, onStored: { photo in + stored = photo + onStored?(photo) + }, onPrepared: { width, height in + preparedWidth = width + preparedHeight = height + state = .uploading + }, onEncoded: { data in + encoded = data + onEncoded?(data) + }) } if !Task.isCancelled { state = .uploaded(photo.blobID) } return photo } catch let error as ChatMediaUploadError { + if case .rejected = error { + stored = nil + } if !Task.isCancelled { state = .failed(error.isRetryable ? .retryable : .notRetryable) + progress.fail() } throw error } diff --git a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift index af495806c..470bacdeb 100644 --- a/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift +++ b/Flipcash/Core/Screens/Conversation/ComposerChipStrip.swift @@ -10,20 +10,19 @@ import FlipcashUI /// What a staged chip draws over its thumbnail. enum ComposerChipBadge: Equatable { - /// The photo is still being prepared or uploaded. - case progress /// The upload failed and can be tried again. case retry /// The server refused the photo; the only way forward is to remove it. case error - /// The photo is uploaded, so the thumbnail stands alone. + /// The thumbnail stands alone: uploaded, or uploading quietly until the send, whose bubble + /// shows the progress. case none /// Returns the badge for a chip in `state`. init(_ state: ComposerChip.State) { switch state { - case .preparing, .uploading: self = .progress - case .uploaded: self = .none + case .preparing, .uploading, .uploaded: + self = .none case .failed(.retryable): self = .retry case .failed(.notRetryable): self = .error } @@ -159,13 +158,6 @@ private struct ComposerChipView: View { @ViewBuilder private var badge: some View { switch ComposerChipBadge(chip.state) { - case .progress: - ZStack { - Color.black.opacity(0.35) - ProgressView() - .tint(.white) - } - .accessibilityLabel("Uploading photo") case .retry: Button(action: onRetry) { ZStack { diff --git a/Flipcash/Core/Screens/Conversation/PendingMediaStore.swift b/Flipcash/Core/Screens/Conversation/PendingMediaStore.swift new file mode 100644 index 000000000..d7fe787e8 --- /dev/null +++ b/Flipcash/Core/Screens/Conversation/PendingMediaStore.swift @@ -0,0 +1,143 @@ +// +// PendingMediaStore.swift +// Flipcash +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashCore + +private let logger = Logger(label: "flipcash.pending-media-store") + +/// The photo sends that have left the composer but not yet been confirmed, kept on disk so a send +/// survives iOS killing the app while it is backgrounded. +/// +/// A JSON manifest plus one JPEG per photo, owner-scoped in Application Support and outside +/// `FlipcashStore` for the reasons `ChatDraftStore` gives: that store is rebuilt from the server on +/// a version bump, and an unsent photo cannot be re-fetched. +@MainActor +final class PendingMediaStore { + + /// One photo send, as `ChatMediaSendPlan` assigned it. + struct Entry: Codable, Equatable, Sendable { + var clientMessageID: UUID + /// The raw bytes of the chat's `ConversationID`. + var conversationID: Data + var createdAt: Date + var fileName: String + var caption: String? + var replyTo: MessageID? + /// The photo once its bytes are stored; nil while the encoded JPEG is the only thing held. + var stored: UploadedPhoto? + + init(clientMessageID: UUID, conversationID: ConversationID, createdAt: Date, caption: String?, replyTo: MessageID?, stored: UploadedPhoto? = nil) { + self.clientMessageID = clientMessageID + self.conversationID = conversationID.data + self.createdAt = createdAt + self.fileName = "\(clientMessageID.uuidString).jpg" + self.caption = caption + self.replyTo = replyTo + self.stored = stored + } + + /// The chat the photo is being sent to. + var chatID: ConversationID { ConversationID(data: conversationID) } + } + + private struct File: Codable { + var version: Int + var entries: [Entry] + } + + private static let version = 1 + + private let manifestURL: URL + private let photosDirectory: URL + private var held: [Entry] + + /// Loads the owner's pending sends. A manifest that cannot be read is treated as empty. + init(directory: URL, owner: PublicKey) { + let prefix = "flipcash-\(owner.base58)-pending-media" + self.manifestURL = directory.appendingPathComponent("\(prefix).json") + self.photosDirectory = directory.appendingPathComponent(prefix, isDirectory: true) + guard + let data = try? Data(contentsOf: manifestURL), + let file = try? JSONDecoder().decode(File.self, from: data), + file.version == Self.version + else { + self.held = [] + return + } + self.held = file.entries + } + + /// Every send still held, oldest first. + var entries: [Entry] { held } + + /// Starts holding `entry`, replacing one with the same client id. + func add(_ entry: Entry) { + held.removeAll { $0.clientMessageID == entry.clientMessageID } + held.append(entry) + writeManifest() + } + + /// Writes the encoded JPEG for a held send, so the photo survives a kill from here on. + func writeImage(_ data: Data, for clientMessageID: UUID) { + guard let entry = held.first(where: { $0.clientMessageID == clientMessageID }) else { return } + do { + try FileManager.default.createDirectory(at: photosDirectory, withIntermediateDirectories: true) + try data.write(to: photosDirectory.appendingPathComponent(entry.fileName), options: .atomic) + } catch { + ErrorReporting.captureError(error, reason: "Failed to write pending chat photo") + } + } + + /// The JPEG held for `entry`, or nil when its file is gone. + func imageData(for entry: Entry) -> Data? { + try? Data(contentsOf: photosDirectory.appendingPathComponent(entry.fileName)) + } + + /// Records that the send's bytes are stored, so a relaunch resumes instead of uploading again. + func setStored(_ photo: UploadedPhoto, for clientMessageID: UUID) { + guard let index = held.firstIndex(where: { $0.clientMessageID == clientMessageID }) else { return } + held[index].stored = photo + writeManifest() + } + + /// Forgets a send and deletes its photo — confirmed, discarded, or refused for good. + func remove(clientMessageID: UUID) { + guard let index = held.firstIndex(where: { $0.clientMessageID == clientMessageID }) else { return } + let entry = held.remove(at: index) + try? FileManager.default.removeItem(at: photosDirectory.appendingPathComponent(entry.fileName)) + writeManifest() + } + + /// Drops entries whose photo file is missing and deletes photo files no entry refers to. + func sweepOrphans() { + let before = held.count + held.removeAll { imageData(for: $0) == nil } + if held.count != before { writeManifest() } + + let known = Set(held.map(\.fileName)) + let files = (try? FileManager.default.contentsOfDirectory(atPath: photosDirectory.path)) ?? [] + for name in files where !known.contains(name) { + try? FileManager.default.removeItem(at: photosDirectory.appendingPathComponent(name)) + } + if before != held.count || files.count != known.count { + logger.info("Swept orphaned pending photos", metadata: [ + "droppedEntries": "\(before - held.count)", + "files": "\(files.count)", + ]) + } + } + + private func writeManifest() { + do { + let data = try JSONEncoder().encode(File(version: Self.version, entries: held)) + try data.write(to: manifestURL, options: .atomic) + } catch { + ErrorReporting.captureError(error, reason: "Failed to write pending chat photo manifest") + } + } +} diff --git a/Flipcash/Core/Session/BackgroundStoreWriteGuard.swift b/Flipcash/Core/Session/BackgroundStoreWriteGuard.swift index 2a53f1b13..00173bc6a 100644 --- a/Flipcash/Core/Session/BackgroundStoreWriteGuard.swift +++ b/Flipcash/Core/Session/BackgroundStoreWriteGuard.swift @@ -117,10 +117,13 @@ nonisolated protocol BackgroundTaskAsserting: Sendable { /// ``BackgroundTaskAsserting`` over `UIApplication.shared`, hopping to the main thread it requires. nonisolated struct UIApplicationBackgroundTasks: BackgroundTaskAsserting { + /// The name iOS reports the task under in its diagnostics. + var name = "database.write" + func begin(expiration: @escaping @Sendable () -> Void) -> UIBackgroundTaskIdentifier { let begin = { MainActor.assumeIsolated { - UIApplication.shared.beginBackgroundTask(withName: "database.write", expirationHandler: expiration) + UIApplication.shared.beginBackgroundTask(withName: name, expirationHandler: expiration) } } return Thread.isMainThread ? begin() : DispatchQueue.main.sync(execute: begin) @@ -134,3 +137,35 @@ nonisolated struct UIApplicationBackgroundTasks: BackgroundTaskAsserting { } } } + +/// One background-task assertion, held from ``begin()`` until ``end()`` or until iOS expires it. +nonisolated final class BackgroundTimeAssertion: @unchecked Sendable { + + private let assertions: any BackgroundTaskAsserting + + /// Guarded by `lock`. + private let lock = NSLock() + private var taskID: UIBackgroundTaskIdentifier = .invalid + + init(assertions: any BackgroundTaskAsserting) { + self.assertions = assertions + } + + /// Asks iOS to keep the app running after it leaves the screen; a no-op when none is granted. + func begin() { + // Expiry only ends the assertion: the work under it carries on until iOS suspends the app. + let acquired = assertions.begin { [weak self] in self?.end() } + lock.withLock { taskID = acquired } + } + + /// Releases the assertion; safe to call more than once. + func end() { + let held = lock.withLock { + defer { taskID = .invalid } + return taskID + } + if held != .invalid { + assertions.end(held) + } + } +} diff --git a/Flipcash/Core/Session/SessionAuthenticator.swift b/Flipcash/Core/Session/SessionAuthenticator.swift index b637b4ea2..1d39d6a57 100644 --- a/Flipcash/Core/Session/SessionAuthenticator.swift +++ b/Flipcash/Core/Session/SessionAuthenticator.swift @@ -701,6 +701,17 @@ final class SessionContainer { // puts its text back — none of which the controller can do before it has the store. conversationController.chatDrafts = chatDrafts conversationController.chatArchive = chatArchive + // Same owner-scoped placement as the drafts; a photo whose send a kill interrupted comes back + // through `start()`, which needs the uploader to finish it. + conversationController.pendingMedia = PendingMediaStore( + directory: .applicationSupportDirectory, + owner: owner.publicKey + ) + conversationController.restoredPhotoUploader = { [weak conversationController, session, flipClient] conversationID in + var uploader = ChatMediaUploader(blob: SessionChatMediaBlobStore(session: session, flipClient: flipClient)) + uploader.seal = { try await conversationController?.photoSeal(for: conversationID) } + return uploader + } let recentReactions = RecentReactionsStore(owner: owner.publicKey) self.recentReactions = recentReactions conversationController.reactions.recents = recentReactions diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift b/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift index 999d1c4c0..9ad6431a7 100644 --- a/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift +++ b/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploader.swift @@ -41,7 +41,14 @@ final class BlobUploader: Sendable { /// /// Separate from `awaitFinalization` so a caller whose poll times out can /// resume the same blob instead of uploading a second copy. - func store(_ data: Data, mimeType: String, owner: KeyPair) async throws -> BlobID { + /// + /// `onProgress` hears the bytes going out to storage, from any thread. + func store( + _ data: Data, + mimeType: String, + owner: KeyPair, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void = { _ in } + ) async throws -> BlobID { // Sanitize before reserving: the reservation signs the byte count, and // storage refuses an image carrying personal metadata. Doing it here // rather than at the call site is what makes it true of every upload. @@ -60,7 +67,7 @@ final class BlobUploader: Sendable { "sizeBytes": "\(data.count)", ]) - try await store(data, mimeType: mimeType, to: reserved.target) + try await store(data, mimeType: mimeType, to: reserved.target, onProgress: onProgress) switch try await reserving.completeExternalUpload(blobID: reserved.blobID, owner: owner) { case .rejected(let reason): @@ -78,11 +85,13 @@ final class BlobUploader: Sendable { /// finalization to the caller. /// /// `encrypt` seals the plaintext under the blob id the reservation assigns, which is part of - /// its aad, so the reservation declares the sealed size before the blob exists. + /// its aad, so the reservation declares the sealed size before the blob exists. `onProgress` + /// hears the sealed bytes going out to storage, from any thread. func storeEncrypted( _ image: Data, for conversationID: ConversationID, owner: KeyPair, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void = { _ in }, encrypt: @Sendable (Data, BlobID) throws -> Data ) async throws -> EncryptedBlobUpload { // The server never reads these bytes, so stripping location and camera metadata is on us. @@ -111,7 +120,7 @@ final class BlobUploader: Sendable { throw ErrorBlob.unknown } - try await store(blob, mimeType: Self.encryptedMimeType, to: reserved.target) + try await store(blob, mimeType: Self.encryptedMimeType, to: reserved.target, onProgress: onProgress) switch try await reserving.completeExternalUpload(blobID: reserved.blobID, owner: owner) { case .rejected(let reason): @@ -130,27 +139,39 @@ final class BlobUploader: Sendable { /// Polls until the blob is finalized. /// - /// Returns immediately when it is already ready; a rejection is terminal. + /// Returns immediately when it is already ready; a rejection is terminal. A poll lost in transit + /// is retried, since iOS drops the connection when it suspends the app mid-wait. The `timeout` + /// budget is counted in polls rather than wall time, so time spent suspended doesn't use it up. func awaitFinalization(blobID: BlobID, owner: KeyPair) async throws { - let deadline = ContinuousClock.now.advanced(by: timeout) + let maxPolls = max(1, Int((timeout / pollInterval).rounded(.up))) + var polls = 0 while true { try Task.checkCancellation() - - switch try await reserving.blobState(blobID: blobID, owner: owner) { - case .ready: - return - case .rejected(let reason): - logger.info("Blob rejected", metadata: [ + polls += 1 + + do { + switch try await reserving.blobState(blobID: blobID, owner: owner) { + case .ready: + return + case .rejected(let reason): + logger.info("Blob rejected", metadata: [ + "blobId": "\(blobID)", + "reason": "\(reason)", + ]) + throw ErrorBlob.rejected(reason) + case .pending, .processing: + break + } + } catch ErrorBlob.network(let error) { + try Task.checkCancellation() + logger.info("Blob poll lost in transit", metadata: [ "blobId": "\(blobID)", - "reason": "\(reason)", + "error": "\(error)", ]) - throw ErrorBlob.rejected(reason) - case .pending, .processing: - break } - guard ContinuousClock.now < deadline else { + guard polls < maxPolls else { logger.info("Blob finalization timed out", metadata: ["blobId": "\(blobID)"]) throw ErrorBlob.timedOut } @@ -161,7 +182,12 @@ final class BlobUploader: Sendable { // MARK: - Upload - - private func store(_ data: Data, mimeType: String, to target: UploadTarget) async throws { + private func store( + _ data: Data, + mimeType: String, + to target: UploadTarget, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void + ) async throws { let boundary = "Boundary-\(UUID().uuidString)" let status: Int @@ -180,7 +206,8 @@ final class BlobUploader: Sendable { file: data, mimeType: mimeType, boundary: boundary - ) + ), + onProgress: onProgress ) } catch is CancellationError { throw CancellationError() diff --git a/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploading.swift b/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploading.swift index 1e5904562..c2e901673 100644 --- a/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploading.swift +++ b/FlipcashCore/Sources/FlipcashCore/Blob/BlobUploading.swift @@ -14,10 +14,30 @@ protocol BlobUploading: Sendable { url: URL, contentType: String, headers: [String: String], - body: Data + body: Data, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void ) async throws -> (status: Int, body: Data) } +/// How much of an upload's request body has gone out. +public struct BlobUploadProgress: Sendable, Equatable { + /// Bytes of the request body sent so far. + public let sentBytes: Int64 + /// The request body's full length. + public let totalBytes: Int64 + + public init(sentBytes: Int64, totalBytes: Int64) { + self.sentBytes = sentBytes + self.totalBytes = totalBytes + } + + /// The share of the body sent, from 0 to 1, or nil when the length is unknown. + public var fraction: Double? { + guard totalBytes > 0 else { return nil } + return min(max(Double(sentBytes) / Double(totalBytes), 0), 1) + } +} + /// The production `BlobUploading`, over a shared `URLSession`. struct URLSessionBlobUploader: BlobUploading { @@ -31,7 +51,8 @@ struct URLSessionBlobUploader: BlobUploading { url: URL, contentType: String, headers: [String: String], - body: Data + body: Data, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void ) async throws -> (status: Int, body: Data) { var request = URLRequest(url: url) @@ -42,9 +63,33 @@ struct URLSessionBlobUploader: BlobUploading { request.setValue(value, forHTTPHeaderField: field) } - let (data, response) = try await session.upload(for: request, from: body) + // The body's own length, since `totalBytesExpectedToSend` can be unknown (-1). + let delegate = UploadProgressDelegate(totalBytes: Int64(body.count), onProgress: onProgress) + let (data, response) = try await session.upload(for: request, from: body, delegate: delegate) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 return (status, data) } } + +/// Forwards one upload task's sent-byte counts. +private final class UploadProgressDelegate: NSObject, URLSessionTaskDelegate, Sendable { + + private let totalBytes: Int64 + private let onProgress: @Sendable (BlobUploadProgress) -> Void + + init(totalBytes: Int64, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) { + self.totalBytes = totalBytes + self.onProgress = onProgress + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didSendBodyData bytesSent: Int64, + totalBytesSent: Int64, + totalBytesExpectedToSend: Int64 + ) { + onProgress(BlobUploadProgress(sentBytes: totalBytesSent, totalBytes: totalBytes)) + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift index 2e18231b1..8ccb1c3df 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Blob.swift @@ -11,15 +11,25 @@ extension FlipClient { /// /// Pair with `awaitBlobFinalization(blobID:owner:)`: holding the blob from /// the moment the bytes land is what lets a timed-out wait resume rather - /// than upload a second copy. - public func storeBlob(_ data: Data, mimeType: String, owner: KeyPair) async throws -> BlobID { - try await blobUploader.store(data, mimeType: mimeType, owner: owner) + /// than upload a second copy. `onProgress` hears the bytes going out, from any thread. + public func storeBlob( + _ data: Data, + mimeType: String, + owner: KeyPair, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void = { _ in } + ) async throws -> BlobID { + try await blobUploader.store(data, mimeType: mimeType, owner: owner, onProgress: onProgress) } /// Stores `image` end-to-end encrypted with `seal` for its DM and returns the blob, before the /// server has finalized it. Pair with `awaitBlobFinalization(blobID:owner:)` as for `storeBlob`. - public func storeEncryptedBlob(_ image: Data, seal: ChatSeal, owner: KeyPair) async throws -> EncryptedBlobUpload { - try await blobUploader.storeEncrypted(image, for: seal.conversationID, owner: owner) { plaintext, blobID in + public func storeEncryptedBlob( + _ image: Data, + seal: ChatSeal, + owner: KeyPair, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void = { _ in } + ) async throws -> EncryptedBlobUpload { + try await blobUploader.storeEncrypted(image, for: seal.conversationID, owner: owner, onProgress: onProgress) { plaintext, blobID in try seal.encryptBlob(plaintext, blobID: blobID) } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift index f28798686..6a164a8a4 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ChatSeal.swift @@ -166,7 +166,7 @@ public enum BlobOpenFailure: Error, Hashable, Sendable { } /// A photo uploaded encrypted for a chat, with the plaintext metadata its sealed message carries. -public struct SealedPhoto: Hashable, Sendable { +public struct SealedPhoto: Hashable, Sendable, Codable { /// The encrypted blob, READY before the message is sent. public let blobID: BlobID /// The plaintext image's MIME type, always an `image/` type. diff --git a/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift index 8c52e4507..a69223731 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/BlobUploaderTests.swift @@ -4,6 +4,7 @@ // import Foundation +import Synchronization import Testing @testable import FlipcashCore @@ -62,6 +63,55 @@ struct BlobUploaderTests { #expect(await transport.body == nil) } + // MARK: - Progress - + + @Test("Forwards the transport's sent-byte counts for a plaintext upload") + func store_forwardsProgress() async throws { + let uploader = makeUploader(transport: RecordingTransport(), states: [.ready]) + let seen = Mutex<[BlobUploadProgress]>([]) + + _ = try await uploader.store(Self.fileBytes, mimeType: "image/jpeg", owner: try Self.owner()) { progress in + seen.withLock { $0.append(progress) } + } + + let fractions = seen.withLock { $0 }.compactMap(\.fraction) + #expect(fractions.count == 2) + #expect(fractions.first.map { $0 > 0 && $0 < 1 } == true) + #expect(fractions.last == 1.0) + } + + @Test("Forwards the sealed bytes' progress for an encrypted upload") + func storeEncrypted_forwardsProgress() async throws { + let uploader = makeUploader(transport: RecordingTransport(), states: [.ready]) + let seen = Mutex<[BlobUploadProgress]>([]) + + _ = try await uploader.storeEncrypted( + Self.fileBytes, + for: ConversationID(data: Data(repeating: 7, count: 32)), + owner: try Self.owner(), + onProgress: { progress in seen.withLock { $0.append(progress) } } + ) { plaintext, _ in + Data(count: plaintext.count + EncryptedBlobUpload.overhead) + } + + #expect(seen.withLock { $0 }.last?.fraction == 1.0) + } + + @Test("Maps sent bytes to a fraction of the body, clamped to 0...1") + func progress_fraction() { + #expect(BlobUploadProgress(sentBytes: 0, totalBytes: 200).fraction == 0) + #expect(BlobUploadProgress(sentBytes: 50, totalBytes: 200).fraction == 0.25) + #expect(BlobUploadProgress(sentBytes: 200, totalBytes: 200).fraction == 1) + #expect(BlobUploadProgress(sentBytes: 300, totalBytes: 200).fraction == 1) + #expect(BlobUploadProgress(sentBytes: -1, totalBytes: 200).fraction == 0) + } + + @Test("Reports no fraction when the body's length is unknown") + func progress_unknownLength() { + #expect(BlobUploadProgress(sentBytes: 10, totalBytes: 0).fraction == nil) + #expect(BlobUploadProgress(sentBytes: 10, totalBytes: -1).fraction == nil) + } + @Test("The reservation names the chat only for an encrypted upload") func reservationRequestNamesChat() throws { let owner = try Self.owner() @@ -325,6 +375,44 @@ struct BlobUploaderTests { #expect(await reserving.reserveCount == 0) } + /// iOS drops the connection when it suspends the app mid-wait; one lost poll is not a failed + /// upload. + @Test("A poll lost in transit is retried rather than failing the upload") + func lostPollIsRetried() async throws { + let transport = RecordingTransport() + let reserving = StubReserving(states: [.processing, .ready], lostPolls: 2) + let uploader = BlobUploader( + reserving: reserving, + transport: transport, + pollInterval: .milliseconds(1), + timeout: .seconds(5) + ) + + try await uploader.awaitFinalization(blobID: StubReserving.blobID, owner: try Self.owner()) + + #expect(await reserving.pollCount == 4) + #expect(await transport.body == nil) + } + + @Test("Polls lost in transit still count toward the deadline") + func lostPollsHonourTheDeadline() async throws { + let reserving = StubReserving(states: [], lostPolls: .max) + let uploader = BlobUploader( + reserving: reserving, + transport: RecordingTransport(), + pollInterval: .milliseconds(1), + timeout: .milliseconds(5) + ) + + await #expect { + try await uploader.awaitFinalization(blobID: StubReserving.blobID, owner: try Self.owner()) + } throws: { error in + guard case ErrorBlob.timedOut = error else { return false } + return true + } + #expect(await reserving.pollCount == 5) + } + // MARK: - Fixtures - private static let fileBytes = Data(repeating: 0xAB, count: 4096) @@ -453,7 +541,18 @@ private actor RecordingTransport: BlobUploading { self.failure = error } - func post(url: URL, contentType: String, headers: [String: String], body: Data) async throws -> (status: Int, body: Data) { + func post( + url: URL, + contentType: String, + headers: [String: String], + body: Data, + onProgress: @escaping @Sendable (BlobUploadProgress) -> Void + ) async throws -> (status: Int, body: Data) { + // Reports the body going out in two halves, as URLSession does in chunks. + let total = Int64(body.count) + onProgress(BlobUploadProgress(sentBytes: total / 2, totalBytes: total)) + onProgress(BlobUploadProgress(sentBytes: total, totalBytes: total)) + self.url = url self.contentType = contentType self.headers = headers @@ -473,15 +572,18 @@ private actor StubReserving: BlobReserving { private var states: [BlobState] private let completion: BlobState + private var lostPolls: Int private(set) var pollCount = 0 private(set) var reserveCount = 0 private(set) var declaredSizeBytes: Int? private(set) var declaredMimeType: String? private(set) var encryptedFor: ConversationID? - init(states: [BlobState], completion: BlobState = .processing) { + /// `lostPolls` is how many polls fail in transit before the stub starts answering. + init(states: [BlobState], completion: BlobState = .processing, lostPolls: Int = 0) { self.states = states self.completion = completion + self.lostPolls = lostPolls } func initiateExternalUpload(mimeType: String, sizeBytes: Int, encryptedFor: ConversationID?, owner: KeyPair) async throws -> ReservedUpload { @@ -512,6 +614,10 @@ private actor StubReserving: BlobReserving { func blobState(blobID: BlobID, owner: KeyPair) async throws -> BlobState { pollCount += 1 + if lostPolls > 0 { + lostPolls -= 1 + throw ErrorBlob.network(URLError(.networkConnectionLost)) + } return states.isEmpty ? .processing : states.removeFirst() } } diff --git a/FlipcashTests/Chat/ChatMediaCellTests.swift b/FlipcashTests/Chat/ChatMediaCellTests.swift index c3b6e1fb4..0cd036db9 100644 --- a/FlipcashTests/Chat/ChatMediaCellTests.swift +++ b/FlipcashTests/Chat/ChatMediaCellTests.swift @@ -46,6 +46,97 @@ struct ChatMediaCellTests { return cell } + private func outgoing(id: String = "1", receipt: ChatReceipt? = nil) -> ChatMessage { + ChatMessage(id: id, content: .media(media()), sender: .me, receipt: receipt) + } + + // MARK: - Send progress - + + @Test("A row with no send progress draws no overlay") + func noProgressNoOverlay() { + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: nil, remote: nil) + #expect(!cell.progressOverlay.isShowing) + #expect(cell.progressOverlay.isHidden) + } + + @Test("An uploading photo shows its share of bytes sent; processing shows a full, indeterminate bar") + func overlayFollowsProgress() async { + let progress = ChatPhotoSendProgress() + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + #expect(cell.progressOverlay.isShowing) + #expect(cell.progressOverlay.fraction == 0) + #expect(!cell.progressOverlay.isIndeterminate) + + progress.didUpload(BlobUploadProgress(sentBytes: 40, totalBytes: 100)) + await waitFor { cell.progressOverlay.fraction == 0.4 } + #expect(cell.progressOverlay.fraction == 0.4) + + progress.beginProcessing() + await waitFor { cell.progressOverlay.isIndeterminate } + #expect(cell.progressOverlay.fraction == 1) + #expect(cell.progressOverlay.isIndeterminate) + #expect(cell.progressOverlay.isShowing) + } + + @Test("The overlay hides once the photo is sent") + func overlayHidesWhenSent() async { + let progress = ChatPhotoSendProgress() + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + progress.beginSending() + + progress.finish() + await waitFor { !cell.progressOverlay.isShowing } + #expect(!cell.progressOverlay.isShowing) + } + + @Test("The confirmed row, reconfigured without progress, hides the overlay") + func overlayHidesWhenConfirmedRowLosesProgress() { + let progress = ChatPhotoSendProgress() + progress.beginSending() + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + #expect(cell.progressOverlay.isShowing) + + cell.configure(with: outgoing(receipt: .delivered), maxWidth: 240, localImage: nil, progress: nil, remote: nil) + #expect(!cell.progressOverlay.isShowing) + } + + @Test("A failed row hides the overlay for the failed receipt, and a retry brings it back") + func failedRowHidesOverlay() async { + let progress = ChatPhotoSendProgress() + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + + progress.fail() + cell.configure(with: outgoing(receipt: .failed("Not delivered")), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + #expect(!cell.progressOverlay.isShowing) + #expect(cell.progressOverlay.isHidden) + + progress.beginAttempt() + cell.configure(with: outgoing(), maxWidth: 240, localImage: nil, progress: progress, remote: nil) + #expect(cell.progressOverlay.isShowing) + } + + @Test("A recycled cell drops the previous row's progress") + func recycledCellIgnoresOldProgress() async { + let old = ChatPhotoSendProgress() + let cell = ChatMediaCell(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + cell.configure(with: outgoing(id: "old"), maxWidth: 240, localImage: nil, progress: old, remote: nil) + cell.prepareForReuse() + cell.configure(with: outgoing(id: "new", receipt: .delivered), maxWidth: 240, localImage: nil, progress: nil, remote: nil) + + old.didUpload(BlobUploadProgress(sentBytes: 50, totalBytes: 100)) + for _ in 0..<5 { await Task.yield() } + #expect(!cell.progressOverlay.isShowing) + } + + private func waitFor(_ condition: () -> Bool) async { + for _ in 0..<50 where !condition() { await Task.yield() } + } + @Test("A row with no image yet draws its BlurHash") func drawsBlurhashBeforeImageLoads() { let cell = configuredCell(media()) diff --git a/FlipcashTests/Chat/ChatMediaUploaderTests.swift b/FlipcashTests/Chat/ChatMediaUploaderTests.swift index e82923ee5..ddff56d7c 100644 --- a/FlipcashTests/Chat/ChatMediaUploaderTests.swift +++ b/FlipcashTests/Chat/ChatMediaUploaderTests.swift @@ -263,8 +263,10 @@ struct ChatMediaUploaderTests { #expect(chip.state == .failed(.notRetryable)) } - @Test("Retrying a failed chip uploads it again") - func retryingFailedChipUploadsAgain() async throws { + /// The bytes were stored before the wait failed, which is how a send interrupted by the app + /// going to the background fails; storing them again would leave an orphan blob. + @Test("Retrying a chip whose wait for the server failed waits again without storing again") + func retryingFailedChipResumesStoredBlob() async throws { let blob = MockChatMediaBlobStore() blob.finalization = .failure(ErrorBlob.timedOut) let uploader = ChatMediaUploader(blob: blob) @@ -278,9 +280,46 @@ struct ChatMediaUploaderTests { let blobID = try await #require(chip.uploadTask).value.blobID #expect(chip.state == .uploaded(blobID)) + #expect(blob.storeAttempts == 1) + #expect(blob.finalizedBlobIDs == [blobID]) + } + + @Test("A chip's progress follows its bytes, then waits on the server's processing once stored") + func chipProgressReachesProcessing() async throws { + let blob = MockChatMediaBlobStore() + blob.progressReports = [ + BlobUploadProgress(sentBytes: 50, totalBytes: 100), + BlobUploadProgress(sentBytes: 100, totalBytes: 100), + ] + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: ChatMediaUploader(blob: blob))) + #expect(chip.progress.phase == .preparing) + + _ = try await #require(chip.uploadTask).value + + #expect(chip.progress.phase == .processing) + #expect(chip.progress.showsOverlay) + } + + @Test("A chip whose upload fails hands its progress to the failed state; retrying starts it over") + func chipProgressFailsAndRetries() async throws { + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.timedOut) + let uploader = ChatMediaUploader(blob: blob) + let composer = ComposerModel() + let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: uploader)) + _ = await chip.uploadTask?.result + #expect(chip.progress.phase == .failed) + #expect(!chip.progress.showsOverlay) + + blob.finalization = .success(()) + chip.startUpload(using: uploader) + #expect(chip.progress.phase == .preparing) + _ = try await #require(chip.uploadTask).value + #expect(chip.progress.phase == .processing) } - @Test("Removing a chip cancels its upload") + @Test("Removing a chip cancels its upload") func removingChipCancelsUpload() throws { let composer = ComposerModel() let chip = try #require(composer.stageChip(image: Self.image(width: 40, height: 30), uploader: ChatMediaUploader(blob: MockChatMediaBlobStore()))) diff --git a/FlipcashTests/Chat/ChatPhotoSendProgressTests.swift b/FlipcashTests/Chat/ChatPhotoSendProgressTests.swift new file mode 100644 index 000000000..4281815e5 --- /dev/null +++ b/FlipcashTests/Chat/ChatPhotoSendProgressTests.swift @@ -0,0 +1,113 @@ +// +// ChatPhotoSendProgressTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import FlipcashCore +@testable import FlipcashUI + +@Suite("Chat photo send progress") +@MainActor +struct ChatPhotoSendProgressTests { + + private func bytes(_ sent: Int64, of total: Int64) -> BlobUploadProgress { + BlobUploadProgress(sentBytes: sent, totalBytes: total) + } + + @Test("Bytes sent map to the share of the body, clamped, and nil for an unknown length") + func bytesToFraction() { + #expect(bytes(0, of: 200).fraction == 0) + #expect(bytes(50, of: 200).fraction == 0.25) + #expect(bytes(200, of: 200).fraction == 1) + #expect(bytes(300, of: 200).fraction == 1) + #expect(bytes(10, of: 0).fraction == nil) + #expect(bytes(10, of: -1).fraction == nil) + } + + @Test("A send runs preparing, uploading, processing, sending, sent") + func happyPath() { + let progress = ChatPhotoSendProgress() + #expect(progress.phase == .preparing) + #expect(progress.showsOverlay) + + progress.didUpload(bytes(25, of: 100)) + #expect(progress.phase == .uploading(fraction: 0.25)) + + progress.didUpload(bytes(100, of: 100)) + #expect(progress.phase == .uploading(fraction: 1)) + + progress.beginProcessing() + #expect(progress.phase == .processing) + #expect(progress.showsOverlay) + + progress.beginSending() + #expect(progress.phase == .sending) + #expect(progress.showsOverlay) + + progress.finish() + #expect(progress.phase == .sent) + #expect(!progress.showsOverlay) + } + + @Test("The bar never runs backwards within an attempt") + func monotonicWithinAttempt() { + let progress = ChatPhotoSendProgress() + progress.didUpload(bytes(60, of: 100)) + progress.didUpload(bytes(40, of: 100)) + #expect(progress.phase == .uploading(fraction: 0.6)) + } + + @Test("A byte count that lands after the upload moved on is ignored") + func lateBytesIgnored() { + let progress = ChatPhotoSendProgress() + progress.beginProcessing() + progress.didUpload(bytes(50, of: 100)) + #expect(progress.phase == .processing) + + progress.fail() + progress.didUpload(bytes(50, of: 100)) + #expect(progress.phase == .failed) + } + + @Test("A byte count with no known length leaves the phase alone") + func unknownLengthIgnored() { + let progress = ChatPhotoSendProgress() + progress.didUpload(bytes(50, of: 0)) + #expect(progress.phase == .preparing) + } + + @Test("A failure hides the overlay; a new attempt brings it back from the start", arguments: [ + ChatPhotoSendProgress.Phase.preparing, .uploading(fraction: 0.5), .processing, .sending, + ]) + func failureThenRetry(from phase: ChatPhotoSendProgress.Phase) { + let progress = ChatPhotoSendProgress() + switch phase { + case .preparing: break + case .uploading(let fraction): progress.didUpload(bytes(Int64(fraction * 100), of: 100)) + case .processing: progress.beginProcessing() + case .sending: progress.beginSending() + case .sent, .failed: Issue.record("not a starting phase") + } + #expect(progress.phase == phase) + + progress.fail() + #expect(progress.phase == .failed) + #expect(!progress.showsOverlay) + + progress.beginAttempt() + #expect(progress.phase == .preparing) + #expect(progress.showsOverlay) + } + + @Test("A retried store starts the bar over") + func retriedStoreResets() { + let progress = ChatPhotoSendProgress() + progress.didUpload(bytes(80, of: 100)) + progress.beginAttempt() + progress.didUpload(bytes(10, of: 100)) + #expect(progress.phase == .uploading(fraction: 0.1)) + } +} diff --git a/FlipcashTests/Chat/ComposerChipStripTests.swift b/FlipcashTests/Chat/ComposerChipStripTests.swift index 0b74624bf..724dcf2a3 100644 --- a/FlipcashTests/Chat/ComposerChipStripTests.swift +++ b/FlipcashTests/Chat/ComposerChipStripTests.swift @@ -14,12 +14,12 @@ import FlipcashCore @Suite("Composer chip strip") struct ComposerChipStripTests { - @Test("A chip still preparing or uploading shows the progress ring", arguments: [ + @Test("A chip still preparing or uploading shows only its thumbnail; the sent bubble shows progress", arguments: [ ComposerChip.State.preparing, .uploading, ]) - func inFlightShowsProgress(state: ComposerChip.State) { - #expect(ComposerChipBadge(state) == .progress) + func inFlightShowsNothing(state: ComposerChip.State) { + #expect(ComposerChipBadge(state) == .none) } @Test("An uploaded chip shows only its thumbnail") diff --git a/FlipcashTests/Chat/ConversationMediaSendTests.swift b/FlipcashTests/Chat/ConversationMediaSendTests.swift index 47c9ad38b..ee5507031 100644 --- a/FlipcashTests/Chat/ConversationMediaSendTests.swift +++ b/FlipcashTests/Chat/ConversationMediaSendTests.swift @@ -158,6 +158,7 @@ struct ConversationMediaSendTests { #expect(pending.count == 1) #expect(pending.first?.status == .failed) #expect(controller.pendingMediaImage(forMessageID: try #require(pending.first?.stableID)) === rejected.image) + #expect(rejected.progress.phase == .failed) } @Test("A photo the server refuses to post leaves a failed bubble") @@ -166,9 +167,11 @@ struct ConversationMediaSendTests { mock.mediaSendError = ErrorSendMessage.transportFailure let controller = makeController(mock) - #expect(!(await controller.sendMedia([uploadedChip("a")], caption: "hi", to: conversationID))) + let chip = uploadedChip("a") + #expect(!(await controller.sendMedia([chip], caption: "hi", to: conversationID))) #expect(pendingMedia(controller).map(\.status) == [.failed]) + #expect(chip.progress.phase == .failed) } // MARK: - Pending bubble @@ -210,8 +213,8 @@ struct ConversationMediaSendTests { _ = await send.value } - @Test("The local image is served for a pending photo and dropped once it is confirmed") - func localImageLivesUntilConfirmed() async throws { + @Test("The local image is served for a pending photo and kept once it is confirmed") + func localImageOutlivesConfirm() async throws { let controller = makeController(MockConversations()) let gate = UploadGate() let chip = gatedChip(gate) @@ -223,7 +226,25 @@ struct ConversationMediaSendTests { gate.open(.success(blobID("a"))) _ = await send.value - #expect(controller.pendingMediaImage(forMessageID: messageID) == nil) + // The sent row keeps the client id, so its bubble keeps drawing the photo instead of the BlurHash. + #expect(controller.pendingMediaImage(forMessageID: messageID) === chip.image) + } + + @Test("The send progress is served for a pending photo, ends sent, and is dropped once confirmed") + func progressLivesUntilConfirmed() async throws { + let controller = makeController(MockConversations()) + let gate = UploadGate() + let chip = gatedChip(gate) + + let send = Task { await controller.sendMedia([chip], caption: nil, to: conversationID) } + await yield { pendingMedia(controller).count == 1 } + let messageID = try #require(pendingMedia(controller).first?.stableID) + #expect(controller.pendingMediaProgress(forMessageID: messageID) === chip.progress) + + gate.open(.success(blobID("a"))) + #expect(await send.value) + #expect(chip.progress.phase == .sent) + #expect(controller.pendingMediaProgress(forMessageID: messageID) == nil) } // MARK: - Retry @@ -260,6 +281,7 @@ struct ConversationMediaSendTests { #expect(!(await controller.sendMedia([chip], caption: nil, to: conversationID))) #expect(chip.state == .failed(.retryable)) + #expect(chip.progress.phase == .failed) let clientMessageID = try #require(pendingMedia(controller).first?.clientMessageID) await controller.retry(clientMessageID: clientMessageID, in: conversationID) @@ -267,6 +289,7 @@ struct ConversationMediaSendTests { #expect(blob.storeAttempts == 2) #expect(mock.sentMedia.map(\.blobID) == [MockChatMediaBlobStore.blobID]) #expect(pendingMedia(controller).isEmpty) + #expect(chip.progress.phase == .sent, "the retried upload brings the overlay back and ends it sent") } @Test("A photo refused by moderation is not retried") diff --git a/FlipcashTests/Chat/PendingMediaPersistenceTests.swift b/FlipcashTests/Chat/PendingMediaPersistenceTests.swift new file mode 100644 index 000000000..239092c73 --- /dev/null +++ b/FlipcashTests/Chat/PendingMediaPersistenceTests.swift @@ -0,0 +1,281 @@ +// +// PendingMediaPersistenceTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +import UIKit +import FlipcashCore +import FlipcashStore +@testable import Flipcash + +@MainActor +@Suite("Pending photo persistence") +struct PendingMediaPersistenceTests { + + private let owner = try! PublicKey(Data(repeating: 7, count: 32)) + private let conversationID = ConversationID.test(1) + private let selfUserID = UUID() + + private func makeDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("pending-media-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func jpeg() -> Data { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: CGSize(width: 8, height: 6), format: format).jpegData(withCompressionQuality: 0.8) { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 8, height: 6)) + } + } + + private func blobID(_ name: String) -> BlobID { BlobID(data: Data(name.utf8)) } + + private func entry(stored: UploadedPhoto? = nil, caption: String? = nil, createdAt: Date = .now) -> PendingMediaStore.Entry { + .init(clientMessageID: UUID(), conversationID: conversationID, createdAt: createdAt, caption: caption, replyTo: MessageID(value: 9), stored: stored) + } + + private func makeController( + _ mock: MockConversations, + database: Database, + store: PendingMediaStore, + blob: MockChatMediaBlobStore + ) -> ConversationController { + let controller = ConversationController( + fetching: mock, membership: mock, viewerSettings: mock, messaging: mock, streaming: mock, + contactNaming: MockDMContactNaming(), + database: database, + owner: .generate()!, selfUserID: selfUserID, + typingHeartbeatInterval: .seconds(3), incomingTypingExpiry: .seconds(10) + ) + controller.pendingMedia = store + controller.restoredPhotoUploader = { _ in ChatMediaUploader(blob: blob) } + return controller + } + + private func pendingRows(_ controller: ConversationController) -> [ConversationMessage] { + controller.messages(for: conversationID).filter { $0.clientMessageID != nil && $0.id == .unassigned } + } + + private func yield(until condition: () -> Bool) async { + for _ in 0..<500 where !condition() { + await Task.yield() + } + } + + // MARK: - Store + + @Test("Entries, their photo and their stored state survive a new store over the same directory") + func roundTrips() throws { + let directory = try makeDirectory() + let store = PendingMediaStore(directory: directory, owner: owner) + let plain = entry(stored: .plain(blobID("a")), caption: "hi") + let sealed = entry(stored: .sealed(SealedPhoto(blobID: blobID("b"), mimeType: "image/jpeg", sizeBytes: 10, width: 8, height: 6, blurhash: "LEHV6n"))) + let bare = entry() + for item in [plain, sealed, bare] { + store.add(item) + store.writeImage(jpeg(), for: item.clientMessageID) + } + store.setStored(.plain(blobID("c")), for: bare.clientMessageID) + + let reloaded = PendingMediaStore(directory: directory, owner: owner) + + #expect(reloaded.entries.map(\.clientMessageID) == [plain, sealed, bare].map(\.clientMessageID)) + #expect(reloaded.entries[0].caption == "hi") + #expect(reloaded.entries[0].replyTo == MessageID(value: 9)) + #expect(reloaded.entries[0].chatID == conversationID) + #expect(reloaded.entries[1].stored == sealed.stored) + #expect(reloaded.entries[2].stored == .plain(blobID("c"))) + #expect(reloaded.imageData(for: reloaded.entries[0]) == store.imageData(for: plain)) + } + + @Test("Another owner's store does not see the entries") + func ownerScoped() throws { + let directory = try makeDirectory() + let store = PendingMediaStore(directory: directory, owner: owner) + store.add(entry()) + + let other = PendingMediaStore(directory: directory, owner: try PublicKey(Data(repeating: 8, count: 32))) + + #expect(other.entries.isEmpty) + } + + @Test("Removing an entry deletes its photo") + func removeDeletesFile() throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let item = entry() + store.add(item) + store.writeImage(jpeg(), for: item.clientMessageID) + + store.remove(clientMessageID: item.clientMessageID) + + #expect(store.entries.isEmpty) + #expect(store.imageData(for: item) == nil) + } + + @Test("Sweeping drops an entry with no photo and a photo with no entry") + func sweepsOrphans() throws { + let directory = try makeDirectory() + let store = PendingMediaStore(directory: directory, owner: owner) + let kept = entry() + let noFile = entry() + store.add(kept) + store.writeImage(jpeg(), for: kept.clientMessageID) + store.add(noFile) + let orphan = directory + .appendingPathComponent("flipcash-\(owner.base58)-pending-media") + .appendingPathComponent("\(UUID().uuidString).jpg") + try jpeg().write(to: orphan) + + store.sweepOrphans() + + #expect(store.entries.map(\.clientMessageID) == [kept.clientMessageID]) + #expect(!FileManager.default.fileExists(atPath: orphan.path)) + #expect(PendingMediaStore(directory: directory, owner: owner).entries.count == 1) + } + + @Test("A manifest that cannot be read is an empty store") + func unreadableManifestIsEmpty() throws { + let directory = try makeDirectory() + try Data("not json".utf8).write(to: directory.appendingPathComponent("flipcash-\(owner.base58)-pending-media.json")) + + #expect(PendingMediaStore(directory: directory, owner: owner).entries.isEmpty) + } + + // MARK: - Restore + + @Test("A send whose bytes never landed comes back as a failed, retryable row") + func unstoredEntryRestoresFailed() async throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let item = entry(caption: "hi") + store.add(item) + let bytes = jpeg() + store.writeImage(bytes, for: item.clientMessageID) + let mock = MockConversations() + let blob = MockChatMediaBlobStore() + let controller = makeController(mock, database: try Database.makeTemp().database, store: store, blob: blob) + + controller.restorePendingMedia() + + let rows = pendingRows(controller) + #expect(rows.map(\.status) == [.failed]) + #expect(rows.first?.clientMessageID == item.clientMessageID) + if case .media(_, let caption) = try #require(rows.first).content { + #expect(caption == "hi") + } else { + Issue.record("expected a media row") + } + #expect(rows.first?.repliedTo == MessageID(value: 9)) + #expect(controller.pendingMediaImage(forMessageID: try #require(rows.first?.stableID)) != nil) + #expect(blob.storeAttempts == 0) + + // Retrying uploads the held bytes as they are. + await controller.retry(clientMessageID: item.clientMessageID, in: conversationID) + + #expect(blob.storedData == [bytes]) + #expect(mock.sentMedia.count == 1) + #expect(mock.sentMedia.first?.clientMessageID == item.clientMessageID) + #expect(pendingRows(controller).isEmpty) + #expect(store.entries.isEmpty, "confirmed sends leave the store") + } + + @Test("A send whose bytes had landed resumes and posts without storing again") + func storedEntryResumes() async throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let item = entry(stored: .plain(blobID("landed"))) + store.add(item) + store.writeImage(jpeg(), for: item.clientMessageID) + let mock = MockConversations() + let blob = MockChatMediaBlobStore() + let controller = makeController(mock, database: try Database.makeTemp().database, store: store, blob: blob) + + controller.restorePendingMedia() + #expect(pendingRows(controller).map(\.status) == [.sending]) + await yield { !mock.sentMedia.isEmpty && store.entries.isEmpty } + + #expect(mock.sentMedia.map(\.blobID) == [blobID("landed")]) + #expect(blob.storeAttempts == 0) + #expect(blob.finalizedBlobIDs == [blobID("landed")]) + #expect(store.entries.isEmpty) + #expect(pendingRows(controller).isEmpty) + } + + @Test("An entry whose blob is already in a message this user sent is dropped; one whose blob is not is kept") + func reconcilesAgainstLoadedMessages() async throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let sent = entry(stored: .plain(blobID("sent"))) + let unsent = entry(stored: .plain(blobID("unsent"))) + for item in [sent, unsent] { + store.add(item) + store.writeImage(jpeg(), for: item.clientMessageID) + } + let database = try Database.makeTemp().database + let message = ConversationMessage( + id: MessageID(value: 5), + senderID: selfUserID, + content: .media([MediaAttachment(blobID: blobID("sent"), width: 8, height: 6, blurhash: nil)], caption: nil), + date: .now, + unreadSeq: 0, + repliedTo: nil, + status: .sent, + clientMessageID: nil + ) + try database.upsertConversationMessages([message], conversationID: conversationID) + let mock = MockConversations() + mock.mediaSendError = ErrorSendMessage.transportFailure + let controller = makeController(mock, database: database, store: store, blob: MockChatMediaBlobStore()) + + controller.restorePendingMedia() + await yield { pendingRows(controller).first?.status == .failed } + + #expect(store.entries.map(\.clientMessageID) == [unsent.clientMessageID]) + #expect(pendingRows(controller).map(\.clientMessageID) == [unsent.clientMessageID]) + #expect(mock.sentMedia.map(\.blobID) == [blobID("unsent")], "the already-sent photo is never posted again") + } + + @Test("A photo the server refuses for good leaves the store") + func rejectedUploadLeavesStore() async throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let item = entry(stored: .plain(blobID("bad"))) + store.add(item) + store.writeImage(jpeg(), for: item.clientMessageID) + let blob = MockChatMediaBlobStore() + blob.finalization = .failure(ErrorBlob.rejected(.moderation)) + let controller = makeController(MockConversations(), database: try Database.makeTemp().database, store: store, blob: blob) + + controller.restorePendingMedia() + await yield { store.entries.isEmpty } + + #expect(store.entries.isEmpty) + #expect(pendingRows(controller).map(\.status) == [.failed]) + } + + @Test("A photo sent through the controller is held until its post is confirmed") + func sendKeepsEntryUntilConfirmed() async throws { + let store = PendingMediaStore(directory: try makeDirectory(), owner: owner) + let mock = MockConversations() + mock.mediaSendError = ErrorSendMessage.transportFailure + let blob = MockChatMediaBlobStore() + let controller = makeController(mock, database: try Database.makeTemp().database, store: store, blob: blob) + let chip = ComposerChip(image: UIImage(data: jpeg())!) + chip.startUpload(using: ChatMediaUploader(blob: blob)) + + #expect(!(await controller.sendMedia([chip], caption: "hi", to: conversationID))) + + let held = try #require(store.entries.first) + #expect(held.caption == "hi") + #expect(held.stored == .plain(MockChatMediaBlobStore.blobID)) + #expect(store.imageData(for: held) == blob.storedData.first) + + mock.mediaSendError = nil + await controller.retry(clientMessageID: held.clientMessageID, in: conversationID) + + #expect(store.entries.isEmpty) + } +} diff --git a/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift b/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift index 92a8464b5..16f2deac8 100644 --- a/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift +++ b/FlipcashTests/TestSupport/MockChatMediaBlobStore.swift @@ -20,6 +20,8 @@ final class MockChatMediaBlobStore: ChatMediaBlobStoring { var storeResults: [Result] = [] var finalization: Result = .success(()) var onStore: (() -> Void)? + /// Reported through each store call's `onProgress` before it ends. + var progressReports: [BlobUploadProgress] = [] private(set) var storeAttempts = 0 private(set) var storedData: [Data] = [] @@ -38,8 +40,9 @@ final class MockChatMediaBlobStore: ChatMediaBlobStoring { policy } - func storeBlob(_ data: Data, mimeType: String) async throws -> BlobID { + func storeBlob(_ data: Data, mimeType: String, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> BlobID { storeAttempts += 1 + progressReports.forEach(onProgress) onStore?() let result = storeResults.isEmpty ? .success(Self.blobID) : storeResults.removeFirst() let blobID = try result.get() @@ -48,8 +51,9 @@ final class MockChatMediaBlobStore: ChatMediaBlobStoring { return blobID } - func storeEncryptedBlob(_ image: Data, seal: ChatSeal) async throws -> EncryptedBlobUpload { + func storeEncryptedBlob(_ image: Data, seal: ChatSeal, onProgress: @escaping @Sendable (BlobUploadProgress) -> Void) async throws -> EncryptedBlobUpload { storeAttempts += 1 + progressReports.forEach(onProgress) onStore?() let result = storeResults.isEmpty ? .success(Self.blobID) : storeResults.removeFirst() let blobID = try result.get() diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift index e4f301146..2a35e76e4 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatMediaCell.swift @@ -26,12 +26,16 @@ public final class ChatMediaCell: ChatColumnCell { private static let captionInset: CGFloat = 12 private static let captionPadding: CGFloat = 9 private static let fadeDuration: TimeInterval = 0.25 + /// The progress capsule's inset from the photo's bottom-right corner. + private static let progressInset: CGFloat = 10 private let stack = UIStackView() private let imageBubble = BubbleBackgroundView() let imageView = UIImageView() /// Shown over the BlurHash when an encrypted photo's bytes fail to decrypt or check out. let unavailableLabel = UILabel() + /// Shows how far an outgoing photo's send has got, until it is sent or fails. + let progressOverlay = ChatPhotoProgressOverlay() let captionBubble = BubbleBackgroundView() let captionLabel = UILabel() let reactionRow = ReactionPillRowView() @@ -75,6 +79,8 @@ public final class ChatMediaCell: ChatColumnCell { unavailableLabel.isHidden = true unavailableLabel.translatesAutoresizingMaskIntoConstraints = false imageBubble.addSubview(unavailableLabel) + progressOverlay.translatesAutoresizingMaskIntoConstraints = false + imageBubble.addSubview(progressOverlay) imageTap.addTarget(self, action: #selector(imageTapped)) imageBubble.addGestureRecognizer(imageTap) @@ -113,6 +119,11 @@ public final class ChatMediaCell: ChatColumnCell { imageView.trailingAnchor.constraint(equalTo: imageBubble.trailingAnchor), imageView.bottomAnchor.constraint(equalTo: imageBubble.bottomAnchor), + progressOverlay.widthAnchor.constraint(equalToConstant: ChatPhotoProgressOverlay.size.width), + progressOverlay.heightAnchor.constraint(equalToConstant: ChatPhotoProgressOverlay.size.height), + progressOverlay.trailingAnchor.constraint(equalTo: imageBubble.trailingAnchor, constant: -Self.progressInset), + progressOverlay.bottomAnchor.constraint(equalTo: imageBubble.bottomAnchor, constant: -Self.progressInset), + unavailableLabel.centerYAnchor.constraint(equalTo: imageBubble.centerYAnchor), unavailableLabel.leadingAnchor.constraint(equalTo: imageBubble.leadingAnchor, constant: Self.captionInset), unavailableLabel.trailingAnchor.constraint(equalTo: imageBubble.trailingAnchor, constant: -Self.captionInset), @@ -139,6 +150,7 @@ public final class ChatMediaCell: ChatColumnCell { imageView.kf.cancelDownloadTask() imageView.image = nil showUnavailable(false) + progressOverlay.bind(nil, suppressed: false, animated: false) drawnRowID = nil reactionRow.prepareForReuse() } @@ -152,11 +164,13 @@ public final class ChatMediaCell: ChatColumnCell { /// - Parameters: /// - maxWidth: the transcript's widest bubble, which the photo always spans. /// - localImage: the picked image of a pending send this device staged, or nil. + /// - progress: the send progress of a pending photo this device staged, or nil. /// - remote: where the photo downloads from, or nil until it is resolved. public func configure( with message: ChatMessage, maxWidth: CGFloat, localImage: UIImage?, + progress: ChatPhotoSendProgress? = nil, remote: ChatMediaLocation?, authorImageData: Data? = nil ) { @@ -171,6 +185,8 @@ public final class ChatMediaCell: ChatColumnCell { ) imageWidthConstraint.constant = size.width imageHeightConstraint.constant = size.height + // The same row losing its progress is a confirmed send, which fades; a new row starts clean. + progressOverlay.bind(progress, suppressed: message.isFailed, animated: drawnRowID == message.id) drawImage( for: message.id, blobID: media.blobID, diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoProgressOverlay.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoProgressOverlay.swift new file mode 100644 index 000000000..48d397816 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoProgressOverlay.swift @@ -0,0 +1,200 @@ +// +// ChatPhotoProgressOverlay.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import SwiftUI +import Observation + +/// A thin bar in the corner of an outgoing photo, after iMessage's send bar, that follows its +/// ``ChatPhotoSendProgress``. +/// +/// The bar fills with the bytes sent. Once they are stored, the server's processing and the message +/// send have no measurable progress, so a blue segment slides along the track instead — held still +/// in the middle under Reduce Motion. The bar fades out when the message is sent, and is gone at once on a failure, +/// where the row's failed receipt and tap-to-retry take over. +final class ChatPhotoProgressOverlay: UIView { + + static let size = CGSize(width: 64, height: 5) + private static let fadeDuration: TimeInterval = 0.25 + private static let fillDuration: TimeInterval = 0.2 + private static let segmentShare: CGFloat = 0.35 + private static let slideDuration: CFTimeInterval = 1.1 + private static let slideKey = "slide" + + private let track = UIView() + private let fill = UIView() + private let segment = UIView() + + private var progress: ChatPhotoSendProgress? + /// Set while the row is failed, which hides the bar whatever the progress says. + private var suppressed = false + /// Bumped on every bind, so an observation armed for an earlier row is dropped. + private var generation = 0 + + /// The share of the bar drawn filled. + private(set) var fraction: CGFloat = 0 + /// Whether the bar is showing, or fading in to show. + private(set) var isShowing = false + /// Whether the bar is in its indeterminate, post-upload state. + private(set) var isIndeterminate = false + + override init(frame: CGRect) { + super.init(frame: frame) + isUserInteractionEnabled = false + isAccessibilityElement = false + alpha = 0 + isHidden = true + + // The shadow keeps the bar legible over a white region of the photo. + layer.shadowColor = UIColor.black.cgColor + layer.shadowOpacity = 0.3 + layer.shadowRadius = 2 + layer.shadowOffset = .zero + + track.backgroundColor = UIColor(Color.backgroundMain).withAlphaComponent(0.35) + track.layer.cornerRadius = Self.size.height / 2 + track.clipsToBounds = true + addSubview(track) + + fill.backgroundColor = .systemBlue + track.addSubview(fill) + + segment.backgroundColor = .systemBlue + segment.layer.cornerRadius = Self.size.height / 2 + segment.isHidden = true + track.addSubview(segment) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + + override var intrinsicContentSize: CGSize { Self.size } + + override func layoutSubviews() { + super.layoutSubviews() + track.frame = bounds + layer.shadowPath = UIBezierPath(roundedRect: bounds, cornerRadius: bounds.height / 2).cgPath + fill.frame = CGRect(x: 0, y: 0, width: track.bounds.width * fraction, height: track.bounds.height) + let width = track.bounds.width * Self.segmentShare + segment.bounds = CGRect(x: 0, y: 0, width: width, height: track.bounds.height) + segment.center = CGPoint(x: track.bounds.midX, y: track.bounds.midY) + // A slide armed before the track had a width never started. + if isIndeterminate, segment.layer.animation(forKey: Self.slideKey) == nil { startSliding() } + } + + /// Follows `progress`, or shows nothing when it is nil. `animated` is false for a row the + /// overlay was not already drawing, so a recycled cell never fades another row's state. + func bind(_ progress: ChatPhotoSendProgress?, suppressed: Bool, animated: Bool) { + generation += 1 + self.progress = progress + self.suppressed = suppressed + render(animated: animated) + observe(generation: generation) + } + + private func observe(generation armed: Int) { + guard let progress else { return } + withObservationTracking { + _ = progress.phase + } onChange: { [weak self] in + // Fires before the new value is readable; render once it has landed. + Task { @MainActor [weak self] in + guard let self, self.generation == armed else { return } + self.render(animated: true) + self.observe(generation: armed) + } + } + } + + private func render(animated: Bool) { + let phase = suppressed ? nil : progress?.phase + let animated = animated && window != nil + + let target: (show: Bool, fraction: CGFloat, indeterminate: Bool) + switch phase { + case .preparing: + target = (true, 0, false) + case .uploading(let value): + target = (true, CGFloat(value), false) + case .processing, .sending: + target = (true, 1, true) + case .sent: + target = (false, 1, false) + case .failed, nil: + target = (false, fraction, false) + } + + if target.show { + setFraction(target.fraction, animated: animated) + setIndeterminate(target.indeterminate) + } + // A failure hands straight to the failed receipt. Anything else leaving fades: a send, or a + // row whose progress went away because its send confirmed. + setShowing(target.show, animated: animated && !suppressed && phase != .failed) + } + + private func setFraction(_ value: CGFloat, animated: Bool) { + guard value != fraction else { return } + fraction = value + guard animated, !UIAccessibility.isReduceMotionEnabled, !isHidden else { + setNeedsLayout() + return + } + UIView.animate(withDuration: Self.fillDuration, delay: 0, options: [.beginFromCurrentState, .curveEaseOut]) { + self.layoutIfNeeded() + } + setNeedsLayout() + } + + private func setIndeterminate(_ indeterminate: Bool) { + guard indeterminate != isIndeterminate else { return } + isIndeterminate = indeterminate + fill.isHidden = indeterminate + segment.isHidden = !indeterminate + if indeterminate { startSliding() } else { stopSliding() } + } + + private func startSliding() { + guard !UIAccessibility.isReduceMotionEnabled, track.bounds.width > 0 else { return } + let half = segment.bounds.width / 2 + let slide = CABasicAnimation(keyPath: "position.x") + slide.fromValue = -half + slide.toValue = track.bounds.width + half + slide.duration = Self.slideDuration + slide.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + slide.repeatCount = .infinity + slide.isRemovedOnCompletion = false + segment.layer.add(slide, forKey: Self.slideKey) + } + + private func stopSliding() { + segment.layer.removeAnimation(forKey: Self.slideKey) + } + + private func setShowing(_ show: Bool, animated: Bool) { + guard show != isShowing else { return } + isShowing = show + if show { + isHidden = false + layoutIfNeeded() + } + let apply = { self.alpha = show ? 1 : 0 } + guard animated else { + apply() + isHidden = !show + if !show { setIndeterminate(false) } + return + } + UIView.animate(withDuration: Self.fadeDuration, delay: 0, options: [.beginFromCurrentState], animations: apply) { _ in + guard !self.isShowing else { return } + self.isHidden = true + self.setIndeterminate(false) + } + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift new file mode 100644 index 000000000..a8ff05d43 --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatPhotoSendProgress.swift @@ -0,0 +1,88 @@ +// +// ChatPhotoSendProgress.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Observation +import FlipcashCore + +/// How far one outgoing photo has got, from encoding through the message that carries it. +/// +/// One per staged photo, so each bubble in a multi-photo send draws its own progress. +@MainActor +@Observable +public final class ChatPhotoSendProgress { + + public enum Phase: Equatable, Sendable { + /// Encoding, encrypting, or reserving the upload; no bytes have gone out yet. + case preparing + /// Sending bytes to storage, with the share of them sent. + case uploading(fraction: Double) + /// Stored, while the server checks the bytes before they can be served. + case processing + /// Posting the message that references the stored photo. + case sending + /// The message is confirmed. + case sent + /// The upload or the message failed; the transcript's retry affordance takes over. + case failed + } + + public private(set) var phase: Phase = .preparing + + public init() {} + + /// Whether the bubble draws a progress overlay for this phase. + public var showsOverlay: Bool { + switch phase { + case .preparing, .uploading, .processing, .sending: + true + case .sent, .failed: + false + } + } + + /// Starts an upload attempt over, including after a failure or a retried store. + public func beginAttempt() { + phase = .preparing + } + + /// Records bytes sent to storage. + /// + /// Byte counts arrive asynchronously from the network, so one landing after the upload has + /// moved on is ignored, and the bar never runs backwards within an attempt. + public func didUpload(_ progress: BlobUploadProgress) { + guard let fraction = progress.fraction else { return } + switch phase { + case .preparing: + phase = .uploading(fraction: fraction) + case .uploading(let current): + if fraction > current { phase = .uploading(fraction: fraction) } + case .processing, .sending, .sent, .failed: + break + } + } + + /// Records that the bytes are stored and the server is finalizing them. + public func beginProcessing() { + phase = .processing + } + + /// Records that the message referencing the photo is being posted. + public func beginSending() { + phase = .sending + } + + /// Records that the message is confirmed. + public func finish() { + phase = .sent + } + + /// Records that the upload or the message failed. + public func fail() { + phase = .failed + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index 6e44d29f5..ad5c7a0c1 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -230,6 +230,12 @@ public final class ChatScreenViewController: UIViewController { set { transcript.pendingMediaImage = newValue } } + /// A pending photo's send progress — see ``ChatViewController/pendingMediaProgress``. + public var pendingMediaProgress: ((String) -> ChatPhotoSendProgress?)? { + get { transcript.pendingMediaProgress } + set { transcript.pendingMediaProgress = newValue } + } + public var onContactAction: (() -> Void)? { get { transcript.onContactAction } set { transcript.onContactAction = newValue } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 2b87c5462..fb2a9ddfd 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -129,6 +129,10 @@ public final class ChatViewController: UICollectionViewController { /// it until the send confirms and the download takes over. public var pendingMediaImage: ((String) -> UIImage?)? + /// The send progress of a photo this device is still sending, by the pending row's id; the row + /// draws it over the photo until the send confirms or fails. + public var pendingMediaProgress: ((String) -> ChatPhotoSendProgress?)? + /// The widest a bubble may grow, as a share of the collection view's width. private static let maxBubbleWidthFraction: CGFloat = 0.78 @@ -704,6 +708,7 @@ public final class ChatViewController: UICollectionViewController { with: message, maxWidth: maxWidth, localImage: localImage, + progress: pendingMediaProgress?(message.id), remote: remote, authorImageData: authorImageData ) From c3d96c730280e40f3b9e463ce86593809abc4548 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sun, 4 Oct 2026 12:02:50 -0400 Subject: [PATCH 7/8] feat(chat): one-row glass composer with $ beside it The composer is a single row: +, the message field and send, in glass tinted 3% white with a lit rim and 28pt corners. A round $ sits left of the field and splits from the field's glass on the draft's first character, rejoining it when the draft is cleared. Send pops in on a draft and is gone without one. An empty field takes focus from a tap anywhere right of +. Text that wraps or holds a newline stacks the field: the text takes the full width and + and send drop to a row below. It stays stacked until the draft is cleared, so editing near the wrap point does not flip the layout. The caret and text spring into place together rather than the caret trailing on UIKit's own animation. The bar now reports its height with `onGeometryChange`. The `GeometryReader` report delivered only the height an animated stack started from, so the bar's clip stayed one row tall and cut off the top line. When the composer shrinks, the clip eases down on the stack spring while the transcript moves in one frame, as #908 does. The + menu holds camera and photos. It opens from the $'s margin at 220pt, its rows sharpening out of a blur as it grows from +, and springs back into + without a colour jump. The composer stays visible under the camera and photo cards, and a capture or pick collapses into its chip with the photo drawn throughout. --- .claude/plans/2026-10-04-two-row-composer.md | 20 + .../Screens/Conversation/AttachCard.swift | 6 +- .../Screens/Conversation/AttachMenu.swift | 12 +- .../Conversation/AttachOverKeyboard.swift | 16 +- .../Screens/Conversation/AttachPanel.swift | 6 +- .../Screens/Conversation/AttachSurface.swift | 91 +++- .../Conversation/ChatCameraSheet.swift | 4 +- .../Screens/Conversation/ChatPhotosCard.swift | 4 +- .../ChatScreenRepresentable.swift | 20 +- .../Conversation/ConversationBottomBar.swift | 465 ++++++++++++++---- .../Chat/AttachOverlayModeTests.swift | 8 +- .../Chat/AttachPanelPlacementTests.swift | 29 +- FlipcashTests/Chat/AttachPanelTests.swift | 35 +- .../Chat/ChatScreenBarClipTests.swift | 27 + .../Chat/ComposerStackingTests.swift | 44 ++ .../ConversationBarLeadingControlTests.swift | 89 ++-- .../FlipcashUI/Chat/BarAccessories.swift | 2 +- .../Chat/ChatScreenViewController.swift | 31 ++ .../Views/Containers/GlassBackground.swift | 42 ++ 19 files changed, 773 insertions(+), 178 deletions(-) create mode 100644 .claude/plans/2026-10-04-two-row-composer.md create mode 100644 FlipcashTests/Chat/ComposerStackingTests.swift diff --git a/.claude/plans/2026-10-04-two-row-composer.md b/.claude/plans/2026-10-04-two-row-composer.md new file mode 100644 index 000000000..0163c95c1 --- /dev/null +++ b/.claude/plans/2026-10-04-two-row-composer.md @@ -0,0 +1,20 @@ +# Two-row composer (E2) + +The composer field becomes two rows inside one glass field. + +- Top row: chip strip, then the message text, full width. +- Bottom row: 34pt `+` circle, grey "Send $" capsule, Spacer, white circular send button. +- Field padding 8 all round; the text row is inset 6 more on the leading side. + +## Decisions + +- `ConversationBarLeadingControl` keeps only what stands outside the field: `.cancelEdit`, `.sendCash` (pre-chat CTA), `.none`. +- New `ConversationBarBottomRow` (plus items, pill flag). Empty while editing and before the chat exists. Cash never appears in its menu: the pill replaces it. +- No bottom row (no `+`, no pill, or editing): single-row field with send on the text row. +- `AttachMenu` is restyled to the 34pt circle and rendered by `ConversationComposer`; panel toggle, frame reporting, stand-in and accessibility are unchanged. +- The attach surface stays an overlay on the bar's row; its collapsed radius is `plus.height / 2`. +- `BarMetrics.contentHeight` still means the single-row field. `BarMetrics.twoRowContentHeight` (84) is the two-row field. + +## Tests + +Bottom-row decision, Cash dropped from the menu, collapsed radius, bar height, and `AttachPanelPlacementTests` retargeted to `+`'s new column. diff --git a/Flipcash/Core/Screens/Conversation/AttachCard.swift b/Flipcash/Core/Screens/Conversation/AttachCard.swift index 3ccfec088..4e32be06b 100644 --- a/Flipcash/Core/Screens/Conversation/AttachCard.swift +++ b/Flipcash/Core/Screens/Conversation/AttachCard.swift @@ -55,6 +55,8 @@ final class AttachCard { /// That chip's frame in window coordinates, once the bar has laid it out. private(set) var landingChipFrame: CGRect? + /// The photo the card is shrinking into the chip, drawn by the surface for the whole landing. + private(set) var landingImage: UIImage? /// Whether the keyboard coming up takes the card down. Off while the card is drawn over the /// keyboard, where the keyboard never left, and an input view swap announces it again. @@ -105,9 +107,10 @@ final class AttachCard { /// Marks `chipID` as the chip the card will shrink onto, keeping the card up until the bar has /// laid that chip out. - func beginLanding(on chipID: ComposerChip.ID) { + func beginLanding(on chipID: ComposerChip.ID, image: UIImage? = nil) { landingChipID = chipID landingChipFrame = nil + landingImage = image } /// Records where the landing chip was laid out. Returns whether the card is still up and can now @@ -127,6 +130,7 @@ final class AttachCard { withTransaction(transaction) { landingChipID = nil landingChipFrame = nil + landingImage = nil } } diff --git a/Flipcash/Core/Screens/Conversation/AttachMenu.swift b/Flipcash/Core/Screens/Conversation/AttachMenu.swift index 2afa5bfe1..a39811645 100644 --- a/Flipcash/Core/Screens/Conversation/AttachMenu.swift +++ b/Flipcash/Core/Screens/Conversation/AttachMenu.swift @@ -35,8 +35,7 @@ enum AttachMenuItem: Equatable { } } -/// The composer's leading `+` control, in the slot Send Cash takes before the chat exists. Opens the -/// attach surface — the floating glass menu of Cash, Camera, and Photos, which grows out of `+` over +/// The `+` circle in the composer field's bottom row. Opens the attach surface — the floating glass menu of Cash, Camera, and Photos, which grows out of `+` over /// the field and collapses back into it — and reports where it is laid out, for the surface to grow /// from. The surface itself is ``AttachSurface``, drawn by the bar or over the keyboard. struct AttachMenu: View { @@ -61,14 +60,13 @@ struct AttachMenu: View { panel.animate { $0.toggle() } } label: { Image(systemName: SystemSymbol.plus.rawValue) - .font(.default(size: 20, weight: .semibold)) + .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.textMain) - .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) - .contentShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + .background(AttachSurfaceLayout.plusFill, in: Circle()) + .contentShape(Circle()) } .buttonStyle(.plain) - .glassBackground(cornerRadius: BarMetrics.cornerRadius) - .clipShape(RoundedRectangle(cornerRadius: BarMetrics.cornerRadius)) .accessibilityLabel("Attach") .accessibilityValue(panel.isOpen ? "Expanded" : "Collapsed") .accessibilityIdentifier("attach-menu-button") diff --git a/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift index 3598fa809..2acf7fd9d 100644 --- a/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift +++ b/Flipcash/Core/Screens/Conversation/AttachOverKeyboard.swift @@ -80,6 +80,10 @@ final class AttachOverKeyboard { /// `+`'s frame in window coordinates, as last laid out. var plusFrame: CGRect = .zero + /// How far left of the composer field the bar's row starts: `$`'s width and gap while it shows, + /// else zero. The menu opens out to there, so it covers the row it opens from. + var menuLeadingReach: CGFloat = 0 + /// Whether the photo card opens the full library as it next mounts in the bar. Set when All /// Photos hands the card over to the keyboard-down flow, whose sheet the keyboard would cover. var opensLibraryInBar = false @@ -101,13 +105,21 @@ nonisolated enum AttachOverlayLayout { /// The margin between a card and the screen's sides and bottom: the bar's keyboard-up edge inset. static let cardInset: CGFloat = 12 - /// The panel's frame for a panel `size` big: its leading edge on `+`'s, and centred on `+`'s + /// The panel's frame for a panel `size` big: its leading edge on the composer field's, which is + /// the field's padding out from `+`'s, and centred on `+`'s /// bottom, the composer's bottom edge. It straddles that edge, as ChatGPT's does, so its lower /// half lies over the keys that blur through it and its upper half over the field. static func panelFrame(plusFrame: CGRect, size: CGSize) -> CGRect { - CGRect(x: plusFrame.minX, y: plusFrame.maxY - size.height / 2, width: size.width, height: size.height) + // Centred on the field's bottom edge, a short menu tops out just under the field's top and + // leaves its corner showing; lift it to clear the top by `fieldTopOverhang`. + let fieldTop = plusFrame.maxY + BarMetrics.fieldPadding - BarMetrics.contentHeight + let y = min(plusFrame.maxY - size.height / 2, fieldTop - fieldTopOverhang) + return CGRect(x: plusFrame.minX - BarMetrics.fieldPadding, y: y, width: size.width, height: size.height) } + /// How far the open menu reaches above the composer field's top edge. + static let fieldTopOverhang: CGFloat = 8 + /// The point in a panel `size` big that `+`'s centre sits on, for the panel to grow out of and /// collapse back into. static func panelAnchor(plusFrame: CGRect, size: CGSize) -> UnitPoint { diff --git a/Flipcash/Core/Screens/Conversation/AttachPanel.swift b/Flipcash/Core/Screens/Conversation/AttachPanel.swift index 369aca1bc..9fd0c4281 100644 --- a/Flipcash/Core/Screens/Conversation/AttachPanel.swift +++ b/Flipcash/Core/Screens/Conversation/AttachPanel.swift @@ -48,9 +48,11 @@ final class AttachPanel { } /// Runs `change` on the panel's spring, and releases the room above the bar once the motion it - /// started has logically finished. + /// started has finished: logically for an open, fully settled for a close, so the surface is not + /// taken down before it has reached `+`. func animate(_ change: (AttachPanel) -> Void) { - withAnimation(ChatMotion.attachPanel.animation, completionCriteria: .logicallyComplete) { + let closes = isOpen + withAnimation(ChatMotion.attachPanel.animation, completionCriteria: closes ? .removed : .logicallyComplete) { change(self) } completion: { [weak self] in self?.exitDidFinish() diff --git a/Flipcash/Core/Screens/Conversation/AttachSurface.swift b/Flipcash/Core/Screens/Conversation/AttachSurface.swift index 9862f055f..06f1580e3 100644 --- a/Flipcash/Core/Screens/Conversation/AttachSurface.swift +++ b/Flipcash/Core/Screens/Conversation/AttachSurface.swift @@ -67,28 +67,41 @@ nonisolated struct AttachSurfaceShape: Equatable { /// Where the attach surface stands in each phase, in its host's coordinates. enum AttachSurfaceLayout { - /// `+`'s radius, which the surface grows out of and collapses back into. - static let plusCornerRadius: CGFloat = BarMetrics.cornerRadius - static let menuCornerRadius: CGFloat = 40 + /// Matches the composer field's corner, so the menu reads as part of it. + static let menuCornerRadius: CGFloat = BarMetrics.fieldCornerRadius + /// `+`'s flat fill on the composer field, which the surface turns into as it reaches `+`. + static let plusFill = Color.white.opacity(0.10) + /// How far past `+`'s size the surface has fully turned from `+`'s fill into the panel's glass. + static let plusBlendDistance: CGFloat = 40 static let cardCornerRadius: CGFloat = 24 /// A staged chip's radius, which a landing ends on. static let chipCornerRadius: CGFloat = 10 - /// The menu's width: enough to cover the field from `+`, short of the send button on a 375pt screen. - static let menuWidth: CGFloat = 280 + /// The menu's width: room for an icon and a short label, well short of the send button. + static let menuWidth: CGFloat = 220 /// A menu row's height, sized to the system's own attachment menus. static let menuRowHeight: CGFloat = 62 static let menuVerticalPadding: CGFloat = 10 + /// The rows' blur and scale while hidden, which they sharpen and grow out of as the menu opens. + static let rowsBloomBlur: CGFloat = 8 + static let rowsBloomScale: CGFloat = 0.85 /// The menu's size before its rows are measured. static func estimatedMenuSize(rowCount: Int) -> CGSize { CGSize(width: menuWidth, height: CGFloat(rowCount) * menuRowHeight + menuVerticalPadding * 2) } - /// The menu's frame for a menu `size` big, placed against `plus` by `placement`. - static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect { + /// The menu's frame for a menu `size` big, placed against `plus` by `placement`. Either way its + /// leading edge is the composer field's, ``BarMetrics/fieldPadding`` out from `+`'s, moved out a + /// further `leadingReach` at the same width; standing on `+`, its bottom edge is the field's too. + static func menuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement, leadingReach: CGFloat = 0) -> CGRect { + let rect = fieldAlignedMenuRect(plus: plus, size: size, placement: placement) + return CGRect(x: rect.minX - leadingReach, y: rect.minY, width: rect.width, height: rect.height) + } + + private static func fieldAlignedMenuRect(plus: CGRect, size: CGSize, placement: AttachMenuPlacement) -> CGRect { switch placement { case .standsOnPlus: - CGRect(x: plus.minX, y: plus.maxY - size.height, width: size.width, height: size.height) + CGRect(x: plus.minX - BarMetrics.fieldPadding, y: plus.maxY + BarMetrics.fieldPadding - size.height, width: size.width, height: size.height) case .straddlesPlus: AttachOverlayLayout.panelFrame(plusFrame: plus, size: size) } @@ -100,11 +113,16 @@ enum AttachSurfaceLayout { CGRect(x: -outset, y: row.height - height, width: row.width + outset * 2, height: height) } + /// Returns the radius of the surface collapsed onto `plus`, which is a circle: half its height. + static func collapsedCornerRadius(plus: CGRect) -> CGFloat { + plus.height / 2 + } + /// The surface's shape in `phase`. A landing whose chip hasn't been laid out yet holds the card's. static func shape(for phase: AttachSurfacePhase, plus: CGRect, menu: CGRect, card: CGRect, landing: CGRect?) -> AttachSurfaceShape { switch phase { case .collapsed: - AttachSurfaceShape(rect: plus, cornerRadius: plusCornerRadius) + AttachSurfaceShape(rect: plus, cornerRadius: collapsedCornerRadius(plus: plus)) case .menu: AttachSurfaceShape(rect: menu, cornerRadius: menuCornerRadius) case .card: @@ -162,13 +180,14 @@ struct AttachSurface: View { let menu = AttachSurfaceLayout.menuRect( plus: plus, size: menuSize ?? AttachSurfaceLayout.estimatedMenuSize(rowCount: items.count), - placement: menuPlacement + placement: menuPlacement, + leadingReach: model.overKeyboard.menuLeadingReach ) let shape = AttachSurfaceLayout.shape(for: appeared ? phase : .collapsed, plus: plus, menu: menu, card: card, landing: landing) // Morphing, the surface is `+` at either end and stays opaque; a cross-fade fades it instead. let isVisible = motion.animatesGeometry || (appeared && phase != .collapsed) let showsPlusGlyph = !appeared || phase == .collapsed - AttachSurfaceFrame(shape: shape) { rect in + AttachSurfaceFrame(shape: shape, plus: plus, blendsIntoPlus: phase != .landing) { rect in ZStack(alignment: .topLeading) { plusGlyph .offset(x: plus.minX - rect.minX, y: plus.minY - rect.minY) @@ -176,7 +195,19 @@ struct AttachSurface: View { cardLayer(phase: phase) .frame(width: card.width, height: card.height) .offset(x: card.minX - rect.minX, y: card.minY - rect.minY) - rows(phase: phase) + // From the hand-off on, filling the surface at every step: the viewfinder holds the + // capture, and it collapses into the chip rather than an empty surface flying there. + if let image = model.attachCard.landingImage { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: rect.width, height: rect.height) + .clipped() + .allowsHitTesting(false) + .accessibilityHidden(true) + .transition(.identity) + } + rows(phase: phase, menu: menu, motion: motion) .frame(width: menu.width, height: menu.height, alignment: .topLeading) .offset(x: menu.minX - rect.minX, y: menu.minY - rect.minY) } @@ -207,21 +238,33 @@ struct AttachSurface: View { /// `+`'s glyph where `+` stands, so the surface reads as `+` itself as it grows and collapses. private var plusGlyph: some View { Image(systemName: SystemSymbol.plus.rawValue) - .font(.default(size: 20, weight: .semibold)) + .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.textMain) .frame(width: plus.width, height: plus.height) .accessibilityHidden(true) } - private func rows(phase: AttachSurfacePhase) -> some View { + /// The rows sharpen out of a blur and grow from `+` as the surface opens, and go back into it as + /// it closes, so they read as poured out of `+` rather than faded over it. + private func rows(phase: AttachSurfacePhase, menu: CGRect, motion: AttachMotion) -> some View { let panel = model.attachPanel + let shown = phase.showsRows + let blooms = motion.animatesGeometry + let anchor = UnitPoint( + x: menu.width > 0 ? (plus.midX - menu.minX) / menu.width : 0, + y: menu.height > 0 ? (plus.midY - menu.minY) / menu.height : 1 + ) return AttachMenuRows(items: items, onSelect: { item in panel.select(item, warmUp: model.attachWarmUp, onCash: actions.onCash, onCamera: actions.onCamera, onPhotos: actions.onPhotos) }) { panel.animate { $0.dismiss() } } .onGeometryChange(for: CGSize.self, of: { $0.size }) { menuSize = $0 } - .attachLayer(isShown: phase.showsRows) + .animation(ChatMotion.attachPanel.animation) { + $0.blur(radius: shown || !blooms ? 0 : AttachSurfaceLayout.rowsBloomBlur) + .scaleEffect(shown || !blooms ? 1 : AttachSurfaceLayout.rowsBloomScale, anchor: anchor) + } + .attachLayer(isShown: shown) } /// The camera and the photo picker, each mounted from the surface's first frame at the card's @@ -271,6 +314,10 @@ private extension View { private struct AttachSurfaceFrame: View, Animatable { var shape: AttachSurfaceShape + /// `+`'s frame, which the surface takes on the look of as it shrinks to `+`'s size. + let plus: CGRect + /// Whether the surface turns into `+`'s fill near `+`'s size; not while it lands on a chip. + let blendsIntoPlus: Bool let content: (CGRect) -> Content var animatableData: AnimatablePair { @@ -284,10 +331,16 @@ private struct AttachSurfaceFrame: View, Animatable { var body: some View { let rect = shape.rect let clip = RoundedRectangle(cornerRadius: shape.cornerRadius, style: .continuous) + let glass = glassAmount(at: rect) ZStack(alignment: .topLeading) { + clip + .fill(AttachSurfaceLayout.plusFill) + .frame(width: rect.width, height: rect.height) + .opacity(1 - glass) Color.clear .frame(width: rect.width, height: rect.height) .panelGlassBackground(cornerRadius: shape.cornerRadius) + .opacity(glass) content(rect) } .frame(width: rect.width, height: rect.height, alignment: .topLeading) @@ -295,4 +348,12 @@ private struct AttachSurfaceFrame: View, Animatable { .contentShape(clip) .offset(x: rect.minX, y: rect.minY) } + + /// How much of the panel's glass shows at `rect`, against `+`'s flat fill: none at `+`'s size, + /// all of it once the surface is a few dozen points bigger, so the two meet without a colour jump. + private func glassAmount(at rect: CGRect) -> CGFloat { + guard blendsIntoPlus else { return 1 } + let growth = max(rect.width - plus.width, rect.height - plus.height) + return min(max(growth / AttachSurfaceLayout.plusBlendDistance, 0), 1) + } } diff --git a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift index 95a4e1124..617515449 100644 --- a/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift +++ b/Flipcash/Core/Screens/Conversation/ChatCameraSheet.swift @@ -241,7 +241,7 @@ struct ChatCameraSheet: View { } } -/// A round control over the viewfinder: a white glyph on a dimmed disc. +/// A round control over the viewfinder: a white glyph on dark-tinted glass. private struct ChatCameraControlLabel: View { let systemImage: String @@ -251,7 +251,7 @@ private struct ChatCameraControlLabel: View { .font(.default(size: 17, weight: .semibold)) .foregroundStyle(Color.white) .frame(width: 44, height: 44) - .background(Circle().fill(Color.black.opacity(0.35))) + .overlayGlassBackground(in: Circle()) .contentShape(Circle()) } } diff --git a/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift index 3f14e2245..7b79c123c 100644 --- a/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift +++ b/Flipcash/Core/Screens/Conversation/ChatPhotosCard.swift @@ -131,7 +131,7 @@ struct ChatPhotosCard: View { .contentShape(Circle()) } .buttonStyle(.plain) - .glassBackground(cornerRadius: 22) + .overlayGlassBackground(in: Circle()) .clipShape(Circle()) .accessibilityLabel("Back to attach menu") .accessibilityIdentifier("attach-photos-back") @@ -155,7 +155,7 @@ struct ChatPhotosCard: View { .contentShape(Capsule()) } .buttonStyle(.plain) - .glassBackground(cornerRadius: 22) + .overlayGlassBackground(in: Capsule()) .clipShape(Capsule()) .accessibilityIdentifier("attach-photos-all") .transition(.opacity) diff --git a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift index 30ee0d332..d040c30c8 100644 --- a/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift +++ b/Flipcash/Core/Screens/Conversation/ChatScreenRepresentable.swift @@ -429,7 +429,8 @@ struct ChatScreenRepresentable: UIViewControllerRepresentable { screen?.focusComposer() return } - card.beginLanding(on: chipID) + let chip = composer.chips.first { $0.id == chipID } + card.beginLanding(on: chipID, image: chip.map { $0.preview ?? $0.image }) screen?.focusComposer() // A chip the bar never lays out leaves nothing to land on, so the card goes anyway. DispatchQueue.main.asyncAfter(deadline: .now() + ChatMotion.attachCard.duration) { @@ -473,13 +474,14 @@ private struct MeasuredBarHeight: ViewModifier { content .fixedSize(horizontal: false, vertical: true) .overlayPreferenceValue(BarAccessoriesKey.self) { accessories in - GeometryReader { proxy in - Color.clear.onChange( - of: BarReport(height: proxy.size.height, accessories: accessories), - initial: true - ) { _, new in - report(new.height, new.accessories) - } + // `onGeometryChange`, not a `GeometryReader`: under an animated layout change the + // reader reports the height the animation starts from and then goes quiet, which + // leaves the clip short of the content it settles at. + Color.clear.onGeometryChange( + for: BarReport.self, + of: { BarReport(height: $0.size.height, accessories: accessories) } + ) { new in + report(new.height, new.accessories) } } // Sit on the host's bottom edge rather than in the middle of it. The two heights are @@ -493,7 +495,7 @@ private struct MeasuredBarHeight: ViewModifier { } /// One measurement of the bar: its height and the cards that make it up. -private struct BarReport: Equatable { +private nonisolated struct BarReport: Equatable, Sendable { let height: CGFloat let accessories: BarAccessories } diff --git a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift index 6a75e2bcb..88e09ed97 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationBottomBar.swift @@ -155,12 +155,19 @@ private let replySpring = ChatMotion.replySurface.animation /// heights and corners can't desync. Deliberately not `Metrics.buttonHeight`/`buttonRadius` — beside /// the field the controls are field-sized, not standard-button-sized. enum BarMetrics { - static let fieldMinHeight: CGFloat = 34 - static let fieldVerticalPadding: CGFloat = 8 + nonisolated static let fieldMinHeight: CGFloat = 34 + nonisolated static let fieldVerticalPadding: CGFloat = 8 + /// The field's padding on every side: where `+` stands in from the field's leading edge, and so + /// how far the open attach menu reaches back past `+` to line up with the field. + nonisolated static let fieldPadding: CGFloat = 8 static let cornerRadius: CGFloat = 14 + /// The composer field's corner, rounder than the bar's other controls. + nonisolated static let fieldCornerRadius: CGFloat = 28 /// The height of every bar control: a single-line field plus its padding, and the height the /// Send Cash button morphs at while there is a composer beside it. - static let contentHeight: CGFloat = fieldMinHeight + fieldVerticalPadding * 2 + nonisolated static let contentHeight: CGFloat = fieldMinHeight + fieldVerticalPadding * 2 + /// The diameter of the controls beside the text: `+`, the Send Cash button and the send button. + nonisolated static let accessorySize: CGFloat = 34 /// The bar's own margin around its controls, above and below. static let contentPadding: CGFloat = 8 /// The margin between the bar's controls and the screen's sides while the keyboard is up. @@ -171,35 +178,57 @@ enum BarMetrics { static let compactInset: CGFloat = 32 } -/// What stands in the bar's leading slot, beside the message field. +/// What stands outside the message field, beside it. enum ConversationBarLeadingControl: Equatable { /// The way out of an edit. case cancelEdit /// The full-width Send Cash call to action, alone in the bar before the chat exists. case sendCash - /// The `+` menu with these rows. - case attach([AttachMenuItem]) - /// Nothing: the chat takes neither cash nor media from this user. + /// The round `$` beside the field once the chat exists, shown while the draft is empty. + case cash + /// Nothing beside the field. case none - /// Returns the control for the bar's state. An edit wins; before the chat exists the only - /// control is the Send Cash call to action; after, it is the attach menu, when it has a row. - init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool, acceptsMedia: Bool, attachedCount: Int) { + /// Returns the control for the bar's state. An edit wins; otherwise Send Cash, as the call to + /// action before the chat exists and as the round `$` after. + init(isEditing: Bool, chatExists: Bool, showsSendCash: Bool) { if isEditing { self = .cancelEdit - } else if !chatExists { - self = showsSendCash ? .sendCash : .none + } else if !showsSendCash { + self = .none } else { - let items = AttachMenuItem.items( - showsCash: showsSendCash, - acceptsMedia: acceptsMedia, - attachedCount: attachedCount - ) - self = items.isEmpty ? .none : .attach(items) + self = chatExists ? .cash : .sendCash } } } +/// What the field's row holds beside the send button: the `+` menu. +struct ConversationBarBottomRow: Equatable { + /// The rows of the `+` menu; empty hides `+`. Never holds Cash, which the `$` beside the field + /// takes over. + let plusItems: [AttachMenuItem] + + /// A row with nothing in it. + static let empty = ConversationBarBottomRow(plusItems: []) + + /// Whether there is nothing for `+` to open. + var isEmpty: Bool { plusItems.isEmpty } + + /// Returns the row for the bar's state: empty during an edit and before the chat exists; + /// otherwise `+` while it has a row to open. + init(isEditing: Bool, chatExists: Bool, acceptsMedia: Bool, attachedCount: Int) { + guard !isEditing, chatExists else { + self.init(plusItems: []) + return + } + self.init(plusItems: AttachMenuItem.items(showsCash: false, acceptsMedia: acceptsMedia, attachedCount: attachedCount)) + } + + private init(plusItems: [AttachMenuItem]) { + self.plusItems = plusItems + } +} + /// The unified bottom bar: the attach menu beside the message field. /// A standard-size filled Send Cash alone until the chat exists server-side. struct ConversationBottomBar: View { @@ -241,6 +270,20 @@ struct ConversationBottomBar: View { /// The composer row's measured height, reported as part of what the mention list's room is /// measured without. @State private var composerRowHeight: CGFloat = 0 + /// Whether the round `$` shows: while the draft is empty. Set in a transaction after the text + /// update, so the split animates without carrying the field's text change with it. + @State private var cashIsShown = true + + /// How far left of the field the row starts, for the attach menu to open out to. + private var menuLeadingReach: CGFloat { + switch leadingControl { + case .cash: + cashIsShown ? BarMetrics.contentHeight + Self.leadingSpacing : 0 + case .cancelEdit, .sendCash, .none: + 0 + } + } + @Namespace private var composerGlassNamespace /// Whether the chat takes photos; false for an E2EE DM, whose encryption does not cover media. var acceptsMedia: Bool = false /// Fired as `+` opens the attach panel with the given rows: the panel goes up over the keyboard, @@ -264,6 +307,11 @@ struct ConversationBottomBar: View { /// The curve the bar narrows and widens on as the keyboard goes and comes. private static let widthSpring = Animation.spring(duration: 0.22, bounce: 0.14) + /// `$` splitting from the field's glass and joining back into it. + private static let cashSpring = Animation.spring(duration: 0.4, bounce: 0.3) + /// The gap between the leading control and the field. The composer's glass joins across no + /// more than this, so `$` stays bridged to the field while it travels and pinches off at rest. + static let leadingSpacing: CGFloat = 10 /// Whether the bar sits inset from the screen's sides: at rest with the keyboard down. It widens /// to the full edge inset as the keyboard comes up. Only once there is a composer; the pre-chat @@ -299,6 +347,37 @@ struct ConversationBottomBar: View { } } + /// The glass behind the leading control and the field, drawn as one layer apart from them so + /// `$` splits from the field and joins back into it. The real controls sit above it, outside the + /// container: in one, the glass composites above sibling content and covers the typed text. + @ViewBuilder + private var composerGlass: some View { + let field = RoundedRectangle(cornerRadius: BarMetrics.fieldCornerRadius, style: .continuous) + let layout = HStack(alignment: .bottom, spacing: Self.leadingSpacing) { + switch leadingControl { + case .cancelEdit: + // The button's size, so the field's glass starts where the field does. + Color.clear.frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + case .cash: + if cashIsShown { + Color.clear + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + .composerGlass(in: Circle(), id: "cash", namespace: composerGlassNamespace) + } + case .sendCash, .none: + EmptyView() + } + Color.clear + .frame(maxWidth: .infinity, maxHeight: .infinity) + .composerGlass(in: field, id: "field", namespace: composerGlassNamespace) + } + if #available(iOS 26, *) { + GlassEffectContainer(spacing: Self.leadingSpacing) { layout } + } else { + layout + } + } + private var composerBar: some View { // Bottom-aligned, against the bar's own pinned bottom: the field is the side that grows, and // top-aligning the control beside it made the control travel with every line the draft @@ -307,13 +386,12 @@ struct ConversationBottomBar: View { // sprang underneath it. let motion = AttachMotion(reduceMotion: reduceMotion) let content = VStack(alignment: .leading, spacing: Self.rowSpacing) { - HStack(alignment: .bottom, spacing: 10) { + HStack(alignment: .bottom, spacing: Self.leadingSpacing) { // An edit takes over the bar: the leading control becomes the way out of it and the - // attach menu steps aside until it resolves, the way WhatsApp hides its accessory controls. + // field's bottom row steps aside until it resolves, the way WhatsApp hides its accessory controls. switch leadingControl { case .cancelEdit: CancelEditButton { composer.endEditing() } - .hiddenUnderAttachCard(showsCard) case .sendCash: SendCashMorphButton( symbol: symbol, @@ -330,38 +408,54 @@ struct ConversationBottomBar: View { expandedTitle: isTipDm ? startChattingTitle : nil, action: onSendCash ) - .hiddenUnderAttachCard(showsCard) - case .attach(let items): - AttachMenu( - items: items, - panel: model.attachPanel, - hidesButton: showsCard, + case .cash: + if cashIsShown { + ComposerCashButton(symbol: symbol, action: onSendCash) + .transition(.scale(scale: 0.4).combined(with: .opacity)) + } + case .none: + EmptyView() + } + if chatExists { + ConversationComposer( + conversationID: conversationID, + model: model, + composer: composer, + bottomRow: bottomRow, + hidesPlus: showsCard, // Swapped out in one frame for the surface, which is drawn as `+` where it stands. - isStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, - onOpen: onAttachOpen, - onPlusFrame: { model.overKeyboard.plusFrame = $0 } + plusIsStoodInFor: model.surfaceStandsInForPlus && motion.animatesGeometry, + onAttachOpen: onAttachOpen ) - // Over the field beside it, which its panel floats across. + // Over the row's other controls, which the attach panel floats across. .zIndex(1) - case .none: - EmptyView() + .transition(.opacity) } + } + .background { if chatExists { - ConversationComposer(conversationID: conversationID, model: model, composer: composer) - .hiddenUnderAttachCard(showsCard) - .transition(.opacity) + composerGlass } } + // A draft already there when the chat opens hides `$` without animating it out. + .onAppear { cashIsShown = composer.draft.isEmpty } + .onChange(of: composer.draft.isEmpty) { _, isEmpty in + guard cashIsShown != isEmpty else { return } + withAnimation(Self.cashSpring) { cashIsShown = isEmpty } + } + .onChange(of: menuLeadingReach, initial: true) { _, reach in + model.overKeyboard.menuLeadingReach = reach + } // Kept mounted under the card, so `+` is there for the surface to shrink back into and the - // field is there to take focus the moment the card closes. Each control fades on its own - // rather than the row, which holds the surface. An open panel lets a touch on the row — + // field is there to take focus the moment the card closes. Left visible, not faded: the + // card grows over it and shrinks back onto it, so it is never seen missing. An open panel lets a touch on the row — // send included — fall through to the dismiss area behind the bar, so it only closes the panel. .allowsHitTesting(!showsCard && !model.attachPanel.isOpen) .accessibilityHidden(showsCard) // After the row's own opacity, so the surface does not fade with it. .overlay(alignment: .topLeading) { - if model.attachSurfaceIsMounted, !model.overKeyboard.isActive, case .attach(let items) = leadingControl { - attachSurface(items: items) + if model.attachSurfaceIsMounted, !model.overKeyboard.isActive, !bottomRow.plusItems.isEmpty { + attachSurface(items: bottomRow.plusItems) } } } @@ -552,19 +646,21 @@ struct ConversationBottomBar: View { /// Whether the camera should be warm: while the open panel offers it, or its card shows it. private var wantsCameraWarm: Bool { let showsCamera = model.attachCard.content == .camera - switch leadingControl { - case .attach(let items): - return showsCamera || (model.attachPanel.isOpen && items.contains(.camera)) - case .cancelEdit, .sendCash, .none: - return showsCamera - } + return showsCamera || (model.attachPanel.isOpen && bottomRow.plusItems.contains(.camera)) } private var leadingControl: ConversationBarLeadingControl { ConversationBarLeadingControl( isEditing: composer.isEditing, chatExists: chatExists, - showsSendCash: showsSendCash, + showsSendCash: showsSendCash + ) + } + + private var bottomRow: ConversationBarBottomRow { + ConversationBarBottomRow( + isEditing: composer.isEditing, + chatExists: chatExists, acceptsMedia: acceptsMedia, attachedCount: composer.chips.count ) @@ -938,64 +1034,177 @@ struct ConversationComposer: View { let conversationID: ConversationID? @Bindable var model: ConversationBarModel @Bindable var composer: ComposerModel + /// What the row holds beside the text: `+`. + var bottomRow = ConversationBarBottomRow.empty + /// Whether `+` is hidden under the camera or photo card. + var hidesPlus = false + /// Whether the attach surface is drawn in `+`'s place, which hides `+`. + var plusIsStoodInFor = false + /// Fired as `+` opens the attach panel with the given rows. + var onAttachOpen: ([AttachMenuItem]) -> Void = { _ in } @Environment(ConversationController.self) private var conversationController @FocusState private var isFocused: Bool /// Send button scale-in/out as text appears/clears. private static let sendButtonSpring = ChatMotion.sendButton.animation + /// The text's and chips' inset from the field's leading edge. private static let leadingInset: CGFloat = 14 - private static let trailingInset: CGFloat = 8 + /// The stacked text's inset from the field's leading edge, matched to its inset from the top so + /// the first line sits evenly in the corner. + private static let stackedLeadingInset: CGFloat = 12 + /// The gap between the row's controls and the text. + private static let controlSpacing: CGFloat = 8 + /// The move between the one-row and stacked layouts. + /// The clip around the bar follows a shrink on `replySurface`, so the glass shares it. + private static let stackSpring = ChatMotion.replySurface.animation + + /// Whether the text spans the field with the controls in a row under it. + @State private var isStacked = false + /// The text's own layout, switched without animation: an animated width re-lays the text + /// every frame, and UIKit chases each new caret position on its own timing. + @State private var textIsStacked = false + /// The text's offset from its new place, sprung back to zero so it travels as one piece. + @State private var textShift: CGFloat = 0 + /// Whether send is up; follows `showsSubmit` in its own transaction so the pop animates. + @State private var submitIsShown = false + /// The text field's width while it sits inline between the controls. + /// The row's width, which stacking does not change, so the inline text width can be derived + /// in either layout rather than measured from a field that is about to move. + @State private var rowWidth: CGFloat = 0 + /// The draft's width laid out on one line. + @State private var draftLineWidth: CGFloat = 0 + + private var hasPlus: Bool { !bottomRow.plusItems.isEmpty } + + private var inlineLeadingPadding: CGFloat { + hasPlus ? BarMetrics.accessorySize + Self.controlSpacing : Self.leadingInset - BarMetrics.fieldPadding + } + + private func textLeadingPadding(stacked: Bool) -> CGFloat { + stacked ? Self.stackedLeadingInset - BarMetrics.fieldPadding : inlineLeadingPadding + } + + private var inlineTrailingPadding: CGFloat { BarMetrics.accessorySize + Self.controlSpacing } + + /// Stacks once the draft wraps or takes a newline, and unstacks only when it is cleared, so + /// deleting back under one line does not bounce the layout. + private func updateStacking() { + let stacked = Self.stacks( + draft: composer.draft, + wasStacked: textIsStacked, + draftLineWidth: draftLineWidth, + inlineWidth: rowWidth > 0 ? rowWidth - inlineLeadingPadding - inlineTrailingPadding : 0 + ) + // Its own transaction, after the text update has landed: the whole bar follows the field up + // or down, while the text update itself stays unanimated. + guard stacked != textIsStacked else { return } + let shift = textLeadingPadding(stacked: textIsStacked) - textLeadingPadding(stacked: stacked) + var snap = Transaction() + snap.disablesAnimations = true + withTransaction(snap) { + textIsStacked = stacked + textShift = shift + } + // A frame later, so the shift has rendered before it springs back with the bar. + Task { @MainActor in + withAnimation(Self.stackSpring) { + isStacked = stacked + textShift = 0 + } + } + } + + /// Whether the composer stacks for `draft`, given whether it was stacked and the draft's + /// one-line width against the inline field's. + nonisolated static func stacks(draft: String, wasStacked: Bool, draftLineWidth: CGFloat, inlineWidth: CGFloat) -> Bool { + guard !draft.isEmpty else { return false } + if wasStacked || draft.contains(where: \.isNewline) { return true } + return inlineWidth > 0 && draftLineWidth > inlineWidth + } @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { let motion = AttachMotion(reduceMotion: reduceMotion) - let textRow = HStack(alignment: .bottom, spacing: 10) { - TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) - .font(.appTextMessage) - .foregroundStyle(Color.textMain) - .tint(.white) - .lineLimit(1...5) - .focused($isFocused) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(minHeight: BarMetrics.fieldMinHeight) - // Queried by the UI tests. The placeholder is not usable as a handle: it is gone the - // moment there is a draft, so a test that types and then reads the field back finds - // nothing. A multiline `TextField(axis:)` also surfaces as a text view wearing a - // text-field automation type, so the query has to be identifier-based, not type-based. - .accessibilityIdentifier("composer-message-field") - - // The spring is scoped to the button, not to the row. On the row it took the field - // into the transaction as well, and `showsSubmit` falls on the same update that empties - // the draft — so the field's text update ran as an animated one against its text view, - // where it can be coalesced away. That leaves the sent text on screen with the binding - // already empty, and an unchanged binding never pushes it again. - Group { - if showsSubmit { - Button(action: submit) { - Image(systemName: submitSymbol) - .font(.default(size: 16, weight: .bold)) - .foregroundStyle(Color.textAction) - .frame(width: 34, height: 34) - .background(Color.white, in: RoundedRectangle(cornerRadius: 6)) - // Arrow and checkmark are the same button in two jobs, so the glyph swaps in - // place rather than the button popping out and a new one popping back. - .contentTransition(.symbolEffect(.replace)) - } - .buttonStyle(.plain) - .accessibilityLabel(composer.isEditing ? "Save" : "Send") - .accessibilityIdentifier("send-message-button") - // Pop from 60% + fade, so the opacity ramp actually reads - // (scaling from 0 hides the fade behind a tiny speck). - .transition(.scale(scale: 0.6).combined(with: .opacity)) + let textField = TextField(fieldPrompt, text: $composer.draft, selection: $composer.selection, axis: .vertical) + .font(.appTextMessage) + .foregroundStyle(Color.textMain) + .tint(.white) + .lineLimit(1...5) + .focused($isFocused) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(minHeight: BarMetrics.fieldMinHeight) + // Queried by the UI tests. The placeholder is not usable as a handle: it is gone the + // moment there is a draft, so a test that types and then reads the field back finds + // nothing. A multiline `TextField(axis:)` also surfaces as a text view wearing a + // text-field automation type, so the query has to be identifier-based, not type-based. + .accessibilityIdentifier("composer-message-field") + + // One row: `+`, the text, `$` until there is a draft, and send. Once the text wraps or takes + // a newline the row stacks: the text spans the field and the controls drop to a row under + // it, until the draft is cleared. The text field never moves in the hierarchy, only its + // insets do, so it keeps focus and the keyboard through the change. + let controls = HStack(alignment: .bottom, spacing: 0) { + if hasPlus { + AttachMenu( + items: bottomRow.plusItems, + panel: model.attachPanel, + hidesButton: hidesPlus, + isStoodInFor: plusIsStoodInFor, + onOpen: onAttachOpen, + onPlusFrame: { model.overKeyboard.plusFrame = $0 } + ) + } + Spacer(minLength: 0) + // The open menu covers this row; only `+` stays, for the menu to collapse back into. + sendButton + .animation(Self.sendButtonSpring) { + $0.opacity(model.attachPanel.isOpen ? 0 : 1) } + } + + let row = ZStack(alignment: .bottomLeading) { + textField + .background(alignment: .leading) { + // The draft's width on one line, to tell when the inline field would wrap. + Text(composer.draft.isEmpty ? " " : composer.draft) + .font(.appTextMessage) + .lineLimit(1) + .fixedSize() + .hidden() + .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in + draftLineWidth = width + updateStacking() + } + } + // The text snaps to its new layout and `textShift` carries it there on the bar's spring. + .padding(.leading, textLeadingPadding(stacked: textIsStacked)) + .padding(.trailing, textIsStacked ? 0 : inlineTrailingPadding) + .offset(x: textShift) + // Position only, so it can spring with the bar without re-laying the text. + .padding(.bottom, isStacked ? BarMetrics.accessorySize + BarMetrics.fieldVerticalPadding : 0) + controls + } + .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width in + rowWidth = width + updateStacking() + } + .onChange(of: composer.draft) { updateStacking() } + // An empty field is small beside the space send will take; a tap anywhere right of `+`, + // out to the field's edges, focuses it. Behind the row, so `+` keeps its own taps. + .background { + if composer.draft.isEmpty { + Color.clear + .padding(.vertical, -BarMetrics.fieldPadding) + .padding(.trailing, -BarMetrics.fieldPadding) + .contentShape(Rectangle()) + .onTapGesture { isFocused = true } } - .animation(Self.sendButtonSpring, value: showsSubmit) } - // The staged photos ride inside the field, above the text, so the field reads as one message. - let field = VStack(alignment: .leading, spacing: BarMetrics.fieldVerticalPadding) { + // The staged photos ride inside the field, above the row, so the field reads as one message. + let content = VStack(alignment: .leading, spacing: BarMetrics.fieldVerticalPadding) { if ComposerChipStrip.isShown(chipCount: composer.chips.count, isEditing: composer.isEditing) { ComposerChipStrip( chips: composer.chips, @@ -1006,22 +1215,18 @@ struct ConversationComposer: View { edgeInset: Self.leadingInset ) // Out to the field's own edges, so chips scroll under a fade rather than a hard margin. - .padding(.leading, -Self.leadingInset) - .padding(.trailing, -Self.trailingInset) + .padding(.leading, -BarMetrics.fieldPadding) + .padding(.trailing, -BarMetrics.fieldPadding) .padding(.top, BarMetrics.fieldVerticalPadding / 2) .transition(motion.stripTransition(isLanding: model.attachCard.landingChipID != nil)) } - textRow + row } - return field - .padding(.leading, Self.leadingInset) - .padding(.trailing, Self.trailingInset) - .padding(.vertical, BarMetrics.fieldVerticalPadding) - // Glass *behind* the field, not wrapping it: wrapping an editable - // TextField in `glassEffect` reparents its text view into the glass - // platter and breaks the text-selection grabbers. - .glassFieldBackground(cornerRadius: BarMetrics.cornerRadius) + return content + .padding(BarMetrics.fieldPadding) + // The field's glass is drawn by the bar, behind the whole row, so `$` can split from it. + .composerRim(in: RoundedRectangle(cornerRadius: BarMetrics.fieldCornerRadius, style: .continuous)) // Focus is the single source of `isComposing` — the button morph and the // screen's interactive-dismiss gate both key off it. Losing focus // (keyboard swiped down) ends composing. @@ -1037,6 +1242,45 @@ struct ConversationComposer: View { } } + /// The confirm button. The spring is scoped to it, not to the row it sits in. + private var sendButton: some View { + // The spring is scoped to the button, not to the row. On the row it took the field + // into the transaction as well, and `showsSubmit` falls on the same update that empties + // the draft — so the field's text update ran as an animated one against its text view, + // where it can be coalesced away. That leaves the sent text on screen with the binding + // already empty, and an unchanged binding never pushes it again. + // A ZStack, not a Group: a Group hands its modifiers to its children, so with the button + // gone the slot's frame and the callbacks below would attach to nothing. + ZStack { + if submitIsShown { + Button(action: submit) { + Image(systemName: submitSymbol) + .font(.default(size: 16, weight: .bold)) + .foregroundStyle(Color.textAction) + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + .background(Color.white, in: Circle()) + // Arrow and checkmark are the same button in two jobs, so the glyph swaps in + // place rather than the button popping out and a new one popping back. + .contentTransition(.symbolEffect(.replace)) + } + .buttonStyle(.plain) + .accessibilityLabel(composer.isEditing ? "Save" : "Send") + .accessibilityIdentifier("send-message-button") + // Pop from 60% + fade, so the opacity ramp actually reads + // (scaling from 0 hides the fade behind a tiny speck). + .transition(.scale(scale: 0.6).combined(with: .opacity)) + } + } + // The slot stays, so the text does not reflow as the button comes and goes. + .frame(width: BarMetrics.accessorySize, height: BarMetrics.accessorySize) + // Its own transaction after the text update: an implicit animation keyed on `showsSubmit` + // rode the text field's update, which carries no animation, so the pop never played. + .onAppear { submitIsShown = showsSubmit } + .onChange(of: showsSubmit) { _, shows in + withAnimation(Self.sendButtonSpring) { submitIsShown = shows } + } + } + /// The hint names what the field will send. An edit arrives with the existing text already in /// the field, so its hint is never on screen and stays the new-message one. private var fieldPrompt: String { @@ -1107,6 +1351,27 @@ struct ConversationComposer: View { /// The way out of an edit: the bar's leading control while the field holds an existing message, /// standing where Send Cash stands the rest of the time. Field-sized and glass, so the swap reads /// as the same control changing job rather than a foreign button arriving. +/// The round `$` beside the field. Its glass is drawn by the bar, joined to the field's. +private struct ComposerCashButton: View { + + let symbol: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(symbol) + .font(.appTextXL) + .foregroundStyle(Color.textMain) + .frame(width: BarMetrics.contentHeight, height: BarMetrics.contentHeight) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .composerRim(in: Circle()) + .accessibilityLabel("Send Cash") + .accessibilityIdentifier("send-cash-button") + } +} + private struct CancelEditButton: View { let onCancel: () -> Void diff --git a/FlipcashTests/Chat/AttachOverlayModeTests.swift b/FlipcashTests/Chat/AttachOverlayModeTests.swift index 61012b968..02359f029 100644 --- a/FlipcashTests/Chat/AttachOverlayModeTests.swift +++ b/FlipcashTests/Chat/AttachOverlayModeTests.swift @@ -81,13 +81,15 @@ struct AttachOverlayModeTests { #expect(AttachOverlayMode.select(keyboardHeight: minimum, hasKeyboardWindow: false, canReplaceInputView: true) == .inputView) } - @Test("The panel straddles the composer's bottom edge and grows out of +") + @Test("The panel straddles the composer's bottom edge, clears its top, and grows out of +") func panelStraddlesTheComposerEdge() { let plus = CGRect(x: 12, y: 500, width: 50, height: 50) let size = CGSize(width: 240, height: 144) let frame = AttachOverlayLayout.panelFrame(plusFrame: plus, size: size) - #expect(frame.minX == plus.minX) - #expect(frame.midY == plus.maxY) + #expect(frame.minX == plus.minX - BarMetrics.fieldPadding) + #expect(frame.minY < frame.maxY && frame.minY <= plus.maxY && frame.maxY >= plus.maxY) + let fieldTop = plus.maxY + BarMetrics.fieldPadding - BarMetrics.contentHeight + #expect(frame.minY <= fieldTop - AttachOverlayLayout.fieldTopOverhang) let anchor = AttachOverlayLayout.panelAnchor(plusFrame: plus, size: size) #expect(frame.minX + anchor.x * size.width == plus.midX) diff --git a/FlipcashTests/Chat/AttachPanelPlacementTests.swift b/FlipcashTests/Chat/AttachPanelPlacementTests.swift index ab69196ff..9a54eb6f4 100644 --- a/FlipcashTests/Chat/AttachPanelPlacementTests.swift +++ b/FlipcashTests/Chat/AttachPanelPlacementTests.swift @@ -76,9 +76,10 @@ struct AttachPanelHost { /// The bar's measured height, as last reported to the screen. var barHeight: CGFloat { screenBox.barHeight } - /// The window-space point `distance` above the bar's top edge, over `+`'s column. + /// The window-space point `distance` above the bar's top edge, over `+`'s column: `+` is the + /// first control in the field's bottom row, past the compact bar's margin and the field's padding. func pointAboveBar(by distance: CGFloat) -> CGPoint { - CGPoint(x: 40, y: window.bounds.height - barHeight - distance) + CGPoint(x: model.overKeyboard.plusFrame.midX, y: window.bounds.height - barHeight - distance) } /// The window's rendered colour at `point`. @@ -158,7 +159,7 @@ struct AttachPanelPlacementTests { // Inside the panel's rows, above the bar: before the fix the panel hung down from `+`'s top // and nothing of the bar reached here. let point = host.pointAboveBar(by: 40) - let transcript = host.renderedColor(at: CGPoint(x: 40, y: 100), backdrop: .black) + let transcript = host.renderedColor(at: CGPoint(x: point.x, y: 100), backdrop: .black) let probe = host.renderedColor(at: point, backdrop: .black) #expect(!probe.isClose(to: transcript), "Panel is not drawn above the bar") @@ -167,6 +168,28 @@ struct AttachPanelPlacementTests { #expect(hit.isDescendant(of: host.barHost.view), "Taps above the bar miss the panel") } + @Test("The bar is one row tall: +, the text, and the controls beside it") + func barHeight_isOneRow() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + let expected = BarMetrics.contentHeight + BarMetrics.contentPadding * 2 + #expect(abs(host.barHeight - expected) < 1, "Bar is \(host.barHeight) tall, expected \(expected)") + } + + @Test("The menu's leading edge is the composer field's, past the compact margin and the `$` beside it") + func menu_linesUpWithField() async throws { + let host = try AttachPanelHost() + defer { host.tearDown() } + await host.settle() + + let plus = host.model.overKeyboard.plusFrame + let menu = AttachSurfaceLayout.menuRect(plus: plus, size: CGSize(width: 280, height: 200), placement: .standsOnPlus) + let field = BarMetrics.compactInset + BarMetrics.contentHeight + ConversationBottomBar.leadingSpacing + #expect(abs(menu.minX - field) < 1, "Menu starts at \(menu.minX), the field at \(field)") + } + @Test("The card stands on the composer over the transcript, takes taps on itself, and lets taps above it through") func card_overlaysTranscript() async throws { let host = try AttachPanelHost() diff --git a/FlipcashTests/Chat/AttachPanelTests.swift b/FlipcashTests/Chat/AttachPanelTests.swift index 72c8bf029..4634660f0 100644 --- a/FlipcashTests/Chat/AttachPanelTests.swift +++ b/FlipcashTests/Chat/AttachPanelTests.swift @@ -7,6 +7,7 @@ import Testing import Foundation +import UIKit import FlipcashUI @testable import Flipcash @@ -170,6 +171,21 @@ struct AttachSurfaceTests { #expect(!model.attachSurfaceIsMounted) } + @Test("A landing holds the staged photo from the hand-off until it has landed") + func landingHoldsImage() { + let model = ConversationBarModel() + model.attachCard.open(.camera, screenHeight: 874) + let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 3)).image { _ in } + + model.attachCard.beginLanding(on: UUID(), image: image) + #expect(model.attachCard.landingImage === image) + model.attachCard.close() + #expect(model.attachCard.landingImage === image, "The surface still draws it as it shrinks") + + model.attachCard.endLanding() + #expect(model.attachCard.landingImage == nil) + } + @Test("Only the menu shows rows, and only a card shows card content") func contentPerPhase() { #expect(AttachSurfacePhase.menu.showsRows) @@ -188,7 +204,8 @@ struct AttachSurfaceTests { let chip = CGRect(x: 20, y: 640, width: 56, height: 56) let collapsed = AttachSurfaceLayout.shape(for: .collapsed, plus: plus, menu: menu, card: card, landing: chip) - #expect(collapsed == AttachSurfaceShape(rect: plus, cornerRadius: AttachSurfaceLayout.plusCornerRadius)) + #expect(collapsed == AttachSurfaceShape(rect: plus, cornerRadius: plus.height / 2)) + #expect(AttachSurfaceLayout.collapsedCornerRadius(plus: plus) == 22) let open = AttachSurfaceLayout.shape(for: .menu, plus: plus, menu: menu, card: card, landing: nil) #expect(open == AttachSurfaceShape(rect: menu, cornerRadius: AttachSurfaceLayout.menuCornerRadius)) let carded = AttachSurfaceLayout.shape(for: .card(.camera), plus: plus, menu: menu, card: card, landing: nil) @@ -204,9 +221,23 @@ struct AttachSurfaceTests { let plus = CGRect(x: 16, y: 700, width: 44, height: 44) let size = CGSize(width: 240, height: 144) let standing = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .standsOnPlus) - #expect(standing == CGRect(x: 16, y: 600, width: 240, height: 144)) + #expect(standing == CGRect(x: 16 - BarMetrics.fieldPadding, y: 600 + BarMetrics.fieldPadding, width: 240, height: 144)) + #expect(standing.minX == plus.minX - BarMetrics.fieldPadding, "The menu lines up with the field's edge") + #expect(standing.maxY == plus.maxY + BarMetrics.fieldPadding, "The menu covers the field's bottom edge") let straddling = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: .straddlesPlus) #expect(straddling == AttachOverlayLayout.panelFrame(plusFrame: plus, size: size)) + #expect(straddling.minX == plus.minX - BarMetrics.fieldPadding, "The over-keyboard menu lines up with the field's edge") + } + + @Test("With $ beside the field the menu moves out over it at the same width", arguments: [AttachMenuPlacement.standsOnPlus, .straddlesPlus]) + func menuReachesOverCash(placement: AttachMenuPlacement) { + let plus = CGRect(x: 60, y: 700, width: 34, height: 34) + let size = CGSize(width: 280, height: 144) + let aligned = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: placement) + let reaching = AttachSurfaceLayout.menuRect(plus: plus, size: size, placement: placement, leadingReach: 44) + #expect(reaching.minX == aligned.minX - 44) + #expect(reaching.width == aligned.width) + #expect(reaching.minY == aligned.minY && reaching.height == aligned.height) } @Test("The bar's card stands on the row's bottom edge, out to the keyboard-up margin") diff --git a/FlipcashTests/Chat/ChatScreenBarClipTests.swift b/FlipcashTests/Chat/ChatScreenBarClipTests.swift index b8379cf2a..9f6471cb9 100644 --- a/FlipcashTests/Chat/ChatScreenBarClipTests.swift +++ b/FlipcashTests/Chat/ChatScreenBarClipTests.swift @@ -106,6 +106,24 @@ struct ChatScreenBarClipTests { #expect(clipHeight(bar, window) == composerRow + secondLine) } + @Test("A composer shrinking holds the clip until its spring is done, and reserves the new room at once") + func composerShrink_holdsTheClip() async throws { + let (screen, bar, window) = makeScreen() + let stacked = composerRow + 48 + + report(screen, composerRow) + report(screen, stacked) + let transcript = try #require(screen.view.firstSubview(of: UICollectionView.self)) + let insetBefore = transcript.contentInset.bottom + + report(screen, composerRow) + #expect(transcript.contentInset.bottom == insetBefore - 48) + #expect(clipHeight(bar, window) == stacked, "The clip keeps the text and caret uncovered on the way down") + + try await Task.sleep(for: .seconds(ChatMotion.replySurface.duration + 0.2)) + #expect(clipHeight(bar, window) == composerRow) + } + @Test("The mention list stacks over an open reply and the clip reaches the whole bar") func mentionsOverReply_clipReachesTheWholeBar() { let (screen, bar, window) = makeScreen() @@ -184,3 +202,12 @@ struct ChatScreenBarClipTests { #expect(rooms.last.map { $0 - strip } == withStrip) } } + +private extension UIView { + func firstSubview(of type: T.Type) -> T? { + for subview in subviews { + if let match = subview as? T ?? subview.firstSubview(of: type) { return match } + } + return nil + } +} diff --git a/FlipcashTests/Chat/ComposerStackingTests.swift b/FlipcashTests/Chat/ComposerStackingTests.swift new file mode 100644 index 000000000..41fe2de16 --- /dev/null +++ b/FlipcashTests/Chat/ComposerStackingTests.swift @@ -0,0 +1,44 @@ +// +// ComposerStackingTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import Testing +@testable import Flipcash + +@Suite("Composer stacking") +struct ComposerStackingTests { + + @Test("A draft that fits on one line stays inline") + func fitsInline() { + #expect(!ConversationComposer.stacks(draft: "Hi", wasStacked: false, draftLineWidth: 20, inlineWidth: 200)) + } + + @Test("A draft wider than the inline field stacks") + func wrapStacks() { + #expect(ConversationComposer.stacks(draft: "Long", wasStacked: false, draftLineWidth: 201, inlineWidth: 200)) + } + + @Test("A newline stacks, however short the draft") + func newlineStacks() { + #expect(ConversationComposer.stacks(draft: "H\n", wasStacked: false, draftLineWidth: 10, inlineWidth: 200)) + } + + @Test("Deleting back under one line stays stacked") + func staysStacked() { + #expect(ConversationComposer.stacks(draft: "H", wasStacked: true, draftLineWidth: 10, inlineWidth: 200)) + } + + @Test("Clearing the draft unstacks") + func clearUnstacks() { + #expect(!ConversationComposer.stacks(draft: "", wasStacked: true, draftLineWidth: 0, inlineWidth: 200)) + } + + @Test("Before the field is measured, only a newline stacks") + func unmeasured() { + #expect(!ConversationComposer.stacks(draft: "Long", wasStacked: false, draftLineWidth: 500, inlineWidth: 0)) + } +} diff --git a/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift b/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift index a0f792d3f..c166c3969 100644 --- a/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift +++ b/FlipcashTests/Chat/ConversationBarLeadingControlTests.swift @@ -15,51 +15,82 @@ struct ConversationBarLeadingControlTests { private func control( isEditing: Bool = false, chatExists: Bool = true, - showsSendCash: Bool = true, - acceptsMedia: Bool = true, - attachedCount: Int = 0 + showsSendCash: Bool = true ) -> ConversationBarLeadingControl { - ConversationBarLeadingControl( - isEditing: isEditing, - chatExists: chatExists, - showsSendCash: showsSendCash, - acceptsMedia: acceptsMedia, - attachedCount: attachedCount - ) + ConversationBarLeadingControl(isEditing: isEditing, chatExists: chatExists, showsSendCash: showsSendCash) } - @Test("Before the chat exists, Send Cash stays the full-width call to action", arguments: [true, false]) - func callToActionBeforeChatExists(acceptsMedia: Bool) { - #expect(control(chatExists: false, acceptsMedia: acceptsMedia) == .sendCash) + @Test("Before the chat exists, Send Cash stays the full-width call to action") + func callToActionBeforeChatExists() { + #expect(control(chatExists: false) == .sendCash) } - @Test("Before the chat exists with nothing to send, the slot is empty") + @Test("Before the chat exists with nothing to send, nothing stands outside the field") func emptyBeforeChatExistsWithoutCash() { #expect(control(chatExists: false, showsSendCash: false) == .none) } - @Test("Once the chat exists, the slot is the attach menu with every row") - func attachMenuOnceChatExists() { - #expect(control() == .attach([.cash, .camera, .photos])) + @Test("Once the chat exists, Send Cash is the round `$` beside the field") + func cashOnceChatExists() { + #expect(control() == .cash) } - @Test("An E2EE chat keeps the attach menu with Cash alone") - func attachMenuCashOnlyWithoutMedia() { - #expect(control(acceptsMedia: false) == .attach([.cash])) + @Test("Once the chat exists with nothing to send, nothing stands beside the field") + func noneOnceChatExistsWithoutCash() { + #expect(control(showsSendCash: false) == .none) } - @Test("A chat that takes neither cash nor media leaves the slot empty") - func emptyWithoutCashOrMedia() { - #expect(control(showsSendCash: false, acceptsMedia: false) == .none) + @Test("An edit takes the slot whatever else is true", arguments: [true, false]) + func cancelWhileEditing(chatExists: Bool) { + #expect(control(isEditing: true, chatExists: chatExists) == .cancelEdit) } +} + +@MainActor +@Suite("Conversation bar bottom row") +struct ConversationBarBottomRowTests { - @Test("A full composer in a chat without cash leaves the slot empty") - func emptyWhenFullWithoutCash() { - #expect(control(showsSendCash: false, attachedCount: ComposerModel.maxAttachments) == .none) + private func row( + isEditing: Bool = false, + chatExists: Bool = true, + acceptsMedia: Bool = true, + attachedCount: Int = 0 + ) -> ConversationBarBottomRow { + ConversationBarBottomRow( + isEditing: isEditing, + chatExists: chatExists, + acceptsMedia: acceptsMedia, + attachedCount: attachedCount + ) } - @Test("An edit takes the slot whatever else is true", arguments: [true, false]) - func cancelWhileEditing(chatExists: Bool) { - #expect(control(isEditing: true, chatExists: chatExists) == .cancelEdit) + @Test("Where the chat takes media, the row holds `+` with the camera and photos") + func plus() { + let row = row() + #expect(row.plusItems == [.camera, .photos]) + #expect(!row.isEmpty) + } + + @Test("Cash is never a menu row: the `$` beside the field takes it") + func cashLeavesTheMenu() { + #expect(!row().plusItems.contains(.cash)) + #expect(!row(acceptsMedia: false).plusItems.contains(.cash)) + } + + @Test("With no menu rows left, `+` goes", arguments: [false, true]) + func noPlus(isFull: Bool) { + let row = row(acceptsMedia: isFull, attachedCount: isFull ? ComposerModel.maxAttachments : 0) + #expect(row.plusItems.isEmpty) + #expect(row.isEmpty) + } + + @Test("An edit hides the bottom row") + func hiddenWhileEditing() { + #expect(row(isEditing: true).isEmpty) + } + + @Test("Before the chat exists there is no bottom row") + func hiddenBeforeChatExists() { + #expect(row(chatExists: false).isEmpty) } } diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift index 3e0873de5..bb6f31551 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/BarAccessories.swift @@ -8,7 +8,7 @@ import CoreGraphics /// The cards stacked above the composer row, as the bar reports them alongside its height. -public struct BarAccessories: Equatable, Sendable { +public nonisolated struct BarAccessories: Equatable, Sendable { /// A card that can stand above the composer row. public enum Kind: Hashable, Sendable { diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift index ad5c7a0c1..0e559c536 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatScreenViewController.swift @@ -780,6 +780,10 @@ public final class ChatScreenViewController: UIViewController { holdForInPlaceResize(clipHeight: clipHeight) return } + if clipHeight < previousClip, !toggled, !isFirst, view.window != nil { + holdForShrink(clipHeight: clipHeight) + return + } transcriptCover = nil fadeTopConstraint?.constant = 0 @@ -804,6 +808,33 @@ public final class ChatScreenViewController: UIViewController { } } + /// Shrinks the bar's room for a composer folding back to one row or dropping its chips. + /// + /// The transcript takes the new inset in this frame, unanimated, so a send's insertion moves its + /// rows on its own spring alone. The clip stays at its old height until the glass's spring is + /// done: cut at once, it would hide the text and caret still travelling down to their place. + private func holdForShrink(clipHeight: CGFloat) { + let heldClip = barClipHeightConstraint.constant + transcriptCover = clipHeight + fadeTopConstraint.constant = heldClip - clipHeight + UIView.performWithoutAnimation { + self.view.layoutIfNeeded() + self.updateTranscriptInset() + } + let height = measuredBarHeight + DispatchQueue.main.asyncAfter(deadline: .now() + ChatMotion.replySurface.duration) { [weak self] in + // A later report may already have moved the bar on; only the one still current lands. + guard let self, self.measuredBarHeight == height, + self.barClipHeightConstraint.constant == heldClip else { return } + self.barClipHeightConstraint.constant = clipHeight + self.transcriptCover = nil + self.fadeTopConstraint.constant = 0 + UIView.performWithoutAnimation { + self.view.layoutIfNeeded() + } + } + } + /// Makes room for a change the bar draws itself, without animating anything. /// /// The glass grows and shrinks on its own spring, its bottom on the composer, and a second diff --git a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift index 8ccb29c49..8a2217c80 100644 --- a/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift +++ b/FlipcashUI/Sources/FlipcashUI/Views/Containers/GlassBackground.swift @@ -34,6 +34,18 @@ extension View { } } + /// Dark-tinted glass for a control floating over a photo or viewfinder, where clear glass takes + /// on the image behind it and washes the glyph out. + @ViewBuilder + public func overlayGlassBackground(in shape: some Shape) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.tint(.black.opacity(0.45)).interactive(), in: shape) + } else { + background(Color.black.opacity(0.45), in: shape) + .background(.ultraThinMaterial, in: shape) + } + } + /// The app's glass surface for a panel that holds its own buttons: Liquid Glass on iOS 26, an /// ultra-thin material below. Non-interactive, because `.interactive()` on the container competes /// with its buttons for the tap. @@ -73,4 +85,34 @@ extension View { background(.ultraThinMaterial, in: .rect(cornerRadius: cornerRadius)) } } + + /// The chat composer's glass in `shape`, as a layer of its own: Liquid Glass with a light white + /// tint, joined by `id` to the other composer glass in an enclosing container. A tinted material + /// before iOS 26. + @ViewBuilder + public func composerGlass(in shape: some Shape, id: String, namespace: Namespace.ID) -> some View { + if #available(iOS 26, *) { + glassEffect(.regular.tint(Color.white.opacity(0.03)).interactive(), in: shape) + .glassEffectID(id, in: namespace) + } else { + background(Color.white.opacity(0.03), in: shape) + .background(.ultraThinMaterial, in: shape) + } + } + + /// The chat composer's light rim around `shape`, brightest along the top. + public func composerRim(in shape: some InsettableShape) -> some View { + overlay { + shape + .strokeBorder( + LinearGradient( + colors: [Color.white.opacity(0.18), Color.white.opacity(0.04)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 + ) + .allowsHitTesting(false) + } + } } From 9f48dbbc87e91bc2c20a52b822688c6d7420e560 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 5 Oct 2026 00:39:06 -0400 Subject: [PATCH 8/8] test(chat): expect the lift shadow opacity #908 set #908 lowered BubbleBackgroundView.liftShadowOpacity from 0.65 to 0.135 and left this expectation at 0.65. --- FlipcashTests/Chat/ChatBubbleViewTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/FlipcashTests/Chat/ChatBubbleViewTests.swift b/FlipcashTests/Chat/ChatBubbleViewTests.swift index c0b078245..2baaa3324 100644 --- a/FlipcashTests/Chat/ChatBubbleViewTests.swift +++ b/FlipcashTests/Chat/ChatBubbleViewTests.swift @@ -355,7 +355,7 @@ struct ChatBubbleViewBareTests { let view = UIView() let path = UIBezierPath(rect: CGRect(x: 0, y: 0, width: 40, height: 40)) BubbleBackgroundView.raise(view, shape: path) - #expect(view.layer.shadowOpacity == 0.65) + #expect(view.layer.shadowOpacity == 0.135) #expect(view.layer.shadowPath != nil) }