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
124 changes: 124 additions & 0 deletions Modules/Sources/WordPressComments/Models/CommentDetail.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import Foundation
import WordPressAPI
import WordPressShared

/// Value type consumed by the detail screen, mapped from either
/// wordpress-rs view- or edit-context wire types. Edit context adds the
/// author's email and IP address, which view context omits.
struct CommentDetail: Equatable, Sendable {
let id: Int64
let authorName: String
let avatarURL: URL?
let authorURL: URL?
let authorEmail: String? // edit context only
let authorIP: String? // edit context only
let postID: Int64
let parentID: Int64? // nil when the wire value is 0 (top-level)
let contentHTML: String
let link: URL?
let date: Date
let status: CommentListItem.Status
/// False when the fetch fell back to view context (no email/IP; M3 edit
/// needs content.raw, also unavailable).
let hasEditContext: Bool

init(comment: CommentWithViewContext) {
self.init(
id: comment.id,
authorName: comment.authorName,
avatarURL: comment.authorAvatarUrls.avatarURL,
authorURL: comment.authorUrl,
authorEmail: nil,
authorIP: nil,
postID: comment.post,
parentID: comment.parent,
contentHTML: comment.content.rendered,
link: comment.link,
date: comment.dateGmt,
status: CommentListItem.Status(comment.status),
hasEditContext: false
)
}

init(comment: CommentWithEditContext) {
self.init(
id: comment.id,
authorName: comment.authorName,
avatarURL: comment.authorAvatarUrls.avatarURL,
authorURL: comment.authorUrl,
authorEmail: comment.authorEmail,
authorIP: comment.authorIp,
postID: comment.post,
parentID: comment.parent,
contentHTML: comment.content.rendered,
link: comment.link,
date: comment.dateGmt,
status: CommentListItem.Status(comment.status),
hasEditContext: true
)
}

private init(
id: Int64,
authorName: String,
avatarURL: URL?,
authorURL: String,
authorEmail: String?,
authorIP: String?,
postID: Int64,
parentID: Int64,
contentHTML: String,
link: String,
date: Date,
status: CommentListItem.Status,
hasEditContext: Bool
) {
self.id = id
self.authorName = authorName.nonEmptyString() ?? Strings.anonymousAuthor
self.avatarURL = avatarURL
// wordpress-rs represents an absent value as an empty string rather
// than nil; normalize before constructing a URL.
self.authorURL = authorURL.nonEmptyString().flatMap { URL(string: $0) }
self.authorEmail = authorEmail.flatMap { $0.nonEmptyString() }
self.authorIP = authorIP.flatMap { $0.nonEmptyString() }
self.postID = postID
self.parentID = parentID == 0 ? nil : parentID
self.contentHTML = contentHTML
self.link = link.nonEmptyString().flatMap { URL(string: $0) }
self.date = date
self.status = status
self.hasEditContext = hasEditContext
}
}

#if DEBUG
extension CommentDetail {
/// Preview-only builder. Production paths construct `CommentDetail` from a
/// wire type, but SwiftUI previews can't reach the uniffi builders, so this
/// assembles one from plain values.
static func preview(
id: Int64 = 1,
status: CommentListItem.Status = .pending,
parentID: Int64 = 0,
contentHTML: String =
"<p>Really appreciate the detailed writeup. This is exactly the kind of review I was hoping to find before committing to the upgrade.</p>",
hasEditContext: Bool = true
) -> CommentDetail {
CommentDetail(
id: id,
authorName: "Priya Nair",
avatarURL: nil,
authorURL: "https://example.com",
authorEmail: hasEditContext ? "priya@example.com" : "",
authorIP: hasEditContext ? "203.0.113.4" : "",
postID: 10,
parentID: parentID,
contentHTML: contentHTML,
link: "https://example.com/?p=10#comment-\(id)",
date: Date(timeIntervalSince1970: 1_700_000_000),
status: status,
hasEditContext: hasEditContext
)
}
}
#endif
43 changes: 33 additions & 10 deletions Modules/Sources/WordPressComments/Models/CommentListItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct CommentListItem: Identifiable, Equatable, Sendable {
case approved
case spam
case trash
case other
case other(String)
}

let id: Int64
Expand Down Expand Up @@ -42,27 +42,50 @@ struct CommentListItem: Identifiable, Equatable, Sendable {

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:))
authorName = comment.authorName.nonEmptyString() ?? Strings.anonymousAuthor
avatarURL = comment.authorAvatarUrls.avatarURL
postID = comment.post
snippet = comment.content.rendered
.makePlainText()
.replacingOccurrences(of: "\n", with: " ")
snippet = Self.snippet(fromHTML: comment.content.rendered)
date = comment.dateGmt
status = Status(comment.status)
}

/// Row-shaped projection of a fetched detail (used for the parent preview
/// strip), so the snippet rule stays in one place.
init(detail: CommentDetail) {
self.init(
id: detail.id,
authorName: detail.authorName,
avatarURL: detail.avatarURL,
postID: detail.postID,
snippet: Self.snippet(fromHTML: detail.contentHTML),
date: detail.date,
status: detail.status
)
}

/// Single-line plain-text preview of comment HTML.
static func snippet(fromHTML html: String) -> String {
html.makePlainText().replacingOccurrences(of: "\n", with: " ")
}
}

extension Dictionary where Key == UserAvatarSize, Value == String? {
/// The 96pt avatar URL. The subscript yields a double optional (missing
/// key vs. a stored nil); flatten it before building the URL.
var avatarURL: URL? {
self[.size96].flatMap { $0 }.flatMap(URL.init(string:))
}
}

private extension CommentListItem.Status {
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
case .custom(let raw): self = .other(raw)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import UIKit

/// Contract: the returned view MUST scroll internally when content exceeds
/// its bounds. The fixed-region detail layout has no other scroll surface
/// and no Show More fallback.
@MainActor
public protocol CommentContentRendering: AnyObject {
var view: UIView { get }
var onLinkTapped: ((URL) -> Void)? { get set }
func render(html: String)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
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
}

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
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import SwiftUI
import UIKit

/// Owns the shared detail dependencies and pushes comment detail screens onto
/// the navigation stack of `host`, the comments list controller.
@MainActor
final class CommentsDetailRouter {
/// The list's hosting controller; detail screens push onto its navigation
/// controller. Weak: the controller retains this router through the tab view.
weak var host: UIViewController?

private let service: any CommentsServiceProtocol
private let capabilities: any CommentsCapabilitiesProtocol
private let titleResolver: PostTitleResolver
private let tracker: (any CommentsTracker)?
private let makeContentRenderer: @MainActor () -> any CommentContentRendering

init(
service: any CommentsServiceProtocol,
capabilities: any CommentsCapabilitiesProtocol,
titleResolver: PostTitleResolver,
tracker: (any CommentsTracker)?,
makeContentRenderer: @escaping @MainActor () -> any CommentContentRendering
) {
self.service = service
self.capabilities = capabilities
self.titleResolver = titleResolver
self.tracker = tracker
self.makeContentRenderer = makeContentRenderer
}

/// 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?) {
let viewModel = CommentDetailViewModel(
commentID: id,
seed: seed,
service: service,
capabilities: capabilities,
titleResolver: titleResolver,
tracker: tracker
)
let renderer = makeContentRenderer()
renderer.onLinkTapped = { url in
UIApplication.shared.open(url)
}
let detail = CommentDetailView(
viewModel: viewModel,
titleResolver: titleResolver,
renderer: renderer,
openComment: { [weak self] id, seed in self?.open(id: id, seed: seed) }
)
let controller = UIHostingController(rootView: detail)
controller.navigationItem.largeTitleDisplayMode = .never
host?.navigationController?.pushViewController(controller, animated: true)
}
}
42 changes: 42 additions & 0 deletions Modules/Sources/WordPressComments/Services/CommentsService.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import WordPressAPI
import WordPressAPIInternal
import WordPressCore

/// Opaque next-page cursor. Wraps the wordpress-rs `nextPageParams` (parsed
Expand All @@ -25,6 +26,11 @@ protocol CommentsServiceProtocol: Sendable {
/// 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

/// Fetches full comment detail. When `allowsEditContext` is true, tries
/// edit context first (adds author email/IP) and falls back to view
/// context on a 401/403 (stale cached capability after a demotion).
func fetchComment(id: Int64, allowsEditContext: Bool) async throws -> CommentDetail
}

final class CommentsService: CommentsServiceProtocol {
Expand All @@ -42,6 +48,42 @@ final class CommentsService: CommentsServiceProtocol {
nextPage: response.nextPageParams.map(CommentsPageToken.init)
)
}

func fetchComment(id: Int64, allowsEditContext: Bool) async throws -> CommentDetail {
if allowsEditContext {
do {
// `CommentRetrieveParams` isn't re-exported by wordpress-rs's
// `WordPressAPI` module (only used internally), so its name
// can't be spelled here; `.init()` resolves it from the
// parameter type instead.
let response = try await client.api.comments.retrieveWithEditContext(
commentId: id,
params: .init()
)
return CommentDetail(comment: response.data)
} catch {
let statusCode = (error as? WpApiError)?.httpStatusCode
guard statusCode == 401 || statusCode == 403 else { throw error }
// Fall through to the view-context retry below.
}
}
let response = try await client.api.comments.retrieveWithViewContext(
commentId: id,
params: .init()
)
return CommentDetail(comment: response.data)
}
}

extension WpApiError {
/// The HTTP status code carried by the error, when it has one.
var httpStatusCode: UInt32? {
switch self {
case .WpError(_, _, let statusCode, _, _, _): return statusCode
case .RequestExecutionFailed(let statusCode, _, _, _, _): return statusCode
default: return nil
}
}
}

extension CommentsListFilter {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
public enum CommentsTrackedEvent: Equatable, Sendable {
case detailViewed(commentID: Int64, postID: Int64)
}

public protocol CommentsTracker: Sendable {
func track(_ event: CommentsTrackedEvent)
}
Loading