Files
yattee/Yattee/Views/Components/CommentView.swift
Arkadiusz Fal 0b2cb4deeb Honor the comments avatars setting in the overlay and pill, align replies without avatars
The "Author Avatars" toggle only reached the iPad/macOS comments page.
The expanded comments overlay now takes the same flag (hosts pass it in
the next commit), and the expanded comments pill shows the comments icon
instead of the author avatar when avatars are off.

The replies block in a comment used a hardcoded 44pt leading inset for
the avatar column regardless of the toggle, so "Show replies" was
indented past the comment text when avatars were hidden. The toggle now
aligns with the text (44pt with avatars, 0 without) and nested replies
keep a smaller 16pt inset without avatars so the hierarchy still reads.
2026-09-26 23:21:37 +02:00

307 lines
10 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
let showAvatar: 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, showAvatar: Bool = true) {
self.comment = comment
self.videoID = videoID
self.source = source
self.isReply = isReply
self.showAvatar = showAvatar
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 12) {
// Author avatar
if showAvatar {
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
/// Leading inset that lines the replies toggle up with the comment text:
/// the avatar column (avatar width + spacing) when avatars show, else
/// nothing.
private var contentIndent: CGFloat {
showAvatar ? 44 : 0
}
/// Leading inset for the nested replies. Without avatars a smaller
/// inset still marks the hierarchy.
private var repliesIndent: CGFloat {
showAvatar ? 44 : 16
}
@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, contentIndent)
.padding(.bottom, 8)
// Replies list
if showReplies {
VStack(alignment: .leading, spacing: 0) {
ForEach(replies) { reply in
CommentView(comment: reply, source: source, isReply: true, showAvatar: showAvatar)
.padding(.leading, repliesIndent)
if reply.id != replies.last?.id {
Divider()
.padding(.leading, repliesIndent)
}
}
// Load more replies
if isLoadingReplies {
HStack {
Spacer()
ProgressView()
.padding(.vertical, 8)
Spacer()
}
.padding(.leading, repliesIndent)
} 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, repliesIndent)
.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
}
}