195 lines
6.1 KiB
Swift
195 lines
6.1 KiB
Swift
//
|
|
// 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: 6.0) {
|
|
ForEach($model.displayItems.reversed()) { item in
|
|
displayItemView(item.wrappedValue)
|
|
.id(item.id)
|
|
.scaleEffect(CGSize(width: 1.0, height: -1.0))
|
|
.transition(
|
|
.push(from: .top)
|
|
.combined(with: .opacity)
|
|
)
|
|
}
|
|
}
|
|
.padding()
|
|
}
|
|
.scaleEffect(CGSize(width: 1.0, height: -1.0))
|
|
.id(model.displayedConversation?.id)
|
|
.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 let displayedConversation = model.displayedConversation,
|
|
conversationId == displayedConversation.id
|
|
{
|
|
model.setNeedsReload(animated: true)
|
|
}
|
|
case .updateStreamReconnected:
|
|
await model.triggerSync()
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func displayItemView(_ item: DisplayItem) -> some View {
|
|
switch item {
|
|
case .message(let message):
|
|
TextBubbleItemView(text: message.text, sender: message.sender, date: message.date)
|
|
case .date(let date):
|
|
DateItemView(date: date)
|
|
case .senderAttribition(let message):
|
|
SenderAttributionView(sender: message.sender)
|
|
case .spacer(let length, _):
|
|
Spacer(minLength: length)
|
|
case .attachment(let attachment):
|
|
if attachment.isPreviewDownloaded {
|
|
ImageItemView(
|
|
sender: attachment.sender,
|
|
date: attachment.dateSent,
|
|
attachment: attachment
|
|
)
|
|
} else {
|
|
PlaceholderImageItemView(
|
|
sender: attachment.sender,
|
|
date: attachment.dateSent,
|
|
size: attachment.size
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Types
|
|
|
|
@Observable
|
|
class ViewModel
|
|
{
|
|
var displayItems: [DisplayItem] = []
|
|
var displayedConversation: Display.Conversation? = nil
|
|
|
|
internal var needsReload: NeedsReload = .no
|
|
internal var messages: [Display.Message]
|
|
internal let client = XPCClient()
|
|
|
|
init(messages: [Display.Message] = []) {
|
|
self.messages = messages
|
|
observeDisplayedConversation()
|
|
rebuildDisplayItems()
|
|
}
|
|
|
|
func setNeedsReload(animated: Bool) {
|
|
guard case .no = needsReload else {
|
|
return
|
|
}
|
|
|
|
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: false)
|
|
}
|
|
|
|
private func observeDisplayedConversation() {
|
|
withObservationTracking {
|
|
_ = displayedConversation
|
|
} onChange: {
|
|
Task { @MainActor [weak self] in
|
|
guard let self else { return }
|
|
|
|
await markAsRead()
|
|
await triggerSync()
|
|
|
|
setNeedsReload(animated: false)
|
|
observeDisplayedConversation()
|
|
}
|
|
}
|
|
}
|
|
|
|
func markAsRead() async {
|
|
guard let displayedConversation else { return }
|
|
|
|
do {
|
|
try await client.markConversationAsRead(conversationId: displayedConversation.id)
|
|
} catch {
|
|
print("Error triggering sync: \(error)")
|
|
}
|
|
}
|
|
|
|
func triggerSync() async {
|
|
guard let displayedConversation else { return }
|
|
|
|
do {
|
|
try await client.syncConversation(conversationId: displayedConversation.id)
|
|
} catch {
|
|
print("Error triggering sync: \(error)")
|
|
}
|
|
}
|
|
|
|
private func reloadMessages() async {
|
|
guard case .yes(let animated) = needsReload else { return }
|
|
needsReload = .no
|
|
|
|
guard let displayedConversation else { return }
|
|
|
|
do {
|
|
let clientMessages = try await client.getMessages(conversationId: displayedConversation.id)
|
|
.map { Display.Message(from: $0) }
|
|
|
|
let newIds = Set(clientMessages.map(\.id))
|
|
.subtracting(self.messages.map(\.id))
|
|
|
|
// Only animate for incoming messages.
|
|
let shouldAnimate = (newIds.count == 1)
|
|
|
|
self.messages = clientMessages
|
|
self.rebuildDisplayItems(animated: animated && shouldAnimate)
|
|
} 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)
|
|
}
|