From 5a927e926bb498797926cab0c7e59db1dc41c4da Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 13:27:16 +1200 Subject: [PATCH 1/9] Format WebCommentContentRenderer --- .../Views/WebCommentContentRenderer.swift | 55 ++++++++++++------- 1 file changed, 35 insertions(+), 20 deletions(-) diff --git a/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift b/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift index a034b43d261a..004cfadef961 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. @@ -51,7 +54,12 @@ public final class WebCommentContentRenderer: NSObject, CommentContentRenderer { webView.scrollView.backgroundColor = .clear webView.scrollView.isScrollEnabled = false - 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 func render(comment: String) { @@ -101,8 +109,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 +124,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 +161,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 +188,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 "" } From fa2ba71e38447e719267f2168f462d1934c0399c Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 15:24:09 +1200 Subject: [PATCH 2/9] Let WebCommentContentRenderer scroll its content The renderer was built for Reader cells that size themselves to the content. Add an `isScrollEnabled` option for hosts that pin the web view to a fixed region and let it scroll internally, with the native bounce (on overflow) and scroll indicator. It defaults to the previous behavior. --- .../Views/WebCommentContentRenderer.swift | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift b/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift index 004cfadef961..99653d976430 100644 --- a/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift +++ b/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift @@ -41,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 @@ -49,10 +49,19 @@ 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, @@ -62,6 +71,10 @@ public final class WebCommentContentRenderer: NSObject, CommentContentRenderer { ) } + public override convenience init() { + self.init(isScrollEnabled: false) + } + public func render(comment: String) { self.comment = comment actuallyRender(comment: comment) From d41213fb2c8a08c636bf085e3585f3ae922e1e85 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 13:27:17 +1200 Subject: [PATCH 3/9] Preserve the raw value of custom comment statuses The detail screen shows unknown statuses verbatim, so `other` now carries the server's raw string. --- .../Sources/WordPressComments/Models/CommentListItem.swift | 6 +++--- .../WordPressCommentsTests/CommentListItemTests.swift | 7 ++++++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/Modules/Sources/WordPressComments/Models/CommentListItem.swift b/Modules/Sources/WordPressComments/Models/CommentListItem.swift index 643ab6dea481..54f3367b9b3c 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 @@ -55,14 +55,14 @@ struct CommentListItem: Identifiable, Equatable, Sendable { } } -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/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")) } } From 074772b56f90842115df138c0500644c48d5f26f Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 15:24:09 +1200 Subject: [PATCH 4/9] Share CommentListItem's avatar URL and snippet helpers Move the avatar double-optional flattening and the plain-text snippet rule out of the wire-type initializer so the detail model can reuse them, and treat a whitespace-only author name as anonymous. --- .../Models/CommentListItem.swift | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/Modules/Sources/WordPressComments/Models/CommentListItem.swift b/Modules/Sources/WordPressComments/Models/CommentListItem.swift index 54f3367b9b3c..3abb4c2f7ea5 100644 --- a/Modules/Sources/WordPressComments/Models/CommentListItem.swift +++ b/Modules/Sources/WordPressComments/Models/CommentListItem.swift @@ -42,17 +42,27 @@ 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) } + + + /// 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:)) + } } extension CommentListItem.Status { From 73954da81743d0e0630ca5280a00144056b8329a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 15:24:09 +1200 Subject: [PATCH 5/9] Extract CommentAvatarView from the list row --- .../Views/CommentAvatarView.swift | 17 +++++++++++++++++ .../Views/CommentRowView.swift | 9 +-------- 2 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 Modules/Sources/WordPressComments/Views/CommentAvatarView.swift 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 From e25ab741821679b0ca3f4f7b4fbb6b61d681114a Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 13:27:17 +1200 Subject: [PATCH 6/9] Add the comment detail model and fetch `CommentDetail` maps either the view- or edit-context wire type. The service fetches with edit context when the user can moderate comments and falls back to view context on a 401/403, so a stale capability degrades to a read-only detail instead of an error. --- .../Models/CommentDetail.swift | 124 ++++++++++++++++++ .../Services/CommentsCapabilities.swift | 18 +++ .../Services/CommentsService.swift | 42 ++++++ .../CommentDetailTests.swift | 31 +++++ .../Support/BlockingCommentsService.swift | 14 +- .../Support/CommentBuilders.swift | 67 ++++++++++ .../Support/FakeCommentsCapabilities.swift | 10 ++ .../Support/FakeCommentsService.swift | 11 ++ 8 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 Modules/Sources/WordPressComments/Models/CommentDetail.swift create mode 100644 Modules/Sources/WordPressComments/Services/CommentsCapabilities.swift create mode 100644 Modules/Tests/WordPressCommentsTests/CommentDetailTests.swift create mode 100644 Modules/Tests/WordPressCommentsTests/Support/FakeCommentsCapabilities.swift 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/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/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/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/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: [CheckedContinuation] = [] + private var fetchContinuations: [CheckedContinuation] = [] private(set) var callCount = 0 + private(set) var fetchCommentInvocations: [(id: Int64, allowsEditContext: Bool)] = [] func listComments(filter: CommentsListFilter, nextPage: CommentsPageToken?) async throws -> CommentsPage { callCount += 1 return try await withCheckedThrowingContinuation { continuations.append($0) } } + func fetchComment(id: Int64, allowsEditContext: Bool) async throws -> CommentDetail { + fetchCommentInvocations.append((id, allowsEditContext)) + return try await withCheckedThrowingContinuation { fetchContinuations.append($0) } + } + func resolve(callIndex: Int, with page: CommentsPage) { continuations[callIndex].resume(returning: page) } + + func resolveFetch(callIndex: Int, with detail: CommentDetail) { + fetchContinuations[callIndex].resume(returning: detail) + } } diff --git a/Modules/Tests/WordPressCommentsTests/Support/CommentBuilders.swift b/Modules/Tests/WordPressCommentsTests/Support/CommentBuilders.swift index 2388ce76c390..b83b5e214cba 100644 --- a/Modules/Tests/WordPressCommentsTests/Support/CommentBuilders.swift +++ b/Modules/Tests/WordPressCommentsTests/Support/CommentBuilders.swift @@ -38,3 +38,70 @@ func makeItem( ) -> CommentListItem { CommentListItem(comment: makeComment(id: id, authorName: authorName, post: post, status: status)) } + +extension CommentWithViewContext { + static func detailBuilder( + id: Int64 = 1, + authorName: String = "Author", + authorUrl: 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) + ) -> 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/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] = [] private(set) var requests: [(filter: CommentsListFilter, nextPage: CommentsPageToken?)] = [] + /// Per-id results take precedence; `fetchCommentResult` is the fallback. + var fetchCommentResultsByID: [Int64: Result] = [:] + var fetchCommentResult: Result? + private(set) var fetchCommentInvocations: [(id: Int64, allowsEditContext: Bool)] = [] + func listComments(filter: CommentsListFilter, nextPage: CommentsPageToken?) async throws -> CommentsPage { requests.append((filter, nextPage)) guard !queuedResults.isEmpty else { @@ -16,6 +21,12 @@ final class FakeCommentsService: CommentsServiceProtocol { } return try queuedResults.removeFirst().get() } + + func fetchComment(id: Int64, allowsEditContext: Bool) async throws -> CommentDetail { + fetchCommentInvocations.append((id, allowsEditContext)) + guard let result = fetchCommentResultsByID[id] ?? fetchCommentResult else { throw FakeServiceError() } + return try result.get() + } } func makePage(items: [CommentListItem], hasNext: Bool) -> CommentsPage { From a92f605d46d7be6af8f173e35225242f8c4bee78 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 13:27:17 +1200 Subject: [PATCH 7/9] Add the comment detail view model Resolves the moderation capability once, fetches the detail, keeps the list row's seed painted while the request is in flight, loads the parent comment for the "In reply to" strip, and tracks the first successful view. --- .../Models/CommentListItem.swift | 13 ++ .../Services/CommentsTracker.swift | 7 + .../ViewModels/CommentDetailViewModel.swift | 141 ++++++++++++++++++ .../CommentDetailViewModelTests.swift | 113 ++++++++++++++ .../CommentsAnalyticsTests.swift | 32 ++++ .../Support/CommentDetailTestHelpers.swift | 39 +++++ .../Support/FakeRenderingTracking.swift | 18 +++ 7 files changed, 363 insertions(+) create mode 100644 Modules/Sources/WordPressComments/Services/CommentsTracker.swift create mode 100644 Modules/Sources/WordPressComments/ViewModels/CommentDetailViewModel.swift create mode 100644 Modules/Tests/WordPressCommentsTests/CommentDetailViewModelTests.swift create mode 100644 Modules/Tests/WordPressCommentsTests/CommentsAnalyticsTests.swift create mode 100644 Modules/Tests/WordPressCommentsTests/Support/CommentDetailTestHelpers.swift create mode 100644 Modules/Tests/WordPressCommentsTests/Support/FakeRenderingTracking.swift diff --git a/Modules/Sources/WordPressComments/Models/CommentListItem.swift b/Modules/Sources/WordPressComments/Models/CommentListItem.swift index 3abb4c2f7ea5..c8eee7153032 100644 --- a/Modules/Sources/WordPressComments/Models/CommentListItem.swift +++ b/Modules/Sources/WordPressComments/Models/CommentListItem.swift @@ -50,6 +50,19 @@ struct CommentListItem: Identifiable, Equatable, Sendable { 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 { 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/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/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/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/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/FakeRenderingTracking.swift b/Modules/Tests/WordPressCommentsTests/Support/FakeRenderingTracking.swift new file mode 100644 index 000000000000..49d6d29cc964 --- /dev/null +++ b/Modules/Tests/WordPressCommentsTests/Support/FakeRenderingTracking.swift @@ -0,0 +1,18 @@ +import UIKit +@testable import WordPressComments + +// `CommentsTracker` is non-isolated, so this spy can't be `@MainActor` like the +// other fakes. `NSLock` enforces (rather than just documents) that concurrent +// `track(_:)` writes and `trackedEvents` reads are race-free. +final class SpyCommentsTracker: CommentsTracker, @unchecked Sendable { + private let lock = NSLock() + private var events: [CommentsTrackedEvent] = [] + + var trackedEvents: [CommentsTrackedEvent] { + lock.withLock { events } + } + + func track(_ event: CommentsTrackedEvent) { + lock.withLock { events.append(event) } + } +} From 9325a40e9c00a09468b1bbfa6a944165188c76f5 Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 15:24:09 +1200 Subject: [PATCH 8/9] Add the comment detail screen A fixed-region layout: pinned status pill and author header, an optional parent strip, and an injected content renderer that scrolls internally. The router owns the shared dependencies and pushes detail screens onto the list controller's navigation stack, so a parent comment can push recursively. --- .../Services/CommentContentRendering.swift | 11 ++ .../Services/CommentsDetailRouter.swift | 57 ++++++ .../WordPressComments/Strings/Strings.swift | 66 +++++++ .../Views/Detail/CommentAuthorHeader.swift | 94 ++++++++++ .../Views/Detail/CommentContentRegion.swift | 33 ++++ .../Views/Detail/CommentDetailView.swift | 168 ++++++++++++++++++ .../Views/Detail/CommentParentStrip.swift | 37 ++++ .../Views/Detail/CommentStatusPill.swift | 39 ++++ .../CommentsDetailRouterTests.swift | 24 +++ .../Support/FakeRenderingTracking.swift | 11 ++ 10 files changed, 540 insertions(+) create mode 100644 Modules/Sources/WordPressComments/Services/CommentContentRendering.swift create mode 100644 Modules/Sources/WordPressComments/Services/CommentsDetailRouter.swift create mode 100644 Modules/Sources/WordPressComments/Views/Detail/CommentAuthorHeader.swift create mode 100644 Modules/Sources/WordPressComments/Views/Detail/CommentContentRegion.swift create mode 100644 Modules/Sources/WordPressComments/Views/Detail/CommentDetailView.swift create mode 100644 Modules/Sources/WordPressComments/Views/Detail/CommentParentStrip.swift create mode 100644 Modules/Sources/WordPressComments/Views/Detail/CommentStatusPill.swift create mode 100644 Modules/Tests/WordPressCommentsTests/CommentsDetailRouterTests.swift 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/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/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/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/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/FakeRenderingTracking.swift b/Modules/Tests/WordPressCommentsTests/Support/FakeRenderingTracking.swift index 49d6d29cc964..6850dd7c9937 100644 --- a/Modules/Tests/WordPressCommentsTests/Support/FakeRenderingTracking.swift +++ b/Modules/Tests/WordPressCommentsTests/Support/FakeRenderingTracking.swift @@ -1,6 +1,17 @@ import UIKit @testable import WordPressComments +@MainActor +final class FakeContentRenderer: NSObject, CommentContentRendering { + let view = UIView() + var onLinkTapped: ((URL) -> Void)? + private(set) var renderedHTML: [String] = [] + + func render(html: String) { + renderedHTML.append(html) + } +} + // `CommentsTracker` is non-isolated, so this spy can't be `@MainActor` like the // other fakes. `NSLock` enforces (rather than just documents) that concurrent // `track(_:)` writes and `trackedEvents` reads are race-free. From 83b0fc1f638e902f449510279935f52f10e0781f Mon Sep 17 00:00:00 2001 From: Tony Li Date: Thu, 27 Aug 2026 15:24:09 +1200 Subject: [PATCH 9/9] Open comment detail from the list List rows become buttons that push the detail screen. The app supplies the web content renderer and the analytics adapter to the hosting controller. --- .../Views/CommentsHostingController.swift | 26 ++++++++++-- .../Views/CommentsListView.swift | 14 ++++++- .../Views/CommentsTabView.swift | 17 +++++++- .../Comments/CommentsRouting.swift | 18 +++++--- .../Comments/V2/CommentsTrackerAdapter.swift | 30 ++++++++++++++ .../CommentsWebContentRendererAdapter.swift | 41 +++++++++++++++++++ 6 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 WordPress/Classes/ViewRelated/Comments/V2/CommentsTrackerAdapter.swift create mode 100644 WordPress/Classes/ViewRelated/Comments/V2/CommentsWebContentRendererAdapter.swift 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/WordPress/Classes/ViewRelated/Comments/CommentsRouting.swift b/WordPress/Classes/ViewRelated/Comments/CommentsRouting.swift index a2d4ebff5bcb..2a96e54fb342 100644 --- a/WordPress/Classes/ViewRelated/Comments/CommentsRouting.swift +++ b/WordPress/Classes/ViewRelated/Comments/CommentsRouting.swift @@ -10,11 +10,13 @@ import WordPressData /// 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. +/// Deferred M1 limitation: users without the edit_posts capability get a 401 +/// from the list's Pending/Spam/Trash 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. M2 adds capability handling +/// only for the DETAIL screen (a /users/me fetch degrades it to a view-context, +/// read-only screen); the list's low-capability query handling stays a deferred +/// M1 limitation to revisit at rollout. @MainActor enum CommentsRouting { static func makeViewController(for blog: Blog) -> UIViewController? { @@ -25,6 +27,10 @@ enum CommentsRouting { return nil } let client = WordPressClientFactory.shared.instance(for: site) - return CommentsHostingController.make(client: client) + return CommentsHostingController.make( + client: client, + makeContentRenderer: { CommentsWebContentRendererAdapter() }, + tracker: CommentsTrackerAdapter(blogProperties: blog.analyticsProperties) + ) } } diff --git a/WordPress/Classes/ViewRelated/Comments/V2/CommentsTrackerAdapter.swift b/WordPress/Classes/ViewRelated/Comments/V2/CommentsTrackerAdapter.swift new file mode 100644 index 000000000000..ad9ba2a769a8 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/V2/CommentsTrackerAdapter.swift @@ -0,0 +1,30 @@ +import WordPressComments +import WordPressShared + +/// Maps `CommentsTrackedEvent` to the app's real analytics events, attaching the +/// site segmentation (blog_id, site_type) that legacy `CommentAnalytics` adds via +/// the blog-associated track overload. +struct CommentsTrackerAdapter: CommentsTracker { + /// Captured once at construction as a `Sendable` snapshot. + private let blogProperties: BlogAnalyticsProperties + + init(blogProperties: BlogAnalyticsProperties) { + self.blogProperties = blogProperties + } + + func track(_ event: CommentsTrackedEvent) { + let (analyticsEvent, commentID, postID): (WPAnalyticsEvent, Int64, Int64) = + switch event { + case .detailViewed(let commentID, let postID): (.commentViewed, commentID, postID) + } + WPAnalytics.track( + analyticsEvent, + properties: [ + "context": "sites", + WPAppAnalyticsKeyPostID: postID, + WPAppAnalyticsKeyCommentID: commentID + ], + blogProperties: blogProperties + ) + } +} diff --git a/WordPress/Classes/ViewRelated/Comments/V2/CommentsWebContentRendererAdapter.swift b/WordPress/Classes/ViewRelated/Comments/V2/CommentsWebContentRendererAdapter.swift new file mode 100644 index 000000000000..c245cfd75ec3 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Comments/V2/CommentsWebContentRendererAdapter.swift @@ -0,0 +1,41 @@ +import UIKit +import WebKit +import WordPressComments +import WordPressReader + +/// Adapts `WordPressReader.WebCommentContentRenderer` to the module's +/// `CommentContentRendering` seam. +/// TODO: extract `WebCommentContentRenderer` into a package module so +/// `WordPressComments` can depend on it directly instead of this adapter. +final class CommentsWebContentRendererAdapter: NSObject, CommentContentRendering { + private let renderer: WebCommentContentRenderer = { + let renderer = WebCommentContentRenderer(isScrollEnabled: true) + // Breathing room above and below the body; content still scrolls + // under the region's edges. Horizontal padding is the module's job. + (renderer.view as? WKWebView)?.scrollView.contentInset = UIEdgeInsets(top: 12, left: 0, bottom: 16, right: 0) + return renderer + }() + + var onLinkTapped: ((URL) -> Void)? + var view: UIView { renderer.view } + + override init() { + super.init() + renderer.delegate = self + } + + func render(html: String) { + renderer.render(comment: html) + } +} + +extension CommentsWebContentRendererAdapter: CommentContentRendererDelegate { + func renderer(_ renderer: CommentContentRenderer, asyncRenderCompletedWithHeight height: CGFloat, comment: String) { + // Height is irrelevant: the detail layout gives the web view a fixed + // region and lets it scroll internally. + } + + func renderer(_ renderer: CommentContentRenderer, interactedWithURL url: URL) { + onLinkTapped?(url) + } +}