CommentWriterViewModel.swift 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import AppKit
  2. import SwiftUI
  3. @MainActor
  4. @Observable
  5. final class CommentWriterViewModel {
  6. var draft = CommentDraft()
  7. var selectedTab: CommentWriterTab = .compose
  8. var isGenerating = false
  9. var errorMessage: String?
  10. var successMessage: String?
  11. var variants: [CommentVariant] = []
  12. var selectedVariantID: UUID?
  13. var showOverwriteConfirmation = false
  14. /// True after a successful Generate or History restore. Cleared on Reset.
  15. /// Controls whether the comment result field is shown in Compose.
  16. private(set) var hasGeneratedContent = false
  17. private var hasPremiumAccess = false
  18. private var hasEverPurchasedPremium = false
  19. private let injectedGenerationService: (any CommentGenerationServiceProtocol)?
  20. private let historyManager: AIHistoryManager
  21. private let freeUsageManager: AIFreeUsageManager
  22. var onPaywallRequired: (() -> Void)?
  23. private(set) var remainingFreeAIUses: Int
  24. private let emptyDraft = CommentDraft()
  25. init(
  26. hasPremiumAccess: Bool = false,
  27. generationService: (any CommentGenerationServiceProtocol)? = nil,
  28. historyManager: AIHistoryManager = .shared,
  29. freeUsageManager: AIFreeUsageManager = .shared
  30. ) {
  31. self.hasPremiumAccess = hasPremiumAccess
  32. self.injectedGenerationService = generationService
  33. self.historyManager = historyManager
  34. self.freeUsageManager = freeUsageManager
  35. self.remainingFreeAIUses = freeUsageManager.remainingUses
  36. }
  37. func setSubscriptionState(hasPremiumAccess: Bool, hasEverPurchasedPremium: Bool) {
  38. self.hasPremiumAccess = hasPremiumAccess
  39. self.hasEverPurchasedPremium = hasEverPurchasedPremium
  40. refreshFreeUsageState()
  41. }
  42. func setPremiumAccess(_ hasPremiumAccess: Bool) {
  43. setSubscriptionState(
  44. hasPremiumAccess: hasPremiumAccess,
  45. hasEverPurchasedPremium: hasEverPurchasedPremium
  46. )
  47. }
  48. func refreshFreeUsageState() {
  49. remainingFreeAIUses = freeUsageManager.remainingUses
  50. }
  51. private var generationService: any CommentGenerationServiceProtocol {
  52. injectedGenerationService ?? CommentGenerationServiceFactory.make(usesLiveAI: usesLiveAI)
  53. }
  54. var formattedSubreddit: String {
  55. let name = PostDraftValidator.normalizedSubreddit(draft.subreddit)
  56. guard !name.isEmpty else { return "r/subreddit" }
  57. return "r/\(name)"
  58. }
  59. var usesLiveAI: Bool {
  60. AIConfiguration.usesLiveAI(
  61. hasPremiumAccess: hasPremiumAccess,
  62. canUseFreeTrial: freeUsageManager.canUseFreeLiveAI(
  63. hasEverPurchasedPremium: hasEverPurchasedPremium
  64. )
  65. )
  66. }
  67. var generationEngineSubtitle: String {
  68. if usesLiveAI {
  69. if hasPremiumAccess {
  70. return "Write Reddit-ready comments with AI"
  71. }
  72. let remaining = remainingFreeAIUses
  73. let useLabel = remaining == 1 ? "use" : "uses"
  74. return "Write Reddit-ready comments with AI (\(remaining) free \(useLabel) left)"
  75. }
  76. if hasEverPurchasedPremium {
  77. return "Upgrade to Premium for unlimited AI generations"
  78. }
  79. return "Write Reddit-ready comments with AI templates"
  80. }
  81. var hasDraftChanges: Bool {
  82. draft != emptyDraft
  83. }
  84. var canGenerate: Bool {
  85. CommentDraftValidator.canGenerate(from: draft, isGenerating: isGenerating)
  86. }
  87. var canExport: Bool {
  88. let body = activeCommentBody.trimmingCharacters(in: .whitespaces)
  89. guard !body.isEmpty, body.count <= CommentDraftValidator.maxCommentLength else { return false }
  90. return (try? CommentDraftValidator.validateForGeneration(from: draft)) != nil
  91. }
  92. var activeCommentBody: String {
  93. if let selectedID = selectedVariantID,
  94. let variant = variants.first(where: { $0.id == selectedID }) {
  95. return variant.body
  96. }
  97. return draft.body
  98. }
  99. var bestVariant: CommentVariant? {
  100. variants.max(by: { $0.score < $1.score })
  101. }
  102. var needsOverwriteConfirmation: Bool {
  103. !draft.body.trimmingCharacters(in: .whitespaces).isEmpty
  104. }
  105. func selectCommentType(_ type: CommentType) {
  106. draft.commentType = type
  107. if type == .topLevel {
  108. draft.parentComment = ""
  109. }
  110. clearMessages()
  111. }
  112. func selectVariant(_ variant: CommentVariant) {
  113. selectedVariantID = variant.id
  114. clearMessages()
  115. }
  116. func applyVariant(_ variant: CommentVariant) {
  117. draft.body = variant.body
  118. selectedVariantID = variant.id
  119. successMessage = "Variant applied."
  120. errorMessage = nil
  121. }
  122. func generateComment() async {
  123. guard canGenerate else { return }
  124. if requiresPaywallForGeneration() {
  125. onPaywallRequired?()
  126. return
  127. }
  128. if needsOverwriteConfirmation {
  129. showOverwriteConfirmation = true
  130. return
  131. }
  132. await performGenerate(replaceExisting: true)
  133. }
  134. func confirmOverwriteAndGenerate() async {
  135. showOverwriteConfirmation = false
  136. await performGenerate(replaceExisting: true)
  137. }
  138. func cancelOverwriteConfirmation() {
  139. showOverwriteConfirmation = false
  140. }
  141. func copyToClipboard() {
  142. let content = activeCommentBody.trimmingCharacters(in: .whitespaces)
  143. guard !content.isEmpty else {
  144. errorMessage = CommentDraftValidationError.emptyComment.localizedDescription
  145. successMessage = nil
  146. return
  147. }
  148. guard content.count <= CommentDraftValidator.maxCommentLength else {
  149. errorMessage = CommentDraftValidationError.commentTooLong.localizedDescription
  150. successMessage = nil
  151. return
  152. }
  153. NSPasteboard.general.clearContents()
  154. NSPasteboard.general.setString(content, forType: .string)
  155. successMessage = "Comment copied to clipboard."
  156. errorMessage = nil
  157. }
  158. func resetDraft() {
  159. draft = CommentDraft()
  160. hasGeneratedContent = false
  161. selectedTab = .compose
  162. variants = []
  163. selectedVariantID = nil
  164. clearMessages()
  165. }
  166. func restore(from entry: AIHistoryEntry) {
  167. guard case .commentWriter(let storedDraft, let storedVariants, let selectedID) = entry.payload else {
  168. return
  169. }
  170. draft = storedDraft.commentDraft
  171. variants = storedVariants.map(\.commentVariant)
  172. selectedVariantID = selectedID
  173. hasGeneratedContent = true
  174. selectedTab = variants.isEmpty ? .compose : .preview
  175. clearMessages()
  176. successMessage = "Restored from history."
  177. }
  178. func clearMessages() {
  179. errorMessage = nil
  180. successMessage = nil
  181. }
  182. func notifyDraftEdited() {
  183. clearMessages()
  184. }
  185. private func performGenerate(replaceExisting: Bool) async {
  186. if requiresPaywallForGeneration() {
  187. onPaywallRequired?()
  188. return
  189. }
  190. isGenerating = true
  191. errorMessage = nil
  192. successMessage = nil
  193. let usedLiveAI = usesLiveAI
  194. do {
  195. let result = try await generationService.generateComment(from: draft)
  196. if replaceExisting || draft.body.trimmingCharacters(in: .whitespaces).isEmpty {
  197. draft.body = String(result.body.prefix(CommentDraftValidator.maxCommentLength))
  198. }
  199. variants = result.variants
  200. selectedVariantID = result.variants.first?.id
  201. selectedTab = .preview
  202. if usedLiveAI, !hasPremiumAccess, !hasEverPurchasedPremium {
  203. freeUsageManager.recordUse(for: .commentWriter)
  204. refreshFreeUsageState()
  205. }
  206. historyManager.save(AIHistoryEntry.fromCommentWriter(
  207. draft: draft,
  208. variants: variants,
  209. selectedVariantID: selectedVariantID
  210. ))
  211. hasGeneratedContent = true
  212. let engine = usedLiveAI ? "AI" : "local AI templates"
  213. successMessage = "Generated comment with \(result.variants.count) variants using \(engine)."
  214. } catch {
  215. errorMessage = UserFacingError.message(for: error)
  216. }
  217. isGenerating = false
  218. }
  219. private func requiresPaywallForGeneration() -> Bool {
  220. !hasPremiumAccess && (hasEverPurchasedPremium || remainingFreeAIUses == 0)
  221. }
  222. }