Selaa lähdekoodia

Fix embedded Reddit header interactions, layout, and scrolling.

Restore reliable Log In and menu clicks, keep the search bar pinned while feeds scroll, and make fullscreen use the full window with balanced top spacing.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 kuukausi sitten
vanhempi
sitoutus
dd7ba42e47

+ 4 - 2
Reddit App/Managers/RedditLoginScriptBridge.swift

@@ -10,11 +10,13 @@ final class RedditLoginScriptBridge: NSObject, WKScriptMessageHandler {
         super.init()
     }
 
-    func userContentController(
+    nonisolated func userContentController(
         _ userContentController: WKUserContentController,
         didReceive message: WKScriptMessage
     ) {
         guard message.name == RedditLoginBridgeStyle.messageHandlerName else { return }
-        onLoginRequested?()
+        Task { @MainActor in
+            onLoginRequested?()
+        }
     }
 }

+ 1 - 1
Reddit App/Managers/RedditWebViewManager.swift

@@ -29,7 +29,7 @@ enum RedditWebViewManager {
 
         let chromeScript = WKUserScript(
             source: RedditHomeChromeStyle.injectionScript,
-            injectionTime: .atDocumentEnd,
+            injectionTime: .atDocumentStart,
             forMainFrameOnly: true
         )
         configuration.userContentController.addUserScript(chromeScript)

+ 5 - 2
Reddit App/Utilities/AppWindow.swift

@@ -6,6 +6,9 @@ enum AppWindow {
     static let minWidth: CGFloat = width
     static let minHeight: CGFloat = height
 
-    /// Extra top inset when the window is in macOS full screen (hidden title bar has no safe area there).
-    static let fullScreenTopInset: CGFloat = 28
+    /// Extra top inset for the sidebar when the window is in macOS full screen.
+    static let fullScreenTopInset: CGFloat = 16
+
+    /// Small top breathing room for the embedded Reddit panel.
+    static let redditTopInset: CGFloat = 30
 }

+ 191 - 2
Reddit App/Utilities/RedditHomeChromeStyle.swift

@@ -1,20 +1,48 @@
 import Foundation
 
 enum RedditHomeChromeStyle {
-    /// Keeps Reddit header separators flush with the in-app sidebar divider.
+    /// Keeps Reddit header separators flush with the in-app sidebar divider and
+    /// fixes Log In / menu hit targets that get covered by borders or feed chrome.
     static let injectionScript = """
     (function() {
       const STYLE_ID = 'reddora-home-chrome-style';
-      if (document.getElementById(STYLE_ID)) return;
+      if (window.__reddoraHomeChromeInstalled) return;
+      window.__reddoraHomeChromeInstalled = true;
+
+      const HEADER_ACTION_IDS = ['login-button', 'expand-user-drawer-button'];
+      const DECORATIVE_SELECTORS = [
+        'faceplate-border',
+        'hr',
+        '[role="separator"]',
+        'shreddit-overlay-display'
+      ].join(', ');
 
       const style = document.createElement('style');
       style.id = STYLE_ID;
       style.textContent = `
+        html,
+        body,
+        shreddit-app {
+          margin-top: 0 !important;
+          padding-top: 0 !important;
+        }
+
         header,
         header > * {
           margin-left: 0 !important;
         }
 
+        header {
+          position: sticky !important;
+          top: 0 !important;
+          z-index: 300 !important;
+          align-items: center !important;
+          isolation: isolate !important;
+          margin-top: 0 !important;
+          padding-top: 0 !important;
+          background: var(--shreddit-content-background, #ffffff) !important;
+        }
+
         header faceplate-border,
         header [role="separator"],
         header hr {
@@ -22,10 +50,171 @@ enum RedditHomeChromeStyle {
           max-width: none !important;
           margin-left: 0 !important;
           margin-right: 0 !important;
+          pointer-events: none !important;
+          z-index: 0 !important;
+        }
+
+        header faceplate-search-input,
+        header #SearchDropdown,
+        header [data-testid="search-input-container"] {
+          flex: 1 1 auto !important;
+          min-width: 0 !important;
+          max-width: 100% !important;
+          overflow: hidden !important;
+          align-self: center !important;
+          pointer-events: auto !important;
+        }
+
+        /* Wrappers around header actions often extend past the visible button. */
+        faceplate-tracker:has(#login-button),
+        faceplate-tracker:has(#expand-user-drawer-button),
+        rpl-tooltip:has(#login-button),
+        rpl-tooltip:has(#expand-user-drawer-button),
+        activate-feature:has(#expand-user-drawer-button) {
+          display: contents !important;
+        }
+
+        #login-button,
+        #expand-user-drawer-button {
+          position: relative !important;
+          z-index: 301 !important;
+          pointer-events: auto !important;
+          flex-shrink: 0 !important;
+          align-self: center !important;
+          touch-action: manipulation !important;
+        }
+
+        shreddit-overlay-display {
+          pointer-events: none !important;
+        }
+
+        shreddit-overlay-display:has(
+          shreddit-signup-drawer,
+          auth-flow-modal,
+          input[type="password"]
+        ) {
+          pointer-events: auto !important;
+        }
+
+        /* Feed sort row sits below the header; keep it from overlapping action buttons. */
+        shreddit-sort-dropdown,
+        [data-testid="sort-dropdown"],
+        [data-testid="sort-posts-dropdown"],
+        shreddit-feed-sort-dropdown {
+          position: relative !important;
+          z-index: 1 !important;
         }
       `;
 
       (document.head || document.documentElement).appendChild(style);
+
+      function isDecorative(node) {
+        if (!(node instanceof Element)) return false;
+        return node.matches(DECORATIVE_SELECTORS)
+          || (node.closest('header') && node.matches('faceplate-border, hr, [role="separator"]'));
+      }
+
+      function isInteractive(node) {
+        if (!(node instanceof Element)) return false;
+        return node.matches(
+          'button, a, [role="button"], input, select, textarea, label, faceplate-button'
+        );
+      }
+
+      function clearBlockersForButton(button) {
+        if (!(button instanceof Element)) return;
+        const rect = button.getBoundingClientRect();
+        if (rect.width < 1 || rect.height < 1) return;
+
+        const sampleYs = [
+          rect.top + rect.height * 0.25,
+          rect.top + rect.height * 0.5,
+          rect.top + rect.height * 0.75,
+          rect.bottom - 1
+        ];
+        const x = rect.left + rect.width * 0.5;
+
+        sampleYs.forEach(function(y) {
+          const stack = document.elementsFromPoint(x, y);
+          for (let i = 0; i < stack.length; i++) {
+            const el = stack[i];
+            if (el === button || button.contains(el)) break;
+            if (isInteractive(el)) break;
+            if (isDecorative(el) || !el.closest('header')) {
+              el.style.setProperty('pointer-events', 'none', 'important');
+              el.dataset.reddoraPointerFix = '1';
+            }
+          }
+        });
+      }
+
+      function pinHeader() {
+        document.querySelectorAll('header, reddit-header-large, faceplate-header').forEach(function(node) {
+          if (!(node instanceof HTMLElement)) return;
+          node.style.setProperty('position', 'sticky', 'important');
+          node.style.setProperty('top', '0', 'important');
+          node.style.setProperty('z-index', '300', 'important');
+          node.style.setProperty(
+            'background',
+            'var(--shreddit-content-background, #ffffff)',
+            'important'
+          );
+        });
+      }
+
+      function patchHeaderHitTargets() {
+        pinHeader();
+
+        HEADER_ACTION_IDS.forEach(function(id) {
+          const button = document.getElementById(id);
+          if (!button) return;
+          clearBlockersForButton(button);
+          button.style.setProperty('position', 'relative', 'important');
+          button.style.setProperty('z-index', '301', 'important');
+        });
+
+        document.querySelectorAll('header faceplate-border, header hr, header [role="separator"]').forEach(function(el) {
+          el.style.setProperty('pointer-events', 'none', 'important');
+        });
+      }
+
+      let patchTimer = null;
+      function schedulePatch() {
+        if (patchTimer) return;
+        patchTimer = setTimeout(function() {
+          patchTimer = null;
+          patchHeaderHitTargets();
+        }, 50);
+      }
+
+      patchHeaderHitTargets();
+      [0, 100, 300, 750, 1500, 3000].forEach(function(delay) {
+        setTimeout(patchHeaderHitTargets, delay);
+      });
+
+      const observer = new MutationObserver(schedulePatch);
+      observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
+
+      window.addEventListener('resize', schedulePatch, { passive: true });
+      window.addEventListener('scroll', schedulePatch, { passive: true });
+
+      function forwardClicksWithinButtonBounds(event) {
+        if (event.type !== 'pointerdown') return;
+
+        const menuButton = document.getElementById('expand-user-drawer-button');
+        if (!menuButton || menuButton.contains(event.target)) return;
+
+        const rect = menuButton.getBoundingClientRect();
+        const x = event.clientX;
+        const y = event.clientY;
+        if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) return;
+
+        event.preventDefault();
+        event.stopImmediatePropagation();
+        menuButton.click();
+      }
+
+      document.addEventListener('pointerdown', forwardClicksWithinButtonBounds, true);
     })();
     """
 }

+ 128 - 34
Reddit App/Utilities/RedditLoginBridgeStyle.swift

@@ -11,41 +11,29 @@ enum RedditLoginBridgeStyle {
       window.__reddoraLoginBridgeInstalled = true;
 
       const LOGIN_PATTERN = /log\\s*in/i;
-      const HEADER_SELECTORS = 'header, [role="banner"], #header, faceplate-header, nav';
+      const HEADER_SELECTORS = 'header, [role="banner"], #header, faceplate-header, nav, shreddit-header-action';
+      const CLICKABLE_SELECTORS = 'button, a, [role="button"], faceplate-tracker, faceplate-button';
       const AUTH_MODAL_SELECTORS = [
         'auth-flow-modal',
         '[data-testid="login-modal"]',
         '#credentials-login-popup',
+        'shreddit-signup-drawer',
         'faceplate-modal[slot="login"]',
         'faceplate-modal[open]'
       ].join(', ');
 
+      const boundLoginTriggers = new WeakSet();
+
       function nodeLabel(node) {
         if (!(node instanceof Element)) return '';
         return (
           (node.textContent || '') + ' ' +
           (node.getAttribute('aria-label') || '') + ' ' +
-          (node.getAttribute('title') || '')
+          (node.getAttribute('title') || '') + ' ' +
+          (node.getAttribute('href') || '')
         ).trim();
       }
 
-      function isHeaderLoginTrigger(node) {
-        if (!(node instanceof Element)) return false;
-
-        const clickable = node.matches('button, a, [role="button"], faceplate-tracker, faceplate-button')
-          ? node
-          : node.closest('button, a, [role="button"], faceplate-tracker, faceplate-button');
-        if (!clickable) return false;
-
-        if (clickable.id === 'login-button') return true;
-        if (clickable.getAttribute('data-testid') === 'login-button') return true;
-
-        const label = nodeLabel(clickable);
-        if (!LOGIN_PATTERN.test(label)) return false;
-
-        return !!clickable.closest(HEADER_SELECTORS);
-      }
-
       function isLoginPage() {
         const host = window.location.hostname.toLowerCase();
         return (host === 'www.reddit.com' || host === 'reddit.com' || host === 'new.reddit.com')
@@ -69,11 +57,95 @@ enum RedditLoginBridgeStyle {
         }
       }
 
+      function interceptLoginEvent(event) {
+        if (isLoginPage()) return false;
+        if (eventMatchesLoginTrigger(event)) {
+          event.preventDefault();
+          event.stopImmediatePropagation();
+          window.__reddoraCloseRedditAuthModal && window.__reddoraCloseRedditAuthModal();
+          requestInAppLogin();
+          return true;
+        }
+
+        const loginButton = document.getElementById('login-button');
+        if (loginButton && eventMatchesButtonBounds(event, loginButton)) {
+          event.preventDefault();
+          event.stopImmediatePropagation();
+          window.__reddoraCloseRedditAuthModal && window.__reddoraCloseRedditAuthModal();
+          requestInAppLogin();
+          return true;
+        }
+
+        return false;
+      }
+
+      function eventMatchesButtonBounds(event, button) {
+        if (!(button instanceof Element)) return false;
+        if (!(event instanceof MouseEvent) && event.type !== 'pointerdown' && event.type !== 'mousedown') {
+          return false;
+        }
+        if (button.contains(event.target)) return false;
+
+        const rect = button.getBoundingClientRect();
+        const x = event.clientX;
+        const y = event.clientY;
+        return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
+      }
+
+      function eventMatchesLoginTrigger(event) {
+        const path = event.composedPath ? event.composedPath() : [event.target];
+        for (let i = 0; i < path.length; i++) {
+          if (resolveLoginTrigger(path[i])) return true;
+        }
+        return false;
+      }
+
+      function resolveLoginTrigger(node) {
+        if (!(node instanceof Element)) return null;
+
+        if (node.id === 'login-button') return node;
+        if (node.getAttribute('data-testid') === 'login-button') return node;
+
+        const clickable = node.matches(CLICKABLE_SELECTORS)
+          ? node
+          : node.closest(CLICKABLE_SELECTORS);
+        if (!clickable) return null;
+
+        if (clickable.id === 'login-button') return clickable;
+        if (clickable.getAttribute('data-testid') === 'login-button') return clickable;
+
+        const href = (clickable.getAttribute('href') || '').toLowerCase();
+        if (href.includes('/login')) return clickable;
+
+        const label = nodeLabel(clickable);
+        if (!LOGIN_PATTERN.test(label)) return null;
+
+        if (clickable.closest(HEADER_SELECTORS)) return clickable;
+
+        const rect = clickable.getBoundingClientRect();
+        if (rect.top >= 0 && rect.top < 96 && rect.width > 0 && rect.height > 0) {
+          return clickable;
+        }
+
+        return null;
+      }
+
+      function isLoginTriggerElement(node) {
+        return resolveLoginTrigger(node) !== null;
+      }
+
       function isAuthModal(element) {
         if (!(element instanceof Element)) return false;
-        if (element.matches('auth-flow-modal, [data-testid="login-modal"], #credentials-login-popup')) {
+        if (element.matches(
+          'auth-flow-modal, [data-testid="login-modal"], #credentials-login-popup, shreddit-signup-drawer'
+        )) {
           return true;
         }
+        if (element.matches('shreddit-overlay-display')) {
+          return !!element.querySelector(
+            'shreddit-signup-drawer, auth-flow-modal, input[type="password"]'
+          );
+        }
         if (element.matches('faceplate-modal')) {
           const label = nodeLabel(element);
           return LOGIN_PATTERN.test(label) || !!element.querySelector('input[type="password"]');
@@ -95,6 +167,28 @@ enum RedditLoginBridgeStyle {
         });
       };
 
+      function bindDirectLoginListeners(root) {
+        function walk(node) {
+          if (!(node instanceof Element)) return;
+
+          const trigger = resolveLoginTrigger(node);
+          if (trigger && !boundLoginTriggers.has(trigger)) {
+            boundLoginTriggers.add(trigger);
+            ['pointerdown', 'mousedown', 'click'].forEach(function(type) {
+              trigger.addEventListener(type, interceptLoginEvent, true);
+            });
+          }
+
+          if (node.shadowRoot) walk(node.shadowRoot);
+          const children = node.children;
+          for (let i = 0; i < children.length; i++) {
+            walk(children[i]);
+          }
+        }
+
+        walk(root || document.documentElement);
+      }
+
       function installAuthModalBlocker() {
         if (isLoginPage()) return;
 
@@ -105,7 +199,8 @@ enum RedditLoginBridgeStyle {
           style.textContent = [
             'auth-flow-modal,',
             '[data-testid="login-modal"],',
-            '#credentials-login-popup {',
+            '#credentials-login-popup,',
+            'shreddit-signup-drawer {',
             '  display: none !important;',
             '  pointer-events: none !important;',
             '}'
@@ -115,6 +210,8 @@ enum RedditLoginBridgeStyle {
 
         const observer = new MutationObserver(function() {
           if (isLoginPage()) return;
+          bindDirectLoginListeners(document.documentElement);
+
           let foundAuthModal = false;
           document.querySelectorAll(AUTH_MODAL_SELECTORS).forEach(function(element) {
             if (!isAuthModal(element)) return;
@@ -129,21 +226,18 @@ enum RedditLoginBridgeStyle {
         observer.observe(document.documentElement, { childList: true, subtree: true });
       }
 
-      document.addEventListener('click', function(event) {
-        if (isLoginPage()) return;
-        const path = event.composedPath();
-        for (let i = 0; i < path.length; i++) {
-          if (!isHeaderLoginTrigger(path[i])) continue;
-
-          event.preventDefault();
-          event.stopImmediatePropagation();
-          window.__reddoraCloseRedditAuthModal();
-          requestInAppLogin();
-          return;
-        }
-      }, true);
+      ['pointerdown', 'mousedown', 'click'].forEach(function(type) {
+        document.addEventListener(type, interceptLoginEvent, true);
+      });
 
+      bindDirectLoginListeners(document.documentElement);
       installAuthModalBlocker();
+
+      [250, 750, 1500, 3000].forEach(function(delay) {
+        setTimeout(function() {
+          bindDirectLoginListeners(document.documentElement);
+        }, delay);
+      });
     })();
     """
 

+ 21 - 7
Reddit App/Views/Components/WindowFullScreenObserver.swift

@@ -88,22 +88,36 @@ final class WindowObservationView: NSView {
         super.viewDidMoveToWindow()
         onWindowChange?(window)
     }
+
+    override func hitTest(_ point: NSPoint) -> NSView? {
+        nil
+    }
 }
 
-private struct FullScreenTopInsetModifier: ViewModifier {
+private struct FullScreenLayoutModifier: ViewModifier {
     @State private var isFullScreen = false
 
     func body(content: Content) -> some View {
-        content
-            .padding(.top, isFullScreen ? AppWindow.fullScreenTopInset : 0)
-            .background {
-                WindowFullScreenObserver(isFullScreen: $isFullScreen)
+        VStack(spacing: 0) {
+            if isFullScreen {
+                AppTheme.sidebarBackground
+                    .frame(height: AppWindow.fullScreenTopInset)
+                    .frame(maxWidth: .infinity)
+                    .allowsHitTesting(false)
             }
+
+            content
+                .frame(maxWidth: .infinity, maxHeight: .infinity)
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+        .background {
+            WindowFullScreenObserver(isFullScreen: $isFullScreen)
+        }
     }
 }
 
 extension View {
-    func fullScreenTopInsetWhenNeeded() -> some View {
-        modifier(FullScreenTopInsetModifier())
+    func fullScreenLayoutWhenNeeded() -> some View {
+        modifier(FullScreenLayoutModifier())
     }
 }

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

@@ -29,7 +29,7 @@ struct FrontPageView: View {
                 mainContent
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
             }
-            .fullScreenTopInsetWhenNeeded()
+            .fullScreenLayoutWhenNeeded()
             .environment(\.requirePremiumAccess, {
                 if subscriptions.hasPremiumAccess { return true }
                 viewModel.showPaywall()
@@ -47,6 +47,8 @@ struct FrontPageView: View {
             }
         }
         .animation(.easeInOut(duration: 0.2), value: viewModel.isPaywallPresented)
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+        .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
         .onChange(of: subscriptions.hasPremiumAccess) { hasPremium in
             if hasPremium {
                 viewModel.dismissPaywall()
@@ -70,7 +72,6 @@ struct FrontPageView: View {
                 configureRedditAccessForPremiumStatus()
             }
         }
-        .frame(minWidth: AppWindow.minWidth, minHeight: AppWindow.minHeight)
         .background(AppTheme.background)
         .alert("Enjoying \(AppLinks.appName)?", isPresented: Binding(
             get: { appRating.showRatingPrompt },
@@ -112,6 +113,13 @@ struct FrontPageView: View {
     @ViewBuilder
     private var mainContent: some View {
         VStack(spacing: 0) {
+            if viewModel.isRedditActive {
+                AppTheme.sidebarBackground
+                    .frame(height: AppWindow.redditTopInset)
+                    .frame(maxWidth: .infinity)
+                    .allowsHitTesting(false)
+            }
+
             if viewModel.isRedditActive, viewModel.redditCanGoBack {
                 RedditWebBackBar(
                     canGoBack: true,
@@ -145,6 +153,8 @@ struct FrontPageView: View {
             }
         }
         .background(viewModel.isRedditActive ? AppTheme.sidebarBackground : AppTheme.background)
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+        .ignoresSafeArea(.container, edges: viewModel.isRedditActive ? .top : [])
     }
 
     @ViewBuilder

+ 22 - 10
Reddit App/Views/RedditWebView.swift

@@ -244,19 +244,27 @@ struct RedditWebView: NSViewRepresentable {
         }
 
         private func beginCredentialLogin() {
-            guard let container else { return }
+            let performLogin = { [weak self] in
+                guard let self, let container = self.container else { return }
 
-            if let url = container.webView.url, RedditWebAuthHelper.isCredentialLoginPage(url) {
-                return
-            }
+                if let url = container.webView.url, RedditWebAuthHelper.isCredentialLoginPage(url) {
+                    return
+                }
 
-            RedditOAuthPanelController.shared.dismiss()
+                RedditOAuthPanelController.shared.dismiss()
 
-            container.webView.evaluateJavaScript(
-                RedditLoginBridgeStyle.closeAuthModalScript,
-                completionHandler: nil
-            )
-            container.load(url: RedditWebAuthHelper.redditLoginURL)
+                container.webView.evaluateJavaScript(
+                    RedditLoginBridgeStyle.closeAuthModalScript,
+                    completionHandler: nil
+                )
+                container.load(url: RedditWebAuthHelper.redditLoginURL)
+            }
+
+            if Thread.isMainThread {
+                performLogin()
+            } else {
+                DispatchQueue.main.async(execute: performLogin)
+            }
         }
 
         private func beginOAuthSignIn(url: URL) {
@@ -336,6 +344,10 @@ final class RedditWebViewContainer: NSView {
         tryLoadIfReady()
     }
 
+    override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
+        true
+    }
+
     private func tryLoadIfReady() {
         guard let url = pendingURL else { return }
         guard bounds.width > 1, bounds.height > 1 else { return }

+ 1 - 0
Reddit App/Views/SidebarView.swift

@@ -13,6 +13,7 @@ struct SidebarView: View {
             upgradeCard
         }
         .frame(width: AppTheme.sidebarWidth)
+        .frame(maxHeight: .infinity, alignment: .top)
         .background(AppTheme.sidebarBackground)
     }