mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-11-29 11:41:01 +00:00
Timeline: wrap datasource in an actor for safety and perforamances
This commit is contained in:
parent
b1424aadd0
commit
7112e6515b
2 changed files with 128 additions and 63 deletions
54
Packages/Timeline/Sources/Timeline/TimelineDatasource.swift
Normal file
54
Packages/Timeline/Sources/Timeline/TimelineDatasource.swift
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
import Foundation
|
||||||
|
import Models
|
||||||
|
|
||||||
|
actor TimelineDatasource {
|
||||||
|
private var statuses: [Status] = []
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
statuses.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
func get() -> [Status] {
|
||||||
|
statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
statuses = []
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(statusId: String) -> Int? {
|
||||||
|
statuses.firstIndex(where: { $0.id == statusId })
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(statusId: String) -> Bool {
|
||||||
|
statuses.contains(where: { $0.id == statusId })
|
||||||
|
}
|
||||||
|
|
||||||
|
func set(_ statuses: [Status]) {
|
||||||
|
self.statuses = statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
func append(_ status: Status) {
|
||||||
|
statuses.append(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func append(contentOf: [Status]) {
|
||||||
|
statuses.append(contentsOf: contentOf)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insert(_ status: Status, at: Int) {
|
||||||
|
statuses.insert(status, at: at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insert(contentOf: [Status], at: Int) {
|
||||||
|
statuses.insert(contentsOf: contentOf, at: at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replace(_ status: Status, at: Int) {
|
||||||
|
statuses[at] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(_ statusId: String) {
|
||||||
|
statuses.removeAll(where: { $0.id == statusId })
|
||||||
|
}
|
||||||
|
}
|
|
@ -17,7 +17,7 @@ class TimelineViewModel: ObservableObject {
|
||||||
timeline = .home
|
timeline = .home
|
||||||
}
|
}
|
||||||
if oldValue != timeline {
|
if oldValue != timeline {
|
||||||
statuses = []
|
await datasource.reset()
|
||||||
pendingStatusesObserver.pendingStatuses = []
|
pendingStatusesObserver.pendingStatuses = []
|
||||||
tag = nil
|
tag = nil
|
||||||
}
|
}
|
||||||
|
@ -40,7 +40,7 @@ class TimelineViewModel: ObservableObject {
|
||||||
@Published var tag: Tag?
|
@Published var tag: Tag?
|
||||||
|
|
||||||
// Internal source of truth for a timeline.
|
// Internal source of truth for a timeline.
|
||||||
private var statuses: [Status] = []
|
private var datasource = TimelineDatasource()
|
||||||
private let cache: TimelineCache = .shared
|
private let cache: TimelineCache = .shared
|
||||||
private var visibileStatusesIds = Set<String>()
|
private var visibileStatusesIds = Set<String>()
|
||||||
private var canStreamEvents: Bool = true
|
private var canStreamEvents: Bool = true
|
||||||
|
@ -52,7 +52,9 @@ class TimelineViewModel: ObservableObject {
|
||||||
var client: Client? {
|
var client: Client? {
|
||||||
didSet {
|
didSet {
|
||||||
if oldValue != client {
|
if oldValue != client {
|
||||||
statuses = []
|
Task {
|
||||||
|
await datasource.reset()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -92,41 +94,39 @@ class TimelineViewModel: ObservableObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleEvent(event: any StreamEvent, currentAccount _: CurrentAccount) {
|
func handleEvent(event: any StreamEvent, currentAccount _: CurrentAccount) {
|
||||||
if let event = event as? StreamEventUpdate,
|
Task {
|
||||||
canStreamEvents,
|
if let event = event as? StreamEventUpdate,
|
||||||
isTimelineVisible,
|
canStreamEvents,
|
||||||
pendingStatusesEnabled,
|
isTimelineVisible,
|
||||||
!statuses.contains(where: { $0.id == event.status.id })
|
pendingStatusesEnabled,
|
||||||
{
|
await !datasource.contains(statusId: event.status.id)
|
||||||
pendingStatusesObserver.pendingStatuses.insert(event.status.id, at: 0)
|
{
|
||||||
let newStatus = event.status
|
pendingStatusesObserver.pendingStatuses.insert(event.status.id, at: 0)
|
||||||
if let accountId {
|
let newStatus = event.status
|
||||||
if newStatus.mentions.first(where: { $0.id == accountId }) != nil {
|
if let accountId {
|
||||||
newStatus.userMentioned = true
|
if newStatus.mentions.first(where: { $0.id == accountId }) != nil {
|
||||||
|
newStatus.userMentioned = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
await datasource.insert(newStatus, at: 0)
|
||||||
statuses.insert(newStatus, at: 0)
|
|
||||||
Task {
|
|
||||||
await cacheHome()
|
await cacheHome()
|
||||||
}
|
let statuses = await datasource.get()
|
||||||
withAnimation {
|
withAnimation {
|
||||||
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||||
}
|
|
||||||
} else if let event = event as? StreamEventDelete {
|
|
||||||
withAnimation {
|
|
||||||
statuses.removeAll(where: { $0.id == event.status })
|
|
||||||
Task {
|
|
||||||
await cacheHome()
|
|
||||||
}
|
}
|
||||||
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
} else if let event = event as? StreamEventDelete {
|
||||||
}
|
await datasource.remove(event.status)
|
||||||
} else if let event = event as? StreamEventStatusUpdate {
|
await cacheHome()
|
||||||
if let originalIndex = statuses.firstIndex(where: { $0.id == event.status.id }) {
|
let statuses = await datasource.get()
|
||||||
statuses[originalIndex] = event.status
|
withAnimation {
|
||||||
Task {
|
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
||||||
await cacheHome()
|
}
|
||||||
|
} else if let event = event as? StreamEventStatusUpdate {
|
||||||
|
if let originalIndex = await datasource.indexOf(statusId: event.status.id) {
|
||||||
|
await datasource.replace(event.status, at: originalIndex)
|
||||||
|
await cacheHome()
|
||||||
|
statusesState = await .display(statuses: datasource.get(), nextPageState: .hasNextPage)
|
||||||
}
|
}
|
||||||
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -137,7 +137,7 @@ class TimelineViewModel: ObservableObject {
|
||||||
extension TimelineViewModel {
|
extension TimelineViewModel {
|
||||||
private func cacheHome() async {
|
private func cacheHome() async {
|
||||||
if let client, timeline == .home {
|
if let client, timeline == .home {
|
||||||
await cache.set(statuses: statuses, client: client)
|
await cache.set(statuses: datasource.get(), client: client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,12 +155,12 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
func fetchStatuses() async {
|
func fetchStatuses() async {
|
||||||
guard let client else { return }
|
guard let client else { return }
|
||||||
do {
|
do {
|
||||||
if statuses.isEmpty || timeline == .trending {
|
if await datasource.isEmpty || timeline == .trending {
|
||||||
if !statuses.isEmpty && timeline == .trending {
|
if await !datasource.isEmpty && timeline == .trending {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try await fetchFirstPage(client: client)
|
try await fetchFirstPage(client: client)
|
||||||
} else if let latest = statuses.first {
|
} else if let latest = await datasource.get().first {
|
||||||
try await fetchNewPagesFrom(latestStatus: latest, client: client)
|
try await fetchNewPagesFrom(latestStatus: latest, client: client)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -174,7 +174,7 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
private func fetchFirstPage(client: Client) async throws {
|
private func fetchFirstPage(client: Client) async throws {
|
||||||
pendingStatusesObserver.pendingStatuses = []
|
pendingStatusesObserver.pendingStatuses = []
|
||||||
|
|
||||||
if statuses.isEmpty {
|
if await datasource.isEmpty {
|
||||||
statusesState = .loading
|
statusesState = .loading
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,17 +184,18 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
!cachedStatuses.isEmpty,
|
!cachedStatuses.isEmpty,
|
||||||
timeline == .home
|
timeline == .home
|
||||||
{
|
{
|
||||||
statuses = cachedStatuses
|
await datasource.set(cachedStatuses)
|
||||||
if let latestSeenId = await cache.getLatestSeenStatus(for: client)?.last,
|
if let latestSeenId = await cache.getLatestSeenStatus(for: client)?.last,
|
||||||
let index = statuses.firstIndex(where: { $0.id == 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 = .display(statuses: statuses, nextPageState: .hasNextPage)
|
statusesState = await .display(statuses: datasource.get(), 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)
|
||||||
}
|
}
|
||||||
|
@ -202,14 +203,15 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
// And then we fetch statuses again toget newest statuses from there.
|
// And then we fetch statuses again toget newest statuses from there.
|
||||||
await fetchStatuses()
|
await fetchStatuses()
|
||||||
} else {
|
} else {
|
||||||
statuses = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
var statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
||||||
maxId: nil,
|
maxId: nil,
|
||||||
minId: nil,
|
minId: nil,
|
||||||
offset: 0))
|
offset: 0))
|
||||||
|
|
||||||
updateMentionsToBeHighlighted(&statuses)
|
updateMentionsToBeHighlighted(&statuses)
|
||||||
ReblogCache.shared.removeDuplicateReblogs(&statuses)
|
ReblogCache.shared.removeDuplicateReblogs(&statuses)
|
||||||
|
|
||||||
|
await datasource.set(statuses)
|
||||||
await cacheHome()
|
await cacheHome()
|
||||||
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
|
@ -225,8 +227,9 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
var newStatuses: [Status] = await fetchNewPages(minId: latestStatus.id, maxPages: 10)
|
var newStatuses: [Status] = await fetchNewPages(minId: latestStatus.id, maxPages: 10)
|
||||||
|
|
||||||
// Dedup statuses, a status with the same id could have been streamed in.
|
// Dedup statuses, a status with the same id could have been streamed in.
|
||||||
|
let ids = await datasource.get().map{ $0.id }
|
||||||
newStatuses = newStatuses.filter { status in
|
newStatuses = newStatuses.filter { status in
|
||||||
!statuses.contains(where: { $0.id == status.id })
|
!ids.contains(where: { $0 == status.id })
|
||||||
}
|
}
|
||||||
|
|
||||||
ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
|
ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
|
||||||
|
@ -256,10 +259,10 @@ 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 = statuses.first?.id
|
let topStatusId = await datasource.get().first?.id
|
||||||
|
|
||||||
// Insert new statuses in internal datasource.
|
// Insert new statuses in internal datasource.
|
||||||
statuses.insert(contentsOf: newStatuses, at: 0)
|
await datasource.insert(contentOf: newStatuses, at: 0)
|
||||||
|
|
||||||
// Cache statuses for home timeline.
|
// Cache statuses for home timeline.
|
||||||
await cacheHome()
|
await cacheHome()
|
||||||
|
@ -267,8 +270,10 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
// If pending statuses are not enabled, we simply load status on the top regardless of the current position.
|
// If pending statuses are not enabled, we simply load status on the top regardless of the current position.
|
||||||
if !pendingStatusesEnabled {
|
if !pendingStatusesEnabled {
|
||||||
pendingStatusesObserver.pendingStatuses = []
|
pendingStatusesObserver.pendingStatuses = []
|
||||||
|
let statuses = await datasource.get()
|
||||||
withAnimation {
|
withAnimation {
|
||||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
statusesState = .display(statuses: statuses,
|
||||||
|
nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||||
canStreamEvents = true
|
canStreamEvents = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -279,7 +284,9 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
// 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 topStatusId, visibileStatusesIds.contains(topStatusId), scrollToTopVisible {
|
||||||
pendingStatusesObserver.disableUpdate = true
|
pendingStatusesObserver.disableUpdate = true
|
||||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
let statuses = await datasource.get()
|
||||||
|
statusesState = .display(statuses: statuses,
|
||||||
|
nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||||
scrollToIndexAnimated = false
|
scrollToIndexAnimated = false
|
||||||
scrollToIndex = newStatuses.count + 1
|
scrollToIndex = newStatuses.count + 1
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
|
@ -288,15 +295,17 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// This will keep the scroll position (if the list is scrolled) and prepend statuses on the top.
|
// This will keep the scroll position (if the list is scrolled) and prepend statuses on the top.
|
||||||
|
let statuses = await datasource.get()
|
||||||
withAnimation {
|
withAnimation {
|
||||||
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
statusesState = .display(statuses: statuses,
|
||||||
|
nextPageState: statuses.count < 20 ? .none : .hasNextPage)
|
||||||
canStreamEvents = true
|
canStreamEvents = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We trigger a new fetch so we can get the next new statuses if any.
|
// We trigger a new fetch so we can get the next new statuses if any.
|
||||||
// If none, it'll stop there.
|
// If none, it'll stop there.
|
||||||
if !Task.isCancelled, let latest = statuses.first, let client {
|
if !Task.isCancelled, let latest = await datasource.get().first, let client {
|
||||||
try await fetchNewPagesFrom(latestStatus: latest, client: client)
|
try await fetchNewPagesFrom(latestStatus: latest, client: client)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -308,10 +317,11 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
var allStatuses: [Status] = []
|
var allStatuses: [Status] = []
|
||||||
var latestMinId = minId
|
var latestMinId = minId
|
||||||
do {
|
do {
|
||||||
while var newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
while var newStatuses: [Status] =
|
||||||
maxId: nil,
|
try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
||||||
minId: latestMinId,
|
maxId: nil,
|
||||||
offset: statuses.count)),
|
minId: latestMinId,
|
||||||
|
offset: datasource.get().count)),
|
||||||
!newStatuses.isEmpty,
|
!newStatuses.isEmpty,
|
||||||
pagesLoaded < maxPages
|
pagesLoaded < maxPages
|
||||||
{
|
{
|
||||||
|
@ -332,19 +342,20 @@ extension TimelineViewModel: StatusesFetcher {
|
||||||
func fetchNextPage() async {
|
func fetchNextPage() async {
|
||||||
guard let client else { return }
|
guard let client else { return }
|
||||||
do {
|
do {
|
||||||
guard let lastId = statuses.last?.id else { return }
|
guard let lastId = await datasource.get().last?.id else { return }
|
||||||
statusesState = .display(statuses: statuses, nextPageState: .loadingNextPage)
|
statusesState = await .display(statuses: datasource.get(), nextPageState: .loadingNextPage)
|
||||||
var newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
var newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
|
||||||
maxId: lastId,
|
maxId: lastId,
|
||||||
minId: nil,
|
minId: nil,
|
||||||
offset: statuses.count))
|
offset: datasource.get().count))
|
||||||
|
|
||||||
updateMentionsToBeHighlighted(&newStatuses)
|
updateMentionsToBeHighlighted(&newStatuses)
|
||||||
ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
|
ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
|
||||||
|
|
||||||
statuses.append(contentsOf: newStatuses)
|
await datasource.append(contentOf: newStatuses)
|
||||||
|
|
||||||
statusesState = .display(statuses: statuses, nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)
|
statusesState = await .display(statuses: datasource.get(),
|
||||||
|
nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)
|
||||||
} catch {
|
} catch {
|
||||||
statusesState = .error(error: error)
|
statusesState = .error(error: error)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue