Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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
}
}
90 changes: 90 additions & 0 deletions Modules/Sources/WordPressComments/Strings/Strings+Review.swift
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Combine
import Foundation
import WordPressAPIInternal
import WordPressShared

/// Drives the comment detail and moderation screen. Reads the moderation
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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?

Expand Down Expand Up @@ -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
}
Expand Down
Loading