214 lines
9.5 KiB
Swift
214 lines
9.5 KiB
Swift
import SwiftUI
|
||
import PhotosUI
|
||
import UniformTypeIdentifiers
|
||
import NucleicProtocol
|
||
|
||
/// A file or image the user has staged in a composer but not yet sent. Holds the (already
|
||
/// compressed, for images) bytes plus a display name and, when it's an image, a small preview for
|
||
/// the chip. Converted to a `WireAttachment` at send time and shipped to the host, which
|
||
/// materializes it into the session's working tree (see `AttachmentMaterializer`). The phone can't
|
||
/// reach the host's tree, so it carries the bytes itself.
|
||
struct StagedAttachment: Identifiable, Equatable {
|
||
let id = UUID()
|
||
var filename: String
|
||
var data: Data
|
||
/// A downscaled preview for the chip; `nil` for non-image files (they show a doc glyph).
|
||
var thumbnail: UIImage?
|
||
|
||
var wire: WireAttachment { WireAttachment(filename: filename, data: data) }
|
||
|
||
static func == (lhs: StagedAttachment, rhs: StagedAttachment) -> Bool { lhs.id == rhs.id }
|
||
}
|
||
|
||
extension Array where Element == StagedAttachment {
|
||
/// The wire form to hand to `RemoteStore.sendInput` / `startChat`.
|
||
var wireAttachments: [WireAttachment] { map(\.wire) }
|
||
var totalBytes: Int { reduce(0) { $0 + $1.data.count } }
|
||
}
|
||
|
||
/// Staging helpers: turn picked photos / documents into `StagedAttachment`s, compressing images so
|
||
/// a phone photo (often 5–15 MB) rides the wire comfortably under the 16 MB frame cap.
|
||
enum ComposerAttachmentLoader {
|
||
/// Longest edge an attached image is scaled down to before sending — keeps big camera-roll
|
||
/// shots small while staying legible when the agent reads them.
|
||
static let maxImageDimension: CGFloat = 2048
|
||
/// A soft ceiling on the combined size of one message's attachments, leaving headroom under the
|
||
/// wire frame limit (`WireFraming.maxFrameSize`, 16 MB) for the rest of the envelope.
|
||
static let maxTotalBytes = 12 * 1024 * 1024
|
||
|
||
/// Load and compress picked photo-library items into staged image attachments. Runs off the main
|
||
/// actor (decoding/encoding is heavy); returns in pick order, skipping any that fail to load.
|
||
static func stage(photoItems items: [PhotosPickerItem]) async -> [StagedAttachment] {
|
||
var staged: [StagedAttachment] = []
|
||
for (index, item) in items.enumerated() {
|
||
guard let data = try? await item.loadTransferable(type: Data.self),
|
||
let attachment = stageImageData(data, index: index) else { continue }
|
||
staged.append(attachment)
|
||
}
|
||
return staged
|
||
}
|
||
|
||
/// Load picked documents into staged attachments (images are compressed like photos; other file
|
||
/// types pass through verbatim). Reads each URL under its security scope.
|
||
static func stage(fileURLs urls: [URL]) -> [StagedAttachment] {
|
||
var staged: [StagedAttachment] = []
|
||
for (index, url) in urls.enumerated() {
|
||
let scoped = url.startAccessingSecurityScopedResource()
|
||
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
|
||
guard let data = try? Data(contentsOf: url) else { continue }
|
||
let name = url.lastPathComponent
|
||
if let image = stageImageData(data, index: index, preferredName: name) {
|
||
staged.append(image)
|
||
} else {
|
||
staged.append(StagedAttachment(filename: name, data: data, thumbnail: nil))
|
||
}
|
||
}
|
||
return staged
|
||
}
|
||
|
||
/// Compress image bytes (downscale to `maxImageDimension`, re-encode as JPEG) into a staged
|
||
/// attachment with a thumbnail. Returns `nil` if the bytes aren't a decodable image, so callers
|
||
/// can fall back to shipping the file verbatim.
|
||
private static func stageImageData(
|
||
_ data: Data, index: Int, preferredName: String? = nil
|
||
) -> StagedAttachment? {
|
||
guard let image = UIImage(data: data) else { return nil }
|
||
let scaled = downscale(image, maxDimension: maxImageDimension)
|
||
guard let jpeg = scaled.jpegData(compressionQuality: 0.8) else { return nil }
|
||
// Keep the picked file's name when we have one (retargeted to .jpg since we re-encoded);
|
||
// photo-library items carry no filename, so synthesize a stable, unique one.
|
||
let base = preferredName.map { ($0 as NSString).deletingPathExtension } ?? "image-\(shortToken())"
|
||
return StagedAttachment(filename: "\(base).jpg", data: jpeg, thumbnail: scaled)
|
||
}
|
||
|
||
/// Scale `image` down so its longest edge is at most `maxDimension`, preserving aspect ratio.
|
||
/// Returns the image untouched if it's already small enough.
|
||
private static func downscale(_ image: UIImage, maxDimension: CGFloat) -> UIImage {
|
||
let longest = max(image.size.width, image.size.height)
|
||
guard longest > maxDimension, longest > 0 else { return image }
|
||
let scale = maxDimension / longest
|
||
let target = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||
let format = UIGraphicsImageRendererFormat.default()
|
||
format.scale = 1 // target is already in pixels; don't multiply by screen scale
|
||
let renderer = UIGraphicsImageRenderer(size: target, format: format)
|
||
return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: target)) }
|
||
}
|
||
|
||
private static func shortToken() -> String { String(UUID().uuidString.prefix(8)).lowercased() }
|
||
}
|
||
|
||
/// The paperclip button: a menu offering Photos (PhotosPicker) or Files (document picker), each
|
||
/// appending compressed/verbatim attachments to `attachments`. Shows a spinner while staging.
|
||
struct AttachMenuButton: View {
|
||
@Binding var attachments: [StagedAttachment]
|
||
/// Surfaced to the parent so it can show a note when an add is refused for exceeding the cap.
|
||
@Binding var overflowed: Bool
|
||
var tint: Color = Palette.accent
|
||
|
||
@State private var photoItems: [PhotosPickerItem] = []
|
||
@State private var showPhotos = false
|
||
@State private var showFiles = false
|
||
@State private var staging = false
|
||
|
||
var body: some View {
|
||
Menu {
|
||
Button { showPhotos = true } label: { Label("Photos", systemImage: "photo") }
|
||
Button { showFiles = true } label: { Label("Files", systemImage: "folder") }
|
||
} label: {
|
||
if staging {
|
||
ProgressView().controlSize(.small).frame(width: 24, height: 24)
|
||
} else {
|
||
Image(systemName: "paperclip")
|
||
.font(.title3)
|
||
.foregroundStyle(tint)
|
||
.frame(width: 24, height: 24)
|
||
}
|
||
}
|
||
.disabled(staging)
|
||
.accessibilityLabel("Attach")
|
||
.photosPicker(
|
||
isPresented: $showPhotos, selection: $photoItems,
|
||
maxSelectionCount: 10, matching: .images)
|
||
.fileImporter(
|
||
isPresented: $showFiles, allowedContentTypes: [.item], allowsMultipleSelection: true
|
||
) { result in
|
||
guard case .success(let urls) = result else { return }
|
||
append(ComposerAttachmentLoader.stage(fileURLs: urls))
|
||
}
|
||
.onChange(of: photoItems) { _, items in
|
||
guard !items.isEmpty else { return }
|
||
staging = true
|
||
Task {
|
||
let staged = await ComposerAttachmentLoader.stage(photoItems: items)
|
||
await MainActor.run {
|
||
append(staged)
|
||
photoItems = []
|
||
staging = false
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Append newly staged items, dropping any that would push the message over the total-size cap
|
||
/// (and flagging `overflowed` so the composer can say so).
|
||
private func append(_ staged: [StagedAttachment]) {
|
||
var running = attachments.totalBytes
|
||
for item in staged {
|
||
if running + item.data.count > ComposerAttachmentLoader.maxTotalBytes {
|
||
overflowed = true
|
||
continue
|
||
}
|
||
running += item.data.count
|
||
attachments.append(item)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The horizontal strip of staged-attachment chips shown above a composer's text field. Image
|
||
/// attachments show a thumbnail; other files show a doc glyph. Each chip has a remove button.
|
||
struct StagedAttachmentBar: View {
|
||
@Binding var attachments: [StagedAttachment]
|
||
|
||
var body: some View {
|
||
ScrollView(.horizontal, showsIndicators: false) {
|
||
HStack(spacing: 8) {
|
||
ForEach(attachments) { attachment in
|
||
chip(attachment)
|
||
}
|
||
}
|
||
.padding(.vertical, 2)
|
||
}
|
||
}
|
||
|
||
@ViewBuilder
|
||
private func chip(_ attachment: StagedAttachment) -> some View {
|
||
HStack(spacing: 6) {
|
||
if let thumbnail = attachment.thumbnail {
|
||
Image(uiImage: thumbnail)
|
||
.resizable()
|
||
.aspectRatio(contentMode: .fill)
|
||
.frame(width: 28, height: 28)
|
||
.clipShape(.rect(cornerRadius: 5))
|
||
} else {
|
||
Image(systemName: "doc")
|
||
.font(.footnote)
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 28, height: 28)
|
||
}
|
||
Text(attachment.filename)
|
||
.font(.caption2)
|
||
.lineLimit(1)
|
||
.truncationMode(.middle)
|
||
.frame(maxWidth: 120)
|
||
Button {
|
||
attachments.removeAll { $0.id == attachment.id }
|
||
} label: {
|
||
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.padding(.leading, 4).padding(.trailing, 6).padding(.vertical, 4)
|
||
.background(.quaternary.opacity(0.5), in: .capsule)
|
||
}
|
||
}
|