Private
Public Access
1
0

UI support for uploading image attachments

This commit is contained in:
2025-08-30 21:52:30 -06:00
parent 236070ccc9
commit fc02d86a68
3 changed files with 265 additions and 17 deletions

View File

@@ -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
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -5,7 +5,9 @@
// Created by James Magahern on 8/24/25.
//
import AppKit
import SwiftUI
import UniformTypeIdentifiers
struct MessageEntryView: View
{
@@ -14,11 +16,17 @@ struct MessageEntryView: View
var body: some View {
VStack(spacing: 0.0) {
Rectangle()
.fill(.separator)
.frame(height: 1.0)
Separator()
HStack {
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)
@@ -27,8 +35,9 @@ struct MessageEntryView: View
.fixedSize(horizontal: false, vertical: true)
.font(.body)
.scrollDisabled(true)
.padding(8.0)
.disabled(selectedConversation == nil)
}
.padding(8.0)
.background {
RoundedRectangle(cornerRadius: 8.0)
.stroke(SeparatorShapeStyle())
@@ -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()
}
}
}
}
}