Files
yattee/Yattee/Views/Components/CommentView.swift
Arkadiusz Fal b47888fe04 Add custom accent color with system color picker
Add a Custom swatch to the appearance settings accent color grid,
backed by a hex value stored in settings (synced via iCloud like the
rest). iOS uses the native ColorPicker wheel; macOS uses a circular
swatch that opens NSColorPanel, since the SwiftUI color well looks out
of place among the circles. All accent consumers now read the resolved
color through SettingsManager.resolvedAccentColor.

The indigo preset is retired from the grid but kept in the enum, so
users who selected it keep their color until they pick another one.

Claude-Session: https://claude.ai/code/session_0154KH8RAVAvm6iVanhmoW8o
2026-07-04 23:57:59 +02:00

290 lines
9.8 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.resolvedAccentColor ?? .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 render with clickable URLs and timestamps
Text(DescriptionText.attributed(comment.content, linkColor: accentColor))
#if os(macOS)
.font(.body)
#else
.font(.subheadline)
#endif
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
#if !os(tvOS)
.textSelection(.enabled)
.handleTimestampLinks(using: appEnvironment?.playerService)
#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 {
#if os(tvOS)
authorAvatarImage
#else
Button {
navigateToChannel()
} label: {
authorAvatarImage
}
.buttonStyle(.plain)
#endif
}
@ViewBuilder
private var authorAvatarImage: some View {
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())
}
@ViewBuilder
private var authorInfo: some View {
HStack(spacing: 4) {
Text(comment.author.name)
#if os(macOS)
.font(.subheadline)
#else
.font(.caption)
#endif
.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)
#if os(macOS)
.font(.subheadline)
#else
.font(.caption)
#endif
.foregroundStyle(.secondary)
}
// Like count
if let likeCount = comment.formattedLikeCount {
HStack(spacing: 2) {
Image(systemName: "hand.thumbsup")
#if os(macOS)
.font(.caption)
#else
.font(.caption2)
#endif
Text(likeCount)
#if os(macOS)
.font(.subheadline)
#else
.font(.caption)
#endif
}
.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)"))
#if os(macOS)
.font(.subheadline)
#else
.font(.caption)
#endif
.fontWeight(.medium)
}
#if !os(tvOS)
.foregroundStyle(accentColor)
#endif
}
.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"))
#if os(macOS)
.font(.subheadline)
#else
.font(.caption)
#endif
.fontWeight(.medium)
#if !os(tvOS)
.foregroundStyle(accentColor)
#endif
}
.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
}
}