Files
yattee/Yattee/Views/Components/CommentView.swift
Arkadiusz Fal 612dce6b9f Refactor views
2026-02-09 01:13:02 +01:00

248 lines
8.4 KiB
Swift

//
// CommentView.swift
// Yattee
//
// View displaying a single comment.
//
import SwiftUI
import NukeUI
struct CommentView: View {
@Environment(\.appEnvironment) private var appEnvironment
let comment: Comment
let videoID: String?
let source: ContentSource?
let isReply: Bool
@State private var replies: [Comment] = []
@State private var isLoadingReplies = false
@State private var showReplies = false
@State private var repliesContinuation: String?
private var contentService: ContentService? { appEnvironment?.contentService }
private var instancesManager: InstancesManager? { appEnvironment?.instancesManager }
private var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator }
private var accentColor: Color { appEnvironment?.settingsManager.accentColor.color ?? .accentColor }
init(comment: Comment, videoID: String? = nil, source: ContentSource? = nil, isReply: Bool = false) {
self.comment = comment
self.videoID = videoID
self.source = source
self.isReply = isReply
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 12) {
// Author avatar
authorAvatar
VStack(alignment: .leading, spacing: 4) {
// Author name and badges
authorInfo
// Comment content
Text(comment.content)
.font(.subheadline)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
#if !os(tvOS)
.textSelection(.enabled)
#endif
// Metadata row
metadataRow
}
}
.padding(.vertical, 8)
// Replies section (only for top-level comments)
if !isReply && comment.replyCount > 0 {
repliesSection
}
}
}
@ViewBuilder
private var authorAvatar: some View {
Button {
navigateToChannel()
} label: {
LazyImage(url: comment.author.thumbnailURL) { state in
if let image = state.image {
image
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Circle()
.fill(.quaternary)
.overlay {
Text(String(comment.author.name.prefix(1)))
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(.secondary)
}
}
}
.frame(width: 32, height: 32)
.clipShape(Circle())
}
.buttonStyle(.plain)
}
@ViewBuilder
private var authorInfo: some View {
HStack(spacing: 4) {
Text(comment.author.name)
.font(.caption)
.fontWeight(.semibold)
.foregroundStyle(comment.isCreatorComment ? accentColor : .primary)
if comment.isCreatorComment {
Image(systemName: "checkmark.seal.fill")
.font(.caption2)
.foregroundStyle(accentColor)
}
if comment.isPinned {
Image(systemName: "pin.fill")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
}
private func navigateToChannel() {
guard let source, !comment.author.id.isEmpty else { return }
// Set collapsing first so mini player shows video immediately
navigationCoordinator?.isPlayerCollapsing = true
navigationCoordinator?.isPlayerExpanded = false
navigationCoordinator?.navigate(to: .channel(comment.author.id, source))
}
@ViewBuilder
private var metadataRow: some View {
HStack(spacing: 12) {
// Published time
if let publishedText = comment.formattedPublishedDate {
Text(publishedText)
.font(.caption)
.foregroundStyle(.secondary)
}
// Like count
if let likeCount = comment.formattedLikeCount {
HStack(spacing: 2) {
Image(systemName: "hand.thumbsup")
.font(.caption2)
Text(likeCount)
.font(.caption)
}
.foregroundStyle(.secondary)
}
// Creator heart
if comment.hasCreatorHeart {
Image(systemName: "heart.fill")
.font(.caption2)
.foregroundStyle(.red)
}
}
}
// MARK: - Replies Section
@ViewBuilder
private var repliesSection: some View {
VStack(alignment: .leading, spacing: 0) {
// Toggle replies button
Button {
withAnimation(.easeInOut(duration: 0.2)) {
if showReplies {
showReplies = false
} else {
showReplies = true
if replies.isEmpty {
Task { await loadReplies() }
}
}
}
} label: {
HStack(spacing: 4) {
Image(systemName: "chevron.down")
.font(.caption2)
.rotationEffect(.degrees(showReplies ? -180 : 0))
Text(String(localized: "comments.showReplies \(comment.replyCount)"))
.font(.caption)
.fontWeight(.medium)
}
.foregroundStyle(accentColor)
}
.buttonStyle(.plain)
.padding(.leading, 44) // Align with comment content (avatar width + spacing)
.padding(.bottom, 8)
// Replies list
if showReplies {
VStack(alignment: .leading, spacing: 0) {
ForEach(replies) { reply in
CommentView(comment: reply, source: source, isReply: true)
.padding(.leading, 44)
if reply.id != replies.last?.id {
Divider()
.padding(.leading, 44)
}
}
// Load more replies
if isLoadingReplies {
HStack {
Spacer()
ProgressView()
.padding(.vertical, 8)
Spacer()
}
.padding(.leading, 44)
} else if repliesContinuation != nil {
Button {
Task { await loadReplies() }
} label: {
Text(String(localized: "comments.loadMoreReplies"))
.font(.caption)
.fontWeight(.medium)
.foregroundStyle(accentColor)
}
.buttonStyle(.plain)
.padding(.leading, 44)
.padding(.vertical, 8)
}
}
}
}
}
private func loadReplies() async {
guard let videoID, let contentService, let instancesManager else { return }
guard let instance = instancesManager.enabledInstances.first(where: \.isYouTubeInstance) else { return }
// Use the comment's replies continuation, or the stored one for subsequent pages
let continuation = replies.isEmpty ? comment.repliesContinuation : repliesContinuation
guard let continuation else { return }
isLoadingReplies = true
do {
let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: continuation)
replies.append(contentsOf: page.comments)
repliesContinuation = page.continuation
} catch {
// Silently fail for replies
}
isLoadingReplies = false
}
}