Private
Public Access
1
0
Files
Kordophone/osx/kordophone2/MessageEntryView.swift

304 lines
11 KiB
Swift
Raw Permalink Normal View History

2025-08-24 17:58:37 -07:00
//
// MessageEntryView.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import AppKit
2025-08-24 17:58:37 -07:00
import SwiftUI
import UniformTypeIdentifiers
2025-08-24 17:58:37 -07:00
struct MessageEntryView: View
{
@Binding var viewModel: ViewModel
@Environment(\.selectedConversation) private var selectedConversation
var body: some View {
VStack(spacing: 0.0) {
Separator()
2025-08-24 17:58:37 -07:00
HStack {
VStack {
if !viewModel.attachments.isEmpty {
AttachmentWell(attachments: $viewModel.attachments)
.frame(height: 150.0)
Separator()
2025-08-24 17:58:37 -07:00
}
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)
2025-09-10 14:41:24 -07:00
.id("messageEntry")
}
.padding(8.0)
.background {
RoundedRectangle(cornerRadius: 8.0)
.stroke(SeparatorShapeStyle())
.fill(.background)
}
2025-08-24 17:58:37 -07:00
Button("Send") {
viewModel.sendDraft(to: selectedConversation)
}
2025-09-03 22:38:26 -07:00
.disabled(viewModel.draftText.isEmpty && viewModel.uploadedAttachmentGUIDs.isEmpty)
2025-08-24 17:58:37 -07:00
.keyboardShortcut(.defaultAction)
}
.padding(10.0)
}
2025-08-25 00:37:48 -07:00
.onChange(of: selectedConversation) { oldValue, newValue in
2025-09-03 17:08:54 -07:00
if oldValue?.id != newValue?.id {
viewModel.draftText = ""
}
2025-08-25 00:37:48 -07:00
}
2025-08-24 17:58:37 -07:00
}
// MARK: - Types
@Observable
class ViewModel
{
var draftText: String = ""
var attachments: [OutgoingAttachment] = []
var isDropTargeted: Bool = false
2025-08-24 17:58:37 -07:00
2025-09-03 22:38:26 -07:00
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() }
2025-08-24 17:58:37 -07:00
func sendDraft(to convo: Display.Conversation?) {
guard let convo else { return }
2025-09-03 22:38:26 -07:00
guard !(draftText.isEmpty && uploadedAttachmentGUIDs.isEmpty) else { return }
2025-08-24 17:58:37 -07:00
let messageText = self.draftText
2025-08-24 18:41:42 -07:00
.trimmingCharacters(in: .whitespacesAndNewlines)
2025-09-03 22:38:26 -07:00
let transferGuids = self.uploadedAttachmentGUIDs
2025-08-24 17:58:37 -07:00
self.draftText = ""
2025-09-03 22:38:26 -07:00
self.attachments = []
2025-08-24 17:58:37 -07:00
Task {
do {
2025-09-03 22:38:26 -07:00
try await client.sendMessage(
conversationId: convo.id,
message: messageText,
transferGuids: transferGuids
)
2025-08-24 17:58:37 -07:00
} 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 }
2025-09-03 22:38:26 -07:00
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)
}
}
}
2025-09-03 22:38:26 -07:00
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()
}
}
}
2025-08-24 17:58:37 -07:00
}
}
#Preview {
@Previewable @State var model = MessageEntryView.ViewModel()
VStack {
Spacer()
MessageEntryView(viewModel: $model)
}
}