f9f30564fe
The profile view was walking the raw post list and showing pure reactions,
boosts and poll votes as if they were first-class posts — visible as
"Reacted 😊" etc. rows. The timeline already filters these since they are
protocol artefacts rather than authored content; the profile should behave
the same way. The protocol spec is silent on profile filtering (confirmed
by re-reading README.org and the org-social.el reference lib) so this is a
UX choice, but consistency with the timeline is the right default.
- OrgSocialPost.isPureInteraction: single predicate covering
REPLY_TO+MOOD, INCLUDE, and REPLY_TO+POLL_OPTION body-less posts;
migration posts kept because their badge carries the content.
- TimelineFetcher.shouldInclude uses the helper.
- ProfileView's Posts section filters with it.
Separately: RootView now calls RelayClient.registerFeed on launch for the
configured feed URL, keyed by a UserDefaults flag per (relay, feed) pair so
it runs once. A brand-new account otherwise wouldn't appear on the relay
until someone followed it (daily follow-crawl cron) or it hit 404 in
NotificationsViewModel — up to 24h of silence. With self-register the scan
cron picks it up within ~60s, so interactions and notifications start
flowing immediately.
180 lines
6.5 KiB
Swift
180 lines
6.5 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)
|
|
/// ```
|
|
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
|
|
}
|
|
}
|