Browse Source

Prefill poll options when opening Reddit submit from Post Generator.

Reddit's submit URL cannot pass poll fields directly, so queue draft poll data and inject it into the submit page after load.

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

+ 14 - 0
Reddit App/Managers/RedditSubmitPrefillManager.swift

@@ -0,0 +1,14 @@
+import Foundation
+
+@MainActor
+enum RedditSubmitPrefillManager {
+    private(set) static var pending: RedditSubmitPrefill?
+
+    static func setPending(from draft: PostDraft) {
+        pending = RedditSubmitPrefill.from(draft: draft)
+    }
+
+    static func clear() {
+        pending = nil
+    }
+}

+ 25 - 0
Reddit App/Models/RedditSubmitPrefill.swift

@@ -0,0 +1,25 @@
+import Foundation
+
+struct RedditSubmitPrefill: Equatable, Sendable {
+    let postType: RedditPostType
+    let body: String
+    let pollOptions: [String]
+    let pollDurationDays: Int
+
+    static func from(draft: PostDraft) -> RedditSubmitPrefill? {
+        guard draft.postType == .poll else { return nil }
+
+        let options = draft.pollOptions
+            .map { $0.text.trimmingCharacters(in: .whitespaces) }
+            .filter { !$0.isEmpty }
+
+        guard options.count >= 2 else { return nil }
+
+        return RedditSubmitPrefill(
+            postType: .poll,
+            body: draft.body.trimmingCharacters(in: .whitespaces),
+            pollOptions: options,
+            pollDurationDays: draft.pollDuration.rawValue
+        )
+    }
+}

+ 159 - 0
Reddit App/Utilities/RedditSubmitPrefillScript.swift

@@ -0,0 +1,159 @@
+import Foundation
+
+enum RedditSubmitPrefillScript {
+    static func make(prefill: RedditSubmitPrefill) -> String {
+        let payload: [String: Any] = [
+            "body": prefill.body,
+            "pollOptions": prefill.pollOptions,
+            "pollDurationDays": prefill.pollDurationDays,
+        ]
+
+        guard
+            let jsonData = try? JSONSerialization.data(withJSONObject: payload),
+            let jsonString = String(data: jsonData, encoding: .utf8)
+        else {
+            return ""
+        }
+
+        return """
+        (function() {
+          const config = \(jsonString);
+
+          const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
+          const queryAllDeep = (selector, root = document) => {
+            const results = [];
+            const visit = (node) => {
+              results.push(...Array.from(node.querySelectorAll(selector)));
+              Array.from(node.querySelectorAll('*')).forEach((element) => {
+                if (element.shadowRoot) {
+                  visit(element.shadowRoot);
+                }
+              });
+            };
+            visit(root);
+            return results;
+          };
+
+          const clickPollTab = () => {
+            const candidates = queryAllDeep('button, [role="tab"], [role="radio"], label, a, div[role="button"]');
+            const pollControl = candidates.find((element) => {
+              const text = (element.textContent || '').trim().toLowerCase();
+              const label = (element.getAttribute('aria-label') || '').trim().toLowerCase();
+              return text === 'poll' || label === 'poll' || label.includes('poll post');
+            });
+            if (!pollControl) {
+              return false;
+            }
+            pollControl.click();
+            return true;
+          };
+
+          const setFieldValue = (field, value) => {
+            if (!field || value == null) {
+              return;
+            }
+            if (field.tagName === 'TEXTAREA' || field.tagName === 'INPUT') {
+              field.value = value;
+            } else if (field.isContentEditable) {
+              field.textContent = value;
+            } else {
+              return;
+            }
+            field.dispatchEvent(new Event('input', { bubbles: true }));
+            field.dispatchEvent(new Event('change', { bubbles: true }));
+          };
+
+          const fillBody = () => {
+            if (!config.body) {
+              return;
+            }
+
+            const fields = queryAllDeep('textarea, div[contenteditable="true"], faceplate-textarea-input textarea, [data-testid*="body"] textarea');
+            const bodyField = fields.find((field) => {
+              const placeholder = (field.getAttribute('placeholder') || '').toLowerCase();
+              const aria = (field.getAttribute('aria-label') || '').toLowerCase();
+              return placeholder.includes('body') || placeholder.includes('text') || aria.includes('body') || aria.includes('optional');
+            }) || fields[0];
+
+            if (bodyField) {
+              setFieldValue(bodyField, config.body);
+            }
+          };
+
+          const pollOptionFields = () => {
+            return queryAllDeep('input[type="text"], textarea').filter((field) => {
+              const placeholder = (field.getAttribute('placeholder') || '').toLowerCase();
+              const aria = (field.getAttribute('aria-label') || '').toLowerCase();
+              const name = (field.getAttribute('name') || '').toLowerCase();
+              const testId = (field.getAttribute('data-testid') || '').toLowerCase();
+              return placeholder.includes('option')
+                || aria.includes('option')
+                || name.includes('poll')
+                || testId.includes('poll-option');
+            });
+          };
+
+          const clickAddOption = () => {
+            const buttons = queryAllDeep('button, [role="button"]');
+            const addButton = buttons.find((button) => /add.*option/i.test((button.textContent || '').trim()));
+            if (addButton) {
+              addButton.click();
+              return true;
+            }
+            return false;
+          };
+
+          const fillPollOptions = async () => {
+            for (let index = 0; index < config.pollOptions.length; index += 1) {
+              let fields = pollOptionFields();
+              let attempts = 0;
+
+              while (fields.length <= index && attempts < 4) {
+                if (!clickAddOption()) {
+                  break;
+                }
+                await sleep(200);
+                fields = pollOptionFields();
+                attempts += 1;
+              }
+
+              if (fields[index]) {
+                setFieldValue(fields[index], config.pollOptions[index]);
+              }
+            }
+          };
+
+          const selectDuration = () => {
+            const label = config.pollDurationDays === 1
+              ? '1 day'
+              : `${config.pollDurationDays} days`;
+
+            const controls = queryAllDeep('button, [role="radio"], label, option, span, div[role="button"]');
+            const match = controls.find((control) => {
+              const text = (control.textContent || '').trim().toLowerCase();
+              return text === label || text.startsWith(`${config.pollDurationDays} day`);
+            });
+
+            if (match) {
+              match.click();
+            }
+          };
+
+          const applyPollPrefill = async () => {
+            if (!clickPollTab()) {
+              return false;
+            }
+
+            await sleep(500);
+            fillBody();
+            await fillPollOptions();
+            selectDuration();
+            return true;
+          };
+
+          return applyPollPrefill();
+        })();
+        """
+    }
+}

+ 11 - 1
Reddit App/Utilities/RedditSubmitURLBuilder.swift

@@ -1,6 +1,11 @@
 import Foundation
 
 enum RedditSubmitURLBuilder {
+    static func isSubmitPage(_ url: URL) -> Bool {
+        guard let host = url.host?.lowercased(), host.contains("reddit.com") else { return false }
+        return url.path.range(of: #"/r/[^/]+/submit"#, options: .regularExpression) != nil
+    }
+
     static func submitURL(for draft: PostDraft) throws -> URL {
         try PostDraftValidator.validateForExport(draft)
 
@@ -25,12 +30,17 @@ enum RedditSubmitURLBuilder {
             queryItems.append(
                 URLQueryItem(name: "url", value: draft.linkURL.trimmingCharacters(in: .whitespaces))
             )
-        case .image, .video, .poll:
+        case .image, .video:
             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 .poll:
+            let body = draft.body.trimmingCharacters(in: .whitespaces)
+            if !body.isEmpty {
+                queryItems.append(URLQueryItem(name: "text", value: body))
+            }
         }
 
         components.queryItems = queryItems

+ 9 - 1
Reddit App/ViewModels/PostGeneratorViewModel.swift

@@ -143,10 +143,18 @@ final class PostGeneratorViewModel {
     func openInReddit(openInApp: (URL) -> Void) {
         do {
             let url = try RedditSubmitURLBuilder.submitURL(for: draft)
+            if draft.postType == .poll {
+                RedditSubmitPrefillManager.setPending(from: draft)
+            } else {
+                RedditSubmitPrefillManager.clear()
+            }
             openInApp(url)
-            successMessage = "Opened Reddit submit page in the app."
+            successMessage = draft.postType == .poll
+                ? "Opened Reddit submit page. Poll options will be filled automatically."
+                : "Opened Reddit submit page in the app."
             errorMessage = nil
         } catch {
+            RedditSubmitPrefillManager.clear()
             errorMessage = error.localizedDescription
             successMessage = nil
         }

+ 44 - 0
Reddit App/Views/RedditWebView.swift

@@ -34,9 +34,53 @@ struct RedditWebView: NSViewRepresentable {
         weak var container: RedditWebViewContainer?
         var lastReloadTrigger = -1
         var lastLoadedURL: URL?
+        private var prefillWorkItem: DispatchWorkItem?
 
         func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
             webView.evaluateJavaScript(RedditPostBoundaryStyle.injectionScript, completionHandler: nil)
+            scheduleSubmitPrefillIfNeeded(on: webView)
+        }
+
+        private func scheduleSubmitPrefillIfNeeded(on webView: WKWebView) {
+            guard
+                let url = webView.url,
+                RedditSubmitURLBuilder.isSubmitPage(url),
+                let prefill = RedditSubmitPrefillManager.pending
+            else { return }
+
+            prefillWorkItem?.cancel()
+
+            let script = RedditSubmitPrefillScript.make(prefill: prefill)
+            guard !script.isEmpty else {
+                RedditSubmitPrefillManager.clear()
+                return
+            }
+
+            attemptSubmitPrefill(on: webView, script: script, remainingAttempts: 6)
+        }
+
+        private func attemptSubmitPrefill(on webView: WKWebView, script: String, remainingAttempts: Int) {
+            guard remainingAttempts > 0, RedditSubmitPrefillManager.pending != nil else { return }
+
+            webView.evaluateJavaScript(script) { result, _ in
+                let succeeded = (result as? Bool) == true
+                if succeeded {
+                    RedditSubmitPrefillManager.clear()
+                    return
+                }
+
+                guard remainingAttempts > 1 else {
+                    RedditSubmitPrefillManager.clear()
+                    return
+                }
+
+                let workItem = DispatchWorkItem { [weak self, weak webView] in
+                    guard let self, let webView else { return }
+                    self.attemptSubmitPrefill(on: webView, script: script, remainingAttempts: remainingAttempts - 1)
+                }
+                self.prefillWorkItem = workItem
+                DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: workItem)
+            }
         }
 
         func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {