Private
Public Access
1
0
Files
Kordophone/osx/kordophone2/Transcript/TranscriptView.swift

239 lines
7.5 KiB
Swift
Raw Normal View History

2025-08-24 23:38:35 -07:00
//
// 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
2025-09-12 15:58:34 -07:00
init(model: Binding<ViewModel>) {
self._model = model
}
2025-08-24 23:38:35 -07:00
var body: some View {
2025-09-12 15:58:34 -07:00
ScrollViewReader { proxy in
ScrollView {
// For resetting scroll position to the "bottom"
EmptyView()
.id(ViewID.bottomAnchor)
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()
}
// Flip vertically so newest messages are at the bottom.
.scaleEffect(CGSize(width: 1.0, height: -1.0))
// Watch for xpc events
.task { await watchForMessageListChanges() }
// On conversation change, reload displayed messages and mark as read.
.onChange(of: model.displayedConversation) { oldValue, newValue in
Task {
guard oldValue != newValue else { return }
// Reload NOW
await model.reloadMessages(animated: false) {
// Once that's done, scroll to the "bottom" (actually top)
proxy.scrollTo(ViewID.bottomAnchor, anchor: .top)
}
}
Task.detached {
// Mark as read on server, and trigger a sync.
await model.markAsRead()
await model.triggerSync()
2025-08-24 23:38:35 -07:00
}
}
}
}
private func watchForMessageListChanges() async {
for await event in xpcClient.eventStream() {
switch event {
case .attachmentDownloaded(let attachmentId):
model.attachmentDownloaded(id: attachmentId)
case .messagesUpdated(let conversationId):
2025-09-03 17:08:54 -07:00
if let displayedConversation = model.displayedConversation,
conversationId == displayedConversation.id
{
2025-08-24 23:38:35 -07:00
model.setNeedsReload(animated: true)
}
case .updateStreamReconnected:
2025-08-25 00:13:55 -07:00
await model.triggerSync()
2025-08-24 23:38:35 -07:00
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)
2025-08-24 23:38:35 -07:00
case .date(let date):
DateItemView(date: date)
case .senderAttribition(let message):
SenderAttributionView(sender: message.sender)
case .spacer(let length, _):
Spacer(minLength: length)
2025-08-24 23:38:35 -07:00
case .attachment(let attachment):
if attachment.isPreviewDownloaded {
ImageItemView(
sender: attachment.sender,
date: attachment.dateSent,
2025-08-24 23:38:35 -07:00
attachment: attachment
)
} else {
PlaceholderImageItemView(
sender: attachment.sender,
date: attachment.dateSent,
2025-08-24 23:38:35 -07:00
size: attachment.size
)
}
}
}
2025-09-12 15:58:34 -07:00
2025-08-24 23:38:35 -07:00
// MARK: - Types
2025-09-12 15:58:34 -07:00
enum ViewID: String
{
case bottomAnchor
}
// MARK: - View Model
2025-08-24 23:38:35 -07:00
@Observable
class ViewModel
{
var displayItems: [DisplayItem] = []
var displayedConversation: Display.Conversation? = nil
2025-08-25 00:13:55 -07:00
internal var needsReload: NeedsReload = .no
2025-08-24 23:38:35 -07:00
internal var messages: [Display.Message]
2025-08-25 00:13:55 -07:00
internal let client = XPCClient()
2025-08-24 23:38:35 -07:00
private var needsMarkAsRead: Bool = false
private var lastMarkAsRead: Date = .now
2025-08-24 23:38:35 -07:00
init(messages: [Display.Message] = []) {
self.messages = messages
rebuildDisplayItems()
}
func setNeedsReload(animated: Bool) {
2025-08-25 00:13:55 -07:00
guard case .no = needsReload else {
return
}
2025-08-24 23:38:35 -07:00
needsReload = .yes(animated)
Task { @MainActor [weak self] in
guard let self else { return }
2025-09-12 15:58:34 -07:00
await reloadIfNeeded()
2025-08-24 23:38:35 -07:00
}
}
func setNeedsMarkAsRead() {
guard needsMarkAsRead == false else { return }
guard Date.now.timeIntervalSince(lastMarkAsRead) > 5.0 else { return }
needsMarkAsRead = true
Task { @MainActor [weak self] in
guard let self else { return }
await markAsRead()
needsMarkAsRead = false
lastMarkAsRead = .now
}
}
2025-08-24 23:38:35 -07:00
func attachmentDownloaded(id: String) {
// TODO: should be smarter here
2025-08-29 19:45:27 -06:00
setNeedsReload(animated: false)
2025-08-24 23:38:35 -07:00
}
2025-08-25 00:37:48 -07:00
func markAsRead() async {
guard let displayedConversation else { return }
do {
try await client.markConversationAsRead(conversationId: displayedConversation.id)
2025-08-25 00:37:48 -07:00
} catch {
print("Error triggering sync: \(error)")
}
}
2025-08-25 00:13:55 -07:00
func triggerSync() async {
guard let displayedConversation else { return }
do {
try await client.syncConversation(conversationId: displayedConversation.id)
2025-08-25 00:13:55 -07:00
} catch {
print("Error triggering sync: \(error)")
}
}
2025-09-12 15:58:34 -07:00
func reloadIfNeeded(completion: () -> Void = {}) async {
2025-08-24 23:38:35 -07:00
guard case .yes(let animated) = needsReload else { return }
needsReload = .no
2025-09-12 15:58:34 -07:00
await reloadMessages(animated: animated, completion: completion)
}
func reloadMessages(animated: Bool, completion: () -> Void) async {
2025-08-24 23:38:35 -07:00
guard let displayedConversation else { return }
do {
let clientMessages = try await client.getMessages(conversationId: displayedConversation.id)
2025-08-24 23:38:35 -07:00
.map { Display.Message(from: $0) }
2025-09-03 17:08:54 -07:00
let newIds = Set(clientMessages.map(\.id))
.subtracting(self.messages.map(\.id))
// Only animate for incoming messages.
let shouldAnimate = (newIds.count == 1)
2025-09-12 15:58:34 -07:00
await MainActor.run {
self.messages = clientMessages
self.rebuildDisplayItems(animated: animated && shouldAnimate, completion: completion)
}
2025-08-24 23:38:35 -07:00
} 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)
}