From 99a58e2c3307374215ab3d97a34f475609b25df3 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 10 Mar 2024 14:49:57 -0400 Subject: [PATCH] Extract TimelineLikeDataSource into separate protocol --- ...otificationsCollectionViewController.swift | 63 ++++++++++--------- .../ProfileStatusesViewController.swift | 19 +++--- .../Timeline/TimelineViewController.swift | 27 ++++---- Tusker/TimelineLikeController.swift | 34 +++++----- 4 files changed, 79 insertions(+), 64 deletions(-) diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index 6290522020..93cbabd0be 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -40,7 +40,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle super.init(nibName: nil, bundle: nil) - self.controller = TimelineLikeController(delegate: self, ownerType: String(describing: self)) + self.controller = TimelineLikeController(delegate: self, dataSource: self, ownerType: String(describing: self)) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications")) } @@ -387,8 +387,16 @@ extension NotificationsCollectionViewController { } } -// MARK: TimelineLikeControllerDelegate +// MARK: TimelineLikeCollectionViewController extension NotificationsCollectionViewController { + enum Error: TimelineLikeCollectionViewError { + case noNewer + case noOlder + case allCaughtUp + } +} + +extension NotificationsCollectionViewController: TimelineLikeControllerDataSource { typealias TimelineItem = NotificationGroup private static let pageSize = 40 @@ -478,27 +486,6 @@ extension NotificationsCollectionViewController { return NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes) } - func handlePrependItems(_ timelineItems: [NotificationGroup]) async { - let topItem = dataSource.snapshot().itemIdentifiers(inSection: .notifications).first - - // we always replace all, because new items are merged with existing ones - await handleReplaceAllItems(timelineItems) - - // preserve the scroll position - // todo: this won't work for cmd+r when not at top - if let topID = topItem?.group?.notifications.first?.id { - // the exact item may have changed, due to merging - let newTopGroup = timelineItems.first { - $0.notifications.contains { - $0.id == topID - } - }! - if let newTopIndexPath = dataSource.indexPath(for: .group(newTopGroup, nil, nil)) { - collectionView.scrollToItem(at: newTopIndexPath, at: .top, animated: false) - } - } - } - func loadOlder() async throws -> [NotificationGroup] { guard let older else { throw Error.noOlder @@ -523,16 +510,34 @@ extension NotificationsCollectionViewController { let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group) return NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes) } +} + +// MARK: TimelineLikeControllerDelegate +extension NotificationsCollectionViewController { + func handlePrependItems(_ timelineItems: [NotificationGroup]) async { + let topItem = dataSource.snapshot().itemIdentifiers(inSection: .notifications).first + + // we always replace all, because new items are merged with existing ones + await handleReplaceAllItems(timelineItems) + + // preserve the scroll position + // todo: this won't work for cmd+r when not at top + if let topID = topItem?.group?.notifications.first?.id { + // the exact item may have changed, due to merging + let newTopGroup = timelineItems.first { + $0.notifications.contains { + $0.id == topID + } + }! + if let newTopIndexPath = dataSource.indexPath(for: .group(newTopGroup, nil, nil)) { + collectionView.scrollToItem(at: newTopIndexPath, at: .top, animated: false) + } + } + } func handleAppendItems(_ timelineItems: [NotificationGroup]) async { await handleReplaceAllItems(timelineItems) } - - enum Error: TimelineLikeCollectionViewError { - case noNewer - case noOlder - case allCaughtUp - } } extension NotificationsCollectionViewController: UICollectionViewDelegate { diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index d687a64914..4eed100def 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -45,7 +45,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie super.init(nibName: nil, bundle: nil) - self.controller = TimelineLikeController(delegate: self, ownerType: String(describing: self)) + self.controller = TimelineLikeController(delegate: self, dataSource: self, ownerType: String(describing: self)) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Profile")) } @@ -501,7 +501,16 @@ extension ProfileStatusesViewController { } } -extension ProfileStatusesViewController: TimelineLikeControllerDelegate { +// MARK: TimelineLikeCollectionViewController +extension ProfileStatusesViewController { + enum Error: TimelineLikeCollectionViewError { + case noNewer + case noOlder + case allCaughtUp + } +} + +extension ProfileStatusesViewController: TimelineLikeControllerDataSource { typealias TimelineItem = String // status ID private func request(for range: RequestRange = .default) -> Request<[Status]> { @@ -572,12 +581,6 @@ extension ProfileStatusesViewController: TimelineLikeControllerDelegate { } } } - - enum Error: TimelineLikeCollectionViewError { - case noNewer - case noOlder - case allCaughtUp - } } extension ProfileStatusesViewController: UICollectionViewDelegate { diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index d0c26d1ff1..2438497788 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -66,7 +66,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro super.init(nibName: nil, bundle: nil) - self.controller = TimelineLikeController(delegate: self, ownerType: String(describing: self)) + self.controller = TimelineLikeController(delegate: self, dataSource: self, ownerType: String(describing: self)) self.navigationItem.title = timeline.title addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline")) @@ -1092,8 +1092,18 @@ extension TimelineViewController { } } -// MARK: TimelineLikeControllerDelegate +// MARK: TimelineLikeCollectionViewController extension TimelineViewController { + enum Error: TimelineLikeCollectionViewError { + case noClient + case noNewer + case noOlder + case allCaughtUp + case noGap + } +} + +extension TimelineViewController: TimelineLikeControllerDataSource { typealias TimelineItem = String // status ID // the maximum mastodon will provide in a single request @@ -1209,7 +1219,10 @@ extension TimelineViewController { } self.showToast(configuration: config, animated: true) } - +} + +// MARK: TimelineLikeControllerDelegate +extension TimelineViewController { func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async { var snapshot = dataSource.snapshot() let addedItems: Bool @@ -1297,14 +1310,6 @@ extension TimelineViewController { showToast(configuration: config, animated: true) } } - - enum Error: TimelineLikeCollectionViewError { - case noClient - case noNewer - case noOlder - case allCaughtUp - case noGap - } } extension TimelineViewController: UICollectionViewDelegate { diff --git a/Tusker/TimelineLikeController.swift b/Tusker/TimelineLikeController.swift index cfc6428938..f74c4eba3c 100644 --- a/Tusker/TimelineLikeController.swift +++ b/Tusker/TimelineLikeController.swift @@ -10,20 +10,20 @@ import Foundation import OSLog import Combine +protocol TimelineLikeControllerDataSource: AnyObject { + associatedtype TimelineItem: Sendable + + func loadInitial() async throws -> [TimelineItem] + func loadNewer() async throws -> [TimelineItem] + func canLoadOlder() async -> Bool + func loadOlder() async throws -> [TimelineItem] + func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] +} + @MainActor protocol TimelineLikeControllerDelegate: AnyObject { associatedtype TimelineItem: Sendable - func loadInitial() async throws -> [TimelineItem] - - func loadNewer() async throws -> [TimelineItem] - - func canLoadOlder() async -> Bool - - func loadOlder() async throws -> [TimelineItem] - - func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] - func handleAddLoadingIndicator() async func handleRemoveLoadingIndicator() async func handleLoadAllError(_ error: Swift.Error) async @@ -42,6 +42,7 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: class TimelineLikeController { private unowned var delegate: any TimelineLikeControllerDelegate + private unowned var dataSource: any TimelineLikeControllerDataSource private let ownerType: String @AsyncObservable private(set) var state = State.notLoadedInitial { @@ -54,8 +55,9 @@ class TimelineLikeController { } } - init(delegate: any TimelineLikeControllerDelegate, ownerType: String) { + init(delegate: any TimelineLikeControllerDelegate, dataSource: any TimelineLikeControllerDataSource, ownerType: String) { self.delegate = delegate + self.dataSource = dataSource self.ownerType = ownerType } @@ -81,7 +83,7 @@ class TimelineLikeController { await emit(event: .addLoadingIndicator) state = .loadingInitial(token, hasAddedLoadingIndicator: true) do { - let items = try await delegate.loadInitial() + let items = try await dataSource.loadInitial() guard case .loadingInitial(token, _) = state else { return } @@ -118,7 +120,7 @@ class TimelineLikeController { let token = LoadAttemptToken() state = .loadingNewer(token) do { - let items = try await delegate.loadNewer() + let items = try await dataSource.loadNewer() guard case .loadingNewer(token) = state else { return } @@ -137,7 +139,7 @@ class TimelineLikeController { return } let token = LoadAttemptToken() - guard await delegate.canLoadOlder(), + guard await dataSource.canLoadOlder(), // Make sure we're still in the idle state before continuing on, since that may have chnaged while waiting for user input. // If the load more cell appears, then the users scrolls up and back down, the VC may kick off a second loadOlder task // but we only want one to proceed. The actor prevents a data race, and this prevents multiple simultaneousl loadOlder tasks from running. @@ -148,7 +150,7 @@ class TimelineLikeController { await emit(event: .addLoadingIndicator) state = .loadingOlder(token, hasAddedLoadingIndicator: true) do { - let items = try await delegate.loadOlder() + let items = try await dataSource.loadOlder() guard case .loadingOlder(token, _) = state else { return } @@ -171,7 +173,7 @@ class TimelineLikeController { let token = LoadAttemptToken() state = .loadingGap(token, direction) do { - let items = try await delegate.loadGap(in: direction) + let items = try await dataSource.loadGap(in: direction) guard case .loadingGap(token, direction) = state else { return }