Skip to content
Merged
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
@@ -1,18 +1,15 @@
import WordPressCore

protocol CommentsCapabilitiesProtocol: Sendable {
/// Whether the current user can moderate comments. Resolved once per
/// detail screen; false also when the lookup fails, which degrades the
/// screen to read-only (view-context fetch, no author email or IP).
func canModerateComments() async -> Bool
/// Whether the current user can moderate comments. Throws when the lookup
/// fails, so a caller can decide whether to cache the answer.
func canModerateComments() async throws -> Bool
}

struct CommentsCapabilities: CommentsCapabilitiesProtocol {
let client: WordPressClient

func canModerateComments() async -> Bool {
// A failed current-user request must not block a readable
// view-context detail screen.
(try? await client.currentUserCan(.moderateComments)) ?? false
func canModerateComments() async throws -> Bool {
try await client.currentUserCan(.moderateComments)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/// Resolves the moderation capability once per comments session and caches
/// the answer, so every detail screen can read it synchronously at init (its
/// toolbar and nav bar items then render on the first frame and take part in
/// the push transition). Concurrent callers share one lookup. A failed lookup
/// is not cached: the value stays nil and the next call retries.
@MainActor
final class CommentsCapabilityResolver {
/// The capability once a lookup succeeded; nil while unknown.
private(set) var canModerate: Bool?

private let capabilities: any CommentsCapabilitiesProtocol
private var lookup: Task<Bool?, Never>?

init(capabilities: any CommentsCapabilitiesProtocol) {
self.capabilities = capabilities
}

/// Starts a lookup unless the answer is known or one is already running.
func prefetch() {
guard canModerate == nil else { return }
_ = lookupIfNeeded()
}

/// The cached answer, else the result of the running or a fresh lookup.
/// Nil when that lookup fails.
func resolve() async -> Bool? {
if let canModerate { return canModerate }
return await lookupIfNeeded().value
}

private func lookupIfNeeded() -> Task<Bool?, Never> {
if let lookup { return lookup }
let task = Task { [weak self, capabilities] () -> Bool? in
let result = try? await capabilities.canModerateComments()
self?.canModerate = result
self?.lookup = nil
return result
}
lookup = task
return task
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ final class CommentsDetailRouter {
weak var host: UIViewController?

private let service: any CommentsServiceProtocol
private let capabilities: any CommentsCapabilitiesProtocol
/// Shared by every detail view model, so the capability resolves once
/// while the list loads and later screens read it synchronously.
private let capabilities: CommentsCapabilityResolver
private let coordinator: CommentsModerationCoordinator
private let titleResolver: PostTitleResolver
private let tracker: (any CommentsTracker)?
Expand All @@ -27,17 +29,19 @@ final class CommentsDetailRouter {
makeContentRenderer: @escaping @MainActor () -> any CommentContentRendering
) {
self.service = service
self.capabilities = capabilities
self.capabilities = CommentsCapabilityResolver(capabilities: capabilities)
self.coordinator = coordinator
self.titleResolver = titleResolver
self.tracker = tracker
self.noticePresenter = noticePresenter
self.makeContentRenderer = makeContentRenderer
self.capabilities.prefetch()
}

/// Builds the detail view model and screen for `id` (seeded from the list
/// row when available) and pushes it onto the shared navigation stack.
func open(id: Int64, seed: CommentListItem?) {
capabilities.prefetch()
let viewModel = CommentDetailViewModel(
commentID: id,
seed: seed,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import Combine
import Foundation
import WordPressShared

/// Drives the comment detail and moderation screen. Resolves the moderation
/// capability once, runs the authoritative detail/parent/reply-count fetches,
/// Drives the comment detail and moderation screen. Reads the moderation
/// capability from the shared resolver when already known (so the toolbar and
/// nav bar items render on the first frame), else awaits it once; runs the
/// authoritative detail/parent/reply-count fetches,
/// and enforces the ordering rules the design requires: no action lands before
/// the authoritative fetch, the toolbar is disabled while a mutation is in
/// flight, and an open screen still hears late status changes by subscribing to
Expand Down Expand Up @@ -109,7 +111,7 @@ final class CommentDetailViewModel: ObservableObject {

private let seed: CommentListItem?
private let service: any CommentsServiceProtocol
private let capabilities: any CommentsCapabilitiesProtocol
private let capabilities: CommentsCapabilityResolver
private let coordinator: CommentsModerationCoordinator
private let titleResolver: PostTitleResolver
/// Fires `.detailViewed` once per screen, on the first successful fetch.
Expand All @@ -125,7 +127,7 @@ final class CommentDetailViewModel: ObservableObject {
commentID: Int64,
seed: CommentListItem?,
service: any CommentsServiceProtocol,
capabilities: any CommentsCapabilitiesProtocol,
capabilities: CommentsCapabilityResolver,
coordinator: CommentsModerationCoordinator,
titleResolver: PostTitleResolver,
tracker: (any CommentsTracker)? = nil,
Expand All @@ -135,6 +137,7 @@ final class CommentDetailViewModel: ObservableObject {
self.seed = seed
self.service = service
self.capabilities = capabilities
canModerate = capabilities.canModerate
self.coordinator = coordinator
self.titleResolver = titleResolver
self.tracker = tracker
Expand Down Expand Up @@ -183,7 +186,9 @@ final class CommentDetailViewModel: ObservableObject {
isLoading = true
defer { isLoading = false }
if canModerate == nil {
canModerate = await capabilities.canModerateComments()
// A failed lookup degrades this screen to read-only (view-context
// fetch, no author email or IP) without blocking it.
canModerate = await capabilities.resolve() ?? false
}
await runFetch()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ private final class StubContentRenderer: NSObject, CommentContentRendering {
commentID: 1,
seed: nil,
service: service,
capabilities: PreviewCapabilities(),
capabilities: CommentsCapabilityResolver(capabilities: PreviewCapabilities()),
coordinator: coordinator,
titleResolver: titleResolver
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ extension CommentModerationAction {
switch self {
case .approve: "checkmark"
case .unapprove: "clock"
case .spam: "exclamationmark.bubble"
case .spam: "exclamationmark.octagon"
case .trash: "trash"
case .restore: "arrow.uturn.backward"
case .delete: "trash"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ final class PreviewCommentsService: CommentsServiceProtocol {
}

struct PreviewCapabilities: CommentsCapabilitiesProtocol {
func canModerateComments() async -> Bool { true }
func canModerateComments() async throws -> Bool { true }
}
#endif
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Testing
@testable import WordPressComments

@MainActor
struct CommentsCapabilityResolverTests {
@Test func resolveCachesASuccessfulLookup() async {
let capabilities = FakeCommentsCapabilities()
capabilities.canModerate = false
let resolver = CommentsCapabilityResolver(capabilities: capabilities)
#expect(resolver.canModerate == nil)

#expect(await resolver.resolve() == false)
#expect(await resolver.resolve() == false)

#expect(resolver.canModerate == false)
#expect(capabilities.invocations == 1)
}

@Test func concurrentResolvesShareOneLookup() async {
let capabilities = FakeCommentsCapabilities()
let resolver = CommentsCapabilityResolver(capabilities: capabilities)

async let first = resolver.resolve()
async let second = resolver.resolve()
let results = await [first, second]

#expect(results == [true, true])
#expect(capabilities.invocations == 1)
}

@Test func prefetchStartsOneLookupThatResolveAwaits() async {
let capabilities = FakeCommentsCapabilities()
let resolver = CommentsCapabilityResolver(capabilities: capabilities)

resolver.prefetch()
resolver.prefetch()
#expect(await resolver.resolve() == true)

#expect(capabilities.invocations == 1)
// Known: a later prefetch is a no-op.
resolver.prefetch()
#expect(capabilities.invocations == 1)
}

@Test func failedLookupIsNotCachedAndRetries() async {
let capabilities = FakeCommentsCapabilities()
capabilities.error = FakeServiceError()
let resolver = CommentsCapabilityResolver(capabilities: capabilities)

#expect(await resolver.resolve() == nil)
#expect(resolver.canModerate == nil)

capabilities.error = nil
#expect(await resolver.resolve() == true)
#expect(capabilities.invocations == 2)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,41 @@ struct CommentsDetailRouterTests {
@Test func openPushesDetailOntoHostNavigationStack() {
let host = UIViewController()
let navigation = UINavigationController(rootViewController: host)
let router = CommentsDetailRouter(
let router = makeRouter(capabilities: FakeCommentsCapabilities())
router.host = host

router.open(id: 1, seed: nil)

#expect(navigation.viewControllers.count == 2)
}

@Test func resolvesCapabilityOnceAndRetriesAfterFailure() async {
let capabilities = FakeCommentsCapabilities()
capabilities.error = FakeServiceError()
let router = makeRouter(capabilities: capabilities)
router.host = UINavigationController(rootViewController: UIViewController()).viewControllers[0]

// Resolved while the list loads; the failure is not cached.
await waitUntil { capabilities.invocations == 1 }
capabilities.error = nil
router.open(id: 1, seed: nil)
await waitUntil { capabilities.invocations == 2 }

// Once known, later opens reuse the answer.
router.open(id: 2, seed: nil)
for _ in 0..<10 { await Task.yield() }
#expect(capabilities.invocations == 2)
}

private func makeRouter(capabilities: FakeCommentsCapabilities) -> CommentsDetailRouter {
CommentsDetailRouter(
service: FakeCommentsService(),
capabilities: FakeCommentsCapabilities(),
capabilities: capabilities,
coordinator: CommentsModerationCoordinator(service: FakeCommentsService()),
titleResolver: PostTitleResolver(fetcher: { _ in .init(titles: [:]) }),
tracker: nil,
noticePresenter: FakeNoticePresenter(),
makeContentRenderer: { FakeContentRenderer() }
)
router.host = host

router.open(id: 1, seed: nil)

#expect(navigation.viewControllers.count == 2)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ func makeVM(
seed: CommentListItem? = nil,
service: any CommentsServiceProtocol,
capabilities: FakeCommentsCapabilities = FakeCommentsCapabilities(),
resolver: CommentsCapabilityResolver? = nil,
coordinator: CommentsModerationCoordinator? = nil,
tracker: (any CommentsTracker)? = nil,
noticePresenter: (any NoticePresenting)? = nil
Expand All @@ -34,14 +35,25 @@ func makeVM(
commentID: commentID,
seed: seed,
service: service,
capabilities: capabilities,
capabilities: resolver ?? CommentsCapabilityResolver(capabilities: capabilities),
coordinator: coordinator ?? CommentsModerationCoordinator(service: FakeCommentsService()),
titleResolver: makeResolver(),
tracker: tracker,
noticePresenter: noticePresenter
)
}

/// A resolver whose lookup has already landed with `canModerate`, standing in
/// for the router's prefetch finishing before a detail screen opens.
@MainActor
func makeResolvedCapabilities(canModerate: Bool) async -> CommentsCapabilityResolver {
let capabilities = FakeCommentsCapabilities()
capabilities.canModerate = canModerate
let resolver = CommentsCapabilityResolver(capabilities: capabilities)
_ = await resolver.resolve()
return resolver
}

/// A view model whose authoritative fetch has landed with edit context at
/// `status`, so the toolbar is enabled and actions run through `coordinator`.
@MainActor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
@MainActor
final class FakeCommentsCapabilities: CommentsCapabilitiesProtocol {
var canModerate = true
var error: Error?
private(set) var invocations = 0

func canModerateComments() async -> Bool {
canModerate
func canModerateComments() async throws -> Bool {
invocations += 1
if let error { throw error }
return canModerate
}
}