diff --git a/WordPress/Classes/Services/CommentService.h b/WordPress/Classes/Services/CommentService.h index ff84f9ce9717..05a3c81c9d37 100644 --- a/WordPress/Classes/Services/CommentService.h +++ b/WordPress/Classes/Services/CommentService.h @@ -138,13 +138,13 @@ extern NSUInteger const WPTopLevelHierarchicalCommentsPerPage; // Replies - (void)replyToPost:(ReaderPost *)post content:(NSString *)content - success:(void (^ _Nullable)(void))success + success:(void (^ _Nullable)(Comment * _Nullable comment))success failure:(void (^ _Nullable)(NSError * _Nullable error))failure; - (void)replyToHierarchicalCommentWithID:(NSNumber *)commentID post:(ReaderPost *)post content:(NSString *)content - success:(void (^ _Nullable)(void))success + success:(void (^ _Nullable)(Comment * _Nullable comment))success failure:(void (^ _Nullable)(NSError * _Nullable error))failure; - (void)replyToCommentWithID:(NSNumber *)commentID diff --git a/WordPress/Classes/Services/CommentService.m b/WordPress/Classes/Services/CommentService.m index dca4f0f8e765..0c4e527a2e80 100644 --- a/WordPress/Classes/Services/CommentService.m +++ b/WordPress/Classes/Services/CommentService.m @@ -712,7 +712,7 @@ - (void)updateCommentWithID:(NSNumber *)commentID // Replies - (void)replyToPost:(ReaderPost *)post content:(NSString *)content - success:(void (^)(void))success + success:(void (^)(Comment * _Nullable comment))success failure:(void (^)(NSError *error))failure { // Create and optimistically save a comment, based on the current wpcom acct @@ -737,7 +737,11 @@ - (void)replyToPost:(ReaderPost *)post remoteComment.content = [self sanitizeCommentContent:remoteComment.content isPrivateSite:isPrivateSite]; [self updateHierarchicalComment:comment withRemoteComment:remoteComment]; - } completion:success onQueue:dispatch_get_main_queue()]; + } completion:^{ + if (success) { + success([self.coreDataStack.mainContext existingObjectWithID:commentID error:nil]); + } + } onQueue:dispatch_get_main_queue()]; }; void (^failureBlock)(NSError *error) = ^void(NSError *error) { @@ -765,7 +769,7 @@ - (void)replyToPost:(ReaderPost *)post - (void)replyToHierarchicalCommentWithID:(NSNumber *)commentID post:(ReaderPost *)post content:(NSString *)content - success:(void (^)(void))success + success:(void (^)(Comment * _Nullable comment))success failure:(void (^)(NSError *error))failure { // Create and optimistically save a comment, based on the current wpcom acct @@ -791,7 +795,11 @@ - (void)replyToHierarchicalCommentWithID:(NSNumber *)commentID remoteComment.content = [self sanitizeCommentContent:remoteComment.content isPrivateSite:isPrivateSite]; [self updateHierarchicalComment:comment withRemoteComment:remoteComment]; - } completion:success onQueue:dispatch_get_main_queue()]; + } completion:^{ + if (success) { + success([self.coreDataStack.mainContext existingObjectWithID:commentObjectID error:nil]); + } + } onQueue:dispatch_get_main_queue()]; }; void (^failureBlock)(NSError *error) = ^void(NSError *error) { diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift index e6b0e0eef0bc..1d0fbb2309e3 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift @@ -993,53 +993,62 @@ private extension CommentDetailViewController { @objc func buttonAddCommentTapped() { let viewModel = CommentCreateViewModel(replyingTo: comment) { [weak self] in - try await self?.createReply(content: $0) + try await self?.createReply(content: $0) ?? false } let composerVC = CommentCreateViewController(viewModel: viewModel) let navigationVC = UINavigationController(rootViewController: composerVC) present(navigationVC, animated: true) } + /// - returns: `true` if the comment is pending moderation (not immediately approved). @MainActor - func createReply(content: String) async throws { - isNotificationComment ? WPAppAnalytics.track(.notificationsCommentRepliedTo) : - CommentAnalytics.trackCommentRepliedTo(comment: comment) + func createReply(content: String) async throws -> Bool { + isNotificationComment + ? WPAppAnalytics.track(.notificationsCommentRepliedTo) + : CommentAnalytics.trackCommentRepliedTo(comment: comment) // If there is no Blog, try with the Post. guard comment.blog != nil else { - try await createPostCommentReply(content: content) - return + return try await createPostCommentReply(content: content) } - try await withUnsafeThrowingContinuation { continuation in + return try await withUnsafeThrowingContinuation { continuation in commentService.createReply(for: comment, content: content) { reply in - self.commentService.uploadComment(reply, success: { [weak self] in - self?.refreshCommentReplyIfNeeded() - continuation.resume() - }, failure: { error in - DDLogError("Failed uploading comment reply: \(String(describing: error))") - continuation.resume(throwing: error ?? URLError(.unknown)) - }) + self.commentService.uploadComment( + reply, + success: { [weak self] in + self?.refreshCommentReplyIfNeeded() + continuation.resume(returning: reply.isApproved() == false) + }, + failure: { error in + DDLogError("Failed uploading comment reply: \(String(describing: error))") + continuation.resume(throwing: error ?? URLError(.unknown)) + } + ) } } } + /// - returns: `true` if the comment is pending moderation (not immediately approved). @MainActor - func createPostCommentReply(content: String) async throws { + func createPostCommentReply(content: String) async throws -> Bool { guard let post = comment.post as? ReaderPost else { - return + return false } - try await withUnsafeThrowingContinuation { continuation in - commentService.replyToHierarchicalComment(withID: NSNumber(value: comment.commentID), - post: post, - content: content, - success: { [weak self] in - self?.refreshCommentReplyIfNeeded() - continuation.resume() - }, failure: { error in - DDLogError("Failed creating post comment reply: \(String(describing: error))") - continuation.resume(throwing: error ?? URLError(.unknown)) - }) + return try await withUnsafeThrowingContinuation { continuation in + commentService.replyToHierarchicalComment( + withID: NSNumber(value: comment.commentID), + post: post, + content: content, + success: { [weak self] newComment in + self?.refreshCommentReplyIfNeeded() + continuation.resume(returning: newComment?.isApproved() == false) + }, + failure: { error in + DDLogError("Failed creating post comment reply: \(String(describing: error))") + continuation.resume(throwing: error ?? URLError(.unknown)) + } + ) } } } diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewController.swift index 527cf50a53ed..c7b3de6e2783 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewController.swift @@ -64,10 +64,22 @@ final class CommentCreateViewController: UIViewController { Task { @MainActor in do { let text = await editorVC.text - try await viewModel.save(content: text) + let isPendingModeration = try await viewModel.save(content: text) UINotificationFeedbackGenerator().notificationOccurred(.success) NotificationCenter.default.post(name: .ReaderCommentModifiedNotification, object: nil) - presentingViewController?.dismiss(animated: true) + presentingViewController? + .dismiss(animated: true) { + guard isPendingModeration else { return } + Notice( + title: Strings.commentHeldForModeration, + style: InAppUpdateNoticeStyle( + icon: UIImage(systemName: "checkmark.seal.fill"), + iconColor: UIAppColor.success, + title: Strings.commentHeldForModeration + ) + ) + .post() + } } catch { setLoading(false) UINotificationFeedbackGenerator().notificationOccurred(.error) @@ -105,9 +117,10 @@ final class CommentCreateViewController: UIViewController { if viewModel.canSaveDraft { alert.addActionWithTitle(Strings.closeConfirmationAlertSaveDraft, style: .default) { [weak self] _ in self?.viewModel.saveDraft(content) - self?.presentingViewController?.dismiss(animated: true) { - UINotificationFeedbackGenerator().notificationOccurred(.success) - } + self?.presentingViewController? + .dismiss(animated: true) { + UINotificationFeedbackGenerator().notificationOccurred(.success) + } } } alert.popoverPresentationController?.barButtonItem = navigationItem.leftBarButtonItem @@ -147,6 +160,7 @@ extension CommentCreateViewController: CommentEditorViewControllerDelegate { private enum Strings { 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 commentHeldForModeration = NSLocalizedString("commentCreate.commentHeldForModeration", value: "Comment is awaiting review", comment: "Toast title shown after successfully submitting a comment") 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 index 5c835fb0651e..aadd0c327198 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewModel.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/Create/CommentCreateViewModel.swift @@ -23,8 +23,10 @@ final class CommentCreateViewModel { /// - note: It's a temporary solution until the respective save logic /// can be moved from the view controllers. - private var _save: (String) async throws -> Void = { _ in + /// - returns: `true` if the comment is pending moderation (not immediately approved). + private var _save: (String) async throws -> Bool = { _ in wpAssertionFailure("Not implemented") + return false } var isGutenbergEnabled: Bool { @@ -49,12 +51,12 @@ final class CommentCreateViewModel { } self._save = { [weak self] in - try await self?.sendComment($0, post: post, replyingTo: comment) + try await self?.sendComment($0, post: post, replyingTo: comment) ?? false } } /// Create a reply to the given comment (from notifications) - init(replyingTo comment: Comment, save: @escaping (String) async throws -> Void) { + init(replyingTo comment: Comment, save: @escaping (String) async throws -> Bool) { let siteID = comment.associatedSiteID ?? 0 self.siteID = siteID @@ -72,27 +74,37 @@ final class CommentCreateViewModel { Strings.leaveComment } - func save(content: String) async throws { - try await _save(content) + /// - returns: `true` if the comment is pending moderation (not immediately approved). + func save(content: String) async throws -> Bool { + let isPendingModeration = try await _save(content) deleteDraft() + return isPendingModeration } // MARK: Reader - private func sendComment(_ content: String, post: ReaderPost, replyingTo comment: Comment? = nil) async throws { + private func sendComment( + _ content: String, + post: ReaderPost, + replyingTo comment: Comment? = nil + ) async throws -> Bool { try await withUnsafeThrowingContinuation { [weak self] continuation in let service = CommentService(coreDataStack: ContextManager.shared) if let comment { - service.replyToHierarchicalComment(withID: comment.commentID as NSNumber, post: post, content: content) { + service.replyToHierarchicalComment( + withID: comment.commentID as NSNumber, + post: post, + content: content + ) { newComment in self?.trackReply(isReplyingToComment: true, post: post) - continuation.resume() + continuation.resume(returning: newComment?.isApproved() == false) } failure: { continuation.resume(throwing: $0 ?? URLError(.unknown)) } } else { - service.reply(to: post, content: content) { + service.reply(to: post, content: content) { newComment in self?.trackReply(isReplyingToComment: true, post: post) - continuation.resume() + continuation.resume(returning: newComment?.isApproved() == false) } failure: { continuation.resume(throwing: $0 ?? URLError(.unknown)) } diff --git a/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift b/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift index c632b786c586..a50e69f24ae5 100644 --- a/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift +++ b/WordPress/Classes/ViewRelated/System/Notices/NoticeStyle.swift @@ -1,5 +1,6 @@ import UIKit import WordPressShared +import DesignSystem public enum NoticeAnimationStyle { case moveIn @@ -73,9 +74,46 @@ public struct NormalNoticeStyle: NoticeStyle { public struct InAppUpdateNoticeStyle: NoticeStyle { public let attributedMessage: NSAttributedString? - - init(attributedMessage: NSAttributedString? = nil) { - self.attributedMessage = attributedMessage + public let isDismissable: Bool + + /// - Parameters: + /// - icon: An optional SF Symbol rendered inline before `title`, e.g. a checkmark seal to indicate success. + /// - iconColor: The tint color applied to `icon`. + /// - title: When provided (with or without `icon`), builds `attributedMessage` from it and makes the + /// Notice auto-dismiss after a few seconds. When `nil`, the Notice falls back to its own `title`/`message` + /// and stays on screen until the user dismisses it, matching the original in-app-update banner behavior. + init(icon: UIImage? = nil, iconColor: UIColor = .invertedLabel, title: String? = nil) { + guard let title else { + self.attributedMessage = nil + self.isDismissable = false + return + } + + self.isDismissable = true + + let font = UIFont.boldSystemFont(ofSize: 14.0) + let message = NSMutableAttributedString() + + if let icon = icon?.withTintColor(iconColor, renderingMode: .alwaysOriginal) { + let attachment = NSTextAttachment(image: icon) + attachment.accessibilityLabel = "" // Decorative; the title text conveys the meaning. + let iconHeight = font.lineHeight + let ratio = icon.size.width / icon.size.height + attachment.bounds = CGRect( + x: 0, + y: (font.capHeight - iconHeight) / 2, + width: iconHeight * ratio, + height: iconHeight + ) + message.append(NSAttributedString(attachment: attachment)) + message.append(NSAttributedString(string: " ")) + } + + message.append( + NSAttributedString(string: title, attributes: [.font: font, .foregroundColor: UIColor.invertedLabel]) + ) + + self.attributedMessage = message } // Return new UIFont instance everytime in order to be responsive to accessibility font size changes @@ -85,7 +123,6 @@ public struct InAppUpdateNoticeStyle: NoticeStyle { public let directionalLayoutMargins = NSDirectionalEdgeInsets(top: 13.0, leading: 16.0, bottom: 13.0, trailing: 16.0) - public var isDismissable = false public let showNextArrow = false public let animationStyle = NoticeAnimationStyle.moveIn