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 c0a47f9df..37dbfa0e8 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -196,6 +196,7 @@ class GutenbergView : FrameLayout { private var onFileChooserRequested: ((Intent, Int) -> Unit)? = null private var contentChangeListener: ContentChangeListener? = null private var historyChangeListener: HistoryChangeListener? = null + private var saveAvailabilityListener: SaveAvailabilityListener? = null private var featuredImageChangeListener: FeaturedImageChangeListener? = null private var openMediaLibraryListener: OpenMediaLibraryListener? = null private var editorDidBecomeAvailableListener: EditorAvailableListener? = null @@ -243,6 +244,10 @@ class GutenbergView : FrameLayout { historyChangeListener = listener } + fun setSaveAvailabilityListener(listener: SaveAvailabilityListener) { + saveAvailabilityListener = listener + } + fun setFeaturedImageChangeListener(listener: FeaturedImageChangeListener) { featuredImageChangeListener = listener } @@ -862,6 +867,40 @@ class GutenbergView : FrameLayout { fun onHistoryChanged(hasUndo: Boolean, hasRedo: Boolean) } + /** + * Whether the post can be saved, as reported by the editor. + * + * @property isSaveable `true` if the post has a title, excerpt, or content + * worth saving. + * @property isSavingLocked `true` while something in the editor, such as a + * media upload, has locked saving. + */ + data class SaveAvailability( + val isSaveable: Boolean, + val isSavingLocked: Boolean + ) + + /** + * Notified on the main thread with the initial save availability, which + * may arrive before [EditorAvailableListener.onEditorAvailable], then each + * time it changes. Register before loading the editor to receive it. + * + * Hosts should not save the post to the server while + * [SaveAvailability.isSavingLocked] is `true`, for example while a media + * upload is in progress, so they don't persist temporary media URLs. That + * covers saves the host starts in code, such as autosaves, not only its + * save controls. + * Gutenberg releases the lock for a multi-file upload as soon as any file + * fails, while the remaining files may still be uploading. + * + * Disregard the last reported availability after + * [EditorUnavailableListener.onEditorUnavailable]; the editor reports it + * again once it reloads. + */ + fun interface SaveAvailabilityListener { + fun onSaveAvailabilityChanged(availability: SaveAvailability) + } + interface FeaturedImageChangeListener { fun onFeaturedImageChanged(mediaID: Long) } @@ -1115,6 +1154,14 @@ class GutenbergView : FrameLayout { historyChangeListener?.onHistoryChanged(hasUndo, hasRedo) } + @JavascriptInterface + fun onEditorSaveAvailabilityChanged(isSaveable: Boolean, isSavingLocked: Boolean) { + val availability = SaveAvailability(isSaveable, isSavingLocked) + handler.post { + saveAvailabilityListener?.onSaveAvailabilityChanged(availability) + } + } + @JavascriptInterface fun onEditorFeaturedImageChanged(mediaID: Long) { featuredImageChangeListener?.onFeaturedImageChanged(mediaID) @@ -1337,6 +1384,7 @@ class GutenbergView : FrameLayout { webView.stopLoading() contentChangeListener = null historyChangeListener = null + saveAvailabilityListener = null featuredImageChangeListener = null openMediaLibraryListener = null logJsExceptionListener = null diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt index 569a820df..c79224e2a 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/GutenbergViewTest.kt @@ -438,6 +438,17 @@ class GutenbergViewTest { assertEquals(gutenbergView, notified) } + @Test + fun `onEditorSaveAvailabilityChanged notifies the listener`() { + var notified: GutenbergView.SaveAvailability? = null + gutenbergView.setSaveAvailabilityListener { availability -> notified = availability } + + gutenbergView.onEditorSaveAvailabilityChanged(isSaveable = true, isSavingLocked = true) + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(GutenbergView.SaveAvailability(isSaveable = true, isSavingLocked = true), notified) + } + @Test fun `onEditorUnavailable stops content reads from reaching the web view`() { gutenbergView.onEditorLoaded() 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 a5218a376..2acf0d33e 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -165,6 +165,8 @@ fun EditorScreen( var isCodeEditorEnabled by remember { mutableStateOf(false) } var isEditorAvailable by remember { mutableStateOf(false) } var isSaving by remember { mutableStateOf(false) } + // Whether the editor has locked saving, e.g. while a media upload is in progress. + var isSavingLocked by remember { mutableStateOf(false) } var gutenbergViewRef by remember { mutableStateOf(null) } val openedContent = GutenbergView.LatestContent(configuration.title, configuration.content) // The newest content read from the editor, held in memory as a host app's @@ -177,7 +179,7 @@ fun EditorScreen( val context = LocalContext.current val hasChanges = latestContent != savedContent - val canSave = isEditorAvailable && !isSaving && accountId != null && configuration.postId != null && hasChanges + val canSave = isEditorAvailable && !isSaving && !isSavingLocked && accountId != null && configuration.postId != null && hasChanges BackHandler(enabled = isModalDialogOpen) { gutenbergViewRef?.dismissTopModal() @@ -312,7 +314,14 @@ fun EditorScreen( gutenbergViewRef = this setEditorDidBecomeAvailable { isEditorAvailable = true } - setEditorDidBecomeUnavailable { isEditorAvailable = false } + setEditorDidBecomeUnavailable { + isEditorAvailable = false + // The crashed editor's save lock no longer applies. + isSavingLocked = false + } + setSaveAvailabilityListener { availability -> + isSavingLocked = availability.isSavingLocked + } setModalDialogStateListener(object : GutenbergView.ModalDialogStateListener { override fun onModalDialogOpened(dialogType: String) { isModalDialogOpen = true diff --git a/docs/integration.md b/docs/integration.md index 676c3642a..978105401 100644 --- a/docs/integration.md +++ b/docs/integration.md @@ -78,6 +78,11 @@ extension YourViewController: EditorViewControllerDelegate { // Undo/redo state changed - use state.hasUndo and state.hasRedo } + func editor(_ viewController: EditorViewController, didUpdateSaveAvailability availability: EditorSaveAvailability) { + // Optional - while availability.isSavingLocked (e.g. during uploads), don't save + // the post to the server, whether from a save control or in code (e.g. autosave) + } + func editor(_ viewController: EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) { // User requested media picker - present your media library UI } @@ -202,6 +207,11 @@ gutenbergView.setHistoryChangeListener { hasUndo, hasRedo -> // Undo/redo state changed } +gutenbergView.setSaveAvailabilityListener { availability -> + // While availability.isSavingLocked (e.g. during uploads), don't save the post + // to the server, whether from a save control or in code (e.g. autosave) +} + gutenbergView.setOpenMediaLibraryListener { config -> // User requested media picker // config.allowedTypes, config.multiple, etc. diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 74329f2a1..0e9fbeb1c 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -231,6 +231,8 @@ private struct _EditorView: UIViewControllerRepresentable { func editorDidBecomeUnavailable(_ viewController: EditorViewController) { viewModel.isEditorReady = false + // The crashed editor's save lock no longer applies. + viewModel.isSavingLocked = false } func editor(_ viewContoller: EditorViewController, didDisplayInitialContent content: String) { @@ -259,6 +261,10 @@ private struct _EditorView: UIViewControllerRepresentable { viewModel.hasRedo = state.hasRedo } + func editor(_ viewController: EditorViewController, didUpdateSaveAvailability availability: EditorSaveAvailability) { + viewModel.isSavingLocked = availability.isSavingLocked + } + func editor(_ viewController: EditorViewController, didUpdateFeaturedImage mediaID: Int) { // No-op for demo } @@ -406,6 +412,8 @@ private final class EditorViewModel { var isCodeEditorEnabled = false var isSaving = false var isEditorReady = false + /// Whether the editor has locked saving, e.g. while a media upload is in progress. + var isSavingLocked = false var hasPostID = false @@ -419,7 +427,7 @@ private final class EditorViewModel { } var canSave: Bool { - isEditorReady && !isSaving && hasPostID && hasChanges + isEditorReady && !isSaving && !isSavingLocked && hasPostID && hasChanges } enum Action { diff --git a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift index 9b633d4ee..651896990 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift @@ -31,6 +31,8 @@ struct EditorJSMessage { case onEditorContentChanged /// The editor history (undo, redo) changed. case onEditorHistoryChanged + /// The post's save availability changed. + case onEditorSaveAvailabilityChanged /// The editor featured image changed. case onEditorFeaturedImageChanged /// The editor logged an exception. diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 9df59e538..1df5c5ba4 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -845,6 +845,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro self.state.hasUndo = body.hasUndo self.state.hasRedo = body.hasRedo delegate?.editor(self, didUpdateHistoryState: state) + case .onEditorSaveAvailabilityChanged: + let availability = try message.decode(EditorSaveAvailability.self) + delegate?.editor(self, didUpdateSaveAvailability: availability) case .onEditorFeaturedImageChanged: let body = try message.decode(EditorJSMessage.DidUpdateFeaturedImageBody.self) delegate?.editor(self, didUpdateFeaturedImage: body.mediaID) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift index 94816b31e..40bfd6f80 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewControllerDelegate.swift @@ -52,6 +52,23 @@ public protocol EditorViewControllerDelegate: AnyObject { /// Notifies the client about new history state. func editor(_ viewController: EditorViewController, didUpdateHistoryState state: EditorState) + /// Notifies the client that the post's save availability changed. + /// + /// Called with the initial availability, which may arrive before + /// ``editorDidLoad(_:)``, then each time it changes. Clients should not save + /// the post to the server while ``EditorSaveAvailability/isSavingLocked`` + /// is `true`, for example while a media upload is in progress, so they + /// don't persist temporary media URLs. That covers saves the client starts + /// in code, such as autosaves, not only its save controls. + /// + /// - warning: Gutenberg releases the lock for a multi-file upload as soon + /// as any file fails, while the remaining files may still be uploading. + /// + /// - note: Disregard the last reported availability after + /// ``editorDidBecomeUnavailable(_:)``. The editor reports it again once it + /// reloads. + func editor(_ viewController: EditorViewController, didUpdateSaveAvailability availability: EditorSaveAvailability) + /// Notifies the client about a new featured image. func editor(_ viewController: EditorViewController, didUpdateFeaturedImage mediaID: Int) @@ -99,6 +116,7 @@ public protocol EditorViewControllerDelegate: AnyObject { extension EditorViewControllerDelegate { public func editor(_ viewController: EditorViewController, didFailToLoad error: Error) {} + public func editor(_ viewController: EditorViewController, didUpdateSaveAvailability availability: EditorSaveAvailability) {} } #endif @@ -112,6 +130,20 @@ public struct EditorState { public var hasRedo = false } +/// Whether the post can be saved, as reported by the editor. +public struct EditorSaveAvailability: Decodable, Equatable, Sendable { + /// `true` if the post has a title, excerpt, or content worth saving. + public let isSaveable: Bool + /// `true` while something in the editor, such as a media upload, has + /// locked saving. + public let isSavingLocked: Bool + + public init(isSaveable: Bool, isSavingLocked: Bool) { + self.isSaveable = isSaveable + self.isSavingLocked = isSavingLocked + } +} + // Definition of JavaScript exception, which will be used to // log exception to the Crash Logging service. public struct GutenbergJSException { diff --git a/ios/Tests/GutenbergKitTests/Model/EditorSaveAvailabilityTests.swift b/ios/Tests/GutenbergKitTests/Model/EditorSaveAvailabilityTests.swift new file mode 100644 index 000000000..76522475e --- /dev/null +++ b/ios/Tests/GutenbergKitTests/Model/EditorSaveAvailabilityTests.swift @@ -0,0 +1,17 @@ +import Foundation +import Testing + +@testable import GutenbergKit + +@Suite +struct EditorSaveAvailabilityTests { + + @Test("Decodes the onEditorSaveAvailabilityChanged message body") + func decodesMessageBody() throws { + let json = #"{"isSaveable":true,"isSavingLocked":true}"# + + let availability = try JSONDecoder().decode(EditorSaveAvailability.self, from: Data(json.utf8)) + + #expect(availability == EditorSaveAvailability(isSaveable: true, isSavingLocked: true)) + } +} diff --git a/src/components/editor/index.jsx b/src/components/editor/index.jsx index f4c3d98ac..66efd696c 100644 --- a/src/components/editor/index.jsx +++ b/src/components/editor/index.jsx @@ -4,6 +4,7 @@ import { store as editorStore, EditorProvider } from '@wordpress/editor'; import VisualEditor from '../visual-editor'; import './style.scss'; import { useSyncHistoryControls } from './use-sync-history-controls'; +import { useSyncSaveAvailability } from './use-sync-save-availability'; import { useHostBridge } from './use-host-bridge'; import { useEditorReady } from './use-editor-ready'; import { useHostExceptionLogging } from './use-host-exception-logging'; @@ -34,6 +35,7 @@ export default function Editor( { post, children, hideTitle } ) { useHostBridge( post, editorRef, markBridgeReady ); useSyncFeaturedImage(); useSyncHistoryControls(); + useSyncSaveAvailability(); useHostExceptionLogging(); useEditorSetup( post ); useMediaUpload(); diff --git a/src/components/editor/test/use-sync-save-availability.test.jsx b/src/components/editor/test/use-sync-save-availability.test.jsx new file mode 100644 index 000000000..2e633ae73 --- /dev/null +++ b/src/components/editor/test/use-sync-save-availability.test.jsx @@ -0,0 +1,81 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useSyncSaveAvailability } from '../use-sync-save-availability'; +import { onEditorSaveAvailabilityChanged } from '../../../utils/bridge'; + +let editorState; + +vi.mock( '@wordpress/data', () => ( { + useSelect: ( mapSelect ) => + mapSelect( () => ( { + __unstableIsEditorReady: () => editorState.isReady, + isEditedPostSaveable: () => editorState.isSaveable, + isPostSavingLocked: () => editorState.isSavingLocked, + } ) ), +} ) ); + +vi.mock( '@wordpress/editor', () => ( { store: {} } ) ); + +vi.mock( '../../../utils/bridge', () => ( { + onEditorSaveAvailabilityChanged: vi.fn(), +} ) ); + +describe( 'useSyncSaveAvailability', () => { + beforeEach( () => { + vi.clearAllMocks(); + editorState = { + isReady: true, + isSaveable: true, + isSavingLocked: false, + }; + } ); + + it( 'reports the initial availability once the editor is ready', () => { + editorState.isReady = false; + const { rerender } = renderHook( () => useSyncSaveAvailability() ); + + expect( onEditorSaveAvailabilityChanged ).not.toHaveBeenCalled(); + + editorState.isReady = true; + rerender(); + + expect( onEditorSaveAvailabilityChanged ).toHaveBeenCalledTimes( 1 ); + expect( onEditorSaveAvailabilityChanged ).toHaveBeenCalledWith( + true, + false + ); + } ); + + it( 'reports each change once', () => { + const { rerender } = renderHook( () => useSyncSaveAvailability() ); + vi.clearAllMocks(); + + editorState.isSavingLocked = true; + rerender(); + rerender(); + + expect( onEditorSaveAvailabilityChanged ).toHaveBeenCalledTimes( 1 ); + expect( onEditorSaveAvailabilityChanged ).toHaveBeenCalledWith( + true, + true + ); + + editorState.isSaveable = false; + rerender(); + + expect( onEditorSaveAvailabilityChanged ).toHaveBeenCalledTimes( 2 ); + expect( onEditorSaveAvailabilityChanged ).toHaveBeenLastCalledWith( + false, + true + ); + } ); + + it( 'stops reporting after unmounting', () => { + const { unmount } = renderHook( () => useSyncSaveAvailability() ); + vi.clearAllMocks(); + + unmount(); + + expect( onEditorSaveAvailabilityChanged ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/src/components/editor/use-sync-save-availability.js b/src/components/editor/use-sync-save-availability.js new file mode 100644 index 000000000..10900ec6b --- /dev/null +++ b/src/components/editor/use-sync-save-availability.js @@ -0,0 +1,32 @@ +import { useEffect } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { onEditorSaveAvailabilityChanged } from '../../utils/bridge'; + +/** + * Synchronizes the host's save controls with the editor's save availability. + * + * Reports whether the post is saveable and whether saving is locked (e.g. by + * an in-progress upload) once the post is set up, which precedes + * `editorLoaded`, and then on each change. + * Reporting stops when the editor unmounts; `onEditorUnavailable` tells the + * host to disregard the last reported state. + * + * @return {void} + */ +export function useSyncSaveAvailability() { + const { isReady, isSaveable, isSavingLocked } = useSelect( ( select ) => { + const store = select( editorStore ); + return { + isReady: store.__unstableIsEditorReady(), + isSaveable: store.isEditedPostSaveable(), + isSavingLocked: store.isPostSavingLocked(), + }; + }, [] ); + + useEffect( () => { + if ( isReady ) { + onEditorSaveAvailabilityChanged( isSaveable, isSavingLocked ); + } + }, [ isReady, isSaveable, isSavingLocked ] ); +} diff --git a/src/utils/bridge.js b/src/utils/bridge.js index 6b2462c8d..70b9b25d3 100644 --- a/src/utils/bridge.js +++ b/src/utils/bridge.js @@ -73,6 +73,21 @@ export function onEditorHistoryChanged( hasUndo, hasRedo ) { dispatchToBridge( 'onEditorHistoryChanged', { hasUndo, hasRedo } ); } +/** + * Notifies the native host that the post's save availability has changed. + * + * @param {boolean} isSaveable Whether the post has content worth saving. + * @param {boolean} isSavingLocked Whether saving is locked, e.g. by an upload in progress. + * + * @return {void} + */ +export function onEditorSaveAvailabilityChanged( isSaveable, isSavingLocked ) { + dispatchToBridge( 'onEditorSaveAvailabilityChanged', { + isSaveable, + isSavingLocked, + } ); +} + /** * Notifies the native host that the featured image has changed. * diff --git a/src/utils/bridge.test.js b/src/utils/bridge.test.js index a37fe5daf..fe80728b6 100644 --- a/src/utils/bridge.test.js +++ b/src/utils/bridge.test.js @@ -4,6 +4,7 @@ import { getGBKit, getPost, showBlockInserter, + onEditorSaveAvailabilityChanged, } from './bridge'; vi.mock( './logger.js', () => ( { @@ -584,3 +585,54 @@ describe( 'showBlockInserter', () => { expect( postMessage ).toHaveBeenCalledTimes( 1 ); } ); } ); + +describe( 'onEditorSaveAvailabilityChanged', () => { + let originalWindow; + + beforeEach( () => { + originalWindow = { + webkit: window.webkit, + editorDelegate: window.editorDelegate, + }; + delete window.webkit; + delete window.editorDelegate; + } ); + + afterEach( () => { + if ( originalWindow.webkit !== undefined ) { + window.webkit = originalWindow.webkit; + } + if ( originalWindow.editorDelegate !== undefined ) { + window.editorDelegate = originalWindow.editorDelegate; + } + } ); + + // The Android interface receives the payload values positionally. + it( 'passes isSaveable then isSavingLocked to the Android bridge', () => { + const editorDelegate = { onEditorSaveAvailabilityChanged: vi.fn() }; + window.editorDelegate = editorDelegate; + + onEditorSaveAvailabilityChanged( true, false ); + + expect( + editorDelegate.onEditorSaveAvailabilityChanged + ).toHaveBeenCalledWith( true, false ); + } ); + + // `EditorSaveAvailability` decodes these keys on iOS. + it( 'passes a keyed payload to the iOS bridge', () => { + const postMessage = vi.fn(); + window.webkit = { + messageHandlers: { + editorDelegate: { postMessage }, + }, + }; + + onEditorSaveAvailabilityChanged( false, true ); + + expect( postMessage ).toHaveBeenCalledWith( { + message: 'onEditorSaveAvailabilityChanged', + body: { isSaveable: false, isSavingLocked: true }, + } ); + } ); +} );