Files

140 lines
5.1 KiB
Swift

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] = [:]
var followerCount: Int?
private let fetcher = ProfileFetcher()
private let feedURL: URL
private var enrichTask: Task<Void, Never>?
private var relayURL: URL? {
guard let raw = UserDefaults.standard.string(forKey: "relayURL"),
let url = URL(string: raw) else { return nil }
return url
}
private var useRelay: Bool {
UserDefaults.standard.object(forKey: "useRelay") as? Bool ?? true
}
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 {
async let profileResult = fetcher.fetch(from: feedURL)
async let countResult = fetchFollowerCountIfEnabled()
profile = try await profileResult
followerCount = await countResult
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 {
async let profileResult = fetcher.fetch(from: feedURL, bypassCache: true)
async let countResult = fetchFollowerCountIfEnabled()
profile = try await profileResult
followerCount = await countResult
startEnrichingFollows()
} catch {
errorMessage = error.localizedDescription
}
}
private func fetchFollowerCountIfEnabled() async -> Int? {
guard useRelay, let relay = relayURL else { return nil }
return await RelayClient().fetchFollowerCount(for: feedURL, from: relay)
}
/// 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..<maxConcurrent {
guard let url = iterator.next() else { break }
group.addTask { await Self.fetchDetails(for: url) }
}
while let (url, details) = await group.next() {
if Task.isCancelled { group.cancelAll(); return }
if let details { enrichedFollows[url] = details }
if let next = iterator.next() {
group.addTask { await Self.fetchDetails(for: next) }
}
}
}
}
private static func fetchDetails(for url: URL) async -> (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)
}
}