aa8b5563e6
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.
169 lines
6.3 KiB
Swift
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 }
|
|
}
|