Avoid converting HTML to attributed string twice when displaying a status cell for the first time

Now, when Filterer performs the conversion, the status cell can reuse
the attributed string.
This commit is contained in:
Shadowfacts 2022-12-04 12:08:22 -05:00
parent b28f616e85
commit 54857a3bf3
13 changed files with 108 additions and 38 deletions

View File

@ -67,6 +67,7 @@
D61F75B7293C119700C0B37F /* Filterer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B6293C119700C0B37F /* Filterer.swift */; };
D61F75B9293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */; };
D61F75BB293C183100C0B37F /* HTMLConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75BA293C183100C0B37F /* HTMLConverter.swift */; };
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D61F75BC293D099600C0B37F /* Lazy.swift */; };
D620483423D3801D008A63EF /* LinkTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483323D3801D008A63EF /* LinkTextView.swift */; };
D620483623D38075008A63EF /* ContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483523D38075008A63EF /* ContentTextView.swift */; };
D620483823D38190008A63EF /* StatusContentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D620483723D38190008A63EF /* StatusContentTextView.swift */; };
@ -450,6 +451,7 @@
D61F75B6293C119700C0B37F /* Filterer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Filterer.swift; sourceTree = "<group>"; };
D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZeroHeightCollectionViewCell.swift; sourceTree = "<group>"; };
D61F75BA293C183100C0B37F /* HTMLConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLConverter.swift; sourceTree = "<group>"; };
D61F75BC293D099600C0B37F /* Lazy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lazy.swift; sourceTree = "<group>"; };
D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = "<group>"; };
D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = "<group>"; };
D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = "<group>"; };
@ -1463,6 +1465,7 @@
D6E4269C2532A3E100C02E1C /* FuzzyMatcher.swift */,
D61F75BA293C183100C0B37F /* HTMLConverter.swift */,
D6B30E08254BAF63009CAEE5 /* ImageGrayscalifier.swift */,
D61F75BC293D099600C0B37F /* Lazy.swift */,
D60E2F2B24423EAD005F8713 /* LazilyDecoding.swift */,
D64D0AAC2128D88B005A6F37 /* LocalData.swift */,
D61DC84528F498F200B82C6E /* Logging.swift */,
@ -2004,6 +2007,7 @@
D6420AEE26BED18B00ED8175 /* PublicTimelineDescriptionTableViewCell.swift in Sources */,
D6E0DC8E216EDF1E00369478 /* Previewing.swift in Sources */,
D6B93667281D937300237D0E /* MainSidebarMyProfileCollectionViewCell.swift in Sources */,
D61F75BD293D099600C0B37F /* Lazy.swift in Sources */,
D6BED174212667E900F02DA0 /* TimelineStatusTableViewCell.swift in Sources */,
D69693F42585941A00F4E116 /* UIWindowSceneDelegate+Close.swift in Sources */,
D6C143DA253510F4007DC240 /* ComposeEmojiTextField.swift in Sources */,

View File

@ -42,7 +42,7 @@ class Filterer {
var filtersChanged: ((Bool) -> Void)?
private let htmlConverter = HTMLConverter()
var htmlConverter = HTMLConverter()
private var hasSetup = false
private var matchers = [(NSRegularExpression, Result)]()
private var allFiltersObserver: AnyCancellable?
@ -114,16 +114,16 @@ class Filterer {
}
// Use a closure for the status in case the result is cached and we don't need to look it up
func resolve(state: FilterState, status: () -> StatusMO) -> Filterer.Result {
func resolve(state: FilterState, status: () -> StatusMO) -> (Filterer.Result, NSAttributedString?) {
switch state.state {
case .known(_, generation: let knownGen) where knownGen < generation:
fallthrough
case .unknown:
let result = doResolve(status: status())
let (result, attributedString) = doResolve(status: status())
state.state = .known(result, generation: generation)
return result
return (result, attributedString)
case .known(let result, _):
return result
return (result, nil)
}
}
@ -131,21 +131,21 @@ class Filterer {
state.state = .known(result, generation: generation)
}
private func doResolve(status: StatusMO) -> Result {
private func doResolve(status: StatusMO) -> (Result, NSAttributedString?) {
if !hasSetup {
setupFilters(filters: mastodonController.filters)
}
if matchers.isEmpty {
return .allow
return (.allow, nil)
}
lazy var text = htmlConverter.convert(status.content).string
@Lazy var text = self.htmlConverter.convert(status.content)
for (regex, result) in matchers {
if (!status.spoilerText.isEmpty && regex.numberOfMatches(in: status.spoilerText, range: NSRange(location: 0, length: status.spoilerText.utf16.count)) > 0)
|| regex.numberOfMatches(in: text, range: NSRange(location: 0, length: text.utf16.count)) > 0 {
return result
|| regex.numberOfMatches(in: text.string, range: NSRange(location: 0, length: text.length)) > 0 {
return (result, _text.valueIfInitialized)
}
}
return .allow
return (.allow, _text.valueIfInitialized)
}
enum Result: Equatable {

54
Tusker/Lazy.swift Normal file
View File

@ -0,0 +1,54 @@
//
// Lazy.swift
// Tusker
//
// Created by Shadowfacts on 12/4/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
/// A lazy initialization property wrapper that allows checking the initialization state.
@propertyWrapper
enum Lazy<Value> {
case uninitialized(() -> Value)
case initialized(Value)
init(wrappedValue: @autoclosure @escaping () -> Value) {
self = .uninitialized(wrappedValue)
}
/// Returns the contained value, initializing it if the value hasn't been accessed before.
var wrappedValue: Value {
mutating get {
switch self {
case .uninitialized(let closure):
let value = closure()
self = .initialized(value)
return value
case .initialized(let value):
return value
}
}
}
/// Whether this Lazy has been initialized yet.
var isInitialized: Bool {
switch self {
case .uninitialized(_):
return false
case .initialized(_):
return true
}
}
/// If this Lazy is initialized, this returns the value. Otherwise, it returns `nil`.
var valueIfInitialized: Value? {
switch self {
case .uninitialized(_):
return nil
case .initialized(let value):
return value
}
}
}

View File

@ -13,6 +13,7 @@ struct Logging {
private init() {}
static let general = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "General")
static let generalSignposter = OSSignposter(logger: general)
static func getLogData() -> Data? {
do {

View File

@ -65,7 +65,7 @@ class TrendingStatusesViewController: UIViewController {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState)> { [unowned self] cell, indexPath, item in
cell.delegate = self
// TODO: filter these
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow)
cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil)
}
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { cell, _, _ in
cell.indicator.startAnimating()

View File

@ -40,6 +40,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
self.owner = owner
self.mastodonController = owner.mastodonController
self.filterer = Filterer(mastodonController: mastodonController, context: .account)
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
super.init(nibName: nil, bundle: nil)
@ -116,10 +118,10 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell")
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result, Bool)> { [unowned self] cell, indexPath, item in
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result, NSAttributedString?, Bool)> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.showPinned = item.3
cell.updateUI(statusID: item.0, state: item.1, filterResult: item.2)
cell.showPinned = item.4
cell.updateUI(statusID: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3)
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
@ -146,8 +148,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
return cell
}
case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned):
let result = filterResult(state: filterState, statusID: id)
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, pinned))
let (result, precomputedContent) = filterResult(state: filterState, statusID: id)
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, precomputedContent, pinned))
case .loadingIndicator:
return loadingIndicatorCell(for: indexPath)
case .confirmLoadMore:
@ -245,7 +247,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
await apply(snapshot, animatingDifferences: true)
}
private func filterResult(state: FilterState, statusID: String) -> Filterer.Result {
private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) {
let status = {
let status = self.mastodonController.persistentContainer.status(for: statusID)!
// if the status is a reblog of another one, filter based on that one

View File

@ -80,7 +80,7 @@ class StatusActionAccountListViewController: UIViewController {
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, Void> { [unowned self] cell, indexPath, _ in
cell.delegate = self
cell.updateUI(statusID: self.statusID, state: self.statusState, filterResult: .allow)
cell.updateUI(statusID: self.statusID, state: self.statusState, filterResult: .allow, precomputedContent: nil)
}
let accountCell = UICollectionView.CellRegistration<AccountCollectionViewCell, String> { [unowned self] cell, indexPath, item in
cell.delegate = self

View File

@ -69,10 +69,10 @@ class InstanceTimelineViewController: TimelineViewController {
toggleSaveButton.title = toggleSaveButtonTitle
}
override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result) {
override func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
cell.delegate = browsingEnabled ? self : nil
cell.overrideMastodonController = mastodonController
cell.updateUI(statusID: id, state: state, filterResult: filterResult)
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
}
override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {

View File

@ -37,6 +37,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
filterContext = .public
}
self.filterer = Filterer(mastodonController: mastodonController, context: filterContext)
self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont
self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
super.init(nibName: nil, bundle: nil)
@ -69,7 +71,8 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else if case .status(id: let id, collapseState: _, filterState: let filterState) = item,
case .hide = filterResult(state: filterState, statusID: id) {
case (.hide, _) = filterResult(state: filterState, statusID: id) {
// this runs after the cell is setup, so the filter state is already known and this check is cheap
config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = .hidden
} else {
@ -116,19 +119,19 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
}
// separate method because InstanceTimelineViewController needs to be able to customize it
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result) {
func configureStatusCell(_ cell: TimelineStatusCollectionViewCell, id: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
cell.delegate = self
if case .home = timeline {
cell.showFollowedHashtags = true
} else {
cell.showFollowedHashtags = false
}
cell.updateUI(statusID: id, state: state, filterResult: filterResult)
cell.updateUI(statusID: id, state: state, filterResult: filterResult, precomputedContent: precomputedContent)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result)> { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1, filterResult: item.2)
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Filterer.Result, NSAttributedString?)> { [unowned self] cell, indexPath, item in
self.configureStatusCell(cell, id: item.0, state: item.1, filterResult: item.2, precomputedContent: item.3)
}
let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
}
@ -148,10 +151,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .status(id: let id, collapseState: let state, filterState: let filterState):
let result = filterResult(state: filterState, statusID: id)
let (result, attributedString) = filterResult(state: filterState, statusID: id)
switch result {
case .allow, .warn(_):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result))
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result, nil))
case .hide:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
}
@ -329,7 +332,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
isShowingTimelineDescription = false
}
private func filterResult(state: FilterState, statusID: String) -> Filterer.Result {
private func filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) {
let status = {
let status = self.mastodonController.persistentContainer.status(for: statusID)!
// if the status is a reblog of another one, filter based on that one

View File

@ -76,14 +76,14 @@ extension StatusCollectionViewCell {
.store(in: &cancellables)
}
func doUpdateUI(status: StatusMO) {
func doUpdateUI(status: StatusMO, precomputedContent: NSAttributedString? = nil) {
statusID = status.id
accountID = status.account.id
updateAccountUI(account: status.account)
updateUIForPreferences(status: status)
contentContainer.contentTextView.setTextFrom(status: status)
contentContainer.contentTextView.setTextFrom(status: status, precomputed: precomputedContent)
contentContainer.contentTextView.navigationDelegate = delegate
contentContainer.attachmentsView.delegate = self
contentContainer.attachmentsView.updateUI(status: status)
@ -173,7 +173,7 @@ extension StatusCollectionViewCell {
func updateGrayscaleableUI(status: StatusMO) {
isGrayscale = Preferences.shared.grayscaleImages
if contentContainer.contentTextView.hasEmojis {
contentContainer.contentTextView.setTextFrom(status: status)
contentContainer.contentTextView.setEmojis(status.emojis, identifier: status.id)
}
displayNameLabel.updateForAccountDisplayName(account: status.account)
}

View File

@ -11,7 +11,6 @@ import UIKit
class StatusContentContainer: UIView {
let contentTextView = StatusContentTextView().configure {
$0.defaultFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 16))
$0.adjustsFontForContentSizeCategory = true
$0.isScrollEnabled = false
$0.backgroundColor = nil

View File

@ -16,6 +16,8 @@ private let hashtagIcon = UIImage(systemName: "number")
class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollectionViewCell {
static let separatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 0)
static let contentFont = UIFontMetrics(forTextStyle: .body).scaledFont(for: .systemFont(ofSize: 16))
static let contentParagraphStyle = HTMLConverter.defaultParagraphStyle
// MARK: Subviews
@ -164,6 +166,8 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
}
let contentContainer = StatusContentContainer().configure {
$0.contentTextView.font = TimelineStatusCollectionViewCell.contentFont
$0.contentTextView.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle
$0.setContentHuggingPriority(.defaultLow, for: .vertical)
}
private var contentTextView: StatusContentTextView {
@ -478,8 +482,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
}
}
func updateUI(statusID: String, state: CollapseState, filterResult: Filterer.Result) {
func updateUI(statusID: String, state: CollapseState, filterResult: Filterer.Result, precomputedContent: NSAttributedString?) {
guard var status = mastodonController.persistentContainer.status(for: statusID) else {
fatalError()
}
@ -531,7 +534,7 @@ class TimelineStatusCollectionViewCell: UICollectionViewListCell, StatusCollecti
mainContainerTopToReblogLabelConstraint.isActive = !hideTimelineReason
mainContainerTopToSelfConstraint.isActive = hideTimelineReason
doUpdateUI(status: status)
doUpdateUI(status: status, precomputedContent: precomputedContent)
doUpdateTimestamp(status: status)
timestampLabel.isHidden = showPinned

View File

@ -14,9 +14,13 @@ class StatusContentTextView: ContentTextView {
private var statusID: String?
func setTextFrom(status: StatusMO) {
func setTextFrom(status: StatusMO, precomputed attributedText: NSAttributedString? = nil) {
statusID = status.id
setTextFromHtml(status.content)
if let attributedText {
self.attributedText = attributedText
} else {
setTextFromHtml(status.content)
}
setEmojis(status.emojis, identifier: status.id)
}