UI support for uploading image attachments
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user