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

151 lines
6.4 KiB
Swift

import Foundation
import Observation
import OrgSocialKit
// OrgSocialParser, FeedFetcher, OrgSocialProfile are all in OrgSocialKit
@Observable @MainActor
final class TimelineViewModel {
var posts: [OrgSocialPost] = []
var isLoading = false
var errorMessage: String?
@ObservationIgnored private var deletedTimestamps: Set<String> = []
@ObservationIgnored private var isMutating = false
private let fetcher = TimelineFetcher()
private static let anonymousProfile = OrgSocialProfile()
func removePost(timestamp: String) {
deletedTimestamps.insert(timestamp)
isMutating = true
posts.removeAll { $0.timestamp == timestamp }
isMutating = false
}
func updatePost(timestamp: String, newText: String) {
guard let idx = posts.firstIndex(where: { $0.timestamp == timestamp }) else { return }
isMutating = true
posts[idx].text = newText
isMutating = false
}
func applyEdit(timestamp: String, result: PostEditResult) {
guard let idx = posts.firstIndex(where: { $0.timestamp == timestamp }) else { return }
isMutating = true
posts[idx].text = result.newText
posts[idx].lang = result.lang
posts[idx].tags = result.tags.map { $0.split(separator: " ").map(String.init) } ?? []
posts[idx].mood = result.mood
posts[idx].visibility = result.visibility == .mention ? "mention" : nil
if let newTs = result.newTimestamp {
posts[idx].timestamp = newTs
if let newDate = PostWriter.parseTimestamp(newTs) {
posts[idx].date = newDate
}
}
isMutating = false
}
/// Fetch the user's own feed and merge any posts not already in the timeline.
/// Used after composing to show the new post immediately, without waiting for the relay to re-index.
func mergeOwnFeed() async {
guard let ownURL = ownFeedURL else { return }
guard let content = try? await FeedFetcher().fetch(from: ownURL, bypassCache: true) else { return }
// After await: guard against concurrent mutation and recompute existing state freshly.
guard !isMutating else { return }
var parsed = OrgSocialParser().parse(content)
parsed.feedURL = ownURL
let now = Date()
// Dedupe by timestamp (not stableID) because the same own-post may already be in
// `posts` via the relay with a slightly different feedURL representation.
let existingTimestamps = Set(posts.map(\.timestamp))
let muted = MuteFilter.tokens(from: UserDefaults.standard.string(forKey: "mutedWords") ?? "")
let newOwn = parsed.posts
.filter { $0.date <= now }
// Same rule as TimelineFetcher: protocol artefacts (pure
// reaction / boost / poll-vote posts) don't belong as their
// own timeline row. Without this filter my own just-written
// boost appeared as an empty "Boost" card in the merged view.
.filter { !$0.isPureInteraction }
.filter { !existingTimestamps.contains($0.timestamp) && !deletedTimestamps.contains($0.timestamp) }
.filter { !MuteFilter.shouldHide($0, mutedTokens: muted) }
.map { post -> OrgSocialPost in
var p = post
p.authorNick = parsed.nick
p.authorURL = ownURL
p.authorAvatar = parsed.avatar
p.feedURL = ownURL
return p
}
guard !newOwn.isEmpty else { return }
isMutating = true
posts = (newOwn + posts).sorted { $0.date > $1.date }
isMutating = false
}
func load() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
let defaults = UserDefaults.standard
let useRelay = defaults.object(forKey: "useRelay") as? Bool ?? true
let showAllRelayFeeds = defaults.object(forKey: "showAllRelayFeeds") as? Bool ?? false
let relayRaw = defaults.string(forKey: "relayURL") ?? "https://relay.org-social.org"
let relayURL = URL(string: relayRaw) ?? URL(string: "https://relay.org-social.org")!
let maxAge = defaults.integer(forKey: "maxPostAgeDays").nonZero ?? 14
let options = TimelineOptions(
relayURL: relayURL,
useRelay: useRelay,
maxConcurrentDownloads: 20,
maxPostAgeDays: maxAge,
languageFilter: [],
showAllRelayFeeds: showAllRelayFeeds
)
// Always load the user's own profile with bypassCache. Two reasons:
// - When showAllRelayFeeds=false, its #+FOLLOW: list drives which feeds load.
// - When showAllRelayFeeds=true, the fetcher merges `profile` as the
// authoritative copy of own posts (TimelineFetcher dedupes by URL).
// Without this, the user's just-published post could be missing while
// the relay propagation catches up, or if the concurrent download of
// their feed happens to fail silently.
let profile: OrgSocialProfile
if let ownURL = ownFeedURL {
let content = (try? await FeedFetcher().fetch(from: ownURL, bypassCache: true)) ?? ""
var parsed = OrgSocialParser().parse(content)
parsed.feedURL = ownURL
profile = parsed
} else {
profile = Self.anonymousProfile
}
let muted = MuteFilter.tokens(from: defaults.string(forKey: "mutedWords") ?? "")
let blocks = BlockList.shared
for await batch in fetcher.stream(following: profile, options: options) {
posts = batch
.filter { !deletedTimestamps.contains($0.timestamp) }
.filter { !MuteFilter.shouldHide($0, mutedTokens: muted) }
.filter { post in
guard let url = post.authorURL else { return true }
return !blocks.isBlocked(url)
}
}
// Empty result is a valid state (e.g. following only accounts with no recent posts).
// The view renders a contextual empty-state banner with Discover / toggle actions.
}
private var ownFeedURL: URL? {
guard let raw = UserDefaults.standard.string(forKey: "publicFeedURL"),
let url = URL(string: raw), url.scheme?.hasPrefix("http") == true else { return nil }
return url
}
}
private extension Int {
var nonZero: Int? { self == 0 ? nil : self }
}