mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
248 lines
8.4 KiB
Swift
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
|
|
}
|
|
}
|