diff --git a/Modules/Sources/WordPressComments/Models/CommentDetail.swift b/Modules/Sources/WordPressComments/Models/CommentDetail.swift new file mode 100644 index 000000000000..1cc87e77fce7 --- /dev/null +++ b/Modules/Sources/WordPressComments/Models/CommentDetail.swift @@ -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 = + "
Really appreciate the detailed writeup. This is exactly the kind of review I was hoping to find before committing to the upgrade.
", + 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 diff --git a/Modules/Sources/WordPressComments/Models/CommentListItem.swift b/Modules/Sources/WordPressComments/Models/CommentListItem.swift index 643ab6dea481..c8eee7153032 100644 --- a/Modules/Sources/WordPressComments/Models/CommentListItem.swift +++ b/Modules/Sources/WordPressComments/Models/CommentListItem.swift @@ -11,7 +11,7 @@ struct CommentListItem: Identifiable, Equatable, Sendable { case approved case spam case trash - case other + case other(String) } let id: Int64 @@ -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) } } } diff --git a/Modules/Sources/WordPressComments/Services/CommentContentRendering.swift b/Modules/Sources/WordPressComments/Services/CommentContentRendering.swift new file mode 100644 index 000000000000..463e0fe5dcec --- /dev/null +++ b/Modules/Sources/WordPressComments/Services/CommentContentRendering.swift @@ -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) +} diff --git a/Modules/Sources/WordPressComments/Services/CommentsCapabilities.swift b/Modules/Sources/WordPressComments/Services/CommentsCapabilities.swift new file mode 100644 index 000000000000..872fe271d11d --- /dev/null +++ b/Modules/Sources/WordPressComments/Services/CommentsCapabilities.swift @@ -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 + } +} diff --git a/Modules/Sources/WordPressComments/Services/CommentsDetailRouter.swift b/Modules/Sources/WordPressComments/Services/CommentsDetailRouter.swift new file mode 100644 index 000000000000..c5c323038a40 --- /dev/null +++ b/Modules/Sources/WordPressComments/Services/CommentsDetailRouter.swift @@ -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) + } +} diff --git a/Modules/Sources/WordPressComments/Services/CommentsService.swift b/Modules/Sources/WordPressComments/Services/CommentsService.swift index 0f8049df62ad..745dca7eb325 100644 --- a/Modules/Sources/WordPressComments/Services/CommentsService.swift +++ b/Modules/Sources/WordPressComments/Services/CommentsService.swift @@ -1,5 +1,6 @@ import Foundation import WordPressAPI +import WordPressAPIInternal import WordPressCore /// Opaque next-page cursor. Wraps the wordpress-rs `nextPageParams` (parsed @@ -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 { @@ -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 { diff --git a/Modules/Sources/WordPressComments/Services/CommentsTracker.swift b/Modules/Sources/WordPressComments/Services/CommentsTracker.swift new file mode 100644 index 000000000000..7582d3bee1a8 --- /dev/null +++ b/Modules/Sources/WordPressComments/Services/CommentsTracker.swift @@ -0,0 +1,7 @@ +public enum CommentsTrackedEvent: Equatable, Sendable { + case detailViewed(commentID: Int64, postID: Int64) +} + +public protocol CommentsTracker: Sendable { + func track(_ event: CommentsTrackedEvent) +} diff --git a/Modules/Sources/WordPressComments/Strings/Strings.swift b/Modules/Sources/WordPressComments/Strings/Strings.swift index abb3cde5138a..9ad12ea31352 100644 --- a/Modules/Sources/WordPressComments/Strings/Strings.swift +++ b/Modules/Sources/WordPressComments/Strings/Strings.swift @@ -96,4 +96,70 @@ enum Strings { value: "Pending", comment: "Accessibility value announced for a comment row that is awaiting moderation" ) + + static let statusApproved = NSLocalizedString( + "commentDetail.status.approved", + value: "Approved", + comment: "Status pill label on the comment detail screen for an approved comment" + ) + + static let statusPending = NSLocalizedString( + "commentDetail.status.pending", + value: "Pending", + comment: "Status pill label on the comment detail screen for a comment awaiting moderation" + ) + + static let statusSpam = NSLocalizedString( + "commentDetail.status.spam", + value: "Spam", + comment: "Status pill label on the comment detail screen for a comment marked as spam" + ) + + static let statusTrash = NSLocalizedString( + "commentDetail.status.trash", + value: "Trash", + comment: "Status pill label on the comment detail screen for a trashed comment" + ) + + static let authorHeaderOnPost = NSLocalizedString( + "commentDetail.header.onPost", + value: "on %@", + comment: "Secondary line under the comment author. %@ is the post title the comment was left on." + ) + + static let infoDateLabel = NSLocalizedString( + "commentDetail.info.date", + value: "Date", + comment: "Label for the full comment date row in the author info sheet" + ) + + static let infoWebsiteLabel = NSLocalizedString( + "commentDetail.info.website", + value: "Website", + comment: "Label for the author website row in the author info sheet" + ) + + static let infoEmailLabel = NSLocalizedString( + "commentDetail.info.email", + value: "Email", + comment: "Label for the author email row in the author info sheet" + ) + + static let infoIPLabel = NSLocalizedString( + "commentDetail.info.ipAddress", + value: "IP address", + comment: "Label for the author IP address row in the author info sheet" + ) + + static let inReplyToFormat = NSLocalizedString( + "commentDetail.parent.inReplyTo", + value: "In reply to %@", + comment: "Prefix of the parent-comment strip. %@ is the parent comment's author name." + ) + + static let detailErrorTitle = NSLocalizedString( + "commentDetail.error.title", + value: "Couldn't load this comment", + comment: "Error state title when the comment detail fails to load" + ) } diff --git a/Modules/Sources/WordPressComments/ViewModels/CommentDetailViewModel.swift b/Modules/Sources/WordPressComments/ViewModels/CommentDetailViewModel.swift new file mode 100644 index 000000000000..c3d547f0aae0 --- /dev/null +++ b/Modules/Sources/WordPressComments/ViewModels/CommentDetailViewModel.swift @@ -0,0 +1,141 @@ +import Foundation +import WordPressShared + +/// Drives the read-only comment detail screen. Resolves the capability once, +/// fetches the authoritative detail and optional parent preview, and keeps the +/// seeded header visible while the request is in flight. +@MainActor +final class CommentDetailViewModel: ObservableObject { + enum ContentState: Equatable { + case loading + case loaded(CommentDetail) + case failed + } + + /// Paintable from either the list seed or the fetched detail. + struct Header: Equatable { + let authorName: String + let avatarURL: URL? + let postID: Int64 + let date: Date + let status: CommentListItem.Status + } + + @Published private(set) var header: Header? // nil = seedless, show placeholders + @Published private(set) var content: ContentState = .loading + @Published private(set) var canModerate: Bool? // nil = resolving + @Published private(set) var parentPreview: CommentListItem? // "In reply to" strip + + let commentID: Int64 + + private let service: any CommentsServiceProtocol + private let capabilities: any CommentsCapabilitiesProtocol + private let titleResolver: PostTitleResolver + /// Fires `.detailViewed` once per screen, on the first successful fetch. + private let tracker: (any CommentsTracker)? + + /// The authoritative fetch has landed successfully at least once. + private var hasFetched = false + /// A load (capability + fetch) is currently running; guards re-entry. + private var isLoading = false + + init( + commentID: Int64, + seed: CommentListItem?, + service: any CommentsServiceProtocol, + capabilities: any CommentsCapabilitiesProtocol, + titleResolver: PostTitleResolver, + tracker: (any CommentsTracker)? = nil + ) { + self.commentID = commentID + self.service = service + self.capabilities = capabilities + self.titleResolver = titleResolver + self.tracker = tracker + if let seed { + header = Header(seed: seed) + } + } + + func onAppear() async { + guard !hasFetched, !isLoading else { return } + await load() + } + + func retry() async { + guard !isLoading else { return } + content = .loading + await load() + } + + private func load() async { + isLoading = true + defer { isLoading = false } + if canModerate == nil { + canModerate = await capabilities.canModerateComments() + } + await runFetch() + } + + private func runFetch() async { + guard let canModerate else { return } + guard + let detail = try? await service.fetchComment( + id: commentID, + allowsEditContext: canModerate + ) + else { + content = .failed + return + } + applyLoaded(detail) + await loadParentPreview(for: detail) + } + + private func applyLoaded(_ detail: CommentDetail) { + let isFirstFetch = !hasFetched + hasFetched = true + content = .loaded(detail) + header = Header(detail: detail) + titleResolver.resolve(ids: [detail.postID]) + if isFirstFetch { + tracker?.track(.detailViewed(commentID: commentID, postID: detail.postID)) + } + } + + private func loadParentPreview(for detail: CommentDetail) async { + guard let parentID = detail.parentID else { + parentPreview = nil + return + } + // The parent is always read with view context; the strip never needs + // the author email/IP that edit context would add. + guard let parent = try? await service.fetchComment(id: parentID, allowsEditContext: false) else { + parentPreview = nil // failure hides the strip + return + } + parentPreview = CommentListItem(detail: parent) + } +} + +private extension CommentDetailViewModel.Header { + init(seed: CommentListItem) { + self.init( + authorName: seed.authorName, + avatarURL: seed.avatarURL, + postID: seed.postID, + date: seed.date, + status: seed.status + ) + } + + init(detail: CommentDetail) { + self.init( + authorName: detail.authorName, + avatarURL: detail.avatarURL, + postID: detail.postID, + date: detail.date, + status: detail.status + ) + } +} diff --git a/Modules/Sources/WordPressComments/Views/CommentAvatarView.swift b/Modules/Sources/WordPressComments/Views/CommentAvatarView.swift new file mode 100644 index 000000000000..c02219eb490e --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/CommentAvatarView.swift @@ -0,0 +1,17 @@ +import AsyncImageKit +import SwiftUI + +/// The 40pt circular author avatar shared by the list row and the detail header. +struct CommentAvatarView: View { + let url: URL? + + var body: some View { + CachedAsyncImage(url: url) { image in + image.resizable() + } placeholder: { + Color(.secondarySystemBackground) + } + .frame(width: 40, height: 40) + .clipShape(Circle()) + } +} diff --git a/Modules/Sources/WordPressComments/Views/CommentRowView.swift b/Modules/Sources/WordPressComments/Views/CommentRowView.swift index 2613a375dab6..3fce673585f6 100644 --- a/Modules/Sources/WordPressComments/Views/CommentRowView.swift +++ b/Modules/Sources/WordPressComments/Views/CommentRowView.swift @@ -1,4 +1,3 @@ -import AsyncImageKit import DesignSystem import SwiftUI @@ -38,13 +37,7 @@ struct CommentRowView: View { } private var avatar: some View { - CachedAsyncImage(url: item.avatarURL) { image in - image.resizable() - } placeholder: { - Color(.secondarySystemBackground) - } - .frame(width: 40, height: 40) - .clipShape(Circle()) + CommentAvatarView(url: item.avatarURL) } @ViewBuilder diff --git a/Modules/Sources/WordPressComments/Views/CommentsHostingController.swift b/Modules/Sources/WordPressComments/Views/CommentsHostingController.swift index 477e81ce780e..e935dfb74df8 100644 --- a/Modules/Sources/WordPressComments/Views/CommentsHostingController.swift +++ b/Modules/Sources/WordPressComments/Views/CommentsHostingController.swift @@ -7,13 +7,33 @@ import WordPressCore /// `WordPress/Classes/ViewRelated/Comments/CommentsRouting.swift`. public enum CommentsHostingController { @MainActor - public static func make(client: WordPressClient) -> UIViewController { + public static func make( + client: WordPressClient, + makeContentRenderer: @escaping @MainActor () -> any CommentContentRendering, + tracker: any CommentsTracker + ) -> UIViewController { + let service = CommentsService(client: client) + let titleResolver = PostTitleResolver(fetcher: PostTitleResolver.liveFetcher(client: client)) + + // The router builds each detail screen (recursively, so a parent comment + // pushes onto the same stack). The tab view retains it for the + // controller's lifetime. + let router = CommentsDetailRouter( + service: service, + capabilities: CommentsCapabilities(client: client), + titleResolver: titleResolver, + tracker: tracker, + makeContentRenderer: makeContentRenderer + ) + let view = CommentsTabView( - service: CommentsService(client: client), - titleResolver: PostTitleResolver(fetcher: PostTitleResolver.liveFetcher(client: client)) + service: service, + titleResolver: titleResolver, + router: router ) let host = UIHostingController(rootView: view) host.navigationItem.largeTitleDisplayMode = .never + router.host = host return host } } diff --git a/Modules/Sources/WordPressComments/Views/CommentsListView.swift b/Modules/Sources/WordPressComments/Views/CommentsListView.swift index b4a988ee1e77..81bd5be872de 100644 --- a/Modules/Sources/WordPressComments/Views/CommentsListView.swift +++ b/Modules/Sources/WordPressComments/Views/CommentsListView.swift @@ -3,11 +3,23 @@ import SwiftUI struct CommentsListView: View { @ObservedObject var viewModel: CommentsListViewModel @ObservedObject var titleResolver: PostTitleResolver + /// Pushes the detail screen for a tapped row. + let openComment: (Int64, CommentListItem?) -> Void var body: some View { List { ForEach(viewModel.items) { item in - CommentRowView(item: item, titleState: titleResolver.titleState(for: item.postID)) + // A Button (plain style) so assistive tech announces and + // activates the row as a control; the plain style keeps the + // visual layout unchanged and the rectangle content shape keeps + // the whole row tappable. + Button { + openComment(item.id, item) + } label: { + CommentRowView(item: item, titleState: titleResolver.titleState(for: item.postID)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } if viewModel.canLoadMore { ProgressView() diff --git a/Modules/Sources/WordPressComments/Views/CommentsTabView.swift b/Modules/Sources/WordPressComments/Views/CommentsTabView.swift index bc417eb0eebc..9fc5eeb12d74 100644 --- a/Modules/Sources/WordPressComments/Views/CommentsTabView.swift +++ b/Modules/Sources/WordPressComments/Views/CommentsTabView.swift @@ -7,7 +7,16 @@ struct CommentsTabView: View { @State private var viewModels: [CommentsListFilter: CommentsListViewModel] @State private var titleResolver: PostTitleResolver - init(service: any CommentsServiceProtocol, titleResolver: PostTitleResolver) { + /// A tapped row (and, recursively, a parent comment) pushes a detail screen + /// through it. + private let router: CommentsDetailRouter + + init( + service: any CommentsServiceProtocol, + titleResolver: PostTitleResolver, + router: CommentsDetailRouter + ) { + self.router = router _titleResolver = State(initialValue: titleResolver) // The All view model is built first so Pending and Approved can seed @@ -51,7 +60,11 @@ struct CommentsTabView: View { VStack(spacing: 0) { tabBar if let viewModel = viewModels[selectedFilter] { - CommentsListView(viewModel: viewModel, titleResolver: titleResolver) + CommentsListView( + viewModel: viewModel, + titleResolver: titleResolver, + openComment: { router.open(id: $0, seed: $1) } + ) } } .navigationTitle(Strings.title) diff --git a/Modules/Sources/WordPressComments/Views/Detail/CommentAuthorHeader.swift b/Modules/Sources/WordPressComments/Views/Detail/CommentAuthorHeader.swift new file mode 100644 index 000000000000..fa112bc50239 --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/Detail/CommentAuthorHeader.swift @@ -0,0 +1,94 @@ +import SwiftUI + +/// The pinned author row: avatar, name, resolved post title, and relative +/// date. Tapping it reveals the author info sheet (full date plus the contact +/// details edit context carries). +struct CommentAuthorHeader: View { + let header: CommentDetailViewModel.Header + let titleState: PostTitleResolver.TitleState + /// Present once the authoritative fetch lands; supplies the website, email, + /// and IP the info sheet shows. + let detail: CommentDetail? + + @State private var isInfoPresented = false + + var body: some View { + Button { + isInfoPresented = true + } label: { + content + } + .buttonStyle(.plain) + .sheet(isPresented: $isInfoPresented) { + CommentAuthorInfoSheet(header: header, detail: detail) + } + } + + private var content: some View { + HStack(alignment: .top, spacing: 12) { + CommentAvatarView(url: header.avatarURL) + VStack(alignment: .leading, spacing: 2) { + Text(header.authorName) + .font(.subheadline.weight(.semibold)) + postLine + Text(header.date, format: .relative(presentation: .named)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Image(systemName: "info.circle") + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var postLine: some View { + switch titleState { + case .resolved(let title): + postLineText(title) + case .loading: + postLineText("Sample Post Title") + .redacted(reason: .placeholder) + case .unavailable: + EmptyView() + } + } + + private func postLineText(_ title: String) -> some View { + Text(String(format: Strings.authorHeaderOnPost, title)) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(1) + } +} + +/// Author details presented as a medium sheet. Website, email, and IP appear +/// only when the fetched detail carries them (email and IP require edit +/// context). +private struct CommentAuthorInfoSheet: View { + let header: CommentDetailViewModel.Header + let detail: CommentDetail? + + var body: some View { + NavigationStack { + List { + LabeledContent(Strings.infoDateLabel, value: header.date.formatted(.dateTime)) + if let url = detail?.authorURL { + Link(destination: url) { + LabeledContent(Strings.infoWebsiteLabel, value: url.absoluteString) + } + } + if let email = detail?.authorEmail { + LabeledContent(Strings.infoEmailLabel, value: email) + } + if let ip = detail?.authorIP { + LabeledContent(Strings.infoIPLabel, value: ip) + } + } + .navigationTitle(header.authorName) + .navigationBarTitleDisplayMode(.inline) + } + .presentationDetents([.medium]) + } +} diff --git a/Modules/Sources/WordPressComments/Views/Detail/CommentContentRegion.swift b/Modules/Sources/WordPressComments/Views/Detail/CommentContentRegion.swift new file mode 100644 index 000000000000..5bcd6fc7f3f3 --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/Detail/CommentContentRegion.swift @@ -0,0 +1,33 @@ +import SwiftUI +import UIKit +import WordPressUI + +/// Hosts the injected comment content renderer as a fixed, edge-pinned +/// subview. The renderer scrolls internally (its contract), so this is the +/// only scroll surface for the comment body. `render(html:)` runs on the first +/// update and whenever the HTML changes. +struct CommentContentRegion: UIViewRepresentable { + let renderer: any CommentContentRendering + let html: String + + func makeUIView(context: Context) -> UIView { + let container = UIView() + container.addSubview(renderer.view) + renderer.view.pinEdges() + return container + } + + func updateUIView(_ uiView: UIView, context: Context) { + guard context.coordinator.lastRenderedHTML != html else { return } + context.coordinator.lastRenderedHTML = html + renderer.render(html: html) + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + final class Coordinator { + var lastRenderedHTML: String? + } +} diff --git a/Modules/Sources/WordPressComments/Views/Detail/CommentDetailView.swift b/Modules/Sources/WordPressComments/Views/Detail/CommentDetailView.swift new file mode 100644 index 000000000000..5aa7881ec0e0 --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/Detail/CommentDetailView.swift @@ -0,0 +1,168 @@ +import SwiftUI +import UIKit + +/// The fixed-region comment detail screen: a pinned status pill +/// and author header, an optional "In reply to" strip, the internally +/// scrolling content region, and the navigation-bar share action. +struct CommentDetailView: View { + @StateObject private var viewModel: CommentDetailViewModel + @ObservedObject private var titleResolver: PostTitleResolver + + /// Recursive: tapping the parent strip pushes another detail screen for the + /// parent comment. + private let openComment: (Int64, CommentListItem?) -> Void + /// Built by the router once per screen; the content region keeps it for + /// the screen's lifetime. + private let renderer: any CommentContentRendering + + init( + viewModel: CommentDetailViewModel, + titleResolver: PostTitleResolver, + renderer: any CommentContentRendering, + openComment: @escaping (Int64, CommentListItem?) -> Void + ) { + _viewModel = StateObject(wrappedValue: viewModel) + self.titleResolver = titleResolver + self.renderer = renderer + self.openComment = openComment + } + + var body: some View { + fixedRegions + .toolbar { shareToolbarItem } + .navigationBarTitleDisplayMode(.inline) + .task { await viewModel.onAppear() } + } + + private var fixedRegions: some View { + VStack(spacing: 0) { + if let header = viewModel.header { + VStack(alignment: .leading, spacing: 12) { + CommentStatusPill(status: header.status) + CommentAuthorHeader( + header: header, + titleState: titleResolver.titleState(for: header.postID), + detail: loadedDetail + ) + } + .padding(.horizontal) + .padding(.vertical, 12) + } + if let parent = viewModel.parentPreview { + Divider() + CommentParentStrip(parent: parent) { openComment(parent.id, parent) } + .padding(.horizontal) + .padding(.vertical, 10) + } + Divider() + contentRegion + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + @ViewBuilder + private var contentRegion: some View { + switch viewModel.content { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed: + failureView + case .loaded(let detail): + CommentContentRegion(renderer: renderer, html: detail.contentHTML) + .padding(.horizontal) + } + } + + private var failureView: some View { + ContentUnavailableView { + Label(Strings.detailErrorTitle, systemImage: "exclamationmark.triangle") + } actions: { + Button(Strings.errorRetry) { + Task { await viewModel.retry() } + } + .buttonStyle(.borderedProminent) + } + } + + @ToolbarContentBuilder + private var shareToolbarItem: some ToolbarContent { + if let link = loadedDetail?.link { + ToolbarItem(placement: .topBarTrailing) { + ShareLink(item: link) + } + } + } + + private var loadedDetail: CommentDetail? { + if case .loaded(let detail) = viewModel.content { return detail } + return nil + } +} + +#if DEBUG +/// Renders comment HTML as plain text inside a scroll view. Stands in for the +/// production WebKit-backed renderer so the preview stays self-contained. +private final class StubContentRenderer: NSObject, CommentContentRendering { + let scrollView = UIScrollView() + private let label = UILabel() + + var view: UIView { scrollView } + var onLinkTapped: ((URL) -> Void)? + + override init() { + super.init() + label.numberOfLines = 0 + label.translatesAutoresizingMaskIntoConstraints = false + scrollView.addSubview(label) + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor, constant: 16), + label.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor, constant: -16), + label.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor, constant: 16), + label.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor, constant: -16), + label.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor, constant: -32) + ]) + } + + func render(html: String) { + label.text = html.makePlainText() + } +} + +@MainActor +private final class PreviewCommentsService: CommentsServiceProtocol { + func listComments(filter: CommentsListFilter, nextPage: CommentsPageToken?) async throws -> CommentsPage { + CommentsPage(items: [], nextPage: nil) + } + + func fetchComment(id: Int64, allowsEditContext: Bool) async throws -> CommentDetail { + .preview(id: id, status: .pending) + } +} + +private struct PreviewCapabilities: CommentsCapabilitiesProtocol { + func canModerateComments() async -> Bool { true } +} + +#Preview { + let service = PreviewCommentsService() + let titleResolver = PostTitleResolver(fetcher: { _ in + PostTitleResolver.FetchResult(titles: [10: "Reviewing the 2027 Upgrade"]) + }) + let viewModel = CommentDetailViewModel( + commentID: 1, + seed: nil, + service: service, + capabilities: PreviewCapabilities(), + titleResolver: titleResolver + ) + return NavigationStack { + CommentDetailView( + viewModel: viewModel, + titleResolver: titleResolver, + renderer: StubContentRenderer(), + openComment: { _, _ in } + ) + } +} +#endif diff --git a/Modules/Sources/WordPressComments/Views/Detail/CommentParentStrip.swift b/Modules/Sources/WordPressComments/Views/Detail/CommentParentStrip.swift new file mode 100644 index 000000000000..ecb5e289b983 --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/Detail/CommentParentStrip.swift @@ -0,0 +1,37 @@ +import SwiftUI + +/// The "In reply to" strip shown above the content when the comment has a +/// parent. Tapping it pushes the parent comment via the recursive +/// `openComment` closure. +struct CommentParentStrip: View { + let parent: CommentListItem + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 8) { + Text(text) + .font(.footnote) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.secondary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var text: AttributedString { + var result = AttributedString(String(format: Strings.inReplyToFormat, parent.authorName)) + if let range = result.range(of: parent.authorName) { + result[range].font = .footnote.weight(.semibold) + } + var snippet = AttributedString(": \(parent.snippet)") + snippet.foregroundColor = .secondary + result.append(snippet) + return result + } +} diff --git a/Modules/Sources/WordPressComments/Views/Detail/CommentStatusPill.swift b/Modules/Sources/WordPressComments/Views/Detail/CommentStatusPill.swift new file mode 100644 index 000000000000..5ba79818b8d0 --- /dev/null +++ b/Modules/Sources/WordPressComments/Views/Detail/CommentStatusPill.swift @@ -0,0 +1,39 @@ +import DesignSystem +import SwiftUI + +/// The pinned status pill above the author header. +struct CommentStatusPill: View { + let status: CommentListItem.Status + + var body: some View { + Text(label) + .font(.footnote.weight(.semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(tint.opacity(0.15), in: Capsule()) + .accessibilityLabel(label) + } + + private var tint: Color { + switch status { + case .approved: Color(UIAppColor.green(.shade40)) + case .pending: Color(UIAppColor.yellow(.shade20)) + case .spam: Color(UIAppColor.orange(.shade40)) + case .trash: Color(UIAppColor.red(.shade40)) + case .other: Color(UIAppColor.gray(.shade30)) + } + } + + private var label: String { + switch status { + case .approved: Strings.statusApproved + case .pending: Strings.statusPending + case .spam: Strings.statusSpam + case .trash: Strings.statusTrash + // A custom/unknown status is shown verbatim: the app can't localize a + // value it doesn't model. + case .other(let raw): raw + } + } +} diff --git a/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift b/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift index a034b43d261a..99653d976430 100644 --- a/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift +++ b/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift @@ -11,12 +11,15 @@ public final class WebCommentContentRenderer: NSObject, CommentContentRenderer { public var view: UIView { webView } - private let webView = WKWebView(frame: .zero, configuration: { - let configuration = WKWebViewConfiguration() - configuration.allowsInlineMediaPlayback = true - configuration.defaultWebpagePreferences.allowsContentJavaScript = true - return configuration - }()) + private let webView = WKWebView( + frame: .zero, + configuration: { + let configuration = WKWebViewConfiguration() + configuration.allowsInlineMediaPlayback = true + configuration.defaultWebpagePreferences.allowsContentJavaScript = true + return configuration + }() + ) /// It can't be changed at the moment, but this capability was included from the /// start, and this implementation continues supporting it. @@ -38,7 +41,7 @@ public final class WebCommentContentRenderer: NSObject, CommentContentRenderer { private var isReloadNeeded = false // MARK: Methods - public override init() { + public init(isScrollEnabled: Bool = false) { super.init() webView.isInspectable = true @@ -46,12 +49,30 @@ public final class WebCommentContentRenderer: NSObject, CommentContentRenderer { webView.isOpaque = false // gets rid of the white flash upon content load in dark mode. webView.translatesAutoresizingMaskIntoConstraints = false webView.navigationDelegate = self - webView.scrollView.bounces = false - webView.scrollView.showsVerticalScrollIndicator = false webView.scrollView.backgroundColor = .clear - webView.scrollView.isScrollEnabled = false + webView.scrollView.isScrollEnabled = isScrollEnabled + webView.scrollView.bounces = isScrollEnabled + // WebKit turns this on for its scroll view; off so a comment that + // fits its region stays still. + webView.scrollView.alwaysBounceVertical = false + if isScrollEnabled { + // A scrolling host owns the layout. Without this, the safe-area + // inset UIKit adds gives a viewport-tall document a scrollable + // range, so even a one-line comment rubber-bands. + webView.scrollView.contentInsetAdjustmentBehavior = .never + } + webView.scrollView.showsVerticalScrollIndicator = isScrollEnabled + + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil + ) + } - NotificationCenter.default.addObserver(self, selector: #selector(applicationWillEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil) + public override convenience init() { + self.init(isScrollEnabled: false) } public func render(comment: String) { @@ -101,8 +122,9 @@ extension WebCommentContentRenderer: WKNavigationDelegate { // `document.body` does not capture margins on tag, so we'll use `document.documentElement` instead. webView.evaluateJavaScript("document.documentElement.scrollHeight") { [weak self] height, _ in guard let self, - let height = height as? CGFloat, - navigation === self.currentNavigation else { + let height = height as? CGFloat, + navigation === self.currentNavigation + else { return } @@ -115,7 +137,10 @@ extension WebCommentContentRenderer: WKNavigationDelegate { } } - public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { + public func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction + ) async -> WKNavigationActionPolicy { switch navigationAction.navigationType { case .other: // allow local file requests. @@ -149,17 +174,18 @@ private extension WebCommentContentRenderer { func formattedHTMLString(for comment: String) -> String { // remove empty HTML elements from the `content`, as the content often contains empty paragraph elements which adds unnecessary padding/margin. // `rawContent` does not have this problem, but it's not used because `rawContent` gets rid of links ( tags) for mentions. - let comment = comment + let comment = + comment .replacingOccurrences(of: Self.emptyElementRegexPattern, with: "", options: [.regularExpression]) .trimmingCharacters(in: .whitespacesAndNewlines) return """ - - \(makeHead()) - - \(comment) - - - """ + + \(makeHead()) + + \(comment) + + + """ } static let emptyElementRegexPattern = "<[a-z]+>()+<\\/[a-z]+>" @@ -175,14 +201,16 @@ private extension WebCommentContentRenderer { } private func actuallyMakeHead() -> String { - let meta = "width=device-width,initial-scale=\(displaySettings.size.scale),maximum-scale=\(displaySettings.size.scale),user-scalable=no,shrink-to-fit=no" + let meta = + "width=device-width,initial-scale=\(displaySettings.size.scale),maximum-scale=\(displaySettings.size.scale),user-scalable=no,shrink-to-fit=no" let styles = displaySettings.makeStyles(tintColor: webView.tintColor) return String(format: Self.headTemplate, meta, styles) } private static let headTemplate: String = { guard let fileURL = Bundle.module.url(forResource: "gutenbergCommentHeadTemplate", withExtension: "html"), - let string = try? String(contentsOf: fileURL) else { + let string = try? String(contentsOf: fileURL) + else { assertionFailure("template missing") return "" } diff --git a/Modules/Tests/WordPressCommentsTests/CommentDetailTests.swift b/Modules/Tests/WordPressCommentsTests/CommentDetailTests.swift new file mode 100644 index 000000000000..2c77e3519586 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentDetailTests.swift @@ -0,0 +1,31 @@ +import Testing +import WordPressAPI +@testable import WordPressComments + +struct CommentDetailTests { + @Test func viewContextHasNoEditFields() { + let detail = CommentDetail(comment: .detailBuilder(id: 5, parent: 0, status: .approved)) + #expect(!detail.hasEditContext) + #expect(detail.authorEmail == nil) + #expect(detail.authorIP == nil) + #expect(detail.parentID == nil) + } + + @Test func editContextNormalizesEmptyStringsToNil() { + let detail = CommentDetail(comment: .editDetailBuilder(id: 5, email: "", ip: "203.0.113.9")) + #expect(detail.hasEditContext) + #expect(detail.authorEmail == nil) + #expect(detail.authorIP == "203.0.113.9") + } + + @Test func parentAndCustomStatusMapping() { + let detail = CommentDetail(comment: .detailBuilder(id: 6, parent: 3, status: .custom("post-trashed"))) + #expect(detail.parentID == 3) + #expect(detail.status == .other("post-trashed")) + } + + @Test func emptyAuthorNameFallsBackToAnonymous() { + let detail = CommentDetail(comment: .detailBuilder(authorName: "")) + #expect(detail.authorName == Strings.anonymousAuthor) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentDetailViewModelTests.swift b/Modules/Tests/WordPressCommentsTests/CommentDetailViewModelTests.swift new file mode 100644 index 000000000000..104bd938b94f --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentDetailViewModelTests.swift @@ -0,0 +1,113 @@ +import Foundation +import Testing +import WordPressAPI +@testable import WordPressComments + +@MainActor +struct CommentDetailViewModelTests { + @Test func seededHeaderPaintsBeforeFetch() { + let seed = makeItem(id: 1, authorName: "Ada", post: 42, status: .hold) + let vm = makeVM(seed: seed, service: FakeCommentsService()) + + #expect(vm.header?.authorName == "Ada") + #expect(vm.header?.postID == 42) + #expect(vm.header?.status == .pending) + } + + @Test func seedlessHeaderIsNilUntilFetch() async { + let service = FakeCommentsService() + service.fetchCommentResult = .success(makeDetail(id: 1, status: .approved)) + let vm = makeVM(seed: nil, service: service) + + #expect(vm.header == nil) + + await vm.onAppear() + #expect(vm.header?.status == .approved) + } + + @Test func capabilityTrueFetchesEditContext() async { + let service = FakeCommentsService() + service.fetchCommentResult = .success(makeDetail(id: 1, editContext: true)) + let capabilities = FakeCommentsCapabilities() + capabilities.canModerate = true + let vm = makeVM(service: service, capabilities: capabilities) + + await vm.onAppear() + + #expect(service.fetchCommentInvocations.last?.allowsEditContext == true) + } + + @Test func capabilityFalseFetchesViewContext() async { + let service = FakeCommentsService() + service.fetchCommentResult = .success(makeDetail(id: 1)) + let capabilities = FakeCommentsCapabilities() + capabilities.canModerate = false + let vm = makeVM(service: service, capabilities: capabilities) + + await vm.onAppear() + + #expect(service.fetchCommentInvocations.last?.allowsEditContext == false) + } + + @Test func duplicateAppearanceDoesNotRefetchAfterSuccess() async { + let service = FakeCommentsService() + service.fetchCommentResult = .success(makeDetail(id: 1)) + let vm = makeVM(service: service) + + await vm.onAppear() + await vm.onAppear() + + #expect(service.fetchCommentInvocations.count == 1) + } + + @Test func duplicateAppearanceDoesNotStartConcurrentFetch() async { + let service = BlockingCommentsService() + let vm = makeVM(service: service) + + async let firstAppearance: Void = vm.onAppear() + while service.fetchCommentInvocations.isEmpty { await Task.yield() } + await vm.onAppear() + + #expect(service.fetchCommentInvocations.count == 1) + service.resolveFetch(callIndex: 0, with: makeDetail(id: 1)) + await firstAppearance + } + + @Test func failedFetchShowsFailureAndRetryLoadsDetail() async { + let service = FakeCommentsService() + service.fetchCommentResult = .failure(FakeServiceError()) + let vm = makeVM(service: service) + + await vm.onAppear() + #expect(vm.content == .failed) + + service.fetchCommentResult = .success(makeDetail(id: 1, status: .approved)) + await vm.retry() + + #expect(vm.content == .loaded(makeDetail(id: 1, status: .approved))) + } + + @Test func parentPreviewLoadedForReply() async { + let service = FakeCommentsService() + service.fetchCommentResultsByID = [ + 1: .success(makeDetail(id: 1, parent: 5)), + 5: .success(makeDetail(id: 5, status: .approved)) + ] + let vm = makeVM(service: service) + + await vm.onAppear() + + #expect(vm.parentPreview?.id == 5) + #expect(service.fetchCommentInvocations.contains { $0.id == 5 && $0.allowsEditContext == false }) + } + + @Test func parentFetchFailureHidesStrip() async { + let service = FakeCommentsService() + service.fetchCommentResultsByID = [1: .success(makeDetail(id: 1, parent: 5))] + let vm = makeVM(service: service) + + await vm.onAppear() + + #expect(vm.parentPreview == nil) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentListItemTests.swift b/Modules/Tests/WordPressCommentsTests/CommentListItemTests.swift index b541712ba3f3..99628662deaf 100644 --- a/Modules/Tests/WordPressCommentsTests/CommentListItemTests.swift +++ b/Modules/Tests/WordPressCommentsTests/CommentListItemTests.swift @@ -34,6 +34,11 @@ struct CommentListItemTests { #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) + #expect(CommentListItem(comment: makeComment(status: .custom("weird"))).status == .other("weird")) + } + + @Test func customStatusPreservesRawValue() { + let status = CommentListItem.Status(CommentStatus.custom("post-trashed")) + #expect(status == .other("post-trashed")) } } diff --git a/Modules/Tests/WordPressCommentsTests/CommentsAnalyticsTests.swift b/Modules/Tests/WordPressCommentsTests/CommentsAnalyticsTests.swift new file mode 100644 index 000000000000..6fe188a12fca --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentsAnalyticsTests.swift @@ -0,0 +1,32 @@ +import Foundation +import Testing +import WordPressAPI +@testable import WordPressComments + +@MainActor +struct CommentsAnalyticsTests { + @Test func detailViewedFiresOnceOnFirstSuccessfulFetch() async { + let service = FakeCommentsService() + service.fetchCommentResult = .success(makeDetail(id: 7, post: 42)) + let spy = SpyCommentsTracker() + let vm = makeVM(commentID: 7, service: service, tracker: spy) + + await vm.onAppear() + // A second appearance is a no-op after the first success; the event must + // not fire twice. + await vm.onAppear() + + #expect(spy.trackedEvents == [.detailViewed(commentID: 7, postID: 42)]) + } + + @Test func detailViewedDoesNotFireWhenFetchFails() async { + let service = FakeCommentsService() + service.fetchCommentResult = .failure(FakeServiceError()) + let spy = SpyCommentsTracker() + let vm = makeVM(commentID: 7, service: service, tracker: spy) + + await vm.onAppear() + + #expect(spy.trackedEvents.isEmpty) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/CommentsDetailRouterTests.swift b/Modules/Tests/WordPressCommentsTests/CommentsDetailRouterTests.swift new file mode 100644 index 000000000000..48a44e3a0e69 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/CommentsDetailRouterTests.swift @@ -0,0 +1,24 @@ +import Foundation +import Testing +import UIKit +@testable import WordPressComments + +@MainActor +struct CommentsDetailRouterTests { + @Test func openPushesDetailOntoHostNavigationStack() { + let host = UIViewController() + let navigation = UINavigationController(rootViewController: host) + let router = CommentsDetailRouter( + service: FakeCommentsService(), + capabilities: FakeCommentsCapabilities(), + titleResolver: PostTitleResolver(fetcher: { _ in .init(titles: [:]) }), + tracker: nil, + makeContentRenderer: { FakeContentRenderer() } + ) + router.host = host + + router.open(id: 1, seed: nil) + + #expect(navigation.viewControllers.count == 2) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/Support/BlockingCommentsService.swift b/Modules/Tests/WordPressCommentsTests/Support/BlockingCommentsService.swift index 7bcfc90c0160..baf14c148f1e 100644 --- a/Modules/Tests/WordPressCommentsTests/Support/BlockingCommentsService.swift +++ b/Modules/Tests/WordPressCommentsTests/Support/BlockingCommentsService.swift @@ -3,18 +3,30 @@ 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. +/// test can interleave in-flight calls deterministically (for example a +/// `loadMore` with a `refresh`, or two detail appearances). @MainActor final class BlockingCommentsService: CommentsServiceProtocol { private var continuations: [CheckedContinuationHello world
", + post: Int64 = 10, + parent: Int64 = 0, + status: CommentStatus = .approved, + date: Date = Date(timeIntervalSince1970: 1_700_000_000) + ) -> CommentWithViewContext { + CommentWithViewContext( + id: id, + author: 1, + authorName: authorName, + authorUrl: authorUrl, + content: CommentContentWithViewContext(rendered: content), + date: "2023-11-14T22:13:20", + dateGmt: date, + link: "https://example.com/?p=\(post)#comment-\(id)", + parent: parent, + post: post, + status: status, + commentType: .comment, + authorAvatarUrls: avatar.map { [.size96: $0] } ?? [:], + additionalFields: WpAdditionalFields() + ) + } +} + +extension CommentWithEditContext { + static func editDetailBuilder( + id: Int64 = 1, + authorName: String = "Author", + authorUrl: String = "", + email: String = "author@example.com", + ip: String = "", + avatar: String? = "https://example.com/avatar.png", + content: String = "Hello world
", + post: Int64 = 10, + parent: Int64 = 0, + status: CommentStatus = .approved, + date: Date = Date(timeIntervalSince1970: 1_700_000_000) + ) -> CommentWithEditContext { + CommentWithEditContext( + id: id, + author: 1, + authorEmail: email, + authorIp: ip, + authorName: authorName, + authorUrl: authorUrl, + authorUserAgent: "", + content: CommentContentWithEditContext(raw: content, rendered: content), + date: "2023-11-14T22:13:20", + dateGmt: date, + link: "https://example.com/?p=\(post)#comment-\(id)", + parent: parent, + post: post, + status: status, + commentType: .comment, + authorAvatarUrls: avatar.map { [.size96: $0] } ?? [:], + additionalFields: WpAdditionalFields() + ) + } +} diff --git a/Modules/Tests/WordPressCommentsTests/Support/CommentDetailTestHelpers.swift b/Modules/Tests/WordPressCommentsTests/Support/CommentDetailTestHelpers.swift new file mode 100644 index 000000000000..57d43c32eef7 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/Support/CommentDetailTestHelpers.swift @@ -0,0 +1,39 @@ +import Foundation +import WordPressAPI +@testable import WordPressComments + +@MainActor +func makeResolver() -> PostTitleResolver { + PostTitleResolver(fetcher: { _ in PostTitleResolver.FetchResult(titles: [:]) }) +} + +func makeDetail( + id: Int64 = 1, + parent: Int64 = 0, + post: Int64 = 10, + status: CommentStatus = .approved, + editContext: Bool = false +) -> CommentDetail { + if editContext { + return CommentDetail(comment: .editDetailBuilder(id: id, post: post, parent: parent, status: status)) + } + return CommentDetail(comment: .detailBuilder(id: id, post: post, parent: parent, status: status)) +} + +@MainActor +func makeVM( + commentID: Int64 = 1, + seed: CommentListItem? = nil, + service: any CommentsServiceProtocol, + capabilities: FakeCommentsCapabilities = FakeCommentsCapabilities(), + tracker: (any CommentsTracker)? = nil +) -> CommentDetailViewModel { + CommentDetailViewModel( + commentID: commentID, + seed: seed, + service: service, + capabilities: capabilities, + titleResolver: makeResolver(), + tracker: tracker + ) +} diff --git a/Modules/Tests/WordPressCommentsTests/Support/FakeCommentsCapabilities.swift b/Modules/Tests/WordPressCommentsTests/Support/FakeCommentsCapabilities.swift new file mode 100644 index 000000000000..14a5ee3a0619 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/Support/FakeCommentsCapabilities.swift @@ -0,0 +1,10 @@ +@testable import WordPressComments + +@MainActor +final class FakeCommentsCapabilities: CommentsCapabilitiesProtocol { + var canModerate = true + + func canModerateComments() async -> Bool { + canModerate + } +} diff --git a/Modules/Tests/WordPressCommentsTests/Support/FakeCommentsService.swift b/Modules/Tests/WordPressCommentsTests/Support/FakeCommentsService.swift index 84adf8e30de4..109e2a95e141 100644 --- a/Modules/Tests/WordPressCommentsTests/Support/FakeCommentsService.swift +++ b/Modules/Tests/WordPressCommentsTests/Support/FakeCommentsService.swift @@ -9,6 +9,11 @@ final class FakeCommentsService: CommentsServiceProtocol { var queuedResults: [Result