diff --git a/apps/ios/Sources/Litter/Models/ConversationAttachmentSupport.swift b/apps/ios/Sources/Litter/Models/ConversationAttachmentSupport.swift index bcede308c..ef8cb4dad 100644 --- a/apps/ios/Sources/Litter/Models/ConversationAttachmentSupport.swift +++ b/apps/ios/Sources/Litter/Models/ConversationAttachmentSupport.swift @@ -80,17 +80,42 @@ enum ConversationAttachmentSupport { return inputs } + private static let maxTransportPixelDimension: CGFloat = 2048 + private static let minTransportPixelDimension: CGFloat = 1024 + private static let maxTransportImageBytes = 1_200_000 + private static let transportJpegQuality: CGFloat = 0.7 + private static func encodedImageData(for image: UIImage) -> (data: Data, mimeType: String)? { - if image.litterHasAlpha, let pngData = image.pngData() { + var current = downscaled(image, longestSide: maxTransportPixelDimension) + if current.litterHasAlpha, + let pngData = current.pngData(), + pngData.count <= maxTransportImageBytes { return (pngData, "image/png") } - if let jpegData = image.jpegData(compressionQuality: 0.85) { - return (jpegData, "image/jpeg") + var dimension = maxTransportPixelDimension + while true { + guard let jpegData = current.jpegData(compressionQuality: transportJpegQuality) else { break } + if jpegData.count <= maxTransportImageBytes || dimension <= minTransportPixelDimension { + return (jpegData, "image/jpeg") + } + dimension = max(dimension * 0.75, minTransportPixelDimension) + current = downscaled(current, longestSide: dimension) } - if let pngData = image.pngData() { - return (pngData, "image/png") + return current.pngData().map { ($0, "image/png") } + } + + private static func downscaled(_ image: UIImage, longestSide limit: CGFloat) -> UIImage { + let pixelWidth = image.size.width * image.scale + let pixelHeight = image.size.height * image.scale + let longestSide = max(pixelWidth, pixelHeight) + guard longestSide > limit else { return image } + let ratio = limit / longestSide + let targetSize = CGSize(width: pixelWidth * ratio, height: pixelHeight * ratio) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + return UIGraphicsImageRenderer(size: targetSize, format: format).image { _ in + image.draw(in: CGRect(origin: .zero, size: targetSize)) } - return nil } private static func isSupportedImageFile(_ url: URL) -> Bool { diff --git a/apps/ios/Sources/Litter/Views/ConversationComposerContentView.swift b/apps/ios/Sources/Litter/Views/ConversationComposerContentView.swift index beda21d3e..f0f50581a 100644 --- a/apps/ios/Sources/Litter/Views/ConversationComposerContentView.swift +++ b/apps/ios/Sources/Litter/Views/ConversationComposerContentView.swift @@ -5,7 +5,7 @@ import UIKit /// in-conversation composer and the photo pickers cannot drift apart. enum ComposerAttachmentLimits { /// Maximum number of images that can ride along with a single turn. - static let maxImages = 4 + static let maxImages = 10 } struct ConversationComposerContentView: View { diff --git a/apps/ios/Sources/Litter/Views/ConversationComposerModalCoordinator.swift b/apps/ios/Sources/Litter/Views/ConversationComposerModalCoordinator.swift index d76f8f63b..316a8fbd3 100644 --- a/apps/ios/Sources/Litter/Views/ConversationComposerModalCoordinator.swift +++ b/apps/ios/Sources/Litter/Views/ConversationComposerModalCoordinator.swift @@ -38,14 +38,14 @@ struct ConversationComposerModalCoordinator: View { @State private var modelSelectorDetent: PresentationDetent = .large /// Bridge binding for `CameraView` which expects a single `UIImage?`. - /// Appends the captured photo to the `attachedImages` array (capped at 4) - /// instead of replacing existing attachments. + /// Appends the captured photo to the `attachedImages` array (capped at + /// the shared limit) instead of replacing existing attachments. private var cameraImageBinding: Binding { Binding( get: { attachedImages.last }, set: { newImage in guard let newImage else { return } - if attachedImages.count < 4 { + if attachedImages.count < ComposerAttachmentLimits.maxImages { attachedImages.append(newImage) } } @@ -182,7 +182,7 @@ struct ConversationComposerModalCoordinator: View { .presentationDetents([.height(attachSheetDetentHeight)]) .presentationDragIndicator(.visible) } - .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotos, maxSelectionCount: 4, matching: .images) + .photosPicker(isPresented: $showPhotoPicker, selection: $selectedPhotos, maxSelectionCount: ComposerAttachmentLimits.maxImages, matching: .images) .fileImporter( isPresented: $showFileImporter, allowedContentTypes: ConversationAttachmentSupport.supportedFileContentTypes,