import SwiftUI import WebKit // MARK: - Reading progress persistence enum ReadingProgress { private static let key = "readingProgress" static func get(url: String) -> Double { (UserDefaults.standard.dictionary(forKey: key) as? [String: Double])?[url] ?? 0 } static func set(url: String, progress: Double) { var store = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:] if progress >= 0.99 { store.removeValue(forKey: url) // fully read — clean up } else { store[url] = progress } UserDefaults.standard.set(store, forKey: key) } static func all() -> [String: Double] { (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:] } } // MARK: - Indeterminate page load bar private struct PageLoadBar: View { @State private var phase: CGFloat = 0 var body: some View { GeometryReader { geo in let barWidth = geo.size.width * 0.38 Capsule() .fill(.blue.opacity(0.75)) .frame(width: barWidth, height: 3) .offset(x: (phase * (geo.size.width + barWidth)) - barWidth) } .frame(height: 3) .clipped() .onAppear { withAnimation(.easeInOut(duration: 1.3).repeatForever(autoreverses: false)) { phase = 1 } } } } // MARK: - BrowserState @Observable final class BrowserState: NSObject, WKNavigationDelegate, WKUIDelegate { let webView: WKWebView var isLoading = false var canGoBack = false var canGoForward = false var pageTitle = "" var currentURL: URL? var readingProgress: Double = 0 // Weak wrapper breaks WKUserContentController's strong retain of the handler private final class ScrollHandler: NSObject, WKScriptMessageHandler { weak var state: BrowserState? init(_ state: BrowserState) { self.state = state } func userContentController(_ c: WKUserContentController, didReceive msg: WKScriptMessage) { if let p = msg.body as? Double { state?.readingProgress = p } } } private var scrollHandler: ScrollHandler? init(url: URL) { let config = WKWebViewConfiguration() config.allowsInlineMediaPlayback = true webView = WKWebView(frame: .zero, configuration: config) super.init() let handler = ScrollHandler(self) scrollHandler = handler webView.configuration.userContentController.add(handler, name: "scrollProgress") webView.navigationDelegate = self webView.uiDelegate = self webView.load(URLRequest(url: url)) } deinit { webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress") } func webView(_ webView: WKWebView, didStartProvisionalNavigation _: WKNavigation!) { isLoading = true } func webView(_ webView: WKWebView, didCommit _: WKNavigation!) { currentURL = webView.url canGoBack = webView.canGoBack canGoForward = webView.canGoForward } func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { isLoading = false canGoBack = webView.canGoBack canGoForward = webView.canGoForward pageTitle = webView.title ?? "" currentURL = webView.url // Restore persisted progress, then start tracking scroll if let urlStr = webView.url?.absoluteString { let saved = ReadingProgress.get(url: urlStr) if saved > 0 { let pct = saved * 100 webView.evaluateJavaScript("window.scrollTo(0, (document.body.scrollHeight - window.innerHeight) * \(pct) / 100)", completionHandler: nil) } } webView.evaluateJavaScript(scrollTrackingJS, completionHandler: nil) } func webView(_: WKWebView, didFail _: WKNavigation!, withError _: Error) { isLoading = false } func webView(_: WKWebView, didFailProvisionalNavigation _: WKNavigation!, withError _: Error) { isLoading = false } // target="_blank" and window.open() — load in the same webview instead of opening Safari func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? { if let url = action.request.url { webView.load(URLRequest(url: url)) } return nil } func enterReaderMode(completion: @escaping () -> Void) { webView.evaluateJavaScript(readerModeJS) { _, _ in DispatchQueue.main.async { completion() self.webView.evaluateJavaScript(self.scrollTrackingJS, completionHandler: nil) } } } func exitReaderMode() { webView.reload() } private var scrollTrackingJS: String { """ (function(){ function report(){ var h=document.body.scrollHeight-window.innerHeight; var p=h>0?window.scrollY/h:0; window.webkit.messageHandlers.scrollProgress.postMessage(Math.min(1,Math.max(0,p))); } window.removeEventListener('scroll',window._marksScrollFn); window._marksScrollFn=report; window.addEventListener('scroll',report,{passive:true}); report(); })(); """ } private var readerModeJS: String { """ (function() { var title = document.title; var selectors = ['article','[role="article"]','main','[role="main"]', '.post-content','.article-content','.entry-content', '.post-body','.article-body','.story-body','.content','#content']; var content = null; for (var i = 0; i < selectors.length; i++) { var el = document.querySelector(selectors[i]); if (el && (el.innerText||'').trim().length > 300) { content = el; break; } } if (!content) { var best = { el: null, len: 0 }; Array.from(document.querySelectorAll('div')).forEach(function(d) { var len = Array.from(d.querySelectorAll('p')).reduce(function(a,p){return a+(p.innerText||'').length;},0); if (len > best.len) best = { el: d, len: len }; }); content = best.el || document.body; } var clone = content.cloneNode(true); ['script','style','nav','header','footer','aside'].forEach(function(tag){ clone.querySelectorAll(tag).forEach(function(el){ el.remove(); }); }); var html = clone.innerHTML; var css = [ 'body{font-family:-apple-system,Georgia,serif;max-width:680px;margin:0 auto;', 'padding:20px 20px 60px;line-height:1.75;font-size:18px;background:#fafafa;color:#1c1c1e}', 'h1{font-size:1.6em;line-height:1.3;margin:0 0 .5em}', 'h2,h3,h4{line-height:1.35;margin:1.5em 0 .5em}p{margin:0 0 1em}', 'img{max-width:100%;height:auto;border-radius:8px;margin:8px 0}', 'a{color:#007aff;text-decoration:none}', 'blockquote{border-left:3px solid #ccc;margin:1em 0;padding:0 1em;color:#555}', 'code,pre{font-family:ui-monospace,monospace;font-size:.9em;background:#f0f0f0;border-radius:4px;padding:2px 4px}', 'pre code{padding:0;background:none}pre{padding:12px;overflow-x:auto}', '@media(prefers-color-scheme:dark){body{background:#1c1c1e;color:#e5e5e7}', 'blockquote{border-color:#555;color:#aaa}code,pre{background:#2c2c2e}a{color:#0a84ff}}' ].join(''); document.open(); document.write('' + '' + '' + '' + title + '' + '

' + title + '

' + html + ''); document.close(); return 'ok'; })() """ } } // MARK: - WebView representable private struct WebViewRepresentable: UIViewRepresentable { let webView: WKWebView func makeUIView(context: Context) -> WKWebView { webView } func updateUIView(_ uiView: WKWebView, context: Context) {} } // MARK: - BrowserView struct BrowserView: View { let initialURL: URL let initialTitle: String let claude: ClaudeService let podcastPlayer: PodcastPlayerViewModel var onArchive: (() async -> Void)? = nil @Environment(\.dismiss) private var dismiss @Environment(\.openURL) private var openURL @State private var state: BrowserState @State private var readerMode = false @State private var showPodcast = false @State private var archiveTapCount = 0 @State private var navBackCount = 0 @State private var navForwardCount = 0 @State private var toolbarHidden = false init(url: URL, title: String, claude: ClaudeService, podcastPlayer: PodcastPlayerViewModel, onArchive: (() async -> Void)? = nil) { self.initialURL = url self.initialTitle = title self.claude = claude self.podcastPlayer = podcastPlayer self.onArchive = onArchive self._state = State(initialValue: BrowserState(url: url)) } var body: some View { NavigationStack { ZStack(alignment: .top) { WebViewRepresentable(webView: state.webView) .ignoresSafeArea(edges: .bottom) // Reading progress bar if state.readingProgress > 0.01 && state.readingProgress < 0.99 { GeometryReader { geo in Rectangle() .fill(Color.blue.opacity(0.55)) .frame(width: geo.size.width * state.readingProgress, height: 3) } .frame(height: 3) .animation(.linear(duration: 0.1), value: state.readingProgress) } // Page loading sweep bar (replaces center spinner) if state.isLoading { PageLoadBar() .transition(.opacity) } } .navigationTitle(state.pageTitle.isEmpty ? initialTitle : state.pageTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button { dismiss() } label: { Image(systemName: "xmark") .fontWeight(.semibold) } } ToolbarItem(placement: .topBarTrailing) { Button { openURL(state.currentURL ?? initialURL) } label: { Image(systemName: "safari") } } } } .safeAreaInset(edge: .bottom, spacing: 0) { bottomBar .offset(y: toolbarHidden ? 88 : 0) .animation(.spring(duration: 0.32, bounce: 0), value: toolbarHidden) } .onChange(of: state.readingProgress) { old, new in let delta = new - old withAnimation(.spring(duration: 0.3, bounce: 0)) { if delta > 0.012 && new > 0.07 { toolbarHidden = true } else if delta < -0.005 || new < 0.05 { toolbarHidden = false } } } .onChange(of: state.isLoading) { _, loading in if loading { withAnimation(.spring(duration: 0.3, bounce: 0)) { toolbarHidden = false } } } .onDisappear { let urlStr = (state.currentURL ?? initialURL).absoluteString let p = state.readingProgress if p > 0.01 { ReadingProgress.set(url: urlStr, progress: p) } } .sheet(isPresented: $showPodcast) { PodcastPlayerView( vm: podcastPlayer, articleUrl: (state.currentURL ?? initialURL).absoluteString, articleTitle: state.pageTitle.isEmpty ? initialTitle : state.pageTitle, claude: claude, stopOnDismiss: false ) } } private var bottomBar: some View { HStack { Button { navBackCount += 1 state.webView.goBack() } label: { Image(systemName: "chevron.left") .frame(width: 44, height: 44) .contentShape(Rectangle()) } .disabled(!state.canGoBack) .sensoryFeedback(.impact(weight: .light), trigger: navBackCount) Spacer() Button { navForwardCount += 1 state.webView.goForward() } label: { Image(systemName: "chevron.right") .frame(width: 44, height: 44) .contentShape(Rectangle()) } .disabled(!state.canGoForward) .sensoryFeedback(.impact(weight: .light), trigger: navForwardCount) Spacer() Button { if readerMode { state.exitReaderMode(); readerMode = false } else { state.enterReaderMode { readerMode = true } } } label: { Image(systemName: readerMode ? "doc.text.fill" : "doc.text") .contentTransition(.symbolEffect(.replace)) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .disabled(state.isLoading) if onArchive != nil { Spacer() } if let onArchive { Button { archiveTapCount += 1 Task { await onArchive() } dismiss() } label: { Image(systemName: "archivebox") .frame(width: 44, height: 44) .contentShape(Rectangle()) } .sensoryFeedback(.impact(weight: .medium), trigger: archiveTapCount) } Spacer() Button { let currentUrl = (state.currentURL ?? initialURL).absoluteString let currentTitle = state.pageTitle.isEmpty ? initialTitle : state.pageTitle podcastPlayer.start( articleUrl: currentUrl, articleTitle: currentTitle, claude: claude, parentBookmarkUrl: initialURL.absoluteString ) showPodcast = true } label: { Image(systemName: "headphones") .frame(width: 44, height: 44) .contentShape(Rectangle()) } .disabled(state.isLoading) Spacer() ShareLink(item: state.currentURL ?? initialURL) { Image(systemName: "square.and.arrow.up") .frame(width: 44, height: 44) .contentShape(Rectangle()) } } .font(.system(size: 17)) .foregroundStyle(.primary) .padding(.horizontal, 12) .padding(.vertical, 4) .background(.bar) .overlay(alignment: .top) { Divider() } } }