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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Mactrix/Extensions/Logger.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ extension Logger {
static let liveTimeline = Logger(subsystem: subsystem, category: "live-timeline")
static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view")
static let SidebarRoom = Logger(subsystem: subsystem, category: "sidebar-room")
static let chatTextView = Logger(subsystem: subsystem, category: "chat-text-view")

static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ struct ChatInputView: View {

@State private var isDraftLoaded: Bool = false
@State private var chatInput: String = ""
@FocusState private var chatFocused: Bool

func sendMessage() async {
guard !chatInput.isEmpty else { return }
Expand All @@ -35,7 +34,7 @@ struct ChatInputView: View {

private func saveDraft() async {
guard isDraftLoaded else { return } // avoid saving a draft hasn't yet been restored
if chatInput.isEmpty && replyTo == nil {
if chatInput.isEmpty, replyTo == nil {
Logger.viewCycle.debug("clearing draft")
do {
try await room.clearComposerDraft(threadRoot: timeline.focusedThreadId)
Expand Down Expand Up @@ -65,14 +64,14 @@ struct ChatInputView: View {
}

private func loadDraft() async {
guard !isDraftLoaded else { return } // don't load a draft more than once
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
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
Expand All @@ -83,7 +82,7 @@ struct ChatInputView: View {

do {
let item = try await innerTimeline.getEventTimelineItemByEventId(eventId: eventId)
self.timeline.sendReplyTo = item
timeline.sendReplyTo = item
} catch {
Logger.viewCycle.error("failed to resolve reply target: \(error)")
}
Expand All @@ -95,7 +94,7 @@ struct ChatInputView: View {
} catch {
Logger.viewCycle.error("failed to load draft: \(error)")
}
isDraftLoaded = true // so we don't try again
isDraftLoaded = true // so we don't try again
}

private func chatInputChanged() async {
Expand Down Expand Up @@ -123,29 +122,15 @@ struct ChatInputView: View {
replyTo = nil
}
}
TextField("Message room", text: $chatInput, axis: .vertical)
.focused($chatFocused)
.onSubmit { Task { await sendMessage() } }
.textFieldStyle(.plain)
.lineLimit(nil)
.scrollContentBackground(.hidden)
.background(.clear)
.padding(10)
.disabled(!isDraftLoaded) // avoid inputs until we've tried to load a draft
ChatTextView(text: $chatInput, disabled: !isDraftLoaded, onSubmit: { Task { await sendMessage() }})
}
.font(.system(size: .init(fontSize)))
.background(Color(NSColor.textBackgroundColor))
.cornerRadius(4)
.lineSpacing(2)
.frame(minHeight: 20)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(Color(NSColor.separatorColor), lineWidth: 1)
)
// .shadow(color: .black.opacity(0.1), radius: 4)
.onTapGesture {
chatFocused = true
}
.task(id: chatInput) {
await chatInputChanged()
}
Expand Down
129 changes: 129 additions & 0 deletions Mactrix/Views/ChatView/ChatInputView/ChatTextView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import AppKit
import OSLog
import SwiftUI

struct ChatTextView: NSViewRepresentable {
typealias NSViewRepresentableType = NSTextView

let text: Binding<String>
let disabled: Bool
let onSubmit: () -> Void

func makeNSView(context: Context) -> DynamicTextView {
let textView = DynamicTextView()

textView.onSubmit = onSubmit

context.coordinator.textView = textView
textView.delegate = context.coordinator

textView.textContainerInset = NSSize(width: DynamicTextView.padding, height: DynamicTextView.padding)

textView.isVerticallyResizable = true
textView.isHorizontallyResizable = false
unsafe textView.textContainer?.widthTracksTextView = true

textView.setContentHuggingPriority(.required, for: .vertical)
textView.setContentCompressionResistancePriority(.required, for: .vertical)

return textView
}

func updateNSView(_ textView: DynamicTextView, context: Context) {
context.coordinator.text = text

textView.onSubmit = onSubmit

if textView.string != text.wrappedValue {
textView.string = text.wrappedValue
textView.invalidateIntrinsicContentSize()
}

if textView.isEditable != !disabled {
textView.isEditable = !disabled
textView.isSelectable = !disabled
textView.alphaValue = !disabled ? 1.0 : 0.5

if disabled {
// resign first responder
unsafe textView.window?.makeFirstResponder(nil)
}
}
}

func makeCoordinator() -> Coordinator {
return Coordinator(text: text)
}

class Coordinator: NSObject, NSTextViewDelegate {
var textView: NSTextView?
var text: Binding<String>

init(text: Binding<String>) {
self.text = text
}

func textDidChange(_ notification: Notification) {
guard let textView else { return }

text.wrappedValue = textView.string
}
}
}

class DynamicTextView: NSTextView {
static let padding = 4

var onSubmit: (() -> Void)?

override var intrinsicContentSize: NSSize {
guard let container = unsafe textContainer, let manager = unsafe layoutManager else {
return .zero
}

// Force the layout for the current width
manager.ensureLayout(for: container)
let usedRect = manager.usedRect(for: container)

// Return a flexible width but a fixed height based on text
return NSSize(width: NSView.noIntrinsicMetric, height: ceil(usedRect.height) + CGFloat(2 * Self.padding))
}

override func setFrameSize(_ newSize: NSSize) {
let oldWidth = frame.width
super.setFrameSize(newSize)

if oldWidth != newSize.width {
invalidateIntrinsicContentSize()
}
}

override func didChangeText() {
super.didChangeText()
invalidateIntrinsicContentSize()
}

override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Always submit on cmd+enter
if (event.specialKey == .enter || event.specialKey == .carriageReturn)
&& event.modifierFlags.intersection(.deviceIndependentFlagsMask) == [.command]
{
onSubmit?()
return true
}

return super.performKeyEquivalent(with: event)
}

override func keyDown(with event: NSEvent) {
// Handle enter as submit instead of newline
if event.specialKey == .enter || event.specialKey == .carriageReturn,
event.modifierFlags.intersection(.deviceIndependentFlagsMask) == []
{
onSubmit?()
return
}

super.keyDown(with: event)
}
}
24 changes: 22 additions & 2 deletions MactrixLibrary/Sources/MessageFormatting/AttributedTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ public struct AttributedTextView: NSViewRepresentable {
public let attributedString: NSAttributedString

public init(attributedString: NSAttributedString) {
self.attributedString = attributedString
self.attributedString = attributedString.trimmed
}

public func makeNSView(context: Context) -> NSTextField {
Expand All @@ -31,6 +31,26 @@ public struct AttributedTextView: NSViewRepresentable {
guard let width = proposal.width, width > 0, width != .infinity else { return nil }

textField.preferredMaxLayoutWidth = width
return textField.cell?.cellSize(forBounds: NSRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude))
guard let size = textField.cell?.cellSize(forBounds: NSRect(x: 0, y: 0, width: width, height: CGFloat.greatestFiniteMagnitude)) else {
return nil
}

return CGSize(width: ceil(size.width), height: ceil(size.height))
}
}

extension NSAttributedString {
var trimmed: NSAttributedString {
let nonWhitespaces = CharacterSet.whitespacesAndNewlines.inverted
let startRange = string.rangeOfCharacter(from: nonWhitespaces)
let endRange = string.rangeOfCharacter(from: nonWhitespaces, options: .backwards)
guard let startLocation = startRange?.upperBound, let endLocation = endRange?.lowerBound else {
return self
}
let location = string.distance(from: string.startIndex, to: startLocation) - 1
let length = string.distance(from: startLocation, to: endLocation) + 2
let range = NSRange(location: location, length: length)

return attributedSubstring(from: range)
}
}
Loading