Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Relay/Views/Timeline/TimelineActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ import SwiftUI
final class ExpandedGroupsState {
var expandedIDs: Set<String> = []

/// 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)
}
Expand All @@ -33,6 +41,7 @@ final class ExpandedGroupsState {
} else {
expandedIDs.insert(groupID)
}
onToggle?(groupID)
}
}

Expand Down
36 changes: 36 additions & 0 deletions Relay/Views/Timeline/TimelineTableView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions Relay/Views/Timeline/TimelineTableViewRepresentable.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading