db5ae3b189
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'.
140 lines
5.8 KiB
Swift
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 }
|
|
}
|