Ver código fonte

Improve Post Generator with validation, AI generation, and in-app Reddit submit.

Add draft validation, OpenAI-backed generation with template fallback, markdown preview, and route Open in Reddit through the embedded web view instead of the browser.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 mês atrás
pai
commit
6a2f53be75

+ 30 - 0
Reddit App/Managers/AIConfiguration.swift

@@ -0,0 +1,30 @@
+import Foundation
+
+enum AIConfiguration {
+    private static let apiKeyDefaultsKey = "openai_api_key"
+
+    static var apiKey: String? {
+        if let envKey = ProcessInfo.processInfo.environment["OPENAI_API_KEY"],
+           !envKey.trimmingCharacters(in: .whitespaces).isEmpty {
+            return envKey.trimmingCharacters(in: .whitespaces)
+        }
+
+        let stored = UserDefaults.standard.string(forKey: apiKeyDefaultsKey)?
+            .trimmingCharacters(in: .whitespaces)
+        guard let stored, !stored.isEmpty else { return nil }
+        return stored
+    }
+
+    static var usesLiveAI: Bool {
+        apiKey != nil
+    }
+
+    static func setAPIKey(_ key: String?) {
+        let trimmed = key?.trimmingCharacters(in: .whitespaces)
+        if let trimmed, !trimmed.isEmpty {
+            UserDefaults.standard.set(trimmed, forKey: apiKeyDefaultsKey)
+        } else {
+            UserDefaults.standard.removeObject(forKey: apiKeyDefaultsKey)
+        }
+    }
+}

+ 1 - 0
Reddit App/Models/RedditPostModels.swift

@@ -127,4 +127,5 @@ struct GeneratedPost: Equatable {
     var title: String
     var body: String
     var suggestedFlair: String?
+    var pollOptions: [String]?
 }

+ 221 - 22
Reddit App/Services/PostGenerationService.swift

@@ -4,47 +4,207 @@ protocol PostGenerationServiceProtocol: Sendable {
     func generatePost(from draft: PostDraft) async throws -> GeneratedPost
 }
 
+enum PostGenerationServiceFactory {
+    static func makeDefault() -> any PostGenerationServiceProtocol {
+        if let apiKey = AIConfiguration.apiKey {
+            return OpenAIPostGenerationService(apiKey: apiKey)
+        }
+        return MockPostGenerationService()
+    }
+}
+
 enum PostGenerationError: LocalizedError {
     case emptyTopic
     case emptySubreddit
+    case invalidSubreddit
+    case apiUnavailable(String)
+    case invalidResponse
 
     var errorDescription: String? {
         switch self {
-        case .emptyTopic: "Enter a topic or keywords for AI generation."
-        case .emptySubreddit: "Enter a subreddit to tailor the post."
+        case .emptyTopic:
+            "Enter a topic or keywords for AI generation."
+        case .emptySubreddit:
+            "Enter a subreddit to tailor the post."
+        case .invalidSubreddit:
+            "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
+        case .apiUnavailable(let message):
+            message
+        case .invalidResponse:
+            "The AI returned an unexpected response. Please try again."
         }
     }
 }
 
-struct MockPostGenerationService: PostGenerationServiceProtocol {
+// MARK: - OpenAI
+
+struct OpenAIPostGenerationService: PostGenerationServiceProtocol {
+    let apiKey: String
+    var model: String = "gpt-4o-mini"
+
     func generatePost(from draft: PostDraft) async throws -> GeneratedPost {
-        guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
-            throw PostGenerationError.emptySubreddit
+        try PostDraftValidator.validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)
+
+        let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
+        let topic = draft.topic.trimmingCharacters(in: .whitespaces)
+        let prompt = buildPrompt(draft: draft, subreddit: subreddit, topic: topic)
+
+        var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
+        request.httpMethod = "POST"
+        request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+        let body: [String: Any] = [
+            "model": model,
+            "temperature": 0.8,
+            "response_format": ["type": "json_object"],
+            "messages": [
+                [
+                    "role": "system",
+                    "content": """
+                    You write Reddit posts. Respond with JSON only using keys:
+                    title (string, max 300 chars), body (string), suggestedFlair (string or null), \
+                    pollOptions (array of 2-6 strings when post type is poll, otherwise null).
+                    Respect NSFW, spoiler, and OC tags. Match the requested tone and post type.
+                    """,
+                ],
+                ["role": "user", "content": prompt],
+            ],
+        ]
+        request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+        guard let http = response as? HTTPURLResponse else {
+            throw PostGenerationError.apiUnavailable("Couldn't reach the AI service.")
+        }
+
+        if http.statusCode == 401 {
+            throw PostGenerationError.apiUnavailable("Invalid OpenAI API key. Check AIConfiguration or OPENAI_API_KEY.")
+        }
+        guard (200 ... 299).contains(http.statusCode) else {
+            let message = String(data: data, encoding: .utf8) ?? "HTTP \(http.statusCode)"
+            throw PostGenerationError.apiUnavailable("AI request failed: \(message)")
+        }
+
+        return try parseResponse(data, postType: draft.postType)
+    }
+
+    private func buildPrompt(draft: PostDraft, subreddit: String, topic: String) -> String {
+        var lines = [
+            "Subreddit: r/\(subreddit)",
+            "Post type: \(draft.postType.title)",
+            "Topic: \(topic)",
+            "Tone: \(draft.tone.title)",
+        ]
+
+        if draft.isNSFW { lines.append("Tag: NSFW") }
+        if draft.isSpoiler { lines.append("Tag: Spoiler") }
+        if draft.isOC { lines.append("Tag: Original Content") }
+        if !draft.flair.isEmpty { lines.append("Preferred flair: \(draft.flair)") }
+
+        switch draft.postType {
+        case .text:
+            lines.append("Write a full text post with markdown formatting.")
+        case .image:
+            lines.append("Write a short image caption and optional supporting context in body.")
+        case .link:
+            lines.append("Write a link post description explaining why r/\(subreddit) should care.")
+        case .video:
+            lines.append("Write a video post description with a compelling hook.")
+        case .poll:
+            lines.append("Write poll context in body and provide \(draft.pollOptions.count) distinct poll options.")
+        }
+
+        return lines.joined(separator: "\n")
+    }
+
+    private func parseResponse(_ data: Data, postType: RedditPostType) throws -> GeneratedPost {
+        struct APIEnvelope: Decodable {
+            struct Choice: Decodable {
+                struct Message: Decodable {
+                    let content: String
+                }
+                let message: Message
+            }
+            let choices: [Choice]
+        }
+
+        struct Payload: Decodable {
+            let title: String
+            let body: String
+            let suggestedFlair: String?
+            let pollOptions: [String]?
+        }
+
+        let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
+        guard let content = envelope.choices.first?.message.content.data(using: .utf8) else {
+            throw PostGenerationError.invalidResponse
         }
-        guard !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty else {
-            throw PostGenerationError.emptyTopic
+
+        let payload = try JSONDecoder().decode(Payload.self, from: content)
+        let title = String(payload.title.prefix(PostDraftValidator.maxTitleLength))
+        let body = String(payload.body.prefix(PostDraftValidator.maxBodyLength))
+
+        var pollOptions = payload.pollOptions
+        if postType == .poll {
+            pollOptions = pollOptions?.map {
+                $0.trimmingCharacters(in: .whitespaces)
+            }.filter { !$0.isEmpty }
+            if let pollOptions, pollOptions.count < 2 {
+                throw PostGenerationError.invalidResponse
+            }
+        } else {
+            pollOptions = nil
         }
 
+        return GeneratedPost(
+            title: title,
+            body: body,
+            suggestedFlair: payload.suggestedFlair,
+            pollOptions: pollOptions
+        )
+    }
+}
+
+// MARK: - Mock
+
+struct MockPostGenerationService: PostGenerationServiceProtocol {
+    func generatePost(from draft: PostDraft) async throws -> GeneratedPost {
+        try PostDraftValidator.validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)
+
         try await Task.sleep(for: .milliseconds(900))
 
-        let subreddit = draft.subreddit.replacingOccurrences(of: "r/", with: "")
+        let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
         let topic = draft.topic.trimmingCharacters(in: .whitespaces)
         let tone = draft.tone.title.lowercased()
 
-        let title = makeTitle(topic: topic, subreddit: subreddit, tone: draft.tone)
+        let title = makeTitle(topic: topic, subreddit: subreddit, tone: draft.tone, tags: draft)
         let body = makeBody(
             topic: topic,
             subreddit: subreddit,
             tone: tone,
-            postType: draft.postType
+            postType: draft.postType,
+            tags: draft
         )
-        let flair = suggestFlair(for: draft.postType, subreddit: subreddit)
+        let flair = suggestFlair(for: draft.postType, draft: draft)
+        let pollOptions = draft.postType == .poll ? makePollOptions(topic: topic, subreddit: subreddit) : nil
 
-        return GeneratedPost(title: title, body: body, suggestedFlair: flair)
+        return GeneratedPost(
+            title: String(title.prefix(PostDraftValidator.maxTitleLength)),
+            body: String(body.prefix(PostDraftValidator.maxBodyLength)),
+            suggestedFlair: flair,
+            pollOptions: pollOptions
+        )
     }
 
-    private func makeTitle(topic: String, subreddit: String, tone: PostTone) -> String {
-        switch tone {
+    private func makeTitle(
+        topic: String,
+        subreddit: String,
+        tone: PostTone,
+        tags: PostDraft
+    ) -> String {
+        let prefix = tags.isNSFW ? "[NSFW] " : ""
+        let base: String = switch tone {
         case .humorous:
             "I tried \(topic) so you don't have to — r/\(subreddit) was not wrong"
         case .professional:
@@ -58,15 +218,26 @@ struct MockPostGenerationService: PostGenerationServiceProtocol {
         case .casual:
             "Anyone else obsessed with \(topic) lately?"
         }
+        return prefix + base
     }
 
     private func makeBody(
         topic: String,
         subreddit: String,
         tone: String,
-        postType: RedditPostType
+        postType: RedditPostType,
+        tags: PostDraft
     ) -> String {
-        switch postType {
+        var sections: [String] = []
+
+        if tags.isSpoiler {
+            sections.append("> **Spoiler warning** — details below.")
+        }
+        if tags.isOC {
+            sections.append("*Original content — created by me.*")
+        }
+
+        let core: String = switch postType {
         case .text:
             """
             Hey r/\(subreddit),
@@ -89,18 +260,46 @@ struct MockPostGenerationService: PostGenerationServiceProtocol {
             Written in a \(tone) tone — happy to refine based on feedback!
             """
         case .image:
-            "Caption: A visual breakdown of \(topic) — details in the comments. Let me know what you think!"
+            """
+            Visual breakdown of **\(topic)** — hope this helps someone in r/\(subreddit).
+
+            Happy to answer questions in the comments!
+            """
         case .link:
-            "Found this great resource on \(topic). Thought r/\(subreddit) would find it useful. Summary in comments."
+            """
+            Found this resource on **\(topic)** and thought r/\(subreddit) would find it useful.
+
+            **Why it matters:** concise overview with practical takeaways for this community.
+            """
         case .video:
-            "Just watched this breakdown of \(topic) and had to share. The section at 3:42 really nails it."
+            """
+            Just watched this breakdown of **\(topic)** and had to share with r/\(subreddit).
+
+            The section that really nails it is around the middle — curious if you agree.
+            """
         case .poll:
-            "Curious where r/\(subreddit) stands on \(topic). Vote below!"
+            """
+            Curious where r/\(subreddit) stands on **\(topic)**. Vote below and share your reasoning!
+            """
         }
+
+        sections.append(core)
+        return sections.joined(separator: "\n\n")
+    }
+
+    private func makePollOptions(topic: String, subreddit: String) -> [String] {
+        [
+            "Love it — best thing for r/\(subreddit)",
+            "It's fine, but overhyped",
+            "Not for me",
+            "Still figuring out \(topic)",
+        ]
     }
 
-    private func suggestFlair(for postType: RedditPostType, subreddit: String) -> String {
-        switch postType {
+    private func suggestFlair(for postType: RedditPostType, draft: PostDraft) -> String {
+        if !draft.flair.isEmpty { return draft.flair }
+        if draft.isNSFW { return "NSFW" }
+        return switch postType {
         case .text: "Discussion"
         case .image: "Media"
         case .link: "News"

+ 126 - 0
Reddit App/Utilities/PostDraftValidator.swift

@@ -0,0 +1,126 @@
+import Foundation
+
+enum PostDraftValidationError: LocalizedError {
+    case emptyTopic
+    case emptySubreddit
+    case invalidSubreddit
+    case emptyTitle
+    case titleTooLong
+    case bodyTooLong
+    case missingImage
+    case invalidLinkURL
+    case emptyLinkURL
+    case invalidVideoURL
+    case emptyVideoURL
+    case insufficientPollOptions
+    case emptyPollOption
+
+    var errorDescription: String? {
+        switch self {
+        case .emptyTopic:
+            "Enter a topic or keywords for AI generation."
+        case .emptySubreddit:
+            "Enter a subreddit to tailor the post."
+        case .invalidSubreddit:
+            "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
+        case .emptyTitle:
+            "Add a post title before exporting or submitting."
+        case .titleTooLong:
+            "Title must be 300 characters or fewer."
+        case .bodyTooLong:
+            "Post body must be 40,000 characters or fewer."
+        case .missingImage:
+            "Upload an image for image posts."
+        case .invalidLinkURL:
+            "Enter a valid http or https URL for the link."
+        case .emptyLinkURL:
+            "Enter a URL for link posts."
+        case .invalidVideoURL:
+            "Enter a valid http or https URL for the video."
+        case .emptyVideoURL:
+            "Enter a video URL for video posts."
+        case .insufficientPollOptions:
+            "Polls need at least two non-empty options."
+        case .emptyPollOption:
+            "Fill in all poll options or remove empty ones."
+        }
+    }
+}
+
+enum PostDraftValidator {
+    static let maxTitleLength = 300
+    static let maxBodyLength = 40_000
+
+    static func normalizedSubreddit(_ raw: String) -> String {
+        raw.trimmingCharacters(in: .whitespaces)
+            .replacingOccurrences(of: "^r/", with: "", options: .regularExpression)
+            .lowercased()
+    }
+
+    static func isValidSubreddit(_ raw: String) -> Bool {
+        let name = normalizedSubreddit(raw)
+        guard name.count >= 3, name.count <= 21 else { return false }
+        let pattern = "^[a-z0-9_]+$"
+        return name.range(of: pattern, options: .regularExpression) != nil
+    }
+
+    static func isValidHTTPURL(_ string: String) -> Bool {
+        let trimmed = string.trimmingCharacters(in: .whitespaces)
+        guard let url = URL(string: trimmed),
+              let scheme = url.scheme?.lowercased(),
+              scheme == "http" || scheme == "https",
+              url.host != nil
+        else { return false }
+        return true
+    }
+
+    static func validateForGeneration(topic: String, subreddit: String) throws {
+        guard !topic.trimmingCharacters(in: .whitespaces).isEmpty else {
+            throw PostDraftValidationError.emptyTopic
+        }
+        guard !subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
+            throw PostDraftValidationError.emptySubreddit
+        }
+        guard isValidSubreddit(subreddit) else {
+            throw PostDraftValidationError.invalidSubreddit
+        }
+    }
+
+    static func validateForExport(_ draft: PostDraft) throws {
+        try validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)
+
+        let title = draft.title.trimmingCharacters(in: .whitespaces)
+        guard !title.isEmpty else { throw PostDraftValidationError.emptyTitle }
+        guard title.count <= maxTitleLength else { throw PostDraftValidationError.titleTooLong }
+        guard draft.body.count <= maxBodyLength else { throw PostDraftValidationError.bodyTooLong }
+
+        switch draft.postType {
+        case .text:
+            break
+        case .image:
+            guard draft.imageFileURL != nil else { throw PostDraftValidationError.missingImage }
+        case .link:
+            let url = draft.linkURL.trimmingCharacters(in: .whitespaces)
+            guard !url.isEmpty else { throw PostDraftValidationError.emptyLinkURL }
+            guard isValidHTTPURL(url) else { throw PostDraftValidationError.invalidLinkURL }
+        case .video:
+            let url = draft.videoURL.trimmingCharacters(in: .whitespaces)
+            guard !url.isEmpty else { throw PostDraftValidationError.emptyVideoURL }
+            guard isValidHTTPURL(url) else { throw PostDraftValidationError.invalidVideoURL }
+        case .poll:
+            let filled = draft.pollOptions.map(\.text).map {
+                $0.trimmingCharacters(in: .whitespaces)
+            }.filter { !$0.isEmpty }
+            guard filled.count >= 2 else { throw PostDraftValidationError.insufficientPollOptions }
+        }
+    }
+
+    static func canGenerate(from draft: PostDraft, isGenerating: Bool) -> Bool {
+        guard !isGenerating else { return false }
+        return (try? validateForGeneration(topic: draft.topic, subreddit: draft.subreddit)) != nil
+    }
+
+    static func canExport(_ draft: PostDraft) -> Bool {
+        (try? validateForExport(draft)) != nil
+    }
+}

+ 43 - 0
Reddit App/Utilities/RedditSubmitURLBuilder.swift

@@ -0,0 +1,43 @@
+import Foundation
+
+enum RedditSubmitURLBuilder {
+    static func submitURL(for draft: PostDraft) throws -> URL {
+        try PostDraftValidator.validateForExport(draft)
+
+        let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
+        var components = URLComponents()
+        components.scheme = "https"
+        components.host = "www.reddit.com"
+        components.path = "/r/\(subreddit)/submit"
+
+        var queryItems = [
+            URLQueryItem(name: "title", value: draft.title.trimmingCharacters(in: .whitespaces)),
+        ]
+
+        switch draft.postType {
+        case .text:
+            queryItems.append(URLQueryItem(name: "selftext", value: "true"))
+            let body = draft.body.trimmingCharacters(in: .whitespaces)
+            if !body.isEmpty {
+                queryItems.append(URLQueryItem(name: "text", value: body))
+            }
+        case .link:
+            queryItems.append(
+                URLQueryItem(name: "url", value: draft.linkURL.trimmingCharacters(in: .whitespaces))
+            )
+        case .image, .video, .poll:
+            queryItems.append(URLQueryItem(name: "selftext", value: "true"))
+            let body = draft.body.trimmingCharacters(in: .whitespaces)
+            if !body.isEmpty {
+                queryItems.append(URLQueryItem(name: "text", value: body))
+            }
+        }
+
+        components.queryItems = queryItems
+
+        guard let url = components.url else {
+            throw PostDraftValidationError.invalidSubreddit
+        }
+        return url
+    }
+}

+ 5 - 2
Reddit App/ViewModels/FrontPageViewModel.swift

@@ -3,14 +3,17 @@ import SwiftUI
 @MainActor
 @Observable
 final class FrontPageViewModel {
-    let redditURL = URL(string: "https://www.reddit.com")!
+    private static let redditHomeURL = URL(string: "https://www.reddit.com")!
+
+    private(set) var redditLoadURL = redditHomeURL
     var selectedNavItem: SidebarNavItem?
     var isRedditActive = true
     private(set) var redditReloadTrigger = 0
 
-    func openReddit() {
+    func openReddit(at url: URL? = nil) {
         selectedNavItem = nil
         isRedditActive = true
+        redditLoadURL = url ?? Self.redditHomeURL
         redditReloadTrigger += 1
     }
 

+ 139 - 30
Reddit App/ViewModels/PostGeneratorViewModel.swift

@@ -1,6 +1,5 @@
 import AppKit
 import SwiftUI
-import UniformTypeIdentifiers
 
 @MainActor
 @Observable
@@ -11,23 +10,37 @@ final class PostGeneratorViewModel {
     var errorMessage: String?
     var successMessage: String?
     var showImageImporter = false
+    var showOverwriteConfirmation = false
 
     private let generationService: any PostGenerationServiceProtocol
+    private var imageAccessURL: URL?
+    private var isAccessingImageResource = false
+    private let emptyDraft = PostDraft()
 
-    init(generationService: any PostGenerationServiceProtocol = MockPostGenerationService()) {
-        self.generationService = generationService
+    init(generationService: (any PostGenerationServiceProtocol)? = nil) {
+        self.generationService = generationService ?? PostGenerationServiceFactory.makeDefault()
     }
 
     var formattedSubreddit: String {
-        let trimmed = draft.subreddit.trimmingCharacters(in: .whitespaces)
-        guard !trimmed.isEmpty else { return "r/subreddit" }
-        return trimmed.hasPrefix("r/") ? trimmed : "r/\(trimmed)"
+        let name = PostDraftValidator.normalizedSubreddit(draft.subreddit)
+        guard !name.isEmpty else { return "r/subreddit" }
+        return "r/\(name)"
+    }
+
+    var usesLiveAI: Bool {
+        AIConfiguration.usesLiveAI
+    }
+
+    var hasDraftChanges: Bool {
+        draft != emptyDraft
     }
 
     var canGenerate: Bool {
-        !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty
-            && !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty
-            && !isGenerating
+        PostDraftValidator.canGenerate(from: draft, isGenerating: isGenerating)
+    }
+
+    var canExport: Bool {
+        PostDraftValidator.canExport(draft)
     }
 
     var canAddPollOption: Bool {
@@ -38,6 +51,12 @@ final class PostGeneratorViewModel {
         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()
@@ -46,60 +65,109 @@ final class PostGeneratorViewModel {
     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 = "Image import failed: \(error.localizedDescription)"
+        successMessage = nil
+    }
+
     func removeImage() {
+        releaseImageAccess()
         draft.imageFileURL = nil
+        clearMessages()
     }
 
     func generatePost() async {
         guard canGenerate else { return }
 
-        isGenerating = true
-        errorMessage = nil
-        successMessage = nil
+        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 {
-            let result = try await generationService.generatePost(from: draft)
-            draft.title = result.title
-            if draft.postType == .text || draft.postType == .poll {
-                draft.body = result.body
-            }
-            if let flair = result.suggestedFlair, draft.flair.isEmpty {
-                draft.flair = flair
-            }
-            successMessage = "Post generated successfully."
+            try PostDraftValidator.validateForExport(draft)
+            let content = exportText()
+            NSPasteboard.general.clearContents()
+            NSPasteboard.general.setString(content, forType: .string)
+            successMessage = "Copied to clipboard."
+            errorMessage = nil
         } catch {
             errorMessage = error.localizedDescription
+            successMessage = nil
         }
-
-        isGenerating = false
     }
 
-    func copyToClipboard() {
-        let content = exportText()
-        NSPasteboard.general.clearContents()
-        NSPasteboard.general.setString(content, forType: .string)
-        successMessage = "Copied to clipboard."
-        errorMessage = nil
+    func openInReddit(openInApp: (URL) -> Void) {
+        do {
+            let url = try RedditSubmitURLBuilder.submitURL(for: draft)
+            openInApp(url)
+            successMessage = "Opened Reddit submit page in the app."
+            errorMessage = nil
+        } catch {
+            errorMessage = error.localizedDescription
+            successMessage = nil
+        }
     }
 
     func resetDraft() {
+        releaseImageAccess()
         draft = PostDraft()
         selectedTab = .compose
         clearMessages()
     }
 
+    func clearMessages() {
+        errorMessage = nil
+        successMessage = nil
+    }
+
+    func notifyDraftEdited() {
+        clearMessages()
+    }
+
     func exportText() -> String {
         var lines: [String] = []
         lines.append("Subreddit: \(formattedSubreddit)")
@@ -142,8 +210,49 @@ final class PostGeneratorViewModel {
         return lines.joined(separator: "\n")
     }
 
-    private func clearMessages() {
+    private func performGenerate(replaceExisting: Bool) async {
+        isGenerating = true
         errorMessage = nil
         successMessage = nil
+
+        do {
+            let result = try await generationService.generatePost(from: draft)
+            applyGeneratedPost(result, replaceExisting: replaceExisting)
+            selectedTab = .preview
+            let engine = usesLiveAI ? "OpenAI" : "local AI templates"
+            successMessage = "Post generated successfully using \(engine)."
+        } catch {
+            errorMessage = error.localizedDescription
+        }
+
+        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) }
+            }
+        }
+    }
+
+    private func releaseImageAccess() {
+        if isAccessingImageResource, let imageAccessURL {
+            imageAccessURL.stopAccessingSecurityScopedResource()
+        }
+        imageAccessURL = nil
+        isAccessingImageResource = false
     }
 }

+ 39 - 15
Reddit App/Views/Components/PostGeneratorComponents.swift

@@ -280,6 +280,36 @@ struct PostGeneratorMessageBanner: View {
     }
 }
 
+// MARK: - Markdown
+
+struct PostMarkdownText: View {
+    let text: String
+    var fontSize: CGFloat = 12
+    var color: Color = AppTheme.textSecondary
+    var lineSpacing: CGFloat = 3
+
+    var body: some View {
+        if let attributed = try? AttributedString(
+            markdown: text,
+            options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
+        ) {
+            Text(attributed)
+                .font(.system(size: fontSize))
+                .foregroundStyle(color)
+                .lineSpacing(lineSpacing)
+                .fixedSize(horizontal: false, vertical: true)
+                .textSelection(.enabled)
+        } else {
+            Text(text)
+                .font(.system(size: fontSize))
+                .foregroundStyle(color)
+                .lineSpacing(lineSpacing)
+                .fixedSize(horizontal: false, vertical: true)
+                .textSelection(.enabled)
+        }
+    }
+}
+
 // MARK: - Reddit Preview Card
 
 struct RedditPostPreviewCard: View {
@@ -398,11 +428,7 @@ struct RedditPostPreviewCard: View {
     @ViewBuilder
     private var textPreview: some View {
         if !draft.body.isEmpty {
-            Text(draft.body)
-                .font(.system(size: 12))
-                .foregroundStyle(AppTheme.textSecondary)
-                .lineSpacing(3)
-                .fixedSize(horizontal: false, vertical: true)
+            PostMarkdownText(text: draft.body)
         }
     }
 
@@ -419,9 +445,7 @@ struct RedditPostPreviewCard: View {
         }
 
         if !draft.body.isEmpty {
-            Text(draft.body)
-                .font(.system(size: 11))
-                .foregroundStyle(AppTheme.textSecondary)
+            PostMarkdownText(text: draft.body, fontSize: 11)
         }
     }
 
@@ -434,9 +458,7 @@ struct RedditPostPreviewCard: View {
         )
 
         if !draft.body.isEmpty {
-            Text(draft.body)
-                .font(.system(size: 11))
-                .foregroundStyle(AppTheme.textSecondary)
+            PostMarkdownText(text: draft.body, fontSize: 11)
         }
     }
 
@@ -447,20 +469,22 @@ struct RedditPostPreviewCard: View {
         } else {
             linkCard(
                 domain: videoDomain,
-                title: "Video link",
+                title: draft.title.isEmpty ? "Video link" : draft.title,
                 url: draft.videoURL
             )
         }
 
         if !draft.body.isEmpty {
-            Text(draft.body)
-                .font(.system(size: 11))
-                .foregroundStyle(AppTheme.textSecondary)
+            PostMarkdownText(text: draft.body, fontSize: 11)
         }
     }
 
     @ViewBuilder
     private var pollPreview: some View {
+        if !draft.body.isEmpty {
+            PostMarkdownText(text: draft.body, fontSize: 11)
+        }
+
         VStack(spacing: 6) {
             ForEach(draft.pollOptions) { option in
                 HStack {

+ 4 - 2
Reddit App/Views/FrontPageView.swift

@@ -20,7 +20,7 @@ struct FrontPageView: View {
     private var mainContent: some View {
         ZStack {
             RedditWebView(
-                url: viewModel.redditURL,
+                url: viewModel.redditLoadURL,
                 reloadTrigger: viewModel.redditReloadTrigger
             )
             .id("reddit-webview")
@@ -39,7 +39,9 @@ struct FrontPageView: View {
     private func toolContent(for item: SidebarNavItem) -> some View {
         switch item.iconKind {
         case .postGenerator:
-            PostGeneratorView(viewModel: postGeneratorViewModel)
+            PostGeneratorView(viewModel: postGeneratorViewModel) { url in
+                viewModel.openReddit(at: url)
+            }
         case .titleOptimizer:
             TitleOptimizerView(viewModel: titleOptimizerViewModel)
         default:

+ 143 - 44
Reddit App/Views/PostGeneratorView.swift

@@ -4,10 +4,12 @@ import UniformTypeIdentifiers
 
 struct PostGeneratorView: View {
     @Bindable var viewModel: PostGeneratorViewModel
+    let onOpenInReddit: (URL) -> Void
 
     var body: some View {
         VStack(spacing: 0) {
             header
+            messageBanners
             tabBar
 
             ScrollView {
@@ -30,10 +32,32 @@ struct PostGeneratorView: View {
             allowedContentTypes: [.image],
             allowsMultipleSelection: false
         ) { result in
-            if case .success(let urls) = result, let url = urls.first {
-                viewModel.setImage(from: url)
+            switch result {
+            case .success(let urls):
+                if let url = urls.first {
+                    viewModel.setImage(from: url)
+                }
+            case .failure(let error):
+                viewModel.handleImageImportFailure(error)
+            }
+        }
+        .alert("Replace existing content?", isPresented: $viewModel.showOverwriteConfirmation) {
+            Button("Cancel", role: .cancel) {
+                viewModel.cancelOverwriteConfirmation()
+            }
+            Button("Replace", role: .destructive) {
+                Task { await viewModel.confirmOverwriteAndGenerate() }
             }
+        } message: {
+            Text("Generating will replace your current title, body, and poll options.")
         }
+        .onChange(of: viewModel.draft.topic) { _, _ in viewModel.notifyDraftEdited() }
+        .onChange(of: viewModel.draft.subreddit) { _, _ in viewModel.notifyDraftEdited() }
+        .onChange(of: viewModel.draft.title) { _, _ in viewModel.notifyDraftEdited() }
+        .onChange(of: viewModel.draft.body) { _, _ in viewModel.notifyDraftEdited() }
+        .onChange(of: viewModel.draft.linkURL) { _, _ in viewModel.notifyDraftEdited() }
+        .onChange(of: viewModel.draft.videoURL) { _, _ in viewModel.notifyDraftEdited() }
+        .onChange(of: viewModel.draft.flair) { _, _ in viewModel.notifyDraftEdited() }
     }
 
     // MARK: - Header
@@ -43,10 +67,20 @@ struct PostGeneratorView: View {
             ModernSidebarIconView(kind: .postGenerator, size: 40)
 
             VStack(alignment: .leading, spacing: 2) {
-                Text("Post Generator")
-                    .font(.system(size: 18, weight: .semibold))
-                    .foregroundStyle(AppTheme.textPrimary)
-                Text("Create Reddit-ready posts with AI")
+                HStack(spacing: 6) {
+                    Text("Post Generator")
+                        .font(.system(size: 18, weight: .semibold))
+                        .foregroundStyle(AppTheme.textPrimary)
+
+                    if viewModel.hasDraftChanges {
+                        Circle()
+                            .fill(AppTheme.accentOrange)
+                            .frame(width: 6, height: 6)
+                            .help("Draft has unsaved edits")
+                    }
+                }
+
+                Text(viewModel.usesLiveAI ? "Create Reddit-ready posts with OpenAI" : "Create Reddit-ready posts with AI templates")
                     .font(.system(size: 11))
                     .foregroundStyle(AppTheme.textSecondary)
             }
@@ -64,6 +98,19 @@ struct PostGeneratorView: View {
         }
     }
 
+    @ViewBuilder
+    private var messageBanners: some View {
+        if let error = viewModel.errorMessage {
+            PostGeneratorMessageBanner(message: error, isError: true)
+                .padding(.horizontal, 24)
+                .padding(.top, 8)
+        } else if let success = viewModel.successMessage {
+            PostGeneratorMessageBanner(message: success, isError: false)
+                .padding(.horizontal, 24)
+                .padding(.top, 8)
+        }
+    }
+
     private var headerActions: some View {
         HStack(spacing: 8) {
             Button {
@@ -74,6 +121,15 @@ struct PostGeneratorView: View {
             }
             .buttonStyle(PostSecondaryButtonStyle())
 
+            Button {
+                viewModel.openInReddit(openInApp: onOpenInReddit)
+            } label: {
+                Label("Open in Reddit", systemImage: "arrow.up.right.square")
+                    .font(.system(size: 11, weight: .medium))
+            }
+            .buttonStyle(PostSecondaryButtonStyle())
+            .disabled(!viewModel.canExport)
+
             Button {
                 viewModel.copyToClipboard()
             } label: {
@@ -81,7 +137,7 @@ struct PostGeneratorView: View {
                     .font(.system(size: 11, weight: .medium))
             }
             .buttonStyle(PostSecondaryButtonStyle())
-            .disabled(viewModel.draft.title.isEmpty)
+            .disabled(!viewModel.canExport)
 
             Button {
                 Task { await viewModel.generatePost() }
@@ -142,9 +198,16 @@ struct PostGeneratorView: View {
     // MARK: - Compose
 
     private var composeContent: some View {
-        HStack(alignment: .top, spacing: 16) {
-            configurationPanel
-            editorPanel
+        ViewThatFits(in: .horizontal) {
+            HStack(alignment: .top, spacing: 16) {
+                configurationPanel
+                editorPanel
+            }
+
+            VStack(alignment: .leading, spacing: 16) {
+                configurationPanel
+                editorPanel
+            }
         }
     }
 
@@ -158,12 +221,21 @@ struct PostGeneratorView: View {
             }
 
             PostFormCard(title: "Target Subreddit", subtitle: "Where will this be posted?") {
-                PostFormField(
-                    label: "Subreddit",
-                    placeholder: "technology",
-                    text: $viewModel.draft.subreddit,
-                    prefix: "r/"
-                )
+                VStack(alignment: .leading, spacing: 6) {
+                    PostFormField(
+                        label: "Subreddit",
+                        placeholder: "technology",
+                        text: $viewModel.draft.subreddit,
+                        prefix: "r/"
+                    )
+
+                    if !viewModel.draft.subreddit.isEmpty,
+                       !PostDraftValidator.isValidSubreddit(viewModel.draft.subreddit) {
+                        Text("Use 3–21 characters: letters, numbers, underscores only.")
+                            .font(.system(size: 9))
+                            .foregroundStyle(Color(hex: 0xF87171))
+                    }
+                }
             }
 
             PostFormCard(title: "AI Settings", subtitle: "Guide the tone and topic") {
@@ -213,14 +285,8 @@ struct PostGeneratorView: View {
                     text: $viewModel.draft.flair
                 )
             }
-
-            if let error = viewModel.errorMessage {
-                PostGeneratorMessageBanner(message: error, isError: true)
-            } else if let success = viewModel.successMessage {
-                PostGeneratorMessageBanner(message: success, isError: false)
-            }
         }
-        .frame(width: 300)
+        .frame(minWidth: 280, idealWidth: 300, maxWidth: 320)
     }
 
     private var editorPanel: some View {
@@ -291,6 +357,13 @@ struct PostGeneratorView: View {
                     text: $viewModel.draft.linkURL
                 )
 
+                if !viewModel.draft.linkURL.isEmpty,
+                   !PostDraftValidator.isValidHTTPURL(viewModel.draft.linkURL) {
+                    Text("Enter a valid http or https URL.")
+                        .font(.system(size: 9))
+                        .foregroundStyle(Color(hex: 0xF87171))
+                }
+
                 PostFormTextEditor(
                     label: "Description (optional)",
                     placeholder: "Why are you sharing this link?",
@@ -310,6 +383,13 @@ struct PostGeneratorView: View {
                     text: $viewModel.draft.videoURL
                 )
 
+                if !viewModel.draft.videoURL.isEmpty,
+                   !PostDraftValidator.isValidHTTPURL(viewModel.draft.videoURL) {
+                    Text("Enter a valid http or https URL.")
+                        .font(.system(size: 9))
+                        .foregroundStyle(Color(hex: 0xF87171))
+                }
+
                 PostFormTextEditor(
                     label: "Description (optional)",
                     placeholder: "Add context for your video…",
@@ -330,11 +410,7 @@ struct PostGeneratorView: View {
                             placeholder: "Enter poll option",
                             text: Binding(
                                 get: { option.text },
-                                set: { newValue in
-                                    if let idx = viewModel.draft.pollOptions.firstIndex(where: { $0.id == option.id }) {
-                                        viewModel.draft.pollOptions[idx].text = newValue
-                                    }
-                                }
+                                set: { viewModel.updatePollOption(id: option.id, text: $0) }
                             )
                         )
 
@@ -372,6 +448,7 @@ struct PostGeneratorView: View {
                         ForEach(PollDuration.allCases) { duration in
                             Button {
                                 viewModel.draft.pollDuration = duration
+                                viewModel.notifyDraftEdited()
                             } label: {
                                 Text(duration.label)
                                     .font(.system(size: 10, weight: .semibold))
@@ -466,39 +543,60 @@ struct PostGeneratorView: View {
             Text(titleCharacterCount)
                 .font(.system(size: 10))
                 .foregroundStyle(
-                    viewModel.draft.title.count > 300 ? Color(hex: 0xF87171) : AppTheme.textTertiary
+                    viewModel.draft.title.count > PostDraftValidator.maxTitleLength
+                        ? Color(hex: 0xF87171)
+                        : AppTheme.textTertiary
                 )
 
+            if !viewModel.draft.body.isEmpty {
+                Text(bodyCharacterCount)
+                    .font(.system(size: 10))
+                    .foregroundStyle(
+                        viewModel.draft.body.count > PostDraftValidator.maxBodyLength
+                            ? Color(hex: 0xF87171)
+                            : AppTheme.textTertiary
+                    )
+            }
+
             Spacer()
 
-            Text("Reddit limit: 300 characters for title")
+            Text("Reddit limits: 300 title · 40,000 body")
                 .font(.system(size: 10))
                 .foregroundStyle(AppTheme.textTertiary)
         }
     }
 
     private var titleCharacterCount: String {
-        "\(viewModel.draft.title.count) / 300"
+        "\(viewModel.draft.title.count) / \(PostDraftValidator.maxTitleLength) title"
+    }
+
+    private var bodyCharacterCount: String {
+        "\(viewModel.draft.body.count) / \(PostDraftValidator.maxBodyLength) body"
     }
 
     // MARK: - Preview
 
     private var previewContent: some View {
-        VStack(spacing: 16) {
-            Text("Live Preview")
-                .font(.system(size: 12, weight: .semibold))
-                .foregroundStyle(AppTheme.textSecondary)
-                .frame(maxWidth: .infinity, alignment: .leading)
+        HStack {
+            Spacer(minLength: 0)
 
-            RedditPostPreviewCard(
-                draft: viewModel.draft,
-                formattedSubreddit: viewModel.formattedSubreddit
-            )
+            VStack(spacing: 16) {
+                Text("Live Preview")
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(AppTheme.textSecondary)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+
+                RedditPostPreviewCard(
+                    draft: viewModel.draft,
+                    formattedSubreddit: viewModel.formattedSubreddit
+                )
 
-            previewMetadata
+                previewMetadata
+            }
+            .frame(maxWidth: 560)
+
+            Spacer(minLength: 0)
         }
-        .frame(maxWidth: 560)
-        .frame(maxWidth: .infinity)
     }
 
     private var previewMetadata: some View {
@@ -506,6 +604,7 @@ struct PostGeneratorView: View {
             metadataRow(label: "Type", value: viewModel.draft.postType.title)
             metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
             metadataRow(label: "Tone", value: viewModel.draft.tone.title)
+            metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
 
             if viewModel.draft.isNSFW || viewModel.draft.isSpoiler || viewModel.draft.isOC {
                 metadataRow(
@@ -561,6 +660,6 @@ private struct PostSecondaryButtonStyle: ButtonStyle {
 }
 
 #Preview {
-    PostGeneratorView(viewModel: PostGeneratorViewModel())
+    PostGeneratorView(viewModel: PostGeneratorViewModel()) { _ in }
         .frame(width: 880, height: 720)
 }

+ 6 - 1
Reddit App/Views/RedditWebView.swift

@@ -21,14 +21,19 @@ struct RedditWebView: NSViewRepresentable {
     }
 
     func updateNSView(_ container: RedditWebViewContainer, context: Context) {
-        guard context.coordinator.lastReloadTrigger != reloadTrigger else { return }
+        let urlChanged = context.coordinator.lastLoadedURL != url
+        let triggerChanged = context.coordinator.lastReloadTrigger != reloadTrigger
+        guard urlChanged || triggerChanged else { return }
+
         context.coordinator.lastReloadTrigger = reloadTrigger
+        context.coordinator.lastLoadedURL = url
         container.load(url: url)
     }
 
     final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
         weak var container: RedditWebViewContainer?
         var lastReloadTrigger = -1
+        var lastLoadedURL: URL?
 
         func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
             webView.evaluateJavaScript(RedditPostBoundaryStyle.injectionScript, completionHandler: nil)