Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/ComposerAttachments.swift
T

214 lines
9.5 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}