303 lines
11 KiB
Swift
303 lines
11 KiB
Swift
//
|
|
// 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<String> {
|
|
attachments.reduce(Set<String>()) { 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<Void, Never>? = 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)
|
|
}
|
|
}
|