Extract TimelineLikeDataSource into separate protocol

This commit is contained in:
Shadowfacts 2024-03-10 14:49:57 -04:00
parent c740fb1c1f
commit 99a58e2c33
4 changed files with 79 additions and 64 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -10,20 +10,20 @@ import Foundation
import OSLog
import Combine
protocol TimelineLikeControllerDataSource<TimelineItem>: 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<TimelineItem>: 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<Item: Sendable> {
private unowned var delegate: any TimelineLikeControllerDelegate<Item>
private unowned var dataSource: any TimelineLikeControllerDataSource<Item>
private let ownerType: String
@AsyncObservable private(set) var state = State.notLoadedInitial {
@ -54,8 +55,9 @@ class TimelineLikeController<Item: Sendable> {
}
}
init(delegate: any TimelineLikeControllerDelegate<Item>, ownerType: String) {
init(delegate: any TimelineLikeControllerDelegate<Item>, dataSource: any TimelineLikeControllerDataSource<Item>, ownerType: String) {
self.delegate = delegate
self.dataSource = dataSource
self.ownerType = ownerType
}
@ -81,7 +83,7 @@ class TimelineLikeController<Item: Sendable> {
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<Item: Sendable> {
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<Item: Sendable> {
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<Item: Sendable> {
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<Item: Sendable> {
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
}