From 6c9d9161dcbd382cb70e3eb17040c8e91c4476fd Mon Sep 17 00:00:00 2001
From: Thomas Ricouard <ricouard77@gmail.com>
Date: Mon, 5 Aug 2024 13:59:38 +0200
Subject: [PATCH] Extract function from TimelineViewModel

---
 .../Timeline/View/TimelineViewModel.swift     | 151 ++++++++----------
 .../actors/TimelineStatusFetcher.swift        |  60 +++++++
 2 files changed, 131 insertions(+), 80 deletions(-)
 create mode 100644 Packages/Timeline/Sources/Timeline/actors/TimelineStatusFetcher.swift

diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift
index dbe18632..b2f94a11 100644
--- a/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift
+++ b/Packages/Timeline/Sources/Timeline/View/TimelineViewModel.swift
@@ -47,6 +47,7 @@ import SwiftUI
 
   // Internal source of truth for a timeline.
   private(set) var datasource = TimelineDatasource()
+  private let statusFetcher: TimelineStatusFetching
   private let cache = TimelineCache()
   private var isCacheEnabled: Bool {
     canFilterTimeline && timeline.supportNewestPagination && client?.isAuth == true
@@ -93,7 +94,8 @@ import SwiftUI
   var scrollToIndexAnimated: Bool = false
   var marker: Marker.Content?
 
-  init() {
+  init(statusFetcher: TimelineStatusFetching = TimelineStatusFetcher()) {
+    self.statusFetcher = statusFetcher
     pendingStatusesObserver.scrollToIndex = { [weak self] index in
       self?.scrollToIndexAnimated = true
       self?.scrollToIndex = index
@@ -123,41 +125,6 @@ import SwiftUI
       timeline = oldValue
     }
   }
-
-  func handleEvent(event: any StreamEvent) async {
-    if let event = event as? StreamEventUpdate,
-       let client,
-       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)
-      await cache()
-      StatusDataControllerProvider.shared.updateDataControllers(for: [event.status], client: client)
-      let statuses = await datasource.getFiltered()
-      withAnimation {
-        statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
-      }
-    } else if let event = event as? StreamEventDelete {
-      await datasource.remove(event.status)
-      await cache()
-      let statuses = await datasource.getFiltered()
-      withAnimation {
-        statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
-      }
-    } else if let event = event as? StreamEventStatusUpdate, let client {
-      if let originalIndex = await datasource.indexOf(statusId: event.status.id) {
-        StatusDataControllerProvider.shared.updateDataControllers(for: [event.status], client: client)
-        await datasource.replace(event.status, at: originalIndex)
-        await cache()
-        let statuses = await datasource.getFiltered()
-        statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
-      }
-    }
-  }
 }
 
 // MARK: - Cache
@@ -216,10 +183,8 @@ extension TimelineViewModel: StatusesFetcher {
   func fetchStatuses(from: Marker.Content) async throws {
     guard let client else { return }
     statusesState = .loading
-    var statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
-                                                                              maxId: from.lastReadId,
-                                                                              minId: nil,
-                                                                              offset: 0))
+    var statuses: [Status] = try await statusFetcher.fetchFirstPage(client: client,
+                                                                    timeline: timeline)
 
     StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client)
 
@@ -286,10 +251,8 @@ extension TimelineViewModel: StatusesFetcher {
       // And then we fetch statuses again toget newest statuses from there.
       await fetchNewestStatuses(pullToRefresh: false)
     } else {
-      var statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
-                                                                                maxId: nil,
-                                                                                minId: nil,
-                                                                                offset: 0))
+      var statuses: [Status] = try await statusFetcher.fetchFirstPage(client: client,
+                                                                      timeline: timeline)
 
       StatusDataControllerProvider.shared.updateDataControllers(for: statuses, client: client)
 
@@ -308,7 +271,8 @@ extension TimelineViewModel: StatusesFetcher {
     canStreamEvents = false
     let initialTimeline = timeline
 
-    let newStatuses = await fetchAndDedupNewStatuses(latestStatus: latestStatus, client: client)
+    let newStatuses = try await fetchAndDedupNewStatuses(latestStatus: latestStatus,
+                                                         client: client)
 
     guard !newStatuses.isEmpty,
           isTimelineVisible,
@@ -327,8 +291,11 @@ extension TimelineViewModel: StatusesFetcher {
     }
   }
 
-  private func fetchAndDedupNewStatuses(latestStatus: String, client: Client) async -> [Status] {
-    var newStatuses = await fetchNewPages(minId: latestStatus, maxPages: 5)
+  private func fetchAndDedupNewStatuses(latestStatus: String, client: Client) async throws -> [Status] {
+    var newStatuses = try await statusFetcher.fetchNewPages(client: client,
+                                                  timeline: timeline,
+                                                  minId: latestStatus,
+                                                  maxPages: 5)
     let ids = await datasource.get().map(\.id)
     newStatuses = newStatuses.filter { status in
       !ids.contains(where: { $0 == status.id })
@@ -378,34 +345,6 @@ extension TimelineViewModel: StatusesFetcher {
     }
   }
 
-  private func fetchNewPages(minId: String, maxPages: Int) async -> [Status] {
-    guard let client else { return [] }
-    var allStatuses: [Status] = []
-    var latestMinId = minId
-    do {
-      for _ in 1 ... maxPages {
-        if Task.isCancelled { break }
-
-        let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(
-          sinceId: nil,
-          maxId: nil,
-          minId: latestMinId,
-          offset: nil
-        ))
-
-        if newStatuses.isEmpty { break }
-
-        StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client)
-        allStatuses.insert(contentsOf: newStatuses, at: 0)
-        latestMinId = newStatuses.first?.id ?? latestMinId
-      }
-    } catch {
-      return allStatuses
-    }
-
-    return allStatuses
-  }
-
   enum NextPageError: Error {
     case internalError
   }
@@ -413,10 +352,10 @@ extension TimelineViewModel: StatusesFetcher {
   func fetchNextPage() async throws {
     let statuses = await datasource.get()
     guard let client, let lastId = statuses.last?.id else { throw NextPageError.internalError }
-    let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil,
-                                                                                 maxId: lastId,
-                                                                                 minId: nil,
-                                                                                 offset: statuses.count))
+    let newStatuses: [Status] = try await statusFetcher.fetchNextPage(client: client,
+                                                                      timeline: timeline,
+                                                                      lastId: lastId,
+                                                                      offset: statuses.count)
 
     await datasource.append(contentOf: newStatuses)
     StatusDataControllerProvider.shared.updateDataControllers(for: newStatuses, client: client)
@@ -441,7 +380,7 @@ extension TimelineViewModel: StatusesFetcher {
   }
 }
 
-// MARK: - MARKER
+// MARK: - Marker handling
 
 extension TimelineViewModel {
   func fetchMarker() async -> Marker.Content? {
@@ -466,3 +405,55 @@ extension TimelineViewModel {
     }
   }
 }
+
+// MARK: - Event handling
+
+extension TimelineViewModel {
+  func handleEvent(event: any StreamEvent) async {
+    guard let client = client, canStreamEvents, isTimelineVisible else { return }
+
+    switch event {
+    case let updateEvent as StreamEventUpdate:
+      await handleUpdateEvent(updateEvent, client: client)
+    case let deleteEvent as StreamEventDelete:
+      await handleDeleteEvent(deleteEvent)
+    case let statusUpdateEvent as StreamEventStatusUpdate:
+      await handleStatusUpdateEvent(statusUpdateEvent, client: client)
+    default:
+      break
+    }
+  }
+
+  private func handleUpdateEvent(_ event: StreamEventUpdate, client: Client) async {
+    guard timeline == .home,
+          await !datasource.contains(statusId: event.status.id) else { return }
+
+    pendingStatusesObserver.pendingStatuses.insert(event.status.id, at: 0)
+    await datasource.insert(event.status, at: 0)
+    await cache()
+    StatusDataControllerProvider.shared.updateDataControllers(for: [event.status], client: client)
+    await updateStatusesState()
+  }
+
+  private func handleDeleteEvent(_ event: StreamEventDelete) async {
+    await datasource.remove(event.status)
+    await cache()
+    await updateStatusesState()
+  }
+
+  private func handleStatusUpdateEvent(_ event: StreamEventStatusUpdate, client: Client) async {
+    guard let originalIndex = await datasource.indexOf(statusId: event.status.id) else { return }
+
+    StatusDataControllerProvider.shared.updateDataControllers(for: [event.status], client: client)
+    await datasource.replace(event.status, at: originalIndex)
+    await cache()
+    await updateStatusesState()
+  }
+
+  private func updateStatusesState() async {
+    let statuses = await datasource.getFiltered()
+    withAnimation {
+      statusesState = .display(statuses: statuses, nextPageState: .hasNextPage)
+    }
+  }
+}
diff --git a/Packages/Timeline/Sources/Timeline/actors/TimelineStatusFetcher.swift b/Packages/Timeline/Sources/Timeline/actors/TimelineStatusFetcher.swift
new file mode 100644
index 00000000..91a2cac2
--- /dev/null
+++ b/Packages/Timeline/Sources/Timeline/actors/TimelineStatusFetcher.swift
@@ -0,0 +1,60 @@
+import Foundation
+import Models
+import Network
+
+protocol TimelineStatusFetching: Sendable {
+  func fetchFirstPage(client: Client?,
+                      timeline: TimelineFilter) async throws -> [Status]
+  func fetchNewPages(client: Client?,
+                     timeline: TimelineFilter,
+                     minId: String,
+                     maxPages: Int) async throws -> [Status]
+  func fetchNextPage(client: Client?,
+                     timeline: TimelineFilter,
+                     lastId: String,
+                     offset: Int) async throws -> [Status]
+}
+
+enum StatusFetcherError: Error {
+  case noClientAvailable
+}
+
+struct TimelineStatusFetcher: TimelineStatusFetching {
+  func fetchFirstPage(client: Client?, timeline: TimelineFilter) async throws -> [Status] {
+    guard let client = client else { throw StatusFetcherError.noClientAvailable }
+    return try await client.get(endpoint: timeline.endpoint(sinceId: nil,
+                                                            maxId: nil,
+                                                            minId: nil,
+                                                            offset: 0))
+  }
+  
+  func fetchNewPages(client: Client?, timeline: TimelineFilter, minId: String, maxPages: Int) async throws -> [Status] {
+    guard let client = client else { throw StatusFetcherError.noClientAvailable }
+    var allStatuses: [Status] = []
+    var latestMinId = minId
+    for _ in 1...maxPages {
+      if Task.isCancelled { break }
+      
+      let newStatuses: [Status] = try await client.get(endpoint: timeline.endpoint(
+        sinceId: nil,
+        maxId: nil,
+        minId: latestMinId,
+        offset: nil
+      ))
+      
+      if newStatuses.isEmpty { break }
+      
+      allStatuses.insert(contentsOf: newStatuses, at: 0)
+      latestMinId = newStatuses.first?.id ?? latestMinId
+    }
+    return allStatuses
+  }
+  
+  func fetchNextPage(client: Client?, timeline: TimelineFilter, lastId: String, offset: Int) async throws -> [Status] {
+    guard let client = client else { throw StatusFetcherError.noClientAvailable }
+    return try await client.get(endpoint: timeline.endpoint(sinceId: nil,
+                                                            maxId: lastId,
+                                                            minId: nil,
+                                                            offset: offset))
+  }
+}