78ba61034f
- Remove .toolbarBackground(.visible) from all NavigationStack views: this modifier suppresses large title rendering in iOS 26. - Discover: switch to always-present List + overlay pattern so SwiftUI never loses the scroll context during loading transitions. - Discover, Notifications, Groups: use .inline title mode; the tab bar already identifies these screens, large titles are redundant. - RootView: add .toolbarColorScheme propagation for nav bar foreground. - Settings sheet: apply theme background and toolbar color to the Form.
163 lines
6.0 KiB
Swift
163 lines
6.0 KiB
Swift
import SwiftUI
|
|
import OrgSocialKit
|
|
|
|
struct ThreadRoute: Hashable, Identifiable {
|
|
let postURL: String
|
|
let relayURL: URL
|
|
var id: String { postURL }
|
|
}
|
|
|
|
struct ThreadView: View {
|
|
@State private var viewModel: ThreadViewModel
|
|
let relayURL: URL
|
|
@Environment(\.appTheme) private var theme
|
|
|
|
init(postURL: String, relayURL: URL) {
|
|
self.relayURL = relayURL
|
|
_viewModel = State(initialValue: ThreadViewModel(postURL: postURL, relayURL: relayURL))
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if viewModel.isLoading && viewModel.thread == nil {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let error = viewModel.errorMessage, viewModel.thread == nil {
|
|
ContentUnavailableView {
|
|
Label("Thread unavailable", systemImage: "bubble.left.and.bubble.right")
|
|
} description: {
|
|
Text(error)
|
|
} actions: {
|
|
Button("Retry") { Task { await viewModel.load() } }
|
|
.buttonStyle(.bordered)
|
|
}
|
|
} else if let thread = viewModel.thread {
|
|
threadContent(thread)
|
|
}
|
|
}
|
|
.background(theme.background)
|
|
.navigationTitle("Thread")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.task { await viewModel.load() }
|
|
.toolbarBackground(theme.secondaryBackground, for: .navigationBar)
|
|
.navigationDestination(for: URL.self) { ProfileView(feedURL: $0) }
|
|
.navigationDestination(for: ThreadRoute.self) {
|
|
ThreadView(postURL: $0.postURL, relayURL: $0.relayURL)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func threadContent(_ thread: OrgSocialThread) -> some View {
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
// Parent chain (ancestors, oldest first)
|
|
ForEach(thread.parentChain, id: \.timestamp) { post in
|
|
ancestorRow(post)
|
|
threadConnector
|
|
}
|
|
|
|
// Focal post (highlighted)
|
|
focalPostRow(thread.focalPost)
|
|
|
|
if !thread.replies.isEmpty {
|
|
threadConnector
|
|
}
|
|
|
|
// Reply tree
|
|
ForEach(Array(thread.replies.enumerated()), id: \.element.post.timestamp) { index, node in
|
|
replyNodeRow(node, depth: 0)
|
|
if index < thread.replies.count - 1 {
|
|
Divider().padding(.leading, 72)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
.background(theme.background)
|
|
}
|
|
|
|
private func ancestorRow(_ post: OrgSocialPost) -> some View {
|
|
PostRowView(post: post, relayURL: relayURL, showReplyCount: false)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private var threadConnector: some View {
|
|
Rectangle()
|
|
.fill(Color.secondary.opacity(0.3))
|
|
.frame(width: 2, height: 20)
|
|
.padding(.leading, 27)
|
|
}
|
|
|
|
private func focalPostRow(_ post: OrgSocialPost) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
PostRowView(post: post, relayURL: relayURL, showReplyCount: false, truncate: false)
|
|
if let moods = viewModel.thread?.moods, !moods.isEmpty {
|
|
reactionChips(moods)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
.background(theme.accent.opacity(0.05))
|
|
}
|
|
|
|
private func replyNodeRow(_ node: OrgSocialThreadNode, depth: Int) -> AnyView {
|
|
AnyView(
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack(alignment: .top, spacing: 0) {
|
|
if depth > 0 {
|
|
Rectangle()
|
|
.fill(Color.secondary.opacity(0.25))
|
|
.frame(width: 2)
|
|
.padding(.leading, CGFloat(16 + (depth - 1) * 32))
|
|
.padding(.trailing, 10)
|
|
}
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
PostRowView(post: node.post, relayURL: relayURL, showReplyCount: false, threadRoute: replyRoute(for: node))
|
|
if !node.moods.isEmpty {
|
|
reactionChips(node.moods)
|
|
}
|
|
}
|
|
.padding(.horizontal, depth == 0 ? 16 : 0)
|
|
.padding(.trailing, depth > 0 ? 16 : 0)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
if !node.children.isEmpty {
|
|
threadConnector
|
|
.padding(.leading, CGFloat(depth) * 32)
|
|
}
|
|
|
|
ForEach(Array(node.children.enumerated()), id: \.element.post.timestamp) { index, child in
|
|
replyNodeRow(child, depth: depth + 1)
|
|
if index < node.children.count - 1 {
|
|
Divider().padding(.leading, CGFloat(72 + (depth + 1) * 32))
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private func reactionChips(_ moods: [OrgSocialMood]) -> some View {
|
|
HStack(spacing: 6) {
|
|
ForEach(moods, id: \.emoji) { mood in
|
|
HStack(spacing: 3) {
|
|
Text(EmojiShortcode.resolve(mood.emoji)).font(.caption)
|
|
Text("\(mood.posts.count)")
|
|
.font(.caption.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, 7).padding(.vertical, 3)
|
|
.background(Color.secondary.opacity(0.08), in: Capsule())
|
|
}
|
|
}
|
|
}
|
|
|
|
private func replyRoute(for node: OrgSocialThreadNode) -> ThreadRoute? {
|
|
guard !node.children.isEmpty,
|
|
let feedURL = node.post.feedURL else { return nil }
|
|
let postURL = "\(feedURL.absoluteString)#\(node.post.timestamp)"
|
|
return ThreadRoute(postURL: postURL, relayURL: relayURL)
|
|
}
|
|
}
|