// // MessageEntryView.swift // kordophone2 // // Created by James Magahern on 8/24/25. // import AppKit import SwiftUI import UniformTypeIdentifiers struct MessageEntryView: View { @Binding var viewModel: ViewModel @Environment(\.selectedConversation) private var selectedConversation var body: some View { VStack(spacing: 0.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) .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) } .disabled(viewModel.draftText.isEmpty && viewModel.uploadedAttachmentGUIDs.isEmpty) .keyboardShortcut(.defaultAction) } .padding(10.0) } .onChange(of: selectedConversation) { oldValue, newValue in if oldValue?.id != newValue?.id { viewModel.draftText = "" } } } // MARK: - Types @Observable class ViewModel { var draftText: String = "" var attachments: [OutgoingAttachment] = [] var isDropTargeted: Bool = false var uploadedAttachmentGUIDs: Set { attachments.reduce(Set()) { partialResult, attachment in switch attachment.state { case .uploaded(let guid): partialResult.union([ guid ]) default: partialResult } } } private let client = XPCClient() private var uploadGuidToAttachmentId: [String: String] = [:] private var signalTask: Task? = nil init() { signalTask = Task { [weak self] in guard let self else { return } for await signal in client.eventStream() { switch signal { case .attachmentUploaded(let uploadGuid, let attachmentGuid): // Mark local attachment as uploaded when the daemon confirms await MainActor.run { if let localId = self.uploadGuidToAttachmentId[uploadGuid], let idx = self.attachments.firstIndex(where: { $0.id == localId }) { self.attachments[idx].state = .uploaded(attachmentGuid) self.uploadGuidToAttachmentId.removeValue(forKey: uploadGuid) } } default: break } } } } deinit { signalTask?.cancel() } func sendDraft(to convo: Display.Conversation?) { guard let convo else { return } guard !(draftText.isEmpty && uploadedAttachmentGUIDs.isEmpty) else { return } let messageText = self.draftText .trimmingCharacters(in: .whitespacesAndNewlines) let transferGuids = self.uploadedAttachmentGUIDs self.draftText = "" self.attachments = [] Task { do { try await client.sendMessage( conversationId: convo.id, message: messageText, transferGuids: transferGuids ) } catch { print("Sending error: \(error)") } } } // 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) startUploadIfNeeded(for: att.id) } } private func startUploadIfNeeded(for attachmentId: String) { guard let idx = attachments.firstIndex(where: { $0.id == attachmentId }) else { return } let attachment = attachments[idx] switch attachment.state { case .staged, .failed: attachments[idx].state = .uploading(progress: 0.0) Task { do { let guid = try await client.uploadAttachment(path: attachment.stagedURL.path) await MainActor.run { self.uploadGuidToAttachmentId[guid] = attachmentId } } catch { await MainActor.run { if let i = self.attachments.firstIndex(where: { $0.id == attachmentId }) { self.attachments[i].state = .failed } } } } default: break } } } 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() } } } } } #Preview { @Previewable @State var model = MessageEntryView.ViewModel() VStack { Spacer() MessageEntryView(viewModel: $model) } }