From 603671c96e26c9d22b70d3a9a87e92a38dbd9389 Mon Sep 17 00:00:00 2001 From: Sean Coates Date: Mon, 16 Feb 2026 00:29:41 -0500 Subject: [PATCH 1/5] use drafts to save unsent messages so they repopulate when switching back to a room (or thread) --- Mactrix/Models/LiveTimeline.swift | 6 +- Mactrix/Views/ChatView/ChatInputView.swift | 86 +++++++++++++++++++++- 2 files changed, 88 insertions(+), 4 deletions(-) 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..5928025 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 isLoaded: Bool = false @State private var chatInput: String = "" @FocusState private var chatFocused: Bool @@ -33,6 +34,90 @@ struct ChatInputView: View { timeline.scrollPosition.scrollTo(edge: .bottom) } + private func clearDraft() { + Task { + do { + try await room.clearComposerDraft(threadRoot: timeline.focusedThreadId) + } catch { + Logger.viewCycle.error("failed to clear draft: \(error)") + } + } + } + + private func saveDraft() { + if chatInput.isEmpty { + clearDraft() + return + } + + Task { + 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 { + do { + if let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) { + self.chatInput = draft.plainText + switch draft.draftType { + case .reply(eventId: let eventId): + // we need a resolved timeline to populate the reply, so attempt to wait for it + if timeline.timeline == nil { + waitForTimeline: for _ in 0..<10 { // up to 1 second + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + if timeline.timeline != nil { + break waitForTimeline // named so it's not confused with the switch + } + } + } + + // if we still don't have a timeline, log a warning, but we can't populate the reply + guard let innerTimeline = timeline.timeline else { + Logger.viewCycle.warning("Did not get an inner timeline before timeout.") + 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: + break + } + } + } catch { + Logger.viewCycle.error("failed to load draft: \(error)") + } + } + + func chatInputChanged() { + guard isLoaded else { return } // avoid working on a draft that's being restored + if !chatInput.isEmpty { + Task { + try await room.typingNotice(isTyping: !chatInput.isEmpty) + } + } + saveDraft() + } + var replyEmbeddedDetails: EmbeddedEventDetails? { guard let replyTo else { return nil } @@ -60,7 +145,6 @@ struct ChatInputView: View { GeometryReader { proxy in Color(NSColor.textBackgroundColor) .onChange(of: proxy.size.height) { _, inputHeight in - print("Input height: \(inputHeight)") self.height = inputHeight } } From c382ac93f0403d4160e043acf910673747909add Mon Sep 17 00:00:00 2001 From: Sean Coates Date: Mon, 16 Feb 2026 13:09:31 -0500 Subject: [PATCH 2/5] improve guard semantics and private chatInputChanged --- Mactrix/Views/ChatView/ChatInputView.swift | 49 +++++++++++----------- 1 file changed, 24 insertions(+), 25 deletions(-) diff --git a/Mactrix/Views/ChatView/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView.swift index 5928025..45dd138 100644 --- a/Mactrix/Views/ChatView/ChatInputView.swift +++ b/Mactrix/Views/ChatView/ChatInputView.swift @@ -73,42 +73,41 @@ struct ChatInputView: View { private func loadDraft() async { do { - if let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) { - self.chatInput = draft.plainText - switch draft.draftType { - case .reply(eventId: let eventId): - // we need a resolved timeline to populate the reply, so attempt to wait for it - if timeline.timeline == nil { - waitForTimeline: for _ in 0..<10 { // up to 1 second - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms - if timeline.timeline != nil { - break waitForTimeline // named so it's not confused with the switch - } + guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) else { return } + self.chatInput = draft.plainText + switch draft.draftType { + case .reply(eventId: let eventId): + // we need a resolved timeline to populate the reply, so attempt to wait for it + if timeline.timeline == nil { + waitForTimeline: for _ in 0..<10 { // up to 1 second + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + if timeline.timeline != nil { + break waitForTimeline // named so it's not confused with the switch } } + } - // if we still don't have a timeline, log a warning, but we can't populate the reply - guard let innerTimeline = timeline.timeline else { - Logger.viewCycle.warning("Did not get an inner timeline before timeout.") - return - } + // if we still don't have a timeline, log a warning, but we can't populate the reply + guard let innerTimeline = timeline.timeline else { + Logger.viewCycle.warning("Did not get an inner timeline before timeout.") + 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: - break + 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: + break } } catch { Logger.viewCycle.error("failed to load draft: \(error)") } } - func chatInputChanged() { + private func chatInputChanged() { guard isLoaded else { return } // avoid working on a draft that's being restored if !chatInput.isEmpty { Task { From 850881ae0c219694d897e9b00b0786790e5f01d1 Mon Sep 17 00:00:00 2001 From: Sean Coates Date: Mon, 16 Feb 2026 15:49:21 -0500 Subject: [PATCH 3/5] avoid the sleep; repopulate when the timeline becomes populated --- Mactrix/Views/ChatView/ChatInputView.swift | 26 +++++++++------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/Mactrix/Views/ChatView/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView.swift index 45dd138..f503449 100644 --- a/Mactrix/Views/ChatView/ChatInputView.swift +++ b/Mactrix/Views/ChatView/ChatInputView.swift @@ -71,26 +71,18 @@ struct ChatInputView: View { } } - private func loadDraft() async { + private func loadDraft() async -> Bool { do { - guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) else { return } + guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) else { + // no draft to load + return true + } self.chatInput = draft.plainText switch draft.draftType { case .reply(eventId: let eventId): - // we need a resolved timeline to populate the reply, so attempt to wait for it - if timeline.timeline == nil { - waitForTimeline: for _ in 0..<10 { // up to 1 second - try? await Task.sleep(nanoseconds: 100_000_000) // 100ms - if timeline.timeline != nil { - break waitForTimeline // named so it's not confused with the switch - } - } - } - - // if we still don't have a timeline, log a warning, but we can't populate the reply + // we need a timeline to be able to populate the reply; return false so we can try again guard let innerTimeline = timeline.timeline else { - Logger.viewCycle.warning("Did not get an inner timeline before timeout.") - return + return false } do { @@ -100,11 +92,13 @@ struct ChatInputView: View { Logger.viewCycle.error("failed to resolve reply target: \(error)") } case .newMessage, .edit: - break + // nothing to do + return true } } catch { Logger.viewCycle.error("failed to load draft: \(error)") } + return true // so we don't try again } private func chatInputChanged() { From 482f43b6be23fee37b3642863e03d497523f801e Mon Sep 17 00:00:00 2001 From: Sean Coates Date: Wed, 18 Feb 2026 21:30:33 -0500 Subject: [PATCH 4/5] rework drafts - tidy up async/Task - use the `.task(id:)` pattern (instead of `.onChange(of:)`) - rename to isDraftLoaded - better state control --- Mactrix/Views/ChatView/ChatInputView.swift | 91 ++++++++++++---------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/Mactrix/Views/ChatView/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView.swift index f503449..27f55db 100644 --- a/Mactrix/Views/ChatView/ChatInputView.swift +++ b/Mactrix/Views/ChatView/ChatInputView.swift @@ -9,7 +9,7 @@ struct ChatInputView: View { @Binding var height: CGFloat? @AppStorage("fontSize") var fontSize: Int = 13 - @State private var isLoaded: Bool = false + @State private var isDraftLoaded: Bool = false @State private var chatInput: String = "" @FocusState private var chatFocused: Bool @@ -34,55 +34,53 @@ struct ChatInputView: View { timeline.scrollPosition.scrollTo(edge: .bottom) } - private func clearDraft() { - Task { + 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)") } - } - } - - private func saveDraft() { - if chatInput.isEmpty { - clearDraft() return } - Task { - 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)") - } + 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) + Logger.viewCycle.debug("saved draft") + } catch { + Logger.viewCycle.error("failed save draft: \(error)") } } - private func loadDraft() async -> Bool { + 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 - return true + 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 { - return false + isDraftLoaded = false + return } do { @@ -93,22 +91,25 @@ struct ChatInputView: View { } case .newMessage, .edit: // nothing to do - return true + isDraftLoaded = true + return } } catch { Logger.viewCycle.error("failed to load draft: \(error)") } - return true // so we don't try again + isDraftLoaded = true // so we don't try again } - private func chatInputChanged() { - guard isLoaded else { return } // avoid working on a draft that's being restored + private func chatInputChanged() async { + guard isDraftLoaded else { return } // avoid working on a draft that's being restored if !chatInput.isEmpty { - Task { + do { try await room.typingNotice(isTyping: !chatInput.isEmpty) + } catch { + Logger.viewCycle.warning("Failed to send typing notice: \(error)") } } - saveDraft() + await saveDraft() } var replyEmbeddedDetails: EmbeddedEventDetails? { @@ -132,6 +133,7 @@ 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( @@ -153,13 +155,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) From bf9f366bc0b345eeeab7d7c998dc0ee1024268b6 Mon Sep 17 00:00:00 2001 From: Sean Coates Date: Wed, 18 Feb 2026 21:52:56 -0500 Subject: [PATCH 5/5] remove too-noisy debug message --- Mactrix/Views/ChatView/ChatInputView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Mactrix/Views/ChatView/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView.swift index 27f55db..e5630a0 100644 --- a/Mactrix/Views/ChatView/ChatInputView.swift +++ b/Mactrix/Views/ChatView/ChatInputView.swift @@ -60,7 +60,6 @@ struct ChatInputView: View { ) do { try await room.saveComposerDraft(draft: draft, threadRoot: timeline.focusedThreadId) - Logger.viewCycle.debug("saved draft") } catch { Logger.viewCycle.error("failed save draft: \(error)") }