Cache and restore position on all timelines

This commit is contained in:
Thomas Ricouard 2024-01-03 11:34:50 +01:00
parent 6854df4b89
commit 2e23b08b88
4 changed files with 83 additions and 52 deletions

View file

@ -4,7 +4,7 @@ public enum Timelines: Endpoint {
case pub(sinceId: String?, maxId: String?, minId: String?, local: Bool) case pub(sinceId: String?, maxId: String?, minId: String?, local: Bool)
case home(sinceId: String?, maxId: String?, minId: String?) case home(sinceId: String?, maxId: String?, minId: String?)
case list(listId: String, 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 { public func path() -> String {
switch self { switch self {
@ -14,7 +14,7 @@ public enum Timelines: Endpoint {
"timelines/home" "timelines/home"
case let .list(listId, _, _, _): case let .list(listId, _, _, _):
"timelines/list/\(listId)" "timelines/list/\(listId)"
case let .hashtag(tag, _, _): case let .hashtag(tag, _, _, _):
"timelines/tag/\(tag)" "timelines/tag/\(tag)"
} }
} }
@ -29,8 +29,8 @@ public enum Timelines: Endpoint {
return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId) return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId)
case let .list(_, sinceId, maxId, mindId): case let .list(_, sinceId, maxId, mindId):
return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId) return makePaginationParam(sinceId: sinceId, maxId: maxId, mindId: mindId)
case let .hashtag(_, additional, maxId): case let .hashtag(_, additional, maxId, minId):
var params = makePaginationParam(sinceId: nil, maxId: maxId, mindId: nil) ?? [] var params = makePaginationParam(sinceId: nil, maxId: maxId, mindId: minId) ?? []
params.append(contentsOf: (additional ?? []) params.append(contentsOf: (additional ?? [])
.map { URLQueryItem(name: "any[]", value: $0) }) .map { URLQueryItem(name: "any[]", value: $0) })
return params return params

View file

@ -162,15 +162,15 @@ public enum TimelineFilter: Hashable, Equatable, Identifiable {
if let accountId { if let accountId {
return Accounts.statuses(id: accountId, sinceId: nil, tag: tag, onlyMedia: nil, excludeReplies: nil, pinned: nil) return Accounts.statuses(id: accountId, sinceId: nil, tag: tag, onlyMedia: nil, excludeReplies: nil, pinned: nil)
} else { } 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): case let .tagGroup(_, tags):
var tags = tags var tags = tags
if !tags.isEmpty { if !tags.isEmpty {
let tag = tags.removeFirst() 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 { } else {
return Timelines.hashtag(tag: "", additional: tags, maxId: maxId) return Timelines.hashtag(tag: "", additional: tags, maxId: maxId, minId: minId)
} }
} }
} }

View file

@ -48,7 +48,10 @@ import SwiftUI
// Internal source of truth for a timeline. // Internal source of truth for a timeline.
private(set) var datasource = TimelineDatasource() private(set) var datasource = TimelineDatasource()
private let cache = TimelineCache() private let cache = TimelineCache()
private var visibileStatusesIds = Set<String>()
@ObservationIgnored
private var visibileStatuses: [Status] = []
private var canStreamEvents: Bool = true private var canStreamEvents: Bool = true
var client: Client? { var client: Client? {
@ -98,9 +101,7 @@ import SwiftUI
private func handleLatestOrResume(_ oldValue: TimelineFilter) async { private func handleLatestOrResume(_ oldValue: TimelineFilter) async {
if timeline == .latest || timeline == .resume { if timeline == .latest || timeline == .resume {
if oldValue == .home { await clearCache(filter: oldValue)
await clearHomeCache()
}
if timeline == .resume, let marker = await fetchMarker() { if timeline == .resume, let marker = await fetchMarker() {
self.marker = marker self.marker = marker
} }
@ -119,7 +120,7 @@ import SwiftUI
pendingStatusesObserver.pendingStatuses.insert(event.status.id, at: 0) pendingStatusesObserver.pendingStatuses.insert(event.status.id, at: 0)
let newStatus = event.status let newStatus = event.status
await datasource.insert(newStatus, at: 0) await datasource.insert(newStatus, at: 0)
await cacheHome() await cache()
StatusDataControllerProvider.shared.updateDataControllers(for: [event.status], client: client) StatusDataControllerProvider.shared.updateDataControllers(for: [event.status], client: client)
let statuses = await datasource.get() let statuses = await datasource.get()
withAnimation { withAnimation {
@ -127,7 +128,7 @@ import SwiftUI
} }
} else if let event = event as? StreamEventDelete { } else if let event = event as? StreamEventDelete {
await datasource.remove(event.status) await datasource.remove(event.status)
await cacheHome() await cache()
let statuses = await datasource.get() let statuses = await datasource.get()
withAnimation { withAnimation {
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
@ -137,7 +138,7 @@ import SwiftUI
StatusDataControllerProvider.shared.updateDataControllers(for: [event.status], client: client) StatusDataControllerProvider.shared.updateDataControllers(for: [event.status], client: client)
await datasource.replace(event.status, at: originalIndex) await datasource.replace(event.status, at: originalIndex)
let statuses = await datasource.get() let statuses = await datasource.get()
await cacheHome() await cache()
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} }
} }
@ -147,23 +148,23 @@ import SwiftUI
// MARK: - Cache // MARK: - Cache
extension TimelineViewModel { extension TimelineViewModel {
private func cacheHome() async { private func cache() async {
if let client, timeline == .home { if let client, timeline.supportNewestPagination {
await cache.set(statuses: datasource.get(), client: client.id) await cache.set(statuses: datasource.get(), client: client.id, filter: timeline.id)
} }
} }
private func getCachedStatuses() async -> [Status]? { private func getCachedStatuses() async -> [Status]? {
if let client { if let client {
return await cache.getStatuses(for: client.id) return await cache.getStatuses(for: client.id, filter: timeline.id)
} }
return nil return nil
} }
private func clearHomeCache() async { private func clearCache(filter: TimelineFilter) async {
if let client { if let client {
await cache.clearCache(for: client.id) await cache.clearCache(for: client.id, filter: filter.id)
await cache.setLatestSeenStatuses(ids: [], for: client) await cache.setLatestSeenStatuses([], for: client, filter: filter.id)
} }
} }
} }
@ -201,7 +202,7 @@ extension TimelineViewModel: StatusesFetcher {
StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client) StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client)
await datasource.set(statuses) await datasource.set(statuses)
await cacheHome() await cache()
marker = nil marker = nil
withAnimation { withAnimation {
@ -237,22 +238,23 @@ extension TimelineViewModel: StatusesFetcher {
// If we get statuses from the cache for the home timeline, we displays those. // If we get statuses from the cache for the home timeline, we displays those.
// Else we fetch top most page from the API. // Else we fetch top most page from the API.
if let cachedStatuses = await getCachedStatuses(), if timeline.supportNewestPagination,
let cachedStatuses = await getCachedStatuses(),
!cachedStatuses.isEmpty, !cachedStatuses.isEmpty,
timeline == .home, !UserPreferences.shared.fastRefreshEnabled !UserPreferences.shared.fastRefreshEnabled
{ {
await datasource.set(cachedStatuses) 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), let index = await datasource.indexOf(statusId: latestSeenId),
index > 0 index > 0
{ {
// Restore cache and scroll to latest seen status. // Restore cache and scroll to latest seen status.
statusesState = await .display(statuses: datasource.get(), nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
scrollToIndexAnimated = false scrollToIndexAnimated = false
scrollToIndex = index + 1 scrollToIndex = index + 1
} else { } else {
// Restore cache and scroll to top. // Restore cache and scroll to top.
let statuses = await datasource.get()
withAnimation { withAnimation {
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage) statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
} }
@ -270,7 +272,7 @@ extension TimelineViewModel: StatusesFetcher {
await datasource.set(statuses) await datasource.set(statuses)
statuses = await datasource.get() statuses = await datasource.get()
await cacheHome() await cache()
withAnimation { withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage) 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. // 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. // Insert new statuses in internal datasource.
await datasource.insert(contentOf: newStatuses, at: 0) await datasource.insert(contentOf: newStatuses, at: 0)
// Cache statuses for home timeline. // Cache statuses for timeline.
await cacheHome() await cache()
// Append new statuses in the timeline indicator. // Append new statuses in the timeline indicator.
pendingStatusesObserver.pendingStatuses.insert(contentsOf: newStatuses.map(\.id), at: 0) pendingStatusesObserver.pendingStatuses.insert(contentsOf: newStatuses.map(\.id), at: 0)
// High chance the user is scrolled to the top. // 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. // 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 pendingStatusesObserver.disableUpdate = true
let statuses = await datasource.get() let statuses = await datasource.get()
statusesState = .display(statuses: statuses, statusesState = .display(statuses: statuses,
@ -417,17 +419,17 @@ extension TimelineViewModel: StatusesFetcher {
func statusDidAppear(status: Status) { func statusDidAppear(status: Status) {
pendingStatusesObserver.removeStatus(status: status) pendingStatusesObserver.removeStatus(status: status)
visibileStatusesIds.insert(status.id) visibileStatuses.insert(status, at: 0)
if let client, timeline == .home { if let client, timeline.supportNewestPagination {
Task { Task {
await cache.setLatestSeenStatuses(ids: visibileStatusesIds.map { $0 }, for: client) await cache.setLatestSeenStatuses(visibileStatuses, for: client, filter: timeline.id)
} }
} }
} }
func statusDidDisappear(status: Status) { func statusDidDisappear(status: Status) {
visibileStatusesIds.remove(status.id) visibileStatuses.removeAll(where: { $0.id == status.id })
} }
} }
@ -448,7 +450,7 @@ extension TimelineViewModel {
func saveMarker() { func saveMarker() {
guard timeline == .home, let client else { return } guard timeline == .home, let client else { return }
Task { 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 { do {
let _: Marker = try await client.post(endpoint: Markers.markHome(lastReadId: id)) let _: Marker = try await client.post(endpoint: Markers.markHome(lastReadId: id))
} catch { } } catch { }

View file

@ -4,31 +4,52 @@ import Network
import SwiftUI import SwiftUI
public actor TimelineCache { public actor TimelineCache {
private func storageFor(_ client: String) -> SQLiteStorageEngine { private func storageFor(_ client: String, _ filter: String) -> SQLiteStorageEngine {
SQLiteStorageEngine.default(appendingPath: client) if filter == "Home" {
SQLiteStorageEngine.default(appendingPath: "\(client)")
} else {
SQLiteStorageEngine.default(appendingPath: "\(client)/\(filter)")
}
} }
private let decoder = JSONDecoder() private let decoder = JSONDecoder()
private let encoder = JSONEncoder() private let encoder = JSONEncoder()
public init() {} public init() {}
public func cachedPostsCount(for client: String) async -> Int { 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 { public func clearCache(for client: String, filter: String) async {
let engine = storageFor(client) let engine = storageFor(client, filter)
do { do {
try await engine.removeAllData() try await engine.removeAllData()
} catch {} } catch {}
} }
func set(statuses: [Status], client: String) async { func set(statuses: [Status], client: String, filter: String) async {
guard !statuses.isEmpty else { return } guard !statuses.isEmpty else { return }
let statuses = statuses.prefix(upTo: min(600, statuses.count - 1)).map { $0 } let statuses = statuses.prefix(upTo: min(600, statuses.count - 1)).map { $0 }
do { do {
let engine = storageFor(client) let engine = storageFor(client, filter)
try await engine.removeAllData() try await engine.removeAllData()
let itemKeys = statuses.map { CacheKey($0[keyPath: \.id]) } let itemKeys = statuses.map { CacheKey($0[keyPath: \.id]) }
let dataAndKeys = try zip(itemKeys, statuses) let dataAndKeys = try zip(itemKeys, statuses)
@ -37,8 +58,8 @@ public actor TimelineCache {
} catch {} } catch {}
} }
func getStatuses(for client: String) async -> [Status]? { func getStatuses(for client: String, filter: String) async -> [Status]? {
let engine = storageFor(client) let engine = storageFor(client, filter)
do { do {
return try await engine return try await engine
.readAllData() .readAllData()
@ -49,12 +70,20 @@ public actor TimelineCache {
} }
} }
func setLatestSeenStatuses(ids: [String], for client: Client) { func setLatestSeenStatuses(_ statuses: [Status], for client: Client, filter: String) {
UserDefaults.standard.set(ids, forKey: "timeline-last-seen-\(client.id)") 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]? { func getLatestSeenStatus(for client: Client, filter: String) -> [String]? {
UserDefaults.standard.array(forKey: "timeline-last-seen-\(client.id)") as? [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]
}
} }
} }