Files
andros e4fdde15aa Full edit fidelity, poll field parity, mention warning, reaction remove
- EditPostView now edits Schedule / Visibility / Language / Mood / Tags; form
  state lives in an @Observable model to prevent init-time @State resets that
  were silently dropping selections. Visibility/Mood rows switched away from
  Menu/confirmationDialog (which refused to propagate selection inside the
  sheet) to an inline segmented Picker and a horizontal emoji strip + free
  text field. Shared across Compose and Edit via PostOptionRows.
- Polls now accept lang/tags/mood/visibility (spec-compliant); ComposeView no
  longer hides those rows when Poll is on. NewPostOptions.poll signature
  extended with the new optional params.
- Compose warns before publishing a mention-only post that has no
  [[org-social:URL][nick]] links in the body (would otherwise be invisible).
- Reactions can be removed: ReactionViewModel gains loadExistingReaction
  (scans own feed on row appear so the toggled state survives relaunches) and
  unreact (deletes the reaction post). Tapping the React button when already
  reacted now removes the reaction.
- PostRowView strips raw `- [ ] Option` checkbox lines from poll body render
  so the poll card is the sole UI for options.
- PostWriter.editPost learns MOOD; OrgSocialPost.mood promoted to var so
  timeline/profile applyEdit can update it.
2026-04-22 11:27:30 +02:00

119 lines
4.3 KiB
Swift

import Foundation
import Observation
import OrgSocialKit
@Observable @MainActor
final class ProfileViewModel {
struct FollowDetails: Equatable {
let nick: String?
let avatar: URL?
}
var profile: OrgSocialProfile?
var isLoading = false
var errorMessage: String?
/// Nick + avatar fetched from each followed feed's own header. Populated
/// asynchronously after the profile loads so rows fill in incrementally
/// instead of all at once.
var enrichedFollows: [URL: FollowDetails] = [:]
private let fetcher = ProfileFetcher()
private let feedURL: URL
private var enrichTask: Task<Void, Never>?
init(feedURL: URL) {
self.feedURL = feedURL
}
func removePost(timestamp: String) {
profile?.posts.removeAll { $0.timestamp == timestamp }
}
func updatePost(timestamp: String, newText: String) {
guard let idx = profile?.posts.firstIndex(where: { $0.timestamp == timestamp }) else { return }
profile?.posts[idx].text = newText
}
func applyEdit(timestamp: String, result: PostEditResult) {
guard let idx = profile?.posts.firstIndex(where: { $0.timestamp == timestamp }) else { return }
profile?.posts[idx].text = result.newText
profile?.posts[idx].lang = result.lang
profile?.posts[idx].tags = result.tags.map { $0.split(separator: " ").map(String.init) } ?? []
profile?.posts[idx].mood = result.mood
profile?.posts[idx].visibility = result.visibility == .mention ? "mention" : nil
if let newTs = result.newTimestamp {
profile?.posts[idx].timestamp = newTs
if let newDate = PostWriter.parseTimestamp(newTs) {
profile?.posts[idx].date = newDate
}
}
}
func load() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
profile = try await fetcher.fetch(from: feedURL)
startEnrichingFollows()
} catch {
errorMessage = error.localizedDescription
}
}
/// Force reload. Use after the profile was mutated remotely (e.g. after EditProfile saves).
/// Bypasses caches so the newly-uploaded headers are visible immediately.
func reload() async {
guard !isLoading else { return }
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
profile = try await fetcher.fetch(from: feedURL, bypassCache: true)
startEnrichingFollows()
} catch {
errorMessage = error.localizedDescription
}
}
/// Fetches each followed feed's header in parallel (cap: 8 concurrent)
/// and merges nick + avatar into `enrichedFollows`. Runs in the
/// background; the UI updates incrementally as results arrive.
private func startEnrichingFollows() {
enrichTask?.cancel()
guard let follows = profile?.follows else { return }
let pending = follows.filter { enrichedFollows[$0.url] == nil }
guard !pending.isEmpty else { return }
enrichTask = Task { [weak self] in
await self?.enrich(urls: pending.map(\.url))
}
}
private func enrich(urls: [URL]) async {
let maxConcurrent = 8
await withTaskGroup(of: (URL, FollowDetails?).self) { group in
var iterator = urls.makeIterator()
for _ in 0..<maxConcurrent {
guard let url = iterator.next() else { break }
group.addTask { await Self.fetchDetails(for: url) }
}
while let (url, details) = await group.next() {
if Task.isCancelled { group.cancelAll(); return }
if let details { enrichedFollows[url] = details }
if let next = iterator.next() {
group.addTask { await Self.fetchDetails(for: next) }
}
}
}
}
private static func fetchDetails(for url: URL) async -> (URL, FollowDetails?) {
guard let content = try? await FeedFetcher().fetch(from: url) else { return (url, nil) }
let parsed = OrgSocialParser().parse(content)
let details = FollowDetails(nick: parsed.nick, avatar: parsed.avatar)
return (url, details)
}
}