15eb8acbd0
- 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
210 lines
7.8 KiB
Swift
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
|
|
}
|
|
}
|