2154e1d576
Pinned feeds (org-social + andros) were included in the random shuffle and capped at 100, so on a large relay they could miss the cut entirely. Now fetched sequentially before the batch loop and excluded from the shuffled pool.
135 lines
4.8 KiB
Swift
135 lines
4.8 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?
|
|
|
|
// These always appear first in Discover, in this order.
|
|
private let pinnedFeedURLs: [URL] = [
|
|
URL(string: "https://host.org-social.org/org-social/social.org")!,
|
|
URL(string: "https://host.org-social.org/andros/social.org")!
|
|
]
|
|
|
|
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 ?? ""
|
|
let partialFetcher = PartialFeedFetcher()
|
|
|
|
func makeUser(from feedURL: URL) async -> DiscoverUser? {
|
|
guard let profile = await partialFetcher.fetchProfileHeader(from: feedURL),
|
|
let nick = profile.nick, !nick.isEmpty else { return nil }
|
|
return DiscoverUser(
|
|
feedURL: feedURL,
|
|
nick: nick,
|
|
description: profile.description,
|
|
avatar: profile.avatar,
|
|
isFollowing: coordinator.isFollowing(feedURL)
|
|
)
|
|
}
|
|
|
|
// Always fetch pinned feeds first, in declared order.
|
|
var pinned: [DiscoverUser] = []
|
|
for url in pinnedFeedURLs {
|
|
if url.absoluteString != ownURLString, let user = await makeUser(from: url) {
|
|
pinned.append(user)
|
|
}
|
|
}
|
|
|
|
// Shuffle the rest (excluding own feed and pinned), cap at 100.
|
|
let pinnedStrings = Set(pinnedFeedURLs.map(\.absoluteString))
|
|
let shuffled = allFeeds
|
|
.filter { $0.absoluteString != ownURLString && !pinnedStrings.contains($0.absoluteString) }
|
|
.shuffled()
|
|
|
|
// Fetch only the header section (nick/avatar/description) for each feed.
|
|
// PartialFeedFetcher downloads just the first 16 KB via Range requests,
|
|
// skipping the posts section entirely — ~10-100x less data than a full fetch.
|
|
var rest: [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 { await makeUser(from: feedURL) }
|
|
}
|
|
var collected: [DiscoverUser] = []
|
|
for await user in group {
|
|
if let user { collected.append(user) }
|
|
}
|
|
return collected
|
|
}
|
|
rest.append(contentsOf: batchResults)
|
|
}
|
|
|
|
users = pinned + rest.shuffled()
|
|
} 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)
|
|
}
|
|
}
|
|
}
|