Extract TimelineLikeDataSource into separate protocol
This commit is contained in:
parent
c740fb1c1f
commit
99a58e2c33
|
@ -40,7 +40,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
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"))
|
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications"))
|
||||||
}
|
}
|
||||||
|
@ -387,8 +387,16 @@ extension NotificationsCollectionViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: TimelineLikeControllerDelegate
|
// MARK: TimelineLikeCollectionViewController
|
||||||
extension NotificationsCollectionViewController {
|
extension NotificationsCollectionViewController {
|
||||||
|
enum Error: TimelineLikeCollectionViewError {
|
||||||
|
case noNewer
|
||||||
|
case noOlder
|
||||||
|
case allCaughtUp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NotificationsCollectionViewController: TimelineLikeControllerDataSource {
|
||||||
typealias TimelineItem = NotificationGroup
|
typealias TimelineItem = NotificationGroup
|
||||||
|
|
||||||
private static let pageSize = 40
|
private static let pageSize = 40
|
||||||
|
@ -478,27 +486,6 @@ extension NotificationsCollectionViewController {
|
||||||
return NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes)
|
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] {
|
func loadOlder() async throws -> [NotificationGroup] {
|
||||||
guard let older else {
|
guard let older else {
|
||||||
throw Error.noOlder
|
throw Error.noOlder
|
||||||
|
@ -523,16 +510,34 @@ extension NotificationsCollectionViewController {
|
||||||
let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group)
|
let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group)
|
||||||
return NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes)
|
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 {
|
func handleAppendItems(_ timelineItems: [NotificationGroup]) async {
|
||||||
await handleReplaceAllItems(timelineItems)
|
await handleReplaceAllItems(timelineItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Error: TimelineLikeCollectionViewError {
|
|
||||||
case noNewer
|
|
||||||
case noOlder
|
|
||||||
case allCaughtUp
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension NotificationsCollectionViewController: UICollectionViewDelegate {
|
extension NotificationsCollectionViewController: UICollectionViewDelegate {
|
||||||
|
|
|
@ -45,7 +45,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
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"))
|
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
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
private func request(for range: RequestRange = .default) -> Request<[Status]> {
|
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 {
|
extension ProfileStatusesViewController: UICollectionViewDelegate {
|
||||||
|
|
|
@ -66,7 +66,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
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
|
self.navigationItem.title = timeline.title
|
||||||
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
|
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Timeline"))
|
||||||
|
@ -1092,8 +1092,18 @@ extension TimelineViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: TimelineLikeControllerDelegate
|
// MARK: TimelineLikeCollectionViewController
|
||||||
extension TimelineViewController {
|
extension TimelineViewController {
|
||||||
|
enum Error: TimelineLikeCollectionViewError {
|
||||||
|
case noClient
|
||||||
|
case noNewer
|
||||||
|
case noOlder
|
||||||
|
case allCaughtUp
|
||||||
|
case noGap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension TimelineViewController: TimelineLikeControllerDataSource {
|
||||||
typealias TimelineItem = String // status ID
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
// the maximum mastodon will provide in a single request
|
// the maximum mastodon will provide in a single request
|
||||||
|
@ -1209,7 +1219,10 @@ extension TimelineViewController {
|
||||||
}
|
}
|
||||||
self.showToast(configuration: config, animated: true)
|
self.showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: TimelineLikeControllerDelegate
|
||||||
|
extension TimelineViewController {
|
||||||
func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async {
|
func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
let addedItems: Bool
|
let addedItems: Bool
|
||||||
|
@ -1297,14 +1310,6 @@ extension TimelineViewController {
|
||||||
showToast(configuration: config, animated: true)
|
showToast(configuration: config, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Error: TimelineLikeCollectionViewError {
|
|
||||||
case noClient
|
|
||||||
case noNewer
|
|
||||||
case noOlder
|
|
||||||
case allCaughtUp
|
|
||||||
case noGap
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TimelineViewController: UICollectionViewDelegate {
|
extension TimelineViewController: UICollectionViewDelegate {
|
||||||
|
|
|
@ -10,20 +10,20 @@ import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import Combine
|
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
|
@MainActor
|
||||||
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
||||||
associatedtype TimelineItem: Sendable
|
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 handleAddLoadingIndicator() async
|
||||||
func handleRemoveLoadingIndicator() async
|
func handleRemoveLoadingIndicator() async
|
||||||
func handleLoadAllError(_ error: Swift.Error) async
|
func handleLoadAllError(_ error: Swift.Error) async
|
||||||
|
@ -42,6 +42,7 @@ private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category:
|
||||||
class TimelineLikeController<Item: Sendable> {
|
class TimelineLikeController<Item: Sendable> {
|
||||||
|
|
||||||
private unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
private unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
||||||
|
private unowned var dataSource: any TimelineLikeControllerDataSource<Item>
|
||||||
private let ownerType: String
|
private let ownerType: String
|
||||||
|
|
||||||
@AsyncObservable private(set) var state = State.notLoadedInitial {
|
@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.delegate = delegate
|
||||||
|
self.dataSource = dataSource
|
||||||
self.ownerType = ownerType
|
self.ownerType = ownerType
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,7 +83,7 @@ class TimelineLikeController<Item: Sendable> {
|
||||||
await emit(event: .addLoadingIndicator)
|
await emit(event: .addLoadingIndicator)
|
||||||
state = .loadingInitial(token, hasAddedLoadingIndicator: true)
|
state = .loadingInitial(token, hasAddedLoadingIndicator: true)
|
||||||
do {
|
do {
|
||||||
let items = try await delegate.loadInitial()
|
let items = try await dataSource.loadInitial()
|
||||||
guard case .loadingInitial(token, _) = state else {
|
guard case .loadingInitial(token, _) = state else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -118,7 +120,7 @@ class TimelineLikeController<Item: Sendable> {
|
||||||
let token = LoadAttemptToken()
|
let token = LoadAttemptToken()
|
||||||
state = .loadingNewer(token)
|
state = .loadingNewer(token)
|
||||||
do {
|
do {
|
||||||
let items = try await delegate.loadNewer()
|
let items = try await dataSource.loadNewer()
|
||||||
guard case .loadingNewer(token) = state else {
|
guard case .loadingNewer(token) = state else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -137,7 +139,7 @@ class TimelineLikeController<Item: Sendable> {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let token = LoadAttemptToken()
|
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.
|
// 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
|
// 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.
|
// 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)
|
await emit(event: .addLoadingIndicator)
|
||||||
state = .loadingOlder(token, hasAddedLoadingIndicator: true)
|
state = .loadingOlder(token, hasAddedLoadingIndicator: true)
|
||||||
do {
|
do {
|
||||||
let items = try await delegate.loadOlder()
|
let items = try await dataSource.loadOlder()
|
||||||
guard case .loadingOlder(token, _) = state else {
|
guard case .loadingOlder(token, _) = state else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -171,7 +173,7 @@ class TimelineLikeController<Item: Sendable> {
|
||||||
let token = LoadAttemptToken()
|
let token = LoadAttemptToken()
|
||||||
state = .loadingGap(token, direction)
|
state = .loadingGap(token, direction)
|
||||||
do {
|
do {
|
||||||
let items = try await delegate.loadGap(in: direction)
|
let items = try await dataSource.loadGap(in: direction)
|
||||||
guard case .loadingGap(token, direction) = state else {
|
guard case .loadingGap(token, direction) = state else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue