diff --git a/Modules/Sources/WordPressComments/Services/CommentsDetailRouter.swift b/Modules/Sources/WordPressComments/Services/CommentsDetailRouter.swift index 2110d9362cb9..01db1a649b97 100644 --- a/Modules/Sources/WordPressComments/Services/CommentsDetailRouter.swift +++ b/Modules/Sources/WordPressComments/Services/CommentsDetailRouter.swift @@ -45,7 +45,38 @@ final class CommentsDetailRouter { /// row when available) and pushes it onto the shared navigation stack. func open(id: Int64, seed: CommentListItem?) { capabilities.prefetch() - let viewModel = CommentDetailViewModel( + let detail = CommentDetailView( + viewModel: makeDetailViewModel(id: id, seed: seed), + titleResolver: titleResolver, + renderer: makeLinkOpeningRenderer(), + openComment: { [weak self] id, seed in self?.open(id: id, seed: seed) } + ) + let controller = UIHostingController(rootView: detail) + controller.navigationItem.largeTitleDisplayMode = .never + host?.navigationController?.pushViewController(controller, animated: true) + } + + func makeReviewSession(batch: [CommentListItem]) -> CommentReviewViewModel? { + guard !batch.isEmpty else { return nil } + // The session holds the router strongly; the router never holds a session. + return CommentReviewViewModel( + batch: batch, + coordinator: coordinator, + noticePresenter: noticePresenter, + makeDetail: { self.makeDetailViewModel(id: $0.id, seed: $0) } + ) + } + + func makeReviewView(session: CommentReviewViewModel) -> CommentReviewView { + CommentReviewView( + viewModel: session, + titleResolver: titleResolver, + makeContentRenderer: makeLinkOpeningRenderer + ) + } + + private func makeDetailViewModel(id: Int64, seed: CommentListItem?) -> CommentDetailViewModel { + CommentDetailViewModel( commentID: id, seed: seed, service: service, @@ -56,18 +87,11 @@ final class CommentsDetailRouter { tracker: tracker, noticePresenter: noticePresenter ) + } + + private func makeLinkOpeningRenderer() -> any CommentContentRendering { let renderer = makeContentRenderer() - renderer.onLinkTapped = { url in - UIApplication.shared.open(url) - } - let detail = CommentDetailView( - viewModel: viewModel, - titleResolver: titleResolver, - renderer: renderer, - openComment: { [weak self] id, seed in self?.open(id: id, seed: seed) } - ) - let controller = UIHostingController(rootView: detail) - controller.navigationItem.largeTitleDisplayMode = .never - host?.navigationController?.pushViewController(controller, animated: true) + renderer.onLinkTapped = { UIApplication.shared.open($0) } + return renderer } } diff --git a/Modules/Sources/WordPressComments/Services/CommentsModerationCoordinator.swift b/Modules/Sources/WordPressComments/Services/CommentsModerationCoordinator.swift index 9a9c6ca8261a..dca479dcc082 100644 --- a/Modules/Sources/WordPressComments/Services/CommentsModerationCoordinator.swift +++ b/Modules/Sources/WordPressComments/Services/CommentsModerationCoordinator.swift @@ -24,6 +24,20 @@ enum CommentModerationAction: Hashable, Sendable { case .restore, .delete: nil } } + + /// Whether a confirmed status proves this action's goal holds. Any active + /// status confirms a restore, because untrash/unspam reapply the saved + /// pre-bin status (approved or pending). Delete leaves no status behind. + func isConfirmed(by status: CommentListItem.Status) -> Bool { + switch self { + case .approve: status == .approved + case .unapprove: status == .pending + case .spam: status == .spam + case .trash: status == .trash + case .restore: status == .approved || status == .pending + case .delete: false + } + } } /// The outcome of a successful `reply(to:content:)` call. @@ -109,7 +123,7 @@ final class CommentsModerationCoordinator { // and detail, which is the true server state. We consider that an // edge case and accept the risk; the user can approve manually. if parent.status == .pending { - try? await self.runModeration(.approve, on: parent) + _ = try? await self.runModeration(.approve, on: parent) } // Reply is moderator-gated, so core auto-approves our replies; a // duplicate (unknown landed status) assumes approved on the same @@ -152,22 +166,26 @@ final class CommentsModerationCoordinator { } } - /// Broadcasts a status change the detail screen observed on load (its seed - /// status disagreed with the fetched truth) without running a mutation, so - /// loaded list tabs reconcile the corrected status in place. + /// Broadcasts a confirmed status without crediting a moderation action, + /// so detail screens and loaded list tabs reconcile the server state. func noteExternalStatus(id: Int64, to: CommentListItem.Status) { events.send(.statusChanged(id: id, to: to)) } - /// Runs `action` pessimistically: the request is issued first and the change - /// event is emitted only after it succeeds (or maps to a success). Throws - /// the mutation error when the action genuinely failed, leaving the UI on - /// the true pre-action state; the caller shows the error. - func perform(_ action: CommentModerationAction, on comment: CommentDetail) async throws { - guard !isMutating(id: comment.id) else { return } // one mutation per comment; UI already gates - try await holdingSlot(for: comment.id) { [weak self] in + /// Runs `action` pessimistically, emitting changes only after server + /// confirmation. Returns this submission's confirmed event, or nil if the + /// comment's slot was busy and no request ran. Throws the original error + /// if the action failed, after broadcasting any status confirmed by a + /// reconciliation probe. + @discardableResult + func perform( + _ action: CommentModerationAction, + on comment: CommentDetail + ) async throws -> CommentChangeEvent? { + guard !isMutating(id: comment.id) else { return nil } // no submitted operation + return try await holdingSlot(for: comment.id) { [weak self] in guard let self else { throw CancellationError() } - try await self.runModeration(action, on: comment) + return try await self.runModeration(action, on: comment) } } @@ -204,12 +222,16 @@ final class CommentsModerationCoordinator { /// Runs one moderation request. The change event is emitted only after the /// server confirms, so every event describes committed state and list /// fetches never race an unconfirmed change. - private func runModeration(_ action: CommentModerationAction, on comment: CommentDetail) async throws { + private func runModeration( + _ action: CommentModerationAction, + on comment: CommentDetail + ) async throws -> CommentChangeEvent { do { let event = try await execute(action, id: comment.id) succeed(action, on: comment, event: event) + return event } catch { - try await mapFailure(error, action: action, on: comment) + return try await mapFailure(error, action: action, on: comment) } } @@ -279,9 +301,8 @@ final class CommentsModerationCoordinator { } } - /// Maps the failures whose desired outcome nevertheless holds; rethrows - /// everything else as a genuine failure (the UI kept the pre-action state, - /// so no correction is needed). + /// Maps failures whose desired outcome nevertheless holds. Other failures + /// broadcast any status learned by the existing probe before throwing. /// /// It's possible that a request fails after the server already committed the /// change (client timeout, a proxy 502/504 while PHP finishes, a corrupted @@ -293,46 +314,40 @@ final class CommentsModerationCoordinator { _ error: Error, action: CommentModerationAction, on comment: CommentDetail - ) async throws { + ) async throws -> CommentChangeEvent { let apiError = error as? WpApiError // Core returns 500 rest_comment_failed_edit when the requested status // equals the current one, i.e. the comment is already where the user // wants it (an earlier timed-out attempt landed, or another moderator // made the same change). One sparse status probe confirms; on match // this is a success, not a failure. - if apiError?.wpErrorCode == .CommentFailedEdit, - let actual = try? await service.fetchStatus(id: comment.id), - probeConfirmsSuccess(action, actual: actual) - { - succeed(action, on: comment, event: .statusChanged(id: comment.id, to: actual)) - return + let probedStatus: CommentListItem.Status? = + if apiError?.wpErrorCode == .CommentFailedEdit { + try? await service.fetchStatus(id: comment.id) + } else { + nil + } + if let actual = probedStatus, action.isConfirmed(by: actual) { + let event = CommentChangeEvent.statusChanged(id: comment.id, to: actual) + succeed(action, on: comment, event: event) + return event } // Trash of an already-trashed comment: the goal state holds. if action == .trash, apiError?.wpErrorCode == .AlreadyTrashed { - succeed(action, on: comment, event: .statusChanged(id: comment.id, to: .trash)) - return + let event = CommentChangeEvent.statusChanged(id: comment.id, to: .trash) + succeed(action, on: comment, event: event) + return event } // The comment is gone regardless of which action ran; remove it // everywhere. For delete that IS the goal; for anything else the action // still failed. if apiError?.httpStatusCode == 404 { events.send(.deleted(id: comment.id)) - if action == .delete { return } + if action == .delete { return .deleted(id: comment.id) } } - throw error - } - - /// Whether the probed status proves the action's goal holds. Any active - /// status confirms a restore, because untrash/unspam reapply the saved - /// pre-bin status (approved or pending). - private func probeConfirmsSuccess(_ action: CommentModerationAction, actual: CommentListItem.Status) -> Bool { - switch action { - case .approve: actual == .approved - case .unapprove: actual == .pending - case .spam: actual == .spam - case .trash: actual == .trash - case .restore: actual == .approved || actual == .pending - case .delete: false + if let probedStatus { + noteExternalStatus(id: comment.id, to: probedStatus) } + throw error } } diff --git a/Modules/Sources/WordPressComments/Strings/Strings+Review.swift b/Modules/Sources/WordPressComments/Strings/Strings+Review.swift new file mode 100644 index 000000000000..430651502e5a --- /dev/null +++ b/Modules/Sources/WordPressComments/Strings/Strings+Review.swift @@ -0,0 +1,90 @@ +import Foundation + +extension Strings { + enum Review { + static let review = NSLocalizedString( + "comments.review.button", + value: "Review", + comment: "Button to review loaded pending comments" + ) + static let reviewAccessibility = NSLocalizedString( + "comments.review.accessibility", + value: "Review pending comments", + comment: "Accessibility label for the Review button" + ) + static let title = NSLocalizedString( + "comments.review.title", + value: "Review Pending", + comment: "Pending comment review screen title" + ) + static let position = NSLocalizedString( + "comments.review.position", + value: "%1$d of %2$d", + comment: "Review position. %1$d is the one-based position; %2$d is the fixed batch size" + ) + static let skip = NSLocalizedString( + "comments.review.skip", + value: "Skip", + comment: "Leave this comment pending and advance" + ) + static let complete = NSLocalizedString( + "comments.review.complete", + value: "Review complete", + comment: "Heading after all captured comments have been reviewed" + ) + private static let none = NSLocalizedString( + "comments.review.none", + value: "No comments moderated in this session.", + comment: "Result when all captured comments were already handled or missing" + ) + private static let moderatedOne = NSLocalizedString( + "comments.review.moderated.one", + value: "%1$d comment moderated.", + comment: "Review result for one confirmed moderation; %1$d is the count" + ) + private static let moderatedMany = NSLocalizedString( + "comments.review.moderated.many", + value: "%1$d comments moderated.", + comment: "Review result for multiple confirmed moderations; %1$d is the count" + ) + private static let skippedOne = NSLocalizedString( + "comments.review.skipped.one", + value: "%1$d comment skipped.", + comment: "Review result when only one comment was skipped; %1$d is the count" + ) + private static let skippedMany = NSLocalizedString( + "comments.review.skipped.many", + value: "%1$d comments skipped.", + comment: "Review result when only comments were skipped; %1$d is the count" + ) + private static let skippedClause = NSLocalizedString( + "comments.review.skipped.clause", + value: "%1$d skipped.", + comment: "Skipped count following a moderated count; %1$d is the count" + ) + private static let both = NSLocalizedString( + "comments.review.summary.both", + value: "%1$@ %2$@", + comment: "Combined review result; %1$@ is the moderated sentence, %2$@ is the skipped sentence" + ) + + static func summary(moderated: Int, skipped: Int) -> String { + guard moderated > 0 else { + if skipped > 0 { + return String.localizedStringWithFormat(skipped == 1 ? skippedOne : skippedMany, skipped) + } + return none + } + let moderatedText = String.localizedStringWithFormat( + moderated == 1 ? moderatedOne : moderatedMany, + moderated + ) + guard skipped > 0 else { return moderatedText } + return String.localizedStringWithFormat( + both, + moderatedText, + String.localizedStringWithFormat(skippedClause, skipped) + ) + } + } +} diff --git a/Modules/Sources/WordPressComments/ViewModels/CommentDetailViewModel.swift b/Modules/Sources/WordPressComments/ViewModels/CommentDetailViewModel.swift index 005a111650ae..811083b3f1e1 100644 --- a/Modules/Sources/WordPressComments/ViewModels/CommentDetailViewModel.swift +++ b/Modules/Sources/WordPressComments/ViewModels/CommentDetailViewModel.swift @@ -1,5 +1,6 @@ import Combine import Foundation +import WordPressAPIInternal import WordPressShared /// Drives the comment detail and moderation screen. Reads the moderation @@ -55,6 +56,7 @@ final class CommentDetailViewModel: ObservableObject { /// authoritative fetch or a later status change proving the comment exists /// again. @Published private(set) var isDeleted = false + @Published private(set) var isMissing = false let commentID: Int64 @@ -194,7 +196,7 @@ final class CommentDetailViewModel: ObservableObject { private let noticePresenter: (any NoticePresenting)? /// A load (capability + fetch) is currently running; guards re-entry. - private var isLoading = false + @Published private var isLoading = false private var eventSubscription: AnyCancellable? @@ -280,8 +282,13 @@ final class CommentDetailViewModel: ObservableObject { // alongside the comment fetch (`.generic` covers the gap), is applied // last, and is skipped when the toolbar can never show. async let replies: Int? = canModerate ? (try? await service.numberOfReplies(for: commentID)) : nil - guard let detail = try? await service.fetchComment(id: commentID, allowsEditContext: canModerate) else { + let detail: CommentDetail + do { + detail = try await service.fetchComment(id: commentID, allowsEditContext: canModerate) + isMissing = false + } catch { _ = await replies + isMissing = (error as? WpApiError)?.httpStatusCode == 404 content = .failed return } diff --git a/Modules/Sources/WordPressComments/ViewModels/CommentReviewViewModel.swift b/Modules/Sources/WordPressComments/ViewModels/CommentReviewViewModel.swift new file mode 100644 index 000000000000..2a336f76789d --- /dev/null +++ b/Modules/Sources/WordPressComments/ViewModels/CommentReviewViewModel.swift @@ -0,0 +1,140 @@ +import Combine +import Foundation +import WordPressShared + +/// One pass through a copied list. Entry IDs bind delayed loads and controls to +/// their original comment; coordinator results alone can earn moderation credit. +@MainActor +final class CommentReviewViewModel: ObservableObject, Identifiable { + enum Outcome { + case moderated + case skipped + case bypassed + } + + let batch: [CommentListItem] + @Published private(set) var detail: CommentDetailViewModel? + @Published private(set) var position = 0 + @Published private(set) var outcomes: [Int64: Outcome] = [:] + @Published private(set) var pendingAction: CommentModerationAction? + @Published private(set) var isDismissed = false + + var isComplete: Bool { !isDismissed && position == batch.count } + var moderatedCount: Int { outcomes.values.filter { $0 == .moderated }.count } + var skippedCount: Int { outcomes.values.filter { $0 == .skipped }.count } + var canSkip: Bool { !isDismissed && detail != nil && pendingAction == nil } + /// Reads the current detail's state live; views that render this must + /// observe that detail as well as the session. + var canModerate: Bool { canSkip && detail?.isToolbarEnabled == true && detail?.loadedDetail?.status == .pending } + + private let coordinator: CommentsModerationCoordinator + private let noticePresenter: any NoticePresenting + private let makeDetail: (CommentListItem) -> CommentDetailViewModel + private var subscription: AnyCancellable? + private var knownNonpending: Set = [] + + init( + batch: [CommentListItem], + coordinator: CommentsModerationCoordinator, + noticePresenter: any NoticePresenting, + makeDetail: @escaping (CommentListItem) -> CommentDetailViewModel + ) { + // Unique IDs let a live `detail` for an ID imply it has no outcome yet. + self.batch = batch.deduplicated(by: \.id) + self.coordinator = coordinator + self.noticePresenter = noticePresenter + self.makeDetail = makeDetail + subscription = coordinator.events.sink { [weak self] in self?.handle($0) } + showCurrent() + } + + func loadCurrent(id: Int64, retry: Bool = false) async { + guard !isDismissed, let detail, detail.commentID == id else { return } + if retry { + await detail.retry() + } else { + await detail.onAppear() + } + guard !isDismissed, self.detail === detail, pendingAction == nil else { return } + if detail.isMissing { + coordinator.events.send(.deleted(id: id)) + } else if let loaded = detail.loadedDetail, loaded.status != .pending { + record(.bypassed, id: id) + } + } + + func skip(id: Int64) { + guard canSkip, detail?.commentID == id else { return } + record(.skipped, id: id) + } + + func close() { + isDismissed = true + subscription = nil + detail = nil + } + + func perform(_ action: CommentModerationAction, id: Int64) { + guard canModerate, let detail, detail.commentID == id, let loaded = detail.loadedDetail else { return } + pendingAction = action + // This task survives Close. The presenter and coordinator remain alive + // even if the session is released while its request is running. + Task { [coordinator, noticePresenter, weak self] in + var landed: CommentListItem.Status? + var failed = false + do { + if case .statusChanged(_, let status) = try await coordinator.perform(action, on: loaded) { + landed = status + } + } catch { + failed = true + } + if failed || landed == .pending { + noticePresenter.present(title: Strings.moderationFailed) + } + guard let self, !self.isDismissed, self.detail === detail else { return } + self.pendingAction = nil + if let landed, action.isConfirmed(by: landed) { + self.record(.moderated, id: id) + } else if self.knownNonpending.contains(id) { + self.record(.bypassed, id: id) + } + } + } + + private func handle(_ event: CommentChangeEvent) { + guard !isDismissed, outcomes[event.commentID] == nil else { return } + switch event { + case .deleted: + knownNonpending.insert(event.commentID) + case .statusChanged(_, let status): + if status == .pending { + knownNonpending.remove(event.commentID) + } else { + knownNonpending.insert(event.commentID) + } + case .contentChanged, .replyCreated: + return + } + // Our own operation emits before returning. Settle its response and + // events together, so an event cannot advance before credit is known. + if detail?.commentID == event.commentID, pendingAction == nil, knownNonpending.contains(event.commentID) { + record(.bypassed, id: event.commentID) + } + } + + private func record(_ outcome: Outcome, id: Int64) { + guard !isDismissed, detail?.commentID == id else { return } + outcomes[id] = outcome + position += 1 + showCurrent() + } + + private func showCurrent() { + while position < batch.count, knownNonpending.contains(batch[position].id) { + outcomes[batch[position].id] = .bypassed + position += 1 + } + detail = position < batch.count ? makeDetail(batch[position]) : nil + } +} diff --git a/Modules/Sources/WordPressComments/ViewModels/CommentsListViewModel.swift b/Modules/Sources/WordPressComments/ViewModels/CommentsListViewModel.swift index 0d20a0be931f..ff58170896f7 100644 --- a/Modules/Sources/WordPressComments/ViewModels/CommentsListViewModel.swift +++ b/Modules/Sources/WordPressComments/ViewModels/CommentsListViewModel.swift @@ -68,6 +68,17 @@ final class CommentsListViewModel: ObservableObject { hasLoaded && items.isEmpty } + /// Whether the Review button shows. Cheaper than `reviewBatch.isEmpty` + /// for a check that runs on every list render. + var canReview: Bool { + filter == .pending && hasLoaded && items.contains { $0.status == .pending } + } + + var reviewBatch: [CommentListItem] { + guard filter == .pending, hasLoaded else { return [] } + return items.filter { $0.status == .pending } + } + init( filter: CommentsListFilter, service: any CommentsServiceProtocol, diff --git a/Modules/Sources/WordPressComments/Views/CommentReviewView.swift b/Modules/Sources/WordPressComments/Views/CommentReviewView.swift new file mode 100644 index 000000000000..1ad206fc51fc --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/CommentReviewView.swift @@ -0,0 +1,177 @@ +import SwiftUI +import WordPressUI + +struct CommentReviewView: View { + @ObservedObject var viewModel: CommentReviewViewModel + let titleResolver: PostTitleResolver + let makeContentRenderer: () -> any CommentContentRendering + @Environment(\.dismiss) private var dismiss + + @AccessibilityFocusState private var isHeadingFocused: Bool + + var body: some View { + NavigationStack { + Group { + if viewModel.isComplete { + CommentReviewCompletionView( + moderatedCount: viewModel.moderatedCount, + skippedCount: viewModel.skippedCount + ) + } else if let detail = viewModel.detail { + CommentReviewEntryView( + detail: detail, + session: viewModel, + titleResolver: titleResolver, + makeContentRenderer: makeContentRenderer + ) + .id(detail.commentID) + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button.make(role: .close, action: close) + } + ToolbarItem(placement: .principal) { + VStack { + Text(Strings.Review.title).font(.headline) + if !viewModel.isComplete { + Text( + String.localizedStringWithFormat( + Strings.Review.position, + viewModel.position + 1, + viewModel.batch.count + ) + ) + .font(.caption) + } + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isHeader) + .accessibilityFocused($isHeadingFocused) + } + ToolbarItem(placement: .topBarTrailing) { + if let id = viewModel.detail?.commentID { + Button(Strings.Review.skip) { viewModel.skip(id: id) } + .disabled(!viewModel.canSkip) + } + } + } + .onChange(of: viewModel.position) { _, _ in isHeadingFocused = true } + } + .interactiveDismissDisabled(!viewModel.isComplete) + .onDisappear { viewModel.close() } + } + + private func close() { + viewModel.close() + dismiss() + } +} + +/// Recreated by comment ID, including the renderer, scroll view, and dialogs. +/// Observes the entry's detail as well as the session because the toolbar's +/// enablement reads the detail's load and mutation state through the session. +private struct CommentReviewEntryView: View { + @ObservedObject var detail: CommentDetailViewModel + @ObservedObject var session: CommentReviewViewModel + let titleResolver: PostTitleResolver + @StateObject private var content: ReviewContentRenderer + + init( + detail: CommentDetailViewModel, + session: CommentReviewViewModel, + titleResolver: PostTitleResolver, + makeContentRenderer: @escaping () -> any CommentContentRendering + ) { + self.detail = detail + self.session = session + self.titleResolver = titleResolver + _content = StateObject(wrappedValue: ReviewContentRenderer(renderer: makeContentRenderer())) + } + + var body: some View { + let id = detail.commentID + CommentDetailBody( + viewModel: detail, + titleResolver: titleResolver, + renderer: content.renderer, + retry: { Task { await session.loadCurrent(id: id, retry: true) } } + ) + .safeAreaInset(edge: .bottom, spacing: 0) { + CommentModerationToolbar( + model: .pending, + isEnabled: session.canModerate, + pendingAction: session.pendingAction, + trashConfirmation: detail.trashConfirmation + ) { action in + session.perform(action, id: id) + } + } + .task { await session.loadCurrent(id: id) } + } +} + +/// StateObject defers construction until this entry owns its SwiftUI identity. +/// Session updates must not allocate additional WebKit renderers. +private final class ReviewContentRenderer: ObservableObject { + let renderer: any CommentContentRendering + + init(renderer: any CommentContentRendering) { + self.renderer = renderer + } +} + +private struct CommentReviewCompletionView: View { + let moderatedCount: Int + let skippedCount: Int + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var appeared = false + + var body: some View { + ViewThatFits(in: .vertical) { + content(imageFont: .largeTitle) + content(imageFont: .body) + } + .padding(32) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + withAnimation(reduceMotion || moderatedCount == 0 ? nil : .easeOut(duration: 0.2)) { + appeared = true + } + } + } + + private func content(imageFont: Font) -> some View { + VStack(spacing: 20) { + Image(systemName: moderatedCount > 0 ? "checkmark.circle" : "bubble.left") + .font(imageFont) + .imageScale(.large) + .foregroundStyle(moderatedCount > 0 ? Color.accentColor : .secondary) + .accessibilityHidden(true) + .scaleEffect(appeared ? 1 : 0.9) + Text(Strings.Review.complete) + .font(.title2.weight(.semibold)) + .accessibilityAddTraits(.isHeader) + Text(Strings.Review.summary(moderated: moderatedCount, skipped: skippedCount)) + } + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } +} + +#Preview("Moderated") { + CommentReviewCompletionView(moderatedCount: 5, skippedCount: 0) +} + +#Preview("Skipped") { + CommentReviewCompletionView(moderatedCount: 0, skippedCount: 5) +} + +#Preview("Mixed results") { + CommentReviewCompletionView(moderatedCount: 3, skippedCount: 2) +} + +#Preview("Already handled") { + CommentReviewCompletionView(moderatedCount: 0, skippedCount: 0) +} diff --git a/Modules/Sources/WordPressComments/Views/CommentsListView.swift b/Modules/Sources/WordPressComments/Views/CommentsListView.swift index 34182b0b2e0f..ef9d00b20296 100644 --- a/Modules/Sources/WordPressComments/Views/CommentsListView.swift +++ b/Modules/Sources/WordPressComments/Views/CommentsListView.swift @@ -5,6 +5,8 @@ struct CommentsListView: View { @ObservedObject var titleResolver: PostTitleResolver /// Pushes the detail screen for a tapped row. let openComment: (Int64, CommentListItem?) -> Void + /// Starts a review session over the loaded pending comments. + let review: ([CommentListItem]) -> Void var body: some View { List { @@ -35,6 +37,14 @@ struct CommentsListView: View { } } .listStyle(.plain) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if viewModel.canReview { + Button(Strings.Review.review) { review(viewModel.reviewBatch) } + .accessibilityLabel(Strings.Review.reviewAccessibility) + } + } + } .refreshable { await viewModel.refresh() } diff --git a/Modules/Sources/WordPressComments/Views/CommentsTabView.swift b/Modules/Sources/WordPressComments/Views/CommentsTabView.swift index ff13d0f84039..6d0baa12ed33 100644 --- a/Modules/Sources/WordPressComments/Views/CommentsTabView.swift +++ b/Modules/Sources/WordPressComments/Views/CommentsTabView.swift @@ -6,6 +6,7 @@ struct CommentsTabView: View { @State private var selectedFilter: CommentsListFilter = .all @State private var viewModels: [CommentsListFilter: CommentsListViewModel] @State private var titleResolver: PostTitleResolver + @State private var reviewSession: CommentReviewViewModel? /// A tapped row (and, recursively, a parent comment) pushes a detail screen /// through it. @@ -81,12 +82,19 @@ struct CommentsTabView: View { CommentsListView( viewModel: viewModel, titleResolver: titleResolver, - openComment: { router.open(id: $0, seed: $1) } + openComment: { router.open(id: $0, seed: $1) }, + review: { batch in + guard reviewSession == nil else { return } + reviewSession = router.makeReviewSession(batch: batch) + } ) } } .navigationTitle(Strings.title) .navigationBarTitleDisplayMode(.inline) + .sheet(item: $reviewSession) { session in + router.makeReviewView(session: session) + } } private var tabBar: some View { diff --git a/Modules/Sources/WordPressComments/Views/Detail/CommentDetailView.swift b/Modules/Sources/WordPressComments/Views/Detail/CommentDetailView.swift index 0978c85acff4..d42804004c90 100644 --- a/Modules/Sources/WordPressComments/Views/Detail/CommentDetailView.swift +++ b/Modules/Sources/WordPressComments/Views/Detail/CommentDetailView.swift @@ -32,69 +32,25 @@ struct CommentDetailView: View { } var body: some View { - fixedRegions - .safeAreaInset(edge: .bottom, spacing: 0) { bottomToolbar } - .toolbar { trailingToolbarItems } - .navigationBarTitleDisplayMode(.inline) - .task { await viewModel.onAppear() } - // The comment no longer exists, so there is nothing left to show. - // `dismiss` pops this screen off the UIKit navigation stack. - .onChange(of: viewModel.isDeleted) { _, isDeleted in - if isDeleted { dismiss() } - } - .sheet(item: $viewModel.composer) { composer in - CommentComposerView(viewModel: composer) { viewModel.composerClosed($0) } - .presentationDetents([.large]) - } - } - - private var fixedRegions: some View { - VStack(spacing: 0) { - if let header = viewModel.header { - VStack(alignment: .leading, spacing: 12) { - CommentStatusPill(status: header.status) - CommentAuthorHeader( - header: header, - titleState: titleResolver.titleState(for: header.postID), - detail: viewModel.loadedDetail - ) - } - .padding(.horizontal) - .padding(.vertical, 12) - } - if let parent = viewModel.parentPreview { - Divider() - CommentParentStrip(parent: parent) { openComment(parent.id, parent) } - .padding(.horizontal) - .padding(.vertical, 10) - } - Divider() - contentRegion - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - } - - @ViewBuilder - private var contentRegion: some View { - switch viewModel.content { - case .loading: - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - case .failed: - failureView - case .loaded(let detail): - CommentContentRegion(renderer: renderer, html: detail.contentHTML) + CommentDetailBody( + viewModel: viewModel, + titleResolver: titleResolver, + renderer: renderer, + openParent: { openComment($0.id, $0) }, + retry: { Task { await viewModel.retry() } } + ) + .safeAreaInset(edge: .bottom, spacing: 0) { bottomToolbar } + .toolbar { trailingToolbarItems } + .navigationBarTitleDisplayMode(.inline) + .task { await viewModel.onAppear() } + // The comment no longer exists, so there is nothing left to show. + // `dismiss` pops this screen off the UIKit navigation stack. + .onChange(of: viewModel.isDeleted) { _, isDeleted in + if isDeleted { dismiss() } } - } - - private var failureView: some View { - ContentUnavailableView { - Label(Strings.detailErrorTitle, systemImage: "exclamationmark.triangle") - } actions: { - Button(Strings.errorRetry) { - Task { await viewModel.retry() } - } - .buttonStyle(.borderedProminent) + .sheet(item: $viewModel.composer) { composer in + CommentComposerView(viewModel: composer) { viewModel.composerClosed($0) } + .presentationDetents([.large]) } } @@ -155,6 +111,68 @@ struct CommentDetailView: View { } } +/// The fixed regions every detail presentation shares: the status pill and +/// author header, the optional "In reply to" strip, and the content region. +/// The ordinary screen and the review sheet add their own toolbars and +/// lifecycle around it. +struct CommentDetailBody: View { + @ObservedObject var viewModel: CommentDetailViewModel + @ObservedObject var titleResolver: PostTitleResolver + let renderer: any CommentContentRendering + /// Makes the parent strip tappable; nil renders it as plain context. + var openParent: ((CommentListItem) -> Void)? + let retry: () -> Void + + var body: some View { + VStack(spacing: 0) { + if let header = viewModel.header { + VStack(alignment: .leading, spacing: 12) { + CommentStatusPill(status: header.status) + CommentAuthorHeader( + header: header, + titleState: titleResolver.titleState(for: header.postID), + detail: viewModel.loadedDetail + ) + } + .padding(.horizontal) + .padding(.vertical, 12) + } + if let parent = viewModel.parentPreview { + Divider() + CommentParentStrip(parent: parent, onTap: parentTapAction(parent)) + .padding(.horizontal) + .padding(.vertical, 10) + } + Divider() + contentRegion + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + private func parentTapAction(_ parent: CommentListItem) -> (() -> Void)? { + guard let openParent else { return nil } + return { openParent(parent) } + } + + @ViewBuilder + private var contentRegion: some View { + switch viewModel.content { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed: + ContentUnavailableView { + Label(Strings.detailErrorTitle, systemImage: "exclamationmark.triangle") + } actions: { + Button(Strings.errorRetry, action: retry) + .buttonStyle(.borderedProminent) + } + case .loaded(let detail): + CommentContentRegion(renderer: renderer, html: detail.contentHTML) + } + } +} + #if DEBUG /// Renders comment HTML as plain text inside a scroll view. Stands in for the /// production WebKit-backed renderer so the preview stays self-contained. diff --git a/Modules/Sources/WordPressComments/Views/Detail/CommentParentStrip.swift b/Modules/Sources/WordPressComments/Views/Detail/CommentParentStrip.swift index ecb5e289b983..cea722869b87 100644 --- a/Modules/Sources/WordPressComments/Views/Detail/CommentParentStrip.swift +++ b/Modules/Sources/WordPressComments/Views/Detail/CommentParentStrip.swift @@ -1,37 +1,37 @@ import SwiftUI -/// The "In reply to" strip shown above the content when the comment has a -/// parent. Tapping it pushes the parent comment via the recursive -/// `openComment` closure. +/// Parent context with optional navigation to the parent comment. struct CommentParentStrip: View { let parent: CommentListItem - let onTap: () -> Void + var onTap: (() -> Void)? var body: some View { - Button(action: onTap) { - HStack(spacing: 8) { - Text(text) + if let onTap { + Button(action: onTap) { content } + .buttonStyle(.plain) + } else { + content + } + } + + private var content: some View { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 4) { + Text(String.localizedStringWithFormat(Strings.inReplyToFormat, parent.authorName)) + .font(.footnote.weight(.semibold)) + Text(parent.snippet) .font(.footnote) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 0) + .foregroundStyle(.secondary) + .lineLimit(3) + } + .frame(maxWidth: .infinity, alignment: .leading) + if onTap != nil { Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(.secondary) + .accessibilityHidden(true) } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - - private var text: AttributedString { - var result = AttributedString(String(format: Strings.inReplyToFormat, parent.authorName)) - if let range = result.range(of: parent.authorName) { - result[range].font = .footnote.weight(.semibold) } - var snippet = AttributedString(": \(parent.snippet)") - snippet.foregroundColor = .secondary - result.append(snippet) - return result + .contentShape(Rectangle()) } } diff --git a/WordPress/Classes/Utility/Button+Extensions.swift b/Modules/Sources/WordPressUI/Extensions/Button+Extensions.swift similarity index 53% rename from WordPress/Classes/Utility/Button+Extensions.swift rename to Modules/Sources/WordPressUI/Extensions/Button+Extensions.swift index 49efd09facd7..7a816f12421b 100644 --- a/WordPress/Classes/Utility/Button+Extensions.swift +++ b/Modules/Sources/WordPressUI/Extensions/Button+Extensions.swift @@ -1,4 +1,5 @@ import SwiftUI +import WordPressShared extension Button where Label == Text { @ViewBuilder @@ -20,9 +21,24 @@ public enum BackportButtonRole { var title: String { switch self { - case .cancel: SharedStrings.Button.cancel - case .close: SharedStrings.Button.close - case .confirm: SharedStrings.Button.done + case .cancel: + AppLocalizedString( + "shared.button.cancel", + value: "Cancel", + comment: "A shared button title used in different contexts" + ) + case .close: + AppLocalizedString( + "shared.button.close", + value: "Close", + comment: "A shared button title used in different contexts" + ) + case .confirm: + AppLocalizedString( + "shared.button.done", + value: "Done", + comment: "A shared button title used in different contexts" + ) } } } diff --git a/Modules/Tests/WordPressCommentsTests/CommentReviewBatchTests.swift b/Modules/Tests/WordPressCommentsTests/CommentReviewBatchTests.swift new file mode 100644 index 000000000000..d3d553f23a4d --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentReviewBatchTests.swift @@ -0,0 +1,80 @@ +import Testing +@testable import WordPressComments + +@MainActor +struct CommentReviewBatchTests { + @Test func provisionalSeedCannotEnableReview() async { + let service = BlockingCommentsService() + let list = CommentsListViewModel(filter: .pending, service: service, seedItems: { [makeItem(status: .hold)] }) + #expect(list.reviewBatch.isEmpty) + let load = Task { await list.onAppear() } + await waitUntil { service.callCount == 1 } + #expect(list.items.count == 1) + #expect(list.reviewBatch.isEmpty) + service.resolve(callIndex: 0, with: makePage(items: [makeItem(status: .hold)], hasNext: false)) + await load.value + #expect(list.reviewBatch.count == 1) + } + + @Test func failedEmptyAndOtherTabsCannotEnableReview() async { + let service = FakeCommentsService() + let failed = CommentsListViewModel(filter: .pending, service: service, seedItems: { [makeItem(status: .hold)] }) + await failed.onAppear() + #expect(failed.reviewBatch.isEmpty) + for filter in CommentsListFilter.allCases { + service.queuedResults = [ + .success(makePage(items: filter == .pending ? [] : [makeItem(status: .hold)], hasNext: false)) + ] + let list = CommentsListViewModel(filter: filter, service: service) + await list.onAppear() + #expect(list.reviewBatch.isEmpty) + } + } + + @Test func batchIncludesLoadedPagesBeyondOneHundredAndIgnoresLaterPaginationAndRefresh() async { + let service = BlockingCommentsService() + let coordinator = CommentsModerationCoordinator(service: service) + let list = CommentsListViewModel( + filter: .pending, + service: service, + changeEvents: coordinator.events.eraseToAnyPublisher() + ) + let pageOne = (1...100).map { makeItem(id: Int64($0), status: .hold) } + let pageTwo = (101...150).map { makeItem(id: Int64($0), status: .hold) } + let first = Task { await list.onAppear() } + await waitUntil { service.callCount == 1 } + service.resolve(callIndex: 0, with: makePage(items: pageOne, hasNext: true)) + await first.value + let second = Task { await list.loadMore() } + await waitUntil { service.callCount == 2 } + service.resolve(callIndex: 1, with: makePage(items: pageTwo, hasNext: true)) + await second.value + let third = Task { await list.loadMore() } + await waitUntil { service.callCount == 3 } + let session = CommentReviewViewModel( + batch: list.reviewBatch, + coordinator: coordinator, + noticePresenter: FakeNoticePresenter() + ) { item in + makeVM(commentID: item.id, seed: item, service: service, coordinator: coordinator) + } + #expect(session.batch.map(\.id) == (1...150).map(Int64.init)) + #expect(service.callCount == 3) + service.resolve(callIndex: 2, with: makePage(items: [makeItem(id: 151, status: .hold)], hasNext: false)) + await third.value + #expect(list.items.count == 151) + coordinator.noteExternalStatus(id: 2, to: .spam) + session.skip(id: 1) + #expect(session.position == 2) + #expect(session.detail?.commentID == 3) + #expect(session.batch.count == 150) + let refresh = Task { await list.refresh() } + await waitUntil { service.callCount == 4 } + #expect(!list.reviewBatch.isEmpty) + service.resolve(callIndex: 3, with: makePage(items: [makeItem(id: 152, status: .hold)], hasNext: false)) + await refresh.value + #expect(session.batch.map(\.id) == (1...150).map(Int64.init)) + #expect(session.position == 2) + #expect(service.callCount == 4) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentReviewViewModelTests.swift b/Modules/Tests/WordPressCommentsTests/CommentReviewViewModelTests.swift new file mode 100644 index 000000000000..292140045532 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentReviewViewModelTests.swift @@ -0,0 +1,335 @@ +import Testing +import WordPressAPI +import WordPressAPIInternal +@testable import WordPressComments + +@MainActor +struct CommentReviewViewModelTests { + @Test(arguments: [CommentModerationAction.approve, .spam, .trash]) + func confirmedSubmissionCountsOnceAndAdvances(_ action: CommentModerationAction) async { + let service = loadedService() + service.setStatusResult = .success(makeDetail(status: action == .approve ? .approved : .spam)) + let session = makeSession(service: service) + await session.loadCurrent(id: 1) + session.perform(action, id: 1) + session.perform(action, id: 1) + session.skip(id: 1) + #expect(session.position == 0) + #expect(!session.canSkip) + await waitUntil { session.pendingAction == nil } + #expect(session.position == 1) + #expect(session.moderatedCount == 1) + #expect(session.skippedCount == 0) + #expect(service.setStatusInvocations.count + service.trashInvocations.count == 1) + // Queued controls from the old view must not act on the next entry, + // even after that entry has finished loading. + await session.loadCurrent(id: 2) + session.perform(action, id: 1) + session.skip(id: 1) + #expect(session.position == 1) + #expect(session.pendingAction == nil) + } + + @Test(arguments: [false, true]) + func unexpectedStatusHasNoCredit(remainsPending: Bool) async { + let returnedStatus: CommentStatus = remainsPending ? .hold : .spam + let service = loadedService() + service.setStatusResult = .success(makeDetail(status: returnedStatus)) + let notices = FakeNoticePresenter() + let session = makeSession(service: service, notices: notices) + await session.loadCurrent(id: 1) + session.perform(.approve, id: 1) + await waitUntil { session.pendingAction == nil } + #expect(session.moderatedCount == 0) + #expect(session.position == (returnedStatus == .hold ? 0 : 1)) + #expect(notices.presented.count == (returnedStatus == .hold ? 1 : 0)) + } + + @Test func failedActionKeepsCurrentComment() async { + let service = loadedService() + let notices = FakeNoticePresenter() + let session = makeSession(service: service, notices: notices) + await session.loadCurrent(id: 1) + session.perform(.approve, id: 1) + await waitUntil { session.pendingAction == nil } + #expect(session.position == 0) + #expect(session.canModerate) + #expect(notices.presented == [Strings.moderationFailed]) + } + + @Test func loadFailureRetainsHeaderAndCanRetryOrSkip() async { + let service = loadedService() + service.fetchCommentResultsByID[1] = .failure(FakeServiceError()) + let session = makeSession(service: service) + await session.loadCurrent(id: 1) + #expect(session.detail?.content == .failed) + #expect(session.detail?.header?.authorName == "Author 1") + #expect(!session.canModerate) + #expect(session.canSkip) + service.fetchCommentResultsByID[1] = .success(makeDetail(status: .hold, editContext: true)) + await session.loadCurrent(id: 1, retry: true) + #expect(session.canModerate) + session.skip(id: 1) + #expect(session.skippedCount == 1) + #expect(service.setStatusInvocations.isEmpty) + } + + @Test func cannotModerateWithoutEditContext() async { + let service = loadedService() + service.fetchCommentResultsByID[1] = .success(makeDetail(status: .hold)) + let session = makeSession(service: service) + session.perform(.approve, id: 1) + await session.loadCurrent(id: 1) + session.perform(.approve, id: 1) + #expect(!session.canModerate) + #expect(service.setStatusInvocations.isEmpty) + #expect(session.canSkip) + } + + @Test func missingAndPreviouslyHandledCommentsAreBypassed() async { + let service = loadedService() + service.fetchCommentResultsByID[1] = .failure(missingError()) + service.fetchCommentResultsByID[2] = .success(makeDetail(id: 2, status: .approved)) + let session = makeSession(service: service) + await session.loadCurrent(id: 1) + #expect(session.position == 1) + await session.loadCurrent(id: 2) + #expect(session.isComplete) + #expect(session.moderatedCount == 0) + #expect(session.skippedCount == 0) + } + + @Test func sameStatusProbeConfirmsOwnSubmission() async { + let service = loadedService() + service.setStatusResult = .failure(WpApiError.stub(code: .CommentFailedEdit, statusCode: 500)) + service.fetchStatusResults = [.success(.approved)] + let session = makeSession(service: service) + await session.loadCurrent(id: 1) + session.perform(.approve, id: 1) + await waitUntil { session.pendingAction == nil } + #expect(session.moderatedCount == 1) + #expect(session.position == 1) + } + + @Test(arguments: [false, true]) + func nonpendingProbeAfterFailureBypassesAndReconcilesList(closeDuringAction: Bool) async { + let service = loadedService() + service.queuedResults = [.success(makePage(items: items(), hasNext: false))] + service.setStatusResult = .failure(WpApiError.stub(code: .CommentFailedEdit, statusCode: 500)) + service.fetchStatusResults = [.success(.spam)] + let coordinator = CommentsModerationCoordinator(service: service) + let notices = FakeNoticePresenter() + let list = CommentsListViewModel( + filter: .pending, + service: service, + changeEvents: coordinator.events.eraseToAnyPublisher() + ) + await list.onAppear() + let session = makeSession(service: service, coordinator: coordinator, notices: notices) + await session.loadCurrent(id: 1) + session.perform(.approve, id: 1) + if closeDuringAction { session.close() } + await waitUntil { !notices.presented.isEmpty } + #expect(session.position == (closeDuringAction ? 0 : 1)) + #expect(session.isDismissed == closeDuringAction) + #expect(session.moderatedCount == 0) + #expect(session.skippedCount == 0) + #expect(notices.presented == [Strings.moderationFailed]) + #expect(list.items.map(\.id) == [2]) + } + + @Test func externalEventsBypassFutureEntriesAndNeverRewriteSkips() async { + let service = loadedService() + let coordinator = CommentsModerationCoordinator(service: service) + let session = makeSession(service: service, coordinator: coordinator) + session.skip(id: 1) + coordinator.noteExternalStatus(id: 1, to: .approved) + coordinator.events.send(.deleted(id: 2)) + #expect(session.isComplete) + #expect(session.outcomes[1] == .skipped) + #expect(session.moderatedCount == 0) + #expect(session.batch.count == 2) + } + + @Test func skipDuringLoadingIgnoresOldResponseAndParent() async { + let service = BlockingCommentsService() + let session = makeSession(service: service) + let firstLoad = Task { await session.loadCurrent(id: 1) } + await waitUntil { service.fetchCommentInvocations.count == 1 } + session.skip(id: 1) + #expect(session.detail?.header?.authorName == "Author 2") + #expect(session.detail?.content == .loading) + service.resolveFetch(callIndex: 0, with: makeDetail(parent: 3, status: .hold, editContext: true)) + await waitUntil { service.fetchCommentInvocations.count == 2 } + service.resolveFetch(callIndex: 1, with: makeDetail(id: 3)) + await firstLoad.value + #expect(session.detail?.parentPreview == nil) + #expect(session.detail?.trashConfirmation == .generic) + #expect(session.position == 1) + #expect(session.skippedCount == 1) + #expect(service.callCount == 0) + } + + @Test(arguments: [false, true]) + func closeDuringActionStillReconcilesPendingListAndReportsLateFailure(fails: Bool) async { + let service = BlockingCommentsService() + let coordinator = CommentsModerationCoordinator(service: service) + let notices = FakeNoticePresenter() + let list = CommentsListViewModel( + filter: .pending, + service: service, + changeEvents: coordinator.events.eraseToAnyPublisher() + ) + let listLoad = Task { await list.onAppear() } + await waitUntil { service.callCount == 1 } + service.resolve(callIndex: 0, with: makePage(items: items(), hasNext: false)) + await listLoad.value + let session = makeSession(service: service, coordinator: coordinator, notices: notices) + let load = Task { await session.loadCurrent(id: 1) } + await waitUntil { service.fetchCommentInvocations.count == 1 } + service.resolveFetch(callIndex: 0, with: makeDetail(status: .hold, editContext: true)) + await load.value + session.perform(.approve, id: 1) + await waitUntil { service.setStatusInvocations.count == 1 } + session.close() + if fails { + service.failSetStatus(callIndex: 0, with: FakeServiceError()) + } else { + service.resolveSetStatus(callIndex: 0, with: makeDetail(status: .approved)) + } + await coordinator.waitForPendingMutation(id: 1) + if fails { await waitUntil { !notices.presented.isEmpty } } + #expect(session.isDismissed) + #expect(!session.isComplete) + #expect(session.detail == nil) + #expect(session.moderatedCount == 0) + #expect(list.items.map(\.id) == (fails ? [1, 2] : [2])) + #expect(notices.presented.count == (fails ? 1 : 0)) + } + + @Test func eventsDuringSubmissionWaitForItsResult() async { + let service = BlockingCommentsService() + let coordinator = CommentsModerationCoordinator(service: service) + let session = makeSession(service: service, coordinator: coordinator) + let load = Task { await session.loadCurrent(id: 1) } + await waitUntil { service.fetchCommentInvocations.count == 1 } + service.resolveFetch(callIndex: 0, with: makeDetail(status: .hold, editContext: true)) + await load.value + session.perform(.approve, id: 1) + await waitUntil { service.setStatusInvocations.count == 1 } + coordinator.noteExternalStatus(id: 1, to: .spam) + #expect(session.position == 0) + #expect(session.moderatedCount == 0) + service.failSetStatus(callIndex: 0, with: FakeServiceError()) + await waitUntil { session.pendingAction == nil } + #expect(session.position == 1) + #expect(session.moderatedCount == 0) + } + + @Test func oldTrashConfirmationCannotSubmitAgainstNextComment() async { + let service = loadedService() + service.numberOfRepliesResult = .success(2) + let session = makeSession(service: service) + await session.loadCurrent(id: 1) + #expect(session.detail?.trashConfirmation == .withReplies) + session.skip(id: 1) + await session.loadCurrent(id: 2) + // A confirmation retained by the old view still carries its old ID. + session.perform(.trash, id: 1) + #expect(service.trashInvocations.isEmpty) + } + + @Test func completionCountsOnlyExplicitDecisions() async { + let service = loadedService() + service.setStatusResult = .success(makeDetail(status: .approved)) + let session = makeSession(service: service) + await session.loadCurrent(id: 1) + session.perform(.approve, id: 1) + await waitUntil { session.pendingAction == nil } + session.skip(id: 2) + #expect(session.isComplete) + #expect(session.moderatedCount == 1) + #expect(session.skippedCount == 1) + #expect(!session.canSkip) + session.skip(id: 2) + #expect(session.skippedCount == 1) + } + + @Test func completionTextOmitsZeroCounts() { + #expect(Strings.Review.summary(moderated: 0, skipped: 0) == "No comments moderated in this session.") + #expect(Strings.Review.summary(moderated: 1, skipped: 0) == "1 comment moderated.") + #expect(Strings.Review.summary(moderated: 3, skipped: 0) == "3 comments moderated.") + #expect(Strings.Review.summary(moderated: 0, skipped: 1) == "1 comment skipped.") + #expect(Strings.Review.summary(moderated: 0, skipped: 5) == "5 comments skipped.") + #expect(Strings.Review.summary(moderated: 3, skipped: 2) == "3 comments moderated. 2 skipped.") + } + + @Test func closeDuringLoadingCannotAdvanceOrCompleteLater() async { + let service = BlockingCommentsService() + let session = makeSession(service: service) + let load = Task { await session.loadCurrent(id: 1) } + await waitUntil { service.fetchCommentInvocations.count == 1 } + session.close() + service.resolveFetch(callIndex: 0, with: makeDetail(status: .approved)) + await load.value + #expect(session.isDismissed) + #expect(!session.isComplete) + #expect(session.position == 0) + #expect(session.outcomes.isEmpty) + } + + @Test func allSkippedSessionCompletesAndFreshSessionIncludesSkippedComments() { + let service = loadedService() + let session = makeSession(service: service) + session.skip(id: 1) + session.skip(id: 2) + #expect(session.isComplete) + #expect(session.moderatedCount == 0) + #expect(session.skippedCount == 2) + session.close() + let fresh = makeSession(service: service) + #expect(fresh.detail?.commentID == 1) + #expect(fresh.skippedCount == 0) + #expect(service.setStatusInvocations.isEmpty) + } + + @Test func disappearingDuringSubmissionBypassesWithoutCredit() async { + let service = loadedService() + service.setStatusResult = .failure(missingError()) + let session = makeSession(service: service) + await session.loadCurrent(id: 1) + session.perform(.approve, id: 1) + await waitUntil { session.pendingAction == nil } + #expect(session.position == 1) + #expect(session.moderatedCount == 0) + #expect(session.outcomes[1] == .bypassed) + } + + private func items() -> [CommentListItem] { + [makeItem(id: 1, authorName: "Author 1", status: .hold), makeItem(id: 2, authorName: "Author 2", status: .hold)] + } + + private func loadedService() -> FakeCommentsService { + let service = FakeCommentsService() + for id: Int64 in [1, 2] { + service.fetchCommentResultsByID[id] = .success(makeDetail(id: id, status: .hold, editContext: true)) + } + service.numberOfRepliesResult = .success(0) + return service + } + + private func makeSession( + service: any CommentsServiceProtocol, + coordinator: CommentsModerationCoordinator? = nil, + notices: FakeNoticePresenter = FakeNoticePresenter() + ) -> CommentReviewViewModel { + let coordinator = coordinator ?? CommentsModerationCoordinator(service: service) + return CommentReviewViewModel(batch: items(), coordinator: coordinator, noticePresenter: notices) { item in + makeVM(commentID: item.id, seed: item, service: service, coordinator: coordinator) + } + } + + private func missingError() -> WpApiError { + .stub(statusCode: 404) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentsDetailRouterTests.swift b/Modules/Tests/WordPressCommentsTests/CommentsDetailRouterTests.swift index e97f710dfb57..4ed66916b574 100644 --- a/Modules/Tests/WordPressCommentsTests/CommentsDetailRouterTests.swift +++ b/Modules/Tests/WordPressCommentsTests/CommentsDetailRouterTests.swift @@ -5,6 +5,26 @@ import UIKit @MainActor struct CommentsDetailRouterTests { + @Test func reviewSessionsAreIndependentOfHostAndPreviousSession() throws { + let router = makeRouter(capabilities: FakeCommentsCapabilities()) + let batch = [makeItem(status: .hold)] + let first = try #require(router.makeReviewSession(batch: batch)) + first.skip(id: batch[0].id) + first.close() + + let second = try #require(router.makeReviewSession(batch: batch)) + #expect(first.id != second.id) + #expect(second.batch.map(\.id) == batch.map(\.id)) + #expect(second.position == 0) + #expect(second.outcomes.isEmpty) + #expect(!second.isDismissed) + } + + @Test func emptyReviewDoesNotCreateSession() { + let router = makeRouter(capabilities: FakeCommentsCapabilities()) + #expect(router.makeReviewSession(batch: []) == nil) + } + @Test func openPushesDetailOntoHostNavigationStack() { let host = UIViewController() let navigation = UINavigationController(rootViewController: host) diff --git a/Modules/Tests/WordPressCommentsTests/CommentsModerationCoordinatorTests.swift b/Modules/Tests/WordPressCommentsTests/CommentsModerationCoordinatorTests.swift index 70e6b1b1ba0c..06bfabf22bbe 100644 --- a/Modules/Tests/WordPressCommentsTests/CommentsModerationCoordinatorTests.swift +++ b/Modules/Tests/WordPressCommentsTests/CommentsModerationCoordinatorTests.swift @@ -11,7 +11,7 @@ struct CommentsModerationCoordinatorTests { let coordinator = CommentsModerationCoordinator(service: service) let recorder = EventRecorder(coordinator) - async let performed: Void = coordinator.perform(.approve, on: makeDetail(id: 1, status: .hold)) + async let performed: CommentChangeEvent? = coordinator.perform(.approve, on: makeDetail(id: 1, status: .hold)) await waitUntil { !service.setStatusInvocations.isEmpty } // Still blocked on the continuation: nothing emitted until it resolves. @@ -85,7 +85,7 @@ struct CommentsModerationCoordinatorTests { #expect(recorder.events == [.statusChanged(id: 1, to: .approved)]) } - @Test func commentFailedEditProbeMismatchThrowsWithoutEvent() async { + @Test func commentFailedEditProbeMismatchEmitsConfirmedStatusAndThrowsOriginalError() async { let service = FakeCommentsService() service.setStatusResult = .failure(WpApiError.stub(code: .CommentFailedEdit)) // The probe finds a different status: the action genuinely failed. @@ -93,10 +93,10 @@ struct CommentsModerationCoordinatorTests { let coordinator = CommentsModerationCoordinator(service: service) let recorder = EventRecorder(coordinator) - await #expect(throws: (any Error).self) { + await #expect(throws: WpApiError.self) { try await coordinator.perform(.approve, on: makeDetail(id: 1, status: .hold)) } - #expect(recorder.events.isEmpty) + #expect(recorder.events == [.statusChanged(id: 1, to: .spam)]) } @Test func commentFailedEditProbeFailureThrowsOriginalError() async { @@ -223,7 +223,7 @@ struct CommentsModerationCoordinatorTests { let coordinator = CommentsModerationCoordinator(service: service) let recorder = EventRecorder(coordinator) - async let first: Void = coordinator.perform(.approve, on: makeDetail(id: 1, status: .hold)) + async let first: CommentChangeEvent? = coordinator.perform(.approve, on: makeDetail(id: 1, status: .hold)) await waitUntil { !service.setStatusInvocations.isEmpty } // A second action while the first is in flight returns without a request. @@ -239,7 +239,7 @@ struct CommentsModerationCoordinatorTests { let service = BlockingCommentsService() let coordinator = CommentsModerationCoordinator(service: service) - async let performed: Void = coordinator.perform(.approve, on: makeDetail(id: 1, status: .hold)) + async let performed: CommentChangeEvent? = coordinator.perform(.approve, on: makeDetail(id: 1, status: .hold)) await waitUntil { !service.setStatusInvocations.isEmpty } async let waiterResumed: Bool = { diff --git a/WordPress/Classes/ViewRelated/Post/Categories/PostCategoryCreateView.swift b/WordPress/Classes/ViewRelated/Post/Categories/PostCategoryCreateView.swift index 68436b00480f..462f9ac1f674 100644 --- a/WordPress/Classes/ViewRelated/Post/Categories/PostCategoryCreateView.swift +++ b/WordPress/Classes/ViewRelated/Post/Categories/PostCategoryCreateView.swift @@ -1,5 +1,6 @@ import SwiftUI import WordPressData +import WordPressUI struct PostCategoryCreateView: View { let blog: Blog