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 = [] @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 } }