From 0f8c5c079275ca2b94d433bca7e66aeafc406c49 Mon Sep 17 00:00:00 2001 From: CMK Date: Thu, 15 Jul 2021 09:56:26 +0800 Subject: [PATCH] feat: add prefetching logic for status content --- Mastodon.xcodeproj/project.pbxproj | 23 ++--- .../xcschemes/xcschememanagement.plist | 6 +- Mastodon/Diffiable/Item/Item.swift | 22 +++++ .../Diffiable/Item/NotificationItem.swift | 11 +++ .../Diffiable/Item/SearchResultItem.swift | 15 ++++ .../Section/StatusFilterService.swift | 86 +++++++++++++++++++ .../Diffiable/Section/StatusSection.swift | 71 +++++++++++++-- ...der+UITableViewDataSourcePrefetching.swift | 8 ++ .../StatusProvider/StatusProvider.swift | 12 +++ .../StatusTableViewControllerAspect.swift | 14 ++- ...shtagTimelineViewController+Provider.swift | 6 ++ .../HomeTimelineViewController+Provider.swift | 6 ++ .../HomeTimelineViewController.swift | 4 + ...icationViewController+StatusProvider.swift | 5 ++ .../FavoriteViewController+Provider.swift | 6 ++ .../UserTimelineViewController+Provider.swift | 6 ++ ...ublicTimelineViewController+Provider.swift | 6 ++ ...hResultViewController+StatusProvider.swift | 6 ++ .../ThreadViewController+Provider.swift | 6 ++ Mastodon/Service/AuthenticationService.swift | 48 ----------- .../Service/StatusPrefetchingService.swift | 72 +++++++++++++++- Mastodon/State/AppContext.swift | 11 ++- Mastodon/Supporting Files/SceneDelegate.swift | 3 + 23 files changed, 378 insertions(+), 75 deletions(-) create mode 100644 Mastodon/Diffiable/Section/StatusFilterService.swift diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 6da303ad3..a120c7fe5 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -239,7 +239,6 @@ DB427DED25BAA00100D1B89D /* MastodonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DEC25BAA00100D1B89D /* MastodonTests.swift */; }; DB427DF825BAA00100D1B89D /* MastodonUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB427DF725BAA00100D1B89D /* MastodonUITests.swift */; }; DB44384F25E8C1FA008912A2 /* CALayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44384E25E8C1FA008912A2 /* CALayer.swift */; }; - DB443CD22694326A00159B29 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = DB443CD0269415D200159B29 /* Localizable.stringsdict */; }; DB443CD42694627B00159B29 /* AppearanceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB443CD32694627B00159B29 /* AppearanceView.swift */; }; DB44767B260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */; }; DB447681260B3ED600B66B82 /* CustomEmojiPickerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */; }; @@ -284,6 +283,8 @@ DB51D173262832380062B7A1 /* BlurHashEncode.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB51D171262832380062B7A1 /* BlurHashEncode.swift */; }; DB52D33A26839DD800D43133 /* ImageTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB52D33926839DD800D43133 /* ImageTask.swift */; }; DB55D33025FB630A0002F825 /* TwitterTextEditor+String.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */; }; + DB564BD0269F2F83001E39A7 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */; }; + DB564BD3269F3B35001E39A7 /* StatusFilterService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */; }; DB59F0FE25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */; }; DB59F10425EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */; }; DB59F10E25EF724F001F1DAB /* APIService+Poll.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */; }; @@ -885,8 +886,6 @@ DB427DF725BAA00100D1B89D /* MastodonUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonUITests.swift; sourceTree = ""; }; DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = ""; }; - DB443CCF269415D200159B29 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; - DB443CD1269415D800159B29 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = ""; }; DB443CD32694627B00159B29 /* AppearanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceView.swift; sourceTree = ""; }; DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = ""; }; DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerSection.swift; sourceTree = ""; }; @@ -931,6 +930,9 @@ DB51D171262832380062B7A1 /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = ""; }; DB52D33926839DD800D43133 /* ImageTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTask.swift; sourceTree = ""; }; DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = ""; }; + DB564BCF269F2F83001E39A7 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = ""; }; + DB564BD1269F2F8A001E39A7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; + DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFilterService.swift; sourceTree = ""; }; DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = ""; }; DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = ""; }; DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = ""; }; @@ -1585,6 +1587,7 @@ 5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */, DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */, DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */, + DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */, ); path = Section; sourceTree = ""; @@ -1858,7 +1861,7 @@ 164F0EBB267D4FE400249499 /* BoopSound.caf */, DB427DDE25BAA00100D1B89D /* Assets.xcassets */, DB118A8125E4B6E600FAB162 /* Preview Assets.xcassets */, - DB443CD0269415D200159B29 /* Localizable.stringsdict */, + DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */, DB3D100F25BAA75E00EAA174 /* Localizable.strings */, DB2B3ABE25E37E15007045F9 /* InfoPlist.strings */, ); @@ -3013,7 +3016,7 @@ files = ( 164F0EBC267D4FE400249499 /* BoopSound.caf in Resources */, DB427DE225BAA00100D1B89D /* LaunchScreen.storyboard in Resources */, - DB443CD22694326A00159B29 /* Localizable.stringsdict in Resources */, + DB564BD0269F2F83001E39A7 /* Localizable.stringsdict in Resources */, DB3D100D25BAA75E00EAA174 /* Localizable.strings in Resources */, DB427DDF25BAA00100D1B89D /* Assets.xcassets in Resources */, DB427DDD25BAA00100D1B89D /* Main.storyboard in Resources */, @@ -3311,6 +3314,7 @@ 2D607AD826242FC500B70763 /* NotificationViewModel.swift in Sources */, DBA0A10925FB3C2B0079C110 /* RoundedEdgesButton.swift in Sources */, DBABE3EC25ECAC4B00879EE5 /* WelcomeIllustrationView.swift in Sources */, + DB564BD3269F3B35001E39A7 /* StatusFilterService.swift in Sources */, DB71FD4625F8C6D200512AE1 /* StatusProvider+UITableViewDataSourcePrefetching.swift in Sources */, DB297B1B2679FAE200704C90 /* PlaceholderImageCacheService.swift in Sources */, 2D8FCA082637EABB00137F46 /* APIService+FollowRequest.swift in Sources */, @@ -3842,15 +3846,14 @@ name = LaunchScreen.storyboard; sourceTree = ""; }; - DB443CD0269415D200159B29 /* Localizable.stringsdict */ = { + DB564BCE269F2F83001E39A7 /* Localizable.stringsdict */ = { isa = PBXVariantGroup; children = ( - DB443CCF269415D200159B29 /* en */, - DB443CD1269415D800159B29 /* ar */, + DB564BCF269F2F83001E39A7 /* ar */, + DB564BD1269F2F8A001E39A7 /* en */, ); name = Localizable.stringsdict; - path = /Users/mainasuk/Developer/Mastodon/Mastodon/Resources; - sourceTree = ""; + sourceTree = ""; }; /* End PBXVariantGroup section */ diff --git a/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist b/Mastodon.xcodeproj/xcuserdata/mainasuk.xcuserdatad/xcschemes/xcschememanagement.plist index ecde1bcc5..88c48132f 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 - 20 + 21 Mastodon - ASDK.xcscheme_^#shared#^_ orderHint - 2 + 3 Mastodon - RTL.xcscheme_^#shared#^_ @@ -37,7 +37,7 @@ NotificationService.xcscheme_^#shared#^_ orderHint - 21 + 19 SuppressBuildableAutocreation diff --git a/Mastodon/Diffiable/Item/Item.swift b/Mastodon/Diffiable/Item/Item.swift index bde116b69..b7ea9df4a 100644 --- a/Mastodon/Diffiable/Item/Item.swift +++ b/Mastodon/Diffiable/Item/Item.swift @@ -167,3 +167,25 @@ extension Item: Hashable { } extension Item: Differentiable { } + +extension Item { + var statusObjectItem: StatusObjectItem? { + switch self { + case .homeTimelineIndex(let objectID, _): + return .homeTimelineIndex(objectID: objectID) + case .root(let objectID, _), + .reply(let objectID, _), + .leaf(let objectID, _), + .status(let objectID, _), + .reportStatus(let objectID, _): + return .status(objectID: objectID) + case .leafBottomLoader, + .homeMiddleLoader, + .publicMiddleLoader, + .topLoader, + .bottomLoader, + .emptyStateHeader: + return nil + } + } +} diff --git a/Mastodon/Diffiable/Item/NotificationItem.swift b/Mastodon/Diffiable/Item/NotificationItem.swift index f26d2e43d..22949b3a5 100644 --- a/Mastodon/Diffiable/Item/NotificationItem.swift +++ b/Mastodon/Diffiable/Item/NotificationItem.swift @@ -37,3 +37,14 @@ extension NotificationItem: Hashable { } } } + +extension NotificationItem { + var statusObjectItem: StatusObjectItem? { + switch self { + case .notification(let objectID, _): + return .mastodonNotification(objectID: objectID) + case .bottomLoader: + return nil + } + } +} diff --git a/Mastodon/Diffiable/Item/SearchResultItem.swift b/Mastodon/Diffiable/Item/SearchResultItem.swift index 130da8247..4765c4db2 100644 --- a/Mastodon/Diffiable/Item/SearchResultItem.swift +++ b/Mastodon/Diffiable/Item/SearchResultItem.swift @@ -71,3 +71,18 @@ extension SearchResultItem { } } } + +extension SearchResultItem { + var statusObjectItem: StatusObjectItem? { + switch self { + case .status(let objectID, _): + return .status(objectID: objectID) + case .hashtag, + .account, + .accountObjectID, + .hashtagObjectID, + .bottomLoader: + return nil + } + } +} diff --git a/Mastodon/Diffiable/Section/StatusFilterService.swift b/Mastodon/Diffiable/Section/StatusFilterService.swift new file mode 100644 index 000000000..38a1a17c4 --- /dev/null +++ b/Mastodon/Diffiable/Section/StatusFilterService.swift @@ -0,0 +1,86 @@ +// +// StatusFilterService.swift +// Mastodon +// +// Created by Cirno MainasuK on 2021-7-14. +// + +import os.log +import Foundation +import Combine +import CoreData +import CoreDataStack +import MastodonSDK +import MastodonMeta + +final class StatusFilterService { + + var disposeBag = Set() + + // input + weak var apiService: APIService? + weak var authenticationService: AuthenticationService? + let filterUpdatePublisher = PassthroughSubject() + + // output + let activeFilters = CurrentValueSubject<[Mastodon.Entity.Filter], Never>([]) + + init( + apiService: APIService, + authenticationService: AuthenticationService + ) { + self.apiService = apiService + self.authenticationService = authenticationService + + // fetch account filters every 300s + // also trigger fetch when app resume from background + let filterUpdateTimerPublisher = Timer.publish(every: 300.0, on: .main, in: .common) + .autoconnect() + .share() + .eraseToAnyPublisher() + + filterUpdateTimerPublisher + .map { _ in } + .subscribe(filterUpdatePublisher) + .store(in: &disposeBag) + + let activeMastodonAuthenticationBox = authenticationService.activeMastodonAuthenticationBox + Publishers.CombineLatest( + activeMastodonAuthenticationBox, + filterUpdatePublisher + ) + .flatMap { box, _ -> AnyPublisher, Error>, Never> in + guard let box = box else { + return Just(Result { throw APIService.APIError.implicit(.authenticationMissing) }).eraseToAnyPublisher() + } + return apiService.filters(mastodonAuthenticationBox: box) + .map { response in + let now = Date() + let newResponse = response.map { filters in + return filters.filter { $0.expiresAt > now } // filter out expired rules + } + return Result, Error>.success(newResponse) + } + .catch { error in + Just(Result, Error>.failure(error)) + } + .eraseToAnyPublisher() + } + .sink { result in + switch result { + case .success(let response): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters success. %ld items", ((#file as NSString).lastPathComponent), #line, #function, response.value.count) + self.activeFilters.value = response.value + case .failure(let error): + os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) + + break + } + } + .store(in: &disposeBag) + + // make initial trigger once + filterUpdatePublisher.send() + } + +} diff --git a/Mastodon/Diffiable/Section/StatusSection.swift b/Mastodon/Diffiable/Section/StatusSection.swift index 5c62edc42..3b808ccc5 100644 --- a/Mastodon/Diffiable/Section/StatusSection.swift +++ b/Mastodon/Diffiable/Section/StatusSection.swift @@ -60,6 +60,8 @@ extension StatusSection { } #endif + static let logger = Logger(subsystem: "StatusSection", category: "logic") + static func tableViewDiffableDataSource( for tableView: UITableView, timelineContext: TimelineContext, @@ -248,7 +250,8 @@ extension StatusSection { timelineContext: TimelineContext ) -> AnyPublisher { guard let content = content, - let currentFilterContext = timelineContext.filterContext else { + let currentFilterContext = timelineContext.filterContext, + !filters.isEmpty else { return Just(false).eraseToAnyPublisher() } @@ -352,18 +355,29 @@ extension StatusSection { } .store(in: &cell.disposeBag) - let document = MastodonContent( - content: (status.reblog ?? status).content, - emojis: (status.reblog ?? status).emojiMeta - ) - let content = try? MastodonMetaContent.convert(document: document) - + let content: MastodonMetaContent? = { + if let operation = dependency.context.statusPrefetchingService.statusContentOperations.removeValue(forKey: status.objectID), + let result = operation.result { + switch result { + case .success(let content): return content + case .failure: return nil + } + } else { + let document = MastodonContent( + content: (status.reblog ?? status).content, + emojis: (status.reblog ?? status).emojiMeta + ) + return try? MastodonMetaContent.convert(document: document) + } + }() + + if status.author.id == requestUserID || status.reblog?.author.id == requestUserID { // do not filter myself } else { let needsFilter = StatusSection.needsFilterStatus( content: content, - filters: AppContext.shared.authenticationService.activeFilters.value, + filters: AppContext.shared.statusFilterService.activeFilters.value, timelineContext: timelineContext ) needsFilter @@ -1130,3 +1144,44 @@ extension StatusSection { ) } } + +class StatusContentOperation: Operation { + + let logger = Logger(subsystem: "StatusContentOperation", category: "logic") + + // input + let statusObjectID: NSManagedObjectID + let mastodonContent: MastodonContent + + // output + var result: Result? + + init( + statusObjectID: NSManagedObjectID, + mastodonContent: MastodonContent + ) { + self.statusObjectID = statusObjectID + self.mastodonContent = mastodonContent + super.init() + } + + override func main() { + guard !isCancelled else { return } + // logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): prcoess \(self.statusObjectID)…") + + do { + let content = try MastodonMetaContent.convert(document: mastodonContent) + result = .success(content) + // logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): process success \(self.statusObjectID)") + } catch { + result = .failure(error) + // logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): process fail \(self.statusObjectID)") + } + + } + + override func cancel() { + // logger.debug("\((#file as NSString).lastPathComponent, privacy: .public)[\(#line, privacy: .public)], \(#function, privacy: .public): cancel \(self.statusObjectID.debugDescription)") + super.cancel() + } +} diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift index 5d6e81d51..537f10c8c 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider+UITableViewDataSourcePrefetching.swift @@ -11,6 +11,9 @@ import CoreDataStack extension StatusTableViewCellDelegate where Self: StatusProvider { func handleTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + let statusObjectItems = self.statusObjectItems(indexPaths: indexPaths) + self.context.statusPrefetchingService.prefetch(statusObjectItems: statusObjectItems) + // prefetch reply status guard let activeMastodonAuthenticationBox = context.authenticationService.activeMastodonAuthenticationBox.value else { return } let domain = activeMastodonAuthenticationBox.domain @@ -47,4 +50,9 @@ extension StatusTableViewCellDelegate where Self: StatusProvider { } // end for in } // end context.perform } // end func + + func handleTableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { + let statusObjectItems = self.statusObjectItems(indexPaths: indexPaths) + self.context.statusPrefetchingService.cancelPrefetch(statusObjectItems: statusObjectItems) + } } diff --git a/Mastodon/Protocol/StatusProvider/StatusProvider.swift b/Mastodon/Protocol/StatusProvider/StatusProvider.swift index cc0b690f6..3497fd7a8 100644 --- a/Mastodon/Protocol/StatusProvider/StatusProvider.swift +++ b/Mastodon/Protocol/StatusProvider/StatusProvider.swift @@ -22,10 +22,16 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl // sync var managedObjectContext: NSManagedObjectContext { get } + + @available(*, deprecated) var tableViewDiffableDataSource: UITableViewDiffableDataSource? { get } + @available(*, deprecated) func item(for cell: UITableViewCell?, indexPath: IndexPath?) -> Item? + @available(*, deprecated) func items(indexPaths: [IndexPath]) -> [Item] + func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] + #if ASDK func status(node: ASCellNode?, indexPath: IndexPath?) -> Status? #endif @@ -38,3 +44,9 @@ extension StatusProvider { } } #endif + +enum StatusObjectItem { + case status(objectID: NSManagedObjectID) + case homeTimelineIndex(objectID: NSManagedObjectID) + case mastodonNotification(objectID: NSManagedObjectID) // may not contains status +} diff --git a/Mastodon/Protocol/StatusTableViewControllerAspect.swift b/Mastodon/Protocol/StatusTableViewControllerAspect.swift index c118b1206..45eeedc8e 100644 --- a/Mastodon/Protocol/StatusTableViewControllerAspect.swift +++ b/Mastodon/Protocol/StatusTableViewControllerAspect.swift @@ -10,12 +10,12 @@ import AVKit import GameController // Check List Last Updated -// - HomeViewController: 2021/4/30 +// - HomeViewController: 2021/7/15 // - FavoriteViewController: 2021/4/30 // - HashtagTimelineViewController: 2021/4/30 // - UserTimelineViewController: 2021/4/30 // - ThreadViewController: 2021/4/30 -// * StatusTableViewControllerAspect: 2021/4/30 +// * StatusTableViewControllerAspect: 2021/7/15 // (Fake) Aspect protocol to group common protocol extension implementations // Needs update related view controller when aspect interface changes @@ -146,12 +146,20 @@ extension StatusTableViewControllerAspect where Self: StatusTableViewCellDelegat // [C1] aspectTableView(:prefetchRowsAt) extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider { - /// [Data Source] hook to prefetch reply to info for status + /// [Data Source] hook to prefetch status func aspectTableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { handleTableView(tableView, prefetchRowsAt: indexPaths) } } +// [C2] aspectTableView(:prefetchRowsAt) +extension StatusTableViewControllerAspect where Self: UITableViewDataSourcePrefetching & StatusTableViewCellDelegate & StatusProvider { + /// [Data Source] hook to cancel prefetch status + func aspectTableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { + handleTableView(tableView, cancelPrefetchingForRowsAt: indexPaths) + } +} + // MARK: - AVPlayerViewControllerDelegate & NeedsDependency [D] // [D1] aspectPlayerViewController(_:willBeginFullScreenPresentationWithAnimationCoordinator:) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift index 3bc3a36b5..d7beaca6f 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewController+Provider.swift @@ -83,6 +83,12 @@ extension HashtagTimelineViewController: StatusProvider { } return items } + + func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } + let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } + return items + } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift index d735d5843..83022f5d7 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController+Provider.swift @@ -83,6 +83,12 @@ extension HomeTimelineViewController: StatusProvider { } return items } + + func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } + let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } + return items + } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index a51fa6d78..85f90cfb8 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -431,6 +431,10 @@ extension HomeTimelineViewController: UITableViewDataSourcePrefetching { func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { aspectTableView(tableView, prefetchRowsAt: indexPaths) } + + func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { + aspectTableView(tableView, cancelPrefetchingForRowsAt: indexPaths) + } } // MARK: - ContentOffsetAdjustableTimelineViewControllerDelegate diff --git a/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift b/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift index 486d18832..127cca1b9 100644 --- a/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift +++ b/Mastodon/Scene/Notification/NotificationViewController+StatusProvider.swift @@ -61,5 +61,10 @@ extension NotificationViewController: StatusProvider { return [] } + func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } + let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } + return items + } } diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift index 88f368c15..f4631b6e6 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewController+Provider.swift @@ -83,6 +83,12 @@ extension FavoriteViewController: StatusProvider { } return items } + + func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } + let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } + return items + } } diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift index 30029ae5b..8c46f0ad6 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewController+Provider.swift @@ -83,6 +83,12 @@ extension UserTimelineViewController: StatusProvider { } return items } + + func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } + let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } + return items + } } diff --git a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift index 96963914c..dd7730630 100644 --- a/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift +++ b/Mastodon/Scene/PublicTimeline/PublicTimelineViewController+Provider.swift @@ -83,6 +83,12 @@ extension PublicTimelineViewController: StatusProvider { } return items } + + func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } + let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } + return items + } } diff --git a/Mastodon/Scene/Search/SearchResult/SearchResultViewController+StatusProvider.swift b/Mastodon/Scene/Search/SearchResult/SearchResultViewController+StatusProvider.swift index 2de9ecef8..73e3ffb82 100644 --- a/Mastodon/Scene/Search/SearchResult/SearchResultViewController+StatusProvider.swift +++ b/Mastodon/Scene/Search/SearchResult/SearchResultViewController+StatusProvider.swift @@ -64,6 +64,12 @@ extension SearchResultViewController: StatusProvider { return [] } + func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } + let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } + return items + } + } extension SearchResultViewController: UserProvider {} diff --git a/Mastodon/Scene/Thread/ThreadViewController+Provider.swift b/Mastodon/Scene/Thread/ThreadViewController+Provider.swift index a76a22d0b..c6bd29e15 100644 --- a/Mastodon/Scene/Thread/ThreadViewController+Provider.swift +++ b/Mastodon/Scene/Thread/ThreadViewController+Provider.swift @@ -84,6 +84,12 @@ extension ThreadViewController: StatusProvider { } return items } + + func statusObjectItems(indexPaths: [IndexPath]) -> [StatusObjectItem] { + guard let diffableDataSource = self.viewModel.diffableDataSource else { return [] } + let items = indexPaths.compactMap { diffableDataSource.itemIdentifier(for: $0)?.statusObjectItem } + return items + } } diff --git a/Mastodon/Service/AuthenticationService.swift b/Mastodon/Service/AuthenticationService.swift index b72f281fe..a0bbca57a 100644 --- a/Mastodon/Service/AuthenticationService.swift +++ b/Mastodon/Service/AuthenticationService.swift @@ -27,7 +27,6 @@ final class AuthenticationService: NSObject { let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([]) let activeMastodonAuthentication = CurrentValueSubject(nil) let activeMastodonAuthenticationBox = CurrentValueSubject(nil) - let activeFilters = CurrentValueSubject<[Mastodon.Entity.Filter], Never>([]) init( managedObjectContext: NSManagedObjectContext, @@ -88,53 +87,6 @@ final class AuthenticationService: NSObject { } catch { assertionFailure(error.localizedDescription) } - - // fetch account filters every 60s and filter out expired items - let filterUpdateTimerPublisher = Timer.publish(every: 60.0, on: .main, in: .common) - .autoconnect() - .share() - .eraseToAnyPublisher() - let filterUpdatePublisher = PassthroughSubject() - - filterUpdateTimerPublisher - .map { _ in } - .subscribe(filterUpdatePublisher) - .store(in: &disposeBag) - - Publishers.CombineLatest( - activeMastodonAuthenticationBox, - filterUpdatePublisher - ) - .flatMap { box, _ -> AnyPublisher, Error>, Never> in - guard let box = box else { - return Just(Result { throw APIService.APIError.implicit(.authenticationMissing) }).eraseToAnyPublisher() - } - return apiService.filters(mastodonAuthenticationBox: box) - .map { response in - let now = Date() - let newResponse = response.map { filters in - return filters.filter { $0.expiresAt > now } - } - return Result, Error>.success(newResponse) - } - .catch { error in - Just(Result, Error>.failure(error)) - } - .eraseToAnyPublisher() - } - .sink { result in - switch result { - case .success(let response): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters success. %ld items", ((#file as NSString).lastPathComponent), #line, #function, response.value.count) - self.activeFilters.value = response.value - case .failure(let error): - os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s: fetch account filters fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription) - - break - } - } - .store(in: &disposeBag) - filterUpdatePublisher.send() } } diff --git a/Mastodon/Service/StatusPrefetchingService.swift b/Mastodon/Service/StatusPrefetchingService.swift index 7828c5cf7..34b1d7a01 100644 --- a/Mastodon/Service/StatusPrefetchingService.swift +++ b/Mastodon/Service/StatusPrefetchingService.swift @@ -11,24 +11,92 @@ import Combine import CoreData import CoreDataStack import MastodonSDK +import MastodonMeta final class StatusPrefetchingService { typealias TaskID = String + typealias StatusObjectID = NSManagedObjectID let workingQueue = DispatchQueue(label: "org.joinmastodon.app.StatusPrefetchingService.working-queue") + // StatusContentOperation + let statusContentOperationQueue: OperationQueue = { + let queue = OperationQueue() + queue.name = "org.joinmastodon.app.StatusPrefetchingService.statusContentOperationQueue" + queue.maxConcurrentOperationCount = 2 + return queue + }() + var statusContentOperations: [StatusObjectID: StatusContentOperation] = [:] + var disposeBag = Set() private(set) var statusPrefetchingDisposeBagDict: [TaskID: AnyCancellable] = [:] - + + // input weak var apiService: APIService? + let managedObjectContext: NSManagedObjectContext + let backgroundManagedObjectContext: NSManagedObjectContext // read-only - init(apiService: APIService) { + init( + managedObjectContext: NSManagedObjectContext, + backgroundManagedObjectContext: NSManagedObjectContext, + apiService: APIService + ) { + self.managedObjectContext = managedObjectContext + self.backgroundManagedObjectContext = backgroundManagedObjectContext self.apiService = apiService } + + private func status(from statusObjectItem: StatusObjectItem) -> Status? { + assert(Thread.isMainThread) + switch statusObjectItem { + case .homeTimelineIndex(let objectID): + let homeTimelineIndex = try? managedObjectContext.existingObject(with: objectID) as? HomeTimelineIndex + return homeTimelineIndex?.status + case .mastodonNotification(let objectID): + let mastodonNotification = try? managedObjectContext.existingObject(with: objectID) as? MastodonNotification + return mastodonNotification?.status + case .status(let objectID): + let status = try? managedObjectContext.existingObject(with: objectID) as? Status + return status + } + + } } +extension StatusPrefetchingService { + func prefetch(statusObjectItems items: [StatusObjectItem]) { + for item in items { + guard let status = status(from: item), !status.isDeleted else { continue } + + // status content parser task + if statusContentOperations[status.objectID] == nil { + let mastodonContent = MastodonContent( + content: (status.reblog ?? status).content, + emojis: (status.reblog ?? status).emojiMeta + ) + let operation = StatusContentOperation( + statusObjectID: status.objectID, + mastodonContent: mastodonContent + ) + statusContentOperations[status.objectID] = operation + statusContentOperationQueue.addOperation(operation) + } + } + } + + func cancelPrefetch(statusObjectItems items: [StatusObjectItem]) { + for item in items { + guard let status = status(from: item), !status.isDeleted else { continue } + + // cancel status content parser task + statusContentOperations.removeValue(forKey: status.objectID)?.cancel() + } + } + +} + extension StatusPrefetchingService { func prefetchReplyTo( diff --git a/Mastodon/State/AppContext.swift b/Mastodon/State/AppContext.swift index f1de6dd7a..7db669e5e 100644 --- a/Mastodon/State/AppContext.swift +++ b/Mastodon/State/AppContext.swift @@ -33,8 +33,9 @@ class AppContext: ObservableObject { let settingService: SettingService let blockDomainService: BlockDomainService + let statusFilterService: StatusFilterService let photoLibraryService = PhotoLibraryService() - + let placeholderImageCacheService = PlaceholderImageCacheService() let blurhashImageCacheService = BlurhashImageCacheService() let statusContentCacheService = StatusContentCacheService() @@ -69,7 +70,10 @@ class AppContext: ObservableObject { emojiService = EmojiService( apiService: apiService ) + statusPrefetchingService = StatusPrefetchingService( + managedObjectContext: _managedObjectContext, + backgroundManagedObjectContext: _backgroundManagedObjectContext, apiService: _apiService ) let _notificationService = NotificationService( @@ -88,6 +92,11 @@ class AppContext: ObservableObject { backgroundManagedObjectContext: _backgroundManagedObjectContext, authenticationService: _authenticationService ) + + statusFilterService = StatusFilterService( + apiService: _apiService, + authenticationService: _authenticationService + ) documentStore = DocumentStore() documentStoreSubscription = documentStore.objectWillChange diff --git a/Mastodon/Supporting Files/SceneDelegate.swift b/Mastodon/Supporting Files/SceneDelegate.swift index 695f2df80..6964eef91 100644 --- a/Mastodon/Supporting Files/SceneDelegate.swift +++ b/Mastodon/Supporting Files/SceneDelegate.swift @@ -81,6 +81,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { // reset notification badge UserDefaults.shared.notificationBadgeCount = 0 UIApplication.shared.applicationIconBadgeNumber = 0 + + // trigger status filter update + AppContext.shared.statusFilterService.filterUpdatePublisher.send() } func sceneWillResignActive(_ scene: UIScene) {