-
Notifications
You must be signed in to change notification settings - Fork 1.1k
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
Changes from 25 commits
7748e35
ac8beb4
5f43d37
1f2587a
d65418f
b0278a5
258d11b
ea2ec03
fc34aad
78ec774
9efed5f
8f7d054
33870f4
bdf7b8b
4f25334
342c5fe
f5a87c9
37ee2ca
8e83414
f59c176
f8023c1
1d8cf53
81468e6
b839e97
108380e
075261c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
} |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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]) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
---|---|---|
@@ -0,0 +1,64 @@ | ||
import UIKit | ||
|
||
// MARK: - ReaderDisplaySetting (CSS) | ||
|
||
extension ReaderDisplaySettings { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There is an opportunity to reuse this with |
||
/// 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)" | ||
} | ||
} |
There was a problem hiding this comment.
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.