mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-29 11:41:01 +00:00
Cache and restore position on all timelines
This commit is contained in:
parent
6854df4b89
commit
2e23b08b88
4 changed files with 83 additions and 52 deletions
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 { }
|
||||||
|
|
|
@ -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]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue