From 2e23b08b88f2f6748e2fac134c2d761e0d4a583c Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Wed, 3 Jan 2024 11:34:50 +0100 Subject: [PATCH] Cache and restore position on all timelines --- .../Sources/Network/Endpoint/Timelines.swift | 8 +-- .../Sources/Timeline/TimelineFilter.swift | 6 +- .../Timeline/View/TimelineViewModel.swift | 64 ++++++++++--------- .../Timeline/actors/TimelineCache.swift | 57 +++++++++++++---- 4 files changed, 83 insertions(+), 52 deletions(-) diff --git a/Packages/Network/Sources/Network/Endpoint/Timelines.swift b/Packages/Network/Sources/Network/Endpoint/Timelines.swift index c4526b33..ab1a2309 100644 --- a/Packages/Network/Sources/Network/Endpoint/Timelines.swift +++ b/Packages/Network/Sources/Network/Endpoint/Timelines.swift @@ -4,7 +4,7 @@ public enum Timelines: Endpoint { case pub(sinceId: String?, maxId: String?, minId: String?, local: Bool) case home(sinceId: String?, maxId: String?, minId: String?) case list(listId: String, sinceId: String?, maxId: String?, minId: String?) - case hashtag(tag: String, additional: [String]?, maxId: String?) + case hashtag(tag: String, additional: [String]?, maxId: String?, minId: String?) public func path() -> String { switch self { @@ -14,7 +14,7 @@ public enum Timelines: Endpoint { "timelines/home" case let .list(listId, _, _, _): "timelines/list/\(listId)" - case let .hashtag(tag, _, _): + case let .hashtag(tag, _, _, _): "timelines/tag/\(tag)" } } @@ -29,8 +29,8 @@ public enum Timelines: Endpoint { return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId) case let .list(_, sinceId, maxId, mindId): return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId) - case let .hashtag(_, additional, maxId): - var params = makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) ?? [] + case let .hashtag(_, additional, maxId, minId): + var params = makePaginationParam(sinceId: nil, maxId: maxId, mindId: minId) ?? [] params.append(contentsOf: (additional ?? []) .map { URLQueryItem(name: "any[]", value: $0) }) return params diff --git a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift index a68d955e..7ce69009 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift @@ -162,15 +162,15 @@ public enum TimelineFilter: Hashable, Equatable, Identifiable { if let accountId { return Accounts.statuses(id: accountId, sinceId: nil, tag: tag, onlyMedia: nil, excludeReplies: nil, pinned: nil) } else { - return Timelines.hashtag(tag: tag, additional: nil, maxId: maxId) + return Timelines.hashtag(tag: tag, additional: nil, maxId: maxId, minId: minId) } case let .tagGroup(_, tags): var tags = tags if !tags.isEmpty { let tag = tags.removeFirst() - return Timelines.hashtag(tag: tag, additional: tags, maxId: maxId) + return Timelines.hashtag(tag: tag, additional: tags, maxId: maxId, minId: minId) } else { - return Timelines.hashtag(tag: "", additional: tags, maxId: maxId) + return Timelines.hashtag(tag: "", additional: tags, maxId: maxId, minId: minId) } } } diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift index dbcdcf8d..52a03ae0 100644 --- a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift +++ b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift @@ -48,7 +48,10 @@ import SwiftUI // Internal source of truth for a timeline. private(set) var datasource = TimelineDatasource() private let cache = TimelineCache() - private var visibileStatusesIds = Set() + + @ObservationIgnored + private var visibileStatuses: [Status] = [] + private var canStreamEvents: Bool = true var client: Client? { @@ -98,9 +101,7 @@ import SwiftUI private func handleLatestOrResume(_ oldValue: TimelineFilter) async { if timeline == .latest || timeline == .resume { - if oldValue == .home { - await clearHomeCache() - } + await clearCache(filter: oldValue) if timeline == .resume, let marker = await fetchMarker() { self.marker = marker } @@ -119,7 +120,7 @@ import SwiftUI pendingStatusesObserver.pendingStatuses.insert(event.status.id, at: 0) let newStatus = event.status await datasource.insert(newStatus, at: 0) - await cacheHome() + await cache() StatusDataControllerProvider.shared.updateDataControllers(for: [event.status], client: client) let statuses = await datasource.get() withAnimation { @@ -127,7 +128,7 @@ import SwiftUI } } else if let event = event as? StreamEventDelete { await datasource.remove(event.status) - await cacheHome() + await cache() let statuses = await datasource.get() withAnimation { statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) @@ -137,7 +138,7 @@ import SwiftUI StatusDataControllerProvider.shared.updateDataControllers(for: [event.status], client: client) await datasource.replace(event.status, at: originalIndex) let statuses = await datasource.get() - await cacheHome() + await cache() statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } } @@ -147,23 +148,23 @@ import SwiftUI // MARK: - Cache extension TimelineViewModel { - private func cacheHome() async { - if let client, timeline == .home { - await cache.set(statuses: datasource.get(), client: client.id) + private func cache() async { + if let client, timeline.supportNewestPagination { + await cache.set(statuses: datasource.get(), client: client.id, filter: timeline.id) } } private func getCachedStatuses() async -> [Status]? { if let client { - return await cache.getStatuses(for: client.id) + return await cache.getStatuses(for: client.id, filter: timeline.id) } return nil } - private func clearHomeCache() async { + private func clearCache(filter: TimelineFilter) async { if let client { - await cache.clearCache(for: client.id) - await cache.setLatestSeenStatuses(ids: [], for: client) + await cache.clearCache(for: client.id, filter: filter.id) + await cache.setLatestSeenStatuses([], for: client, filter: filter.id) } } } @@ -201,7 +202,7 @@ extension TimelineViewModel: StatusesFetcher { StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client) await datasource.set(statuses) - await cacheHome() + await cache() marker = nil withAnimation { @@ -237,22 +238,23 @@ extension TimelineViewModel: StatusesFetcher { // If we get statuses from the cache for the home timeline, we displays those. // Else we fetch top most page from the API. - if let cachedStatuses = await getCachedStatuses(), + if timeline.supportNewestPagination, + let cachedStatuses = await getCachedStatuses(), !cachedStatuses.isEmpty, - timeline == .home, !UserPreferences.shared.fastRefreshEnabled + !UserPreferences.shared.fastRefreshEnabled { await datasource.set(cachedStatuses) - if let latestSeenId = await cache.getLatestSeenStatus(for: client)?.last, + let statuses = await datasource.get() + if let latestSeenId = await cache.getLatestSeenStatus(for: client, filter: timeline.id)?.first, let index = await datasource.indexOf(statusId: latestSeenId), index > 0 { // Restore cache and scroll to latest seen status. - statusesState = await .display(statuses: datasource.get(), nextPageState: .hasNextPage) + statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) scrollToIndexAnimated = false scrollToIndex = index + 1 } else { // Restore cache and scroll to top. - let statuses = await datasource.get() withAnimation { statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) } @@ -270,7 +272,7 @@ extension TimelineViewModel: StatusesFetcher { await datasource.set(statuses) statuses = await datasource.get() - await cacheHome() + await cache() withAnimation { statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage) @@ -318,20 +320,20 @@ extension TimelineViewModel: StatusesFetcher { } // Keep track of the top most status, so we can scroll back to it after view update. - let topStatusId = await datasource.get().first?.id + let topStatus = await datasource.get().first // Insert new statuses in internal datasource. await datasource.insert(contentOf: newStatuses, at: 0) - // Cache statuses for home timeline. - await cacheHome() + // Cache statuses for timeline. + await cache() // Append new statuses in the timeline indicator. pendingStatusesObserver.pendingStatuses.insert(contentsOf: newStatuses.map(\.id), at: 0) // High chance the user is scrolled to the top. // We need to update the statuses state, and then scroll to the previous top most status. - if let topStatusId, visibileStatusesIds.contains(topStatusId), scrollToTopVisible { + if let topStatus, visibileStatuses.contains(where: { $0.id == topStatus.id}), scrollToTopVisible { pendingStatusesObserver.disableUpdate = true let statuses = await datasource.get() statusesState = .display(statuses: statuses, @@ -417,17 +419,17 @@ extension TimelineViewModel: StatusesFetcher { func statusDidAppear(status: Status) { pendingStatusesObserver.removeStatus(status: status) - visibileStatusesIds.insert(status.id) - - if let client, timeline == .home { + visibileStatuses.insert(status, at: 0) + + if let client, timeline.supportNewestPagination { Task { - await cache.setLatestSeenStatuses(ids: visibileStatusesIds.map { $0 }, for: client) + await cache.setLatestSeenStatuses(visibileStatuses, for: client, filter: timeline.id) } } } func statusDidDisappear(status: Status) { - visibileStatusesIds.remove(status.id) + visibileStatuses.removeAll(where: { $0.id == status.id }) } } @@ -448,7 +450,7 @@ extension TimelineViewModel { func saveMarker() { guard timeline == .home, let client else { return } Task { - guard let id = await cache.getLatestSeenStatus(for: client)?.first else { return } + guard let id = await cache.getLatestSeenStatus(for: client, filter: timeline.id)?.first else { return } do { let _: Marker = try await client.post(endpoint: Markers.markHome(lastReadId: id)) } catch { } diff --git a/Packages/Timeline/Sources/Timeline/actors/TimelineCache.swift b/Packages/Timeline/Sources/Timeline/actors/TimelineCache.swift index 7363c237..91723f31 100644 --- a/Packages/Timeline/Sources/Timeline/actors/TimelineCache.swift +++ b/Packages/Timeline/Sources/Timeline/actors/TimelineCache.swift @@ -4,31 +4,52 @@ import Network import SwiftUI public actor TimelineCache { - private func storageFor(_ client: String) -> SQLiteStorageEngine { - SQLiteStorageEngine.default(appendingPath: client) + private func storageFor(_ client: String, _ filter: String) -> SQLiteStorageEngine { + if filter == "Home" { + SQLiteStorageEngine.default(appendingPath: "\(client)") + } else { + SQLiteStorageEngine.default(appendingPath: "\(client)/\(filter)") + } } private let decoder = JSONDecoder() private let encoder = JSONEncoder() public init() {} - + public func cachedPostsCount(for client: String) async -> Int { - await storageFor(client).allKeys().count + do { + let directory = FileManager.Directory.defaultStorageDirectory(appendingPath: client).url + let content = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + var total: Int = await storageFor(client, "Home").allKeys().count + for storage in content { + if !storage.lastPathComponent.hasSuffix("sqlite3") { + total += await storageFor(client, storage.lastPathComponent).allKeys().count + } + } + return total + } catch { + return 0 + } + } + + public func clearCache(for client: String) async { + let directory = FileManager.Directory.defaultStorageDirectory(appendingPath: client) + try? FileManager.default.removeItem(at: directory.url) } - public func clearCache(for client: String) async { - let engine = storageFor(client) + public func clearCache(for client: String, filter: String) async { + let engine = storageFor(client, filter) do { try await engine.removeAllData() } catch {} } - func set(statuses: [Status], client: String) async { + func set(statuses: [Status], client: String, filter: String) async { guard !statuses.isEmpty else { return } let statuses = statuses.prefix(upTo: min(600, statuses.count - 1)).map { $0 } do { - let engine = storageFor(client) + let engine = storageFor(client, filter) try await engine.removeAllData() let itemKeys = statuses.map { CacheKey($0[keyPath: \.id]) } let dataAndKeys = try zip(itemKeys, statuses) @@ -37,8 +58,8 @@ public actor TimelineCache { } catch {} } - func getStatuses(for client: String) async -> [Status]? { - let engine = storageFor(client) + func getStatuses(for client: String, filter: String) async -> [Status]? { + let engine = storageFor(client, filter) do { return try await engine .readAllData() @@ -49,12 +70,20 @@ public actor TimelineCache { } } - func setLatestSeenStatuses(ids: [String], for client: Client) { - UserDefaults.standard.set(ids, forKey: "timeline-last-seen-\(client.id)") + func setLatestSeenStatuses(_ statuses: [Status], for client: Client, filter: String) { + if filter == "Home" { + UserDefaults.standard.set(statuses.map{ $0.id }, forKey: "timeline-last-seen-\(client.id)") + } else { + UserDefaults.standard.set(statuses.map{ $0.id }, forKey: "timeline-last-seen-\(client.id)-\(filter)") + } } - func getLatestSeenStatus(for client: Client) -> [String]? { - UserDefaults.standard.array(forKey: "timeline-last-seen-\(client.id)") as? [String] + func getLatestSeenStatus(for client: Client, filter: String) -> [String]? { + if filter == "Home" { + UserDefaults.standard.array(forKey: "timeline-last-seen-\(client.id)") as? [String] + } else { + UserDefaults.standard.array(forKey: "timeline-last-seen-\(client.id)-\(filter)") as? [String] + } } }