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 } }