From f7d7ddf054b92cfc24bd76716cea89b00b1a6c00 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 20 Feb 2026 12:52:48 +0100 Subject: [PATCH 01/10] Start on NSTableView implementation of timeline --- Mactrix/Extensions/Data+Mime.swift | 4 +- Mactrix/Views/ChatView/ChatView.swift | 3 +- .../TimelineView/TimelineTableView.swift | 82 +++++++++++++++++++ .../TimelineViewRepresentable.swift | 15 ++++ 4 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift create mode 100644 Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift diff --git a/Mactrix/Extensions/Data+Mime.swift b/Mactrix/Extensions/Data+Mime.swift index c5d0a21..4215c46 100644 --- a/Mactrix/Extensions/Data+Mime.swift +++ b/Mactrix/Extensions/Data+Mime.swift @@ -3,9 +3,7 @@ import UniformTypeIdentifiers extension Data { func computeMimeType() -> UTType? { - guard !self.isEmpty else { return nil } - var b: UInt8 = 0 - self.copyBytes(to: &b, count: 1) + guard let b: UInt8 = first else { return nil } switch b { case 0xff: diff --git a/Mactrix/Views/ChatView/ChatView.swift b/Mactrix/Views/ChatView/ChatView.swift index 919be2b..10b8684 100644 --- a/Mactrix/Views/ChatView/ChatView.swift +++ b/Mactrix/Views/ChatView/ChatView.swift @@ -129,7 +129,8 @@ struct ChatJoinedRoom: View { } var body: some View { - ChatTimelineScrollView(timeline: timeline) + // ChatTimelineScrollView(timeline: timeline) + TimelineViewRepresentable() .safeAreaPadding(.bottom, inputHeight ?? 60) // chat input overlay .overlay(alignment: .bottom) { ChatInputView(room: room.room, timeline: timeline, replyTo: $timeline.sendReplyTo, height: $inputHeight) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift new file mode 100644 index 0000000..399190a --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -0,0 +1,82 @@ +import AppKit +import MatrixRustSDK +import SwiftUI + +class TimelineViewController: NSViewController { + let coordinator: TimelineViewRepresentable.Coordinator + + private var dataSource: NSTableViewDiffableDataSource? + + let scrollView = NSScrollView() + let tableView = NSTableView() + + init(coordinator: TimelineViewRepresentable.Coordinator) { + self.coordinator = coordinator + super.init(nibName: nil, bundle: nil) + } + + override func viewDidLoad() { + super.viewDidLoad() + + tableView.addTableColumn(NSTableColumn()) + + dataSource = .init(tableView: tableView) { [weak self] tableView, tableColumn, row, identifier in + _ = tableView + _ = tableColumn + _ = row + _ = identifier + + let hostView = tableView.makeView(withIdentifier: TimelineItemCell.reuseIdentifier, owner: self) + print("Data source called \(row) \(identifier) \(hostView == nil ? "fresh" : "reuse")") + + let view = Text("SwiftUI Text \(row)") + + if let hostView = hostView as? NSHostingView { + print("reusing swift ui view") + hostView.rootView = view + return hostView + } + + let newHostView = NSHostingView(rootView: view) + newHostView.identifier = TimelineItemCell.reuseIdentifier + return newHostView + } + tableView.delegate = self + + scrollView.documentView = tableView + scrollView.hasVerticalScroller = true + view = scrollView + + applySnapshot() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not available") + } + + enum TimelineSection { + case main + case typingIndicator + } + + func applySnapshot() { + guard let dataSource else { return } + + var snapshot = NSDiffableDataSourceSnapshot() + + snapshot.appendSections([.main]) + for i in 0 ..< 10000 { + snapshot.appendItems([.init(id: "item \(i)")], toSection: .main) + } + + dataSource.apply(snapshot, animatingDifferences: false) + print("Applied snapshot") + } +} + +extension TimelineViewController: NSTableViewDelegate {} + +class TimelineItemCell: NSTableCellView { + static var reuseIdentifier: NSUserInterfaceItemIdentifier = .init("TimelineItemCell") +} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift new file mode 100644 index 0000000..3309db0 --- /dev/null +++ b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift @@ -0,0 +1,15 @@ +import SwiftUI + +struct TimelineViewRepresentable: NSViewControllerRepresentable { + func makeCoordinator() -> Coordinator { + return Coordinator() + } + + class Coordinator {} + + func makeNSViewController(context: Context) -> TimelineViewController { + return TimelineViewController(coordinator: context.coordinator) + } + + func updateNSViewController(_ nsViewController: TimelineViewController, context: Context) {} +} From fee43afc04bf92e4a4106588430bc469cafc6ce4 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Fri, 20 Feb 2026 16:31:10 +0100 Subject: [PATCH 02/10] Start to integrate old message views --- Mactrix/Models/LiveTimeline.swift | 48 ++--- Mactrix/Views/ChatView/ChatMessageView.swift | 14 +- Mactrix/Views/ChatView/ChatView.swift | 180 +++++++++--------- .../TimelineView/TimelineTableView.swift | 75 ++++++-- .../TimelineViewRepresentable.swift | 15 +- 5 files changed, 193 insertions(+), 139 deletions(-) diff --git a/Mactrix/Models/LiveTimeline.swift b/Mactrix/Models/LiveTimeline.swift index 7661c7c..7b81e95 100644 --- a/Mactrix/Models/LiveTimeline.swift +++ b/Mactrix/Models/LiveTimeline.swift @@ -22,8 +22,8 @@ public final class LiveTimeline { public var sendReplyTo: MatrixRustSDK.EventTimelineItem? - @ObservationIgnored private var timelineItems: [TimelineItem] = [] - public private(set) var timelineGroups: TimelineGroups = .init() + public private(set) var timelineItems: [TimelineItem] = [] + // public private(set) var timelineGroups: TimelineGroups = .init() public private(set) var paginating: RoomPaginationStatus = .idle(hitTimelineStart: false) public private(set) var hitTimelineStart: Bool = false @@ -138,7 +138,7 @@ public final class LiveTimeline { Logger.liveTimeline.info("focus event: \(eventId.id)") focusedTimelineEventId = eventId - let group = timelineGroups.groups.first { group in + /*let group = timelineGroups.groups.first { group in switch group { case let .messages(messages, _, _): return messages.contains(where: { $0.event.eventOrTransactionId == eventId }) @@ -154,62 +154,62 @@ public final class LiveTimeline { withAnimation { scrollPosition.scrollTo(id: focusedTimelineGroupId) } - } + }*/ } } extension LiveTimeline { private func updateTimeline(diff: [TimelineDiff]) { - let oldView = scrollPosition.viewID - let oldEdge = scrollPosition.edge - Logger.liveTimeline.trace("onUpdate old view \(oldView.debugDescription) \(oldEdge.debugDescription)") + // let oldView = scrollPosition.viewID + // let oldEdge = scrollPosition.edge + //Logger.liveTimeline.trace("onUpdate old view \(oldView.debugDescription) \(oldEdge.debugDescription)") - var updatedIds = Set() + // var updatedIds = Set() for update in diff { switch update { case let .append(values): timelineItems.append(contentsOf: values) - for value in values { - updatedIds.insert(value.uniqueId().id) - } + /* for value in values { + updatedIds.insert(value.uniqueId().id) + } */ case .clear: timelineItems.removeAll() case let .pushFront(room): timelineItems.insert(room, at: 0) - updatedIds.insert(room.uniqueId().id) + // updatedIds.insert(room.uniqueId().id) case let .pushBack(room): timelineItems.append(room) - updatedIds.insert(room.uniqueId().id) + // updatedIds.insert(room.uniqueId().id) case .popFront: timelineItems.removeFirst() case .popBack: timelineItems.removeLast() case let .insert(index, room): timelineItems.insert(room, at: Int(index)) - updatedIds.insert(room.uniqueId().id) + // updatedIds.insert(room.uniqueId().id) case let .set(index, room): timelineItems[Int(index)] = room - updatedIds.insert(room.uniqueId().id) + // updatedIds.insert(room.uniqueId().id) case let .remove(index): timelineItems.remove(at: Int(index)) case let .truncate(length): timelineItems.removeSubrange(Int(length) ..< timelineItems.count) case let .reset(values: values): timelineItems = values - for value in values { - updatedIds.insert(value.uniqueId().id) - } + /* for value in values { + updatedIds.insert(value.uniqueId().id) + } */ } } - timelineGroups.updateItems(items: timelineItems, updatedIds: updatedIds) + /* timelineGroups.updateItems(items: timelineItems, updatedIds: updatedIds) - if let oldEdge { - scrollPosition.scrollTo(edge: oldEdge) - } else if let oldView { - scrollPosition.scrollTo(id: oldView, anchor: .top) - } + if let oldEdge { + scrollPosition.scrollTo(edge: oldEdge) + } else if let oldView { + scrollPosition.scrollTo(id: oldView, anchor: .top) + } */ } } diff --git a/Mactrix/Views/ChatView/ChatMessageView.swift b/Mactrix/Views/ChatView/ChatMessageView.swift index 7828499..fd24b78 100644 --- a/Mactrix/Views/ChatView/ChatMessageView.swift +++ b/Mactrix/Views/ChatView/ChatMessageView.swift @@ -9,7 +9,7 @@ struct ChatMessageView: View, UI.MessageEventActions { @Environment(WindowState.self) private var windowState @AppStorage("fontSize") private var fontSize = 13 - let timeline: LiveTimeline + let timeline: LiveTimeline? let event: MatrixRustSDK.EventTimelineItem let msg: MatrixRustSDK.MsgLikeContent let includeProfileHeader: Bool @@ -23,7 +23,7 @@ struct ChatMessageView: View, UI.MessageEventActions { func toggleReaction(key: String) { Task { - guard let innerTimeline = timeline.timeline else { return } + 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)") @@ -35,7 +35,7 @@ struct ChatMessageView: View, UI.MessageEventActions { func reply() { Logger.viewCycle.info("Reply to event: \(event.eventOrTransactionId.id)") - timeline.sendReplyTo = event + timeline?.sendReplyTo = event } func replyInThread() { @@ -47,7 +47,7 @@ struct ChatMessageView: View, UI.MessageEventActions { guard case let .eventId(eventId: eventId) = event.eventOrTransactionId else { return } Task { do { - let _ = try await timeline.timeline?.pinEvent(eventId: eventId) + let _ = try await timeline?.timeline?.pinEvent(eventId: eventId) } catch { Logger.viewCycle.error("Failed to ping message: \(error)") } @@ -109,7 +109,7 @@ struct ChatMessageView: View, UI.MessageEventActions { } var isEventFocused: Bool { - return timeline.focusedTimelineEventId == event.eventOrTransactionId + return timeline?.focusedTimelineEventId == event.eventOrTransactionId } var ownUserId: String { @@ -126,11 +126,11 @@ struct ChatMessageView: View, UI.MessageEventActions { UI.MessageEventProfileView(event: event, actions: self, 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: isEventFocused, reactions: msg.reactions, actions: self, ownUserID: ownUserId, imageLoader: appState.matrixClient, roomMembers: timeline?.room.members ?? []) { VStack(alignment: .leading, spacing: 10) { if let replyTo = msg.inReplyTo { EmbeddedMessageView(embeddedEvent: replyTo.event()) { - timeline.focusEvent(id: .eventId(eventId: replyTo.eventId())) + timeline?.focusEvent(id: .eventId(eventId: replyTo.eventId())) } .padding(.bottom, 10) } diff --git a/Mactrix/Views/ChatView/ChatView.swift b/Mactrix/Views/ChatView/ChatView.swift index 10b8684..0165451 100644 --- a/Mactrix/Views/ChatView/ChatView.swift +++ b/Mactrix/Views/ChatView/ChatView.swift @@ -22,95 +22,95 @@ struct TimelineGroupView: View { } } -struct TimelineItemsView: View { - let timeline: LiveTimeline - - var body: some View { - if !timeline.timelineGroups.groups.isEmpty { - LazyVStack { - ForEach(timeline.timelineGroups.groups) { item in - TimelineGroupView(timeline: timeline, timelineGroup: item) - } - } - .scrollTargetLayout() - } else { - ProgressView() - } - } -} - -struct ChatTimelineScrollView: View { - @Bindable var timeline: LiveTimeline - - @State private var scrollNearTop: Bool = false - - func loadMoreMessages() { - guard scrollNearTop else { return } - guard timeline.paginating == .idle(hitTimelineStart: false) else { - let p = timeline.paginating.debugDescription - Logger.viewCycle.info("Fetching messages cancelled, already: paginating \(p)") - return - } - Logger.viewCycle.info("Reached top, fetching more messages...") - - Task { - do { - try await self.timeline.fetchOlderMessages() - -// if scrollNearTop { -// try await Task.sleep(for: .seconds(1)) -// loadMoreMessages() -// } - } catch { - Logger.viewCycle.error("failed to fetch more message for timeline: \(error)") - } - } - } - - var body: some View { - ScrollView { - ProgressView("Loading more messages") - .opacity(timeline.paginating == .paginating ? 1 : 0) - - TimelineItemsView(timeline: timeline) - - if let errorMessage = timeline.errorMessage { - Text(errorMessage) - .foregroundStyle(Color.red) - .frame(maxWidth: .infinity) - } - - HStack { - UI.UserTypingIndicator(names: timeline.room.typingUserIds) - Spacer() - } - .padding(.horizontal, 10) - } - .scrollPosition($timeline.scrollPosition) - .defaultScrollAnchor(.bottom) - .onScrollGeometryChange(for: Bool.self) { geo in - geo.visibleRect.maxY - geo.containerSize.height < 400.0 - } action: { _, nearTop in - Logger.viewCycle.info("scroll near top: \(nearTop)") - scrollNearTop = nearTop - if nearTop { - loadMoreMessages() - } - } - .task(id: timeline.timelineGroups, priority: .background) { - do { - try await Task.sleep(for: .seconds(1)) - - Logger.viewCycle.debug("Mark room as read") - try await timeline.timeline?.markAsRead(receiptType: .read) - } catch is CancellationError { - /* sleep cancelled */ - } catch { - Logger.viewCycle.error("failed to send timeline read receipt: \(error)") - } - } - } -} +/* struct TimelineItemsView: View { + let timeline: LiveTimeline + + var body: some View { + if !timeline.timelineGroups.groups.isEmpty { + LazyVStack { + ForEach(timeline.timelineGroups.groups) { item in + TimelineGroupView(timeline: timeline, timelineGroup: item) + } + } + .scrollTargetLayout() + } else { + ProgressView() + } + } + } */ + +/* struct ChatTimelineScrollView: View { + @Bindable var timeline: LiveTimeline + + @State private var scrollNearTop: Bool = false + + func loadMoreMessages() { + guard scrollNearTop else { return } + guard timeline.paginating == .idle(hitTimelineStart: false) else { + let p = timeline.paginating.debugDescription + Logger.viewCycle.info("Fetching messages cancelled, already: paginating \(p)") + return + } + Logger.viewCycle.info("Reached top, fetching more messages...") + + Task { + do { + try await self.timeline.fetchOlderMessages() + + // if scrollNearTop { + // try await Task.sleep(for: .seconds(1)) + // loadMoreMessages() + // } + } catch { + Logger.viewCycle.error("failed to fetch more message for timeline: \(error)") + } + } + } + + var body: some View { + ScrollView { + ProgressView("Loading more messages") + .opacity(timeline.paginating == .paginating ? 1 : 0) + + TimelineItemsView(timeline: timeline) + + if let errorMessage = timeline.errorMessage { + Text(errorMessage) + .foregroundStyle(Color.red) + .frame(maxWidth: .infinity) + } + + HStack { + UI.UserTypingIndicator(names: timeline.room.typingUserIds) + Spacer() + } + .padding(.horizontal, 10) + } + .scrollPosition($timeline.scrollPosition) + .defaultScrollAnchor(.bottom) + .onScrollGeometryChange(for: Bool.self) { geo in + geo.visibleRect.maxY - geo.containerSize.height < 400.0 + } action: { _, nearTop in + Logger.viewCycle.info("scroll near top: \(nearTop)") + scrollNearTop = nearTop + if nearTop { + loadMoreMessages() + } + } + .task(id: timeline.timelineGroups, priority: .background) { + do { + try await Task.sleep(for: .seconds(1)) + + Logger.viewCycle.debug("Mark room as read") + try await timeline.timeline?.markAsRead(receiptType: .read) + } catch is CancellationError { + /* sleep cancelled */ + } catch { + Logger.viewCycle.error("failed to send timeline read receipt: \(error)") + } + } + } + } */ struct ChatJoinedRoom: View { @Environment(AppState.self) private var appState @@ -130,7 +130,7 @@ struct ChatJoinedRoom: View { var body: some View { // ChatTimelineScrollView(timeline: timeline) - TimelineViewRepresentable() + TimelineViewRepresentable(timelineItems: timeline.timelineItems) .safeAreaPadding(.bottom, inputHeight ?? 60) // chat input overlay .overlay(alignment: .bottom) { ChatInputView(room: room.room, timeline: timeline, replyTo: $timeline.sendReplyTo, height: $inputHeight) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 399190a..897be9a 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -1,6 +1,25 @@ import AppKit import MatrixRustSDK import SwiftUI +import UI + +struct TimelineItemView: View { + let item: TimelineItem + + var body: some View { + if let virtual = item.asVirtual() { + UI.VirtualItemView(item: virtual.asModel) + } else if let event = item.asEvent() { + if case let .msgLike(content: content) = event.content { + ChatMessageView(timeline: nil, event: event, msg: content, includeProfileHeader: true) + } else { + Text("Not msg like") + } + } else { + Text("Invalid timeline item") + } + } +} class TimelineViewController: NSViewController { let coordinator: TimelineViewRepresentable.Coordinator @@ -8,10 +27,13 @@ class TimelineViewController: NSViewController { private var dataSource: NSTableViewDiffableDataSource? let scrollView = NSScrollView() - let tableView = NSTableView() + let tableView = BottomStickyTableView() + + var timelineItems: [TimelineItem] - init(coordinator: TimelineViewRepresentable.Coordinator) { + init(coordinator: TimelineViewRepresentable.Coordinator, timelineItems: [TimelineItem]) { self.coordinator = coordinator + self.timelineItems = timelineItems super.init(nibName: nil, bundle: nil) } @@ -19,6 +41,12 @@ class TimelineViewController: NSViewController { super.viewDidLoad() tableView.addTableColumn(NSTableColumn()) + tableView.headerView = nil + tableView.style = .plain + tableView.allowsColumnSelection = false + + tableView.rowHeight = -1 + tableView.usesAutomaticRowHeights = true dataSource = .init(tableView: tableView) { [weak self] tableView, tableColumn, row, identifier in _ = tableView @@ -26,18 +54,22 @@ class TimelineViewController: NSViewController { _ = row _ = identifier + guard let self else { return NSView() } + let hostView = tableView.makeView(withIdentifier: TimelineItemCell.reuseIdentifier, owner: self) print("Data source called \(row) \(identifier) \(hostView == nil ? "fresh" : "reuse")") - let view = Text("SwiftUI Text \(row)") + // let view = Text("SwiftUI Text \(row)") - if let hostView = hostView as? NSHostingView { + let view = TimelineItemView(item: timelineItems[row]) + + if let hostView = hostView as? NSHostingView { print("reusing swift ui view") hostView.rootView = view return hostView } - let newHostView = NSHostingView(rootView: view) + let newHostView = NSHostingView(rootView: view) newHostView.identifier = TimelineItemCell.reuseIdentifier return newHostView } @@ -46,8 +78,6 @@ class TimelineViewController: NSViewController { scrollView.documentView = tableView scrollView.hasVerticalScroller = true view = scrollView - - applySnapshot() } @available(*, unavailable) @@ -60,23 +90,38 @@ class TimelineViewController: NSViewController { case typingIndicator } - func applySnapshot() { - guard let dataSource else { return } + func updateTimelineItems(_ timelineItems: [TimelineItem]) { + print("update timeline items") + self.timelineItems = timelineItems var snapshot = NSDiffableDataSourceSnapshot() - snapshot.appendSections([.main]) - for i in 0 ..< 10000 { - snapshot.appendItems([.init(id: "item \(i)")], toSection: .main) + + for item in timelineItems { + snapshot.appendItems([.init(id: item.uniqueId().id)], toSection: .main) } - dataSource.apply(snapshot, animatingDifferences: false) - print("Applied snapshot") + dataSource?.apply(snapshot, animatingDifferences: false) } } -extension TimelineViewController: NSTableViewDelegate {} +extension TimelineViewController: NSTableViewDelegate { + func selectionShouldChange(in tableView: NSTableView) -> Bool { + return false + } + + func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { + return false + } +} class TimelineItemCell: NSTableCellView { static var reuseIdentifier: NSUserInterfaceItemIdentifier = .init("TimelineItemCell") } + +class BottomStickyTableView: NSTableView { + // By returning false, the table starts drawing from the bottom up + override var isFlipped: Bool { + return false + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift index 3309db0..b7ca569 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift @@ -1,15 +1,24 @@ +import MatrixRustSDK import SwiftUI struct TimelineViewRepresentable: NSViewControllerRepresentable { + let timelineItems: [TimelineItem] + + init(timelineItems: [TimelineItem]) { + self.timelineItems = timelineItems + } + func makeCoordinator() -> Coordinator { return Coordinator() } - + class Coordinator {} func makeNSViewController(context: Context) -> TimelineViewController { - return TimelineViewController(coordinator: context.coordinator) + return TimelineViewController(coordinator: context.coordinator, timelineItems: timelineItems) } - func updateNSViewController(_ nsViewController: TimelineViewController, context: Context) {} + func updateNSViewController(_ timelineViewController: TimelineViewController, context: Context) { + timelineViewController.updateTimelineItems(timelineItems) + } } From 886380689e5686b1447d634cca605d31b8d3dad3 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sat, 21 Feb 2026 09:33:41 +0100 Subject: [PATCH 03/10] Split timeline types in 3 for better reuse --- Mactrix/Views/ChatView/ChatMessageView.swift | 5 +- Mactrix/Views/ChatView/ChatView.swift | 2 +- .../TimelineView/TimelineTableView.swift | 106 ++++++++++++------ .../TimelineViewRepresentable.swift | 24 ++-- 4 files changed, 96 insertions(+), 41 deletions(-) diff --git a/Mactrix/Views/ChatView/ChatMessageView.swift b/Mactrix/Views/ChatView/ChatMessageView.swift index fd24b78..8a4baf6 100644 --- a/Mactrix/Views/ChatView/ChatMessageView.swift +++ b/Mactrix/Views/ChatView/ChatMessageView.swift @@ -80,8 +80,11 @@ struct ChatMessageView: View, UI.MessageEventActions { Text(content.body.formatAsMarkdown) .textSelection(.enabled) .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) case let .text(content: content): - Text(content.body.formatAsMarkdown).textSelection(.enabled) + Text(content.body.formatAsMarkdown) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) case let .location(content: content): Text("Location: \(content.body) \(content.geoUri)").textSelection(.enabled) case let .other(msgtype: msgtype, body: body): diff --git a/Mactrix/Views/ChatView/ChatView.swift b/Mactrix/Views/ChatView/ChatView.swift index 0165451..4628828 100644 --- a/Mactrix/Views/ChatView/ChatView.swift +++ b/Mactrix/Views/ChatView/ChatView.swift @@ -130,7 +130,7 @@ struct ChatJoinedRoom: View { var body: some View { // ChatTimelineScrollView(timeline: timeline) - TimelineViewRepresentable(timelineItems: timeline.timelineItems) + TimelineViewRepresentable(timeline: timeline, items: timeline.timelineItems) .safeAreaPadding(.bottom, inputHeight ?? 60) // chat input overlay .overlay(alignment: .bottom) { ChatInputView(room: room.room, timeline: timeline, replyTo: $timeline.sendReplyTo, height: $inputHeight) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 897be9a..81835ff 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -3,21 +3,39 @@ import MatrixRustSDK import SwiftUI import UI -struct TimelineItemView: View { - let item: TimelineItem - - var body: some View { - if let virtual = item.asVirtual() { - UI.VirtualItemView(item: virtual.asModel) - } else if let event = item.asEvent() { - if case let .msgLike(content: content) = event.content { - ChatMessageView(timeline: nil, event: event, msg: content, includeProfileHeader: true) - } else { - Text("Not msg like") +enum TimelineItemRowInfo { + case message(event: EventTimelineItem, content: MsgLikeContent) + case state(event: EventTimelineItem) + case virtual(virtual: VirtualTimelineItem) + + var reuseIdentifier: NSUserInterfaceItemIdentifier { + switch self { + case .message: + return NSUserInterfaceItemIdentifier("message") + case .state: + return NSUserInterfaceItemIdentifier("state") + case .virtual: + return NSUserInterfaceItemIdentifier("virtual") + } + } +} + +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) } - } else { - Text("Invalid timeline item") } + + fatalError("unreachable state: item must be either virtual or event") } } @@ -29,10 +47,12 @@ class TimelineViewController: NSViewController { let scrollView = NSScrollView() let tableView = BottomStickyTableView() + let timeline: LiveTimeline var timelineItems: [TimelineItem] - init(coordinator: TimelineViewRepresentable.Coordinator, timelineItems: [TimelineItem]) { + init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline, timelineItems: [TimelineItem]) { self.coordinator = coordinator + self.timeline = timeline self.timelineItems = timelineItems super.init(nibName: nil, bundle: nil) } @@ -48,30 +68,52 @@ class TimelineViewController: NSViewController { tableView.rowHeight = -1 tableView.usesAutomaticRowHeights = true - dataSource = .init(tableView: tableView) { [weak self] tableView, tableColumn, row, identifier in - _ = tableView - _ = tableColumn - _ = row - _ = identifier - + dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, identifier in guard let self else { return NSView() } let hostView = tableView.makeView(withIdentifier: TimelineItemCell.reuseIdentifier, owner: self) print("Data source called \(row) \(identifier) \(hostView == nil ? "fresh" : "reuse")") - // let view = Text("SwiftUI Text \(row)") - - let view = TimelineItemView(item: timelineItems[row]) - - if let hostView = hostView as? NSHostingView { - print("reusing swift ui view") - hostView.rootView = view - return hostView + let item = timelineItems[row] + + switch item.rowInfo { + case .message(event: let event, content: let content): + let view = ChatMessageView(timeline: nil, event: event, msg: content, includeProfileHeader: true) + + if let hostView = hostView as? NSHostingView { + print("reusing message view") + hostView.rootView = view + return hostView + } else { + let newHostView = NSHostingView(rootView: view) + newHostView.identifier = TimelineItemCell.reuseIdentifier + return newHostView + } + case .state(event: let event): + let view = UI.GenericEventView(event: event, name: event.content.description) + + if let hostView = hostView as? NSHostingView> { + print("reusing state view") + hostView.rootView = view + return hostView + } else { + let newHostView = NSHostingView>(rootView: view) + newHostView.identifier = TimelineItemCell.reuseIdentifier + return newHostView + } + case .virtual(virtual: let virtual): + let view = UI.VirtualItemView(item: virtual.asModel) + + if let hostView = hostView as? NSHostingView { + print("reusing virtual view") + hostView.rootView = view + return hostView + } else { + let newHostView = NSHostingView(rootView: view) + newHostView.identifier = TimelineItemCell.reuseIdentifier + return newHostView + } } - - let newHostView = NSHostingView(rootView: view) - newHostView.identifier = TimelineItemCell.reuseIdentifier - return newHostView } tableView.delegate = self diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift index b7ca569..bc9f618 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift @@ -2,23 +2,33 @@ import MatrixRustSDK import SwiftUI struct TimelineViewRepresentable: NSViewControllerRepresentable { - let timelineItems: [TimelineItem] + @Environment(AppState.self) private var appState - init(timelineItems: [TimelineItem]) { - self.timelineItems = timelineItems + let timeline: LiveTimeline + let items: [TimelineItem] + + init(timeline: LiveTimeline, items: [TimelineItem]) { + self.timeline = timeline + self.items = items } func makeCoordinator() -> Coordinator { - return Coordinator() + return Coordinator(appState: appState) } - class Coordinator {} + class Coordinator { + let appState: AppState + + init(appState: AppState) { + self.appState = appState + } + } func makeNSViewController(context: Context) -> TimelineViewController { - return TimelineViewController(coordinator: context.coordinator, timelineItems: timelineItems) + return TimelineViewController(coordinator: context.coordinator, timeline: timeline, timelineItems: items) } func updateNSViewController(_ timelineViewController: TimelineViewController, context: Context) { - timelineViewController.updateTimelineItems(timelineItems) + timelineViewController.updateTimelineItems(items) } } From 55dc3e9ccaefff9e2b24e6e95cfed0a118c2dc2a Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sat, 21 Feb 2026 17:49:58 +0100 Subject: [PATCH 04/10] Work on row height resizing --- .../TimelineView/TimelineTableView.swift | 113 +++++++++++------- 1 file changed, 73 insertions(+), 40 deletions(-) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 81835ff..933109d 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -20,6 +20,27 @@ enum TimelineItemRowInfo { } } +struct TimelineItemRowView: View { + let rowInfo: TimelineItemRowInfo + let appState: AppState + + @ViewBuilder + var contentView: some View { + switch rowInfo { + case .message(let event, let content): + ChatMessageView(timeline: nil, event: event, msg: content, includeProfileHeader: true) + case .state(let event): + UI.GenericEventView(event: event, name: event.content.description) + case .virtual(let virtual): + UI.VirtualItemView(item: virtual.asModel) + } + } + + var body: some View { + contentView.environment(appState) + } +} + extension TimelineItem { var rowInfo: TimelineItemRowInfo { if let virtual = asVirtual() { @@ -65,8 +86,10 @@ class TimelineViewController: NSViewController { tableView.style = .plain tableView.allowsColumnSelection = false - tableView.rowHeight = -1 - tableView.usesAutomaticRowHeights = true + tableView.rowHeight = 50 // estimate + tableView.usesAutomaticRowHeights = false + + oldWidth = tableView.frame.width dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, identifier in guard let self else { return NSView() } @@ -75,51 +98,44 @@ class TimelineViewController: NSViewController { print("Data source called \(row) \(identifier) \(hostView == nil ? "fresh" : "reuse")") let item = timelineItems[row] - - switch item.rowInfo { - case .message(event: let event, content: let content): - let view = ChatMessageView(timeline: nil, event: event, msg: content, includeProfileHeader: true) - - if let hostView = hostView as? NSHostingView { - print("reusing message view") - hostView.rootView = view - return hostView - } else { - let newHostView = NSHostingView(rootView: view) - newHostView.identifier = TimelineItemCell.reuseIdentifier - return newHostView - } - case .state(event: let event): - let view = UI.GenericEventView(event: event, name: event.content.description) - - if let hostView = hostView as? NSHostingView> { - print("reusing state view") - hostView.rootView = view - return hostView - } else { - let newHostView = NSHostingView>(rootView: view) - newHostView.identifier = TimelineItemCell.reuseIdentifier - return newHostView - } - case .virtual(virtual: let virtual): - let view = UI.VirtualItemView(item: virtual.asModel) - - if let hostView = hostView as? NSHostingView { - print("reusing virtual view") - hostView.rootView = view - return hostView - } else { - let newHostView = NSHostingView(rootView: view) - newHostView.identifier = TimelineItemCell.reuseIdentifier - return newHostView - } + let view = TimelineItemRowView(rowInfo: item.rowInfo, appState: coordinator.appState) + + if let hostView = hostView as? NSHostingView { + print("reusing message view") + hostView.rootView = view + return hostView + } else { + let newHostView = NSHostingView(rootView: view) + newHostView.identifier = item.rowInfo.reuseIdentifier + return newHostView } } + tableView.delegate = self scrollView.documentView = tableView scrollView.hasVerticalScroller = true view = scrollView + + // 1. Tell the clip view to post notifications when its bounds change (resize) + scrollView.contentView.postsBoundsChangedNotifications = true + + // 2. Observe that notification + NotificationCenter.default.addObserver( + self, + selector: #selector(handleTableResize), + name: NSView.frameDidChangeNotification, + object: scrollView.contentView + ) + } + + @objc func handleTableResize(_ notification: Notification) { + print("table view resize \(oldWidth.debugDescription) \(tableView.frame.width)") + // 3. This forces the table to re-call `heightOfRow` for all visible rows + if oldWidth != tableView.frame.width { + oldWidth = tableView.frame.width + tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: 0 ..< timelineItems.count)) + } } @available(*, unavailable) @@ -145,6 +161,10 @@ class TimelineViewController: NSViewController { dataSource?.apply(snapshot, animatingDifferences: false) } + + // values used to calculate height of a row + var oldWidth: CGFloat? + let measurementHostingView = NSHostingController(rootView: AnyView(EmptyView())) } extension TimelineViewController: NSTableViewDelegate { @@ -155,6 +175,19 @@ extension TimelineViewController: NSTableViewDelegate { func tableView(_ tableView: NSTableView, shouldSelectRow row: Int) -> Bool { return false } + + func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { + let item = timelineItems[row] + + measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item.rowInfo, appState: coordinator.appState)) + + let proposedSize = CGSize(width: tableView.frame.width, height: CGFloat.greatestFiniteMagnitude) + + let size = measurementHostingView.sizeThatFits(in: proposedSize) + print("Size of row \(row): \(size)") + + return size.height + } } class TimelineItemCell: NSTableCellView { From ddf47ca2d3da1f83550328435f7e4d1f0c06367e Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 22 Feb 2026 08:37:00 +0100 Subject: [PATCH 05/10] Small code restructuring --- .../TimelineView/TimelineTableView.swift | 38 +++++++++++++------ .../TimelineViewRepresentable.swift | 7 +++- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 933109d..a25e258 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -23,6 +23,13 @@ enum TimelineItemRowInfo { struct TimelineItemRowView: View { let rowInfo: TimelineItemRowInfo let appState: AppState + let windowState: WindowState + + init(rowInfo: TimelineItemRowInfo, coordinator: TimelineViewRepresentable.Coordinator) { + self.rowInfo = rowInfo + self.appState = coordinator.appState + self.windowState = coordinator.windowState + } @ViewBuilder var contentView: some View { @@ -37,7 +44,13 @@ struct TimelineItemRowView: View { } var body: some View { - contentView.environment(appState) + HStack(alignment: .bottom, spacing: 0) { + VStack(spacing: 0) { + contentView + .environment(appState) + .environment(windowState) + } + } } } @@ -94,21 +107,24 @@ class TimelineViewController: NSViewController { dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, identifier in guard let self else { return NSView() } - let hostView = tableView.makeView(withIdentifier: TimelineItemCell.reuseIdentifier, owner: self) - print("Data source called \(row) \(identifier) \(hostView == nil ? "fresh" : "reuse")") + print("Data source called \(row) \(identifier)") let item = timelineItems[row] - let view = TimelineItemRowView(rowInfo: item.rowInfo, appState: coordinator.appState) + let view = TimelineItemRowView(rowInfo: item.rowInfo, coordinator: coordinator) - if let hostView = hostView as? NSHostingView { + let hostView: NSHostingView + if let recycledView = tableView.makeView(withIdentifier: TimelineItemCell.reuseIdentifier, owner: self) + as? NSHostingView + { print("reusing message view") - hostView.rootView = view - return hostView + recycledView.rootView = view + hostView = recycledView } else { - let newHostView = NSHostingView(rootView: view) - newHostView.identifier = item.rowInfo.reuseIdentifier - return newHostView + hostView = NSHostingView(rootView: view) + hostView.identifier = item.rowInfo.reuseIdentifier } + + return hostView } tableView.delegate = self @@ -179,7 +195,7 @@ extension TimelineViewController: NSTableViewDelegate { func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { let item = timelineItems[row] - measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item.rowInfo, appState: coordinator.appState)) + measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item.rowInfo, coordinator: coordinator)) let proposedSize = CGSize(width: tableView.frame.width, height: CGFloat.greatestFiniteMagnitude) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift index bc9f618..2777ed1 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineViewRepresentable.swift @@ -3,6 +3,7 @@ import SwiftUI struct TimelineViewRepresentable: NSViewControllerRepresentable { @Environment(AppState.self) private var appState + @Environment(WindowState.self) private var windowState let timeline: LiveTimeline let items: [TimelineItem] @@ -13,14 +14,16 @@ struct TimelineViewRepresentable: NSViewControllerRepresentable { } func makeCoordinator() -> Coordinator { - return Coordinator(appState: appState) + return Coordinator(appState: appState, windowState: windowState) } class Coordinator { let appState: AppState + let windowState: WindowState - init(appState: AppState) { + init(appState: AppState, windowState: WindowState) { self.appState = appState + self.windowState = windowState } } From 19fd3fd1c6390c9cf6b63312ac70e12323a47545 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Sun, 22 Feb 2026 11:10:10 +0100 Subject: [PATCH 06/10] Correctly calculate row heights --- .../TimelineView/TimelineTableView.swift | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index a25e258..0adeaff 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -99,8 +99,8 @@ class TimelineViewController: NSViewController { tableView.style = .plain tableView.allowsColumnSelection = false - tableView.rowHeight = 50 // estimate - tableView.usesAutomaticRowHeights = false + tableView.rowHeight = -1 + tableView.usesAutomaticRowHeights = true oldWidth = tableView.frame.width @@ -113,7 +113,7 @@ class TimelineViewController: NSViewController { let view = TimelineItemRowView(rowInfo: item.rowInfo, coordinator: coordinator) let hostView: NSHostingView - if let recycledView = tableView.makeView(withIdentifier: TimelineItemCell.reuseIdentifier, owner: self) + if let recycledView = tableView.makeView(withIdentifier: item.rowInfo.reuseIdentifier, owner: self) as? NSHostingView { print("reusing message view") @@ -122,6 +122,9 @@ class TimelineViewController: NSViewController { } else { hostView = NSHostingView(rootView: view) hostView.identifier = item.rowInfo.reuseIdentifier + hostView.autoresizingMask = [.width, .height] + hostView.sizingOptions = [.preferredContentSize] + hostView.setContentHuggingPriority(.required, for: .vertical) } return hostView @@ -147,10 +150,14 @@ class TimelineViewController: NSViewController { @objc func handleTableResize(_ notification: Notification) { print("table view resize \(oldWidth.debugDescription) \(tableView.frame.width)") - // 3. This forces the table to re-call `heightOfRow` for all visible rows if oldWidth != tableView.frame.width { oldWidth = tableView.frame.width - tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: 0 ..< timelineItems.count)) + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0 + context.allowsImplicitAnimation = false + tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: 0 ..< timelineItems.count)) + } } } @@ -180,7 +187,11 @@ class TimelineViewController: NSViewController { // values used to calculate height of a row var oldWidth: CGFloat? - let measurementHostingView = NSHostingController(rootView: AnyView(EmptyView())) + let measurementHostingView = { + let hostView = NSHostingController(rootView: AnyView(EmptyView())) + hostView.sizingOptions = [.preferredContentSize] + return hostView + }() } extension TimelineViewController: NSTableViewDelegate { @@ -197,7 +208,8 @@ extension TimelineViewController: NSTableViewDelegate { measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item.rowInfo, coordinator: coordinator)) - let proposedSize = CGSize(width: tableView.frame.width, height: CGFloat.greatestFiniteMagnitude) + let targetWidth = tableView.tableColumns[0].width + let proposedSize = CGSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude) let size = measurementHostingView.sizeThatFits(in: proposedSize) print("Size of row \(row): \(size)") @@ -206,10 +218,6 @@ extension TimelineViewController: NSTableViewDelegate { } } -class TimelineItemCell: NSTableCellView { - static var reuseIdentifier: NSUserInterfaceItemIdentifier = .init("TimelineItemCell") -} - class BottomStickyTableView: NSTableView { // By returning false, the table starts drawing from the bottom up override var isFlipped: Bool { From 0a8869a641f9922b0aba40253902884981d0a12c Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 24 Feb 2026 19:16:30 +0100 Subject: [PATCH 07/10] General cleanup and improvements to new timeline --- Mactrix/Extensions/Logger.swift | 1 + Mactrix/Models/LiveTimeline.swift | 43 +++++++++++-------- .../TimelineView/TimelineTableView.swift | 22 +++++----- 3 files changed, 37 insertions(+), 29 deletions(-) diff --git a/Mactrix/Extensions/Logger.swift b/Mactrix/Extensions/Logger.swift index 01d869f..4e7ee35 100644 --- a/Mactrix/Extensions/Logger.swift +++ b/Mactrix/Extensions/Logger.swift @@ -10,6 +10,7 @@ extension Logger { static let liveSpaceService = Logger(subsystem: subsystem, category: "live-space-service") static let liveSpaceRoomList = Logger(subsystem: subsystem, category: "live-space-room-list") static let liveTimeline = Logger(subsystem: subsystem, category: "live-timeline") + static let timelineTableView = Logger(subsystem: subsystem, category: "timeline-table-view") static let SidebarRoom = Logger(subsystem: subsystem, category: "sidebar-room") static let viewCycle = Logger(subsystem: subsystem, category: "viewcycle") diff --git a/Mactrix/Models/LiveTimeline.swift b/Mactrix/Models/LiveTimeline.swift index 7b81e95..416e84c 100644 --- a/Mactrix/Models/LiveTimeline.swift +++ b/Mactrix/Models/LiveTimeline.swift @@ -120,6 +120,10 @@ public final class LiveTimeline { Logger.liveTimeline.debug("updating timeline paginating: \(status.debugDescription)") paginating = status + + if paginating == .idle(hitTimelineStart: false) && timelineItems.count < 20 { + try await fetchOlderMessages() + } } } } @@ -131,30 +135,31 @@ public final class LiveTimeline { return } - _ = try await timeline?.paginateBackwards(numEvents: 100) + Logger.liveTimeline.info("fetch more messages") + _ = try await timeline?.paginateBackwards(numEvents: 20) } public func focusEvent(id eventId: EventOrTransactionId) { Logger.liveTimeline.info("focus event: \(eventId.id)") focusedTimelineEventId = eventId - /*let group = timelineGroups.groups.first { group in - switch group { - case let .messages(messages, _, _): - return messages.contains(where: { $0.event.eventOrTransactionId == eventId }) - case .stateChanges: - return false - case .virtual: - return false - } - } - focusedTimelineGroupId = group?.id - - if let focusedTimelineGroupId { - withAnimation { - scrollPosition.scrollTo(id: focusedTimelineGroupId) - } - }*/ + /* let group = timelineGroups.groups.first { group in + switch group { + case let .messages(messages, _, _): + return messages.contains(where: { $0.event.eventOrTransactionId == eventId }) + case .stateChanges: + return false + case .virtual: + return false + } + } + focusedTimelineGroupId = group?.id + + if let focusedTimelineGroupId { + withAnimation { + scrollPosition.scrollTo(id: focusedTimelineGroupId) + } + } */ } } @@ -162,7 +167,7 @@ extension LiveTimeline { private func updateTimeline(diff: [TimelineDiff]) { // let oldView = scrollPosition.viewID // let oldEdge = scrollPosition.edge - //Logger.liveTimeline.trace("onUpdate old view \(oldView.debugDescription) \(oldEdge.debugDescription)") + // Logger.liveTimeline.trace("onUpdate old view \(oldView.debugDescription) \(oldEdge.debugDescription)") // var updatedIds = Set() diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 0adeaff..3e76274 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -1,5 +1,6 @@ import AppKit import MatrixRustSDK +import OSLog import SwiftUI import UI @@ -104,11 +105,9 @@ class TimelineViewController: NSViewController { oldWidth = tableView.frame.width - dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, identifier in + dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, _ in guard let self else { return NSView() } - print("Data source called \(row) \(identifier)") - let item = timelineItems[row] let view = TimelineItemRowView(rowInfo: item.rowInfo, coordinator: coordinator) @@ -116,7 +115,7 @@ class TimelineViewController: NSViewController { if let recycledView = tableView.makeView(withIdentifier: item.rowInfo.reuseIdentifier, owner: self) as? NSHostingView { - print("reusing message view") + Logger.timelineTableView.debug("reusing message view") recycledView.rootView = view hostView = recycledView } else { @@ -149,14 +148,17 @@ class TimelineViewController: NSViewController { } @objc func handleTableResize(_ notification: Notification) { - print("table view resize \(oldWidth.debugDescription) \(tableView.frame.width)") if oldWidth != tableView.frame.width { oldWidth = tableView.frame.width NSAnimationContext.runAnimationGroup { context in context.duration = 0 context.allowsImplicitAnimation = false - tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: 0 ..< timelineItems.count)) + + // Update only the height of visible rows + let visibleRect = tableView.visibleRect + let visibleRows = tableView.rows(in: visibleRect) + tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: visibleRows.lowerBound ..< visibleRows.upperBound)) } } } @@ -172,13 +174,13 @@ class TimelineViewController: NSViewController { } func updateTimelineItems(_ timelineItems: [TimelineItem]) { - print("update timeline items") - self.timelineItems = timelineItems + Logger.timelineTableView.info("update timeline items") + self.timelineItems = timelineItems.reversed() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) - for item in timelineItems { + for item in self.timelineItems { snapshot.appendItems([.init(id: item.uniqueId().id)], toSection: .main) } @@ -212,7 +214,7 @@ extension TimelineViewController: NSTableViewDelegate { let proposedSize = CGSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude) let size = measurementHostingView.sizeThatFits(in: proposedSize) - print("Size of row \(row): \(size)") + Logger.timelineTableView.debug("Size of row \(row): \(size.height)") return size.height } From 0f81bd77845392dac18103996d1995f48729d148 Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 24 Feb 2026 20:18:39 +0100 Subject: [PATCH 08/10] Load more messages when timeline is near top --- Mactrix/Models/LiveTimeline.swift | 2 +- .../TimelineView/TimelineTableView.swift | 38 ++++++++++++++++--- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/Mactrix/Models/LiveTimeline.swift b/Mactrix/Models/LiveTimeline.swift index 416e84c..9bf947a 100644 --- a/Mactrix/Models/LiveTimeline.swift +++ b/Mactrix/Models/LiveTimeline.swift @@ -136,7 +136,7 @@ public final class LiveTimeline { } Logger.liveTimeline.info("fetch more messages") - _ = try await timeline?.paginateBackwards(numEvents: 20) + _ = try await timeline?.paginateBackwards(numEvents: 100) } public func focusEvent(id eventId: EventOrTransactionId) { diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 3e76274..9ade44a 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -115,7 +115,6 @@ class TimelineViewController: NSViewController { if let recycledView = tableView.makeView(withIdentifier: item.rowInfo.reuseIdentifier, owner: self) as? NSHostingView { - Logger.timelineTableView.debug("reusing message view") recycledView.rootView = view hostView = recycledView } else { @@ -135,16 +134,21 @@ class TimelineViewController: NSViewController { scrollView.hasVerticalScroller = true view = scrollView - // 1. Tell the clip view to post notifications when its bounds change (resize) + // Subscribe to view resize notifications scrollView.contentView.postsBoundsChangedNotifications = true - - // 2. Observe that notification NotificationCenter.default.addObserver( self, selector: #selector(handleTableResize), name: NSView.frameDidChangeNotification, object: scrollView.contentView ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(viewDidScroll(_:)), + name: NSView.boundsDidChangeNotification, + object: scrollView.contentView + ) } @objc func handleTableResize(_ notification: Notification) { @@ -163,6 +167,30 @@ class TimelineViewController: NSViewController { } } + var timelineFetchTask: Task? + + @objc func viewDidScroll(_ notification: Notification) { + let currentOffset = scrollView.contentView.bounds.origin.y + let timelineHeight = scrollView.contentView.documentRect.height + let viewHeight = scrollView.contentView.documentVisibleRect.height + + let distanceFromTop = timelineHeight - viewHeight - currentOffset + let threshold: CGFloat = 200.0 // Pixels from the top to trigger load + + if distanceFromTop <= threshold, timelineFetchTask == nil { + Logger.timelineTableView.info("Fetching older messages (scroll near top)") + timelineFetchTask = Task { + do { + try await timeline.fetchOlderMessages() + } catch { + Logger.timelineTableView.error("Failed to fetch older messages: \(error)") + } + + timelineFetchTask = nil + } + } + } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not available") @@ -214,8 +242,6 @@ extension TimelineViewController: NSTableViewDelegate { let proposedSize = CGSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude) let size = measurementHostingView.sizeThatFits(in: proposedSize) - Logger.timelineTableView.debug("Size of row \(row): \(size.height)") - return size.height } } From f8d4cc280b299868747eaf77e276858182fd603e Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 24 Feb 2026 21:08:34 +0100 Subject: [PATCH 09/10] Reintegrate message actions + scroll to reply --- Mactrix/Extensions/NSTableView.swift | 35 +++++++++++++++++++ .../TimelineView/TimelineTableView.swift | 30 +++++++++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 Mactrix/Extensions/NSTableView.swift diff --git a/Mactrix/Extensions/NSTableView.swift b/Mactrix/Extensions/NSTableView.swift new file mode 100644 index 0000000..1165651 --- /dev/null +++ b/Mactrix/Extensions/NSTableView.swift @@ -0,0 +1,35 @@ +import AppKit + +extension NSTableView { + /// Animates scrolling to row with a given index. + func animateRowToVisible(_ index: Int) { + guard index >= 0, index < numberOfRows else { return } + guard let scrollView = enclosingScrollView else { return } + + let rowRect = rect(ofRow: index) + let clipView = scrollView.contentView + let visibleRect = clipView.bounds + + var targetY = visibleRect.origin.y + if rowRect.origin.y < visibleRect.origin.y { + // Row is above: Scroll up until the top of the row is at the top + targetY = rowRect.origin.y + } else if rowRect.maxY > visibleRect.maxY { + // Row is below: Scroll down until the bottom of the row is at the bottom + targetY = rowRect.maxY - visibleRect.height + } else { + // Row is already fully visible: Exit + return + } + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + context.allowsImplicitAnimation = true + + var newBounds = clipView.bounds + newBounds.origin.y = targetY + clipView.animator().bounds = newBounds + } + } +} diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index 9ade44a..4e1c5b7 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -23,11 +23,14 @@ enum TimelineItemRowInfo { struct TimelineItemRowView: View { let rowInfo: TimelineItemRowInfo + let timeline: LiveTimeline? + let appState: AppState let windowState: WindowState - init(rowInfo: TimelineItemRowInfo, coordinator: TimelineViewRepresentable.Coordinator) { + init(rowInfo: TimelineItemRowInfo, timeline: LiveTimeline?, coordinator: TimelineViewRepresentable.Coordinator) { self.rowInfo = rowInfo + self.timeline = timeline self.appState = coordinator.appState self.windowState = coordinator.windowState } @@ -36,7 +39,7 @@ struct TimelineItemRowView: View { var contentView: some View { switch rowInfo { case .message(let event, let content): - ChatMessageView(timeline: nil, event: event, msg: content, includeProfileHeader: true) + ChatMessageView(timeline: timeline, event: event, msg: content, includeProfileHeader: true) case .state(let event): UI.GenericEventView(event: event, name: event.content.description) case .virtual(let virtual): @@ -109,7 +112,7 @@ class TimelineViewController: NSViewController { guard let self else { return NSView() } let item = timelineItems[row] - let view = TimelineItemRowView(rowInfo: item.rowInfo, coordinator: coordinator) + let view = TimelineItemRowView(rowInfo: item.rowInfo, timeline: timeline, coordinator: coordinator) let hostView: NSHostingView if let recycledView = tableView.makeView(withIdentifier: item.rowInfo.reuseIdentifier, owner: self) @@ -149,6 +152,8 @@ class TimelineViewController: NSViewController { name: NSView.boundsDidChangeNotification, object: scrollView.contentView ) + + listenForFocusTimelineItem() } @objc func handleTableResize(_ notification: Notification) { @@ -191,6 +196,23 @@ class TimelineViewController: NSViewController { } } + func listenForFocusTimelineItem() { + Logger.timelineTableView.debug("Listen for focus timeline item") + + let focusedTimelineEventId = withObservationTracking { + timeline.focusedTimelineEventId + } onChange: { + Task { @MainActor in self.listenForFocusTimelineItem() } + } + + guard let focusedTimelineEventId, + let rowIndex = timelineItems.firstIndex(where: { + $0.asEvent()?.eventOrTransactionId == focusedTimelineEventId + }) else { return } + + tableView.animateRowToVisible(rowIndex) + } + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) is not available") @@ -236,7 +258,7 @@ extension TimelineViewController: NSTableViewDelegate { func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat { let item = timelineItems[row] - measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item.rowInfo, coordinator: coordinator)) + measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item.rowInfo, timeline: nil, coordinator: coordinator)) let targetWidth = tableView.tableColumns[0].width let proposedSize = CGSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude) From 416e6d14d780e1112a3e8474730f2767cce56a6d Mon Sep 17 00:00:00 2001 From: viktorstrate Date: Tue, 24 Feb 2026 21:21:11 +0100 Subject: [PATCH 10/10] Clean up old comments and mark rooms as read --- Mactrix/Models/LiveTimeline.swift | 45 +---------- Mactrix/Views/ChatView/ChatView.swift | 103 +++----------------------- 2 files changed, 14 insertions(+), 134 deletions(-) diff --git a/Mactrix/Models/LiveTimeline.swift b/Mactrix/Models/LiveTimeline.swift index 9bf947a..17c1b28 100644 --- a/Mactrix/Models/LiveTimeline.swift +++ b/Mactrix/Models/LiveTimeline.swift @@ -18,7 +18,7 @@ public final class LiveTimeline { public var errorMessage: String? public private(set) var focusedTimelineEventId: EventOrTransactionId? - public private(set) var focusedTimelineGroupId: String? + // public private(set) var focusedTimelineGroupId: String? public var sendReplyTo: MatrixRustSDK.EventTimelineItem? @@ -122,6 +122,7 @@ public final class LiveTimeline { paginating = status if paginating == .idle(hitTimelineStart: false) && timelineItems.count < 20 { + try await Task.sleep(for: .milliseconds(500)) try await fetchOlderMessages() } } @@ -142,79 +143,37 @@ public final class LiveTimeline { public func focusEvent(id eventId: EventOrTransactionId) { Logger.liveTimeline.info("focus event: \(eventId.id)") focusedTimelineEventId = eventId - - /* let group = timelineGroups.groups.first { group in - switch group { - case let .messages(messages, _, _): - return messages.contains(where: { $0.event.eventOrTransactionId == eventId }) - case .stateChanges: - return false - case .virtual: - return false - } - } - focusedTimelineGroupId = group?.id - - if let focusedTimelineGroupId { - withAnimation { - scrollPosition.scrollTo(id: focusedTimelineGroupId) - } - } */ } } extension LiveTimeline { private func updateTimeline(diff: [TimelineDiff]) { - // let oldView = scrollPosition.viewID - // let oldEdge = scrollPosition.edge - // Logger.liveTimeline.trace("onUpdate old view \(oldView.debugDescription) \(oldEdge.debugDescription)") - - // var updatedIds = Set() - for update in diff { switch update { case let .append(values): timelineItems.append(contentsOf: values) - /* for value in values { - updatedIds.insert(value.uniqueId().id) - } */ case .clear: timelineItems.removeAll() case let .pushFront(room): timelineItems.insert(room, at: 0) - // updatedIds.insert(room.uniqueId().id) case let .pushBack(room): timelineItems.append(room) - // updatedIds.insert(room.uniqueId().id) case .popFront: timelineItems.removeFirst() case .popBack: timelineItems.removeLast() case let .insert(index, room): timelineItems.insert(room, at: Int(index)) - // updatedIds.insert(room.uniqueId().id) case let .set(index, room): timelineItems[Int(index)] = room - // updatedIds.insert(room.uniqueId().id) case let .remove(index): timelineItems.remove(at: Int(index)) case let .truncate(length): timelineItems.removeSubrange(Int(length) ..< timelineItems.count) case let .reset(values: values): timelineItems = values - /* for value in values { - updatedIds.insert(value.uniqueId().id) - } */ } } - - /* timelineGroups.updateItems(items: timelineItems, updatedIds: updatedIds) - - if let oldEdge { - scrollPosition.scrollTo(edge: oldEdge) - } else if let oldView { - scrollPosition.scrollTo(id: oldView, anchor: .top) - } */ } } diff --git a/Mactrix/Views/ChatView/ChatView.swift b/Mactrix/Views/ChatView/ChatView.swift index 4628828..6b1420d 100644 --- a/Mactrix/Views/ChatView/ChatView.swift +++ b/Mactrix/Views/ChatView/ChatView.swift @@ -22,96 +22,6 @@ struct TimelineGroupView: View { } } -/* struct TimelineItemsView: View { - let timeline: LiveTimeline - - var body: some View { - if !timeline.timelineGroups.groups.isEmpty { - LazyVStack { - ForEach(timeline.timelineGroups.groups) { item in - TimelineGroupView(timeline: timeline, timelineGroup: item) - } - } - .scrollTargetLayout() - } else { - ProgressView() - } - } - } */ - -/* struct ChatTimelineScrollView: View { - @Bindable var timeline: LiveTimeline - - @State private var scrollNearTop: Bool = false - - func loadMoreMessages() { - guard scrollNearTop else { return } - guard timeline.paginating == .idle(hitTimelineStart: false) else { - let p = timeline.paginating.debugDescription - Logger.viewCycle.info("Fetching messages cancelled, already: paginating \(p)") - return - } - Logger.viewCycle.info("Reached top, fetching more messages...") - - Task { - do { - try await self.timeline.fetchOlderMessages() - - // if scrollNearTop { - // try await Task.sleep(for: .seconds(1)) - // loadMoreMessages() - // } - } catch { - Logger.viewCycle.error("failed to fetch more message for timeline: \(error)") - } - } - } - - var body: some View { - ScrollView { - ProgressView("Loading more messages") - .opacity(timeline.paginating == .paginating ? 1 : 0) - - TimelineItemsView(timeline: timeline) - - if let errorMessage = timeline.errorMessage { - Text(errorMessage) - .foregroundStyle(Color.red) - .frame(maxWidth: .infinity) - } - - HStack { - UI.UserTypingIndicator(names: timeline.room.typingUserIds) - Spacer() - } - .padding(.horizontal, 10) - } - .scrollPosition($timeline.scrollPosition) - .defaultScrollAnchor(.bottom) - .onScrollGeometryChange(for: Bool.self) { geo in - geo.visibleRect.maxY - geo.containerSize.height < 400.0 - } action: { _, nearTop in - Logger.viewCycle.info("scroll near top: \(nearTop)") - scrollNearTop = nearTop - if nearTop { - loadMoreMessages() - } - } - .task(id: timeline.timelineGroups, priority: .background) { - do { - try await Task.sleep(for: .seconds(1)) - - Logger.viewCycle.debug("Mark room as read") - try await timeline.timeline?.markAsRead(receiptType: .read) - } catch is CancellationError { - /* sleep cancelled */ - } catch { - Logger.viewCycle.error("failed to send timeline read receipt: \(error)") - } - } - } - } */ - struct ChatJoinedRoom: View { @Environment(AppState.self) private var appState @Bindable var timeline: LiveTimeline @@ -129,7 +39,6 @@ struct ChatJoinedRoom: View { } var body: some View { - // ChatTimelineScrollView(timeline: timeline) TimelineViewRepresentable(timeline: timeline, items: timeline.timelineItems) .safeAreaPadding(.bottom, inputHeight ?? 60) // chat input overlay .overlay(alignment: .bottom) { @@ -150,6 +59,18 @@ struct ChatJoinedRoom: View { Logger.viewCycle.error("failed to mark room as recently visited: \(error)") } } + .task(id: timeline.timelineItems.count, priority: .background) { + do { + try await Task.sleep(for: .seconds(1)) + + Logger.viewCycle.debug("Mark room as read") + try await timeline.timeline?.markAsRead(receiptType: .read) + } catch is CancellationError { + /* sleep cancelled */ + } catch { + Logger.viewCycle.error("failed to send timeline read receipt: \(error)") + } + } .onDisappear { Task { guard let timeline = timeline.timeline else { return }