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

View file

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

View file

@ -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<String>()
@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 { }

View file

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