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..7d61221 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). + Task { @MainActor [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,