From e8ee351a1f98970bb9169400c8ef15a5d9cacb56 Mon Sep 17 00:00:00 2001 From: Willian Ricardo Date: Tue, 9 Jun 2026 20:17:53 -0300 Subject: [PATCH 1/2] fix(#278): restore swipe-to-exit gesture in posts without breaking image galleries The interactive pop gesture was lost in post details because navigationBarBackButtonHidden(true) disables it, while posts opened from search results kept the gesture and conflicted with image galleries. MMWebView now restores the pop gesture even with a hidden back button and suspends it while an in-page image gallery (PhotoSwipe/Powerkit, fancybox, magnific) is open, detected via an injected MutationObserver that reports lightbox state to a script message handler. Co-Authored-By: Claude Fable 5 --- .../Webview/GalleryStateMessageHandler.swift | 18 ++++++ .../Webview/InteractivePopGesture.swift | 55 +++++++++++++++++++ .../Webview/MMWebView.swift | 6 ++ .../Webview/MMWebViewUserScripts.swift | 30 ++++++++++ 4 files changed, 109 insertions(+) create mode 100644 MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/GalleryStateMessageHandler.swift create mode 100644 MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/InteractivePopGesture.swift diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/GalleryStateMessageHandler.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/GalleryStateMessageHandler.swift new file mode 100644 index 00000000..7de908f9 --- /dev/null +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/GalleryStateMessageHandler.swift @@ -0,0 +1,18 @@ +import WebKit + +/// Receives gallery open/close notifications posted by +/// `MMWebViewUserScripts.galleryStateObserver`. +@MainActor +final class GalleryStateMessageHandler: NSObject, WKScriptMessageHandler { + static let handlerName = "mmGalleryState" + + var onGalleryStateChange: ((Bool) -> Void)? + + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard let isOpen = message.body as? Bool else { return } + onGalleryStateChange?(isOpen) + } +} diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/InteractivePopGesture.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/InteractivePopGesture.swift new file mode 100644 index 00000000..4ade982c --- /dev/null +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/InteractivePopGesture.swift @@ -0,0 +1,55 @@ +import SwiftUI +import UIKit + +public extension View { + /// Restores the interactive pop gesture (swipe from the leading edge to go back) + /// even when the system back button is hidden, and disables it on demand — + /// e.g. while an in-page image gallery is open and owns horizontal swipes. + func interactivePopGesture(enabled: Bool) -> some View { + background(InteractivePopGestureBridge(enabled: enabled)) + } +} + +private struct InteractivePopGestureBridge: UIViewControllerRepresentable { + let enabled: Bool + + func makeUIViewController(context: Context) -> BridgeViewController { + BridgeViewController() + } + + func updateUIViewController(_ controller: BridgeViewController, context: Context) { + controller.gestureEnabled = enabled + } +} + +private final class BridgeViewController: UIViewController, UIGestureRecognizerDelegate { + var gestureEnabled = true + + private weak var popGesture: UIGestureRecognizer? + private weak var originalDelegate: UIGestureRecognizerDelegate? + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + guard popGesture == nil, + let gesture = navigationController?.interactivePopGestureRecognizer else { return } + popGesture = gesture + originalDelegate = gesture.delegate + gesture.delegate = self + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + if let popGesture, popGesture.delegate === self { + popGesture.delegate = originalDelegate + } + popGesture = nil + originalDelegate = nil + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard let navigationController else { return false } + return gestureEnabled + && navigationController.viewControllers.count > 1 + && navigationController.transitionCoordinator == nil + } +} diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift index 0d2f84e7..d72e136a 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift @@ -11,6 +11,8 @@ public struct MMWebView: View { @State private var internalLinkURL: URL? @State private var page: WebPage? @State private var navigationDecider = MMNavigationDecider() + @State private var galleryStateHandler = GalleryStateMessageHandler() + @State private var isGalleryOpen = false @State private var reloadID = UUID() private let url: String? @@ -41,6 +43,7 @@ public struct MMWebView: View { page: $page, reloadTrigger: reloadID ) + .interactivePopGesture(enabled: !isGalleryOpen) .navigationDestination(item: $internalLinkURL) { url in MMWebView(url: url.absoluteString, dismissAction: dismissAction) .toolbar { @@ -102,6 +105,7 @@ private extension MMWebView { navigationDecider.onOpenComments = { [self] slug in commentsURL = slug } navigationDecider.onOpenInternalLink = { [self] url in internalLinkURL = url } + galleryStateHandler.onGalleryStateChange = { [self] isOpen in isGalleryOpen = isOpen } let configuration = makeConfiguration() let page: WebPage @@ -138,6 +142,8 @@ private extension MMWebView { contentController.addUserScript(MMWebViewUserScripts.disableGallery) contentController.addUserScript(MMWebViewUserScripts.disableNewGallery) contentController.addUserScript(MMWebViewUserScripts.removeBackToBlog) + contentController.addUserScript(MMWebViewUserScripts.galleryStateObserver) + contentController.add(galleryStateHandler, name: GalleryStateMessageHandler.handlerName) return configuration } diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebViewUserScripts.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebViewUserScripts.swift index 7618394a..6f5ec2c0 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebViewUserScripts.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebViewUserScripts.swift @@ -49,6 +49,36 @@ public enum MMWebViewUserScripts { ) } + @MainActor + static var galleryStateObserver: WKUserScript { + WKUserScript( + source: """ + (function() { + if (window.__mmGalleryObserver) { return; } + var selectors = '.pswp--open, .fancybox-container, #fancybox-overlay, .mfp-wrap'; + var lastState = false; + function check() { + var open = !!document.querySelector(selectors); + if (open !== lastState) { + lastState = open; + window.webkit.messageHandlers.mmGalleryState.postMessage(open); + } + } + var observer = new MutationObserver(check); + observer.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + attributeFilter: ['class', 'style'] + }); + window.__mmGalleryObserver = observer; + })(); + """, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true + ) + } + @MainActor public static var hideSiteHeader: WKUserScript { WKUserScript( From 09268c077426e78ec9c5c1d734681a513950f02e Mon Sep 17 00:00:00 2001 From: Willian Ricardo Date: Sun, 14 Jun 2026 12:24:54 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(#278):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20gallery=20state=20stuck,=20cache=20handler=20stalen?= =?UTF-8?q?ess,=20delegate=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - galleryStateObserver: init lastState to null and run check() once so every new document posts its initial state (fixes isGalleryOpen stuck true across reloads, incl. dark-mode switch and content-process crash reloads) - WebPageCache: cache GalleryStateMessageHandler alongside the page and rebind its closure on cache hits so messages reach the live view instance - reset isGalleryOpen next to colorScheme/removeAds reload triggers - InteractivePopGesture: extract restoreDelegate() and call it from isolated deinit so the system delegate is restored on teardown paths that skip viewWillDisappear Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Webview/InteractivePopGesture.swift | 8 ++++++++ .../MacMagazineUILibrary/Webview/MMWebView.swift | 7 ++++++- .../Webview/MMWebViewUserScripts.swift | 3 ++- .../MacMagazineUILibrary/Webview/WebPageCache.swift | 10 +++++++++- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/InteractivePopGesture.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/InteractivePopGesture.swift index 4ade982c..687e2f8a 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/InteractivePopGesture.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/InteractivePopGesture.swift @@ -39,6 +39,14 @@ private final class BridgeViewController: UIViewController, UIGestureRecognizerD override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + restoreDelegate() + } + + isolated deinit { + restoreDelegate() + } + + private func restoreDelegate() { if let popGesture, popGesture.delegate === self { popGesture.delegate = originalDelegate } diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift index d72e136a..7ddb7ee5 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebView.swift @@ -65,12 +65,14 @@ public struct MMWebView: View { } } .onChange(of: colorScheme) { + isGalleryOpen = false page?.reload() } .onChange(of: removeAds) { if let cacheKey { WebPageCache.shared.removePage(for: cacheKey) } + isGalleryOpen = false page = nil reloadID = UUID() } @@ -114,8 +116,11 @@ private extension MMWebView { page = WebPageCache.shared.page( for: cacheKey, configurationProvider: { configuration }, - navigationDecider: navigationDecider + navigationDecider: navigationDecider, + galleryStateHandler: galleryStateHandler ) + WebPageCache.shared.galleryHandler(for: cacheKey)? + .onGalleryStateChange = { [self] isOpen in isGalleryOpen = isOpen } } else { page = WebPage( configuration: configuration, diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebViewUserScripts.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebViewUserScripts.swift index 6f5ec2c0..49016531 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebViewUserScripts.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/MMWebViewUserScripts.swift @@ -56,7 +56,7 @@ public enum MMWebViewUserScripts { (function() { if (window.__mmGalleryObserver) { return; } var selectors = '.pswp--open, .fancybox-container, #fancybox-overlay, .mfp-wrap'; - var lastState = false; + var lastState = null; function check() { var open = !!document.querySelector(selectors); if (open !== lastState) { @@ -72,6 +72,7 @@ public enum MMWebViewUserScripts { attributeFilter: ['class', 'style'] }); window.__mmGalleryObserver = observer; + check(); })(); """, injectionTime: .atDocumentEnd, diff --git a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WebPageCache.swift b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WebPageCache.swift index e904c9b6..247d3749 100644 --- a/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WebPageCache.swift +++ b/MacMagazine/Features/MacMagazineUILibrary/Sources/MacMagazineUILibrary/Webview/WebPageCache.swift @@ -5,6 +5,7 @@ final class WebPageCache { static let shared = WebPageCache() private var cache: [String: WebPage] = [:] + private var galleryHandlers: [String: GalleryStateMessageHandler] = [:] private init() {} @@ -20,7 +21,8 @@ final class WebPageCache { func page( for key: String, configurationProvider: () -> WebPage.Configuration, - navigationDecider: some WebPage.NavigationDeciding + navigationDecider: some WebPage.NavigationDeciding, + galleryStateHandler: GalleryStateMessageHandler ) -> WebPage { if let existing = cache[key] { return existing @@ -30,14 +32,20 @@ final class WebPageCache { navigationDecider: navigationDecider ) cache[key] = page + galleryHandlers[key] = galleryStateHandler return page } + func galleryHandler(for key: String) -> GalleryStateMessageHandler? { + galleryHandlers[key] + } + func hasPage(for key: String) -> Bool { cache[key] != nil } func removePage(for key: String) { cache.removeValue(forKey: key) + galleryHandlers.removeValue(forKey: key) } }