From 1a5f13f2b879d069d3cfdf2a7fc79882773c23f5 Mon Sep 17 00:00:00 2001 From: James Magahern Date: Fri, 12 Sep 2025 18:17:58 -0700 Subject: [PATCH] osx: implements quicklook --- osx/kordophone2/Models.swift | 10 +++- osx/kordophone2/Transcript/PreviewPanel.swift | 39 ++++++++++++ .../TranscriptDisplayItemViews.swift | 60 +++++++++++++++---- osx/kordophone2/XPC/XPCClient.swift | 32 +++++++++- osx/kordophone2/XPC/XPCConvertible.swift | 16 +++++ 5 files changed, 143 insertions(+), 14 deletions(-) create mode 100644 osx/kordophone2/Transcript/PreviewPanel.swift diff --git a/osx/kordophone2/Models.swift b/osx/kordophone2/Models.swift index b0c8780..1cf28fb 100644 --- a/osx/kordophone2/Models.swift +++ b/osx/kordophone2/Models.swift @@ -115,7 +115,15 @@ enum Display var previewPath: String { data.previewPath } - + + var isFullsizeDownloaded: Bool { + data.isDownloaded + } + + var fullsizePath: String { + data.path + } + init(from serialized: Serialized.Attachment, dateSent: Date, sender: Sender) { self.id = serialized.guid self.sender = sender diff --git a/osx/kordophone2/Transcript/PreviewPanel.swift b/osx/kordophone2/Transcript/PreviewPanel.swift new file mode 100644 index 0000000..abf7b59 --- /dev/null +++ b/osx/kordophone2/Transcript/PreviewPanel.swift @@ -0,0 +1,39 @@ +// +// PreviewPanel.swift +// Kordophone +// +// Created by James Magahern on 9/12/25. +// + +import AppKit +import QuickLook +import QuickLookUI + +internal class PreviewPanel +{ + static let shared = PreviewPanel() + + private var displayedURL: URL? = nil + private var impl: QLPreviewPanel { QLPreviewPanel.shared() } + + private init() { + impl.dataSource = self + } + + public func show(url: URL) { + self.displayedURL = url + impl.makeKeyAndOrderFront(self) + } +} + + +extension PreviewPanel: QLPreviewPanelDataSource +{ + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { + 1 + } + + func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { + return displayedURL! as NSURL + } +} diff --git a/osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift b/osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift index 981361c..387a203 100644 --- a/osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift +++ b/osx/kordophone2/Transcript/TranscriptDisplayItemViews.swift @@ -90,7 +90,8 @@ struct ImageItemView: View @Environment(\.xpcClient) var xpcClient @State private var containerWidth: CGFloat? = nil - + @State private var isDownloadingFullAttachment: Bool = false + private var aspectRatio: CGFloat { attachment.size?.aspectRatio ?? 1.0 } @@ -102,17 +103,36 @@ struct ImageItemView: View var body: some View { BubbleView(sender: sender, date: date) { let maxWidth = CGFloat.minimum(.imageMaxWidth, containerWidth ?? .imageMaxWidth) - if let img { - Image(nsImage: img) - .resizable() - .scaledToFit() - .frame(maxWidth: maxWidth) - } else { - Rectangle() - .fill(.gray.opacity(0.4)) - .frame(width: preferredWidth, height: preferredWidth / aspectRatio) - .frame(maxWidth: maxWidth) + + Group { + if let img { + Image(nsImage: img) + .resizable() + .scaledToFit() + .frame(maxWidth: maxWidth) + } else { + Rectangle() + .fill(.gray.opacity(0.4)) + .frame(width: preferredWidth, height: preferredWidth / aspectRatio) + .frame(maxWidth: maxWidth) + } } + + // Download indicator + .overlay { + if isDownloadingFullAttachment { + ZStack { + Rectangle() + .fill(.black.opacity(0.2)) + + ProgressView() + .progressViewStyle(.circular) + } + } + } + } + .onTapGesture(count: 2) { + openAttachment() } .onGeometryChange(for: CGFloat.self, of: { $0.size.width }, @@ -136,6 +156,24 @@ struct ImageItemView: View } } } + + private func openAttachment() { + Task { + var path = attachment.fullsizePath + if !attachment.isFullsizeDownloaded { + isDownloadingFullAttachment = true + try await xpcClient.downloadAttachment(attachmentId: attachment.id, preview: false, awaitCompletion: true) + + // Need to re-fetch this -- the extension may have changed. + let info = try await xpcClient.getAttachmentInfo(attachmentId: attachment.id) + path = info.path + + isDownloadingFullAttachment = false + } + + PreviewPanel.shared.show(url: URL(filePath: path)) + } + } } struct PlaceholderImageItemView: View diff --git a/osx/kordophone2/XPC/XPCClient.swift b/osx/kordophone2/XPC/XPCClient.swift index a0a5f09..5e81740 100644 --- a/osx/kordophone2/XPC/XPCClient.swift +++ b/osx/kordophone2/XPC/XPCClient.swift @@ -146,13 +146,33 @@ final class XPCClient 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 { + public func getAttachmentInfo(attachmentId: String) async throws -> AttachmentInfo { + var args: [String: xpc_object_t] = [:] + args["attachment_id"] = xpcString(attachmentId) + + let req = makeRequest(method: "GetAttachmentInfo", arguments: args) + guard let reply = try await sendSync(req), xpc_get_type(reply) == XPC_TYPE_DICTIONARY else { throw Error.typeError } + + return AttachmentInfo( + path: reply["path"] ?? "", + previewPath: reply["preview_path"] ?? "", + isDownloaded: reply["is_downloaded"] ?? false, + isPreviewDownloaded: reply["is_preview_downloaded"] ?? false + ) + } + + public func downloadAttachment(attachmentId: String, preview: Bool, awaitCompletion: Bool = false) 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) + + if awaitCompletion { + // Wait for downloaded event + let _ = await eventStream().first { $0 == .attachmentDownloaded(attachmentId: attachmentId) } + } } public func uploadAttachment(path: String) async throws -> String { @@ -200,6 +220,14 @@ final class XPCClient } // MARK: - Types + + struct AttachmentInfo: Decodable + { + let path: String + let previewPath: String + let isDownloaded: Bool + let isPreviewDownloaded: Bool + } enum Error: Swift.Error { @@ -209,7 +237,7 @@ final class XPCClient case connectionError } - enum Signal + enum Signal: Equatable { case conversationsUpdated case messagesUpdated(conversationId: String) diff --git a/osx/kordophone2/XPC/XPCConvertible.swift b/osx/kordophone2/XPC/XPCConvertible.swift index 4dd164b..4a18582 100644 --- a/osx/kordophone2/XPC/XPCConvertible.swift +++ b/osx/kordophone2/XPC/XPCConvertible.swift @@ -76,6 +76,22 @@ extension Array: XPCConvertible where Element: XPCConvertible } } +extension Bool: XPCConvertible +{ + static func fromXPC(_ value: xpc_object_t) -> Bool? { + if xpc_get_type(value) == XPC_TYPE_BOOL { + return xpc_bool_get_value(value) + } + + if xpc_get_type(value) == XPC_TYPE_STRING { + guard let cstr = xpc_string_get_string_ptr(value) else { return nil } + return strcmp(cstr, "true") == 0 + } + + return nil + } +} + extension xpc_object_t { func getObject(_ key: String) -> xpc_object_t? {