import Foundation /// Builds a unified, sorted timeline from multiple Org Social feeds. /// /// ```swift /// let fetcher = TimelineFetcher() /// let posts = await fetcher.fetch(following: myProfile) /// ``` public struct TimelineFetcher: Sendable { private let session: URLSession private let feedFetcher: FeedFetcher private let relayClient: RelayClient private let parser: OrgSocialParser public init(session: URLSession = .shared) { self.session = session self.feedFetcher = FeedFetcher(session: session) self.relayClient = RelayClient(session: session) self.parser = OrgSocialParser() } // MARK: - Public API /// Downloads all relevant feeds and returns the merged, filtered, and /// date-sorted timeline. /// /// Feed sources (in priority order): /// 1. If `options.useRelay` is `true`: all feeds known to the relay. /// Falls back to the profile's follow list if the relay is unreachable. /// 2. If `options.useRelay` is `false`: only the profile's `#+FOLLOW:` list. /// /// The profile's own posts are always included (deduplicated by URL). /// Failed individual feed downloads are silently skipped. /// /// - Parameters: /// - profile: Your parsed `OrgSocialProfile`. Must have `feedURL` set /// for visibility filtering to work correctly. /// - options: Timeline configuration. Defaults to `TimelineOptions.default`. /// - Returns: Posts sorted by date descending, with author metadata attached. public func fetch( following profile: OrgSocialProfile, options: TimelineOptions = .default ) async -> [OrgSocialPost] { // 1. Resolve the list of feed URLs to download let feedURLs = await resolveFeedURLs(profile: profile, options: options) // 2. Download and parse all feeds concurrently var profiles = await downloadProfiles(from: feedURLs, maxConcurrent: options.maxConcurrentDownloads) // 3. Include own profile. The caller-provided copy takes precedence — // it was fetched with bypassCache, so it's authoritative even when the // relay-wide download picked up a stale version of the same URL. if let ownURL = profile.feedURL?.absoluteString { if let idx = profiles.firstIndex(where: { $0.feedURL?.absoluteString == ownURL }) { profiles[idx] = profile } else { profiles.append(profile) } } // 4. Flatten posts and attach author metadata let cutoff = cutoffDate(options: options) var allPosts: [OrgSocialPost] = [] for p in profiles { for var post in p.posts { post.authorNick = p.nick post.authorURL = p.feedURL post.authorAvatar = p.avatar post.feedURL = p.feedURL allPosts.append(post) } } // 5. Filter and sort return allPosts .filter { shouldInclude(post: $0, myFeedURL: profile.feedURL, options: options, cutoff: cutoff) } .sorted { $0.date > $1.date } } // MARK: - Feed URL resolution private func resolveFeedURLs( profile: OrgSocialProfile, options: TimelineOptions ) async -> [URL] { let followURLs = profile.follows.map(\.url) guard options.useRelay else { return followURLs } // With the relay on, the user can still opt to see only feeds they follow. guard options.showAllRelayFeeds else { return followURLs } do { return try await relayClient.fetchFeeds(from: options.relayURL) } catch { // Relay unreachable: fall back silently to local follow list return followURLs } } // MARK: - Concurrent feed download /// Downloads and parses multiple feeds with a bounded concurrency window. private func downloadProfiles(from urls: [URL], maxConcurrent: Int) async -> [OrgSocialProfile] { guard !urls.isEmpty else { return [] } let limit = max(1, maxConcurrent) return await withTaskGroup(of: OrgSocialProfile?.self) { group in var results: [OrgSocialProfile] = [] var index = 0 // Seed the initial batch while index < min(limit, urls.count) { let url = urls[index] group.addTask { await self.downloadProfile(from: url) } index += 1 } // Sliding window: as each task finishes, add the next URL for await result in group { if let p = result { results.append(p) } if index < urls.count { let url = urls[index] group.addTask { await self.downloadProfile(from: url) } index += 1 } } return results } } /// Downloads and parses a single feed. Returns `nil` on any error. private func downloadProfile(from url: URL) async -> OrgSocialProfile? { guard let content = try? await feedFetcher.fetch(from: url) else { return nil } var profile = parser.parse(content) profile.feedURL = url return profile } // MARK: - Post filtering private func cutoffDate(options: TimelineOptions) -> Date? { guard let days = options.maxPostAgeDays else { return nil } return Calendar.current.date(byAdding: .day, value: -days, to: Date()) } private func shouldInclude( post: OrgSocialPost, myFeedURL: URL?, options: TimelineOptions, cutoff: Date? ) -> Bool { // Group posts belong to a separate feed, not the main timeline if post.group != nil { return false } // Hide protocol artefacts (pure reactions/boosts/votes). if post.isPureInteraction { return false } // Date cutoff if let cutoff, post.date < cutoff { return false } // Language filter if !options.languageFilter.isEmpty { guard let lang = post.lang, options.languageFilter.contains(lang) else { return false } } // Visibility: mention-only posts are shown only to the author and mentioned users if post.visibility == "mention" { guard let myURL = myFeedURL?.absoluteString else { return false } let isAuthor = post.authorURL?.absoluteString == myURL let isMentioned = post.text.contains("[[org-social:\(myURL)][") if !isAuthor && !isMentioned { return false } } return true } }