diff --git a/Mactrix/Models/LiveTimeline.swift b/Mactrix/Models/LiveTimeline.swift index 618b855..7661c7c 100644 --- a/Mactrix/Models/LiveTimeline.swift +++ b/Mactrix/Models/LiveTimeline.swift @@ -7,7 +7,7 @@ import SwiftUI @MainActor @Observable public final class LiveTimeline { public let room: LiveRoom - public let isThreadFocus: Bool + public let focusedThreadId: String? public var timeline: Timeline? @@ -29,7 +29,7 @@ public final class LiveTimeline { public private(set) var hitTimelineStart: Bool = false public init(room: LiveRoom) { - self.isThreadFocus = false + self.focusedThreadId = nil self.room = room Task { do { @@ -42,7 +42,7 @@ public final class LiveTimeline { } public init(room: LiveRoom, focusThread threadId: String) { - self.isThreadFocus = true + self.focusedThreadId = threadId self.room = room Task { do { diff --git a/Mactrix/Views/ChatView/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView.swift index db4a1a1..e5630a0 100644 --- a/Mactrix/Views/ChatView/ChatInputView.swift +++ b/Mactrix/Views/ChatView/ChatInputView.swift @@ -9,6 +9,7 @@ struct ChatInputView: View { @Binding var height: CGFloat? @AppStorage("fontSize") var fontSize: Int = 13 + @State private var isDraftLoaded: Bool = false @State private var chatInput: String = "" @FocusState private var chatFocused: Bool @@ -33,6 +34,83 @@ struct ChatInputView: View { timeline.scrollPosition.scrollTo(edge: .bottom) } + private func saveDraft() async { + guard isDraftLoaded else { return } // avoid saving a draft hasn't yet been restored + if chatInput.isEmpty && replyTo == nil { + Logger.viewCycle.debug("clearing draft") + do { + try await room.clearComposerDraft(threadRoot: timeline.focusedThreadId) + } catch { + Logger.viewCycle.error("failed to clear draft: \(error)") + } + return + } + + let draftType: ComposerDraftType + if let replyTo { + draftType = .reply(eventId: replyTo.eventOrTransactionId.id) + } else { + draftType = .newMessage + } + let draft = ComposerDraft( + plainText: chatInput, + htmlText: nil, + draftType: draftType, + attachments: [] + ) + do { + try await room.saveComposerDraft(draft: draft, threadRoot: timeline.focusedThreadId) + } catch { + Logger.viewCycle.error("failed save draft: \(error)") + } + } + + private func loadDraft() async { + guard !isDraftLoaded else { return } // don't load a draft more than once + do { + guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) else { + // no draft to load + isDraftLoaded = true + return + } + self.chatInput = draft.plainText + switch draft.draftType { + case .reply(eventId: let eventId): + // we need a timeline to be able to populate the reply; return false so we can try again + guard let innerTimeline = timeline.timeline else { + isDraftLoaded = false + return + } + + do { + let item = try await innerTimeline.getEventTimelineItemByEventId(eventId: eventId) + self.timeline.sendReplyTo = item + } catch { + Logger.viewCycle.error("failed to resolve reply target: \(error)") + } + case .newMessage, .edit: + // nothing to do + isDraftLoaded = true + return + } + } catch { + Logger.viewCycle.error("failed to load draft: \(error)") + } + isDraftLoaded = true // so we don't try again + } + + private func chatInputChanged() async { + guard isDraftLoaded else { return } // avoid working on a draft that's being restored + if !chatInput.isEmpty { + do { + try await room.typingNotice(isTyping: !chatInput.isEmpty) + } catch { + Logger.viewCycle.warning("Failed to send typing notice: \(error)") + } + } + await saveDraft() + } + var replyEmbeddedDetails: EmbeddedEventDetails? { guard let replyTo else { return nil } @@ -54,13 +132,13 @@ struct ChatInputView: View { .scrollContentBackground(.hidden) .background(.clear) .padding(10) + .disabled(!isDraftLoaded) // avoid inputs until we've tried to load a draft } .font(.system(size: .init(fontSize))) .background( GeometryReader { proxy in Color(NSColor.textBackgroundColor) .onChange(of: proxy.size.height) { _, inputHeight in - print("Input height: \(inputHeight)") self.height = inputHeight } } @@ -76,13 +154,16 @@ struct ChatInputView: View { .onTapGesture { chatFocused = true } - .task(id: !chatInput.isEmpty) { - let isTyping = !chatInput.isEmpty - do { - try await room.typingNotice(isTyping: isTyping) - } catch { - Logger.viewCycle.error("Failed to set typing notice: \(error)") - } + .task(id: chatInput) { + await chatInputChanged() + } + .task(id: replyTo?.eventOrTransactionId) { + await saveDraft() + } + .task(id: timeline.timeline != nil) { + // we need the timeline to be populated before we load a draft + // (in case the draft holds a reply) + await loadDraft() } .pointerStyle(.horizontalText) .padding([.horizontal, .bottom], 10)