Skip to content

feat: GutenbergKit supports mentions and cross-posts #24733

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Aug 13, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -55,7 +55,7 @@ let package = Package(
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
// We can't use wordpress-rs branches nor commits here. Only tags work.
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250715"),
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.7.0"),
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.8.0-alpha.0"),
.package(
url: "https://github.com/Automattic/color-studio",
revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,8 @@ extension CommentGutenbergEditorViewController: GutenbergKit.EditorViewControlle
func editor(_ viewController: GutenbergKit.EditorViewController, didRequestMediaFromSiteMediaLibrary config: GutenbergKit.OpenMediaLibraryAction) {
// Do nothing
}

func editor(_ viewController: GutenbergKit.EditorViewController, didTriggerAutocompleter type: String) {
// Do nothing
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import SafariServices
import WordPressData
import WordPressShared
import WebKit
import CocoaLumberjackSwift

class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor {
let errorDomain: String = "GutenbergViewController.errorDomain"
Expand Down Expand Up @@ -79,6 +80,14 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
self.performAutoSave()
}

// MARK: - Private Properties

private var keyboardShowObserver: Any?
private var keyboardHideObserver: Any?
private var keyboardFrame = CGRect.zero
private var suggestionViewBottomConstraint: NSLayoutConstraint?
private var currentSuggestionsController: GutenbergSuggestionsViewController?

// TODO: remove (none of these APIs are needed for the new editor)
func prepopulateMediaItems(_ media: [Media]) {}
var debouncer = WordPressShared.Debouncer(delay: 10)
Expand Down Expand Up @@ -146,10 +155,15 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
fatalError()
}

deinit {
tearDownKeyboardObservers()
}

// MARK: - Lifecycle methods

override func viewDidLoad() {
super.viewDidLoad()
setupKeyboardObservers()

view.backgroundColor = .systemBackground

Expand Down Expand Up @@ -218,10 +232,9 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
setTitle(post.postTitle ?? "")
editorViewController.setContent(content)

// TODO: reimplement
// SiteSuggestionService.shared.prefetchSuggestionsIfNeeded(for: post.blog) { [weak self] in
// self?.gutenberg.updateCapabilities()
// }
SiteSuggestionService.shared.prefetchSuggestionsIfNeeded(for: post.blog) {
// Do nothing
}
}

private func refreshInterface() {
Expand All @@ -245,7 +258,7 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
let startTime = CFAbsoluteTimeGetCurrent()
let editorData = try? await editorViewController.getTitleAndContent()
let duration = CFAbsoluteTimeGetCurrent() - startTime
print("gutenbergkit-measure_get-latest-content:", duration)
DDLogDebug("gutenbergkit-measure_get-latest-content: \(duration)")

if let title = editorData?.title,
let content = editorData?.content,
Expand Down Expand Up @@ -273,6 +286,49 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
}
}

// MARK: - Keyboard Observers

private func setupKeyboardObservers() {
keyboardShowObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidShowNotification, object: nil, queue: .main) { [weak self] (notification) in
if let self, let keyboardRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
self.keyboardFrame = keyboardRect
self.updateConstraintsToAvoidKeyboard(frame: keyboardRect)
}
}
keyboardHideObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidHideNotification, object: nil, queue: .main) { [weak self] (notification) in
if let self, let keyboardRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
self.keyboardFrame = keyboardRect
self.updateConstraintsToAvoidKeyboard(frame: keyboardRect)
}
}
}

private func tearDownKeyboardObservers() {
if let keyboardShowObserver {
NotificationCenter.default.removeObserver(keyboardShowObserver)
}
if let keyboardHideObserver {
NotificationCenter.default.removeObserver(keyboardHideObserver)
}
}

private func updateConstraintsToAvoidKeyboard(frame: CGRect) {
keyboardFrame = frame
let minimumKeyboardHeight = CGFloat(50)
guard let suggestionViewBottomConstraint else {
return
}

// There are cases where the keyboard is not visible, but the system instead of returning zero, returns a low number, for example: 0, 3, 69.
// So in those scenarios, we just need to take in account the safe area and ignore the keyboard all together.
if keyboardFrame.height < minimumKeyboardHeight {
suggestionViewBottomConstraint.constant = -self.view.safeAreaInsets.bottom
}
else {
suggestionViewBottomConstraint.constant = -self.keyboardFrame.height
}
}

// MARK: - Activity Indicator

private func showActivityIndicator() {
Expand Down Expand Up @@ -459,14 +515,41 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate
}
}

func editor(_ viewController: GutenbergKit.EditorViewController, didTriggerAutocompleter type: String) {
switch type {
case "at-symbol":
showSuggestions(type: .mention) { [weak self] result in
switch result {
case .success(let suggestion):
// Appended space completes the autocomplete session
self?.editorViewController.appendTextAtCursor(suggestion + " ")
case .failure(let error):
DDLogError("Mention selection cancelled or failed: \(error)")
}
}
case "plus-symbol":
showSuggestions(type: .xpost) { [weak self] result in
switch result {
case .success(let suggestion):
// Appended space completes the autocomplete session
self?.editorViewController.appendTextAtCursor(suggestion + " ")
case .failure(let error):
DDLogError("Xpost selection cancelled or failed: \(error)")
}
}
default:
DDLogError("Unknown autocompleter type: \(type)")
}
}

private func convertMediaInfoArrayToJSONString(_ mediaInfoArray: [MediaInfo]) -> String? {
do {
let jsonData = try JSONEncoder().encode(mediaInfoArray)
if let jsonString = String(data: jsonData, encoding: .utf8) {
return jsonString
}
} catch {
print("Error encoding MediaInfo array: \(error)")
DDLogError("Error encoding MediaInfo array: \(error)")
}
return nil
}
Expand Down Expand Up @@ -521,18 +604,80 @@ extension NewGutenbergViewController {
present(lightboxVC, animated: true)
}

// TODO: reimplement
// func gutenbergDidRequestMention(callback: @escaping (Swift.Result<String, NSError>) -> Void) {
// DispatchQueue.main.async(execute: { [weak self] in
// self?.showSuggestions(type: .mention, callback: callback)
// })
// }
//
// func gutenbergDidRequestXpost(callback: @escaping (Swift.Result<String, NSError>) -> Void) {
// DispatchQueue.main.async(execute: { [weak self] in
// self?.showSuggestions(type: .xpost, callback: callback)
// })
// }
}

// MARK: - Suggestions implementation

extension NewGutenbergViewController {

private func showSuggestions(type: SuggestionType, callback: @escaping (Swift.Result<String, NSError>) -> Void) {
// Prevent multiple suggestions UI instances - simply ignore if already showing
guard currentSuggestionsController == nil else {
return
}
guard let siteID = post.blog.dotComID else {
callback(.failure(GutenbergSuggestionsViewController.SuggestionError.notAvailable as NSError))
return
}

switch type {
case .mention:
guard SuggestionService.shared.shouldShowSuggestions(for: post.blog) else { return }
case .xpost:
guard SiteSuggestionService.shared.shouldShowSuggestions(for: post.blog) else { return }
}

let previousFirstResponder = view.findFirstResponder()
let suggestionsController = GutenbergSuggestionsViewController(siteID: siteID, suggestionType: type)
currentSuggestionsController = suggestionsController
suggestionsController.onCompletion = { [weak self] (result) in
callback(result)

if let self {
// Clear the current controller reference
self.currentSuggestionsController = nil
self.suggestionViewBottomConstraint = nil

// Clean up the UI (should only happen if parent still exists)
suggestionsController.view.removeFromSuperview()
suggestionsController.removeFromParent()

previousFirstResponder?.becomeFirstResponder()
}

var analyticsName: String
switch type {
case .mention:
analyticsName = "user"
case .xpost:
analyticsName = "xpost"
}

var didSelectSuggestion = false
if case let .success(text) = result, !text.isEmpty {
didSelectSuggestion = true
}

let analyticsProperties: [String: Any] = [
"suggestion_type": analyticsName,
"did_select_suggestion": didSelectSuggestion
]

WPAnalytics.track(.gutenbergSuggestionSessionFinished, properties: analyticsProperties)
}
addChild(suggestionsController)
view.addSubview(suggestionsController.view)
let suggestionsBottomConstraint = suggestionsController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0)
NSLayoutConstraint.activate([
suggestionsController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 0),
suggestionsController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: 0),
suggestionsBottomConstraint,
suggestionsController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
])
self.suggestionViewBottomConstraint = suggestionsBottomConstraint
updateConstraintsToAvoidKeyboard(frame: keyboardFrame)
suggestionsController.didMove(toParent: self)
}
}

// MARK: - GutenbergBridgeDataSource
Expand Down