Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Mactrix/Models/LiveTimeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
97 changes: 89 additions & 8 deletions Mactrix/Views/ChatView/ChatInputView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 }

Expand All @@ -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
}
}
Expand All @@ -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)
Expand Down