diff --git a/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailView.swift b/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailView.swift index 648d0bb57..f68c1aa04 100644 --- a/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailView.swift @@ -113,12 +113,15 @@ public struct StatusDetailView: View { .navigationBarTitleDisplayMode(.inline) } + @ViewBuilder private func makeStatusesListView(statuses: [Status]) -> some View { - ForEach(statuses) { status in + let collapsedIds = viewModel.hierarchyCollapseState.implicitlyCollapsedStatusIds(for: statuses) + ForEach(statuses.filter { !collapsedIds.contains($0.id) }) { status in let (indentationLevel, extraInsets) = viewModel.getIndentationLevel(id: status.id, maxIndent: userPreferences.getRealMaxIndent()) let viewModel: StatusRowViewModel = .init(status: status, client: client, routerPath: routerPath, + hierarchyCollapseState: viewModel.hierarchyCollapseState, scrollToId: $viewModel.scrollToId) let isFocused = self.viewModel.statusId == status.id diff --git a/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailViewModel.swift b/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailViewModel.swift index 9fcbdfe2b..07c386bda 100644 --- a/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailViewModel.swift +++ b/Packages/StatusKit/Sources/StatusKit/Detail/StatusDetailViewModel.swift @@ -11,6 +11,8 @@ import SwiftUI var client: Client? var routerPath: RouterPath? + + let hierarchyCollapseState = StatusHierarchyCollapseState() enum State { case loading, display(statuses: [Status]), error(error: Error) diff --git a/Packages/StatusKit/Sources/StatusKit/Hierarchy/StatusHierarchyCollapseStatus.swift b/Packages/StatusKit/Sources/StatusKit/Hierarchy/StatusHierarchyCollapseStatus.swift new file mode 100644 index 000000000..4ea2a43e4 --- /dev/null +++ b/Packages/StatusKit/Sources/StatusKit/Hierarchy/StatusHierarchyCollapseStatus.swift @@ -0,0 +1,24 @@ +import Observation +import Models + +@MainActor +@Observable public class StatusHierarchyCollapseState { + public var explicitlyCollapsedStatusIds: Set + + public init(explicitlyCollapsedStatusIds: Set = []) { + self.explicitlyCollapsedStatusIds = explicitlyCollapsedStatusIds + } + + public func implicitlyCollapsedStatusIds(for statuses: [Status]) -> Set { + let childs: [String: [String]] = Dictionary( + grouping: statuses.filter { $0.inReplyToId != nil }, + by: { $0.inReplyToId! } + ).mapValues { $0.map(\.id) } + + func descendants(for id: String) -> [String] { + (childs[id] ?? []).flatMap { [$0] + descendants(for: $0) } + } + + return Set(explicitlyCollapsedStatusIds.flatMap(descendants(for:))) + } +} diff --git a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift index 0d2d23599..6a2dcc561 100644 --- a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift +++ b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowView.swift @@ -86,17 +86,18 @@ public struct StatusRowView: View { if !isCompact { StatusRowHeaderView(viewModel: viewModel) } - StatusRowContentView(viewModel: viewModel) - .contentShape(Rectangle()) - .onTapGesture { - guard !isFocused else { return } - viewModel.navigateToDetail() - } - .accessibilityActions { - if isFocused, viewModel.showActions { - accessibilityActions + if !viewModel.isHierarchyExplicitlyCollapsed { + StatusRowContentView(viewModel: viewModel) + .contentShape(Rectangle()) + .onTapGesture { + handleTap() } - } + .accessibilityActions { + if isFocused, viewModel.showActions { + accessibilityActions + } + } + } if !reasons.contains(.placeholder), viewModel.showActions, isFocused || theme.statusActionsDisplay != .none, !isInCaptureMode @@ -161,8 +162,7 @@ public struct StatusRowView: View { ? StatusRowAccessibilityLabel(viewModel: viewModel).finalLabel() : Text("")) .accessibilityHidden(viewModel.filter?.filter.filterAction == .hide) .accessibilityAction { - guard !isFocused else { return } - viewModel.navigateToDetail() + handleTap() } .accessibilityActions { if !isFocused, viewModel.showActions, accessibilityVoiceOverEnabled { @@ -173,8 +173,7 @@ public struct StatusRowView: View { Color.clear .contentShape(Rectangle()) .onTapGesture { - guard !isFocused else { return } - viewModel.navigateToDetail() + handleTap() } } .overlay { @@ -348,6 +347,17 @@ public struct StatusRowView: View { .background(Color.black.opacity(0.40)) .transition(.opacity) } + + private func handleTap() { + guard !isFocused else { return } + if indentationLevel > 0, viewModel.hierarchyCollapseState != nil { + withAnimation { + viewModel.isHierarchyExplicitlyCollapsed.toggle() + } + } else { + viewModel.navigateToDetail() + } + } } #Preview { diff --git a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift index 9b1c86c3c..0b24559f8 100644 --- a/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift +++ b/Packages/StatusKit/Sources/StatusKit/Row/StatusRowViewModel.swift @@ -18,6 +18,8 @@ import SwiftUI let client: Client let routerPath: RouterPath + + let hierarchyCollapseState: StatusHierarchyCollapseState? let userFollowedTag: HTMLString.Link? @@ -67,6 +69,20 @@ import SwiftUI } } } + + // toggled on tap, collapses the post with its hierarchy of replies + var isHierarchyExplicitlyCollapsed: Bool { + get { + hierarchyCollapseState?.explicitlyCollapsedStatusIds.contains(status.id) ?? false + } + set { + if newValue { + hierarchyCollapseState?.explicitlyCollapsedStatusIds.insert(status.id) + } else { + hierarchyCollapseState?.explicitlyCollapsedStatusIds.remove(status.id) + } + } + } // used by the button to expand a collapsed post var isCollapsed: Bool = true { @@ -162,6 +178,7 @@ import SwiftUI public init(status: Status, client: Client, routerPath: RouterPath, + hierarchyCollapseState: StatusHierarchyCollapseState? = nil, isRemote: Bool = false, showActions: Bool = true, textDisabled: Bool = false, @@ -171,6 +188,7 @@ import SwiftUI finalStatus = status.reblog ?? status self.client = client self.routerPath = routerPath + self.hierarchyCollapseState = hierarchyCollapseState self.isRemote = isRemote self.showActions = showActions self.textDisabled = textDisabled