Private
Public Access
1
0

Implements attachment previewing

This commit is contained in:
2025-08-24 23:38:35 -07:00
parent 126a4cc55f
commit f0029d02e1
9 changed files with 500 additions and 178 deletions

View File

@@ -11,7 +11,7 @@ import SwiftUI
struct KordophoneApp: App struct KordophoneApp: App
{ {
@State var conversationListModel = ConversationListView.ViewModel() @State var conversationListModel = ConversationListView.ViewModel()
@State var transcriptViewModel = ChatTranscriptView.ViewModel() @State var transcriptViewModel = TranscriptView.ViewModel()
@State var entryViewModel = MessageEntryView.ViewModel() @State var entryViewModel = MessageEntryView.ViewModel()
private let xpcClient = XPCClient() private let xpcClient = XPCClient()

View File

@@ -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)
}

View File

@@ -9,12 +9,12 @@ import SwiftUI
struct ConversationView: View struct ConversationView: View
{ {
@Binding var transcriptModel: ChatTranscriptView.ViewModel @Binding var transcriptModel: TranscriptView.ViewModel
@Binding var entryModel: MessageEntryView.ViewModel @Binding var entryModel: MessageEntryView.ViewModel
var body: some View { var body: some View {
VStack { VStack {
ChatTranscriptView(model: $transcriptModel) TranscriptView(model: $transcriptModel)
MessageEntryView(viewModel: $entryModel) MessageEntryView(viewModel: $entryModel)
} }
} }

View File

@@ -21,6 +21,7 @@ struct MessageEntryView: View
HStack { HStack {
TextField("", text: $viewModel.draftText, axis: .vertical) TextField("", text: $viewModel.draftText, axis: .vertical)
.focusEffectDisabled(true) .focusEffectDisabled(true)
.textFieldStyle(.plain)
.lineLimit(nil) .lineLimit(nil)
.scrollContentBackground(.hidden) .scrollContentBackground(.hidden)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)

View File

@@ -42,26 +42,35 @@ enum Display
let id: String let id: String
let sender: Sender let sender: Sender
let text: String let text: String
let date: Date
let attachments: [ImageAttachment]
var isFromMe: Bool { var isFromMe: Bool { sender.isMe }
if case .me = sender { true }
else { false }
}
init(from m: Serialized.Message) { init(from m: Serialized.Message) {
self.id = m.guid self.id = m.guid
self.text = m.text self.text = m.text
self.sender = if m.sender == "(Me)" { self.date = m.date
let sender: Sender = if m.sender == "(Me)" {
.me .me
} else { } else {
.counterpart(m.sender) .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.id = id
self.sender = sender self.sender = sender
self.text = text self.text = text
self.date = date
self.attachments = []
} }
static func == (lhs: Message, rhs: Message) -> Bool { static func == (lhs: Message, rhs: Message) -> Bool {
@@ -71,11 +80,44 @@ enum Display
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
hasher.combine(id) hasher.combine(id)
} }
}
struct ImageAttachment: Identifiable
{
let id: String
let sender: Sender
let data: Serialized.Attachment
enum Sender var size: CGSize? {
{ if let attr = data.metadata?.attributionInfo, let width = attr.width, let height = attr.height {
case me return CGSize(width: width, height: height)
case counterpart(String) }
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 sender: String
let text: String let text: String
let date: Date let date: Date
let attachments: [Attachment]
init?(xpc dict: xpc_object_t) init?(xpc dict: xpc_object_t)
{ {
@@ -124,11 +167,70 @@ enum Serialized
let s: String = d["sender"] ?? "" let s: String = d["sender"] ?? ""
let t: String = d["text"] ?? "" let t: String = d["text"] ?? ""
let dd: Date = d["date"] ?? Date(timeIntervalSince1970: 0) let dd: Date = d["date"] ?? Date(timeIntervalSince1970: 0)
let atts: [Attachment] = d["attachments"] ?? []
self.guid = g self.guid = g
self.sender = s self.sender = s
self.text = t self.text = t
self.date = dd 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
)
}
}

View File

@@ -0,0 +1,148 @@
//
// TranscriptDisplayItemViews.swift
// kordophone2
//
// Created by James Magahern on 8/24/25.
//
import SwiftUI
struct BubbleView<Content: View>: 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
}

View File

@@ -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
}
}
}

View File

@@ -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)
}

View File

@@ -114,6 +114,29 @@ final class XPCClient
let req = makeRequest(method: "SendMessage", arguments: args) 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 } 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 // MARK: - Types