diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index ecb8505d..348c3236 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -108,6 +108,7 @@ class GutenbergView : WebView { private var modalDialogStateListener: ModalDialogStateListener? = null private var networkRequestListener: NetworkRequestListener? = null private var loadingListener: EditorLoadingListener? = null + private var latestContentProvider: LatestContentProvider? = null /** * Stores the contextId from the most recent openMediaLibrary call @@ -158,6 +159,10 @@ class GutenbergView : WebView { networkRequestListener = listener } + fun setLatestContentProvider(provider: LatestContentProvider?) { + latestContentProvider = provider + } + fun setOnFileChooserRequestedListener(listener: (Intent, Int) -> Unit) { onFileChooserRequested = listener } @@ -511,6 +516,29 @@ class GutenbergView : WebView { fun onNetworkRequest(request: RecordedNetworkRequest) } + /** + * Provides the latest persisted content for recovery after WebView refresh. + * + * When the WebView reinitializes (e.g., due to OS memory pressure or page refresh), + * the editor requests the latest content from this provider. The host app should + * return the most recently persisted title and content from autosave. + */ + interface LatestContentProvider { + /** + * Returns the most recently persisted title and content from autosave. + * @return LatestContent if available, null if no persisted content exists. + */ + fun getLatestContent(): LatestContent? + } + + /** + * Represents persisted editor content for recovery. + */ + data class LatestContent( + val title: String, + val content: String + ) + fun getTitleAndContent(originalContent: CharSequence, callback: TitleAndContentCallback, completeComposition: Boolean = false) { if (!isEditorLoaded) { Log.e("GutenbergView", "You can't change the editor content until it has loaded") @@ -731,6 +759,28 @@ class GutenbergView : WebView { } } + /** + * Called by JavaScript to request the latest persisted content. + * + * This method is invoked during editor initialization to recover content + * after WebView refresh. The host app provides content via [LatestContentProvider]. + * + * @return JSON string with title and content fields, or null if unavailable. + */ + @JavascriptInterface + fun requestLatestContent(): String? { + val content = latestContentProvider?.getLatestContent() ?: return null + return try { + JSONObject().apply { + put("title", content.title) + put("content", content.content) + }.toString() + } catch (e: JSONException) { + Log.e("GutenbergView", "Failed to serialize latest content", e) + null + } + } + fun resetFilePathCallback() { filePathCallback = null } @@ -814,6 +864,7 @@ class GutenbergView : WebView { modalDialogStateListener = null networkRequestListener = null requestInterceptor = DefaultGutenbergRequestInterceptor() + latestContentProvider = null handler.removeCallbacksAndMessages(null) this.destroy() } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index c5a6cdc5..2e44cbae 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -12,6 +12,7 @@ data class EditorConfiguration( val content: String, val postId: Int?, val postType: String, + val postStatus: String, val themeStyles: Boolean, val plugins: Boolean, val hideTitle: Boolean, @@ -57,6 +58,7 @@ data class EditorConfiguration( private var title: String = "" private var content: String = "" private var postId: Int? = null + private var postStatus: String = "draft" private var themeStyles: Boolean = false private var plugins: Boolean = false private var hideTitle: Boolean = false @@ -76,6 +78,7 @@ data class EditorConfiguration( fun setContent(content: String) = apply { this.content = content } fun setPostId(postId: Int?) = apply { this.postId = postId } fun setPostType(postType: String) = apply { this.postType = postType } + fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } fun setPlugins(plugins: Boolean) = apply { this.plugins = plugins } fun setHideTitle(hideTitle: Boolean) = apply { this.hideTitle = hideTitle } @@ -98,6 +101,7 @@ data class EditorConfiguration( content = content, postId = postId, postType = postType, + postStatus = postStatus, themeStyles = themeStyles, plugins = plugins, hideTitle = hideTitle, @@ -126,6 +130,7 @@ data class EditorConfiguration( .setTitle(title) .setContent(content) .setPostId(postId) + .setPostStatus(postStatus) .setThemeStyles(themeStyles) .setPlugins(plugins) .setHideTitle(hideTitle) @@ -151,6 +156,7 @@ data class EditorConfiguration( if (content != other.content) return false if (postId != other.postId) return false if (postType != other.postType) return false + if (postStatus != other.postStatus) return false if (themeStyles != other.themeStyles) return false if (plugins != other.plugins) return false if (hideTitle != other.hideTitle) return false @@ -177,6 +183,7 @@ data class EditorConfiguration( result = 31 * result + content.hashCode() result = 31 * result + (postId ?: 0) result = 31 * result + postType.hashCode() + result = 31 * result + postStatus.hashCode() result = 31 * result + themeStyles.hashCode() result = 31 * result + plugins.hashCode() result = 31 * result + hideTitle.hashCode() diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index 7193ae0d..898d9ed8 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -69,6 +69,10 @@ data class GBKitGlobal( data class Post( /** The post ID, or -1 for new posts. */ val id: Int, + /** The post type (e.g., `post`, `page`). */ + val type: String, + /** The post status (e.g., `draft`, `publish`, `pending`). */ + val status: String, /** The post title (URL-encoded). */ val title: String, /** The post content (URL-encoded Gutenberg block markup). */ @@ -100,6 +104,8 @@ data class GBKitGlobal( locale = configuration.locale ?: "en", post = Post( id = configuration.postId ?: -1, + type = configuration.postType, + status = configuration.postStatus ?: "draft", title = configuration.title.encodeForEditor(), content = configuration.content.encodeForEditor() ), diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index 685be9bf..e3f9cd0a 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -30,6 +30,7 @@ class EditorConfigurationBuilderTest { assertEquals("", config.content) assertNull(config.postId) assertEquals(TEST_POST_TYPE, config.postType) + assertEquals("draft", config.postStatus) assertFalse(config.themeStyles) assertFalse(config.plugins) assertFalse(config.hideTitle) @@ -96,6 +97,15 @@ class EditorConfigurationBuilderTest { assertEquals("page", config.postType) } + @Test + fun `setPostStatus updates postStatus`() { + val config = builder() + .setPostStatus("publish") + .build() + + assertEquals("publish", config.postStatus) + } + @Test fun `setThemeStyles updates themeStyles`() { val config = builder() @@ -292,6 +302,7 @@ class EditorConfigurationBuilderTest { .setContent("

Round trip content

") .setPostId(999) .setPostType("page") + .setPostStatus("draft") .setThemeStyles(true) .setPlugins(true) .setHideTitle(true) @@ -368,6 +379,7 @@ class EditorConfigurationBuilderTest { val original = builder() .setPostId(123) .setPostType("post") + .setPostStatus("publish") .setEditorSettings("""{"test":true}""") .setEditorAssetsEndpoint("https://example.com/assets") .build() @@ -376,6 +388,7 @@ class EditorConfigurationBuilderTest { assertEquals(123, rebuilt.postId) assertEquals("post", rebuilt.postType) + assertEquals("publish", rebuilt.postStatus) assertEquals("""{"test":true}""", rebuilt.editorSettings) assertEquals("https://example.com/assets", rebuilt.editorAssetsEndpoint) } @@ -540,6 +553,19 @@ class EditorConfigurationTest { assertNotEquals(config1, config2) } + @Test + fun `Configurations with different postStatus are not equal`() { + val config1 = builder() + .setPostStatus("draft") + .build() + + val config2 = builder() + .setPostStatus("publish") + .build() + + assertNotEquals(config1, config2) + } + @Test fun `Configurations with different themeStyles are not equal`() { val config1 = builder() @@ -820,6 +846,7 @@ class EditorConfigurationTest { .setContent("Test Content") .setPostId(123) .setPostType("post") + .setPostStatus("publish") .setThemeStyles(true) .setPlugins(true) .setHideTitle(false) @@ -842,6 +869,7 @@ class EditorConfigurationTest { assertEquals("Test Content", config.content) assertEquals(123, config.postId) assertEquals("post", config.postType) + assertEquals("publish", config.postStatus) assertTrue(config.themeStyles) assertTrue(config.plugins) assertFalse(config.hideTitle) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index efe1f29f..78ed9dce 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -344,6 +344,13 @@ fun EditorScreen( loadingError = error.message ?: "Unknown error" } }) + // Demo app has no persistence layer, so return null. + // In a real app, return the persisted title and content from autosave. + setLatestContentProvider(object : GutenbergView.LatestContentProvider { + override fun getLatestContent(): GutenbergView.LatestContent? { + return null + } + }) onGutenbergViewCreated(this) } }, diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index f47538f2..90ca1be8 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -213,6 +213,12 @@ private struct _EditorView: UIViewControllerRepresentable { print(" Response Body: \(responseBody.prefix(200))...") } } + + func editorDidRequestLatestContent(_ controller: EditorViewController) -> (title: String, content: String)? { + // Demo app has no persistence layer, so return nil. + // In a real app, return the persisted title and content from autosave. + return nil + } } } diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index bfa216f0..9edaff7a 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -170,6 +170,10 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro // Set-up communications with the editor. config.userContentController.add(controller, name: "editorDelegate") + // Register async message handler for content recovery requests. + // This allows JavaScript to request the latest persisted content from the native host. + config.userContentController.addScriptMessageHandler(controller, contentWorld: .page, name: "requestLatestContent") + // This is important so they user can't select anything but text across blocks. config.selectionGranularity = .character @@ -659,6 +663,14 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro } } + fileprivate func controllerDidRequestLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? { + return delegate?.editorDidRequestLatestContent(self) + } + + fileprivate func controllerWebContentProcessDidTerminate(_ controller: GutenbergEditorController) { + webView.reload() + } + // MARK: - Loading Complete: Editor Ready /// Called when the editor JavaScript emits the `onEditorLoaded` message. @@ -714,10 +726,12 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro @MainActor private protocol GutenbergEditorControllerDelegate: AnyObject { func controller(_ controller: GutenbergEditorController, didReceiveMessage message: EditorJSMessage) + func controllerDidRequestLatestContent(_ controller: GutenbergEditorController) -> (title: String, content: String)? + func controllerWebContentProcessDidTerminate(_ controller: GutenbergEditorController) } /// Hiding the conformances, and breaking retain cycles. -private final class GutenbergEditorController: NSObject, WKNavigationDelegate, WKScriptMessageHandler { +private final class GutenbergEditorController: NSObject, WKNavigationDelegate, WKScriptMessageHandler, WKScriptMessageHandlerWithReply { weak var delegate: GutenbergEditorControllerDelegate? let configuration: EditorConfiguration private let editorURL: URL? @@ -728,6 +742,27 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W super.init() } + // MARK: - WKScriptMessageHandlerWithReply + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) async -> (Any?, String?) { + guard message.name == "requestLatestContent" else { + return (nil, "Unknown message handler: \(message.name)") + } + + let content = await MainActor.run { + delegate?.controllerDidRequestLatestContent(self) + } + + guard let content else { + return (nil, nil) // No content available - not an error + } + + return ([ + "title": content.title, + "content": content.content + ] as [String: String], nil) + } + // MARK: - WKNavigationDelegate func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -756,6 +791,13 @@ private final class GutenbergEditorController: NSObject, WKNavigationDelegate, W return .allow } + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + NSLog("webViewWebContentProcessDidTerminate: reloading editor") + MainActor.assumeIsolated { + delegate?.controllerWebContentProcessDidTerminate(self) + } + } + // MARK: - WKScriptMessageHandler func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 4f01be0a..979acd61 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -59,6 +59,19 @@ public protocol EditorViewControllerDelegate: AnyObject { /// /// - parameter request: The network request details including URL, headers, body, response, and timing. func editor(_ viewController: EditorViewController, didLogNetworkRequest request: RecordedNetworkRequest) + + /// Provides the latest persisted content for recovery after WebView refresh. + /// + /// Called when the WebView requests content during initialization. The host app should return + /// the most recently persisted title and content from autosave. This allows content recovery + /// when the WebView is re-initialized (e.g., due to OS memory pressure or page refresh). + /// + /// Note: The values in `EditorConfiguration.title` and `EditorConfiguration.content` are "initial values" + /// injected at WebView load time. After a WebView refresh, these may be stale. This delegate method + /// allows the host app to provide fresher content from its autosave mechanism. + /// + /// - Returns: A tuple of (title, content), or nil if no persisted content is available. + func editorDidRequestLatestContent(_ controller: EditorViewController) -> (title: String, content: String)? } #endif diff --git a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift index e1afcad8..d2fd8577 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/EditorConfiguration.swift @@ -12,8 +12,10 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { public let content: String /// ID of the post being edited public let postID: Int? - /// Type of the post being edited + /// Type of the post being edited (e.g., "post", "page") public let postType: String + /// Status of the post being edited (e.g., "draft", "publish", "pending") + public let postStatus: String /// Toggles application of theme styles public let shouldUseThemeStyles: Bool /// Toggles loading plugin-provided editor assets @@ -55,6 +57,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { content: String, postID: Int?, postType: String, + postStatus: String, shouldUseThemeStyles: Bool, shouldUsePlugins: Bool, shouldHideTitle: Bool, @@ -76,6 +79,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { self.content = content self.postID = postID self.postType = postType + self.postStatus = postStatus self.shouldUseThemeStyles = shouldUseThemeStyles self.shouldUsePlugins = shouldUsePlugins self.shouldHideTitle = shouldHideTitle @@ -106,6 +110,7 @@ public struct EditorConfiguration: Sendable, Hashable, Equatable { content: content, postID: postID, postType: postType, + postStatus: postStatus, shouldUseThemeStyles: shouldUseThemeStyles, shouldUsePlugins: shouldUsePlugins, shouldHideTitle: shouldHideTitle, @@ -150,6 +155,7 @@ public struct EditorConfigurationBuilder { private var content: String private var postID: Int? private var postType: String + private var postStatus: String private var shouldUseThemeStyles: Bool private var shouldUsePlugins: Bool private var shouldHideTitle: Bool @@ -172,6 +178,7 @@ public struct EditorConfigurationBuilder { content: String = "", postID: Int? = nil, postType: String, + postStatus: String = "draft", shouldUseThemeStyles: Bool = false, shouldUsePlugins: Bool = false, shouldHideTitle: Bool = false, @@ -193,6 +200,7 @@ public struct EditorConfigurationBuilder { self.content = content self.postID = postID self.postType = postType + self.postStatus = postStatus self.shouldUseThemeStyles = shouldUseThemeStyles self.shouldUsePlugins = shouldUsePlugins self.shouldHideTitle = shouldHideTitle @@ -265,6 +273,12 @@ public struct EditorConfigurationBuilder { return copy } + public func setPostStatus(_ postStatus: String) -> EditorConfigurationBuilder { + var copy = self + copy.postStatus = postStatus + return copy + } + public func setSiteApiNamespace(_ siteApiNamespace: [String]) -> EditorConfigurationBuilder { var copy = self copy.siteApiNamespace = siteApiNamespace @@ -365,6 +379,7 @@ public struct EditorConfigurationBuilder { content: content, postID: postID, postType: postType, + postStatus: postStatus, shouldUseThemeStyles: shouldUseThemeStyles, shouldUsePlugins: shouldUsePlugins, shouldHideTitle: shouldHideTitle, diff --git a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift index de3ad4e6..40f98112 100644 --- a/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift +++ b/ios/Sources/GutenbergKit/Sources/Model/GBKitGlobal.swift @@ -11,10 +11,16 @@ public struct GBKitGlobal: Sendable, Codable { public struct Post: Sendable, Codable { /// The post ID, or -1 for new posts. let id: Int - + + /// The post type (e.g., "post", "page"). + let type: String + + /// The post status (e.g., "draft", "publish", "pending"). + let status: String + /// The post title. let title: String - + /// The post content (Gutenberg block markup). let content: String } @@ -93,6 +99,8 @@ public struct GBKitGlobal: Sendable, Codable { self.locale = configuration.locale self.post = Post( id: configuration.postID ?? -1, + type: configuration.postType, + status: configuration.postStatus, title: configuration.escapedTitle, content: configuration.escapedContent ) diff --git a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift index dfa22664..2e057150 100644 --- a/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/EditorConfigurationTests.swift @@ -20,6 +20,7 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { #expect(config.content == "") #expect(config.postID == nil) #expect(config.postType == "post") + #expect(config.postStatus == "draft") #expect(config.shouldUseThemeStyles == false) #expect(config.shouldUsePlugins == false) #expect(config.shouldHideTitle == false) @@ -214,6 +215,22 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { #expect(config.enableNetworkLogging == true) } + @Test("Builder uses draft as default postStatus") + func builderDefaultPostStatus() { + let config = makeConfigurationBuilder().build() + + #expect(config.postStatus == "draft") + } + + @Test("setPostStatus updates postStatus") + func setPostStatusUpdatesPostStatus() { + let config = makeConfigurationBuilder() + .setPostStatus("publish") + .build() + + #expect(config.postStatus == "publish") + } + // MARK: - Method Chaining Tests @Test("Builder supports method chaining") @@ -308,6 +325,7 @@ struct EditorConfigurationBuilderTests: MakesTestFixtures { .setTitle("Round Trip Title") .setContent("

Round trip content

") .setPostID(789) + .setPostStatus("publish") .setShouldUseThemeStyles(true) .setShouldUsePlugins(true) .setShouldHideTitle(true) diff --git a/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift b/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift index 7920c9cc..e1fcd1aa 100644 --- a/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift +++ b/ios/Tests/GutenbergKitTests/Model/GBKitGlobalTests.swift @@ -95,6 +95,35 @@ struct GBKitGlobalTests: MakesTestFixtures { #expect(globalWithout.post.id == -1) } + @Test("maps postType to post.type") + func mapsPostType() throws { + let postConfig = makeConfiguration(postType: "post") + let pageConfig = makeConfiguration(postType: "page") + + let postGlobal = try GBKitGlobal(configuration: postConfig, dependencies: makeDependencies()) + let pageGlobal = try GBKitGlobal(configuration: pageConfig, dependencies: makeDependencies()) + + #expect(postGlobal.post.type == "post") + #expect(pageGlobal.post.type == "page") + } + + @Test("maps postStatus to post.status") + func mapsPostStatus() throws { + let draftConfig = makeConfigurationBuilder() + .setPostStatus("draft") + .build() + let publishConfig = makeConfigurationBuilder() + .setPostStatus("publish") + .build() + + let draftGlobal = try GBKitGlobal(configuration: draftConfig, dependencies: makeDependencies()) + let publishGlobal = try GBKitGlobal( + configuration: publishConfig, dependencies: makeDependencies()) + + #expect(draftGlobal.post.status == "draft") + #expect(publishGlobal.post.status == "publish") + } + @Test("maps title with percent encoding") func mapsTitleWithEncoding() throws { let configuration = makeConfiguration(title: "Hello World") @@ -137,6 +166,8 @@ struct GBKitGlobalTests: MakesTestFixtures { #expect(jsonString.contains("themeStyles")) #expect(jsonString.contains("plugins")) #expect(jsonString.contains("post")) + #expect(jsonString.contains("\"type\"")) + #expect(jsonString.contains("\"status\"")) #expect(jsonString.contains("locale")) #expect(jsonString.contains("logLevel")) } @@ -152,6 +183,8 @@ struct GBKitGlobalTests: MakesTestFixtures { #expect(decoded.siteURL == original.siteURL) #expect(decoded.post.id == original.post.id) + #expect(decoded.post.type == original.post.type) + #expect(decoded.post.status == original.post.status) #expect(decoded.post.title == original.post.title) #expect(decoded.themeStyles == original.themeStyles) #expect(decoded.plugins == original.plugins) diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 93345ba9..fd2acf3b 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -2,7 +2,7 @@ * Internal dependencies */ import parseException from './exception-parser'; -import { debug } from './logger'; +import { debug, error } from './logger'; import { isDevMode } from './dev-mode'; import { basicFetch } from './fetch'; @@ -229,7 +229,8 @@ export function getGBKit() { try { return JSON.parse( localStorage.getItem( 'GBKit' ) ) || {}; - } catch ( error ) { + } catch ( err ) { + error( 'Failed to parse GBKit from localStorage', err ); return {}; } } @@ -245,28 +246,83 @@ export function getGBKit() { */ /** - * Retrieves the current post data from the GBKit global. + * Requests the latest persisted content from the native host. * - * @return {Post} The post object containing the following properties: + * Used during editor initialization to recover content after WebView refresh. + * The native host maintains the authoritative content via autosave events. + * + * @return {Promise<{title: string, content: string}|null>} The latest content or null if unavailable. + */ +export async function requestLatestContent() { + if ( window.webkit?.messageHandlers?.requestLatestContent ) { + try { + return await window.webkit.messageHandlers.requestLatestContent.postMessage( + {} + ); + } catch ( err ) { + error( 'Failed to request content from iOS host', err ); + return null; + } + } + + if ( window.editorDelegate?.requestLatestContent ) { + try { + const result = window.editorDelegate.requestLatestContent(); + return result ? JSON.parse( result ) : null; + } catch ( err ) { + error( 'Failed to request content from Android host', err ); + return null; + } + } + + return null; +} + +/** + * Retrieves the current post data from the native host + * + * Always requests content from the native host first, as it maintains the + * latest content via autosave. Falls back to `window.GBKit.post` only if the + * native bridge is unavailable (e.g., dev mode). + * + * Note: `window.GBKit.post.title/content` are "initial values" injected at + * WebView load. After a WebView refresh, these may be stale. The native host + * has the authoritative content from autosave. + * + * @return {Promise} The post object. */ -export function getPost() { +export async function getPost() { const { post } = getGBKit(); + + const hostContent = await requestLatestContent(); + + if ( hostContent ) { + debug( 'Using content from native host' ); + return { + id: post?.id ?? -1, + type: post?.type || 'post', + status: post?.status || 'draft', + title: { raw: hostContent.title }, + content: { raw: hostContent.content }, + }; + } + if ( post ) { + debug( 'Native bridge unavailable, using GBKit initial content' ); return { id: post.id, type: post.type || 'post', - status: post.status, + status: post.status || 'draft', title: { raw: decodeURIComponent( post.title ) }, content: { raw: decodeURIComponent( post.content ) }, }; } - // Since we don't use the auto-save functionality, draft posts need to have an ID. - // We assign a temporary ID of -1. + // Fallback to default empty post return { id: -1, type: 'post', - status: 'auto-draft', + status: 'draft', title: { raw: '' }, content: { raw: '' }, }; diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js new file mode 100644 index 00000000..6928581d --- /dev/null +++ b/src/utils/bridge.test.js @@ -0,0 +1,373 @@ +/** + * External dependencies + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +/** + * Internal dependencies + */ +import { requestLatestContent, getPost } from './bridge'; + +describe( 'requestLatestContent', () => { + let originalWindow; + + beforeEach( () => { + // Store original window properties + originalWindow = { + webkit: window.webkit, + editorDelegate: window.editorDelegate, + }; + // Clear any existing bridge properties + delete window.webkit; + delete window.editorDelegate; + } ); + + afterEach( () => { + // Restore original window properties + if ( originalWindow.webkit !== undefined ) { + window.webkit = originalWindow.webkit; + } + if ( originalWindow.editorDelegate !== undefined ) { + window.editorDelegate = originalWindow.editorDelegate; + } + } ); + + describe( 'iOS bridge', () => { + it( 'should return parsed response from iOS handler', async () => { + const mockContent = { + title: 'Test Title', + content: 'Test Content', + }; + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( mockContent ), + }, + }, + }; + + const result = await requestLatestContent(); + + expect( result ).toEqual( mockContent ); + expect( + window.webkit.messageHandlers.requestLatestContent.postMessage + ).toHaveBeenCalledWith( {} ); + } ); + + it( 'should return null when iOS handler rejects', async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi + .fn() + .mockRejectedValue( new Error( 'Handler error' ) ), + }, + }, + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when iOS handler returns null', async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( null ), + }, + }, + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + } ); + + describe( 'Android bridge', () => { + it( 'should return parsed JSON from Android interface', async () => { + const mockContent = { + title: 'Test Title', + content: 'Test Content', + }; + window.editorDelegate = { + requestLatestContent: vi + .fn() + .mockReturnValue( JSON.stringify( mockContent ) ), + }; + + const result = await requestLatestContent(); + + expect( result ).toEqual( mockContent ); + expect( + window.editorDelegate.requestLatestContent + ).toHaveBeenCalled(); + } ); + + it( 'should return null when Android returns null', async () => { + window.editorDelegate = { + requestLatestContent: vi.fn().mockReturnValue( null ), + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when Android returns empty string', async () => { + window.editorDelegate = { + requestLatestContent: vi.fn().mockReturnValue( '' ), + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when Android returns malformed JSON', async () => { + window.editorDelegate = { + requestLatestContent: vi + .fn() + .mockReturnValue( 'not valid json' ), + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when Android method throws', async () => { + window.editorDelegate = { + requestLatestContent: vi.fn().mockImplementation( () => { + throw new Error( 'Method error' ); + } ), + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + } ); + + describe( 'no bridge available', () => { + it( 'should return null when no bridge is available', async () => { + // Neither window.webkit nor window.editorDelegate is set + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when webkit exists but handler does not', async () => { + window.webkit = { + messageHandlers: {}, + }; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + + it( 'should return null when editorDelegate exists but method does not', async () => { + window.editorDelegate = {}; + + const result = await requestLatestContent(); + + expect( result ).toBeNull(); + } ); + } ); +} ); + +describe( 'getPost', () => { + let originalWindow; + + beforeEach( () => { + // Store original window properties + originalWindow = { + webkit: window.webkit, + editorDelegate: window.editorDelegate, + GBKit: window.GBKit, + }; + // Clear any existing properties + delete window.webkit; + delete window.editorDelegate; + delete window.GBKit; + } ); + + afterEach( () => { + // Restore original window properties + if ( originalWindow.webkit !== undefined ) { + window.webkit = originalWindow.webkit; + } + if ( originalWindow.editorDelegate !== undefined ) { + window.editorDelegate = originalWindow.editorDelegate; + } + if ( originalWindow.GBKit !== undefined ) { + window.GBKit = originalWindow.GBKit; + } + } ); + + describe( 'content from native host', () => { + it( 'should return host content when available', async () => { + const hostContent = { + title: 'Host Title', + content: 'Host Content', + }; + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( hostContent ), + }, + }, + }; + window.GBKit = { + post: { + id: 123, + type: 'page', + status: 'draft', + title: 'GBKit%20Title', + content: 'GBKit%20Content', + }, + }; + + const result = await getPost(); + + expect( result ).toEqual( { + id: 123, + type: 'page', + status: 'draft', + title: { raw: 'Host Title' }, + content: { raw: 'Host Content' }, + } ); + } ); + + it( 'should use GBKit post metadata with host content', async () => { + const hostContent = { + title: 'Updated Title', + content: 'Updated Content', + }; + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( hostContent ), + }, + }, + }; + window.GBKit = { + post: { + id: 456, + type: 'post', + status: 'publish', + title: 'Original%20Title', + content: 'Original%20Content', + }, + }; + + const result = await getPost(); + + // Should use id/type/status from GBKit, but title/content from host + expect( result.id ).toBe( 456 ); + expect( result.type ).toBe( 'post' ); + expect( result.status ).toBe( 'publish' ); + expect( result.title.raw ).toBe( 'Updated Title' ); + expect( result.content.raw ).toBe( 'Updated Content' ); + } ); + } ); + + describe( 'fallback to GBKit', () => { + it( 'should fall back to GBKit when host returns null', async () => { + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( null ), + }, + }, + }; + window.GBKit = { + post: { + id: 789, + type: 'post', + status: 'draft', + title: 'GBKit%20Title', + content: 'GBKit%20Content', + }, + }; + + const result = await getPost(); + + expect( result ).toEqual( { + id: 789, + type: 'post', + status: 'draft', + title: { raw: 'GBKit Title' }, + content: { raw: 'GBKit Content' }, + } ); + } ); + + it( 'should fall back to GBKit when no bridge available', async () => { + // No bridge set up + window.GBKit = { + post: { + id: 101, + type: 'page', + status: 'publish', + title: 'Fallback%20Title', + content: 'Fallback%20Content', + }, + }; + + const result = await getPost(); + + expect( result ).toEqual( { + id: 101, + type: 'page', + status: 'publish', + title: { raw: 'Fallback Title' }, + content: { raw: 'Fallback Content' }, + } ); + } ); + } ); + + describe( 'default empty post', () => { + it( 'should return default empty post when both are unavailable', async () => { + // No bridge and no GBKit + + const result = await getPost(); + + expect( result ).toEqual( { + id: -1, + type: 'post', + status: 'draft', + title: { raw: '' }, + content: { raw: '' }, + } ); + } ); + + it( 'should use defaults when GBKit post lacks optional fields', async () => { + const hostContent = { + title: 'Title', + content: 'Content', + }; + window.webkit = { + messageHandlers: { + requestLatestContent: { + postMessage: vi.fn().mockResolvedValue( hostContent ), + }, + }, + }; + // GBKit exists but post is empty/undefined + window.GBKit = {}; + + const result = await getPost(); + + expect( result ).toEqual( { + id: -1, + type: 'post', + status: 'draft', + title: { raw: 'Title' }, + content: { raw: 'Content' }, + } ); + } ); + } ); +} ); diff --git a/src/utils/editor.jsx b/src/utils/editor.jsx index 149d1c1b..b6074a3b 100644 --- a/src/utils/editor.jsx +++ b/src/utils/editor.jsx @@ -18,7 +18,7 @@ import { getDefaultEditorSettings } from './editor-settings'; * @param {Array} [options.allowedBlockTypes] Array of allowed block types * @param {boolean} [options.pluginLoadFailed] Whether plugin loading failed */ -export function initializeEditor( { +export async function initializeEditor( { allowedBlockTypes, pluginLoadFailed, } = {} ) { @@ -42,7 +42,8 @@ export function initializeEditor( { registerCoreBlocks(); unregisterDisallowedBlocks( allowedBlockTypes ); - const post = getPost(); + + const post = await getPost(); createRoot( document.getElementById( 'root' ) ).render(