Skip to content

Commit 720b6f0

Browse files
authored
feat: GutenbergKit supports mentions and cross-posts (#24733)
* feat: Display suggestions UI for GutenbergKit autocompletion * feat: Dynamically position suggestions UI above virtual keyboard * feat: Append suggestion text at GutenbergKit cursor * fix: Prevent rendering duplicative suggestions UI Stepping through history state may retrigger autocompletion events, which led to rendering duplicative UI views. * perf: Prefetch suggestions Improve performance and avoid displaying empty suggestions UI for blogs without support for suggestions. * build: Update GutenbergKit version * style: Fix lint warnings * build: Update GutenbergKit version * build: Update GutenbergKit version * docs: Shorten inline comment * build: Update GutenbergKit version * refactor: Replace `print` usage with project logs * refactor: Use weak reference to avoid a retain cycle * refactor: Remove unnecessary dispatch async * refactor: Remove unnecessary view property * build: Update GutenbergKit version
1 parent cddd586 commit 720b6f0

File tree

4 files changed

+171
-22
lines changed

4 files changed

+171
-22
lines changed

Modules/Package.resolved

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Modules/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ let package = Package(
5555
.package(url: "https://github.com/zendesk/support_sdk_ios", from: "8.0.3"),
5656
// We can't use wordpress-rs branches nor commits here. Only tags work.
5757
.package(url: "https://github.com/Automattic/wordpress-rs", revision: "alpha-20250715"),
58-
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.7.0"),
58+
.package(url: "https://github.com/wordpress-mobile/GutenbergKit", from: "0.8.0-alpha.0"),
5959
.package(
6060
url: "https://github.com/Automattic/color-studio",
6161
revision: "bf141adc75e2769eb469a3e095bdc93dc30be8de"

WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,4 +91,8 @@ extension CommentGutenbergEditorViewController: GutenbergKit.EditorViewControlle
9191
func editor(_ viewController: GutenbergKit.EditorViewController, didRequestMediaFromSiteMediaLibrary config: GutenbergKit.OpenMediaLibraryAction) {
9292
// Do nothing
9393
}
94+
95+
func editor(_ viewController: GutenbergKit.EditorViewController, didTriggerAutocompleter type: String) {
96+
// Do nothing
97+
}
9498
}

WordPress/Classes/ViewRelated/NewGutenberg/NewGutenbergViewController.swift

Lines changed: 163 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import SafariServices
77
import WordPressData
88
import WordPressShared
99
import WebKit
10+
import CocoaLumberjackSwift
1011

1112
class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor {
1213
let errorDomain: String = "GutenbergViewController.errorDomain"
@@ -79,6 +80,14 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
7980
self.performAutoSave()
8081
}
8182

83+
// MARK: - Private Properties
84+
85+
private var keyboardShowObserver: Any?
86+
private var keyboardHideObserver: Any?
87+
private var keyboardFrame = CGRect.zero
88+
private var suggestionViewBottomConstraint: NSLayoutConstraint?
89+
private var currentSuggestionsController: GutenbergSuggestionsViewController?
90+
8291
// TODO: remove (none of these APIs are needed for the new editor)
8392
func prepopulateMediaItems(_ media: [Media]) {}
8493
var debouncer = WordPressShared.Debouncer(delay: 10)
@@ -146,10 +155,15 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
146155
fatalError()
147156
}
148157

158+
deinit {
159+
tearDownKeyboardObservers()
160+
}
161+
149162
// MARK: - Lifecycle methods
150163

151164
override func viewDidLoad() {
152165
super.viewDidLoad()
166+
setupKeyboardObservers()
153167

154168
view.backgroundColor = .systemBackground
155169

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

221-
// TODO: reimplement
222-
// SiteSuggestionService.shared.prefetchSuggestionsIfNeeded(for: post.blog) { [weak self] in
223-
// self?.gutenberg.updateCapabilities()
224-
// }
235+
SiteSuggestionService.shared.prefetchSuggestionsIfNeeded(for: post.blog) {
236+
// Do nothing
237+
}
225238
}
226239

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

250263
if let title = editorData?.title,
251264
let content = editorData?.content,
@@ -273,6 +286,49 @@ class NewGutenbergViewController: UIViewController, PostEditor, PublishingEditor
273286
}
274287
}
275288

289+
// MARK: - Keyboard Observers
290+
291+
private func setupKeyboardObservers() {
292+
keyboardShowObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidShowNotification, object: nil, queue: .main) { [weak self] (notification) in
293+
if let self, let keyboardRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
294+
self.keyboardFrame = keyboardRect
295+
self.updateConstraintsToAvoidKeyboard(frame: keyboardRect)
296+
}
297+
}
298+
keyboardHideObserver = NotificationCenter.default.addObserver(forName: UIResponder.keyboardDidHideNotification, object: nil, queue: .main) { [weak self] (notification) in
299+
if let self, let keyboardRect = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
300+
self.keyboardFrame = keyboardRect
301+
self.updateConstraintsToAvoidKeyboard(frame: keyboardRect)
302+
}
303+
}
304+
}
305+
306+
private func tearDownKeyboardObservers() {
307+
if let keyboardShowObserver {
308+
NotificationCenter.default.removeObserver(keyboardShowObserver)
309+
}
310+
if let keyboardHideObserver {
311+
NotificationCenter.default.removeObserver(keyboardHideObserver)
312+
}
313+
}
314+
315+
private func updateConstraintsToAvoidKeyboard(frame: CGRect) {
316+
keyboardFrame = frame
317+
let minimumKeyboardHeight = CGFloat(50)
318+
guard let suggestionViewBottomConstraint else {
319+
return
320+
}
321+
322+
// 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.
323+
// So in those scenarios, we just need to take in account the safe area and ignore the keyboard all together.
324+
if keyboardFrame.height < minimumKeyboardHeight {
325+
suggestionViewBottomConstraint.constant = -self.view.safeAreaInsets.bottom
326+
}
327+
else {
328+
suggestionViewBottomConstraint.constant = -self.keyboardFrame.height
329+
}
330+
}
331+
276332
// MARK: - Activity Indicator
277333

278334
private func showActivityIndicator() {
@@ -459,14 +515,41 @@ extension NewGutenbergViewController: GutenbergKit.EditorViewControllerDelegate
459515
}
460516
}
461517

518+
func editor(_ viewController: GutenbergKit.EditorViewController, didTriggerAutocompleter type: String) {
519+
switch type {
520+
case "at-symbol":
521+
showSuggestions(type: .mention) { [weak self] result in
522+
switch result {
523+
case .success(let suggestion):
524+
// Appended space completes the autocomplete session
525+
self?.editorViewController.appendTextAtCursor(suggestion + " ")
526+
case .failure(let error):
527+
DDLogError("Mention selection cancelled or failed: \(error)")
528+
}
529+
}
530+
case "plus-symbol":
531+
showSuggestions(type: .xpost) { [weak self] result in
532+
switch result {
533+
case .success(let suggestion):
534+
// Appended space completes the autocomplete session
535+
self?.editorViewController.appendTextAtCursor(suggestion + " ")
536+
case .failure(let error):
537+
DDLogError("Xpost selection cancelled or failed: \(error)")
538+
}
539+
}
540+
default:
541+
DDLogError("Unknown autocompleter type: \(type)")
542+
}
543+
}
544+
462545
private func convertMediaInfoArrayToJSONString(_ mediaInfoArray: [MediaInfo]) -> String? {
463546
do {
464547
let jsonData = try JSONEncoder().encode(mediaInfoArray)
465548
if let jsonString = String(data: jsonData, encoding: .utf8) {
466549
return jsonString
467550
}
468551
} catch {
469-
print("Error encoding MediaInfo array: \(error)")
552+
DDLogError("Error encoding MediaInfo array: \(error)")
470553
}
471554
return nil
472555
}
@@ -521,18 +604,80 @@ extension NewGutenbergViewController {
521604
present(lightboxVC, animated: true)
522605
}
523606

524-
// TODO: reimplement
525-
// func gutenbergDidRequestMention(callback: @escaping (Swift.Result<String, NSError>) -> Void) {
526-
// DispatchQueue.main.async(execute: { [weak self] in
527-
// self?.showSuggestions(type: .mention, callback: callback)
528-
// })
529-
// }
530-
//
531-
// func gutenbergDidRequestXpost(callback: @escaping (Swift.Result<String, NSError>) -> Void) {
532-
// DispatchQueue.main.async(execute: { [weak self] in
533-
// self?.showSuggestions(type: .xpost, callback: callback)
534-
// })
535-
// }
607+
}
608+
609+
// MARK: - Suggestions implementation
610+
611+
extension NewGutenbergViewController {
612+
613+
private func showSuggestions(type: SuggestionType, callback: @escaping (Swift.Result<String, NSError>) -> Void) {
614+
// Prevent multiple suggestions UI instances - simply ignore if already showing
615+
guard currentSuggestionsController == nil else {
616+
return
617+
}
618+
guard let siteID = post.blog.dotComID else {
619+
callback(.failure(GutenbergSuggestionsViewController.SuggestionError.notAvailable as NSError))
620+
return
621+
}
622+
623+
switch type {
624+
case .mention:
625+
guard SuggestionService.shared.shouldShowSuggestions(for: post.blog) else { return }
626+
case .xpost:
627+
guard SiteSuggestionService.shared.shouldShowSuggestions(for: post.blog) else { return }
628+
}
629+
630+
let previousFirstResponder = view.findFirstResponder()
631+
let suggestionsController = GutenbergSuggestionsViewController(siteID: siteID, suggestionType: type)
632+
currentSuggestionsController = suggestionsController
633+
suggestionsController.onCompletion = { [weak self] (result) in
634+
callback(result)
635+
636+
if let self {
637+
// Clear the current controller reference
638+
self.currentSuggestionsController = nil
639+
self.suggestionViewBottomConstraint = nil
640+
641+
// Clean up the UI (should only happen if parent still exists)
642+
suggestionsController.view.removeFromSuperview()
643+
suggestionsController.removeFromParent()
644+
645+
previousFirstResponder?.becomeFirstResponder()
646+
}
647+
648+
var analyticsName: String
649+
switch type {
650+
case .mention:
651+
analyticsName = "user"
652+
case .xpost:
653+
analyticsName = "xpost"
654+
}
655+
656+
var didSelectSuggestion = false
657+
if case let .success(text) = result, !text.isEmpty {
658+
didSelectSuggestion = true
659+
}
660+
661+
let analyticsProperties: [String: Any] = [
662+
"suggestion_type": analyticsName,
663+
"did_select_suggestion": didSelectSuggestion
664+
]
665+
666+
WPAnalytics.track(.gutenbergSuggestionSessionFinished, properties: analyticsProperties)
667+
}
668+
addChild(suggestionsController)
669+
view.addSubview(suggestionsController.view)
670+
let suggestionsBottomConstraint = suggestionsController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor, constant: 0)
671+
NSLayoutConstraint.activate([
672+
suggestionsController.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 0),
673+
suggestionsController.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: 0),
674+
suggestionsBottomConstraint,
675+
suggestionsController.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
676+
])
677+
self.suggestionViewBottomConstraint = suggestionsBottomConstraint
678+
updateConstraintsToAvoidKeyboard(frame: keyboardFrame)
679+
suggestionsController.didMove(toParent: self)
680+
}
536681
}
537682

538683
// MARK: - GutenbergBridgeDataSource

0 commit comments

Comments
 (0)