Files
andros aa8b5563e6 Surface interaction errors, persist compose drafts, pull-to-refresh profile
PostRowView adds three alerts for the fire-and-forget interaction view
models (boost / reaction / poll-vote) the same way the actions view model
already had one. Their errorMessage was previously written into a
property that no UI ever read — so a failed upload left the button in
the wrong state with no signal to the user.

ComposeViewModel gains draftKey + saveDraftIfTopLevel + clearDraft, and
ComposeView wires onChange(of:text) / onDisappear to persist, plus
clearDraft on successful publish. Top-level posts only; reply and group
contexts are intentionally not drafted because their context URLs might
be stale by the time the user reopens the sheet.

ProfileView gets .refreshable that bypasses the CDN cache and also
forces the follow coordinator to refresh — matches the existing
Timeline pull-to-refresh so both tabs feel the same.

Two new integration tests on PostWriter.editPost cover the full-fidelity
edit path end-to-end: setting LANG/TAGS/MOOD/VISIBILITY + retargeting
the timestamp, and the inverse — clearing every property.

Three mention-renderer tests pin the public contract around inline
`@nick` output and its `.link` attribute so refactors can't quietly
break autocomplete round-tripping.
2026-04-24 11:48:48 +02:00

169 lines
6.3 KiB
Swift

import Foundation
import Observation
import OrgSocialKit
@Observable @MainActor
final class ComposeViewModel {
var text = ""
/// Mentions inserted via the `@` autocomplete. On publish, the Compose
/// view rewrites each `@nick` in `text` to the spec's
/// `[[org-social:URL][nick]]` form using this map.
var mentionMap: [String: String] = [:]
var lang = "en"
var tags = ""
/// Emoji / short text used as `:MOOD:` on the post. Allowed per spec on
/// any post, not just pure reactions.
var mood = ""
var visibility: NewPostOptions.Visibility = .public
var replyTo: String?
/// Optional human-readable nick of the post we are replying to. UI-only,
/// never serialised REPLY_TO holds the URL#timestamp.
var replyToNick: String?
var group: String?
var isPosting = false
var postedURL: String?
var errorMessage: String?
// Scheduling
var isScheduled = false
var scheduledDate = Date(timeIntervalSinceNow: 3600)
// Poll
var isPoll = false
var pollOptions: [String] = ["", ""]
var pollEndDate = Date(timeIntervalSinceNow: 7 * 86400)
init(replyTo: String? = nil, replyToNick: String? = nil, group: String? = nil) {
self.replyTo = replyTo
self.replyToNick = replyToNick
self.group = group
// Restore a top-level draft if the user had one in flight. We
// deliberately don't restore for replies or group posts because
// those drafts carry context that might no longer apply (replyTo
// URL could be stale, group could change).
if replyTo == nil, group == nil {
self.text = UserDefaults.standard.string(forKey: Self.draftKey) ?? ""
}
}
private static let draftKey = "compose.draft.text"
/// Persists the current body text as a draft. Called from ComposeView on
/// every text change and on dismiss. Top-level only.
func saveDraftIfTopLevel() {
guard replyTo == nil, group == nil else { return }
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
UserDefaults.standard.removeObject(forKey: Self.draftKey)
} else {
UserDefaults.standard.set(text, forKey: Self.draftKey)
}
}
/// Clears the persisted draft. Called after a successful publish.
func clearDraft() {
UserDefaults.standard.removeObject(forKey: Self.draftKey)
}
var hasFeedConfigured: Bool { publicFeedURL != nil && makeUploader() != nil }
var canPost: Bool {
let hasText = !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasPollOptions = isPoll && pollOptions.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }.count >= 2
return (hasText || hasPollOptions) && !isPosting && hasFeedConfigured
}
// MARK: - Actions
func post() async {
guard canPost,
let feedURL = publicFeedURL,
let uploader = makeUploader() else { return }
isPosting = true
errorMessage = nil
defer { isPosting = false }
do {
// Always download from the public feed URL (vfile token URL is upload-only)
// bypassCache: raw Git host CDNs lag behind a recent upload; without it
// appendPost can duplicate or the fetched content is missing prior writes.
let content = try await FeedFetcher().fetch(from: feedURL, bypassCache: true)
// Empty language field falls back to "en" rather than omitting
// :LANG:, keeping every post self-describing for downstream filters.
let resolvedLang = lang.trimmingCharacters(in: .whitespaces).nilIfEmpty ?? "en"
let resolvedTags = tags.trimmingCharacters(in: .whitespaces).nilIfEmpty
let resolvedMood = mood.trimmingCharacters(in: .whitespaces).nilIfEmpty
let resolvedVisibility: NewPostOptions.Visibility? = visibility == .public ? nil : .mention
// Rewrite `@nick` shortcuts to the spec's Org-link form before
// serialising. Plain `@text` that isn't in the map stays as-is.
let encodedBody = encodeMentions(
in: text.trimmingCharacters(in: .whitespacesAndNewlines),
using: mentionMap
)
let options: NewPostOptions
if isPoll {
let validOptions = pollOptions.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
options = NewPostOptions.poll(
question: encodedBody,
options: validOptions,
end: pollEndDate,
lang: resolvedLang,
tags: resolvedTags,
mood: resolvedMood,
visibility: resolvedVisibility
)
} else {
options = NewPostOptions(
text: encodedBody,
lang: resolvedLang,
tags: resolvedTags,
replyTo: replyTo,
mood: resolvedMood,
group: group,
visibility: resolvedVisibility
)
}
let postDate = isScheduled ? scheduledDate : Date()
let writer = PostWriter()
let (updated, url) = try writer.appendPost(to: content, feedURL: feedURL, options: options, date: postDate)
try await uploader.upload(content: updated)
FollowCoordinator.shared.updateCachedContent(updated)
postedURL = url
clearDraft()
} catch {
errorMessage = error.localizedDescription
}
}
func reset() {
text = ""; lang = ""; tags = ""; mood = ""
visibility = .public
replyTo = nil; group = nil
postedURL = nil; errorMessage = nil
}
// MARK: - Private helpers
private var publicFeedURL: 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 func makeUploader() -> (any FeedUploader)? { UploaderFactory.makeUploader() }
}
private extension String? {
var nilIfEmpty: String? { self?.isEmpty == false ? self : nil }
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}