Skip to content
Draft
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import Foundation
enum CommentChangeEvent: Equatable, Sendable {
case statusChanged(id: Int64, to: CommentListItem.Status)
case deleted(id: Int64)
/// A reply was created under `parentID`. Carries the reply's own status
/// (not the parent's) so loaded tabs can decide whether it belongs to
/// them; stales rather than inserts because a paged list cannot know the
/// reply's correct position.
case replyCreated(parentID: Int64, replyStatus: CommentListItem.Status)
}

extension CommentChangeEvent {
Expand All @@ -15,6 +20,7 @@ extension CommentChangeEvent {
switch self {
case .statusChanged(let id, _): id
case .deleted(let id): id
case .replyCreated(let parentID, _): parentID
}
}
}
58 changes: 58 additions & 0 deletions Modules/Sources/WordPressComments/Services/CommentDraftStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import Foundation

/// Persists in-progress reply drafts per (site, user, comment), matching the
/// legacy composer's behavior so a half-written reply survives cancel and
/// process death. Edit mode deliberately has no drafts (legacy parity).
@MainActor
public protocol CommentDraftStoring {
func loadDraft(commentID: Int64) -> String?
func saveDraft(_ text: String, commentID: Int64)
func deleteDraft(commentID: Int64)
}

@MainActor
public final class UserDefaultsCommentDraftStore: CommentDraftStoring {
private let namespace: String
private let defaults: UserDefaults

/// `namespace` identifies the (site, user) pair so drafts never leak
/// across sites or accounts; see `namespace(siteURL:username:)`.
public init(namespace: String, defaults: UserDefaults = .standard) {
self.namespace = namespace
self.defaults = defaults
}

public convenience init(siteURL: URL, username: String, defaults: UserDefaults = .standard) {
self.init(namespace: Self.namespace(siteURL: siteURL, username: username), defaults: defaults)
}

/// Keys drafts per (site, user): same person different site, or same
/// site different account, must never see each other's drafts. Only the
/// case-insensitive URL parts (scheme, host) are normalized; the path is
/// case-sensitive, so lowercasing the whole URL would collapse distinct
/// sites like /Blog and /blog and leak drafts between them.
static func namespace(siteURL: URL, username: String) -> String {
guard var components = URLComponents(url: siteURL, resolvingAgainstBaseURL: false) else {
return "\(siteURL.absoluteString)|\(username)"
}
components.scheme = components.scheme?.lowercased()
components.host = components.host?.lowercased()
return "\(components.string ?? siteURL.absoluteString)|\(username)"
}

private func key(_ commentID: Int64) -> String {
"CommentsV2Draft.\(namespace).\(commentID)"
}

public func loadDraft(commentID: Int64) -> String? {
defaults.string(forKey: key(commentID))
}

public func saveDraft(_ text: String, commentID: Int64) {
defaults.set(text, forKey: key(commentID))
}

public func deleteDraft(commentID: Int64) {
defaults.removeObject(forKey: key(commentID))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ final class CommentsDetailRouter {
/// while the list loads and later screens read it synchronously.
private let capabilities: CommentsCapabilityResolver
private let coordinator: CommentsModerationCoordinator
private let draftStore: any CommentDraftStoring
private let titleResolver: PostTitleResolver
private let tracker: (any CommentsTracker)?
private let noticePresenter: any NoticePresenting
Expand All @@ -23,6 +24,7 @@ final class CommentsDetailRouter {
service: any CommentsServiceProtocol,
capabilities: any CommentsCapabilitiesProtocol,
coordinator: CommentsModerationCoordinator,
draftStore: any CommentDraftStoring,
titleResolver: PostTitleResolver,
tracker: (any CommentsTracker)?,
noticePresenter: any NoticePresenting,
Expand All @@ -31,6 +33,7 @@ final class CommentsDetailRouter {
self.service = service
self.capabilities = CommentsCapabilityResolver(capabilities: capabilities)
self.coordinator = coordinator
self.draftStore = draftStore
self.titleResolver = titleResolver
self.tracker = tracker
self.noticePresenter = noticePresenter
Expand All @@ -48,6 +51,7 @@ final class CommentsDetailRouter {
service: service,
capabilities: capabilities,
coordinator: coordinator,
draftStore: draftStore,
titleResolver: titleResolver,
tracker: tracker,
noticePresenter: noticePresenter
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,15 @@ enum CommentModerationAction: Hashable, Sendable {
}
}

/// The outcome of a successful `reply(to:content:approveParent:)` call.
struct ReplyOutcome: Equatable, Sendable {
let replyStatus: CommentListItem.Status
/// True when create returned comment_duplicate: the content already
/// existed server-side (an earlier send landed), so the composer words
/// its notice differently.
let alreadyPosted: Bool
}

/// Owns every comment mutation for the feature. All state changes flow through
/// here so the coordinator can enforce two ordering rules the design requires:
/// 1. One mutation in flight per comment.
Expand Down Expand Up @@ -67,6 +76,50 @@ final class CommentsModerationCoordinator {
await inFlight[id]?.value
}

/// Creates a reply to `parent` and, when `approveParent`, approves the
/// pending parent afterwards, all inside one owning task holding the
/// parent's in-flight slot. Pessimistic: nothing is emitted until the
/// create outcome is known; a failure throws back to the composer.
func reply(to parent: CommentDetail, content: String, approveParent: Bool) async throws -> ReplyOutcome {
try await holdingSlot(for: parent.id, waitingForSlot: true) { [weak self] in
guard let self else { throw CancellationError() }
let created: CommentDetail?
do {
created = try await self.service.createReply(
postID: parent.postID,
parentID: parent.id,
content: content
)
} catch {
// comment_duplicate proves this author already has this exact
// content on this post (core never accepts it twice), so an
// earlier send landed (timeout-after-commit). Continue the
// chain rather than failing, or the promised parent approval
// would be silently dropped on the retry path.
guard CommentComposerRequestError(error: error) == .duplicate else { throw error }
created = nil
}
// Reply is moderator-gated, so core auto-approves our replies; a
// duplicate (unknown landed status) assumes approved on the same
// basis. A plugin forcing moderation is corrected by the next
// list refetch (the event only marks tabs stale).
let replyStatus = created?.status ?? .approved
self.events.send(.replyCreated(parentID: parent.id, replyStatus: replyStatus))
self.tracker?.track(.repliedTo(commentID: parent.id, postID: parent.postID))
if approveParent {
// The approve runs pessimistically: its statusChanged event is
// emitted only after the request succeeds, so there is no
// optimistic emit to undo. It's possible the reply lands but the
// parent approval fails; the parent then remains Pending in list
// and detail, which is the true server state. We consider that
// an edge case and accept the risk; the user can approve
// manually.
try? await self.runModeration(.approve, on: parent)
}
return ReplyOutcome(replyStatus: replyStatus, alreadyPosted: created == nil)
}
}

/// 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.
Expand All @@ -90,10 +143,20 @@ final class CommentsModerationCoordinator {
/// slot, so the mutation outlives a popped screen while the caller can
/// still await its outcome. The slot claim happens synchronously before any
/// suspension, so no second claimant can interleave.
///
/// With `waitingForSlot`, a busy slot is awaited instead of being the
/// caller's problem. The check repeats after every wait rather than
/// awaiting once: two callers can both be suspended on the same in-flight
/// task and both resume once it completes. Looping lets only the first
/// claimant proceed; the second waits on that new claim instead.
private func holdingSlot<T: Sendable>(
for id: Int64,
waitingForSlot: Bool = false,
_ body: @escaping @MainActor () async throws -> T
) async throws -> T {
while waitingForSlot, isMutating(id: id) {
await waitForPendingMutation(id: id)
}
let chain = Task { try await body() }
let slot = Task { [weak self] in
_ = try? await chain.value
Expand Down
36 changes: 36 additions & 0 deletions Modules/Sources/WordPressComments/Services/CommentsService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ protocol CommentsServiceProtocol: Sendable {
/// Total number of replies to `id`, read from the list response's
/// `X-WP-Total` header rather than the (unused) page of results.
func numberOfReplies(for id: Int64) async throws -> Int

/// Creates a reply to `parentID` on `postID` and returns the created
/// comment's detail.
func createReply(postID: Int64, parentID: Int64, content: String) async throws -> CommentDetail
}

/// Errors raised by `CommentsService` that don't originate from wordpress-rs.
Expand Down Expand Up @@ -166,6 +170,18 @@ final class CommentsService: CommentsServiceProtocol {
)
return Int(response.headerMap.wpTotal() ?? 0)
}

func createReply(postID: Int64, parentID: Int64, content: String) async throws -> CommentDetail {
// Core returns the create response in view context unless the caller
// has moderate_comments (create_item overrides any requested
// ?context=), and wordpress-rs decodes it as such, so the result never
// carries edit-only fields (`contentRaw`, email, IP). The reply chain
// only reads its status.
let response = try await client.api.comments.create(
params: CommentCreateParams(post: postID, content: content, parent: parentID)
)
return CommentDetail(comment: response.data)
}
}

extension WpApiError {
Expand All @@ -187,6 +203,26 @@ extension WpApiError {
}
}

/// Classification of a create/update failure for the composer's messaging.
/// `duplicate` is special-cased by the reply chain: core's duplicate check
/// (same post, parent, author, content) proves the content already exists
/// server-side, so it is late confirmation of an earlier send, not a failure.
enum CommentComposerRequestError: Equatable, Sendable {
case duplicate
case commentsClosed
case invalidContent
case other

init(error: Error) {
switch (error as? WpApiError)?.wpErrorCode {
case .CommentDuplicate: self = .duplicate
case .CommentClosed: self = .commentsClosed
case .CommentContentInvalid: self = .invalidContent
default: self = .other
}
}
}

extension CommentsListFilter {
static let pageSize: UInt32 = 20

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ public enum CommentsTrackedEvent: Equatable, Sendable {
case spammed(commentID: Int64, postID: Int64)
case trashed(commentID: Int64, postID: Int64)
// Permanent delete: legacy has no analytics event; deliberately untracked.
/// A reply was successfully created, matching legacy's reply-sent event.
case repliedTo(commentID: Int64, postID: Int64)
}

public protocol CommentsTracker: Sendable {
Expand Down
84 changes: 84 additions & 0 deletions Modules/Sources/WordPressComments/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -242,4 +242,88 @@ enum Strings {
value: "Couldn't load this comment",
comment: "Error state title when the comment detail fails to load"
)

static let composerReplyTitle = NSLocalizedString(
"commentComposer.title.reply",
value: "Reply",
comment: "Title of the compose screen for replying to a comment"
)

static let composerPlaceholder = NSLocalizedString(
"commentComposer.placeholder",
value: "Leave a reply…",
comment: "Placeholder text in the composer text input field"
)

static let composerSend = NSLocalizedString(
"commentComposer.action.send",
value: "Send",
comment: "Button label to send a new reply"
)

static let composerCancel = NSLocalizedString(
"commentComposer.action.cancel",
value: "Cancel",
comment: "Button label to cancel composing or editing a comment"
)

static let composerApproveNote = NSLocalizedString(
"commentComposer.approveNote",
value: "Sending will also approve this comment.",
comment: "Note explaining that sending a reply will also approve the pending comment"
)

static let composerSaveDraft = NSLocalizedString(
"commentComposer.action.saveDraft",
value: "Save Draft",
comment: "Button label to save the current text as a draft"
)

static let composerDeleteDraft = NSLocalizedString(
"commentComposer.action.deleteDraft",
value: "Delete Draft",
comment: "Button label to delete a saved draft"
)

static let composerKeepEditing = NSLocalizedString(
"commentComposer.action.keepEditing",
value: "Keep Editing",
comment: "Button label to continue editing instead of discarding changes"
)

static let composerErrorClosed = NSLocalizedString(
"commentComposer.error.closed",
value: "Comments are closed for this post.",
comment: "Error message shown when comments are disabled for the post"
)

static let composerErrorReplyFailed = NSLocalizedString(
"commentComposer.error.replyFailed",
value: "Failed to send reply.",
comment: "Error message shown when sending a reply fails"
)

static let noticeReplySent = NSLocalizedString(
"commentComposer.notice.replySent",
value: "Reply sent.",
comment: "Notice shown after a reply is successfully sent"
)

static let noticeReplyPending = NSLocalizedString(
"commentComposer.notice.replyPending",
value: "Reply submitted for moderation.",
comment: "Notice shown when a reply is submitted and awaiting moderation"
)

static let noticeReplyAlreadyPosted = NSLocalizedString(
"commentComposer.notice.replyAlreadyPosted",
value: "This reply has already been posted.",
comment: "Notice shown when attempting to post a reply that was already submitted"
)

static let detailReply = NSLocalizedString(
"commentDetail.action.reply",
value: "Reply",
comment: "Button label to reply to a comment on the detail screen"
)
}
Loading