UI support for uploading image attachments
This commit is contained in:
101
kordophone2/Attachments.swift
Normal file
101
kordophone2/Attachments.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct ConversationView: View
|
struct ConversationView: View
|
||||||
{
|
{
|
||||||
@@ -17,5 +18,10 @@ struct ConversationView: View
|
|||||||
TranscriptView(model: $transcriptModel)
|
TranscriptView(model: $transcriptModel)
|
||||||
MessageEntryView(viewModel: $entryModel)
|
MessageEntryView(viewModel: $entryModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onDrop(of: [UTType.image, UTType.fileURL], isTargeted: $entryModel.isDropTargeted) { providers in
|
||||||
|
entryModel.handleDroppedProviders(providers)
|
||||||
|
return true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
// Created by James Magahern on 8/24/25.
|
// Created by James Magahern on 8/24/25.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import AppKit
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct MessageEntryView: View
|
struct MessageEntryView: View
|
||||||
{
|
{
|
||||||
@@ -14,27 +16,34 @@ struct MessageEntryView: View
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0.0) {
|
VStack(spacing: 0.0) {
|
||||||
Rectangle()
|
Separator()
|
||||||
.fill(.separator)
|
|
||||||
.frame(height: 1.0)
|
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
TextField("iMessage", text: $viewModel.draftText, axis: .vertical)
|
VStack {
|
||||||
.focusEffectDisabled(true)
|
if !viewModel.attachments.isEmpty {
|
||||||
.textFieldStyle(.plain)
|
AttachmentWell(attachments: $viewModel.attachments)
|
||||||
.lineLimit(nil)
|
.frame(height: 150.0)
|
||||||
.scrollContentBackground(.hidden)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
Separator()
|
||||||
.font(.body)
|
|
||||||
.scrollDisabled(true)
|
|
||||||
.padding(8.0)
|
|
||||||
.disabled(selectedConversation == nil)
|
|
||||||
.background {
|
|
||||||
RoundedRectangle(cornerRadius: 8.0)
|
|
||||||
.stroke(SeparatorShapeStyle())
|
|
||||||
.fill(.background)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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") {
|
Button("Send") {
|
||||||
viewModel.sendDraft(to: selectedConversation)
|
viewModel.sendDraft(to: selectedConversation)
|
||||||
}
|
}
|
||||||
@@ -55,6 +64,8 @@ struct MessageEntryView: View
|
|||||||
class ViewModel
|
class ViewModel
|
||||||
{
|
{
|
||||||
var draftText: String = ""
|
var draftText: String = ""
|
||||||
|
var attachments: [OutgoingAttachment] = []
|
||||||
|
var isDropTargeted: Bool = false
|
||||||
|
|
||||||
func sendDraft(to convo: Display.Conversation?) {
|
func sendDraft(to convo: Display.Conversation?) {
|
||||||
guard let convo else { return }
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user