Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WebKit Reader Comments (Groundwork) #24022

Merged
merged 26 commits into from
Jan 31, 2025
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was part of the existing CommentContentRenderer protocol, and I don't want to change it too much just yet.

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
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the existing code. I just moved it. Not sure if it works the way it says it does.

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])
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, I want to preprocess this in the background and store in Core Data in this way.

.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 {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an opportunity to reuse this with ReaderWebView. I'm going to look into it.

/// 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