feat: add prefetching logic for status content

This commit is contained in:
CMK 2021-07-15 09:56:26 +08:00
parent 2c041e70b2
commit 0f8c5c0792
23 changed files with 378 additions and 75 deletions

View File

@ -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 = "<group>"; };
DB427DF925BAA00100D1B89D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
DB44384E25E8C1FA008912A2 /* CALayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CALayer.swift; sourceTree = "<group>"; };
DB443CCF269415D200159B29 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
DB443CD1269415D800159B29 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
DB443CD32694627B00159B29 /* AppearanceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppearanceView.swift; sourceTree = "<group>"; };
DB44767A260B3B8C00B66B82 /* CustomEmojiPickerInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerInputView.swift; sourceTree = "<group>"; };
DB447680260B3ED600B66B82 /* CustomEmojiPickerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEmojiPickerSection.swift; sourceTree = "<group>"; };
@ -931,6 +930,9 @@
DB51D171262832380062B7A1 /* BlurHashEncode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BlurHashEncode.swift; sourceTree = "<group>"; };
DB52D33926839DD800D43133 /* ImageTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTask.swift; sourceTree = "<group>"; };
DB55D32F25FB630A0002F825 /* TwitterTextEditor+String.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TwitterTextEditor+String.swift"; sourceTree = "<group>"; };
DB564BCF269F2F83001E39A7 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = ar; path = ar.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
DB564BD1269F2F8A001E39A7 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = "<group>"; };
DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusFilterService.swift; sourceTree = "<group>"; };
DB59F0FD25EF5D96001F1DAB /* StatusProvider+UITableViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatusProvider+UITableViewDelegate.swift"; sourceTree = "<group>"; };
DB59F10325EF5EBC001F1DAB /* TableViewCellHeightCacheableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewCellHeightCacheableContainer.swift; sourceTree = "<group>"; };
DB59F10D25EF724F001F1DAB /* APIService+Poll.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "APIService+Poll.swift"; sourceTree = "<group>"; };
@ -1585,6 +1587,7 @@
5BB04FF4262F0E6D0043BFF6 /* ReportSection.swift */,
DBBF1DC82652538500E5B703 /* AutoCompleteSection.swift */,
DBA94433265CBB5300C537E1 /* ProfileFieldSection.swift */,
DB564BD2269F3B35001E39A7 /* StatusFilterService.swift */,
);
path = Section;
sourceTree = "<group>";
@ -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 = "<group>";
};
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 = "<absolute>";
sourceTree = "<group>";
};
/* End PBXVariantGroup section */

View File

@ -12,12 +12,12 @@
<key>CoreDataStack.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>20</integer>
<integer>21</integer>
</dict>
<key>Mastodon - ASDK.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
<integer>3</integer>
</dict>
<key>Mastodon - RTL.xcscheme_^#shared#^_</key>
<dict>
@ -37,7 +37,7 @@
<key>NotificationService.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>21</integer>
<integer>19</integer>
</dict>
</dict>
<key>SuppressBuildableAutocreation</key>

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}
}

View File

@ -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<AnyCancellable>()
// input
weak var apiService: APIService?
weak var authenticationService: AuthenticationService?
let filterUpdatePublisher = PassthroughSubject<Void, Never>()
// 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<Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.success(newResponse)
}
.catch { error in
Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, 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()
}
}

View File

@ -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<Bool, Never> {
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<MastodonMetaContent, Error>?
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()
}
}

View File

@ -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)
}
}

View File

@ -22,10 +22,16 @@ protocol StatusProvider: NeedsDependency & DisposeBagCollectable & UIViewControl
// sync
var managedObjectContext: NSManagedObjectContext { get }
@available(*, deprecated)
var tableViewDiffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>? { 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
}

View File

@ -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:)

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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 {}

View File

@ -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
}
}

View File

@ -27,7 +27,6 @@ final class AuthenticationService: NSObject {
let mastodonAuthenticationBoxes = CurrentValueSubject<[AuthenticationService.MastodonAuthenticationBox], Never>([])
let activeMastodonAuthentication = CurrentValueSubject<MastodonAuthentication?, Never>(nil)
let activeMastodonAuthenticationBox = CurrentValueSubject<AuthenticationService.MastodonAuthenticationBox?, Never>(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<Void, Never>()
filterUpdateTimerPublisher
.map { _ in }
.subscribe(filterUpdatePublisher)
.store(in: &disposeBag)
Publishers.CombineLatest(
activeMastodonAuthenticationBox,
filterUpdatePublisher
)
.flatMap { box, _ -> AnyPublisher<Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, 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<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.success(newResponse)
}
.catch { error in
Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, 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()
}
}

View File

@ -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<AnyCancellable>()
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(

View File

@ -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

View File

@ -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) {