diff --git a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift index 6af68f7..3b904bb 100644 --- a/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift +++ b/Sources/MactrixApp/Views/ChatView/ChatInputView/ChatTextView.swift @@ -138,12 +138,11 @@ struct ChatTextView: NSViewRepresentable { } } -class DynamicTextView: NSTextView { +class DynamicTextView: SubmitableTextView { @objc var placeholderAttributedString: NSAttributedString? static let padding = NSSize(width: 10, height: 10) - var onSubmit: (() -> Void)? var onAttachmentPaste: ((NSPasteboard) -> Bool)? override func paste(_ sender: Any?) { @@ -181,6 +180,11 @@ class DynamicTextView: NSTextView { invalidateIntrinsicContentSize() } +} + +class SubmitableTextView: NSTextView { + var onSubmit: (() -> Void)? + override func performKeyEquivalent(with event: NSEvent) -> Bool { // Always submit on cmd+enter if (event.specialKey == .enter || event.specialKey == .carriageReturn) diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift index f647346..d08844c 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageContentRowView.swift @@ -8,6 +8,11 @@ protocol MessageContentRowView: NSView { var onArrowKey: ((TimelineSelectionDirection) -> Void)? { get set } func configure(content: MatrixRustSDK.MsgLikeContent, matrixClient: MatrixClient?) func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat) -> CGFloat + func configureEditing(_ state: MessageEditState?) +} + +extension MessageContentRowView { + func configureEditing(_ state: MessageEditState?) {} } /// Media rows deliver downloaded files to their owning timeline controller. diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageEditState.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageEditState.swift new file mode 100644 index 0000000..f621b76 --- /dev/null +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageEditState.swift @@ -0,0 +1,19 @@ +import Foundation +import MatrixRustSDK + +@MainActor final class MessageEditState { + let id: EventOrTransactionId + var text: String + var selectedRange: NSRange + var isSaving = false + var error: String? + var onChange: (() -> Void)? + var onSave: (() -> Void)? + var onCancel: (() -> Void)? + + init(id: EventOrTransactionId, text: String) { + self.id = id + self.text = text + selectedRange = NSRange(location: (text as NSString).length, length: 0) + } +} diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift index 622c5f1..d6d05ee 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageFileContentView.swift @@ -76,7 +76,8 @@ final class MessageFileContentView: NSView, MessageMediaPreviewContentView { size: file.info?.size?.formatted(.byteCount(style: .file)) ) - let hasCaption = file.caption?.isEmpty == false || file.formattedCaption != nil + let hasCaption = + captionView.isEditing || file.caption?.isEmpty == false || file.formattedCaption != nil captionView.isHidden = !hasCaption captionView.configureCaption(file.caption, formatted: file.formattedCaption) fileBottomConstraint.isActive = !hasCaption @@ -91,6 +92,19 @@ final class MessageFileContentView: NSView, MessageMediaPreviewContentView { } } + func configureEditing(_ state: MessageEditState?) { + captionView.configureEditing(state) + let hasCaption = + state != nil || fileContent?.caption?.isEmpty == false + || fileContent?.formattedCaption != nil + fileBottomConstraint.isActive = false + captionBottomConstraint.isActive = false + captionView.isHidden = !hasCaption + fileBottomConstraint.isActive = !hasCaption + captionBottomConstraint.isActive = hasCaption + needsLayout = true + } + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { guard case .message(let message) = content.kind, case .file(let file) = message.msgType @@ -99,7 +113,8 @@ final class MessageFileContentView: NSView, MessageMediaPreviewContentView { } func height(for file: FileMessageContent, width: CGFloat) -> CGFloat { - guard file.caption?.isEmpty == false || file.formattedCaption != nil else { return 36 } + guard captionView.isEditing || file.caption?.isEmpty == false || file.formattedCaption != nil + else { return 36 } return 36 + 10 + ceil( captionView.height(forCaption: file.caption, formatted: file.formattedCaption, width: width) diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageGalleryContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageGalleryContentView.swift index ea5a367..5a6ead9 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageGalleryContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageGalleryContentView.swift @@ -63,6 +63,23 @@ final class MessageGalleryContentView: NSView, MessageMediaPreviewContentView { } attachments = nextAttachments + rebuildComposition() + updateCallbacks() + } + + func configureEditing(_ state: MessageEditState?) { + captionView.configureEditing(state) + captionMeasurementView.configureEditing(state) + rebuildComposition() + needsLayout = true + } + + private func rebuildComposition() { + guard let gallery else { return } + NSLayoutConstraint.deactivate(compositionConstraints) + compositionConstraints = [] + attachmentHeightConstraints = [] + captionHeightConstraint = nil var previous: NSView? for attachment in attachments { let view = attachment.view @@ -79,7 +96,7 @@ final class MessageGalleryContentView: NSView, MessageMediaPreviewContentView { previous = view } - let hasCaption = Self.hasCaption(gallery) + let hasCaption = captionView.isEditing || Self.hasCaption(gallery) captionView.isHidden = !hasCaption captionView.configureCaption(gallery.body, formatted: gallery.formatted) if hasCaption { @@ -102,7 +119,6 @@ final class MessageGalleryContentView: NSView, MessageMediaPreviewContentView { compositionConstraints.append(bottom) updateHeights() NSLayoutConstraint.activate(compositionConstraints) - updateCallbacks() } override func layout() { @@ -123,7 +139,7 @@ final class MessageGalleryContentView: NSView, MessageMediaPreviewContentView { var height = itemHeights.reduce(0, +) + CGFloat(max(itemHeights.count - 1, 0)) * Self.itemSpacing - if Self.hasCaption(gallery) { + if captionView.isEditing || Self.hasCaption(gallery) { height += (itemHeights.isEmpty ? 0 : Self.captionSpacing) + ceil( diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift index 1b891f1..b1f89da 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageHoverOverlayView.swift @@ -8,12 +8,14 @@ final class MessageHoverOverlayView: NSView { case reply case replyInThread case pin + case edit } var onAction: ((Action) -> Void)? var onMouseExited: ((NSEvent) -> Void)? private let stack = NSStackView() + private let editButton = HoverActionButton(symbol: "pencil", label: "Edit message") private let replyButton = HoverActionButton(symbol: "arrowshape.turn.up.left", label: "Reply") private let threadButton = HoverActionButton(symbol: "ellipsis.message", label: "Reply in thread") @@ -74,6 +76,8 @@ final class MessageHoverOverlayView: NSView { threadButton.onClick = { [weak self] in self?.onAction?(.replyInThread) } stack.addArrangedSubview(replyButton) stack.addArrangedSubview(threadButton) + editButton.onClick = { [weak self] in self?.onAction?(.edit) } + stack.addArrangedSubview(editButton) let pinButton = HoverActionButton(symbol: "pin", label: "Pin") pinButton.onClick = { [weak self] in self?.onAction?(.pin) } @@ -92,7 +96,8 @@ final class MessageHoverOverlayView: NSView { configure(canReply: true) } - func configure(canReply: Bool) { + func configure(canReply: Bool, canEdit: Bool = false) { + editButton.isHidden = !canEdit replyButton.isHidden = !canReply threadButton.isHidden = !canReply setFrameSize(fittingSize) diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift index 0ba6667..07fbdc1 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageImageContentView.swift @@ -95,7 +95,8 @@ final class MessageImageContentView: NSView, MessageMediaPreviewContentView { func configure(image: ImageMessageContent, matrixClient: MatrixClient?) { self.imageContent = image self.matrixClient = matrixClient - let hasCaption = image.caption?.isEmpty == false || image.formattedCaption != nil + let hasCaption = + captionView.isEditing || image.caption?.isEmpty == false || image.formattedCaption != nil captionView.isHidden = !hasCaption captionView.configureCaption(image.caption, formatted: image.formattedCaption) imageBottomConstraint.isActive = !hasCaption @@ -121,6 +122,19 @@ final class MessageImageContentView: NSView, MessageMediaPreviewContentView { } } + func configureEditing(_ state: MessageEditState?) { + captionView.configureEditing(state) + let hasCaption = + state != nil || imageContent?.caption?.isEmpty == false + || imageContent?.formattedCaption != nil + imageBottomConstraint.isActive = false + captionBottomConstraint.isActive = false + captionView.isHidden = !hasCaption + imageBottomConstraint.isActive = !hasCaption + captionBottomConstraint.isActive = hasCaption + needsLayout = true + } + override func layout() { super.layout() guard let imageContent else { return } @@ -136,7 +150,8 @@ final class MessageImageContentView: NSView, MessageMediaPreviewContentView { func height(for image: ImageMessageContent, width: CGFloat) -> CGFloat { let imageHeight = Self.imageSize(for: image, width: width).height - guard image.caption?.isEmpty == false || image.formattedCaption != nil else { + guard captionView.isEditing || image.caption?.isEmpty == false || image.formattedCaption != nil + else { return imageHeight } return imageHeight + 10 diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift index 2c8e7f3..84b0077 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageRowView.swift @@ -31,6 +31,7 @@ final class MessageRowView: NSView { private static let receiptSpacing: CGFloat = 10 private let timestamp = NSTextField(labelWithString: "") + private let editedLabel = NSTextField(labelWithString: "(edited)") private let contentView: any MessageContentRowView private var replyPreview: MessageReplyPreviewView? private var replyDetails: MatrixRustSDK.EmbeddedEventDetails? @@ -81,6 +82,13 @@ final class MessageRowView: NSView { timestamp.textColor = .secondaryLabelColor timestamp.alignment = .right timestamp.translatesAutoresizingMaskIntoConstraints = false + + editedLabel.font = .systemFont(ofSize: 10) + editedLabel.textColor = .secondaryLabelColor + editedLabel.alignment = .right + editedLabel.isHidden = true + editedLabel.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false contentView.onSelectRequest = { [weak self] in guard let self else { return } @@ -101,13 +109,18 @@ final class MessageRowView: NSView { )) addSubview(timestamp) + addSubview(editedLabel) addSubview(contentView) + // NSTableView may retain an old encapsulated height while a reused row is reconfigured. contentBottomToRow.priority = .init(999) NSLayoutConstraint.activate([ timestamp.leadingAnchor.constraint(equalTo: leadingAnchor), timestamp.topAnchor.constraint(equalTo: topAnchor, constant: 6), timestamp.widthAnchor.constraint(equalToConstant: 48), + editedLabel.leadingAnchor.constraint(equalTo: timestamp.leadingAnchor), + editedLabel.trailingAnchor.constraint(equalTo: timestamp.trailingAnchor), + editedLabel.topAnchor.constraint(equalTo: timestamp.bottomAnchor, constant: 1), contentView.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), contentView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), @@ -116,6 +129,10 @@ final class MessageRowView: NSView { ]) } + func configureEditing(_ state: MessageEditState?) { + contentView.configureEditing(state) + } + func configure( event: MatrixRustSDK.EventTimelineItem, content: MatrixRustSDK.MsgLikeContent, @@ -136,6 +153,7 @@ final class MessageRowView: NSView { let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) timestamp.stringValue = Self.timeFormatter.string(from: date) + editedLabel.isHidden = !Self.hasBeenEdited(content) contentView.configure(content: content, matrixClient: matrixClient) configureReply(details: replyDetails, onClick: onReplyClick) configureThread(summary: content.threadSummary, onClick: onThreadClick) @@ -402,6 +420,14 @@ final class MessageRowView: NSView { } } + private static func hasBeenEdited(_ content: MatrixRustSDK.MsgLikeContent) -> Bool { + switch content.kind { + case .message(let message): return message.isEdited + case .poll(_, _, _, _, _, _, let hasBeenEdited): return hasBeenEdited + default: return false + } + } + func height( for content: MatrixRustSDK.MsgLikeContent, width: CGFloat, replyDetails: MatrixRustSDK.EmbeddedEventDetails?, reactions: [MatrixRustSDK.Reaction], @@ -430,7 +456,7 @@ final class MessageRowView: NSView { ? 0 : MessageReadReceiptsView.rowHeight + Self.receiptSpacing return max( ceil(contentView.height(for: content, width: contentWidth)) + replyHeight + threadHeight - + reactionsHeight + receiptsHeight + 8, 28) + + reactionsHeight + receiptsHeight + 8, Self.hasBeenEdited(content) ? 40 : 28) } @available(*, unavailable) diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift index 24ffaa6..36cc776 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageTextContentView.swift @@ -3,12 +3,151 @@ import MatrixIntegration import MatrixRustSDK import MessageFormatting +final class MessageEditingView: NSView, NSTextViewDelegate { + private let editor = SubmitableTextView(frame: .zero) + private let editorScrollView = NSScrollView() + private let saveButton = NSButton(title: "Save", target: nil, action: nil) + private let cancelButton = NSButton(title: "Cancel", target: nil, action: nil) + private let editErrorLabel = NSTextField(labelWithString: "") + private var editState: MessageEditState? + private var restoringEditor = false + + public var isEditing: Bool { editState != nil } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + editor.onSubmit = self.saveEdit + editor.isRichText = false + editor.font = .systemFont(ofSize: 13) + editor.isHorizontallyResizable = false + editor.isVerticallyResizable = true + editor.autoresizingMask = [.width] + editor.textContainer?.widthTracksTextView = true + editor.textContainerInset = NSSize(width: 2, height: 5) + editor.delegate = self + editor.setAccessibilityLabel("Edit message") + + editorScrollView.documentView = editor + editorScrollView.hasVerticalScroller = true + editorScrollView.borderType = .noBorder + editorScrollView.translatesAutoresizingMaskIntoConstraints = false + + let containerView = NSBox() + containerView.boxType = .custom + containerView.borderWidth = 1 + containerView.borderColor = .separatorColor + containerView.cornerRadius = 6 + containerView.addSubview(editorScrollView) + + editorScrollView.wantsLayer = true + editorScrollView.layer?.cornerRadius = 6 + editorScrollView.layer?.masksToBounds = true + editorScrollView.contentView.wantsLayer = true + editorScrollView.contentView.layer?.cornerRadius = 6 + editorScrollView.contentView.layer?.masksToBounds = true + + saveButton.target = self + saveButton.action = #selector(saveEdit) + saveButton.keyEquivalent = "\r" + saveButton.keyEquivalentModifierMask = [.command] + + cancelButton.target = self + cancelButton.action = #selector(cancelEdit) + cancelButton.keyEquivalent = "\u{1b}" // escape button + + editErrorLabel.textColor = .systemRed + editErrorLabel.lineBreakMode = .byTruncatingTail + editErrorLabel.isSelectable = true + + for view in [containerView, saveButton, cancelButton, editErrorLabel] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + + NSLayoutConstraint.activate([ + editorScrollView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 1), + editorScrollView.trailingAnchor.constraint( + equalTo: containerView.trailingAnchor, constant: -1), + editorScrollView.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 1), + editorScrollView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor, constant: -1), + + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.bottomAnchor.constraint(equalTo: saveButton.topAnchor, constant: -6), + + saveButton.trailingAnchor.constraint(equalTo: trailingAnchor), + saveButton.bottomAnchor.constraint(equalTo: bottomAnchor), + + cancelButton.trailingAnchor.constraint(equalTo: saveButton.leadingAnchor, constant: -6), + cancelButton.centerYAnchor.constraint(equalTo: saveButton.centerYAnchor), + + editErrorLabel.leadingAnchor.constraint(equalTo: leadingAnchor), + editErrorLabel.trailingAnchor.constraint( + lessThanOrEqualTo: cancelButton.leadingAnchor, constant: 8), + editErrorLabel.centerYAnchor.constraint(equalTo: saveButton.centerYAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func configureEditing(_ state: MessageEditState?) { + editState = state + guard let state else { return } + restoringEditor = true + if editor.string != state.text { editor.string = state.text } + let length = (state.text as NSString).length + let location = min(max(state.selectedRange.location, 0), length) + editor.setSelectedRange( + NSRange( + location: location, length: min(max(state.selectedRange.length, 0), length - location))) + restoringEditor = false + editor.isEditable = !state.isSaving + saveButton.isEnabled = !state.isSaving + cancelButton.isEnabled = !state.isSaving + saveButton.title = state.isSaving ? "Saving…" : "Save" + editErrorLabel.stringValue = state.error ?? "" + editErrorLabel.toolTip = state.error + } + + func textDidChange(_ notification: Notification) { + guard !restoringEditor, let state = editState else { return } + state.text = editor.string + state.selectedRange = editor.selectedRange() + state.onChange?() + } + + func textViewDidChangeSelection(_ notification: Notification) { + guard !restoringEditor, let state = editState else { return } + state.selectedRange = editor.selectedRange() + } + + @objc private func saveEdit() { + guard let state = editState, !state.isSaving else { return } + state.onSave?() + } + + @objc private func cancelEdit() { + guard let state = editState, !state.isSaving else { return } + state.onCancel?() + } +} + /// Retains the NSTextView and its layout machinery when its table row is reused. final class MessageTextContentView: NSView, MessageContentRowView { var onSelectRequest: (() -> Void)? var onArrowKey: ((TimelineSelectionDirection) -> Void)? + private let bodyText = OcclusionAwareTextView(frame: .zero) + static let editingHeight: CGFloat = 140 + var cachedEditingView: MessageEditingView? = nil + public var isEditing: Bool { cachedEditingView?.isEditing ?? false } + override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -26,6 +165,7 @@ final class MessageTextContentView: NSView, MessageContentRowView { bodyText.onArrowKey = { [weak self] direction in self?.onArrowKey?(direction) } addSubview(bodyText) + NSLayoutConstraint.activate([ bodyText.leadingAnchor.constraint(equalTo: leadingAnchor), bodyText.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -43,11 +183,38 @@ final class MessageTextContentView: NSView, MessageContentRowView { } func height(forCaption caption: String?, formatted: FormattedBody?, width: CGFloat) -> CGFloat { - measure(Self.attributedCaption(caption, formatted: formatted), width: width) + isEditing + ? Self.editingHeight + : measure(Self.attributedCaption(caption, formatted: formatted), width: width) } func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat) -> CGFloat { - measure(Self.attributedBody(for: content), width: width) + isEditing ? Self.editingHeight : measure(Self.attributedBody(for: content), width: width) + } + + func configureEditing(_ state: MessageEditState?) { + let editingView: MessageEditingView + if let cachedEditingView { + editingView = cachedEditingView + } else { + editingView = MessageEditingView(frame: .zero) + cachedEditingView = editingView + editingView.isHidden = true + editingView.translatesAutoresizingMaskIntoConstraints = false + addSubview(editingView) + + NSLayoutConstraint.activate([ + editingView.leadingAnchor.constraint(equalTo: leadingAnchor), + editingView.trailingAnchor.constraint(equalTo: trailingAnchor), + editingView.topAnchor.constraint(equalTo: topAnchor), + editingView.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + editingView.configureEditing(state) + + bodyText.isHidden = state != nil + editingView.isHidden = state == nil } private func measure(_ text: NSAttributedString, width: CGFloat) -> CGFloat { @@ -91,6 +258,7 @@ final class MessageTextContentView: NSView, MessageContentRowView { { let fontSize = UserDefaults.standard.object(forKey: "fontSize") as? Int ?? 13 let font = NSFont.systemFont(ofSize: CGFloat(fontSize)) + func plain(_ text: String, color: NSColor = .labelColor, italic: Bool = false) -> NSAttributedString { diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift index 8441beb..29e27f9 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/MessageVideoContentView.swift @@ -119,7 +119,8 @@ final class MessageVideoContentView: NSView, MessageContentRowView { func configure(video: VideoMessageContent, matrixClient: MatrixClient?) { videoContent = video self.matrixClient = matrixClient - let hasCaption = video.caption?.isEmpty == false || video.formattedCaption != nil + let hasCaption = + captionView.isEditing || video.caption?.isEmpty == false || video.formattedCaption != nil captionView.isHidden = !hasCaption captionView.configureCaption(video.caption, formatted: video.formattedCaption) mediaBottomConstraint.isActive = !hasCaption @@ -147,6 +148,19 @@ final class MessageVideoContentView: NSView, MessageContentRowView { loadThumbnail(for: video, from: matrixClient) } + func configureEditing(_ state: MessageEditState?) { + captionView.configureEditing(state) + let hasCaption = + state != nil || videoContent?.caption?.isEmpty == false + || videoContent?.formattedCaption != nil + mediaBottomConstraint.isActive = false + captionBottomConstraint.isActive = false + captionView.isHidden = !hasCaption + mediaBottomConstraint.isActive = !hasCaption + captionBottomConstraint.isActive = hasCaption + needsLayout = true + } + override func layout() { if let videoContent, bounds.width > 0 { updateMediaSize(for: videoContent, width: bounds.width) @@ -163,7 +177,8 @@ final class MessageVideoContentView: NSView, MessageContentRowView { func height(for video: VideoMessageContent, width: CGFloat) -> CGFloat { let mediaHeight = Self.mediaSize(for: video, width: width).height - guard video.caption?.isEmpty == false || video.formattedCaption != nil else { + guard captionView.isEditing || video.caption?.isEmpty == false || video.formattedCaption != nil + else { return mediaHeight } return mediaHeight + 10 diff --git a/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift index ed7e7ce..ec079cb 100644 --- a/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Sources/MactrixApp/Views/ChatView/TimelineView/TimelineTableView.swift @@ -49,6 +49,21 @@ enum TimelineItemRowInfo { return NSUserInterfaceItemIdentifier("typing-indicator") } } + + var timelineItem: TimelineItem? { + switch self { + case .message(let item, _, _): + item + case .profile(let item, _): + item + case .state(let item, _): + item + case .virtual(let item, _): + item + case .typingIndicator: + nil + } + } } extension TimelineItemRowInfo: Identifiable { @@ -80,6 +95,7 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT private weak var hoveredMessageView: MessageRowView? private var hoveredMessageId: String? private var hoveredRowIndex: Int? + private var messageEdit: MessageEditState? private var updatingTimelineItems = false private var previewURL: URL? private var previewEventIdentifier: String? @@ -201,6 +217,7 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT self.showMediaPreview( url: url, handle: handle, eventIdentifier: event.eventOrTransactionId.id) } + view.configureEditing(messageEdit?.id == event.eventOrTransactionId ? messageEdit : nil) view.configure( event: event, content: content, @@ -338,7 +355,9 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT guard row >= 0, row < timelineItems.count, case .message(_, let event, _) = timelineItems[row] else { return false } - hoverOverlay.configure(canReply: event.canBeRepliedTo) + hoverOverlay.configure( + canReply: event.canBeRepliedTo, + canEdit: event.isOwn && event.isEditable && editableText(for: event) != nil) if hoveredMessageView !== rowView { hoveredMessageView?.setHoverHighlight(false) } @@ -426,6 +445,8 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT toggleReaction(key, for: event) case .reactionPicker: break // The old picker button does not have an action yet. + case .edit: + beginEditing(event: event) case .reply: composer.beginReply(to: event) case .replyInThread: @@ -501,17 +522,19 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveT } } + func rowForEventId(id eventId: MatrixRustSDK.EventOrTransactionId) -> Int? { + timelineItems.firstIndex { item in + switch item { + case .message(item: _, let event, content: _): + return event.eventOrTransactionId == eventId + default: + return false + } + } + } + func focusTimelineEvent(id eventId: MatrixRustSDK.EventOrTransactionId) { - guard - let rowIndex = timelineItems.firstIndex(where: { item in - switch item { - case .message(item: _, let event, content: _): - return event.eventOrTransactionId == eventId - default: - return false - } - }) - else { return } + guard let rowIndex = rowForEventId(id: eventId) else { return } tableView.selectRowIndexes(IndexSet(integer: rowIndex), byExtendingSelection: false) updateSelectedMessage() @@ -731,7 +754,10 @@ extension TimelineViewController: NSTableViewDelegate { if case .message(_, let event, let content) = item { let kind = MessageContentKind(content: content) - return measurementView(for: kind).height( + let measurement = measurementView(for: kind) + measurement.configureEditing( + messageEdit?.id == event.eventOrTransactionId ? messageEdit : nil) + return measurement.height( for: content, width: tableView.tableColumns[0].width, replyDetails: replyDetails(for: content), @@ -744,6 +770,114 @@ extension TimelineViewController: NSTableViewDelegate { } } +// Message editing +extension TimelineViewController { + private func editableText(for event: MatrixRustSDK.EventTimelineItem) -> String? { + guard case .msgLike(let content) = event.content, + case .message(let message) = content.kind + else { return nil } + switch message.msgType { + case .text(let text): return text.body + case .notice(let notice): return notice.body + case .emote(let emote): return emote.body + case .image(let image): return image.caption ?? "" + case .video(let video): return video.caption ?? "" + case .file(let file): return file.caption ?? "" + case .gallery(let gallery): return gallery.body + default: return nil + } + } + + private func beginEditing(event: MatrixRustSDK.EventTimelineItem) { + guard event.isOwn, event.isEditable, let text = editableText(for: event) else { return } + if messageEdit?.id == event.eventOrTransactionId { return } + + var dirtyEvents = Set([event.eventOrTransactionId]) + if let messageEdit { + dirtyEvents.insert(messageEdit.id) + } + + let state = MessageEditState(id: event.eventOrTransactionId, text: text) + state.onCancel = { [weak self, weak state] in + guard let self, let state, self.messageEdit === state else { return } + self.messageEdit = nil + self.refreshEditingRows(events: [event.eventOrTransactionId]) + } + state.onSave = { [weak self, weak state] in + guard let self, let state else { return } + self.saveEdit(state, event: event) + } + messageEdit = state + hideHoverOverlay() + + refreshEditingRows(events: dirtyEvents) + } + + private func refreshEditingRows(events: Set) { + var rows = IndexSet() + for eventId in events { + guard + let row = rowForEventId(id: eventId), + let view = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) + as? MessageRowView + else { continue } + + view.configureEditing(messageEdit?.id == eventId ? messageEdit : nil) + rows.insert(row) + } + tableView.noteHeightOfRows(withIndexesChanged: rows) + } + + private func saveEdit(_ state: MessageEditState, event: MatrixRustSDK.EventTimelineItem) { + guard messageEdit === state, !state.isSaving, let sdkTimeline = timeline.timeline, + case .msgLike(let content) = event.content, + case .message(let message) = content.kind + else { return } + + let editedContent: EditedContent + + switch message.msgType { + case .text: + editedContent = .roomMessage(content: messageEventContentFromMarkdown(md: state.text)) + case .emote: + editedContent = .roomMessage(content: messageEventContentFromMarkdownAsEmote(md: state.text)) + case .notice(var notice): + notice.body = state.text + notice.formatted = nil + do { + editedContent = .roomMessage( + content: try messageEventContentNew(msgtype: .notice(content: notice)) + ) + } catch { + state.error = error.localizedDescription + refreshEditingRows(events: [event.eventOrTransactionId]) + return + } + default: + editedContent = .mediaCaption( + caption: state.text.isEmpty ? nil : state.text, + formattedCaption: nil, mentions: nil) + } + state.isSaving = true + state.error = nil + refreshEditingRows(events: [event.eventOrTransactionId]) + Task { [weak self] in + do { + try await sdkTimeline.edit( + eventOrTransactionId: event.eventOrTransactionId, newContent: editedContent) + guard let self, self.messageEdit === state else { return } + self.messageEdit = nil + self.refreshEditingRows(events: [event.eventOrTransactionId]) + } catch { + guard let self, self.messageEdit === state else { return } + state.isSaving = false + state.error = error.localizedDescription + self.refreshEditingRows(events: [event.eventOrTransactionId]) + } + } + } +} + class BottomStickyTableView: NSTableView { var onLayout: (() -> Void)? var onArrowKey: ((TimelineSelectionDirection) -> Void)? diff --git a/Tests/MactrixAppTests/MessageEditingTests.swift b/Tests/MactrixAppTests/MessageEditingTests.swift new file mode 100644 index 0000000..a8f2fdb --- /dev/null +++ b/Tests/MactrixAppTests/MessageEditingTests.swift @@ -0,0 +1,60 @@ +import AppKit +import MatrixRustSDK +import Testing + +@testable import MactrixApp + +@MainActor +struct MessageEditingTests { + @Test func draftAndSelectionSurviveViewReconstruction() throws { + let state = MessageEditState( + id: MatrixRustSDK.EventOrTransactionId.eventId(eventId: "event"), text: "Original") + let first = MessageTextContentView() + first.configureEditing(state) + let editor = try #require(findEditor(in: first)) + editor.string = "Updated draft" + editor.setSelectedRange(NSRange(location: 3, length: 2)) + first.cachedEditingView?.textDidChange( + Notification(name: NSText.didChangeNotification, object: editor)) + first.configureEditing(nil) + + let reconstructed = MessageTextContentView() + reconstructed.configureEditing(state) + let restored = try #require(findEditor(in: reconstructed)) + #expect(restored.string == "Updated draft") + #expect(restored.selectedRange() == NSRange(location: 3, length: 2)) + #expect(reconstructed.height(forCaption: nil, formatted: nil, width: 200) == 140) + reconstructed.configureEditing(nil) + #expect(!reconstructed.isEditing) + } + + @Test func emptyMediaCaptionGetsEditorHeight() throws { + let source = try MediaSource.fromUrl(url: "mxc://example.org/file") + let file = FileMessageContent( + filename: "file.pdf", caption: nil, formattedCaption: nil, source: source, info: nil) + let state = MessageEditState( + id: MatrixRustSDK.EventOrTransactionId.eventId(eventId: "event"), text: "") + let view = MessageFileContentView() + let originalHeight = view.height(for: file, width: 240) + view.configureEditing(state) + #expect(view.height(for: file, width: 240) >= originalHeight + 140) + view.configureEditing(nil) + #expect(view.height(for: file, width: 240) == originalHeight) + + let gallery = GalleryMessageContent(body: "", formatted: nil, itemtypes: []) + let galleryView = MessageGalleryContentView() + galleryView.configureEditing(state) + galleryView.configure(gallery: gallery, matrixClient: nil) + #expect(galleryView.height(for: gallery, width: 240) == 140) + galleryView.configureEditing(nil) + #expect(galleryView.height(for: gallery, width: 240) == 0) + } + + private func findEditor(in view: NSView) -> NSTextView? { + if let text = view as? NSTextView, text.isEditable { return text } + for child in view.subviews { + if let editor = findEditor(in: child) { return editor } + } + return nil + } +}