IceCubesApp/Packages/Timeline/Sources/Timeline/TimelineViewModel.swift

417 lines
13 KiB
Swift
Raw Normal View History

2023-01-17 10:36:01 +00:00
import Env
2022-11-29 08:28:17 +00:00
import Models
2023-01-17 10:36:01 +00:00
import Network
import Observation
2022-12-19 06:17:01 +00:00
import Status
2023-01-17 10:36:01 +00:00
import SwiftUI
2022-11-25 11:00:01 +00:00
@MainActor
@Observable class TimelineViewModel {
var scrollToIndex: Int?
var statusesState: StatusesState = .loading
var timeline: TimelineFilter = .federated {
2022-12-01 08:05:26 +00:00
didSet {
2023-02-08 17:46:09 +00:00
timelineTask?.cancel()
timelineTask = Task {
if timeline == .latest {
if oldValue == .home {
await clearHomeCache()
}
timeline = oldValue
}
if oldValue != timeline {
2023-02-25 18:47:15 +00:00
await reset()
pendingStatusesObserver.pendingStatuses = []
2023-01-04 17:37:58 +00:00
tag = nil
}
2023-02-08 17:46:09 +00:00
guard !Task.isCancelled else {
return
}
2023-02-25 09:10:27 +00:00
await fetchNewestStatuses()
2022-12-25 07:17:16 +00:00
switch timeline {
case let .hashtag(tag, _):
await fetchTag(id: tag)
default:
break
2022-12-01 08:05:26 +00:00
}
}
}
}
2023-02-12 15:29:41 +00:00
2023-02-08 17:46:09 +00:00
private var timelineTask: Task<Void, Never>?
2023-01-17 10:36:01 +00:00
var tag: Tag?
2023-07-19 05:46:25 +00:00
2023-02-04 16:17:38 +00:00
// Internal source of truth for a timeline.
private var datasource = TimelineDatasource()
2023-09-22 06:32:13 +00:00
private let cache = TimelineCache()
2023-02-04 16:17:38 +00:00
private var visibileStatusesIds = Set<String>()
private var canStreamEvents: Bool = true
private var accountId: String? {
CurrentAccount.shared.account?.id
}
var client: Client? {
didSet {
if oldValue != client {
Task {
2023-02-25 18:47:15 +00:00
await reset()
}
2023-02-04 16:17:38 +00:00
}
}
}
var scrollToTopVisible: Bool = false {
didSet {
if scrollToTopVisible {
pendingStatusesObserver.pendingStatuses = []
}
}
}
2022-11-25 11:00:01 +00:00
var serverName: String {
2022-12-19 11:28:55 +00:00
client?.server ?? "Error"
2022-11-25 11:00:01 +00:00
}
2023-02-04 16:17:38 +00:00
var isTimelineVisible: Bool = false
let pendingStatusesObserver: PendingStatusesObserver = .init()
var scrollToIndexAnimated: Bool = false
2023-12-25 17:01:25 +00:00
var searchQuery: String = "" {
didSet {
Task {
let statuses = await datasource.get().filter { status in
status.content.asRawText.contains(searchQuery)
}
statusesState = .display(statuses: statuses, nextPageState: .none)
}
}
}
var isSearchPresented: Bool = false {
didSet {
if !isSearchPresented {
Task {
let statuses = await datasource.get()
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
}
}
}
}
2023-02-04 16:17:38 +00:00
init() {
pendingStatusesObserver.scrollToIndex = { [weak self] index in
self?.scrollToIndexAnimated = true
self?.scrollToIndex = index
}
}
2023-01-17 10:36:01 +00:00
private func fetchTag(id: String) async {
guard let client else { return }
do {
tag = try await client.get(endpoint: Tags.tag(id: id))
} catch {}
}
2023-02-21 06:23:42 +00:00
2023-02-19 20:37:22 +00:00
func reset() async {
await datasource.reset()
}
2023-02-01 11:49:59 +00:00
func handleEvent(event: any StreamEvent, currentAccount _: CurrentAccount) {
Task {
if let event = event as? StreamEventUpdate,
timeline == .home,
canStreamEvents,
isTimelineVisible,
await !datasource.contains(statusId: event.status.id)
{
pendingStatusesObserver.pendingStatuses.insert(event.status.id, at: 0)
let newStatus = event.status
await datasource.insert(newStatus, at: 0)
2023-02-01 12:41:28 +00:00
await cacheHome()
let statuses = await datasource.get()
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
2023-02-01 12:28:04 +00:00
}
} else if let event = event as? StreamEventDelete {
await datasource.remove(event.status)
await cacheHome()
let statuses = await datasource.get()
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
}
} else if let event = event as? StreamEventStatusUpdate {
if let originalIndex = await datasource.indexOf(statusId: event.status.id) {
await datasource.replace(event.status, at: originalIndex)
2023-02-01 12:41:28 +00:00
await cacheHome()
statusesState = await .display(statuses: datasource.get(), nextPageState: .hasNextPage)
2023-02-01 12:28:04 +00:00
}
}
}
}
}
// MARK: - Cache
2023-02-01 11:49:59 +00:00
extension TimelineViewModel {
2023-02-01 12:41:28 +00:00
private func cacheHome() async {
if let client, timeline == .home {
await cache.set(statuses: datasource.get(), client: client.id)
}
}
2023-02-01 11:49:59 +00:00
private func getCachedStatuses() async -> [Status]? {
if let client {
return await cache.getStatuses(for: client.id)
}
return nil
}
2023-02-26 05:45:57 +00:00
private func clearHomeCache() async {
2023-02-27 05:21:49 +00:00
if let client {
await cache.clearCache(for: client.id)
2023-02-27 05:21:49 +00:00
await cache.setLatestSeenStatuses(ids: [], for: client)
}
}
}
// MARK: - StatusesFetcher
2023-02-01 11:49:59 +00:00
extension TimelineViewModel: StatusesFetcher {
func pullToRefresh() async {
timelineTask?.cancel()
2023-12-01 07:51:19 +00:00
if !timeline.supportNewestPagination || UserPreferences.shared.fastRefreshEnabled {
await reset()
}
await fetchNewestStatuses()
}
2023-07-19 05:46:25 +00:00
func refreshTimeline() {
timelineTask?.cancel()
timelineTask = Task {
2023-12-01 07:51:19 +00:00
if UserPreferences.shared.fastRefreshEnabled {
await reset()
}
await fetchNewestStatuses()
}
}
2023-03-13 12:38:28 +00:00
2023-02-25 09:10:27 +00:00
func fetchNewestStatuses() async {
2023-01-31 12:43:27 +00:00
guard let client else { return }
2022-11-25 11:00:01 +00:00
do {
if await datasource.isEmpty {
try await fetchFirstPage(client: client)
} else if let latest = await datasource.get().first, timeline.supportNewestPagination {
try await fetchNewPagesFrom(latestStatus: latest, client: client)
}
2022-11-25 11:00:01 +00:00
} catch {
2022-12-19 06:17:01 +00:00
statusesState = .error(error: error)
canStreamEvents = true
2022-12-21 11:39:29 +00:00
print("timeline parse error: \(error)")
2022-11-25 11:00:01 +00:00
}
}
2023-02-01 11:49:59 +00:00
// Hydrate statuses in the Timeline when statuses are empty.
private func fetchFirstPage(client: Client) async throws {
pendingStatusesObserver.pendingStatuses = []
2023-02-12 15:29:41 +00:00
if await datasource.isEmpty {
2023-02-08 17:46:09 +00:00
statusesState = .loading
}
2023-02-01 11:49:59 +00:00
// If we get statuses from the cache for the home timeline, we displays those.
// Else we fetch top most page from the API.
2023-02-04 11:17:16 +00:00
if let cachedStatuses = await getCachedStatuses(),
2023-02-04 16:17:38 +00:00
!cachedStatuses.isEmpty,
2023-12-18 07:22:59 +00:00
timeline == .home, !UserPreferences.shared.fastRefreshEnabled
2023-02-04 16:17:38 +00:00
{
await datasource.set(cachedStatuses)
2023-02-04 13:42:10 +00:00
if let latestSeenId = await cache.getLatestSeenStatus(for: client)?.last,
let index = await datasource.indexOf(statusId: latestSeenId),
2023-02-04 16:17:38 +00:00
index > 0
{
2023-02-04 13:42:10 +00:00
// Restore cache and scroll to latest seen status.
statusesState = await .display(statuses: datasource.get(), nextPageState: .hasNextPage)
2023-02-04 13:42:10 +00:00
scrollToIndexAnimated = false
scrollToIndex = index + 1
} else {
// Restore cache and scroll to top.
let statuses = await datasource.get()
2023-02-04 13:42:10 +00:00
withAnimation {
2023-02-04 19:37:22 +00:00
statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
2023-02-04 13:42:10 +00:00
}
}
// And then we fetch statuses again toget newest statuses from there.
2023-02-25 09:10:27 +00:00
await fetchNewestStatuses()
} else {
var statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: nil,
minId: nil,
offset: 0))
ReblogCache.shared.removeDuplicateReblogs(&statuses)
StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client)
2023-02-21 06:23:42 +00:00
await datasource.set(statuses)
2023-02-01 12:41:28 +00:00
await cacheHome()
2023-02-12 15:29:41 +00:00
withAnimation {
statusesState = .display(statuses: statuses, nextPageState: statuses.count < 20 ? .none : .hasNextPage)
}
}
}
2023-02-01 11:49:59 +00:00
// Fetch pages from the top most status of the tomeline.
private func fetchNewPagesFrom(latestStatus: Status, client: Client) async throws {
canStreamEvents = false
let initialTimeline = timeline
var newStatuses: [Status] = await fetchNewPages(minId: latestStatus.id, maxPages: 10)
2023-02-01 11:49:59 +00:00
// Dedup statuses, a status with the same id could have been streamed in.
2023-09-16 12:15:03 +00:00
let ids = await datasource.get().map(\.id)
newStatuses = newStatuses.filter { status in
!ids.contains(where: { $0 == status.id })
}
2023-02-04 16:17:38 +00:00
ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client)
// If no new statuses, resume streaming and exit.
guard !newStatuses.isEmpty else {
canStreamEvents = true
return
}
2023-02-04 16:17:38 +00:00
// If the timeline is not visible, we don't update it as it would mess up the user position.
guard isTimelineVisible else {
canStreamEvents = true
return
}
2023-02-12 15:29:41 +00:00
2023-02-08 17:46:09 +00:00
// Return if task has been cancelled.
guard !Task.isCancelled else {
canStreamEvents = true
return
}
2023-02-18 06:26:48 +00:00
// As this is a long runnign task we need to ensure that the user didn't changed the timeline filter.
guard initialTimeline == timeline else {
canStreamEvents = true
return
}
2023-02-01 11:49:59 +00:00
// Keep track of the top most status, so we can scroll back to it after view update.
let topStatusId = await datasource.get().first?.id
2023-02-01 11:49:59 +00:00
// Insert new statuses in internal datasource.
await datasource.insert(contentOf: newStatuses, at: 0)
2023-02-01 11:49:59 +00:00
// Cache statuses for home timeline.
2023-02-01 12:41:28 +00:00
await cacheHome()
2023-02-01 11:49:59 +00:00
2023-02-25 18:47:15 +00:00
// Append new statuses in the timeline indicator.
2023-09-16 12:15:03 +00:00
pendingStatusesObserver.pendingStatuses.insert(contentsOf: newStatuses.map(\.id), at: 0)
2023-02-25 18:47:15 +00:00
// 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
let statuses = await datasource.get()
2023-02-25 18:47:15 +00:00
statusesState = .display(statuses: statuses,
nextPageState: statuses.count < 20 ? .none : .hasNextPage)
scrollToIndexAnimated = false
scrollToIndex = newStatuses.count + 1
DispatchQueue.main.async {
self.pendingStatusesObserver.disableUpdate = false
self.canStreamEvents = true
}
} else {
2023-02-25 18:47:15 +00:00
// This will keep the scroll position (if the list is scrolled) and prepend statuses on the top.
let statuses = await datasource.get()
withAnimation {
statusesState = .display(statuses: statuses,
nextPageState: statuses.count < 20 ? .none : .hasNextPage)
2023-02-25 18:47:15 +00:00
canStreamEvents = true
}
2023-02-25 18:47:15 +00:00
}
2023-02-12 15:29:41 +00:00
2023-02-25 18:47:15 +00:00
// We trigger a new fetch so we can get the next new statuses if any.
// If none, it'll stop there.
// Only do that in the context of the home timeline as other don't worth catching up that much.
if timeline == .home,
2023-07-19 05:46:25 +00:00
!Task.isCancelled,
let latest = await datasource.get().first
{
2023-02-25 18:47:15 +00:00
try await fetchNewPagesFrom(latestStatus: latest, client: client)
}
}
2023-02-01 11:49:59 +00:00
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
!Task.isCancelled,
var newStatuses: [Status] =
2023-02-21 06:23:42 +00:00
try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: nil,
minId: latestMinId,
offset: datasource.get().count)),
2023-01-17 10:36:01 +00:00
!newStatuses.isEmpty,
pagesLoaded < maxPages
{
pagesLoaded += 1
ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client)
2023-02-04 16:17:38 +00:00
allStatuses.insert(contentsOf: newStatuses, at: 0)
latestMinId = newStatuses.first?.id ?? ""
}
} catch {
2023-01-05 05:39:23 +00:00
return allStatuses
}
return allStatuses
}
2023-02-01 11:49:59 +00:00
2022-12-19 06:17:01 +00:00
func fetchNextPage() async {
2022-12-19 11:28:55 +00:00
guard let client else { return }
2022-11-25 11:00:01 +00:00
do {
guard let lastId = await datasource.get().last?.id else { return }
statusesState = await .display(statuses: datasource.get(), nextPageState: .loadingNextPage)
var newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
maxId: lastId,
2023-01-01 13:28:15 +00:00
minId: nil,
offset: datasource.get().count))
ReblogCache.shared.removeDuplicateReblogs(&newStatuses)
await datasource.append(contentOf: newStatuses)
StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client)
statusesState = await .display(statuses: datasource.get(),
nextPageState: newStatuses.count < 20 ? .none : .hasNextPage)
2022-11-25 11:00:01 +00:00
} catch {
2022-12-19 06:17:01 +00:00
statusesState = .error(error: error)
2022-11-25 11:00:01 +00:00
}
}
2023-02-01 11:49:59 +00:00
2023-01-31 07:04:35 +00:00
func statusDidAppear(status: Status) {
pendingStatusesObserver.removeStatus(status: status)
visibileStatusesIds.insert(status.id)
2023-02-04 16:17:38 +00:00
2023-02-04 13:42:10 +00:00
if let client, timeline == .home {
Task {
2023-02-04 16:17:38 +00:00
await cache.setLatestSeenStatuses(ids: visibileStatusesIds.map { $0 }, for: client)
2023-02-04 13:42:10 +00:00
}
}
}
2023-02-01 11:49:59 +00:00
func statusDidDisappear(status: Status) {
visibileStatusesIds.remove(status.id)
2023-01-05 06:07:28 +00:00
}
2022-11-25 11:00:01 +00:00
}