Implements attachment previewing
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(id: String = UUID().uuidString, sender: Sender = .me, text: String) {
|
self.sender = sender
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
enum Sender
|
||||||
{
|
{
|
||||||
case me
|
case me
|
||||||
case counterpart(String)
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
148
kordophone2/Transcript/TranscriptDisplayItemViews.swift
Normal file
148
kordophone2/Transcript/TranscriptDisplayItemViews.swift
Normal 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
|
||||||
|
}
|
||||||
61
kordophone2/Transcript/TranscriptDisplayItems.swift
Normal file
61
kordophone2/Transcript/TranscriptDisplayItems.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
152
kordophone2/Transcript/TranscriptView.swift
Normal file
152
kordophone2/Transcript/TranscriptView.swift
Normal 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)
|
||||||
|
}
|
||||||
@@ -115,6 +115,29 @@ final class XPCClient
|
|||||||
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
|
||||||
|
|
||||||
enum Error: Swift.Error
|
enum Error: Swift.Error
|
||||||
|
|||||||
Reference in New Issue
Block a user