Skip to content
Open
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 @@ -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:)`.
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:)` 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 All @@ -51,20 +60,70 @@ final class CommentsModerationCoordinator {

/// The in-flight mutation task per comment ID. Presence means "mutating";
/// `waitForPendingMutation` awaits the stored task's value.
private var inFlight: [Int64: Task<Void, Never>] = [:]
private var inFlightMutations: [Int64: Task<Void, Never>] = [:]

init(service: any CommentsServiceProtocol, tracker: (any CommentsTracker)? = nil) {
self.service = service
self.tracker = tracker
}

func isMutating(id: Int64) -> Bool {
inFlight[id] != nil
inFlightMutations[id] != nil
}

/// Awaits any in-flight mutation for the comment (re-entry race guard).
func waitForPendingMutation(id: Int64) async {
await inFlight[id]?.value
await inFlightMutations[id]?.value
}

/// Creates a reply to `parent` and, for a pending parent, approves it
/// 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) 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 (error as? WpApiError)?.wpErrorCode == .CommentDuplicate else { throw error }
created = nil
}
self.tracker?.track(.repliedTo(commentID: parent.id, postID: parent.postID))
// The composer is only reachable by moderators, so a pending
// parent always means "approve on send" (no separate consent
// step). 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.
if parent.status == .pending {
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
// basis. A plugin forcing moderation is corrected by the next
// list refetch (the event only marks tabs stale).
//
// Emitted after the approve so its statusChanged lands first: a
// loaded list tab then updates the parent row in place with
// nothing in flight, instead of invalidating the page-one fetch
// that replyCreated's stale mark would already have started.
let replyStatus = created?.status ?? .approved
self.events.send(.replyCreated(parentID: parent.id, replyStatus: replyStatus))
return ReplyOutcome(replyStatus: replyStatus, alreadyPosted: created == nil)
}
}

/// Broadcasts a status change the detail screen observed on load (its seed
Expand All @@ -90,16 +149,26 @@ 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
self?.inFlight[id] = nil
self?.inFlightMutations[id] = nil
}
inFlight[id] = slot
inFlightMutations[id] = slot
// Await the slot first so `isMutating` is false by the time the caller
// resumes; the chain has already settled when the slot clears.
await slot.value
Expand Down
16 changes: 16 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 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