Files
andros 2154e1d576 Discover: guarantee pinned feeds always appear first regardless of relay size
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.
2026-05-16 11:47:38 +02:00

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)
}
}
}