Files
andros 15eb8acbd0 Network performance: ETag caching, partial Range fetches, progressive timeline streaming
- FeedCache actor: conditional GET with ETag/Last-Modified, returns 304 hits from cache
- PartialFeedFetcher: Range requests for header-only Discover fetches and date-filtered timeline fetches (2 requests instead of 3 per feed)
- TimelineFetcher: AsyncStream-based streaming emits posts as each feed arrives instead of waiting for all feeds
- TimelineViewModel: consumes stream progressively so posts appear immediately on first feed
- Discover uses header-only fetch (16 KB vs full file)
- maxConcurrentDownloads raised from 10 to 20
2026-05-16 10:12:41 +02:00

210 lines
7.8 KiB
Swift

import Foundation
/// Builds a unified, sorted timeline from multiple Org Social feeds.
///
/// ```swift
/// let fetcher = TimelineFetcher()
/// let posts = await fetcher.fetch(following: myProfile)
///
/// // Or receive posts progressively as each feed finishes:
/// for await batch in fetcher.stream(following: myProfile) {
/// updateUI(with: batch)
/// }
/// ```
public struct TimelineFetcher: Sendable {
private let session: URLSession
private let feedFetcher: FeedFetcher
private let partialFetcher: PartialFeedFetcher
private let relayClient: RelayClient
private let parser: OrgSocialParser
public init(session: URLSession = .shared) {
self.session = session
self.feedFetcher = FeedFetcher(session: session)
self.partialFetcher = PartialFeedFetcher(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. Equivalent to consuming `stream()` and keeping
/// only the last emitted batch.
public func fetch(
following profile: OrgSocialProfile,
options: TimelineOptions = .default
) async -> [OrgSocialPost] {
var latest: [OrgSocialPost] = []
for await batch in stream(following: profile, options: options) {
latest = batch
}
return latest
}
/// Streams the timeline progressively: emits a new sorted `[OrgSocialPost]`
/// batch each time a feed finishes downloading, so callers can update the UI
/// incrementally without waiting for every feed to complete.
///
/// The own profile (from `profile.feedURL`) is included from the very first
/// emission. Relay feeds are added as they arrive. The stream finishes once
/// all feeds have been processed.
///
/// Feed sources (in priority order):
/// 1. If `options.useRelay && options.showAllRelayFeeds`: all relay feeds.
/// Falls back to the follow list if the relay is unreachable.
/// 2. Otherwise: only the profile's `#+FOLLOW:` list.
///
/// Failed individual feed downloads are silently skipped.
public func stream(
following profile: OrgSocialProfile,
options: TimelineOptions = .default
) -> AsyncStream<[OrgSocialPost]> {
AsyncStream { continuation in
Task {
let feedURLs = await self.resolveFeedURLs(profile: profile, options: options)
let since: Date? = options.maxPostAgeDays.flatMap {
Calendar.current.date(byAdding: .day, value: -$0, to: Date())
}
let cutoff = self.cutoffDate(options: options)
let ownURLString = profile.feedURL?.absoluteString
// Own profile is authoritative: seed the accumulator immediately so
// the first yield already contains the user's own posts.
var accumulated: [OrgSocialProfile] = []
if profile.feedURL != nil {
accumulated.append(profile)
}
let limit = max(1, options.maxConcurrentDownloads)
await withTaskGroup(of: OrgSocialProfile?.self) { group in
var index = 0
while index < min(limit, feedURLs.count) {
let url = feedURLs[index]
group.addTask { await self.downloadProfile(from: url, since: since) }
index += 1
}
for await result in group {
if let p = result {
// Skip relay's copy of own feed caller's copy is authoritative.
if p.feedURL?.absoluteString != ownURLString {
accumulated.append(p)
}
continuation.yield(
self.assemblePosts(from: accumulated, ownFeedURL: profile.feedURL,
options: options, cutoff: cutoff)
)
}
if index < feedURLs.count {
let url = feedURLs[index]
group.addTask { await self.downloadProfile(from: url, since: since) }
index += 1
}
}
}
// Emit final state: covers the case where all feeds failed (no yields
// inside the group) or the feed list was empty (own profile only).
continuation.yield(
self.assemblePosts(from: accumulated, ownFeedURL: profile.feedURL,
options: options, cutoff: cutoff)
)
continuation.finish()
}
}
}
// 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 }
guard options.showAllRelayFeeds else { return followURLs }
do {
return try await relayClient.fetchFeeds(from: options.relayURL)
} catch {
return followURLs
}
}
// MARK: - Single feed download
private func downloadProfile(from url: URL, since: Date?) async -> OrgSocialProfile? {
if let since {
if let content = await partialFetcher.fetchSince(from: url, since: since) {
var profile = parser.parse(content)
profile.feedURL = url
return profile
}
}
guard let content = try? await feedFetcher.fetch(from: url) else { return nil }
var profile = parser.parse(content)
profile.feedURL = url
return profile
}
// MARK: - Post assembly
/// Flattens, filters, and sorts posts from the accumulated profile list.
private func assemblePosts(
from profiles: [OrgSocialProfile],
ownFeedURL: URL?,
options: TimelineOptions,
cutoff: Date?
) -> [OrgSocialPost] {
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)
}
}
return allPosts
.filter { shouldInclude(post: $0, myFeedURL: ownFeedURL, options: options, cutoff: cutoff) }
.sorted { $0.date > $1.date }
}
// 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 {
if post.group != nil { return false }
if post.isPureInteraction { return false }
if let cutoff, post.date < cutoff { return false }
if !options.languageFilter.isEmpty {
guard let lang = post.lang,
options.languageFilter.contains(lang) else { return false }
}
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
}
}