diff --git a/osx/kordophone2/ConversationView.swift b/osx/kordophone2/ConversationView.swift index 100aea8..5a8887d 100644 --- a/osx/kordophone2/ConversationView.swift +++ b/osx/kordophone2/ConversationView.swift @@ -14,7 +14,7 @@ struct ConversationView: View @Binding var entryModel: MessageEntryView.ViewModel var body: some View { - VStack { + VStack(spacing: 0.0) { TranscriptView(model: $transcriptModel) MessageEntryView(viewModel: $entryModel) } diff --git a/osx/kordophone2/Daemon/kordophoned b/osx/kordophone2/Daemon/kordophoned index 82484b9..5c4d1e7 100755 Binary files a/osx/kordophone2/Daemon/kordophoned and b/osx/kordophone2/Daemon/kordophoned differ diff --git a/osx/kordophone2/Transcript/TranscriptDisplayItems.swift b/osx/kordophone2/Transcript/TranscriptDisplayItems.swift index f98a1e0..ec9ae6b 100644 --- a/osx/kordophone2/Transcript/TranscriptDisplayItems.swift +++ b/osx/kordophone2/Transcript/TranscriptDisplayItems.swift @@ -10,7 +10,7 @@ import SwiftUI extension TranscriptView.ViewModel { - internal func rebuildDisplayItems(animated: Bool = false) { + internal func rebuildDisplayItems(animated: Bool = false, completion: () -> Void = {}) { var displayItems: [DisplayItem] = [] var lastDate: Date = .distantPast var lastSender: Display.Sender? = nil @@ -53,6 +53,7 @@ extension TranscriptView.ViewModel let animation: Animation? = animated ? .default : nil withAnimation(animation) { self.displayItems = displayItems + completion() } } } diff --git a/osx/kordophone2/Transcript/TranscriptView.swift b/osx/kordophone2/Transcript/TranscriptView.swift index fae1124..8514e08 100644 --- a/osx/kordophone2/Transcript/TranscriptView.swift +++ b/osx/kordophone2/Transcript/TranscriptView.swift @@ -12,25 +12,57 @@ struct TranscriptView: View @Binding var model: ViewModel @Environment(\.xpcClient) private var xpcClient - + + init(model: Binding) { + self._model = model + } + 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) - ) + 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() } } - .padding() } - .scaleEffect(CGSize(width: 1.0, height: -1.0)) - .id(model.displayedConversation?.id) - .task { await watchForMessageListChanges() } } private func watchForMessageListChanges() async { @@ -79,9 +111,16 @@ struct TranscriptView: View } } } - + // MARK: - Types - + + enum ViewID: String + { + case bottomAnchor + } + + // MARK: - View Model + @Observable class ViewModel { @@ -94,7 +133,6 @@ struct TranscriptView: View init(messages: [Display.Message] = []) { self.messages = messages - observeDisplayedConversation() rebuildDisplayItems() } @@ -106,7 +144,7 @@ struct TranscriptView: View needsReload = .yes(animated) Task { @MainActor [weak self] in guard let self else { return } - await reloadMessages() + await reloadIfNeeded() } } @@ -115,22 +153,6 @@ struct TranscriptView: View 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 } @@ -150,11 +172,15 @@ struct TranscriptView: View print("Error triggering sync: \(error)") } } - - private func reloadMessages() async { + + func reloadIfNeeded(completion: () -> Void = {}) async { guard case .yes(let animated) = needsReload else { return } needsReload = .no - + + await reloadMessages(animated: animated, completion: completion) + } + + func reloadMessages(animated: Bool, completion: () -> Void) async { guard let displayedConversation else { return } do { @@ -167,8 +193,10 @@ struct TranscriptView: View // Only animate for incoming messages. let shouldAnimate = (newIds.count == 1) - self.messages = clientMessages - self.rebuildDisplayItems(animated: animated && shouldAnimate) + await MainActor.run { + self.messages = clientMessages + self.rebuildDisplayItems(animated: animated && shouldAnimate, completion: completion) + } } catch { print("Message fetch error: \(error)") }