From 409180bff17acb106493429ecda2c0dd144d0fb2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 13:19:04 -0400 Subject: [PATCH 1/2] 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 5716bbd1de5e861aa87d00efeb45fc064b2a22fb Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 28 Sep 2026 14:26:43 -0400 Subject: [PATCH 2/2] 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 581db537f..9c475d08a 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1562,6 +1562,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 @@ -1993,7 +1999,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