diff --git a/apps/mobile/native/ios/SharedWebView/FOWebView.swift b/apps/mobile/native/ios/SharedWebView/FOWebView.swift index 73e7f7cf2..771684267 100644 --- a/apps/mobile/native/ios/SharedWebView/FOWebView.swift +++ b/apps/mobile/native/ios/SharedWebView/FOWebView.swift @@ -5,7 +5,9 @@ // Created by Innei on 2025/2/7. // -import WebKit +@preconcurrency import WebKit + +private var pendingJavaScripts: [String] = [] class FOWebView: WKWebView { private func setupView() { @@ -24,12 +26,237 @@ class FOWebView: WKWebView { } - override init(frame: CGRect, configuration: WKWebViewConfiguration) { + private let delegate: WebViewDelegate! + + init(frame: CGRect, state: WebViewState) { + let configuration = WKWebViewConfiguration() + let viewController = Utils.getRootVC()! + self.delegate = WebViewDelegate(state: state, viewController: viewController) + super.init(frame: frame, configuration: configuration) + + let bundle = Utils.bundle + + let hexAccentColor = Utils.accentColor.toHex() + let css = """ + :root { overflow: hidden !important; overflow-behavior: none !important; } + body { + overflow-y: visible !important; + position: absolute !important; + width: 100% !important; + height: auto !important; + -webkit-overflow-scrolling: touch !important; + } + ::selection { + background-color: \(hexAccentColor) !important; + } + """ + + let script = WKUserScript( + source: """ + var style = document.createElement('style'); + style.textContent = '\(css)'; + document.head.appendChild(style); + """, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + + let atStartScripts = loadInjectedJs(forResource: "at_start") + + if let jsString = atStartScripts { + let script = WKUserScript( + source: jsString, + injectionTime: .atDocumentStart, + forMainFrameOnly: true + ) + configuration.userContentController.addUserScript(script) + } + + configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + + let schemeHandler = CustomURLSchemeHandler() + configuration.setURLSchemeHandler( + schemeHandler, forURLScheme: CustomURLSchemeHandler.rewriteScheme) + + let customSchemeScript = WKUserScript( + source: """ + (function() { + const originalXHROpen = XMLHttpRequest.prototype.open; + XMLHttpRequest.prototype.open = function(method, url, ...args) { + const modifiedUrl = url.replace(/^https?:/, '\(CustomURLSchemeHandler.rewriteScheme):'); + originalXHROpen.call(this, method, modifiedUrl, ...args); + }; + + const originalFetch = window.fetch; + window.fetch = function(url, options) { + const modifiedUrl = url.replace(/^https?:/, '\(CustomURLSchemeHandler.rewriteScheme):'); + return originalFetch(modifiedUrl, options); + }; + + const originalImageSrc = Object.getOwnPropertyDescriptor(Image.prototype, 'src'); + Object.defineProperty(Image.prototype, 'src', { + set: function(url) { + const modifiedUrl = url.replace(/^https?:/, '\(CustomURLSchemeHandler.rewriteScheme):'); + originalImageSrc.set.call(this, modifiedUrl); + } + }); + })(); + """, + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + configuration.userContentController.addUserScript(customSchemeScript) + + let atEndScripts = self.loadInjectedJs(forResource: "at_end") + guard let jsString = atEndScripts else { + print("Failed to load injected js") + return + } + let script2 = WKUserScript( + source: jsString, + injectionTime: .atDocumentEnd, + forMainFrameOnly: true + ) + + configuration.userContentController.add(delegate, name: "message") + configuration.userContentController.addUserScript(script2) + setupView() + navigationDelegate = delegate + uiDelegate = delegate + } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } + +extension FOWebView { + + private func loadInjectedJs(forResource: String) -> String? { + if let bundleURL = Bundle(for: WebViewView.self).url( + forResource: "js", withExtension: "bundle"), + let resourceBundle = Bundle(url: bundleURL) + { + + if let jsPath = resourceBundle.path(forResource: forResource, ofType: "js") { + do { + let initJsContent = try String(contentsOfFile: jsPath, encoding: .utf8) + + return initJsContent + } catch { + print("Error reading JS file:", error) + } + } + + } + return nil + } + +} + +private class WebViewDelegate: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate +{ + private let state: WebViewState + private weak var viewController: UIViewController? + + init(state: WebViewState, viewController: UIViewController?) { + self.state = state + self.viewController = viewController + super.init() + } + + func userContentController( + _ userContentController: WKUserContentController, didReceive message: WKScriptMessage + ) { + debugPrint("message", message.body) + if message.name == "message" { + let body = message.body + + if let jsonString = body as? String, let decode = jsonString.data(using: .utf8) { + let data = try? JSONDecoder().decode(BridgeDataBasePayload.self, from: decode) + guard let data = data else { return } + + switch data.type { + case "setContentHeight": + let data = try? JSONDecoder().decode( + SetContentHeightPayload.self, from: decode) + guard let data = data else { return } + + DispatchQueue.main.async { + self.state.contentHeight = data.payload + } + + case "measure": + self.measureWebView(SharedWebViewModule.sharedWebView!) + + case "previewImage": + let data = try? JSONDecoder().decode( + PreviewImagePayload.self, from: decode) + + guard let data = data else { return } + DispatchQueue.main.async { + ImagePreview.quickLookImage( + data.payload.images.compactMap { Data($0) }) + } + + default: + break + } + + } + + } + } + + private func measureWebView(_ webView: WKWebView) { + let jsCode = "document.querySelector('#root').scrollHeight" + webView.evaluateJavaScript(jsCode) { (height, error) in + if let height = height as? CGFloat, height > 0 { + DispatchQueue.main.async { + self.state.contentHeight = height + debugPrint("measure", height) + } + } + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + measureWebView(webView) + + Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in + self?.measureWebView(webView) + } + + } + + func webView( + _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures + ) -> WKWebView? { + if let url = navigationAction.request.url, + let viewController = self.viewController + { + WebViewManager.presentModalWebView(url: url, from: viewController) + } + return nil + } + + func webView( + _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + if navigationAction.targetFrame == nil { + if let url = navigationAction.request.url, + let viewController = self.viewController + { + WebViewManager.presentModalWebView(url: url, from: viewController) + decisionHandler(.cancel) + return + } + } + decisionHandler(.allow) + } +} diff --git a/apps/mobile/native/ios/SharedWebView/SharedWebViewModule.swift b/apps/mobile/native/ios/SharedWebView/SharedWebViewModule.swift index 0f9ec6445..e98ac9c3c 100644 --- a/apps/mobile/native/ios/SharedWebView/SharedWebViewModule.swift +++ b/apps/mobile/native/ios/SharedWebView/SharedWebViewModule.swift @@ -1,12 +1,15 @@ // -// File.swift +// SharedWebViewModule.swift // // Created by Innei on 2025/1/29. // +import Combine import ExpoModulesCore import WebKit +let onContentHeightChanged = "onContentHeightChanged" + public class SharedWebViewModule: Module { private var pendingJavaScripts: [String] = [] @@ -38,6 +41,19 @@ public class SharedWebViewModule: Module { } } } + + Events(onContentHeightChanged) + var cancellable: AnyCancellable? + OnStartObserving { + cancellable = WebViewManager.state.$contentHeight + .receive(on: DispatchQueue.main) + .sink { [weak self] height in + self?.sendEvent(onContentHeightChanged, ["height": height]) + } + } + OnStopObserving { + cancellable?.cancel() + } } private func load(urlString: String) { diff --git a/apps/mobile/native/ios/SharedWebView/WebViewManager.swift b/apps/mobile/native/ios/SharedWebView/WebViewManager.swift index b873fe389..8d86f2861 100644 --- a/apps/mobile/native/ios/SharedWebView/WebViewManager.swift +++ b/apps/mobile/native/ios/SharedWebView/WebViewManager.swift @@ -7,7 +7,6 @@ import Combine import ExpoModulesCore import SafariServices - @preconcurrency import WebKit private var pendingJavaScripts: [String] = [] @@ -18,8 +17,7 @@ protocol WebViewLinkDelegate: AnyObject { } enum WebViewManager { - static let state = WebViewState() - static weak var linkDelegate: WebViewLinkDelegate? + static var state = WebViewState() public static func evaluateJavaScript(_ js: String) { DispatchQueue.main.async { @@ -43,253 +41,45 @@ enum WebViewManager { } static private(set) var shared: WKWebView = { - let configuration = WKWebViewConfiguration() - let bundle = Utils.bundle + FOWebView(frame: .zero, state: state) - let hexAccentColor = Utils.accentColor.toHex() - let css = """ - :root { overflow: hidden !important; overflow-behavior: none !important; } - body { - overflow-y: visible !important; - position: absolute !important; - width: 100% !important; - height: auto !important; - -webkit-overflow-scrolling: touch !important; - } - ::selection { - background-color: \(hexAccentColor) !important; - } - """ - - let script = WKUserScript( - source: """ - var style = document.createElement('style'); - style.textContent = '\(css)'; - document.head.appendChild(style); - """, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - - let jsString = loadInjectedJs(forResource: "at_start") - - if let jsString = jsString { - let script = WKUserScript( - source: jsString, - injectionTime: .atDocumentStart, - forMainFrameOnly: true - ) - configuration.userContentController.addUserScript(script) - } - - configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") - - let schemeHandler = CustomURLSchemeHandler() - configuration.setURLSchemeHandler( - schemeHandler, forURLScheme: CustomURLSchemeHandler.rewriteScheme) - - let customSchemeScript = WKUserScript( - source: """ - (function() { - const originalXHROpen = XMLHttpRequest.prototype.open; - XMLHttpRequest.prototype.open = function(method, url, ...args) { - const modifiedUrl = url.replace(/^https?:/, '\(CustomURLSchemeHandler.rewriteScheme):'); - originalXHROpen.call(this, method, modifiedUrl, ...args); - }; - - const originalFetch = window.fetch; - window.fetch = function(url, options) { - const modifiedUrl = url.replace(/^https?:/, '\(CustomURLSchemeHandler.rewriteScheme):'); - return originalFetch(modifiedUrl, options); - }; - - const originalImageSrc = Object.getOwnPropertyDescriptor(Image.prototype, 'src'); - Object.defineProperty(Image.prototype, 'src', { - set: function(url) { - const modifiedUrl = url.replace(/^https?:/, '\(CustomURLSchemeHandler.rewriteScheme):'); - originalImageSrc.set.call(this, modifiedUrl); - } - }); - })(); - """, - injectionTime: .atDocumentStart, - forMainFrameOnly: false - ) - configuration.userContentController.addUserScript(customSchemeScript) - - let webView = FOWebView(frame: .zero, configuration: configuration) - - setupWebView(webView) - - return webView }() - private static func setupWebView(_ webView: WKWebView) { - guard let viewController = Utils.getRootVC() else { return } - let delegate = WebViewDelegate(state: state, viewController: viewController) - webView.navigationDelegate = delegate - webView.uiDelegate = delegate - - let jsString = self.loadInjectedJs(forResource: "at_end") - guard let jsString = jsString else { - print("Failed to load injected js") - return - } - let script = WKUserScript( - source: jsString, - injectionTime: .atDocumentEnd, - forMainFrameOnly: true - ) - - webView.configuration.userContentController.add(delegate, name: "message") - webView.configuration.userContentController.addUserScript(script) - } - static func resetWebView() { - let newWebView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) - setupWebView(newWebView) - shared = newWebView - } - - private static func loadInjectedJs(forResource: String) -> String? { - if let bundleURL = Bundle(for: WebViewView.self).url( - forResource: "js", withExtension: "bundle"), - let resourceBundle = Bundle(url: bundleURL) - { - - if let jsPath = resourceBundle.path(forResource: forResource, ofType: "js") { - do { - let initJsContent = try String(contentsOfFile: jsPath, encoding: .utf8) - - return initJsContent - } catch { - print("Error reading JS file:", error) - } - } - - } - return nil + self.state = WebViewState() + self.shared = FOWebView(frame: .zero, state: state) } static func presentModalWebView(url: URL, from viewController: UIViewController) { - let safariVC = SFSafariViewController(url: url) - safariVC.view.tintColor = Utils.accentColor - safariVC.preferredControlTintColor = Utils.accentColor - viewController.present(safariVC, animated: true) + let safariVC = SFSafariViewController(url: url) + safariVC.view.tintColor = Utils.accentColor + safariVC.preferredControlTintColor = Utils.accentColor + viewController.present(safariVC, animated: true) } } - -private class WebViewDelegate: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate -{ - private let state: WebViewState - private weak var viewController: UIViewController? - - init(state: WebViewState, viewController: UIViewController?) { - self.state = state - self.viewController = viewController - super.init() - } - - func userContentController( - _ userContentController: WKUserContentController, didReceive message: WKScriptMessage - ) { - debugPrint("message", message.body) - if message.name == "message" { - let body = message.body - - if let jsonString = body as? String, let decode = jsonString.data(using: .utf8) { - let data = try? JSONDecoder().decode(BridgeDataBasePayload.self, from: decode) - guard let data = data else { return } - - switch data.type { - case "setContentHeight": - let data = try? JSONDecoder().decode( - SetContentHeightPayload.self, from: decode) - guard let data = data else { return } - - DispatchQueue.main.async { - self.state.contentHeight = data.payload - } - - case "measure": - self.measureWebView(SharedWebViewModule.sharedWebView!) - - case "previewImage": - let data = try? JSONDecoder().decode( - PreviewImagePayload.self, from: decode) - - guard let data = data else { return } - DispatchQueue.main.async { - ImagePreview.quickLookImage( - data.payload.images.compactMap { Data($0) }) - } - - default: - break - } - - } - - } - } - - private func measureWebView(_ webView: WKWebView) { - let jsCode = "document.querySelector('#root').scrollHeight" - webView.evaluateJavaScript(jsCode) { (height, error) in - if let height = height as? CGFloat, height > 0 { - DispatchQueue.main.async { - self.state.contentHeight = height - debugPrint("measure", height) - } - } - } - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - measureWebView(webView) - - Timer.scheduledTimer(withTimeInterval: 1, repeats: false) { [weak self] _ in - self?.measureWebView(webView) - } - - for js in pendingJavaScripts { - webView.evaluateJavaScript(js) { (_, error) in - if let error = error { - print("Error evaluating JavaScript:", error) - } - } - } - pendingJavaScripts.removeAll() - - } - - func webView( - _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, - for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures - ) -> WKWebView? { - if let url = navigationAction.request.url, - let viewController = self.viewController - { - WebViewManager.presentModalWebView(url: url, from: viewController) - } - return nil - } - - func webView( - _ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void - ) { - if navigationAction.targetFrame == nil { - if let url = navigationAction.request.url, - let viewController = self.viewController - { - WebViewManager.presentModalWebView(url: url, from: viewController) - decisionHandler(.cancel) - return - } - } - decisionHandler(.allow) - } -} +// +//extension WebViewManager { +// private static var contentHeightChangeListeners: [Int: (Float) -> Void] = [:] +// private static var contentHeightChangeListenerId = 0 +// +// static func addContentHeightChangeListener(_ listener: @escaping (Float) -> Void) -> Int { +// let listenerId = contentHeightChangeListenerId +// contentHeightChangeListenerId += 1 +// contentHeightChangeListeners[listenerId] = listener +// return listenerId +// } +// +// static func removeContentHeightChangeListener(_ listenerId: Int) { +// contentHeightChangeListeners.removeValue(forKey: listenerId) +// } +// +//// static func notifyContentHeightChange(_ height: Float) { +//// for listener in contentHeightChangeListeners.values { +//// DispatchQueue.main.async { +//// listener(height) +//// } +//// } +//// } +//} diff --git a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx index dc776ca7b..5719cfe9c 100644 --- a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx +++ b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx @@ -1,5 +1,6 @@ import { clsx } from "@follow/utils" import { requireNativeView } from "expo" +import { useAtom } from "jotai" import * as React from "react" import { useEffect } from "react" import type { ViewProps } from "react-native" @@ -10,6 +11,7 @@ import { BugCuteReIcon } from "@/src/icons/bug_cute_re" import type { EntryModel } from "@/src/store/entry/types" import { Portal } from "../../ui/portal" +import { sharedWebViewHeightAtom } from "./atom" import { htmlUrl } from "./constants" import { prepareEntryRenderWebView, SharedWebViewModule } from "./index" @@ -31,11 +33,12 @@ const setCodeTheme = (light: string, dark: string) => { ) } -export const setWebViewEntry = (entry: EntryModel) => { +const setWebViewEntry = (entry: EntryModel) => { SharedWebViewModule.evaluateJavaScript( `setEntry(JSON.parse(${JSON.stringify(JSON.stringify(entry))}))`, ) } +export { setWebViewEntry as preloadWebViewEntry } const setNoMedia = (value: boolean) => { SharedWebViewModule.evaluateJavaScript(`setNoMedia(${value})`) @@ -46,7 +49,7 @@ const setReaderRenderInlineStyle = (value: boolean) => { } export function EntryContentWebView(props: EntryContentWebViewProps) { - const [contentHeight, setContentHeight] = React.useState(0) + const [contentHeight, setContentHeight] = useAtom(sharedWebViewHeightAtom) const codeThemeLight = useUISettingKey("codeHighlightThemeLight") const codeThemeDark = useUISettingKey("codeHighlightThemeDark") diff --git a/apps/mobile/src/components/native/webview/atom.ts b/apps/mobile/src/components/native/webview/atom.ts new file mode 100644 index 000000000..13c57cba2 --- /dev/null +++ b/apps/mobile/src/components/native/webview/atom.ts @@ -0,0 +1,4 @@ +import { atom } from "jotai" +import { Dimensions } from "react-native" + +export const sharedWebViewHeightAtom = atom(Dimensions.get("window").height) diff --git a/apps/mobile/src/components/native/webview/index.ts b/apps/mobile/src/components/native/webview/index.ts index 14dbc738e..b55b39145 100644 --- a/apps/mobile/src/components/native/webview/index.ts +++ b/apps/mobile/src/components/native/webview/index.ts @@ -3,13 +3,21 @@ import { requireNativeModule } from "expo-modules-core" import { htmlUrl } from "./constants" -declare class ISharedWebViewModule extends NativeModule { +declare class ISharedWebViewModule extends NativeModule<{ + onContentHeightChanged: ({ height }: { height: number }) => void +}> { load(url: string): void evaluateJavaScript(js: string): void } export const SharedWebViewModule = requireNativeModule("FOSharedWebView") +let prepareOnce = false export const prepareEntryRenderWebView = () => { + if (prepareOnce) return + prepareOnce = true SharedWebViewModule.load(htmlUrl) + // SharedWebViewModule.addListener("onContentHeightChanged", ({ height }) => { + // jotaiStore.set(sharedWebViewHeightAtom, height) + // }) } diff --git a/apps/mobile/src/modules/context-menu/entry.tsx b/apps/mobile/src/modules/context-menu/entry.tsx index db58fd4ef..6fe3adda1 100644 --- a/apps/mobile/src/modules/context-menu/entry.tsx +++ b/apps/mobile/src/modules/context-menu/entry.tsx @@ -5,7 +5,7 @@ import { Share, Text, View } from "react-native" import { EntryContentWebView, - setWebViewEntry, + preloadWebViewEntry, } from "@/src/components/native/webview/EntryContentWebView" import { ContextMenu } from "@/src/components/ui/context-menu" import { PortalHost } from "@/src/components/ui/portal" @@ -25,7 +25,7 @@ export const EntryItemContextMenu = ({ id, children }: PropsWithChildren<{ id: s const handlePressPreview = useCallback(() => { if (!entry) return - setWebViewEntry(entry) + preloadWebViewEntry(entry) router.push(`/entries/${id}`) }, [entry, id]) diff --git a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx index f8709876a..8bb4daa8c 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect } from "react" import { ActivityIndicator, Text, TouchableOpacity, View } from "react-native" import ReAnimated, { useAnimatedStyle, useSharedValue, withSpring } from "react-native-reanimated" -import { setWebViewEntry } from "@/src/components/native/webview/EntryContentWebView" +import { preloadWebViewEntry } from "@/src/components/native/webview/EntryContentWebView" import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime" import { FeedIcon } from "@/src/components/ui/icon/feed-icon" import { ProxiedImage } from "@/src/components/ui/image/ProxiedImage" @@ -27,7 +27,7 @@ export function EntryNormalItem({ entryId, extraData }: { entryId: string; extra const handlePress = useCallback(() => { if (!entry) return - setWebViewEntry(entry) + preloadWebViewEntry(entry) router.push(`/entries/${entryId}`) }, [entryId, entry])