Browse Source

Add Comment Writer tool with Reddit-aware compose, preview, and AI variants.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 tháng trước cách đây
mục cha
commit
7e5608656f

+ 174 - 0
Reddit App/Models/CommentModels.swift

@@ -0,0 +1,174 @@
+import Foundation
+
+enum CommentWriterTab: String, CaseIterable, Identifiable {
+    case compose
+    case preview
+    case variants
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .compose: "Compose"
+        case .preview: "Preview"
+        case .variants: "Variants"
+        }
+    }
+}
+
+enum CommentType: String, CaseIterable, Identifiable {
+    case topLevel
+    case reply
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .topLevel: "Top-Level"
+        case .reply: "Reply"
+        }
+    }
+
+    var subtitle: String {
+        switch self {
+        case .topLevel: "Comment on the post"
+        case .reply: "Reply to a comment"
+        }
+    }
+
+    var systemImage: String {
+        switch self {
+        case .topLevel: "text.bubble"
+        case .reply: "arrowshape.turn.up.left.fill"
+        }
+    }
+}
+
+enum CommentIntent: String, CaseIterable, Identifiable {
+    case addValue
+    case agree
+    case disagree
+    case question
+    case funny
+    case empathetic
+    case technical
+    case summary
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .addValue: "Add Value"
+        case .agree: "Agree"
+        case .disagree: "Counter"
+        case .question: "Question"
+        case .funny: "Funny"
+        case .empathetic: "Empathetic"
+        case .technical: "Technical"
+        case .summary: "TL;DR"
+        }
+    }
+
+    var subtitle: String {
+        switch self {
+        case .addValue: "Share insight or tips"
+        case .agree: "Support the post/comment"
+        case .disagree: "Respectful counterpoint"
+        case .question: "Ask for clarification"
+        case .funny: "Witty or lighthearted"
+        case .empathetic: "Personal & relatable"
+        case .technical: "Detailed explanation"
+        case .summary: "Concise recap"
+        }
+    }
+
+    var systemImage: String {
+        switch self {
+        case .addValue: "lightbulb.fill"
+        case .agree: "hand.thumbsup.fill"
+        case .disagree: "arrow.left.arrow.right"
+        case .question: "questionmark.circle.fill"
+        case .funny: "face.smiling.fill"
+        case .empathetic: "heart.fill"
+        case .technical: "wrench.and.screwdriver.fill"
+        case .summary: "text.alignleft"
+        }
+    }
+}
+
+enum CommentLength: String, CaseIterable, Identifiable {
+    case short
+    case medium
+    case long
+
+    var id: String { rawValue }
+
+    var title: String {
+        switch self {
+        case .short: "Short"
+        case .medium: "Medium"
+        case .long: "Long"
+        }
+    }
+
+    var subtitle: String {
+        switch self {
+        case .short: "1–2 sentences"
+        case .medium: "1–2 paragraphs"
+        case .long: "Detailed reply"
+        }
+    }
+
+    var wordRange: String {
+        switch self {
+        case .short: "20–60 words"
+        case .medium: "60–150 words"
+        case .long: "150–300 words"
+        }
+    }
+}
+
+enum CommentVariantCount: Int, CaseIterable, Identifiable {
+    case three = 3
+    case five = 5
+
+    var id: Int { rawValue }
+
+    var label: String { "\(rawValue) variants" }
+}
+
+struct CommentDraft: Equatable {
+    var commentType: CommentType = .topLevel
+    var subreddit: String = ""
+    var postTitle: String = ""
+    var postBody: String = ""
+    var postURL: String = ""
+    var parentComment: String = ""
+    var topic: String = ""
+    var tone: PostTone = .casual
+    var intent: CommentIntent = .addValue
+    var length: CommentLength = .medium
+    var body: String = ""
+    var includeQuote: Bool = true
+    var useMarkdown: Bool = true
+    var variantCount: CommentVariantCount = .three
+}
+
+struct CommentVariant: Identifiable, Equatable {
+    let id: UUID
+    var body: String
+    var score: Int
+    var reasoning: String
+
+    init(id: UUID = UUID(), body: String, score: Int, reasoning: String) {
+        self.id = id
+        self.body = body
+        self.score = score
+        self.reasoning = reasoning
+    }
+}
+
+struct GeneratedComment: Equatable {
+    var body: String
+    var variants: [CommentVariant]
+}

+ 298 - 0
Reddit App/Services/CommentGenerationService.swift

@@ -0,0 +1,298 @@
+import Foundation
+
+protocol CommentGenerationServiceProtocol: Sendable {
+    func generateComment(from draft: CommentDraft) async throws -> GeneratedComment
+}
+
+enum CommentGenerationServiceFactory {
+    static func makeDefault() -> any CommentGenerationServiceProtocol {
+        if let apiKey = AIConfiguration.apiKey {
+            return OpenAICommentGenerationService(apiKey: apiKey)
+        }
+        return MockCommentGenerationService()
+    }
+}
+
+enum CommentGenerationError: LocalizedError {
+    case emptySubreddit
+    case invalidSubreddit
+    case emptyPostTitle
+    case emptyParentComment
+    case apiUnavailable(String)
+    case invalidResponse
+
+    var errorDescription: String? {
+        switch self {
+        case .emptySubreddit:
+            "Enter a subreddit to tailor the comment."
+        case .invalidSubreddit:
+            "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
+        case .emptyPostTitle:
+            "Enter the post title for thread context."
+        case .emptyParentComment:
+            "Paste the parent comment you're replying to."
+        case .apiUnavailable(let message):
+            message
+        case .invalidResponse:
+            "The AI returned an unexpected response. Please try again."
+        }
+    }
+}
+
+// MARK: - OpenAI
+
+struct OpenAICommentGenerationService: CommentGenerationServiceProtocol {
+    let apiKey: String
+    var model: String = "gpt-4o-mini"
+
+    func generateComment(from draft: CommentDraft) async throws -> GeneratedComment {
+        try CommentDraftValidator.validateForGeneration(from: draft)
+
+        let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
+        let prompt = buildPrompt(draft: draft, subreddit: subreddit)
+
+        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.85,
+            "response_format": ["type": "json_object"],
+            "messages": [
+                [
+                    "role": "system",
+                    "content": """
+                    You write authentic Reddit comments. Respond with JSON only using keys:
+                    body (string, primary comment, max 10000 chars),
+                    variants (array of objects with body, score 0-100, reasoning strings).
+                    Generate exactly \(draft.variantCount.rawValue) variants ranked by fit.
+                    Write like a real Redditor: conversational, community-aware, no corporate tone.
+                    Use markdown when requested. Never start with "As an AI" or similar.
+                    Match subreddit culture. Be respectful even when disagreeing.
+                    """,
+                ],
+                ["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 CommentGenerationError.apiUnavailable("Couldn't reach the AI service.")
+        }
+
+        if http.statusCode == 401 {
+            throw CommentGenerationError.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 CommentGenerationError.apiUnavailable("AI request failed: \(message)")
+        }
+
+        return try parseResponse(data)
+    }
+
+    private func buildPrompt(draft: CommentDraft, subreddit: String) -> String {
+        var lines = [
+            "Subreddit: r/\(subreddit)",
+            "Comment type: \(draft.commentType.title)",
+            "Post title: \(draft.postTitle.trimmingCharacters(in: .whitespaces))",
+            "Intent: \(draft.intent.title) — \(draft.intent.subtitle)",
+            "Tone: \(draft.tone.title)",
+            "Length: \(draft.length.title) (\(draft.length.wordRange))",
+            "Markdown: \(draft.useMarkdown ? "yes" : "plain text only")",
+        ]
+
+        if !draft.topic.trimmingCharacters(in: .whitespaces).isEmpty {
+            lines.append("Focus / angle: \(draft.topic.trimmingCharacters(in: .whitespaces))")
+        }
+        if !draft.postBody.trimmingCharacters(in: .whitespaces).isEmpty {
+            lines.append("Post excerpt: \(draft.postBody.trimmingCharacters(in: .whitespaces))")
+        }
+        if draft.commentType == .reply {
+            lines.append("Parent comment to reply to: \(draft.parentComment.trimmingCharacters(in: .whitespaces))")
+            if draft.includeQuote {
+                lines.append("Optionally quote relevant part of parent with > blockquote.")
+            }
+        }
+
+        return lines.joined(separator: "\n")
+    }
+
+    private func parseResponse(_ data: Data) throws -> GeneratedComment {
+        struct APIEnvelope: Decodable {
+            struct Choice: Decodable {
+                struct Message: Decodable {
+                    let content: String
+                }
+                let message: Message
+            }
+            let choices: [Choice]
+        }
+
+        struct VariantPayload: Decodable {
+            let body: String
+            let score: Int
+            let reasoning: String
+        }
+
+        struct Payload: Decodable {
+            let body: String
+            let variants: [VariantPayload]
+        }
+
+        let envelope = try JSONDecoder().decode(APIEnvelope.self, from: data)
+        guard let content = envelope.choices.first?.message.content.data(using: .utf8) else {
+            throw CommentGenerationError.invalidResponse
+        }
+
+        let payload = try JSONDecoder().decode(Payload.self, from: content)
+        let body = String(payload.body.prefix(CommentDraftValidator.maxCommentLength))
+        let variants = payload.variants.map {
+            CommentVariant(
+                body: String($0.body.prefix(CommentDraftValidator.maxCommentLength)),
+                score: min(100, max(0, $0.score)),
+                reasoning: $0.reasoning
+            )
+        }
+
+        guard !body.isEmpty else { throw CommentGenerationError.invalidResponse }
+        return GeneratedComment(body: body, variants: variants)
+    }
+}
+
+// MARK: - Mock
+
+struct MockCommentGenerationService: CommentGenerationServiceProtocol {
+    func generateComment(from draft: CommentDraft) async throws -> GeneratedComment {
+        try CommentDraftValidator.validateForGeneration(from: draft)
+
+        try await Task.sleep(for: .milliseconds(900))
+
+        let subreddit = PostDraftValidator.normalizedSubreddit(draft.subreddit)
+        let postTitle = draft.postTitle.trimmingCharacters(in: .whitespaces)
+        let body = makePrimaryComment(draft: draft, subreddit: subreddit, postTitle: postTitle)
+        let variants = makeVariants(draft: draft, subreddit: subreddit, postTitle: postTitle)
+
+        return GeneratedComment(body: body, variants: variants)
+    }
+
+    private func makePrimaryComment(draft: CommentDraft, subreddit: String, postTitle: String) -> String {
+        let opener: String = switch draft.commentType {
+        case .topLevel:
+            "Great post — "
+        case .reply:
+            draft.includeQuote && !draft.parentComment.isEmpty
+                ? "> \(draft.parentComment.prefix(120))\(draft.parentComment.count > 120 ? "…" : "")\n\n"
+                : ""
+        }
+
+        let core: String = switch draft.intent {
+        case .addValue:
+            """
+            I've been lurking r/\(subreddit) for a while and this is one of the better takes on **\(postTitle)** I've seen.
+
+            A few things that worked for me:
+            - Start small and iterate — don't overthink the first step
+            - The community wiki/FAQ is underrated
+            - Happy to share more detail if anyone's interested
+
+            Solid discussion thread.
+            """
+        case .agree:
+            """
+            100% agree. This matches my experience with \(postTitle.lowercased()) and I'm glad someone finally said it clearly.
+
+            The part about taking it one step at a time really resonates. Thanks for posting this to r/\(subreddit).
+            """
+        case .disagree:
+            """
+            Respectfully disagree on one point here. I think the common advice around \(postTitle.lowercased()) oversimplifies things.
+
+            In my experience the nuance matters a lot — context, timing, and individual circumstances change the answer. Not trying to be contrarian, just adding another data point from someone who's been through it.
+            """
+        case .question:
+            """
+            Genuine question — when you say this applies broadly, do you mean for beginners or everyone in r/\(subreddit)?
+
+            I've had mixed results depending on setup. Would love to hear what worked for others on **\(postTitle)**.
+            """
+        case .funny:
+            """
+            r/\(subreddit) never disappoints. Read the title, thought "yeah same," then read the post and now I'm questioning every life choice.
+
+            Saving this for the next time someone asks about \(postTitle.lowercased()). 😅
+            """
+        case .empathetic:
+            """
+            Thanks for sharing this — it's hard to post about \(postTitle.lowercased()) and I appreciate the honesty.
+
+            You're not alone in feeling this way. Took me a long time to figure out and the r/\(subreddit) community helped more than I expected.
+            """
+        case .technical:
+            """
+            For anyone looking for the technical breakdown on **\(postTitle)**:
+
+            1. **Root cause** — usually configuration or environment mismatch
+            2. **Fix** — verify prerequisites first, then apply the minimal change
+            3. **Verify** — reproduce the original issue and confirm it's resolved
+
+            Happy to go deeper in a follow-up if useful.
+            """
+        case .summary:
+            """
+            **TL;DR:** \(postTitle) — main takeaway is to start simple, validate early, and lean on community resources in r/\(subreddit).
+
+            Worth a read if you're new to this.
+            """
+        }
+
+        let trimmed = adjustLength(core, length: draft.length)
+        return String((opener + trimmed).prefix(CommentDraftValidator.maxCommentLength))
+    }
+
+    private func makeVariants(draft: CommentDraft, subreddit: String, postTitle: String) -> [CommentVariant] {
+        let base = makePrimaryComment(draft: draft, subreddit: subreddit, postTitle: postTitle)
+        let altIntents: [CommentIntent] = [.agree, .question, .summary, .addValue, .funny]
+            .filter { $0 != draft.intent }
+            .prefix(draft.variantCount.rawValue - 1)
+            .map { $0 }
+
+        var variants: [CommentVariant] = [
+            CommentVariant(
+                body: base,
+                score: 92,
+                reasoning: "Best match for \(draft.intent.title) intent and \(draft.tone.title.lowercased()) tone."
+            ),
+        ]
+
+        for (index, intent) in altIntents.enumerated() {
+            var altDraft = draft
+            altDraft.intent = intent
+            let body = makePrimaryComment(draft: altDraft, subreddit: subreddit, postTitle: postTitle)
+            variants.append(
+                CommentVariant(
+                    body: body,
+                    score: 88 - index * 4,
+                    reasoning: "Alternative \(intent.title.lowercased()) angle for r/\(subreddit)."
+                )
+            )
+        }
+
+        return Array(variants.prefix(draft.variantCount.rawValue))
+    }
+
+    private func adjustLength(_ text: String, length: CommentLength) -> String {
+        switch length {
+        case .short:
+            let sentences = text.components(separatedBy: ". ")
+            return sentences.prefix(2).joined(separator: ". ") + (sentences.count > 1 ? "." : "")
+        case .medium:
+            return text
+        case .long:
+            return text + "\n\nEdit: thanks for the awards kind strangers — didn't expect this to blow up."
+        }
+    }
+}

+ 83 - 0
Reddit App/Utilities/CommentDraftValidator.swift

@@ -0,0 +1,83 @@
+import Foundation
+
+enum CommentDraftValidationError: LocalizedError {
+    case emptySubreddit
+    case invalidSubreddit
+    case emptyPostTitle
+    case emptyParentComment
+    case emptyComment
+    case commentTooLong
+    case invalidPostURL
+
+    var errorDescription: String? {
+        switch self {
+        case .emptySubreddit:
+            "Enter a subreddit to tailor the comment."
+        case .invalidSubreddit:
+            "Subreddit names must be 3–21 characters and use only letters, numbers, or underscores."
+        case .emptyPostTitle:
+            "Enter the post title so the AI understands the thread context."
+        case .emptyParentComment:
+            "Paste the comment you're replying to."
+        case .emptyComment:
+            "Write or generate a comment before copying."
+        case .commentTooLong:
+            "Comments must be 10,000 characters or fewer."
+        case .invalidPostURL:
+            "Enter a valid Reddit post URL (https://reddit.com/…)."
+        }
+    }
+}
+
+enum CommentDraftValidator {
+    static let maxCommentLength = 10_000
+
+    static func validateForGeneration(from draft: CommentDraft) throws {
+        guard !draft.subreddit.trimmingCharacters(in: .whitespaces).isEmpty else {
+            throw CommentDraftValidationError.emptySubreddit
+        }
+        guard PostDraftValidator.isValidSubreddit(draft.subreddit) else {
+            throw CommentDraftValidationError.invalidSubreddit
+        }
+        guard !draft.postTitle.trimmingCharacters(in: .whitespaces).isEmpty else {
+            throw CommentDraftValidationError.emptyPostTitle
+        }
+        if draft.commentType == .reply {
+            guard !draft.parentComment.trimmingCharacters(in: .whitespaces).isEmpty else {
+                throw CommentDraftValidationError.emptyParentComment
+            }
+        }
+    }
+
+    static func validateForExport(_ draft: CommentDraft) throws {
+        try validateForGeneration(from: draft)
+        let body = draft.body.trimmingCharacters(in: .whitespaces)
+        guard !body.isEmpty else { throw CommentDraftValidationError.emptyComment }
+        guard body.count <= maxCommentLength else { throw CommentDraftValidationError.commentTooLong }
+
+        let url = draft.postURL.trimmingCharacters(in: .whitespaces)
+        if !url.isEmpty, !isValidRedditURL(url) {
+            throw CommentDraftValidationError.invalidPostURL
+        }
+    }
+
+    static func canGenerate(from draft: CommentDraft, isGenerating: Bool) -> Bool {
+        guard !isGenerating else { return false }
+        return (try? validateForGeneration(from: draft)) != nil
+    }
+
+    static func canExport(_ draft: CommentDraft) -> Bool {
+        (try? validateForExport(draft)) != nil
+    }
+
+    static func isValidRedditURL(_ string: String) -> Bool {
+        let trimmed = string.trimmingCharacters(in: .whitespaces)
+        guard let url = URL(string: trimmed),
+              let scheme = url.scheme?.lowercased(),
+              scheme == "http" || scheme == "https",
+              let host = url.host?.lowercased(),
+              host.contains("reddit.com")
+        else { return false }
+        return true
+    }
+}

+ 180 - 0
Reddit App/ViewModels/CommentWriterViewModel.swift

@@ -0,0 +1,180 @@
+import AppKit
+import SwiftUI
+
+@MainActor
+@Observable
+final class CommentWriterViewModel {
+    var draft = CommentDraft()
+    var selectedTab: CommentWriterTab = .compose
+    var isGenerating = false
+    var errorMessage: String?
+    var successMessage: String?
+    var variants: [CommentVariant] = []
+    var selectedVariantID: UUID?
+    var showOverwriteConfirmation = false
+
+    private let generationService: any CommentGenerationServiceProtocol
+    private let emptyDraft = CommentDraft()
+
+    init(generationService: (any CommentGenerationServiceProtocol)? = nil) {
+        self.generationService = generationService ?? CommentGenerationServiceFactory.makeDefault()
+    }
+
+    var formattedSubreddit: String {
+        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 {
+        CommentDraftValidator.canGenerate(from: draft, isGenerating: isGenerating)
+    }
+
+    var canExport: Bool {
+        let body = activeCommentBody.trimmingCharacters(in: .whitespaces)
+        guard !body.isEmpty, body.count <= CommentDraftValidator.maxCommentLength else { return false }
+        return (try? CommentDraftValidator.validateForGeneration(from: draft)) != nil
+    }
+
+    var activeCommentBody: String {
+        if let selectedID = selectedVariantID,
+           let variant = variants.first(where: { $0.id == selectedID }) {
+            return variant.body
+        }
+        return draft.body
+    }
+
+    var bestVariant: CommentVariant? {
+        variants.max(by: { $0.score < $1.score })
+    }
+
+    var needsOverwriteConfirmation: Bool {
+        !draft.body.trimmingCharacters(in: .whitespaces).isEmpty
+    }
+
+    func selectCommentType(_ type: CommentType) {
+        draft.commentType = type
+        if type == .topLevel {
+            draft.parentComment = ""
+        }
+        clearMessages()
+    }
+
+    func selectVariant(_ variant: CommentVariant) {
+        selectedVariantID = variant.id
+        clearMessages()
+    }
+
+    func applyVariant(_ variant: CommentVariant) {
+        draft.body = variant.body
+        selectedVariantID = variant.id
+        successMessage = "Variant applied."
+        errorMessage = nil
+    }
+
+    func generateComment() async {
+        guard canGenerate else { 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() {
+        let content = activeCommentBody.trimmingCharacters(in: .whitespaces)
+        guard !content.isEmpty else {
+            errorMessage = CommentDraftValidationError.emptyComment.localizedDescription
+            successMessage = nil
+            return
+        }
+        guard content.count <= CommentDraftValidator.maxCommentLength else {
+            errorMessage = CommentDraftValidationError.commentTooLong.localizedDescription
+            successMessage = nil
+            return
+        }
+
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(content, forType: .string)
+        successMessage = "Comment copied to clipboard."
+        errorMessage = nil
+    }
+
+    func openInReddit(openInApp: (URL) -> Void) {
+        let urlString = draft.postURL.trimmingCharacters(in: .whitespaces)
+        guard !urlString.isEmpty else {
+            errorMessage = "Add a Reddit post URL to open the thread."
+            successMessage = nil
+            return
+        }
+
+        guard CommentDraftValidator.isValidRedditURL(urlString) else {
+            errorMessage = CommentDraftValidationError.invalidPostURL.localizedDescription
+            successMessage = nil
+            return
+        }
+
+        guard let url = URL(string: urlString) else { return }
+        openInApp(url)
+        successMessage = "Opened thread in Reddit. Paste your comment when ready."
+        errorMessage = nil
+    }
+
+    func resetDraft() {
+        draft = CommentDraft()
+        selectedTab = .compose
+        variants = []
+        selectedVariantID = nil
+        clearMessages()
+    }
+
+    func clearMessages() {
+        errorMessage = nil
+        successMessage = nil
+    }
+
+    func notifyDraftEdited() {
+        clearMessages()
+    }
+
+    private func performGenerate(replaceExisting: Bool) async {
+        isGenerating = true
+        errorMessage = nil
+        successMessage = nil
+
+        do {
+            let result = try await generationService.generateComment(from: draft)
+            if replaceExisting || draft.body.trimmingCharacters(in: .whitespaces).isEmpty {
+                draft.body = String(result.body.prefix(CommentDraftValidator.maxCommentLength))
+            }
+            variants = result.variants
+            selectedVariantID = result.variants.first?.id
+            selectedTab = .preview
+            let engine = usesLiveAI ? "OpenAI" : "local AI templates"
+            successMessage = "Generated comment with \(result.variants.count) variants using \(engine)."
+        } catch {
+            errorMessage = error.localizedDescription
+        }
+
+        isGenerating = false
+    }
+}

+ 508 - 0
Reddit App/Views/CommentWriterView.swift

@@ -0,0 +1,508 @@
+import SwiftUI
+
+struct CommentWriterView: View {
+    @Bindable var viewModel: CommentWriterViewModel
+    let onOpenInReddit: (URL) -> Void
+
+    var body: some View {
+        VStack(spacing: 0) {
+            header
+            messageBanners
+            tabBar
+
+            ScrollView {
+                PostPageBoundary {
+                    switch viewModel.selectedTab {
+                    case .compose:
+                        composeContent
+                    case .preview:
+                        previewContent
+                    case .variants:
+                        variantsContent
+                    }
+                }
+                .padding(.horizontal, 24)
+                .padding(.top, 8)
+                .padding(.bottom, 24)
+            }
+        }
+        .background(AppTheme.background)
+        .alert("Replace existing comment?", isPresented: $viewModel.showOverwriteConfirmation) {
+            Button("Cancel", role: .cancel) {
+                viewModel.cancelOverwriteConfirmation()
+            }
+            Button("Replace", role: .destructive) {
+                Task { await viewModel.confirmOverwriteAndGenerate() }
+            }
+        } message: {
+            Text("Generating will replace your current comment text.")
+        }
+        .onChange(of: viewModel.draft.subreddit) { _, _ in viewModel.notifyDraftEdited() }
+        .onChange(of: viewModel.draft.postTitle) { _, _ in viewModel.notifyDraftEdited() }
+        .onChange(of: viewModel.draft.postBody) { _, _ in viewModel.notifyDraftEdited() }
+        .onChange(of: viewModel.draft.parentComment) { _, _ in viewModel.notifyDraftEdited() }
+        .onChange(of: viewModel.draft.body) { _, _ in viewModel.notifyDraftEdited() }
+    }
+
+    // MARK: - Header
+
+    private var header: some View {
+        HStack(spacing: 14) {
+            ModernSidebarIconView(kind: .commentWriter, size: 40)
+
+            VStack(alignment: .leading, spacing: 2) {
+                HStack(spacing: 6) {
+                    Text("Comment Writer")
+                        .font(.system(size: 18, weight: .semibold))
+                        .foregroundStyle(AppTheme.textPrimary)
+
+                    if viewModel.hasDraftChanges {
+                        Circle()
+                            .fill(AppTheme.accentGreen)
+                            .frame(width: 6, height: 6)
+                            .help("Draft has unsaved edits")
+                    }
+                }
+
+                Text(viewModel.usesLiveAI ? "Write Reddit-ready comments with OpenAI" : "Write Reddit-ready comments with AI templates")
+                    .font(.system(size: 11))
+                    .foregroundStyle(AppTheme.textSecondary)
+            }
+
+            Spacer()
+
+            headerActions
+        }
+        .padding(.horizontal, 24)
+        .padding(.vertical, 16)
+        .overlay(alignment: .bottom) {
+            Rectangle()
+                .fill(AppTheme.border)
+                .frame(height: 1)
+        }
+    }
+
+    @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 {
+                viewModel.resetDraft()
+            } label: {
+                Label("Reset", systemImage: "arrow.counterclockwise")
+                    .font(.system(size: 11, weight: .medium))
+            }
+            .buttonStyle(CommentSecondaryButtonStyle())
+
+            Button {
+                viewModel.openInReddit(openInApp: onOpenInReddit)
+            } label: {
+                Label("Open Thread", systemImage: "arrow.up.right.square")
+                    .font(.system(size: 11, weight: .medium))
+            }
+            .buttonStyle(CommentSecondaryButtonStyle())
+            .disabled(viewModel.draft.postURL.trimmingCharacters(in: .whitespaces).isEmpty)
+
+            Button {
+                viewModel.copyToClipboard()
+            } label: {
+                Label("Copy", systemImage: "doc.on.doc")
+                    .font(.system(size: 11, weight: .medium))
+            }
+            .buttonStyle(CommentSecondaryButtonStyle())
+            .disabled(!viewModel.canExport)
+
+            Button {
+                Task { await viewModel.generateComment() }
+            } label: {
+                HStack(spacing: 6) {
+                    if viewModel.isGenerating {
+                        ProgressView()
+                            .controlSize(.small)
+                            .tint(.white)
+                    } else {
+                        Image(systemName: "sparkles")
+                            .font(.system(size: 11, weight: .semibold))
+                    }
+                    Text(viewModel.isGenerating ? "Generating…" : "Generate")
+                        .font(.system(size: 11, weight: .semibold))
+                }
+                .foregroundStyle(.white)
+                .padding(.horizontal, 14)
+                .padding(.vertical, 8)
+                .background(viewModel.canGenerate ? AppTheme.accentGreen : AppTheme.accentGreen.opacity(0.4))
+                .clipShape(RoundedRectangle(cornerRadius: 8))
+            }
+            .buttonStyle(.plain)
+            .disabled(!viewModel.canGenerate)
+        }
+    }
+
+    // MARK: - Tab Bar
+
+    private var tabBar: some View {
+        HStack(spacing: 4) {
+            ForEach(CommentWriterTab.allCases) { tab in
+                Button {
+                    viewModel.selectedTab = tab
+                } label: {
+                    HStack(spacing: 4) {
+                        Text(tab.title)
+                            .font(.system(size: 11, weight: .semibold))
+
+                        if tab == .variants, !viewModel.variants.isEmpty {
+                            Text("\(viewModel.variants.count)")
+                                .font(.system(size: 9, weight: .bold))
+                                .foregroundStyle(.white)
+                                .padding(.horizontal, 5)
+                                .padding(.vertical, 2)
+                                .background(AppTheme.accentGreen)
+                                .clipShape(Capsule())
+                        }
+                    }
+                    .foregroundStyle(viewModel.selectedTab == tab ? AppTheme.textPrimary : AppTheme.textSecondary)
+                    .padding(.horizontal, 14)
+                    .padding(.vertical, 8)
+                    .background(
+                        viewModel.selectedTab == tab
+                            ? AppTheme.cardBackground
+                            : Color.clear
+                    )
+                    .clipShape(RoundedRectangle(cornerRadius: 8))
+                }
+                .buttonStyle(.plain)
+            }
+
+            Spacer()
+        }
+        .padding(.horizontal, 24)
+        .padding(.top, 12)
+        .padding(.bottom, 4)
+    }
+
+    // MARK: - Compose
+
+    private var composeContent: some View {
+        ViewThatFits(in: .horizontal) {
+            HStack(alignment: .top, spacing: 16) {
+                configurationPanel
+                editorPanel
+            }
+
+            VStack(alignment: .leading, spacing: 16) {
+                configurationPanel
+                editorPanel
+            }
+        }
+    }
+
+    private var configurationPanel: some View {
+        VStack(spacing: 12) {
+            PostFormCard(title: "Comment Type", subtitle: "Top-level or reply to a comment") {
+                CommentTypePicker(selectedType: Binding(
+                    get: { viewModel.draft.commentType },
+                    set: { viewModel.selectCommentType($0) }
+                ))
+            }
+
+            PostFormCard(title: "Thread Context", subtitle: "What are you commenting on?") {
+                VStack(alignment: .leading, spacing: 12) {
+                    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))
+                    }
+
+                    PostFormField(
+                        label: "Post Title",
+                        placeholder: "Paste or type the post title",
+                        text: $viewModel.draft.postTitle
+                    )
+
+                    PostFormTextEditor(
+                        label: "Post Body (optional)",
+                        placeholder: "Excerpt from the post for better context…",
+                        text: $viewModel.draft.postBody,
+                        minHeight: 70
+                    )
+
+                    if viewModel.draft.commentType == .reply {
+                        PostFormTextEditor(
+                            label: "Parent Comment",
+                            placeholder: "Paste the comment you're replying to…",
+                            text: $viewModel.draft.parentComment,
+                            minHeight: 90
+                        )
+                    }
+
+                    PostFormField(
+                        label: "Post URL (optional)",
+                        placeholder: "https://reddit.com/r/…/comments/…",
+                        text: $viewModel.draft.postURL
+                    )
+
+                    if !viewModel.draft.postURL.isEmpty,
+                       !CommentDraftValidator.isValidRedditURL(viewModel.draft.postURL) {
+                        Text("Enter a valid reddit.com URL to open the thread.")
+                            .font(.system(size: 9))
+                            .foregroundStyle(Color(hex: 0xF87171))
+                    }
+                }
+            }
+
+            PostFormCard(title: "Comment Intent", subtitle: "What should this comment achieve?") {
+                CommentIntentPicker(selectedIntent: $viewModel.draft.intent)
+            }
+
+            PostFormCard(title: "AI Settings", subtitle: "Tone, length, and style") {
+                VStack(alignment: .leading, spacing: 12) {
+                    PostFormField(
+                        label: "Focus / Angle (optional)",
+                        placeholder: "e.g. share personal experience",
+                        text: $viewModel.draft.topic
+                    )
+
+                    VStack(alignment: .leading, spacing: 6) {
+                        Text("Tone")
+                            .font(.system(size: 10, weight: .medium))
+                            .foregroundStyle(AppTheme.textTertiary)
+                        CommentTonePicker(selectedTone: $viewModel.draft.tone)
+                    }
+
+                    VStack(alignment: .leading, spacing: 6) {
+                        Text("Length")
+                            .font(.system(size: 10, weight: .medium))
+                            .foregroundStyle(AppTheme.textTertiary)
+                        CommentLengthPicker(selectedLength: $viewModel.draft.length)
+                    }
+
+                    CommentToggleRow(
+                        title: "Use Markdown",
+                        subtitle: "Bold, italic, quotes, links",
+                        isOn: $viewModel.draft.useMarkdown
+                    )
+
+                    if viewModel.draft.commentType == .reply {
+                        CommentToggleRow(
+                            title: "Quote Parent",
+                            subtitle: "Include > blockquote of parent comment",
+                            isOn: $viewModel.draft.includeQuote
+                        )
+                    }
+
+                    VStack(alignment: .leading, spacing: 6) {
+                        Text("Variants")
+                            .font(.system(size: 10, weight: .medium))
+                            .foregroundStyle(AppTheme.textTertiary)
+                        CommentVariantCountPicker(selectedCount: $viewModel.draft.variantCount)
+                    }
+                }
+            }
+
+            RedditCommentEtiquetteCard()
+        }
+        .frame(minWidth: 280, idealWidth: 300, maxWidth: 320)
+    }
+
+    private var editorPanel: some View {
+        VStack(spacing: 12) {
+            PostFormCard(
+                title: "Your Comment",
+                subtitle: viewModel.draft.useMarkdown
+                    ? "Markdown supported — edit after generating"
+                    : "Plain text — edit after generating"
+            ) {
+                PostFormTextEditor(
+                    label: "Comment Body",
+                    placeholder: "Generate a comment or write your own…",
+                    text: $viewModel.draft.body,
+                    minHeight: 220
+                )
+            }
+
+            characterCountFooter
+
+            RedditThreadContextCard(
+                postTitle: viewModel.draft.postTitle,
+                postBody: viewModel.draft.postBody,
+                parentComment: viewModel.draft.parentComment,
+                commentType: viewModel.draft.commentType,
+                formattedSubreddit: viewModel.formattedSubreddit
+            )
+        }
+        .frame(maxWidth: .infinity)
+    }
+
+    private var characterCountFooter: some View {
+        HStack {
+            Text("\(viewModel.draft.body.count) / \(CommentDraftValidator.maxCommentLength)")
+                .font(.system(size: 10))
+                .foregroundStyle(
+                    viewModel.draft.body.count > CommentDraftValidator.maxCommentLength
+                        ? Color(hex: 0xF87171)
+                        : AppTheme.textTertiary
+                )
+
+            Spacer()
+
+            Text("Reddit limit: 10,000 characters")
+                .font(.system(size: 10))
+                .foregroundStyle(AppTheme.textTertiary)
+        }
+    }
+
+    // MARK: - Preview
+
+    private var previewContent: some View {
+        HStack {
+            Spacer(minLength: 0)
+
+            VStack(spacing: 16) {
+                Text("Live Preview")
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(AppTheme.textSecondary)
+                    .frame(maxWidth: .infinity, alignment: .leading)
+
+                RedditCommentPreviewCard(
+                    commentBody: viewModel.activeCommentBody,
+                    formattedSubreddit: viewModel.formattedSubreddit,
+                    commentType: viewModel.draft.commentType,
+                    parentComment: viewModel.draft.parentComment
+                )
+
+                previewMetadata
+            }
+            .frame(maxWidth: 560)
+
+            Spacer(minLength: 0)
+        }
+    }
+
+    private var previewMetadata: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            metadataRow(label: "Type", value: viewModel.draft.commentType.title)
+            metadataRow(label: "Subreddit", value: viewModel.formattedSubreddit)
+            metadataRow(label: "Intent", value: viewModel.draft.intent.title)
+            metadataRow(label: "Tone", value: viewModel.draft.tone.title)
+            metadataRow(label: "Length", value: viewModel.draft.length.title)
+            metadataRow(label: "Engine", value: viewModel.usesLiveAI ? "OpenAI" : "Local templates")
+
+            if let best = viewModel.bestVariant {
+                metadataRow(label: "Best Score", value: "\(best.score)/100")
+            }
+        }
+        .padding(14)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(
+            RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
+                .fill(AppTheme.cardBackground)
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
+                        .stroke(AppTheme.cardBorder, lineWidth: 1)
+                )
+        )
+    }
+
+    // MARK: - Variants
+
+    private var variantsContent: some View {
+        VStack(spacing: 16) {
+            if viewModel.variants.isEmpty {
+                CommentWriterEmptyState(
+                    icon: "bubble.left.and.bubble.right",
+                    title: "No variants yet",
+                    subtitle: "Fill in thread context on the Compose tab,\nthen tap Generate to get comment variants."
+                )
+            } else {
+                variantsHeader
+
+                VStack(spacing: 8) {
+                    ForEach(viewModel.variants) { variant in
+                        CommentVariantRow(
+                            variant: variant,
+                            isSelected: viewModel.selectedVariantID == variant.id,
+                            onSelect: { viewModel.selectVariant(variant) },
+                            onApply: { viewModel.applyVariant(variant) }
+                        )
+                    }
+                }
+
+                if !viewModel.activeCommentBody.isEmpty {
+                    PostFormCard(title: "Selected Preview", subtitle: "How this variant looks in a thread") {
+                        RedditCommentPreviewCard(
+                            commentBody: viewModel.activeCommentBody,
+                            formattedSubreddit: viewModel.formattedSubreddit,
+                            commentType: viewModel.draft.commentType,
+                            parentComment: viewModel.draft.parentComment
+                        )
+                    }
+                }
+            }
+        }
+        .frame(maxWidth: 640)
+        .frame(maxWidth: .infinity)
+    }
+
+    private var variantsHeader: some View {
+        HStack {
+            VStack(alignment: .leading, spacing: 2) {
+                Text("Comment Variants")
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+                Text("Tap a variant to preview, or Apply to use it")
+                    .font(.system(size: 10))
+                    .foregroundStyle(AppTheme.textSecondary)
+            }
+
+            Spacer()
+
+            if let best = viewModel.bestVariant {
+                HStack(spacing: 6) {
+                    Image(systemName: "trophy.fill")
+                        .font(.system(size: 10))
+                        .foregroundStyle(AppTheme.accentYellow)
+                    Text("Best: \(best.score)/100")
+                        .font(.system(size: 10, weight: .semibold))
+                        .foregroundStyle(AppTheme.textSecondary)
+                }
+            }
+        }
+    }
+
+    private func metadataRow(label: String, value: String) -> some View {
+        HStack(alignment: .top) {
+            Text(label)
+                .font(.system(size: 10, weight: .medium))
+                .foregroundStyle(AppTheme.textTertiary)
+                .frame(width: 70, alignment: .leading)
+            Text(value)
+                .font(.system(size: 10))
+                .foregroundStyle(AppTheme.textSecondary)
+                .fixedSize(horizontal: false, vertical: true)
+        }
+    }
+}
+
+#Preview {
+    CommentWriterView(viewModel: CommentWriterViewModel()) { _ in }
+        .frame(width: 880, height: 720)
+}

+ 633 - 0
Reddit App/Views/Components/CommentWriterComponents.swift

@@ -0,0 +1,633 @@
+import SwiftUI
+
+// MARK: - Pickers
+
+struct CommentTypePicker: View {
+    @Binding var selectedType: CommentType
+
+    var body: some View {
+        HStack(spacing: 8) {
+            ForEach(CommentType.allCases) { type in
+                Button {
+                    selectedType = type
+                } label: {
+                    HStack(spacing: 8) {
+                        Image(systemName: type.systemImage)
+                            .font(.system(size: 12, weight: .semibold))
+                            .foregroundStyle(
+                                selectedType == type ? AppTheme.accentGreen : AppTheme.textSecondary
+                            )
+                            .frame(width: 16)
+
+                        VStack(alignment: .leading, spacing: 1) {
+                            Text(type.title)
+                                .font(.system(size: 11, weight: .semibold))
+                                .foregroundStyle(AppTheme.textPrimary)
+                            Text(type.subtitle)
+                                .font(.system(size: 9))
+                                .foregroundStyle(AppTheme.textTertiary)
+                                .lineLimit(1)
+                        }
+
+                        Spacer(minLength: 0)
+                    }
+                    .padding(.horizontal, 10)
+                    .padding(.vertical, 8)
+                    .background(
+                        RoundedRectangle(cornerRadius: 8)
+                            .fill(
+                                selectedType == type
+                                    ? AppTheme.accentGreen.opacity(0.15)
+                                    : AppTheme.panelBackground
+                            )
+                    )
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 8)
+                            .stroke(
+                                selectedType == type
+                                    ? AppTheme.accentGreen.opacity(0.5)
+                                    : AppTheme.cardBorder,
+                                lineWidth: 1
+                            )
+                    )
+                }
+                .buttonStyle(.plain)
+            }
+        }
+    }
+}
+
+struct CommentIntentPicker: View {
+    @Binding var selectedIntent: CommentIntent
+
+    var body: some View {
+        LazyVGrid(
+            columns: [GridItem(.flexible()), GridItem(.flexible())],
+            spacing: 8
+        ) {
+            ForEach(CommentIntent.allCases) { intent in
+                Button {
+                    selectedIntent = intent
+                } label: {
+                    HStack(spacing: 8) {
+                        Image(systemName: intent.systemImage)
+                            .font(.system(size: 11, weight: .semibold))
+                            .foregroundStyle(
+                                selectedIntent == intent ? AppTheme.accentGreen : AppTheme.textSecondary
+                            )
+                            .frame(width: 14)
+
+                        VStack(alignment: .leading, spacing: 1) {
+                            Text(intent.title)
+                                .font(.system(size: 11, weight: .semibold))
+                                .foregroundStyle(AppTheme.textPrimary)
+                            Text(intent.subtitle)
+                                .font(.system(size: 9))
+                                .foregroundStyle(AppTheme.textTertiary)
+                                .lineLimit(1)
+                        }
+
+                        Spacer(minLength: 0)
+                    }
+                    .padding(.horizontal, 10)
+                    .padding(.vertical, 8)
+                    .background(
+                        RoundedRectangle(cornerRadius: 8)
+                            .fill(
+                                selectedIntent == intent
+                                    ? AppTheme.accentGreen.opacity(0.15)
+                                    : AppTheme.panelBackground
+                            )
+                    )
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 8)
+                            .stroke(
+                                selectedIntent == intent
+                                    ? AppTheme.accentGreen.opacity(0.5)
+                                    : AppTheme.cardBorder,
+                                lineWidth: 1
+                            )
+                    )
+                }
+                .buttonStyle(.plain)
+            }
+        }
+    }
+}
+
+struct CommentLengthPicker: View {
+    @Binding var selectedLength: CommentLength
+
+    var body: some View {
+        HStack(spacing: 6) {
+            ForEach(CommentLength.allCases) { length in
+                Button {
+                    selectedLength = length
+                } label: {
+                    VStack(spacing: 2) {
+                        Text(length.title)
+                            .font(.system(size: 10, weight: .semibold))
+                        Text(length.wordRange)
+                            .font(.system(size: 8))
+                            .foregroundStyle(
+                                selectedLength == length ? .white.opacity(0.8) : AppTheme.textTertiary
+                            )
+                    }
+                    .foregroundStyle(selectedLength == length ? .white : AppTheme.textSecondary)
+                    .padding(.horizontal, 12)
+                    .padding(.vertical, 8)
+                    .frame(maxWidth: .infinity)
+                    .background(
+                        RoundedRectangle(cornerRadius: 8)
+                            .fill(
+                                selectedLength == length
+                                    ? AppTheme.accentGreen
+                                    : AppTheme.panelBackground
+                            )
+                    )
+                    .overlay(
+                        RoundedRectangle(cornerRadius: 8)
+                            .stroke(AppTheme.cardBorder, lineWidth: 1)
+                    )
+                }
+                .buttonStyle(.plain)
+            }
+        }
+    }
+}
+
+struct CommentTonePicker: View {
+    @Binding var selectedTone: PostTone
+
+    var body: some View {
+        ScrollView(.horizontal, showsIndicators: false) {
+            HStack(spacing: 6) {
+                ForEach(PostTone.allCases) { tone in
+                    Button {
+                        selectedTone = tone
+                    } label: {
+                        Text(tone.title)
+                            .font(.system(size: 10, weight: .semibold))
+                            .foregroundStyle(selectedTone == tone ? .white : AppTheme.textSecondary)
+                            .padding(.horizontal, 10)
+                            .padding(.vertical, 6)
+                            .background(
+                                Capsule()
+                                    .fill(
+                                        selectedTone == tone
+                                            ? AppTheme.accentGreen
+                                            : AppTheme.panelBackground
+                                    )
+                            )
+                            .overlay(
+                                Capsule()
+                                    .stroke(
+                                        selectedTone == tone
+                                            ? AppTheme.accentGreen.opacity(0.5)
+                                            : AppTheme.cardBorder,
+                                        lineWidth: 1
+                                    )
+                            )
+                    }
+                    .buttonStyle(.plain)
+                }
+            }
+        }
+    }
+}
+
+struct CommentVariantCountPicker: View {
+    @Binding var selectedCount: CommentVariantCount
+
+    var body: some View {
+        HStack(spacing: 6) {
+            ForEach(CommentVariantCount.allCases) { count in
+                Button {
+                    selectedCount = count
+                } label: {
+                    Text(count.label)
+                        .font(.system(size: 10, weight: .semibold))
+                        .foregroundStyle(selectedCount == count ? .white : AppTheme.textSecondary)
+                        .padding(.horizontal, 12)
+                        .padding(.vertical, 6)
+                        .background(
+                            Capsule()
+                                .fill(
+                                    selectedCount == count
+                                        ? AppTheme.accentGreen
+                                        : AppTheme.panelBackground
+                                )
+                        )
+                        .overlay(
+                            Capsule()
+                                .stroke(AppTheme.cardBorder, lineWidth: 1)
+                        )
+                }
+                .buttonStyle(.plain)
+            }
+        }
+    }
+}
+
+struct CommentToggleRow: View {
+    let title: String
+    let subtitle: String
+    @Binding var isOn: Bool
+
+    var body: some View {
+        Toggle(isOn: $isOn) {
+            VStack(alignment: .leading, spacing: 2) {
+                Text(title)
+                    .font(.system(size: 11, weight: .medium))
+                    .foregroundStyle(AppTheme.textPrimary)
+                Text(subtitle)
+                    .font(.system(size: 9))
+                    .foregroundStyle(AppTheme.textTertiary)
+            }
+        }
+        .toggleStyle(.switch)
+        .tint(AppTheme.accentGreen)
+    }
+}
+
+// MARK: - Reddit Etiquette Card
+
+struct RedditCommentEtiquetteCard: View {
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack(spacing: 6) {
+                Image(systemName: "info.circle.fill")
+                    .font(.system(size: 11))
+                    .foregroundStyle(AppTheme.accentGreen)
+                Text("Reddit Comment Tips")
+                    .font(.system(size: 11, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+            }
+
+            VStack(alignment: .leading, spacing: 4) {
+                etiquetteRow("10,000 character limit per comment")
+                etiquetteRow("Markdown: **bold**, *italic*, > quotes, `code`")
+                etiquetteRow("Stay on-topic for the subreddit")
+                etiquetteRow("Be civil — disagree with ideas, not people")
+                etiquetteRow("Edit with \"Edit:\" to clarify, don't bait-and-switch")
+            }
+        }
+        .padding(12)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(
+            RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
+                .fill(AppTheme.accentGreen.opacity(0.06))
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
+                        .stroke(AppTheme.accentGreen.opacity(0.2), lineWidth: 1)
+                )
+        )
+    }
+
+    private func etiquetteRow(_ text: String) -> some View {
+        HStack(alignment: .top, spacing: 6) {
+            Text("•")
+                .font(.system(size: 10))
+                .foregroundStyle(AppTheme.accentGreen)
+            Text(text)
+                .font(.system(size: 10))
+                .foregroundStyle(AppTheme.textSecondary)
+                .fixedSize(horizontal: false, vertical: true)
+        }
+    }
+}
+
+// MARK: - Thread Context Card
+
+struct RedditThreadContextCard: View {
+    let postTitle: String
+    let postBody: String
+    let parentComment: String
+    let commentType: CommentType
+    let formattedSubreddit: String
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            HStack(spacing: 6) {
+                Image(systemName: "doc.text")
+                    .font(.system(size: 11))
+                    .foregroundStyle(AppTheme.accentOrange)
+                Text("Thread Context")
+                    .font(.system(size: 11, weight: .semibold))
+                    .foregroundStyle(AppTheme.textPrimary)
+            }
+
+            VStack(alignment: .leading, spacing: 8) {
+                contextBlock(
+                    label: formattedSubreddit,
+                    content: postTitle.isEmpty ? "Post title" : postTitle,
+                    isTitle: true
+                )
+
+                if !postBody.isEmpty {
+                    contextBlock(label: "Post body", content: postBody, isTitle: false)
+                }
+
+                if commentType == .reply, !parentComment.isEmpty {
+                    contextBlock(label: "Replying to", content: parentComment, isTitle: false, isQuoted: true)
+                }
+            }
+        }
+        .padding(12)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(
+            RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
+                .fill(AppTheme.cardBackground)
+                .overlay(
+                    RoundedRectangle(cornerRadius: AppTheme.cornerRadiusSmall)
+                        .stroke(AppTheme.cardBorder, lineWidth: 1)
+                )
+        )
+    }
+
+    private func contextBlock(
+        label: String,
+        content: String,
+        isTitle: Bool,
+        isQuoted: Bool = false
+    ) -> some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text(label)
+                .font(.system(size: 9, weight: .semibold))
+                .foregroundStyle(AppTheme.textTertiary)
+
+            if isQuoted {
+                HStack(spacing: 0) {
+                    Rectangle()
+                        .fill(AppTheme.accentGreen.opacity(0.5))
+                        .frame(width: 3)
+                    Text(content)
+                        .font(.system(size: 10))
+                        .foregroundStyle(AppTheme.textSecondary)
+                        .lineLimit(3)
+                        .padding(.leading, 8)
+                }
+                .padding(.vertical, 4)
+            } else {
+                Text(content)
+                    .font(.system(size: isTitle ? 11 : 10, weight: isTitle ? .semibold : .regular))
+                    .foregroundStyle(isTitle ? AppTheme.textPrimary : AppTheme.textSecondary)
+                    .lineLimit(isTitle ? 2 : 3)
+            }
+        }
+    }
+}
+
+// MARK: - Comment Preview
+
+struct RedditCommentPreviewCard: View {
+    let commentBody: String
+    let formattedSubreddit: String
+    let commentType: CommentType
+    let parentComment: String
+    var isReply: Bool = false
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 0) {
+            if commentType == .reply, !parentComment.isEmpty {
+                parentCommentRow
+                    .padding(.leading, 14)
+            }
+
+            commentRow
+                .padding(.leading, commentType == .reply ? 28 : 0)
+        }
+        .background(AppTheme.panelBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cornerRadius)
+                .stroke(AppTheme.cardBorder, lineWidth: 1)
+        )
+    }
+
+    private var parentCommentRow: some View {
+        HStack(alignment: .top, spacing: 8) {
+            voteColumn
+
+            VStack(alignment: .leading, spacing: 6) {
+                commentHeader(username: "u/OriginalPoster")
+
+                Text(parentComment)
+                    .font(.system(size: 11))
+                    .foregroundStyle(AppTheme.textSecondary)
+                    .lineLimit(3)
+                    .fixedSize(horizontal: false, vertical: true)
+
+                commentActions
+            }
+        }
+        .padding(.horizontal, 14)
+        .padding(.vertical, 12)
+        .opacity(0.7)
+    }
+
+    private var commentRow: some View {
+        HStack(alignment: .top, spacing: 8) {
+            voteColumn
+
+            VStack(alignment: .leading, spacing: 6) {
+                commentHeader(username: "u/ReddoraUser")
+
+                if commentBody.isEmpty {
+                    Text("Your comment will appear here…")
+                        .font(.system(size: 12))
+                        .foregroundStyle(AppTheme.textTertiary)
+                        .italic()
+                } else {
+                    PostMarkdownText(text: commentBody, fontSize: 12, color: AppTheme.textPrimary)
+                }
+
+                commentActions
+            }
+        }
+        .padding(.horizontal, 14)
+        .padding(.vertical, 12)
+        .background(
+            commentType == .reply
+                ? AppTheme.accentGreen.opacity(0.04)
+                : Color.clear
+        )
+    }
+
+    private var voteColumn: some View {
+        VStack(spacing: 2) {
+            Image(systemName: "arrow.up")
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(AppTheme.textTertiary)
+            Text("1")
+                .font(.system(size: 10, weight: .semibold))
+                .foregroundStyle(AppTheme.textSecondary)
+            Image(systemName: "arrow.down")
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(AppTheme.textTertiary)
+        }
+        .frame(width: 24)
+    }
+
+    private func commentHeader(username: String) -> some View {
+        HStack(spacing: 4) {
+            Text(username)
+                .font(.system(size: 10, weight: .semibold))
+                .foregroundStyle(AppTheme.textPrimary)
+            Text("·")
+                .foregroundStyle(AppTheme.textTertiary)
+            Text("just now")
+                .font(.system(size: 9))
+                .foregroundStyle(AppTheme.textTertiary)
+            if commentType == .reply, username == "u/ReddoraUser" {
+                Text("·")
+                    .foregroundStyle(AppTheme.textTertiary)
+                Text(formattedSubreddit)
+                    .font(.system(size: 9, weight: .medium))
+                    .foregroundStyle(AppTheme.accentGreen)
+            }
+        }
+    }
+
+    private var commentActions: some View {
+        HStack(spacing: 12) {
+            actionButton("Reply", icon: "arrowshape.turn.up.left")
+            actionButton("Share", icon: "square.and.arrow.up")
+            actionButton("Award", icon: "seal")
+            actionButton("⋯", icon: nil)
+        }
+        .padding(.top, 2)
+    }
+
+    private func actionButton(_ title: String, icon: String?) -> some View {
+        HStack(spacing: 4) {
+            if let icon {
+                Image(systemName: icon)
+                    .font(.system(size: 9, weight: .semibold))
+            }
+            Text(title)
+                .font(.system(size: 9, weight: .semibold))
+        }
+        .foregroundStyle(AppTheme.textTertiary)
+    }
+}
+
+// MARK: - Variant Row
+
+struct CommentVariantRow: View {
+    let variant: CommentVariant
+    let isSelected: Bool
+    let onSelect: () -> Void
+    let onApply: () -> Void
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 8) {
+            HStack {
+                CommentScoreBadge(score: variant.score)
+
+                Text(variant.reasoning)
+                    .font(.system(size: 9))
+                    .foregroundStyle(AppTheme.textTertiary)
+                    .lineLimit(1)
+
+                Spacer()
+
+                HStack(spacing: 6) {
+                    Button("Select", action: onSelect)
+                        .font(.system(size: 9, weight: .semibold))
+                        .foregroundStyle(isSelected ? AppTheme.accentGreen : AppTheme.textSecondary)
+                        .buttonStyle(.plain)
+
+                    Button("Apply", action: onApply)
+                        .font(.system(size: 9, weight: .semibold))
+                        .foregroundStyle(.white)
+                        .padding(.horizontal, 8)
+                        .padding(.vertical, 4)
+                        .background(AppTheme.accentGreen.opacity(0.8))
+                        .clipShape(RoundedRectangle(cornerRadius: 5))
+                        .buttonStyle(.plain)
+                }
+            }
+
+            Text(variant.body)
+                .font(.system(size: 11))
+                .foregroundStyle(AppTheme.textSecondary)
+                .lineLimit(4)
+                .fixedSize(horizontal: false, vertical: true)
+        }
+        .padding(12)
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .background(
+            RoundedRectangle(cornerRadius: 8)
+                .fill(isSelected ? AppTheme.accentGreen.opacity(0.1) : AppTheme.panelBackground)
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: 8)
+                .stroke(
+                    isSelected ? AppTheme.accentGreen.opacity(0.4) : AppTheme.cardBorder,
+                    lineWidth: 1
+                )
+        )
+        .onTapGesture(perform: onSelect)
+    }
+}
+
+struct CommentScoreBadge: View {
+    let score: Int
+
+    var body: some View {
+        Text("\(score)")
+            .font(.system(size: 10, weight: .bold))
+            .foregroundStyle(scoreColor)
+            .padding(.horizontal, 6)
+            .padding(.vertical, 3)
+            .background(scoreColor.opacity(0.15))
+            .clipShape(RoundedRectangle(cornerRadius: 4))
+    }
+
+    private var scoreColor: Color {
+        switch score {
+        case 80...: AppTheme.accentGreen
+        case 60..<80: AppTheme.accentYellow
+        default: AppTheme.textTertiary
+        }
+    }
+}
+
+struct CommentWriterEmptyState: View {
+    let icon: String
+    let title: String
+    let subtitle: String
+
+    var body: some View {
+        VStack(spacing: 10) {
+            Image(systemName: icon)
+                .font(.system(size: 28))
+                .foregroundStyle(AppTheme.accentGreen.opacity(0.6))
+            Text(title)
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(AppTheme.textPrimary)
+            Text(subtitle)
+                .font(.system(size: 10))
+                .foregroundStyle(AppTheme.textSecondary)
+                .multilineTextAlignment(.center)
+        }
+        .frame(maxWidth: .infinity)
+        .padding(.vertical, 32)
+    }
+}
+
+struct CommentSecondaryButtonStyle: ButtonStyle {
+    func makeBody(configuration: Configuration) -> some View {
+        configuration.label
+            .foregroundStyle(AppTheme.textSecondary)
+            .padding(.horizontal, 12)
+            .padding(.vertical, 8)
+            .background(AppTheme.cardBackground.opacity(configuration.isPressed ? 0.6 : 1))
+            .clipShape(RoundedRectangle(cornerRadius: 8))
+            .overlay(
+                RoundedRectangle(cornerRadius: 8)
+                    .stroke(AppTheme.cardBorder, lineWidth: 1)
+            )
+    }
+}

+ 5 - 0
Reddit App/Views/FrontPageView.swift

@@ -4,6 +4,7 @@ struct FrontPageView: View {
     @State private var viewModel = FrontPageViewModel()
     @State private var postGeneratorViewModel = PostGeneratorViewModel()
     @State private var titleOptimizerViewModel = TitleOptimizerViewModel()
+    @State private var commentWriterViewModel = CommentWriterViewModel()
 
     var body: some View {
         HStack(spacing: 0) {
@@ -44,6 +45,10 @@ struct FrontPageView: View {
             }
         case .titleOptimizer:
             TitleOptimizerView(viewModel: titleOptimizerViewModel)
+        case .commentWriter:
+            CommentWriterView(viewModel: commentWriterViewModel) { url in
+                viewModel.openReddit(at: url)
+            }
         default:
             ToolPlaceholderView(item: item)
         }