Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
aa81131
build: update GutenbergKit to trunk
dcalhoun Oct 28, 2026
420d9b5
fix(editor): implement GutenbergKit's editor-unavailable callback
dcalhoun Sep 30, 2026
c2d7962
feat(editor): localize GutenbergKit's crash and media error strings
dcalhoun Sep 30, 2026
26320fa
fix(editor): disable editor controls while the editor is unavailable
dcalhoun Sep 30, 2026
c883e45
fix(editor): reload custom posts from their last saved content
dcalhoun Sep 30, 2026
731a3b5
fix(editor): let users close the custom post editor without its content
dcalhoun Sep 30, 2026
aaf4169
fix(editor): don't save posts to the server while GutenbergKit locks …
dcalhoun Oct 28, 2026
e173fad
fix(editor): disable custom post saving while GutenbergKit locks saving
dcalhoun Oct 28, 2026
f58d0c0
style: format PostEditorStrings with swift-format
dcalhoun Oct 28, 2026
f797688
fix(editor): confirm closing the editor while GutenbergKit uploads media
dcalhoun Oct 28, 2026
e7745bb
build: update GutenbergKit to 0.20.0-alpha.1
dcalhoun Oct 28, 2026
b4aa651
fix(editor): run the normal close flow after leaving mid-upload
dcalhoun Oct 29, 2026
958b85d
fix(editor): ask before saving while GutenbergKit locks saving
dcalhoun Oct 29, 2026
8963c16
docs: add a release note for the GutenbergKit 0.20.0-alpha.1 update
dcalhoun Oct 29, 2026
89c7e07
fix(editor): ask before closing custom posts while GutenbergKit locks…
dcalhoun Oct 29, 2026
e69c547
fix(editor): say "page" in the saving lock alerts for pages
dcalhoun Oct 29, 2026
8bcbb4e
fix(editor): say "page" in the saving lock alert messages for pages
dcalhoun Oct 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Modules/Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
}
}
)
Expand All @@ -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])
}
Expand All @@ -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()
}
}
}
}
}
Expand Down
Loading