mirror of
https://github.com/yattee/yattee.git
synced 2026-07-20 14:22:02 +00:00
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
290 lines
9.8 KiB
Swift
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
|
|
}
|
|
}
|