From 685e4d7c3e3bcff23029bfeea5a3aa7f9409dcf4 Mon Sep 17 00:00:00 2001 From: Andrew Hunter Date: Thu, 25 Jun 2026 16:12:25 -0400 Subject: [PATCH 1/2] Re-measure timeline row when a collapsed event group expands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expanding a collapsed system-event group clipped its content at a fixed height. The table renderer caches row heights keyed by (messageID, width), and toggling expansion only flips the shared ExpandedGroupsState — it doesn't change the `rows` data, so `updateRows` never invalidates the cached height and `heightOfRow` keeps returning the collapsed value. NSTableView then clips the taller expanded content to that height. ExpandedGroupsState.toggle now fires an onToggle(groupID) callback; the table controller's new remeasureRow(forMessageID:) invalidates that row's cached height and notes its new height. heightOfRow's measurement host rebuilds the row reading the updated expansion state, so it returns the full expanded (or collapsed) height. Scroll position is preserved so expanding a row above the viewport doesn't shift the visible content. Fixes #150. Co-Authored-By: Claude Opus 4.8 --- Relay/Views/Timeline/TimelineActions.swift | 9 +++++ Relay/Views/Timeline/TimelineTableView.swift | 36 +++++++++++++++++++ .../TimelineTableViewRepresentable.swift | 9 +++++ 3 files changed, 54 insertions(+) diff --git a/Relay/Views/Timeline/TimelineActions.swift b/Relay/Views/Timeline/TimelineActions.swift index f976f51..ae84419 100644 --- a/Relay/Views/Timeline/TimelineActions.swift +++ b/Relay/Views/Timeline/TimelineActions.swift @@ -23,6 +23,14 @@ import SwiftUI final class ExpandedGroupsState { var expandedIDs: Set = [] + /// Invoked after a group's expansion state changes, with the group's ID. + /// The table-backed renderer (``TimelineTableViewController``) uses this to + /// re-measure the affected row: expanding/collapsing changes the row's + /// content height without changing the underlying message data, so the + /// height cache would otherwise keep the stale (collapsed) value and clip + /// the expanded content. + @ObservationIgnored var onToggle: ((String) -> Void)? + func isExpanded(_ groupID: String) -> Bool { expandedIDs.contains(groupID) } @@ -33,6 +41,7 @@ final class ExpandedGroupsState { } else { expandedIDs.insert(groupID) } + onToggle?(groupID) } } diff --git a/Relay/Views/Timeline/TimelineTableView.swift b/Relay/Views/Timeline/TimelineTableView.swift index 1eea86e..4accbee 100644 --- a/Relay/Views/Timeline/TimelineTableView.swift +++ b/Relay/Views/Timeline/TimelineTableView.swift @@ -818,6 +818,42 @@ final class TimelineTableViewController: NSViewController { } } + /// Re-measures a single row whose content height changed without any + /// change to the underlying message data — specifically when a collapsed + /// system-event group is expanded or collapsed. + /// + /// `updateRows` only re-measures when `rows` diff, which they don't here + /// (only the shared ``ExpandedGroupsState`` flipped). We invalidate the + /// cached height for this row and note its new height; `heightOfRow`'s + /// measurement host rebuilds the row reading the now-updated expansion + /// state, so it returns the full expanded (or collapsed) height. + func remeasureRow(forMessageID id: String) { + guard let messageIndex = rows.firstIndex(where: { $0.id == id }) else { return } + let sentinelOffset = typingUsers.isEmpty ? 0 : 1 + let rowIndex = messageIndex + sentinelOffset + + // Defer so the live hosting cell has settled its SwiftUI re-render + // before we note the new height (matches the resize handler). + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.invalidateHeight(for: id) + let scrollBefore = self.scrollView.contentView.bounds.origin + NSAnimationContext.runAnimationGroup { context in + context.duration = 0 + context.allowsImplicitAnimation = false + self.tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integer: rowIndex)) + } + // Preserve the scroll position; growing a row above the viewport + // would otherwise shift the visible content. + if self.isNearBottom { + self.scrollToBottom(animated: false) + } else if abs(scrollBefore.y - self.scrollView.contentView.bounds.origin.y) > 0.5 { + self.scrollView.contentView.scroll(to: scrollBefore) + self.scrollView.reflectScrolledClipView(self.scrollView.contentView) + } + } + } + // MARK: - Resize Handling @objc private func viewDidResize(_ notification: Notification) { diff --git a/Relay/Views/Timeline/TimelineTableViewRepresentable.swift b/Relay/Views/Timeline/TimelineTableViewRepresentable.swift index 0dd1d1b..7c34c30 100644 --- a/Relay/Views/Timeline/TimelineTableViewRepresentable.swift +++ b/Relay/Views/Timeline/TimelineTableViewRepresentable.swift @@ -69,6 +69,15 @@ struct TimelineTableViewRepresentable: NSViewControllerRepresentable { private func configureCallbacks(_ vc: TimelineTableViewController, context: Context) { let actions = actions + + // When a collapsed system-event group is expanded/collapsed, the row's + // content height changes without a `rows` diff, so the table must be + // told to re-measure that row (otherwise it stays clipped at the + // cached collapsed height). + actions.expandedGroups.onToggle = { [weak vc] groupID in + vc?.remeasureRow(forMessageID: groupID) + } + vc.callbacks = .init( onNearBottomChanged: onNearBottomChanged, onPaginateBackward: onPaginateBackward, From 3cd2f3150fc1788878ab60254d2bc605e2ad8947 Mon Sep 17 00:00:00 2001 From: Andrew Hunter Date: Fri, 26 Jun 2026 15:16:59 -0400 Subject: [PATCH 2/2] Use Task @MainActor instead of DispatchQueue for row remeasure Co-Authored-By: Claude Opus 4.8 --- Relay/Views/Timeline/TimelineTableView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Relay/Views/Timeline/TimelineTableView.swift b/Relay/Views/Timeline/TimelineTableView.swift index 4accbee..7d61221 100644 --- a/Relay/Views/Timeline/TimelineTableView.swift +++ b/Relay/Views/Timeline/TimelineTableView.swift @@ -834,7 +834,7 @@ final class TimelineTableViewController: NSViewController { // Defer so the live hosting cell has settled its SwiftUI re-render // before we note the new height (matches the resize handler). - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in guard let self else { return } self.invalidateHeight(for: id) let scrollBefore = self.scrollView.contentView.bounds.origin