Skip to content

Commit f670f12

Browse files
authored
Message Drafts (#39)
* use drafts to save unsent messages so they repopulate when switching back to a room (or thread) * improve guard semantics and private chatInputChanged * avoid the sleep; repopulate when the timeline becomes populated * rework drafts - tidy up async/Task - use the `.task(id:)` pattern (instead of `.onChange(of:)`) - rename to isDraftLoaded - better state control * remove too-noisy debug message
1 parent cc72a74 commit f670f12

2 files changed

Lines changed: 92 additions & 11 deletions

File tree

‎Mactrix/Models/LiveTimeline.swift‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import SwiftUI
77
@MainActor @Observable
88
public final class LiveTimeline {
99
public let room: LiveRoom
10-
public let isThreadFocus: Bool
10+
public let focusedThreadId: String?
1111

1212
public var timeline: Timeline?
1313

@@ -29,7 +29,7 @@ public final class LiveTimeline {
2929
public private(set) var hitTimelineStart: Bool = false
3030

3131
public init(room: LiveRoom) {
32-
self.isThreadFocus = false
32+
self.focusedThreadId = nil
3333
self.room = room
3434
Task {
3535
do {
@@ -42,7 +42,7 @@ public final class LiveTimeline {
4242
}
4343

4444
public init(room: LiveRoom, focusThread threadId: String) {
45-
self.isThreadFocus = true
45+
self.focusedThreadId = threadId
4646
self.room = room
4747
Task {
4848
do {

‎Mactrix/Views/ChatView/ChatInputView.swift‎

Lines changed: 89 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ struct ChatInputView: View {
99
@Binding var height: CGFloat?
1010
@AppStorage("fontSize") var fontSize: Int = 13
1111

12+
@State private var isDraftLoaded: Bool = false
1213
@State private var chatInput: String = ""
1314
@FocusState private var chatFocused: Bool
1415

@@ -33,6 +34,83 @@ struct ChatInputView: View {
3334
timeline.scrollPosition.scrollTo(edge: .bottom)
3435
}
3536

37+
private func saveDraft() async {
38+
guard isDraftLoaded else { return } // avoid saving a draft hasn't yet been restored
39+
if chatInput.isEmpty && replyTo == nil {
40+
Logger.viewCycle.debug("clearing draft")
41+
do {
42+
try await room.clearComposerDraft(threadRoot: timeline.focusedThreadId)
43+
} catch {
44+
Logger.viewCycle.error("failed to clear draft: \(error)")
45+
}
46+
return
47+
}
48+
49+
let draftType: ComposerDraftType
50+
if let replyTo {
51+
draftType = .reply(eventId: replyTo.eventOrTransactionId.id)
52+
} else {
53+
draftType = .newMessage
54+
}
55+
let draft = ComposerDraft(
56+
plainText: chatInput,
57+
htmlText: nil,
58+
draftType: draftType,
59+
attachments: []
60+
)
61+
do {
62+
try await room.saveComposerDraft(draft: draft, threadRoot: timeline.focusedThreadId)
63+
} catch {
64+
Logger.viewCycle.error("failed save draft: \(error)")
65+
}
66+
}
67+
68+
private func loadDraft() async {
69+
guard !isDraftLoaded else { return } // don't load a draft more than once
70+
do {
71+
guard let draft = try await room.loadComposerDraft(threadRoot: timeline.focusedThreadId) else {
72+
// no draft to load
73+
isDraftLoaded = true
74+
return
75+
}
76+
self.chatInput = draft.plainText
77+
switch draft.draftType {
78+
case .reply(eventId: let eventId):
79+
// we need a timeline to be able to populate the reply; return false so we can try again
80+
guard let innerTimeline = timeline.timeline else {
81+
isDraftLoaded = false
82+
return
83+
}
84+
85+
do {
86+
let item = try await innerTimeline.getEventTimelineItemByEventId(eventId: eventId)
87+
self.timeline.sendReplyTo = item
88+
} catch {
89+
Logger.viewCycle.error("failed to resolve reply target: \(error)")
90+
}
91+
case .newMessage, .edit:
92+
// nothing to do
93+
isDraftLoaded = true
94+
return
95+
}
96+
} catch {
97+
Logger.viewCycle.error("failed to load draft: \(error)")
98+
}
99+
isDraftLoaded = true // so we don't try again
100+
}
101+
102+
private func chatInputChanged() async {
103+
guard isDraftLoaded else { return } // avoid working on a draft that's being restored
104+
if !chatInput.isEmpty {
105+
do {
106+
try await room.typingNotice(isTyping: !chatInput.isEmpty)
107+
} catch {
108+
Logger.viewCycle.warning("Failed to send typing notice: \(error)")
109+
}
110+
}
111+
await saveDraft()
112+
}
113+
36114
var replyEmbeddedDetails: EmbeddedEventDetails? {
37115
guard let replyTo else { return nil }
38116

@@ -54,13 +132,13 @@ struct ChatInputView: View {
54132
.scrollContentBackground(.hidden)
55133
.background(.clear)
56134
.padding(10)
135+
.disabled(!isDraftLoaded) // avoid inputs until we've tried to load a draft
57136
}
58137
.font(.system(size: .init(fontSize)))
59138
.background(
60139
GeometryReader { proxy in
61140
Color(NSColor.textBackgroundColor)
62141
.onChange(of: proxy.size.height) { _, inputHeight in
63-
print("Input height: \(inputHeight)")
64142
self.height = inputHeight
65143
}
66144
}
@@ -76,13 +154,16 @@ struct ChatInputView: View {
76154
.onTapGesture {
77155
chatFocused = true
78156
}
79-
.task(id: !chatInput.isEmpty) {
80-
let isTyping = !chatInput.isEmpty
81-
do {
82-
try await room.typingNotice(isTyping: isTyping)
83-
} catch {
84-
Logger.viewCycle.error("Failed to set typing notice: \(error)")
85-
}
157+
.task(id: chatInput) {
158+
await chatInputChanged()
159+
}
160+
.task(id: replyTo?.eventOrTransactionId) {
161+
await saveDraft()
162+
}
163+
.task(id: timeline.timeline != nil) {
164+
// we need the timeline to be populated before we load a draft
165+
// (in case the draft holds a reply)
166+
await loadDraft()
86167
}
87168
.pointerStyle(.horizontalText)
88169
.padding([.horizontal, .bottom], 10)

0 commit comments

Comments
 (0)