Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)",
Expand Down
17 changes: 15 additions & 2 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions Flipcash/Core/Screens/Conversation/ComposerReplyStrip.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
}
}
Expand Down
7 changes: 6 additions & 1 deletion Flipcash/Core/Screens/Conversation/ConversationScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
}
}
Expand Down
1 change: 1 addition & 0 deletions Flipcash/Core/Spotlight/ChatSpotlightItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
30 changes: 30 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaConstraints.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
48 changes: 48 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaDownscale.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
151 changes: 151 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Blob/ChatMediaEncoder.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading