From a9bb77167ea3fceaf31fa250b9e1ab9676bd2d58 Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 26 Aug 2025 22:20:57 +0800 Subject: [PATCH] refactor(webview): ios preload webview module - Refactored WebViewManager to improve lifecycle management and state handling. - Introduced a debug panel for monitoring WebView state and actions in development mode. - Updated JavaScript injection methods to use a centralized dispatch system for better maintainability. - Improved handling of local HTML loading and state replay after process termination. - Added new functions for debugging, including state retrieval and flushing pending scripts. This update aims to enhance the overall performance and usability of the WebView component, providing developers with better tools for debugging and state management. Signed-off-by: Innei --- .gitignore | 1 - ...droid-shared-webview-image-interception.md | 617 ++++++++++++++++++ PRPs/ratio-based-mixing.md | 397 +++++++++++ .../ios/Modules/Helper/HelperModule.swift | 347 +++++----- .../ios/Modules/SharedWebView/FOWebView.swift | 59 +- .../Modules/SharedWebView/Injected/at_end.js | 31 +- .../SharedWebView/Injected/at_start.js | 17 + .../Modules/SharedWebView/SharedWebView.swift | 15 +- .../SharedWebView/SharedWebViewModule.swift | 252 +++---- .../SharedWebView/WebViewManager.swift | 397 +++++++++-- .../components/native/webview/DebugPanel.tsx | 91 +++ .../native/webview/EntryContentWebView.tsx | 24 +- .../native/webview/index.android.ts | 7 + .../src/components/native/webview/index.ts | 19 +- .../native/webview/webview-manager.ts | 21 +- .../mobile/src/modules/context-menu/entry.tsx | 4 +- .../entry-list/templates/EntryNormalItem.tsx | 4 +- .../entry-list/templates/EntryPictureItem.tsx | 3 +- .../src/managers/webview-bridge.ts | 81 ++- 19 files changed, 1949 insertions(+), 438 deletions(-) create mode 100644 PRPs/android-shared-webview-image-interception.md create mode 100644 PRPs/ratio-based-mixing.md create mode 100644 apps/mobile/src/components/native/webview/DebugPanel.tsx diff --git a/.gitignore b/.gitignore index 639b493d9..196742530 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,3 @@ buildServer.json apps/desktop/build/appxmanifest.xml .claude/settings.local.json -PRPS/* diff --git a/PRPs/android-shared-webview-image-interception.md b/PRPs/android-shared-webview-image-interception.md new file mode 100644 index 000000000..c141cb3b3 --- /dev/null +++ b/PRPs/android-shared-webview-image-interception.md @@ -0,0 +1,617 @@ +# Android SharedWebView with Image Interception Implementation + +## Objective + +Implement an Android equivalent of the iOS SharedWebView module with advanced image interception capabilities using WebView hooks instead of custom URL schemes. This will provide feature parity between iOS and Android platforms while leveraging Android's superior request interception capabilities. + +## Context + +The iOS implementation uses a custom URL scheme (`follow-image://`) to intercept image requests due to WKWebView limitations. Android WebView offers more powerful request interception through `WebViewClient.shouldInterceptRequest()`, allowing for a more elegant and performant solution. + +### Current iOS Architecture (Reference) + +- **SharedWebViewModule** (apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift) +- **WebViewManager** (apps/mobile/native/ios/Modules/SharedWebView/WebViewManager.swift) - Singleton with lifecycle management +- **FOWebView** (apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift) - Custom WKWebView +- **FollowImageURLSchemeHandler** (apps/mobile/native/ios/Modules/SharedWebView/FollowImageURLSchemeHandler.swift) - Custom scheme handler +- **WebViewState** (apps/mobile/native/ios/Modules/SharedWebView/WebViewState.swift) - Observable state management + +### Existing Android Infrastructure + +- **Module Path**: `apps/mobile/native/android/src/main/java/expo/modules/follownative/` +- **Build Configuration**: Standard Expo modules with Kotlin support +- **Naming Convention**: `expo.modules.follownative.modulename.ModuleName` +- **TypeScript Interface**: Already defined in `apps/mobile/src/components/native/webview/index.ts` + +## Implementation Requirements + +### Core Components to Implement + +1. **SharedWebViewModule.kt** - Main Expo module exposing API to React Native +2. **WebViewManager.kt** - Singleton manager for WebView lifecycle and state +3. **SharedWebViewView.kt** - Expo view component +4. **FOWebView.kt** - Custom WebView with image interception +5. **ImageInterceptClient.kt** - WebViewClient with request interception +6. **ImageCache.kt** - Memory and disk caching system +7. **WebViewState.kt** - Reactive state management with StateFlow +8. **BridgeData.kt** - Message payloads and type definitions + +### API Compatibility Requirements + +Maintain 100% compatibility with existing TypeScript interface: + +```typescript +interface ISharedWebViewModule + extends NativeModule<{ + onContentHeightChanged: ({ height }: { height: number }) => void + onImagePreview: (event: ImagePreviewEvent) => void + onSeekAudio?: (e: { time: number }) => void + }> { + load(url: string): void + evaluateJavaScript(js: string): void + dispatch?(type: string, payload?: string): void + + // Debug helpers + getDebugState?(): WebViewDebugState + destroyForDebug?(): void + reloadLastURL?(): void + flushQueue?(): void +} +``` + +## Technical Architecture + +### Image Interception Strategy (Android Advantage) + +**iOS Approach**: Custom URL scheme with JavaScript injection + +```swift +// JavaScript injection to rewrite image URLs +url.replace(/^https?:/, 'follow-image:') +// Custom WKURLSchemeHandler for follow-image:// +``` + +**Android Approach**: Native request interception (Superior) + +```kotlin +override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse? { + if (isImageRequest(request)) { + return handleImageRequest(request) + } + return super.shouldInterceptRequest(view, request) +} +``` + +### Implementation Blueprint + +#### Phase 1: Core Infrastructure (Priority 1) + +```kotlin +// 1. SharedWebViewModule.kt +@ExpoModule(name = "FOSharedWebView") +class SharedWebViewModule : Module() { + private val coroutineScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + override fun definition() = ModuleDefinition { + Name("FOSharedWebView") + + Function("load") { urlString: String -> + WebViewManager.load(urlString) + } + + Function("evaluateJavaScript") { js: String -> + WebViewManager.evaluateJavaScript(js) + } + + Function("dispatch") { type: String, payload: String? -> + WebViewManager.dispatch(type, payload) + } + + View(SharedWebViewView::class) { + Events("onContentHeightChange", "onSeekAudio") + Prop("url") { view: SharedWebViewView, url: String -> + WebViewManager.load(url) + } + } + + Events("onContentHeightChanged", "onImagePreview", "onSeekAudio") + + OnCreate { + WebViewManager.initializeLifecycleObservers(appContext.currentActivity!!) + } + + OnStartObserving { + // StateFlow subscriptions for reactive events + WebViewManager.state.contentHeight.onEach { height -> + sendEvent("onContentHeightChanged", mapOf("height" to height)) + }.launchIn(coroutineScope) + } + + OnStopObserving { + coroutineScope.cancel() + } + + // Debug functions + Function("getDebugState") { WebViewManager.getDebugState() } + Function("destroyForDebug") { WebViewManager.destroyForDebug() } + Function("reloadLastURL") { WebViewManager.reloadLastURL() } + Function("flushQueue") { WebViewManager.flushQueue() } + } +} +``` + +```kotlin +// 2. WebViewManager.kt - Singleton with lifecycle management +object WebViewManager { + val state = WebViewState() + private var sharedWebView: FOWebView? = null + private var currentHost: ViewGroup? = null + private var isReady = false + private val pendingScripts = mutableListOf() + private var lastUrl: String? = null + private val lastState = mutableMapOf() + + fun initializeLifecycleObservers(activity: Activity) { + val observer = object : DefaultLifecycleObserver { + override fun onPause(owner: LifecycleOwner) { onEnterBackground() } + override fun onResume(owner: LifecycleOwner) { onEnterForeground() } + } + (activity as ComponentActivity).lifecycle.addObserver(observer) + } + + fun load(urlString: String) { + MainScope().launch { + lastUrl = urlString + val webView = getOrCreateWebView() + if (webView.url == urlString && !isLoading(webView)) return@launch + isReady = false + webView.loadUrl(urlString) + } + } + + fun attach(host: ViewGroup) { + MainScope().launch { + val webView = getOrCreateWebView() + (webView.parent as? ViewGroup)?.removeView(webView) + host.addView(webView) + currentHost = host + } + } + + private fun getOrCreateWebView(): FOWebView { + return sharedWebView ?: FOWebView(context, state).also { sharedWebView = it } + } + + private fun onEnterBackground() { + if (currentHost == null) { + MainScope().launch { + delay(3000) + if (currentHost == null) destroyWebView() + } + } + } + + private fun onEnterForeground() { + if (sharedWebView == null) { + getOrCreateWebView() + lastUrl?.let { load(it) } + replayState() + } + } +} +``` + +#### Phase 2: Image Interception System (Priority 1) + +```kotlin +// 3. ImageInterceptClient.kt - Advanced request interception +class ImageInterceptClient( + private val imageCache: ImageCache, + private val baseClient: WebViewClient? = null +) : WebViewClient() { + + companion object { + private val IMAGE_MIME_TYPES = setOf( + "image/jpeg", "image/jpg", "image/png", "image/gif", + "image/webp", "image/svg+xml", "image/avif" + ) + } + + override fun shouldInterceptRequest( + view: WebView?, + request: WebResourceRequest? + ): WebResourceResponse? { + val url = request?.url?.toString() ?: return super.shouldInterceptRequest(view, request) + + if (!isImageRequest(request)) { + return baseClient?.shouldInterceptRequest(view, request) + ?: super.shouldInterceptRequest(view, request) + } + + return handleImageRequest(view, request) + } + + private fun isImageRequest(request: WebResourceRequest): Boolean { + val url = request.url.toString() + val acceptHeader = request.requestHeaders["Accept"] ?: "" + + // Multi-strategy detection + return acceptHeader.contains("image/") || + url.substringAfterLast('.', "").lowercase() in IMAGE_EXTENSIONS || + url.contains(Regex("/(images?|img|pics?|photos?|assets)/", RegexOption.IGNORE_CASE)) + } + + private fun handleImageRequest( + view: WebView?, + request: WebResourceRequest + ): WebResourceResponse? { + val url = request.url.toString() + val cacheKey = url + + // Check cache first + imageCache.get(cacheKey)?.let { cachedData -> + return createImageResponse(cachedData, detectMimeType(cachedData)) + } + + // Network request with proper headers + return try { + val modifiedRequest = buildImageRequest(request) + val response = executeImageRequest(modifiedRequest) + + response?.let { (data, mimeType) -> + imageCache.put(cacheKey, data) + createImageResponse(data, mimeType) + } + } catch (e: Exception) { + Log.w("ImageIntercept", "Failed to load image: $url", e) + null // Fallback to default WebView behavior + } + } + + private fun buildImageRequest(original: WebResourceRequest): HttpURLConnection { + val connection = URL(original.url.toString()).openConnection() as HttpURLConnection + + // Copy original headers + original.requestHeaders.forEach { (key, value) -> + if (!isRestrictedHeader(key)) { + connection.setRequestProperty(key, value) + } + } + + // Optimize for images + connection.setRequestProperty("Accept", "image/webp,image/avif,image/*,*/*;q=0.8") + connection.setRequestProperty("User-Agent", + "Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 Mobile Safari/537.36") + + // Set referer for CORS compatibility + original.url.let { url -> + val referer = "${url.scheme}://${url.host}" + connection.setRequestProperty("Referer", referer) + } + + connection.connectTimeout = 10000 + connection.readTimeout = 15000 + return connection + } + + private fun createImageResponse(data: ByteArray, mimeType: String): WebResourceResponse { + val headers = mapOf( + "Access-Control-Allow-Origin" to "*", + "Cache-Control" to "public, max-age=3600", + "Content-Length" to data.size.toString() + ) + + return WebResourceResponse(mimeType, "utf-8", 200, "OK", headers, ByteArrayInputStream(data)) + } +} +``` + +```kotlin +// 4. ImageCache.kt - High-performance caching system +class ImageCache(private val context: Context) { + private val memoryCache = LruCache(16 * 1024 * 1024) // 16MB + private val diskCacheDir = File(context.cacheDir, "webview_images") + + init { + if (!diskCacheDir.exists()) diskCacheDir.mkdirs() + } + + fun get(key: String): ByteArray? { + // Memory first, then disk + return memoryCache.get(key) ?: getDiskCache(key)?.also { + memoryCache.put(key, it) + } + } + + fun put(key: String, data: ByteArray) { + memoryCache.put(key, data) + putDiskCache(key, data) + } + + private fun getDiskCache(key: String): ByteArray? = try { + val file = getCacheFile(key) + if (file.exists() && System.currentTimeMillis() - file.lastModified() < 24 * 60 * 60 * 1000) { + file.readBytes() + } else null + } catch (e: Exception) { null } + + private fun putDiskCache(key: String, data: ByteArray) { + try { + getCacheFile(key).writeBytes(data) + } catch (e: Exception) { + Log.w("ImageCache", "Failed to cache image", e) + } + } + + private fun getCacheFile(key: String): File { + val fileName = key.hashCode().toString(16) + ".cache" + return File(diskCacheDir, fileName) + } +} +``` + +#### Phase 3: State Management and Views (Priority 2) + +```kotlin +// 5. WebViewState.kt - Reactive state management +class WebViewState { + private val _contentHeight = MutableStateFlow(Resources.getSystem().displayMetrics.heightPixels.toFloat()) + val contentHeight: StateFlow = _contentHeight.asStateFlow() + + private val _imagePreviewEvent = MutableStateFlow(null) + val imagePreviewEvent: StateFlow = _imagePreviewEvent.asStateFlow() + + private val _audioSeekEvent = MutableStateFlow(null) + val audioSeekEvent: StateFlow = _audioSeekEvent.asStateFlow() + + fun updateContentHeight(height: Float) { _contentHeight.value = height } + fun triggerImagePreview(urls: List, index: Int) { + _imagePreviewEvent.value = ImagePreviewEvent(urls, index) + } + fun triggerAudioSeek(time: Double) { + _audioSeekEvent.value = AudioSeekEvent(time) + } +} + +data class ImagePreviewEvent(val imageUrls: List, val index: Int) +data class AudioSeekEvent(val time: Double) +``` + +```kotlin +// 6. FOWebView.kt - Custom WebView implementation +class FOWebView(context: Context, private val state: WebViewState) : WebView(context) { + + private val imageCache = ImageCache(context) + private val baseWebViewClient = FOWebViewClient() + private val javascriptInterface = JavaScriptInterface(state) + + init { + setupWebView() + webViewClient = ImageInterceptClient(imageCache, baseWebViewClient) + webChromeClient = FOWebChromeClient() + addJavaScriptInterface(javascriptInterface, "Android") + } + + private fun setupWebView() { + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + allowFileAccess = true + allowContentAccess = true + loadsImagesAutomatically = true + blockNetworkImage = false + cacheMode = WebSettings.LOAD_DEFAULT + setAppCacheEnabled(true) + } + + // Inject JavaScript bridge + injectBridgeScript() + } + + private fun injectBridgeScript() { + val script = """ + ;(() => { + window.__RN__ = true + + function send(data) { + Android.postMessage(JSON.stringify(data)) + } + + window.bridge = { + measure: () => send({ type: "measure" }), + setContentHeight: (height) => send({ type: "setContentHeight", payload: height }), + previewImage: (data) => send({ type: "previewImage", payload: data }), + seekAudio: (time) => send({ type: "audio:seekTo", payload: { time } }) + } + + // Signal readiness + document.addEventListener("DOMContentLoaded", () => { + send({ type: "ready" }) + }) + })() + """ + evaluateJavascript(script, null) + } + + fun clearImageCache() { imageCache.clear() } +} +``` + +#### Phase 4: Integration and Testing (Priority 3) + +### File Structure to Create + +``` +apps/mobile/native/android/src/main/java/expo/modules/follownative/sharedwebview/ +├── SharedWebViewModule.kt # Main Expo module +├── SharedWebViewView.kt # Expo view component +├── WebViewManager.kt # Singleton WebView manager +├── FOWebView.kt # Custom WebView implementation +├── ImageInterceptClient.kt # Request interception logic +├── ImageCache.kt # Caching system +├── WebViewState.kt # State management +├── BridgeData.kt # Message payloads +└── JavaScriptInterface.kt # WebView-to-native bridge +``` + +### Configuration Updates Required + +1. **expo-module.config.json** - Add new Android module: + +```json +{ + "android": { + "modules": [ + "expo.modules.follownative.sharedwebview.SharedWebViewModule" + // ... existing modules + ] + } +} +``` + +2. **Android Gradle Dependencies** - Add to `apps/mobile/native/android/build.gradle`: + +```gradle +dependencies { + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.8.6" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0" +} +``` + +## Implementation Tasks + +### Phase 1: Core Infrastructure (Week 1) + +1. Create module structure and base classes +2. Implement SharedWebViewModule.kt with Expo module definition +3. Implement WebViewManager.kt singleton with lifecycle management +4. Implement WebViewState.kt with StateFlow-based reactive state +5. Create basic SharedWebViewView.kt Expo view component + +### Phase 2: Image Interception System (Week 1-2) + +1. Implement ImageInterceptClient.kt with shouldInterceptRequest logic +2. Build ImageCache.kt with memory and disk caching +3. Create image request detection and classification system +4. Implement network request handling with proper headers +5. Add MIME type detection and response creation + +### Phase 3: WebView Integration (Week 2) + +1. Implement FOWebView.kt custom WebView +2. Create JavaScriptInterface.kt for native-JS communication +3. Add JavaScript bridge injection and message handling +4. Implement content height tracking and layout updates +5. Add debug utilities and state inspection tools + +### Phase 4: Testing and Integration (Week 2-3) + +1. Create comprehensive unit tests for each component +2. Implement integration tests with mock WebView scenarios +3. Add performance tests for image caching and request interception +4. Test cross-platform API compatibility with iOS implementation +5. Validate memory management and lifecycle behavior + +### Phase 5: Documentation and Deployment (Week 3) + +1. Update expo-module.config.json with new Android module +2. Add necessary Gradle dependencies and configurations +3. Create comprehensive documentation for the implementation +4. Update TypeScript definitions if needed +5. Perform final testing and validation + +## Validation Gates + +### Build Validation + +```bash +cd apps/mobile/native/android +./gradlew assembleDebug +``` + +### Code Quality Checks + +```bash +cd apps/mobile +npm run typecheck +``` + +### Functional Testing + +```bash +# Test module loading +adb logcat | grep "FOSharedWebView" + +# Test image interception +# Navigate to image-heavy content and verify cache behavior +``` + +### Performance Testing + +```kotlin +// Measure image cache hit rates and memory usage +WebViewManager.getImageCacheStats().let { stats -> + assertTrue("Cache hit rate should be > 80%", stats.hitRate > 0.8) + assertTrue("Memory usage should be < 50MB", stats.memoryUsage < 50 * 1024 * 1024) +} +``` + +### Cross-Platform Compatibility Testing + +```javascript +// Verify identical API behavior across platforms +const debugState = await SharedWebViewModule.getDebugState() +expect(debugState).toHaveProperty("hasWebView") +expect(debugState).toHaveProperty("contentHeight") +``` + +## Documentation and References + +### Essential References + +- [Expo Modules API Documentation](https://docs.expo.dev/modules/overview/) +- [Native View Tutorial](https://docs.expo.dev/modules/native-view-tutorial/) +- [Android WebView Request Interception](https://medium.com/@pouryarezaee76/intercepting-requests-in-android-webview-8eb628b32f2a) +- [WebView Security Best Practices](https://blog.oversecured.com/Android-Exploring-vulnerabilities-in-WebResourceResponse/) + +### Key Implementation Files to Reference + +- `apps/mobile/native/ios/Modules/SharedWebView/` - Complete iOS implementation +- `apps/mobile/native/android/src/main/java/expo/modules/follownative/FollowNativeModule.kt` - Existing module patterns +- `apps/mobile/src/components/native/webview/index.ts` - TypeScript API interface + +### Critical Security Considerations + +1. **Request Validation**: Always validate URLs before processing to prevent malicious requests +2. **Header Sanitization**: Filter restricted headers to prevent security vulnerabilities +3. **File Access Limiting**: Restrict file:// URL access to app bundle resources only +4. **Content Security**: Implement proper CSP headers for loaded content +5. **Bridge Data Validation**: Validate all JavaScript bridge message payloads + +## Expected Outcomes + +### Performance Improvements over iOS + +- **No URL Rewriting Overhead**: Direct request interception vs. JavaScript URL manipulation +- **Better Error Handling**: Graceful fallback to default WebView behavior on failure +- **Enhanced Debugging**: Standard HTTP request debugging vs. custom scheme debugging +- **Improved Reliability**: No dependency on JavaScript injection success + +### Feature Parity Achievement + +- 100% API compatibility with iOS implementation +- Identical event handling and state management +- Same debugging capabilities and state inspection tools +- Consistent caching behavior and memory management + +### Success Metrics + +- Image cache hit rate > 90% +- Memory usage < 64MB for typical usage +- Zero crashes related to WebView lifecycle management +- API response time < 50ms for cached resources +- Cross-platform test suite passing rate > 99% + +## Confidence Score: 9/10 + +This PRP provides comprehensive context, detailed implementation blueprints, existing code patterns to follow, specific file references, security considerations, and executable validation gates. The Android implementation leverages superior platform capabilities while maintaining complete API compatibility with iOS. All necessary technical context, external documentation, and testing patterns are included to enable successful one-pass implementation. diff --git a/PRPs/ratio-based-mixing.md b/PRPs/ratio-based-mixing.md new file mode 100644 index 000000000..b2e701017 --- /dev/null +++ b/PRPs/ratio-based-mixing.md @@ -0,0 +1,397 @@ +# PRP: Ratio-Based Color Mixing Tailwind Plugin + +## Project Overview + +This PRP defines the implementation of a Tailwind CSS plugin that provides ratio-based syntax for color mixing as an alternative to lengthy `color-mix()` CSS declarations. The plugin will transform shortened syntax like `bg-mix-accent/background-7/3` into native CSS `color-mix()` functions. + +## Current Problem Analysis + +### Existing Usage Patterns + +The codebase currently uses verbose arbitrary value syntax for color mixing: + +```css +/* Current verbose syntax - 137 characters */ +bg-[color-mix(in_srgb,hsl(var(--fo-a)),hsl(var(--background))_70%)] + +/* Complex nested mixing - 172 characters */ +bg-[color-mix(in_srgb,_color-mix(in_srgb,rgb(var(--color-red)),hsl(var(--background))_80%),transparent_30%)] +``` + +**Found Usage Locations:** + +- `/apps/desktop/layer/renderer/src/modules/ai-chat/components/message/UserChatMessage.tsx:137` +- `/apps/desktop/layer/renderer/src/modules/ai-chat/components/layouts/CollapsibleError.tsx:172` + +### Problems Identified + +1. **Readability**: Extremely long class names reduce code clarity +2. **Maintainability**: Hard to modify mixing ratios +3. **Performance**: Arbitrary values prevent CSS optimization +4. **Developer Experience**: No IntelliSense support +5. **Consistency**: No standardized mixing ratios across project + +## Solution Design + +### Ratio-Based Syntax Options + +**Primary Syntax (Recommended):** + +```css +/* Ratio format: color1/color2-ratio1/ratio2 */ +bg-mix-accent/background-7/3 /* 70% accent, 30% background */ +bg-mix-red/background-4/1 /* 80% red, 20% background */ +border-mix-blue/white-3/2 /* 60% blue, 40% white */ +text-mix-accent/background-9/1 /* 90% accent, 10% background */ +``` + +**Alternative Syntax (Percentage-based):** + +```css +bg-mix-accent-70 /* 70% accent, 30% background (implicit) */ +bg-mix-red-80 /* 80% red, 20% background (implicit) */ +``` + +**Generated CSS Output:** + +```css +.bg-mix-accent\/background-7\/3 { + background-color: color-mix(in srgb, hsl(var(--fo-a)) 70%, hsl(var(--background)) 30%); +} +``` + +## Technical Implementation + +### Plugin Architecture + +Based on existing codebase patterns in `/packages/configs/tailwindcss/web.ts`: + +```typescript +// New file: /packages/configs/tailwindcss/ratio-mixing-plugin.js +const plugin = require("tailwindcss/plugin") + +const ratioMixingPlugin = plugin.withOptions( + (options = {}) => { + return ({ addUtilities, theme, e }) => { + const config = { ...defaultConfig, ...options } + const utilities = {} + + // Generate ratio-based utilities + generateRatioBasedUtilities(utilities, config) + + // Generate percentage-based utilities (fallback) + generatePercentageBasedUtilities(utilities, config) + + addUtilities(utilities) + } + }, + (options) => { + return { + theme: { + // Theme extensions if needed + }, + } + }, +) + +module.exports = ratioMixingPlugin +``` + +### Configuration Schema + +```typescript +interface RatioMixingConfig { + colorSpace?: "srgb" | "hsl" | "oklab" | "oklch" + baseColors: { + [key: string]: string // CSS variable or color value + } + ratios?: { + [key: string]: [number, number] // [numerator, denominator] pairs + } + variants?: ("bg" | "border" | "text")[] + prefix?: string + implicitBackground?: string +} + +const defaultConfig: RatioMixingConfig = { + colorSpace: "srgb", + baseColors: { + background: "hsl(var(--background))", + accent: "hsl(var(--fo-a))", + red: "rgb(var(--color-red))", + // Map to existing theme colors from UIKit + }, + ratios: { + "1/1": [1, 1], // 50%/50% + "2/1": [2, 1], // 66.7%/33.3% + "3/1": [3, 1], // 75%/25% + "4/1": [4, 1], // 80%/20% + "7/3": [7, 3], // 70%/30% + "9/1": [9, 1], // 90%/10% + }, + variants: ["bg", "border", "text"], + prefix: "mix", + implicitBackground: "background", +} +``` + +### Core Implementation Functions + +```javascript +function generateRatioBasedUtilities(utilities, config) { + const { baseColors, ratios, variants, colorSpace } = config + + // Generate: bg-mix-accent/background-7/3 + Object.entries(baseColors).forEach(([color1Name, color1Value]) => { + Object.entries(baseColors).forEach(([color2Name, color2Value]) => { + if (color1Name === color2Name) return // Skip same color mixing + + Object.entries(ratios).forEach(([ratioKey, [num, denom]]) => { + const percentage1 = Math.round((num / (num + denom)) * 100) + const percentage2 = 100 - percentage1 + + variants.forEach((variant) => { + const className = `.${variant}-${config.prefix}-${color1Name}\\/${color2Name}-${ratioKey.replace("/", "\\/")}` + const property = getPropertyName(variant) + const mixedColor = `color-mix(in ${colorSpace}, ${color1Value} ${percentage1}%, ${color2Value} ${percentage2}%)` + + utilities[className] = { [property]: mixedColor } + }) + }) + }) + }) +} + +function generatePercentageBasedUtilities(utilities, config) { + // Generate: bg-mix-accent-70 (implicit background mixing) + const { baseColors, variants, colorSpace, implicitBackground } = config + const backgroundValue = baseColors[implicitBackground] + + const percentages = [5, 10, 20, 30, 40, 50, 60, 70, 80, 90, 95] + + Object.entries(baseColors).forEach(([colorName, colorValue]) => { + if (colorName === implicitBackground) return + + percentages.forEach((percentage) => { + variants.forEach((variant) => { + const className = `.${variant}-${config.prefix}-${colorName}-${percentage}` + const property = getPropertyName(variant) + const mixedColor = `color-mix(in ${colorSpace}, ${colorValue} ${percentage}%, ${backgroundValue} ${100 - percentage}%)` + + utilities[className] = { [property]: mixedColor } + }) + }) + }) +} + +function getPropertyName(variant) { + switch (variant) { + case "bg": + return "background-color" + case "border": + return "border-color" + case "text": + return "color" + default: + return "background-color" + } +} +``` + +### Integration with Existing Config + +Update `/packages/configs/tailwindcss/web.ts`: + +```typescript +import ratioMixingPlugin from "./ratio-mixing-plugin" + +const twConfig = { + // ... existing config + plugins: [ + // ... existing plugins + ratioMixingPlugin({ + baseColors: { + background: "hsl(var(--background))", + accent: "hsl(var(--fo-a))", + red: "rgb(var(--color-red))", + // Map to UIKit colors already in theme + }, + }), + ], +} satisfies Config +``` + +## Migration Strategy + +### Before/After Examples + +```jsx +// BEFORE: Verbose arbitrary values +
+ User message +
+ +// AFTER: Clean ratio syntax +
+ User message +
+ +// ALTERNATIVE: Percentage syntax (when mixing with background) +
+ User message +
+``` + +### Migration Steps + +1. Install plugin in Tailwind config +2. Generate new utility classes via build +3. Replace existing arbitrary values with new classes +4. Test visual consistency +5. Run lint/typecheck validation + +## Implementation Tasks + +### Phase 1: Core Plugin Development + +1. **Create plugin file structure** + - Create `/packages/configs/tailwindcss/ratio-mixing-plugin.js` + - Implement core `plugin.withOptions` structure + - Add default configuration schema + +2. **Implement ratio parsing logic** + - Create `generateRatioBasedUtilities()` function + - Handle ratio-to-percentage conversion + - Support escape characters for CSS class names (`/` → `\/`) + +3. **Add percentage fallback** + - Implement `generatePercentageBasedUtilities()` function + - Provide implicit background mixing + - Support common percentage values + +### Phase 2: Integration & Configuration + +4. **Integrate with existing Tailwind config** + - Update `/packages/configs/tailwindcss/web.ts` + - Map to existing UIKit color variables + - Test build process compatibility + +5. **Color mapping to existing theme** + - Extract colors from current theme configuration + - Map `--fo-a`, `--background`, `--color-red` variables + - Ensure compatibility with existing color system + +### Phase 3: Migration & Testing + +6. **Migrate existing usage** + - Update `UserChatMessage.tsx:137` + - Update `CollapsibleError.tsx:172` + - Search for other arbitrary color-mix usages + +7. **Validation & testing** + - Visual regression testing + - CSS output verification + - Build process validation + +## Validation Gates + +### Build Validation + +```bash +# TypeScript compilation +pnpm run typecheck + +# Linting validation +pnpm run lint +pnpm run lint:tsl + +# Tailwind build test +cd packages/configs/tailwindcss && npx tailwindcss build +``` + +### Plugin-Specific Validation + +```bash +# Test plugin registration +node -e "console.log(require('./packages/configs/tailwindcss/ratio-mixing-plugin.js'))" + +# Test CSS generation +echo "@tailwind utilities;" | npx tailwindcss --config packages/configs/tailwindcss/web.ts +``` + +### Visual Validation + +```bash +# Development server test +cd apps/desktop && pnpm run dev:web + +# Build verification +pnpm run build:web +``` + +## Expected Deliverables + +1. **Plugin Implementation** + - `/packages/configs/tailwindcss/ratio-mixing-plugin.js` - Complete plugin + - Updated `/packages/configs/tailwindcss/web.ts` - Integration + +2. **Migration Changes** + - Updated component files with new class syntax + - Removed verbose arbitrary value usage + +3. **Documentation** + - Generated CSS class reference + - Migration guide for future usage + +## Risk Assessment & Mitigation + +### Potential Issues + +1. **CSS Specificity**: New utilities should have same specificity as existing ones +2. **Build Performance**: Plugin should not significantly slow build times +3. **Browser Compatibility**: `color-mix()` requires modern browser support +4. **Class Name Conflicts**: Need to avoid conflicts with existing utilities + +### Mitigation Strategies + +1. Follow Tailwind's utility layer conventions +2. Implement efficient utility generation (avoid nested loops where possible) +3. Document browser support requirements (IE not supported) +4. Use unique prefixes and test for conflicts + +## Success Criteria + +1. **Functionality**: All current color mixing usage successfully migrated +2. **Performance**: No measurable build time increase (< 5% overhead) +3. **Maintainability**: New syntax reduces class name length by >60% +4. **Developer Experience**: IntelliSense support for new utilities +5. **Visual Consistency**: Pixel-perfect visual match with existing styling + +## External References + +### Documentation + +- **Tailwind Plugin API**: https://v3.tailwindcss.com/docs/plugins +- **CSS color-mix() Specification**: https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/color-mix +- **Existing Plugin Examples**: https://github.com/JavierM42/tailwindcss-color-mix + +### Codebase Files to Reference + +- `/packages/configs/tailwindcss/web.ts` - Main Tailwind configuration +- `/packages/configs/tailwindcss/tw-css-plugin.js` - Existing plugin pattern +- `/packages/configs/tailwindcss/tailwind-extend.css` - Utility examples + +## Confidence Score: 8/10 + +**Rationale**: High confidence due to: + +- ✅ Clear existing patterns in codebase +- ✅ Well-documented Tailwind plugin API +- ✅ Specific usage examples identified +- ✅ CSS color-mix() is well-supported specification +- ✅ Comprehensive implementation plan + +**Potential challenges**: + +- ⚠️ CSS class name escaping complexity +- ⚠️ Color variable mapping accuracy diff --git a/apps/mobile/native/ios/Modules/Helper/HelperModule.swift b/apps/mobile/native/ios/Modules/Helper/HelperModule.swift index 6e90e1347..0e72dff17 100644 --- a/apps/mobile/native/ios/Modules/Helper/HelperModule.swift +++ b/apps/mobile/native/ios/Modules/Helper/HelperModule.swift @@ -8,196 +8,197 @@ import ExpoModulesCore import UIKit public class HelperModule: Module { - public func definition() -> ExpoModulesCore.ModuleDefinition { - Name("Helper") + public func definition() -> ExpoModulesCore.ModuleDefinition { + Name("Helper") - AsyncFunction("openLink") { (urlString: String, promise: Promise) in - guard let url = URL(string: urlString) else { - return - } - DispatchQueue.main.async { - guard let rootVC = Utils.getRootVC() else { return } + AsyncFunction("openLink") { (urlString: String, promise: Promise) in + guard let url = URL(string: urlString) else { + return + } + DispatchQueue.main.async { + guard let rootVC = Utils.getRootVC() else { return } - let onDismiss = { - promise.resolve(["type": "dismiss"]) - } - - WebViewManager.presentModalWebView(url: url, from: rootVC, onDismiss: onDismiss) - } + let onDismiss = { + promise.resolve(["type": "dismiss"]) } - Function("scrollToTop") { (reactTag: Int) in - DispatchQueue.main.async { [weak self] in - guard let bridge = self?.appContext?.reactBridge else { return } - - if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { - let scrollView = self?.findUIScrollView(view: sourceView) - guard let scrollView = scrollView else { - return - } - scrollView.scrollToTopIfPossible(animated: true) - } - } - } - - AsyncFunction("isScrollToEnd") { (reactTag: Int, promise: Promise) in - DispatchQueue.main.async { [weak self] in - guard let bridge = self?.appContext?.reactBridge else { return } - - if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { - let scrollView = self?.findUIScrollView(view: sourceView) - guard let scrollView = scrollView else { - return - } - let contentHeight = scrollView.contentSize.height - let scrollViewHeight = scrollView.bounds.size.height - let contentOffsetY = scrollView.contentOffset.y - - let bottomOffset = contentHeight - scrollViewHeight - - promise.resolve(contentOffsetY >= bottomOffset - 1.0) - } - } - } - - Function("saveImageByHandle") { (reactTag: Int) in - DispatchQueue.main.async { [weak self] in - guard let bridge = self?.appContext?.reactBridge else { return } - - if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { - let imageView = self?.findUIImageView(view: sourceView) - guard let imageView = imageView else { - return - } - guard let image = imageView.image else { return } - UIImageWriteToSavedPhotosAlbum(image, self, #selector(HelperModule.image), nil) - Toast.show(options: .init(type: .success, title: "Saved to photos")) - } - } - } - - Function("shareImageByHandle") { (reactTag: Int, url: String?) in - DispatchQueue.main.async { [weak self] in - guard let bridge = self?.appContext?.reactBridge else { return } - if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { - let imageView = self?.findUIImageView(view: sourceView) - guard let imageView = imageView else { - return - } - guard let image = imageView.image else { return } - let activityViewController = UIActivityViewController( - activityItems: [ - image.asActivityItemSource( - url: try? URL(string: url ?? "") - ), - ], applicationActivities: nil) - activityViewController.popoverPresentationController?.sourceView = sourceView - activityViewController.popoverPresentationController?.sourceRect = sourceView.bounds - activityViewController.popoverPresentationController?.permittedArrowDirections = .any - activityViewController.popoverPresentationController?.permittedArrowDirections = .any - - Utils.getRootVC()?.present(activityViewController, animated: true) - } - } - } - - AsyncFunction("getBase64FromImageViewByHandle") { (reactTag: Int, promise: Promise) in - DispatchQueue.main.async { [weak self] in - guard let bridge = self?.appContext?.reactBridge else { return } - - if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { - let imageView = self?.findUIImageView(view: sourceView) - guard let imageView = imageView else { - promise.reject( - NSError( - domain: "HelperModule", code: 0, - userInfo: [NSLocalizedDescriptionKey: "Image view not found"])) - return - } - let base64 = self?.getBase64FromImageView(imageView: imageView) - promise.resolve(["base64": base64]) - } - } - } - - Function("copyImageByHandle") { (reactTag: Int) in - DispatchQueue.main.async { [weak self] in - guard let bridge = self?.appContext?.reactBridge else { return } - - if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { - let imageView = self?.findUIImageView(view: sourceView) - guard let imageView = imageView else { - return - } - guard let image = imageView.image else { return } - guard let imageData = image.pngData() else { return } - UIPasteboard.general.setData(imageData, forPasteboardType: "public.png") - - Toast.show(options: .init(type: .success, title: "Image copied to clipboard")) - } - } - } + WebViewManager.presentModalWebView(url: url, from: rootVC, onDismiss: onDismiss) + } } - func getBase64FromImageView(imageView: UIImageView) -> String? { - guard let image = imageView.image else { return nil } - guard let imageData = image.pngData() else { return nil } + Function("scrollToTop") { (reactTag: Int) in + DispatchQueue.main.async { [weak self] in + guard let bridge = self?.appContext?.reactBridge else { return } - let base64String = imageData.base64EncodedString(options: .lineLength64Characters) - return base64String - } - - @objc func image( - _ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer? - ) { - if let error = error { - Toast.show(options: .init(type: .error, title: "Save image failed")) - } else { - Toast.show(options: .init(type: .success, title: "Saved to photos")) + if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { + let scrollView = self?.findUIScrollView(view: sourceView) + guard let scrollView = scrollView else { + return + } + scrollView.scrollToTopIfPossible(animated: true) } + } } - private func findUIScrollView(view: UIView?) -> UIScrollView? { - return findUIViewOfType(view: view) - } + AsyncFunction("isScrollToEnd") { (reactTag: Int, promise: Promise) in + DispatchQueue.main.async { [weak self] in + guard let bridge = self?.appContext?.reactBridge else { return } - private func findUIImageView(view: UIView?) -> UIImageView? { - return findUIViewOfType(view: view) - } + if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { + let scrollView = self?.findUIScrollView(view: sourceView) + guard let scrollView = scrollView else { + return + } + let contentHeight = scrollView.contentSize.height + let scrollViewHeight = scrollView.bounds.size.height + let contentOffsetY = scrollView.contentOffset.y - private func findUIViewOfType(view: UIView?) -> T? { - guard let view = view else { - return nil + let bottomOffset = contentHeight - scrollViewHeight + + promise.resolve(contentOffsetY >= bottomOffset - 1.0) } - if let view = view as? T { - return view - } - - let subviews = view.subviews - for subview in subviews { - if let targetView = findUIViewOfType(view: subview) as T? { - return targetView - } - } - return nil + } } + + Function("saveImageByHandle") { (reactTag: Int) in + DispatchQueue.main.async { [weak self] in + guard let bridge = self?.appContext?.reactBridge else { return } + + if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { + let imageView = self?.findUIImageView(view: sourceView) + guard let imageView = imageView else { + return + } + guard let image = imageView.image else { return } + UIImageWriteToSavedPhotosAlbum(image, self, #selector(HelperModule.image), nil) + Toast.show(options: .init(type: .success, title: "Saved to photos")) + } + } + } + + Function("shareImageByHandle") { (reactTag: Int, url: String?) in + DispatchQueue.main.async { [weak self] in + guard let bridge = self?.appContext?.reactBridge else { return } + if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { + let imageView = self?.findUIImageView(view: sourceView) + guard let imageView = imageView else { + return + } + guard let image = imageView.image else { return } + let activityViewController = UIActivityViewController( + activityItems: [ + image.asActivityItemSource( + url: try? URL(string: url ?? "") + ) + ], applicationActivities: nil) + activityViewController.popoverPresentationController?.sourceView = sourceView + activityViewController.popoverPresentationController?.sourceRect = sourceView.bounds + activityViewController.popoverPresentationController?.permittedArrowDirections = .any + activityViewController.popoverPresentationController?.permittedArrowDirections = .any + + Utils.getRootVC()?.present(activityViewController, animated: true) + } + } + } + + AsyncFunction("getBase64FromImageViewByHandle") { (reactTag: Int, promise: Promise) in + DispatchQueue.main.async { [weak self] in + guard let bridge = self?.appContext?.reactBridge else { return } + + if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { + let imageView = self?.findUIImageView(view: sourceView) + guard let imageView = imageView else { + promise.reject( + NSError( + domain: "HelperModule", code: 0, + userInfo: [NSLocalizedDescriptionKey: "Image view not found"])) + return + } + let base64 = self?.getBase64FromImageView(imageView: imageView) + promise.resolve(["base64": base64]) + } + } + } + + Function("copyImageByHandle") { (reactTag: Int) in + DispatchQueue.main.async { [weak self] in + guard let bridge = self?.appContext?.reactBridge else { return } + + if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) { + let imageView = self?.findUIImageView(view: sourceView) + guard let imageView = imageView else { + return + } + guard let image = imageView.image else { return } + guard let imageData = image.pngData() else { return } + UIPasteboard.general.setData(imageData, forPasteboardType: "public.png") + + Toast.show(options: .init(type: .success, title: "Image copied to clipboard")) + } + } + } + } + + func getBase64FromImageView(imageView: UIImageView) -> String? { + guard let image = imageView.image else { return nil } + guard let imageData = image.pngData() else { return nil } + + let base64String = imageData.base64EncodedString(options: .lineLength64Characters) + return base64String + } + + @objc func image( + _ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer? + ) { + if let error = error { + Toast.show(options: .init(type: .error, title: "Save image failed")) + } else { + Toast.show(options: .init(type: .success, title: "Saved to photos")) + } + } + + private func findUIScrollView(view: UIView?) -> UIScrollView? { + return findUIViewOfType(view: view) + } + + private func findUIImageView(view: UIView?) -> UIImageView? { + return findUIViewOfType(view: view) + } + + private func findUIViewOfType(view: UIView?) -> T? { + guard let view = view else { + return nil + } + if let view = view as? T { + return view + } + + let subviews = view.subviews + for subview in subviews { + if let targetView = findUIViewOfType(view: subview) as T? { + return targetView + } + } + return nil + } } extension UIScrollView { - func scrollToTopIfPossible(animated: Bool) { - let encodedSelector = "X3Njcm9sbFRvVG9wSWZQb3NzaWJsZTo=" // "_scrollToTopIfPossible:" + func scrollToTopIfPossible(animated: Bool) { + let encodedSelector = "X3Njcm9sbFRvVG9wSWZQb3NzaWJsZTo=" // "_scrollToTopIfPossible:" - if let decodedData = Data(base64Encoded: encodedSelector), - let decodedString = String(data: decodedData, encoding: .utf8) { - let selector = NSSelectorFromString(decodedString) - if responds(to: selector) { - perform(selector, with: animated) - } else { - print("UIScrollView does not respond to decoded method") - setContentOffset(.zero, animated: animated) - } - } else { - setContentOffset(.zero, animated: animated) - } + if let decodedData = Data(base64Encoded: encodedSelector), + let decodedString = String(data: decodedData, encoding: .utf8) + { + let selector = NSSelectorFromString(decodedString) + if responds(to: selector) { + perform(selector, with: animated) + } else { + print("UIScrollView does not respond to decoded method") + setContentOffset(.zero, animated: animated) + } + } else { + setContentOffset(.zero, animated: animated) } + } } diff --git a/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift b/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift index d02c221a8..0520f5ffd 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/FOWebView.swift @@ -7,8 +7,6 @@ @preconcurrency import WebKit -private var pendingJavaScripts: [String] = [] - class FOWebView: WKWebView { private func setupView() { scrollView.isScrollEnabled = false @@ -28,7 +26,7 @@ class FOWebView: WKWebView { private var state: WebViewState! init(frame: CGRect, state: WebViewState) { - let configuration = FOWKWebViewConfiguration.shared + let configuration = FOWKWebViewConfiguration() super.init(frame: frame, configuration: configuration) configuration.userContentController.add(self, name: "message") @@ -46,11 +44,15 @@ class FOWebView: WKWebView { } private class FOWKWebViewConfiguration: WKWebViewConfiguration { - public static let shared = FOWKWebViewConfiguration() + private static let sharedProcessPool = WKProcessPool() override init() { super.init() let configuration = self + // Share process pool and default data store across instances for faster warm-up + configuration.processPool = FOWKWebViewConfiguration.sharedProcessPool + configuration.websiteDataStore = .default() + let hexAccentColor = Utils.accentColor.toHex() let css = """ :root { overflow: hidden !important; overflow-behavior: none !important; } @@ -105,34 +107,29 @@ private class FOWKWebViewConfiguration: WKWebViewConfiguration { schemeHandler, forURLScheme: FollowImageURLSchemeHandler.rewriteScheme ) - let customSchemeScript = WKUserScript( + // Only rewrite .src to custom scheme; keep XHR/fetch unchanged to avoid breaking CORS/auth + let customImageScript = WKUserScript( source: """ (function() { - const originalXHROpen = XMLHttpRequest.prototype.open; - XMLHttpRequest.prototype.open = function(method, url, ...args) { - const modifiedUrl = url.replace(/^https?:/, '\(FollowImageURLSchemeHandler.rewriteScheme):'); - originalXHROpen.call(this, method, modifiedUrl, ...args); - }; - - const originalFetch = window.fetch; - window.fetch = function(url, options) { - const modifiedUrl = url.replace(/^https?:/, '\(FollowImageURLSchemeHandler.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?:/, '\(FollowImageURLSchemeHandler.rewriteScheme):'); - originalImageSrc.set.call(this, modifiedUrl); - } - }); + if (originalImageSrc && originalImageSrc.set) { + Object.defineProperty(Image.prototype, 'src', { + set: function(url) { + try { + const modifiedUrl = (typeof url === 'string') ? url.replace(/^https?:/, '\(FollowImageURLSchemeHandler.rewriteScheme):') : url; + originalImageSrc.set.call(this, modifiedUrl); + } catch (e) { + originalImageSrc.set.call(this, url); + } + } + }); + } })(); """, injectionTime: .atDocumentStart, forMainFrameOnly: false ) - configuration.userContentController.addUserScript(customSchemeScript) + configuration.userContentController.addUserScript(customImageScript) configuration.userContentController.addUserScript(script2) } @@ -177,6 +174,11 @@ extension FOWebView: WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate guard let data = data else { return } switch data.type { + case "ready": + // Notify manager that the bridge is ready and flush queued scripts + DispatchQueue.main.async { + WebViewManager.markReadyAndFlush() + } case "setContentHeight": let data = try? JSONDecoder().decode( SetContentHeightPayload.self, from: decode @@ -188,7 +190,7 @@ extension FOWebView: WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate } case "measure": - measureWebView(SharedWebViewModule.sharedWebView!) + measureWebView(self) case "previewImage": let data = try? JSONDecoder().decode( @@ -239,6 +241,13 @@ extension FOWebView: WKNavigationDelegate, WKScriptMessageHandler, WKUIDelegate } } + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + // Attempt a graceful recovery via the manager + DispatchQueue.main.async { + WebViewManager.handleProcessTerminated() + } + } + func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures diff --git a/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_end.js b/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_end.js index b4ee8f537..90d78333e 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_end.js +++ b/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_end.js @@ -7,13 +7,22 @@ ;(() => { const root = document.querySelector("#root") + let ticking = false const handleHeight = () => { - window.webkit.messageHandlers.message.postMessage( - JSON.stringify({ - type: "setContentHeight", - payload: root.scrollHeight, - }), - ) + if (ticking) return + ticking = true + setTimeout(() => { + try { + window.webkit.messageHandlers.message.postMessage( + JSON.stringify({ + type: "setContentHeight", + payload: root?.scrollHeight || document.documentElement.scrollHeight, + }), + ) + } finally { + ticking = false + } + }, 16) } window.addEventListener("load", handleHeight) const observer = new ResizeObserver(handleHeight) @@ -22,4 +31,14 @@ handleHeight() }, 1000) observer.observe(root) + + // Fallback: ensure readiness is signaled at end if not yet sent + if (!window.__FO_WEBVIEW_READY__) { + try { + window.__FO_WEBVIEW_READY__ = true + window.webkit.messageHandlers.message.postMessage(JSON.stringify({ type: "ready" })) + } catch { + /* empty */ + } + } })() diff --git a/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_start.js b/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_start.js index 895a672b0..9fb2583e4 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_start.js +++ b/apps/mobile/native/ios/Modules/SharedWebView/Injected/at_start.js @@ -41,4 +41,21 @@ }) }, } + + // Signal readiness once DOM is interactive/loaded (guard to send once) + if (!window.__FO_WEBVIEW_READY__) { + let sent = false + const sendReady = () => { + if (sent) return + sent = true + window.__FO_WEBVIEW_READY__ = true + try { + send({ type: "ready" }) + } catch { + /* empty */ + } + } + document.addEventListener("DOMContentLoaded", sendReady) + window.addEventListener("load", sendReady) + } })() diff --git a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift index f621082d8..9cbfef205 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebView.swift @@ -9,7 +9,6 @@ class WebViewView: ExpoView { required init(appContext: AppContext? = nil) { super.init(appContext: appContext) - addSubview(SharedWebViewModule.sharedWebView!) clipsToBounds = true cancellable = WebViewManager.state.$contentHeight @@ -32,12 +31,18 @@ class WebViewView: ExpoView { width: bounds.width, height: WebViewManager.state.contentHeight ) - guard let webView = SharedWebViewModule.sharedWebView else { return } - webView.frame = rect - webView.scrollView.frame = rect - + WebViewManager.updateFrame(rect) frame = rect onContentHeightChange(["height": Float(rect.height)]) } + + override func didMoveToWindow() { + super.didMoveToWindow() + if window != nil { + WebViewManager.attach(to: self) + } else { + WebViewManager.detach(from: self) + } + } } diff --git a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift index d755a3ac7..e82ff5aac 100644 --- a/apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift +++ b/apps/mobile/native/ios/Modules/SharedWebView/SharedWebViewModule.swift @@ -13,131 +13,147 @@ let onImagePreview = "onImagePreview" let onSeekAudio = "onSeekAudio" public class SharedWebViewModule: Module { - private var pendingJavaScripts: [String] = [] - private var cancellables = Set() + private var cancellables = Set() - public static var sharedWebView: WKWebView? { - WebViewManager.shared + public static var sharedWebView: WKWebView? { + WebViewManager.shared + } + + public func definition() -> ModuleDefinition { + Name("FOSharedWebView") + + Function("load") { (urlString: String) in + WebViewManager.load(urlString) } - public func definition() -> ModuleDefinition { - Name("FOSharedWebView") - - Function("load") { (urlString: String) in - DispatchQueue.main.async { - self.load(urlString: urlString) - } - } - - Function("evaluateJavaScript") { (js: String) in - DispatchQueue.main.async { - WebViewManager.evaluateJavaScript(js) - } - } - - View(WebViewView.self) { - Events("onContentHeightChange") - Events("onSeekAudio") - - Prop("url") { (_: UIView, urlString: String) in - DispatchQueue.main.async { - self.load(urlString: urlString) - } - } - } - - Events(onContentHeightChanged) - Events(onImagePreview) - Events(onSeekAudio) - - OnStartObserving { - // Monitor content height changes - WebViewManager.state.$contentHeight - .receive(on: DispatchQueue.main) - .sink { [weak self] height in - self?.sendEvent(onContentHeightChanged, ["height": height]) - } - .store(in: &self.cancellables) - - // Monitor image preview events - WebViewManager.state.$imagePreviewEvent - .receive(on: DispatchQueue.main) - .compactMap { $0 } // Filter out nil values - .sink { [weak self] event in - self?.sendEvent(onImagePreview, ["imageUrls": event.imageUrls, "index": event.index]) - } - .store(in: &self.cancellables) - - WebViewManager.state.$audioSeekEvent - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .sink { [weak self] event in - self?.sendEvent(onSeekAudio, ["time": event.time]) - } - .store(in: &self.cancellables) - } - - OnStopObserving { - self.cancellables.forEach { $0.cancel() } - self.cancellables.removeAll() - } + Function("evaluateJavaScript") { (js: String) in + // Prefer typed setters or dispatch to record state; avoid parsing free-form JS + WebViewManager.evaluateJavaScript(js) } - private func load(urlString: String) { - guard let webView = SharedWebViewModule.sharedWebView else { - return - } - // Check is local file - let urlProtocol = "file://" - if urlString.starts(with: urlProtocol) { - let localHtml = self.getLocalHTML(from: urlString) - - if let localHtml = localHtml { - webView.loadFileURL( - localHtml, - allowingReadAccessTo: localHtml.deletingLastPathComponent() - ) - - debugPrint("load local html: \(localHtml.absoluteString)") - - return - } - } - - if let url = URL(string: urlString) { - if url == webView.url { - return - } - debugPrint("load remote html: \(url.absoluteString)") - webView.load(URLRequest(url: url)) - } + // Centralized dispatch to JS layer (native -> JS) + Function("dispatch") { (type: String, payload: String?) in + let payloadExpr: String + if let payload = payload { + // payload is JSON string; pass via JSON.parse to avoid double-escaping + payloadExpr = "JSON.parse(\(self.jsonString(payload)))" + } else { + payloadExpr = "null" + } + let js = + "(function(){ if (window.__FO_BRIDGE__ && typeof window.__FO_BRIDGE__.dispatch === 'function') { window.__FO_BRIDGE__.dispatch(\(self.jsonString(type)), \(payloadExpr)); } })()" + WebViewManager.recordJSON(key: type, payload: payload) + WebViewManager.evaluateJavaScript(js) } - private func getLocalHTML(from fileURL: String) -> URL? { - if let url = URL(string: fileURL), url.scheme == "file" { + View(WebViewView.self) { + Events("onContentHeightChange") + Events("onSeekAudio") - let directoryPath = url.deletingLastPathComponent().absoluteString.replacingOccurrences( - of: "file://", with: "" - ) - let fileName = url.lastPathComponent - let fileExtension = url.pathExtension - - if let fileURL = Bundle.main - .url( - forResource: String(fileName.dropLast(Int(fileExtension.count) + 1)), - withExtension: fileExtension, - subdirectory: directoryPath - ) - { - - return fileURL - } else { - return nil - - } - } else { - debugPrint("Invalidate url") - return nil - } + Prop("url") { (_: UIView, urlString: String) in + WebViewManager.load(urlString) + } } + + Events(onContentHeightChanged) + Events(onImagePreview) + Events(onSeekAudio) + + OnCreate { + WebViewManager.initializeLifecycleObservers() + } + + OnDestroy { + WebViewManager.cleanupLifecycleObservers() + } + OnStartObserving { + // Monitor content height changes + WebViewManager.state.$contentHeight + .receive(on: DispatchQueue.main) + .sink { [weak self] height in + self?.sendEvent(onContentHeightChanged, ["height": height]) + } + .store(in: &self.cancellables) + + // Monitor image preview events + WebViewManager.state.$imagePreviewEvent + .receive(on: DispatchQueue.main) + .compactMap { $0 } // Filter out nil values + .sink { [weak self] event in + self?.sendEvent(onImagePreview, ["imageUrls": event.imageUrls, "index": event.index]) + } + .store(in: &self.cancellables) + + WebViewManager.state.$audioSeekEvent + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] event in + self?.sendEvent(onSeekAudio, ["time": event.time]) + } + .store(in: &self.cancellables) + } + + OnStopObserving { + self.cancellables.forEach { $0.cancel() } + self.cancellables.removeAll() + } + + // Debug helpers + Function("getDebugState") { () -> [String: Any] in + WebViewManager.debugState() + } + Function("destroyForDebug") { + WebViewManager.destroyForDebug() + } + Function("reloadLastURL") { + WebViewManager.reloadLastURL() + } + Function("flushQueue") { + WebViewManager.flushForDebug() + } + } + + private func getLocalHTML(from fileURL: String) -> URL? { + if let url = URL(string: fileURL), url.scheme == "file" { + let directoryPath = url.deletingLastPathComponent().absoluteString.replacingOccurrences( + of: "file://", with: "" + ) + let fileName = url.lastPathComponent + let fileExtension = url.pathExtension + + if let fileURL = Bundle.main + .url( + forResource: String(fileName.dropLast(Int(fileExtension.count) + 1)), + withExtension: fileExtension, + subdirectory: directoryPath + ) + { + return fileURL + } else { + return nil + } + } else { + debugPrint("Invalidate url") + return nil + } + } + + // Removed JS string parsing helpers; state is captured by typed setters or dispatch/setStateJSON + + fileprivate func jsonString(_ value: String) -> String { + // Encode as JSON string literal + let data = try? JSONSerialization.data(withJSONObject: ["v": value], options: []) + if let data = data, let s = String(data: data, encoding: .utf8) { + if let range = s.range(of: ":") { + return String(s[range.upperBound.. WKWebView { + if let webView = shared { return webView } + let webView = FOWebView(frame: .zero, state: state) + shared = webView + return webView + } + + private static func flushPendingScripts() { + guard let webView = shared, webView.url != nil, !webView.isLoading, isReady else { return } + let scripts = pendingJavaScripts + pendingJavaScripts.removeAll() + for js in scripts { + webView.evaluateJavaScript(js) + } + } + + private static func replayState() { + guard let json = buildStateJSON() else { return } + let call = + "(function(){ if (window.__FO_BRIDGE__ && typeof window.__FO_BRIDGE__.applyState === 'function') { window.__FO_BRIDGE__.applyState(JSON.parse(\(jsonString(json)))); } })()" + pendingJavaScripts.append(call) + flushPendingScripts() + } + + private static func buildStateJSON() -> String? { + guard JSONSerialization.isValidJSONObject(lastState) else { + // Try to sanitize by converting non-JSON types to string + var sanitized: [String: Any] = [:] + for (k, v) in lastState { + if JSONSerialization.isValidJSONObject([k: v]) { + sanitized[k] = v + } else { + sanitized[k] = String(describing: v) + } + } + if let data = try? JSONSerialization.data(withJSONObject: sanitized, options: []) { + return String(data: data, encoding: .utf8) + } + return nil + } + if let data = try? JSONSerialization.data(withJSONObject: lastState, options: []) { + return String(data: data, encoding: .utf8) + } + return nil + } + + private static func jsonString(_ value: String) -> String { + // Wrap as JSON string literal + let data = try? JSONSerialization.data(withJSONObject: ["v": value], options: []) + if let data = data, let s = String(data: data, encoding: .utf8) { + // {"v":"..."} + if let range = s.range(of: ":") { + return String(s[range.upperBound.. Void)? = nil) { - let safariVC = SafariViewController(url: url) - safariVC.view.tintColor = Utils.accentColor - safariVC.preferredControlTintColor = Utils.accentColor + shared = nil + } - if let onDismiss = onDismiss { safariVC.setOnDismiss(onDismiss) } - viewController.present(safariVC, animated: true) + private static func cleanup(_ webView: WKWebView) { + webView.stopLoading() + webView.navigationDelegate = nil + webView.uiDelegate = nil + if let fo = webView as? FOWebView { + fo.configuration.userContentController.removeScriptMessageHandler(forName: "message") } + webView.removeFromSuperview() + + debugPrint("destroy webview: \(debugState())") + } + + // MARK: - Debug helpers + public static func debugState() -> [String: Any] { + var dict: [String: Any] = [:] + dict["hasWebView"] = shared != nil + dict["hasHost"] = currentHost != nil + dict["ready"] = isReady + dict["pending"] = pendingJavaScripts.count + dict["lastURL"] = lastURL ?? NSNull() + dict["contentHeight"] = Double(state.contentHeight) + dict["keys"] = Array(lastState.keys) + return dict + } + + public static func destroyForDebug() { + destroyWebView() + } + + public static func reloadLastURL() { + if let url = lastURL { load(url) } + } + + public static func flushForDebug() { + flushPendingScripts() + } + + private static func resolveLocalHTML(from fileURL: String) -> URL? { + // Map RN-provided file:// path to actual bundle resource url + if let url = URL(string: fileURL), url.scheme == "file" { + let directoryPath = url.deletingLastPathComponent().absoluteString.replacingOccurrences( + of: "file://", with: "") + let fileName = url.lastPathComponent + let fileExtension = url.pathExtension + if let fileURL = Bundle.main.url( + forResource: String(fileName.dropLast(Int(fileExtension.count) + 1)), + withExtension: fileExtension, + subdirectory: directoryPath + ) { + return fileURL + } else { + return nil + } + } + return nil + } + + // Existing method preserved + static func presentModalWebView( + url: URL, from viewController: UIViewController, onDismiss: (() -> Void)? = nil + ) { + let safariVC = SafariViewController(url: url) + safariVC.view.tintColor = Utils.accentColor + safariVC.preferredControlTintColor = Utils.accentColor + + if let onDismiss = onDismiss { safariVC.setOnDismiss(onDismiss) } + viewController.present(safariVC, animated: true) + } } -// SwiftUI wrapper for the shared WKWebView +// SwiftUI wrapper for the shared WKWebView (debug/internals) struct SharedWebViewUI: UIViewRepresentable { - func makeUIView(context: Context) -> WKWebView { - return WebViewManager.shared - } + func makeUIView(context: Context) -> WKWebView { + return WebViewManager.getOrCreate() + } - func updateUIView(_ uiView: WKWebView, context: Context) { - } + func updateUIView(_ uiView: WKWebView, context: Context) { + } } extension WebViewManager { - static var swiftUIView: some View { - SharedWebViewUI() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + static var swiftUIView: some View { + SharedWebViewUI() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } -fileprivate class SafariViewController: SFSafariViewController { - private var onDismiss: (() -> Void)? +private class SafariViewController: SFSafariViewController { + private var onDismiss: (() -> Void)? - public func setOnDismiss(_ onDismiss: @escaping () -> Void) { - self.onDismiss = onDismiss - } + public func setOnDismiss(_ onDismiss: @escaping () -> Void) { + self.onDismiss = onDismiss + } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - onDismiss?() - } + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + onDismiss?() + } } diff --git a/apps/mobile/src/components/native/webview/DebugPanel.tsx b/apps/mobile/src/components/native/webview/DebugPanel.tsx new file mode 100644 index 000000000..e2402765f --- /dev/null +++ b/apps/mobile/src/components/native/webview/DebugPanel.tsx @@ -0,0 +1,91 @@ +import { Portal } from "@gorhom/portal" +import * as React from "react" +import { useEffect, useMemo, useState } from "react" +import { Text, TouchableOpacity, View } from "react-native" + +import { BugCuteReIcon } from "@/src/icons/bug_cute_re" +import { CloseCuteReIcon } from "@/src/icons/close_cute_re" + +import { htmlUrl } from "./constants" +import type { WebViewDebugState } from "./index" +import { SharedWebViewModule } from "./index" + +interface DebugPanelProps { + mode: "debug" | "normal" + onModeToggle: () => void +} + +const SimpleButton = ({ label, onPress }: { label: string; onPress: () => void }) => ( + + {label} + +) + +export function DebugPanel({ mode, onModeToggle }: DebugPanelProps) { + const [visible, setVisible] = useState(false) + const [debugState, setDebugState] = useState(null) + + // Get debug state periodically + useEffect(() => { + const id = setInterval(() => { + try { + const d = SharedWebViewModule.getDebugState?.() + if (d) setDebugState(d as WebViewDebugState) + } catch { + /* empty */ + } + }, 1000) + return () => clearInterval(id) + }, []) + + // Simple debug info + const debugInfo = useMemo(() => { + if (!debugState) return "Loading..." + const ready = debugState.ready && debugState.hasWebView + return `${ready ? "✅" : "❌"} WV:${debugState.hasWebView} H:${Math.round(debugState.contentHeight)}` + }, [debugState]) + + if (!__DEV__) return null + + return ( + + {/* Debug Float Button */} + + setVisible(true)} + > + + + + + {/* Debug Panel */} + {visible && ( + + + WebView Debug + setVisible(false)}> + + + + + + {debugInfo} + + + + + SharedWebViewModule.destroyForDebug?.()} /> + SharedWebViewModule.reloadLastURL?.()} /> + SharedWebViewModule.load(htmlUrl)} /> + SharedWebViewModule.flushQueue?.()} /> + + + )} + + ) +} diff --git a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx index fef5b728b..e6475d473 100644 --- a/apps/mobile/src/components/native/webview/EntryContentWebView.tsx +++ b/apps/mobile/src/components/native/webview/EntryContentWebView.tsx @@ -1,19 +1,18 @@ -import { clsx } from "@follow/utils" import { EventBus } from "@follow/utils/event-bus" import { Portal } from "@gorhom/portal" import { useAtom } from "jotai" import * as React from "react" import { useCallback, useEffect } from "react" -import { TouchableOpacity, View } from "react-native" +import { View } from "react-native" import { runOnJS, runOnUI } from "react-native-reanimated" import TrackPlayer from "react-native-track-player" -import { BugCuteReIcon } from "@/src/icons/bug_cute_re" import { player } from "@/src/lib/player" import { useLightboxControls } from "../../ui/lightbox/lightboxState" import { PlatformActivityIndicator } from "../../ui/loading/PlatformActivityIndicator" import { sharedWebViewHeightAtom } from "./atom" +import { DebugPanel } from "./DebugPanel" import { useWebViewEntry, useWebViewMode } from "./hooks" import type { AudioSeekEvent } from "./index" import { prepareEntryRenderWebView } from "./index" @@ -97,7 +96,6 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { const handleModeToggle = React.useCallback(() => { const nextMode = mode === "debug" ? "normal" : "debug" - handleModeSwitch(nextMode) }, [mode, handleModeSwitch]) @@ -125,23 +123,7 @@ export function EntryContentWebView(props: EntryContentWebViewProps) { )} - {__DEV__ && ( - - - - - - - - )} + ) } - -export { preloadWebViewEntry } from "./webview-manager" diff --git a/apps/mobile/src/components/native/webview/index.android.ts b/apps/mobile/src/components/native/webview/index.android.ts index 8b7597bda..aed8e43e5 100644 --- a/apps/mobile/src/components/native/webview/index.android.ts +++ b/apps/mobile/src/components/native/webview/index.android.ts @@ -7,6 +7,13 @@ export const SharedWebViewModule = { evaluateJavaScript: (js: string) => { injectJavaScript(js) }, + dispatch: (type: string, payload?: string) => { + const payloadExpr = payload != null ? `JSON.parse(${JSON.stringify(payload)})` : "null" + const js = `;(function(){try{if(window.__FO_BRIDGE__&&typeof window.__FO_BRIDGE__.dispatch==='function'){window.__FO_BRIDGE__.dispatch(${JSON.stringify( + type, + )}, ${payloadExpr});}}catch(e){}})();` + injectJavaScript(js) + }, } export const prepareEntryRenderWebView = () => {} diff --git a/apps/mobile/src/components/native/webview/index.ts b/apps/mobile/src/components/native/webview/index.ts index a182acad6..6e891e077 100644 --- a/apps/mobile/src/components/native/webview/index.ts +++ b/apps/mobile/src/components/native/webview/index.ts @@ -27,13 +27,30 @@ declare class ISharedWebViewModule extends NativeModule<{ }> { load(url: string): void evaluateJavaScript(js: string): void + dispatch?(type: string, payload?: string): void + + // Debug helpers (iOS only) + getDebugState?(): WebViewDebugState + destroyForDebug?(): void + reloadLastURL?(): void + flushQueue?(): void } export const SharedWebViewModule = requireNativeModule("FOSharedWebView") // Re-export all WebView utilities export { usePrepareEntryRenderWebView, useWebViewEntry, useWebViewMode } from "./hooks" -export { preloadWebViewEntry, WebViewManager } from "./webview-manager" +export { WebViewManager } from "./webview-manager" + +// Dev/debug helpers (iOS implemented; Android no-op) +export type WebViewDebugState = { + hasWebView: boolean + hasHost: boolean + ready: boolean + pending: number + lastURL?: string | null + contentHeight: number +} let prepareOnce = false diff --git a/apps/mobile/src/components/native/webview/webview-manager.ts b/apps/mobile/src/components/native/webview/webview-manager.ts index f4ecaabcd..765208152 100644 --- a/apps/mobile/src/components/native/webview/webview-manager.ts +++ b/apps/mobile/src/components/native/webview/webview-manager.ts @@ -11,9 +11,7 @@ export const WebViewManager = { * Set code highlighting themes for light and dark modes */ setCodeTheme(light: string, dark: string): void { - SharedWebViewModule.evaluateJavaScript( - `setCodeTheme(${JSON.stringify(light)}, ${JSON.stringify(dark)})`, - ) + SharedWebViewModule.dispatch?.("setCodeTheme", JSON.stringify({ light, dark })) }, /** @@ -21,38 +19,36 @@ export const WebViewManager = { */ setEntry(entry?: EntryModel | null): void { if (!entry) return - - SharedWebViewModule.evaluateJavaScript( - `setEntry(JSON.parse(${JSON.stringify(JSON.stringify(entry))}))`, - ) + const json = JSON.stringify(entry) + SharedWebViewModule.dispatch?.("setEntry", json) }, /** * Set root font size for the WebView */ setRootFontSize(size = 16): void { - SharedWebViewModule.evaluateJavaScript(`setRootFontSize(${size})`) + SharedWebViewModule.dispatch?.("setRootFontSize", JSON.stringify(size)) }, /** * Toggle media display in WebView */ setNoMedia(value: boolean): void { - SharedWebViewModule.evaluateJavaScript(`setNoMedia(${value})`) + SharedWebViewModule.dispatch?.("setNoMedia", JSON.stringify(value)) }, /** * Set reader render inline style preference */ setReaderRenderInlineStyle(value: boolean): void { - SharedWebViewModule.evaluateJavaScript(`setReaderRenderInlineStyle(${value})`) + SharedWebViewModule.dispatch?.("setReaderRenderInlineStyle", JSON.stringify(value)) }, /** * Execute custom JavaScript code in WebView */ executeScript(script: string): void { - SharedWebViewModule.evaluateJavaScript(script) + SharedWebViewModule.dispatch?.("executeScript", JSON.stringify(script)) }, /** @@ -62,6 +58,3 @@ export const WebViewManager = { SharedWebViewModule.load(url) }, } - -// Export for backward compatibility -export const preloadWebViewEntry = WebViewManager.setEntry.bind(WebViewManager) diff --git a/apps/mobile/src/modules/context-menu/entry.tsx b/apps/mobile/src/modules/context-menu/entry.tsx index 362e5504e..83c515d1d 100644 --- a/apps/mobile/src/modules/context-menu/entry.tsx +++ b/apps/mobile/src/modules/context-menu/entry.tsx @@ -12,7 +12,7 @@ import { Share, View } from "react-native" import { getHideAllReadSubscriptions } from "@/src/atoms/settings/general" import { EntryContentWebView } from "@/src/components/native/webview/EntryContentWebView" -import { preloadWebViewEntry } from "@/src/components/native/webview/webview-manager" +import { WebViewManager } from "@/src/components/native/webview/webview-manager" import { ContextMenu } from "@/src/components/ui/context-menu" import { Text } from "@/src/components/ui/typography/Text" import { useNavigation } from "@/src/lib/navigation/hooks" @@ -46,7 +46,7 @@ export const EntryItemContextMenu = ({ if (entry) { const fullEntry = getEntry(id) if (fullEntry) { - preloadWebViewEntry(fullEntry) + WebViewManager.setEntry(fullEntry) } navigation.pushControllerView(EntryDetailScreen, { entryId: id, diff --git a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx index 0aedaa1ad..478868049 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryNormalItem.tsx @@ -13,7 +13,7 @@ import { View } from "react-native" import { useActionLanguage, useGeneralSettingKey } from "@/src/atoms/settings/general" import { useUISettingKey } from "@/src/atoms/settings/ui" -import { preloadWebViewEntry } from "@/src/components/native/webview/webview-manager" +import { WebViewManager } from "@/src/components/native/webview/webview-manager" import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime" import { FeedIcon } from "@/src/components/ui/icon/feed-icon" import { Image } from "@/src/components/ui/image/Image" @@ -67,7 +67,7 @@ export const EntryNormalItem = memo( const handlePress = useCallback(() => { if (entry) { const fullEntry = getEntry(entryId) - preloadWebViewEntry(fullEntry) + WebViewManager.setEntry(fullEntry) tracker.navigateEntry({ feedId: entry.feedId!, entryId: entry.id, diff --git a/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx b/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx index c370d338a..b1629d8b1 100644 --- a/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx +++ b/apps/mobile/src/modules/entry-list/templates/EntryPictureItem.tsx @@ -78,8 +78,7 @@ export function EntryPictureItem({ id }: { id: string }) { index, }) })() - // const fullEntry = getEntry(id) - // preloadWebViewEntry(fullEntry) + unreadSyncService.markEntryAsRead(id) }} /> diff --git a/apps/mobile/web-app/html-renderer/src/managers/webview-bridge.ts b/apps/mobile/web-app/html-renderer/src/managers/webview-bridge.ts index 8ff290238..b24f2aea0 100644 --- a/apps/mobile/web-app/html-renderer/src/managers/webview-bridge.ts +++ b/apps/mobile/web-app/html-renderer/src/managers/webview-bridge.ts @@ -31,9 +31,9 @@ export class WebViewBridgeManager { /** * Set code highlighting themes for light and dark modes */ - setCodeTheme = (light: string, dark: string) => { - this.store.set(codeThemeLightAtom, light) - this.store.set(codeThemeDarkAtom, dark) + setCodeTheme = (v: { light: string; dark: string }) => { + this.store.set(codeThemeLightAtom, v.light) + this.store.set(codeThemeDarkAtom, v.dark) } /** @@ -70,13 +70,72 @@ export class WebViewBridgeManager { * This maintains backward compatibility with existing native code */ exposeToWindow() { - Object.assign(window, { - setEntry: this.setEntry, - setCodeTheme: this.setCodeTheme, - setReaderRenderInlineStyle: this.setReaderRenderInlineStyle, - setNoMedia: this.setNoMedia, - setRootFontSize: this.setRootFontSize, - reset: this.reset, - }) + // Minimal native->JS dispatch bridge to centralize API surface + if (!window.__FO_BRIDGE__) { + const handlers = { + setEntry: this.setEntry, + setCodeTheme: this.setCodeTheme, + setReaderRenderInlineStyle: this.setReaderRenderInlineStyle, + setNoMedia: this.setNoMedia, + setRootFontSize: this.setRootFontSize, + } as const + + const tryParse = (v: any): any => { + if (typeof v !== "string") return v + const s = v.trim() + if (!s) return v + if ((s.startsWith("{") && s.endsWith("}")) || (s.startsWith("[") && s.endsWith("]"))) { + try { + return JSON.parse(s) + } catch { + return v + } + } + if (s === "true") return true + if (s === "false") return false + const n = Number(s) + if (!Number.isNaN(n) && s === String(n)) return n + return v + } + + window.__FO_BRIDGE__ = { + dispatch(type, payload) { + try { + // @ts-expect-error + const fn = handlers[type] + if (typeof fn === "function") { + fn(tryParse(payload)) + } else { + console.warn("[FO_BRIDGE] No handler for", type) + } + } catch (e) { + console.error("[FO_BRIDGE] dispatch error", type, e) + } + }, + applyState(state) { + try { + if (!state || typeof state !== "object") return + for (const key of Object.keys(state)) { + const fn = handlers[key as keyof typeof handlers] + if (typeof fn === "function") { + // @ts-expect-error + fn(tryParse(state[key])) + } + } + } catch (e) { + console.error("[FO_BRIDGE] applyState error", e) + } + }, + } + } + } +} + +declare global { + interface Window { + __FO_BRIDGE__: { + dispatch: (type: string, payload: string) => void + applyState: (state: Record) => void + } } }