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
23 changes: 22 additions & 1 deletion Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -513,6 +533,7 @@ enum XcodeSupport {
"WordPressSharedObjCUI",
"WordPressLegacy",
"WordPressMediaLibrary",
"WordPressComments",
"WordPressReader",
"WordPressUI",
"WordPressCore",
Expand Down
68 changes: 68 additions & 0 deletions Modules/Sources/WordPressComments/Models/CommentListItem.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
53 changes: 53 additions & 0 deletions Modules/Sources/WordPressComments/Models/CommentsListFilter.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
58 changes: 58 additions & 0 deletions Modules/Sources/WordPressComments/Services/CommentsService.swift
Original file line number Diff line number Diff line change
@@ -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
)
}
}
147 changes: 147 additions & 0 deletions Modules/Sources/WordPressComments/Services/PostTitleResolver.swift
Original file line number Diff line number Diff line change
@@ -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<Int64>

init(titles: [Int64: String], retryable: Set<Int64> = []) {
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<Int64> = []
/// 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<Int64> = []
/// 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<Int64> = []

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))
}
}
}
}
Loading