diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index f423a529..36750ea4 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -417,6 +417,7 @@ DBAE3FAF26172FC0004B8251 /* RemoteProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */; }; DBAEDE5C267A058D00D25FF5 /* BlurhashImageCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */; }; DBAEDE5F267A0B1500D25FF5 /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = DBAEDE5E267A0B1500D25FF5 /* Nuke */; }; + DBAEDE61267B342D00D25FF5 /* StatusContentCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAEDE60267B342D00D25FF5 /* StatusContentCacheService.swift */; }; DBAFB7352645463500371D5F /* Emojis.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBAFB7342645463500371D5F /* Emojis.swift */; }; DBB525082611EAC0002F1F29 /* Tabman in Frameworks */ = {isa = PBXBuildFile; productRef = DBB525072611EAC0002F1F29 /* Tabman */; }; DBB5250E2611EBAF002F1F29 /* ProfileSegmentedViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */; }; @@ -989,6 +990,7 @@ DBAE3FA82617106E004B8251 /* MastodonMetricFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonMetricFormatter.swift; sourceTree = ""; }; DBAE3FAE26172FC0004B8251 /* RemoteProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteProfileViewModel.swift; sourceTree = ""; }; DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlurhashImageCacheService.swift; sourceTree = ""; }; + DBAEDE60267B342D00D25FF5 /* StatusContentCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusContentCacheService.swift; sourceTree = ""; }; DBAFB7342645463500371D5F /* Emojis.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Emojis.swift; sourceTree = ""; }; DBB5250D2611EBAF002F1F29 /* ProfileSegmentedViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSegmentedViewController.swift; sourceTree = ""; }; DBB525202611EBD6002F1F29 /* ProfilePagingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePagingViewController.swift; sourceTree = ""; }; @@ -1358,6 +1360,7 @@ DBA5E7A2263AD0A3004598BB /* PhotoLibraryService.swift */, DB297B1A2679FAE200704C90 /* PlaceholderImageCacheService.swift */, DBAEDE5B267A058D00D25FF5 /* BlurhashImageCacheService.swift */, + DBAEDE60267B342D00D25FF5 /* StatusContentCacheService.swift */, ); path = Service; sourceTree = ""; @@ -2985,6 +2988,7 @@ DB92CF7225E7BB98002C1017 /* PollOptionTableViewCell.swift in Sources */, DB72602725E36A6F00235243 /* MastodonServerRulesViewModel.swift in Sources */, 2D364F7225E66D7500204FDC /* MastodonResendEmailViewController.swift in Sources */, + DBAEDE61267B342D00D25FF5 /* StatusContentCacheService.swift in Sources */, 2D38F1F125CD477D00561493 /* HomeTimelineViewModel+LoadMiddleState.swift in Sources */, DB68A06325E905E000CFDF14 /* UIApplication.swift in Sources */, DB02CDAB26256A9500D0A2AF /* ThreadReplyLoaderTableViewCell.swift in Sources */, diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index e8494ef8..2fb2d980 100644 --- a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist @@ -12,12 +12,12 @@ CoreDataStack.xcscheme_^#shared#^_ orderHint - 15 + 17 Mastodon - RTL.xcscheme_^#shared#^_ orderHint - 2 + 1 Mastodon - Release.xcscheme_^#shared#^_ @@ -27,7 +27,7 @@ Mastodon.xcscheme_^#shared#^_ orderHint - 12 + 2 NotificationService.xcscheme_^#shared#^_ diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 4b400522..b0a4ffbc 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -194,9 +194,15 @@ extension StatusSection { let author = (status.reblog ?? status).author return author.displayName.isEmpty ? author.username : author.displayName }() - cell.statusView.nameLabel.configure(content: nameText, emojiDict: (status.reblog ?? status).author.emojiDict) + MastodonStatusContent.parseResult(content: nameText, emojiDict: (status.reblog ?? status).author.emojiDict) + .receive(on: DispatchQueue.main) + .sink { [weak cell] parseResult in + guard let cell = cell else { return } + cell.statusView.nameLabel.configure(contentParseResult: parseResult) + } + .store(in: &cell.disposeBag) cell.statusView.usernameLabel.text = "@" + (status.reblog ?? status).author.acct - + // set avatar if let reblog = status.reblog { cell.statusView.avatarButton.isHidden = true @@ -210,6 +216,19 @@ extension StatusSection { } // set text +// func configureStatusContent() { +// let content = (status.reblog ?? status).content +// let emojiDict = (status.reblog ?? status).emojiDict +// if let cachedParseResult = AppContext.shared.statusContentCacheService.parseResult(content: content, emojiDict: emojiDict) { +// cell.statusView.activeTextLabel.configure(contentParseResult: cachedParseResult) +// } else { +// cell.statusView.activeTextLabel.configure( +// content: (status.reblog ?? status).content, +// emojiDict: (status.reblog ?? status).emojiDict +// ) +// } +// } +// configureStatusContent() cell.statusView.activeTextLabel.configure( content: (status.reblog ?? status).content, emojiDict: (status.reblog ?? status).emojiDict @@ -221,7 +240,7 @@ extension StatusSection { cell.statusView.updateVisibility(visibility: visibility) cell.statusView.revealContentWarningButton.publisher(for: \.isHidden) - .receive(on: RunLoop.main) + .receive(on: DispatchQueue.main) .sink { [weak cell] isHidden in cell?.statusView.visibilityImageView.isHidden = !isHidden } @@ -646,7 +665,13 @@ extension StatusSection { let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userReblogged(name) }() - cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.author.emojiDict) + MastodonStatusContent.parseResult(content: headerText, emojiDict: status.author.emojiDict) + .receive(on: DispatchQueue.main) + .sink { [weak cell] parseResult in + guard let cell = cell else { return } + cell.statusView.headerInfoLabel.configure(contentParseResult: parseResult) + } + .store(in: &cell.disposeBag) cell.statusView.headerInfoLabel.isAccessibilityElement = true } else if status.inReplyToID != nil { cell.statusView.headerContainerView.isHidden = false @@ -659,7 +684,13 @@ extension StatusSection { let name = author.displayName.isEmpty ? author.username : author.displayName return L10n.Common.Controls.Status.userRepliedTo(name) }() - cell.statusView.headerInfoLabel.configure(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:]) + MastodonStatusContent.parseResult(content: headerText, emojiDict: status.replyTo?.author.emojiDict ?? [:]) + .receive(on: DispatchQueue.main) + .sink { [weak cell] parseResult in + guard let cell = cell else { return } + cell.statusView.headerInfoLabel.configure(contentParseResult: parseResult) + } + .store(in: &cell.disposeBag) cell.statusView.headerInfoLabel.isAccessibilityElement = true } else { cell.statusView.headerContainerView.isHidden = true diff --git a/Mastodon/Extension/ActiveLabel.swift b/Mastodon/Extension/ActiveLabel.swift index 5d2ca7c6..3d59fc92 100644 --- a/Mastodon/Extension/ActiveLabel.swift +++ b/Mastodon/Extension/ActiveLabel.swift @@ -61,6 +61,7 @@ extension ActiveLabel { } extension ActiveLabel { + /// status content func configure(content: String, emojiDict: MastodonStatusContent.EmojiDict) { attributedText = nil @@ -76,6 +77,14 @@ extension ActiveLabel { } } + func configure(contentParseResult parseResult: MastodonStatusContent.ParseResult?) { + attributedText = nil + activeEntities.removeAll() + text = parseResult?.trimmed ?? "" + activeEntities = parseResult?.activeEntities ?? [] + accessibilityLabel = parseResult?.original ?? nil + } + /// account note func configure(note: String, emojiDict: MastodonStatusContent.EmojiDict) { configure(content: note, emojiDict: emojiDict) diff --git a/Mastodon/Helper/MastodonStatusContent.swift b/Mastodon/Helper/MastodonStatusContent.swift index d338615d..dbeb2647 100755 --- a/Mastodon/Helper/MastodonStatusContent.swift +++ b/Mastodon/Helper/MastodonStatusContent.swift @@ -6,6 +6,7 @@ // import Foundation +import Combine import Kanna import ActiveLabel @@ -14,6 +15,18 @@ enum MastodonStatusContent { typealias EmojiShortcode = String typealias EmojiDict = [EmojiShortcode: URL] + static let workingQueue = DispatchQueue(label: "org.joinmastodon.app.ActiveLabel.working-queue", qos: .userInteractive, attributes: .concurrent) + + static func parseResult(content: String, emojiDict: MastodonStatusContent.EmojiDict) -> AnyPublisher { + return Future { promise in + self.workingQueue.async { + let parseResult = try? MastodonStatusContent.parse(content: content, emojiDict: emojiDict) + promise(.success(parseResult)) + } + } + .eraseToAnyPublisher() + } + static func parse(content: String, emojiDict: EmojiDict) throws -> MastodonStatusContent.ParseResult { let document: String = { var content = content @@ -113,11 +126,25 @@ extension String { } extension MastodonStatusContent { - struct ParseResult { + struct ParseResult: Hashable { let document: String let original: String let trimmed: String let activeEntities: [ActiveEntity] + + static func == (lhs: MastodonStatusContent.ParseResult, rhs: MastodonStatusContent.ParseResult) -> Bool { + return lhs.document == rhs.document + && lhs.original == rhs.original + && lhs.trimmed == rhs.trimmed + && lhs.activeEntities.count == rhs.activeEntities.count // FIXME: + } + + func hash(into hasher: inout Hasher) { + hasher.combine(document) + hasher.combine(original) + hasher.combine(trimmed) + hasher.combine(activeEntities.count) // FIXME: + } } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift index a0aaa543..a9e1898a 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift @@ -33,17 +33,22 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { guard let self = self else { return } for objectID in statusObjectIDs { let status = backgroundManagedObjectContext.object(with: objectID) as! Status - guard let replyToID = status.inReplyToID, status.replyTo == nil else { - // skip - continue + + // fetch in-reply info if needs + if let replyToID = status.inReplyToID, status.replyTo == nil { + self.context.statusPrefetchingService.prefetchReplyTo( + domain: domain, + statusObjectID: status.objectID, + statusID: status.id, + replyToStatusID: replyToID, + authorizationBox: activeMastodonAuthenticationBox + ) } - self.context.statusPrefetchingService.prefetchReplyTo( - domain: domain, - statusObjectID: status.objectID, - statusID: status.id, - replyToStatusID: replyToID, - authorizationBox: activeMastodonAuthenticationBox - ) + +// self.context.statusContentCacheService.prefetch( +// content: (status.reblog ?? status).content, +// emojiDict: (status.reblog ?? status).emojiDict +// ) } } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index b293c7e5..6aed1f57 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -15,6 +15,10 @@ import GameplayKit import MastodonSDK import AlamofireImage +#if DEBUG +import GDPerformanceView_Swift +#endif + final class HomeTimelineViewController: UIViewController, NeedsDependency, MediaPreviewableViewController { weak var context: AppContext! { willSet { precondition(!isViewLoaded) } } @@ -87,7 +91,9 @@ extension HomeTimelineViewController { titleView.delegate = self viewModel.homeTimelineNavigationBarTitleViewModel.state - .receive(on: DispatchQueue.main) + .removeDuplicates() + .debounce(for: 0.3, scheduler: RunLoop.main) + .receive(on: RunLoop.main) .sink { [weak self] state in guard let self = self else { return } self.titleView.configure(state: state) @@ -98,6 +104,8 @@ extension HomeTimelineViewController { #if DEBUG // long press to trigger debug menu settingBarButtonItem.menu = debugMenu + PerformanceMonitor.shared().delegate = self + #else settingBarButtonItem.target = self settingBarButtonItem.action = #selector(HomeTimelineViewController.settingBarButtonItemPressed(_:)) @@ -554,3 +562,11 @@ extension HomeTimelineViewController: StatusTableViewControllerNavigateable { statusKeyCommandHandler(sender) } } + +#if DEBUG +extension HomeTimelineViewController: PerformanceMonitorDelegate { + func performanceMonitor(didReport performanceReport: PerformanceReport) { + // print(performanceReport) + } +} +#endif diff --git a/Mastodon/Service/StatusContentCacheService.swift b/Mastodon/Service/StatusContentCacheService.swift new file mode 100644 index 00000000..ff17b047 --- /dev/null +++ b/Mastodon/Service/StatusContentCacheService.swift @@ -0,0 +1,77 @@ +// +// StatusContentCacheService.swift +// Mastodon +// +// Created by MainasuK Cirno on 2021-6-17. +// + +import UIKit +import Combine + +final class StatusContentCacheService { + + var disposeBag = Set() + + let cache = NSCache() + + let workingQueue = DispatchQueue(label: "org.joinmastodon.app.BlurhashImageCacheService.working-queue", qos: .userInitiated, attributes: .concurrent) + + func parseResult(content: String, emojiDict: MastodonStatusContent.EmojiDict) -> MastodonStatusContent.ParseResult? { + let key = Key(content: content, emojiDict: emojiDict) + return cache.object(forKey: key)?.parseResult + } + + func prefetch(content: String, emojiDict: MastodonStatusContent.EmojiDict) { + let key = Key(content: content, emojiDict: emojiDict) + guard cache.object(forKey: key) == nil else { return } + MastodonStatusContent.parseResult(content: content, emojiDict: emojiDict) + .sink { [weak self] parseResult in + guard let self = self else { return } + guard let parseResult = parseResult else { return } + let wrapper = ParseResultWrapper(parseResult: parseResult) + self.cache.setObject(wrapper, forKey: key) + } + .store(in: &disposeBag) + } + +} + +extension StatusContentCacheService { + class Key: NSObject { + let content: String + let emojiDict: MastodonStatusContent.EmojiDict + + init(content: String, emojiDict: MastodonStatusContent.EmojiDict) { + self.content = content + self.emojiDict = emojiDict + } + + override func isEqual(_ object: Any?) -> Bool { + guard let object = object as? Key else { return false } + return object.content == content + && object.emojiDict == emojiDict + } + + override var hash: Int { + return content.hashValue ^ + emojiDict.hashValue + } + } + + class ParseResultWrapper: NSObject { + let parseResult: MastodonStatusContent.ParseResult + + init(parseResult: MastodonStatusContent.ParseResult) { + self.parseResult = parseResult + } + + override func isEqual(_ object: Any?) -> Bool { + guard let object = object as? ParseResultWrapper else { return false } + return object.parseResult == parseResult + } + + override var hash: Int { + return parseResult.hashValue + } + } +} diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index e9b6073a..b6b0cdb5 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -38,6 +38,7 @@ class AppContext: ObservableObject { let placeholderImageCacheService = PlaceholderImageCacheService() let blurhashImageCacheService = BlurhashImageCacheService() + let statusContentCacheService = StatusContentCacheService() let documentStore: DocumentStore private var documentStoreSubscription: AnyCancellable! diff --git a/Mastodon/Supporting Files/AppDelegate.swift b/Mastodon/Supporting Files/AppDelegate.swift index 9b084649..e6ccaaac 100644 --- a/Mastodon/Supporting Files/AppDelegate.swift +++ b/Mastodon/Supporting Files/AppDelegate.swift @@ -10,6 +10,10 @@ import UIKit import UserNotifications import AppShared +#if DEBUG +import GDPerformanceView_Swift +#endif + @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -27,6 +31,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UNUserNotificationCenter.current().delegate = self application.registerForRemoteNotifications() + #if DEBUG + PerformanceMonitor.shared().start() + #endif + return true } diff --git a/Podfile b/Podfile index d9f295c2..796473d6 100644 --- a/Podfile +++ b/Podfile @@ -16,6 +16,7 @@ target 'Mastodon' do # DEBUG pod 'FLEX', '~> 4.4.0', :configurations => ['Debug'] + pod 'GDPerformanceView-Swift', '~> 2.1.1', :configurations => ['Debug'] target 'MastodonTests' do inherit! :search_paths diff --git a/Podfile.lock b/Podfile.lock index ea8ecc82..4e7baf34 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,6 +1,7 @@ PODS: - DateToolsSwift (5.0.0) - FLEX (4.4.1) + - GDPerformanceView-Swift (2.1.1) - Kanna (5.2.4) - Keys (1.0.1) - SwiftGen (6.4.0) @@ -9,6 +10,7 @@ PODS: DEPENDENCIES: - DateToolsSwift (~> 5.0.0) - FLEX (~> 4.4.0) + - GDPerformanceView-Swift (~> 2.1.1) - Kanna (~> 5.2.2) - Keys (from `Pods/CocoaPodsKeys`) - SwiftGen (~> 6.4.0) @@ -18,6 +20,7 @@ SPEC REPOS: trunk: - DateToolsSwift - FLEX + - GDPerformanceView-Swift - Kanna - SwiftGen - "UITextField+Shake" @@ -29,11 +32,12 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: DateToolsSwift: 4207ada6ad615d8dc076323d27037c94916dbfa6 FLEX: 7ca2c8cd3a435ff501ff6d2f2141e9bdc934eaab + GDPerformanceView-Swift: 22d964fe40b19e3d914dba2586237d064de8fd77 Kanna: b9d00d7c11428308c7f95e1f1f84b8205f567a8f Keys: a576f4c9c1c641ca913a959a9c62ed3f215a8de9 SwiftGen: 67860cc7c3cfc2ed25b9b74cfd55495fc89f9108 "UITextField+Shake": 298ac5a0f239d731bdab999b19b628c956ca0ac3 -PODFILE CHECKSUM: 0daad1778e56099e7a7e7ebe3d292d20051840fa +PODFILE CHECKSUM: 257c550231fcd1336a29f7835aa331171bb66ebd COCOAPODS: 1.10.1