Files
andros 4e5a1d0cb7 Serialize follow toggles, enrich Following list with nick and avatar
- New FollowCoordinator keeps the own feed in memory, runs toggles as a
  strict sequence, and normalizes URL comparisons (scheme+host lowercase,
  trailing slash). Fixes lost writes when tapping Follow on several
  profiles in quick succession and wrong Follow/Unfollow state on rows
  whose URL differed only in casing or a trailing slash.
- ProfileView and DiscoverViewModel now route every follow/unfollow
  through the coordinator instead of each issuing its own read-modify-
  write cycle against a stale CDN copy.
- Timeline reloads automatically when followedURLs changes and pulls to
  refresh from the empty state.
- Own profile reloads with bypassCache and re-fetches when follows change
  elsewhere, so Following reflects reality.
- Following section fetches each follow's feed in parallel (cap 8) to
  fill in nick and avatar for entries without an inline #+FOLLOW: nick.
2026-04-21 10:10:23 +02:00

114 lines
4.0 KiB
Swift

import Foundation
import Observation
import OrgSocialKit
@Observable @MainActor
final class DiscoverViewModel {
struct DiscoverUser: Identifiable {
let feedURL: URL
let nick: String?
let description: String?
let avatar: URL?
var id: String { feedURL.absoluteString }
var isFollowing: Bool = false
}
var users: [DiscoverUser] = []
var isLoading = false
var errorMessage: String?
private var ownFeedURL: URL? {
guard let raw = UserDefaults.standard.string(forKey: "publicFeedURL"),
let url = URL(string: raw) else { return nil }
return url
}
private var relayURL: URL? {
guard let raw = UserDefaults.standard.string(forKey: "relayURL"),
let url = URL(string: raw) else { return nil }
return url
}
var useRelay: Bool {
UserDefaults.standard.object(forKey: "useRelay") as? Bool ?? true
}
func load() async {
guard useRelay else {
errorMessage = "Discover requires a relay. Enable Use Relay in Settings."
users = []
return
}
guard let relay = relayURL else {
errorMessage = "No relay URL configured."
return
}
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
let allFeeds = try await RelayClient().fetchFeeds(from: relay)
// Refresh the shared follow coordinator so we can mark followed users.
await FollowCoordinator.shared.refreshIfNeeded()
let coordinator = FollowCoordinator.shared
let ownURLString = ownFeedURL?.absoluteString ?? ""
// Shuffle and filter out own feed
let shuffled = allFeeds.filter { $0.absoluteString != ownURLString }.shuffled()
// Fetch profiles in batches of 10
var result: [DiscoverUser] = []
let batchSize = 10
for batchStart in stride(from: 0, to: min(shuffled.count, 100), by: batchSize) {
let batch = Array(shuffled[batchStart..<min(batchStart + batchSize, shuffled.count)])
let batchResults = await withTaskGroup(of: DiscoverUser?.self) { group in
for feedURL in batch {
group.addTask {
guard let content = try? await FeedFetcher().fetch(from: feedURL) else { return nil }
let profile = OrgSocialParser().parse(content)
guard let nick = profile.nick, !nick.isEmpty else { return nil }
return await DiscoverUser(
feedURL: feedURL,
nick: nick,
description: profile.description,
avatar: profile.avatar,
isFollowing: coordinator.isFollowing(feedURL)
)
}
}
var collected: [DiscoverUser] = []
for await user in group {
if let user { collected.append(user) }
}
return collected
}
result.append(contentsOf: batchResults)
}
users = result.sorted { ($0.nick ?? "") < ($1.nick ?? "") }
} catch {
errorMessage = error.localizedDescription
}
}
func follow(user: DiscoverUser) async {
await FollowCoordinator.shared.toggle(url: user.feedURL, nick: user.nick)
syncFollowState()
}
func unfollow(user: DiscoverUser) async {
await FollowCoordinator.shared.toggle(url: user.feedURL, nick: user.nick)
syncFollowState()
}
private func syncFollowState() {
let coordinator = FollowCoordinator.shared
for i in users.indices {
users[i].isFollowing = coordinator.isFollowing(users[i].feedURL)
}
}
}