Files
org-social-ios/App/ViewModels/PostActionsViewModel.swift
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

123 lines
5.0 KiB
Swift

import Foundation
import Observation
import OrgSocialKit
@Observable @MainActor
final class PostActionsViewModel {
var isDeleting = false
var isEditing = false
var errorMessage: String?
var didDelete = false
private let writer = PostWriter()
private let fetcher = FeedFetcher()
func deletePost(_ post: OrgSocialPost) async {
guard let feedURL = ownFeedURL, let uploader = ownUploader else { return }
isDeleting = true
errorMessage = nil
defer { isDeleting = false }
do {
// bypassCache: raw Git host CDNs (raw.githubusercontent, codeberg.org/raw,
// host.org-social.org) can serve stale content after a recent upload; without
// this the post lookup would fail with "No post with that timestamp was found".
let content = try await fetcher.fetch(from: feedURL, bypassCache: true)
var updated = try writer.deletePost(timestamp: post.timestamp, from: content)
// Clear #+PINNED if it still points at the post we just deleted,
// otherwise other clients render a broken pin reference.
if OrgSocialParser().parse(content).pinned == post.timestamp {
updated = ProfileWriter().removeKeyword("PINNED", from: updated)
}
try await uploader.upload(content: updated)
FollowCoordinator.shared.updateCachedContent(updated)
didDelete = true
} catch {
errorMessage = error.localizedDescription
}
}
func editPost(_ post: OrgSocialPost, newText: String) async {
await editPost(post, newText: newText, newTimestamp: nil, lang: nil, tags: nil, mood: nil, visibility: .public)
}
/// Full-fidelity edit that updates text plus LANG, TAGS, MOOD, VISIBILITY and optionally the timestamp.
func editPost(
_ post: OrgSocialPost,
newText: String,
newTimestamp: String?,
lang: String?,
tags: String?,
mood: String?,
visibility: NewPostOptions.Visibility
) async {
guard let feedURL = ownFeedURL, let uploader = ownUploader else { return }
isEditing = true
errorMessage = nil
defer { isEditing = false }
do {
// bypassCache: raw Git host CDNs (raw.githubusercontent, codeberg.org/raw,
// host.org-social.org) can serve stale content after a recent upload; without
// this the post lookup would fail with "No post with that timestamp was found".
let content = try await fetcher.fetch(from: feedURL, bypassCache: true)
var updated = try writer.editPost(
timestamp: post.timestamp,
in: content,
newText: newText,
newTimestamp: newTimestamp,
lang: lang,
tags: tags,
mood: mood,
visibility: visibility
)
// If the edit moved the post to a new timestamp, retarget PINNED
// so it still points at the edited post instead of a ghost.
if let newTs = newTimestamp,
OrgSocialParser().parse(content).pinned == post.timestamp {
updated = ProfileWriter().setKeyword("PINNED", value: newTs, in: updated)
}
try await uploader.upload(content: updated)
FollowCoordinator.shared.updateCachedContent(updated)
} catch {
errorMessage = error.localizedDescription
}
}
/// Sets `#+PINNED:` to the given post's timestamp, replacing any previous pin.
func pinPost(_ post: OrgSocialPost) async {
guard let feedURL = ownFeedURL, let uploader = ownUploader else { return }
errorMessage = nil
do {
let content = try await fetcher.fetch(from: feedURL, bypassCache: true)
let updated = ProfileWriter().setKeyword("PINNED", value: post.timestamp, in: content)
try await uploader.upload(content: updated)
FollowCoordinator.shared.updateCachedContent(updated)
} catch {
errorMessage = error.localizedDescription
}
}
/// Removes `#+PINNED:` from the feed.
func unpinPost() async {
guard let feedURL = ownFeedURL, let uploader = ownUploader else { return }
errorMessage = nil
do {
let content = try await fetcher.fetch(from: feedURL, bypassCache: true)
let updated = ProfileWriter().removeKeyword("PINNED", from: content)
try await uploader.upload(content: updated)
FollowCoordinator.shared.updateCachedContent(updated)
} catch {
errorMessage = error.localizedDescription
}
}
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 var ownUploader: (any FeedUploader)? { UploaderFactory.makeUploader() }
}