-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
WebKit Reader Comments (Groundwork) (#24022)
* 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
Showing
33 changed files
with
665 additions
and
619 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
17 changes: 8 additions & 9 deletions
17
...tentRenderer/CommentContentRenderer.swift → ...mments/Views/CommentContentRenderer.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
62 changes: 62 additions & 0 deletions
62
Modules/Sources/WordPressReader/Comments/Views/CommentWebView.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 – 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 | ||
} |
158 changes: 158 additions & 0 deletions
158
Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}() | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
64 changes: 64 additions & 0 deletions
64
Modules/Sources/WordPressReader/Settings/ReaderDisplaySettings+WebKit.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)" | ||
} | ||
} |
Oops, something went wrong.