| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367 |
- import AppKit
- import SwiftUI
- @MainActor
- @Observable
- final class PostGeneratorViewModel {
- var draft = PostDraft()
- var selectedTab: PostGeneratorTab = .compose
- var isGenerating = false
- var errorMessage: String?
- var successMessage: String?
- var showImageImporter = false
- var showOverwriteConfirmation = false
- /// True after a successful Generate or History restore. Cleared on Reset.
- /// Controls whether title/body result fields are shown in Compose.
- private(set) var hasGeneratedContent = false
- private var hasPremiumAccess = false
- private var hasEverPurchasedPremium = false
- private var generationService: any PostGenerationServiceProtocol
- private let injectedGenerationService: (any PostGenerationServiceProtocol)?
- private let historyManager: AIHistoryManager
- private let freeUsageManager: AIFreeUsageManager
- var onPaywallRequired: (() -> Void)?
- private(set) var remainingFreeAIUses: Int
- private var imageAccessURL: URL?
- private var isAccessingImageResource = false
- private let emptyDraft = PostDraft()
- init(
- hasPremiumAccess: Bool = false,
- generationService: (any PostGenerationServiceProtocol)? = nil,
- historyManager: AIHistoryManager = .shared,
- freeUsageManager: AIFreeUsageManager = .shared
- ) {
- self.hasPremiumAccess = hasPremiumAccess
- self.injectedGenerationService = generationService
- self.historyManager = historyManager
- self.freeUsageManager = freeUsageManager
- self.remainingFreeAIUses = freeUsageManager.remainingUses
- self.hasEverPurchasedPremium = false
- self.generationService = generationService
- ?? PostGenerationServiceFactory.make(usesLiveAI: Self.liveAIEnabled(
- hasPremiumAccess: hasPremiumAccess,
- hasEverPurchasedPremium: false,
- freeUsageManager: freeUsageManager
- ))
- }
- func setSubscriptionState(hasPremiumAccess: Bool, hasEverPurchasedPremium: Bool) {
- self.hasPremiumAccess = hasPremiumAccess
- self.hasEverPurchasedPremium = hasEverPurchasedPremium
- refreshFreeUsageState()
- }
- func setPremiumAccess(_ hasPremiumAccess: Bool) {
- setSubscriptionState(
- hasPremiumAccess: hasPremiumAccess,
- hasEverPurchasedPremium: hasEverPurchasedPremium
- )
- }
- func refreshFreeUsageState() {
- remainingFreeAIUses = freeUsageManager.remainingUses
- refreshGenerationService()
- }
- private func refreshGenerationService() {
- if injectedGenerationService == nil {
- generationService = PostGenerationServiceFactory.make(usesLiveAI: usesLiveAI)
- }
- }
- private static func liveAIEnabled(
- hasPremiumAccess: Bool,
- hasEverPurchasedPremium: Bool,
- freeUsageManager: AIFreeUsageManager
- ) -> Bool {
- AIConfiguration.usesLiveAI(
- hasPremiumAccess: hasPremiumAccess,
- canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
- hasEverPurchasedPremium: hasEverPurchasedPremium
- )
- )
- }
- var formattedSubreddit: String {
- let name = PostDraftValidator.normalizedSubreddit(draft.subreddit)
- guard !name.isEmpty else { return "r/subreddit" }
- return "r/\(name)"
- }
- var usesLiveAI: Bool {
- AIConfiguration.usesLiveAI(
- hasPremiumAccess: hasPremiumAccess,
- canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
- hasEverPurchasedPremium: hasEverPurchasedPremium
- )
- )
- }
- var generationEngineSubtitle: String {
- if usesLiveAI {
- if hasPremiumAccess {
- return "Create Reddit-ready posts with AI"
- }
- let remaining = remainingFreeAIUses
- let useLabel = remaining == 1 ? "use" : "uses"
- return "Create Reddit-ready posts with AI (\(remaining) free \(useLabel) left)"
- }
- if hasEverPurchasedPremium {
- return "Upgrade to Premium for unlimited AI generations"
- }
- return "Create Reddit-ready posts with AI templates"
- }
- var hasDraftChanges: Bool {
- draft != emptyDraft
- }
- var canGenerate: Bool {
- PostDraftValidator.canGenerate(from: draft, isGenerating: isGenerating)
- }
- var canExport: Bool {
- PostDraftValidator.canExport(draft)
- }
- var canAddPollOption: Bool {
- draft.pollOptions.count < 6
- }
- var canRemovePollOption: Bool {
- draft.pollOptions.count > 2
- }
- var needsOverwriteConfirmation: Bool {
- !draft.title.trimmingCharacters(in: .whitespaces).isEmpty
- || !draft.body.trimmingCharacters(in: .whitespaces).isEmpty
- || draft.pollOptions.contains { !$0.text.trimmingCharacters(in: .whitespaces).isEmpty }
- }
- func selectPostType(_ type: RedditPostType) {
- draft.postType = type
- clearMessages()
- }
- func addPollOption() {
- guard canAddPollOption else { return }
- draft.pollOptions.append(PollOption())
- clearMessages()
- }
- func removePollOption(_ option: PollOption) {
- guard canRemovePollOption else { return }
- draft.pollOptions.removeAll { $0.id == option.id }
- clearMessages()
- }
- func updatePollOption(id: UUID, text: String) {
- guard let index = draft.pollOptions.firstIndex(where: { $0.id == id }) else { return }
- draft.pollOptions[index].text = text
- clearMessages()
- }
- func setImage(from url: URL) {
- releaseImageAccess()
- guard url.startAccessingSecurityScopedResource() else {
- errorMessage = "Couldn't access the selected image. Try choosing the file again."
- successMessage = nil
- return
- }
- imageAccessURL = url
- isAccessingImageResource = true
- draft.imageFileURL = url
- clearMessages()
- }
- func handleImageImportFailure(_ error: Error) {
- errorMessage = UserFacingError.message(for: error)
- successMessage = nil
- }
- func removeImage() {
- releaseImageAccess()
- draft.imageFileURL = nil
- clearMessages()
- }
- func generatePost() async {
- guard canGenerate else { return }
- if requiresPaywallForGeneration() {
- onPaywallRequired?()
- return
- }
- if needsOverwriteConfirmation {
- showOverwriteConfirmation = true
- return
- }
- await performGenerate(replaceExisting: true)
- }
- func confirmOverwriteAndGenerate() async {
- showOverwriteConfirmation = false
- await performGenerate(replaceExisting: true)
- }
- func cancelOverwriteConfirmation() {
- showOverwriteConfirmation = false
- }
- func copyToClipboard() {
- do {
- try PostDraftValidator.validateForExport(draft)
- let content = exportText()
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(content, forType: .string)
- successMessage = "Copied to clipboard."
- errorMessage = nil
- } catch {
- errorMessage = UserFacingError.message(for: error)
- successMessage = nil
- }
- }
- func resetDraft() {
- releaseImageAccess()
- draft = PostDraft()
- hasGeneratedContent = false
- selectedTab = .compose
- clearMessages()
- }
- func restore(from entry: AIHistoryEntry) {
- guard case .postGenerator(let storedDraft) = entry.payload else { return }
- releaseImageAccess()
- draft = storedDraft.postDraft
- hasGeneratedContent = true
- selectedTab = .preview
- clearMessages()
- successMessage = "Restored from history."
- }
- func clearMessages() {
- errorMessage = nil
- successMessage = nil
- }
- func notifyDraftEdited() {
- clearMessages()
- }
- func exportText() -> String {
- var lines: [String] = []
- lines.append("Subreddit: \(formattedSubreddit)")
- lines.append("Type: \(draft.postType.title)")
- if !draft.title.isEmpty {
- lines.append("Title: \(draft.title)")
- }
- switch draft.postType {
- case .text:
- if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
- case .image:
- if let url = draft.imageFileURL {
- lines.append("Image: \(url.lastPathComponent)")
- }
- if !draft.body.isEmpty { lines.append("Caption: \(draft.body)") }
- case .link:
- if !draft.linkURL.isEmpty { lines.append("URL: \(draft.linkURL)") }
- if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
- case .video:
- if !draft.videoURL.isEmpty { lines.append("Video URL: \(draft.videoURL)") }
- if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
- case .poll:
- lines.append("Duration: \(draft.pollDuration.label)")
- for (index, option) in draft.pollOptions.enumerated() where !option.text.isEmpty {
- lines.append("Option \(index + 1): \(option.text)")
- }
- if !draft.body.isEmpty { lines.append("\n\(draft.body)") }
- }
- var tags: [String] = []
- if draft.isNSFW { tags.append("NSFW") }
- if draft.isSpoiler { tags.append("Spoiler") }
- if draft.isOC { tags.append("OC") }
- if !tags.isEmpty { lines.append("Tags: \(tags.joined(separator: ", "))") }
- if !draft.flair.isEmpty { lines.append("Flair: \(draft.flair)") }
- return lines.joined(separator: "\n")
- }
- private func performGenerate(replaceExisting: Bool) async {
- if requiresPaywallForGeneration() {
- onPaywallRequired?()
- return
- }
- isGenerating = true
- errorMessage = nil
- successMessage = nil
- await Task.yield()
- let usedLiveAI = usesLiveAI
- do {
- let result = try await generationService.generatePost(from: draft)
- applyGeneratedPost(result, replaceExisting: replaceExisting)
- selectedTab = .preview
- if usedLiveAI, !hasPremiumAccess, !hasEverPurchasedPremium {
- freeUsageManager.recordUse(for: .postGenerator)
- refreshFreeUsageState()
- }
- let engine = usedLiveAI ? "AI" : "local AI templates"
- successMessage = "Post generated successfully using \(engine)."
- let entry = AIHistoryEntry.fromPostGenerator(draft: draft)
- Task { @MainActor in
- historyManager.save(entry)
- }
- } catch {
- errorMessage = UserFacingError.message(for: error)
- }
- isGenerating = false
- }
- private func applyGeneratedPost(_ result: GeneratedPost, replaceExisting: Bool) {
- if replaceExisting || draft.title.trimmingCharacters(in: .whitespaces).isEmpty {
- draft.title = String(result.title.prefix(PostDraftValidator.maxTitleLength))
- }
- if replaceExisting || draft.body.trimmingCharacters(in: .whitespaces).isEmpty {
- draft.body = String(result.body.prefix(PostDraftValidator.maxBodyLength))
- }
- if let flair = result.suggestedFlair, draft.flair.trimmingCharacters(in: .whitespaces).isEmpty {
- draft.flair = flair
- }
- if draft.postType == .poll, let options = result.pollOptions, !options.isEmpty {
- if replaceExisting || draft.pollOptions.allSatisfy({ $0.text.trimmingCharacters(in: .whitespaces).isEmpty }) {
- draft.pollOptions = options.map { PollOption(text: $0) }
- }
- }
- hasGeneratedContent = true
- }
- private func requiresPaywallForGeneration() -> Bool {
- !hasPremiumAccess && (hasEverPurchasedPremium || remainingFreeAIUses == 0)
- }
- private func releaseImageAccess() {
- if isAccessingImageResource, let imageAccessURL {
- imageAccessURL.stopAccessingSecurityScopedResource()
- }
- imageAccessURL = nil
- isAccessingImageResource = false
- }
- }
|