From fc02d86a6839fff3f306ea2f317e79d104d7379e Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sat, 30 Aug 2025 21:52:30 -0600 Subject: [PATCH] UI support for uploading image attachments --- kordophone2/Attachments.swift | 101 +++++++++++++++++ kordophone2/ConversationView.swift | 6 + kordophone2/MessageEntryView.swift | 175 ++++++++++++++++++++++++++--- 3 files changed, 265 insertions(+), 17 deletions(-) create mode 100644 kordophone2/Attachments.swift diff --git a/kordophone2/Attachments.swift b/kordophone2/Attachments.swift new file mode 100644 index 0000000..c8643a3 --- /dev/null +++ b/kordophone2/Attachments.swift @@ -0,0 +1,101 @@ +// +// Attachments.swift +// kordophone2 +// +// Created by Assistant on 8/31/25. +// + +import Foundation +import UniformTypeIdentifiers + +enum AttachmentState +{ + case staged + case uploading(progress: Double) + case uploaded + case failed +} + +struct OutgoingAttachment: Identifiable, Hashable +{ + let id: String + let originalURL: URL + let stagedURL: URL + let fileName: String + let contentType: UTType? + var state: AttachmentState + + init(id: String = UUID().uuidString, + originalURL: URL, + stagedURL: URL, + fileName: String? = nil, + contentType: UTType? = nil, + state: AttachmentState = .staged) + { + self.id = id + self.originalURL = originalURL + self.stagedURL = stagedURL + self.fileName = fileName ?? originalURL.lastPathComponent + self.contentType = contentType + self.state = state + } + + static func ==(lhs: OutgoingAttachment, rhs: OutgoingAttachment) -> Bool { + return lhs.id == rhs.id + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + hasher.combine(originalURL) + hasher.combine(stagedURL) + hasher.combine(fileName) + } +} + +final class AttachmentManager +{ + static let shared = AttachmentManager() + + private let fileManager = FileManager.default + private let stagingRoot: URL + + private init() { + let base = fileManager.temporaryDirectory.appendingPathComponent("kordophone-staging", isDirectory: true) + try? fileManager.createDirectory(at: base, withIntermediateDirectories: true) + self.stagingRoot = base + } + + func stageIfImage(url: URL) -> OutgoingAttachment? { + guard isImage(url: url) else { return nil } + let staged = stage(url: url) + let type = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType + ?? UTType(filenameExtension: url.pathExtension) + return OutgoingAttachment(originalURL: url, stagedURL: staged, fileName: url.lastPathComponent, contentType: type) + } + + // MARK: - Helpers + + private func isImage(url: URL) -> Bool { + if let type = (try? url.resourceValues(forKeys: [.contentTypeKey]))?.contentType { + return type.conforms(to: .image) + } + return UTType(filenameExtension: url.pathExtension)?.conforms(to: .image) ?? false + } + + private func stage(url: URL) -> URL { + let ext = url.pathExtension + let dest = stagingRoot.appendingPathComponent(UUID().uuidString).appendingPathExtension(ext) + do { + if fileManager.fileExists(atPath: dest.path) { try? fileManager.removeItem(at: dest) } + try fileManager.copyItem(at: url, to: dest) + return dest + } catch { + do { + try fileManager.moveItem(at: url, to: dest) + return dest + } catch { + return url + } + } + } +} diff --git a/kordophone2/ConversationView.swift b/kordophone2/ConversationView.swift index 69dddd9..100aea8 100644 --- a/kordophone2/ConversationView.swift +++ b/kordophone2/ConversationView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import UniformTypeIdentifiers struct ConversationView: View { @@ -17,5 +18,10 @@ struct ConversationView: View TranscriptView(model: $transcriptModel) MessageEntryView(viewModel: $entryModel) } + + .onDrop(of: [UTType.image, UTType.fileURL], isTargeted: $entryModel.isDropTargeted) { providers in + entryModel.handleDroppedProviders(providers) + return true + } } } diff --git a/kordophone2/MessageEntryView.swift b/kordophone2/MessageEntryView.swift index 549d9db..88e2715 100644 --- a/kordophone2/MessageEntryView.swift +++ b/kordophone2/MessageEntryView.swift @@ -5,7 +5,9 @@ // Created by James Magahern on 8/24/25. // +import AppKit import SwiftUI +import UniformTypeIdentifiers struct MessageEntryView: View { @@ -14,26 +16,33 @@ struct MessageEntryView: View var body: some View { VStack(spacing: 0.0) { - Rectangle() - .fill(.separator) - .frame(height: 1.0) + Separator() HStack { - TextField("iMessage", text: $viewModel.draftText, axis: .vertical) - .focusEffectDisabled(true) - .textFieldStyle(.plain) - .lineLimit(nil) - .scrollContentBackground(.hidden) - .fixedSize(horizontal: false, vertical: true) - .font(.body) - .scrollDisabled(true) - .padding(8.0) - .disabled(selectedConversation == nil) - .background { - RoundedRectangle(cornerRadius: 8.0) - .stroke(SeparatorShapeStyle()) - .fill(.background) + VStack { + if !viewModel.attachments.isEmpty { + AttachmentWell(attachments: $viewModel.attachments) + .frame(height: 150.0) + + Separator() } + + TextField("iMessage", text: $viewModel.draftText, axis: .vertical) + .focusEffectDisabled(true) + .textFieldStyle(.plain) + .lineLimit(nil) + .scrollContentBackground(.hidden) + .fixedSize(horizontal: false, vertical: true) + .font(.body) + .scrollDisabled(true) + .disabled(selectedConversation == nil) + } + .padding(8.0) + .background { + RoundedRectangle(cornerRadius: 8.0) + .stroke(SeparatorShapeStyle()) + .fill(.background) + } Button("Send") { viewModel.sendDraft(to: selectedConversation) @@ -55,6 +64,8 @@ struct MessageEntryView: View class ViewModel { var draftText: String = "" + var attachments: [OutgoingAttachment] = [] + var isDropTargeted: Bool = false func sendDraft(to convo: Display.Conversation?) { guard let convo else { return } @@ -75,6 +86,136 @@ struct MessageEntryView: View } } } + + // MARK: - Attachments + + func handleDroppedProviders(_ providers: [NSItemProvider]) { + let imageId = UTType.image.identifier + let fileURLId = UTType.fileURL.identifier + + for provider in providers { + if provider.hasItemConformingToTypeIdentifier(fileURLId) { + provider.loadItem(forTypeIdentifier: fileURLId, options: nil) { item, error in + if let error { print("Drop load fileURL error: \(error)"); return } + if let url = item as? URL { + self.stageAndAppend(url: url) + } else if let data = item as? Data, + let s = String(data: data, encoding: .utf8), + let url = URL(string: s.trimmingCharacters(in: .whitespacesAndNewlines)) { + self.stageAndAppend(url: url) + } + } + continue + } + + if provider.hasItemConformingToTypeIdentifier(imageId) { + provider.loadFileRepresentation(forTypeIdentifier: imageId) { url, error in + if let error { print("Drop load image error: \(error)"); return } + guard let url else { return } + self.stageAndAppend(url: url) + } + continue + } + } + } + + private func stageAndAppend(url: URL) { + guard let att = AttachmentManager.shared.stageIfImage(url: url) else { return } + Task { @MainActor in attachments.append(att) } + } + } + + struct Separator: View + { + var body: some View { + Rectangle() + .fill(.separator) + .frame(height: 1.0) + } + } + + struct AttachmentWell: View + { + @Binding var attachments: [OutgoingAttachment] + + var body: some View { + ScrollView(.horizontal) { + LazyHStack(spacing: 8.0) { + ForEach(attachments) { attachment in + ZStack(alignment: .topTrailing) { + AttachmentThumbnail(attachment: attachment) + .clipShape(RoundedRectangle(cornerRadius: 8.0)) + .overlay { + RoundedRectangle(cornerRadius: 8.0) + .stroke(.separator) + } + + Button { + if let idx = attachments.firstIndex(where: { $0.id == attachment.id }) { + attachments.remove(at: idx) + } + } label: { + Image(systemName: "xmark.circle.fill") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .padding(6.0) + } + } + + Spacer() + } + .padding(8.0) + } + } + } + + struct AttachmentThumbnail: View { + let attachment: OutgoingAttachment + + var body: some View { + ZStack { + if let img = NSImage(contentsOf: attachment.stagedURL) { + Image(nsImage: img) + .resizable() + .scaledToFill() + .frame(width: 180.0, height: 120.0) + .clipped() + .background(.quaternary) + } else { + ZStack { + Rectangle() + .fill(.quaternary) + Image(systemName: "photo") + .font(.title2) + .foregroundStyle(.secondary) + } + .frame(width: 180.0, height: 120.0) + } + + switch attachment.state { + case .uploading(let progress): + VStack { + Spacer() + ProgressView(value: progress) + .progressViewStyle(.linear) + .tint(.accentColor) + .padding(.horizontal, 8.0) + .padding(.bottom, 6.0) + } + case .failed: + HStack { + Spacer() + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + .padding(6.0) + } + default: + EmptyView() + } + } + } } }