~ knotless Search/
↑↓ move↵ open⇧↵ all results

grain.social/grain-ios

1 collaborator
0
1
main
180 lines · 6.4 KB Raw History
import Foundation
import os

private let logger = Logger(subsystem: "social.grain.grain", category: "StoryComments")
private let scvmSignposter = OSSignposter(subsystem: "social.grain.grain", category: "StoryComments")

@Observable
@MainActor
final class StoryCommentsViewModel {
    var comments: [GrainComment] = []
    // `firstComment` is `response.comments.first`, which is the oldest/chronological
    // comment on the story. Ideally this would prefer comments from followed users,
    // then fall back to most-liked, but the getCommentThread endpoint doesn't return
    // viewer state on authors or likeCount on comments. Bumping the preview fetch
    // from limit=1 to support client-side selection also adds ~100ms per request.
    // Revisit when the backend hydrates those fields.
    var firstComment: GrainComment?
    var totalCount: Int = 0
    var isLoading = false
    var isPostingComment = false

    private(set) var activeStoryUri: String?
    private var commentCursor: String?
    private var hasMoreComments = true
    private var previewCache: [String: CachedPreview] = [:]
    private let client: XRPCClient

    init(client: XRPCClient) {
        self.client = client
    }

    // MARK: - Story Switching

    func switchToStory(uri: String, auth: AuthContext? = nil) {
        guard uri != activeStoryUri else { return }
        activeStoryUri = uri
        comments = []
        commentCursor = nil
        hasMoreComments = true

        if let cached = previewCache[uri] {
            firstComment = cached.comment
            totalCount = cached.count
        } else {
            firstComment = nil
            totalCount = 0
            Task { await loadPreview(storyUri: uri, auth: auth) }
        }
    }

    // MARK: - Preview Loading

    /// Fires background preview loads for the given story URIs. Already-cached URIs are skipped.
    func prefetchPreviews(for storyUris: [String], auth: AuthContext? = nil) {
        for uri in storyUris where previewCache[uri] == nil {
            Task { await loadPreview(storyUri: uri, auth: auth) }
        }
    }

    func loadPreview(storyUri: String, auth: AuthContext? = nil) async {
        if let cached = previewCache[storyUri] {
            if activeStoryUri == nil || activeStoryUri == storyUri {
                firstComment = cached.comment
                totalCount = cached.count
            }
            return
        }

        do {
            let response = try await client.getCommentThread(subject: storyUri, limit: 1, auth: auth)
            let preview = CachedPreview(
                comment: response.comments.first,
                count: response.totalCount ?? response.comments.count
            )
            previewCache[storyUri] = preview
            if activeStoryUri == nil || activeStoryUri == storyUri {
                firstComment = preview.comment
                totalCount = preview.count
            }
        } catch {
            logger.error("Failed to load comment preview: \(error)")
        }
    }

    // MARK: - Full Comment Loading

    func loadComments(storyUri: String, auth: AuthContext? = nil) async {
        guard !isLoading else {
            scvmSignposter.emitEvent("loadComments.skipped", "reason=already-loading")
            return
        }
        let state = scvmSignposter.beginInterval("loadComments", "uri=\(storyUri)")
        isLoading = true
        commentCursor = nil
        hasMoreComments = true

        do {
            let response = try await client.getCommentThread(subject: storyUri, auth: auth)
            comments = response.comments
            commentCursor = response.cursor
            hasMoreComments = response.cursor != nil
            totalCount = response.totalCount ?? response.comments.count

            // Update cache with fresh data
            let latest = response.comments.first
            previewCache[storyUri] = CachedPreview(comment: latest, count: totalCount)
            if storyUri == activeStoryUri {
                firstComment = latest
            }
            let count = response.comments.count
            scvmSignposter.endInterval("loadComments", state, "count=\(count)")
        } catch {
            logger.error("Failed to load comments: \(error)")
            scvmSignposter.endInterval("loadComments", state, "error")
        }
        isLoading = false
    }

    func loadMoreComments(storyUri: String, auth: AuthContext? = nil) async {
        guard !isLoading, hasMoreComments, let cursor = commentCursor else { return }
        isLoading = true

        do {
            let response = try await client.getCommentThread(subject: storyUri, cursor: cursor, auth: auth)
            comments.append(contentsOf: response.comments)
            commentCursor = response.cursor
            hasMoreComments = response.cursor != nil
        } catch {
            logger.error("Failed to load more comments: \(error)")
        }
        isLoading = false
    }

    // MARK: - Comment CRUD

    func postComment(text: String, storyUri: String, replyTo: GrainComment? = nil, auth: AuthContext) async {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }

        isPostingComment = true
        do {
            _ = try await CommentService.create(
                subject: storyUri,
                text: trimmed,
                replyTo: replyTo?.uri,
                client: client,
                auth: auth
            )
            previewCache.removeValue(forKey: storyUri)
            await loadComments(storyUri: storyUri, auth: auth)
        } catch {
            logger.error("Failed to post comment: \(error)")
        }
        isPostingComment = false
    }

    func deleteComment(_ comment: GrainComment, storyUri: String, auth: AuthContext) async {
        do {
            try await CommentService.delete(commentUri: comment.uri, client: client, auth: auth)
            comments.removeAll { $0.uri == comment.uri }
            totalCount = max(totalCount - 1, 0)

            // Update cache
            let latest = comments.first
            previewCache[storyUri] = CachedPreview(comment: latest, count: totalCount)
            if storyUri == activeStoryUri {
                firstComment = latest
            }
        } catch {
            logger.error("Failed to delete comment: \(error)")
        }
    }
}

// MARK: - Cache

private struct CachedPreview {
    let comment: GrainComment?
    let count: Int
}