Compare commits
No commits in common. "c8319d8af230b853fb7891026e2784fd2d3df9d6" and "a5ad8e43b16876b565c63e08689b8259d9adb1af" have entirely different histories.
c8319d8af2
...
a5ad8e43b1
|
@ -11,9 +11,7 @@ import Foundation
|
||||||
public enum RequestRange {
|
public enum RequestRange {
|
||||||
case `default`
|
case `default`
|
||||||
case count(Int)
|
case count(Int)
|
||||||
/// Chronologically immediately before the given ID
|
|
||||||
case before(id: String, count: Int?)
|
case before(id: String, count: Int?)
|
||||||
/// Chronologically immediately after the given ID
|
|
||||||
case after(id: String, count: Int?)
|
case after(id: String, count: Int?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -270,7 +270,6 @@
|
||||||
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
|
D6C99FCB24FADC91005C74D3 /* MainComposeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */; };
|
||||||
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
|
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */; };
|
||||||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
|
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */; };
|
||||||
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */; };
|
|
||||||
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
|
D6D3F4C424FDB6B700EC4A6A /* View+ConditionalModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */; };
|
||||||
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
D6D3FDE224F46A8D00FF50A5 /* ComposeUIState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */; };
|
||||||
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
|
D6D4CC94250DB86A00FCCF8D /* ComposeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */; };
|
||||||
|
@ -633,7 +632,6 @@
|
||||||
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
|
D6C99FCA24FADC91005C74D3 /* MainComposeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainComposeTextView.swift; sourceTree = "<group>"; };
|
||||||
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
|
D6CA6A91249FAD8900AD45C1 /* AudioSessionHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionHelper.swift; sourceTree = "<group>"; };
|
||||||
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
|
D6CA6A93249FADE700AD45C1 /* GalleryPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryPlayerViewController.swift; sourceTree = "<group>"; };
|
||||||
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineGapCollectionViewCell.swift; sourceTree = "<group>"; };
|
|
||||||
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
|
D6D3F4C324FDB6B700EC4A6A /* View+ConditionalModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ConditionalModifier.swift"; sourceTree = "<group>"; };
|
||||||
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
|
D6D3FDE124F46A8D00FF50A5 /* ComposeUIState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeUIState.swift; sourceTree = "<group>"; };
|
||||||
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
|
D6D4CC93250DB86A00FCCF8D /* ComposeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeTests.swift; sourceTree = "<group>"; };
|
||||||
|
@ -926,7 +924,6 @@
|
||||||
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
|
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
|
||||||
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
|
D66C900A28DAB7FD00217BF2 /* TimelineViewController.swift */,
|
||||||
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
|
D6ADB6E728E8C878009924AB /* PublicTimelineDescriptionCollectionViewCell.swift */,
|
||||||
D6D12B2E2925D66500D528E1 /* TimelineGapCollectionViewCell.swift */,
|
|
||||||
);
|
);
|
||||||
path = Timeline;
|
path = Timeline;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -1835,7 +1832,6 @@
|
||||||
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
|
D6CA6A94249FADE700AD45C1 /* GalleryPlayerViewController.swift in Sources */,
|
||||||
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
D6A3BC802321B7E600FD64D5 /* FollowNotificationGroupTableViewCell.swift in Sources */,
|
||||||
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
|
D627944D23A9A03D00D38C68 /* ListTimelineViewController.swift in Sources */,
|
||||||
D6D12B2F2925D66500D528E1 /* TimelineGapCollectionViewCell.swift in Sources */,
|
|
||||||
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
D6945C3823AC739F005C403C /* InstanceTimelineViewController.swift in Sources */,
|
||||||
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
D625E4822588262A0074BB2B /* DraggableTableViewCell.swift in Sources */,
|
||||||
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
D68E525B24A3D77E0054355A /* TuskerRootViewController.swift in Sources */,
|
||||||
|
|
|
@ -99,11 +99,6 @@
|
||||||
value = "1"
|
value = "1"
|
||||||
isEnabled = "NO">
|
isEnabled = "NO">
|
||||||
</EnvironmentVariable>
|
</EnvironmentVariable>
|
||||||
<EnvironmentVariable
|
|
||||||
key = "CG_CONTEXT_SHOW_BACKTRACE"
|
|
||||||
value = ""
|
|
||||||
isEnabled = "NO">
|
|
||||||
</EnvironmentVariable>
|
|
||||||
<EnvironmentVariable
|
<EnvironmentVariable
|
||||||
key = "CG_NUMERICS_SHOW_BACKTRACE"
|
key = "CG_NUMERICS_SHOW_BACKTRACE"
|
||||||
value = ""
|
value = ""
|
||||||
|
|
|
@ -97,7 +97,7 @@ class TrendingStatusesViewController: UIViewController {
|
||||||
do {
|
do {
|
||||||
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
statuses = try await mastodonController.run(Client.getTrendingStatuses()).0
|
||||||
} catch {
|
} catch {
|
||||||
let snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
await dataSource.apply(snapshot)
|
await dataSource.apply(snapshot)
|
||||||
let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in
|
let config = ToastConfiguration(from: error, with: "Loading Trending Posts", in: self) { toast in
|
||||||
toast.dismissToast(animated: true)
|
toast.dismissToast(animated: true)
|
||||||
|
|
|
@ -25,8 +25,8 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
private var older: RequestRange?
|
private var older: RequestRange?
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
var collectionView: UICollectionView! {
|
var collectionView: UICollectionView {
|
||||||
view as? UICollectionView
|
view as! UICollectionView
|
||||||
}
|
}
|
||||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
private(set) var headerCell: ProfileHeaderCollectionViewCell?
|
||||||
|
@ -157,7 +157,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
if case .notLoadedInitial = controller.state {
|
if case .notLoadedInitial = await controller.state {
|
||||||
await load()
|
await load()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,164 +0,0 @@
|
||||||
//
|
|
||||||
// TimelineGapCollectionViewCell.swift
|
|
||||||
// Tusker
|
|
||||||
//
|
|
||||||
// Created by Shadowfacts on 11/16/22.
|
|
||||||
// Copyright © 2022 Shadowfacts. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
class TimelineGapCollectionViewCell: UICollectionViewCell {
|
|
||||||
|
|
||||||
private(set) var direction = TimelineGapDirection.above
|
|
||||||
|
|
||||||
private let indicator = UIActivityIndicatorView(style: .medium)
|
|
||||||
private let chevronView = AnimatingChevronView()
|
|
||||||
|
|
||||||
override var isHighlighted: Bool {
|
|
||||||
didSet {
|
|
||||||
backgroundColor = isHighlighted ? .systemFill : .systemGroupedBackground
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var showsIndicator: Bool = false {
|
|
||||||
didSet {
|
|
||||||
if showsIndicator {
|
|
||||||
indicator.isHidden = false
|
|
||||||
indicator.startAnimating()
|
|
||||||
} else {
|
|
||||||
indicator.isHidden = true
|
|
||||||
indicator.stopAnimating()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
|
||||||
super.init(frame: frame)
|
|
||||||
|
|
||||||
backgroundColor = .systemGroupedBackground
|
|
||||||
|
|
||||||
indicator.isHidden = true
|
|
||||||
indicator.color = .tintColor
|
|
||||||
|
|
||||||
let label = UILabel()
|
|
||||||
label.text = "Load more"
|
|
||||||
label.font = .preferredFont(forTextStyle: .headline)
|
|
||||||
label.textColor = .tintColor
|
|
||||||
|
|
||||||
chevronView.update(direction: .above)
|
|
||||||
|
|
||||||
let stack = UIStackView(arrangedSubviews: [
|
|
||||||
label,
|
|
||||||
chevronView,
|
|
||||||
])
|
|
||||||
stack.axis = .horizontal
|
|
||||||
stack.spacing = 8
|
|
||||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
contentView.addSubview(stack)
|
|
||||||
|
|
||||||
indicator.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
contentView.addSubview(indicator)
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
stack.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
|
|
||||||
stack.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
||||||
indicator.trailingAnchor.constraint(equalTo: stack.leadingAnchor, constant: -8),
|
|
||||||
indicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
|
||||||
contentView.heightAnchor.constraint(equalToConstant: 44),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func didMoveToSuperview() {
|
|
||||||
super.didMoveToSuperview()
|
|
||||||
update()
|
|
||||||
}
|
|
||||||
|
|
||||||
func update() {
|
|
||||||
guard let superview = superview as? UICollectionView else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let yInParent = frame.minY - superview.contentOffset.y
|
|
||||||
let centerInParent = yInParent + bounds.height / 2
|
|
||||||
let centerOfParent = superview.frame.height / 2
|
|
||||||
|
|
||||||
let newDirection: TimelineGapDirection
|
|
||||||
if centerInParent > centerOfParent {
|
|
||||||
newDirection = .above
|
|
||||||
} else {
|
|
||||||
newDirection = .below
|
|
||||||
}
|
|
||||||
|
|
||||||
if newDirection != direction {
|
|
||||||
direction = newDirection
|
|
||||||
chevronView.update(direction: newDirection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private class AnimatingChevronView: UIView {
|
|
||||||
|
|
||||||
override class var layerClass: AnyClass { CAShapeLayer.self }
|
|
||||||
var shapeLayer: CAShapeLayer { layer as! CAShapeLayer }
|
|
||||||
|
|
||||||
override var intrinsicContentSize: CGSize { CGSize(width: 20, height: 25) }
|
|
||||||
|
|
||||||
var animator: UIViewPropertyAnimator?
|
|
||||||
|
|
||||||
let upPath: CGPath = {
|
|
||||||
let path = UIBezierPath()
|
|
||||||
let width: CGFloat = 20
|
|
||||||
let height: CGFloat = 25
|
|
||||||
path.move(to: CGPoint(x: 0, y: height / 2))
|
|
||||||
path.addLine(to: CGPoint(x: width / 2, y: height / 5))
|
|
||||||
path.addLine(to: CGPoint(x: width, y: height / 2))
|
|
||||||
return path.cgPath
|
|
||||||
}()
|
|
||||||
let downPath: CGPath = {
|
|
||||||
let path = UIBezierPath()
|
|
||||||
let width: CGFloat = 20
|
|
||||||
let height: CGFloat = 25
|
|
||||||
path.move(to: CGPoint(x: 0, y: height / 2))
|
|
||||||
path.addLine(to: CGPoint(x: width / 2, y: 4 * height / 5))
|
|
||||||
path.addLine(to: CGPoint(x: width, y: height / 2))
|
|
||||||
return path.cgPath
|
|
||||||
}()
|
|
||||||
|
|
||||||
init() {
|
|
||||||
super.init(frame: .zero)
|
|
||||||
shapeLayer.fillColor = nil
|
|
||||||
shapeLayer.strokeColor = tintColor.cgColor
|
|
||||||
shapeLayer.lineCap = .round
|
|
||||||
shapeLayer.lineJoin = .round
|
|
||||||
shapeLayer.lineWidth = 3
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) has not been implemented")
|
|
||||||
}
|
|
||||||
|
|
||||||
override func tintColorDidChange() {
|
|
||||||
super.tintColorDidChange()
|
|
||||||
shapeLayer.strokeColor = tintColor.cgColor
|
|
||||||
}
|
|
||||||
|
|
||||||
func update(direction: TimelineGapDirection) {
|
|
||||||
if animator?.isRunning == true {
|
|
||||||
animator!.stopAnimation(true)
|
|
||||||
}
|
|
||||||
animator = UIViewPropertyAnimator(duration: 0.1, curve: .linear) {
|
|
||||||
if direction == .below {
|
|
||||||
self.shapeLayer.path = self.upPath
|
|
||||||
} else {
|
|
||||||
self.shapeLayer.path = self.downPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
animator!.startAnimation()
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -16,14 +16,16 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
|
|
||||||
private(set) var controller: TimelineLikeController<TimelineItem>!
|
private(set) var controller: TimelineLikeController<TimelineItem>!
|
||||||
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
let confirmLoadMore = PassthroughSubject<Void, Never>()
|
||||||
|
private var newer: RequestRange?
|
||||||
|
private var older: RequestRange?
|
||||||
// stored separately because i don't want to query the snapshot every time the user scrolls
|
// stored separately because i don't want to query the snapshot every time the user scrolls
|
||||||
private var isShowingTimelineDescription = false
|
private var isShowingTimelineDescription = false
|
||||||
|
|
||||||
private(set) var collectionView: UICollectionView!
|
var collectionView: UICollectionView {
|
||||||
|
view as! UICollectionView
|
||||||
|
}
|
||||||
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
private var contentOffsetObservation: NSKeyValueObservation?
|
|
||||||
|
|
||||||
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
init(for timeline: Timeline, mastodonController: MastodonController!) {
|
||||||
self.timeline = timeline
|
self.timeline = timeline
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
|
@ -40,9 +42,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
fatalError("init(coder:) has not been implemented")
|
fatalError("init(coder:) has not been implemented")
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func loadView() {
|
||||||
super.viewDidLoad()
|
|
||||||
|
|
||||||
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
var config = UICollectionLayoutListConfiguration(appearance: .plain)
|
||||||
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
|
config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
|
||||||
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
|
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions()
|
||||||
|
@ -58,24 +58,17 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
if item.hideSeparators {
|
if item.hideSeparators {
|
||||||
config.topSeparatorVisibility = .hidden
|
config.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
} else {
|
}
|
||||||
|
if case .status(_, _) = item {
|
||||||
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
|
||||||
}
|
}
|
||||||
return config
|
return config
|
||||||
}
|
}
|
||||||
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
let layout = UICollectionViewCompositionalLayout.list(using: config)
|
||||||
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
view = UICollectionView(frame: .zero, collectionViewLayout: layout)
|
||||||
collectionView.delegate = self
|
collectionView.delegate = self
|
||||||
collectionView.dragDelegate = self
|
collectionView.dragDelegate = self
|
||||||
collectionView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
view.addSubview(collectionView)
|
|
||||||
NSLayoutConstraint.activate([
|
|
||||||
collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
||||||
collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
|
||||||
collectionView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
||||||
collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
||||||
])
|
|
||||||
|
|
||||||
registerTimelineLikeCells()
|
registerTimelineLikeCells()
|
||||||
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
|
collectionView.register(PublicTimelineDescriptionCollectionViewCell.self, forCellWithReuseIdentifier: "publicTimelineDescription")
|
||||||
|
@ -86,15 +79,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
collectionView.refreshControl = UIRefreshControl()
|
collectionView.refreshControl = UIRefreshControl()
|
||||||
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
|
||||||
#endif
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
contentOffsetObservation = collectionView.observe(\.contentOffset) { [weak self] _, _ in
|
override func viewDidLoad() {
|
||||||
if let indexPath = self?.dataSource.indexPath(for: .gap),
|
super.viewDidLoad()
|
||||||
let cell = self?.collectionView.cellForItem(at: indexPath) as? TimelineGapCollectionViewCell {
|
|
||||||
cell.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// separate method because InstanceTimelineViewController needs to be able to customize it
|
// separate method because InstanceTimelineViewController needs to be able to customize it
|
||||||
|
@ -107,9 +95,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, StatusState)> { [unowned self] cell, indexPath, item in
|
||||||
self.configureStatusCell(cell, id: item.0, state: item.1)
|
self.configureStatusCell(cell, id: item.0, state: item.1)
|
||||||
}
|
}
|
||||||
let gapCell = UICollectionView.CellRegistration<TimelineGapCollectionViewCell, Void> { cell, indexPath, _ in
|
|
||||||
cell.showsIndicator = false
|
|
||||||
}
|
|
||||||
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
|
let timelineDescriptionCell = UICollectionView.CellRegistration<PublicTimelineDescriptionCollectionViewCell, Item> { [unowned self] cell, indexPath, item in
|
||||||
guard case .public(let local) = timeline else {
|
guard case .public(let local) = timeline else {
|
||||||
fatalError()
|
fatalError()
|
||||||
|
@ -124,8 +109,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
switch itemIdentifier {
|
switch itemIdentifier {
|
||||||
case .status(id: let id, state: let state):
|
case .status(id: let id, state: let state):
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state))
|
||||||
case .gap:
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: gapCell, for: indexPath, item: ())
|
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
return loadingIndicatorCell(for: indexPath)
|
return loadingIndicatorCell(for: indexPath)
|
||||||
case .confirmLoadMore:
|
case .confirmLoadMore:
|
||||||
|
@ -157,7 +140,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
|
|
||||||
Task {
|
Task {
|
||||||
if case .notLoadedInitial = controller.state {
|
if case .notLoadedInitial = await controller.state {
|
||||||
await controller.loadInitial()
|
await controller.loadInitial()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -176,6 +159,12 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func viewDidDisappear(_ animated: Bool) {
|
||||||
|
super.viewDidDisappear(animated)
|
||||||
|
|
||||||
|
// pruneOffscreenRows()
|
||||||
|
}
|
||||||
|
|
||||||
private func removeTimelineDescriptionCell() {
|
private func removeTimelineDescriptionCell() {
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.deleteSections([.header])
|
snapshot.deleteSections([.header])
|
||||||
|
@ -183,30 +172,35 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
isShowingTimelineDescription = false
|
isShowingTimelineDescription = false
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
|
// private func pruneOffscreenRows() {
|
||||||
guard let scene = notification.object as? UIScene,
|
// guard let lastVisibleIndexPath = collectionView.indexPathsForVisibleItems.last else {
|
||||||
// view.window is nil when the VC is not on screen
|
// return
|
||||||
view.window?.windowScene == scene else {
|
// }
|
||||||
return
|
// var snapshot = dataSource.snapshot()
|
||||||
}
|
// guard snapshot.indexOfSection(.statuses) != nil else {
|
||||||
Task {
|
// return
|
||||||
if case .idle = controller.state,
|
// }
|
||||||
let presentItems = try? await loadInitial() {
|
// let items = snapshot.itemIdentifiers(inSection: .statuses)
|
||||||
insertPresentItemsIfNecessary(presentItems)
|
// let pageSize = 20
|
||||||
}
|
// let numberOfPagesToPrune = (items.count - lastVisibleIndexPath.row - 1) / pageSize
|
||||||
}
|
// if numberOfPagesToPrune > 0 {
|
||||||
}
|
// let itemsToRemove = Array(items.suffix(numberOfPagesToPrune * pageSize))
|
||||||
|
// snapshot.deleteItems(itemsToRemove)
|
||||||
|
//
|
||||||
|
// dataSource.apply(snapshot, animatingDifferences: false)
|
||||||
|
//
|
||||||
|
// if case .status(id: let id, state: _) = snapshot.itemIdentifiers(inSection: .statuses).last {
|
||||||
|
// older = .before(id: id, count: nil)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
@objc func refresh() {
|
@objc func refresh() {
|
||||||
Task {
|
Task {
|
||||||
if case .notLoadedInitial = controller.state {
|
if case .notLoadedInitial = await controller.state {
|
||||||
await controller.loadInitial()
|
await controller.loadInitial()
|
||||||
} else {
|
} else {
|
||||||
// I'm not sure whether this should move into TimelineLikeController/TimelineLikeCollectionViewController
|
await controller.loadNewer()
|
||||||
let (_, presentItems) = await (controller.loadNewer(), try? loadInitial())
|
|
||||||
if let presentItems {
|
|
||||||
insertPresentItemsIfNecessary(presentItems)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
collectionView.refreshControl?.endRefreshing()
|
collectionView.refreshControl?.endRefreshing()
|
||||||
|
@ -214,68 +208,6 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func insertPresentItemsIfNecessary(_ presentItems: [String]) {
|
|
||||||
var snapshot = dataSource.snapshot()
|
|
||||||
let currentItems = snapshot.itemIdentifiers(inSection: .statuses)
|
|
||||||
if case .status(id: let firstID, state: _) = currentItems.first,
|
|
||||||
// if there's no overlap between presentItems and the existing items in the data source, insert the present items and prompt the user
|
|
||||||
!presentItems.contains(firstID) {
|
|
||||||
let applySnapshotBeforeScrolling: Bool
|
|
||||||
|
|
||||||
// remove any existing gap, if there is one
|
|
||||||
if let index = currentItems.lastIndex(of: .gap) {
|
|
||||||
snapshot.deleteItems(Array(currentItems[index...]))
|
|
||||||
|
|
||||||
let statusesSection = snapshot.indexOfSection(.statuses)!
|
|
||||||
if collectionView.indexPathsForVisibleItems.contains(IndexPath(row: index, section: statusesSection)) {
|
|
||||||
// the gap cell is on screen
|
|
||||||
applySnapshotBeforeScrolling = false
|
|
||||||
} else if let topMostVisibleCell = collectionView.indexPathsForVisibleItems.first(where: { $0.section == statusesSection }),
|
|
||||||
index < topMostVisibleCell.row {
|
|
||||||
// the gap cell is above the top, so applying the snapshot would remove the currently-viewed statuses
|
|
||||||
applySnapshotBeforeScrolling = false
|
|
||||||
} else {
|
|
||||||
// the gap cell is below the bottom of the screen
|
|
||||||
applySnapshotBeforeScrolling = true
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// there is no existing gap
|
|
||||||
applySnapshotBeforeScrolling = true
|
|
||||||
}
|
|
||||||
snapshot.insertItems([.gap], beforeItem: currentItems.first!)
|
|
||||||
snapshot.insertItems(presentItems.map { .status(id: $0, state: .unknown) }, beforeItem: .gap)
|
|
||||||
|
|
||||||
if applySnapshotBeforeScrolling {
|
|
||||||
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
|
|
||||||
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
|
|
||||||
snapshotView.layer.zPosition = 1000
|
|
||||||
snapshotView.frame = view.bounds
|
|
||||||
view.addSubview(snapshotView)
|
|
||||||
|
|
||||||
let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y
|
|
||||||
self.dataSource.apply(snapshot, animatingDifferences: false) {
|
|
||||||
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
|
|
||||||
snapshotView.removeFromSuperview()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var config = ToastConfiguration(title: "Jump to present")
|
|
||||||
config.edge = .top
|
|
||||||
config.systemImageName = "arrow.up"
|
|
||||||
config.dismissAutomaticallyAfter = 4
|
|
||||||
config.action = { [unowned self] toast in
|
|
||||||
toast.dismissToast(animated: true)
|
|
||||||
|
|
||||||
if !applySnapshotBeforeScrolling {
|
|
||||||
self.dataSource.apply(snapshot, animatingDifferences: false)
|
|
||||||
}
|
|
||||||
|
|
||||||
self.collectionView.scrollToTop()
|
|
||||||
}
|
|
||||||
self.showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TimelineViewController {
|
extension TimelineViewController {
|
||||||
|
@ -290,7 +222,6 @@ extension TimelineViewController {
|
||||||
typealias TimelineItem = String // status ID
|
typealias TimelineItem = String // status ID
|
||||||
|
|
||||||
case status(id: String, state: StatusState)
|
case status(id: String, state: StatusState)
|
||||||
case gap
|
|
||||||
case loadingIndicator
|
case loadingIndicator
|
||||||
case confirmLoadMore
|
case confirmLoadMore
|
||||||
case publicTimelineDescription
|
case publicTimelineDescription
|
||||||
|
@ -303,8 +234,6 @@ extension TimelineViewController {
|
||||||
switch (lhs, rhs) {
|
switch (lhs, rhs) {
|
||||||
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
||||||
return a == b
|
return a == b
|
||||||
case (.gap, .gap):
|
|
||||||
return true
|
|
||||||
case (.loadingIndicator, .loadingIndicator):
|
case (.loadingIndicator, .loadingIndicator):
|
||||||
return true
|
return true
|
||||||
case (.confirmLoadMore, .confirmLoadMore):
|
case (.confirmLoadMore, .confirmLoadMore):
|
||||||
|
@ -321,14 +250,12 @@ extension TimelineViewController {
|
||||||
case .status(id: let id, state: _):
|
case .status(id: let id, state: _):
|
||||||
hasher.combine(0)
|
hasher.combine(0)
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
case .gap:
|
|
||||||
hasher.combine(1)
|
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
hasher.combine(2)
|
hasher.combine(1)
|
||||||
case .confirmLoadMore:
|
case .confirmLoadMore:
|
||||||
hasher.combine(3)
|
hasher.combine(2)
|
||||||
case .publicTimelineDescription:
|
case .publicTimelineDescription:
|
||||||
hasher.combine(4)
|
hasher.combine(3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -343,7 +270,7 @@ extension TimelineViewController {
|
||||||
|
|
||||||
var isSelectable: Bool {
|
var isSelectable: Bool {
|
||||||
switch self {
|
switch self {
|
||||||
case .publicTimelineDescription, .gap, .status(id: _, state: _):
|
case .publicTimelineDescription, .status(id: _, state: _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -359,48 +286,50 @@ extension TimelineViewController {
|
||||||
func loadInitial() async throws -> [TimelineItem] {
|
func loadInitial() async throws -> [TimelineItem] {
|
||||||
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
try await Task.sleep(nanoseconds: 1 * NSEC_PER_SEC)
|
||||||
|
|
||||||
|
guard let mastodonController else {
|
||||||
|
throw Error.noClient
|
||||||
|
}
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline)
|
let request = Client.getStatuses(timeline: timeline)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
if !statuses.isEmpty {
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
newer = .after(id: statuses.first!.id, count: nil)
|
||||||
continuation.resume()
|
older = .before(id: statuses.last!.id, count: nil)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return statuses.map(\.id)
|
return await withCheckedContinuation { continuation in
|
||||||
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
|
continuation.resume(returning: statuses.map(\.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadNewer() async throws -> [TimelineItem] {
|
func loadNewer() async throws -> [TimelineItem] {
|
||||||
let statusesSection = dataSource.snapshot().indexOfSection(.statuses)!
|
guard let newer else {
|
||||||
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: 0, section: statusesSection)) else {
|
|
||||||
throw Error.noNewer
|
throw Error.noNewer
|
||||||
}
|
}
|
||||||
let newer = RequestRange.after(id: id, count: nil)
|
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline, range: newer)
|
let request = Client.getStatuses(timeline: timeline, range: newer)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
|
||||||
guard !statuses.isEmpty else {
|
guard !statuses.isEmpty else {
|
||||||
throw TimelineViewController.Error.allCaughtUp
|
throw Error.allCaughtUp
|
||||||
}
|
}
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
self.newer = .after(id: statuses.first!.id, count: nil)
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
continuation.resume()
|
continuation.resume(returning: statuses.map(\.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return statuses.map(\.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadOlder() async throws -> [TimelineItem] {
|
func loadOlder() async throws -> [TimelineItem] {
|
||||||
let snapshot = dataSource.snapshot()
|
guard let older else {
|
||||||
let statusesSection = snapshot.indexOfSection(.statuses)!
|
throw Error.noOlder
|
||||||
guard case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: snapshot.numberOfItems(inSection: .statuses) - 1, section: statusesSection)) else {
|
|
||||||
throw Error.noNewer
|
|
||||||
}
|
}
|
||||||
let older = RequestRange.before(id: id, count: nil)
|
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline, range: older)
|
let request = Client.getStatuses(timeline: timeline, range: older)
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
let (statuses, _) = try await mastodonController.run(request)
|
||||||
|
@ -409,170 +338,13 @@ extension TimelineViewController {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
self.older = .before(id: statuses.last!.id, count: nil)
|
||||||
|
|
||||||
|
return await withCheckedContinuation { continuation in
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
||||||
continuation.resume()
|
continuation.resume(returning: statuses.map(\.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return statuses.map(\.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
|
|
||||||
guard let gapIndexPath = dataSource.indexPath(for: .gap) else {
|
|
||||||
throw Error.noGap
|
|
||||||
}
|
|
||||||
let statusItemsCount = collectionView.numberOfItems(inSection: gapIndexPath.section)
|
|
||||||
let range: RequestRange
|
|
||||||
switch direction {
|
|
||||||
case .above:
|
|
||||||
guard gapIndexPath.row > 0,
|
|
||||||
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row - 1, section: gapIndexPath.section)) else {
|
|
||||||
// not really the right error but w/e
|
|
||||||
throw Error.noGap
|
|
||||||
}
|
|
||||||
range = .before(id: id, count: nil)
|
|
||||||
case .below:
|
|
||||||
guard gapIndexPath.row < statusItemsCount - 1,
|
|
||||||
case .status(id: let id, state: _) = dataSource.itemIdentifier(for: IndexPath(row: gapIndexPath.row + 1, section: gapIndexPath.section)) else {
|
|
||||||
throw Error.noGap
|
|
||||||
}
|
|
||||||
range = .after(id: id, count: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
let request = Client.getStatuses(timeline: timeline, range: range)
|
|
||||||
let (statuses, _) = try await mastodonController.run(request)
|
|
||||||
|
|
||||||
guard !statuses.isEmpty else {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: closing the gap (if necessary) happens in handleFillGap
|
|
||||||
|
|
||||||
await withCheckedContinuation { continuation in
|
|
||||||
mastodonController.persistentContainer.addAll(statuses: statuses) {
|
|
||||||
continuation.resume()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return statuses.map(\.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
|
|
||||||
// TODO: better title, involving direction?
|
|
||||||
let config = ToastConfiguration(from: error, with: "Error Loading", in: self) { [weak self] toast in
|
|
||||||
toast.dismissToast(animated: true)
|
|
||||||
Task {
|
|
||||||
await self?.controller.fillGap(in: direction)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleFillGap(_ timelineItems: [String], direction: TimelineGapDirection) async {
|
|
||||||
var snapshot = dataSource.snapshot()
|
|
||||||
let addedItems: Bool
|
|
||||||
|
|
||||||
let statusItems = snapshot.itemIdentifiers(inSection: .statuses)
|
|
||||||
let gapIndex = statusItems.firstIndex(of: .gap)!
|
|
||||||
|
|
||||||
switch direction {
|
|
||||||
case .above:
|
|
||||||
// dropFirst to remove .gap item
|
|
||||||
let afterGap = statusItems[gapIndex...].dropFirst().prefix(20)
|
|
||||||
precondition(!afterGap.contains(.gap))
|
|
||||||
|
|
||||||
// if there is any overlap, the first overlapping item will be the first item below the gap
|
|
||||||
var indexOfFirstTimelineItemExistingBelowGap: Int?
|
|
||||||
if case .status(id: let id, state: _) = afterGap.first {
|
|
||||||
indexOfFirstTimelineItemExistingBelowGap = timelineItems.firstIndex(of: id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// the end index of the range of timelineItems that don't yet exist in the data source
|
|
||||||
let endIndex = indexOfFirstTimelineItemExistingBelowGap ?? timelineItems.endIndex
|
|
||||||
let toInsert = timelineItems[..<endIndex].map { Item.status(id: $0, state: .unknown) }
|
|
||||||
if toInsert.isEmpty {
|
|
||||||
addedItems = false
|
|
||||||
} else {
|
|
||||||
snapshot.insertItems(toInsert, beforeItem: .gap)
|
|
||||||
addedItems = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there's any overlap between the items we loaded to insert above the gap
|
|
||||||
// and the items that already exist below the gap, we've completely filled the gap
|
|
||||||
if indexOfFirstTimelineItemExistingBelowGap != nil {
|
|
||||||
snapshot.deleteItems([.gap])
|
|
||||||
}
|
|
||||||
|
|
||||||
await apply(snapshot, animatingDifferences: !addedItems)
|
|
||||||
|
|
||||||
case .below:
|
|
||||||
let beforeGap = statusItems[..<gapIndex].suffix(20)
|
|
||||||
precondition(!beforeGap.contains(.gap))
|
|
||||||
|
|
||||||
// if there's any overlap, last overlapping item will be the last item below the gap
|
|
||||||
var indexOfLastTimelineItemExistingAboveGap: Int?
|
|
||||||
if case .status(id: let id, state: _) = beforeGap.last {
|
|
||||||
indexOfLastTimelineItemExistingAboveGap = timelineItems.lastIndex(of: id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// the start index of the reange of timeline items that don't yet exist in the data source
|
|
||||||
let startIndex: Int
|
|
||||||
if let indexOfLastTimelineItemExistingAboveGap {
|
|
||||||
// index(after:) because the beginning of the range is inclusive, but we don't want the item at indexOfLastTimelineItemExistingAboveGap
|
|
||||||
startIndex = timelineItems.index(after: indexOfLastTimelineItemExistingAboveGap)
|
|
||||||
} else {
|
|
||||||
startIndex = timelineItems.startIndex
|
|
||||||
}
|
|
||||||
let toInsert = timelineItems[startIndex...].map { Item.status(id: $0, state: .unknown) }
|
|
||||||
if toInsert.isEmpty {
|
|
||||||
addedItems = false
|
|
||||||
} else {
|
|
||||||
snapshot.insertItems(toInsert, afterItem: .gap)
|
|
||||||
addedItems = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// if there's any overlap between the items we loaded to insert below the gap
|
|
||||||
// and the items that already exist above the gap, we've completely filled the gap
|
|
||||||
if indexOfLastTimelineItemExistingAboveGap != nil {
|
|
||||||
snapshot.deleteItems([.gap])
|
|
||||||
}
|
|
||||||
|
|
||||||
if addedItems {
|
|
||||||
// use a snapshot of the collection view to hide the flicker as the content offset changes and then changes back
|
|
||||||
let snapshotView = collectionView.snapshotView(afterScreenUpdates: false)!
|
|
||||||
snapshotView.layer.zPosition = 1000
|
|
||||||
snapshotView.frame = view.bounds
|
|
||||||
view.addSubview(snapshotView)
|
|
||||||
|
|
||||||
// Yes, these are all load bearing. Setting the contentOffset seems to cause the collection view to recalculate the size
|
|
||||||
// of some cells, thus changing the contentSize and the offset necessary to match to match the bottom offset.
|
|
||||||
// Three DispatchQueue.main.async's seems to be the fewest we can reliably get away with.
|
|
||||||
let bottomOffset = collectionView.contentSize.height - collectionView.contentOffset.y
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: false) {
|
|
||||||
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.collectionView.contentOffset = CGPoint(x: 0, y: self.collectionView.contentSize.height - bottomOffset)
|
|
||||||
snapshotView.removeFromSuperview()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dataSource.apply(snapshot, animatingDifferences: true) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we didn't add any items, that implies the gap was removed, and we want to to make clear what's happening
|
|
||||||
if !addedItems {
|
|
||||||
var config = ToastConfiguration(title: "There's nothing in between!")
|
|
||||||
config.dismissAutomaticallyAfter = 2
|
|
||||||
showToast(configuration: config, animated: true)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Error: TimelineLikeCollectionViewError {
|
enum Error: TimelineLikeCollectionViewError {
|
||||||
|
@ -580,7 +352,6 @@ extension TimelineViewController {
|
||||||
case noNewer
|
case noNewer
|
||||||
case noOlder
|
case noOlder
|
||||||
case allCaughtUp
|
case allCaughtUp
|
||||||
case noGap
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -613,13 +384,6 @@ extension TimelineViewController: UICollectionViewDelegate {
|
||||||
let status = mastodonController.persistentContainer.status(for: id)!
|
let status = mastodonController.persistentContainer.status(for: id)!
|
||||||
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
// if the status in the timeline is a reblog, show the status that it is a reblog of
|
||||||
selected(status: status.reblog?.id ?? id, state: state.copy())
|
selected(status: status.reblog?.id ?? id, state: state.copy())
|
||||||
case .gap:
|
|
||||||
let cell = collectionView.cellForItem(at: indexPath) as! TimelineGapCollectionViewCell
|
|
||||||
cell.showsIndicator = true
|
|
||||||
Task {
|
|
||||||
await controller.fillGap(in: cell.direction)
|
|
||||||
cell.showsIndicator = false
|
|
||||||
}
|
|
||||||
case .loadingIndicator, .confirmLoadMore:
|
case .loadingIndicator, .confirmLoadMore:
|
||||||
fatalError("unreachable")
|
fatalError("unreachable")
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,7 @@ protocol TimelineLikeCollectionViewController: UIViewController, TimelineLikeCon
|
||||||
var controller: TimelineLikeController<TimelineItem>! { get }
|
var controller: TimelineLikeController<TimelineItem>! { get }
|
||||||
var confirmLoadMore: PassthroughSubject<Void, Never> { get }
|
var confirmLoadMore: PassthroughSubject<Void, Never> { get }
|
||||||
|
|
||||||
var collectionView: UICollectionView! { get }
|
var collectionView: UICollectionView { get }
|
||||||
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
|
var dataSource: UICollectionViewDiffableDataSource<Section, Item>! { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ extension TimelineLikeCollectionViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleAddLoadingIndicator() async {
|
func handleAddLoadingIndicator() async {
|
||||||
if case .loadingInitial(_, _) = controller.state,
|
if case .loadingInitial(_, _) = await controller.state,
|
||||||
let refreshControl = collectionView.refreshControl,
|
let refreshControl = collectionView.refreshControl,
|
||||||
refreshControl.isRefreshing {
|
refreshControl.isRefreshing {
|
||||||
refreshControl.beginRefreshing()
|
refreshControl.beginRefreshing()
|
||||||
|
@ -85,7 +85,7 @@ extension TimelineLikeCollectionViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleRemoveLoadingIndicator() async {
|
func handleRemoveLoadingIndicator() async {
|
||||||
if case .loadingInitial(_, _) = controller.state,
|
if case .loadingInitial(_, _) = await controller.state,
|
||||||
let refreshControl = collectionView.refreshControl,
|
let refreshControl = collectionView.refreshControl,
|
||||||
refreshControl.isRefreshing {
|
refreshControl.isRefreshing {
|
||||||
refreshControl.endRefreshing()
|
refreshControl.endRefreshing()
|
||||||
|
@ -179,17 +179,6 @@ extension TimelineLikeCollectionViewController {
|
||||||
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
|
snapshot.appendItems(timelineItems.map { .fromTimelineItem($0) }, toSection: .entries)
|
||||||
await apply(snapshot, animatingDifferences: false)
|
await apply(snapshot, animatingDifferences: false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem] {
|
|
||||||
fatalError("not supported by \(String(describing: type(of: self)))")
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async {
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async {
|
|
||||||
fatalError("not supported by \(String(describing: type(of: self)))")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension TimelineLikeCollectionViewController {
|
extension TimelineLikeCollectionViewController {
|
||||||
|
@ -217,7 +206,7 @@ extension TimelineLikeCollectionViewController {
|
||||||
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
|
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "confirmLoadMore", for: indexPath) as! ConfirmLoadMoreCollectionViewCell
|
||||||
cell.confirmLoadMore = self.confirmLoadMore
|
cell.confirmLoadMore = self.confirmLoadMore
|
||||||
Task {
|
Task {
|
||||||
if case .loadingOlder(_, _) = controller.state {
|
if case .loadingOlder(_, _) = await controller.state {
|
||||||
cell.isLoading = true
|
cell.isLoading = true
|
||||||
} else {
|
} else {
|
||||||
cell.isLoading = false
|
cell.isLoading = false
|
||||||
|
|
|
@ -16,11 +16,9 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
||||||
|
|
||||||
func loadNewer() async throws -> [TimelineItem]
|
func loadNewer() async throws -> [TimelineItem]
|
||||||
|
|
||||||
func canLoadOlder() async -> Bool
|
|
||||||
|
|
||||||
func loadOlder() async throws -> [TimelineItem]
|
func loadOlder() async throws -> [TimelineItem]
|
||||||
|
|
||||||
func loadGap(in direction: TimelineGapDirection) async throws -> [TimelineItem]
|
func canLoadOlder() async -> Bool
|
||||||
|
|
||||||
func handleAddLoadingIndicator() async
|
func handleAddLoadingIndicator() async
|
||||||
func handleRemoveLoadingIndicator() async
|
func handleRemoveLoadingIndicator() async
|
||||||
|
@ -30,16 +28,13 @@ protocol TimelineLikeControllerDelegate<TimelineItem>: AnyObject {
|
||||||
func handlePrependItems(_ timelineItems: [TimelineItem]) async
|
func handlePrependItems(_ timelineItems: [TimelineItem]) async
|
||||||
func handleLoadOlderError(_ error: Swift.Error) async
|
func handleLoadOlderError(_ error: Swift.Error) async
|
||||||
func handleAppendItems(_ timelineItems: [TimelineItem]) async
|
func handleAppendItems(_ timelineItems: [TimelineItem]) async
|
||||||
func handleLoadGapError(_ error: Swift.Error, direction: TimelineGapDirection) async
|
|
||||||
func handleFillGap(_ timelineItems: [TimelineItem], direction: TimelineGapDirection) async
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
|
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "TimelineLikeController")
|
||||||
|
|
||||||
@MainActor
|
actor TimelineLikeController<Item> {
|
||||||
class TimelineLikeController<Item> {
|
|
||||||
|
|
||||||
private unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
unowned var delegate: any TimelineLikeControllerDelegate<Item>
|
||||||
|
|
||||||
private(set) var state = State.notLoadedInitial {
|
private(set) var state = State.notLoadedInitial {
|
||||||
willSet {
|
willSet {
|
||||||
|
@ -131,27 +126,6 @@ class TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fillGap(in direction: TimelineGapDirection) async {
|
|
||||||
guard state == .idle else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let token = LoadAttemptToken()
|
|
||||||
state = .loadingGap(token, direction)
|
|
||||||
do {
|
|
||||||
let items = try await delegate.loadGap(in: direction)
|
|
||||||
guard case .loadingGap(token, direction) = state else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await emit(event: .fillGap(items, direction, token))
|
|
||||||
state = .idle
|
|
||||||
} catch is CancellationError {
|
|
||||||
return
|
|
||||||
} catch {
|
|
||||||
await emit(event: .loadGapError(error, direction, token))
|
|
||||||
state = .idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func transition(to newState: State) {
|
private func transition(to newState: State) {
|
||||||
self.state = newState
|
self.state = newState
|
||||||
}
|
}
|
||||||
|
@ -178,10 +152,6 @@ class TimelineLikeController<Item> {
|
||||||
await delegate.handleLoadOlderError(error)
|
await delegate.handleLoadOlderError(error)
|
||||||
case .appendItems(let items, _):
|
case .appendItems(let items, _):
|
||||||
await delegate.handleAppendItems(items)
|
await delegate.handleAppendItems(items)
|
||||||
case .loadGapError(let error, let direction, _):
|
|
||||||
await delegate.handleLoadGapError(error, direction: direction)
|
|
||||||
case .fillGap(let items, let direction, _):
|
|
||||||
await delegate.handleFillGap(items, direction: direction)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -191,7 +161,6 @@ class TimelineLikeController<Item> {
|
||||||
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case loadingInitial(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
case loadingNewer(LoadAttemptToken)
|
case loadingNewer(LoadAttemptToken)
|
||||||
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
case loadingOlder(LoadAttemptToken, hasAddedLoadingIndicator: Bool)
|
||||||
case loadingGap(LoadAttemptToken, TimelineGapDirection)
|
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -205,8 +174,6 @@ class TimelineLikeController<Item> {
|
||||||
return "loadingNewer(\(ObjectIdentifier(token)))"
|
return "loadingNewer(\(ObjectIdentifier(token)))"
|
||||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||||
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
return "loadingOlder(\(ObjectIdentifier(token)), hasAddedLoadingIndicator: \(hasAddedLoadingIndicator))"
|
||||||
case .loadingGap(let token, let direction):
|
|
||||||
return "loadingGap(\(ObjectIdentifier(token)), \(direction))"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -221,7 +188,7 @@ class TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
case .idle:
|
case .idle:
|
||||||
switch to {
|
switch to {
|
||||||
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _), .loadingGap(_, _):
|
case .loadingInitial(_, _), .loadingNewer(_), .loadingOlder(_, _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -232,8 +199,6 @@ class TimelineLikeController<Item> {
|
||||||
return to == .idle
|
return to == .idle
|
||||||
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
case .loadingOlder(let token, let hasAddedLoadingIndicator):
|
||||||
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
|
return to == .idle || (!hasAddedLoadingIndicator && to == .loadingOlder(token, hasAddedLoadingIndicator: true))
|
||||||
case .loadingGap(_, _):
|
|
||||||
return to == .idle
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -274,13 +239,6 @@ class TimelineLikeController<Item> {
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case .loadGapError(_, let direction, let token), .fillGap(_, let direction, let token):
|
|
||||||
switch self {
|
|
||||||
case .loadingGap(token, direction):
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -294,8 +252,6 @@ class TimelineLikeController<Item> {
|
||||||
case prependItems([Item], LoadAttemptToken)
|
case prependItems([Item], LoadAttemptToken)
|
||||||
case loadOlderError(Error, LoadAttemptToken)
|
case loadOlderError(Error, LoadAttemptToken)
|
||||||
case appendItems([Item], LoadAttemptToken)
|
case appendItems([Item], LoadAttemptToken)
|
||||||
case loadGapError(Error, TimelineGapDirection, LoadAttemptToken)
|
|
||||||
case fillGap([Item], TimelineGapDirection, LoadAttemptToken)
|
|
||||||
|
|
||||||
var debugDescription: String {
|
var debugDescription: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
@ -315,10 +271,6 @@ class TimelineLikeController<Item> {
|
||||||
return "loadOlderError(\(error), \(token))"
|
return "loadOlderError(\(error), \(token))"
|
||||||
case .appendItems(_, let token):
|
case .appendItems(_, let token):
|
||||||
return "appendItems(<omitted>, \(token))"
|
return "appendItems(<omitted>, \(token))"
|
||||||
case .loadGapError(let error, let direction, let token):
|
|
||||||
return "loadGapError(\(error), \(direction), \(token))"
|
|
||||||
case .fillGap(_, let direction, let token):
|
|
||||||
return "loadGapError(<omitted>, \(direction), \(token))"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -358,10 +310,3 @@ class TimelineLikeController<Item> {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum TimelineGapDirection {
|
|
||||||
/// Fill in below the gap. I.e., statuses that are immediately newer than the status below the gap.
|
|
||||||
case below
|
|
||||||
/// Fill in above the gap. I.e., statuses that are immediately older than the status above the gap.
|
|
||||||
case above
|
|
||||||
}
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ class ConfirmLoadMoreCollectionViewCell: UICollectionViewCell {
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
super.init(frame: frame)
|
super.init(frame: frame)
|
||||||
|
|
||||||
backgroundColor = .systemGroupedBackground
|
backgroundColor = .secondarySystemBackground
|
||||||
|
|
||||||
let label = UILabel()
|
let label = UILabel()
|
||||||
label.text = "Infinite scrolling is off. Do you want to keep going?"
|
label.text = "Infinite scrolling is off. Do you want to keep going?"
|
||||||
|
|
Loading…
Reference in New Issue