From 54857a3bf3053b356a6b9cdda8ef645f3a9f7538 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 4 Dec 2022 12:08:22 -0500 Subject: [PATCH] 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. --- Tusker.xcodeproj/project.pbxproj | 4 ++ Tusker/Filterer.swift | 22 ++++---- Tusker/Lazy.swift | 54 +++++++++++++++++++ Tusker/Logging.swift | 1 + .../TrendingStatusesViewController.swift | 2 +- .../ProfileStatusesViewController.swift | 14 ++--- ...tatusActionAccountListViewController.swift | 2 +- .../InstanceTimelineViewController.swift | 4 +- .../Timeline/TimelineViewController.swift | 19 ++++--- .../Status/StatusCollectionViewCell.swift | 6 +-- .../Views/Status/StatusContentContainer.swift | 1 - .../TimelineStatusCollectionViewCell.swift | 9 ++-- Tusker/Views/StatusContentTextView.swift | 8 ++- 13 files changed, 108 insertions(+), 38 deletions(-) create mode 100644 Tusker/Lazy.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 08b1126e..fd604000 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D61F75B8293C15A000C0B37F /* ZeroHeightCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZeroHeightCollectionViewCell.swift; sourceTree = ""; }; D61F75BA293C183100C0B37F /* HTMLConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLConverter.swift; sourceTree = ""; }; + D61F75BC293D099600C0B37F /* Lazy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Lazy.swift; sourceTree = ""; }; D620483323D3801D008A63EF /* LinkTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkTextView.swift; sourceTree = ""; }; D620483523D38075008A63EF /* ContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentTextView.swift; sourceTree = ""; }; D620483723D38190008A63EF /* StatusContentTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentTextView.swift; sourceTree = ""; }; @@ -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 */, diff --git a/Tusker/Filterer.swift b/Tusker/Filterer.swift index 2486bead..10fa3ff9 100644 --- a/Tusker/Filterer.swift +++ b/Tusker/Filterer.swift @@ -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 { diff --git a/Tusker/Lazy.swift b/Tusker/Lazy.swift new file mode 100644 index 00000000..d87318fa --- /dev/null +++ b/Tusker/Lazy.swift @@ -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 { + 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 + } + } +} diff --git a/Tusker/Logging.swift b/Tusker/Logging.swift index a5714496..61cc117b 100644 --- a/Tusker/Logging.swift +++ b/Tusker/Logging.swift @@ -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 { diff --git a/Tusker/Screens/Explore/TrendingStatusesViewController.swift b/Tusker/Screens/Explore/TrendingStatusesViewController.swift index 9864bdf1..6166539d 100644 --- a/Tusker/Screens/Explore/TrendingStatusesViewController.swift +++ b/Tusker/Screens/Explore/TrendingStatusesViewController.swift @@ -65,7 +65,7 @@ class TrendingStatusesViewController: UIViewController { let statusCell = UICollectionView.CellRegistration { [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 { cell, _, _ in cell.indicator.startAnimating() diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index 1b8c8b8f..225a9aec 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -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 { collectionView.register(ProfileHeaderCollectionViewCell.self, forCellWithReuseIdentifier: "headerCell") - let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in + let statusCell = UICollectionView.CellRegistration { [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 diff --git a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift index 0debd7be..b556de92 100644 --- a/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift +++ b/Tusker/Screens/Status Action Account List/StatusActionAccountListViewController.swift @@ -80,7 +80,7 @@ class StatusActionAccountListViewController: UIViewController { private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [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 { [unowned self] cell, indexPath, item in cell.delegate = self diff --git a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift index 5ce4a5f8..0b46b9ab 100644 --- a/Tusker/Screens/Timeline/InstanceTimelineViewController.swift +++ b/Tusker/Screens/Timeline/InstanceTimelineViewController.swift @@ -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) { diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 1e2b4aa0..5a0f54b0 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -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 { - let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in - self.configureStatusCell(cell, id: item.0, state: item.1, filterResult: item.2) + let statusCell = UICollectionView.CellRegistration { [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 { _, _, _ 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 diff --git a/Tusker/Views/Status/StatusCollectionViewCell.swift b/Tusker/Views/Status/StatusCollectionViewCell.swift index b76317cb..66634ab2 100644 --- a/Tusker/Views/Status/StatusCollectionViewCell.swift +++ b/Tusker/Views/Status/StatusCollectionViewCell.swift @@ -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) } diff --git a/Tusker/Views/Status/StatusContentContainer.swift b/Tusker/Views/Status/StatusContentContainer.swift index 64dce514..cdb4a7fe 100644 --- a/Tusker/Views/Status/StatusContentContainer.swift +++ b/Tusker/Views/Status/StatusContentContainer.swift @@ -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 diff --git a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift index 370397bd..1216a6f0 100644 --- a/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift +++ b/Tusker/Views/Status/TimelineStatusCollectionViewCell.swift @@ -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 diff --git a/Tusker/Views/StatusContentTextView.swift b/Tusker/Views/StatusContentTextView.swift index 2db13a98..ceb1ca45 100644 --- a/Tusker/Views/StatusContentTextView.swift +++ b/Tusker/Views/StatusContentTextView.swift @@ -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) }