Skip to content

Commit

Permalink
WebKit Reader Comments (Groundwork) (#24022)
Browse files Browse the repository at this point in the history
* Enable web-based rendering

* Disable animations when changing height for comments

* Add ReaderCommentsHelper for caching height for comment cells

* Add reuse of WKWebView instances

* Add NSCache-based cache for comments

* Mask the initial load

* Fix text-based rendering

* Add a note about self.tableView.alpha = 0.0;

* Add WordPressReader target

* Fix retain cycle

* Move templates to the new module

* Add a preview

* Add preview

* Extract CommentWebView to a separate file

* Add Gutenberg preview

* Add tint color for web view

* Log URLs

* Disable JavaScript just in case

* Cache html template and stylesheet

* Cleanup stylesheet creation a bit

* Extract ReaderDisplaySettings+WebKit

* Cache preprocessed <head>

* Rename ReaderDisplaySettings

* Fix compilation

* Disable FF

* Fix compilation
  • Loading branch information
kean authored Jan 31, 2025
1 parent f892b5f commit 342b8ba
Show file tree
Hide file tree
Showing 33 changed files with 665 additions and 619 deletions.
12 changes: 8 additions & 4 deletions Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ let package = Package(
.library(name: "WordPressFlux", targets: ["WordPressFlux"]),
.library(name: "WordPressShared", targets: ["WordPressShared"]),
.library(name: "WordPressUI", targets: ["WordPressUI"]),
.library(name: "WordPressReader", targets: ["WordPressReader"]),
],
dependencies: [
.package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"),
Expand Down Expand Up @@ -69,13 +70,15 @@ let package = Package(
.target(name: "WordPressTesting", resources: [.process("Resources")]),
.target(
name: "WordPressUI",
dependencies: [
"AsyncImageKit",
.target(name: "WordPressShared")
],
dependencies: ["AsyncImageKit", "WordPressShared"],
resources: [.process("Resources")],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.target(
name: "WordPressReader",
dependencies: ["AsyncImageKit", "WordPressUI", "WordPressShared"],
resources: [.process("Resources")]
),
.testTarget(name: "JetpackStatsWidgetsCoreTests", dependencies: [.target(name: "JetpackStatsWidgetsCore")], swiftSettings: [.swiftLanguageMode(.v5)]),
.testTarget(name: "DesignSystemTests", dependencies: [.target(name: "DesignSystem")], swiftSettings: [.swiftLanguageMode(.v5)]),
.testTarget(name: "WordPressFluxTests", dependencies: ["WordPressFlux"], swiftSettings: [.swiftLanguageMode(.v5)]),
Expand Down Expand Up @@ -156,6 +159,7 @@ enum XcodeSupport {
"JetpackStatsWidgetsCore",
"WordPressFlux",
"WordPressShared",
"WordPressReader",
"AsyncImageKit",
"WordPressUI",
"WordPressCore",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import UIKit

/// Defines methods related to Comment content rendering.
///
protocol CommentContentRenderer {
@MainActor
public protocol CommentContentRenderer: AnyObject {
var delegate: CommentContentRendererDelegate? { get set }

init(comment: Comment)
init()

/// Returns a view component that's configured to display the formatted content of the comment.
///
/// Note that the renderer *might* return a view with the wrong sizing at first, but it should update its delegate with the correct height
/// through the `renderer(_:asyncRenderCompletedWithHeight:)` method.
func render() -> UIView

/// Checks if the provided comment contains the same content as the current one that's processed by the renderer.
/// This is used as an optimization strategy by the caller to skip view creations if the rendered content matches the one provided in the parameter.
func matchesContent(from comment: Comment) -> Bool
func render(comment: String) -> UIView
}

protocol CommentContentRendererDelegate: AnyObject {
@MainActor
public protocol CommentContentRendererDelegate: AnyObject {
/// Called when the rendering process completes. Note that this method is only called when using complex rendering methods that involves
/// asynchronous operations, so the container can readjust its size at a later time.
func renderer(_ renderer: CommentContentRenderer, asyncRenderCompletedWithHeight height: CGFloat)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import UIKit

/// -warning: It's not designed to be used publically yet.
@MainActor
final class CommentWebView: UIView, CommentContentRendererDelegate {
let renderer = WebCommentContentRenderer()
let webView: UIView
lazy var heightConstraint = webView.heightAnchor.constraint(equalToConstant: 20)

init(comment: String) {
let webView = renderer.render(comment: comment)
self.webView = webView

super.init(frame: .zero)

renderer.delegate = self

addSubview(webView)
webView.pinEdges()

heightConstraint.isActive = true
}

required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: CommentContentRendererDelegate

func renderer(_ renderer: any CommentContentRenderer, interactedWithURL url: URL) {
print("interact:", url)
}

func renderer(_ renderer: any CommentContentRenderer, asyncRenderCompletedWithHeight height: CGFloat) {
heightConstraint.constant = height
}
}

@available(iOS 17, *)
#Preview("Plain Text") {
makeView(comment: "<p>Thank you so much! You should see it now &#8211; people are losing their minds!</p>\n")
}

@available(iOS 17, *)
#Preview("Gutenberg") {
makeView(comment: """
<p>Thank you for putting this together, I’m in support of all proposed improvements, we know that the current experience is less-than-ideal. </p><blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\"><p><strong>Get rid of This.</strong> We’re moving everything to That anyway, and this is our last remaining This instance in Jetpack. It’s not performing great, so let’s remove it.</p></blockquote><p><a href=\"https://tset.wordpress.com/mentions/test/\" class=\"__p2-hovercard mention\" data-type=\"fragment-mention\" data-username=\"tester\"><span class=\"mentions-prefix\">@</span>tester</a>‘s most recent review found <a href=\"https:://wordpress.com/" rel=\"nofollow ugc\">it failed to provide a valid response in more than half of interactions</a>.</p>
""")
}

@MainActor
private func makeView(comment: String) -> UIView {
let webView = CommentWebView(comment: comment)
webView.layer.borderColor = UIColor.opaqueSeparator.withAlphaComponent(0.66).cgColor
webView.layer.borderWidth = 0.5

let container = UIView()
container.addSubview(webView)
webView.pinEdges(insets: UIEdgeInsets(.all, 16))

return container
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import WebKit
import WordPressShared
import WordPressUI

/// Renders the comment body with a web view. Provides the best visual experience but has the highest performance cost.
@MainActor
public final class WebCommentContentRenderer: NSObject, CommentContentRenderer {
// MARK: Properties

public weak var delegate: CommentContentRendererDelegate?

private let webView = WKWebView(frame: .zero, configuration: {
let configuration = WKWebViewConfiguration()
configuration.allowsInlineMediaPlayback = true
configuration.defaultWebpagePreferences.allowsContentJavaScript = false
return configuration
}())

private var comment: String?

/// It can't be changed at the moment, but this capability was included from the
/// start, and this implementation continues supporting it.
private var displaySetting = ReaderDisplaySettings.standard

/// - warning: This has to be configured _before_ you render.
public var tintColor: UIColor {
get { webView.tintColor }
set {
webView.tintColor = newValue
cachedHead = nil
}
}

private var cachedHead: String?

// MARK: Methods

public required override init() {
super.init()

if #available(iOS 16.4, *) {
webView.isInspectable = true
}
webView.backgroundColor = .clear
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
}

public func render(comment: String) -> UIView {
guard self.comment != comment else {
return webView // Already rendering this comment
}
self.comment = comment

// - important: `wordPressSharedBundle` contains custom fonts
webView.loadHTMLString(formattedHTMLString(for: comment), baseURL: Bundle.wordPressSharedBundle.bundleURL)
return webView
}
}

// MARK: - WKNavigationDelegate

extension WebCommentContentRenderer: WKNavigationDelegate {
public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
// Wait until the HTML document finished loading.
// This also waits for all of resources within the HTML (images, video thumbnail images) to be fully loaded.
webView.evaluateJavaScript("document.readyState") { complete, _ in
guard complete != nil else {
return
}

// To capture the content height, the methods to use is either `document.body.scrollHeight` or `document.documentElement.scrollHeight`.
// `document.body` does not capture margins on <body> 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 else {
return
}

/// The display setting's custom size is applied through the HTML's initial-scale property
/// in the meta tag. The `scrollHeight` value seems to return the height as if it's at 1.0 scale,
/// so we'll need to add the custom scale into account.
let actualHeight = round(height * self.displaySetting.size.scale)
self.delegate?.renderer(self, asyncRenderCompletedWithHeight: actualHeight)
}
}
}

public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy {
switch navigationAction.navigationType {
case .other:
// allow local file requests.
return .allow
default:
guard let destinationURL = navigationAction.request.url else {
return .allow
}
self.delegate?.renderer(self, interactedWithURL: destinationURL)
return .cancel
}
}
}

private extension WebCommentContentRenderer {
/// Returns a formatted HTML string by loading the template for rich comment.
///
/// The method will try to return cached content if possible, by detecting whether the content matches the previous content.
/// If it's different (e.g. due to edits), it will reprocess the HTML string.
///
/// - Parameter content: The content value from the `Comment` object.
/// - Returns: Formatted HTML string to be displayed in the web view.
///
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 (<a> tags) for mentions.
let comment = comment
.replacingOccurrences(of: Self.emptyElementRegexPattern, with: "", options: [.regularExpression])
.trimmingCharacters(in: .whitespacesAndNewlines)
return """
<html dir="auto">
\(makeHead())
<body>
\(comment)
</body>
</html>
"""
}

static let emptyElementRegexPattern = "<[a-z]+>(<!-- [a-zA-Z0-9\\/: \"{}\\-\\.,\\?=\\[\\]]+ -->)+<\\/[a-z]+>"

/// Returns HTML page <head> with the preconfigured styles and scripts.
private func makeHead() -> String {
if let cachedHead {
return cachedHead
}
let head = actuallyMakeHead()
cachedHead = head
return head
}

private func actuallyMakeHead() -> String {
let meta = "width=device-width,initial-scale=\(displaySetting.size.scale),maximum-scale=\(displaySetting.size.scale),user-scalable=no,shrink-to-fit=no"
let styles = displaySetting.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 {
assertionFailure("template missing")
return ""
}
return string
}()
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
<html dir="auto">
<head>
<title>Comment</title>
<meta name="viewport" content="%1$@" />
Expand Down Expand Up @@ -27,7 +26,3 @@
document.addEventListener('copy', event => postEvent("commentTextCopied"));
</script>
</head>
<body>
%3$@
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import UIKit

// MARK: - ReaderDisplaySetting (CSS)

extension ReaderDisplaySettings {
/// Creates a set of CSS styles that could be applied to a HTML file with
/// Gutenberg blocks to render them in a nice app that fits with the design
/// of the app.
@MainActor
func makeStyles(tintColor: UIColor) -> String {
Self.baseStylesheet.appending(makeStyleOverrides(tintColor: tintColor))
}

private static let baseStylesheet: String = {
guard let fileURL = Bundle.module.url(forResource: "gutenbergContentStyles", withExtension: "css"),
let string = try? String(contentsOf: fileURL) else {
assertionFailure("css missing")
return ""
}
return string
}()

/// Additional styles based on system or custom theme.
private func makeStyleOverrides(tintColor: UIColor) -> String {
"""
:root {
--text-font: \(font.cssString);
--link-font-weight: \(color == .system ? "inherit" : "600");
--link-text-decoration: \(color == .system ? "inherit" : "underline");
}
@media(prefers-color-scheme: light) {
\(makeColors(interfaceStyle: .light, tintColor: tintColor))
}
@media(prefers-color-scheme: dark) {
\(makeColors(interfaceStyle: .dark, tintColor: tintColor))
}
"""
}

/// CSS color definitions that matches the current color theme.
///
/// - parameter interfaceStyle: The current `UIUserInterfaceStyle` value.
private func makeColors(interfaceStyle: UIUserInterfaceStyle, tintColor: UIColor) -> String {
let trait = UITraitCollection(userInterfaceStyle: interfaceStyle)
return """
:root {
--text-color: \(color.foreground.color(for: trait).cssHex);
--text-secondary-color: \(color.secondaryForeground.color(for: trait).cssHex);
--link-color: \(tintColor.color(for: trait).cssHex);
--mention-background-color: \(tintColor.withAlphaComponent(0.1).color(for: trait).cssHex);
--background-secondary-color: \( color.secondaryBackground.color(for: trait).cssHex);
--border-color: \(color.border.color(for: trait).cssHex);
}
"""
}
}

private extension UIColor {
var cssHex: String {
"#\(hexStringWithAlpha)"
}
}
Loading

0 comments on commit 342b8ba

Please sign in to comment.