diff --git a/Modules/Package.resolved b/Modules/Package.resolved index 1255cefde198..2993b8deebf4 100644 --- a/Modules/Package.resolved +++ b/Modules/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "dd3f23948b260a1c9986a7d17d46643d207bd0bde18b8d1c2538688bd9b2e5f9", + "originHash" : "592e79b41dba5d3294d9cf4d7d0c1779909fb88094a586af8416e8dce2ab4011", "pins" : [ { "identity" : "automattic-tracks-ios", @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/GutenbergKit", "state" : { - "revision" : "b6604e26792725b2e8125b3b715e1d4a298441b6", - "version" : "0.19.0" + "revision" : "0b9e362245ce96948ca4902e0dfdfb4183473969", + "version" : "0.20.0-alpha.1" } }, { diff --git a/Modules/Package.swift b/Modules/Package.swift index a5f1f4703685..918d9f933608 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -63,7 +63,7 @@ let package = Package( revision: "b34794c9a3f32312e1593d4a3d120572afa0d010" ), .package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"), - .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.19.0"), + .package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.20.0-alpha.1"), .package( url: "https://github.com/automattic/wordpress-rs", exact: "0.9.1" diff --git a/Package.resolved b/Package.resolved index 0767464fe359..4864ec1da187 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f5cdff960717c84869b888911edb7d85c24e067a5afc87dab1896518df70b864", + "originHash" : "495f02d8954dc227ee3df7512b9269bb4b230e15fb7c96ba5bfd6c54b7df4a8c", "pins" : [ { "identity" : "automattic-tracks-ios", @@ -122,8 +122,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/GutenbergKit", "state" : { - "revision" : "b6604e26792725b2e8125b3b715e1d4a298441b6", - "version" : "0.19.0" + "revision" : "0b9e362245ce96948ca4902e0dfdfb4183473969", + "version" : "0.20.0-alpha.1" } }, { diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index eb044d42cb50..e43c7c4bb864 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -9,6 +9,7 @@ * [*] Reader: Fix the spacing around headings in posts [#26170] * [*] Stats: Fix the back button missing on iPhone Duo and on Plus and Pro Max iPhones in landscape [#26207] * [**] iPad and large iPhones: Screens now adapt to the app window's size in split view, Stage Manager and landscape, not the device type [#26201] +* [*] Experimental editor: Recover from editor crashes, and ask before saving while media is still uploading [#26267] 27.4 diff --git a/Tests/KeystoneTests/Tests/Features/Gutenberg/PostGBKEditorViewControllerTests.swift b/Tests/KeystoneTests/Tests/Features/Gutenberg/PostGBKEditorViewControllerTests.swift index 9b9125c082f2..06549b55e830 100644 --- a/Tests/KeystoneTests/Tests/Features/Gutenberg/PostGBKEditorViewControllerTests.swift +++ b/Tests/KeystoneTests/Tests/Features/Gutenberg/PostGBKEditorViewControllerTests.swift @@ -89,6 +89,128 @@ struct PostGBKEditorViewControllerTests { #expect(reported?.isEmpty == true) } + @Test("tracks whether the editor locked saving") + func tracksSavingLock() { + let viewController = makeViewController() + + viewController.editor( + viewController.editorViewController, + didUpdateSaveAvailability: EditorSaveAvailability(isSaveable: true, isSavingLocked: true) + ) + #expect(viewController.isSavingLocked) + + viewController.editor( + viewController.editorViewController, + didUpdateSaveAvailability: EditorSaveAvailability(isSaveable: true, isSavingLocked: false) + ) + #expect(!viewController.isSavingLocked) + } + + @Test("clears the saving lock when the editor becomes unavailable") + func clearsSavingLockWhenUnavailable() { + let viewController = makeViewController() + viewController.editor( + viewController.editorViewController, + didUpdateSaveAvailability: EditorSaveAvailability(isSaveable: true, isSavingLocked: true) + ) + + // The reloaded editor reports its availability again, so a lock from + // before the crash must not keep asking before saves. + viewController.editorDidBecomeUnavailable(viewController.editorViewController) + + #expect(!viewController.isSavingLocked) + } + + @Test("saves without asking while saving is unlocked") + func savesWithoutAskingWhenUnlocked() { + let viewController = makeViewController() + let window = UIWindow() + window.rootViewController = viewController + window.makeKeyAndVisible() + + var didSave = false + viewController.performAfterConfirmingSavingLock { didSave = true } + + #expect(didSave) + #expect(viewController.presentedViewController == nil) + } + + @Test("asks before saving while saving is locked") + func asksBeforeSavingWhenLocked() throws { + let viewController = makeViewController() + let window = UIWindow() + window.rootViewController = viewController + window.makeKeyAndVisible() + viewController.editor( + viewController.editorViewController, + didUpdateSaveAvailability: EditorSaveAvailability(isSaveable: true, isSavingLocked: true) + ) + + var didSave = false + viewController.performAfterConfirmingSavingLock { didSave = true } + + // A lock is advisory, so the user can still choose to save. + #expect(!didSave) + let alert = try #require(viewController.presentedViewController as? UIAlertController) + #expect(alert.title == PostEditorStrings.notReadyPostTitle) + #expect(alert.actions.map(\.title) == [PostEditorStrings.keepEditing, PostEditorStrings.continueAnyway]) + } + + @Test("says page in the saving lock alerts for pages") + func saysPageInSavingLockAlerts() throws { + let viewController = makeViewController(postType: .page) + let window = UIWindow() + window.rootViewController = viewController + window.makeKeyAndVisible() + + viewController.editor( + viewController.editorViewController, + didUpdateSaveAvailability: EditorSaveAvailability(isSaveable: true, isSavingLocked: true) + ) + + viewController.performAfterConfirmingSavingLock {} + let saveAlert = try #require(viewController.presentedViewController as? UIAlertController) + #expect(saveAlert.title == PostEditorStrings.notReadyPageTitle) + #expect(saveAlert.message == PostEditorStrings.savePageWhileNotReadyMessage) + + // A fresh view controller, since the save alert is still presented. + let leaveViewController = makeViewController(postType: .page) + window.rootViewController = leaveViewController + leaveViewController.showLeaveWhileSavingLockedAlert {} + let leaveAlert = try #require(leaveViewController.presentedViewController as? UIAlertController) + #expect(leaveAlert.title == PostEditorStrings.notReadyPageTitle) + #expect(leaveAlert.message == PostEditorStrings.leavePageWhileNotReadyMessage) + } + + @Test("asks before leaving while saving is locked") + func asksBeforeLeavingWhenLocked() throws { + let viewController = makeViewController() + let window = UIWindow() + window.rootViewController = viewController + window.makeKeyAndVisible() + + var didLeave = false + viewController.showLeaveWhileSavingLockedAlert { didLeave = true } + + #expect(!didLeave) + let alert = try #require(viewController.presentedViewController as? UIAlertController) + #expect(alert.message == PostEditorStrings.leavePostWhileNotReadyMessage) + #expect(alert.actions.map(\.title) == [PostEditorStrings.keepEditing, PostEditorStrings.leave]) + } + + private func makeViewController(postType: PostTypeDetails = .post) -> PostGBKEditorViewController { + let context = ContextManager.forTesting().mainContext + let blog = BlogBuilder(context).build() + return PostGBKEditorViewController( + postId: nil, + postType: postType, + title: "", + content: "", + status: "draft", + blog: blog + ) + } + /// Returns the media the picker has actually preselected. /// /// Read from the embedded collection view controller, which the picker adds as diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift index f5bc79d02ab8..4229573ba013 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift @@ -83,6 +83,10 @@ extension CommentGutenbergEditorViewController: GutenbergKit.EditorViewControlle // Do nothing } + func editorDidBecomeUnavailable(_ viewController: GutenbergKit.EditorViewController) { + // Do nothing + } + func editorDidRequestLatestContent(_ controller: GutenbergKit.EditorViewController) -> (title: String, content: String)? { return nil } diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift index 824eb8d0ca74..b91d86bc30c6 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/CustomPostEditorViewController.swift @@ -100,11 +100,34 @@ class CustomPostEditorViewController: PostGBKEditorViewController { } override func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { + super.editorDidLoad(viewContoller) + + updatePrimarySaveButton() if editorSession?.started == false { editorSession?.start() } } + override func editorDidBecomeUnavailable(_ viewController: GutenbergKit.EditorViewController) { + super.editorDidBecomeUnavailable(viewController) + + // Saving reads the content from the editor, so it has to wait for the reload. + updatePrimarySaveButton() + redoButton.isEnabled = false + undoButton.isEnabled = false + } + + private func updatePrimarySaveButton() { + primarySaveButton.isEnabled = !isEditorUnavailable + } + + override func editorDidRequestLatestContent( + _ controller: GutenbergKit.EditorViewController + ) -> (title: String, content: String)? { + // After a save, the post is newer than the content the editor opened with. + post.map { ($0.title?.raw ?? "", $0.content.raw ?? "") } + } + override func editor( _ viewController: GutenbergKit.EditorViewController, didUpdateHistoryState state: EditorState @@ -128,23 +151,42 @@ class CustomPostEditorViewController: PostGBKEditorViewController { private extension CustomPostEditorViewController { @objc func closeButtonAction() { + // Closing ends the editor's uploads, so confirm before the media is lost. + guard !isSavingLocked else { + return showLeaveWhileSavingLockedAlert { [weak self] in + Task { + await self?.dismiss(hasConfirmedSavingLock: true) + } + } + } Task { await dismiss() } } - func dismiss() async { + func dismiss(hasConfirmedSavingLock: Bool = false) async { navigationController?.view.isUserInteractionEnabled = false defer { navigationController?.view.isUserInteractionEnabled = true } + // The editor can't report its content before it loads, but nothing could have been edited yet either. + guard hasEditorLoaded || editorService.hasSettingsChanges else { + endEditorSession(outcome: .cancel) + navigationController?.dismiss(animated: true) + return + } + let changed: Bool + var canSave = true do { changed = try await hasUnsavedChanges() } catch { + // Without the editor's content (e.g. after a crash), let the user discard + // their changes rather than trapping them in the editor. DDLogError("Failed to get editor content: \(error)") - return + changed = true + canSave = false } if changed { @@ -155,14 +197,22 @@ private extension CustomPostEditorViewController { self.endEditorSession(outcome: .discard) self.navigationController?.dismiss(animated: true) } - if post?.status ?? .draft == .draft { + if canSave, post?.status ?? .draft == .draft { alert.addAction( UIAlertAction( title: PostEditorStrings.saveDraft, style: .default, handler: { [weak self] _ in - Task { - await self?.save() + let save: () -> Void = { + Task { + await self?.save() + } + } + // The Leave alert already warned about the upload, so don't ask again. + if hasConfirmedSavingLock { + save() + } else { + self?.performAfterConfirmingSavingLock(save) } } ) @@ -178,20 +228,27 @@ private extension CustomPostEditorViewController { } func rightBarButtonItems() -> [UIBarButtonItem] { - var children: [UIMenuElement] = [editorModeToggle(), settingsAction(), helpAction(), feedbackAction()] + // Deferred so the toggle reflects the current editor mode and availability. + let editorModeToggle = UIDeferredMenuElement.uncached { [weak self] resolve in + resolve(self.map { [$0.editorModeToggle()] } ?? []) + } + var children: [UIMenuElement] = [editorModeToggle, settingsAction(), helpAction(), feedbackAction()] if post?.status ?? .draft == .draft { let menu = UIDeferredMenuElement.uncached { [weak self] resolve in Task { - let enabled = (try? await self?.hasUnsavedChanges()) == true + let hasUnsavedChanges = (try? await self?.hasUnsavedChanges()) == true let saveDraft = UIAction( title: PostEditorStrings.saveDraft, image: UIImage(systemName: "doc"), - attributes: enabled ? [] : [.disabled] + attributes: hasUnsavedChanges ? [] : [.disabled] ) { [weak self] _ in - Task { - await self?.save() - } + self? + .performAfterConfirmingSavingLock { + Task { + await self?.save() + } + } } resolve([saveDraft]) } @@ -210,15 +267,21 @@ private extension CustomPostEditorViewController { private func savePostAction() -> UIAction { if post?.status ?? .draft == .draft { return UIAction(title: PostEditorStrings.publish) { [weak self] _ in - Task { - await self?.showPublishingSheet() - } + self? + .performAfterConfirmingSavingLock { + Task { + await self?.showPublishingSheet() + } + } } } else { return UIAction(title: PostEditorStrings.update) { [weak self] _ in - Task { - await self?.save() - } + self? + .performAfterConfirmingSavingLock { + Task { + await self?.save() + } + } } } } diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/GBKExtensions.swift b/WordPress/Classes/ViewRelated/NewGutenberg/GBKExtensions.swift index 2f705879e639..fee91dd11408 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/GBKExtensions.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/GBKExtensions.swift @@ -65,6 +65,21 @@ private func getLocalizedString(for value: GutenbergKit.EditorLocalizableString) value: "Failed to insert media", comment: "Error message when media insertion fails" ) + case .failedToLoadSelectedMedia: + NSLocalizedString( + "editor.media.failedToLoadSelected", + value: "The selected media could not be loaded. It may not be fully downloaded to this device.", + comment: "Error message when media picked in the block inserter cannot be loaded, e.g. an iCloud photo" + ) + case .failedToProcessCapturedMedia: + NSLocalizedString( + "editor.media.failedToProcessCaptured", + value: "The captured media could not be processed.", + comment: + "Error message when a photo or video taken with the camera from the block inserter cannot be processed" + ) + case .ok: + SharedStrings.Button.ok case .patterns: NSLocalizedString("editor.patterns.title", value: "Patterns", comment: "Navigation title for patterns view") case .noPatternsFound: @@ -121,6 +136,24 @@ private func getLocalizedString(for value: GutenbergKit.EditorLocalizableString) value: "Editor Error", comment: "Title shown when the editor encounters an error" ) + case .editorCrashedTitle: + NSLocalizedString( + "editor.crashed.title", + value: "The editor stopped working", + comment: "Title shown over the editor after it crashes" + ) + case .editorCrashedDescription: + NSLocalizedString( + "editor.crashed.description", + value: "Reload the editor to continue editing.", + comment: "Message shown over the editor after it crashes, above a button to reload it" + ) + case .editorCrashedReload: + NSLocalizedString( + "editor.crashed.reload", + value: "Reload Editor", + comment: "Button title to reload the editor after it crashes" + ) case .lockdownModeTitle: NSLocalizedString( "editor.lockdownMode.title", diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift index 0109635afcc2..0795a1f88a15 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift @@ -76,6 +76,8 @@ class NewGutenbergViewController: PostGBKEditorViewController, PostEditor, Publi func prepopulateMediaItems(_ media: [Media]) {} var debouncer = WordPressShared.Debouncer(delay: 10) var replaceEditor: (EditorViewController, EditorViewController) -> () + // The editor uploads media itself and locks saving until uploads finish. Rather + // than the shared guards blocking saves, `performAfterConfirmingSavingLock` asks. var isUploadingMedia: Bool { false } var wordCount: UInt { 0 } var postIsReblogged: Bool = false @@ -228,6 +230,8 @@ class NewGutenbergViewController: PostGBKEditorViewController, PostEditor, Publi */ override func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { + super.editorDidLoad(viewContoller) + if !editorSession.started { // Note that this method is also used to track startup performance // It assumes this is being called when the editor has finished loading @@ -237,6 +241,14 @@ class NewGutenbergViewController: PostGBKEditorViewController, PostEditor, Publi } } + override func editorDidBecomeUnavailable(_ viewController: GutenbergKit.EditorViewController) { + super.editorDidBecomeUnavailable(viewController) + + // A reloaded editor starts with empty history and re-enables these as the user edits. + gutenbergDidRequestToggleRedoButton(true) + gutenbergDidRequestToggleUndoButton(true) + } + override func editor( _ viewController: GutenbergKit.EditorViewController, didUpdateHistoryState state: GutenbergKit.EditorState @@ -308,6 +320,14 @@ extension NewGutenbergViewController: PostEditorNavigationBarManagerDelegate { func navigationBarManager(_ manager: PostEditorNavigationBarManager, closeWasPressed sender: UIButton) { performAfterUpdatingContent { [self] in + // Closing ends the editor's uploads, so confirm before the media is lost. + guard !isSavingLocked else { + return showLeaveWhileSavingLockedAlert { [weak self] in + // Refresh in case the upload finished while the alert was shown, then + // let the user save or discard the remaining changes. + self?.performAfterUpdatingContent { self?.cancelEditing() } + } + } cancelEditing() } } @@ -325,11 +345,15 @@ extension NewGutenbergViewController: PostEditorNavigationBarManagerDelegate { } func navigationBarManager(_ manager: PostEditorNavigationBarManager, publishButtonWasPressed sender: UIButton) { - performAfterUpdatingContent { [self] in - if editorHasContent { - handlePrimaryActionButtonTap() - } else { - showAlertForEmptyPostPublish() + // Unlike the media uploads of other editors, `PostCoordinator` can't wait for + // the editor's before publishing. + performAfterConfirmingSavingLock { [self] in + performAfterUpdatingContent { [self] in + if editorHasContent { + handlePrimaryActionButtonTap() + } else { + showAlertForEmptyPostPublish() + } } } } @@ -521,7 +545,7 @@ extension NewGutenbergViewController { image: UIImage(systemName: "doc"), attributes: (editorHasChanges && editorHasContent) ? [] : [.disabled] ) { [weak self] _ in - self?.buttonSaveDraftTapped() + self?.performAfterConfirmingSavingLock { self?.buttonSaveDraftTapped() } } ) } @@ -535,7 +559,7 @@ extension NewGutenbergViewController { actions.append( UIAction(title: Strings.preview, image: UIImage(systemName: "safari")) { [weak self] _ in - self?.displayPreview() + self?.performAfterConfirmingSavingLock { self?.displayPreview() } } ) diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/PostEditorStrings.swift b/WordPress/Classes/ViewRelated/NewGutenberg/PostEditorStrings.swift index aea1577dbdae..a420c4398835 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/PostEditorStrings.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/PostEditorStrings.swift @@ -2,19 +2,114 @@ import Foundation enum PostEditorStrings { // More menu - static let codeEditor = NSLocalizedString("postEditor.moreMenu.codeEditor", value: "Code Editor", comment: "Post Editor / Button in the 'More' menu") - static let visualEditor = NSLocalizedString("postEditor.moreMenu.visualEditor", value: "Visual Editor", comment: "Post Editor / Button in the 'More' menu") - static let helpAndSupport = NSLocalizedString("postEditor.moreMenu.helpAndSupport", value: "Help & Support", comment: "Post Editor / Button in the 'More' menu") - static let help = NSLocalizedString("postEditor.moreMenu.help", value: "Help", comment: "Post Editor / Button in the 'More' menu") - static let postSettings = NSLocalizedString("postEditor.moreMenu.postSettings", value: "Post Settings", comment: "Post Editor / Button in the 'More' menu") - static let sendFeedback = NSLocalizedString("postEditor.moreMenu.sendFeedback", value: "Send Feedback", comment: "Post Editor / Button in the 'More' menu") - static let saveDraft = NSLocalizedString("postEditor.moreMenu.saveDraft", value: "Save Draft", comment: "Post Editor / Button in the 'More' menu") + static let codeEditor = NSLocalizedString( + "postEditor.moreMenu.codeEditor", + value: "Code Editor", + comment: "Post Editor / Button in the 'More' menu" + ) + static let visualEditor = NSLocalizedString( + "postEditor.moreMenu.visualEditor", + value: "Visual Editor", + comment: "Post Editor / Button in the 'More' menu" + ) + static let helpAndSupport = NSLocalizedString( + "postEditor.moreMenu.helpAndSupport", + value: "Help & Support", + comment: "Post Editor / Button in the 'More' menu" + ) + static let help = NSLocalizedString( + "postEditor.moreMenu.help", + value: "Help", + comment: "Post Editor / Button in the 'More' menu" + ) + static let postSettings = NSLocalizedString( + "postEditor.moreMenu.postSettings", + value: "Post Settings", + comment: "Post Editor / Button in the 'More' menu" + ) + static let sendFeedback = NSLocalizedString( + "postEditor.moreMenu.sendFeedback", + value: "Send Feedback", + comment: "Post Editor / Button in the 'More' menu" + ) + static let saveDraft = NSLocalizedString( + "postEditor.moreMenu.saveDraft", + value: "Save Draft", + comment: "Post Editor / Button in the 'More' menu" + ) // Close confirmation - static let keepEditing = NSLocalizedString("postEditor.closeConfirmationAlert.keepEditing", value: "Keep Editing", comment: "Button to keep the changes in an alert confirming discarding changes") - static let discardChanges = NSLocalizedString("postEditor.closeConfirmationAlert.discardChanges", value: "Discard Changes", comment: "Button in an alert confirming discarding changes") + static let keepEditing = NSLocalizedString( + "postEditor.closeConfirmationAlert.keepEditing", + value: "Keep Editing", + comment: "Button to keep the changes in an alert confirming discarding changes" + ) + static let discardChanges = NSLocalizedString( + "postEditor.closeConfirmationAlert.discardChanges", + value: "Discard Changes", + comment: "Button in an alert confirming discarding changes" + ) + static let notReadyPostTitle = NSLocalizedString( + "postEditor.notReadyAlert.postTitle", + value: "Your post isn't ready yet", + comment: + "Title of an alert shown when saving or closing the editor for a post while something, like a media upload, is in progress" + ) + static let notReadyPageTitle = NSLocalizedString( + "postEditor.notReadyAlert.pageTitle", + value: "Your page isn't ready yet", + comment: + "Title of an alert shown when saving or closing the editor for a page while something, like a media upload, is in progress" + ) + static let leavePostWhileNotReadyMessage = NSLocalizedString( + "postEditor.notReadyAlert.leavePostMessage", + value: + "If you leave now, anything still in progress, like a media upload, stops and won't be added to your post.", + comment: + "Message of an alert shown when closing the editor for a post while something, like a media upload, is in progress" + ) + static let leavePageWhileNotReadyMessage = NSLocalizedString( + "postEditor.notReadyAlert.leavePageMessage", + value: + "If you leave now, anything still in progress, like a media upload, stops and won't be added to your page.", + comment: + "Message of an alert shown when closing the editor for a page while something, like a media upload, is in progress" + ) + static let savePostWhileNotReadyMessage = NSLocalizedString( + "postEditor.notReadyAlert.savePostMessage", + value: + "Something in the editor, like a media upload, is still in progress. If you continue, it may be missing or broken in your post.", + comment: + "Message of an alert shown when saving, publishing or previewing a post while something, like a media upload, is in progress" + ) + static let savePageWhileNotReadyMessage = NSLocalizedString( + "postEditor.notReadyAlert.savePageMessage", + value: + "Something in the editor, like a media upload, is still in progress. If you continue, it may be missing or broken in your page.", + comment: + "Message of an alert shown when saving, publishing or previewing a page while something, like a media upload, is in progress" + ) + static let leave = NSLocalizedString( + "postEditor.notReadyAlert.leave", + value: "Leave", + comment: "Button to close the editor in an alert shown while something, like a media upload, is in progress" + ) + static let continueAnyway = NSLocalizedString( + "postEditor.notReadyAlert.continue", + value: "Continue", + comment: + "Button to save, publish or preview anyway in an alert shown while something, like a media upload, is in progress" + ) // Primary actions - static let publish = NSLocalizedString("postEditor.publishButton.title", value: "Publish", comment: "Title for the publish button in the post editor navigation bar") - static let update = NSLocalizedString("postEditor.updateButton.title", value: "Update", comment: "Title for the update button in the post editor navigation bar") + static let publish = NSLocalizedString( + "postEditor.publishButton.title", + value: "Publish", + comment: "Title for the publish button in the post editor navigation bar" + ) + static let update = NSLocalizedString( + "postEditor.updateButton.title", + value: "Update", + comment: "Title for the update button in the post editor navigation bar" + ) } diff --git a/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift b/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift index 0974e201d94a..c72d36cacd2a 100644 --- a/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/NewGutenberg/PostGBKEditorViewController.swift @@ -18,6 +18,19 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont private var currentSuggestionsController: GutenbergSuggestionsViewController? + /// Whether the editor has finished loading at least once, so the user could have edited it. + private(set) var hasEditorLoaded = false + + /// Whether the editor crashed and has not reloaded yet, so calls into it are refused. + private(set) var isEditorUnavailable = false + + /// Whether the editor locked saving, e.g. during a media upload, so its content + /// may be incomplete or reference temporary media URLs. + private(set) var isSavingLocked = false + + /// Pages say "page" in alerts. Custom post types say "post", as "Post Settings" does. + private let isPage: Bool + init( postId: Int?, postType: PostTypeDetails, @@ -28,6 +41,7 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont ) { self.status = status ?? "draft" self.blog = blog + self.isPage = postType.postType == PostTypeDetails.page.postType EditorLocalization.localize = { $0.localized } @@ -81,7 +95,11 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont let title = editorViewController.isCodeEditorEnabled ? PostEditorStrings.visualEditor : PostEditorStrings.codeEditor let icon = editorViewController.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" - return UIAction(title: title, image: UIImage(systemName: icon)) { [weak editorViewController] _ in + return UIAction( + title: title, + image: UIImage(systemName: icon), + attributes: isEditorUnavailable ? [.disabled] : [] + ) { [weak editorViewController] _ in editorViewController?.isCodeEditorEnabled.toggle() } } @@ -96,6 +114,40 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont } } + /// Asks before saving while the editor locks saving. A lock is only advisory, so a + /// plugin that never releases one can't keep the user from saving. + func performAfterConfirmingSavingLock(_ action: @escaping () -> Void) { + guard isSavingLocked else { + return action() + } + let alert = UIAlertController( + title: notReadyTitle, + message: isPage + ? PostEditorStrings.savePageWhileNotReadyMessage : PostEditorStrings.savePostWhileNotReadyMessage, + preferredStyle: .alert + ) + alert.addCancelActionWithTitle(PostEditorStrings.keepEditing) + alert.addDefaultActionWithTitle(PostEditorStrings.continueAnyway) { _ in action() } + present(alert, animated: true) + } + + /// Asks before closing while the editor locks saving, since closing ends the editor's uploads. + func showLeaveWhileSavingLockedAlert(onLeave: @escaping () -> Void) { + let alert = UIAlertController( + title: notReadyTitle, + message: isPage + ? PostEditorStrings.leavePageWhileNotReadyMessage : PostEditorStrings.leavePostWhileNotReadyMessage, + preferredStyle: .alert + ) + alert.addCancelActionWithTitle(PostEditorStrings.keepEditing) + alert.addDestructiveActionWithTitle(PostEditorStrings.leave) { _ in onLeave() } + present(alert, animated: true) + } + + private var notReadyTitle: String { + isPage ? PostEditorStrings.notReadyPageTitle : PostEditorStrings.notReadyPostTitle + } + func feedbackAction() -> UIAction { UIAction(title: PostEditorStrings.sendFeedback, image: UIImage(systemName: "envelope")) { [weak self] _ in self? @@ -108,8 +160,17 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont // MARK: - GutenbergKit.EditorViewControllerDelegate + /// Subclasses that override this must call `super`. func editorDidLoad(_ viewContoller: GutenbergKit.EditorViewController) { - // Do nothing + hasEditorLoaded = true + isEditorUnavailable = false + } + + /// Subclasses that override this must call `super`. + func editorDidBecomeUnavailable(_ viewController: GutenbergKit.EditorViewController) { + isEditorUnavailable = true + // The reloaded editor reports its availability again. + isSavingLocked = false } func editor(_ viewContoller: GutenbergKit.EditorViewController, didDisplayInitialContent content: String) { @@ -171,6 +232,14 @@ class PostGBKEditorViewController: UIViewController, GutenbergKit.EditorViewCont // Do nothing } + /// Subclasses that override this must call `super`. + func editor( + _ viewController: GutenbergKit.EditorViewController, + didUpdateSaveAvailability availability: GutenbergKit.EditorSaveAvailability + ) { + isSavingLocked = availability.isSavingLocked + } + func editor(_ viewController: GutenbergKit.EditorViewController, didUpdateFeaturedImage mediaID: Int) { // Do nothing }