import Foundation import Observation import OrgSocialKit @Observable @MainActor final class ProfileViewModel { struct FollowDetails: Equatable { let nick: String? let avatar: URL? } var profile: OrgSocialProfile? var isLoading = false var errorMessage: String? /// Nick + avatar fetched from each followed feed's own header. Populated /// asynchronously after the profile loads so rows fill in incrementally /// instead of all at once. var enrichedFollows: [URL: FollowDetails] = [:] private let fetcher = ProfileFetcher() private let feedURL: URL private var enrichTask: Task? init(feedURL: URL) { self.feedURL = feedURL } func removePost(timestamp: String) { profile?.posts.removeAll { $0.timestamp == timestamp } } func updatePost(timestamp: String, newText: String) { guard let idx = profile?.posts.firstIndex(where: { $0.timestamp == timestamp }) else { return } profile?.posts[idx].text = newText } func applyEdit(timestamp: String, result: PostEditResult) { guard let idx = profile?.posts.firstIndex(where: { $0.timestamp == timestamp }) else { return } profile?.posts[idx].text = result.newText profile?.posts[idx].lang = result.lang profile?.posts[idx].tags = result.tags.map { $0.split(separator: " ").map(String.init) } ?? [] profile?.posts[idx].mood = result.mood profile?.posts[idx].visibility = result.visibility == .mention ? "mention" : nil if let newTs = result.newTimestamp { profile?.posts[idx].timestamp = newTs if let newDate = PostWriter.parseTimestamp(newTs) { profile?.posts[idx].date = newDate } } } func load() async { guard !isLoading else { return } isLoading = true errorMessage = nil defer { isLoading = false } do { profile = try await fetcher.fetch(from: feedURL) startEnrichingFollows() } catch { errorMessage = error.localizedDescription } } /// Force reload. Use after the profile was mutated remotely (e.g. after EditProfile saves). /// Bypasses caches so the newly-uploaded headers are visible immediately. func reload() async { guard !isLoading else { return } isLoading = true errorMessage = nil defer { isLoading = false } do { profile = try await fetcher.fetch(from: feedURL, bypassCache: true) startEnrichingFollows() } catch { errorMessage = error.localizedDescription } } /// Fetches each followed feed's header in parallel (cap: 8 concurrent) /// and merges nick + avatar into `enrichedFollows`. Runs in the /// background; the UI updates incrementally as results arrive. private func startEnrichingFollows() { enrichTask?.cancel() guard let follows = profile?.follows else { return } let pending = follows.filter { enrichedFollows[$0.url] == nil } guard !pending.isEmpty else { return } enrichTask = Task { [weak self] in await self?.enrich(urls: pending.map(\.url)) } } private func enrich(urls: [URL]) async { let maxConcurrent = 8 await withTaskGroup(of: (URL, FollowDetails?).self) { group in var iterator = urls.makeIterator() for _ in 0.. (URL, FollowDetails?) { guard let content = try? await FeedFetcher().fetch(from: url) else { return (url, nil) } let parsed = OrgSocialParser().parse(content) let details = FollowDetails(nick: parsed.nick, avatar: parsed.avatar) return (url, details) } }