import SwiftUI import OrgSocialKit struct ThreadRoute: Hashable { let postURL: String let relayURL: URL } struct ThreadView: View { @State private var viewModel: ThreadViewModel let relayURL: URL 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) } } .navigationTitle("Thread") .navigationBarTitleDisplayMode(.inline) .task { await viewModel.load() } .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) } } 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) if let moods = viewModel.thread?.moods, !moods.isEmpty { reactionChips(moods) } } .padding(.horizontal, 16) .padding(.vertical, 12) .background(Color.accentColor.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) } }