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

grain.social/grain-ios

1 collaborator
0
1
main
329 lines · 13.2 KB Raw History
import Foundation
import OSLog
import UIKit

private let profileLogger = Logger(subsystem: "social.grain.grain", category: "Profile")

@Observable
@MainActor
final class ProfileDetailViewModel {
    var profile: GrainProfileDetailed?
    var galleries: [GrainGallery] = []
    var stories: [GrainStory] = []
    var archivedStories: [GrainStory] = []
    var favoriteGalleries: [GrainGallery] = []
    var knownFollowers: [FollowerItem] = []
    var isLoading = false
    var error: Error?
    var showReauthAlert = false
    /// Set to `true` only after the first favorites network fetch completes.
    /// Views should use this (not `favoriteGalleries.isEmpty`) to decide between
    /// "loading" and "empty" — an empty array before the server has answered is
    /// not the same as a confirmed empty list.
    var favoritesLoaded = false
    var isLoadingFavorites = false
    var favoritesError: Error?
    /// URIs whose thumb a HEAD probe has confirmed is gone (404/410) — usually
    /// dangling refs to since-deleted blobs. `visibleFavorites` filters these
    /// out at render time so they vanish without a "deleted" placeholder, and
    /// `hydratedFavorites` strips them on the next load so they stay gone.
    var brokenFavoriteUris: Set<String> = []
    /// URIs whose thumb has already been probed this session (either confirmed
    /// reachable or confirmed broken). Used to skip redundant HEAD requests
    /// when new favorite batches come in. Transient probe failures don't add
    /// here so they're retried next pass.
    var probedFavoriteUris: Set<String> = []

    /// Favorites currently safe to render — drops anything a probe has
    /// confirmed broken. `favoriteGalleries` keeps the raw list so broken
    /// items can be retried if they come back; this computed view is what the
    /// grid iterates.
    var visibleFavorites: [GrainGallery] {
        favoriteGalleries.filter { !brokenFavoriteUris.contains($0.uri) }
    }

    private var galleryCursor: String?
    private(set) var hasMoreGalleries = true
    private var archiveCursor: String?
    private var hasMoreArchive = true
    private var archiveLoaded = false
    private var favoritesCursor: String?
    private(set) var hasMoreFavorites = true
    private let client: XRPCClient

    /// Max favorites persisted to disk. Enough for an instant top-of-list on
    /// re-open without bloating the cache.
    private static let favoritesDiskCacheLimit = 30

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

    func load(did: String, viewer: String? = nil, auth: AuthContext? = nil) async {
        isLoading = true
        error = nil
        favoritesLoaded = false
        archiveLoaded = false

        let isOwnProfile = viewer != nil && viewer == did
        if isOwnProfile, favoriteGalleries.isEmpty {
            let cached = FeedCache.shared.load(key: Self.favoritesCacheKey(did: did))
            let hydrated = hydratedFavorites(cached)
            if !hydrated.isEmpty {
                favoriteGalleries = hydrated
            }
        }

        async let profileFetch = client.getActorProfile(actor: did, auth: auth)
        async let feedFetch = client.getFeed(feed: "actor", actor: did, auth: auth)
        async let storiesFetch = client.getStories(actor: did, auth: auth)
        async let knownFollowersFetch: [FollowerItem] = {
            guard let viewer, viewer != did else { return [] }
            let response = try? await client.getKnownFollowers(actor: did, auth: auth)
            return response?.items ?? []
        }()

        // Await every `async let` binding in declaration order with no early
        // throw. `async let` child tasks are allocated on a stack and must be
        // torn down in reverse (LIFO) order; if `try await profileFetch` threw
        // before the later bindings were awaited, their tasks got finished out
        // of allocation order, tripping the Swift task allocator and aborting
        // the app (SIGABRT in asyncLet_finish_after_task_completion — App Store
        // crash at ProfileView.swift:487, builds 53/56). Capturing each result
        // first guarantees all four tasks are awaited in-scope before teardown.
        let profileResult: GrainProfileDetailed?
        do {
            profileResult = try await profileFetch
        } catch {
            self.error = error
            profileResult = nil
        }
        // Publish the header the moment its own fetch lands rather than gating
        // it on the feed/stories/known-followers requests. Purely an assignment
        // — the await order below is untouched, per the note above.
        if let profileResult {
            profile = profileResult
        }
        let feedResult = try? await feedFetch
        let storiesResult = try? await storiesFetch
        let knownResult = await knownFollowersFetch

        if profileResult != nil {
            galleries = feedResult?.items ?? []
            galleryCursor = feedResult?.cursor
            hasMoreGalleries = feedResult?.cursor != nil
            stories = storiesResult?.stories ?? []
            knownFollowers = knownResult
        }
        isLoading = false

        if isOwnProfile {
            Task { await self.loadFavorites(did: did, auth: auth) }
        }
    }

    func loadMoreGalleries(did: String, auth: AuthContext? = nil) async {
        guard !isLoading, hasMoreGalleries, let cursor = galleryCursor else { return }
        isLoading = true

        do {
            let response = try await client.getFeed(feed: "actor", cursor: cursor, actor: did, auth: auth)
            galleries.append(contentsOf: response.items ?? [])
            galleryCursor = response.cursor
            hasMoreGalleries = response.cursor != nil
        } catch {
            self.error = error
        }
        isLoading = false
    }

    func loadStoryArchive(did: String, auth: AuthContext? = nil) async {
        guard !archiveLoaded else { return }
        archiveLoaded = true
        do {
            let response = try await client.getStoryArchive(actor: did, auth: auth)
            archivedStories = response.stories
            archiveCursor = response.cursor
            hasMoreArchive = response.cursor != nil
        } catch {}
    }

    func loadMoreArchive(did: String, auth: AuthContext? = nil) async {
        guard !isLoading, hasMoreArchive, let cursor = archiveCursor else { return }
        isLoading = true
        do {
            let response = try await client.getStoryArchive(actor: did, cursor: cursor, auth: auth)
            archivedStories.append(contentsOf: response.stories)
            archiveCursor = response.cursor
            hasMoreArchive = response.cursor != nil
        } catch {}
        isLoading = false
    }

    func loadFavorites(did: String, auth: AuthContext? = nil) async {
        guard !favoritesLoaded, !isLoadingFavorites else { return }
        isLoadingFavorites = true
        favoritesError = nil
        profileLogger.info("loadFavorites start did=\(did, privacy: .public) hasAuth=\(auth != nil, privacy: .public)")
        do {
            let response = try await client.getActorFavorites(actor: did, auth: auth)
            favoriteGalleries = hydratedFavorites(response.items ?? [])
            favoritesCursor = response.cursor
            hasMoreFavorites = response.cursor != nil
            let loadedCount = favoriteGalleries.count
            profileLogger.info("loadFavorites ok count=\(loadedCount, privacy: .public)")
            let toCache = Array(favoriteGalleries.prefix(Self.favoritesDiskCacheLimit))
            let key = Self.favoritesCacheKey(did: did)
            // Captured before the hop, for the same reason as `FeedViewModel`:
            // the detached task would otherwise resolve the owner after the fact.
            let owner = AccountScopedStorage.activeAccountID
            Task.detached(priority: .utility) {
                FeedCache.shared.save(toCache, key: key, did: owner)
            }
        } catch {
            favoritesError = error
            profileLogger.error("loadFavorites failed: \(error, privacy: .public)")
        }
        favoritesLoaded = true
        isLoadingFavorites = false
    }

    private static func favoritesCacheKey(did: String) -> String {
        "favorites_\(did)"
    }

    /// Drops favorites that would render empty: galleries with no photos
    /// (deleted or never-populated), galleries whose thumb URL is missing or
    /// unparseable (LazyImage can't attempt a load so it'd sit blank forever),
    /// and galleries whose thumbnail was already confirmed broken this session
    /// (tracked in `brokenFavoriteUris`). Applied on every load path.
    private func hydratedFavorites(_ galleries: [GrainGallery]) -> [GrainGallery] {
        galleries.filter { gallery in
            guard !(gallery.items?.isEmpty ?? true) else { return false }
            guard !brokenFavoriteUris.contains(gallery.uri) else { return false }
            guard let thumb = gallery.items?.first?.thumb,
                  !thumb.isEmpty,
                  URL(string: thumb) != nil
            else { return false }
            return true
        }
    }

    func loadMoreFavorites(did: String, auth: AuthContext? = nil) async {
        guard !isLoading, hasMoreFavorites, let cursor = favoritesCursor else { return }
        isLoading = true
        do {
            let response = try await client.getActorFavorites(actor: did, cursor: cursor, auth: auth)
            favoriteGalleries.append(contentsOf: hydratedFavorites(response.items ?? []))
            favoritesCursor = response.cursor
            hasMoreFavorites = response.cursor != nil
        } catch {}
        isLoading = false
    }

    /// Whether the profile content should be hidden due to a block
    var isBlockHidden: Bool {
        profile?.viewer?.blocking != nil || profile?.viewer?.blockedBy == true
    }

    func toggleBlock(auth: AuthContext?) async {
        guard let profile, let auth else { return }
        let prevViewer = profile.viewer
        let did = profile.did

        if let blockUri = profile.viewer?.blocking {
            // Optimistic unblock
            self.profile?.viewer?.blocking = nil
            do {
                try await client.unblockActor(blockUri: blockUri, auth: auth)
            } catch {
                self.profile?.viewer = prevViewer
            }
        } else {
            // Optimistic block
            self.profile?.viewer?.blocking = "pending"
            do {
                let response = try await client.blockActor(did: did, auth: auth)
                self.profile?.viewer?.blocking = response.uri
            } catch {
                self.profile?.viewer = prevViewer
                showReauthAlert = true
            }
        }
    }

    func toggleMute(auth: AuthContext?) async {
        guard let profile, let auth else { return }
        let prevViewer = profile.viewer
        let did = profile.did

        if profile.viewer?.muted == true {
            // Optimistic unmute
            self.profile?.viewer?.muted = false
            do {
                try await client.unmuteActor(did: did, auth: auth)
            } catch {
                self.profile?.viewer = prevViewer
            }
        } else {
            // Optimistic mute
            self.profile?.viewer?.muted = true
            do {
                try await client.muteActor(did: did, auth: auth)
            } catch {
                self.profile?.viewer = prevViewer
            }
        }
    }

    func toggleFollow(auth: AuthContext?) async {
        guard profile != nil, let auth else { return }

        // Capture all values before any mutation
        let followUri = profile?.viewer?.following
        let prevViewer = profile?.viewer
        let prevCount = profile?.followersCount
        let did = profile!.did

        if let followUri {
            // Optimistic unfollow
            UIImpactFeedbackGenerator(style: .light).impactOccurred()
            profile?.viewer?.following = nil
            profile?.followersCount = max((prevCount ?? 1) - 1, 0)

            let rkey = followUri.split(separator: "/").last.map(String.init) ?? ""
            do {
                try await client.deleteRecord(collection: "social.grain.graph.follow", rkey: rkey, auth: auth)
            } catch {
                profile?.viewer = prevViewer
                profile?.followersCount = prevCount
            }
        } else {
            // Optimistic follow — preserve existing block/mute state
            UIImpactFeedbackGenerator(style: .medium).impactOccurred()
            if profile?.viewer == nil {
                profile?.viewer = ActorViewerState()
            }
            profile?.viewer?.following = "pending"
            profile?.followersCount = (prevCount ?? 0) + 1

            let record = AnyCodable([
                "subject": did,
                "createdAt": DateFormatting.nowISO(),
            ])
            let repo = TokenStorage.userDID ?? ""
            do {
                let response = try await client.createRecord(
                    collection: "social.grain.graph.follow",
                    repo: repo,
                    record: record,
                    auth: auth
                )
                profile?.viewer?.following = response.uri
            } catch {
                profile?.viewer = prevViewer
                profile?.followersCount = prevCount
            }
        }
    }
}