From b0c773da09d05c4093771c7480314f3736490354 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Thu, 24 Sep 2026 13:57:29 +0200 Subject: [PATCH 01/23] Begin preparing for appkit backed rows --- Mactrix/Views/ChatView/ChatMessageView.swift | 2 +- .../TimelineView/TimelineTableView.swift | 112 ++++++++++++------ .../UI/Timeline/MessageEventView.swift | 10 +- 3 files changed, 80 insertions(+), 44 deletions(-) diff --git a/Mactrix/Views/ChatView/ChatMessageView.swift b/Mactrix/Views/ChatView/ChatMessageView.swift index b107d13..cd5b484 100644 --- a/Mactrix/Views/ChatView/ChatMessageView.swift +++ b/Mactrix/Views/ChatView/ChatMessageView.swift @@ -123,7 +123,7 @@ struct ChatMessageView: View, UI.MessageEventActions { var body: some View { if includeProfileHeader { - UI.MessageEventProfileView(event: event, actions: self, imageLoader: appState.matrixClient) + UI.MessageEventProfileView(event: event, focusUserAction: self.focusUser, imageLoader: appState.matrixClient) .font(.system(size: .init(fontSize))) } UI.MessageEventBodyView(event: event, focused: isEventFocused, reactions: msg.reactions, actions: self, ownUserID: ownUserId, imageLoader: appState.matrixClient, roomMembers: timeline?.room.members ?? []) { diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 76f9ec4..125665f 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -5,12 +5,15 @@ import SwiftUI import UI enum TimelineItemRowInfo { - case message(event: EventTimelineItem, content: MsgLikeContent) - case state(event: EventTimelineItem) - case virtual(virtual: VirtualTimelineItem) + case profile(item: TimelineItem, event: EventTimelineItem) + case message(item: TimelineItem, event: EventTimelineItem, content: MsgLikeContent) + case state(item: TimelineItem, event: EventTimelineItem) + case virtual(item: TimelineItem, virtual: VirtualTimelineItem) var reuseIdentifier: NSUserInterfaceItemIdentifier { switch self { + case .profile(profile: _): + return NSUserInterfaceItemIdentifier("profile") case .message: return NSUserInterfaceItemIdentifier("message") case .state: @@ -21,6 +24,21 @@ enum TimelineItemRowInfo { } } +extension TimelineItemRowInfo: Identifiable { + var id: String { + switch self { + case .profile(_, let event): + "profile:\(event.eventOrTransactionId.id)" + case .message(_, let event, _): + "message:\(event.eventOrTransactionId.id)" + case .state(_, let event): + "state:\(event.eventOrTransactionId.id)" + case .virtual(let item, _): + "virtual:\(item.uniqueId().id)" + } + } +} + struct TimelineItemRowView: View { let rowInfo: TimelineItemRowInfo let timeline: LiveTimeline? @@ -38,11 +56,13 @@ struct TimelineItemRowView: View { @ViewBuilder var contentView: some View { switch rowInfo { - case .message(let event, let content): - ChatMessageView(timeline: timeline, event: event, msg: content, includeProfileHeader: true) - case .state(let event): + case .profile(_, let event): + UI.MessageEventProfileView(event: event, focusUserAction: { windowState.focusUser(userId: event.sender) }, imageLoader: appState.matrixClient) + case .message(_, let event, let content): + ChatMessageView(timeline: timeline, event: event, msg: content, includeProfileHeader: false) + case .state(_, let event): UI.GenericEventView(event: event, name: event.content.description) - case .virtual(let virtual): + case .virtual(_, let virtual): UI.VirtualItemView(item: virtual.asModel) } } @@ -58,25 +78,6 @@ struct TimelineItemRowView: View { } } -extension TimelineItem { - var rowInfo: TimelineItemRowInfo { - if let virtual = asVirtual() { - return .virtual(virtual: virtual) - } - - if let event = asEvent() { - switch event.content { - case .msgLike(content: let content): - return .message(event: event, content: content) - default: - return .state(event: event) - } - } - - fatalError("unreachable state: item must be either virtual or event") - } -} - class TimelineViewController: NSViewController { let coordinator: TimelineViewRepresentable.Coordinator @@ -86,13 +87,13 @@ class TimelineViewController: NSViewController { let tableView = BottomStickyTableView() let timeline: LiveTimeline - var timelineItems: [TimelineItem] + var timelineItems: [TimelineItemRowInfo] = [] init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline, timelineItems: [TimelineItem]) { self.coordinator = coordinator self.timeline = timeline - self.timelineItems = timelineItems super.init(nibName: nil, bundle: nil) + self.timelineItems = mapTimelineItems(items: timelineItems) } override func viewDidLoad() { @@ -113,17 +114,17 @@ class TimelineViewController: NSViewController { guard let self else { return NSView() } let item = timelineItems[row] - let view = TimelineItemRowView(rowInfo: item.rowInfo, timeline: timeline, coordinator: coordinator) + let view = TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator) let hostView: NSHostingView - if let recycledView = tableView.makeView(withIdentifier: item.rowInfo.reuseIdentifier, owner: self) + if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) as? NSHostingView { recycledView.rootView = view hostView = recycledView } else { hostView = NSHostingView(rootView: view) - hostView.identifier = item.rowInfo.reuseIdentifier + hostView.identifier = item.reuseIdentifier hostView.autoresizingMask = [.width, .height] hostView.sizingOptions = [.preferredContentSize] hostView.setContentHuggingPriority(.required, for: .vertical) @@ -206,8 +207,13 @@ class TimelineViewController: NSViewController { } guard let focusedTimelineEventId, - let rowIndex = timelineItems.firstIndex(where: { - $0.asEvent()?.eventOrTransactionId == focusedTimelineEventId + let rowIndex = timelineItems.firstIndex(where: { item in + switch item { + case .message(item: _, event: let event, content: _): + return event.eventOrTransactionId == focusedTimelineEventId + default: + return false + } }) else { return } tableView.animateRowToVisible(rowIndex) @@ -226,9 +232,9 @@ class TimelineViewController: NSViewController { func updateTimelineItems(_ timelineItems: [TimelineItem]) { Logger.timelineTableView.info("update timeline items") - let oldIds = self.timelineItems.map { $0.uniqueId().id } - self.timelineItems = timelineItems.reversed() - let newIds = self.timelineItems.map { $0.uniqueId().id } + let oldIds = self.timelineItems.map { $0.id } + self.timelineItems = mapTimelineItems(items: timelineItems) + let newIds = self.timelineItems.map { $0.id } // If the IDs haven't changed, reload all rows in place (content-only update: reactions, read receipts, etc.) // Reloads all rows rather than just visible ones to avoid stale content in NSTableView's prepared/cached views. @@ -242,7 +248,7 @@ class TimelineViewController: NSViewController { snapshot.appendSections([.main]) for item in self.timelineItems { - snapshot.appendItems([.init(id: item.uniqueId().id)], toSection: .main) + snapshot.appendItems([.init(id: item.id)], toSection: .main) } dataSource?.apply(snapshot, animatingDifferences: false) @@ -255,6 +261,36 @@ class TimelineViewController: NSViewController { } } + private func mapTimelineItems(items: [TimelineItem]) -> [TimelineItemRowInfo] { + var result = [TimelineItemRowInfo]() + + var currentSender: String? = nil + for item in items { + if let event = item.asEvent() { + switch event.content { + case .msgLike(content: let content): + if event.sender != currentSender { + currentSender = event.sender + result.append(.profile(item: item, event: event)) + } + result.append(.message(item: item, event: event, content: content)) + default: + currentSender = nil + result.append(.state(item: item, event: event)) + } + } + + if let virtual = item.asVirtual() { + currentSender = nil + result.append(.virtual(item: item, virtual: virtual)) + } + } + + result.reverse() + + return result + } + // values used to track width changes var oldWidth: CGFloat? let measurementHostingView = { @@ -276,7 +312,7 @@ extension TimelineViewController: NSTableViewDelegate { func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { let item = timelineItems[row] - measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item.rowInfo, timeline: timeline, coordinator: coordinator)) + measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator)) let targetWidth = tableView.tableColumns[0].width let proposedSize = CGSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude) diff --git a/MactrixLibrary/Sources/UI/Timeline/MessageEventView.swift b/MactrixLibrary/Sources/UI/Timeline/MessageEventView.swift index 7b80dea..4aed354 100644 --- a/MactrixLibrary/Sources/UI/Timeline/MessageEventView.swift +++ b/MactrixLibrary/Sources/UI/Timeline/MessageEventView.swift @@ -89,12 +89,12 @@ struct MessageMainBody: View { let event: EventTimelineItem - let actions: MessageEventActions + let focusUserAction: () -> Void let imageLoader: ImageLoader? - public init(event: EventTimelineItem, actions: MessageEventActions, imageLoader: ImageLoader?) { + public init(event: EventTimelineItem, focusUserAction: @escaping () -> Void, imageLoader: ImageLoader?) { self.event = event - self.actions = actions + self.focusUserAction = focusUserAction self.imageLoader = imageLoader } @@ -107,7 +107,7 @@ public struct MessageEventProfileView Date: Thu, 24 Sep 2026 15:46:50 +0200 Subject: [PATCH 02/23] Implement profile row in AppKit --- Mactrix/Extensions/NSColor+userID.swift | 10 ++ Mactrix/Models/MatrixClient.swift | 11 +- .../TimelineView/MessageProfileRowView.swift | 110 ++++++++++++++++++ .../TimelineView/TimelineTableView.swift | 23 +++- MactrixLibrary/Sources/UI/AvatarImage.swift | 14 ++- 5 files changed, 156 insertions(+), 12 deletions(-) create mode 100644 Mactrix/Extensions/NSColor+userID.swift create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift diff --git a/Mactrix/Extensions/NSColor+userID.swift b/Mactrix/Extensions/NSColor+userID.swift new file mode 100644 index 0000000..d02e06d --- /dev/null +++ b/Mactrix/Extensions/NSColor+userID.swift @@ -0,0 +1,10 @@ +import AppKit + +public extension NSColor { + convenience init(userID: String) { + unsafe self.init(named: String( + format: "Username%02d", + userID.unicodeScalars.reduce(0) { $0 + Int($1.value) } % 16 + 1 + ))! + } +} diff --git a/Mactrix/Models/MatrixClient.swift b/Mactrix/Models/MatrixClient.swift index 445b481..ce748a7 100644 --- a/Mactrix/Models/MatrixClient.swift +++ b/Mactrix/Models/MatrixClient.swift @@ -255,19 +255,18 @@ extension MatrixClient: UI.ImageLoader { imageCache.setObject(image, forKey: key, cost: cost) } - func cachedImage(matrixUrl: String) -> Image? { - guard let nsImage = Self.imageCache.object(forKey: NSString(string: matrixUrl)) else { return nil } - return Image(nsImage: nsImage) + func cachedImage(matrixUrl: String) -> NSImage? { + return Self.imageCache.object(forKey: NSString(string: matrixUrl)) } - func loadImage(matrixUrl: String, size: CGSize?) async throws -> Image? { + func loadImage(matrixUrl: String, size: CGSize?) async throws -> NSImage? { let cacheKey = if let size { NSString(string: "\(matrixUrl)_\(Int(size.width))x\(Int(size.height))") } else { NSString(string: matrixUrl) } if let cached = Self.imageCache.object(forKey: cacheKey) { - return Image(nsImage: cached) + return cached } let mediaSource = try MediaSource.fromUrl(url: matrixUrl) @@ -284,7 +283,7 @@ extension MatrixClient: UI.ImageLoader { do { let nsImage = try imageData.toOrientedImage(contentType: imageData.computeMimeType()) Self.setCachedImage(nsImage, forKey: cacheKey) - return Image(nsImage: nsImage) + return nsImage } catch { Logger.matrixClient.error("failed convert matrix media data to Image: \(error) \(imageData)") throw error diff --git a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift new file mode 100644 index 0000000..935209f --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift @@ -0,0 +1,110 @@ +import AppKit +import MatrixRustSDK +import UI + +class MessageProfileRowView: NSView { + let profilePicture = NSButton() + let name = NSTextField() + + private var sender: String? + private var focusUserAction: (_ sender: String) -> Void = { _ in } + private var imageLoader: UI.ImageLoader? + + private var avatarTask: Task? + private var avatarUrl: String? + + static let ROW_HEIGHT: Double = 32 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + profilePicture.target = self + profilePicture.action = #selector(onProfileClicked) + profilePicture.translatesAutoresizingMaskIntoConstraints = false + profilePicture.imageScaling = .scaleProportionallyDown + profilePicture.imagePosition = .imageOnly + profilePicture.isBordered = false + profilePicture.wantsLayer = true + profilePicture.layer?.cornerRadius = 16 + profilePicture.layer?.masksToBounds = true + profilePicture.layer?.backgroundColor = NSColor.gray.cgColor + + name.translatesAutoresizingMaskIntoConstraints = false + name.isSelectable = true + name.isEditable = false + name.isBordered = false + name.drawsBackground = false + name.font = .boldSystemFont(ofSize: NSFont.systemFontSize) + name.lineBreakMode = .byTruncatingTail + + addSubview(profilePicture) + addSubview(name) + + NSLayoutConstraint.activate([ + profilePicture.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + profilePicture.centerYAnchor.constraint(equalTo: centerYAnchor), + profilePicture.widthAnchor.constraint(equalToConstant: 32), + profilePicture.heightAnchor.constraint(equalToConstant: 32), + + name.leadingAnchor.constraint(equalTo: profilePicture.trailingAnchor, constant: 16), + name.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -10), + name.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func initialize(imageLoader: UI.ImageLoader?, focusUser: @escaping (_ sender: String) -> Void) { + self.imageLoader = imageLoader + focusUserAction = focusUser + } + + func configure(event: EventTimelineItem) { + sender = event.sender + + let username: String + switch event.senderProfile { + case .ready(let displayName, _, let avatarUrl, _, _): + username = displayName ?? event.sender + self.avatarUrl = avatarUrl + default: + username = event.sender + avatarUrl = nil + } + + name.stringValue = username + name.textColor = NSColor(userID: event.sender) + name.toolTip = username + + reloadAvatar() + } + + @objc func onProfileClicked(_ target: Any) { + if let sender { + focusUserAction(sender) + } + } + + private func reloadAvatar() { + avatarTask?.cancel() + profilePicture.image = nil + guard let avatarUrl, let imageLoader else { return } + + if let cached = imageLoader.cachedImage(matrixUrl: avatarUrl) { + profilePicture.image = cached + return + } + + avatarTask = Task { [weak self] in + guard let image = try? await imageLoader.loadImage(matrixUrl: avatarUrl, size: nil), + !Task.isCancelled, + let self, + self.avatarUrl == avatarUrl else { return } + + self.profilePicture.image = image + } + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 125665f..c2a9346 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -114,8 +114,25 @@ class TimelineViewController: NSViewController { guard let self else { return NSView() } let item = timelineItems[row] - let view = TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator) + if case .profile(item: _, event: let event) = item { + if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + as? MessageProfileRowView + { + recycledView.configure(event: event) + return recycledView + } else { + let view = MessageProfileRowView() + view.initialize(imageLoader: coordinator.appState.matrixClient, focusUser: { [weak self] in + self?.coordinator.windowState.focusUser(userId: $0) + }) + view.configure(event: event) + view.identifier = item.reuseIdentifier + return view + } + } + + let view = TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator) let hostView: NSHostingView if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) as? NSHostingView @@ -312,6 +329,10 @@ extension TimelineViewController: NSTableViewDelegate { func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { let item = timelineItems[row] + if case .profile = item { + return MessageProfileRowView.ROW_HEIGHT + } + measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator)) let targetWidth = tableView.tableColumns[0].width diff --git a/MactrixLibrary/Sources/UI/AvatarImage.swift b/MactrixLibrary/Sources/UI/AvatarImage.swift index 06fb1c3..b58027b 100644 --- a/MactrixLibrary/Sources/UI/AvatarImage.swift +++ b/MactrixLibrary/Sources/UI/AvatarImage.swift @@ -4,8 +4,8 @@ import SwiftUI @MainActor public protocol ImageLoader { - func loadImage(matrixUrl: String, size: CGSize?) async throws -> Image? - func cachedImage(matrixUrl: String) -> Image? + func loadImage(matrixUrl: String, size: CGSize?) async throws -> NSImage? + func cachedImage(matrixUrl: String) -> NSImage? } public struct AvatarImage: View { @@ -24,7 +24,7 @@ public struct AvatarImage: View { self.imageLoader = imageLoader self.placeholder = placeholder if let avatarUrl, let cached = imageLoader?.cachedImage(matrixUrl: avatarUrl) { - self._avatar = State(initialValue: cached) + self._avatar = State(initialValue: Image(nsImage: cached)) } } @@ -60,14 +60,18 @@ public struct AvatarImage: View { // Check cache first (handles cell reuse with stale @State) if let cached = imageLoader?.cachedImage(matrixUrl: avatarUrl) { - avatar = cached + avatar = Image(nsImage: cached) return } avatar = nil do { - avatar = try await imageLoader?.loadImage(matrixUrl: avatarUrl, size: nil) + if let image = try await imageLoader?.loadImage(matrixUrl: avatarUrl, size: nil) { + avatar = Image(nsImage: image) + } else { + avatar = nil + } } catch { Logger.viewCycle.error("failed to load avatar (\(avatarUrl): \(error)") } From 81a79a2faf654e2dd973d7e32754ee0a9105e7f9 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Thu, 24 Sep 2026 17:04:24 +0200 Subject: [PATCH 03/23] Add typing indicator in AppKit --- .../TimelineView/MessageProfileRowView.swift | 1 + .../TimelineView/TimelineTableView.swift | 88 +++++++++++---- .../TimelineView/TypingIndicatorRowView.swift | 101 ++++++++++++++++++ 3 files changed, 170 insertions(+), 20 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift index 935209f..8277389 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift @@ -36,6 +36,7 @@ class MessageProfileRowView: NSView { name.drawsBackground = false name.font = .boldSystemFont(ofSize: NSFont.systemFontSize) name.lineBreakMode = .byTruncatingTail + name.maximumNumberOfLines = 1 addSubview(profilePicture) addSubview(name) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index c2a9346..4b6a7c8 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -9,6 +9,7 @@ enum TimelineItemRowInfo { case message(item: TimelineItem, event: EventTimelineItem, content: MsgLikeContent) case state(item: TimelineItem, event: EventTimelineItem) case virtual(item: TimelineItem, virtual: VirtualTimelineItem) + case typingIndicator var reuseIdentifier: NSUserInterfaceItemIdentifier { switch self { @@ -20,6 +21,8 @@ enum TimelineItemRowInfo { return NSUserInterfaceItemIdentifier("state") case .virtual: return NSUserInterfaceItemIdentifier("virtual") + case .typingIndicator: + return NSUserInterfaceItemIdentifier("typing-indicator") } } } @@ -35,6 +38,8 @@ extension TimelineItemRowInfo: Identifiable { "state:\(event.eventOrTransactionId.id)" case .virtual(let item, _): "virtual:\(item.uniqueId().id)" + case .typingIndicator: + "typing-indicator" } } } @@ -56,14 +61,16 @@ struct TimelineItemRowView: View { @ViewBuilder var contentView: some View { switch rowInfo { - case .profile(_, let event): - UI.MessageEventProfileView(event: event, focusUserAction: { windowState.focusUser(userId: event.sender) }, imageLoader: appState.matrixClient) + case .profile: + Text("Profile rows implemented in AppKit now") case .message(_, let event, let content): ChatMessageView(timeline: timeline, event: event, msg: content, includeProfileHeader: false) case .state(_, let event): UI.GenericEventView(event: event, name: event.content.description) case .virtual(_, let virtual): UI.VirtualItemView(item: virtual.asModel) + case .typingIndicator: + Text("Typing indicator implemented in AppKit now") } } @@ -115,7 +122,8 @@ class TimelineViewController: NSViewController { let item = timelineItems[row] - if case .profile(item: _, event: let event) = item { + switch item { + case .profile(item: _, event: let event): if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) as? MessageProfileRowView { @@ -130,24 +138,34 @@ class TimelineViewController: NSViewController { view.identifier = item.reuseIdentifier return view } - } + case .typingIndicator: + if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) as? TypingIndicatorRowView { + recycledView.configure(names: self.typingNames) + return recycledView + } else { + let view = TypingIndicatorRowView() + view.configure(names: self.typingNames) + view.identifier = item.reuseIdentifier + return view + } + default: + let view = TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator) + let hostView: NSHostingView + if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + as? NSHostingView + { + recycledView.rootView = view + hostView = recycledView + } else { + hostView = NSHostingView(rootView: view) + hostView.identifier = item.reuseIdentifier + hostView.autoresizingMask = [.width, .height] + hostView.sizingOptions = [.preferredContentSize] + hostView.setContentHuggingPriority(.required, for: .vertical) + } - let view = TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator) - let hostView: NSHostingView - if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) - as? NSHostingView - { - recycledView.rootView = view - hostView = recycledView - } else { - hostView = NSHostingView(rootView: view) - hostView.identifier = item.reuseIdentifier - hostView.autoresizingMask = [.width, .height] - hostView.sizingOptions = [.preferredContentSize] - hostView.setContentHuggingPriority(.required, for: .vertical) + return hostView } - - return hostView } tableView.delegate = self @@ -178,6 +196,30 @@ class TimelineViewController: NSViewController { ) listenForFocusTimelineItem() + listenForTypingUsers() + } + + private var typingNames: [String] { + let members = timeline.room.members + return timeline.room.typingUserIds.map { userId in + members.first(where: { $0.userId == userId })?.displayName ?? userId + } + } + + private func listenForTypingUsers() { + withObservationTracking { + _ = timeline.room.typingUserIds + _ = timeline.room.members + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.listenForTypingUsers() + guard self.tableView.numberOfRows > 0, + let view = self.tableView.view(atColumn: 0, row: 0, makeIfNecessary: false) as? TypingIndicatorRowView + else { return } + view.configure(names: self.typingNames) + } + } } @objc func handleTableResize(_ notification: Notification) { @@ -243,7 +285,7 @@ class TimelineViewController: NSViewController { enum TimelineSection { case main - case typingIndicator + // case typingIndicator } func updateTimelineItems(_ timelineItems: [TimelineItem]) { @@ -303,6 +345,8 @@ class TimelineViewController: NSViewController { } } + result.append(.typingIndicator) + result.reverse() return result @@ -333,6 +377,10 @@ extension TimelineViewController: NSTableViewDelegate { return MessageProfileRowView.ROW_HEIGHT } + if case .typingIndicator = item { + return TypingIndicatorRowView.rowHeight + } + measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator)) let targetWidth = tableView.tableColumns[0].width diff --git a/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift b/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift new file mode 100644 index 0000000..d9aa344 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift @@ -0,0 +1,101 @@ +import AppKit +import QuartzCore + +final class TypingIndicatorRowView: NSView { + static let rowHeight: CGFloat = 32 + + private let label = NSTextField(labelWithString: "") + private let dots = (0 ..< 3).map { _ in NSView() } + private let content = NSStackView() + private var isAnimating = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + let dotStack = NSStackView(views: dots) + dotStack.orientation = .horizontal + dotStack.alignment = .centerY + dotStack.spacing = 2 + + for dot in dots { + dot.wantsLayer = true + dot.layer?.backgroundColor = NSColor.secondaryLabelColor.cgColor + dot.layer?.cornerRadius = 3 + NSLayoutConstraint.activate([ + dot.widthAnchor.constraint(equalToConstant: 6), + dot.heightAnchor.constraint(equalToConstant: 6), + ]) + } + + label.isBordered = false + label.isBezeled = false + label.drawsBackground = false + label.lineBreakMode = .byTruncatingTail + label.maximumNumberOfLines = 1 + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + content.orientation = .horizontal + content.alignment = .centerY + content.spacing = 8 + content.addArrangedSubview(dotStack) + content.addArrangedSubview(label) + content.translatesAutoresizingMaskIntoConstraints = false + content.isHidden = true + addSubview(content) + + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + content.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16), + content.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + func configure(names: [String]) { + guard !names.isEmpty else { + label.stringValue = "" + content.isHidden = true + stopAnimating() + return + } + + let text = NSMutableAttributedString( + string: names.joined(separator: ", "), + attributes: [.font: NSFont.boldSystemFont(ofSize: NSFont.systemFontSize)] + ) + text.append(NSAttributedString( + string: String(localized: names.count == 1 ? " is typing" : " are typing"), + attributes: [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] + )) + label.attributedStringValue = text + content.isHidden = false + startAnimating() + } + + private func startAnimating() { + guard !isAnimating else { return } + isAnimating = true + + for (index, dot) in dots.enumerated() { + let opacity = CAKeyframeAnimation(keyPath: "opacity") + opacity.values = [0.2, 0.7, 0.2] + opacity.keyTimes = [0, 0.5, 1] + opacity.duration = 1.8 + opacity.beginTime = CACurrentMediaTime() + Double(index) * 0.18 + opacity.repeatCount = .infinity + dot.layer?.add(opacity, forKey: "typingPulse") + } + } + + private func stopAnimating() { + guard isAnimating else { return } + isAnimating = false + for dot in dots { + dot.layer?.removeAnimation(forKey: "typingPulse") + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} From 40f8672142547d486a2c66282c235758c8f9fb1a Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 25 Sep 2026 08:58:09 +0200 Subject: [PATCH 04/23] Start porting message rows --- .../TimelineView/MessageBodyRowView.swift | 110 ++++++++++++++++++ .../TimelineView/MessageProfileRowView.swift | 4 +- .../TimelineView/TimelineTableView.swift | 32 +++-- 3 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift new file mode 100644 index 0000000..5ad3fcb --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift @@ -0,0 +1,110 @@ +import AppKit +import MatrixRustSDK +import MessageFormatting + +/// The AppKit row for a text message without replies or bottom content. +final class MessageBodyRowView: NSView { + private let timestamp = NSTextField(labelWithString: "") + private let bodyText = NSTextField(wrappingLabelWithString: "") + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + timestamp.font = .systemFont(ofSize: NSFont.labelFontSize) + timestamp.textColor = .secondaryLabelColor + timestamp.alignment = .right + timestamp.translatesAutoresizingMaskIntoConstraints = false + + bodyText.isSelectable = true + bodyText.isEditable = false + bodyText.allowsEditingTextAttributes = true + bodyText.cell?.wraps = true + bodyText.cell?.isScrollable = false + bodyText.cell?.lineBreakMode = .byWordWrapping + bodyText.translatesAutoresizingMaskIntoConstraints = false + + addSubview(timestamp) + addSubview(bodyText) + NSLayoutConstraint.activate([ + timestamp.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), + timestamp.topAnchor.constraint(equalTo: topAnchor, constant: 6), + timestamp.widthAnchor.constraint(equalToConstant: 32 + 16), + bodyText.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), + bodyText.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + bodyText.topAnchor.constraint(equalTo: topAnchor, constant: 4), + bodyText.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + ]) + } + + static func supports(event: EventTimelineItem, content: MsgLikeContent) -> Bool { + guard content.reactions.isEmpty, + content.inReplyTo == nil, + content.threadSummary == nil, + event.readReceipts.isEmpty, + case let .message(message) = content.kind else { return false } + switch message.msgType { + case .text, .notice: return true + default: return false + } + } + + func configure(event: EventTimelineItem, content: MsgLikeContent) { + let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) + timestamp.stringValue = Self.timeFormatter.string(from: date) + bodyText.attributedStringValue = Self.attributedBody(for: content) + } + + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + bodyText.attributedStringValue = Self.attributedBody(for: content) + let bodyWidth = max(width - 74, 1) + bodyText.preferredMaxLayoutWidth = bodyWidth + let size = bodyText.cell?.cellSize(forBounds: NSRect( + x: 0, y: 0, width: bodyWidth, height: CGFloat.greatestFiniteMagnitude + )) ?? .zero + return max(ceil(size.height) + 8, 28) + } + + private static func attributedBody(for content: MsgLikeContent) -> NSAttributedString { + let fontSize = UserDefaults.standard.object(forKey: "fontSize") as? Int ?? 13 + let font = NSFont.systemFont(ofSize: CGFloat(fontSize)) + guard case let .message(message) = content.kind else { return NSAttributedString() } + + switch message.msgType { + case let .text(text): + return attributedText(for: text, font: font, color: .labelColor) + case let .notice(notice): + return attributedText(for: notice, font: font, color: .secondaryLabelColor) + default: + return NSAttributedString() + } + } + + private static func attributedText( + for message: some MessageContent, font: NSFont, color: NSColor + ) -> NSAttributedString { + if let formatted = message.formatted, formatted.format == .html { + let result = NSMutableAttributedString(attributedString: parseFormattedBody( + formatted.body, baseFontSize: font.pointSize + )) + if color == .secondaryLabelColor { + result.addAttribute(.foregroundColor, value: color, range: NSRange(location: 0, length: result.length)) + } + return result + } + return NSAttributedString(string: message.body, attributes: [ + .font: font, + .foregroundColor: color, + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift index 8277389..b25b2ac 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift @@ -4,7 +4,7 @@ import UI class MessageProfileRowView: NSView { let profilePicture = NSButton() - let name = NSTextField() + let name = NSTextField(labelWithString: "") private var sender: String? private var focusUserAction: (_ sender: String) -> Void = { _ in } @@ -31,8 +31,6 @@ class MessageProfileRowView: NSView { name.translatesAutoresizingMaskIntoConstraints = false name.isSelectable = true - name.isEditable = false - name.isBordered = false name.drawsBackground = false name.font = .boldSystemFont(ofSize: NSFont.systemFontSize) name.lineBreakMode = .byTruncatingTail diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 4b6a7c8..34f07d4 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -139,26 +139,28 @@ class TimelineViewController: NSViewController { return view } case .typingIndicator: - if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) as? TypingIndicatorRowView { - recycledView.configure(names: self.typingNames) - return recycledView - } else { - let view = TypingIndicatorRowView() - view.configure(names: self.typingNames) - view.identifier = item.reuseIdentifier - return view - } + let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + as? TypingIndicatorRowView ?? TypingIndicatorRowView() + view.configure(names: self.typingNames) + view.identifier = item.reuseIdentifier + return view + case .message(_, let event, let content) where MessageBodyRowView.supports(event: event, content: content): + let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + as? MessageBodyRowView ?? MessageBodyRowView() + view.configure(event: event, content: content) + view.identifier = item.reuseIdentifier + return view default: let view = TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator) let hostView: NSHostingView - if let recycledView = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + if let recycledView = tableView.makeView(withIdentifier: .init("swiftui-view"), owner: self) as? NSHostingView { recycledView.rootView = view hostView = recycledView } else { hostView = NSHostingView(rootView: view) - hostView.identifier = item.reuseIdentifier + hostView.identifier = .init("swiftui-view") hostView.autoresizingMask = [.width, .height] hostView.sizingOptions = [.preferredContentSize] hostView.setContentHuggingPriority(.required, for: .vertical) @@ -359,6 +361,8 @@ class TimelineViewController: NSViewController { hostView.sizingOptions = [.preferredContentSize] return hostView }() + + let measurementMessageView = MessageBodyRowView() } extension TimelineViewController: NSTableViewDelegate { @@ -381,6 +385,12 @@ extension TimelineViewController: NSTableViewDelegate { return TypingIndicatorRowView.rowHeight } + if case .message(_, let event, let content) = item, + MessageBodyRowView.supports(event: event, content: content) + { + return measurementMessageView.height(for: content, width: tableView.tableColumns[0].width) + } + measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator)) let targetWidth = tableView.tableColumns[0].width From 01f755a71eb5a04601303b454dde7fa45ea10eb0 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 25 Sep 2026 09:42:38 +0200 Subject: [PATCH 05/23] Replace text body with NSTextView and disable automaticHeights --- Mactrix.xcodeproj/project.pbxproj | 2 - .../TimelineView/MessageBodyRowView.swift | 51 ++++++++++++++----- .../TimelineView/TimelineTableView.swift | 7 ++- 3 files changed, 43 insertions(+), 17 deletions(-) diff --git a/Mactrix.xcodeproj/project.pbxproj b/Mactrix.xcodeproj/project.pbxproj index 86756a8..c9935b6 100644 --- a/Mactrix.xcodeproj/project.pbxproj +++ b/Mactrix.xcodeproj/project.pbxproj @@ -343,7 +343,6 @@ STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_STRICT_MEMORY_SAFETY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; }; @@ -396,7 +395,6 @@ STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_STRICT_MEMORY_SAFETY = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 6.0; }; diff --git a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift index 5ad3fcb..99e9e69 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift @@ -5,7 +5,7 @@ import MessageFormatting /// The AppKit row for a text message without replies or bottom content. final class MessageBodyRowView: NSView { private let timestamp = NSTextField(labelWithString: "") - private let bodyText = NSTextField(wrappingLabelWithString: "") + private let bodyText = NSTextView(frame: .zero) private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() @@ -23,12 +23,24 @@ final class MessageBodyRowView: NSView { bodyText.isSelectable = true bodyText.isEditable = false - bodyText.allowsEditingTextAttributes = true - bodyText.cell?.wraps = true - bodyText.cell?.isScrollable = false - bodyText.cell?.lineBreakMode = .byWordWrapping + bodyText.isRichText = true + bodyText.drawsBackground = false + bodyText.textContainerInset = .zero + bodyText.textContainer?.lineFragmentPadding = 0 + bodyText.textContainer?.widthTracksTextView = true + bodyText.isHorizontallyResizable = false + bodyText.isVerticallyResizable = true bodyText.translatesAutoresizingMaskIntoConstraints = false + wantsLayer = true + layer?.cornerRadius = 4 + addTrackingArea(NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + )) + addSubview(timestamp) addSubview(bodyText) NSLayoutConstraint.activate([ @@ -57,17 +69,30 @@ final class MessageBodyRowView: NSView { func configure(event: EventTimelineItem, content: MsgLikeContent) { let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) timestamp.stringValue = Self.timeFormatter.string(from: date) - bodyText.attributedStringValue = Self.attributedBody(for: content) + bodyText.textStorage?.setAttributedString(Self.attributedBody(for: content)) + layer?.backgroundColor = nil + } + + override func mouseEntered(with event: NSEvent) { + layer?.backgroundColor = .init(gray: 0.5, alpha: 0.1) + } + + override func mouseExited(with event: NSEvent) { + layer?.backgroundColor = nil } func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { - bodyText.attributedStringValue = Self.attributedBody(for: content) let bodyWidth = max(width - 74, 1) - bodyText.preferredMaxLayoutWidth = bodyWidth - let size = bodyText.cell?.cellSize(forBounds: NSRect( - x: 0, y: 0, width: bodyWidth, height: CGFloat.greatestFiniteMagnitude - )) ?? .zero - return max(ceil(size.height) + 8, 28) + guard let textStorage = bodyText.textStorage, + let layoutManager = bodyText.layoutManager, + let textContainer = bodyText.textContainer else { return 28 } + + textContainer.widthTracksTextView = false + textContainer.containerSize = NSSize(width: bodyWidth, height: .greatestFiniteMagnitude) + textStorage.setAttributedString(Self.attributedBody(for: content)) + layoutManager.ensureLayout(for: textContainer) + let textHeight = layoutManager.usedRect(for: textContainer).height + return max(ceil(textHeight) + 8, 28) } private static func attributedBody(for content: MsgLikeContent) -> NSAttributedString { @@ -97,7 +122,7 @@ final class MessageBodyRowView: NSView { } return result } - return NSAttributedString(string: message.body, attributes: [ + return NSAttributedString(string: message.body.trimmingCharacters(in: .whitespacesAndNewlines), attributes: [ .font: font, .foregroundColor: color, ]) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 34f07d4..6ecf6b7 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -112,8 +112,11 @@ class TimelineViewController: NSViewController { tableView.allowsColumnSelection = false tableView.selectionHighlightStyle = .none - tableView.rowHeight = -1 - tableView.usesAutomaticRowHeights = true + // Every row is sized by tableView(_:heightOfRow:). Automatic heights can + // collapse NSTextView rows because the view has no intrinsic height. + tableView.rowSizeStyle = .custom + tableView.rowHeight = 28 + tableView.usesAutomaticRowHeights = false oldWidth = tableView.frame.width From af7a138eda1c797feff587c9897cc2eff3bd999b Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 25 Sep 2026 10:21:57 +0200 Subject: [PATCH 06/23] First draft of the new hover actions --- .../TimelineView/MessageBodyRowView.swift | 12 +++- .../MessageHoverOverlayView.swift | 28 ++++++++ .../TimelineView/TimelineTableView.swift | 64 +++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift index 99e9e69..8203d05 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift @@ -4,6 +4,8 @@ import MessageFormatting /// The AppKit row for a text message without replies or bottom content. final class MessageBodyRowView: NSView { + var onHoverChange: ((MessageBodyRowView, Bool, NSEvent) -> Bool)? + private let timestamp = NSTextField(labelWithString: "") private let bodyText = NSTextView(frame: .zero) @@ -74,11 +76,17 @@ final class MessageBodyRowView: NSView { } override func mouseEntered(with event: NSEvent) { - layer?.backgroundColor = .init(gray: 0.5, alpha: 0.1) + if onHoverChange?(self, true, event) ?? true { + setHoverHighlight(true) + } } override func mouseExited(with event: NSEvent) { - layer?.backgroundColor = nil + setHoverHighlight(onHoverChange?(self, false, event) ?? false) + } + + func setHoverHighlight(_ highlighted: Bool) { + layer?.backgroundColor = highlighted ? .init(gray: 0.5, alpha: 0.1) : nil } func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { diff --git a/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift b/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift new file mode 100644 index 0000000..536db4c --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift @@ -0,0 +1,28 @@ +import AppKit + +/// Prototype toolbar surface that can extend across timeline rows. +final class MessageHoverOverlayView: NSView { + var onMouseExited: ((NSEvent) -> Void)? + + init() { + super.init(frame: NSRect(x: 0, y: 0, width: 120, height: 28)) + + wantsLayer = true + layer?.backgroundColor = NSColor.systemPink.cgColor + addTrackingArea(NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + )) + } + + override func mouseExited(with event: NSEvent) { + onMouseExited?(event) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 6ecf6b7..c3a082d 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -92,6 +92,8 @@ class TimelineViewController: NSViewController { let scrollView = NSScrollView() let tableView = BottomStickyTableView() + private let hoverOverlay = MessageHoverOverlayView() + private weak var hoveredMessageView: MessageBodyRowView? let timeline: LiveTimeline var timelineItems: [TimelineItemRowInfo] = [] @@ -150,6 +152,9 @@ class TimelineViewController: NSViewController { case .message(_, let event, let content) where MessageBodyRowView.supports(event: event, content: content): let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) as? MessageBodyRowView ?? MessageBodyRowView() + view.onHoverChange = { [weak self] rowView, hovering, event in + self?.updateHoverOverlay(for: rowView, hovering: hovering, event: event) ?? false + } view.configure(event: event, content: content) view.identifier = item.reuseIdentifier return view @@ -176,6 +181,11 @@ class TimelineViewController: NSViewController { tableView.delegate = self scrollView.documentView = tableView + hoverOverlay.isHidden = true + hoverOverlay.onMouseExited = { [weak self] event in + self?.hoverOverlayDidExit(with: event) + } + scrollView.contentView.addSubview(hoverOverlay, positioned: .above, relativeTo: tableView) scrollView.hasVerticalScroller = true scrollView.automaticallyAdjustsContentInsets = false @@ -204,6 +214,60 @@ class TimelineViewController: NSViewController { listenForTypingUsers() } + @discardableResult + private func updateHoverOverlay(for rowView: MessageBodyRowView, hovering: Bool, event: NSEvent) -> Bool { + if !hovering { + if hoveredMessageView === rowView { + let mousePoint = hoverOverlay.convert(event.locationInWindow, from: nil) + if hoverOverlay.bounds.contains(mousePoint) { return true } + hideHoverOverlay() + } + return false + } + + // Tracking areas can enter a row even while the overlay covers it. + let overlayPoint = hoverOverlay.convert(event.locationInWindow, from: nil) + if !hoverOverlay.isHidden && hoverOverlay.bounds.contains(overlayPoint) { + return false + } + + let rowPoint = tableView.convert(NSPoint(x: rowView.bounds.midX, y: rowView.bounds.midY), from: rowView) + let row = tableView.row(at: rowPoint) + guard row >= 0 else { return false } + + if hoveredMessageView !== rowView { + hoveredMessageView?.setHoverHighlight(false) + } + let rowRect = tableView.convert(tableView.rect(ofRow: row), to: scrollView.contentView) + hoverOverlay.setFrameOrigin(NSPoint(x: rowRect.maxX - hoverOverlay.frame.width - 20, + y: rowRect.maxY)) + hoveredMessageView = rowView + hoverOverlay.isHidden = false + return true + } + + private func hoverOverlayDidExit(with event: NSEvent) { + let tablePoint = tableView.convert(event.locationInWindow, from: nil) + let row = tableView.row(at: tablePoint) + if row >= 0, + let rowView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageBodyRowView + { + if rowView !== hoveredMessageView { + rowView.setHoverHighlight(updateHoverOverlay(for: rowView, hovering: true, event: event)) + } else { + rowView.setHoverHighlight(true) + } + return + } + hideHoverOverlay() + } + + private func hideHoverOverlay() { + hoveredMessageView?.setHoverHighlight(false) + hoveredMessageView = nil + hoverOverlay.isHidden = true + } + private var typingNames: [String] { let members = timeline.room.members return timeline.room.typingUserIds.map { userId in From 339ebb3549e76fd0590a703a3e52a8a7c1bfc11e Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 25 Sep 2026 13:44:00 +0200 Subject: [PATCH 07/23] Implement hover buttons and fix hover cursor --- .../TimelineView/MessageBodyRowView.swift | 29 ++- .../MessageHoverOverlayView.swift | 205 +++++++++++++++++- .../TimelineView/TimelineTableView.swift | 52 ++++- 3 files changed, 277 insertions(+), 9 deletions(-) diff --git a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift index 8203d05..2cd727c 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift @@ -7,7 +7,7 @@ final class MessageBodyRowView: NSView { var onHoverChange: ((MessageBodyRowView, Bool, NSEvent) -> Bool)? private let timestamp = NSTextField(labelWithString: "") - private let bodyText = NSTextView(frame: .zero) + private let bodyText = OcclusionAwareTextView(frame: .zero) private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() @@ -141,3 +141,30 @@ final class MessageBodyRowView: NSView { fatalError("init(coder:) has not been implemented") } } + +/// Lets a view drawn above the text own the cursor without knowing what that view is. +private final class OcclusionAwareTextView: NSTextView { + override func cursorUpdate(with event: NSEvent) { + guard shouldHandlePointerEvent(event) else { return } + super.cursorUpdate(with: event) + } + + override func mouseMoved(with event: NSEvent) { + // NSTextView also sets its cursor during mouse movement, independently + // of cursorUpdate. Covered text must yield to the view above it here too. + guard shouldHandlePointerEvent(event) else { return } + super.mouseMoved(with: event) + } + + private func shouldHandlePointerEvent(_ event: NSEvent) -> Bool { + guard let window, + let contentView = window.contentView, + let contentSuperview = contentView.superview, + let hitView = contentView.hitTest(contentSuperview.convert(event.locationInWindow, from: nil)) + else { + return true + } + + return hitView === self || hitView.isDescendant(of: self) + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift b/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift index 536db4c..e4e1b98 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageHoverOverlayView.swift @@ -1,20 +1,112 @@ import AppKit -/// Prototype toolbar surface that can extend across timeline rows. +/// The action bar shown above the hovered message row. final class MessageHoverOverlayView: NSView { + enum Action { + case reaction(String) + case reactionPicker + case reply + case replyInThread + case pin + } + + var onAction: ((Action) -> Void)? var onMouseExited: ((NSEvent) -> Void)? + private let stack = NSStackView() + private let replyButton = HoverActionButton(symbol: "arrowshape.turn.up.left", label: "Reply") + private let threadButton = HoverActionButton(symbol: "ellipsis.message", label: "Reply in thread") + init() { - super.init(frame: NSRect(x: 0, y: 0, width: 120, height: 28)) + super.init(frame: .zero) wantsLayer = true - layer?.backgroundColor = NSColor.systemPink.cgColor + layer?.cornerRadius = 4 + layer?.borderWidth = 1 + updateAppearance() + shadow = { + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.1) + shadow.shadowBlurRadius = 4 + shadow.shadowOffset = .zero + return shadow + }() + + stack.orientation = .horizontal + stack.alignment = .centerY + stack.spacing = 0 + stack.detachesHiddenViews = true + stack.translatesAutoresizingMaskIntoConstraints = false + addSubview(stack) + NSLayoutConstraint.activate([ + stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 2), + stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -2), + stack.topAnchor.constraint(equalTo: topAnchor), + stack.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + + for emoji in ["👍", "🎉", "❤️"] { + let button = HoverActionButton(emoji: emoji, label: "React with \(emoji)") + button.onClick = { [weak self] in self?.onAction?(.reaction(emoji)) } + stack.addArrangedSubview(button) + } + + let dividerContainer = NSView() + dividerContainer.translatesAutoresizingMaskIntoConstraints = false + dividerContainer.widthAnchor.constraint(equalToConstant: 6).isActive = true + dividerContainer.heightAnchor.constraint(equalToConstant: 28).isActive = true + let divider = NSBox() + divider.boxType = .separator + divider.translatesAutoresizingMaskIntoConstraints = false + dividerContainer.addSubview(divider) + NSLayoutConstraint.activate([ + divider.centerXAnchor.constraint(equalTo: dividerContainer.centerXAnchor), + divider.centerYAnchor.constraint(equalTo: dividerContainer.centerYAnchor), + divider.heightAnchor.constraint(equalToConstant: 18), + ]) + stack.addArrangedSubview(dividerContainer) + + let reactionPicker = HoverActionButton(symbol: "face.smiling", label: "React") + reactionPicker.onClick = { [weak self] in self?.onAction?(.reactionPicker) } + stack.addArrangedSubview(reactionPicker) + + replyButton.onClick = { [weak self] in self?.onAction?(.reply) } + threadButton.onClick = { [weak self] in self?.onAction?(.replyInThread) } + stack.addArrangedSubview(replyButton) + stack.addArrangedSubview(threadButton) + + let pinButton = HoverActionButton(symbol: "pin", label: "Pin") + pinButton.onClick = { [weak self] in self?.onAction?(.pin) } + stack.addArrangedSubview(pinButton) + addTrackingArea(NSTrackingArea( rect: .zero, - options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + options: [.mouseEnteredAndExited, .mouseMoved, .cursorUpdate, .activeInKeyWindow, .inVisibleRect], owner: self, userInfo: nil )) + + configure(canReply: true) + } + + func configure(canReply: Bool) { + replyButton.isHidden = !canReply + threadButton.isHidden = !canReply + setFrameSize(fittingSize) + } + + override func cursorUpdate(with event: NSEvent) { + NSCursor.arrow.set() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateAppearance() + } + + private func updateAppearance() { + layer?.backgroundColor = NSColor.controlBackgroundColor.cgColor + layer?.borderColor = NSColor.separatorColor.cgColor } override func mouseExited(with event: NSEvent) { @@ -26,3 +118,108 @@ final class MessageHoverOverlayView: NSView { fatalError("init(coder:) has not been implemented") } } + +private final class HoverActionButton: NSButton { + var onClick: (() -> Void)? + private var isHovered = false + private var symbolView: NSImageView? + private let hoverBackground = CALayer() + + init(emoji: String, label: String) { + super.init(frame: .zero) + title = emoji + font = .systemFont(ofSize: 13) + configure(label: label) + } + + init(symbol: String, label: String) { + super.init(frame: .zero) + configure(label: label) + + title = "" + + // NSButton.image makes AppKit grow the button to the symbol's cell height. + // Keep the symbol separate so AppKit does not change the button's hit area. + let symbolView = NSImageView() + symbolView.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) + symbolView.imageScaling = .scaleProportionallyDown + symbolView.contentTintColor = .labelColor + symbolView.translatesAutoresizingMaskIntoConstraints = false + addSubview(symbolView) + NSLayoutConstraint.activate([ + symbolView.centerXAnchor.constraint(equalTo: centerXAnchor), + symbolView.centerYAnchor.constraint(equalTo: centerYAnchor), + symbolView.widthAnchor.constraint(equalToConstant: 18), + symbolView.heightAnchor.constraint(equalToConstant: 18), + ]) + self.symbolView = symbolView + } + + private func configure(label: String) { + isBordered = false + toolTip = label + setAccessibilityLabel(label) + contentTintColor = .labelColor + wantsLayer = true + hoverBackground.cornerRadius = 4 + layer?.insertSublayer(hoverBackground, at: 0) + translatesAutoresizingMaskIntoConstraints = false + widthAnchor.constraint(equalToConstant: 24).isActive = true + heightAnchor.constraint(equalToConstant: 28).isActive = true + target = self + action = #selector(clicked) + + addTrackingArea(NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .cursorUpdate, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + )) + } + + override func cursorUpdate(with event: NSEvent) { + NSCursor.arrow.set() + } + + override func mouseEntered(with event: NSEvent) { + isHovered = true + updateAppearance() + } + + override func mouseExited(with event: NSEvent) { + isHovered = false + updateAppearance() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateAppearance() + } + + private func updateAppearance() { + contentTintColor = isHovered ? .controlAccentColor : .labelColor + symbolView?.contentTintColor = contentTintColor + hoverBackground.backgroundColor = isHovered + ? NSColor.controlAccentColor.withAlphaComponent(0.12).cgColor + : nil + } + + override func layout() { + super.layout() + hoverBackground.frame = bounds.insetBy(dx: 2, dy: 4) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + // Keep the image view from intercepting the button's target/action click. + super.hitTest(point) == nil ? nil : self + } + + @objc private func clicked() { + onClick?() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index c3a082d..ddb5d8d 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -1,14 +1,15 @@ import AppKit import MatrixRustSDK +import Models import OSLog import SwiftUI import UI enum TimelineItemRowInfo { - case profile(item: TimelineItem, event: EventTimelineItem) - case message(item: TimelineItem, event: EventTimelineItem, content: MsgLikeContent) - case state(item: TimelineItem, event: EventTimelineItem) - case virtual(item: TimelineItem, virtual: VirtualTimelineItem) + case profile(item: TimelineItem, event: MatrixRustSDK.EventTimelineItem) + case message(item: TimelineItem, event: MatrixRustSDK.EventTimelineItem, content: MatrixRustSDK.MsgLikeContent) + case state(item: TimelineItem, event: MatrixRustSDK.EventTimelineItem) + case virtual(item: TimelineItem, virtual: MatrixRustSDK.VirtualTimelineItem) case typingIndicator var reuseIdentifier: NSUserInterfaceItemIdentifier { @@ -185,6 +186,9 @@ class TimelineViewController: NSViewController { hoverOverlay.onMouseExited = { [weak self] event in self?.hoverOverlayDidExit(with: event) } + hoverOverlay.onAction = { [weak self] action in + self?.performHoverAction(action) + } scrollView.contentView.addSubview(hoverOverlay, positioned: .above, relativeTo: tableView) scrollView.hasVerticalScroller = true @@ -235,6 +239,9 @@ class TimelineViewController: NSViewController { let row = tableView.row(at: rowPoint) guard row >= 0 else { return false } + if case .message(_, let event, _) = timelineItems[row] { + hoverOverlay.configure(canReply: event.canBeRepliedTo) + } if hoveredMessageView !== rowView { hoveredMessageView?.setHoverHighlight(false) } @@ -268,6 +275,43 @@ class TimelineViewController: NSViewController { hoverOverlay.isHidden = true } + private func performHoverAction(_ action: MessageHoverOverlayView.Action) { + guard let hoveredMessageView else { return } + let rowPoint = tableView.convert( + NSPoint(x: hoveredMessageView.bounds.midX, y: hoveredMessageView.bounds.midY), + from: hoveredMessageView + ) + let row = tableView.row(at: rowPoint) + guard row >= 0, row < timelineItems.count, + case .message(_, let event, _) = timelineItems[row] else { return } + + switch action { + case .reaction(let key): + Task { + do { + _ = try await timeline.timeline?.toggleReaction(itemId: event.eventOrTransactionId, key: key) + } catch { + Logger.timelineTableView.error("Failed to toggle reaction: \(error)") + } + } + case .reactionPicker: + break // The old picker button does not have an action yet. + case .reply: + timeline.sendReplyTo = event + case .replyInThread: + coordinator.windowState.focusThread(rootEventId: event.eventOrTransactionId.id) + case .pin: + guard case let .eventId(eventId: eventId) = event.eventOrTransactionId else { return } + Task { + do { + _ = try await timeline.timeline?.pinEvent(eventId: eventId) + } catch { + Logger.timelineTableView.error("Failed to pin message: \(error)") + } + } + } + } + private var typingNames: [String] { let members = timeline.room.members return timeline.room.typingUserIds.map { userId in From c57d9bd5c21ab64173f7c670703ffeb76af2993d Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 25 Sep 2026 14:12:39 +0200 Subject: [PATCH 08/23] Implement virtual item row in AppKit --- .../TimelineView/TimelineTableView.swift | 10 ++ .../TimelineView/VirtualItemRowView.swift | 95 +++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 Mactrix/Views/ChatView/TimelineView/VirtualItemRowView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index ddb5d8d..92a99f8 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -150,6 +150,12 @@ class TimelineViewController: NSViewController { view.configure(names: self.typingNames) view.identifier = item.reuseIdentifier return view + case .virtual(_, let virtual): + let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + as? VirtualItemRowView ?? VirtualItemRowView() + view.configure(item: virtual) + view.identifier = item.reuseIdentifier + return view case .message(_, let event, let content) where MessageBodyRowView.supports(event: event, content: content): let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) as? MessageBodyRowView ?? MessageBodyRowView() @@ -496,6 +502,10 @@ extension TimelineViewController: NSTableViewDelegate { return TypingIndicatorRowView.rowHeight } + if case .virtual = item { + return VirtualItemRowView.rowHeight + } + if case .message(_, let event, let content) = item, MessageBodyRowView.supports(event: event, content: content) { diff --git a/Mactrix/Views/ChatView/TimelineView/VirtualItemRowView.swift b/Mactrix/Views/ChatView/TimelineView/VirtualItemRowView.swift new file mode 100644 index 0000000..b1942c1 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/VirtualItemRowView.swift @@ -0,0 +1,95 @@ +import AppKit +import MatrixRustSDK + +final class VirtualItemRowView: NSView { + static let rowHeight: CGFloat = 40 + + private let leadingLine = NSView() + private let trailingLine = NSView() + private let label = NSTextField(labelWithString: "") + private var isReadMarker = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + for line in [leadingLine, trailingLine] { + line.wantsLayer = true + line.translatesAutoresizingMaskIntoConstraints = false + addSubview(line) + } + + label.translatesAutoresizingMaskIntoConstraints = false + label.lineBreakMode = .byTruncatingTail + label.maximumNumberOfLines = 1 + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + addSubview(label) + + NSLayoutConstraint.activate([ + leadingLine.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + leadingLine.trailingAnchor.constraint(equalTo: label.leadingAnchor, constant: -10), + leadingLine.centerYAnchor.constraint(equalTo: centerYAnchor), + leadingLine.heightAnchor.constraint(equalToConstant: 1), + + label.centerYAnchor.constraint(equalTo: centerYAnchor), + + trailingLine.leadingAnchor.constraint(equalTo: label.trailingAnchor, constant: 10), + trailingLine.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + trailingLine.centerYAnchor.constraint(equalTo: centerYAnchor), + trailingLine.heightAnchor.constraint(equalToConstant: 1), + trailingLine.widthAnchor.constraint(equalTo: leadingLine.widthAnchor), + ]) + + updateLineColor() + } + + func configure(item: VirtualTimelineItem) { + switch item { + case let .dateDivider(ts): + let date = Date(timeIntervalSince1970: Double(ts) / 1000) + let dayInSeconds: TimeInterval = 24 * 60 * 60 + let elapsed = Date.now.timeIntervalSince(date) + if elapsed < dayInSeconds { + label.stringValue = String(localized: "Today") + } else if elapsed < dayInSeconds * 2 { + label.stringValue = String(localized: "Yesterday") + } else { + label.stringValue = date.formatted(date: .long, time: .omitted) + } + label.font = .systemFont(ofSize: NSFont.systemFontSize) + isReadMarker = false + case .readMarker: + label.stringValue = String(localized: "Read Marker") + label.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .medium) + isReadMarker = true + case .timelineStart: + label.stringValue = String(localized: "Start of conversation") + label.font = .systemFont(ofSize: NSFont.systemFontSize, weight: .medium) + isReadMarker = false + } + + updateLineColor() + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + updateLineColor() + } + + private func updateLineColor() { + let color: NSColor + if isReadMarker { + let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + color = NSColor.red.blended(withFraction: 0.1, of: isDark ? .white : .black) ?? .red + } else { + color = .separatorColor + } + label.textColor = isReadMarker ? color : .labelColor + leadingLine.layer?.backgroundColor = color.cgColor + trailingLine.layer?.backgroundColor = color.cgColor + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} From 3e8fa80077f8ff9960a9b05e568d78e047b0caed Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 25 Sep 2026 14:23:34 +0200 Subject: [PATCH 09/23] Implement state AppKit row --- .../TimelineView/MessageBodyRowView.swift | 1 + .../TimelineView/MessageProfileRowView.swift | 2 +- .../TimelineView/StateEventRowView.swift | 80 +++++++++++++++++++ .../TimelineView/TimelineTableView.swift | 12 ++- .../TimelineView/TypingIndicatorRowView.swift | 2 +- 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/StateEventRowView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift index 2cd727c..7388921 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift @@ -49,6 +49,7 @@ final class MessageBodyRowView: NSView { timestamp.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), timestamp.topAnchor.constraint(equalTo: topAnchor, constant: 6), timestamp.widthAnchor.constraint(equalToConstant: 32 + 16), + bodyText.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), bodyText.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), bodyText.topAnchor.constraint(equalTo: topAnchor, constant: 4), diff --git a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift index b25b2ac..3ffb2cb 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageProfileRowView.swift @@ -13,7 +13,7 @@ class MessageProfileRowView: NSView { private var avatarTask: Task? private var avatarUrl: String? - static let ROW_HEIGHT: Double = 32 + static let rowHeight: Double = 32 override init(frame frameRect: NSRect) { super.init(frame: frameRect) diff --git a/Mactrix/Views/ChatView/TimelineView/StateEventRowView.swift b/Mactrix/Views/ChatView/TimelineView/StateEventRowView.swift new file mode 100644 index 0000000..fd497e6 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/StateEventRowView.swift @@ -0,0 +1,80 @@ +import AppKit +import MatrixRustSDK + +final class StateEventRowView: NSView { + private let timestamp = NSTextField(labelWithString: "") + private let descriptionField = NSTextField(labelWithString: "") + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + timestamp.font = .systemFont(ofSize: NSFont.labelFontSize) + timestamp.textColor = .secondaryLabelColor + timestamp.alignment = .right + timestamp.translatesAutoresizingMaskIntoConstraints = false + + descriptionField.isSelectable = true + descriptionField.usesSingleLineMode = false + descriptionField.lineBreakMode = .byWordWrapping + descriptionField.maximumNumberOfLines = 0 + descriptionField.translatesAutoresizingMaskIntoConstraints = false + descriptionField.allowsEditingTextAttributes = true + + addSubview(timestamp) + addSubview(descriptionField) + NSLayoutConstraint.activate([ + timestamp.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), + timestamp.topAnchor.constraint(equalTo: topAnchor, constant: 2), + timestamp.widthAnchor.constraint(equalToConstant: 32 + 16), + + descriptionField.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), + descriptionField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + descriptionField.topAnchor.constraint(equalTo: topAnchor, constant: 0), + descriptionField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: 0), + ]) + } + + func configure(event: EventTimelineItem) { + let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) + timestamp.stringValue = Self.timeFormatter.string(from: date) + descriptionField.attributedStringValue = Self.attributedDescription(for: event) + } + + static func height(for event: EventTimelineItem, width: CGFloat) -> CGFloat { + let textWidth = max(width - 74, 1) + let textHeight = attributedDescription(for: event).boundingRect( + with: NSSize(width: textWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading] + ).height + return max(ceil(textHeight), 28) + } + + private static func attributedDescription(for event: EventTimelineItem) -> NSAttributedString { + let color = NSColor.secondaryLabelColor + let size = NSFont.systemFontSize + let result = NSMutableAttributedString(string: event.sender, attributes: [ + .font: NSFont.boldSystemFont(ofSize: size), + .foregroundColor: color, + ]) + result.append(NSAttributedString(string: ": ", attributes: [ + .font: NSFont.systemFont(ofSize: size), + .foregroundColor: color, + ])) + result.append(NSAttributedString(string: event.content.description, attributes: [ + .font: NSFontManager.shared.convert(NSFont.systemFont(ofSize: size), toHaveTrait: .italicFontMask), + .foregroundColor: color, + ])) + return result + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 92a99f8..4ed455d 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -156,6 +156,12 @@ class TimelineViewController: NSViewController { view.configure(item: virtual) view.identifier = item.reuseIdentifier return view + case .state(_, let event): + let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) + as? StateEventRowView ?? StateEventRowView() + view.configure(event: event) + view.identifier = item.reuseIdentifier + return view case .message(_, let event, let content) where MessageBodyRowView.supports(event: event, content: content): let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) as? MessageBodyRowView ?? MessageBodyRowView() @@ -495,7 +501,7 @@ extension TimelineViewController: NSTableViewDelegate { let item = timelineItems[row] if case .profile = item { - return MessageProfileRowView.ROW_HEIGHT + return MessageProfileRowView.rowHeight } if case .typingIndicator = item { @@ -506,6 +512,10 @@ extension TimelineViewController: NSTableViewDelegate { return VirtualItemRowView.rowHeight } + if case .state(_, let event) = item { + return StateEventRowView.height(for: event, width: tableView.tableColumns[0].width) + } + if case .message(_, let event, let content) = item, MessageBodyRowView.supports(event: event, content: content) { diff --git a/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift b/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift index d9aa344..7e9f7b3 100644 --- a/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TypingIndicatorRowView.swift @@ -2,7 +2,7 @@ import AppKit import QuartzCore final class TypingIndicatorRowView: NSView { - static let rowHeight: CGFloat = 32 + static let rowHeight: Double = 32 private let label = NSTextField(labelWithString: "") private let dots = (0 ..< 3).map { _ in NSView() } From 8d44ae3bde810db7d9869759b30a1135047b04d6 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 25 Sep 2026 14:49:18 +0200 Subject: [PATCH 10/23] Split message rows into content and outer --- .../TimelineView/MessageRowView.swift | 87 +++++++++++++++++++ ...iew.swift => MessageTextContentView.swift} | 75 +++------------- .../TimelineView/TimelineTableView.swift | 22 ++--- 3 files changed, 111 insertions(+), 73 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageRowView.swift rename Mactrix/Views/ChatView/TimelineView/{MessageBodyRowView.swift => MessageTextContentView.swift} (61%) diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift new file mode 100644 index 0000000..da7a844 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift @@ -0,0 +1,87 @@ +import AppKit +import MatrixRustSDK + +/// Shared row chrome. The content view is created once and retained across table reuse. +final class MessageRowView: NSView { + var onHoverChange: ((MessageRowView, Bool, NSEvent) -> Bool)? + + private let timestamp = NSTextField(labelWithString: "") + private let contentView = MessageTextContentView() + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter + }() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + timestamp.font = .systemFont(ofSize: NSFont.labelFontSize) + timestamp.textColor = .secondaryLabelColor + timestamp.alignment = .right + timestamp.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + + wantsLayer = true + layer?.cornerRadius = 4 + addTrackingArea(NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + )) + + addSubview(timestamp) + addSubview(contentView) + NSLayoutConstraint.activate([ + timestamp.leadingAnchor.constraint(equalTo: leadingAnchor), + timestamp.topAnchor.constraint(equalTo: topAnchor, constant: 6), + timestamp.widthAnchor.constraint(equalToConstant: 48), + + contentView.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + contentView.topAnchor.constraint(equalTo: topAnchor, constant: 4), + contentView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + ]) + } + + static func supports(event: EventTimelineItem, content: MsgLikeContent) -> Bool { + content.reactions.isEmpty && + content.inReplyTo == nil && + content.threadSummary == nil && + event.readReceipts.isEmpty && + MessageTextContentView.supports(content: content) + } + + func configure(event: EventTimelineItem, content: MsgLikeContent) { + let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) + timestamp.stringValue = Self.timeFormatter.string(from: date) + contentView.configure(content: content) + layer?.backgroundColor = nil + } + + override func mouseEntered(with event: NSEvent) { + if onHoverChange?(self, true, event) ?? true { + setHoverHighlight(true) + } + } + + override func mouseExited(with event: NSEvent) { + setHoverHighlight(onHoverChange?(self, false, event) ?? false) + } + + func setHoverHighlight(_ highlighted: Bool) { + layer?.backgroundColor = highlighted ? .init(gray: 0.5, alpha: 0.1) : nil + } + + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + let contentWidth = max(width - 74, 1) + return max(ceil(contentView.height(for: content, width: contentWidth)) + 8, 28) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift similarity index 61% rename from Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift rename to Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift index 7388921..37be057 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageBodyRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift @@ -2,27 +2,13 @@ import AppKit import MatrixRustSDK import MessageFormatting -/// The AppKit row for a text message without replies or bottom content. -final class MessageBodyRowView: NSView { - var onHoverChange: ((MessageBodyRowView, Bool, NSEvent) -> Bool)? - - private let timestamp = NSTextField(labelWithString: "") +/// Retains the NSTextView and its layout machinery when its table row is reused. +final class MessageTextContentView: NSView { private let bodyText = OcclusionAwareTextView(frame: .zero) - private static let timeFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm" - return formatter - }() - override init(frame frameRect: NSRect) { super.init(frame: frameRect) - timestamp.font = .systemFont(ofSize: NSFont.labelFontSize) - timestamp.textColor = .secondaryLabelColor - timestamp.alignment = .right - timestamp.translatesAutoresizingMaskIntoConstraints = false - bodyText.isSelectable = true bodyText.isEditable = false bodyText.isRichText = true @@ -34,74 +20,37 @@ final class MessageBodyRowView: NSView { bodyText.isVerticallyResizable = true bodyText.translatesAutoresizingMaskIntoConstraints = false - wantsLayer = true - layer?.cornerRadius = 4 - addTrackingArea(NSTrackingArea( - rect: .zero, - options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], - owner: self, - userInfo: nil - )) - - addSubview(timestamp) addSubview(bodyText) NSLayoutConstraint.activate([ - timestamp.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 0), - timestamp.topAnchor.constraint(equalTo: topAnchor, constant: 6), - timestamp.widthAnchor.constraint(equalToConstant: 32 + 16), - - bodyText.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), - bodyText.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), - bodyText.topAnchor.constraint(equalTo: topAnchor, constant: 4), - bodyText.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + bodyText.leadingAnchor.constraint(equalTo: leadingAnchor), + bodyText.trailingAnchor.constraint(equalTo: trailingAnchor), + bodyText.topAnchor.constraint(equalTo: topAnchor), + bodyText.bottomAnchor.constraint(equalTo: bottomAnchor), ]) } - static func supports(event: EventTimelineItem, content: MsgLikeContent) -> Bool { - guard content.reactions.isEmpty, - content.inReplyTo == nil, - content.threadSummary == nil, - event.readReceipts.isEmpty, - case let .message(message) = content.kind else { return false } + static func supports(content: MsgLikeContent) -> Bool { + guard case let .message(message) = content.kind else { return false } switch message.msgType { case .text, .notice: return true default: return false } } - func configure(event: EventTimelineItem, content: MsgLikeContent) { - let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) - timestamp.stringValue = Self.timeFormatter.string(from: date) + func configure(content: MsgLikeContent) { bodyText.textStorage?.setAttributedString(Self.attributedBody(for: content)) - layer?.backgroundColor = nil - } - - override func mouseEntered(with event: NSEvent) { - if onHoverChange?(self, true, event) ?? true { - setHoverHighlight(true) - } - } - - override func mouseExited(with event: NSEvent) { - setHoverHighlight(onHoverChange?(self, false, event) ?? false) - } - - func setHoverHighlight(_ highlighted: Bool) { - layer?.backgroundColor = highlighted ? .init(gray: 0.5, alpha: 0.1) : nil } func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { - let bodyWidth = max(width - 74, 1) guard let textStorage = bodyText.textStorage, let layoutManager = bodyText.layoutManager, - let textContainer = bodyText.textContainer else { return 28 } + let textContainer = bodyText.textContainer else { return 20 } textContainer.widthTracksTextView = false - textContainer.containerSize = NSSize(width: bodyWidth, height: .greatestFiniteMagnitude) + textContainer.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) textStorage.setAttributedString(Self.attributedBody(for: content)) layoutManager.ensureLayout(for: textContainer) - let textHeight = layoutManager.usedRect(for: textContainer).height - return max(ceil(textHeight) + 8, 28) + return layoutManager.usedRect(for: textContainer).height } private static func attributedBody(for content: MsgLikeContent) -> NSAttributedString { diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 4ed455d..4447f3b 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -16,8 +16,10 @@ enum TimelineItemRowInfo { switch self { case .profile(profile: _): return NSUserInterfaceItemIdentifier("profile") - case .message: - return NSUserInterfaceItemIdentifier("message") + case .message(_, let event, let content): + return NSUserInterfaceItemIdentifier( + MessageRowView.supports(event: event, content: content) ? "message.text" : "swiftui-view" + ) case .state: return NSUserInterfaceItemIdentifier("state") case .virtual: @@ -94,7 +96,7 @@ class TimelineViewController: NSViewController { let scrollView = NSScrollView() let tableView = BottomStickyTableView() private let hoverOverlay = MessageHoverOverlayView() - private weak var hoveredMessageView: MessageBodyRowView? + private weak var hoveredMessageView: MessageRowView? let timeline: LiveTimeline var timelineItems: [TimelineItemRowInfo] = [] @@ -126,7 +128,7 @@ class TimelineViewController: NSViewController { dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, _ in guard let self else { return NSView() } - let item = timelineItems[row] + let item = self.timelineItems[row] switch item { case .profile(item: _, event: let event): @@ -162,9 +164,9 @@ class TimelineViewController: NSViewController { view.configure(event: event) view.identifier = item.reuseIdentifier return view - case .message(_, let event, let content) where MessageBodyRowView.supports(event: event, content: content): + case .message(_, let event, let content) where MessageRowView.supports(event: event, content: content): let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) - as? MessageBodyRowView ?? MessageBodyRowView() + as? MessageRowView ?? MessageRowView() view.onHoverChange = { [weak self] rowView, hovering, event in self?.updateHoverOverlay(for: rowView, hovering: hovering, event: event) ?? false } @@ -231,7 +233,7 @@ class TimelineViewController: NSViewController { } @discardableResult - private func updateHoverOverlay(for rowView: MessageBodyRowView, hovering: Bool, event: NSEvent) -> Bool { + private func updateHoverOverlay(for rowView: MessageRowView, hovering: Bool, event: NSEvent) -> Bool { if !hovering { if hoveredMessageView === rowView { let mousePoint = hoverOverlay.convert(event.locationInWindow, from: nil) @@ -269,7 +271,7 @@ class TimelineViewController: NSViewController { let tablePoint = tableView.convert(event.locationInWindow, from: nil) let row = tableView.row(at: tablePoint) if row >= 0, - let rowView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageBodyRowView + let rowView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView { if rowView !== hoveredMessageView { rowView.setHoverHighlight(updateHoverOverlay(for: rowView, hovering: true, event: event)) @@ -485,7 +487,7 @@ class TimelineViewController: NSViewController { return hostView }() - let measurementMessageView = MessageBodyRowView() + let measurementMessageView = MessageRowView() } extension TimelineViewController: NSTableViewDelegate { @@ -517,7 +519,7 @@ extension TimelineViewController: NSTableViewDelegate { } if case .message(_, let event, let content) = item, - MessageBodyRowView.supports(event: event, content: content) + MessageRowView.supports(event: event, content: content) { return measurementMessageView.height(for: content, width: tableView.tableColumns[0].width) } From 00433375106c51b7d72b38606109ca376358ce25 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 27 Sep 2026 17:50:25 +0200 Subject: [PATCH 11/23] Port message replies to AppKit --- .../MessageReplyPreviewView.swift | 133 ++++++++++++++++++ .../TimelineView/MessageRowView.swift | 74 +++++++++- .../TimelineView/TimelineTableView.swift | 56 +++++++- 3 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageReplyPreviewView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReplyPreviewView.swift b/Mactrix/Views/ChatView/TimelineView/MessageReplyPreviewView.swift new file mode 100644 index 0000000..1149fbc --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageReplyPreviewView.swift @@ -0,0 +1,133 @@ +import AppKit +import MatrixRustSDK + +/// A reusable, clickable summary of the message being replied to. +final class MessageReplyPreviewView: NSButton { + var onClick: (() -> Void)? + private var isPending = false + + private let accent = NSView() + private let name = NSTextField(labelWithString: "") + private let message = NSTextField(labelWithString: "") + + private static let fontSize: CGFloat = 13 + private static let nameFont = NSFontManager.shared.convert( + NSFont.boldSystemFont(ofSize: fontSize), toHaveTrait: .italicFontMask + ) + private static let messageFont = NSFontManager.shared.convert( + NSFont.systemFont(ofSize: fontSize), toHaveTrait: .italicFontMask + ) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + isBordered = false + title = "" + target = self + action = #selector(activate) + + wantsLayer = true + layer?.cornerRadius = 4 + layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.05).cgColor + + accent.wantsLayer = true + accent.layer?.cornerRadius = 1.5 + accent.layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.5).cgColor + accent.translatesAutoresizingMaskIntoConstraints = false + + for field in [name, message] { + field.textColor = .labelColor + field.usesSingleLineMode = false + field.lineBreakMode = .byWordWrapping + field.maximumNumberOfLines = 0 + field.translatesAutoresizingMaskIntoConstraints = false + } + name.font = Self.nameFont + message.font = Self.messageFont + + addSubview(accent) + addSubview(name) + addSubview(message) + NSLayoutConstraint.activate([ + accent.leadingAnchor.constraint(equalTo: leadingAnchor), + accent.topAnchor.constraint(equalTo: topAnchor), + accent.bottomAnchor.constraint(equalTo: bottomAnchor), + accent.widthAnchor.constraint(equalToConstant: 3), + + name.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), + name.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), + name.topAnchor.constraint(equalTo: topAnchor, constant: 4), + + message.leadingAnchor.constraint(equalTo: name.leadingAnchor), + message.trailingAnchor.constraint(equalTo: name.trailingAnchor), + message.topAnchor.constraint(equalTo: name.bottomAnchor, constant: 8), + message.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + ]) + } + + func configure(details: EmbeddedEventDetails) { + let text = Self.text(for: details) + name.stringValue = text.name + message.stringValue = text.message + isPending = text.isPending + updateOpacity() + } + + static func height(for details: EmbeddedEventDetails, width: CGFloat) -> CGFloat { + let text = text(for: details) + let textWidth = max(width - 20, 1) + func height(_ string: String, font: NSFont) -> CGFloat { + ceil((string as NSString).boundingRect( + with: NSSize(width: textWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: [.font: font] + ).height) + } + return 16 + height(text.name, font: nameFont) + height(text.message, font: messageFont) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + updateOpacity(pressed: true) + window?.displayIfNeeded() + + // AppKit tracks the press and sends the action only for a completed click. + // Its cell highlighting does not affect this button's custom subviews. + super.mouseDown(with: event) + + updateOpacity() + } + + private func updateOpacity(pressed: Bool = false) { + alphaValue = (isPending ? 0.5 : 1) * (pressed ? 0.6 : 1) + } + + @objc private func activate(_ sender: NSButton) { + onClick?() + } + + private static func text(for details: EmbeddedEventDetails) -> (name: String, message: String, isPending: Bool) { + switch details { + case .unavailable, .pending: + return ("Loading reply…", "", true) + case let .ready(content, sender, senderProfile, _, _): + let displayName: String + if case let .ready(name, _, _, _, _) = senderProfile, let name { + displayName = name + } else { + displayName = sender + } + return (displayName, content.description, false) + case let .error(message): + return ("Reply unavailable", message, false) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift index da7a844..89fb22c 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift @@ -5,8 +5,16 @@ import MatrixRustSDK final class MessageRowView: NSView { var onHoverChange: ((MessageRowView, Bool, NSEvent) -> Bool)? + private static let contentHorizontalInset: CGFloat = 74 + private static let replySpacing: CGFloat = 20 + private let timestamp = NSTextField(labelWithString: "") private let contentView = MessageTextContentView() + private var replyPreview: MessageReplyPreviewView? + private var replyDetails: EmbeddedEventDetails? + private var replyHeightConstraint: NSLayoutConstraint? + private var contentTopToReply: NSLayoutConstraint? + private lazy var contentTopToRow = contentView.topAnchor.constraint(equalTo: topAnchor, constant: 4) private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() @@ -41,26 +49,77 @@ final class MessageRowView: NSView { contentView.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), contentView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), - contentView.topAnchor.constraint(equalTo: topAnchor, constant: 4), + contentTopToRow, contentView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), ]) } static func supports(event: EventTimelineItem, content: MsgLikeContent) -> Bool { content.reactions.isEmpty && - content.inReplyTo == nil && content.threadSummary == nil && event.readReceipts.isEmpty && MessageTextContentView.supports(content: content) } - func configure(event: EventTimelineItem, content: MsgLikeContent) { + func configure( + event: EventTimelineItem, + content: MsgLikeContent, + replyDetails: EmbeddedEventDetails?, + onReplyClick: (() -> Void)? + ) { let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) timestamp.stringValue = Self.timeFormatter.string(from: date) contentView.configure(content: content) + configureReply(details: replyDetails, onClick: onReplyClick) layer?.backgroundColor = nil } + func configureReply(details: EmbeddedEventDetails?, onClick: (() -> Void)?) { + replyDetails = details + guard let details else { + replyPreview?.isHidden = true + replyPreview?.onClick = nil + contentTopToReply?.isActive = false + contentTopToRow.isActive = true + return + } + + if replyPreview == nil { + let preview = MessageReplyPreviewView() + preview.translatesAutoresizingMaskIntoConstraints = false + addSubview(preview) + NSLayoutConstraint.activate([ + preview.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + preview.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + preview.topAnchor.constraint(equalTo: topAnchor, constant: 4), + ]) + replyPreview = preview + replyHeightConstraint = preview.heightAnchor.constraint(equalToConstant: 28) + replyHeightConstraint?.isActive = true + contentTopToReply = contentView.topAnchor.constraint(equalTo: preview.bottomAnchor, constant: Self.replySpacing) + } + + replyPreview?.configure(details: details) + replyPreview?.onClick = onClick + replyPreview?.isHidden = false + contentTopToRow.isActive = false + contentTopToReply?.isActive = true + updateReplyHeight() + } + + override func layout() { + super.layout() + updateReplyHeight() + } + + private func updateReplyHeight() { + guard let replyDetails, bounds.width > 0 else { return } + let height = MessageReplyPreviewView.height(for: replyDetails, width: max(bounds.width - Self.contentHorizontalInset, 1)) + if replyHeightConstraint?.constant != height { + replyHeightConstraint?.constant = height + } + } + override func mouseEntered(with event: NSEvent) { if onHoverChange?(self, true, event) ?? true { setHoverHighlight(true) @@ -75,9 +134,12 @@ final class MessageRowView: NSView { layer?.backgroundColor = highlighted ? .init(gray: 0.5, alpha: 0.1) : nil } - func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { - let contentWidth = max(width - 74, 1) - return max(ceil(contentView.height(for: content, width: contentWidth)) + 8, 28) + func height(for content: MsgLikeContent, width: CGFloat, replyDetails: EmbeddedEventDetails?) -> CGFloat { + let contentWidth = max(width - Self.contentHorizontalInset, 1) + let replyHeight = replyDetails.map { + MessageReplyPreviewView.height(for: $0, width: contentWidth) + Self.replySpacing + } ?? 0 + return max(ceil(contentView.height(for: content, width: contentWidth)) + replyHeight + 8, 28) } @available(*, unavailable) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 4447f3b..b3a08fe 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -170,7 +170,12 @@ class TimelineViewController: NSViewController { view.onHoverChange = { [weak self] rowView, hovering, event in self?.updateHoverOverlay(for: rowView, hovering: hovering, event: event) ?? false } - view.configure(event: event, content: content) + view.configure( + event: event, + content: content, + replyDetails: self.replyDetails(for: content), + onReplyClick: self.replyClick(for: content) + ) view.identifier = item.reuseIdentifier return view default: @@ -230,6 +235,49 @@ class TimelineViewController: NSViewController { listenForFocusTimelineItem() listenForTypingUsers() + listenForReplyDetails() + } + + private func replyDetails(for content: MatrixRustSDK.MsgLikeContent) -> MatrixRustSDK.EmbeddedEventDetails? { + guard let reply = content.inReplyTo else { return nil } + return timeline.loadedReplyDetails[reply.eventId()]?.event() ?? reply.event() + } + + private func replyClick(for content: MatrixRustSDK.MsgLikeContent) -> (() -> Void)? { + guard let replyId = content.inReplyTo?.eventId() else { return nil } + return { [weak self] in + self?.timeline.focusEvent(id: .eventId(eventId: replyId)) + } + } + + private func listenForReplyDetails() { + withObservationTracking { + _ = timeline.loadedReplyDetails + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.listenForReplyDetails() + self.refreshVisibleReplyRows() + } + } + } + + private func refreshVisibleReplyRows() { + let visibleRows = tableView.rows(in: tableView.visibleRect) + var changedRows = IndexSet() + for row in visibleRows.lowerBound ..< visibleRows.upperBound { + guard row < timelineItems.count, + case .message(_, _, let content) = timelineItems[row], + content.inReplyTo != nil, + let view = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView + else { continue } + + view.configureReply(details: replyDetails(for: content), onClick: replyClick(for: content)) + changedRows.insert(row) + } + if !changedRows.isEmpty { + tableView.noteHeightOfRows(withIndexesChanged: changedRows) + } } @discardableResult @@ -521,7 +569,11 @@ extension TimelineViewController: NSTableViewDelegate { if case .message(_, let event, let content) = item, MessageRowView.supports(event: event, content: content) { - return measurementMessageView.height(for: content, width: tableView.tableColumns[0].width) + return measurementMessageView.height( + for: content, + width: tableView.tableColumns[0].width, + replyDetails: replyDetails(for: content) + ) } measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator)) From 5d331beec1d7deac72475a7bb28ebd572209e397 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 27 Sep 2026 18:00:39 +0200 Subject: [PATCH 12/23] Port message thread link to AppKit --- .../TimelineView/MessageRowView.swift | 52 ++++++++++- .../MessageThreadSummaryView.swift | 91 +++++++++++++++++++ .../TimelineView/TimelineTableView.swift | 5 +- 3 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageThreadSummaryView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift index 89fb22c..f27feb0 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift @@ -7,6 +7,7 @@ final class MessageRowView: NSView { private static let contentHorizontalInset: CGFloat = 74 private static let replySpacing: CGFloat = 20 + private static let threadSpacing: CGFloat = 10 private let timestamp = NSTextField(labelWithString: "") private let contentView = MessageTextContentView() @@ -15,6 +16,11 @@ final class MessageRowView: NSView { private var replyHeightConstraint: NSLayoutConstraint? private var contentTopToReply: NSLayoutConstraint? private lazy var contentTopToRow = contentView.topAnchor.constraint(equalTo: topAnchor, constant: 4) + private var threadSummaryView: MessageThreadSummaryView? + private var threadHeightConstraint: NSLayoutConstraint? + private var threadTopConstraint: NSLayoutConstraint? + private var threadBottomConstraint: NSLayoutConstraint? + private lazy var contentBottomToRow = contentView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() @@ -50,13 +56,12 @@ final class MessageRowView: NSView { contentView.leadingAnchor.constraint(equalTo: timestamp.trailingAnchor, constant: 16), contentView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), contentTopToRow, - contentView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4), + contentBottomToRow, ]) } static func supports(event: EventTimelineItem, content: MsgLikeContent) -> Bool { content.reactions.isEmpty && - content.threadSummary == nil && event.readReceipts.isEmpty && MessageTextContentView.supports(content: content) } @@ -65,15 +70,51 @@ final class MessageRowView: NSView { event: EventTimelineItem, content: MsgLikeContent, replyDetails: EmbeddedEventDetails?, - onReplyClick: (() -> Void)? + onReplyClick: (() -> Void)?, + onThreadClick: (() -> Void)? ) { let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) timestamp.stringValue = Self.timeFormatter.string(from: date) contentView.configure(content: content) configureReply(details: replyDetails, onClick: onReplyClick) + configureThread(summary: content.threadSummary, onClick: onThreadClick) layer?.backgroundColor = nil } + private func configureThread(summary: ThreadSummary?, onClick: (() -> Void)?) { + guard let summary else { + threadSummaryView?.isHidden = true + threadSummaryView?.onClick = nil + threadTopConstraint?.isActive = false + threadBottomConstraint?.isActive = false + contentBottomToRow.isActive = true + return + } + + if threadSummaryView == nil { + let view = MessageThreadSummaryView() + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + threadSummaryView = view + threadHeightConstraint = view.heightAnchor.constraint(equalToConstant: 0) + threadHeightConstraint?.isActive = true + threadTopConstraint = view.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: Self.threadSpacing) + threadBottomConstraint = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + } + + threadSummaryView?.configure(summary: summary) + threadSummaryView?.onClick = onClick + threadSummaryView?.isHidden = false + threadHeightConstraint?.constant = MessageThreadSummaryView.height(for: summary) + contentBottomToRow.isActive = false + threadTopConstraint?.isActive = true + threadBottomConstraint?.isActive = true + } + func configureReply(details: EmbeddedEventDetails?, onClick: (() -> Void)?) { replyDetails = details guard let details else { @@ -139,7 +180,10 @@ final class MessageRowView: NSView { let replyHeight = replyDetails.map { MessageReplyPreviewView.height(for: $0, width: contentWidth) + Self.replySpacing } ?? 0 - return max(ceil(contentView.height(for: content, width: contentWidth)) + replyHeight + 8, 28) + let threadHeight = content.threadSummary.map { + MessageThreadSummaryView.height(for: $0) + Self.threadSpacing + } ?? 0 + return max(ceil(contentView.height(for: content, width: contentWidth)) + replyHeight + threadHeight + 8, 28) } @available(*, unavailable) diff --git a/Mactrix/Views/ChatView/TimelineView/MessageThreadSummaryView.swift b/Mactrix/Views/ChatView/TimelineView/MessageThreadSummaryView.swift new file mode 100644 index 0000000..8837a81 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageThreadSummaryView.swift @@ -0,0 +1,91 @@ +import AppKit +import MatrixRustSDK + +/// A reusable button for opening the replies to a message. +final class MessageThreadSummaryView: NSButton { + var onClick: (() -> Void)? + + private let icon = NSImageView() + private let heading = NSTextField(labelWithString: "") + private let snippet = NSTextField(labelWithString: "") + + private static let font = NSFontManager.shared.convert( + NSFont.systemFont(ofSize: 13), toHaveTrait: .italicFontMask + ) + private static let lineHeight = ceil(NSFont.systemFont(ofSize: 13).boundingRectForFont.height) + private static let padding: CGFloat = 8 + private static let lineSpacing: CGFloat = 4 + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + isBordered = false + title = "" + target = self + action = #selector(activate) + wantsLayer = true + layer?.cornerRadius = 4 + layer?.backgroundColor = NSColor.labelColor.withAlphaComponent(0.05).cgColor + + icon.image = NSImage(systemSymbolName: "arrow.turn.down.right", accessibilityDescription: nil) + icon.contentTintColor = .labelColor + icon.translatesAutoresizingMaskIntoConstraints = false + + for field in [heading, snippet] { + field.font = Self.font + field.lineBreakMode = .byTruncatingTail + field.maximumNumberOfLines = 1 + field.translatesAutoresizingMaskIntoConstraints = false + } + snippet.textColor = .secondaryLabelColor + + addSubview(icon) + addSubview(heading) + addSubview(snippet) + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.padding), + icon.centerYAnchor.constraint(equalTo: heading.centerYAnchor), + icon.widthAnchor.constraint(equalToConstant: 16), + icon.heightAnchor.constraint(equalToConstant: 16), + + heading.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 4), + heading.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.padding), + heading.topAnchor.constraint(equalTo: topAnchor, constant: Self.padding), + + snippet.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.padding), + snippet.trailingAnchor.constraint(equalTo: heading.trailingAnchor), + snippet.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: Self.lineSpacing), + ]) + } + + func configure(summary: ThreadSummary) { + heading.stringValue = "Thread (\(summary.numReplies()) messages)" + snippet.stringValue = summary.description ?? "" + snippet.isHidden = summary.description == nil + } + + static func height(for summary: ThreadSummary) -> CGFloat { + padding * 2 + lineHeight * (summary.description == nil ? 1 : 2) + + (summary.description == nil ? 0 : lineSpacing) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } + + @objc private func activate(_ sender: NSButton) { + onClick?() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index b3a08fe..5d5975b 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -174,7 +174,10 @@ class TimelineViewController: NSViewController { event: event, content: content, replyDetails: self.replyDetails(for: content), - onReplyClick: self.replyClick(for: content) + onReplyClick: self.replyClick(for: content), + onThreadClick: { [weak self] in + self?.coordinator.windowState.focusThread(rootEventId: event.eventOrTransactionId.id) + } ) view.identifier = item.reuseIdentifier return view From b575a14e7ff015b7c4d1759b8932a5a21ef5fd36 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 27 Sep 2026 18:25:10 +0200 Subject: [PATCH 13/23] Port message reactions to AppKit --- .../TimelineView/MessageReactionsView.swift | 138 ++++++++++++++++++ .../TimelineView/MessageRowView.swift | 77 +++++++++- .../TimelineView/TimelineTableView.swift | 93 +++++++++--- 3 files changed, 283 insertions(+), 25 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageReactionsView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReactionsView.swift b/Mactrix/Views/ChatView/TimelineView/MessageReactionsView.swift new file mode 100644 index 0000000..aef110d --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageReactionsView.swift @@ -0,0 +1,138 @@ +import AppKit +import MatrixRustSDK + +/// Keeps reaction buttons alive when a table row is reused. +final class MessageReactionsView: NSView { + var onReactionClick: ((String) -> Void)? + + private var buttons: [ReactionButton] = [] + private var reactions: [Reaction] = [] + + private static let rowHeight: CGFloat = 28 + private static let spacing: CGFloat = 6 + + override var isFlipped: Bool { + true + } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + } + + func configure(reactions: [Reaction], ownUserId: String?) { + self.reactions = reactions + while buttons.count < reactions.count { + let reaction = reactions[buttons.count] + let button = ReactionButton(frame: NSRect( + x: 0, y: 0, width: ReactionButton.width(for: reaction), height: Self.rowHeight + )) + button.onClick = { [weak self] key in self?.onReactionClick?(key) } + addSubview(button) + buttons.append(button) + } + for (button, reaction) in zip(buttons, reactions) { + button.configure(reaction: reaction, ownUserId: ownUserId) + button.isHidden = false + } + for button in buttons.dropFirst(reactions.count) { + button.isHidden = true + } + needsLayout = true + } + + override func layout() { + super.layout() + var x: CGFloat = 0 + var y: CGFloat = 0 + for (reaction, button) in zip(reactions, buttons) { + let width = ReactionButton.width(for: reaction) + if x > 0, x + width > bounds.width { + x = 0 + y += Self.rowHeight + Self.spacing + } + button.frame = NSRect(x: x, y: y, width: width, height: Self.rowHeight) + x += width + Self.spacing + } + } + + static func height(for reactions: [Reaction], width: CGFloat) -> CGFloat { + guard !reactions.isEmpty else { return 0 } + var x: CGFloat = 0 + var rows = 1 + for reaction in reactions { + let buttonWidth = ReactionButton.width(for: reaction) + if x > 0 && x + buttonWidth > width { + x = 0 + rows += 1 + } + x += buttonWidth + spacing + } + return CGFloat(rows) * rowHeight + CGFloat(rows - 1) * spacing + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class ReactionButton: NSButton { + var onClick: ((String) -> Void)? + private var reactionKey = "" + private let label = NSTextField(labelWithString: "") + private static let font = NSFont.systemFont(ofSize: 13) + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isBordered = false + title = "" + target = self + action = #selector(activate) + wantsLayer = true + layer?.cornerRadius = 4 + layer?.borderWidth = 1 + + label.font = Self.font + label.translatesAutoresizingMaskIntoConstraints = false + addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8), + label.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + func configure(reaction: Reaction, ownUserId: String?) { + reactionKey = reaction.key + label.stringValue = "\(reaction.key) \(reaction.senders.count)" + setAccessibilityLabel(label.stringValue) + toolTip = "Reacted by " + reaction.senders.prefix(5).map(\.senderId).joined(separator: ", ") + let active = ownUserId.map { id in reaction.senders.contains { $0.senderId == id } } ?? false + layer?.borderColor = (active ? NSColor.systemBlue : .systemGray).cgColor + layer?.backgroundColor = (active ? NSColor.systemBlue : .systemGray).withAlphaComponent(0.12).cgColor + } + + static func width(for reaction: Reaction) -> CGFloat { + ceil(("\(reaction.key) \(reaction.senders.count)" as NSString).size(withAttributes: [.font: font]).width) + 16 + } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } + + @objc private func activate(_ sender: NSButton) { + onClick?(reactionKey) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift index f27feb0..6f71c2f 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift @@ -8,6 +8,7 @@ final class MessageRowView: NSView { private static let contentHorizontalInset: CGFloat = 74 private static let replySpacing: CGFloat = 20 private static let threadSpacing: CGFloat = 10 + private static let reactionSpacing: CGFloat = 10 private let timestamp = NSTextField(labelWithString: "") private let contentView = MessageTextContentView() @@ -21,6 +22,12 @@ final class MessageRowView: NSView { private var threadTopConstraint: NSLayoutConstraint? private var threadBottomConstraint: NSLayoutConstraint? private lazy var contentBottomToRow = contentView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + private var reactionsView: MessageReactionsView? + private var reactionsHeightConstraint: NSLayoutConstraint? + private var reactionsTopToContent: NSLayoutConstraint? + private var reactionsTopToThread: NSLayoutConstraint? + private var reactionsBottomToRow: NSLayoutConstraint? + private var reactions: [Reaction] = [] private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() @@ -61,9 +68,8 @@ final class MessageRowView: NSView { } static func supports(event: EventTimelineItem, content: MsgLikeContent) -> Bool { - content.reactions.isEmpty && event.readReceipts.isEmpty && - MessageTextContentView.supports(content: content) + MessageTextContentView.supports(content: content) } func configure( @@ -71,13 +77,16 @@ final class MessageRowView: NSView { content: MsgLikeContent, replyDetails: EmbeddedEventDetails?, onReplyClick: (() -> Void)?, - onThreadClick: (() -> Void)? + onThreadClick: (() -> Void)?, + ownUserId: String?, + onReactionClick: ((String) -> Void)? ) { let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) timestamp.stringValue = Self.timeFormatter.string(from: date) contentView.configure(content: content) configureReply(details: replyDetails, onClick: onReplyClick) configureThread(summary: content.threadSummary, onClick: onThreadClick) + configureReactions(content.reactions, ownUserId: ownUserId, onClick: onReactionClick) layer?.backgroundColor = nil } @@ -86,8 +95,6 @@ final class MessageRowView: NSView { threadSummaryView?.isHidden = true threadSummaryView?.onClick = nil threadTopConstraint?.isActive = false - threadBottomConstraint?.isActive = false - contentBottomToRow.isActive = true return } @@ -104,6 +111,9 @@ final class MessageRowView: NSView { threadHeightConstraint?.isActive = true threadTopConstraint = view.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: Self.threadSpacing) threadBottomConstraint = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + if let reactionsView { + reactionsTopToThread = reactionsView.topAnchor.constraint(equalTo: view.bottomAnchor, constant: Self.reactionSpacing) + } } threadSummaryView?.configure(summary: summary) @@ -112,7 +122,49 @@ final class MessageRowView: NSView { threadHeightConstraint?.constant = MessageThreadSummaryView.height(for: summary) contentBottomToRow.isActive = false threadTopConstraint?.isActive = true - threadBottomConstraint?.isActive = true + } + + private func configureReactions(_ reactions: [Reaction], ownUserId: String?, onClick: ((String) -> Void)?) { + self.reactions = reactions + if !reactions.isEmpty, reactionsView == nil { + let view = MessageReactionsView(frame: .zero) + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + ]) + reactionsView = view + reactionsHeightConstraint = view.heightAnchor.constraint(equalToConstant: 0) + reactionsHeightConstraint?.isActive = true + reactionsTopToContent = view.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: Self.reactionSpacing) + if let threadSummaryView { + reactionsTopToThread = view.topAnchor.constraint(equalTo: threadSummaryView.bottomAnchor, constant: Self.reactionSpacing) + } + reactionsBottomToRow = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + } + + reactionsView?.configure(reactions: reactions, ownUserId: ownUserId) + reactionsView?.onReactionClick = onClick + reactionsView?.isHidden = reactions.isEmpty + + contentBottomToRow.isActive = false + threadBottomConstraint?.isActive = false + reactionsTopToContent?.isActive = false + reactionsTopToThread?.isActive = false + reactionsBottomToRow?.isActive = false + + if reactions.isEmpty { + if threadSummaryView?.isHidden == false { + threadBottomConstraint?.isActive = true + } else { + contentBottomToRow.isActive = true + } + } else { + (threadSummaryView?.isHidden == false ? reactionsTopToThread : reactionsTopToContent)?.isActive = true + reactionsBottomToRow?.isActive = true + updateReactionsHeight() + } } func configureReply(details: EmbeddedEventDetails?, onClick: (() -> Void)?) { @@ -151,6 +203,7 @@ final class MessageRowView: NSView { override func layout() { super.layout() updateReplyHeight() + updateReactionsHeight() } private func updateReplyHeight() { @@ -161,6 +214,14 @@ final class MessageRowView: NSView { } } + private func updateReactionsHeight() { + guard !reactions.isEmpty, bounds.width > 0 else { return } + let height = MessageReactionsView.height(for: reactions, width: max(bounds.width - Self.contentHorizontalInset, 1)) + if reactionsHeightConstraint?.constant != height { + reactionsHeightConstraint?.constant = height + } + } + override func mouseEntered(with event: NSEvent) { if onHoverChange?(self, true, event) ?? true { setHoverHighlight(true) @@ -183,7 +244,9 @@ final class MessageRowView: NSView { let threadHeight = content.threadSummary.map { MessageThreadSummaryView.height(for: $0) + Self.threadSpacing } ?? 0 - return max(ceil(contentView.height(for: content, width: contentWidth)) + replyHeight + threadHeight + 8, 28) + let reactionsHeight = content.reactions.isEmpty ? 0 : + MessageReactionsView.height(for: content.reactions, width: contentWidth) + Self.reactionSpacing + return max(ceil(contentView.height(for: content, width: contentWidth)) + replyHeight + threadHeight + reactionsHeight + 8, 28) } @available(*, unavailable) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 5d5975b..9d5c23e 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -97,6 +97,8 @@ class TimelineViewController: NSViewController { let tableView = BottomStickyTableView() private let hoverOverlay = MessageHoverOverlayView() private weak var hoveredMessageView: MessageRowView? + private var hoveredMessageId: String? + private var hoveredRowIndex: Int? let timeline: LiveTimeline var timelineItems: [TimelineItemRowInfo] = [] @@ -122,6 +124,9 @@ class TimelineViewController: NSViewController { tableView.rowSizeStyle = .custom tableView.rowHeight = 28 tableView.usesAutomaticRowHeights = false + tableView.onLayout = { [weak self] in + self?.positionHoverOverlay() + } oldWidth = tableView.frame.width @@ -177,6 +182,10 @@ class TimelineViewController: NSViewController { onReplyClick: self.replyClick(for: content), onThreadClick: { [weak self] in self?.coordinator.windowState.focusThread(rootEventId: event.eventOrTransactionId.id) + }, + ownUserId: try? self.coordinator.appState.matrixClient?.client.userId(), + onReactionClick: { [weak self] key in + self?.toggleReaction(key, for: event) } ) view.identifier = item.reuseIdentifier @@ -288,7 +297,9 @@ class TimelineViewController: NSViewController { if !hovering { if hoveredMessageView === rowView { let mousePoint = hoverOverlay.convert(event.locationInWindow, from: nil) - if hoverOverlay.bounds.contains(mousePoint) { return true } + if hoverOverlay.bounds.contains(mousePoint) { + return true + } hideHoverOverlay() } return false @@ -302,22 +313,44 @@ class TimelineViewController: NSViewController { let rowPoint = tableView.convert(NSPoint(x: rowView.bounds.midX, y: rowView.bounds.midY), from: rowView) let row = tableView.row(at: rowPoint) - guard row >= 0 else { return false } - - if case .message(_, let event, _) = timelineItems[row] { - hoverOverlay.configure(canReply: event.canBeRepliedTo) - } + guard row >= 0, row < timelineItems.count, + case .message(_, let event, _) = timelineItems[row] else { return false } + hoverOverlay.configure(canReply: event.canBeRepliedTo) if hoveredMessageView !== rowView { hoveredMessageView?.setHoverHighlight(false) } - let rowRect = tableView.convert(tableView.rect(ofRow: row), to: scrollView.contentView) - hoverOverlay.setFrameOrigin(NSPoint(x: rowRect.maxX - hoverOverlay.frame.width - 20, - y: rowRect.maxY)) hoveredMessageView = rowView + hoveredMessageId = timelineItems[row].id + hoveredRowIndex = row hoverOverlay.isHidden = false + positionHoverOverlay() return true } + private func positionHoverOverlay() { + guard !hoverOverlay.isHidden else { return } + guard let row = hoveredRowIndex, + let hoveredMessageId, + row < timelineItems.count, + timelineItems[row].id == hoveredMessageId, + let rowView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView else { + hideHoverOverlay() + return + } + + if hoveredMessageView !== rowView { + hoveredMessageView?.setHoverHighlight(false) + hoveredMessageView = rowView + } + rowView.setHoverHighlight(true) + + let rowRect = tableView.convert(tableView.rect(ofRow: row), to: scrollView.contentView) + let origin = NSPoint(x: rowRect.maxX - hoverOverlay.frame.width - 20, y: rowRect.maxY) + if hoverOverlay.frame.origin != origin { + hoverOverlay.setFrameOrigin(origin) + } + } + private func hoverOverlayDidExit(with event: NSEvent) { let tablePoint = tableView.convert(event.locationInWindow, from: nil) let row = tableView.row(at: tablePoint) @@ -337,9 +370,21 @@ class TimelineViewController: NSViewController { private func hideHoverOverlay() { hoveredMessageView?.setHoverHighlight(false) hoveredMessageView = nil + hoveredMessageId = nil + hoveredRowIndex = nil hoverOverlay.isHidden = true } + private func toggleReaction(_ key: String, for event: MatrixRustSDK.EventTimelineItem) { + Task { + do { + _ = try await timeline.timeline?.toggleReaction(itemId: event.eventOrTransactionId, key: key) + } catch { + Logger.timelineTableView.error("Failed to toggle reaction: \(error)") + } + } + } + private func performHoverAction(_ action: MessageHoverOverlayView.Action) { guard let hoveredMessageView else { return } let rowPoint = tableView.convert( @@ -352,13 +397,7 @@ class TimelineViewController: NSViewController { switch action { case .reaction(let key): - Task { - do { - _ = try await timeline.timeline?.toggleReaction(itemId: event.eventOrTransactionId, key: key) - } catch { - Logger.timelineTableView.error("Failed to toggle reaction: \(error)") - } - } + toggleReaction(key, for: event) case .reactionPicker: break // The old picker button does not have an action yet. case .reply: @@ -366,7 +405,7 @@ class TimelineViewController: NSViewController { case .replyInThread: coordinator.windowState.focusThread(rootEventId: event.eventOrTransactionId.id) case .pin: - guard case let .eventId(eventId: eventId) = event.eventOrTransactionId else { return } + guard case .eventId(eventId: let eventId) = event.eventOrTransactionId else { return } Task { do { _ = try await timeline.timeline?.pinEvent(eventId: eventId) @@ -469,13 +508,24 @@ class TimelineViewController: NSViewController { func updateTimelineItems(_ timelineItems: [TimelineItem]) { Logger.timelineTableView.info("update timeline items") - let oldIds = self.timelineItems.map { $0.id } + let oldItems = self.timelineItems + let oldIds = oldItems.map { $0.id } self.timelineItems = mapTimelineItems(items: timelineItems) let newIds = self.timelineItems.map { $0.id } // If the IDs haven't changed, reload all rows in place (content-only update: reactions, read receipts, etc.) // Reloads all rows rather than just visible ones to avoid stale content in NSTableView's prepared/cached views. if oldIds == newIds { + var reactionRows = IndexSet() + for row in self.timelineItems.indices { + guard case .message(_, _, let oldContent) = oldItems[row], + case .message(_, _, let newContent) = self.timelineItems[row], + oldContent.reactions != newContent.reactions else { continue } + reactionRows.insert(row) + } + if !reactionRows.isEmpty { + tableView.noteHeightOfRows(withIndexesChanged: reactionRows) + } tableView.reloadData(forRowIndexes: IndexSet(integersIn: 0 ..< self.timelineItems.count), columnIndexes: IndexSet(integer: 0)) return @@ -591,6 +641,13 @@ extension TimelineViewController: NSTableViewDelegate { } class BottomStickyTableView: NSTableView { + var onLayout: (() -> Void)? + + override func layout() { + super.layout() + onLayout?() + } + // By returning false, the table starts drawing from the bottom up override var isFlipped: Bool { return false From 2d0d61ac4fdcee36c67929e90e3fb5fadba9c02b Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 28 Sep 2026 09:13:54 +0200 Subject: [PATCH 14/23] Port message read recipts to AppKit --- .../MessageReadReceiptsView.swift | 267 ++++++++++++++++++ .../TimelineView/MessageRowView.swift | 144 ++++++++-- .../TimelineView/TimelineTableView.swift | 23 +- 3 files changed, 398 insertions(+), 36 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift b/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift new file mode 100644 index 0000000..2e2e7e8 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageReadReceiptsView.swift @@ -0,0 +1,267 @@ +import AppKit +import MatrixRustSDK +import Models +import UI + +/// Reuses the visible receipt avatars as the table row is recycled. +final class MessageReadReceiptsView: NSButton { + static let rowHeight: CGFloat = 18 + private static let countFont = NSFont.systemFont(ofSize: 11) + var onFocusUser: ((String) -> Void)? + var receiptCount: Int { + receipts.count + } + + private let countLabel = NSTextField(labelWithString: "") + private var avatars: [ReceiptAvatarView] = [] + private var receipts: [(userId: String, date: Date?)] = [] + private var members: [String: MatrixRustSDK.RoomMember] = [:] + private var imageLoader: UI.ImageLoader? + private let receiptPopover = NSPopover() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + isBordered = false + title = "" + target = self + action = #selector(showReceipts) + countLabel.font = Self.countFont + countLabel.textColor = .secondaryLabelColor + countLabel.alignment = .right + addSubview(countLabel) + } + + func configure(receipts: [String: Models.Receipt], members: [MatrixRustSDK.RoomMember], imageLoader: UI.ImageLoader?) { + self.receipts = receipts.map { ($0.key, $0.value.timestamp) } + .sorted { ($0.date ?? .distantPast) < ($1.date ?? .distantPast) } + self.members = Dictionary(members.map { ($0.userId, $0) }, uniquingKeysWith: { first, _ in first }) + self.imageLoader = imageLoader + receiptPopover.close() + + let visible = Array(self.receipts.suffix(3)) + while avatars.count < visible.count { + let avatar = ReceiptAvatarView(frame: NSRect(x: 0, y: 0, width: 14, height: 14)) + addSubview(avatar) + avatars.append(avatar) + } + for (avatar, receipt) in zip(avatars, visible) { + avatar.configure(userId: receipt.userId, avatarUrl: self.members[receipt.userId]?.avatarUrl, imageLoader: imageLoader) + avatar.isHidden = false + } + for avatar in avatars.dropFirst(visible.count) { + avatar.isHidden = true + avatar.cancelLoad() + } + let hiddenCount = self.receipts.count - visible.count + countLabel.stringValue = hiddenCount > 0 ? "+\(hiddenCount)" : "" + countLabel.isHidden = hiddenCount == 0 + toolTip = "Read by " + self.receipts.map { self.members[$0.userId]?.displayName ?? $0.userId }.joined(separator: ", ") + needsLayout = true + } + + static func width(for count: Int) -> CGFloat { + guard count > 0 else { return 0 } + let visible = min(count, 3) + let avatarsWidth = CGFloat(visible) * 14 - CGFloat(max(visible - 1, 0)) * 2 + return avatarsWidth + countWidth(for: count) + } + + private static func countWidth(for count: Int) -> CGFloat { + guard count > 3 else { return 0 } + let text = "+\(count - 3)" as NSString + return max(20, ceil(text.size(withAttributes: [.font: countFont]).width) + 6) + } + + override func layout() { + super.layout() + var x: CGFloat = 0 + if !countLabel.isHidden { + x = Self.countWidth(for: receipts.count) + countLabel.frame = NSRect(x: 0, y: 2, width: x, height: 14) + } + for avatar in avatars where !avatar.isHidden { + avatar.frame = NSRect(x: x, y: 2, width: 14, height: 14) + x += 12 + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } + + @objc private func showReceipts() { + if receiptPopover.isShown { + receiptPopover.close() + return + } + let stack = FlippedReceiptStackView() + stack.orientation = .vertical + stack.alignment = .leading + stack.spacing = 8 + + let header = NSTextField(labelWithString: receipts.count == 1 ? "Read by 1 person" : "Read by \(receipts.count) people") + header.font = .boldSystemFont(ofSize: NSFont.systemFontSize) + stack.addArrangedSubview(header) + let width: CGFloat = 230 + for receipt in receipts.reversed() { + let row = ReceiptPopoverRowButton(userId: receipt.userId) + row.onClick = { [weak self] userId in + self?.receiptPopover.close() + self?.onFocusUser?(userId) + } + row.widthAnchor.constraint(equalToConstant: width - 24).isActive = true + row.heightAnchor.constraint(equalToConstant: 36).isActive = true + let content = row.content + let avatar = ReceiptAvatarView(frame: NSRect(x: 0, y: 0, width: 28, height: 28)) + avatar.configure(userId: receipt.userId, avatarUrl: members[receipt.userId]?.avatarUrl, imageLoader: imageLoader) + avatar.widthAnchor.constraint(equalToConstant: 28).isActive = true + avatar.heightAnchor.constraint(equalToConstant: 28).isActive = true + content.addArrangedSubview(avatar) + + let details = NSStackView() + details.orientation = .vertical + details.alignment = .leading + details.spacing = 2 + let name = members[receipt.userId]?.displayName ?? receipt.userId + let nameLabel = NSTextField(labelWithString: name) + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.toolTip = name + details.addArrangedSubview(nameLabel) + if let date = receipt.date { + let dateLabel = NSTextField(labelWithString: Self.formattedTimestamp(date)) + dateLabel.font = .systemFont(ofSize: 11) + dateLabel.textColor = .secondaryLabelColor + details.addArrangedSubview(dateLabel) + } + content.addArrangedSubview(details) + stack.addArrangedSubview(row) + } + let scroll = NSScrollView() + scroll.drawsBackground = false + scroll.hasVerticalScroller = receipts.count > 6 + stack.frame = NSRect(x: 0, y: 0, width: width - 24, height: stack.fittingSize.height) + scroll.documentView = stack + scroll.frame = NSRect(x: 12, y: 12, width: width - 24, height: min(stack.fittingSize.height, 220)) + let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: scroll.frame.height + 24)) + container.addSubview(scroll) + let controller = NSViewController() + controller.view = container + receiptPopover.contentViewController = controller + receiptPopover.contentSize = container.bounds.size + receiptPopover.behavior = .transient + receiptPopover.show(relativeTo: bounds, of: self, preferredEdge: .maxY) + } + + private static func formattedTimestamp(_ date: Date) -> String { + if Calendar.current.isDateInToday(date) { + return date.formatted(.dateTime.hour().minute()) + } + return date.formatted(.dateTime.weekday(.abbreviated).hour().minute()) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class FlippedReceiptStackView: NSStackView { + override var isFlipped: Bool { true } +} + +private final class ReceiptPopoverRowButton: NSButton { + let content = NSStackView() + var onClick: ((String) -> Void)? + private let userId: String + + init(userId: String) { + self.userId = userId + super.init(frame: .zero) + isBordered = false + title = "" + target = self + action = #selector(activate) + + content.orientation = .horizontal + content.alignment = .centerY + content.spacing = 10 + content.translatesAutoresizingMaskIntoConstraints = false + addSubview(content) + NSLayoutConstraint.activate([ + content.leadingAnchor.constraint(equalTo: leadingAnchor), + content.trailingAnchor.constraint(equalTo: trailingAnchor), + content.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } + + @objc private func activate() { + onClick?(userId) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class ReceiptAvatarView: NSImageView { + private var loadTask: Task? + private var avatarUrl: String? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + imageScaling = .scaleProportionallyUpOrDown + wantsLayer = true + layer?.masksToBounds = true + } + + override func layout() { + super.layout() + layer?.cornerRadius = bounds.width / 2 + } + + func configure(userId: String, avatarUrl: String?, imageLoader: UI.ImageLoader?) { + cancelLoad() + self.avatarUrl = avatarUrl + image = nil + layer?.backgroundColor = NSColor(userID: userId).cgColor + guard let avatarUrl, let imageLoader else { return } + if let cached = imageLoader.cachedImage(matrixUrl: avatarUrl) { + image = cached + return + } + loadTask = Task { [weak self] in + guard let image = try? await imageLoader.loadImage(matrixUrl: avatarUrl, size: nil), + !Task.isCancelled, let self, self.avatarUrl == avatarUrl else { return } + self.image = image + } + } + + func cancelLoad() { + loadTask?.cancel() + loadTask = nil + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift index 6f71c2f..7c795a9 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift @@ -1,5 +1,7 @@ import AppKit import MatrixRustSDK +import Models +import UI /// Shared row chrome. The content view is created once and retained across table reuse. final class MessageRowView: NSView { @@ -9,11 +11,12 @@ final class MessageRowView: NSView { private static let replySpacing: CGFloat = 20 private static let threadSpacing: CGFloat = 10 private static let reactionSpacing: CGFloat = 10 + private static let receiptSpacing: CGFloat = 10 private let timestamp = NSTextField(labelWithString: "") private let contentView = MessageTextContentView() private var replyPreview: MessageReplyPreviewView? - private var replyDetails: EmbeddedEventDetails? + private var replyDetails: MatrixRustSDK.EmbeddedEventDetails? private var replyHeightConstraint: NSLayoutConstraint? private var contentTopToReply: NSLayoutConstraint? private lazy var contentTopToRow = contentView.topAnchor.constraint(equalTo: topAnchor, constant: 4) @@ -27,7 +30,15 @@ final class MessageRowView: NSView { private var reactionsTopToContent: NSLayoutConstraint? private var reactionsTopToThread: NSLayoutConstraint? private var reactionsBottomToRow: NSLayoutConstraint? - private var reactions: [Reaction] = [] + private var reactionsTrailingToContent: NSLayoutConstraint? + private var reactionsTrailingToReceipts: NSLayoutConstraint? + private var reactions: [MatrixRustSDK.Reaction] = [] + private var receiptsView: MessageReadReceiptsView? + private var receiptsWidthConstraint: NSLayoutConstraint? + private var receiptsTopToContent: NSLayoutConstraint? + private var receiptsTopToThread: NSLayoutConstraint? + private var receiptsTopToReactions: NSLayoutConstraint? + private var receiptsBottomToRow: NSLayoutConstraint? private static let timeFormatter: DateFormatter = { let formatter = DateFormatter() @@ -55,6 +66,8 @@ final class MessageRowView: NSView { addSubview(timestamp) 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), @@ -67,30 +80,39 @@ final class MessageRowView: NSView { ]) } - static func supports(event: EventTimelineItem, content: MsgLikeContent) -> Bool { - event.readReceipts.isEmpty && - MessageTextContentView.supports(content: content) + static func supports(event: MatrixRustSDK.EventTimelineItem, content: MatrixRustSDK.MsgLikeContent) -> Bool { + MessageTextContentView.supports(content: content) } func configure( - event: EventTimelineItem, - content: MsgLikeContent, - replyDetails: EmbeddedEventDetails?, + event: MatrixRustSDK.EventTimelineItem, + content: MatrixRustSDK.MsgLikeContent, + replyDetails: MatrixRustSDK.EmbeddedEventDetails?, onReplyClick: (() -> Void)?, onThreadClick: (() -> Void)?, ownUserId: String?, - onReactionClick: ((String) -> Void)? + onReactionClick: ((String) -> Void)?, + roomMembers: [MatrixRustSDK.RoomMember], + imageLoader: UI.ImageLoader?, + onFocusUser: ((String) -> Void)? ) { + // A reused row may still have its previous table height while its content changes. + contentBottomToRow.isActive = false + threadBottomConstraint?.isActive = false + reactionsBottomToRow?.isActive = false + receiptsBottomToRow?.isActive = false + let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) timestamp.stringValue = Self.timeFormatter.string(from: date) contentView.configure(content: content) configureReply(details: replyDetails, onClick: onReplyClick) configureThread(summary: content.threadSummary, onClick: onThreadClick) configureReactions(content.reactions, ownUserId: ownUserId, onClick: onReactionClick) + configureReceipts(event.userReadReceipts, roomMembers: roomMembers, imageLoader: imageLoader, onFocusUser: onFocusUser) layer?.backgroundColor = nil } - private func configureThread(summary: ThreadSummary?, onClick: (() -> Void)?) { + private func configureThread(summary: MatrixRustSDK.ThreadSummary?, onClick: (() -> Void)?) { guard let summary else { threadSummaryView?.isHidden = true threadSummaryView?.onClick = nil @@ -111,29 +133,30 @@ final class MessageRowView: NSView { threadHeightConstraint?.isActive = true threadTopConstraint = view.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: Self.threadSpacing) threadBottomConstraint = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + threadBottomConstraint?.priority = .init(999) if let reactionsView { reactionsTopToThread = reactionsView.topAnchor.constraint(equalTo: view.bottomAnchor, constant: Self.reactionSpacing) } + if let receiptsView { + receiptsTopToThread = receiptsView.topAnchor.constraint(equalTo: view.bottomAnchor, constant: Self.receiptSpacing) + } } threadSummaryView?.configure(summary: summary) threadSummaryView?.onClick = onClick threadSummaryView?.isHidden = false threadHeightConstraint?.constant = MessageThreadSummaryView.height(for: summary) - contentBottomToRow.isActive = false threadTopConstraint?.isActive = true } - private func configureReactions(_ reactions: [Reaction], ownUserId: String?, onClick: ((String) -> Void)?) { + private func configureReactions(_ reactions: [MatrixRustSDK.Reaction], ownUserId: String?, onClick: ((String) -> Void)?) { self.reactions = reactions if !reactions.isEmpty, reactionsView == nil { let view = MessageReactionsView(frame: .zero) view.translatesAutoresizingMaskIntoConstraints = false addSubview(view) - NSLayoutConstraint.activate([ - view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - ]) + view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).isActive = true + reactionsTrailingToContent = view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor) reactionsView = view reactionsHeightConstraint = view.heightAnchor.constraint(equalToConstant: 0) reactionsHeightConstraint?.isActive = true @@ -142,32 +165,93 @@ final class MessageRowView: NSView { reactionsTopToThread = view.topAnchor.constraint(equalTo: threadSummaryView.bottomAnchor, constant: Self.reactionSpacing) } reactionsBottomToRow = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + reactionsBottomToRow?.priority = .init(999) + if let receiptsView { + receiptsTopToReactions = receiptsView.topAnchor.constraint(equalTo: view.topAnchor, constant: 5) + reactionsTrailingToReceipts = view.trailingAnchor.constraint(equalTo: receiptsView.leadingAnchor, constant: -Self.receiptSpacing) + } } reactionsView?.configure(reactions: reactions, ownUserId: ownUserId) reactionsView?.onReactionClick = onClick reactionsView?.isHidden = reactions.isEmpty - contentBottomToRow.isActive = false - threadBottomConstraint?.isActive = false reactionsTopToContent?.isActive = false reactionsTopToThread?.isActive = false + + if !reactions.isEmpty { + (threadSummaryView?.isHidden == false ? reactionsTopToThread : reactionsTopToContent)?.isActive = true + updateReactionsHeight() + } + } + + private func configureReceipts(_ receipts: [String: Models.Receipt], roomMembers: [MatrixRustSDK.RoomMember], imageLoader: UI.ImageLoader?, onFocusUser: ((String) -> Void)?) { + if !receipts.isEmpty, receiptsView == nil { + let view = MessageReadReceiptsView() + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + receiptsView = view + NSLayoutConstraint.activate([ + view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + view.heightAnchor.constraint(equalToConstant: MessageReadReceiptsView.rowHeight), + ]) + receiptsWidthConstraint = view.widthAnchor.constraint(equalToConstant: 0) + receiptsWidthConstraint?.isActive = true + receiptsTopToContent = view.topAnchor.constraint(equalTo: contentView.bottomAnchor, constant: Self.receiptSpacing) + if let threadSummaryView { + receiptsTopToThread = view.topAnchor.constraint(equalTo: threadSummaryView.bottomAnchor, constant: Self.receiptSpacing) + } + if let reactionsView { + receiptsTopToReactions = view.topAnchor.constraint(equalTo: reactionsView.topAnchor, constant: 5) + reactionsTrailingToReceipts = reactionsView.trailingAnchor.constraint(equalTo: view.leadingAnchor, constant: -Self.receiptSpacing) + } + receiptsBottomToRow = view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + // NSTableView can briefly keep the old row height while configuring a reused view. + receiptsBottomToRow?.priority = .init(999) + } + + receiptsView?.isHidden = receipts.isEmpty + receiptsView?.onFocusUser = onFocusUser + if !receipts.isEmpty { + receiptsView?.configure(receipts: receipts, members: roomMembers, imageLoader: imageLoader) + receiptsWidthConstraint?.constant = MessageReadReceiptsView.width(for: receipts.count) + } + + contentBottomToRow.isActive = false + threadBottomConstraint?.isActive = false reactionsBottomToRow?.isActive = false + receiptsTopToContent?.isActive = false + receiptsTopToThread?.isActive = false + receiptsTopToReactions?.isActive = false + receiptsBottomToRow?.isActive = false + let trailingConstraints = [reactionsTrailingToContent, reactionsTrailingToReceipts].compactMap { $0 } + NSLayoutConstraint.deactivate(trailingConstraints) + (receipts.isEmpty || reactions.isEmpty ? reactionsTrailingToContent : reactionsTrailingToReceipts)?.isActive = true - if reactions.isEmpty { - if threadSummaryView?.isHidden == false { + if receipts.isEmpty { + if !reactions.isEmpty { + reactionsBottomToRow?.isActive = true + } else if threadSummaryView?.isHidden == false { threadBottomConstraint?.isActive = true } else { contentBottomToRow.isActive = true } } else { - (threadSummaryView?.isHidden == false ? reactionsTopToThread : reactionsTopToContent)?.isActive = true - reactionsBottomToRow?.isActive = true - updateReactionsHeight() + if !reactions.isEmpty { + receiptsTopToReactions?.isActive = true + reactionsBottomToRow?.isActive = true + } else if threadSummaryView?.isHidden == false { + receiptsTopToThread?.isActive = true + receiptsBottomToRow?.isActive = true + } else { + receiptsTopToContent?.isActive = true + receiptsBottomToRow?.isActive = true + } } + updateReactionsHeight() } - func configureReply(details: EmbeddedEventDetails?, onClick: (() -> Void)?) { + func configureReply(details: MatrixRustSDK.EmbeddedEventDetails?, onClick: (() -> Void)?) { replyDetails = details guard let details else { replyPreview?.isHidden = true @@ -216,7 +300,9 @@ final class MessageRowView: NSView { private func updateReactionsHeight() { guard !reactions.isEmpty, bounds.width > 0 else { return } - let height = MessageReactionsView.height(for: reactions, width: max(bounds.width - Self.contentHorizontalInset, 1)) + let receiptWidth = receiptsView?.isHidden == false + ? MessageReadReceiptsView.width(for: receiptsView?.receiptCount ?? 0) + Self.receiptSpacing : 0 + let height = MessageReactionsView.height(for: reactions, width: max(bounds.width - Self.contentHorizontalInset - receiptWidth, 1)) if reactionsHeightConstraint?.constant != height { reactionsHeightConstraint?.constant = height } @@ -236,7 +322,7 @@ final class MessageRowView: NSView { layer?.backgroundColor = highlighted ? .init(gray: 0.5, alpha: 0.1) : nil } - func height(for content: MsgLikeContent, width: CGFloat, replyDetails: EmbeddedEventDetails?) -> CGFloat { + func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat, replyDetails: MatrixRustSDK.EmbeddedEventDetails?, receiptCount: Int) -> CGFloat { let contentWidth = max(width - Self.contentHorizontalInset, 1) let replyHeight = replyDetails.map { MessageReplyPreviewView.height(for: $0, width: contentWidth) + Self.replySpacing @@ -244,9 +330,11 @@ final class MessageRowView: NSView { let threadHeight = content.threadSummary.map { MessageThreadSummaryView.height(for: $0) + Self.threadSpacing } ?? 0 + let availableReactionWidth = contentWidth - (receiptCount > 0 ? MessageReadReceiptsView.width(for: receiptCount) + Self.receiptSpacing : 0) let reactionsHeight = content.reactions.isEmpty ? 0 : - MessageReactionsView.height(for: content.reactions, width: contentWidth) + Self.reactionSpacing - return max(ceil(contentView.height(for: content, width: contentWidth)) + replyHeight + threadHeight + reactionsHeight + 8, 28) + MessageReactionsView.height(for: content.reactions, width: max(availableReactionWidth, 1)) + Self.reactionSpacing + let receiptsHeight = receiptCount == 0 || !content.reactions.isEmpty ? 0 : MessageReadReceiptsView.rowHeight + Self.receiptSpacing + return max(ceil(contentView.height(for: content, width: contentWidth)) + replyHeight + threadHeight + reactionsHeight + receiptsHeight + 8, 28) } @available(*, unavailable) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 9d5c23e..a913414 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -186,6 +186,11 @@ class TimelineViewController: NSViewController { ownUserId: try? self.coordinator.appState.matrixClient?.client.userId(), onReactionClick: { [weak self] key in self?.toggleReaction(key, for: event) + }, + roomMembers: self.timeline.room.members, + imageLoader: self.coordinator.appState.matrixClient, + onFocusUser: { [weak self] userId in + self?.coordinator.windowState.focusUser(userId: userId) } ) view.identifier = item.reuseIdentifier @@ -516,15 +521,16 @@ class TimelineViewController: NSViewController { // If the IDs haven't changed, reload all rows in place (content-only update: reactions, read receipts, etc.) // Reloads all rows rather than just visible ones to avoid stale content in NSTableView's prepared/cached views. if oldIds == newIds { - var reactionRows = IndexSet() + var changedHeightRows = IndexSet() for row in self.timelineItems.indices { - guard case .message(_, _, let oldContent) = oldItems[row], - case .message(_, _, let newContent) = self.timelineItems[row], - oldContent.reactions != newContent.reactions else { continue } - reactionRows.insert(row) + guard case .message(_, let oldEvent, let oldContent) = oldItems[row], + case .message(_, let newEvent, let newContent) = self.timelineItems[row], + oldContent.reactions != newContent.reactions || + oldEvent.readReceipts.count != newEvent.readReceipts.count else { continue } + changedHeightRows.insert(row) } - if !reactionRows.isEmpty { - tableView.noteHeightOfRows(withIndexesChanged: reactionRows) + if !changedHeightRows.isEmpty { + tableView.noteHeightOfRows(withIndexesChanged: changedHeightRows) } tableView.reloadData(forRowIndexes: IndexSet(integersIn: 0 ..< self.timelineItems.count), columnIndexes: IndexSet(integer: 0)) @@ -625,7 +631,8 @@ extension TimelineViewController: NSTableViewDelegate { return measurementMessageView.height( for: content, width: tableView.tableColumns[0].width, - replyDetails: replyDetails(for: content) + replyDetails: replyDetails(for: content), + receiptCount: event.readReceipts.count ) } From ad8724aac0004bea063b1b5e18e9331409b21ce3 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 28 Sep 2026 11:26:17 +0200 Subject: [PATCH 15/23] Synchronize timeline row selection with underlying NSTableView --- Mactrix/Models/LiveTimeline.swift | 11 +- Mactrix/Views/ChatView/ChatMessageView.swift | 6 +- .../TimelineView/MessageRowView.swift | 35 ++++- .../TimelineView/MessageTextContentView.swift | 20 +++ .../TimelineView/TimelineTableView.swift | 127 +++++++++++++++--- 5 files changed, 173 insertions(+), 26 deletions(-) diff --git a/Mactrix/Models/LiveTimeline.swift b/Mactrix/Models/LiveTimeline.swift index 8a3142d..99911a0 100644 --- a/Mactrix/Models/LiveTimeline.swift +++ b/Mactrix/Models/LiveTimeline.swift @@ -4,6 +4,12 @@ import MatrixRustSDK import OSLog import SwiftUI +/// Delegate subscribed to by the `NSTimelineView` to update the focused timeline row. +@MainActor +public protocol LiveTimelineFocusDelegate: AnyObject { + func focusTimelineEvent(id: EventOrTransactionId) +} + @MainActor @Observable public final class LiveTimeline { public let room: LiveRoom @@ -17,8 +23,7 @@ public final class LiveTimeline { public var scrollPosition = ScrollPosition(idType: TimelineGroup.ID.self, edge: .bottom) public var errorMessage: String? - public private(set) var focusedTimelineEventId: EventOrTransactionId? - // public private(set) var focusedTimelineGroupId: String? + @ObservationIgnored public weak var focusDelegate: (any LiveTimelineFocusDelegate)? public var sendReplyTo: MatrixRustSDK.EventTimelineItem? @@ -157,7 +162,7 @@ public final class LiveTimeline { public func focusEvent(id eventId: EventOrTransactionId) { Logger.liveTimeline.info("focus event: \(eventId.id)") - focusedTimelineEventId = eventId + focusDelegate?.focusTimelineEvent(id: eventId) } } diff --git a/Mactrix/Views/ChatView/ChatMessageView.swift b/Mactrix/Views/ChatView/ChatMessageView.swift index cd5b484..e5ea718 100644 --- a/Mactrix/Views/ChatView/ChatMessageView.swift +++ b/Mactrix/Views/ChatView/ChatMessageView.swift @@ -108,10 +108,6 @@ struct ChatMessageView: View, UI.MessageEventActions { } } - var isEventFocused: Bool { - return timeline?.focusedTimelineEventId == event.eventOrTransactionId - } - var ownUserId: String { do { return try appState.matrixClient?.client.userId() ?? "" @@ -126,7 +122,7 @@ struct ChatMessageView: View, UI.MessageEventActions { UI.MessageEventProfileView(event: event, focusUserAction: self.focusUser, imageLoader: appState.matrixClient) .font(.system(size: .init(fontSize))) } - UI.MessageEventBodyView(event: event, focused: isEventFocused, reactions: msg.reactions, actions: self, ownUserID: ownUserId, imageLoader: appState.matrixClient, roomMembers: timeline?.room.members ?? []) { + UI.MessageEventBodyView(event: event, focused: false, reactions: msg.reactions, actions: self, ownUserID: ownUserId, imageLoader: appState.matrixClient, roomMembers: timeline?.room.members ?? []) { VStack(alignment: .leading, spacing: 10) { if let replyTo = msg.inReplyTo { let eventId = replyTo.eventId() diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift index 7c795a9..49d3350 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift @@ -6,6 +6,10 @@ import UI /// Shared row chrome. The content view is created once and retained across table reuse. final class MessageRowView: NSView { var onHoverChange: ((MessageRowView, Bool, NSEvent) -> Bool)? + var onSelectRequest: ((MessageRowView) -> Void)? + var onArrowKey: ((TimelineSelectionDirection) -> Void)? + private var isHovered = false + private var isMessageSelected = false private static let contentHorizontalInset: CGFloat = 74 private static let replySpacing: CGFloat = 20 @@ -54,6 +58,13 @@ final class MessageRowView: NSView { timestamp.alignment = .right timestamp.translatesAutoresizingMaskIntoConstraints = false contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.onTextMouseDown = { [weak self] in + guard let self else { return } + self.onSelectRequest?(self) + } + contentView.onArrowKey = { [weak self] direction in + self?.onArrowKey?(direction) + } wantsLayer = true layer?.cornerRadius = 4 @@ -109,7 +120,8 @@ final class MessageRowView: NSView { configureThread(summary: content.threadSummary, onClick: onThreadClick) configureReactions(content.reactions, ownUserId: ownUserId, onClick: onReactionClick) configureReceipts(event.userReadReceipts, roomMembers: roomMembers, imageLoader: imageLoader, onFocusUser: onFocusUser) - layer?.backgroundColor = nil + isHovered = false + updateBackground() } private func configureThread(summary: MatrixRustSDK.ThreadSummary?, onClick: (() -> Void)?) { @@ -314,12 +326,31 @@ final class MessageRowView: NSView { } } + override func mouseDown(with event: NSEvent) { + onSelectRequest?(self) + super.mouseDown(with: event) + } + override func mouseExited(with event: NSEvent) { setHoverHighlight(onHoverChange?(self, false, event) ?? false) } func setHoverHighlight(_ highlighted: Bool) { - layer?.backgroundColor = highlighted ? .init(gray: 0.5, alpha: 0.1) : nil + isHovered = highlighted + updateBackground() + } + + func setSelected(_ selected: Bool) { + isMessageSelected = selected + updateBackground() + } + + private func updateBackground() { + if isMessageSelected { + layer?.backgroundColor = NSColor.controlAccentColor.withAlphaComponent(0.1).cgColor + } else { + layer?.backgroundColor = isHovered ? .init(gray: 0.5, alpha: 0.1) : nil + } } func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat, replyDetails: MatrixRustSDK.EmbeddedEventDetails?, receiptCount: Int) -> CGFloat { diff --git a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift index 37be057..d2ab20c 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift @@ -4,6 +4,8 @@ import MessageFormatting /// Retains the NSTextView and its layout machinery when its table row is reused. final class MessageTextContentView: NSView { + var onTextMouseDown: (() -> Void)? + var onArrowKey: ((TimelineSelectionDirection) -> Void)? private let bodyText = OcclusionAwareTextView(frame: .zero) override init(frame frameRect: NSRect) { @@ -19,6 +21,8 @@ final class MessageTextContentView: NSView { bodyText.isHorizontallyResizable = false bodyText.isVerticallyResizable = true bodyText.translatesAutoresizingMaskIntoConstraints = false + bodyText.onMouseDown = { [weak self] in self?.onTextMouseDown?() } + bodyText.onArrowKey = { [weak self] direction in self?.onArrowKey?(direction) } addSubview(bodyText) NSLayoutConstraint.activate([ @@ -94,6 +98,22 @@ final class MessageTextContentView: NSView { /// Lets a view drawn above the text own the cursor without knowing what that view is. private final class OcclusionAwareTextView: NSTextView { + var onMouseDown: (() -> Void)? + var onArrowKey: ((TimelineSelectionDirection) -> Void)? + + override func keyDown(with event: NSEvent) { + if let direction = TimelineSelectionDirection(event: event), let onArrowKey { + onArrowKey(direction) + } else { + super.keyDown(with: event) + } + } + + override func mouseDown(with event: NSEvent) { + onMouseDown?() + super.mouseDown(with: event) + } + override func cursorUpdate(with event: NSEvent) { guard shouldHandlePointerEvent(event) else { return } super.cursorUpdate(with: event) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index a913414..b75bfb0 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -5,6 +5,22 @@ import OSLog import SwiftUI import UI +enum TimelineSelectionDirection { + case up + case down + + init?(event: NSEvent) { + guard event.modifierFlags.intersection([.command, .option, .control, .shift]).isEmpty else { return nil } + switch event.keyCode { + case 126: self = .up + case 125: self = .down + default: return nil + } + } + + var rowStep: Int { self == .up ? 1 : -1 } +} + enum TimelineItemRowInfo { case profile(item: TimelineItem, event: MatrixRustSDK.EventTimelineItem) case message(item: TimelineItem, event: MatrixRustSDK.EventTimelineItem, content: MatrixRustSDK.MsgLikeContent) @@ -12,6 +28,7 @@ enum TimelineItemRowInfo { case virtual(item: TimelineItem, virtual: MatrixRustSDK.VirtualTimelineItem) case typingIndicator + @MainActor var reuseIdentifier: NSUserInterfaceItemIdentifier { switch self { case .profile(profile: _): @@ -88,7 +105,7 @@ struct TimelineItemRowView: View { } } -class TimelineViewController: NSViewController { +class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { let coordinator: TimelineViewRepresentable.Coordinator private var dataSource: NSTableViewDiffableDataSource? @@ -99,6 +116,7 @@ class TimelineViewController: NSViewController { private weak var hoveredMessageView: MessageRowView? private var hoveredMessageId: String? private var hoveredRowIndex: Int? + private var updatingTimelineItems = false let timeline: LiveTimeline var timelineItems: [TimelineItemRowInfo] = [] @@ -117,6 +135,8 @@ class TimelineViewController: NSViewController { tableView.headerView = nil tableView.style = .plain tableView.allowsColumnSelection = false + tableView.allowsMultipleSelection = false + tableView.allowsEmptySelection = true tableView.selectionHighlightStyle = .none // Every row is sized by tableView(_:heightOfRow:). Automatic heights can @@ -127,6 +147,9 @@ class TimelineViewController: NSViewController { tableView.onLayout = { [weak self] in self?.positionHoverOverlay() } + tableView.onArrowKey = { [weak self] direction in + self?.moveSelection(direction) + } oldWidth = tableView.frame.width @@ -175,6 +198,12 @@ class TimelineViewController: NSViewController { view.onHoverChange = { [weak self] rowView, hovering, event in self?.updateHoverOverlay(for: rowView, hovering: hovering, event: event) ?? false } + view.onSelectRequest = { [weak self] rowView in + self?.selectMessageRow(for: rowView) + } + view.onArrowKey = { [weak self] direction in + self?.moveSelection(direction) + } view.configure( event: event, content: content, @@ -193,6 +222,7 @@ class TimelineViewController: NSViewController { self?.coordinator.windowState.focusUser(userId: userId) } ) + view.setSelected(tableView.selectedRow == row) view.identifier = item.reuseIdentifier return view default: @@ -250,7 +280,7 @@ class TimelineViewController: NSViewController { object: scrollView.contentView ) - listenForFocusTimelineItem() + timeline.focusDelegate = self listenForTypingUsers() listenForReplyDetails() } @@ -338,7 +368,8 @@ class TimelineViewController: NSViewController { let hoveredMessageId, row < timelineItems.count, timelineItems[row].id == hoveredMessageId, - let rowView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView else { + let rowView = tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView + else { hideHoverOverlay() return } @@ -478,28 +509,60 @@ class TimelineViewController: NSViewController { } } - func listenForFocusTimelineItem() { - Logger.timelineTableView.debug("Listen for focus timeline item") - - let focusedTimelineEventId = withObservationTracking { - timeline.focusedTimelineEventId - } onChange: { [weak self] in - Task { @MainActor in self?.listenForFocusTimelineItem() } - } - - guard let focusedTimelineEventId, - let rowIndex = timelineItems.firstIndex(where: { item in + func focusTimelineEvent(id eventId: MatrixRustSDK.EventOrTransactionId) { + guard let rowIndex = timelineItems.firstIndex(where: { item in switch item { case .message(item: _, event: let event, content: _): - return event.eventOrTransactionId == focusedTimelineEventId + return event.eventOrTransactionId == eventId default: return false } }) else { return } + tableView.selectRowIndexes(IndexSet(integer: rowIndex), byExtendingSelection: false) + updateSelectedMessage() tableView.animateRowToVisible(rowIndex) } + private func updateSelectedMessage() { + let selectedRow = tableView.selectedRow + + let visibleRows = tableView.rows(in: tableView.visibleRect) + for row in visibleRows.lowerBound ..< visibleRows.upperBound { + (tableView.view(atColumn: 0, row: row, makeIfNecessary: false) as? MessageRowView)? + .setSelected(row == selectedRow) + } + } + + private func selectMessageRow(for rowView: MessageRowView) { + let point = tableView.convert(NSPoint(x: rowView.bounds.midX, y: rowView.bounds.midY), from: rowView) + let row = tableView.row(at: point) + guard timelineItems.indices.contains(row), case .message = timelineItems[row] else { return } + tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + updateSelectedMessage() + } + + private func moveSelection(_ direction: TimelineSelectionDirection) { + let step = direction.rowStep + let visibleRows = tableView.rows(in: tableView.visibleRect) + var row = tableView.selectedRow + if row < 0 { + row = direction == .up ? visibleRows.lowerBound - 1 : visibleRows.upperBound + } + + row += step + while timelineItems.indices.contains(row) { + if case .message = timelineItems[row] { + tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + updateSelectedMessage() + tableView.animateRowToVisible(row) + tableView.window?.makeFirstResponder(tableView) + return + } + row += step + } + } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not available") @@ -514,6 +577,8 @@ class TimelineViewController: NSViewController { Logger.timelineTableView.info("update timeline items") let oldItems = self.timelineItems + let selectedId = oldItems.indices.contains(tableView.selectedRow) + ? oldItems[tableView.selectedRow].id : nil let oldIds = oldItems.map { $0.id } self.timelineItems = mapTimelineItems(items: timelineItems) let newIds = self.timelineItems.map { $0.id } @@ -544,7 +609,16 @@ class TimelineViewController: NSViewController { snapshot.appendItems([.init(id: item.id)], toSection: .main) } + updatingTimelineItems = true dataSource?.apply(snapshot, animatingDifferences: false) + if let selectedId, + let row = self.timelineItems.firstIndex(where: { $0.id == selectedId }) { + tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } else { + tableView.deselectAll(nil) + } + updatingTimelineItems = false + updateSelectedMessage() // Re-measure visible rows after hosting views settle DispatchQueue.main.async { [weak self] in @@ -599,13 +673,20 @@ class TimelineViewController: NSViewController { extension TimelineViewController: NSTableViewDelegate { func selectionShouldChange(in tableView: NSTableView) -> Bool { - return false + return true } func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + guard timelineItems.indices.contains(row) else { return false } + if case .message = timelineItems[row] { return true } return false } + func tableViewSelectionDidChange(_ notification: Notification) { + guard !updatingTimelineItems else { return } + updateSelectedMessage() + } + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { let item = timelineItems[row] @@ -649,6 +730,20 @@ extension TimelineViewController: NSTableViewDelegate { class BottomStickyTableView: NSTableView { var onLayout: (() -> Void)? + var onArrowKey: ((TimelineSelectionDirection) -> Void)? + + override func keyDown(with event: NSEvent) { + if let direction = TimelineSelectionDirection(event: event) { + onArrowKey?(direction) + } else { + super.keyDown(with: event) + } + } + + override func mouseDown(with event: NSEvent) { + guard row(at: convert(event.locationInWindow, from: nil)) >= 0 else { return } + super.mouseDown(with: event) + } override func layout() { super.layout() From 15fe23f65f3ce4ffbca999f5adfbeb5949dbc97c Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 28 Sep 2026 11:35:43 +0200 Subject: [PATCH 16/23] Move over other message types to AppKit --- .../TimelineView/MessageTextContentView.swift | 58 +++++++++++++++---- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift index d2ab20c..2b6d719 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift @@ -34,10 +34,14 @@ final class MessageTextContentView: NSView { } static func supports(content: MsgLikeContent) -> Bool { - guard case let .message(message) = content.kind else { return false } - switch message.msgType { - case .text, .notice: return true - default: return false + switch content.kind { + case let .message(message): + switch message.msgType { + case .image, .video, .file: return false + default: return true + } + default: + return true } } @@ -60,15 +64,45 @@ final class MessageTextContentView: NSView { private static func attributedBody(for content: MsgLikeContent) -> NSAttributedString { let fontSize = UserDefaults.standard.object(forKey: "fontSize") as? Int ?? 13 let font = NSFont.systemFont(ofSize: CGFloat(fontSize)) - guard case let .message(message) = content.kind else { return NSAttributedString() } + func plain(_ text: String, color: NSColor = .labelColor, italic: Bool = false) -> NSAttributedString { + NSAttributedString(string: text, attributes: [ + .font: italic ? NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask) : font, + .foregroundColor: color, + ]) + } - switch message.msgType { - case let .text(text): - return attributedText(for: text, font: font, color: .labelColor) - case let .notice(notice): - return attributedText(for: notice, font: font, color: .secondaryLabelColor) - default: - return NSAttributedString() + switch content.kind { + case let .message(message): + switch message.msgType { + case let .text(text): + return attributedText(for: text, font: font, color: .labelColor) + case let .notice(notice): + return attributedText(for: notice, font: font, color: .secondaryLabelColor) + case let .emote(emote): + return plain("Emote: \(emote.body)") + case let .audio(audio): + return plain("Audio: \(audio.caption ?? "no caption") \(audio.filename)") + case let .gallery(gallery): + return plain("Gallery: \(gallery.body)") + case let .location(location): + return plain("Location: \(location.body) \(location.geoUri)") + case let .other(msgtype: msgtype, body: body): + return plain("Other: \(msgtype) \(body)") + case .image, .video, .file: + return NSAttributedString() + } + case let .sticker(body: body, info: _, source: _): + return plain("Sticker: \(body)") + case let .poll(question: question, kind: _, maxSelections: _, answers: _, votes: _, endTime: _, hasBeenEdited: _): + return plain("Poll: \(question)") + case .redacted: + return plain("Message redacted", color: .secondaryLabelColor, italic: true) + case .unableToDecrypt: + return plain("Unable to decrypt", color: .secondaryLabelColor, italic: true) + case let .other(eventType: eventType): + return plain("Custom event: \(eventType.description)") + case let .liveLocation(content: location): + return plain("Live location: \(location.description ?? "no description")") } } From f48bdc66ea7b20c2c5786154d1898cf0d10af16b Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 28 Sep 2026 11:40:04 +0200 Subject: [PATCH 17/23] Prepare for different content message types --- .../TimelineView/MessageRowView.swift | 23 ++++++++++++------- .../TimelineView/MessageTextContentView.swift | 2 +- .../TimelineView/TimelineTableView.swift | 4 ++-- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift index 49d3350..bc2a8cd 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift @@ -18,7 +18,7 @@ final class MessageRowView: NSView { private static let receiptSpacing: CGFloat = 10 private let timestamp = NSTextField(labelWithString: "") - private let contentView = MessageTextContentView() + private let contentView: any MessageContentRowView private var replyPreview: MessageReplyPreviewView? private var replyDetails: MatrixRustSDK.EmbeddedEventDetails? private var replyHeightConstraint: NSLayoutConstraint? @@ -50,7 +50,12 @@ final class MessageRowView: NSView { return formatter }() - override init(frame frameRect: NSRect) { + override convenience init(frame frameRect: NSRect) { + self.init(contentView: MessageTextContentView(), frame: frameRect) + } + + init(contentView: any MessageContentRowView, frame frameRect: NSRect = .zero) { + self.contentView = contentView super.init(frame: frameRect) timestamp.font = .systemFont(ofSize: NSFont.labelFontSize) @@ -58,12 +63,14 @@ final class MessageRowView: NSView { timestamp.alignment = .right timestamp.translatesAutoresizingMaskIntoConstraints = false contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.onTextMouseDown = { [weak self] in - guard let self else { return } - self.onSelectRequest?(self) - } - contentView.onArrowKey = { [weak self] direction in - self?.onArrowKey?(direction) + if let textContent = contentView as? MessageTextContentView { + textContent.onTextMouseDown = { [weak self] in + guard let self else { return } + self.onSelectRequest?(self) + } + textContent.onArrowKey = { [weak self] direction in + self?.onArrowKey?(direction) + } } wantsLayer = true diff --git a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift index 2b6d719..93da4f0 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift @@ -3,7 +3,7 @@ import MatrixRustSDK import MessageFormatting /// Retains the NSTextView and its layout machinery when its table row is reused. -final class MessageTextContentView: NSView { +final class MessageTextContentView: NSView, MessageContentRowView { var onTextMouseDown: (() -> Void)? var onArrowKey: ((TimelineSelectionDirection) -> Void)? private let bodyText = OcclusionAwareTextView(frame: .zero) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index b75bfb0..88d5a04 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -194,7 +194,7 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { return view case .message(_, let event, let content) where MessageRowView.supports(event: event, content: content): let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) - as? MessageRowView ?? MessageRowView() + as? MessageRowView ?? MessageRowView(frame: .zero) view.onHoverChange = { [weak self] rowView, hovering, event in self?.updateHoverOverlay(for: rowView, hovering: hovering, event: event) ?? false } @@ -668,7 +668,7 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { return hostView }() - let measurementMessageView = MessageRowView() + let measurementMessageView = MessageRowView(frame: .zero) } extension TimelineViewController: NSTableViewDelegate { From 09269f9a216785a878d2f743a78a4e53dcfc8acc Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 28 Sep 2026 13:34:54 +0200 Subject: [PATCH 18/23] Implement image rows in AppKit --- .../TimelineView/MessageContentRowView.swift | 42 +++ .../MessageImageContentView.swift | 240 ++++++++++++++++++ .../TimelineView/MessageRowView.swift | 38 ++- .../TimelineView/MessageTextContentView.swift | 37 ++- .../TimelineView/TimelineTableView.swift | 37 ++- 5 files changed, 357 insertions(+), 37 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift new file mode 100644 index 0000000..371a467 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift @@ -0,0 +1,42 @@ +import AppKit +import MatrixRustSDK + +/// Content retained by a message row across NSTableView reuse. +protocol MessageContentRowView: NSView { + var onSelectRequest: (() -> Void)? { get set } + var onArrowKey: ((TimelineSelectionDirection) -> Void)? { get set } + func configure(content: MatrixRustSDK.MsgLikeContent, matrixClient: MatrixClient?) + func height(for content: MatrixRustSDK.MsgLikeContent, width: CGFloat) -> CGFloat +} + +/// Determines the reusable row and its retained content view for a message. +enum MessageContentKind: Hashable { + case text + case image + + init?(content: MatrixRustSDK.MsgLikeContent) { + if MessageImageContentView.supports(content: content) { + self = .image + } else if MessageTextContentView.supports(content: content) { + self = .text + } else { + return nil + } + } + + var reuseIdentifier: NSUserInterfaceItemIdentifier { + switch self { + case .text: .init("message.text") + case .image: .init("message.image") + } + } + + func makeRowView() -> MessageRowView { + let contentView: any MessageContentRowView + switch self { + case .text: contentView = MessageTextContentView() + case .image: contentView = MessageImageContentView() + } + return MessageRowView(contentKind: self, contentView: contentView) + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift new file mode 100644 index 0000000..11405ec --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift @@ -0,0 +1,240 @@ +import AppKit +import MatrixRustSDK +import OSLog +import QuickLookUI + +/// One image and its optional text caption. Both views survive table row reuse. +final class MessageImageContentView: NSView, MessageContentRowView { + var onSelectRequest: (() -> Void)? { + didSet { captionView.onSelectRequest = onSelectRequest } + } + var onArrowKey: ((TimelineSelectionDirection) -> Void)? { + didSet { captionView.onArrowKey = onArrowKey } + } + + private let imageButton = NSButton() + private let spinner = NSProgressIndicator() + private let errorLabel = NSTextField(labelWithString: "") + private let captionView = MessageTextContentView() + private var imageWidthConstraint: NSLayoutConstraint! + private var imageHeightConstraint: NSLayoutConstraint! + private var imageBottomConstraint: NSLayoutConstraint! + private var captionBottomConstraint: NSLayoutConstraint! + private var sourceURL: String? + private var imageContent: ImageMessageContent? + private weak var matrixClient: MatrixClient? + private var loadTask: Task? + private var previewTask: Task? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + imageButton.isBordered = false + imageButton.imagePosition = .imageOnly + imageButton.imageScaling = .scaleProportionallyDown + imageButton.target = self + imageButton.action = #selector(previewImage) + imageButton.wantsLayer = true + imageButton.layer?.cornerRadius = 6 + imageButton.layer?.masksToBounds = true + + spinner.style = .spinning + spinner.controlSize = .small + spinner.isDisplayedWhenStopped = false + errorLabel.textColor = .systemRed + errorLabel.alignment = .center + errorLabel.isSelectable = true + errorLabel.usesSingleLineMode = false + errorLabel.lineBreakMode = .byWordWrapping + errorLabel.maximumNumberOfLines = 0 + errorLabel.isHidden = true + + for view in [imageButton, spinner, errorLabel, captionView] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + + imageWidthConstraint = imageButton.widthAnchor.constraint(equalToConstant: 300) + imageHeightConstraint = imageButton.heightAnchor.constraint(equalToConstant: 300) + imageBottomConstraint = imageButton.bottomAnchor.constraint(equalTo: bottomAnchor) + captionBottomConstraint = captionView.bottomAnchor.constraint(equalTo: bottomAnchor) + imageBottomConstraint.priority = .init(999) + captionBottomConstraint.priority = .init(999) + NSLayoutConstraint.activate([ + imageButton.leadingAnchor.constraint(equalTo: leadingAnchor), + imageButton.topAnchor.constraint(equalTo: topAnchor), + imageWidthConstraint, + imageHeightConstraint, + spinner.centerXAnchor.constraint(equalTo: imageButton.centerXAnchor), + spinner.centerYAnchor.constraint(equalTo: imageButton.centerYAnchor), + errorLabel.leadingAnchor.constraint(equalTo: imageButton.leadingAnchor), + errorLabel.trailingAnchor.constraint(equalTo: imageButton.trailingAnchor), + errorLabel.centerYAnchor.constraint(equalTo: imageButton.centerYAnchor), + captionView.leadingAnchor.constraint(equalTo: leadingAnchor), + captionView.trailingAnchor.constraint(equalTo: trailingAnchor), + captionView.topAnchor.constraint(equalTo: imageButton.bottomAnchor, constant: 10), + ]) + } + + static func supports(content: MsgLikeContent) -> Bool { + guard case let .message(message) = content.kind else { return false } + if case .image = message.msgType { return true } + return false + } + + func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { + guard case let .message(message) = content.kind, + case let .image(image) = message.msgType else { return } + + self.imageContent = image + self.matrixClient = matrixClient + let hasCaption = image.caption?.isEmpty == false || image.formattedCaption != nil + captionView.isHidden = !hasCaption + captionView.configureCaption(image.caption, formatted: image.formattedCaption) + imageBottomConstraint.isActive = !hasCaption + captionBottomConstraint.isActive = hasCaption + updateImageSize(for: image, width: bounds.width) + + let url = image.source.url() + guard url != sourceURL else { return } + loadTask?.cancel() + previewTask?.cancel() + loadTask = nil + previewTask = nil + sourceURL = url + errorLabel.isHidden = true + imageButton.toolTip = image.filename + imageButton.image = MatrixClient.imageCache.object(forKey: NSString(string: url)) + imageButton.isEnabled = imageButton.image != nil + if imageButton.image == nil { + spinner.startAnimation(nil) + loadImage(image, from: matrixClient) + } else { + spinner.stopAnimation(nil) + } + } + + override func layout() { + super.layout() + guard let imageContent else { return } + updateImageSize(for: imageContent, width: bounds.width) + } + + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + guard case let .message(message) = content.kind, + case let .image(image) = message.msgType else { return 0 } + let imageHeight = Self.imageSize(for: image, width: width).height + guard image.caption?.isEmpty == false || image.formattedCaption != nil else { return imageHeight } + return imageHeight + 10 + ceil(captionView.height(forCaption: image.caption, formatted: image.formattedCaption, width: width)) + } + + private func updateImageSize(for image: ImageMessageContent, width: CGFloat) { + let size = Self.imageSize(for: image, width: max(width, 1)) + imageWidthConstraint.constant = size.width + imageHeightConstraint.constant = size.height + } + + private static func imageSize(for image: ImageMessageContent, width: CGFloat) -> CGSize { + guard let info = image.info, let pixelWidth = info.width, let pixelHeight = info.height, + pixelWidth > 0, pixelHeight > 0 else { + let side = min(width, 300) + return CGSize(width: side, height: side) + } + let ratio = CGFloat(pixelWidth) / CGFloat(pixelHeight) + let imageWidth = min(width, CGFloat(pixelWidth), 300 * ratio) + return CGSize(width: imageWidth, height: imageWidth / ratio) + } + + private func loadImage(_ image: ImageMessageContent, from client: MatrixClient?) { + guard let client else { + showError("Matrix client not available") + return + } + let url = image.source.url() + loadTask = Task { [weak self] in + do { + let data = try await client.client.getMediaContent(mediaSource: image.source) + try Task.checkCancellation() + let cacheKey = NSString(string: url) + let decoded = try MatrixClient.imageCache.object(forKey: cacheKey) + ?? data.toOrientedImage(contentType: data.computeMimeType()) + try Task.checkCancellation() + MatrixClient.setCachedImage(decoded, forKey: cacheKey) + guard let self, self.sourceURL == url else { return } + self.imageButton.image = decoded + self.imageButton.isEnabled = true + self.spinner.stopAnimation(nil) + self.loadTask = nil + } catch is CancellationError { + return + } catch { + guard let self, self.sourceURL == url else { return } + self.showError(error.localizedDescription) + self.loadTask = nil + } + } + } + + private func showError(_ message: String) { + spinner.stopAnimation(nil) + errorLabel.stringValue = message + errorLabel.isHidden = false + } + + @objc private func previewImage() { + guard let imageContent, let matrixClient else { return } + let url = imageContent.source.url() + previewTask?.cancel() + previewTask = Task { [weak self] in + do { + let handle = try await matrixClient.client.getMediaFile( + mediaSource: imageContent.source, + filename: imageContent.filename, + mimeType: imageContent.info?.mimetype ?? "", + useCache: true, + tempDir: NSTemporaryDirectory() + ) + try Task.checkCancellation() + let path = try handle.path() + guard let self, self.sourceURL == url else { return } + ImageQuickLookPreview.shared.show(handle: handle, url: URL(filePath: path, directoryHint: .notDirectory)) + } catch is CancellationError { + return + } catch { + Logger.viewCycle.error("failed to preview image: \(error)") + } + } + } + + deinit { + loadTask?.cancel() + previewTask?.cancel() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +/// Keeps the temporary media file alive while Quick Look is displaying it. +@MainActor private final class ImageQuickLookPreview: NSObject, @preconcurrency QLPreviewPanelDataSource { + static let shared = ImageQuickLookPreview() + private var handle: MediaFileHandle? + private var url: URL? + + func show(handle: MediaFileHandle, url: URL) { + self.handle = handle + self.url = url + guard let panel = QLPreviewPanel.shared() else { return } + panel.dataSource = self + panel.reloadData() + panel.makeKeyAndOrderFront(nil) + } + + func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int { url == nil ? 0 : 1 } + + func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! { + url as NSURL? + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift index bc2a8cd..43ca2fa 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageRowView.swift @@ -5,6 +5,7 @@ import UI /// Shared row chrome. The content view is created once and retained across table reuse. final class MessageRowView: NSView { + let contentKind: MessageContentKind var onHoverChange: ((MessageRowView, Bool, NSEvent) -> Bool)? var onSelectRequest: ((MessageRowView) -> Void)? var onArrowKey: ((TimelineSelectionDirection) -> Void)? @@ -22,13 +23,16 @@ final class MessageRowView: NSView { private var replyPreview: MessageReplyPreviewView? private var replyDetails: MatrixRustSDK.EmbeddedEventDetails? private var replyHeightConstraint: NSLayoutConstraint? + private var contentTopToReply: NSLayoutConstraint? private lazy var contentTopToRow = contentView.topAnchor.constraint(equalTo: topAnchor, constant: 4) + private lazy var contentBottomToRow = contentView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + private var threadSummaryView: MessageThreadSummaryView? private var threadHeightConstraint: NSLayoutConstraint? private var threadTopConstraint: NSLayoutConstraint? private var threadBottomConstraint: NSLayoutConstraint? - private lazy var contentBottomToRow = contentView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -4) + private var reactionsView: MessageReactionsView? private var reactionsHeightConstraint: NSLayoutConstraint? private var reactionsTopToContent: NSLayoutConstraint? @@ -37,6 +41,7 @@ final class MessageRowView: NSView { private var reactionsTrailingToContent: NSLayoutConstraint? private var reactionsTrailingToReceipts: NSLayoutConstraint? private var reactions: [MatrixRustSDK.Reaction] = [] + private var receiptsView: MessageReadReceiptsView? private var receiptsWidthConstraint: NSLayoutConstraint? private var receiptsTopToContent: NSLayoutConstraint? @@ -50,11 +55,8 @@ final class MessageRowView: NSView { return formatter }() - override convenience init(frame frameRect: NSRect) { - self.init(contentView: MessageTextContentView(), frame: frameRect) - } - - init(contentView: any MessageContentRowView, frame frameRect: NSRect = .zero) { + init(contentKind: MessageContentKind, contentView: any MessageContentRowView, frame frameRect: NSRect = .zero) { + self.contentKind = contentKind self.contentView = contentView super.init(frame: frameRect) @@ -63,14 +65,12 @@ final class MessageRowView: NSView { timestamp.alignment = .right timestamp.translatesAutoresizingMaskIntoConstraints = false contentView.translatesAutoresizingMaskIntoConstraints = false - if let textContent = contentView as? MessageTextContentView { - textContent.onTextMouseDown = { [weak self] in - guard let self else { return } - self.onSelectRequest?(self) - } - textContent.onArrowKey = { [weak self] direction in - self?.onArrowKey?(direction) - } + contentView.onSelectRequest = { [weak self] in + guard let self else { return } + self.onSelectRequest?(self) + } + contentView.onArrowKey = { [weak self] direction in + self?.onArrowKey?(direction) } wantsLayer = true @@ -98,10 +98,6 @@ final class MessageRowView: NSView { ]) } - static func supports(event: MatrixRustSDK.EventTimelineItem, content: MatrixRustSDK.MsgLikeContent) -> Bool { - MessageTextContentView.supports(content: content) - } - func configure( event: MatrixRustSDK.EventTimelineItem, content: MatrixRustSDK.MsgLikeContent, @@ -111,7 +107,7 @@ final class MessageRowView: NSView { ownUserId: String?, onReactionClick: ((String) -> Void)?, roomMembers: [MatrixRustSDK.RoomMember], - imageLoader: UI.ImageLoader?, + matrixClient: MatrixClient?, onFocusUser: ((String) -> Void)? ) { // A reused row may still have its previous table height while its content changes. @@ -122,11 +118,11 @@ final class MessageRowView: NSView { let date = Date(timeIntervalSince1970: Double(event.timestamp) / 1000) timestamp.stringValue = Self.timeFormatter.string(from: date) - contentView.configure(content: content) + contentView.configure(content: content, matrixClient: matrixClient) configureReply(details: replyDetails, onClick: onReplyClick) configureThread(summary: content.threadSummary, onClick: onThreadClick) configureReactions(content.reactions, ownUserId: ownUserId, onClick: onReactionClick) - configureReceipts(event.userReadReceipts, roomMembers: roomMembers, imageLoader: imageLoader, onFocusUser: onFocusUser) + configureReceipts(event.userReadReceipts, roomMembers: roomMembers, imageLoader: matrixClient, onFocusUser: onFocusUser) isHovered = false updateBackground() } diff --git a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift index 93da4f0..ad63c95 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift @@ -4,7 +4,7 @@ import MessageFormatting /// Retains the NSTextView and its layout machinery when its table row is reused. final class MessageTextContentView: NSView, MessageContentRowView { - var onTextMouseDown: (() -> Void)? + var onSelectRequest: (() -> Void)? var onArrowKey: ((TimelineSelectionDirection) -> Void)? private let bodyText = OcclusionAwareTextView(frame: .zero) @@ -21,7 +21,7 @@ final class MessageTextContentView: NSView, MessageContentRowView { bodyText.isHorizontallyResizable = false bodyText.isVerticallyResizable = true bodyText.translatesAutoresizingMaskIntoConstraints = false - bodyText.onMouseDown = { [weak self] in self?.onTextMouseDown?() } + bodyText.onMouseDown = { [weak self] in self?.onSelectRequest?() } bodyText.onArrowKey = { [weak self] direction in self?.onArrowKey?(direction) } addSubview(bodyText) @@ -45,22 +45,51 @@ final class MessageTextContentView: NSView, MessageContentRowView { } } - func configure(content: MsgLikeContent) { + func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { bodyText.textStorage?.setAttributedString(Self.attributedBody(for: content)) } + func configureCaption(_ caption: String?, formatted: FormattedBody?) { + bodyText.textStorage?.setAttributedString(Self.attributedCaption(caption, formatted: formatted)) + } + + func height(forCaption caption: String?, formatted: FormattedBody?, width: CGFloat) -> CGFloat { + measure(Self.attributedCaption(caption, formatted: formatted), width: width) + } + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + measure(Self.attributedBody(for: content), width: width) + } + + private func measure(_ text: NSAttributedString, width: CGFloat) -> CGFloat { guard let textStorage = bodyText.textStorage, let layoutManager = bodyText.layoutManager, let textContainer = bodyText.textContainer else { return 20 } textContainer.widthTracksTextView = false textContainer.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude) - textStorage.setAttributedString(Self.attributedBody(for: content)) + textStorage.setAttributedString(text) layoutManager.ensureLayout(for: textContainer) return layoutManager.usedRect(for: textContainer).height } + private static func attributedCaption(_ caption: String?, formatted: FormattedBody?) -> NSAttributedString { + let fontSize = UserDefaults.standard.object(forKey: "fontSize") as? Int ?? 13 + if let formatted, formatted.format == .html { + return parseFormattedBody(formatted.body, baseFontSize: CGFloat(fontSize)) + } + let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) + let attributed = (try? AttributedString(markdown: caption ?? "", options: options)) ?? AttributedString(caption ?? "") + let result = NSMutableAttributedString(attributedString: NSAttributedString(attributed)) + let fullRange = NSRange(location: 0, length: result.length) + result.enumerateAttribute(.font, in: fullRange) { font, range, _ in + if font == nil { + result.addAttribute(.font, value: NSFont.systemFont(ofSize: CGFloat(fontSize)), range: range) + } + } + return result + } + private static func attributedBody(for content: MsgLikeContent) -> NSAttributedString { let fontSize = UserDefaults.standard.object(forKey: "fontSize") as? Int ?? 13 let font = NSFont.systemFont(ofSize: CGFloat(fontSize)) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 88d5a04..6258676 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -33,10 +33,8 @@ enum TimelineItemRowInfo { switch self { case .profile(profile: _): return NSUserInterfaceItemIdentifier("profile") - case .message(_, let event, let content): - return NSUserInterfaceItemIdentifier( - MessageRowView.supports(event: event, content: content) ? "message.text" : "swiftui-view" - ) + case .message(_, _, let content): + return MessageContentKind(content: content)?.reuseIdentifier ?? .init("swiftui-view") case .state: return NSUserInterfaceItemIdentifier("state") case .virtual: @@ -192,9 +190,17 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { view.configure(event: event) view.identifier = item.reuseIdentifier return view - case .message(_, let event, let content) where MessageRowView.supports(event: event, content: content): - let view = tableView.makeView(withIdentifier: item.reuseIdentifier, owner: self) - as? MessageRowView ?? MessageRowView(frame: .zero) + case .message(_, let event, let content) where MessageContentKind(content: content) != nil: + guard let kind = MessageContentKind(content: content) else { + preconditionFailure("Message content kind changed while creating its row") + } + let recycled = tableView.makeView(withIdentifier: kind.reuseIdentifier, owner: self) as? MessageRowView + let view: MessageRowView + if let recycled, recycled.contentKind == kind { + view = recycled + } else { + view = kind.makeRowView() + } view.onHoverChange = { [weak self] rowView, hovering, event in self?.updateHoverOverlay(for: rowView, hovering: hovering, event: event) ?? false } @@ -217,13 +223,13 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { self?.toggleReaction(key, for: event) }, roomMembers: self.timeline.room.members, - imageLoader: self.coordinator.appState.matrixClient, + matrixClient: self.coordinator.appState.matrixClient, onFocusUser: { [weak self] userId in self?.coordinator.windowState.focusUser(userId: userId) } ) view.setSelected(tableView.selectedRow == row) - view.identifier = item.reuseIdentifier + view.identifier = kind.reuseIdentifier return view default: let view = TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator) @@ -668,7 +674,14 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { return hostView }() - let measurementMessageView = MessageRowView(frame: .zero) + private var measurementMessageViews: [MessageContentKind: MessageRowView] = [:] + + private func measurementView(for kind: MessageContentKind) -> MessageRowView { + if let view = measurementMessageViews[kind] { return view } + let view = kind.makeRowView() + measurementMessageViews[kind] = view + return view + } } extension TimelineViewController: NSTableViewDelegate { @@ -707,9 +720,9 @@ extension TimelineViewController: NSTableViewDelegate { } if case .message(_, let event, let content) = item, - MessageRowView.supports(event: event, content: content) + let kind = MessageContentKind(content: content) { - return measurementMessageView.height( + return measurementView(for: kind).height( for: content, width: tableView.tableColumns[0].width, replyDetails: replyDetails(for: content), From d700675efdaf18659b25f21389c487f19dec10fb Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 28 Sep 2026 14:48:51 +0200 Subject: [PATCH 19/23] Implement video rows in AppKit --- .../TimelineView/MessageContentRowView.swift | 5 + .../MessageVideoContentView.swift | 280 ++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageVideoContentView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift index 371a467..b224fd9 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift @@ -13,10 +13,13 @@ protocol MessageContentRowView: NSView { enum MessageContentKind: Hashable { case text case image + case video init?(content: MatrixRustSDK.MsgLikeContent) { if MessageImageContentView.supports(content: content) { self = .image + } else if MessageVideoContentView.supports(content: content) { + self = .video } else if MessageTextContentView.supports(content: content) { self = .text } else { @@ -28,6 +31,7 @@ enum MessageContentKind: Hashable { switch self { case .text: .init("message.text") case .image: .init("message.image") + case .video: .init("message.video") } } @@ -36,6 +40,7 @@ enum MessageContentKind: Hashable { switch self { case .text: contentView = MessageTextContentView() case .image: contentView = MessageImageContentView() + case .video: contentView = MessageVideoContentView() } return MessageRowView(contentKind: self, contentView: contentView) } diff --git a/Mactrix/Views/ChatView/TimelineView/MessageVideoContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageVideoContentView.swift new file mode 100644 index 0000000..a85167e --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageVideoContentView.swift @@ -0,0 +1,280 @@ +import AppKit +import AVKit +import MatrixRustSDK +import OSLog + +/// A retained video player, thumbnail, and optional caption for a reusable message row. +final class MessageVideoContentView: NSView, MessageContentRowView { + var onSelectRequest: (() -> Void)? { + didSet { captionView.onSelectRequest = onSelectRequest } + } + + var onArrowKey: ((TimelineSelectionDirection) -> Void)? { + didSet { captionView.onArrowKey = onArrowKey } + } + + private let mediaView = NSView() + private let thumbnailView = NSImageView() + private let playButton = NSButton() + private var playerView: AVPlayerView? + private let spinner = NSProgressIndicator() + private let errorLabel = NSTextField(labelWithString: "") + private let captionView = MessageTextContentView() + private var mediaWidthConstraint: NSLayoutConstraint! + private var mediaHeightConstraint: NSLayoutConstraint! + private var mediaBottomConstraint: NSLayoutConstraint! + private var captionBottomConstraint: NSLayoutConstraint! + private var videoContent: VideoMessageContent? + private var sourceURL: String? + private weak var matrixClient: MatrixClient? + private var fileHandle: MediaFileHandle? + private var thumbnailTask: Task? + private var videoTask: Task? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + mediaView.wantsLayer = true + mediaView.layer?.backgroundColor = NSColor.darkGray.cgColor + mediaView.layer?.cornerRadius = 6 + mediaView.layer?.masksToBounds = true + thumbnailView.imageScaling = .scaleProportionallyUpOrDown + + playButton.isBordered = false + playButton.image = NSImage(systemSymbolName: "play.fill", accessibilityDescription: "Play video")? + .withSymbolConfiguration(.init(pointSize: 32, weight: .regular)) + playButton.contentTintColor = .white + playButton.target = self + playButton.action = #selector(playVideo) + playButton.toolTip = "Play video" + + spinner.style = .spinning + spinner.controlSize = .small + spinner.isDisplayedWhenStopped = false + errorLabel.textColor = .systemRed + errorLabel.alignment = .center + errorLabel.isSelectable = true + errorLabel.usesSingleLineMode = false + errorLabel.lineBreakMode = .byWordWrapping + errorLabel.maximumNumberOfLines = 0 + errorLabel.isHidden = true + + for view in [mediaView, captionView] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + for view in [thumbnailView, playButton, spinner, errorLabel] { + view.translatesAutoresizingMaskIntoConstraints = false + mediaView.addSubview(view) + } + + mediaWidthConstraint = mediaView.widthAnchor.constraint(equalToConstant: 300) + mediaHeightConstraint = mediaView.heightAnchor.constraint(equalToConstant: 300) + mediaBottomConstraint = mediaView.bottomAnchor.constraint(equalTo: bottomAnchor) + captionBottomConstraint = captionView.bottomAnchor.constraint(equalTo: bottomAnchor) + mediaBottomConstraint.priority = .init(999) + captionBottomConstraint.priority = .init(999) + NSLayoutConstraint.activate([ + mediaView.leadingAnchor.constraint(equalTo: leadingAnchor), + mediaView.topAnchor.constraint(equalTo: topAnchor), + mediaWidthConstraint, + mediaHeightConstraint, + thumbnailView.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), + thumbnailView.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), + thumbnailView.topAnchor.constraint(equalTo: mediaView.topAnchor), + thumbnailView.bottomAnchor.constraint(equalTo: mediaView.bottomAnchor), + playButton.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), + playButton.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), + playButton.topAnchor.constraint(equalTo: mediaView.topAnchor), + playButton.bottomAnchor.constraint(equalTo: mediaView.bottomAnchor), + spinner.centerXAnchor.constraint(equalTo: mediaView.centerXAnchor), + spinner.centerYAnchor.constraint(equalTo: mediaView.centerYAnchor), + errorLabel.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), + errorLabel.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), + errorLabel.centerYAnchor.constraint(equalTo: mediaView.centerYAnchor), + captionView.leadingAnchor.constraint(equalTo: leadingAnchor), + captionView.trailingAnchor.constraint(equalTo: trailingAnchor), + captionView.topAnchor.constraint(equalTo: mediaView.bottomAnchor, constant: 10), + ]) + } + + static func supports(content: MsgLikeContent) -> Bool { + guard case let .message(message) = content.kind else { return false } + if case .video = message.msgType { + return true + } + return false + } + + func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { + guard case let .message(message) = content.kind, + case let .video(video) = message.msgType else { return } + + videoContent = video + self.matrixClient = matrixClient + let hasCaption = video.caption?.isEmpty == false || video.formattedCaption != nil + captionView.isHidden = !hasCaption + captionView.configureCaption(video.caption, formatted: video.formattedCaption) + mediaBottomConstraint.isActive = !hasCaption + captionBottomConstraint.isActive = hasCaption + if bounds.width > 0 { + updateMediaSize(for: video, width: bounds.width) + } + + let url = video.source.url() + guard sourceURL != url else { return } + thumbnailTask?.cancel() + videoTask?.cancel() + thumbnailTask = nil + videoTask = nil + sourceURL = url + playerView?.player?.pause() + playerView?.player = nil + playerView?.isHidden = true + fileHandle = nil + spinner.stopAnimation(nil) + errorLabel.isHidden = true + playButton.isHidden = false + playButton.isEnabled = true + thumbnailView.image = nil + loadThumbnail(for: video, from: matrixClient) + } + + override func layout() { + if let videoContent, bounds.width > 0 { + updateMediaSize(for: videoContent, width: bounds.width) + } + super.layout() + } + + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + guard case let .message(message) = content.kind, + case let .video(video) = message.msgType else { return 0 } + let mediaHeight = Self.mediaSize(for: video, width: width).height + guard video.caption?.isEmpty == false || video.formattedCaption != nil else { return mediaHeight } + return mediaHeight + 10 + ceil(captionView.height(forCaption: video.caption, formatted: video.formattedCaption, width: width)) + } + + private func updateMediaSize(for video: VideoMessageContent, width: CGFloat) { + let size = Self.mediaSize(for: video, width: width) + if mediaWidthConstraint.constant != size.width { + mediaWidthConstraint.constant = size.width + } + if mediaHeightConstraint.constant != size.height { + mediaHeightConstraint.constant = size.height + } + } + + private static func mediaSize(for video: VideoMessageContent, width: CGFloat) -> CGSize { + guard let info = video.info, let pixelWidth = info.width, let pixelHeight = info.height, + pixelWidth > 0, pixelHeight > 0 + else { + let side = min(width, 300) + return CGSize(width: side, height: side) + } + let ratio = CGFloat(pixelWidth) / CGFloat(pixelHeight) + let mediaWidth = min(width, CGFloat(pixelWidth), 300 * ratio) + return CGSize(width: mediaWidth, height: mediaWidth / ratio) + } + + private func loadThumbnail(for video: VideoMessageContent, from client: MatrixClient?) { + guard let source = video.info?.thumbnailSource, let client else { return } + let url = video.source.url() + let cacheKey = NSString(string: source.url()) + if let cached = MatrixClient.imageCache.object(forKey: cacheKey) { + thumbnailView.image = cached + return + } + thumbnailTask = Task { [weak self] in + do { + let data = try await client.client.getMediaContent(mediaSource: source) + try Task.checkCancellation() + let image = try data.toOrientedImage(contentType: data.computeMimeType()) + try Task.checkCancellation() + MatrixClient.setCachedImage(image, forKey: cacheKey) + guard let self, self.sourceURL == url else { return } + self.thumbnailView.image = image + self.thumbnailTask = nil + } catch is CancellationError { + return + } catch { + guard let self, self.sourceURL == url else { return } + Logger.viewCycle.error("Failed to load video thumbnail: \(error)") + self.thumbnailTask = nil + } + } + } + + @objc private func playVideo() { + guard let videoContent, let matrixClient else { return } + let url = videoContent.source.url() + playButton.isEnabled = false + spinner.startAnimation(nil) + videoTask?.cancel() + videoTask = Task { [weak self] in + do { + let handle = try await matrixClient.client.getMediaFile( + mediaSource: videoContent.source, + filename: videoContent.filename, + mimeType: videoContent.info?.mimetype ?? "", + useCache: true, + tempDir: NSTemporaryDirectory() + ) + try Task.checkCancellation() + let path = try handle.path() + guard let self, self.sourceURL == url else { return } + self.fileHandle = handle + let player = AVPlayer(url: URL(filePath: path, directoryHint: .notDirectory)) + let playerView = self.preparePlayerView() + playerView.player = player + playerView.isHidden = false + self.playButton.isHidden = true + self.spinner.stopAnimation(nil) + self.videoTask = nil + player.play() + } catch is CancellationError { + return + } catch { + guard let self, self.sourceURL == url else { return } + self.spinner.stopAnimation(nil) + self.playButton.isEnabled = true + self.errorLabel.stringValue = error.localizedDescription + self.errorLabel.isHidden = false + self.videoTask = nil + } + } + } + + private func preparePlayerView() -> AVPlayerView { + if let playerView { + return playerView + } + + let view = AVPlayerView(frame: mediaView.bounds) + view.allowsPictureInPicturePlayback = true + view.showsFullScreenToggleButton = true + view.showsSharingServiceButton = true + view.translatesAutoresizingMaskIntoConstraints = false + mediaView.addSubview(view, positioned: .above, relativeTo: thumbnailView) + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: mediaView.leadingAnchor), + view.trailingAnchor.constraint(equalTo: mediaView.trailingAnchor), + view.topAnchor.constraint(equalTo: mediaView.topAnchor), + view.bottomAnchor.constraint(equalTo: mediaView.bottomAnchor), + ]) + playerView = view + return view + } + + @MainActor + deinit { + thumbnailTask?.cancel() + videoTask?.cancel() + playerView?.player?.pause() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} From c654d2d7881c8f91261b8b57ccca3019e99118c5 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 28 Sep 2026 14:59:21 +0200 Subject: [PATCH 20/23] Port file messages to AppKit --- .../TimelineView/MessageContentRowView.swift | 7 +- .../TimelineView/MessageFileContentView.swift | 194 ++++++++++++++++++ .../MessageImageContentView.swift | 6 +- 3 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/MessageFileContentView.swift diff --git a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift index b224fd9..9e091dc 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift @@ -10,16 +10,19 @@ protocol MessageContentRowView: NSView { } /// Determines the reusable row and its retained content view for a message. -enum MessageContentKind: Hashable { +@MainActor enum MessageContentKind: Hashable { case text case image case video + case file init?(content: MatrixRustSDK.MsgLikeContent) { if MessageImageContentView.supports(content: content) { self = .image } else if MessageVideoContentView.supports(content: content) { self = .video + } else if MessageFileContentView.supports(content: content) { + self = .file } else if MessageTextContentView.supports(content: content) { self = .text } else { @@ -32,6 +35,7 @@ enum MessageContentKind: Hashable { case .text: .init("message.text") case .image: .init("message.image") case .video: .init("message.video") + case .file: .init("message.file") } } @@ -41,6 +45,7 @@ enum MessageContentKind: Hashable { case .text: contentView = MessageTextContentView() case .image: contentView = MessageImageContentView() case .video: contentView = MessageVideoContentView() + case .file: contentView = MessageFileContentView() } return MessageRowView(contentKind: self, contentView: contentView) } diff --git a/Mactrix/Views/ChatView/TimelineView/MessageFileContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageFileContentView.swift new file mode 100644 index 0000000..9815cdb --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/MessageFileContentView.swift @@ -0,0 +1,194 @@ +import AppKit +import MatrixRustSDK +import OSLog +import UniformTypeIdentifiers + +final class MessageFileContentView: NSView, MessageContentRowView { + var onSelectRequest: (() -> Void)? { + didSet { captionView.onSelectRequest = onSelectRequest } + } + + var onArrowKey: ((TimelineSelectionDirection) -> Void)? { + didSet { captionView.onArrowKey = onArrowKey } + } + + private let fileButton = FilePreviewButton() + private let captionView = MessageTextContentView() + private var fileBottomConstraint: NSLayoutConstraint! + private var captionBottomConstraint: NSLayoutConstraint! + private var fileContent: FileMessageContent? + private weak var matrixClient: MatrixClient? + private var sourceURL: String? + private var previewTask: Task? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + fileButton.target = self + fileButton.action = #selector(previewFile) + + for view in [fileButton, captionView] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + + fileBottomConstraint = fileButton.bottomAnchor.constraint(equalTo: bottomAnchor) + captionBottomConstraint = captionView.bottomAnchor.constraint(equalTo: bottomAnchor) + fileBottomConstraint.priority = .init(999) + captionBottomConstraint.priority = .init(999) + NSLayoutConstraint.activate([ + fileButton.leadingAnchor.constraint(equalTo: leadingAnchor), + fileButton.trailingAnchor.constraint(equalTo: trailingAnchor), + fileButton.topAnchor.constraint(equalTo: topAnchor), + fileButton.heightAnchor.constraint(equalToConstant: 36), + captionView.leadingAnchor.constraint(equalTo: leadingAnchor), + captionView.trailingAnchor.constraint(equalTo: trailingAnchor), + captionView.topAnchor.constraint(equalTo: fileButton.bottomAnchor, constant: 10), + ]) + } + + static func supports(content: MsgLikeContent) -> Bool { + guard case let .message(message) = content.kind else { return false } + if case .file = message.msgType { + return true + } + return false + } + + func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { + guard case let .message(message) = content.kind, + case let .file(file) = message.msgType else { return } + + fileContent = file + self.matrixClient = matrixClient + let mimeType = file.info?.mimetype.flatMap(UTType.init) ?? .data + fileButton.configure( + icon: NSWorkspace.shared.icon(for: mimeType), + filename: file.filename, + size: file.info?.size?.formatted(.byteCount(style: .file)) + ) + + let hasCaption = file.caption?.isEmpty == false || file.formattedCaption != nil + captionView.isHidden = !hasCaption + captionView.configureCaption(file.caption, formatted: file.formattedCaption) + fileBottomConstraint.isActive = !hasCaption + captionBottomConstraint.isActive = hasCaption + + let url = file.source.url() + if sourceURL != url { + previewTask?.cancel() + previewTask = nil + sourceURL = url + fileButton.isEnabled = true + } + } + + func height(for content: MsgLikeContent, width: CGFloat) -> CGFloat { + guard case let .message(message) = content.kind, + case let .file(file) = message.msgType else { return 0 } + guard file.caption?.isEmpty == false || file.formattedCaption != nil else { return 36 } + return 36 + 10 + ceil(captionView.height(forCaption: file.caption, formatted: file.formattedCaption, width: width)) + } + + @objc private func previewFile() { + guard let fileContent, let matrixClient else { return } + let url = fileContent.source.url() + fileButton.isEnabled = false + previewTask?.cancel() + previewTask = Task { [weak self] in + do { + let handle = try await matrixClient.client.getMediaFile( + mediaSource: fileContent.source, + filename: fileContent.filename, + mimeType: fileContent.info?.mimetype ?? "", + useCache: true, + tempDir: NSTemporaryDirectory() + ) + try Task.checkCancellation() + let path = try handle.path() + guard let self, self.sourceURL == url else { return } + MediaQuickLookPreview.shared.show(handle: handle, url: URL(filePath: path, directoryHint: .notDirectory)) + self.fileButton.setIcon(NSWorkspace.shared.icon(forFile: path)) + self.fileButton.isEnabled = true + self.previewTask = nil + } catch is CancellationError { + return + } catch { + guard let self, self.sourceURL == url else { return } + Logger.viewCycle.error("failed to preview file: \(error)") + self.fileButton.isEnabled = true + self.previewTask = nil + } + } + } + + deinit { previewTask?.cancel() } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class FilePreviewButton: NSButton { + private let iconView = NSImageView() + private let filenameLabel = NSTextField(labelWithString: "") + private let sizeLabel = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + + isBordered = false + title = "" + iconView.imageScaling = .scaleProportionallyDown + filenameLabel.lineBreakMode = .byTruncatingMiddle + filenameLabel.maximumNumberOfLines = 1 + sizeLabel.textColor = .secondaryLabelColor + sizeLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize) + + for view in [iconView, filenameLabel, sizeLabel] { + view.translatesAutoresizingMaskIntoConstraints = false + addSubview(view) + } + NSLayoutConstraint.activate([ + iconView.leadingAnchor.constraint(equalTo: leadingAnchor), + iconView.topAnchor.constraint(equalTo: topAnchor), + iconView.widthAnchor.constraint(equalToConstant: 36), + iconView.heightAnchor.constraint(equalToConstant: 36), + filenameLabel.leadingAnchor.constraint(equalTo: iconView.trailingAnchor, constant: 8), + filenameLabel.trailingAnchor.constraint(equalTo: trailingAnchor), + filenameLabel.topAnchor.constraint(equalTo: topAnchor), + sizeLabel.leadingAnchor.constraint(equalTo: filenameLabel.leadingAnchor), + sizeLabel.trailingAnchor.constraint(equalTo: filenameLabel.trailingAnchor), + sizeLabel.topAnchor.constraint(equalTo: filenameLabel.bottomAnchor, constant: 2), + ]) + } + + func configure(icon: NSImage, filename: String, size: String?) { + iconView.image = icon + filenameLabel.stringValue = filename + sizeLabel.stringValue = size ?? "" + sizeLabel.isHidden = size == nil + toolTip = "Preview \(filename)" + } + + func setIcon(_ icon: NSImage) { + iconView.image = icon + } + + override func hitTest(_ point: NSPoint) -> NSView? { + bounds.contains(convert(point, from: superview)) ? self : nil + } + + override func mouseDown(with event: NSEvent) { + alphaValue = 0.6 + window?.displayIfNeeded() + super.mouseDown(with: event) + alphaValue = 1 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift index 11405ec..fe3ef13 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageImageContentView.swift @@ -197,7 +197,7 @@ final class MessageImageContentView: NSView, MessageContentRowView { try Task.checkCancellation() let path = try handle.path() guard let self, self.sourceURL == url else { return } - ImageQuickLookPreview.shared.show(handle: handle, url: URL(filePath: path, directoryHint: .notDirectory)) + MediaQuickLookPreview.shared.show(handle: handle, url: URL(filePath: path, directoryHint: .notDirectory)) } catch is CancellationError { return } catch { @@ -218,8 +218,8 @@ final class MessageImageContentView: NSView, MessageContentRowView { } /// Keeps the temporary media file alive while Quick Look is displaying it. -@MainActor private final class ImageQuickLookPreview: NSObject, @preconcurrency QLPreviewPanelDataSource { - static let shared = ImageQuickLookPreview() +@MainActor final class MediaQuickLookPreview: NSObject, @preconcurrency QLPreviewPanelDataSource { + static let shared = MediaQuickLookPreview() private var handle: MediaFileHandle? private var url: URL? From ca128d7965f81e791ebe18c932f2e421815a4565 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 28 Sep 2026 15:08:25 +0200 Subject: [PATCH 21/23] Clean up old SwiftUI code in timeline --- .../TimelineView/MessageContentRowView.swift | 6 +- .../TimelineView/MessageTextContentView.swift | 12 --- .../TimelineView/TimelineTableView.swift | 90 ++----------------- 3 files changed, 9 insertions(+), 99 deletions(-) diff --git a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift b/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift index 9e091dc..cb467a9 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageContentRowView.swift @@ -16,17 +16,15 @@ protocol MessageContentRowView: NSView { case video case file - init?(content: MatrixRustSDK.MsgLikeContent) { + init(content: MatrixRustSDK.MsgLikeContent) { if MessageImageContentView.supports(content: content) { self = .image } else if MessageVideoContentView.supports(content: content) { self = .video } else if MessageFileContentView.supports(content: content) { self = .file - } else if MessageTextContentView.supports(content: content) { - self = .text } else { - return nil + self = .text } } diff --git a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift index ad63c95..1a8e305 100644 --- a/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift +++ b/Mactrix/Views/ChatView/TimelineView/MessageTextContentView.swift @@ -33,18 +33,6 @@ final class MessageTextContentView: NSView, MessageContentRowView { ]) } - static func supports(content: MsgLikeContent) -> Bool { - switch content.kind { - case let .message(message): - switch message.msgType { - case .image, .video, .file: return false - default: return true - } - default: - return true - } - } - func configure(content: MsgLikeContent, matrixClient: MatrixClient?) { bodyText.textStorage?.setAttributedString(Self.attributedBody(for: content)) } diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 6258676..0bd3fd2 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -2,8 +2,6 @@ import AppKit import MatrixRustSDK import Models import OSLog -import SwiftUI -import UI enum TimelineSelectionDirection { case up @@ -34,7 +32,7 @@ enum TimelineItemRowInfo { case .profile(profile: _): return NSUserInterfaceItemIdentifier("profile") case .message(_, _, let content): - return MessageContentKind(content: content)?.reuseIdentifier ?? .init("swiftui-view") + return MessageContentKind(content: content).reuseIdentifier case .state: return NSUserInterfaceItemIdentifier("state") case .virtual: @@ -62,47 +60,6 @@ extension TimelineItemRowInfo: Identifiable { } } -struct TimelineItemRowView: View { - let rowInfo: TimelineItemRowInfo - let timeline: LiveTimeline? - - let appState: AppState - let windowState: WindowState - - init(rowInfo: TimelineItemRowInfo, timeline: LiveTimeline?, coordinator: TimelineViewRepresentable.Coordinator) { - self.rowInfo = rowInfo - self.timeline = timeline - self.appState = coordinator.appState - self.windowState = coordinator.windowState - } - - @ViewBuilder - var contentView: some View { - switch rowInfo { - case .profile: - Text("Profile rows implemented in AppKit now") - case .message(_, let event, let content): - ChatMessageView(timeline: timeline, event: event, msg: content, includeProfileHeader: false) - case .state(_, let event): - UI.GenericEventView(event: event, name: event.content.description) - case .virtual(_, let virtual): - UI.VirtualItemView(item: virtual.asModel) - case .typingIndicator: - Text("Typing indicator implemented in AppKit now") - } - } - - var body: some View { - HStack(alignment: .bottom, spacing: 0) { - VStack(spacing: 0) { - contentView - .environment(appState) - .environment(windowState) - } - } - } -} - class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { let coordinator: TimelineViewRepresentable.Coordinator @@ -190,10 +147,8 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { view.configure(event: event) view.identifier = item.reuseIdentifier return view - case .message(_, let event, let content) where MessageContentKind(content: content) != nil: - guard let kind = MessageContentKind(content: content) else { - preconditionFailure("Message content kind changed while creating its row") - } + case .message(_, let event, let content): + let kind = MessageContentKind(content: content) let recycled = tableView.makeView(withIdentifier: kind.reuseIdentifier, owner: self) as? MessageRowView let view: MessageRowView if let recycled, recycled.contentKind == kind { @@ -231,23 +186,6 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { view.setSelected(tableView.selectedRow == row) view.identifier = kind.reuseIdentifier return view - default: - let view = TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator) - let hostView: NSHostingView - if let recycledView = tableView.makeView(withIdentifier: .init("swiftui-view"), owner: self) - as? NSHostingView - { - recycledView.rootView = view - hostView = recycledView - } else { - hostView = NSHostingView(rootView: view) - hostView.identifier = .init("swiftui-view") - hostView.autoresizingMask = [.width, .height] - hostView.sizingOptions = [.preferredContentSize] - hostView.setContentHuggingPriority(.required, for: .vertical) - } - - return hostView } } @@ -626,7 +564,7 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { updatingTimelineItems = false updateSelectedMessage() - // Re-measure visible rows after hosting views settle + // Re-measure visible rows after the table applies the new snapshot. DispatchQueue.main.async { [weak self] in guard let self else { return } let visibleRows = tableView.rows(in: tableView.visibleRect) @@ -668,12 +606,6 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { // values used to track width changes var oldWidth: CGFloat? - let measurementHostingView = { - let hostView = NSHostingController(rootView: AnyView(EmptyView())) - hostView.sizingOptions = [.preferredContentSize] - return hostView - }() - private var measurementMessageViews: [MessageContentKind: MessageRowView] = [:] private func measurementView(for kind: MessageContentKind) -> MessageRowView { @@ -719,9 +651,8 @@ extension TimelineViewController: NSTableViewDelegate { return StateEventRowView.height(for: event, width: tableView.tableColumns[0].width) } - if case .message(_, let event, let content) = item, - let kind = MessageContentKind(content: content) - { + if case .message(_, let event, let content) = item { + let kind = MessageContentKind(content: content) return measurementView(for: kind).height( for: content, width: tableView.tableColumns[0].width, @@ -730,14 +661,7 @@ extension TimelineViewController: NSTableViewDelegate { ) } - measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item, timeline: timeline, coordinator: coordinator)) - - let targetWidth = tableView.tableColumns[0].width - let proposedSize = CGSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude) - - let size = measurementHostingView.sizeThatFits(in: proposedSize) - // Avoid undefined-height rows which can cause NSTableView layout issues - return max(size.height, 1) + preconditionFailure("Unsupported timeline row") } } From e256cc48ab3ac5d7a519a6442f7b5f0b7b24e09e Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 28 Sep 2026 15:51:48 +0200 Subject: [PATCH 22/23] Make timeline update only affected rows. Rather than updating all rows on every update. --- Mactrix/Models/LiveTimeline.swift | 7 + Mactrix/Views/ChatView/ChatView.swift | 2 +- .../TimelineView/TimelineRowProjection.swift | 149 ++++++++++++++++++ .../TimelineView/TimelineTableView.swift | 121 +++++--------- .../TimelineViewRepresentable.swift | 9 +- 5 files changed, 200 insertions(+), 88 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/TimelineRowProjection.swift diff --git a/Mactrix/Models/LiveTimeline.swift b/Mactrix/Models/LiveTimeline.swift index 99911a0..49eacf4 100644 --- a/Mactrix/Models/LiveTimeline.swift +++ b/Mactrix/Models/LiveTimeline.swift @@ -10,6 +10,11 @@ public protocol LiveTimelineFocusDelegate: AnyObject { func focusTimelineEvent(id: EventOrTransactionId) } +@MainActor +protocol LiveTimelineDiffDelegate: AnyObject { + func timelineDidApply(diffs: [TimelineDiff]) +} + @MainActor @Observable public final class LiveTimeline { public let room: LiveRoom @@ -24,6 +29,7 @@ public final class LiveTimeline { public var errorMessage: String? @ObservationIgnored public weak var focusDelegate: (any LiveTimelineFocusDelegate)? + @ObservationIgnored weak var diffDelegate: (any LiveTimelineDiffDelegate)? public var sendReplyTo: MatrixRustSDK.EventTimelineItem? @@ -199,6 +205,7 @@ extension LiveTimeline { hitTimelineStart = true } + diffDelegate?.timelineDidApply(diffs: diff) loadPendingReplyDetails() } diff --git a/Mactrix/Views/ChatView/ChatView.swift b/Mactrix/Views/ChatView/ChatView.swift index 6cfd77d..f3db00d 100644 --- a/Mactrix/Views/ChatView/ChatView.swift +++ b/Mactrix/Views/ChatView/ChatView.swift @@ -43,7 +43,7 @@ struct ChatJoinedRoom: View { } var body: some View { - TimelineViewRepresentable(timeline: timeline, items: timeline.timelineItems) + TimelineViewRepresentable(timeline: timeline) .ignoresSafeArea(edges: .top) .safeAreaInset(edge: .bottom, spacing: 8) { ChatInputView(room: room.room, timeline: timeline, replyTo: $timeline.sendReplyTo) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineRowProjection.swift b/Mactrix/Views/ChatView/TimelineView/TimelineRowProjection.swift new file mode 100644 index 0000000..30ffdc1 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/TimelineRowProjection.swift @@ -0,0 +1,149 @@ +import MatrixRustSDK + +/// Maps contiguous SDK item ranges to ordered table rows. The current rules emit +/// one segment per item; a rule may later consume a run and emit a single row. +struct TimelineRowProjection { + struct Segment { + let sourceItems: [TimelineItem] + let rows: [TimelineItemRowInfo] + } + + struct Change { + let oldIDs: [String] + let changedIDs: Set + } + + private(set) var sourceItems: [TimelineItem] + private var segments: [Segment] + private(set) var rows: [TimelineItemRowInfo] + + init(items: [TimelineItem]) { + sourceItems = items + segments = Self.makeSegments(items, previous: nil) + rows = Self.flatten(segments) + } + + mutating func apply(_ diffs: [TimelineDiff]) -> Change { + let oldIDs = rows.map(\.id) + var changedIDs = Set() + + for diff in diffs { + switch diff { + case let .append(values): + replace(at: sourceItems.count, removing: 0, with: values, changedIDs: &changedIDs) + case .clear: + reset(to: [], changedIDs: &changedIDs) + case let .pushFront(item): + replace(at: 0, removing: 0, with: [item], changedIDs: &changedIDs) + case let .pushBack(item): + replace(at: sourceItems.count, removing: 0, with: [item], changedIDs: &changedIDs) + case .popFront: + replace(at: 0, removing: 1, with: [], changedIDs: &changedIDs) + case .popBack: + replace(at: sourceItems.count - 1, removing: 1, with: [], changedIDs: &changedIDs) + case let .insert(index, item): + replace(at: Int(index), removing: 0, with: [item], changedIDs: &changedIDs) + case let .set(index, item): + replace(at: Int(index), removing: 1, with: [item], changedIDs: &changedIDs) + case let .remove(index): + replace(at: Int(index), removing: 1, with: [], changedIDs: &changedIDs) + case let .truncate(length): + replace(at: Int(length), removing: sourceItems.count - Int(length), with: [], changedIDs: &changedIDs) + case let .reset(values): + reset(to: values, changedIDs: &changedIDs) + } + } + + rows = Self.flatten(segments) + return Change(oldIDs: oldIDs, changedIDs: changedIDs) + } + + private mutating func reset(to items: [TimelineItem], changedIDs: inout Set) { + changedIDs.formUnion(segments.flatMap { $0.rows.map(\.id) }) + sourceItems = items + segments = Self.makeSegments(items, previous: nil) + changedIDs.formUnion(segments.flatMap { $0.rows.map(\.id) }) + } + + private mutating func replace( + at index: Int, removing count: Int, with items: [TimelineItem], changedIDs: inout Set + ) { + guard count > 0 || !items.isEmpty else { return } + + // The predecessor determines whether the first changed message needs a + // profile. The successor may gain or lose its own profile after the edit. + let requestedStart = max(index - 1, 0) + let requestedEnd = min(index + count + 1, sourceItems.count) + let (segmentRange, sourceRange) = segmentWindow(containing: requestedStart ..< requestedEnd) + changedIDs.formUnion(segments[segmentRange].flatMap { $0.rows.map(\.id) }) + + sourceItems.replaceSubrange(index ..< index + count, with: items) + let newEnd = sourceRange.upperBound + items.count - count + let previous = sourceRange.lowerBound > 0 ? sourceItems[sourceRange.lowerBound - 1] : nil + let replacement = Self.makeSegments( + Array(sourceItems[sourceRange.lowerBound ..< newEnd]), previous: previous + ) + changedIDs.formUnion(replacement.flatMap { $0.rows.map(\.id) }) + segments.replaceSubrange(segmentRange, with: replacement) + } + + /// Expands a source edit to whole segments. Future run based rules can + /// extend this window to the nearest unchanged grouping boundaries. + private func segmentWindow(containing requested: Range) -> (Range, Range) { + var sourceOffset = 0 + var first: Int? + var last = 0 + var lower = requested.lowerBound + var upper = requested.upperBound + for (index, segment) in segments.enumerated() { + let end = sourceOffset + segment.sourceItems.count + if first == nil, end > requested.lowerBound { + first = index + lower = sourceOffset + } + if sourceOffset < requested.upperBound { + last = index + 1 + upper = end + } + sourceOffset = end + } + if let first { return (first ..< last, lower ..< upper) } + return (segments.count ..< segments.count, requested.lowerBound ..< requested.upperBound) + } + + private static func makeSegments(_ items: [TimelineItem], previous: TimelineItem?) -> [Segment] { + var previousSender = sender(of: previous) + return items.map { item in + var projectedRows: [TimelineItemRowInfo] = [] + if let event = item.asEvent() { + switch event.content { + case let .msgLike(content): + if previousSender != event.sender { + projectedRows.append(.profile(item: item, event: event)) + } + projectedRows.append(.message(item: item, event: event, content: content)) + previousSender = event.sender + default: + projectedRows.append(.state(item: item, event: event)) + previousSender = nil + } + } else if let virtual = item.asVirtual() { + projectedRows.append(.virtual(item: item, virtual: virtual)) + previousSender = nil + } + return Segment(sourceItems: [item], rows: projectedRows) + } + } + + private static func sender(of item: TimelineItem?) -> String? { + guard let event = item?.asEvent(), case .msgLike = event.content else { return nil } + return event.sender + } + + private static func flatten(_ segments: [Segment]) -> [TimelineItemRowInfo] { + var result = segments.flatMap(\.rows) + result.append(.typingIndicator) + result.reverse() + return result + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 0bd3fd2..c23885c 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -60,7 +60,7 @@ extension TimelineItemRowInfo: Identifiable { } } -class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { +class TimelineViewController: NSViewController, LiveTimelineFocusDelegate, LiveTimelineDiffDelegate { let coordinator: TimelineViewRepresentable.Coordinator private var dataSource: NSTableViewDiffableDataSource? @@ -74,13 +74,14 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { private var updatingTimelineItems = false let timeline: LiveTimeline - var timelineItems: [TimelineItemRowInfo] = [] + private var projection: TimelineRowProjection + var timelineItems: [TimelineItemRowInfo] { projection.rows } - init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline, timelineItems: [TimelineItem]) { + init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline) { self.coordinator = coordinator self.timeline = timeline + self.projection = TimelineRowProjection(items: timeline.timelineItems) super.init(nibName: nil, bundle: nil) - self.timelineItems = mapTimelineItems(items: timelineItems) } override func viewDidLoad() { @@ -225,6 +226,8 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { ) timeline.focusDelegate = self + timeline.diffDelegate = self + applyProjectedRows(oldIDs: [], changedIDs: []) listenForTypingUsers() listenForReplyDetails() } @@ -517,91 +520,47 @@ class TimelineViewController: NSViewController, LiveTimelineFocusDelegate { // case typingIndicator } - func updateTimelineItems(_ timelineItems: [TimelineItem]) { - Logger.timelineTableView.info("update timeline items") - - let oldItems = self.timelineItems - let selectedId = oldItems.indices.contains(tableView.selectedRow) - ? oldItems[tableView.selectedRow].id : nil - let oldIds = oldItems.map { $0.id } - self.timelineItems = mapTimelineItems(items: timelineItems) - let newIds = self.timelineItems.map { $0.id } - - // If the IDs haven't changed, reload all rows in place (content-only update: reactions, read receipts, etc.) - // Reloads all rows rather than just visible ones to avoid stale content in NSTableView's prepared/cached views. - if oldIds == newIds { - var changedHeightRows = IndexSet() - for row in self.timelineItems.indices { - guard case .message(_, let oldEvent, let oldContent) = oldItems[row], - case .message(_, let newEvent, let newContent) = self.timelineItems[row], - oldContent.reactions != newContent.reactions || - oldEvent.readReceipts.count != newEvent.readReceipts.count else { continue } - changedHeightRows.insert(row) - } - if !changedHeightRows.isEmpty { - tableView.noteHeightOfRows(withIndexesChanged: changedHeightRows) - } - tableView.reloadData(forRowIndexes: IndexSet(integersIn: 0 ..< self.timelineItems.count), - columnIndexes: IndexSet(integer: 0)) - return - } + func timelineDidApply(diffs: [TimelineDiff]) { + let change = projection.apply(diffs) + assert(projection.sourceItems.map { $0.uniqueId().id } == timeline.timelineItems.map { $0.uniqueId().id }) + applyProjectedRows(oldIDs: change.oldIDs, changedIDs: change.changedIDs) + } - var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) + private func applyProjectedRows(oldIDs: [String], changedIDs: Set) { + let selectedId = oldIDs.indices.contains(tableView.selectedRow) ? oldIDs[tableView.selectedRow] : nil + let newIDs = timelineItems.map(\.id) + let structureChanged = oldIDs != newIDs - for item in self.timelineItems { - snapshot.appendItems([.init(id: item.id)], toSection: .main) + if structureChanged { + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(newIDs.map { TimelineUniqueId(id: $0) }, toSection: .main) + updatingTimelineItems = true + dataSource?.apply(snapshot, animatingDifferences: false) + if let selectedId, let row = newIDs.firstIndex(of: selectedId) { + tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + } else { + tableView.deselectAll(nil) + } + updatingTimelineItems = false } - updatingTimelineItems = true - dataSource?.apply(snapshot, animatingDifferences: false) - if let selectedId, - let row = self.timelineItems.firstIndex(where: { $0.id == selectedId }) { - tableView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) - } else { - tableView.deselectAll(nil) + let oldIDSet = Set(oldIDs) + var changedRows = IndexSet() + for (row, id) in newIDs.enumerated() where changedIDs.contains(id) && oldIDSet.contains(id) { + changedRows.insert(row) } - updatingTimelineItems = false - updateSelectedMessage() - - // Re-measure visible rows after the table applies the new snapshot. - DispatchQueue.main.async { [weak self] in - guard let self else { return } - let visibleRows = tableView.rows(in: tableView.visibleRect) - tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: visibleRows.lowerBound ..< visibleRows.upperBound)) + if !changedRows.isEmpty { + tableView.noteHeightOfRows(withIndexesChanged: changedRows) + tableView.reloadData(forRowIndexes: changedRows, columnIndexes: IndexSet(integer: 0)) } - } - - private func mapTimelineItems(items: [TimelineItem]) -> [TimelineItemRowInfo] { - var result = [TimelineItemRowInfo]() - var currentSender: String? = nil - for item in items { - if let event = item.asEvent() { - switch event.content { - case .msgLike(content: let content): - if event.sender != currentSender { - currentSender = event.sender - result.append(.profile(item: item, event: event)) - } - result.append(.message(item: item, event: event, content: content)) - default: - currentSender = nil - result.append(.state(item: item, event: event)) - } - } - - if let virtual = item.asVirtual() { - currentSender = nil - result.append(.virtual(item: item, virtual: virtual)) - } + if let hoveredMessageId { + hoveredRowIndex = newIDs.firstIndex(of: hoveredMessageId) + if hoveredRowIndex == nil { hideHoverOverlay() } + else { positionHoverOverlay() } } - - result.append(.typingIndicator) - - result.reverse() - - return result + updateSelectedMessage() } // values used to track width changes diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift index 2777ed1..1919261 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift @@ -6,11 +6,8 @@ struct TimelineViewRepresentable: NSViewControllerRepresentable { @Environment(WindowState.self) private var windowState let timeline: LiveTimeline - let items: [TimelineItem] - - init(timeline: LiveTimeline, items: [TimelineItem]) { + init(timeline: LiveTimeline) { self.timeline = timeline - self.items = items } func makeCoordinator() -> Coordinator { @@ -28,10 +25,10 @@ struct TimelineViewRepresentable: NSViewControllerRepresentable { } func makeNSViewController(context: Context) -> TimelineViewController { - return TimelineViewController(coordinator: context.coordinator, timeline: timeline, timelineItems: items) + return TimelineViewController(coordinator: context.coordinator, timeline: timeline) } func updateNSViewController(_ timelineViewController: TimelineViewController, context: Context) { - timelineViewController.updateTimelineItems(items) + // SDK diff batches update the controller directly through LiveTimeline. } } From 01494efddea4b9821e90ced733619135886b5f91 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Mon, 28 Sep 2026 16:21:00 +0200 Subject: [PATCH 23/23] Remove old unused files after rewrite --- Mactrix/Models/LiveTimeline.swift | 3 - Mactrix/Models/TimelineGroups.swift | 162 --------- Mactrix/Models/WindowState.swift | 9 +- .../ChatInputView/ChatInputView.swift | 1 - Mactrix/Views/ChatView/ChatMessageView.swift | 147 --------- Mactrix/Views/ChatView/ChatView.swift | 19 -- .../Views/ChatView/FormattedBodyView.swift | 50 --- Mactrix/Views/ChatView/MessageFileView.swift | 86 ----- Mactrix/Views/ChatView/MessageImageView.swift | 155 --------- Mactrix/Views/ChatView/MessageVideoView.swift | 78 ----- .../Views/ChatView/TimelineEventView.swift | 83 ----- .../Views/ChatView/TimelineVideoPlayer.swift | 28 -- .../Sources/UI/Inspector/EventItemRow.swift | 20 -- .../UI/Timeline/GenericEventView.swift | 36 -- .../UI/Timeline/MessageEventView.swift | 308 ------------------ .../UI/Timeline/MessageReactionView.swift | 61 ---- .../UI/Timeline/MessageThreadSummary.swift | 38 --- .../Sources/UI/Timeline/ReadReciptsView.swift | 147 --------- .../UI/Timeline/UserTypingIndicator.swift | 63 ---- .../Sources/UI/Timeline/VirtualItemView.swift | 67 ---- 20 files changed, 1 insertion(+), 1560 deletions(-) delete mode 100644 Mactrix/Models/TimelineGroups.swift delete mode 100644 Mactrix/Views/ChatView/ChatMessageView.swift delete mode 100644 Mactrix/Views/ChatView/FormattedBodyView.swift delete mode 100644 Mactrix/Views/ChatView/MessageFileView.swift delete mode 100644 Mactrix/Views/ChatView/MessageImageView.swift delete mode 100644 Mactrix/Views/ChatView/MessageVideoView.swift delete mode 100644 Mactrix/Views/ChatView/TimelineEventView.swift delete mode 100644 Mactrix/Views/ChatView/TimelineVideoPlayer.swift delete mode 100644 MactrixLibrary/Sources/UI/Inspector/EventItemRow.swift delete mode 100644 MactrixLibrary/Sources/UI/Timeline/GenericEventView.swift delete mode 100644 MactrixLibrary/Sources/UI/Timeline/MessageEventView.swift delete mode 100644 MactrixLibrary/Sources/UI/Timeline/MessageReactionView.swift delete mode 100644 MactrixLibrary/Sources/UI/Timeline/MessageThreadSummary.swift delete mode 100644 MactrixLibrary/Sources/UI/Timeline/ReadReciptsView.swift delete mode 100644 MactrixLibrary/Sources/UI/Timeline/UserTypingIndicator.swift delete mode 100644 MactrixLibrary/Sources/UI/Timeline/VirtualItemView.swift diff --git a/Mactrix/Models/LiveTimeline.swift b/Mactrix/Models/LiveTimeline.swift index 49eacf4..eb22a6b 100644 --- a/Mactrix/Models/LiveTimeline.swift +++ b/Mactrix/Models/LiveTimeline.swift @@ -2,7 +2,6 @@ import AsyncAlgorithms import Foundation import MatrixRustSDK import OSLog -import SwiftUI /// Delegate subscribed to by the `NSTimelineView` to update the focused timeline row. @MainActor @@ -25,7 +24,6 @@ public final class LiveTimeline { @ObservationIgnored private var timelineHandle: TaskHandle? @ObservationIgnored private var paginateHandle: TaskHandle? - public var scrollPosition = ScrollPosition(idType: TimelineGroup.ID.self, edge: .bottom) public var errorMessage: String? @ObservationIgnored public weak var focusDelegate: (any LiveTimelineFocusDelegate)? @@ -35,7 +33,6 @@ public final class LiveTimeline { public private(set) var timelineItems: [TimelineItem] = [] public private(set) var loadedReplyDetails: [String: InReplyToDetails] = [:] - // public private(set) var timelineGroups: TimelineGroups = .init() public private(set) var paginating: PaginationStatus = .idle(hitTimelineStart: false) public private(set) var hitTimelineStart: Bool = false diff --git a/Mactrix/Models/TimelineGroups.swift b/Mactrix/Models/TimelineGroups.swift deleted file mode 100644 index 9b0f560..0000000 --- a/Mactrix/Models/TimelineGroups.swift +++ /dev/null @@ -1,162 +0,0 @@ -import Foundation -import MatrixRustSDK -import OSLog -import SwiftUI - -public struct TimelineGroups: Hashable { - public var groups: [TimelineGroup] = [] - public var version: Int = 0 - - public mutating func insertEnd(timelineItem item: TimelineItem, withVersion overrideVersion: Int? = nil) { - if overrideVersion == nil { - version += 1 - } - - let version = if let overrideVersion { overrideVersion } else { self.version } - - if let event = item.asEvent() { - if case let .msgLike(content: content) = event.content { - if case let .messages(messages, id, _) = groups.last, messages[0].event.sender == event.sender { - groups[groups.count - 1] = .messages(messages: messages + [TimelineGroup.Message(id: item.uniqueId().id, event: event, content: content)], id: id, version: version) - return - } - } else { - if case let .stateChanges(events: events, id, _) = groups.last { - groups[groups.count - 1] = .stateChanges(events: events + [TimelineGroup.Event(id: item.uniqueId().id, event: event)], id: id, version: version) - return - } - } - } - - groups.append(TimelineGroup(timelineItem: item, version: version)) - } - - public mutating func updateItems(items: [TimelineItem], updatedIds: Set) { - let oldVersions = versions - - groups.removeAll(keepingCapacity: true) - version += 1 - for item in items { - if updatedIds.contains(item.uniqueId().id) { - insertEnd(timelineItem: item) - } else { - insertEnd(timelineItem: item, withVersion: oldVersions[item.uniqueId().id]) - } - } - } - - public mutating func clear() { - version += 1 - groups.removeAll() - } - - public mutating func removeLast() { - version += 1 - groups.removeLast() - } - - public func indexGroups(_ i: Int) -> (Int, Int)? { - var count = 0 - for group in groups { - count += group.count - if count > i { - return (i, count - i - 1) - } - } - return nil - } - - var versions: [String: Int] { - var result = [String: Int]() - - for group in groups { - switch group { - case let .messages(messages, _, version): - for message in messages { - result[message.event.eventOrTransactionId.id] = version - } - case let .stateChanges(events, _, version): - for event in events { - result[event.id] = version - } - case let .virtual(_, id, version): - result[id] = version - } - } - - return result - } -} - -public enum TimelineGroup: Hashable, Identifiable { - /// Event timeline items that are of type MsgLike, grouped by the same sender - case messages(messages: [Message], id: String, version: Int) - /// Event timeline items that are not of type MsgLike, all grouped - case stateChanges(events: [Event], id: String, version: Int) - case virtual(item: VirtualTimelineItem, id: String, version: Int) - - public struct Message: Identifiable { - public let id: String - public let event: EventTimelineItem - public let content: MsgLikeContent - } - - public struct Event: Identifiable { - public let id: String - public let event: EventTimelineItem - } - - public var id: String { - switch self { - case let .messages(_, id, _): - return id - case let .stateChanges(_, id, _): - return id - case let .virtual(_, id, _): - return id - } - } - - public init(timelineItem item: TimelineItem, version: Int) { - if let event = item.asEvent() { - if case let .msgLike(content: content) = event.content { - self = .messages(messages: [TimelineGroup.Message(id: item.uniqueId().id, event: event, content: content)], id: item.uniqueId().id, version: version) - } else { - self = .stateChanges(events: [TimelineGroup.Event(id: item.uniqueId().id, event: event)], id: item.uniqueId().id, version: version) - } - } else { - self = .virtual(item: item.asVirtual()!, id: item.uniqueId().id, version: version) - } - } - - public var count: Int { - switch self { - case let .messages(messages, _, _): - return messages.count - case let .stateChanges(events, _, _): - return events.count - case .virtual: - return 1 - } - } - - public var version: Int { - switch self { - case let .messages(_, _, version): - return version - case let .stateChanges(_, _, version): - return version - case let .virtual(_, _, version): - return version - } - } - - public static func == (lhs: TimelineGroup, rhs: TimelineGroup) -> Bool { - return lhs.id == rhs.id && lhs.version == rhs.version - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - hasher.combine(version) - } -} diff --git a/Mactrix/Models/WindowState.swift b/Mactrix/Models/WindowState.swift index ae6dccc..6471b80 100644 --- a/Mactrix/Models/WindowState.swift +++ b/Mactrix/Models/WindowState.swift @@ -83,15 +83,8 @@ final class WindowState { return } - /* guard let focusItem = roomTimeline.timelineItems?.first(where: { $0.asEvent()?.eventOrTransactionId.id == eventId }) else { - Logger.windowState.warning("focus message failed, message not found") - return - } */ - Logger.windowState.warning("scrolling to message \(eventId)") - withAnimation { - roomTimeline.scrollPosition.scrollTo(id: eventId) - } + roomTimeline.focusEvent(id: .eventId(eventId: eventId)) } func focusThread(rootEventId: String) { diff --git a/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift b/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift index d1a49f8..6ba6160 100644 --- a/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift +++ b/Mactrix/Views/ChatView/ChatInputView/ChatInputView.swift @@ -31,7 +31,6 @@ struct ChatInputView: View { chatInput = "" replyTo = nil - timeline.scrollPosition.scrollTo(edge: .bottom) } private func saveDraft() async { diff --git a/Mactrix/Views/ChatView/ChatMessageView.swift b/Mactrix/Views/ChatView/ChatMessageView.swift deleted file mode 100644 index e5ea718..0000000 --- a/Mactrix/Views/ChatView/ChatMessageView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import MatrixRustSDK -import Models -import OSLog -import SwiftUI -import UI - -struct ChatMessageView: View, UI.MessageEventActions { - @Environment(AppState.self) private var appState - @Environment(WindowState.self) private var windowState - @AppStorage("fontSize") private var fontSize = 13 - - let timeline: LiveTimeline? - let event: MatrixRustSDK.EventTimelineItem - let msg: MatrixRustSDK.MsgLikeContent - let includeProfileHeader: Bool - - var name: String { - if case let .ready(displayName, _, _) = event.senderProfileDetails, let displayName = displayName { - return displayName - } - return event.sender - } - - func toggleReaction(key: String) { - Task { - guard let innerTimeline = timeline?.timeline else { return } - do { - let reactionWasAdded = try await innerTimeline.toggleReaction(itemId: event.eventOrTransactionId, key: key) - Logger.viewCycle.debug("reaction \(reactionWasAdded ? "added" : "removed"): \(key)") - } catch { - Logger.viewCycle.error("Failed to toggle reaction: \(error)") - } - } - } - - func reply() { - Logger.viewCycle.info("Reply to event: \(event.eventOrTransactionId.id)") - timeline?.sendReplyTo = event - } - - func replyInThread() { - windowState.focusThread(rootEventId: event.eventOrTransactionId.id) - } - - func pin() { - Logger.viewCycle.info("Pinning message") - guard case let .eventId(eventId: eventId) = event.eventOrTransactionId else { return } - Task { - do { - let _ = try await timeline?.timeline?.pinEvent(eventId: eventId) - } catch { - Logger.viewCycle.error("Failed to ping message: \(error)") - } - } - } - - func focusUser() { - Logger.viewCycle.info("Focusing user \(event.sender)") - windowState.focusUser(userId: event.sender) - } - - @ViewBuilder - var message: some View { - switch msg.kind { - case let .message(content: content): - switch content.msgType { - case let .emote(content: content): - Text("Emote: \(content.body)").textSelection(.enabled) - case let .image(content: content): - MessageImageView(content: content) - case let .audio(content: content): - Text("Audio: \(content.caption ?? "no caption") \(content.filename)").textSelection(.enabled) - case let .video(content: content): - MessageVideoView(content: content) - case let .file(content: content): - MessageFileView(content: content) - case let .gallery(content: content): - Text("Gallery: \(content.body)").textSelection(.enabled) - case let .notice(content: content): - FormattedBodyView(messageContent: content, color: .secondaryLabelColor) - case let .text(content: content): - FormattedBodyView(messageContent: content) - case let .location(content: content): - Text("Location: \(content.body) \(content.geoUri)").textSelection(.enabled) - case let .other(msgtype: msgtype, body: body): - Text("Other: \(msgtype) \(body)").textSelection(.enabled) - } - case .sticker(body: let body, info: _, source: _): - Text("Sticker: \(body)").textSelection(.enabled) - case .poll(question: let question, kind: _, maxSelections: _, answers: _, votes: _, endTime: _, hasBeenEdited: _): - Text("Poll: \(question)").textSelection(.enabled) - case .redacted: - Text("Message redacted") - .italic() - .foregroundStyle(.secondary) - .textSelection(.enabled) - case .unableToDecrypt(msg: _): - Text("Unable to decrypt") - .italic() - .foregroundStyle(.secondary) - .textSelection(.enabled) - case let .other(eventType: eventType): - let eventText = eventType.description - - Text("Custom event: \(eventText)").textSelection(.enabled) - case .liveLocation(content: let content): - Text("Live location: \(content.description ?? "no description")") - } - } - - var ownUserId: String { - do { - return try appState.matrixClient?.client.userId() ?? "" - } catch { - Logger.viewCycle.error("failed to get user id for message \(error)") - return "" - } - } - - var body: some View { - if includeProfileHeader { - UI.MessageEventProfileView(event: event, focusUserAction: self.focusUser, imageLoader: appState.matrixClient) - .font(.system(size: .init(fontSize))) - } - UI.MessageEventBodyView(event: event, focused: false, reactions: msg.reactions, actions: self, ownUserID: ownUserId, imageLoader: appState.matrixClient, roomMembers: timeline?.room.members ?? []) { - VStack(alignment: .leading, spacing: 10) { - if let replyTo = msg.inReplyTo { - let eventId = replyTo.eventId() - let embeddedEvent = timeline?.loadedReplyDetails[eventId]?.event() ?? replyTo.event() - EmbeddedMessageView(embeddedEvent: embeddedEvent) { - timeline?.focusEvent(id: .eventId(eventId: eventId)) - } - .padding(.bottom, 10) - } - - message - - if let threadSummary = msg.threadSummary { - MessageThreadSummary(summary: threadSummary) { - windowState.focusThread(rootEventId: event.eventOrTransactionId.id) - } - } - } - } - .font(.system(size: .init(fontSize))) - } -} diff --git a/Mactrix/Views/ChatView/ChatView.swift b/Mactrix/Views/ChatView/ChatView.swift index f3db00d..13d8b1c 100644 --- a/Mactrix/Views/ChatView/ChatView.swift +++ b/Mactrix/Views/ChatView/ChatView.swift @@ -2,25 +2,6 @@ import MatrixRustSDK import Models import OSLog import SwiftUI -import UI - -struct TimelineGroupView: View { - let timeline: LiveTimeline - let timelineGroup: TimelineGroup - - var body: some View { - switch timelineGroup { - case .messages(let messages, _, _): - ForEach(messages) { message in - ChatMessageView(timeline: timeline, event: message.event, msg: message.content, includeProfileHeader: message.id == messages.first?.id) - } - case .stateChanges(let events, _, _): - TimelineStateEventsView(timeline: timeline, events: events) - case .virtual(let item, _, _): - UI.VirtualItemView(item: item.asModel) - } - } -} private struct TimelineReadKey: Equatable { let count: Int diff --git a/Mactrix/Views/ChatView/FormattedBodyView.swift b/Mactrix/Views/ChatView/FormattedBodyView.swift deleted file mode 100644 index 3649190..0000000 --- a/Mactrix/Views/ChatView/FormattedBodyView.swift +++ /dev/null @@ -1,50 +0,0 @@ -import MatrixRustSDK -import MessageFormatting -import SwiftUI - -struct FormattedBodyView: View { - @AppStorage("fontSize") private var fontSize = 13 - - let rawBody: String - var formattedBody: NSAttributedString? = nil - var color: NSColor? = nil - - init(messageContent: some MessageContent) { - self.rawBody = messageContent.body - - if let formatted = messageContent.formatted, formatted.format == .html { - self.formattedBody = parseFormattedBody(formatted.body, baseFontSize: CGFloat(fontSize)) - } - } - - // accepts a custom foreground color (for use in grayed out messages) - init(messageContent: some MessageContent, color: NSColor) { - self.rawBody = messageContent.body - self.color = color - - if let formatted = messageContent.formatted, formatted.format == .html { - let parsed = parseFormattedBody(formatted.body, baseFontSize: CGFloat(fontSize)) - - let mutable = NSMutableAttributedString(attributedString: parsed) - mutable.addAttribute(.foregroundColor, value: color, range: NSRange(location: 0, length: mutable.length)) - - self.formattedBody = mutable - } - } - - var body: some View { - if let formattedBody { - AttributedTextView(attributedString: formattedBody) - .fixedSize(horizontal: false, vertical: true) - } else if let color { - Text(rawBody) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - .foregroundStyle(Color(nsColor: color)) - } else { - Text(rawBody) - .textSelection(.enabled) - .fixedSize(horizontal: false, vertical: true) - } - } -} diff --git a/Mactrix/Views/ChatView/MessageFileView.swift b/Mactrix/Views/ChatView/MessageFileView.swift deleted file mode 100644 index 4829b85..0000000 --- a/Mactrix/Views/ChatView/MessageFileView.swift +++ /dev/null @@ -1,86 +0,0 @@ -import MatrixRustSDK -import Models -import OSLog -import QuickLook -import SwiftUI -import UniformTypeIdentifiers - -struct MessageFileView: View { - @Environment(AppState.self) private var appState - let content: FileMessageContent - - @State private var icon: Image? - @State private var fileHandle: MediaFileHandle? - @State private var fileUrl: URL? - @State private var quickLookUrl: URL? - - var mimeType: UTType? { - guard let mimeStr = content.info?.mimetype, let mime = UTType(mimeStr) else { - return nil - } - return mime - } - - func previewFile() async { - if let fileUrl { - quickLookUrl = fileUrl - return - } - - guard let matrixClient = appState.matrixClient?.client else { return } - - do { - let handle = try await matrixClient.getMediaFile( - mediaSource: content.source, - filename: content.filename, - mimeType: content.info?.mimetype ?? "", - useCache: true, - tempDir: NSTemporaryDirectory() - ) - fileHandle = handle - - let path = try handle.path() - - let fileUrl = URL(filePath: path, directoryHint: .notDirectory) - self.fileUrl = fileUrl - quickLookUrl = fileUrl - - let nsImage = NSWorkspace.shared.icon(forFile: path) - icon = Image(nsImage: nsImage) - - Logger.viewCycle.debug("downloaded file \(fileUrl.absoluteString)") - } catch { - Logger.viewCycle.error("failed to download file: \(error)") - } - } - - var body: some View { - VStack { - Button { - Task(operation: previewFile) - } label: { - if let icon { - icon.quickLookPreview($quickLookUrl) - } else { - Image(nsImage: NSWorkspace.shared.icon(for: mimeType ?? .data)) - } - Text(content.filename).textSelection(.enabled) - if let fileSize = content.info?.size { - Text("(\(fileSize.formatted(.byteCount(style: .file))))") - .textSelection(.enabled) - .font(.caption) - } - } - .buttonStyle(.plain) - - if let caption = content.caption { - Text(caption.formatAsMarkdown) - .textSelection(.enabled) - } - } - .onChange(of: content.info?.mimetype, initial: true) { _, _ in - let nsImage = NSWorkspace.shared.icon(for: mimeType ?? .item) - icon = Image(nsImage: nsImage) - } - } -} diff --git a/Mactrix/Views/ChatView/MessageImageView.swift b/Mactrix/Views/ChatView/MessageImageView.swift deleted file mode 100644 index 85ce88c..0000000 --- a/Mactrix/Views/ChatView/MessageImageView.swift +++ /dev/null @@ -1,155 +0,0 @@ -import MatrixRustSDK -import Models -import OSLog -import QuickLook -import SwiftUI -import UniformTypeIdentifiers - -struct MessageImageView: View { - let content: ImageMessageContent - - @Environment(AppState.self) private var appState - - @State private var imageData: Data? = nil - @State private var image: Image? = nil - @State private var errorMessage: String? = nil - - init(content: ImageMessageContent) { - self.content = content - if let cached = MatrixClient.imageCache.object(forKey: NSString(string: content.source.url())) { - self._image = State(initialValue: Image(nsImage: cached)) - } - } - - var aspectRatio: CGFloat? { - guard let info = content.info, - let height = info.height, height > 0, - let width = info.width, width > 0 else { return nil } - - return CGFloat(width) / CGFloat(height) - } - - var maxHeight: CGFloat { - guard let height = content.info?.height, height > 0 else { return 300 } - return min(CGFloat(height), 300) - } - - var contentType: UTType? { - return content.info?.mimetype.flatMap { UTType(mimeType: $0) } - } - - @ViewBuilder - func imageView(image: Image) -> some View { - Button( - action: { - Task { await previewImage() } - }, - label: { - image - .resizable() - .scaledToFit() - .clipShape(RoundedRectangle(cornerRadius: 6)) - .onDrag { - let itemProvider = NSItemProvider() - itemProvider.suggestedName = content.filename - let data = imageData - itemProvider.registerDataRepresentation(for: UTType.image, visibility: .all) { completion in - completion(data, nil) - return nil - } - return itemProvider - } - } - ) - .buttonStyle(.plain) - } - - var body: some View { - VStack { - if let errorMessage = errorMessage { - Text(errorMessage) - .textSelection(.enabled) - .foregroundStyle(Color.red) - } else { - if let image { - imageView(image: image) - } else { - ProgressView { - Text("Fetching image") - } - } - if let caption = content.caption { - Text(caption.formatAsMarkdown) - .textSelection(.enabled) - } - } - } - .quickLookPreview($quickLookUrl) - .frame(maxHeight: maxHeight) - .aspectRatio(aspectRatio, contentMode: .fit) - .task(id: content.source.url(), priority: .utility) { - guard let matrixClient = appState.matrixClient else { - errorMessage = "Matrix client not available" - return - } - - // Two caching layers serve different purposes: - // - NSCache (checked in init): synchronously pre-populates `image` before the - // view appears, preventing flicker on scroll/revisit. Also skips the expensive - // decode step (toOrientedImage) on repeat loads below. - // - SDK media cache (getMediaContent): avoids network re-fetches. Always called - // here so `imageData` is populated for drag-and-drop, even when the decoded - // NSImage is already in the NSCache. - do { - let data = try await matrixClient.client.getMediaContent(mediaSource: content.source) - imageData = data - - let cacheKey = NSString(string: content.source.url()) - if let cached = MatrixClient.imageCache.object(forKey: cacheKey) { - image = Image(nsImage: cached) - return - } - - let nsImage = try data.toOrientedImage(contentType: contentType) - MatrixClient.setCachedImage(nsImage, forKey: cacheKey) - image = Image(nsImage: nsImage) - } catch { - errorMessage = error.localizedDescription - } - } - } - - @State private var fileHandle: MediaFileHandle? - @State private var fileUrl: URL? - @State private var quickLookUrl: URL? - - func previewImage() async { - if let fileUrl { - quickLookUrl = fileUrl - return - } - - guard let matrixClient = appState.matrixClient?.client else { return } - - do { - let handle = try await matrixClient.getMediaFile( - mediaSource: content.source, - filename: content.filename, - mimeType: content.info?.mimetype ?? "", - useCache: true, - tempDir: NSTemporaryDirectory() - ) - fileHandle = handle - - let path = try handle.path() - - let fileUrl = URL(filePath: path, directoryHint: .notDirectory) - self.fileUrl = fileUrl - quickLookUrl = fileUrl - - Logger.viewCycle.debug("downloaded image file \(fileUrl.absoluteString)") - } catch { - Logger.viewCycle.error("failed to download image file: \(error)") - } - } -} diff --git a/Mactrix/Views/ChatView/MessageVideoView.swift b/Mactrix/Views/ChatView/MessageVideoView.swift deleted file mode 100644 index f9a533e..0000000 --- a/Mactrix/Views/ChatView/MessageVideoView.swift +++ /dev/null @@ -1,78 +0,0 @@ -import AVKit -import MatrixRustSDK -import Models -import OSLog -import SwiftUI - -struct MessageVideoView: View { - @Environment(AppState.self) private var appState - let content: VideoMessageContent - - @State private var fileHandle: MediaFileHandle? - @State private var video: AVPlayer? - - var aspectRatio: CGFloat? { - guard let info = content.info, - let height = info.height, height > 0, - let width = info.width, width > 0 else { return nil } - - return CGFloat(width) / CGFloat(height) - } - - var maxHeight: CGFloat { - guard let height = content.info?.height, height > 0 else { return 300 } - return min(CGFloat(height), 300) - } - - func loadVideo() async { - guard let client = appState.matrixClient?.client else { return } - - do { - let handle = try await client.getMediaFile( - mediaSource: content.source, - filename: content.filename, - mimeType: content.info?.mimetype ?? "", - useCache: true, - tempDir: NSTemporaryDirectory() - ) - - fileHandle = handle - let path = try handle.path() - let url = URL(filePath: path, directoryHint: .notDirectory) - - video = AVPlayer(url: url) - video?.play() - } catch { - Logger.viewCycle.error("Failed to load video: \(error)") - } - } - - var body: some View { - VStack { - if let video { - TimelineVideoPlayer(videoPlayer: video) - .cornerRadius(6) - } else { - Button(action: { Task { await loadVideo() } }) { - MatrixImageView(mediaSource: content.info?.thumbnailSource, mimeType: content.info?.thumbnailInfo?.mimetype) - .overlay { - Image(systemName: "play.fill") - .resizable() - .foregroundStyle(.white) - .shadow(radius: 4) - .frame(width: 48, height: 48) - .opacity(0.9) - } - } - .buttonStyle(.plain) - } - if let caption = content.caption, !caption.isEmpty { - Text(caption.formatAsMarkdown) - .textSelection(.enabled) - } - } - .aspectRatio(aspectRatio, contentMode: .fit) - .frame(maxHeight: maxHeight) - .frame(minHeight: content.info?.thumbnailSource == nil ? maxHeight : nil) - } -} diff --git a/Mactrix/Views/ChatView/TimelineEventView.swift b/Mactrix/Views/ChatView/TimelineEventView.swift deleted file mode 100644 index f92287f..0000000 --- a/Mactrix/Views/ChatView/TimelineEventView.swift +++ /dev/null @@ -1,83 +0,0 @@ -import MatrixRustSDK -import SwiftUI -import UI - -struct TimelineStateEventRow: View { - let event: MatrixRustSDK.EventTimelineItem - - var body: some View { - switch event.content { - case .msgLike(content: _): - Text("Unreachable state") - .foregroundStyle(.red) - case .callInvite: - UI.GenericEventView(event: event, name: "Call invite") - case .rtcNotification: - UI.GenericEventView(event: event, name: "Rtc notification") - case let .roomMembership(userId: user, userDisplayName: _, change: change, reason: reason): - let message = TimelineItemContent.roomMembershipDescription(userId: user, change: change, reason: reason) - UI.GenericEventView(event: event, name: message) - case let .profileChange(displayName: displayName, prevDisplayName: prevDisplayName, avatarUrl: avatarUrl, prevAvatarUrl: prevAvatarUrl): - let changeMsg = switch (displayName, prevDisplayName, avatarUrl, prevAvatarUrl) { - case (.some(_), .some(_), .some(_), .some(_)): - "changed their display name and avatar" - case let (.some(displayName), .some(prevDisplayName), _, _): - "changed their display name from \(prevDisplayName) to \(displayName)" - case (_, _, .some(_), .some(_)): - "changed their avatar" - case _: - "unknown profile change" - } - - UI.GenericEventView(event: event, name: changeMsg) - case let .state(stateKey: stateKey, content: content): - StateEventView(event: event, stateKey: stateKey, state: content) - case .failedToParseMessageLike(eventType: _, error: let error): - UI.GenericEventView(event: event, name: "Failed to parse message: \(error)") - case .failedToParseState(eventType: _, stateKey: _, error: let error): - UI.GenericEventView(event: event, name: "Failed to parse state: \(error)") - } - } -} - -struct TimelineStateEventsView: View { - let timeline: LiveTimeline - let events: [TimelineGroup.Event] - - @State private var expanded: Bool = false - - var body: some View { - if events.count == 1 { - TimelineStateEventRow(event: events[0].event) - } else { - VStack(alignment: .leading) { - HStack { - Text("\(events.count) state events") - Button(expanded ? "Collapse" : "Expand") { - withAnimation { - expanded.toggle() - } - } - .buttonStyle(.link) - Spacer() - } - .padding(.leading, 64) - if expanded { - ForEach(events) { event in - TimelineStateEventRow(event: event.event) - } - } - } - } - } -} - -struct StateEventView: View { - let event: EventTimelineItem - let stateKey: String - let state: OtherState - - var body: some View { - GenericEventView(event: event, name: state.description) - } -} diff --git a/Mactrix/Views/ChatView/TimelineVideoPlayer.swift b/Mactrix/Views/ChatView/TimelineVideoPlayer.swift deleted file mode 100644 index 1e6327e..0000000 --- a/Mactrix/Views/ChatView/TimelineVideoPlayer.swift +++ /dev/null @@ -1,28 +0,0 @@ -import AppKit -import AVKit -import SwiftUI - -struct TimelineVideoPlayer: NSViewRepresentable { - let videoPlayer: AVPlayer - - func makeNSView(context: Context) -> AVPlayerView { - let playerView = AVPlayerView() - playerView.allowsPictureInPicturePlayback = true - playerView.showsFullScreenToggleButton = true - playerView.showsSharingServiceButton = true - - playerView.player = videoPlayer - - return playerView - } - - func updateNSView(_ playerView: AVPlayerView, context: Context) { - playerView.player = videoPlayer - } - - func makeCoordinator() -> Coordinator { - return Coordinator() - } - - class Coordinator {} -} diff --git a/MactrixLibrary/Sources/UI/Inspector/EventItemRow.swift b/MactrixLibrary/Sources/UI/Inspector/EventItemRow.swift deleted file mode 100644 index 5a0003c..0000000 --- a/MactrixLibrary/Sources/UI/Inspector/EventItemRow.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Models -import SwiftUI - -public struct EventItemRow: View { - let event: Event - - public var body: some View { - VStack { - Text(event.sender) - } - } -} - -#Preview { - List { - EventItemRow(event: MockEventTimelineItem()) - EventItemRow(event: MockEventTimelineItem()) - EventItemRow(event: MockEventTimelineItem()) - } -} diff --git a/MactrixLibrary/Sources/UI/Timeline/GenericEventView.swift b/MactrixLibrary/Sources/UI/Timeline/GenericEventView.swift deleted file mode 100644 index e80381e..0000000 --- a/MactrixLibrary/Sources/UI/Timeline/GenericEventView.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Models -import SwiftUI - -public struct GenericEventView: View { - let event: Event - let name: String - - @State private var hover = false - - public init(event: Event, name: String) { - self.event = event - self.name = name - } - - public var body: some View { - HStack(spacing: 0) { - MessageTimestampView(date: event.date, hover: hover) - Text("\(Text(event.sender).bold()): \(Text(name).italic())") - .textSelection(.enabled) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 10) - .onHover { hover in - self.hover = hover - } - } -} - -#Preview { - VStack(spacing: 0) { - GenericEventView(event: MockEventTimelineItem(), name: "Test Event") - GenericEventView(event: MockEventTimelineItem(), name: "Test Event") - GenericEventView(event: MockEventTimelineItem(), name: "Test Event") - } -} diff --git a/MactrixLibrary/Sources/UI/Timeline/MessageEventView.swift b/MactrixLibrary/Sources/UI/Timeline/MessageEventView.swift deleted file mode 100644 index 4aed354..0000000 --- a/MactrixLibrary/Sources/UI/Timeline/MessageEventView.swift +++ /dev/null @@ -1,308 +0,0 @@ -import Models -import SwiftUI - -struct HoverButton: View { - @State private var hovering = false - - @ViewBuilder - let icon: () -> Icon - let tooltip: LocalizedStringKey - let action: () -> Void - - let size: CGFloat = 24.0 - - var body: some View { - Button(action: action) { - icon() - } - .buttonStyle(.plain) - .help(tooltip) - .foregroundStyle(hovering ? Color.accentColor : .primary) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(Color.accentColor.quaternary) - .frame(width: size, height: size) - .opacity(hovering ? 1 : 0) - ) - .frame(width: size, height: size) - .padding(2) - .onHover { hover in - hovering = hover - } - } -} - -@MainActor -public protocol MessageEventActions { - func toggleReaction(key: String) - func reply() - func replyInThread() - func pin() - func focusUser() -} - -struct MessageTimestampView: View { - let date: Date - let hover: Bool - - var timeFormat: DateFormatter { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm" - return formatter - } - - var body: some View { - HStack { - Text(timeFormat.string(from: date)) - .foregroundStyle(.gray) - .font(.system(.footnote)) - .padding(.trailing, 5) - .padding(.top, 3) - } - .frame(width: 64 - 10) - // .opacity(hover ? 1 : 0) - } -} - -struct MessageMainBody: View { - let event: EventTimelineItem - let message: MessageView - let hover: Bool - let focused: Bool - - var body: some View { - // Main body - HStack(alignment: .top, spacing: 0) { - MessageTimestampView(date: event.date, hover: hover) - message - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.vertical, 4.0) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(focused ? Color.accentColor.opacity(0.1) : Color.gray.opacity(0.1)) - .opacity(hover || focused ? 1 : 0.001) - ) - .padding(.horizontal, 10) - } -} - -public struct MessageEventProfileView: View { - let event: EventTimelineItem - let focusUserAction: () -> Void - let imageLoader: ImageLoader? - - public init(event: EventTimelineItem, focusUserAction: @escaping () -> Void, imageLoader: ImageLoader?) { - self.event = event - self.focusUserAction = focusUserAction - self.imageLoader = imageLoader - } - - var name: String { - if case let .ready(displayName, _, _) = event.senderProfileDetails, let displayName = displayName { - return displayName - } - return event.sender - } - - public var body: some View { - // Profile icon and name - Button(action: focusUserAction) { - HStack(spacing: 0) { - HStack(spacing: 0) { - AvatarImage(userProfile: event, imageLoader: imageLoader) - .frame(width: 32, height: 32) - .clipShape(Circle()) - }.frame(width: 64) - - Username(userProfile: event) - .fontWeight(.bold) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .buttonStyle(.plain) - } -} - -public struct MessageEventBodyView< - MessageView: View, - EventTimelineItem: Models.EventTimelineItem, - Reaction: Models.Reaction, - RoomMember: Models.RoomMember ->: View { - let event: EventTimelineItem - let focused: Bool - let reactions: [Reaction] - let message: MessageView - let actions: MessageEventActions - let imageLoader: ImageLoader? - let ownUserId: String - let roomMembers: [RoomMember] - - public init( - event: EventTimelineItem, - focused: Bool, - reactions: [Reaction], - actions: MessageEventActions, - ownUserID: String, - imageLoader: ImageLoader?, - roomMembers: [RoomMember], - @ViewBuilder message: () -> MessageView - ) { - self.event = event - self.focused = focused - self.reactions = reactions - self.actions = actions - self.ownUserId = ownUserID - self.imageLoader = imageLoader - self.roomMembers = roomMembers - self.message = message() - } - - var name: String { - if case let .ready(displayName, _, _) = event.senderProfileDetails, let displayName = displayName { - return displayName - } - return event.sender - } - - @State private var hoverText: Bool = false - - @ViewBuilder - var hoverActions: some View { - HStack(spacing: 0) { - HoverButton(icon: { Text("👍") }, tooltip: "React") { - actions.toggleReaction(key: "👍") - } - HoverButton(icon: { Text("🎉") }, tooltip: "React") { - actions.toggleReaction(key: "🎉") - } - HoverButton(icon: { Text("❤️") }, tooltip: "React") { - actions.toggleReaction(key: "❤️") - } - Divider().frame(height: 18) - HoverButton(icon: { Image(systemName: "face.smiling") }, tooltip: "React") {} - - if event.canBeRepliedTo { - HoverButton(icon: { Image(systemName: "arrowshape.turn.up.left") }, tooltip: "Reply") { - actions.reply() - } - - HoverButton(icon: { Image(systemName: "ellipsis.message") }, tooltip: "Reply in thread") { - actions.replyInThread() - } - } - - HoverButton(icon: { Image(systemName: "pin") }, tooltip: "Pin") { - actions.pin() - } - } - .padding(2) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(Color(NSColor.controlBackgroundColor)) - .stroke(Color(NSColor.separatorColor), lineWidth: 1) - .shadow(color: .black.opacity(0.1), radius: 4) - ) - .padding(.trailing, 20) - .padding(.top, -30) - .opacity(hoverText ? 1 : 0) - } - - func reactionIsActive(_ reaction: Reaction) -> Bool { - return reaction.senders.contains(where: { $0.senderId == ownUserId }) - } - - public var body: some View { - ZStack(alignment: .topTrailing) { - VStack(spacing: 0) { - MessageMainBody( - event: event, - message: message, - hover: hoverText, - focused: focused - ) - - // Always present to keep view tree stable (avoids NSHostingView layout loop) - HStack { - Spacer().frame(width: 64) - ForEach(reactions) { reaction in - MessageReactionView( - reaction: reaction, - active: Binding( - get: { reactionIsActive(reaction) }, - set: { if $0 != reactionIsActive(reaction) { actions.toggleReaction(key: reaction.key) } } - ) - ) - } - Spacer() - if !event.userReadReceipts.isEmpty { - ReadReciptsView(receipts: event.userReadReceipts, imageLoader: imageLoader, roomMembers: roomMembers) - .padding(.horizontal, 10) - } - } - .padding(.top, hasBottomContent ? 10 : 0) - } - - hoverActions - } - .onHover { hover in - hoverText = hover - } - .padding(.bottom, reactions.isEmpty ? 0 : 10) - } - - private var hasBottomContent: Bool { - !reactions.isEmpty || !event.userReadReceipts.isEmpty - } -} - -public struct MockMessageEventActions: MessageEventActions { - public func toggleReaction(key _: String) {} - public func reply() {} - public func replyInThread() {} - public func pin() {} - public func focusUser() {} -} - -#Preview { - VStack(spacing: 0) { - MessageEventProfileView(event: MockEventTimelineItem(), focusUserAction: {}, imageLoader: nil) - - MessageEventBodyView( - event: MockEventTimelineItem(), - focused: false, - reactions: [MockReaction](), - actions: MockMessageEventActions(), - ownUserID: "user@example.com", - imageLoader: nil, - roomMembers: [MockRoomMember()] - ) { - Text("This is the body of the message") - } - - MessageEventBodyView( - event: MockEventTimelineItem(), - focused: false, - reactions: [MockReaction()], - actions: MockMessageEventActions(), - ownUserID: "user@example.com", - imageLoader: nil, - roomMembers: [MockRoomMember()] - ) { - Text("This is another message from the same sender, this message is long enough that it will wrap to the next line".formatAsMarkdown) - } - - MessageEventBodyView( - event: MockEventTimelineItem(), - focused: false, - reactions: [MockReaction()], - actions: MockMessageEventActions(), - ownUserID: "user@example.com", - imageLoader: nil, - roomMembers: [MockRoomMember()] - ) { - Text("Yet another message") - } - } -} diff --git a/MactrixLibrary/Sources/UI/Timeline/MessageReactionView.swift b/MactrixLibrary/Sources/UI/Timeline/MessageReactionView.swift deleted file mode 100644 index f7a3504..0000000 --- a/MactrixLibrary/Sources/UI/Timeline/MessageReactionView.swift +++ /dev/null @@ -1,61 +0,0 @@ -import Models -import SwiftUI - -struct MessageReactionToggleStyle: ToggleStyle { - func makeBody(configuration: Configuration) -> some View { - Button( - action: { configuration.isOn.toggle() }, - label: { - configuration.label - .background( - configuration.isOn ? - Color.blue.quaternary : Color.gray.quaternary - ) - } - ) - .buttonStyle(.plain) - .overlay { - RoundedRectangle(cornerRadius: 4) - .stroke(configuration.isOn ? Color.blue : Color.gray) - } - } -} - -public struct MessageReactionView: View { - let reaction: Reaction - let active: Binding - - @FocusState private var isFocused: Bool - - public init(reaction: Reaction, active: Binding) { - self.reaction = reaction - self.active = active - } - - var helpText: String { - let sendersString = reaction.senders.prefix(5) - .map { $0.senderId } - .joined(separator: ", ") - - return "Reacted by \(sendersString)" - } - - public var body: some View { - Toggle(isOn: active, label: { - HStack(spacing: 0) { - Text(reaction.key) - Text("\(reaction.senders.count)") - .padding(.horizontal, 6) - } - .padding(4) - }) - .toggleStyle(MessageReactionToggleStyle()) - .help(helpText) - } -} - -#Preview { - @Previewable @State var active = false - MessageReactionView(reaction: MockReaction(), active: $active) - .padding() -} diff --git a/MactrixLibrary/Sources/UI/Timeline/MessageThreadSummary.swift b/MactrixLibrary/Sources/UI/Timeline/MessageThreadSummary.swift deleted file mode 100644 index bfa5834..0000000 --- a/MactrixLibrary/Sources/UI/Timeline/MessageThreadSummary.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Models -import SwiftUI - -public struct MessageThreadSummary: View { - let summary: ThreadSummary - let action: () -> Void - - public init(summary: ThreadSummary, action: @escaping () -> Void) { - self.summary = summary - self.action = action - } - - public var body: some View { - Button(action: action) { - VStack(alignment: .leading) { - Label("Thread (\(summary.numReplies()) messages)", systemImage: "arrow.turn.down.right") - .italic() - - if let description = summary.description { - Text(description.formatAsMarkdown) - .lineLimit(1) - .foregroundStyle(.secondary) - } - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 4) - .opacity(0.05) - ) - } - .buttonStyle(.plain) - } -} - -#Preview { - MessageThreadSummary(summary: MockThreadSummary()) {} - .padding() -} diff --git a/MactrixLibrary/Sources/UI/Timeline/ReadReciptsView.swift b/MactrixLibrary/Sources/UI/Timeline/ReadReciptsView.swift deleted file mode 100644 index 4057f74..0000000 --- a/MactrixLibrary/Sources/UI/Timeline/ReadReciptsView.swift +++ /dev/null @@ -1,147 +0,0 @@ -import Models -import SwiftUI - -struct ReadReciptsView: View { - let receipts: [String: Receipt] - let imageLoader: ImageLoader? - let roomMembers: [RoomMember] - - private let truncatedAvatarLimit = 3 - private let fullAvatarLimit = 4 - @State private var showPopover: Bool = false - - var users: [String] { - receipts - .sorted { a, b in - let a = (a.value.timestamp ?? Date(timeIntervalSince1970: 0)) - let b = (b.value.timestamp ?? Date(timeIntervalSince1970: 0)) - return a < b - } - .map { key, _ in key } - } - - var visibleUsers: [String] { - let shouldTruncate = users.count > fullAvatarLimit - let visibleAvatarLimit = shouldTruncate ? truncatedAvatarLimit : fullAvatarLimit - return Array(users.suffix(visibleAvatarLimit)) - } - - var hiddenCount: Int { - users.count - visibleUsers.count - } - - var popoverUsers: [String] { - users.reversed() - } - - @ViewBuilder - func avatarImage(forUserId userId: String) -> some View { - let user = roomMembers.first(where: { $0.id == userId }) - - if let user { - AvatarImage(userProfile: user, imageLoader: imageLoader) - } else { - AvatarImage(avatarUrl: nil, imageLoader: imageLoader) - } - } - - func userDisplayName(forUserId userId: String) -> String { - let user = roomMembers.first(where: { $0.id == userId }) - return user?.displayName ?? userId - } - - func readByTooltip(forUsers userIds: [String]) -> String { - let names = userIds.map { userDisplayName(forUserId: $0) } - - switch names.count { - case 0: - return "" - case 1: - return "Read by \(names[0])" - case 2: - return "Read by \(names[0]) and \(names[1])" - case 3: - return "Read by \(names[0]), \(names[1]), and \(names[2])" - default: - return "Read by \(names[0]), \(names[1]), and \(names.count - 2) others" - } - } - - func formattedTimestamp(_ date: Date) -> String { - if Calendar.current.isDateInToday(date) { - return date.formatted(.dateTime.hour().minute()) - } - return date.formatted(.dateTime.weekday(.abbreviated).hour().minute()) - } - - var popoverHeader: String { - users.count == 1 ? "Read by 1 person" : "Read by \(users.count) people" - } - - @ViewBuilder - var readReceiptsPopover: some View { - VStack(alignment: .leading) { - Text(popoverHeader) - .font(.headline) - .padding(.bottom, 4) - - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(popoverUsers, id: \.self) { userId in - HStack(spacing: 10) { - avatarImage(forUserId: userId) - .frame(width: 28, height: 28) - .clipShape(Circle()) - - VStack(alignment: .leading, spacing: 2) { - Text(userDisplayName(forUserId: userId)) - .font(.body) - .lineLimit(1) - - if let timestamp = receipts[userId]?.timestamp { - Text(formattedTimestamp(timestamp)) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - .padding(.vertical, 4) - } - } - } - } - .frame(width: 200) - .frame(maxHeight: 250) - .padding() - } - - var body: some View { - Button { - showPopover.toggle() - } label: { - HStack(spacing: -2) { - if hiddenCount > 0 { - Text("+\(hiddenCount)") - .font(.system(.caption2)) - .foregroundStyle(.secondary) - .padding(.trailing, 4) - } - ForEach(visibleUsers, id: \.self) { userId in - avatarImage(forUserId: userId) - .frame(width: 14, height: 14) - .clipShape(Circle()) - .background( - Circle().stroke(Color(NSColor.controlBackgroundColor), lineWidth: 3) - ) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .pointerStyle(.link) - .help(readByTooltip(forUsers: users)) - .popover(isPresented: $showPopover) { - readReceiptsPopover - } - } -} diff --git a/MactrixLibrary/Sources/UI/Timeline/UserTypingIndicator.swift b/MactrixLibrary/Sources/UI/Timeline/UserTypingIndicator.swift deleted file mode 100644 index bff3e8c..0000000 --- a/MactrixLibrary/Sources/UI/Timeline/UserTypingIndicator.swift +++ /dev/null @@ -1,63 +0,0 @@ -import SwiftUI - -public struct UserTypingIndicator: View { - let names: [String] - - public init(names: [String]) { - self.names = names - } - - var namesFormattedText: AttributedString { - var result = AttributedString() - - var namesFormatted = AttributedString(names.joined(separator: ", ")) - namesFormatted.font = .body.bold() - - result.append(namesFormatted) - - if names.count == 1 { - result.append(AttributedString(localized: " is typing")) - } else { - result.append(AttributedString(localized: " are typing")) - } - - return result - } - - @ViewBuilder - var dots: some View { - let dotSize: CGFloat = 6 - - KeyframeAnimator(initialValue: -3.0, repeating: true) { stage in - HStack(spacing: 2) { - ForEach(0 ..< 3, id: \.self) { i in - Circle() - .frame(width: dotSize, height: dotSize) - .opacity(max(1.0 - (stage - Double(i)).magnitude / 6.0, 0.5) - 0.3) - .scaleEffect(max(1.0 - (stage - Double(i)).magnitude / 7.0, 0.9)) - } - } - } keyframes: { _ in - KeyframeTrack { - LinearKeyframe(6.0, duration: 1.8) - } - } - } - - public var body: some View { - HStack { - dots - Text(namesFormattedText) - } - .opacity(names.isEmpty ? 0 : 1) - } -} - -#Preview { - VStack(alignment: .leading) { - UserTypingIndicator(names: ["John Doe"]) - UserTypingIndicator(names: ["John Doe", "Person"]) - UserTypingIndicator(names: []) - } - .padding() -} diff --git a/MactrixLibrary/Sources/UI/Timeline/VirtualItemView.swift b/MactrixLibrary/Sources/UI/Timeline/VirtualItemView.swift deleted file mode 100644 index 05cd947..0000000 --- a/MactrixLibrary/Sources/UI/Timeline/VirtualItemView.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Models -import SwiftUI - -public struct VirtualItemView: View { - @Environment(\.colorScheme) var colorScheme - let item: VirtualTimelineItem - - public init(item: VirtualTimelineItem) { - self.item = item - } - - func formatDate(_ date: Date) -> String { - let dayInSecs: Double = 60 * 60 * 24 - if Date.now.timeIntervalSince(date).isLess(than: dayInSecs) { - return String(localized: "Today") - } else if Date.now.timeIntervalSince(date).isLess(than: dayInSecs * 2) { - return String(localized: "Yesterday") - } else { - return date.formatted(date: .long, time: .omitted) - } - } - - public var body: some View { - switch item { - case let .dateDivider(date): - Divider() - .overlay { - Text(formatDate(date)) - .padding(.horizontal, 10) - .background(Color(NSColor.controlBackgroundColor)) - } - .frame(height: 40) - .padding(.horizontal, 10) - case .readMarker: - Divider() - .overlay { - Text("Read Marker") - .fontWeight(.medium) - .padding(.horizontal, 10) - .background(Color(NSColor.controlBackgroundColor)) - } - .frame(height: 40) - .padding(.horizontal, 10) - .foregroundStyle(.red.mix(with: colorScheme == .light ? .black : .white, by: 0.1)) - case .timelineStart: - Divider() - .overlay { - Text("Start of conversation") - .fontWeight(.medium) - .padding(.horizontal, 10) - .background(Color(NSColor.controlBackgroundColor)) - } - .frame(height: 40) - .padding(.horizontal, 10) - } - } -} - -#Preview { - VStack(spacing: 0) { - VirtualItemView(item: .timelineStart) - VirtualItemView(item: .dateDivider(date: Date())) - VirtualItemView(item: .readMarker) - } - .frame(width: 400) - .background(Color(NSColor.controlBackgroundColor)) -}