diff --git a/Modules/Package.swift b/Modules/Package.swift index 1b42f405890f..faa4a994f838 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -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"), @@ -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)]), @@ -156,6 +159,7 @@ enum XcodeSupport { "JetpackStatsWidgetsCore", "WordPressFlux", "WordPressShared", + "WordPressReader", "AsyncImageKit", "WordPressUI", "WordPressCore", diff --git a/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/CommentContentRenderer.swift b/Modules/Sources/WordPressReader/Comments/Views/CommentContentRenderer.swift similarity index 66% rename from WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/CommentContentRenderer.swift rename to Modules/Sources/WordPressReader/Comments/Views/CommentContentRenderer.swift index 6f4c138a2590..b198771587b5 100644 --- a/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/CommentContentRenderer.swift +++ b/Modules/Sources/WordPressReader/Comments/Views/CommentContentRenderer.swift @@ -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) diff --git a/Modules/Sources/WordPressReader/Comments/Views/CommentWebView.swift b/Modules/Sources/WordPressReader/Comments/Views/CommentWebView.swift new file mode 100644 index 000000000000..1ae5833650de --- /dev/null +++ b/Modules/Sources/WordPressReader/Comments/Views/CommentWebView.swift @@ -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: "

Thank you so much! You should see it now – people are losing their minds!

\n") +} + +@available(iOS 17, *) +#Preview("Gutenberg") { + makeView(comment: """ +

Thank you for putting this together, I’m in support of all proposed improvements, we know that the current experience is less-than-ideal.

Get rid of This. 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.

@tester‘s most recent review found it failed to provide a valid response in more than half of interactions.

+ """) +} + +@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 +} diff --git a/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift b/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift new file mode 100644 index 000000000000..d0820ec6f876 --- /dev/null +++ b/Modules/Sources/WordPressReader/Comments/Views/WebCommentContentRenderer.swift @@ -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 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 ( tags) for mentions. + let comment = comment + .replacingOccurrences(of: Self.emptyElementRegexPattern, with: "", options: [.regularExpression]) + .trimmingCharacters(in: .whitespacesAndNewlines) + return """ + + \(makeHead()) + + \(comment) + + + """ + } + + static let emptyElementRegexPattern = "<[a-z]+>()+<\\/[a-z]+>" + + /// Returns HTML page 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 + }() +} diff --git a/WordPress/Resources/HTML/richCommentTemplate.html b/Modules/Sources/WordPressReader/Resources/gutenbergCommentHeadTemplate.html similarity index 94% rename from WordPress/Resources/HTML/richCommentTemplate.html rename to Modules/Sources/WordPressReader/Resources/gutenbergCommentHeadTemplate.html index 46427605d94c..d73c5f3a3962 100644 --- a/WordPress/Resources/HTML/richCommentTemplate.html +++ b/Modules/Sources/WordPressReader/Resources/gutenbergCommentHeadTemplate.html @@ -1,4 +1,3 @@ - Comment @@ -27,7 +26,3 @@ document.addEventListener('copy', event => postEvent("commentTextCopied")); - - %3$@ - - diff --git a/WordPress/Resources/HTML/richCommentStyle.css b/Modules/Sources/WordPressReader/Resources/gutenbergContentStyles.css similarity index 100% rename from WordPress/Resources/HTML/richCommentStyle.css rename to Modules/Sources/WordPressReader/Resources/gutenbergContentStyles.css diff --git a/Modules/Sources/WordPressReader/Settings/ReaderDisplaySettings+WebKit.swift b/Modules/Sources/WordPressReader/Settings/ReaderDisplaySettings+WebKit.swift new file mode 100644 index 000000000000..124b07f5fa38 --- /dev/null +++ b/Modules/Sources/WordPressReader/Settings/ReaderDisplaySettings+WebKit.swift @@ -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)" + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Theme/ReaderDisplaySetting.swift b/Modules/Sources/WordPressReader/Settings/ReaderDisplaySettings.swift similarity index 68% rename from WordPress/Classes/ViewRelated/Reader/Theme/ReaderDisplaySetting.swift rename to Modules/Sources/WordPressReader/Settings/ReaderDisplaySettings.swift index fdcc176662ce..28bbccf27346 100644 --- a/WordPress/Classes/ViewRelated/Reader/Theme/ReaderDisplaySetting.swift +++ b/Modules/Sources/WordPressReader/Settings/ReaderDisplaySettings.swift @@ -1,26 +1,24 @@ -import WordPressUI +import UIKit import WordPressShared -struct ReaderDisplaySetting: Codable, Equatable { +public struct ReaderDisplaySettings: Codable, Equatable, Hashable, Sendable { - static var customizationEnabled: Bool { - AppConfiguration.isJetpack - } + public static var customizationEnabled: Bool { true } // MARK: Properties // The default display setting. - static let standard = ReaderDisplaySetting(color: .system, font: .sans, size: .normal) + public static let standard = ReaderDisplaySettings(color: .system, font: .sans, size: .normal) - var color: Color - var font: Font - var size: Size + public var color: Color + public var font: Font + public var size: Size - var hasLightBackground: Bool { + public var hasLightBackground: Bool { color.background.brighterThan(0.5) } - var isDefaultSetting: Bool { + public var isDefaultSetting: Bool { return self == .standard } @@ -34,10 +32,12 @@ struct ReaderDisplaySetting: Codable, Equatable { /// - textStyle: The preferred text style. /// - weight: The preferred weight. Defaults to nil, which falls back to the inherent weight from the `UITextStyle`. /// - Returns: A `UIFont` instance with the specified configuration. - static func font(with font: Font, - size: Size = .normal, - textStyle: UIFont.TextStyle, - weight: UIFont.Weight? = nil) -> UIFont { + public static func font( + with font: Font, + size: Size = .normal, + textStyle: UIFont.TextStyle, + weight: UIFont.Weight? = nil + ) -> UIFont { let descriptor: UIFontDescriptor = { let defaultDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: textStyle) @@ -66,18 +66,18 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - func font(with textStyle: UIFont.TextStyle, weight: UIFont.Weight = .regular) -> UIFont { + public func font(with textStyle: UIFont.TextStyle, weight: UIFont.Weight = .regular) -> UIFont { return Self.font(with: font, size: size, textStyle: textStyle, weight: weight) } - func toDictionary(_ encoder: JSONEncoder = JSONEncoder()) throws -> NSDictionary? { + public func toDictionary(_ encoder: JSONEncoder = JSONEncoder()) throws -> NSDictionary? { let data = try encoder.encode(self) return try JSONSerialization.jsonObject(with: data, options: []) as? NSDictionary } // MARK: Types - enum Color: String, Codable, CaseIterable { + public enum Color: String, Codable, CaseIterable, Sendable, Hashable { case system case soft case sepia @@ -86,7 +86,7 @@ struct ReaderDisplaySetting: Codable, Equatable { case hacker case candy - var label: String { + public var label: String { switch self { case .system: return NSLocalizedString( @@ -133,7 +133,7 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - var foreground: UIColor { + public var foreground: UIColor { switch self { case .system: return .label @@ -152,7 +152,7 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - var secondaryForeground: UIColor { + public var secondaryForeground: UIColor { switch self { case .system: return .secondaryLabel @@ -161,7 +161,7 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - var background: UIColor { + public var background: UIColor { switch self { case .system: return .systemBackground @@ -180,7 +180,7 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - var secondaryBackground: UIColor { + public var secondaryBackground: UIColor { switch self { case .system: return .secondarySystemBackground @@ -191,7 +191,7 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - var border: UIColor { + public var border: UIColor { switch self { case .system: return .separator @@ -201,7 +201,7 @@ struct ReaderDisplaySetting: Codable, Equatable { } /// Whether the color adjusts between light and dark mode. - var adaptsToInterfaceStyle: Bool { + public var adaptsToInterfaceStyle: Bool { switch self { case .system: return true @@ -210,7 +210,7 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - var valueForTracks: String { + public var valueForTracks: String { switch self { case .system: return "default" @@ -222,12 +222,12 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - enum Font: String, Codable, CaseIterable { + public enum Font: String, Codable, CaseIterable, Sendable { case sans case serif case mono - var cssString: String { + public var cssString: String { switch self { case .sans: return "-apple-system, sans-serif" @@ -238,19 +238,19 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - var valueForTracks: String { + public var valueForTracks: String { rawValue } } - enum Size: Int, Codable, CaseIterable { + public enum Size: Int, Codable, CaseIterable, Sendable { case extraSmall = -2 case small case normal case large case extraLarge - var scale: Double { + public var scale: Double { switch self { case .extraSmall: return 0.75 @@ -265,7 +265,7 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - var accessibilityLabel: String { + public var accessibilityLabel: String { switch self { case .extraSmall: return NSLocalizedString( @@ -300,7 +300,7 @@ struct ReaderDisplaySetting: Codable, Equatable { } } - var valueForTracks: String { + public var valueForTracks: String { switch self { case .extraSmall: return "extra_small" @@ -318,123 +318,13 @@ struct ReaderDisplaySetting: Codable, Equatable { // MARK: Codable - private enum CodingKeys: String, CodingKey { + private enum CodingKeys: String, CodingKey, Sendable { case color case font case size } } -// MARK: - Controller - -protocol ReaderDisplaySettingStoreDelegate: NSObjectProtocol { - func displaySettingDidChange() -} - -/// This should be the object to be strongly retained. Keeps the store up-to-date. -class ReaderDisplaySettingStore: NSObject { - - private let repository: UserPersistentRepository - - private let notificationCenter: NotificationCenter - - weak var delegate: ReaderDisplaySettingStoreDelegate? - - /// A public facade to simplify the flag checking dance for the `ReaderDisplaySetting` object. - /// When the flag is disabled, this will always return the `standard` object, and the setter does nothing. - var setting: ReaderDisplaySetting { - get { - return ReaderDisplaySetting.customizationEnabled ? _setting : .standard - } - set { - guard ReaderDisplaySetting.customizationEnabled, - newValue != _setting else { - return - } - _setting = newValue - broadcastChangeNotification() - } - } - - /// The actual instance variable that holds the setting object. - /// This is intentionally set to private so that it's only controllable by `ReaderDisplaySettingStore`. - private var _setting: ReaderDisplaySetting = .standard { - didSet { - guard oldValue != _setting, - let dictionary = try? setting.toDictionary() else { - return - } - repository.set(dictionary, forKey: Constants.key) - } - } - - init(repository: UserPersistentRepository = UserPersistentStoreFactory.instance(), - notificationCenter: NotificationCenter = .default) { - self.repository = repository - self.notificationCenter = notificationCenter - self._setting = { - guard let dictionary = repository.dictionary(forKey: Constants.key), - let data = try? JSONSerialization.data(withJSONObject: dictionary), - let setting = try? JSONDecoder().decode(ReaderDisplaySetting.self, from: data) else { - return .standard - } - return setting - }() - super.init() - registerNotifications() - } - - private func registerNotifications() { - notificationCenter.addObserver(self, - selector: #selector(handleChangeNotification), - name: .readerDisplaySettingStoreDidChange, - object: nil) - } - - private func broadcastChangeNotification() { - notificationCenter.post(name: .readerDisplaySettingStoreDidChange, object: self) - } - - @objc - private func handleChangeNotification(_ notification: NSNotification) { - // ignore self broadcasts. - if let broadcaster = notification.object as? ReaderDisplaySettingStore, - broadcaster == self { - return - } - - // since we're handling change notifications, a stored setting object *should* exist. - guard let updatedSetting = try? fetchSetting() else { - DDLogError("ReaderDisplaySettingStore: Received a didChange notification with a nil stored value") - return - } - - _setting = updatedSetting - delegate?.displaySettingDidChange() - } - - /// Fetches the stored value of `ReaderDisplaySetting`. - /// - /// - Returns: `ReaderDisplaySetting` - private func fetchSetting() throws -> ReaderDisplaySetting? { - guard let dictionary = repository.dictionary(forKey: Constants.key) else { - return nil - } - - let data = try JSONSerialization.data(withJSONObject: dictionary) - let setting = try JSONDecoder().decode(ReaderDisplaySetting.self, from: data) - return setting - } - - private struct Constants { - static let key = "readerDisplaySettingKey" - } -} - -fileprivate extension NSNotification.Name { - static let readerDisplaySettingStoreDidChange = NSNotification.Name("ReaderDisplaySettingDidChange") -} - private extension UIColor { /** Whether or not the color brightness is higher than a provided brightness value. diff --git a/Modules/Sources/WordPressUI/Extensions/UIColor+Helpers.swift b/Modules/Sources/WordPressUI/Extensions/UIColor+Helpers.swift index 9eb1e0677888..258cf2f80b2f 100644 --- a/Modules/Sources/WordPressUI/Extensions/UIColor+Helpers.swift +++ b/Modules/Sources/WordPressUI/Extensions/UIColor+Helpers.swift @@ -14,6 +14,16 @@ public extension UIColor { } } + convenience init(color: UIColor) { + var r: CGFloat = 0 + var g: CGFloat = 0 + var b: CGFloat = 0 + var a: CGFloat = 0 + + color.getRed(&r, green: &g, blue: &b, alpha: &a) + self.init(red: r, green: g, blue: b, alpha: a) + } + /// Creates a color based on a hexString. If the string is not a valid hexColor it return nil /// Example of colors: #FF0000, #00FF0000 /// @@ -117,4 +127,26 @@ public extension UIColor { preconditionFailure("Color can't be represented in hexadecimal form") } + + // MARK: - Traits + + func color(for trait: UITraitCollection?) -> UIColor { + if let trait { + return resolvedColor(with: trait) + } + return self + } + + func lightVariant() -> UIColor { + return color(for: UITraitCollection(userInterfaceStyle: .light)) + } + + func darkVariant() -> UIColor { + return color(for: UITraitCollection(userInterfaceStyle: .dark)) + } + + /// The same color with the dark and light variants swapped + var variantInverted: UIColor { + UIColor(light: darkVariant(), dark: lightVariant()) + } } diff --git a/WordPress/Classes/Extensions/Colors and Styles/UIColor+Extensions.swift b/WordPress/Classes/Extensions/Colors and Styles/UIColor+Extensions.swift deleted file mode 100644 index 6da499433fa0..000000000000 --- a/WordPress/Classes/Extensions/Colors and Styles/UIColor+Extensions.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Foundation -import WordPressUI - -@objc -extension UIColor { - - convenience init(color: UIColor) { - var r: CGFloat = 0 - var g: CGFloat = 0 - var b: CGFloat = 0 - var a: CGFloat = 0 - - color.getRed(&r, green: &g, blue: &b, alpha: &a) - self.init(red: r, green: g, blue: b, alpha: a) - } - - func color(for trait: UITraitCollection?) -> UIColor { - if let trait { - return resolvedColor(with: trait) - } - return self - } - - func lightVariant() -> UIColor { - return color(for: UITraitCollection(userInterfaceStyle: .light)) - } - - func darkVariant() -> UIColor { - return color(for: UITraitCollection(userInterfaceStyle: .dark)) - } - - /// The same color with the dark and light variants swapped - var variantInverted: UIColor { - UIColor(light: self.darkVariant(), dark: self.lightVariant()) - } -} diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index ce04de452338..91ea86528dfd 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -15,6 +15,7 @@ enum FeatureFlag: Int, CaseIterable { case newGutenbergThemeStyles case newGutenbergPlugins case selfHostedSiteUserManagement + case readerCommentsWebKit /// Returns a boolean indicating if the feature is enabled var enabled: Bool { @@ -49,6 +50,8 @@ enum FeatureFlag: Int, CaseIterable { return false case .selfHostedSiteUserManagement: return false + case .readerCommentsWebKit: + return false } } @@ -84,6 +87,7 @@ extension FeatureFlag { case .newGutenbergThemeStyles: "Experimental Block Editor Styles" case .newGutenbergPlugins: "Experimental Block Editor Plugins" case .selfHostedSiteUserManagement: "Self-hosted Site User Management" + case .readerCommentsWebKit: "Render Comments using WebKit" } } } diff --git a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift index 679cfb7551e6..87aa040221b9 100644 --- a/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift +++ b/WordPress/Classes/ViewRelated/Comments/Controllers/CommentDetailViewController.swift @@ -51,6 +51,7 @@ class CommentDetailViewController: UIViewController, NoResultsViewHost { } } private var notification: Notification? + private let helper = ReaderCommentsHelper() private var isNotificationComment: Bool { notification != nil @@ -441,7 +442,7 @@ private extension CommentDetailViewController { } func configureContentCell(_ cell: CommentContentTableViewCell, comment: Comment) { - cell.configure(with: comment) { [weak self] _ in + cell.configure(with: comment, helper: helper) { [weak self] _ in self?.tableView.performBatchUpdates({}) } diff --git a/WordPress/Classes/ViewRelated/Comments/Views/Detail/CommentContentTableViewCell.swift b/WordPress/Classes/ViewRelated/Comments/Views/Detail/CommentContentTableViewCell.swift index 89acdcacf975..fa3c82d5ee7f 100644 --- a/WordPress/Classes/ViewRelated/Comments/Views/Detail/CommentContentTableViewCell.swift +++ b/WordPress/Classes/ViewRelated/Comments/Views/Detail/CommentContentTableViewCell.swift @@ -1,5 +1,6 @@ import UIKit import WordPressUI +import WordPressReader import Gravatar class CommentContentTableViewCell: UITableViewCell, NibReusable { @@ -119,9 +120,9 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { /// 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 - private var renderer: CommentContentRenderer? = nil - + private var comment: Comment? private var renderMethod: RenderMethod? + private var helper: ReaderCommentsHelper? // MARK: Like Button State @@ -133,10 +134,10 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { /// can be scoped by using the "legacy" style when the passed parameter is nil. private var style: CellStyle = .init(displaySetting: nil) - var displaySetting: ReaderDisplaySetting? = nil { + // TODO: (kean) remove + var displaySetting: ReaderDisplaySettings? = nil { didSet { style = CellStyle(displaySetting: displaySetting) - resetRenderedContents() applyStyles() } } @@ -198,7 +199,15 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { /// - comment: The `Comment` object to display. /// - renderMethod: Specifies how to display the comment body. See `RenderMethod`. /// - onContentLoaded: Callback to be called once the content has been loaded. Provides the new content height as parameter. - func configure(with comment: Comment, renderMethod: RenderMethod = .web, onContentLoaded: ((CGFloat) -> Void)?) { + func configure( + with comment: Comment, + renderMethod: RenderMethod = .web, + helper: ReaderCommentsHelper, + onContentLoaded: ((CGFloat) -> Void)? + ) { + self.comment = comment + self.helper = helper + nameLabel?.setText(comment.authorForDisplay()) dateLabel?.setText(comment.dateForDisplay()?.toMediumString() ?? String()) @@ -229,7 +238,7 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { // Configure content renderer. self.onContentLoaded = onContentLoaded - configureRendererIfNeeded(for: comment, renderMethod: renderMethod) + configureRendererIfNeeded(for: comment, renderMethod: renderMethod, helper: helper) } /// Configures the cell with a `Comment` object, to be displayed in the post details view. @@ -237,8 +246,8 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { /// - 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 configureForPostDetails(with comment: Comment, onContentLoaded: ((CGFloat) -> Void)?) { - configure(with: comment, onContentLoaded: onContentLoaded) + func configureForPostDetails(with comment: Comment, helper: ReaderCommentsHelper, onContentLoaded: ((CGFloat) -> Void)?) { + configure(with: comment, helper: helper, onContentLoaded: onContentLoaded) isCommentLikesEnabled = false isCommentReplyEnabled = false @@ -267,9 +276,18 @@ class CommentContentTableViewCell: UITableViewCell, NibReusable { extension CommentContentTableViewCell: CommentContentRendererDelegate { func renderer(_ renderer: CommentContentRenderer, asyncRenderCompletedWithHeight height: CGFloat) { if renderMethod == .web { - contentContainerHeightConstraint?.constant = height + if let constraint = contentContainerHeightConstraint, let comment { + if height != constraint.constant { + constraint.constant = height + helper?.setCachedContentHeight(height, for: .init(comment)) + onContentLoaded?(height) // We had the right size from the get-go + } + } else { + wpAssertionFailure("constraint missing") + } + } else { + onContentLoaded?(height) } - onContentLoaded?(height) } func renderer(_ renderer: CommentContentRenderer, interactedWithURL url: URL) { @@ -283,11 +301,11 @@ private extension CommentContentTableViewCell { /// A structure to override the cell styling based on `ReaderDisplaySetting`. /// This doesn't cover all aspects of the cell, and iks currently scoped only for Reader Detail. struct CellStyle { - let displaySetting: ReaderDisplaySetting? + let displaySetting: ReaderDisplaySettings? /// NOTE: Remove when the `readerCustomization` flag is removed. var customizationEnabled: Bool { - ReaderDisplaySetting.customizationEnabled + ReaderDisplaySettings.customizationEnabled } // Name Label @@ -470,52 +488,38 @@ private extension CommentContentTableViewCell { // MARK: Content Rendering - func resetRenderedContents() { - renderer = nil - contentContainerView.subviews.forEach { $0.removeFromSuperview() } - } - - func configureRendererIfNeeded(for comment: Comment, renderMethod: RenderMethod) { - // skip creating the renderer if the content does not change. - // this prevents the cell to jump multiple times due to consecutive reloadData calls. - // - // note that this doesn't apply for `.richContent` method. Always reset the textView instead - // of reusing it to prevent crash. Ref: http://git.io/Jtl2U - if let renderer, - renderer.matchesContent(from: comment), - renderMethod == .web { - return - } - - // clean out any pre-existing renderer just to be sure. - resetRenderedContents() - - var renderer: CommentContentRenderer = { + func configureRendererIfNeeded(for comment: Comment, renderMethod: RenderMethod, helper: ReaderCommentsHelper) { + let renderer: CommentContentRenderer = { switch renderMethod { case .web: - return WebCommentContentRenderer(comment: comment, displaySetting: displaySetting ?? .standard) + return helper.getRenderer(for: comment) case .richContent(let attributedText): - let renderer = RichCommentContentRenderer(comment: comment) + let renderer = RichCommentContentRenderer() renderer.richContentDelegate = self.richContentDelegate renderer.attributedText = attributedText + renderer.comment = comment return renderer } }() renderer.delegate = self - self.renderer = renderer - self.renderMethod = renderMethod + + self.renderMethod = renderMethod // we assume the render method can't change if renderMethod == .web { // reset height constraint to handle cases where the new content requires the webview to shrink. contentContainerHeightConstraint?.isActive = true - contentContainerHeightConstraint?.constant = 1 + contentContainerHeightConstraint?.constant = helper.getCachedContentHeight(for: TaggedManagedObjectID(comment)) ?? 20 } else { contentContainerHeightConstraint?.isActive = false } - let contentView = renderer.render() - contentContainerView?.addSubview(contentView) - contentContainerView?.pinSubviewToAllEdges(contentView) + let contentView = renderer.render(comment: comment.content) + if contentContainerView.subviews.first != contentView { + contentContainerView.subviews.forEach { $0.removeFromSuperview() } + contentView.removeFromSuperview() + contentContainerView?.addSubview(contentView) + contentView.pinEdges() + } } // MARK: Button Actions diff --git a/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/RichCommentContentRenderer.swift b/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/RichCommentContentRenderer.swift index 1fb14ad14e7e..e638a3a71507 100644 --- a/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/RichCommentContentRenderer.swift +++ b/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/RichCommentContentRenderer.swift @@ -1,5 +1,6 @@ import UIKit import AsyncImageKit +import WordPressReader /// Renders the comment body through `WPRichContentView`. /// @@ -8,24 +9,16 @@ class RichCommentContentRenderer: NSObject, CommentContentRenderer { weak var richContentDelegate: WPRichContentViewDelegate? = nil var attributedText: NSAttributedString? + var comment: Comment? - private let comment: Comment + required override init() {} - required init(comment: Comment) { - self.comment = comment - } - - func render() -> UIView { + func render(comment: String) -> UIView { let textView = newRichContentView() textView.attributedText = attributedText textView.delegate = self - return textView } - - func matchesContent(from comment: Comment) -> Bool { - return self.comment.content == comment.content - } } // MARK: - WPRichContentViewDelegate @@ -73,6 +66,9 @@ private extension RichCommentContentRenderer { } var mediaHost: MediaHost { + guard let comment else { + return .publicSite + } if let blog = comment.blog { return MediaHost(blog) } else if let post = comment.post as? ReaderPost, post.isBlogPrivate { diff --git a/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/WebCommentContentRenderer.swift b/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/WebCommentContentRenderer.swift deleted file mode 100644 index 123d8b910f89..000000000000 --- a/WordPress/Classes/ViewRelated/Comments/Views/Detail/ContentRenderer/WebCommentContentRenderer.swift +++ /dev/null @@ -1,293 +0,0 @@ -@preconcurrency import WebKit -import WordPressShared - -/// Renders the comment body with a web view. Provides the best visual experience but has the highest performance cost. -/// -class WebCommentContentRenderer: NSObject, CommentContentRenderer { - - // MARK: Properties - - weak var delegate: CommentContentRendererDelegate? - - private let comment: Comment - - private let webView = WKWebView(frame: .zero) - - /// 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 - - private let displaySetting: ReaderDisplaySetting - - // MARK: Methods - - required convenience init(comment: Comment) { - self.init(comment: comment, displaySetting: .standard) - } - - required init(comment: Comment, displaySetting: ReaderDisplaySetting) { - self.comment = comment - self.displaySetting = displaySetting - 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 - webView.configuration.allowsInlineMediaPlayback = true - - // - warning: It retains the handler. It can't be `self`. - webView.configuration.userContentController.add(ReaderWebViewMessageHandler(), name: "eventHandler") - } - - func render() -> UIView { - // Do not reload if the content doesn't change. - if let contentCache = commentContentCache, contentCache == comment.content { - return webView - } - - webView.loadHTMLString(formattedHTMLString(for: comment.content), baseURL: Bundle.wordPressSharedBundle.bundleURL) - - return webView - } - - func matchesContent(from comment: Comment) -> Bool { - // if content cache is still nil, then the comment hasn't been rendered yet. - guard let contentCache = commentContentCache else { - return false - } - - return contentCache == comment.content - } -} - -// MARK: - WKNavigationDelegate - -extension WebCommentContentRenderer: 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 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) - } - } - } - - func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { - switch navigationAction.navigationType { - case .other: - // allow local file requests. - decisionHandler(.allow) - default: - decisionHandler(.cancel) - guard let destinationURL = navigationAction.request.url else { - return - } - - self.delegate?.renderer(self, interactedWithURL: destinationURL) - } - } -} - -// MARK: - Private Methods - -private extension WebCommentContentRenderer { - struct Constants { - static let emptyElementRegexPattern = "<[a-z]+>()+<\\/[a-z]+>" - - static let highlightColor = UIColor(light: UIAppColor.primary, dark: UIAppColor.primary(.shade30)) - - static let mentionBackgroundColor: UIColor = { - var darkColor = UIAppColor.primary(.shade90) - - if AppConfiguration.isWordPress { - darkColor = darkColor.withAlphaComponent(0.5) - } - - return UIColor(light: UIAppColor.primary(.shade0), dark: darkColor) - }() - } - - /// Used for the web view's `baseURL`, to reference any local files (i.e. CSS) linked from the HTML. - static let resourceURL: URL? = { - Bundle.wordPressSharedBundle.bundleURL - }() - - var textColor: UIColor { - ReaderDisplaySetting.customizationEnabled ? displaySetting.color.foreground : .label - } - - var mentionBackgroundColor: UIColor { - guard ReaderDisplaySetting.customizationEnabled else { - return Constants.mentionBackgroundColor - } - - return displaySetting.color == .system ? Constants.mentionBackgroundColor : displaySetting.color.secondaryBackground - } - - var linkColor: UIColor { - guard ReaderDisplaySetting.customizationEnabled else { - return Constants.highlightColor - } - - return displaySetting.color == .system ? Constants.highlightColor : displaySetting.color.foreground - } - - var secondaryBackgroundColor: UIColor { - guard ReaderDisplaySetting.customizationEnabled else { - return .secondarySystemBackground - } - return displaySetting.color.secondaryBackground - } - - /// Cache the HTML template format. We only need read the template once. - var htmlTemplateFormat: String? { - guard let templatePath = Bundle.main.path(forResource: "richCommentTemplate", ofType: "html"), - let templateStringFormat = try? String(contentsOfFile: templatePath) else { - return nil - } - - return String(format: templateStringFormat, - metaContents.joined(separator: ", "), - cssStyles, - "%@") - } - - var metaContents: [String] { - [ - "width=device-width", - "initial-scale=\(displaySetting.size.scale)", - "maximum-scale=\(displaySetting.size.scale)", - "user-scalable=no", - "shrink-to-fit=no" - ] - } - - /// We'll need to load `richCommentStyle.css` from the main bundle and inject it as a string, - /// because the web view needs to be loaded with the WordPressShared bundle to gain access to custom fonts. - var cssStyles: String { - guard let cssURL = Bundle.main.url(forResource: "richCommentStyle", withExtension: "css"), - let cssContent = try? String(contentsOf: cssURL) else { - return String() - } - return cssContent.appending(overrideStyles) - } - - /// Additional styles based on system or custom theme. - var overrideStyles: String { - """ - /* Basic style variables */ - :root { - --text-font: \(displaySetting.font.cssString); - - /* link styling */ - --link-font-weight: \(displaySetting.color == .system ? "inherit" : "600"); - --link-text-decoration: \(displaySetting.color == .system ? "inherit" : "underline"); - } - - /* Color overrides for light mode */ - @media(prefers-color-scheme: light) { - \(cssColors(interfaceStyle: .light)) - } - - /* Color overrides for dark mode */ - @media(prefers-color-scheme: dark) { - \(cssColors(interfaceStyle: .dark)) - } - """ - } - - /// CSS color definitions that matches the current color theme. - /// - Parameter interfaceStyle: The current `UIUserInterfaceStyle` value. - /// - Returns: A string of CSS colors to be injected. - private func cssColors(interfaceStyle: UIUserInterfaceStyle) -> String { - let trait = UITraitCollection(userInterfaceStyle: interfaceStyle) - - return """ - :root { - --text-color: \(textColor.color(for: trait).cssRGBAString()); - --text-secondary-color: \(displaySetting.color.secondaryForeground.color(for: trait).cssRGBAString()); - --link-color: \(linkColor.color(for: trait).cssRGBAString()); - --mention-background-color: \(mentionBackgroundColor.color(for: trait).cssRGBAString()); - --background-secondary-color: \(secondaryBackgroundColor.color(for: trait).cssRGBAString()); - --border-color: \(displaySetting.color.border.color(for: trait).cssRGBAString()); - } - """ - } - - /// 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 else { - DDLogError("WebCommentContentRenderer: 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 ( tags) for mentions. - let htmlContent = String(format: htmlTemplateFormat, content - .replacingOccurrences(of: Constants.emptyElementRegexPattern, with: String(), options: [.regularExpression]) - .trimmingCharacters(in: .whitespacesAndNewlines)) - - // cache the contents. - commentContentCache = content - htmlContentCache = htmlContent - - return htmlContent - } -} - -private extension UIColor { - func cssRGBAString(customAlpha: CGFloat? = nil) -> String { - let red = Int(rgbaComponents.red * 255) - let green = Int(rgbaComponents.green * 255) - let blue = Int(rgbaComponents.blue * 255) - let alpha = { - guard let customAlpha, customAlpha <= 1.0 else { - return rgbaComponents.alpha - } - return customAlpha - }() - - return "rgba(\(red), \(green), \(blue), \(alpha))" - } -} diff --git a/WordPress/Classes/ViewRelated/Reader/Cards/Tags View/ReaderTopicCollectionViewCoordinator.swift b/WordPress/Classes/ViewRelated/Reader/Cards/Tags View/ReaderTopicCollectionViewCoordinator.swift index 05772934527e..0b01f8c6c1f3 100644 --- a/WordPress/Classes/ViewRelated/Reader/Cards/Tags View/ReaderTopicCollectionViewCoordinator.swift +++ b/WordPress/Classes/ViewRelated/Reader/Cards/Tags View/ReaderTopicCollectionViewCoordinator.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressReader enum ReaderTopicCollectionViewState { case collapsed @@ -43,7 +44,7 @@ class ReaderTopicCollectionViewCoordinator: NSObject { } /// For custom styling. When nil, the cell will be configured with default styling. - var displaySetting: ReaderDisplaySetting? = nil + var displaySetting: ReaderDisplaySettings? = nil init(collectionView: UICollectionView, topics: [String]) { self.collectionView = collectionView diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsHelper.swift b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsHelper.swift new file mode 100644 index 000000000000..cd27d4d72283 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsHelper.swift @@ -0,0 +1,31 @@ +import Foundation +import WordPressReader + +/// A collection of utilities for managing rendering for comments. +@MainActor +@objc class ReaderCommentsHelper: NSObject { + private var contentHeights: [TaggedManagedObjectID: CGFloat] = [:] + private let renderers = NSCache() + + override init() { + renderers.countLimit = 30 + } + + func getRenderer(for comment: Comment) -> WebCommentContentRenderer { + if let renderer = renderers.object(forKey: comment) { + return renderer + } + let renderer = WebCommentContentRenderer() + renderer.tintColor = UIAppColor.primary + renderers.setObject(renderer, forKey: comment) + return renderer + } + + func getCachedContentHeight(for commentID: TaggedManagedObjectID) -> CGFloat? { + contentHeights[commentID] + } + + func setCachedContentHeight(_ height: CGFloat, for commentID: TaggedManagedObjectID) { + contentHeights[commentID] = height + } +} diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.h b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.h index 65b4a6d3bfa7..95b5ed27a726 100644 --- a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.h +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.h @@ -13,14 +13,16 @@ typedef NS_ENUM(NSUInteger, ReaderCommentsSource) { ReaderCommentsSourcePostsList }; - +@class Comment; @class ReaderPost; +@class ReaderCommentsHelper; @interface ReaderCommentsViewController : UIViewController @property (nonatomic, strong, readonly) ReaderPost *post; @property (nonatomic, assign, readwrite) BOOL allowsPushingPostDetails; @property (nonatomic, assign, readwrite) ReaderCommentsSource source; +@property (nonatomic, strong, readonly) ReaderCommentsHelper *helper; - (void)setupWithPostID:(NSNumber *)postID siteID:(NSNumber *)siteID; @@ -36,5 +38,6 @@ typedef NS_ENUM(NSUInteger, ReaderCommentsSource) { // Comment moderation support. @property (nonatomic, assign, readwrite) BOOL commentModified; - (void)refreshAfterCommentModeration; +- (NSAttributedString *)cacheContentForComment:(Comment *)comment; @end diff --git a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m index e0610eaedf52..7723d3e922bd 100644 --- a/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m +++ b/WordPress/Classes/ViewRelated/Reader/Comments/ReaderCommentsViewController.m @@ -53,6 +53,7 @@ @interface ReaderCommentsViewController () Void)? private var totalRows = 0 private var hideButton = true - var displaySetting: ReaderDisplaySetting + var displaySetting: ReaderDisplaySettings private var comments: [Comment] = [] { didSet { @@ -42,7 +45,7 @@ class ReaderDetailCommentsTableViewDelegate: NSObject, UITableViewDataSource, UI // MARK: - Public Methods - init(displaySetting: ReaderDisplaySetting = .standard) { + init(displaySetting: ReaderDisplaySettings = .standard) { self.displaySetting = displaySetting } @@ -85,7 +88,7 @@ class ReaderDetailCommentsTableViewDelegate: NSObject, UITableViewDataSource, UI } cell.displaySetting = displaySetting - cell.configureForPostDetails(with: comment) { _ in + cell.configureForPostDetails(with: comment, helper: helper) { _ in do { try WPException.objcTry { tableView.performBatchUpdates({}) @@ -109,7 +112,7 @@ class ReaderDetailCommentsTableViewDelegate: NSObject, UITableViewDataSource, UI cell.backgroundColor = .clear cell.contentView.backgroundColor = .clear - if ReaderDisplaySetting.customizationEnabled { + if ReaderDisplaySettings.customizationEnabled { cell.titleLabel.font = displaySetting.font(with: .body) cell.titleLabel.textColor = displaySetting.color.secondaryForeground } @@ -175,7 +178,7 @@ private extension ReaderDetailCommentsTableViewDelegate { let cell = BorderedButtonTableViewCell() let title = totalComments == 0 ? Constants.leaveCommentButtonTitle : Constants.viewAllButtonTitle - if ReaderDisplaySetting.customizationEnabled { + if ReaderDisplaySettings.customizationEnabled { cell.configure(buttonTitle: title, titleFont: displaySetting.font(with: .body, weight: .semibold), normalColor: displaySetting.color.foreground, diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift index 4d9b516a27e4..c959c5099c71 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailFeaturedImageView.swift @@ -1,6 +1,7 @@ import UIKit import AsyncImageKit import WordPressUI +import WordPressReader protocol ReaderDetailFeaturedImageViewDelegate: AnyObject { func didTapFeaturedImage(_ sender: AsyncImageView) @@ -31,7 +32,7 @@ final class ReaderDetailFeaturedImageView: UIView { self.endTintColor = endTintColor } - init(displaySetting: ReaderDisplaySetting) { + init(displaySetting: ReaderDisplaySettings) { self.init(endTintColor: displaySetting.color.foreground) } } @@ -60,7 +61,7 @@ final class ReaderDetailFeaturedImageView: UIView { } } - var displaySetting: ReaderDisplaySetting = .standard { + var displaySetting: ReaderDisplaySettings = .standard { didSet { style = .init(displaySetting: displaySetting) @@ -188,7 +189,7 @@ final class ReaderDetailFeaturedImageView: UIView { // Re-apply the styles after a potential orientation change. // This fixes a case where the navbar tint would revert after changing orientation. - if ReaderDisplaySetting.customizationEnabled { + if ReaderDisplaySettings.customizationEnabled { resetNavigationBarTintColor() resetStatusBarStyle() } diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift index bfc1b4b8fc3e..49608a8d4e7a 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailHeaderView.swift @@ -1,6 +1,7 @@ import SwiftUI import AsyncImageKit import WordPressUI +import WordPressReader protocol ReaderDetailHeaderViewDelegate: AnyObject { func didTapBlogName() @@ -18,7 +19,7 @@ final class ReaderDetailHeaderHostingView: UIView { } } - var displaySetting: ReaderDisplaySetting = .standard { + var displaySetting: ReaderDisplaySettings = .standard { didSet { viewModel.displaySetting = displaySetting Task { @MainActor in @@ -118,7 +119,7 @@ class ReaderDetailHeaderViewModel: ObservableObject { @Published var showsAuthorName: Bool = true - @Published var displaySetting: ReaderDisplaySetting + @Published var displaySetting: ReaderDisplaySettings var likeCountString: String? { guard let count = likeCount, count > 0 else { @@ -134,7 +135,7 @@ class ReaderDetailHeaderViewModel: ObservableObject { return WPStyleGuide.commentCountForDisplay(count) } - init(displaySetting: ReaderDisplaySetting, coreDataStack: CoreDataStackSwift = ContextManager.shared) { + init(displaySetting: ReaderDisplaySettings, coreDataStack: CoreDataStackSwift = ContextManager.shared) { self.displaySetting = displaySetting self.coreDataStack = coreDataStack } @@ -444,10 +445,10 @@ fileprivate extension ReaderDetailHeaderView { fileprivate struct ReaderDetailTagsWrapperView: UIViewRepresentable { private let topics: [String] - private let displaySetting: ReaderDisplaySetting + private let displaySetting: ReaderDisplaySettings private weak var delegate: ReaderTopicCollectionViewCoordinatorDelegate? - init(topics: [String], displaySetting: ReaderDisplaySetting, delegate: ReaderTopicCollectionViewCoordinatorDelegate?) { + init(topics: [String], displaySetting: ReaderDisplaySettings, delegate: ReaderTopicCollectionViewCoordinatorDelegate?) { self.topics = topics self.displaySetting = displaySetting self.delegate = delegate @@ -458,7 +459,7 @@ fileprivate struct ReaderDetailTagsWrapperView: UIViewRepresentable { view.topics = topics view.topicDelegate = delegate - if ReaderDisplaySetting.customizationEnabled { + if ReaderDisplaySettings.customizationEnabled { view.coordinator?.displaySetting = displaySetting } @@ -470,7 +471,7 @@ fileprivate struct ReaderDetailTagsWrapperView: UIViewRepresentable { func updateUIView(_ uiView: UICollectionView, context: Context) { if let view = uiView as? TopicsCollectionView, - ReaderDisplaySetting.customizationEnabled { + ReaderDisplaySettings.customizationEnabled { view.coordinator?.displaySetting = displaySetting } diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesView.swift index 50c654170bcf..590db5e05664 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailLikesView.swift @@ -1,4 +1,5 @@ import UIKit +import WordPressReader protocol ReaderDetailLikesViewDelegate: AnyObject { func didTapLikesView() @@ -9,7 +10,7 @@ final class ReaderDetailLikesView: UIView, NibLoadable { @IBOutlet private weak var summaryLabel: UILabel! @IBOutlet private weak var selfAvatarImageView: CircularImageView! - var displaySetting: ReaderDisplaySetting = .standard { + var displaySetting: ReaderDisplaySettings = .standard { didSet { applyStyles() if let viewModel { @@ -96,7 +97,7 @@ private extension ReaderDetailLikesView { } } -private func makeHighlightedText(_ text: String, displaySetting: ReaderDisplaySetting) -> NSAttributedString { +private func makeHighlightedText(_ text: String, displaySetting: ReaderDisplaySettings) -> NSAttributedString { let labelParts = text.components(separatedBy: "_") let firstPart = labelParts.first ?? "" diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift index 1d2d814e6c6e..b193800d5183 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/Views/ReaderDetailToolbar.swift @@ -1,5 +1,6 @@ import UIKit import WordPressUI +import WordPressReader protocol ReaderDetailToolbarDelegate: AnyObject { var notificationID: String? { get } @@ -29,7 +30,7 @@ class ReaderDetailToolbar: UIView, NibLoadable { weak var delegate: ReaderDetailToolbarDelegate? = nil - var displaySetting: ReaderDisplaySetting = .standard { + var displaySetting: ReaderDisplaySettings = .standard { didSet { applyStyles() configureActionButtons() diff --git a/WordPress/Classes/ViewRelated/Reader/Detail/WebView/ReaderWebView.swift b/WordPress/Classes/ViewRelated/Reader/Detail/WebView/ReaderWebView.swift index 344e9e50d052..7a6faef75cec 100644 --- a/WordPress/Classes/ViewRelated/Reader/Detail/WebView/ReaderWebView.swift +++ b/WordPress/Classes/ViewRelated/Reader/Detail/WebView/ReaderWebView.swift @@ -1,5 +1,6 @@ import UIKit import ColorStudio +import WordPressReader /// A WKWebView that renders post content with styles applied /// @@ -15,7 +16,11 @@ class ReaderWebView: WKWebView { var isP2 = false - var displaySetting: ReaderDisplaySetting = .standard + var displaySetting: ReaderDisplaySettings = .standard + + deinit { + print("here") + } /// Make the webview transparent /// diff --git a/WordPress/Classes/ViewRelated/Reader/ReaderFollowButton.swift b/WordPress/Classes/ViewRelated/Reader/ReaderFollowButton.swift index 4827d34cfe3d..8a74d3e0ab09 100644 --- a/WordPress/Classes/ViewRelated/Reader/ReaderFollowButton.swift +++ b/WordPress/Classes/ViewRelated/Reader/ReaderFollowButton.swift @@ -1,4 +1,5 @@ import SwiftUI +import WordPressReader struct ReaderFollowButton: View { @@ -6,7 +7,7 @@ struct ReaderFollowButton: View { let isEnabled: Bool let size: ButtonSize var color: ButtonColor = .init() - var displaySetting: ReaderDisplaySetting? + var displaySetting: ReaderDisplaySettings? let action: () -> Void @@ -30,7 +31,7 @@ struct ReaderFollowButton: View { self.unfollowedBackground = unfollowedBackground } - init(displaySetting: ReaderDisplaySetting) { + init(displaySetting: ReaderDisplaySettings) { followedText = Color(displaySetting.color.secondaryForeground) followedBackground = .clear followedStroke = followedText @@ -48,7 +49,7 @@ struct ReaderFollowButton: View { isEnabled: Bool, size: ButtonSize, color: ButtonColor? = nil, - displaySetting: ReaderDisplaySetting? = nil, + displaySetting: ReaderDisplaySettings? = nil, action: @escaping () -> Void) { self.isFollowing = isFollowing self.isEnabled = isEnabled diff --git a/WordPress/Classes/ViewRelated/Reader/Theme/ReaderDisplaySettingStore.swift b/WordPress/Classes/ViewRelated/Reader/Theme/ReaderDisplaySettingStore.swift new file mode 100644 index 000000000000..81c3057e368b --- /dev/null +++ b/WordPress/Classes/ViewRelated/Reader/Theme/ReaderDisplaySettingStore.swift @@ -0,0 +1,113 @@ +import WordPressUI +import WordPressShared +import WordPressReader + +// MARK: - Controller + +protocol ReaderDisplaySettingStoreDelegate: NSObjectProtocol { + func displaySettingDidChange() +} + +/// This should be the object to be strongly retained. Keeps the store up-to-date. +class ReaderDisplaySettingStore: NSObject { + + private let repository: UserPersistentRepository + + private let notificationCenter: NotificationCenter + + weak var delegate: ReaderDisplaySettingStoreDelegate? + + /// A public facade to simplify the flag checking dance for the `ReaderDisplaySetting` object. + /// When the flag is disabled, this will always return the `standard` object, and the setter does nothing. + var setting: ReaderDisplaySettings { + get { + return ReaderDisplaySettings.customizationEnabled ? _setting : .standard + } + set { + guard ReaderDisplaySettings.customizationEnabled, + newValue != _setting else { + return + } + _setting = newValue + broadcastChangeNotification() + } + } + + /// The actual instance variable that holds the setting object. + /// This is intentionally set to private so that it's only controllable by `ReaderDisplaySettingStore`. + private var _setting: ReaderDisplaySettings = .standard { + didSet { + guard oldValue != _setting, + let dictionary = try? setting.toDictionary() else { + return + } + repository.set(dictionary, forKey: Constants.key) + } + } + + init(repository: UserPersistentRepository = UserPersistentStoreFactory.instance(), + notificationCenter: NotificationCenter = .default) { + self.repository = repository + self.notificationCenter = notificationCenter + self._setting = { + guard let dictionary = repository.dictionary(forKey: Constants.key), + let data = try? JSONSerialization.data(withJSONObject: dictionary), + let setting = try? JSONDecoder().decode(ReaderDisplaySettings.self, from: data) else { + return .standard + } + return setting + }() + super.init() + registerNotifications() + } + + private func registerNotifications() { + notificationCenter.addObserver(self, + selector: #selector(handleChangeNotification), + name: .readerDisplaySettingStoreDidChange, + object: nil) + } + + private func broadcastChangeNotification() { + notificationCenter.post(name: .readerDisplaySettingStoreDidChange, object: self) + } + + @objc + private func handleChangeNotification(_ notification: NSNotification) { + // ignore self broadcasts. + if let broadcaster = notification.object as? ReaderDisplaySettingStore, + broadcaster == self { + return + } + + // since we're handling change notifications, a stored setting object *should* exist. + guard let updatedSetting = try? fetchSetting() else { + DDLogError("ReaderDisplaySettingStore: Received a didChange notification with a nil stored value") + return + } + + _setting = updatedSetting + delegate?.displaySettingDidChange() + } + + /// Fetches the stored value of `ReaderDisplaySetting`. + /// + /// - Returns: `ReaderDisplaySetting` + private func fetchSetting() throws -> ReaderDisplaySettings? { + guard let dictionary = repository.dictionary(forKey: Constants.key) else { + return nil + } + + let data = try JSONSerialization.data(withJSONObject: dictionary) + let setting = try JSONDecoder().decode(ReaderDisplaySettings.self, from: data) + return setting + } + + private struct Constants { + static let key = "readerDisplaySettingKey" + } +} + +fileprivate extension NSNotification.Name { + static let readerDisplaySettingStoreDidChange = NSNotification.Name("ReaderDisplaySettingDidChange") +} diff --git a/WordPress/Classes/ViewRelated/Reader/Theme/ReaderDisplaySettingViewController.swift b/WordPress/Classes/ViewRelated/Reader/Theme/ReaderDisplaySettingViewController.swift index afe9a012e9ac..412e1f71ec94 100644 --- a/WordPress/Classes/ViewRelated/Reader/Theme/ReaderDisplaySettingViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Theme/ReaderDisplaySettingViewController.swift @@ -1,5 +1,6 @@ import SwiftUI import DesignSystem +import WordPressReader /// The tracking source values for the customization sheet. /// The values are kept in sync with Android. @@ -9,8 +10,8 @@ enum ReaderDisplaySettingViewSource: String { } class ReaderDisplaySettingViewController: UIViewController { - private let initialSetting: ReaderDisplaySetting - private let completion: ((ReaderDisplaySetting) -> Void)? + private let initialSetting: ReaderDisplaySettings + private let completion: ((ReaderDisplaySettings) -> Void)? private let trackingSource: ReaderDisplaySettingViewSource private var viewModel: ReaderDisplaySettingSelectionViewModel? = nil @@ -18,9 +19,9 @@ class ReaderDisplaySettingViewController: UIViewController { fatalError("init(coder:) has not been implemented") } - init(initialSetting: ReaderDisplaySetting, + init(initialSetting: ReaderDisplaySettings, source: ReaderDisplaySettingViewSource = .unspecified, - completion: ((ReaderDisplaySetting) -> Void)?) { + completion: ((ReaderDisplaySettings) -> Void)?) { self.initialSetting = initialSetting self.completion = completion self.trackingSource = source @@ -83,7 +84,7 @@ class ReaderDisplaySettingViewController: UIViewController { } @MainActor - private func updateNavigationBar(with setting: ReaderDisplaySetting) { + private func updateNavigationBar(with setting: ReaderDisplaySettings) { navigationController?.navigationBar.overrideUserInterfaceStyle = setting.hasLightBackground ? .light : .dark // update the experimental label style @@ -118,14 +119,14 @@ class ReaderDisplaySettingViewController: UIViewController { class ReaderDisplaySettingSelectionViewModel: NSObject, ObservableObject { private typealias TrackingKeys = ReaderDisplaySettingSelectionView.TrackingKeys - @Published var displaySetting: ReaderDisplaySetting + @Published var displaySetting: ReaderDisplaySettings /// Called when the user selects a new option. var didSelectItem: (() -> Void)? = nil - private let completion: ((ReaderDisplaySetting) -> Void)? + private let completion: ((ReaderDisplaySettings) -> Void)? - init(displaySetting: ReaderDisplaySetting, completion: ((ReaderDisplaySetting) -> Void)?) { + init(displaySetting: ReaderDisplaySettings, completion: ((ReaderDisplaySettings) -> Void)?) { self.displaySetting = displaySetting self.completion = completion } @@ -338,7 +339,7 @@ extension ReaderDisplaySettingSelectionView { var colorSelectionView: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: .DS.Padding.half) { - ForEach(ReaderDisplaySetting.Color.allCases, id: \.rawValue) { color in + ForEach(ReaderDisplaySettings.Color.allCases, id: \.rawValue) { color in Button { viewModel.displaySetting.color = color viewModel.didSelectItem?() // notify the view controller to update. @@ -374,7 +375,7 @@ extension ReaderDisplaySettingSelectionView { var fontSelectionView: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: .DS.Padding.half) { - ForEach(ReaderDisplaySetting.Font.allCases, id: \.rawValue) { font in + ForEach(ReaderDisplaySettings.Font.allCases, id: \.rawValue) { font in Button { viewModel.displaySetting.font = font viewModel.didSelectItem?() // notify the view controller to update. @@ -384,7 +385,7 @@ extension ReaderDisplaySettingSelectionView { } label: { VStack(spacing: .DS.Padding.half) { Text("Aa") - .font(Font(ReaderDisplaySetting.font(with: font, textStyle: .largeTitle)).bold()) + .font(Font(ReaderDisplaySettings.font(with: font, textStyle: .largeTitle)).bold()) .foregroundStyle(Color(.label)) Text(font.rawValue.capitalized) .font(.footnote) @@ -409,19 +410,19 @@ extension ReaderDisplaySettingSelectionView { var sizeSelectionView: some View { Slider(value: $sliderValue, - in: Double(ReaderDisplaySetting.Size.extraSmall.rawValue)...Double(ReaderDisplaySetting.Size.extraLarge.rawValue), + in: Double(ReaderDisplaySettings.Size.extraSmall.rawValue)...Double(ReaderDisplaySettings.Size.extraLarge.rawValue), step: 1) { Text(Strings.sizeSliderLabel) } minimumValueLabel: { Text("A") - .font(Font(ReaderDisplaySetting.font(with: .sans, size: .extraSmall, textStyle: .body))) + .font(Font(ReaderDisplaySettings.font(with: .sans, size: .extraSmall, textStyle: .body))) .accessibilityHidden(true) } maximumValueLabel: { Text("A") - .font(Font(ReaderDisplaySetting.font(with: .sans, size: .extraLarge, textStyle: .body))) + .font(Font(ReaderDisplaySettings.font(with: .sans, size: .extraLarge, textStyle: .body))) .accessibilityHidden(true) } onEditingChanged: { _ in - let size = ReaderDisplaySetting.Size(rawValue: Int(sliderValue)) ?? .normal + let size = ReaderDisplaySettings.Size(rawValue: Int(sliderValue)) ?? .normal viewModel.displaySetting.size = size viewModel.didSelectItem?() // notify the view controller to update. WPAnalytics.track(.readingPreferencesItemTapped, diff --git a/WordPress/WordPress.xcodeproj/project.pbxproj b/WordPress/WordPress.xcodeproj/project.pbxproj index 228f44b17036..66493c618a50 100644 --- a/WordPress/WordPress.xcodeproj/project.pbxproj +++ b/WordPress/WordPress.xcodeproj/project.pbxproj @@ -1529,10 +1529,6 @@ FD3D6D2C1349F5D30061136A /* ImageIO.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD3D6D2B1349F5D30061136A /* ImageIO.framework */; }; FE003F62282E73E6006F8D1D /* blogging-prompts-fetch-success.json in Resources */ = {isa = PBXBuildFile; fileRef = FE003F61282E73E6006F8D1D /* blogging-prompts-fetch-success.json */; }; FE1E201E2A49D59400CE7C90 /* JetpackSocialServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE1E201D2A49D59400CE7C90 /* JetpackSocialServiceTests.swift */; }; - FE23EB4926E7C91F005A1698 /* richCommentTemplate.html in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */; }; - FE23EB4A26E7C91F005A1698 /* richCommentTemplate.html in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */; }; - FE23EB4B26E7C91F005A1698 /* richCommentStyle.css in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4826E7C91F005A1698 /* richCommentStyle.css */; }; - FE23EB4C26E7C91F005A1698 /* richCommentStyle.css in Resources */ = {isa = PBXBuildFile; fileRef = FE23EB4826E7C91F005A1698 /* richCommentStyle.css */; }; FE2E3729281C839C00A1E82A /* BloggingPromptsServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2E3728281C839C00A1E82A /* BloggingPromptsServiceTests.swift */; }; FE320CC5294705990046899B /* ReaderPostBackupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE320CC4294705990046899B /* ReaderPostBackupTests.swift */; }; FE32E7F12844971000744D80 /* ReminderScheduleCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE32E7F02844971000744D80 /* ReminderScheduleCoordinatorTests.swift */; }; @@ -3312,8 +3308,6 @@ FDCB9A89134B75B900E5C776 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; FE003F61282E73E6006F8D1D /* blogging-prompts-fetch-success.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "blogging-prompts-fetch-success.json"; sourceTree = ""; }; FE1E201D2A49D59400CE7C90 /* JetpackSocialServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JetpackSocialServiceTests.swift; sourceTree = ""; }; - FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; name = richCommentTemplate.html; path = Resources/HTML/richCommentTemplate.html; sourceTree = ""; }; - FE23EB4826E7C91F005A1698 /* richCommentStyle.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; name = richCommentStyle.css; path = Resources/HTML/richCommentStyle.css; sourceTree = ""; }; FE2E3728281C839C00A1E82A /* BloggingPromptsServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BloggingPromptsServiceTests.swift; sourceTree = ""; }; FE320CC4294705990046899B /* ReaderPostBackupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderPostBackupTests.swift; sourceTree = ""; }; FE32E7F02844971000744D80 /* ReminderScheduleCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReminderScheduleCoordinatorTests.swift; sourceTree = ""; }; @@ -3388,7 +3382,6 @@ Utility/SFHFKeychainUtils.m = "-fno-objc-arc"; }; membershipExceptions = ( - "Extensions/Colors and Styles/UIColor+Extensions.swift", "Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift", "Extensions/Colors and Styles/WPStyleGuide+Gridicon.swift", "Extensions/NSAttributedStringKey+Conversion.swift", @@ -3427,7 +3420,6 @@ Utility/SFHFKeychainUtils.m = "-fno-objc-arc"; }; membershipExceptions = ( - "Extensions/Colors and Styles/UIColor+Extensions.swift", "Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift", "Extensions/Colors and Styles/WPStyleGuide+Gridicon.swift", "Extensions/NSAttributedStringKey+Conversion.swift", @@ -3466,7 +3458,6 @@ Utility/SFHFKeychainUtils.m = "-fno-objc-arc"; }; membershipExceptions = ( - "Extensions/Colors and Styles/UIColor+Extensions.swift", Services/KeyValueDatabase.swift, System/Constants/Constants.m, "Utility/App Configuration/AppConfiguration.swift", @@ -3559,7 +3550,6 @@ Utility/SFHFKeychainUtils.m = "-fno-objc-arc"; }; membershipExceptions = ( - "Extensions/Colors and Styles/UIColor+Extensions.swift", "Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift", "Extensions/Colors and Styles/WPStyleGuide+Gridicon.swift", "Extensions/NSAttributedStringKey+Conversion.swift", @@ -3596,7 +3586,6 @@ Utility/SFHFKeychainUtils.m = "-fno-objc-arc"; }; membershipExceptions = ( - "Extensions/Colors and Styles/UIColor+Extensions.swift", "Extensions/Colors and Styles/WPStyleGuide+ApplicationStyles.swift", "Extensions/Colors and Styles/WPStyleGuide+Gridicon.swift", "Extensions/NSAttributedStringKey+Conversion.swift", @@ -6724,8 +6713,6 @@ 2FAE97080E33B21600CA8540 /* xhtmlValidatorTemplate.xhtml */, E61507E12220A0FE00213D33 /* richEmbedTemplate.html */, E61507E32220A13B00213D33 /* richEmbedScript.js */, - FE23EB4826E7C91F005A1698 /* richCommentStyle.css */, - FE23EB4726E7C91F005A1698 /* richCommentTemplate.html */, ); name = HTML; sourceTree = ""; @@ -8400,7 +8387,6 @@ 17222D81261DDDF90047B163 /* celadon-classic-icon-app-76x76@2x.png in Resources */, 931DF4D618D09A2F00540BDD /* InfoPlist.strings in Resources */, 801D9511291AB3CF0051993E /* JetpackStatsLogoAnimation_ltr.json in Resources */, - FE23EB4926E7C91F005A1698 /* richCommentTemplate.html in Resources */, 1761F18826209AEE000815EF /* pride-icon-app-76x76.png in Resources */, 17222D8A261DDDF90047B163 /* black-classic-icon-app-76x76@2x.png in Resources */, 1761F17F26209AEE000815EF /* open-source-icon-app-76x76@2x.png in Resources */, @@ -8475,7 +8461,6 @@ 4A690C152BA791B100A8E0C5 /* PrivacyInfo.xcprivacy in Resources */, 1761F17526209AEE000815EF /* open-source-dark-icon-app-76x76@2x.png in Resources */, 1761F18A26209AEE000815EF /* hot-pink-icon-app-60x60@2x.png in Resources */, - FE23EB4B26E7C91F005A1698 /* richCommentStyle.css in Resources */, 1761F18526209AEE000815EF /* pride-icon-app-60x60@3x.png in Resources */, 0CD542CA2CEFAE5F00666F44 /* wporg-blog-icon.png in Resources */, 17222DB0261DDDF90047B163 /* spectrum-classic-icon-app-83.5x83.5@2x.png in Resources */, @@ -8764,7 +8749,6 @@ F465980828E66A5B00D5F49A /* white-on-blue-icon-app-76@2x.png in Resources */, F46597BE28E6687800D5F49A /* neumorphic-dark-icon-app-60@3x.png in Resources */, F41E4ECB28F23E00001880C6 /* green-on-white-icon-app-60@3x.png in Resources */, - FE23EB4C26E7C91F005A1698 /* richCommentStyle.css in Resources */, F41E4EE428F24623001880C6 /* 3d-icon-app-60@2x.png in Resources */, F46597F328E669D400D5F49A /* spectrum-on-white-icon-app-83.5@2x.png in Resources */, F41E4EEF28F247D3001880C6 /* white-on-green-icon-app-60@2x.png in Resources */, @@ -8814,7 +8798,6 @@ FABB203D2602FC2C00C8785C /* Localizable.strings in Resources */, FABB28472603067C00C8785C /* Launch Screen.storyboard in Resources */, F465978F28E65F8A00D5F49A /* celadon-on-white-icon-app-60@2x.png in Resources */, - FE23EB4A26E7C91F005A1698 /* richCommentTemplate.html in Resources */, F41E4EAA28F20DF9001880C6 /* stroke-light-icon-app-76.png in Resources */, F46597FE28E66A1100D5F49A /* white-on-black-icon-app-76.png in Resources */, F41E4ED628F2424B001880C6 /* dark-glow-icon-app-83.5@2x.png in Resources */,