diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 363660d02..8862ef43c 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ 2AE202AC297FE19100F66E55 /* FollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */; }; 2AE202AD297FE1CD00F66E55 /* WidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A72813E297EC762004138C5 /* WidgetExtension.swift */; }; 2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; }; + 2AF2E7BF2B19DC6E00D98917 /* FileManager+HomeTimeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AF2E7BE2B19DC6E00D98917 /* FileManager+HomeTimeline.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; 2D206B8625F5FB0900143C56 /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D206B8525F5FB0900143C56 /* Double.swift */; }; @@ -696,6 +697,7 @@ 2AE202A9297FDDF500F66E55 /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = ""; }; 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountIntentHandler.swift; sourceTree = ""; }; 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = ""; }; + 2AF2E7BE2B19DC6E00D98917 /* FileManager+HomeTimeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileManager+HomeTimeline.swift"; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; 2D206B8525F5FB0900143C56 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = ""; }; @@ -1898,6 +1900,7 @@ children = ( D8AC98772B0F62230045EC2B /* Model */, D8AC98752B0F61680045EC2B /* FileManager+SearchHistory.swift */, + 2AF2E7BE2B19DC6E00D98917 /* FileManager+HomeTimeline.swift */, ); path = Persistence; sourceTree = ""; @@ -3978,6 +3981,7 @@ DB9F58EC26EF435000E7BBE9 /* AccountViewController.swift in Sources */, D8318A802A4466D300C0FB73 /* SettingsCoordinator.swift in Sources */, DB3E6FF12806D96900B035AE /* DiscoveryNewsViewModel+Diffable.swift in Sources */, + 2AF2E7BF2B19DC6E00D98917 /* FileManager+HomeTimeline.swift in Sources */, DB3E6FF82807C45300B035AE /* DiscoveryForYouViewModel.swift in Sources */, DB0F9D56283EB46200379AF8 /* ProfileHeaderView+Configuration.swift in Sources */, DB6746F0278F463B008A6B94 /* AutoGenerateProtocolDelegate.swift in Sources */, diff --git a/Mastodon/Coordinator/SceneCoordinator.swift b/Mastodon/Coordinator/SceneCoordinator.swift index 400042065..47a192d22 100644 --- a/Mastodon/Coordinator/SceneCoordinator.swift +++ b/Mastodon/Coordinator/SceneCoordinator.swift @@ -626,7 +626,7 @@ extension SceneCoordinator: SettingsCoordinatorDelegate { try await self.appContext.authenticationService.signOutMastodonUser( authenticationBox: authContext.mastodonAuthenticationBox ) - + FileManager.default.invalidateHomeTimelineCache(for: authContext.mastodonAuthenticationBox.userID) self.setup() } diff --git a/Mastodon/Persistence/FileManager+HomeTimeline.swift b/Mastodon/Persistence/FileManager+HomeTimeline.swift new file mode 100644 index 000000000..de1c38e88 --- /dev/null +++ b/Mastodon/Persistence/FileManager+HomeTimeline.swift @@ -0,0 +1,53 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonCore +import MastodonSDK + +extension FileManager { + private static let cacheHomeItemsLimit: Int = 100 // max number of items to cache + + func cachedHomeTimeline(for userId: String) throws -> [MastodonStatus] { + guard let cachesDirectory else { return [] } + + let filePath = Persistence.homeTimeline(userId).filepath(baseURL: cachesDirectory) + + guard let data = try? Data(contentsOf: filePath) else { return [] } + + do { + let items = try JSONDecoder().decode([Mastodon.Entity.Status].self, from: data) + + return items.map(MastodonStatus.fromEntity) + } catch { + return [] + } + } + + func cacheHomeTimeline(items: [MastodonStatus], for userId: String) { + guard let cachesDirectory else { return } + + let processableItems: [MastodonStatus] + if items.count > Self.cacheHomeItemsLimit { + processableItems = items.dropLast(items.count - Self.cacheHomeItemsLimit) + } else { + processableItems = items + } + + do { + let data = try JSONEncoder().encode(processableItems.map { $0.entity }) + + let filePath = Persistence.homeTimeline(userId).filepath(baseURL: cachesDirectory) + try data.write(to: filePath) + } catch { + debugPrint(error.localizedDescription) + } + } + + func invalidateHomeTimelineCache(for userId: String) { + guard let cachesDirectory else { return } + + let filePath = Persistence.homeTimeline(userId).filepath(baseURL: cachesDirectory) + + try? removeItem(at: filePath) + } +} diff --git a/Mastodon/Persistence/FileManager+SearchHistory.swift b/Mastodon/Persistence/FileManager+SearchHistory.swift index 8c1cabd1d..95dd29a52 100644 --- a/Mastodon/Persistence/FileManager+SearchHistory.swift +++ b/Mastodon/Persistence/FileManager+SearchHistory.swift @@ -68,8 +68,12 @@ extension FileManager { } } -extension FileManager { - public var documentsDirectory: URL? { - return self.urls(for: .documentDirectory, in: .userDomainMask).first +public extension FileManager { + var documentsDirectory: URL? { + urls(for: .documentDirectory, in: .userDomainMask).first + } + + var cachesDirectory: URL? { + urls(for: .cachesDirectory, in: .userDomainMask).first } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift index ed363f397..cae1971d8 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewController.swift @@ -386,6 +386,7 @@ extension HomeTimelineViewController { @objc func signOutAction(_ sender: UIAction) { Task { @MainActor in try await context.authenticationService.signOutMastodonUser(authenticationBox: viewModel.authContext.mastodonAuthenticationBox) + FileManager.default.invalidateHomeTimelineCache(for: viewModel.authContext.mastodonAuthenticationBox.userID) self.coordinator.setup() } } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index 24d3dded7..670103c5e 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -15,6 +15,7 @@ import GameplayKit import AlamofireImage import MastodonCore import MastodonUI +import MastodonSDK final class HomeTimelineViewModel: NSObject { @@ -83,6 +84,10 @@ final class HomeTimelineViewModel: NSObject { self.fetchedResultsController = FeedFetchedResultsController(context: context, authContext: authContext) self.homeTimelineNavigationBarTitleViewModel = HomeTimelineNavigationBarTitleViewModel(context: context) super.init() + self.fetchedResultsController.records = (try? FileManager.default.cachedHomeTimeline(for: authContext.mastodonAuthenticationBox.userID).map { + MastodonFeed.fromStatus($0, kind: .home) + }) ?? [] + homeTimelineNeedRefresh .sink { [weak self] _ in self?.loadLatestStateMachine.enter(LoadLatestState.Loading.self) @@ -98,6 +103,18 @@ final class HomeTimelineViewModel: NSObject { } .store(in: &disposeBag) + self.fetchedResultsController.$records + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink(receiveValue: { feeds in + let items: [MastodonStatus] = feeds.compactMap { feed -> MastodonStatus? in + guard let status = feed.status else { return nil } + return status + } + FileManager.default.cacheHomeTimeline(items: items, for: authContext.mastodonAuthenticationBox.userID) + }) + .store(in: &disposeBag) + self.fetchedResultsController.loadInitial(kind: .home) } } diff --git a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift index 8445d87ba..5b5090bf8 100644 --- a/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift +++ b/MastodonSDK/Sources/MastodonCore/Persistence/Persistence.swift @@ -10,11 +10,14 @@ import Foundation public enum Persistence { case searchHistory + case homeTimeline(String) private var filename: String { switch self { case .searchHistory: return "search_history" + case let .homeTimeline(userId): + return "home_timeline_\(userId)" } }