Files
andros 78ba61034f Fix theme colors across all screens; fix Discover title on iOS 26
- 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.
2026-05-24 21:10:04 +02:00

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)
}
}