mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
246
Yattee/Views/Components/CommentView.swift
Normal file
246
Yattee/Views/Components/CommentView.swift
Normal file
@@ -0,0 +1,246 @@
|
||||
//
|
||||
// CommentView.swift
|
||||
// Yattee
|
||||
//
|
||||
// View displaying a single comment.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import NukeUI
|
||||
|
||||
struct CommentView: View {
|
||||
let comment: Comment
|
||||
let videoID: String?
|
||||
let source: ContentSource?
|
||||
let isReply: Bool
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user