mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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.
307 lines
10 KiB
Swift
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
|
|
}
|
|
}
|