Skip to content

Commit

Permalink
Merge pull request #17129 from wordpress-mobile/issue/17087-content-c…
Browse files Browse the repository at this point in the history
…ell-webview

Comment Detail: Render content in web view
  • Loading branch information
dvdchr authored Sep 9, 2021
2 parents b733df1 + a7c7fe8 commit 931a2cf
Show file tree
Hide file tree
Showing 6 changed files with 400 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,38 +32,116 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable {
@IBOutlet private weak var accessoryButton: UIButton!

@IBOutlet private weak var webView: WKWebView!
@IBOutlet private weak var webViewHeightConstraint: NSLayoutConstraint!

@IBOutlet private weak var reactionBarView: UIView!
@IBOutlet private weak var replyButton: UIButton!
@IBOutlet private weak var likeButton: UIButton!

/// Called when the cell has finished loading and calculating the height of the HTML content. Passes the new content height as parameter.
private var onContentLoaded: ((CGFloat) -> Void)? = nil

/// Cache the HTML template format. We only need read the template once.
private static let htmlTemplateFormat: String? = {
guard let templatePath = Bundle.main.path(forResource: "richCommentTemplate", ofType: "html"),
let templateString = try? String(contentsOfFile: templatePath) else {
return nil
}

return templateString
}()

/// Used for the web view's `baseURL`, to reference any local files (i.e. CSS) linked from the HTML.
private static let resourceURL: URL? = {
Bundle.main.resourceURL
}()

/// Used to determine whether the cache is still valid or not.
private var commentContentCache: String? = nil

/// Caches the HTML content, to be reused when the orientation changed.
private var htmlContentCache: String? = nil

// MARK: Lifecycle

override func awakeFromNib() {
super.awakeFromNib()
configureViews()
}

override func prepareForReuse() {
onContentLoaded = nil
htmlContentCache = nil
}

// MARK: Public Methods

func configure(with comment: Comment) {
/// Configures the cell with a `Comment` object.
///
/// - Parameters:
/// - comment: The `Comment` object to display.
/// - onContentLoaded: Callback to be called once the content has been loaded. Provides the new content height as parameter.
func configure(with comment: Comment, onContentLoaded: ((CGFloat) -> Void)?) {
nameLabel?.setText(comment.authorForDisplay())
dateLabel?.setText(comment.dateForDisplay()?.toMediumString() ?? String())

if let authorURL = comment.authorURL() {
configureImage(with: authorURL)
if let avatarURL = URL(string: comment.authorAvatarURL) {
configureImage(with: avatarURL)
} else {
configureImageWithGravatarEmail(comment.gravatarEmailForDisplay())
}

updateLikeButton(liked: comment.isLiked, numberOfLikes: comment.numberOfLikes())

// TODO: Configure comment content
// configure comment content
self.onContentLoaded = onContentLoaded
webView.isOpaque = false // gets rid of the white flash upon content load in dark mode.
webView.loadHTMLString(formattedHTMLString(for: comment.content), baseURL: Self.resourceURL)

// TODO: Configure component visibility
}
}

// MARK: - WKNavigationDelegate

extension CommentContentTableViewCell: WKNavigationDelegate {
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") { height, _ in
guard let height = height as? CGFloat else {
return
}

// reset the webview to opaque again so the scroll indicator is visible.
webView.isOpaque = true

// update the web view height obtained from the evaluated Javascript.
self.webViewHeightConstraint.constant = height
self.onContentLoaded?(height)
}
}
}

func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
// TODO: Offload the decision making to the delegate.
// For now, all navigation requests will be rejected (except for loading local files).
switch navigationAction.navigationType {
case .other:
decisionHandler(.allow)
default:
decisionHandler(.cancel)
}
}
}

// MARK: - Helpers

private extension CommentContentTableViewCell {
Expand Down Expand Up @@ -92,6 +170,10 @@ private extension CommentContentTableViewCell {
accessoryButton?.setImage(accessoryButtonImage, for: .normal)
accessoryButton?.addTarget(self, action: #selector(accessoryButtonTapped), for: .touchUpInside)

webView.navigationDelegate = self
webView.scrollView.bounces = false
webView.scrollView.showsVerticalScrollIndicator = false

replyButton?.tintColor = Style.buttonTintColor
replyButton?.titleLabel?.font = Style.reactionButtonFont
replyButton?.setTitle(.reply, for: .normal)
Expand Down Expand Up @@ -129,6 +211,41 @@ private extension CommentContentTableViewCell {
avatarImageView.downloadGravatarWithEmail(someEmail, placeholderImage: Style.placeholderImage)
}

/// 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 content: String) -> String {
// return the previous HTML string if the comment content is unchanged.
if let previousCommentContent = commentContentCache,
let previousHTMLString = htmlContentCache,
previousCommentContent == content {
return previousHTMLString
}

// otherwise: sanitize the content, cache it, and then return it.
guard let htmlTemplateFormat = Self.htmlTemplateFormat else {
DDLogError("\(Self.classNameWithoutNamespaces()): Failed to load HTML template format for comment content.")
return 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 htmlContent = String(format: htmlTemplateFormat, content
.replacingOccurrences(of: String.emptyElementRegexPattern, with: String(), options: [.regularExpression])
.trimmingCharacters(in: .whitespacesAndNewlines))

// cache the contents.
commentContentCache = content
htmlContentCache = htmlContent

return htmlContent
}

func likeButtonTitle(for numberOfLikes: Int) -> String {
switch numberOfLikes {
case .zero:
Expand Down Expand Up @@ -168,4 +285,7 @@ private extension String {
+ "%1$d is a placeholder for the number of Likes.")
static let pluralLikesFormat = NSLocalizedString("%1$d Likes", comment: "Plural button title to Like a comment. "
+ "%1$d is a placeholder for the number of Likes.")

// pattern that detects empty HTML elements (including HTML comments within).
static let emptyElementRegexPattern = "<[a-z]+>(<!-- [a-zA-Z0-9\\/: \"{}\\-\\.,\\?=\\[\\]]+ -->)+<\\/[a-z]+>"
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="247" id="KGk-i7-Jjw" customClass="CommentContentTableViewCell" customModule="WordPress" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="247"/>
<tableViewCell contentMode="scaleToFill" selectionStyle="default" indentationWidth="10" rowHeight="112" id="KGk-i7-Jjw" customClass="CommentContentTableViewCell" customModule="WordPress" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="320" height="112"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" tableViewCell="KGk-i7-Jjw" id="H2p-sc-9uM">
<rect key="frame" x="0.0" y="0.0" width="320" height="247"/>
<rect key="frame" x="0.0" y="0.0" width="320" height="112"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<view contentMode="scaleToFill" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="f2E-yC-BJS" userLabel="Header View">
<rect key="frame" x="16" y="0.0" width="288" height="73"/>
<view contentMode="scaleToFill" verticalHuggingPriority="251" verticalCompressionResistancePriority="751" translatesAutoresizingMaskIntoConstraints="NO" id="f2E-yC-BJS" userLabel="Header View">
<rect key="frame" x="16" y="0.0" width="288" height="71"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="gravatar" translatesAutoresizingMaskIntoConstraints="NO" id="9QY-3I-cxv" userLabel="Avatar Image View" customClass="CircularImageView" customModule="WordPress" customModuleProvider="target">
<rect key="frame" x="0.0" y="20" width="38" height="38"/>
Expand All @@ -32,12 +32,12 @@
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="CzL-pe-Tnr">
<rect key="frame" x="48" y="20" width="208" height="31"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="HpE-B7-6wr" userLabel="Name Label">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="HpE-B7-6wr" userLabel="Name Label">
<rect key="frame" x="0.0" y="0.0" width="208" height="14.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ghT-Xy-q8c" userLabel="Date Label">
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="ghT-Xy-q8c" userLabel="Date Label">
<rect key="frame" x="0.0" y="16.5" width="208" height="14.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<color key="textColor" systemColor="secondaryLabelColor"/>
Expand Down Expand Up @@ -76,20 +76,20 @@
</constraints>
</view>
<wkWebView contentMode="scaleToFill" verticalHuggingPriority="249" translatesAutoresizingMaskIntoConstraints="NO" id="Je0-5Q-ty6">
<rect key="frame" x="16" y="73" width="288" height="133"/>
<rect key="frame" x="16" y="71" width="288" height="1"/>
<constraints>
<constraint firstAttribute="height" priority="250" constant="50" id="dGD-8Q-LSr"/>
<constraint firstAttribute="height" priority="999" constant="1" id="dGD-8Q-LSr"/>
</constraints>
<wkWebViewConfiguration key="configuration">
<audiovisualMediaTypes key="mediaTypesRequiringUserActionForPlayback" none="YES"/>
<wkPreferences key="preferences"/>
</wkWebViewConfiguration>
</wkWebView>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ta5-Cz-flw" userLabel="Reaction Bar View">
<rect key="frame" x="16" y="206" width="288" height="41"/>
<rect key="frame" x="16" y="72" width="288" height="40"/>
<subviews>
<button opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="251" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="VoI-YI-Qgc" userLabel="Reply Button">
<rect key="frame" x="0.0" y="0.0" width="62.5" height="41"/>
<rect key="frame" x="0.0" y="0.0" width="62.5" height="40"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="tintColor" systemColor="secondaryLabelColor"/>
<inset key="contentEdgeInsets" minX="0.0" minY="15" maxX="15" maxY="10"/>
Expand All @@ -103,7 +103,7 @@
</state>
</button>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="X2J-8b-R5F" userLabel="Like Button">
<rect key="frame" x="62.5" y="0.0" width="78.5" height="41"/>
<rect key="frame" x="62.5" y="0.0" width="78.5" height="40"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleSubhead"/>
<color key="tintColor" systemColor="secondaryLabelColor"/>
<inset key="contentEdgeInsets" minX="5" minY="15" maxX="35" maxY="10"/>
Expand Down Expand Up @@ -154,8 +154,9 @@
<outlet property="reactionBarView" destination="ta5-Cz-flw" id="puY-Sa-fKk"/>
<outlet property="replyButton" destination="VoI-YI-Qgc" id="Z9J-Tp-bur"/>
<outlet property="webView" destination="Je0-5Q-ty6" id="YaD-wp-E6W"/>
<outlet property="webViewHeightConstraint" destination="dGD-8Q-LSr" id="rBk-4R-GCz"/>
</connections>
<point key="canvasLocation" x="131.8840579710145" y="279.57589285714283"/>
<point key="canvasLocation" x="131.8840579710145" y="234.375"/>
</tableViewCell>
</objects>
<resources>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,16 @@ class CommentDetailViewController: UITableViewController {
configureRows()
}

override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
super.viewWillTransition(to: size, with: coordinator)

// when an orientation change is triggered, recalculate the content cell's height.
guard let contentRowIndex = rows.firstIndex(where: { $0 == .content }) else {
return
}
tableView.reloadRows(at: [.init(row: contentRowIndex, section: .zero)], with: .fade)
}

// MARK: Table view data source

override func numberOfSections(in tableView: UITableView) -> Int {
Expand All @@ -93,7 +103,9 @@ class CommentDetailViewController: UITableViewController {
guard let cell = tableView.dequeueReusableCell(withIdentifier: CommentContentTableViewCell.defaultReuseID) as? CommentContentTableViewCell else {
return .init()
}
cell.configure(with: comment)
cell.configure(with: comment) { _ in
self.tableView.performBatchUpdates({})
}
return cell

case .replyIndicator:
Expand All @@ -115,8 +127,8 @@ class CommentDetailViewController: UITableViewController {
// TODO: Navigate to the comment reply.
break

case .text(_, _, _, let action):
action?()
case .text(let title, _, _) where title == .webAddressLabelText:
visitAuthorURL()

default:
break
Expand All @@ -131,11 +143,11 @@ private extension CommentDetailViewController {

typealias Style = WPStyleGuide.CommentDetail

enum RowType {
enum RowType: Equatable {
case header
case content
case replyIndicator
case text(title: String, detail: String, image: UIImage? = nil, action: (() -> Void)? = nil)
case text(title: String, detail: String, image: UIImage? = nil)
}

struct Constants {
Expand Down Expand Up @@ -165,7 +177,7 @@ private extension CommentDetailViewController {
.header,
.content,
.replyIndicator, // TODO: Conditionally add this when user has replied to the comment.
.text(title: .webAddressLabelText, detail: comment.authorUrlForDisplay(), image: Style.externalIconImage, action: visitAuthorURL),
.text(title: .webAddressLabelText, detail: comment.authorUrlForDisplay(), image: Style.externalIconImage),
.text(title: .emailAddressLabelText, detail: comment.author_email),
.text(title: .ipAddressLabelText, detail: comment.author_ip)
]
Expand All @@ -181,7 +193,7 @@ private extension CommentDetailViewController {
}

func configuredTextCell(for row: RowType) -> UITableViewCell {
guard case let .text(title, detail, image, _) = row else {
guard case let .text(title, detail, image) = row else {
return .init()
}

Expand Down
Loading

0 comments on commit 931a2cf

Please sign in to comment.