From fc85a6f2ff6344d5d3e007e457500ff7885ea8df Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 13 Aug 2026 10:12:43 +1200 Subject: [PATCH 1/7] Add WordPressComments module with comments list filters --- Modules/Package.swift | 23 ++++- .../Models/CommentsListFilter.swift | 53 ++++++++++ .../WordPressComments/Strings/Strings.swift | 99 +++++++++++++++++++ .../CommentsListFilterTests.swift | 22 +++++ .../WordPressUnitTests.xctestplan | 7 ++ 5 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/WordPressComments/Models/CommentsListFilter.swift create mode 100644 Modules/Sources/WordPressComments/Strings/Strings.swift create mode 100644 Modules/Tests/WordPressCommentsTests/CommentsListFilterTests.swift diff --git a/Modules/Package.swift b/Modules/Package.swift index 4b1217724507..2e108247974c 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -29,7 +29,8 @@ let package = Package( .library(name: "WordPressCoreProtocols", targets: ["WordPressCoreProtocols"]), .library(name: "WordPressKit", targets: ["WordPressKit"]), .library(name: "WordPressData", targets: ["WordPressData"]), - .library(name: "WordPressMediaLibrary", targets: ["WordPressMediaLibrary"]) + .library(name: "WordPressMediaLibrary", targets: ["WordPressMediaLibrary"]), + .library(name: "WordPressComments", targets: ["WordPressComments"]) ], dependencies: [ .package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"), @@ -163,6 +164,25 @@ let package = Package( .product(name: "WordPressAPI", package: "wordpress-rs") ] ), + .target( + name: "WordPressComments", + dependencies: [ + "AsyncImageKit", + "DesignSystem", + "WordPressShared", + "WordPressUI", + "WordPressCore", + .product(name: "WordPressAPI", package: "wordpress-rs"), + .product(name: "Logging", package: "swift-log") + ] + ), + .testTarget( + name: "WordPressCommentsTests", + dependencies: [ + .target(name: "WordPressComments"), + .product(name: "WordPressAPI", package: "wordpress-rs") + ] + ), .target( name: "ShareExtensionCore", dependencies: [ @@ -513,6 +533,7 @@ enum XcodeSupport { "WordPressSharedObjCUI", "WordPressLegacy", "WordPressMediaLibrary", + "WordPressComments", "WordPressReader", "WordPressUI", "WordPressCore", diff --git a/Modules/Sources/WordPressComments/Models/CommentsListFilter.swift b/Modules/Sources/WordPressComments/Models/CommentsListFilter.swift new file mode 100644 index 000000000000..3655f0ec9dce --- /dev/null +++ b/Modules/Sources/WordPressComments/Models/CommentsListFilter.swift @@ -0,0 +1,53 @@ +import Foundation +import WordPressAPI +import WordPressUI + +enum CommentsListFilter: Int, CaseIterable, AdaptiveTabBarItem, Sendable { + case all + case pending + case approved + case spam + case trash + + var id: Self { self } + + var localizedTitle: String { + switch self { + case .all: Strings.tabAll + case .pending: Strings.tabPending + case .approved: Strings.tabApproved + case .spam: Strings.tabSpam + case .trash: Strings.tabTrash + } + } + + /// The `status` query param for `/wp/v2/comments`. + /// + /// `.custom` is used where wordpress-rs's `CommentStatus` cannot express the + /// query vocabulary: `WP_Comment_Query` only recognizes the literal values + /// `approve` and `all`, while the enum models the response spelling + /// (`approved`) and has no `all` case. `all` means pending + approved; + /// spam and trash are excluded by core, matching wp-admin's All tab. + /// Same workaround as Android (`CommentsRsListTab.kt`). + /// TODO: Replace both `.custom` values with typed cases once wordpress-rs + /// separates query values from response values (tracked outside this app). + var queryStatus: CommentStatus { + switch self { + case .all: .custom("all") + case .pending: .hold + case .approved: .custom("approve") + case .spam: .spam + case .trash: .trash + } + } + + var emptyStateMessage: String { + switch self { + case .all: Strings.emptyAll + case .pending: Strings.emptyPending + case .approved: Strings.emptyApproved + case .spam: Strings.emptySpam + case .trash: Strings.emptyTrash + } + } +} diff --git a/Modules/Sources/WordPressComments/Strings/Strings.swift b/Modules/Sources/WordPressComments/Strings/Strings.swift new file mode 100644 index 000000000000..abb3cde5138a --- /dev/null +++ b/Modules/Sources/WordPressComments/Strings/Strings.swift @@ -0,0 +1,99 @@ +import Foundation + +enum Strings { + static let title = NSLocalizedString( + "commentsList.screen.title", + value: "Comments", + comment: "Title for the comments list screen" + ) + + static let tabAll = NSLocalizedString( + "commentsList.tab.all", + value: "All", + comment: "Tab title showing pending and approved comments" + ) + + static let tabPending = NSLocalizedString( + "commentsList.tab.pending", + value: "Pending", + comment: "Tab title showing comments awaiting moderation" + ) + + static let tabApproved = NSLocalizedString( + "commentsList.tab.approved", + value: "Approved", + comment: "Tab title showing approved comments" + ) + + static let tabSpam = NSLocalizedString( + "commentsList.tab.spam", + value: "Spam", + comment: "Tab title showing comments marked as spam" + ) + + static let tabTrash = NSLocalizedString( + "commentsList.tab.trash", + value: "Trash", + comment: "Tab title showing trashed comments" + ) + + static let emptyAll = NSLocalizedString( + "commentsList.empty.all", + value: "No comments yet", + comment: "Empty state message on the All tab" + ) + + static let emptyPending = NSLocalizedString( + "commentsList.empty.pending", + value: "No pending comments", + comment: "Empty state message on the Pending tab" + ) + + static let emptyApproved = NSLocalizedString( + "commentsList.empty.approved", + value: "No approved comments", + comment: "Empty state message on the Approved tab" + ) + + static let emptySpam = NSLocalizedString( + "commentsList.empty.spam", + value: "No spam comments", + comment: "Empty state message on the Spam tab" + ) + + static let emptyTrash = NSLocalizedString( + "commentsList.empty.trash", + value: "No trashed comments", + comment: "Empty state message on the Trash tab" + ) + + static let errorTitle = NSLocalizedString( + "commentsList.error.title", + value: "Couldn't load comments", + comment: "Error state title when the comments list fails to load" + ) + + static let errorRetry = NSLocalizedString( + "commentsList.error.retry", + value: "Try again", + comment: "Button label to retry loading comments after an error" + ) + + static let anonymousAuthor = NSLocalizedString( + "commentsList.row.anonymousAuthor", + value: "Anonymous", + comment: "Author name shown when a comment has no author name" + ) + + static let authorOnPost = NSLocalizedString( + "commentsList.row.authorOnPost", + value: "%1$@ on %2$@", + comment: "Comment row headline. %1$@ is the comment author, %2$@ is the post title." + ) + + static let pendingAccessibilityValue = NSLocalizedString( + "commentsList.row.pendingAccessibilityValue", + value: "Pending", + comment: "Accessibility value announced for a comment row that is awaiting moderation" + ) +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentsListFilterTests.swift b/Modules/Tests/WordPressCommentsTests/CommentsListFilterTests.swift new file mode 100644 index 000000000000..4719a3d773fd --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentsListFilterTests.swift @@ -0,0 +1,22 @@ +import Testing +import WordPressAPI +@testable import WordPressComments + +struct CommentsListFilterTests { + // The serialized query value is what goes on the wire as `status=...`. + // WP_Comment_Query only recognizes the literal values `approve` and `all`; + // `approved` (the response spelling) silently returns an empty set. + @Test func queryStatusSerializesToWireValues() { + #expect(CommentsListFilter.all.queryStatus.description == "all") + #expect(CommentsListFilter.pending.queryStatus.description == "hold") + #expect(CommentsListFilter.approved.queryStatus.description == "approve") + #expect(CommentsListFilter.spam.queryStatus.description == "spam") + #expect(CommentsListFilter.trash.queryStatus.description == "trash") + } + + @Test func tabOrderMatchesDesign() { + #expect( + CommentsListFilter.allCases == [.all, .pending, .approved, .spam, .trash] + ) + } +} diff --git a/Tests/KeystoneTests/WordPressUnitTests.xctestplan b/Tests/KeystoneTests/WordPressUnitTests.xctestplan index 68e2e6e2e24f..1324edbc6467 100644 --- a/Tests/KeystoneTests/WordPressUnitTests.xctestplan +++ b/Tests/KeystoneTests/WordPressUnitTests.xctestplan @@ -125,6 +125,13 @@ "name" : "WordPressMediaLibraryTests" } }, + { + "target" : { + "containerPath" : "container:..\/Modules", + "identifier" : "WordPressCommentsTests", + "name" : "WordPressCommentsTests" + } + }, { "target" : { "containerPath" : "container:..\/Modules", From 8d707482319eaacd8b4da9558e6d6cced77eb989 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 13 Aug 2026 10:12:43 +1200 Subject: [PATCH 2/7] Add comment list item domain model --- .../Models/CommentListItem.swift | 68 +++++++++++++++++++ .../CommentListItemTests.swift | 39 +++++++++++ .../Support/CommentBuilders.swift | 40 +++++++++++ 3 files changed, 147 insertions(+) create mode 100644 Modules/Sources/WordPressComments/Models/CommentListItem.swift create mode 100644 Modules/Tests/WordPressCommentsTests/CommentListItemTests.swift create mode 100644 Modules/Tests/WordPressCommentsTests/Support/CommentBuilders.swift diff --git a/Modules/Sources/WordPressComments/Models/CommentListItem.swift b/Modules/Sources/WordPressComments/Models/CommentListItem.swift new file mode 100644 index 000000000000..643ab6dea481 --- /dev/null +++ b/Modules/Sources/WordPressComments/Models/CommentListItem.swift @@ -0,0 +1,68 @@ +import Foundation +import WordPressAPI +import WordPressShared + +/// Value type consumed by the list UI, mapped once from the wordpress-rs +/// response type so views and tests never depend on uniffi types. Mapping is +/// also where wordpress-rs's empty-string-instead-of-nil quirk is normalized. +struct CommentListItem: Identifiable, Equatable, Sendable { + enum Status: Equatable, Sendable { + case pending + case approved + case spam + case trash + case other + } + + let id: Int64 + let authorName: String + let avatarURL: URL? + let postID: Int64 + let snippet: String + let date: Date + let status: Status + + init( + id: Int64, + authorName: String, + avatarURL: URL?, + postID: Int64, + snippet: String, + date: Date, + status: Status + ) { + self.id = id + self.authorName = authorName + self.avatarURL = avatarURL + self.postID = postID + self.snippet = snippet + self.date = date + self.status = status + } + + init(comment: CommentWithViewContext) { + id = comment.id + authorName = comment.authorName.isEmpty ? Strings.anonymousAuthor : comment.authorName + // The avatar subscript yields a double optional (missing key vs. a + // stored nil); flatten it before building the URL. + avatarURL = comment.authorAvatarUrls[.size96].flatMap { $0 }.flatMap(URL.init(string:)) + postID = comment.post + snippet = comment.content.rendered + .makePlainText() + .replacingOccurrences(of: "\n", with: " ") + date = comment.dateGmt + status = Status(comment.status) + } +} + +private extension CommentListItem.Status { + init(_ status: CommentStatus) { + switch status { + case .hold: self = .pending + case .approved: self = .approved + case .spam: self = .spam + case .trash: self = .trash + case .custom: self = .other + } + } +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentListItemTests.swift b/Modules/Tests/WordPressCommentsTests/CommentListItemTests.swift new file mode 100644 index 000000000000..b541712ba3f3 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentListItemTests.swift @@ -0,0 +1,39 @@ +import Foundation +import Testing +import WordPressAPI +@testable import WordPressComments + +struct CommentListItemTests { + @Test func mapsBasicFields() { + let item = CommentListItem(comment: makeComment(id: 7, authorName: "Priya", post: 42, status: .hold)) + #expect(item.id == 7) + #expect(item.authorName == "Priya") + #expect(item.postID == 42) + #expect(item.status == .pending) + #expect(item.avatarURL == URL(string: "https://example.com/avatar.png")) + } + + @Test func stripsHTMLIntoSingleLineSnippet() { + let item = CommentListItem( + comment: makeComment(content: "

Line one

\n

Line & two

") + ) + #expect(item.snippet == "Line one Line & two") + } + + @Test func emptyAuthorNameFallsBackToAnonymous() { + let item = CommentListItem(comment: makeComment(authorName: "")) + #expect(!item.authorName.isEmpty) + } + + @Test func missingAvatarMapsToNil() { + let item = CommentListItem(comment: makeComment(avatar: nil)) + #expect(item.avatarURL == nil) + } + + @Test func statusMapping() { + #expect(CommentListItem(comment: makeComment(status: .approved)).status == .approved) + #expect(CommentListItem(comment: makeComment(status: .spam)).status == .spam) + #expect(CommentListItem(comment: makeComment(status: .trash)).status == .trash) + #expect(CommentListItem(comment: makeComment(status: .custom("weird"))).status == .other) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/Support/CommentBuilders.swift b/Modules/Tests/WordPressCommentsTests/Support/CommentBuilders.swift new file mode 100644 index 000000000000..2388ce76c390 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/Support/CommentBuilders.swift @@ -0,0 +1,40 @@ +import Foundation +import WordPressAPI +import WordPressAPIInternal +@testable import WordPressComments + +func makeComment( + id: Int64 = 1, + authorName: String = "Author", + avatar: String? = "https://example.com/avatar.png", + content: String = "

Hello world

", + post: Int64 = 10, + status: CommentStatus = .approved, + date: Date = Date(timeIntervalSince1970: 1_700_000_000) +) -> CommentWithViewContext { + CommentWithViewContext( + id: id, + author: 1, + authorName: authorName, + authorUrl: "", + content: CommentContentWithViewContext(rendered: content), + date: "2023-11-14T22:13:20", + dateGmt: date, + link: "https://example.com/?p=\(post)#comment-\(id)", + parent: 0, + post: post, + status: status, + commentType: .comment, + authorAvatarUrls: avatar.map { [.size96: $0] } ?? [:], + additionalFields: WpAdditionalFields() + ) +} + +func makeItem( + id: Int64 = 1, + authorName: String = "Author", + post: Int64 = 10, + status: CommentStatus = .approved +) -> CommentListItem { + CommentListItem(comment: makeComment(id: id, authorName: authorName, post: post, status: status)) +} From e2b70c27b25e87f16e946304aa74e4c4ccfd0fad Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 13 Aug 2026 10:12:43 +1200 Subject: [PATCH 3/7] Add comments service over the core REST comments endpoint --- .../Services/CommentsService.swift | 58 +++++++++++++++++++ .../CommentsServiceTests.swift | 19 ++++++ 2 files changed, 77 insertions(+) create mode 100644 Modules/Sources/WordPressComments/Services/CommentsService.swift create mode 100644 Modules/Tests/WordPressCommentsTests/CommentsServiceTests.swift diff --git a/Modules/Sources/WordPressComments/Services/CommentsService.swift b/Modules/Sources/WordPressComments/Services/CommentsService.swift new file mode 100644 index 000000000000..0f8049df62ad --- /dev/null +++ b/Modules/Sources/WordPressComments/Services/CommentsService.swift @@ -0,0 +1,58 @@ +import Foundation +import WordPressAPI +import WordPressCore + +/// Opaque next-page cursor. Wraps the wordpress-rs `nextPageParams` (parsed +/// from the response's `Link: rel="next"` header) so uniffi pagination types +/// never leak past the service. +/// +/// The underlying pagination is offset-based (`page=N`): when the result set +/// changes between requests the window shifts, so a page can re-serve an item +/// (deduplicated by the view model) or skip one (inherent to offset paging; +/// pull-to-refresh is the recovery). +struct CommentsPageToken: Sendable { + let params: CommentListParams +} + +struct CommentsPage: Sendable { + let items: [CommentListItem] + let nextPage: CommentsPageToken? +} + +protocol CommentsServiceProtocol: Sendable { + /// Fetches one page. Pass `nil` for the first page; pass the previous + /// page's token for the next one. A `nil` token in the result means the + /// end of the list. Post titles are not part of this call; they resolve + /// asynchronously through `PostTitleResolver`. + func listComments(filter: CommentsListFilter, nextPage: CommentsPageToken?) async throws -> CommentsPage +} + +final class CommentsService: CommentsServiceProtocol { + private let client: WordPressClient + + init(client: WordPressClient) { + self.client = client + } + + func listComments(filter: CommentsListFilter, nextPage: CommentsPageToken?) async throws -> CommentsPage { + let params = nextPage?.params ?? filter.firstPageParams + let response = try await client.api.comments.listWithViewContext(params: params) + return CommentsPage( + items: response.data.map(CommentListItem.init), + nextPage: response.nextPageParams.map(CommentsPageToken.init) + ) + } +} + +extension CommentsListFilter { + static let pageSize: UInt32 = 20 + + var firstPageParams: CommentListParams { + CommentListParams( + perPage: Self.pageSize, + order: .desc, + orderby: .dateGmt, + status: queryStatus + ) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentsServiceTests.swift b/Modules/Tests/WordPressCommentsTests/CommentsServiceTests.swift new file mode 100644 index 000000000000..aff009a87553 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentsServiceTests.swift @@ -0,0 +1,19 @@ +import Testing +import WordPressAPI +@testable import WordPressComments + +struct CommentsServiceTests { + @Test func firstPageParamsUseDesignDefaults() { + let params = CommentsListFilter.approved.firstPageParams + #expect(params.perPage == 20) + #expect(params.order == .desc) + #expect(params.orderby == .dateGmt) + #expect(params.status?.description == "approve") + } + + @Test func firstPageParamsCarryEachFilterStatus() { + for filter in CommentsListFilter.allCases { + #expect(filter.firstPageParams.status?.description == filter.queryStatus.description) + } + } +} From c2c5726c01db0e05d4812ff588469e362a159987 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 13 Aug 2026 10:12:43 +1200 Subject: [PATCH 4/7] Add comments list view model with paging, dedupe, and state coverage --- .../ViewModels/CommentsListViewModel.swift | 134 ++++++++++++++++++ ...ommentsListViewModelConcurrencyTests.swift | 36 +++++ .../CommentsListViewModelStateTests.swift | 130 +++++++++++++++++ .../CommentsListViewModelTests.swift | 95 +++++++++++++ .../Support/BlockingCommentsService.swift | 20 +++ .../Support/FakeCommentsService.swift | 26 ++++ 6 files changed, 441 insertions(+) create mode 100644 Modules/Sources/WordPressComments/ViewModels/CommentsListViewModel.swift create mode 100644 Modules/Tests/WordPressCommentsTests/CommentsListViewModelConcurrencyTests.swift create mode 100644 Modules/Tests/WordPressCommentsTests/CommentsListViewModelStateTests.swift create mode 100644 Modules/Tests/WordPressCommentsTests/CommentsListViewModelTests.swift create mode 100644 Modules/Tests/WordPressCommentsTests/Support/BlockingCommentsService.swift create mode 100644 Modules/Tests/WordPressCommentsTests/Support/FakeCommentsService.swift diff --git a/Modules/Sources/WordPressComments/ViewModels/CommentsListViewModel.swift b/Modules/Sources/WordPressComments/ViewModels/CommentsListViewModel.swift new file mode 100644 index 000000000000..7cf50405b10b --- /dev/null +++ b/Modules/Sources/WordPressComments/ViewModels/CommentsListViewModel.swift @@ -0,0 +1,134 @@ +import Foundation + +/// One instance per filter tab, alive for the screen's lifetime, so switching +/// tabs never refetches or loses scroll content (the legacy screen's failure +/// mode). Pages accumulate in memory; there is no persistence by design. +@MainActor +final class CommentsListViewModel: ObservableObject { + let filter: CommentsListFilter + + @Published private(set) var items: [CommentListItem] = [] + // TODO: Consider folding the first-page state (isShowingSeededPlaceholder, + // isLoadingFirstPage, firstPageFailed, hasLoaded, showsEmptyState) into a + // single ScreenState enum so illegal combinations become unrepresentable. + // The load-more state (isLoadingMore, loadMoreFailed) is an orthogonal axis + // and would stay separate. Deferred as a non-behavioral cleanup. + @Published private(set) var isShowingSeededPlaceholder = false + @Published private(set) var isLoadingFirstPage = false + @Published private(set) var firstPageFailed = false + @Published private(set) var isLoadingMore = false + @Published private(set) var loadMoreFailed = false + + private(set) var hasLoaded = false + + private let service: any CommentsServiceProtocol + private let seedItems: (@MainActor () -> [CommentListItem])? + private let onItemsAppended: (@MainActor ([CommentListItem]) -> Void)? + private var nextPage: CommentsPageToken? + private var seenIDs: Set = [] + /// Bumped whenever the list is reset to page one (first load or refresh). + /// A `loadMore` started before a reset carries the old value and discards + /// its result on completion, so a slow page fetched from an obsolete cursor + /// can never append onto or overwrite the refreshed list. + private var generation = 0 + + var canLoadMore: Bool { + hasLoaded && nextPage != nil && !isShowingSeededPlaceholder && !isLoadingMore && !loadMoreFailed + } + + var showsEmptyState: Bool { + hasLoaded && items.isEmpty && !firstPageFailed + } + + init( + filter: CommentsListFilter, + service: any CommentsServiceProtocol, + seedItems: (@MainActor () -> [CommentListItem])? = nil, + onItemsAppended: (@MainActor ([CommentListItem]) -> Void)? = nil + ) { + self.filter = filter + self.service = service + self.seedItems = seedItems + self.onItemsAppended = onItemsAppended + } + + func onAppear() async { + guard !hasLoaded, !isLoadingFirstPage else { return } + if let seeded = seedItems?(), !seeded.isEmpty { + items = seeded + isShowingSeededPlaceholder = true + } + await loadFirstPage() + } + + func retryFirstPage() async { + guard !isLoadingFirstPage else { return } + await loadFirstPage() + } + + func loadMore() async { + guard canLoadMore, let token = nextPage else { return } + let requestGeneration = generation + isLoadingMore = true + defer { isLoadingMore = false } + do { + let page = try await service.listComments(filter: filter, nextPage: token) + // A refresh or first-load reset that landed while this page was in + // flight makes it stale; dropping it avoids appending items fetched + // from an obsolete cursor onto the refreshed list. + guard requestGeneration == generation else { return } + append(page: page) + } catch { + guard requestGeneration == generation else { return } + loadMoreFailed = true + } + } + + func retryLoadMore() async { + loadMoreFailed = false + await loadMore() + } + + func refresh() async { + do { + let page = try await service.listComments(filter: filter, nextPage: nil) + replace(with: page) + } catch { + // Keep stale content on refresh failure; the design surfaces no + // blocking error here. + } + } + + private func loadFirstPage() async { + isLoadingFirstPage = true + firstPageFailed = false + defer { isLoadingFirstPage = false } + do { + let page = try await service.listComments(filter: filter, nextPage: nil) + replace(with: page) + hasLoaded = true + isShowingSeededPlaceholder = false + } catch { + firstPageFailed = items.isEmpty || isShowingSeededPlaceholder + } + } + + private func replace(with page: CommentsPage) { + generation &+= 1 + seenIDs = Set(page.items.map(\.id)) + items = page.items + nextPage = page.nextPage + loadMoreFailed = false + onItemsAppended?(page.items) + } + + private func append(page: CommentsPage) { + // Offset paging can re-serve rows when the result set shifts between + // requests; duplicate IDs would also break SwiftUI's ForEach diffing. + let fresh = page.items.filter { !seenIDs.contains($0.id) } + seenIDs.formUnion(fresh.map(\.id)) + items.append(contentsOf: fresh) + nextPage = page.nextPage + onItemsAppended?(fresh) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentsListViewModelConcurrencyTests.swift b/Modules/Tests/WordPressCommentsTests/CommentsListViewModelConcurrencyTests.swift new file mode 100644 index 000000000000..49ba50b7776b --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentsListViewModelConcurrencyTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import WordPressComments + +@MainActor +struct CommentsListViewModelConcurrencyTests { + @Test func staleLoadMoreAfterRefreshIsDiscarded() async { + let service = BlockingCommentsService() + let viewModel = CommentsListViewModel(filter: .all, service: service) + + // First load (call 0). + async let firstLoad: Void = viewModel.onAppear() + while service.callCount < 1 { await Task.yield() } + service.resolve(callIndex: 0, with: makePage(items: [makeItem(id: 1)], hasNext: true)) + await firstLoad + #expect(viewModel.items.map(\.id) == [1]) + + // Start load-more (call 1); it suspends before appending. + async let more: Void = viewModel.loadMore() + while service.callCount < 2 { await Task.yield() } + + // A refresh (call 2) lands and completes while load-more is still in + // flight. + async let refresh: Void = viewModel.refresh() + while service.callCount < 3 { await Task.yield() } + service.resolve(callIndex: 2, with: makePage(items: [makeItem(id: 9)], hasNext: false)) + await refresh + #expect(viewModel.items.map(\.id) == [9]) + + // The stale load-more now resumes with a page from the obsolete cursor; + // it must be dropped rather than appended onto the refreshed list. + service.resolve(callIndex: 1, with: makePage(items: [makeItem(id: 5)], hasNext: true)) + await more + #expect(viewModel.items.map(\.id) == [9]) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentsListViewModelStateTests.swift b/Modules/Tests/WordPressCommentsTests/CommentsListViewModelStateTests.swift new file mode 100644 index 000000000000..dcd00fc11bd3 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentsListViewModelStateTests.swift @@ -0,0 +1,130 @@ +import Foundation +import Testing +@testable import WordPressComments + +@MainActor +struct CommentsListViewModelStateTests { + @Test func firstPageFailureShowsErrorState() async { + let service = FakeCommentsService() + service.queuedResults = [.failure(FakeServiceError())] + let viewModel = CommentsListViewModel(filter: .all, service: service) + + await viewModel.onAppear() + + #expect(viewModel.firstPageFailed) + #expect(!viewModel.showsEmptyState) + #expect(viewModel.items.isEmpty) + } + + @Test func retryAfterFirstPageFailureRecovers() async { + let service = FakeCommentsService() + service.queuedResults = [ + .failure(FakeServiceError()), + .success(makePage(items: [makeItem(id: 1)], hasNext: false)) + ] + let viewModel = CommentsListViewModel(filter: .all, service: service) + + await viewModel.onAppear() + await viewModel.retryFirstPage() + + #expect(!viewModel.firstPageFailed) + #expect(viewModel.items.map(\.id) == [1]) + #expect(viewModel.hasLoaded) + } + + @Test func emptyResultShowsEmptyState() async { + let service = FakeCommentsService() + service.queuedResults = [.success(makePage(items: [], hasNext: false))] + let viewModel = CommentsListViewModel(filter: .spam, service: service) + + await viewModel.onAppear() + + #expect(viewModel.showsEmptyState) + } + + @Test func loadMoreFailureKeepsContentAndAllowsRetry() async { + let service = FakeCommentsService() + service.queuedResults = [ + .success(makePage(items: [makeItem(id: 1)], hasNext: true)), + .failure(FakeServiceError()), + .success(makePage(items: [makeItem(id: 2)], hasNext: false)) + ] + let viewModel = CommentsListViewModel(filter: .all, service: service) + + await viewModel.onAppear() + await viewModel.loadMore() + #expect(viewModel.loadMoreFailed) + #expect(viewModel.items.map(\.id) == [1]) + + await viewModel.retryLoadMore() + #expect(!viewModel.loadMoreFailed) + #expect(viewModel.items.map(\.id) == [1, 2]) + } + + @Test func refreshReplacesContentOnSuccess() async { + let service = FakeCommentsService() + service.queuedResults = [ + .success(makePage(items: [makeItem(id: 1)], hasNext: true)), + .success(makePage(items: [makeItem(id: 9)], hasNext: false)) + ] + let viewModel = CommentsListViewModel(filter: .all, service: service) + + await viewModel.onAppear() + await viewModel.refresh() + + #expect(viewModel.items.map(\.id) == [9]) + #expect(!viewModel.canLoadMore) + } + + @Test func refreshFailureKeepsStaleContent() async { + let service = FakeCommentsService() + service.queuedResults = [ + .success(makePage(items: [makeItem(id: 1)], hasNext: false)), + .failure(FakeServiceError()) + ] + let viewModel = CommentsListViewModel(filter: .all, service: service) + + await viewModel.onAppear() + await viewModel.refresh() + + #expect(viewModel.items.map(\.id) == [1]) + #expect(!viewModel.firstPageFailed) + } + + @Test func seededItemsShowWhilePlaceholderThenReplaced() async { + let service = FakeCommentsService() + service.queuedResults = [.success(makePage(items: [makeItem(id: 3), makeItem(id: 4)], hasNext: false))] + let seed = [makeItem(id: 3)] + let viewModel = CommentsListViewModel(filter: .pending, service: service, seedItems: { seed }) + + // Seeds show synchronously before the first page lands, but onAppear() + // runs load inline in tests, so assert the end state plus the flag + // transition through a mid-flight check below. + await viewModel.onAppear() + + #expect(!viewModel.isShowingSeededPlaceholder) + #expect(viewModel.items.map(\.id) == [3, 4]) + } + + @Test func placeholderDisablesLoadMore() async { + let service = FakeCommentsService() + service.queuedResults = [] // first page will fail, leaving the placeholder up + let viewModel = CommentsListViewModel(filter: .pending, service: service, seedItems: { [makeItem(id: 3)] }) + + await viewModel.onAppear() + + #expect(viewModel.isShowingSeededPlaceholder) + #expect(!viewModel.canLoadMore) + #expect(viewModel.firstPageFailed) + } + + @Test func emptySeedDoesNotEnablePlaceholder() async { + let service = FakeCommentsService() + service.queuedResults = [.success(makePage(items: [], hasNext: false))] + let viewModel = CommentsListViewModel(filter: .approved, service: service, seedItems: { [] }) + + await viewModel.onAppear() + + #expect(viewModel.showsEmptyState) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentsListViewModelTests.swift b/Modules/Tests/WordPressCommentsTests/CommentsListViewModelTests.swift new file mode 100644 index 000000000000..9b0624e7052c --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentsListViewModelTests.swift @@ -0,0 +1,95 @@ +import Foundation +import Testing +@testable import WordPressComments + +@MainActor +struct CommentsListViewModelTests { + @Test func firstLoadPopulatesItems() async { + let service = FakeCommentsService() + service.queuedResults = [.success(makePage(items: [makeItem(id: 1), makeItem(id: 2)], hasNext: true))] + let viewModel = CommentsListViewModel(filter: .all, service: service) + + await viewModel.onAppear() + + #expect(viewModel.items.map(\.id) == [1, 2]) + #expect(viewModel.hasLoaded) + #expect(!viewModel.isLoadingFirstPage) + #expect(viewModel.canLoadMore) + } + + @Test func onAppearIsNoOpAfterFirstLoad() async { + let service = FakeCommentsService() + service.queuedResults = [.success(makePage(items: [makeItem(id: 1)], hasNext: false))] + let viewModel = CommentsListViewModel(filter: .all, service: service) + + await viewModel.onAppear() + await viewModel.onAppear() + + #expect(service.requests.count == 1) + } + + @Test func loadMoreAppendsAndStopsAtEnd() async { + let service = FakeCommentsService() + service.queuedResults = [ + .success(makePage(items: [makeItem(id: 1)], hasNext: true)), + .success(makePage(items: [makeItem(id: 2)], hasNext: false)) + ] + let viewModel = CommentsListViewModel(filter: .all, service: service) + + await viewModel.onAppear() + await viewModel.loadMore() + + #expect(viewModel.items.map(\.id) == [1, 2]) + #expect(!viewModel.canLoadMore) + + await viewModel.loadMore() + #expect(service.requests.count == 2) + } + + @Test func loadMoreDeduplicatesOverlappingPage() async { + let service = FakeCommentsService() + service.queuedResults = [ + .success(makePage(items: [makeItem(id: 1), makeItem(id: 2)], hasNext: true)), + .success(makePage(items: [makeItem(id: 2), makeItem(id: 3)], hasNext: false)) + ] + let viewModel = CommentsListViewModel(filter: .all, service: service) + + await viewModel.onAppear() + await viewModel.loadMore() + + #expect(viewModel.items.map(\.id) == [1, 2, 3]) + } + + @Test func loadMorePassesNextPageToken() async { + let service = FakeCommentsService() + service.queuedResults = [ + .success(makePage(items: [makeItem(id: 1)], hasNext: true)), + .success(makePage(items: [makeItem(id: 2)], hasNext: false)) + ] + let viewModel = CommentsListViewModel(filter: .pending, service: service) + + await viewModel.onAppear() + await viewModel.loadMore() + + #expect(service.requests[0].nextPage == nil) + #expect(service.requests[1].nextPage != nil) + #expect(service.requests.allSatisfy { $0.filter == .pending }) + } + + @Test func onItemsAppendedFiresPerPage() async { + let service = FakeCommentsService() + service.queuedResults = [.success(makePage(items: [makeItem(id: 1, post: 5)], hasNext: false))] + var appended: [[Int64]] = [] + let viewModel = CommentsListViewModel( + filter: .all, + service: service, + onItemsAppended: { items in + appended.append(items.map(\.postID)) + } + ) + + await viewModel.onAppear() + + #expect(appended == [[5]]) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/Support/BlockingCommentsService.swift b/Modules/Tests/WordPressCommentsTests/Support/BlockingCommentsService.swift new file mode 100644 index 000000000000..7bcfc90c0160 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/Support/BlockingCommentsService.swift @@ -0,0 +1,20 @@ +import Foundation +import WordPressAPI +@testable import WordPressComments + +/// A service whose calls suspend until the test resolves them by index, so a +/// test can interleave an in-flight `loadMore` with a `refresh` deterministically. +@MainActor +final class BlockingCommentsService: CommentsServiceProtocol { + private var continuations: [CheckedContinuation] = [] + private(set) var callCount = 0 + + func listComments(filter: CommentsListFilter, nextPage: CommentsPageToken?) async throws -> CommentsPage { + callCount += 1 + return try await withCheckedThrowingContinuation { continuations.append($0) } + } + + func resolve(callIndex: Int, with page: CommentsPage) { + continuations[callIndex].resume(returning: page) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/Support/FakeCommentsService.swift b/Modules/Tests/WordPressCommentsTests/Support/FakeCommentsService.swift new file mode 100644 index 000000000000..84adf8e30de4 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/Support/FakeCommentsService.swift @@ -0,0 +1,26 @@ +import Foundation +import WordPressAPI +@testable import WordPressComments + +struct FakeServiceError: Error {} + +@MainActor +final class FakeCommentsService: CommentsServiceProtocol { + var queuedResults: [Result] = [] + private(set) var requests: [(filter: CommentsListFilter, nextPage: CommentsPageToken?)] = [] + + func listComments(filter: CommentsListFilter, nextPage: CommentsPageToken?) async throws -> CommentsPage { + requests.append((filter, nextPage)) + guard !queuedResults.isEmpty else { + throw FakeServiceError() + } + return try queuedResults.removeFirst().get() + } +} + +func makePage(items: [CommentListItem], hasNext: Bool) -> CommentsPage { + CommentsPage( + items: items, + nextPage: hasNext ? CommentsPageToken(params: CommentListParams(page: 2)) : nil + ) +} From ef86fdf8bf634b6cae8acd02d45cdfe7295db613 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 13 Aug 2026 10:12:43 +1200 Subject: [PATCH 5/7] Add batched post title resolver for comment rows --- .../Services/PostTitleResolver.swift | 147 ++++++++++++++++++ .../PostTitleResolverTests.swift | 106 +++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 Modules/Sources/WordPressComments/Services/PostTitleResolver.swift create mode 100644 Modules/Tests/WordPressCommentsTests/PostTitleResolverTests.swift diff --git a/Modules/Sources/WordPressComments/Services/PostTitleResolver.swift b/Modules/Sources/WordPressComments/Services/PostTitleResolver.swift new file mode 100644 index 000000000000..c0988d06a13e --- /dev/null +++ b/Modules/Sources/WordPressComments/Services/PostTitleResolver.swift @@ -0,0 +1,147 @@ +import Foundation +import WordPressAPI +import WordPressCore +import WordPressShared + +/// Resolves post titles for comment rows. Core REST comments carry only a +/// post ID; titles arrive from a separate batched request and fill in +/// asynchronously. One instance per screen, shared by every tab's view model, +/// so the same title is never fetched twice. +/// +/// Resolution is best-effort by design: a failure never blocks or fails the +/// comments list, it only leaves rows in the author-only presentation. +@MainActor +final class PostTitleResolver: ObservableObject { + /// A fetcher's outcome for one batch: the titles it resolved, and the IDs it + /// could not resolve because a lookup errored (as opposed to authoritatively + /// returning no match). Resolved titles are kept even when some IDs fail, so + /// a partial-endpoint failure never discards titles that did come back; only + /// the `retryable` IDs re-enter the resolve queue. + struct FetchResult: Sendable { + var titles: [Int64: String] + var retryable: Set + + init(titles: [Int64: String], retryable: Set = []) { + self.titles = titles + self.retryable = retryable + } + } + + typealias Fetcher = @Sendable (_ ids: [Int64]) async throws -> FetchResult + + enum TitleState: Equatable { + case resolved(String) + case loading + case unavailable + } + + @Published private(set) var titles: [Int64: String] = [:] + + private let fetcher: Fetcher + private var inFlight: Set = [] + /// Fetched successfully but not returned (deleted post, or a custom post + /// type the fetcher's endpoints don't cover). Never refetched. + /// Published so a row moving from `.loading` to `.unavailable` triggers a + /// view update even though `titles` is untouched. + @Published private var notFound: Set = [] + /// Fetch threw. Shown as unavailable, but retried on the next resolve() + /// that references them. Published for the same reason as `notFound`. + @Published private var failed: Set = [] + + init(fetcher: @escaping Fetcher) { + self.fetcher = fetcher + } + + func titleState(for postID: Int64) -> TitleState { + if let title = titles[postID] { + return .resolved(title) + } + if notFound.contains(postID) || failed.contains(postID) { + return .unavailable + } + return .loading + } + + func resolve(ids: [Int64]) { + Task { await resolveAndWait(ids: ids) } + } + + func resolveAndWait(ids: [Int64]) async { + let pending = Set(ids) + .subtracting(titles.keys) + .subtracting(inFlight) + .subtracting(notFound) + guard !pending.isEmpty else { return } + failed.subtract(pending) + inFlight.formUnion(pending) + defer { inFlight.subtract(pending) } + do { + let result = try await fetcher(Array(pending)) + titles.merge(result.titles) { _, new in new } + failed.formUnion(result.retryable) + // Neither resolved nor flagged retryable means the lookup completed + // and authoritatively found no match (deleted post or a custom post + // type the endpoints don't cover): never refetched. + notFound.formUnion( + pending.subtracting(result.titles.keys).subtracting(result.retryable) + ) + } catch { + failed.formUnion(pending) + } + } + + /// Fetches id + title for regular posts, then retries the remainder + /// against pages. Custom post types are not covered in M1 and resolve to + /// unavailable. + /// TODO: Read the wordpress-rs cache as a first tier once it exposes a + /// public by-post-ID lookup; today only cache-internal EntityId reads + /// exist, and direct sqlite access is off limits. + /// TODO: Consider making this a plain nonisolated async function + /// (`liveFetch(ids:client:)`) wrapped in a `Fetcher` closure at the call + /// site, instead of a factory that returns a closure. Deferred for now. + static func liveFetcher(client: WordPressClient) -> Fetcher { + { ids in + func fetch(_ ids: [Int64], from endpoint: PostEndpointType) async throws -> [Int64: String] { + let response = try await client.api.posts.filterListWithViewContext( + postEndpointType: endpoint, + params: PostListParams(perPage: UInt32(ids.count), include: ids), + fields: [.id, .title] + ) + var titles: [Int64: String] = [:] + for post in response.data { + guard let id = post.id, let rendered = post.title?.rendered else { + continue + } + // `rendered` is HTML; strip it to plain text so entities and + // markup don't leak into the row. Skip an empty title so the + // row falls back to author-only instead of showing a + // dangling "Author on " headline. + let title = rendered.makePlainText() + .trimmingCharacters(in: .whitespacesAndNewlines) + if !title.isEmpty { + titles[id] = title + } + } + return titles + } + + var titles = try await fetch(ids, from: .posts) + let remainder = ids.filter { titles[$0] == nil } + guard !remainder.isEmpty else { + return FetchResult(titles: titles) + } + // A comment on a page is as common as one on a post, so the + // remainder must be looked up rather than assumed absent. If the + // pages lookup fails, keep the post titles already resolved and + // report only the remainder as retryable, so a transient failure + // never discards good titles or permanently hides those IDs. + do { + let pageTitles = try await fetch(remainder, from: .pages) + titles.merge(pageTitles) { _, new in new } + return FetchResult(titles: titles) + } catch { + return FetchResult(titles: titles, retryable: Set(remainder)) + } + } + } +} diff --git a/Modules/Tests/WordPressCommentsTests/PostTitleResolverTests.swift b/Modules/Tests/WordPressCommentsTests/PostTitleResolverTests.swift new file mode 100644 index 000000000000..7621a6bc3ce9 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/PostTitleResolverTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing +@testable import WordPressComments + +// The Fetcher closure is @Sendable, so tests can't mutate captured locals +// from inside it under Swift 6; an actor records the calls instead. +actor FetchLog { + private(set) var batches: [[Int64]] = [] + func record(_ ids: [Int64]) { + batches.append(ids.sorted()) + } +} + +@MainActor +struct PostTitleResolverTests { + @Test func resolvesTitlesAndCaches() async { + let log = FetchLog() + let resolver = PostTitleResolver { ids in + await log.record(ids) + return PostTitleResolver.FetchResult( + titles: Dictionary(uniqueKeysWithValues: ids.map { ($0, "Title \($0)") }) + ) + } + + await resolver.resolveAndWait(ids: [1, 2]) + await resolver.resolveAndWait(ids: [2, 3]) // 2 already resolved + + #expect(resolver.titleState(for: 1) == .resolved("Title 1")) + #expect(resolver.titleState(for: 3) == .resolved("Title 3")) + #expect(await log.batches == [[1, 2], [3]]) + } + + @Test func alreadyResolvedSetFetchesNothing() async { + let log = FetchLog() + let resolver = PostTitleResolver { ids in + await log.record(ids) + return PostTitleResolver.FetchResult( + titles: Dictionary(uniqueKeysWithValues: ids.map { ($0, "T") }) + ) + } + + await resolver.resolveAndWait(ids: [1]) + await resolver.resolveAndWait(ids: [1]) + + #expect(await log.batches.count == 1) + } + + @Test func missingIDsBecomeUnavailableAndAreNotRefetched() async { + let log = FetchLog() + let resolver = PostTitleResolver { ids in + await log.record(ids) + return PostTitleResolver.FetchResult(titles: [:]) // found, nothing matched + } + + await resolver.resolveAndWait(ids: [7]) + await resolver.resolveAndWait(ids: [7]) + + #expect(resolver.titleState(for: 7) == .unavailable) + #expect(await log.batches == [[7]]) + } + + @Test func fetchFailureShowsUnavailableButRetriesNextTime() async { + let log = FetchLog() + let resolver = PostTitleResolver { ids in + await log.record(ids) + if await log.batches.count == 1 { throw FakeServiceError() } + return PostTitleResolver.FetchResult( + titles: Dictionary(uniqueKeysWithValues: ids.map { ($0, "Recovered") }) + ) + } + + await resolver.resolveAndWait(ids: [5]) + #expect(resolver.titleState(for: 5) == .unavailable) + + await resolver.resolveAndWait(ids: [5]) + #expect(resolver.titleState(for: 5) == .resolved("Recovered")) + } + + @Test func partialFailureKeepsResolvedTitlesAndRetriesRemainder() async { + let log = FetchLog() + let resolver = PostTitleResolver { ids in + await log.record(ids) + // First batch resolves id 1 but reports id 2 as retryable (its pages + // lookup failed); the retry then resolves id 2. + if await log.batches.count == 1 { + return PostTitleResolver.FetchResult(titles: [1: "Post One"], retryable: [2]) + } + return PostTitleResolver.FetchResult(titles: [2: "Page Two"]) + } + + await resolver.resolveAndWait(ids: [1, 2]) + #expect(resolver.titleState(for: 1) == .resolved("Post One")) + #expect(resolver.titleState(for: 2) == .unavailable) + + await resolver.resolveAndWait(ids: [1, 2]) + // id 1 stays resolved and is not refetched; only id 2 is retried. + #expect(resolver.titleState(for: 1) == .resolved("Post One")) + #expect(resolver.titleState(for: 2) == .resolved("Page Two")) + #expect(await log.batches == [[1, 2], [2]]) + } + + @Test func unknownIDReportsLoading() { + let resolver = PostTitleResolver { _ in PostTitleResolver.FetchResult(titles: [:]) } + #expect(resolver.titleState(for: 99) == .loading) + } +} From 933a9809ff1affc24f911038764030730f1c7f78 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 13 Aug 2026 10:12:43 +1200 Subject: [PATCH 6/7] Add comments list UI with status filter tabs --- .../Views/CommentRowView.swift | 105 ++++++++++++++++ .../Views/CommentsHostingController.swift | 19 +++ .../Views/CommentsListView.swift | 56 +++++++++ .../Views/CommentsTabView.swift | 114 ++++++++++++++++++ 4 files changed, 294 insertions(+) create mode 100644 Modules/Sources/WordPressComments/Views/CommentRowView.swift create mode 100644 Modules/Sources/WordPressComments/Views/CommentsHostingController.swift create mode 100644 Modules/Sources/WordPressComments/Views/CommentsListView.swift create mode 100644 Modules/Sources/WordPressComments/Views/CommentsTabView.swift diff --git a/Modules/Sources/WordPressComments/Views/CommentRowView.swift b/Modules/Sources/WordPressComments/Views/CommentRowView.swift new file mode 100644 index 000000000000..2613a375dab6 --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/CommentRowView.swift @@ -0,0 +1,105 @@ +import AsyncImageKit +import DesignSystem +import SwiftUI + +struct CommentRowView: View { + let item: CommentListItem + let titleState: PostTitleResolver.TitleState + + // Same geometry as the legacy ListTableViewCell: an 8pt dot leading the + // avatar, painted clear (not hidden) when the comment isn't pending. + private var indicatorColor: Color { + item.status == .pending ? Color(UIAppColor.yellow(.shade20)) : .clear + } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + HStack(spacing: 6) { + Circle() + .fill(indicatorColor) + .frame(width: 8, height: 8) + avatar + } + VStack(alignment: .leading, spacing: 4) { + headline + Text(item.snippet) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + Text(item.date, format: .relative(presentation: .named)) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .accessibilityElement(children: .combine) + // The pending state is otherwise conveyed only by the dot's color, + // which VoiceOver cannot read once the row's children are combined. + .accessibilityValue(item.status == .pending ? Strings.pendingAccessibilityValue : "") + } + + private var avatar: some View { + CachedAsyncImage(url: item.avatarURL) { image in + image.resizable() + } placeholder: { + Color(.secondarySystemBackground) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } + + @ViewBuilder + private var headline: some View { + switch titleState { + case .resolved(let title): + Text(headlineText(postTitle: title)) + .font(.subheadline) + case .loading: + HStack(spacing: 4) { + Text(item.authorName) + .font(.subheadline.weight(.semibold)) + Text("Sample Post Title") + .font(.subheadline) + .redacted(reason: .placeholder) + } + .lineLimit(1) + case .unavailable: + Text(item.authorName) + .font(.subheadline.weight(.semibold)) + } + } + + private func headlineText(postTitle: String) -> AttributedString { + var text = AttributedString( + String.localizedStringWithFormat(Strings.authorOnPost, item.authorName, postTitle) + ) + for segment in [item.authorName, postTitle] { + if let range = text.range(of: segment) { + text[range].font = .subheadline.weight(.semibold) + } + } + return text + } +} + +#Preview { + List { + CommentRowView(item: .preview(id: 1, status: .pending), titleState: .resolved("A Post Title")) + CommentRowView(item: .preview(id: 2, status: .approved), titleState: .loading) + CommentRowView(item: .preview(id: 3, status: .approved), titleState: .unavailable) + } + .listStyle(.plain) +} + +extension CommentListItem { + static func preview(id: Int64, status: Status) -> CommentListItem { + CommentListItem( + id: id, + authorName: "Priya Nair", + avatarURL: nil, + postID: 1, + snippet: "Really appreciate the detailed writeup, this is exactly the kind of review I was hoping to find.", + date: Date(timeIntervalSince1970: 1_700_000_000), + status: status + ) + } +} diff --git a/Modules/Sources/WordPressComments/Views/CommentsHostingController.swift b/Modules/Sources/WordPressComments/Views/CommentsHostingController.swift new file mode 100644 index 000000000000..477e81ce780e --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/CommentsHostingController.swift @@ -0,0 +1,19 @@ +import SwiftUI +import UIKit +import WordPressCore + +/// Module-side factory, the module's only public symbol. The Blog gate and +/// WordPressClient construction live in the app target; see +/// `WordPress/Classes/ViewRelated/Comments/CommentsRouting.swift`. +public enum CommentsHostingController { + @MainActor + public static func make(client: WordPressClient) -> UIViewController { + let view = CommentsTabView( + service: CommentsService(client: client), + titleResolver: PostTitleResolver(fetcher: PostTitleResolver.liveFetcher(client: client)) + ) + let host = UIHostingController(rootView: view) + host.navigationItem.largeTitleDisplayMode = .never + return host + } +} diff --git a/Modules/Sources/WordPressComments/Views/CommentsListView.swift b/Modules/Sources/WordPressComments/Views/CommentsListView.swift new file mode 100644 index 000000000000..b4a988ee1e77 --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/CommentsListView.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct CommentsListView: View { + @ObservedObject var viewModel: CommentsListViewModel + @ObservedObject var titleResolver: PostTitleResolver + + var body: some View { + List { + ForEach(viewModel.items) { item in + CommentRowView(item: item, titleState: titleResolver.titleState(for: item.postID)) + } + if viewModel.canLoadMore { + ProgressView() + .frame(maxWidth: .infinity) + .onAppear { + Task { await viewModel.loadMore() } + } + } else if viewModel.loadMoreFailed { + Button(Strings.errorRetry) { + Task { await viewModel.retryLoadMore() } + } + .frame(maxWidth: .infinity) + } + } + .listStyle(.plain) + .refreshable { + await viewModel.refresh() + } + .overlay { + if viewModel.isLoadingFirstPage && viewModel.items.isEmpty { + ProgressView() + } else if viewModel.firstPageFailed { + ContentUnavailableView { + Label(Strings.errorTitle, systemImage: "exclamationmark.triangle") + } actions: { + Button(Strings.errorRetry) { + Task { await viewModel.retryFirstPage() } + } + .buttonStyle(.borderedProminent) + } + } else if viewModel.showsEmptyState { + ContentUnavailableView( + viewModel.filter.emptyStateMessage, + systemImage: "bubble.left" + ) + } + } + // Keyed by filter: the tab container swaps the observed view model + // behind a stable view identity, so an unkeyed task would run only for + // the first tab and later tabs would never load. onAppear() is a no-op + // after a tab's first successful load, so re-running it is safe. + .task(id: viewModel.filter) { + await viewModel.onAppear() + } + } +} diff --git a/Modules/Sources/WordPressComments/Views/CommentsTabView.swift b/Modules/Sources/WordPressComments/Views/CommentsTabView.swift new file mode 100644 index 000000000000..bc417eb0eebc --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/CommentsTabView.swift @@ -0,0 +1,114 @@ +import SwiftUI +import WordPressCore +import WordPressUI + +struct CommentsTabView: View { + @State private var selectedFilter: CommentsListFilter = .all + @State private var viewModels: [CommentsListFilter: CommentsListViewModel] + @State private var titleResolver: PostTitleResolver + + init(service: any CommentsServiceProtocol, titleResolver: PostTitleResolver) { + _titleResolver = State(initialValue: titleResolver) + + // The All view model is built first so Pending and Approved can seed + // their first appearance from its loaded items. All is a strict + // superset of both under the same sort key, so the filtered items are + // a true prefix of each tab's list. Spam and Trash can't seed: core's + // status=all excludes them. + var models: [CommentsListFilter: CommentsListViewModel] = [:] + let resolve: @MainActor ([CommentListItem]) -> Void = { [titleResolver] items in + titleResolver.resolve(ids: items.map(\.postID)) + } + let allViewModel = CommentsListViewModel( + filter: .all, + service: service, + onItemsAppended: resolve + ) + models[.all] = allViewModel + for filter in [CommentsListFilter.pending, .approved] { + let status: CommentListItem.Status = filter == .pending ? .pending : .approved + models[filter] = CommentsListViewModel( + filter: filter, + service: service, + seedItems: { [weak allViewModel] in + guard let allViewModel, allViewModel.hasLoaded else { return [] } + return allViewModel.items.filter { $0.status == status } + }, + onItemsAppended: resolve + ) + } + for filter in [CommentsListFilter.spam, .trash] { + models[filter] = CommentsListViewModel( + filter: filter, + service: service, + onItemsAppended: resolve + ) + } + _viewModels = State(initialValue: models) + } + + var body: some View { + VStack(spacing: 0) { + tabBar + if let viewModel = viewModels[selectedFilter] { + CommentsListView(viewModel: viewModel, titleResolver: titleResolver) + } + } + .navigationTitle(Strings.title) + .navigationBarTitleDisplayMode(.inline) + } + + private var tabBar: some View { + AdaptiveTabBarRepresentable( + items: CommentsListFilter.allCases, + selection: $selectedFilter + ) + .frame(height: AdaptiveTabBar.tabBarHeight) + } +} + +// Private copy of the SwiftUI bridge for WordPressUI's AdaptiveTabBar. The +// original lives private inside CustomPostTabView in the app target; promoting +// a shared public wrapper is deliberately out of M1 scope. +private struct AdaptiveTabBarRepresentable: UIViewRepresentable { + let items: [CommentsListFilter] + @Binding var selection: CommentsListFilter + + func makeUIView(context: Context) -> AdaptiveTabBar { + let tabBar = AdaptiveTabBar() + tabBar.preferredFont = UIFont.preferredFont(forTextStyle: .subheadline) + tabBar.items = items + tabBar.addTarget( + context.coordinator, + action: #selector(Coordinator.tabChanged(_:)), + for: .valueChanged + ) + return tabBar + } + + func updateUIView(_ uiView: AdaptiveTabBar, context: Context) { + if let index = items.firstIndex(of: selection), uiView.selectedIndex != index { + uiView.setSelectedIndex(index, animated: true) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(items: items, selection: $selection) + } + + final class Coordinator: NSObject { + let items: [CommentsListFilter] + @Binding var selection: CommentsListFilter + + init(items: [CommentsListFilter], selection: Binding) { + self.items = items + _selection = selection + } + + @objc func tabChanged(_ tabBar: AdaptiveTabBar) { + if items.indices.contains(tabBar.selectedIndex) { + selection = items[tabBar.selectedIndex] + } + } + } +} From a62c6c1907d6a14b92d68fd340ebf09235c9f02c Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 13 Aug 2026 10:12:43 +1200 Subject: [PATCH 7/7] Route comments entry points to the v2 list behind a feature flag --- .../BuildInformation/FeatureFlag.swift | 4 +++ .../DashboardQuickActionsCardCell.swift | 10 +++++++ .../BlogDetailsViewController+Swift.swift | 11 +++++++ .../Comments/CommentsRouting.swift | 30 +++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 WordPress/Classes/ViewRelated/Comments/CommentsRouting.swift diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index 0c909a9a5d74..7abfa30a8497 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -25,6 +25,7 @@ public enum FeatureFlag: Int, CaseIterable { case nativeBlockInserter case statsAds case mediaLibraryV2 + case commentsV2 /// Returns a boolean indicating if the feature is enabled. /// @@ -80,6 +81,8 @@ public enum FeatureFlag: Int, CaseIterable { return BuildConfiguration.current == .debug case .mediaLibraryV2: return BuildConfiguration.current == .debug + case .commentsV2: + return BuildConfiguration.current == .debug } } @@ -121,6 +124,7 @@ extension FeatureFlag { case .nativeBlockInserter: "Native Block Inserter" case .statsAds: "Stats Ads Tab" case .mediaLibraryV2: "Media Library v2" + case .commentsV2: "Comments v2" } } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift index 3e9d2aae7f37..54002dc74cf7 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift @@ -103,6 +103,16 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab trackQuickActionsEvent(.openedPages, blog: blog) PageListViewController.showForBlog(blog, from: parentViewController) case .comments: + if let v2 = CommentsRouting.makeViewController(for: blog) { + let properties: [String: Any] = [ + WPAppAnalyticsKeyTapSource: "quick_actions", + WPAppAnalyticsKeyTabSource: "dashboard", + "is_v2": "1" + ] + WPAppAnalytics.track(.openedComments, properties: properties, blog: blog) + parentViewController.show(v2, sender: nil) + return + } if let viewController = CommentsViewController(blog: blog) { trackQuickActionsEvent(.openedComments, blog: blog) parentViewController.show(viewController, sender: nil) diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index ae2211b800e4..0e11ff84fff8 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -265,6 +265,17 @@ extension BlogDetailsViewController { } public func showComments(from source: BlogDetailsNavigationSource) { + if let v2 = CommentsRouting.makeViewController(for: blog) { + let properties: [String: Any] = [ + WPAppAnalyticsKeyTapSource: source.string, + WPAppAnalyticsKeyTabSource: "site_menu", + "is_v2": "1" + ] + WPAppAnalytics.track(.openedComments, properties: properties, blog: blog) + presentationDelegate?.presentBlogDetailsViewController(v2) + return + } + trackEvent(.openedComments, from: source) guard let commentsVC = CommentsViewController(blog: blog) else { diff --git a/WordPress/Classes/ViewRelated/Comments/CommentsRouting.swift b/WordPress/Classes/ViewRelated/Comments/CommentsRouting.swift new file mode 100644 index 000000000000..a2d4ebff5bcb --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/CommentsRouting.swift @@ -0,0 +1,30 @@ +import UIKit +import WordPressComments +import WordPressCore +import WordPressData + +/// Single source of truth for routing into Comments v2. Both legacy entry +/// points (BlogDetailsViewController.showComments and the dashboard quick +/// action) call this helper. Returns nil unless the feature flag is on AND +/// the site is self-hosted with an application password (the only site type +/// M1 supports; WP.com and Jetpack sites are a follow-up project), so the +/// caller's legacy fall-through is a one-liner. +/// +/// Known M1 limitation: users without the edit_posts capability get a 401 +/// from the list's status queries and see the screen's error state. The +/// legacy screen is equally broken for them (XML-RPC requires +/// moderate_comments), so this is not a regression; capability handling +/// arrives with M2's /users/me fetch. +@MainActor +enum CommentsRouting { + static func makeViewController(for blog: Blog) -> UIViewController? { + guard FeatureFlag.commentsV2.enabled, + let site = try? WordPressSite(blog: blog), + case .selfHosted = site.flavor + else { + return nil + } + let client = WordPressClientFactory.shared.instance(for: site) + return CommentsHostingController.make(client: client) + } +}