140 lines
5.1 KiB
Swift
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)
|
|
}
|
|
}
|