Files
andros f9f30564fe Hide pure-interaction posts from profile; self-register feed on launch
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.
2026-04-22 13:09:16 +02:00

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