Files
andros db5ae3b189 Filter pure-interaction posts from the mergeOwnFeed injection too
TimelineFetcher already hides protocol artefacts (pure reactions, boosts,
poll votes) for the relay-assembled timeline, but mergeOwnFeed — which
injects the user's own recent posts ahead of the relay's re-crawl so
composing feels instant — was appending them unfiltered. A just-written
boost or reaction from this client therefore surfaced as an empty "Boost"
card (or a row with no body) until the relay picked up the filtered view.

Applying !$0.isPureInteraction on the merge path matches the timeline's
rule and keeps the user's own feed consistent with others'.
2026-04-24 10:48:18 +02:00

140 lines
5.8 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 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) }
.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: 10,
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 result = await fetcher.fetch(following: profile, options: options)
posts = result.filter { !deletedTimestamps.contains($0.timestamp) }
// 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 }
}