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 <tukon479@gmail.com>
This commit is contained in:
Innei 2025-08-26 22:20:57 +08:00
parent b0c4052632
commit a9bb77167e
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
19 changed files with 1949 additions and 438 deletions

1
.gitignore vendored
View File

@ -28,4 +28,3 @@ buildServer.json
apps/desktop/build/appxmanifest.xml
.claude/settings.local.json
PRPS/*

View File

@ -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<String>()
private var lastUrl: String? = null
private val lastState = mutableMapOf<String, Any>()
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<String, ByteArray>(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<Float> = _contentHeight.asStateFlow()
private val _imagePreviewEvent = MutableStateFlow<ImagePreviewEvent?>(null)
val imagePreviewEvent: StateFlow<ImagePreviewEvent?> = _imagePreviewEvent.asStateFlow()
private val _audioSeekEvent = MutableStateFlow<AudioSeekEvent?>(null)
val audioSeekEvent: StateFlow<AudioSeekEvent?> = _audioSeekEvent.asStateFlow()
fun updateContentHeight(height: Float) { _contentHeight.value = height }
fun triggerImagePreview(urls: List<String>, index: Int) {
_imagePreviewEvent.value = ImagePreviewEvent(urls, index)
}
fun triggerAudioSeek(time: Double) {
_audioSeekEvent.value = AudioSeekEvent(time)
}
}
data class ImagePreviewEvent(val imageUrls: List<String>, 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.

397
PRPs/ratio-based-mixing.md Normal file
View File

@ -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
<div className="bg-[color-mix(in_srgb,hsl(var(--fo-a)),hsl(var(--background))_70%)]">
User message
</div>
// AFTER: Clean ratio syntax
<div className="bg-mix-accent/background-7/3">
User message
</div>
// ALTERNATIVE: Percentage syntax (when mixing with background)
<div className="bg-mix-accent-70">
User message
</div>
```
### 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

View File

@ -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<T: UIView>(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<T: UIView>(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)
}
}
}

View File

@ -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 <img>.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

View File

@ -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 */
}
}
})()

View File

@ -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)
}
})()

View File

@ -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)
}
}
}

View File

@ -13,131 +13,147 @@ let onImagePreview = "onImagePreview"
let onSeekAudio = "onSeekAudio"
public class SharedWebViewModule: Module {
private var pendingJavaScripts: [String] = []
private var cancellables = Set<AnyCancellable>()
private var cancellables = Set<AnyCancellable>()
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..<s.index(before: s.endIndex)])
}
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(
of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
return ""
}
// recordDispatchState removed: dynamic state is recorded directly via WebViewManager.recordJSON
}

View File

@ -8,89 +8,372 @@ import Combine
import ExpoModulesCore
import SafariServices
import SwiftUI
import UIKit
@preconcurrency import WebKit
private var pendingJavaScripts: [String] = []
// Add protocol for handling link clicks
protocol WebViewLinkDelegate: AnyObject {
func webView(_ webView: WKWebView, shouldOpenURL url: URL)
func webView(_ webView: WKWebView, shouldOpenURL url: URL)
}
enum WebViewManager {
static var state = WebViewState()
// Public observable state
static var state = WebViewState()
public static func evaluateJavaScript(_ js: String) {
DispatchQueue.main.async {
guard let webView = SharedWebViewModule.sharedWebView else {
pendingJavaScripts.append(js)
return
}
guard webView.url != nil else {
pendingJavaScripts.append(js)
return
}
// Shared instance and lifecycle
private(set) static var shared: WKWebView?
private static weak var currentHost: UIView?
private static var observersAdded = false
private static var destroyWorkItem: DispatchWorkItem?
if webView.isLoading {
pendingJavaScripts.append(js)
} else {
webView.evaluateJavaScript(js)
}
}
// Readiness and pending scripts
private static var isReady = false
private static var pendingJavaScripts: [String] = []
// Last known URL and replayable state (dynamic key-value, like JS object)
private static var lastURL: String?
private static var lastState: [String: Any] = [:]
// MARK: - Public API
public static func initializeLifecycleObservers() {
guard !observersAdded else { return }
observersAdded = true
NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil,
queue: .main
) { _ in
didEnterBackground()
}
private(set) static var shared: WKWebView = {
if Thread.isMainThread {
return FOWebView(frame: .zero, state: state)
}
return DispatchQueue.main.sync {
FOWebView(frame: .zero, state: state)
}
}()
NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { _ in
willEnterForeground()
}
}
static func resetWebView() {
DispatchQueue.main.async {
state = WebViewState()
shared = FOWebView(frame: .zero, state: state)
public static func cleanupLifecycleObservers() {
guard observersAdded else { return }
observersAdded = false
// Remove notification observers
NotificationCenter.default.removeObserver(
self,
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.removeObserver(
self,
name: UIApplication.willEnterForegroundNotification,
object: nil
)
// Cancel any pending destroy work item
destroyWorkItem?.cancel()
destroyWorkItem = nil
}
public static func evaluateJavaScript(_ js: String) {
DispatchQueue.main.async {
// Queue until ready/loading completed
guard let webView = shared, webView.url != nil, !webView.isLoading, isReady else {
pendingJavaScripts.append(js)
return
}
webView.evaluateJavaScript(js)
}
}
public static func load(_ urlString: String) {
DispatchQueue.main.async {
lastURL = urlString
let webView = getOrCreate()
guard let url = URL(string: urlString) else { return }
if webView.url == url && !webView.isLoading { return }
isReady = false
if url.scheme == "file" {
if let localHtml = resolveLocalHTML(from: urlString) {
webView.loadFileURL(
localHtml, allowingReadAccessTo: localHtml.deletingLastPathComponent())
debugPrint("load local html: \(localHtml.absoluteString)")
} else {
debugPrint("Invalid local html url: \(urlString)")
}
} else {
webView.load(URLRequest(url: url))
debugPrint("load remote html: \(url.absoluteString)")
}
}
}
public static func attach(to host: UIView) {
DispatchQueue.main.async {
let webView = getOrCreate()
// Move from previous host if needed
if webView.superview !== host {
webView.removeFromSuperview()
host.addSubview(webView)
}
currentHost = host
}
}
public static func detach(from host: UIView) {
DispatchQueue.main.async {
guard currentHost === host else { return }
currentHost = nil
// Do not remove subview immediately; releasing is handled by background logic
}
}
public static func updateFrame(_ rect: CGRect) {
DispatchQueue.main.async {
guard let webView = shared else { return }
webView.frame = rect
webView.scrollView.frame = rect
}
}
public static func markReadyAndFlush() {
DispatchQueue.main.async {
isReady = true
flushPendingScripts()
}
}
public static func handleProcessTerminated() {
DispatchQueue.main.async {
// Try to reload last URL and replay state
isReady = false
if let urlString = lastURL {
load(urlString)
}
replayState()
}
}
// Generic state recorders
public static func record(key: String, value: Any) {
lastState[key] = value
}
public static func recordJSON(key: String, payload: String?) {
guard let payload else {
lastState[key] = NSNull()
return
}
if let data = payload.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data)
{
lastState[key] = obj
} else {
// Store raw string if not valid JSON
lastState[key] = payload
}
}
// MARK: - Internals
@discardableResult
static func getOrCreate() -> 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..<s.index(before: s.endIndex)])
}
}
// Fallback naive escaping
let escaped = value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(
of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
private static func didEnterBackground() {
// If not attached to a host, schedule destroy to save memory
guard currentHost == nil else { return }
destroyWorkItem?.cancel()
let work = DispatchWorkItem { destroyWebView() }
destroyWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0, execute: work)
}
private static func willEnterForeground() {
destroyWorkItem?.cancel()
// Prewarm by recreating and loading lastURL
if shared == nil {
_ = getOrCreate()
if let urlString = lastURL {
load(urlString)
}
replayState()
}
}
private static func destroyWebView() {
guard let webView = shared else { return }
isReady = false
pendingJavaScripts.removeAll()
// Detach and cleanup on main thread
if Thread.isMainThread {
cleanup(webView)
} else {
DispatchQueue.main.sync { cleanup(webView) }
}
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
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?()
}
}

View File

@ -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 }) => (
<TouchableOpacity
onPress={onPress}
className="mr-2 rounded bg-gray-200 px-2 py-1 dark:bg-gray-700"
activeOpacity={0.7}
>
<Text className="text-label text-xs">{label}</Text>
</TouchableOpacity>
)
export function DebugPanel({ mode, onModeToggle }: DebugPanelProps) {
const [visible, setVisible] = useState(false)
const [debugState, setDebugState] = useState<WebViewDebugState | null>(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 (
<Portal>
{/* Debug Float Button */}
<View className="bottom-safe-offset-2 absolute left-4 flex-row gap-4">
<TouchableOpacity
className="flex size-12 items-center justify-center rounded-full bg-orange-500"
onPress={() => setVisible(true)}
>
<BugCuteReIcon color="#fff" />
</TouchableOpacity>
</View>
{/* Debug Panel */}
{visible && (
<View className="absolute inset-x-4 bottom-20 rounded-lg bg-white p-3 shadow-lg dark:bg-gray-900">
<View className="mb-3 flex-row items-center justify-between">
<Text className="text-label text-sm font-medium">WebView Debug</Text>
<TouchableOpacity onPress={() => setVisible(false)}>
<CloseCuteReIcon width={16} height={16} color="#8E8E93" />
</TouchableOpacity>
</View>
<Text className="mb-3 font-mono text-xs text-gray-600 dark:text-gray-400">
{debugInfo}
</Text>
<View className="flex-row flex-wrap gap-y-2">
<SimpleButton label={`Mode: ${mode}`} onPress={onModeToggle} />
<SimpleButton label="Destroy" onPress={() => SharedWebViewModule.destroyForDebug?.()} />
<SimpleButton label="Reload" onPress={() => SharedWebViewModule.reloadLastURL?.()} />
<SimpleButton label="Prewarm" onPress={() => SharedWebViewModule.load(htmlUrl)} />
<SimpleButton label="Flush" onPress={() => SharedWebViewModule.flushQueue?.()} />
</View>
</View>
)}
</Portal>
)
}

View File

@ -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) {
</View>
)}
</Portal>
{__DEV__ && (
<Portal>
<View className="bottom-safe-offset-2 absolute left-4 flex-row gap-4">
<TouchableOpacity
className={clsx(
"flex size-12 items-center justify-center rounded-full",
mode === "debug" ? "bg-yellow" : "bg-red",
)}
onPress={handleModeToggle}
>
<BugCuteReIcon color="#fff" />
</TouchableOpacity>
</View>
</Portal>
)}
<DebugPanel mode={mode} onModeToggle={handleModeToggle} />
</>
)
}
export { preloadWebViewEntry } from "./webview-manager"

View File

@ -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 = () => {}

View File

@ -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<ISharedWebViewModule>("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

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -78,8 +78,7 @@ export function EntryPictureItem({ id }: { id: string }) {
index,
})
})()
// const fullEntry = getEntry(id)
// preloadWebViewEntry(fullEntry)
unreadSyncService.markEntryAsRead(id)
}}
/>

View File

@ -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<string, any>) => void
}
}
}