From f0029d02e17e01e04967f215b76d3000e02b267b Mon Sep 17 00:00:00 2001 From: James Magahern Date: Sun, 24 Aug 2025 23:38:35 -0700 Subject: [PATCH] Implements attachment previewing --- kordophone2/App.swift | 2 +- kordophone2/ChatTranscriptView.swift | 165 ------------------ kordophone2/ConversationView.swift | 4 +- kordophone2/MessageEntryView.swift | 1 + kordophone2/Models.swift | 122 +++++++++++-- .../TranscriptDisplayItemViews.swift | 148 ++++++++++++++++ .../Transcript/TranscriptDisplayItems.swift | 61 +++++++ kordophone2/Transcript/TranscriptView.swift | 152 ++++++++++++++++ kordophone2/XPC/XPCClient.swift | 23 +++ 9 files changed, 500 insertions(+), 178 deletions(-) delete mode 100644 kordophone2/ChatTranscriptView.swift create mode 100644 kordophone2/Transcript/TranscriptDisplayItemViews.swift create mode 100644 kordophone2/Transcript/TranscriptDisplayItems.swift create mode 100644 kordophone2/Transcript/TranscriptView.swift diff --git a/kordophone2/App.swift b/kordophone2/App.swift index d498542..7290120 100644 --- a/kordophone2/App.swift +++ b/kordophone2/App.swift @@ -11,7 +11,7 @@ import SwiftUI struct KordophoneApp: App { @State var conversationListModel = ConversationListView.ViewModel() - @State var transcriptViewModel = ChatTranscriptView.ViewModel() + @State var transcriptViewModel = TranscriptView.ViewModel() @State var entryViewModel = MessageEntryView.ViewModel() private let xpcClient = XPCClient() diff --git a/kordophone2/ChatTranscriptView.swift b/kordophone2/ChatTranscriptView.swift deleted file mode 100644 index 52880b4..0000000 --- a/kordophone2/ChatTranscriptView.swift +++ /dev/null @@ -1,165 +0,0 @@ -// -// ChatTranscriptView.swift -// kordophone2 -// -// Created by James Magahern on 8/24/25. -// - -import SwiftUI - -struct ChatTranscriptView: View -{ - @Binding var model: ViewModel - - @Environment(\.xpcClient) private var xpcClient - - var body: some View { - ScrollView { - LazyVStack(spacing: 17.0) { - ForEach($model.messages.reversed()) { message in - MessageCellView(message: message) - .id(message.id) - .scaleEffect(CGSize(width: 1.0, height: -1.0)) - .transition( - .push(from: .bottom) - .combined(with: .opacity) - ) - } - } - .padding() - } - .scaleEffect(CGSize(width: 1.0, height: -1.0)) - .task { await watchForMessageListChanges() } - } - - private func watchForMessageListChanges() async { - for await event in xpcClient.eventStream() { - if case let .messagesUpdated(conversationId) = event { - if conversationId == model.displayedConversation { - model.setNeedsReload() - } - } - } - } - - // MARK: - Types - - @Observable - class ViewModel - { - var messages: [Display.Message] - var displayedConversation: Display.Conversation.ID? = nil - var needsReload: Bool = true - - init(messages: [Display.Message] = []) { - self.messages = messages - observeDisplayedConversation() - } - - private func observeDisplayedConversation() { - withObservationTracking { - _ = displayedConversation - } onChange: { - Task { @MainActor [weak self] in - guard let self else { return } - - setNeedsReload() - observeDisplayedConversation() - } - } - } - - func setNeedsReload() { - needsReload = true - Task { @MainActor [weak self] in - guard let self else { return } - await reloadMessages() - } - } - - func reloadMessages() async { - guard needsReload else { return } - needsReload = false - - guard let displayedConversation else { return } - - do { - let client = XPCClient() - let clientMessages = try await client.getMessages(conversationId: displayedConversation) - .map { Display.Message(from: $0) } - - let newMessages = Set(clientMessages).subtracting(Set(self.messages)) - - let animation: Animation? = newMessages.count == 1 ? .default : nil - withAnimation(animation) { - self.messages = clientMessages - } - } catch { - print("Message fetch error: \(error)") - } - } - } -} - -struct MessageCellView: View -{ - @Binding var message: Display.Message - - var body: some View { - VStack { - HStack(alignment: .bottom) { - if message.isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } - - TextCellContentView(text: message.text, isFromMe: message.isFromMe) - .mask { - UnevenRoundedRectangle(cornerRadii: RectangleCornerRadii( - topLeading: message.isFromMe ? .dominantCornerRadius : .minorCornerRadius, - bottomLeading: .dominantCornerRadius, - bottomTrailing: .dominantCornerRadius, - topTrailing: message.isFromMe ? .minorCornerRadius : .dominantCornerRadius, - )) - } - - if !message.isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } - } - } - } - - // MARK: - Types - - private struct TextCellContentView: View - { - let text: String - let isFromMe: Bool - - var body: some View { - let bubbleColor: Color = isFromMe ? .blue : Color(.systemGray) - let textColor: Color = isFromMe ? .white : .primary - - HStack { - Text(text) - .foregroundStyle(textColor) - .multilineTextAlignment(.leading) - } - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 16.0) - .padding(.vertical, 10.0) - .background(bubbleColor) - } - } -} - -fileprivate extension CGFloat { - static let dominantCornerRadius = 16.0 - static let minorCornerRadius = 4.0 - static let minimumBubbleHorizontalPadding = 80.0 -} - -#Preview { - @Previewable @State var model = ChatTranscriptView.ViewModel(messages: [ - .init(sender: .me, text: "Hello, how are you?"), - .init(sender: .counterpart("Bob"), text: "I am doing fine!") - ]) - - ChatTranscriptView(model: $model) -} diff --git a/kordophone2/ConversationView.swift b/kordophone2/ConversationView.swift index bf6044f..69dddd9 100644 --- a/kordophone2/ConversationView.swift +++ b/kordophone2/ConversationView.swift @@ -9,12 +9,12 @@ import SwiftUI struct ConversationView: View { - @Binding var transcriptModel: ChatTranscriptView.ViewModel + @Binding var transcriptModel: TranscriptView.ViewModel @Binding var entryModel: MessageEntryView.ViewModel var body: some View { VStack { - ChatTranscriptView(model: $transcriptModel) + TranscriptView(model: $transcriptModel) MessageEntryView(viewModel: $entryModel) } } diff --git a/kordophone2/MessageEntryView.swift b/kordophone2/MessageEntryView.swift index 67844d8..0b04aa8 100644 --- a/kordophone2/MessageEntryView.swift +++ b/kordophone2/MessageEntryView.swift @@ -21,6 +21,7 @@ struct MessageEntryView: View HStack { TextField("", text: $viewModel.draftText, axis: .vertical) .focusEffectDisabled(true) + .textFieldStyle(.plain) .lineLimit(nil) .scrollContentBackground(.hidden) .fixedSize(horizontal: false, vertical: true) diff --git a/kordophone2/Models.swift b/kordophone2/Models.swift index a145218..0e48397 100644 --- a/kordophone2/Models.swift +++ b/kordophone2/Models.swift @@ -42,26 +42,35 @@ enum Display let id: String let sender: Sender let text: String + let date: Date + let attachments: [ImageAttachment] - var isFromMe: Bool { - if case .me = sender { true } - else { false } - } + var isFromMe: Bool { sender.isMe } init(from m: Serialized.Message) { self.id = m.guid self.text = m.text - self.sender = if m.sender == "(Me)" { + self.date = m.date + + let sender: Sender = if m.sender == "(Me)" { .me } else { .counterpart(m.sender) } + + self.attachments = m.attachments.map { attachment in + ImageAttachment(from: attachment, sender: sender) + } + + self.sender = sender } - init(id: String = UUID().uuidString, sender: Sender = .me, text: String) { + init(id: String = UUID().uuidString, sender: Sender = .me, date: Date = .now, text: String) { self.id = id self.sender = sender self.text = text + self.date = date + self.attachments = [] } static func == (lhs: Message, rhs: Message) -> Bool { @@ -71,11 +80,44 @@ enum Display func hash(into hasher: inout Hasher) { hasher.combine(id) } + } + + struct ImageAttachment: Identifiable + { + let id: String + let sender: Sender + let data: Serialized.Attachment - enum Sender - { - case me - case counterpart(String) + var size: CGSize? { + if let attr = data.metadata?.attributionInfo, let width = attr.width, let height = attr.height { + return CGSize(width: width, height: height) + } + + return nil + } + + var isPreviewDownloaded: Bool { + data.isPreviewDownloaded + } + + var previewPath: String { + data.previewPath + } + + init(from serialized: Serialized.Attachment, sender: Sender) { + self.id = serialized.guid + self.sender = sender + self.data = serialized + } + } + + enum Sender + { + case me + case counterpart(String) + + var isMe: Bool { + if case .me = self { true } else { false } } } } @@ -116,6 +158,7 @@ enum Serialized let sender: String let text: String let date: Date + let attachments: [Attachment] init?(xpc dict: xpc_object_t) { @@ -124,11 +167,70 @@ enum Serialized let s: String = d["sender"] ?? "" let t: String = d["text"] ?? "" let dd: Date = d["date"] ?? Date(timeIntervalSince1970: 0) + let atts: [Attachment] = d["attachments"] ?? [] self.guid = g self.sender = s self.text = t self.date = dd + self.attachments = atts + } + } + + struct Attachment: Decodable + { + let guid: String + let path: String + let previewPath: String + let isDownloaded: Bool + let isPreviewDownloaded: Bool + let metadata: Metadata? + + struct Metadata: Decodable + { + let attributionInfo: AttributionInfo? + } + + struct AttributionInfo: Decodable + { + let width: Int? + let height: Int? } } } + +extension Serialized.Attachment: XPCConvertible +{ + static func fromXPC(_ value: xpc_object_t) -> Serialized.Attachment? { + guard let d = XPCDictionary(value), let guid: String = d["guid"] else { return nil } + + let path: String = d["path"] ?? "" + let previewPath: String = d["preview_path"] ?? "" + + // Booleans are encoded as strings in XPC + let downloadedStr: String = d["downloaded"] ?? "false" + let previewDownloadedStr: String = d["preview_downloaded"] ?? "false" + let isDownloaded = downloadedStr == "true" + let isPreviewDownloaded = previewDownloadedStr == "true" + + var metadata: Serialized.Attachment.Metadata? = nil + if let metadataObj = d.object("metadata"), let md = XPCDictionary(metadataObj) { + var attribution: Serialized.Attachment.AttributionInfo? = nil + if let attrObj = md.object("attribution_info"), let ad = XPCDictionary(attrObj) { + let width: Int? = ad["width"] + let height: Int? = ad["height"] + attribution = Serialized.Attachment.AttributionInfo(width: width, height: height) + } + metadata = Serialized.Attachment.Metadata(attributionInfo: attribution) + } + + return Serialized.Attachment( + guid: guid, + path: path, + previewPath: previewPath, + isDownloaded: isDownloaded, + isPreviewDownloaded: isPreviewDownloaded, + metadata: metadata + ) + } +} diff --git a/kordophone2/Transcript/TranscriptDisplayItemViews.swift b/kordophone2/Transcript/TranscriptDisplayItemViews.swift new file mode 100644 index 0000000..896834a --- /dev/null +++ b/kordophone2/Transcript/TranscriptDisplayItemViews.swift @@ -0,0 +1,148 @@ +// +// TranscriptDisplayItemViews.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import SwiftUI + +struct BubbleView: View +{ + let isFromMe: Bool + let content: () -> Content + + init(isFromMe: Bool, @ViewBuilder content: @escaping () -> Content) { + self.isFromMe = isFromMe + self.content = content + } + + var body: some View { + VStack { + HStack(alignment: .bottom) { + if isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } + + content() + .mask { + UnevenRoundedRectangle(cornerRadii: RectangleCornerRadii( + topLeading: isFromMe ? .dominantCornerRadius : .minorCornerRadius, + bottomLeading: .dominantCornerRadius, + bottomTrailing: .dominantCornerRadius, + topTrailing: isFromMe ? .minorCornerRadius : .dominantCornerRadius, + )) + } + + if !isFromMe { Spacer(minLength: .minimumBubbleHorizontalPadding) } + } + } + } +} + +struct TextBubbleItemView: View +{ + let text: String + let isFromMe: Bool + + var body: some View { + let bubbleColor: Color = isFromMe ? .blue : Color(.systemGray) + let textColor: Color = isFromMe ? .white : .primary + + BubbleView(isFromMe: isFromMe) { + HStack { + Text(text) + .foregroundStyle(textColor) + .multilineTextAlignment(.leading) + } + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, 16.0) + .padding(.vertical, 10.0) + .background(bubbleColor) + } + } +} + +struct ImageItemView: View +{ + let isFromMe: Bool + let attachment: Display.ImageAttachment + + @State private var img: NSImage? + @Environment(\.xpcClient) var xpcClient + + var body: some View { + BubbleView(isFromMe: isFromMe) { + if let img { + Image(nsImage: img) + .resizable() + .scaledToFit() + } else { + ProgressView() + } + } + .task { + do { + let handle = try await xpcClient.openAttachmentFileHandle( + attachmentId: attachment.id, + preview: true + ) + + try? handle.seek(toOffset: 0) + if let data = try? handle.readToEnd(), + let ns = NSImage(data: data) { + img = ns + } + } catch { + print("Attachment file handle acquisition error: \(error)") + } + } + } +} + +struct PlaceholderImageItemView: View +{ + let isFromMe: Bool + let size: CGSize + + init(isFromMe: Bool, size: CGSize?) { + self.isFromMe = isFromMe + self.size = size ?? CGSize(width: 250.0, height: 100.0) + } + + var body: some View { + BubbleView(isFromMe: isFromMe) { + Color.gray + .frame(width: size.width, height: size.height) + } + } +} + +struct DateItemView: View +{ + let date: Date + + private let formatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "EEEE HH:mm" + + return f + }() + + var body: some View { + VStack { + Spacer(minLength: 34.0) + + Text(formatter.string(from: date)) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.vertical, 4) + + Spacer() + } + } +} + +fileprivate extension CGFloat { + static let dominantCornerRadius = 16.0 + static let minorCornerRadius = 4.0 + static let minimumBubbleHorizontalPadding = 80.0 +} diff --git a/kordophone2/Transcript/TranscriptDisplayItems.swift b/kordophone2/Transcript/TranscriptDisplayItems.swift new file mode 100644 index 0000000..4d038d7 --- /dev/null +++ b/kordophone2/Transcript/TranscriptDisplayItems.swift @@ -0,0 +1,61 @@ +// +// TranscriptDisplayItems.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import Foundation +import SwiftUI + +extension TranscriptView.ViewModel +{ + internal func rebuildDisplayItems(animated: Bool = false) { + var displayItems: [DisplayItem] = [] + var lastDate: Date = .distantPast + + let client = XPCClient() + let dateAnnotationTimeInterval: TimeInterval = 60 * 60 * 30 // 30m + for message in messages { + if message.date.timeIntervalSince(lastDate) > dateAnnotationTimeInterval { + lastDate = message.date + displayItems.append(.date(message.date)) + } + + for attachment in message.attachments { + displayItems.append(.attachment(attachment)) + + if !attachment.isPreviewDownloaded { + Task.detached { + try await client.downloadAttachment(attachmentId: attachment.id, preview: true) + } + } + } + + displayItems.append(.message(message)) + } + + let animation: Animation? = animated ? .default : nil + withAnimation(animation) { + self.displayItems = displayItems + } + } +} + +enum DisplayItem: Identifiable +{ + case message(Display.Message) + case attachment(Display.ImageAttachment) + case date(Date) + + var id: String { + switch self { + case .message(let message): + message.id + case .attachment(let attachment): + attachment.id + case .date(let date): + date.description + } + } +} diff --git a/kordophone2/Transcript/TranscriptView.swift b/kordophone2/Transcript/TranscriptView.swift new file mode 100644 index 0000000..535f7e2 --- /dev/null +++ b/kordophone2/Transcript/TranscriptView.swift @@ -0,0 +1,152 @@ +// +// TranscriptView.swift +// kordophone2 +// +// Created by James Magahern on 8/24/25. +// + +import SwiftUI + +struct TranscriptView: View +{ + @Binding var model: ViewModel + + @Environment(\.xpcClient) private var xpcClient + + var body: some View { + ScrollView { + LazyVStack(spacing: 17.0) { + ForEach($model.displayItems.reversed()) { item in + displayItemView(item.wrappedValue) + .id(item.id) + .scaleEffect(CGSize(width: 1.0, height: -1.0)) + .transition( + .push(from: .bottom) + .combined(with: .opacity) + ) + } + } + .padding() + } + .scaleEffect(CGSize(width: 1.0, height: -1.0)) + .task { await watchForMessageListChanges() } + } + + private func watchForMessageListChanges() async { + for await event in xpcClient.eventStream() { + switch event { + case .attachmentDownloaded(let attachmentId): + model.attachmentDownloaded(id: attachmentId) + case .messagesUpdated(let conversationId): + if conversationId == model.displayedConversation { + model.setNeedsReload(animated: true) + } + case .updateStreamReconnected: + model.setNeedsReload(animated: false) + default: + break + } + } + } + + @ViewBuilder + private func displayItemView(_ item: DisplayItem) -> some View { + switch item { + case .message(let message): + TextBubbleItemView(text: message.text, isFromMe: message.isFromMe) + case .date(let date): + DateItemView(date: date) + case .attachment(let attachment): + if attachment.isPreviewDownloaded { + ImageItemView( + isFromMe: attachment.sender.isMe, + attachment: attachment + ) + } else { + PlaceholderImageItemView( + isFromMe: attachment.sender.isMe, + size: attachment.size + ) + } + } + } + + // MARK: - Types + + @Observable + class ViewModel + { + var displayItems: [DisplayItem] = [] + var displayedConversation: Display.Conversation.ID? = nil + + internal var needsReload: NeedsReload = .yes(false) + internal var messages: [Display.Message] + + init(messages: [Display.Message] = []) { + self.messages = messages + observeDisplayedConversation() + rebuildDisplayItems() + } + + func setNeedsReload(animated: Bool) { + needsReload = .yes(animated) + Task { @MainActor [weak self] in + guard let self else { return } + await reloadMessages() + } + } + + func attachmentDownloaded(id: String) { + // TODO: should be smarter here + setNeedsReload(animated: true) + } + + private func observeDisplayedConversation() { + withObservationTracking { + _ = displayedConversation + } onChange: { + Task { @MainActor [weak self] in + guard let self else { return } + + setNeedsReload(animated: false) + observeDisplayedConversation() + } + } + } + + private func reloadMessages() async { + guard case .yes(let animated) = needsReload else { return } + needsReload = .no + + guard let displayedConversation else { return } + + do { + let client = XPCClient() + let clientMessages = try await client.getMessages(conversationId: displayedConversation) + .map { Display.Message(from: $0) } + + self.messages = clientMessages + self.rebuildDisplayItems(animated: animated) + } catch { + print("Message fetch error: \(error)") + } + } + + // MARK: - Types + + enum NeedsReload + { + case no + case yes(Bool) // animated + } + } +} + +#Preview { + @Previewable @State var model = TranscriptView.ViewModel(messages: [ + .init(sender: .me, text: "Hello, how are you?"), + .init(sender: .counterpart("Bob"), text: "I am doing fine!") + ]) + + TranscriptView(model: $model) +} diff --git a/kordophone2/XPC/XPCClient.swift b/kordophone2/XPC/XPCClient.swift index b3ae6cb..dcb8434 100644 --- a/kordophone2/XPC/XPCClient.swift +++ b/kordophone2/XPC/XPCClient.swift @@ -114,6 +114,29 @@ final class XPCClient let req = makeRequest(method: "SendMessage", arguments: args) guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError } } + + public func downloadAttachment(attachmentId: String, preview: Bool) async throws { + var args: [String: xpc_object_t] = [:] + args["attachment_id"] = xpcString(attachmentId) + args["preview"] = xpcString(preview ? "true" : "false") + + let req = makeRequest(method: "DownloadAttachment", arguments: args) + _ = try await sendSync(req) + } + + public func openAttachmentFileHandle(attachmentId: String, preview: Bool) async throws -> FileHandle { + var args: [String: xpc_object_t] = [:] + args["attachment_id"] = xpcString(attachmentId) + args["preview"] = xpcString(preview ? "true" : "false") + + let req = makeRequest(method: "OpenAttachmentFd", arguments: args) + guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError } + + let fd = xpc_dictionary_dup_fd(reply, "fd") + if fd < 0 { throw Error.typeError } + + return FileHandle(fileDescriptor: fd, closeOnDealloc: true) + } // MARK: - Types