Refactor Timeline position management + add thin cache layer + fix crashes
This commit is contained in:
parent
9bf40b262f
commit
cdf45fa58c
|
@ -2,7 +2,7 @@ import Foundation
|
|||
import Models
|
||||
import SwiftUI
|
||||
|
||||
public class Client: ObservableObject, Equatable, Identifiable {
|
||||
public class Client: ObservableObject, Equatable, Identifiable, Hashable {
|
||||
public static func == (lhs: Client, rhs: Client) -> Bool {
|
||||
lhs.isAuth == rhs.isAuth &&
|
||||
lhs.server == rhs.server &&
|
||||
|
@ -21,6 +21,10 @@ public class Client: ObservableObject, Equatable, Identifiable {
|
|||
public var id: String {
|
||||
"\(isAuth)\(server)\(oauthToken?.accessToken ?? "")"
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
public var server: String
|
||||
public let version: Version
|
||||
|
|
|
@ -23,6 +23,7 @@ public struct StatusesListView<Fetcher>: View where Fetcher: StatusesFetcher {
|
|||
StatusRowView(viewModel: .init(status: status, isCompact: false))
|
||||
.padding(.horizontal, isEmbdedInList ? 0 : .layoutPadding)
|
||||
.redacted(reason: .placeholder)
|
||||
.id(UUID())
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowInsets(.init(top: 12,
|
||||
leading: .layoutPadding,
|
||||
|
|
|
@ -132,7 +132,7 @@ public struct StatusPollView: View {
|
|||
}
|
||||
}
|
||||
.frame(height: .pollBarHeight)
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,17 +28,11 @@ class PendingStatusesObserver: ObservableObject {
|
|||
|
||||
struct PendingStatusesObserverView: View {
|
||||
@ObservedObject var observer: PendingStatusesObserver
|
||||
var proxy: ScrollViewProxy
|
||||
|
||||
var body: some View {
|
||||
if observer.pendingStatusesCount > 0 {
|
||||
HStack(spacing: 6) {
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation {
|
||||
proxy.scrollTo(observer.pendingStatuses.last, anchor: .top)
|
||||
}
|
||||
} label: {
|
||||
Button { } label: {
|
||||
Text("\(observer.pendingStatusesCount)")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import SwiftUI
|
||||
import Models
|
||||
import Network
|
||||
|
||||
actor TimelineCache {
|
||||
static let shared: TimelineCache = .init()
|
||||
|
||||
private var memoryCache: [Client: [Status]] = [:]
|
||||
|
||||
private init() {}
|
||||
|
||||
func set(statuses: [Status], client: Client) {
|
||||
memoryCache[client] = statuses.prefix(upTo: min(100, (statuses.count - 1))).map{ $0 }
|
||||
}
|
||||
|
||||
func getStatuses(for client: Client) -> [Status]? {
|
||||
memoryCache[client]
|
||||
}
|
||||
}
|
|
@ -48,18 +48,26 @@ public struct TimelineView: View {
|
|||
StatusesListView(fetcher: viewModel)
|
||||
}
|
||||
}
|
||||
.id(account.account?.id ?? client.id)
|
||||
.id(client.id)
|
||||
.environment(\.defaultMinListRowHeight, 1)
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(theme.primaryBackgroundColor)
|
||||
if viewModel.pendingStatusesEnabled {
|
||||
makePendingNewPostsView(proxy: proxy)
|
||||
PendingStatusesObserverView(observer: viewModel.pendingStatusesObserver)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
viewModel.scrollProxy = proxy
|
||||
.onChange(of: viewModel.scrollToStatus) { statusId in
|
||||
if let statusId {
|
||||
viewModel.scrollToStatus = nil
|
||||
proxy.scrollTo(statusId, anchor: .top)
|
||||
}
|
||||
}
|
||||
.onChange(of: scrollToTopSignal, perform: { _ in
|
||||
withAnimation {
|
||||
proxy.scrollTo(Constants.scrollToTop, anchor: .top)
|
||||
}
|
||||
})
|
||||
}
|
||||
.navigationTitle(timeline.localizedTitle())
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
|
@ -83,11 +91,6 @@ public struct TimelineView: View {
|
|||
viewModel.handleEvent(event: latestEvent, currentAccount: account)
|
||||
}
|
||||
}
|
||||
.onChange(of: scrollToTopSignal, perform: { _ in
|
||||
withAnimation {
|
||||
viewModel.scrollProxy?.scrollTo(Constants.scrollToTop, anchor: .top)
|
||||
}
|
||||
})
|
||||
.onChange(of: timeline) { newTimeline in
|
||||
switch newTimeline {
|
||||
case let .remoteLocal(server):
|
||||
|
@ -115,11 +118,6 @@ public struct TimelineView: View {
|
|||
})
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func makePendingNewPostsView(proxy: ScrollViewProxy) -> some View {
|
||||
PendingStatusesObserverView(observer: viewModel.pendingStatusesObserver, proxy: proxy)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var tagHeaderView: some View {
|
||||
if let tag = viewModel.tag {
|
||||
|
@ -158,11 +156,17 @@ public struct TimelineView: View {
|
|||
}
|
||||
|
||||
private var scrollToTopView: some View {
|
||||
HStack{ }
|
||||
HStack{ EmptyView() }
|
||||
.listRowBackground(theme.primaryBackgroundColor)
|
||||
.listRowSeparator(.hidden)
|
||||
.listRowInsets(.init())
|
||||
.frame(height: .layoutPadding)
|
||||
.id(Constants.scrollToTop)
|
||||
.onAppear {
|
||||
viewModel.scrollToTopVisible = true
|
||||
}
|
||||
.onDisappear {
|
||||
viewModel.scrollToTopVisible = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,9 +3,10 @@ import Models
|
|||
import Network
|
||||
import Status
|
||||
import SwiftUI
|
||||
import Account
|
||||
|
||||
@MainActor
|
||||
class TimelineViewModel: ObservableObject, StatusesFetcher {
|
||||
class TimelineViewModel: ObservableObject {
|
||||
var client: Client? {
|
||||
didSet {
|
||||
if oldValue != client {
|
||||
|
@ -17,13 +18,15 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
|
|||
// Internal source of truth for a timeline.
|
||||
private var statuses: [Status] = []
|
||||
private var visibileStatusesIds = Set<String>()
|
||||
var scrollToTopVisible: Bool = false
|
||||
|
||||
private var canStreamEvents: Bool = true
|
||||
|
||||
var scrollProxy: ScrollViewProxy?
|
||||
|
||||
var pendingStatusesObserver: PendingStatusesObserver = .init()
|
||||
let pendingStatusesObserver: PendingStatusesObserver = .init()
|
||||
let cache: TimelineCache = .shared
|
||||
|
||||
@Published var scrollToStatus: String?
|
||||
|
||||
@Published var statusesState: StatusesState = .loading
|
||||
@Published var timeline: TimelineFilter = .federated {
|
||||
didSet {
|
||||
|
@ -55,108 +58,6 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
|
|||
client?.server ?? "Error"
|
||||
}
|
||||
|
||||
func fetchStatuses() async {
|
||||
guard let client else { return }
|
||||
do {
|
||||
if statuses.isEmpty {
|
||||
pendingStatusesObserver.pendingStatuses = []
|
||||
statusesState = .loading
|
||||
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
||||
maxId: nil,
|
||||
minId: nil,
|
||||
offset: statuses.count))
|
||||
withAnimation {
|
||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
}
|
||||
} else if let first = statuses.first {
|
||||
canStreamEvents = false
|
||||
var newStatuses: [Status] = await fetchNewPages(minId: first.id, maxPages: 10)
|
||||
if !pendingStatusesEnabled {
|
||||
statuses.insert(contentsOf: newStatuses, at: 0)
|
||||
pendingStatusesObserver.pendingStatuses = []
|
||||
withAnimation {
|
||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
canStreamEvents = true
|
||||
}
|
||||
} else {
|
||||
newStatuses = newStatuses.filter { status in
|
||||
!statuses.contains(where: { $0.id == status.id })
|
||||
}
|
||||
|
||||
guard !newStatuses.isEmpty else {
|
||||
canStreamEvents = true
|
||||
return
|
||||
}
|
||||
|
||||
pendingStatusesObserver.pendingStatuses.insert(contentsOf: newStatuses.map{ $0.id }, at: 0)
|
||||
pendingStatusesObserver.feedbackGenerator.impactOccurred()
|
||||
|
||||
// High chance the user is scrolled to the top, this is a workaround to keep scroll position when prepending statuses.
|
||||
if let firstStatusId = statuses.first?.id, visibileStatusesIds.contains(firstStatusId) {
|
||||
statuses.insert(contentsOf: newStatuses, at: 0)
|
||||
pendingStatusesObserver.disableUpdate = true
|
||||
withAnimation {
|
||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) {
|
||||
self.scrollProxy?.scrollTo(firstStatusId, anchor: .top)
|
||||
self.pendingStatusesObserver.disableUpdate = false
|
||||
self.canStreamEvents = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
statuses.insert(contentsOf: newStatuses, at: 0)
|
||||
withAnimation {
|
||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
canStreamEvents = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
statusesState = .error(error: error)
|
||||
canStreamEvents = true
|
||||
print("timeline parse error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func fetchNewPages(minId: String, maxPages: Int) async -> [Status] {
|
||||
guard let client else { return [] }
|
||||
var pagesLoaded = 0
|
||||
var allStatuses: [Status] = []
|
||||
var latestMinId = minId
|
||||
do {
|
||||
while let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
||||
maxId: nil,
|
||||
minId: latestMinId,
|
||||
offset: statuses.count)),
|
||||
!newStatuses.isEmpty,
|
||||
pagesLoaded < maxPages
|
||||
{
|
||||
pagesLoaded += 1
|
||||
allStatuses.insert(contentsOf: newStatuses, at: 0)
|
||||
latestMinId = newStatuses.first?.id ?? ""
|
||||
}
|
||||
} catch {
|
||||
return allStatuses
|
||||
}
|
||||
return allStatuses
|
||||
}
|
||||
|
||||
func fetchNextPage() async {
|
||||
guard let client else { return }
|
||||
do {
|
||||
guard let lastId = statuses.last?.id else { return }
|
||||
statusesState = .display(statuses: statuses, nextPageState: .loadingNextPage)
|
||||
let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
||||
maxId: lastId,
|
||||
minId: nil,
|
||||
offset: statuses.count))
|
||||
statuses.append(contentsOf: newStatuses)
|
||||
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||
} catch {
|
||||
statusesState = .error(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchTag(id: String) async {
|
||||
guard let client else { return }
|
||||
|
@ -188,7 +89,167 @@ class TimelineViewModel: ObservableObject, StatusesFetcher {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache
|
||||
extension TimelineViewModel {
|
||||
private func cache(statuses: [Status]) async {
|
||||
if let client {
|
||||
await cache.set(statuses: statuses, client: client)
|
||||
}
|
||||
}
|
||||
|
||||
private func getCachedStatuses() async -> [Status]? {
|
||||
if let client {
|
||||
return await cache.getStatuses(for: client)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - StatusesFetcher
|
||||
extension TimelineViewModel: StatusesFetcher {
|
||||
func fetchStatuses() async {
|
||||
guard let client else { return }
|
||||
do {
|
||||
if statuses.isEmpty {
|
||||
try await fetchFirstPage(client: client)
|
||||
} else if let latest = statuses.first {
|
||||
try await fetchNewPagesFrom(latestStatus: latest, client: client)
|
||||
}
|
||||
} catch {
|
||||
statusesState = .error(error: error)
|
||||
canStreamEvents = true
|
||||
print("timeline parse error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// Hydrate statuses in the Timeline when statuses are empty.
|
||||
private func fetchFirstPage(client: Client) async throws {
|
||||
pendingStatusesObserver.pendingStatuses = []
|
||||
statusesState = .loading
|
||||
|
||||
// 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(), timeline == .home {
|
||||
statuses = cachedStatuses
|
||||
withAnimation {
|
||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
}
|
||||
// And then we fetch statuses again toget newest statuses from there.
|
||||
await fetchStatuses()
|
||||
} else {
|
||||
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
||||
maxId: nil,
|
||||
minId: nil,
|
||||
offset: statuses.count))
|
||||
if timeline == .home {
|
||||
await cache(statuses: statuses)
|
||||
}
|
||||
withAnimation {
|
||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch pages from the top most status of the tomeline.
|
||||
private func fetchNewPagesFrom(latestStatus: Status, client: Client) async throws {
|
||||
canStreamEvents = false
|
||||
var newStatuses: [Status] = await fetchNewPages(minId: latestStatus.id, maxPages: 10)
|
||||
|
||||
// Dedup statuses, a status with the same id could have been streamed in.
|
||||
newStatuses = newStatuses.filter { status in
|
||||
!statuses.contains(where: { $0.id == status.id })
|
||||
}
|
||||
|
||||
// If no new statuses, resume streaming and exit.
|
||||
guard !newStatuses.isEmpty else {
|
||||
canStreamEvents = true
|
||||
return
|
||||
}
|
||||
|
||||
// Keep track of the top most status, so we can scroll back to it after view update.
|
||||
let topStatusId = statuses.first?.id
|
||||
|
||||
// Insert new statuses in internal datasource.
|
||||
statuses.insert(contentsOf: newStatuses, at: 0)
|
||||
|
||||
// Cache statuses for home timeline.
|
||||
if timeline == .home {
|
||||
await cache(statuses: statuses)
|
||||
}
|
||||
|
||||
// If pending statuses are not enabled, we simply load status on the top regardless of the current position.
|
||||
if !pendingStatusesEnabled {
|
||||
pendingStatusesObserver.pendingStatuses = []
|
||||
withAnimation {
|
||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
canStreamEvents = true
|
||||
}
|
||||
} else {
|
||||
// Append new statuses in the timeline indicator.
|
||||
pendingStatusesObserver.pendingStatuses.insert(contentsOf: newStatuses.map{ $0.id }, at: 0)
|
||||
pendingStatusesObserver.feedbackGenerator.impactOccurred()
|
||||
|
||||
// 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 {
|
||||
pendingStatusesObserver.disableUpdate = true
|
||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
scrollToStatus = topStatusId
|
||||
DispatchQueue.main.async {
|
||||
self.pendingStatusesObserver.disableUpdate = false
|
||||
self.canStreamEvents = true
|
||||
}
|
||||
} else {
|
||||
// This will keep the scroll position (if the list is scrolled) and prepend statuses on the top.
|
||||
withAnimation {
|
||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||
canStreamEvents = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchNewPages(minId: String, maxPages: Int) async -> [Status] {
|
||||
guard let client else { return [] }
|
||||
var pagesLoaded = 0
|
||||
var allStatuses: [Status] = []
|
||||
var latestMinId = minId
|
||||
do {
|
||||
while let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
||||
maxId: nil,
|
||||
minId: latestMinId,
|
||||
offset: statuses.count)),
|
||||
!newStatuses.isEmpty,
|
||||
pagesLoaded < maxPages
|
||||
{
|
||||
pagesLoaded += 1
|
||||
allStatuses.insert(contentsOf: newStatuses, at: 0)
|
||||
latestMinId = newStatuses.first?.id ?? ""
|
||||
}
|
||||
} catch {
|
||||
return allStatuses
|
||||
}
|
||||
return allStatuses
|
||||
}
|
||||
|
||||
func fetchNextPage() async {
|
||||
guard let client else { return }
|
||||
do {
|
||||
guard let lastId = statuses.last?.id else { return }
|
||||
statusesState = .display(statuses: statuses, nextPageState: .loadingNextPage)
|
||||
let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
||||
maxId: lastId,
|
||||
minId: nil,
|
||||
offset: statuses.count))
|
||||
statuses.append(contentsOf: newStatuses)
|
||||
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||
} catch {
|
||||
statusesState = .error(error: error)
|
||||
}
|
||||
}
|
||||
|
||||
func statusDidAppear(status: Status) {
|
||||
pendingStatusesObserver.removeStatus(status: status)
|
||||
visibileStatusesIds.insert(status.id)
|
||||
|
|
Loading…
Reference in New Issue