diff --git a/Modules/Package.swift b/Modules/Package.swift index 4899f769a5b1..57e25feb23f9 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -44,7 +44,7 @@ let package = Package( .package(url: "https://github.com/wordpress-mobile/MediaEditor-iOS", branch: "task/spm-support"), .package(url: "https://github.com/wordpress-mobile/NSObject-SafeExpectations", from: "0.0.6"), .package(url: "https://github.com/wordpress-mobile/NSURL-IDN", branch: "trunk"), - .package(url: "https://github.com/wordpress-mobile/WordPressKit-iOS", branch: "wpios-edition"), + .package(url: "https://github.com/wordpress-mobile/WordPressKit-iOS", branch: "task/add-remote-comment-update-comment"), .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-20250127"), diff --git a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved index 12e5ebb9906d..b1f051b6eba1 100644 --- a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "75fed979514ba51feb1e17be7b1054f54eaf5ca96b5c622015cd9ba62529596a", + "originHash" : "2712221979a1856677c8c49bc57acee25847f5e210606ebbc01a5dec2cb9db5c", "pins" : [ { "identity" : "alamofire", @@ -392,8 +392,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { - "branch" : "wpios-edition", - "revision" : "cd1458be33c9715a293b6b5ea1dea851dfb64d05" + "branch" : "task/add-remote-comment-update-comment", + "revision" : "d46b5be685c9921e235d3135d03a7f51fb5fb4c1" } }, { diff --git a/WordPress/Classes/Extensions/Comment+Interface.swift b/WordPress/Classes/Extensions/Comment+Interface.swift index 319e8de0c611..fc229b512792 100644 --- a/WordPress/Classes/Extensions/Comment+Interface.swift +++ b/WordPress/Classes/Extensions/Comment+Interface.swift @@ -76,4 +76,20 @@ extension Comment { } } } + + // MARK: Helpers + + /// Returns an associated site ID. + /// + /// - note: The `Comment` class can be used in two different contexts: + /// Reader and Site, and this property works accordingly. + var associatedSiteID: NSNumber? { + if let post = post as? ReaderPost { + return post.siteID + } else if let blogID = blog?.dotComID { + return blogID + } else { + return nil + } + } } diff --git a/WordPress/Classes/Services/CommentService+Replies.swift b/WordPress/Classes/Services/CommentService+Swift.swift similarity index 100% rename from WordPress/Classes/Services/CommentService+Replies.swift rename to WordPress/Classes/Services/CommentService+Swift.swift diff --git a/WordPress/Classes/Services/CommentService.h b/WordPress/Classes/Services/CommentService.h index f6f3addf4b43..2c0fe6412a71 100644 --- a/WordPress/Classes/Services/CommentService.h +++ b/WordPress/Classes/Services/CommentService.h @@ -124,7 +124,7 @@ extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage; - (void)updateCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID content:(NSString *)content - success:(void (^ _Nullable)(void))success + success:(void (^ _Nullable)(RemoteComment * _Nullable comment))success failure:(void (^ _Nullable)(NSError * _Nullable error))failure; // Replies diff --git a/WordPress/Classes/Services/CommentService.m b/WordPress/Classes/Services/CommentService.m index 7a92397b7caa..9b6c8b6ce42f 100644 --- a/WordPress/Classes/Services/CommentService.m +++ b/WordPress/Classes/Services/CommentService.m @@ -666,7 +666,7 @@ - (NSString *)sanitizeCommentContent:(NSString *)string isPrivateSite:(BOOL)isPr - (void)updateCommentWithID:(NSNumber *)commentID siteID:(NSNumber *)siteID content:(NSString *)content - success:(void (^)(void))success + success:(void (^)(RemoteComment *comment))success failure:(void (^)(NSError *error))failure { CommentServiceRemoteREST *remote = [self restRemoteForSite:siteID]; diff --git a/WordPress/Classes/Services/NotificationActionsService.swift b/WordPress/Classes/Services/NotificationActionsService.swift index b14079779ea5..0753f44a221d 100644 --- a/WordPress/Classes/Services/NotificationActionsService.swift +++ b/WordPress/Classes/Services/NotificationActionsService.swift @@ -87,7 +87,7 @@ class NotificationActionsService: CoreDataService { block.textOverride = content // Hit the backend - commentService.updateComment(withID: commentID, siteID: siteID, content: content, success: { + commentService.updateComment(withID: commentID, siteID: siteID, content: content, success: { _ in DDLogInfo("Successfully updated to comment \(siteID).\(commentID)") self.invalidateCacheAndForceSyncNotification(with: block) completion?(true) diff --git a/WordPress/Classes/Stores/NoticeStore.swift b/WordPress/Classes/Stores/NoticeStore.swift index 75d0bc0c5794..282e008483b4 100644 --- a/WordPress/Classes/Stores/NoticeStore.swift +++ b/WordPress/Classes/Stores/NoticeStore.swift @@ -1,5 +1,6 @@ import Foundation import WordPressFlux +import WordPressShared /// Notice represents a small notification that that can be displayed within /// the app, much like Android toasts or snackbars. @@ -65,7 +66,6 @@ struct Notice { self.actionHandler = actionHandler self.style = style } - } extension Notice: Equatable { @@ -75,6 +75,14 @@ extension Notice: Equatable { } extension Notice { + /// Creates a notice with a localized description of the given error. + init(error: Error, title: String? = nil) { + self.init( + title: title ?? SharedStrings.Error.generic, + message: error.localizedDescription.stringByDecodingXMLCharacters() + ) + } + func post() { ActionDispatcher.dispatch(NoticeAction.post(self)) } diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift index 408eac78fef4..28704ad829c1 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift @@ -982,13 +982,10 @@ private extension CommentDetailViewController { } @objc func buttonAddCommentTapped() { - guard let viewModel = CommentComposerViewModel(comment: comment) else { - return wpAssertionFailure("missing required parameters") - } - viewModel.save = { [weak self] in + let viewModel = CommentCreateViewModel(replyingTo: comment) { [weak self] in try await self?.createReply(content: $0) } - let composerVC = CommentComposerViewController(viewModel: viewModel) + let composerVC = CommentCreateViewController(viewModel: viewModel) let navigationVC = UINavigationController(rootViewController: composerVC) present(navigationVC, animated: true) } diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentComposerViewModel.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentComposerViewModel.swift deleted file mode 100644 index 0b6636f6f2e8..000000000000 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentComposerViewModel.swift +++ /dev/null @@ -1,135 +0,0 @@ -import Foundation -import CoreData - -final class CommentComposerViewModel { - let suggestionsViewModel: SuggestionsListViewModel? - - var save: (String) async throws -> Void = { _ in - wpAssertionFailure("must be specified") - } - - /// Comment you are replying it. - var comment: Comment? - - private let parameters: CommentComposerParameters - private var context: NSManagedObjectContext - - var isGutenbergEnabled: Bool { - FeatureFlag.readerGutenbergCommentComposer.enabled - } - - /// Send a top-level comment to the given post. - convenience init(post: ReaderPost) { - let parameters = CommentComposerParameters(siteID: post.siteID, context: .post) - - let suggestionsViewModel = SuggestionsListViewModel.make(siteID: post.siteID) - suggestionsViewModel?.enableProminentSuggestions(postAuthorID: post.authorID) - - self.init(parameters: parameters, suggestionsViewModel: suggestionsViewModel) - } - - /// Reply to the given comment. - convenience init?(comment: Comment) { - let siteID: NSNumber - if let post = comment.post as? ReaderPost { - siteID = post.siteID - } else if let blogID = comment.blog?.dotComID { - siteID = blogID - } else { - return nil - } - - let parameters = CommentComposerParameters(siteID: siteID, context: .comment) - - let suggestionsViewModel = SuggestionsListViewModel.make(siteID: siteID) - suggestionsViewModel?.enableProminentSuggestions( - postAuthorID: comment.post?.authorID, - commentAuthorID: comment.commentID as NSNumber - ) - - self.init(parameters: parameters, suggestionsViewModel: suggestionsViewModel) - self.comment = comment - } - - init( - parameters: CommentComposerParameters, - suggestionsViewModel: SuggestionsListViewModel?, - context: NSManagedObjectContext = ContextManager.shared.mainContext - ) { - self.parameters = parameters - self.suggestionsViewModel = suggestionsViewModel - self.context = context - } - - var navigationTitle: String { - switch parameters.context { - case .post: return Strings.comment - case .comment: return Strings.reply - } - } - - var placeholder: String { - switch parameters.context { - case .post: return Strings.leaveComment - case .comment: return Strings.leaveReply - } - } - - static var leaveCommentLocalizedPlaceholder: String { - Strings.leaveComment - } - - // MARK: Drafts - - func restoreDraft() -> String? { - guard let key = makeDraftKey() else { return nil } - return UserDefaults.standard.string(forKey: key) - } - - var canSaveDraft: Bool { - makeDraftKey() != nil - } - - func saveDraft(_ content: String) { - guard let key = makeDraftKey() else { return } - return UserDefaults.standard.set(content, forKey: key) - } - - func deleteDraft() { - guard let key = makeDraftKey() else { return } - UserDefaults.standard.removeObject(forKey: key) - } - - private func makeDraftKey() -> String? { - guard let userID = (try? WPAccount.lookupDefaultWordPressComAccount(in: context))?.userID else { - return nil - } - return "CommentDraft-\(userID),\(parameters.siteID),\(comment?.commentID ?? 0)" - } -} - -struct CommentComposerParameters { - var siteID: NSNumber - var context: Context - - enum Context { - /// Send a top-level comment to the given post. - case post - - /// Send a reply to the given comment. - case comment - } -} - -private struct CommentID { - let userID: NSNumber - let siteID: NSNumber - let commentID: NSNumber? -} - -private enum Strings { - static let reply = NSLocalizedString("commentComposer.navigationTitleReply", value: "Reply", comment: "Navigation bar title when leaving a reply to a comment") - static let comment = NSLocalizedString("commentComposer.navigationTitleComment", value: "Comment", comment: "Navigation bar title when leaving a reply to a comment") - static let leaveReply = NSLocalizedString("commentComposer.placeholderLeaveReply", value: "Leave a reply…", comment: "Navigation bar title when leaving a reply to a comment") - static let leaveComment = NSLocalizedString("commentComposer.placeholderLeaveComment", value: "Leave a comment…", comment: "Navigation bar title when leaving a reply to a comment") -} diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentComposerViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewController.swift similarity index 51% rename from WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentComposerViewController.swift rename to WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewController.swift index 05ec9f29b29c..cb7086d452d2 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentComposerViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewController.swift @@ -1,7 +1,7 @@ import UIKit import WordPressUI -final class CommentComposerViewController: UIViewController { +final class CommentCreateViewController: UIViewController { private let buttonSend = UIButton(configuration: { var configuration = UIButton.Configuration.borderedProminent() configuration.title = Strings.send @@ -12,10 +12,10 @@ final class CommentComposerViewController: UIViewController { }()) private let contentView = UIStackView(axis: .vertical, []) - private var editor: CommentEditor? - private let viewModel: CommentComposerViewModel + private let editorVC = CommentEditorViewController() + private let viewModel: CommentCreateViewModel - init(viewModel: CommentComposerViewModel) { + init(viewModel: CommentCreateViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) @@ -34,13 +34,7 @@ final class CommentComposerViewController: UIViewController { setupNavigationBar() setupAccessibility() - updateInterface() - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - WPAnalytics.track(.commentFullScreenEntered) + didChangeText(editorVC.initialContent ?? "") } private func setupView() { @@ -48,7 +42,7 @@ final class CommentComposerViewController: UIViewController { contentView.pinEdges([.top, .horizontal], to: view.safeAreaLayoutGuide) contentView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor).isActive = true - if let comment = viewModel.comment { + if let comment = viewModel.replyToComment { let preview = CommentComposerReplyCommentView(comment: comment) contentView.addArrangedSubview(preview) @@ -56,43 +50,16 @@ final class CommentComposerViewController: UIViewController { contentView.addArrangedSubview(separator) } - setupEditor() - } - - private func setupEditor() { - let content = viewModel.restoreDraft() ?? "" - - if viewModel.isGutenbergEnabled { - setupGutenbergEditor(content: content) - } else { - setupPlainTextEditor(content: content) - } - } - - private func setupPlainTextEditor(content: String) { - let editorVC = CommentPlainTextEditorViewController() - editorVC.suggestionsViewModel = viewModel.suggestionsViewModel + editorVC.initialContent = viewModel.restoreDraft() editorVC.placeholder = viewModel.placeholder - editorVC.text = content - editorVC.delegate = self - - addChild(editorVC) - contentView.addArrangedSubview(editorVC.view) - editorVC.didMove(toParent: self) - - self.editor = editorVC - } - - private func setupGutenbergEditor(content: String) { - let editorVC = CommentGutenbergEditorViewController() + editorVC.isGutenbergEnabled = viewModel.isGutenbergEnabled + editorVC.suggestionsViewModel = viewModel.suggestionsViewModel editorVC.delegate = self - editorVC.initialContent = content addChild(editorVC) + view.addSubview(editorVC.view) contentView.addArrangedSubview(editorVC.view) editorVC.didMove(toParent: self) - - self.editor = editorVC } private func setupAccessibility() { @@ -102,40 +69,42 @@ final class CommentComposerViewController: UIViewController { // MARK: - Actions @objc private func buttonSendTapped() { - Task { - await sendComment() - } - } - - @MainActor - private func sendComment() async { - do { - setLoading(true) - try await viewModel.save(text) - UINotificationFeedbackGenerator().notificationOccurred(.success) - presentingViewController?.dismiss(animated: true) - } catch { - setLoading(false) - UINotificationFeedbackGenerator().notificationOccurred(.error) - Notice(title: Strings.failedToSend, message: error.localizedDescription.stringByDecodingXMLCharacters()).post() + setLoading(true) + + Task { @MainActor in + do { + let text = await editorVC.text + try await viewModel.save(content: text) + UINotificationFeedbackGenerator().notificationOccurred(.success) + presentingViewController?.dismiss(animated: true) + } catch { + setLoading(false) + UINotificationFeedbackGenerator().notificationOccurred(.error) + Notice(error: error, title: Strings.failedToSend).post() + } } } private func setLoading(_ isLoading: Bool) { navigationItem.rightBarButtonItem = isLoading ? .activityIndicator : UIBarButtonItem(customView: buttonSend) navigationItem.leftBarButtonItem?.isEnabled = !isLoading - editor?.isEnabled = !isLoading + editorVC.isEnabled = !isLoading } @objc private func buttonCancelTapped() { - if text.isEmpty { - presentingViewController?.dismiss(animated: true) - } else { - showCloseDraftConfirmationAlert(content: text) + navigationItem.leftBarButtonItem?.isEnabled = false + Task { @MainActor in + let text = await editorVC.text + navigationItem.leftBarButtonItem?.isEnabled = true + if text.isEmpty { + presentingViewController?.dismiss(animated: true) + } else { + showCloseConfirmationAlert(content: text) + } } } - private func showCloseDraftConfirmationAlert(content: String) { + private func showCloseConfirmationAlert(content: String) { let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) alert.addCancelActionWithTitle(Strings.closeConfirmationAlertCancel) alert.addDestructiveActionWithTitle(Strings.closeConfirmationAlertDelete) { [weak self] _ in @@ -147,7 +116,6 @@ final class CommentComposerViewController: UIViewController { self?.viewModel.saveDraft(content) self?.presentingViewController?.dismiss(animated: true) { UINotificationFeedbackGenerator().notificationOccurred(.success) - Notice(title: Strings.draftSaved).post() } } } @@ -155,10 +123,8 @@ final class CommentComposerViewController: UIViewController { present(alert, animated: true, completion: nil) } - // MARK: - Private - private func setupNavigationBar() { - title = viewModel.navigationTitle + title = viewModel.title navigationItem.leftBarButtonItem = UIBarButtonItem(title: SharedStrings.Button.cancel, style: .plain, target: self, action: #selector(buttonCancelTapped)) @@ -166,29 +132,24 @@ final class CommentComposerViewController: UIViewController { buttonSend.addTarget(self, action: #selector(buttonSendTapped), for: .primaryActionTriggered) } - /// Changes the `refreshButton` enabled state - private func updateInterface() { - let isEmpty = text.isEmpty - buttonSend.isEnabled = !isEmpty - isModalInPresentation = !isEmpty - } - - private var text: String { - editor?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + private func didChangeText(_ text: String) { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + let isEnabled = !text.isEmpty + buttonSend.isEnabled = isEnabled + isModalInPresentation = isEnabled } } -extension CommentComposerViewController: CommentEditorDelegate { - func commentEditor(_ viewController: UIViewController, didUpateText text: String) { - updateInterface() +extension CommentCreateViewController: CommentEditorViewControllerDelegate { + func commentEditor(_ viewController: CommentEditorViewController, didChangeText text: String) { + didChangeText(text) } } private enum Strings { - static let send = NSLocalizedString("commentComposer.send", value: "Send", comment: "Navigation bar button title") - static let failedToSend = NSLocalizedString("commentComposer.failedToSentComment", value: "Failed to send comment", comment: "Error title") - static let closeConfirmationAlertCancel = NSLocalizedString("commentComposer.closeConfirmationAlert.keepEditing", value: "Keep Editing", comment: "Button to keep the changes in an alert confirming discaring changes") - static let closeConfirmationAlertDelete = NSLocalizedString("commentComposer.closeConfirmationAlert.deleteDraft", value: "Delete Draft", comment: "Button in an alert confirming discaring a new draft") - static let closeConfirmationAlertSaveDraft = NSLocalizedString("commentComposer.closeConfirmationAlert.saveDraft", value: "Save Draft", comment: "Button in an alert confirming saving a new draft") - static let draftSaved = NSLocalizedString("commentComposer.draftSaved", value: "Draft Saved", comment: "Cofirmation snackbar title") + static let send = NSLocalizedString("commentCreate.send", value: "Send", comment: "Navigation bar button title") + static let failedToSend = NSLocalizedString("commentCreate.failedToSentComment", value: "Failed to send comment", comment: "Error title") + static let closeConfirmationAlertCancel = NSLocalizedString("commentCreate.closeConfirmationAlert.keepEditing", value: "Keep Editing", comment: "Button to keep the changes in an alert confirming discaring changes") + static let closeConfirmationAlertDelete = NSLocalizedString("commentCreate.closeConfirmationAlert.deleteDraft", value: "Delete Draft", comment: "Button in an alert confirming discaring a new draft") + static let closeConfirmationAlertSaveDraft = NSLocalizedString("commentCreate.closeConfirmationAlert.saveDraft", value: "Save Draft", comment: "Button in an alert confirming saving a new draft") } diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewModel.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewModel.swift new file mode 100644 index 000000000000..1071d981f726 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewModel.swift @@ -0,0 +1,99 @@ +import Foundation +import CoreData + +final class CommentCreateViewModel { + var title: String { + replyToComment == nil ? Strings.comment : Strings.reply + } + + var placeholder: String { + replyToComment == nil ? Strings.leaveComment : Strings.leaveReply + } + + /// Comment you are replying it. + private(set) var replyToComment: Comment? + + let suggestionsViewModel: SuggestionsListViewModel? + + private let siteID: NSNumber + private let context = ContextManager.shared.mainContext + + /// - note: It's a temporary solution until the respective save logic + /// can be moved from the view controllers. + private let _save: (String) async throws -> Void + + var isGutenbergEnabled: Bool { + FeatureFlag.readerGutenbergCommentComposer.enabled + } + + /// Create a new top-level comment to the given post. + init(post: ReaderPost, save: @escaping (String) async throws -> Void) { + self.siteID = post.siteID ?? 0 + wpAssert(siteID != 0, "missing required parameter siteID") + self._save = save + + self.suggestionsViewModel = SuggestionsListViewModel.make(siteID: post.siteID) + self.suggestionsViewModel?.enableProminentSuggestions(postAuthorID: post.authorID) + } + + /// Create a reply to the given comment. + init(replyingTo comment: Comment, save: @escaping (String) async throws -> Void) { + let siteID = comment.associatedSiteID ?? 0 + wpAssert(siteID != 0, "missing required parameter siteID") + + self.siteID = siteID + self.replyToComment = comment + self._save = save + + self.suggestionsViewModel = SuggestionsListViewModel.make(siteID: siteID) + self.suggestionsViewModel?.enableProminentSuggestions( + postAuthorID: comment.post?.authorID, + commentAuthorID: comment.commentID as NSNumber + ) + } + + static var leaveCommentLocalizedPlaceholder: String { + Strings.leaveComment + } + + @MainActor + func save(content: String) async throws { + try await _save(content) + deleteDraft() + } + + // MARK: Drafts + + func restoreDraft() -> String? { + guard let key = makeDraftKey() else { return nil } + return UserDefaults.standard.string(forKey: key) + } + + var canSaveDraft: Bool { + makeDraftKey() != nil + } + + func saveDraft(_ content: String) { + guard let key = makeDraftKey() else { return } + return UserDefaults.standard.set(content, forKey: key) + } + + func deleteDraft() { + guard let key = makeDraftKey() else { return } + UserDefaults.standard.removeObject(forKey: key) + } + + private func makeDraftKey() -> String? { + guard let userID = (try? WPAccount.lookupDefaultWordPressComAccount(in: context))?.userID else { + return nil + } + return "CommentDraft-\(userID),\(siteID),\(replyToComment?.commentID ?? 0)" + } +} + +private enum Strings { + static let reply = NSLocalizedString("commentCreate.navigationTitleReply", value: "Reply", comment: "Navigation bar title when leaving a reply to a comment") + static let comment = NSLocalizedString("commentCreate.navigationTitleComment", value: "Comment", comment: "Navigation bar title when leaving a reply to a comment") + static let leaveReply = NSLocalizedString("commentCreate.placeholderLeaveReply", value: "Leave a reply…", comment: "Navigation bar title when leaving a reply to a comment") + static let leaveComment = NSLocalizedString("commentCreate.placeholderLeaveComment", value: "Leave a comment…", comment: "Navigation bar title when leaving a reply to a comment") +} diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Edit/CommentEditViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Edit/CommentEditViewController.swift new file mode 100644 index 000000000000..654ec3b59d13 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Edit/CommentEditViewController.swift @@ -0,0 +1,118 @@ +import UIKit +import WordPressUI + +final class CommentEditViewController: UIViewController { + private lazy var buttonSave = UIBarButtonItem(title: SharedStrings.Button.save, style: .done, target: self, action: #selector(buttonSaveTapped)) + private let editorVC = CommentEditorViewController() + private let viewModel: CommentEditViewModel + + init(viewModel: CommentEditViewModel) { + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + setupView() + setupNavigationBar() + + buttonSave.isEnabled = false + isModalInPresentation = false + } + + private func setupView() { + editorVC.initialContent = viewModel.originalContent + editorVC.isGutenbergEnabled = viewModel.isGutenbergEnabled + editorVC.suggestionsViewModel = viewModel.suggestionsViewModel + editorVC.delegate = self + + addChild(editorVC) + view.addSubview(editorVC.view) + editorVC.view.pinEdges() + editorVC.didMove(toParent: self) + } + + // MARK: - Actions + + @objc private func buttonSaveTapped() { + setLoading(true) + Task { @MainActor in + do { + let text = await editorVC.text + try await viewModel.save(content: text) + UINotificationFeedbackGenerator().notificationOccurred(.success) + presentingViewController?.dismiss(animated: true) + } catch { + setLoading(false) + UINotificationFeedbackGenerator().notificationOccurred(.error) + Notice(error: error, title: Strings.failedToSave).post() + } + } + } + + private func setLoading(_ isLoading: Bool) { + navigationItem.rightBarButtonItem = isLoading ? .activityIndicator : buttonSave + navigationItem.leftBarButtonItem?.isEnabled = !isLoading + editorVC.isEnabled = !isLoading + } + + @objc private func buttonCancelTapped() { + navigationItem.leftBarButtonItem?.isEnabled = false + Task { @MainActor in + let text = await editorVC.text + navigationItem.leftBarButtonItem?.isEnabled = true + if text == viewModel.originalContent { + presentingViewController?.dismiss(animated: true) + } else { + showCloseConfirmationAlert() + } + } + } + + private func showCloseConfirmationAlert() { + let alert = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + alert.addCancelActionWithTitle(Strings.closeConfirmationAlertCancel) + alert.addDestructiveActionWithTitle(Strings.closeConfirmationAlertDiscardChanges) { [weak self] _ in + self?.presentingViewController?.dismiss(animated: true) + } + alert.popoverPresentationController?.barButtonItem = navigationItem.leftBarButtonItem + present(alert, animated: true, completion: nil) + } + + // MARK: - Private + + private func setupNavigationBar() { + title = Strings.title + + navigationItem.leftBarButtonItem = UIBarButtonItem(title: SharedStrings.Button.cancel, style: .plain, target: self, action: #selector(buttonCancelTapped)) + + navigationItem.rightBarButtonItem = buttonSave + } + + private func didChangeText(_ text: String) { + let hasChanges = text != viewModel.originalContent + buttonSave.isEnabled = hasChanges + isModalInPresentation = hasChanges + } +} + +extension CommentEditViewController: CommentEditorViewControllerDelegate { + func commentEditor(_ viewController: CommentEditorViewController, didChangeText text: String) { + didChangeText(text) + } +} + +private enum Strings { + static let title = NSLocalizedString("commentEdit.navigationTitle", value: "Edit Comment", comment: "Navigation bar title when leaving a editing an existing comment") + static let failedToSave = NSLocalizedString("commentEdit.failedToSaveComment", value: "Failed to save comment", comment: "Error title") + static let closeConfirmationAlertCancel = NSLocalizedString("commentEdit.closeConfirmationAlert.keepEditing", value: "Keep Editing", comment: "Button to keep the changes in an alert confirming discaring changes") + static let closeConfirmationAlertDiscardChanges = NSLocalizedString("commentEdit.closeConfirmationAlert.deleteDraft", value: "Discard Changes", comment: "Button in an alert confirming discaring a new draft") +} diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Edit/CommentEditViewModel.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Edit/CommentEditViewModel.swift new file mode 100644 index 000000000000..591595ab6953 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Edit/CommentEditViewModel.swift @@ -0,0 +1,58 @@ +import Foundation +import CoreData + +final class CommentEditViewModel { + let suggestionsViewModel: SuggestionsListViewModel? + + private let comment: Comment + private let siteID: NSNumber + private let context = ContextManager.shared.mainContext + + var isGutenbergEnabled: Bool { + FeatureFlag.readerGutenbergCommentComposer.enabled + } + + /// Edit an existing comment. + init(comment: Comment) { + self.comment = comment + self.siteID = comment.associatedSiteID ?? 0 + wpAssert(siteID != 0, "missing required parameter siteID") + + self.suggestionsViewModel = SuggestionsListViewModel.make(siteID: siteID) + self.suggestionsViewModel?.enableProminentSuggestions( + postAuthorID: comment.post?.authorID, + commentAuthorID: comment.commentID as NSNumber + ) + } + + var originalContent: String { + comment.rawContent + } + + @MainActor + func save(content: String) async throws { + let commentID = comment.commentID as NSNumber + let service = CommentService(coreDataStack: ContextManager.shared) + + let remoteComment = try await withUnsafeThrowingContinuation { continuation in + service.updateComment(withID: commentID, siteID: siteID, content: content, success: { + continuation.resume(returning: $0) + }, failure: { error in + continuation.resume(throwing: error ?? URLError(.unknown)) + }) + } + + if let remoteComment { + let objectID = TaggedManagedObjectID(comment) + try await ContextManager.shared.performAndSave { context in + let comment = try context.existingObject(with: objectID) + comment.content = remoteComment.content + comment.rawContent = remoteComment.rawContent + } + } else { + wpAssertionFailure("comment missing from the response") + } + + CommentAnalytics.trackCommentEdited(comment: comment) + } +} diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentComposerReplyCommentView.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentComposerReplyCommentView.swift similarity index 100% rename from WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentComposerReplyCommentView.swift rename to WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentComposerReplyCommentView.swift diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentEditorViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentEditorViewController.swift new file mode 100644 index 000000000000..22d1a8b5d292 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentEditorViewController.swift @@ -0,0 +1,100 @@ +import UIKit +import WordPressUI + +protocol CommentEditorViewControllerDelegate: AnyObject { + func commentEditor(_ viewController: CommentEditorViewController, didChangeText text: String) +} + +/// Manages the editor area of the comment create/edit screens. Supports both +/// plain text and Gutenberg. +final class CommentEditorViewController: UIViewController { + // Configuration + var initialContent: String? + var isGutenbergEnabled = false + var placeholder: String? + var suggestionsViewModel: SuggestionsListViewModel? + weak var delegate: CommentEditorViewControllerDelegate? + + let contentView = UIStackView(axis: .vertical, []) + + private(set) var editorVC: UIViewController? + + var isEnabled = true { + didSet { + view.alpha = isEnabled ? 1.0 : 0.5 + view.isUserInteractionEnabled = isEnabled + } + } + + /// - note: The method is asynchronous because Gutenberg requires a relatively + /// expensive deserialization that doesn't happen interactively. + var text: String { + get async { + if let editorVC = editorVC as? CommentPlainTextEditorViewController { + return editorVC.text.trimmingCharacters(in: .whitespacesAndNewlines) + } + if let editorVC = editorVC as? CommentGutenbergEditorViewController { + return await editorVC.text.trimmingCharacters(in: .whitespacesAndNewlines) + } + return "" + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.addSubview(contentView) + contentView.pinEdges([.top, .horizontal], to: view.safeAreaLayoutGuide) + contentView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor).isActive = true + + setupEditor() + + WPAnalytics.track(.commentFullScreenEntered) + } + + private func setupEditor() { + if isGutenbergEnabled { + setupGutenbergEditor() + } else { + setupPlainTextEditor() + } + } + + private func setupPlainTextEditor() { + let editorVC = CommentPlainTextEditorViewController() + editorVC.suggestionsViewModel = suggestionsViewModel + editorVC.placeholder = placeholder + editorVC.text = initialContent ?? "" + editorVC.delegate = self + + addChild(editorVC) + contentView.addArrangedSubview(editorVC.view) + editorVC.didMove(toParent: self) + + self.editorVC = editorVC + } + + private func setupGutenbergEditor() { + let editorVC = CommentGutenbergEditorViewController() + editorVC.delegate = self + editorVC.initialContent = initialContent ?? "" + + addChild(editorVC) + contentView.addArrangedSubview(editorVC.view) + editorVC.didMove(toParent: self) + + self.editorVC = editorVC + } +} + +extension CommentEditorViewController: CommentPlainTextEditorViewControllerDelegate { + func commentPlainTextEditorViewController(_ viewController: CommentPlainTextEditorViewController, didChangeText text: String) { + delegate?.commentEditor(self, didChangeText: text) + } +} + +extension CommentEditorViewController: CommentGutenbergEditorViewControllerDelegate { + func commentGutenbergEditorViewController(_ viewController: CommentGutenbergEditorViewController, didChangeText text: String) { + delegate?.commentEditor(self, didChangeText: text) + } +} diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentGutenbergEditorViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift similarity index 67% rename from WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentGutenbergEditorViewController.swift rename to WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift index 55ed5b09ef70..4b738f6ebba0 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentGutenbergEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentGutenbergEditorViewController.swift @@ -3,38 +3,29 @@ import GutenbergKit import WordPressUI import Combine -final class CommentGutenbergEditorViewController: UIViewController, CommentEditor { +protocol CommentGutenbergEditorViewControllerDelegate: AnyObject { + func commentGutenbergEditorViewController(_ viewController: CommentGutenbergEditorViewController, didChangeText text: String) +} + +final class CommentGutenbergEditorViewController: UIViewController { private var editorVC: GutenbergKit.EditorViewController? - weak var delegate: CommentEditorDelegate? + weak var delegate: CommentGutenbergEditorViewControllerDelegate? var initialContent: String? var text: String { - set { - wpAssertionFailure("not supported") - } - get { - currentText - } - } - - private var currentText = "" - - var isEnabled: Bool = true { - didSet { - // TODO: implement -// if !isEnabled { -// textView.resignFirstResponder() -// } - editorVC?.view.alpha = isEnabled ? 1.0 : 0.5 - editorVC?.view.isUserInteractionEnabled = isEnabled - } - } - - var placeholder: String? { - didSet { - // TODO: implement placeholder + get async { + guard let editorVC else { + wpAssertionFailure("editor missing") + return "" + } + do { + return try await editorVC.getContent() + } catch { + wpAssertionFailure("failed to refresh content", userInfo: ["error": "\(error)"]) + return "" + } } } @@ -56,22 +47,14 @@ final class CommentGutenbergEditorViewController: UIViewController, CommentEdito editorDidUpdate .throttle(for: 1.0, scheduler: DispatchQueue.main, latest: true) - .sink { [weak self] in self?.refreshText() } + .sink { [weak self] in self?.refresh() } .store(in: &cancellables) } - private func refreshText() { - guard let editorVC else { return } + private func refresh() { Task { @MainActor in - do { - let text = try await editorVC.getContent() - if text != self.currentText { - self.currentText = text - self.delegate?.commentEditor(self, didUpateText: text) - } - } catch { - // TODO: handle errors - } + let text = await self.text + self.delegate?.commentGutenbergEditorViewController(self, didChangeText: text) } } } diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentPlainTextEditorViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentPlainTextEditorViewController.swift similarity index 92% rename from WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentPlainTextEditorViewController.swift rename to WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentPlainTextEditorViewController.swift index 5a84e810b94f..7b2adc4b046f 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/Composer/CommentPlainTextEditorViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Editor/CommentPlainTextEditorViewController.swift @@ -1,35 +1,20 @@ import UIKit import WordPressUI -protocol CommentEditor { - var text: String { get set } - var isEnabled: Bool { get set } +protocol CommentPlainTextEditorViewControllerDelegate: AnyObject { + func commentPlainTextEditorViewController(_ viewController: CommentPlainTextEditorViewController, didChangeText text: String) } -protocol CommentEditorDelegate: AnyObject { - func commentEditor(_ viewController: UIViewController, didUpateText text: String) -} - -final class CommentPlainTextEditorViewController: UIViewController, CommentEditor { +final class CommentPlainTextEditorViewController: UIViewController { var suggestionsViewModel: SuggestionsListViewModel? - weak var delegate: CommentEditorDelegate? + weak var delegate: CommentPlainTextEditorViewControllerDelegate? var text: String { set { textView.text = newValue } get { textView.text } } - var isEnabled: Bool = true { - didSet { - if !isEnabled { - textView.resignFirstResponder() - } - textView.alpha = isEnabled ? 1.0 : 0.5 - textView.isUserInteractionEnabled = isEnabled - } - } - var placeholder: String? { didSet { placeholderLabel.text = placeholder @@ -96,7 +81,7 @@ final class CommentPlainTextEditorViewController: UIViewController, CommentEdito extension CommentPlainTextEditorViewController: UITextViewDelegate { func textViewDidChange(_ textView: UITextView) { placeholderLabel.isHidden = !textView.text.isEmpty - delegate?.commentEditor(self, didUpateText: textView.text) + delegate?.commentPlainTextEditorViewController(self, didChangeText: textView.text) } func textViewDidChangeSelection(_ textView: UITextView) { diff --git a/WordPress/Classes/ViewRelated/Notifications/Views/CommentLargeButton.swift b/WordPress/Classes/ViewRelated/Notifications/Views/CommentLargeButton.swift index 273dead8b49d..b8ce0bd37ea4 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Views/CommentLargeButton.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Views/CommentLargeButton.swift @@ -33,7 +33,7 @@ final class CommentLargeButton: UIView { placeholderLabel.textColor = .tertiaryLabel - placeholderLabel.text = CommentComposerViewModel.leaveCommentLocalizedPlaceholder + placeholderLabel.text = CommentCreateViewModel.leaveCommentLocalizedPlaceholder containerView.addSubview(placeholderLabel) containerView.backgroundColor = .secondarySystemBackground diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift index 92d8a867b4d9..b3eaf4713c42 100644 --- a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.swift @@ -52,18 +52,14 @@ extension NSNotification.Name { } func buttonAddCommentTapped() { - let viewModel = CommentComposerViewModel(post: post) - viewModel.save = { [weak self] in + let viewModel = CommentCreateViewModel(post: post) { [weak self] in try await self?.sendComment($0) } showCommentComposer(viewModel: viewModel) } func didTapReply(comment: Comment) { - guard let viewModel = CommentComposerViewModel(comment: comment) else { - return wpAssertionFailure("invalid context") - } - viewModel.save = { [weak self] in + let viewModel = CommentCreateViewModel(replyingTo: comment) { [weak self] in try await self?.sendComment($0, comment: comment) } showCommentComposer(viewModel: viewModel) @@ -195,8 +191,8 @@ extension NSNotification.Name { } extension ReaderCommentsViewController { - func showCommentComposer(viewModel: CommentComposerViewModel) { - let composerVC = CommentComposerViewController(viewModel: viewModel) + func showCommentComposer(viewModel: CommentCreateViewModel) { + let composerVC = CommentCreateViewController(viewModel: viewModel) let navigationVC = UINavigationController(rootViewController: composerVC) present(navigationVC, animated: true) } @@ -295,30 +291,9 @@ private extension ReaderCommentsViewController { } func editMenuTapped(for comment: Comment, indexPath: IndexPath, tableView: UITableView) { - let editCommentTableViewController = EditCommentTableViewController(comment: comment) { [weak self] comment, commentChanged in - guard commentChanged else { - return - } - - // optimistically update the comment in the thread with local changes. - tableView.reloadRows(at: [indexPath], with: .automatic) - - // track user's intent to edit the comment. - CommentAnalytics.trackCommentEdited(comment: comment) - - self?.commentService.uploadComment(comment, success: { - self?.commentModified = true - - // update the thread again in case the approval status changed. - tableView.reloadRows(at: [indexPath], with: .automatic) - }, failure: { _ in - self?.displayNotice(title: .editCommentFailureNoticeText) - }) - } - - let navigationControllerToPresent = UINavigationController(rootViewController: editCommentTableViewController) - navigationControllerToPresent.modalPresentationStyle = .fullScreen - present(navigationControllerToPresent, animated: true) + let composerVC = CommentEditViewController(viewModel: CommentEditViewModel(comment: comment)) + let navigationVC = UINavigationController(rootViewController: composerVC) + present(navigationVC, animated: true) } func moderateComment(_ comment: Comment, status: CommentStatusType) { @@ -413,8 +388,6 @@ private extension ReaderCommentsViewController { private extension String { static let authorBadgeText = NSLocalizedString("Author", comment: "Title for a badge displayed beside the comment writer's name. " + "Shown when the comment is written by the post author.") - static let editCommentFailureNoticeText = NSLocalizedString("There has been an unexpected error while editing the comment", - comment: "Error displayed if a comment fails to get updated") static let undoActionTitle = NSLocalizedString("Undo", comment: "Button title. Reverts a comment moderation action.") // moderation messages diff --git a/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift b/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift index 40265c75bb9a..1c94b00b3aeb 100644 --- a/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift +++ b/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift @@ -1,3 +1,5 @@ +import UIKit + public enum NoticeAnimationStyle { case moveIn case fade