4e5a1d0cb7
- 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.
114 lines
4.0 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|