Extract common functionality into TimelineLikeTableViewController

This commit is contained in:
Shadowfacts 2020-11-15 15:48:49 -05:00
parent b45dc19811
commit dfad8740eb
Signed by: shadowfacts
GPG Key ID: 94A5AB95422746E5
6 changed files with 535 additions and 594 deletions

View File

@ -116,16 +116,15 @@
D63569E023908A8D003DD353 /* StatusState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60A4FFB238B726A008AC647 /* StatusState.swift */; };
D63661C02381C144004B9E16 /* PreferencesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */; };
D6370B9C24421FF30092A7FF /* Tusker.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = D6370B9A24421FF30092A7FF /* Tusker.xcdatamodeld */; };
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */; };
D63F9C6E241D2D85004C03CF /* CompositionAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */; };
D6403CC224A6B72D00E81C55 /* VisualEffectImageButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */; };
D640D76922BAF5E6004FBE69 /* DomainBlocks.plist in Resources */ = {isa = PBXBuildFile; fileRef = D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */; };
D6412B0324AFF6A600F5412E /* TabBarScrollableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */; };
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0424B0227D00F5412E /* ProfileViewController.swift */; };
D6412B0724B0237700F5412E /* ProfileStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0624B0237700F5412E /* ProfileStatusesViewController.swift */; };
D6412B0924B0291E00F5412E /* MyProfileViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0824B0291E00F5412E /* MyProfileViewController.swift */; };
D6412B0B24B0D4C600F5412E /* ProfileHeaderView.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */; };
D6412B0D24B0D4CF00F5412E /* ProfileHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */; };
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641C772213CAA25004B4513 /* NotificationsTableViewController.swift */; };
D641C77F213DC78A004B4513 /* InlineTextAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */; };
D6434EB3215B1856001A919A /* XCBRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6434EB2215B1856001A919A /* XCBRequest.swift */; };
D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */; };
@ -142,6 +141,9 @@
D64D0AB12128D9AE005A6F37 /* OnboardingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */; };
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */; };
D64F80E2215875CC00BEF393 /* XCBActionType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64F80E1215875CC00BEF393 /* XCBActionType.swift */; };
D65234C9256189D0001AF9CF /* TimelineLikeTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */; };
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */; };
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */; };
D6531DEE246B81C9000F9538 /* GifvAttachmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */; };
D6531DF0246B867E000F9538 /* GifvAttachmentViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */; };
D6538945214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */; };
@ -298,7 +300,6 @@
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F1F84C2193B56E00F5FE67 /* Cache.swift */; };
D6F2E965249E8BFD005846BB /* CrashReporterViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */; };
D6F2E966249E8BFD005846BB /* CrashReporterViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */; };
D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */; };
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F953EF21251A2900CF0F2B /* MastodonController.swift */; };
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */; };
/* End PBXBuildFile section */
@ -466,16 +467,15 @@
D6333B782139AEFD00CE884A /* Date+TimeAgo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+TimeAgo.swift"; sourceTree = "<group>"; };
D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = "<group>"; };
D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = "<group>"; };
D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = "<group>"; };
D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = "<group>"; };
D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = "<group>"; };
D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = "<group>"; };
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarScrollableViewController.swift; sourceTree = "<group>"; };
D6412B0424B0227D00F5412E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = "<group>"; };
D6412B0624B0237700F5412E /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = "<group>"; };
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewController.swift; sourceTree = "<group>"; };
D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = "<group>"; };
D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = "<group>"; };
D641C772213CAA25004B4513 /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = "<group>"; };
D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = "<group>"; };
D6434EB2215B1856001A919A /* XCBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequest.swift; sourceTree = "<group>"; };
D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = "<group>"; };
@ -492,6 +492,9 @@
D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = "<group>"; };
D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = "<group>"; };
D64F80E1215875CC00BEF393 /* XCBActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActionType.swift; sourceTree = "<group>"; };
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeTableViewController.swift; sourceTree = "<group>"; };
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = "<group>"; };
D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = "<group>"; };
D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = "<group>"; };
D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = "<group>"; };
@ -654,7 +657,6 @@
D6F1F84C2193B56E00F5FE67 /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = "<group>"; };
D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporterViewController.swift; sourceTree = "<group>"; };
D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CrashReporterViewController.xib; sourceTree = "<group>"; };
D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = "<group>"; };
D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = "<group>"; };
D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
@ -973,7 +975,7 @@
isa = PBXGroup;
children = (
D6BC9DD6232D7811002CA326 /* TimelinesPageViewController.swift */,
D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */,
D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */,
D6945C3123AC4D36005C403C /* HashtagTimelineViewController.swift */,
D6945C3723AC739F005C403C /* InstanceTimelineViewController.swift */,
);
@ -1005,7 +1007,7 @@
isa = PBXGroup;
children = (
D6412B0424B0227D00F5412E /* ProfileViewController.swift */,
D6412B0624B0237700F5412E /* ProfileStatusesViewController.swift */,
D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */,
D6412B0824B0291E00F5412E /* MyProfileViewController.swift */,
);
path = Profile;
@ -1023,7 +1025,7 @@
isa = PBXGroup;
children = (
D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */,
D641C772213CAA25004B4513 /* NotificationsTableViewController.swift */,
D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */,
);
path = Notifications;
sourceTree = "<group>";
@ -1372,6 +1374,7 @@
D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */,
D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */,
D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */,
D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */,
);
path = Utilities;
sourceTree = "<group>";
@ -1851,6 +1854,7 @@
D6BC9DB3232D4C07002CA326 /* WellnessPrefsView.swift in Sources */,
D64BC18F23C18B9D000D0238 /* FollowRequestNotificationTableViewCell.swift in Sources */,
D693DE5923FE24310061E07D /* InteractivePushTransition.swift in Sources */,
D65234C9256189D0001AF9CF /* TimelineLikeTableViewController.swift in Sources */,
D6A3BC8A2321F79B00FD64D5 /* AccountTableViewCell.swift in Sources */,
D66A77BB233838DC0058F1EC /* UIFont+Traits.swift in Sources */,
D68FEC4F232C5BC300C84F23 /* SegmentedPageViewController.swift in Sources */,
@ -1865,7 +1869,6 @@
D6F953F021251A2900CF0F2B /* MastodonController.swift in Sources */,
0411610022B442870030A9B7 /* LoadingLargeImageViewController.swift in Sources */,
D6A4DCE525537C7A00D9DE31 /* FastSwitchingAccountView.swift in Sources */,
D6412B0724B0237700F5412E /* ProfileStatusesViewController.swift in Sources */,
D6A4DCCC2553667800D9DE31 /* FastAccountSwitcherViewController.swift in Sources */,
D611C2CF232DC61100C86A49 /* HashtagTableViewCell.swift in Sources */,
D627944F23A9C99800D38C68 /* EditListAccountsViewController.swift in Sources */,
@ -1887,6 +1890,7 @@
D6CA6A92249FAD8900AD45C1 /* AudioSessionHelper.swift in Sources */,
D6C143FD25354FD0007DC240 /* EmojiCollectionViewCell.swift in Sources */,
D60D2B8223844C71001B87A3 /* BaseStatusTableViewCell.swift in Sources */,
D63A8D0B2561C27F00D9DFFF /* ProfileStatusesViewController.swift in Sources */,
D60E2F272442372B005F8713 /* StatusMO.swift in Sources */,
D60E2F2C24423EAD005F8713 /* LazilyDecoding.swift in Sources */,
D677284A24ECBDF400C732D3 /* ComposeCurrentAccount.swift in Sources */,
@ -1988,6 +1992,7 @@
D6FF9860255C717400845181 /* AccountSwitchingContainerViewController.swift in Sources */,
D670F8B62537DC890046588A /* EmojiPickerWrapper.swift in Sources */,
D6B81F442560390300F6E31D /* MenuController.swift in Sources */,
D65234E12561AA68001AF9CF /* NotificationsTableViewController.swift in Sources */,
D67C57AF21E28EAD00C3118B /* Array+Uniques.swift in Sources */,
D6945C3223AC4D36005C403C /* HashtagTimelineViewController.swift in Sources */,
D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */,
@ -1997,7 +2002,6 @@
D681E4D7246E32290053414F /* StatusActivityItemSource.swift in Sources */,
D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */,
D6A3BC7C232195C600FD64D5 /* ActionNotificationGroupTableViewCell.swift in Sources */,
D6F953EC212519E700CF0F2B /* TimelineTableViewController.swift in Sources */,
D62275A824F1CA2800B82A16 /* ComposeReplyContentView.swift in Sources */,
D677284824ECBCB100C732D3 /* ComposeView.swift in Sources */,
D68232F72464F4FD00325FB8 /* ComposeDrawingViewController.swift in Sources */,
@ -2007,12 +2011,12 @@
D68015402401A6BA00D6103B /* ComposingPrefsView.swift in Sources */,
D67895BC24671E6D00D4CD9E /* PKDrawing+Render.swift in Sources */,
04D14BB022B34A2800642648 /* GalleryViewController.swift in Sources */,
D641C773213CAA25004B4513 /* NotificationsTableViewController.swift in Sources */,
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */,
D6412B0524B0227D00F5412E /* ProfileViewController.swift in Sources */,
D64BC18A23C16487000D0238 /* UnpinStatusActivity.swift in Sources */,
D64D8CA92463B494006B0BAA /* MultiThreadDictionary.swift in Sources */,
D6757A7C2157E01900721E32 /* XCBManager.swift in Sources */,
D65234D325618EFA001AF9CF /* TimelineTableViewController.swift in Sources */,
D68E6F5F253C9B2D001A1B4C /* BaseEmojiLabel.swift in Sources */,
D6F1F84D2193B56E00F5FE67 /* Cache.swift in Sources */,
D6F0B12B24A3071C001E48C3 /* MainSplitViewController.swift in Sources */,

View File

@ -58,8 +58,7 @@ class ListTimelineViewController: TimelineTableViewController {
dismiss(animated: true)
// todo: show loading indicator
timelineSegments = []
loadInitialStatuses()
reloadInitialItems()
}
}

View File

@ -9,8 +9,8 @@
import UIKit
import Pachyderm
class NotificationsTableViewController: EnhancedTableViewController {
class NotificationsTableViewController: TimelineLikeTableViewController<NotificationGroup> {
private let statusCell = "statusCell"
private let actionGroupCell = "actionGroupCell"
private let followGroupCell = "followGroupCell"
@ -20,125 +20,112 @@ class NotificationsTableViewController: EnhancedTableViewController {
weak var mastodonController: MastodonController!
private let excludedTypes: [Pachyderm.Notification.Kind]
private let groupTypes = [Notification.Kind.favourite, .reblog, .follow]
private let groupTypes = [Pachyderm.Notification.Kind.favourite, .reblog, .follow]
private var loaded = false
private var groups: [NotificationGroup] = []
private let pageSize = 20
private var newer: RequestRange?
private var older: RequestRange?
private var lastLastVisibleRow: IndexPath?
init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) {
self.excludedTypes = Array(Set(Pachyderm.Notification.Kind.allCases).subtracting(allowedTypes))
self.mastodonController = mastodonController
super.init(style: .plain)
#if !targetEnvironment(macCatalyst)
self.refreshControl = UIRefreshControl()
refreshControl!.addTarget(self, action: #selector(refreshNotifications), for: .valueChanged)
#endif
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: NSLocalizedString("Refresh Notifications", comment: "refresh notifications command discoverability title")))
super.init()
}
required init?(coder aDecoder: NSCoder) {
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override class func refreshCommandTitle() -> String {
return NSLocalizedString("Refresh Notifications", comment: "refresh notifications command discoverability title")
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: statusCell)
tableView.register(UINib(nibName: "ActionNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: actionGroupCell)
tableView.register(UINib(nibName: "FollowNotificationGroupTableViewCell", bundle: .main), forCellReuseIdentifier: followGroupCell)
tableView.register(UINib(nibName: "FollowRequestNotificationTableViewCell", bundle: .main), forCellReuseIdentifier: followRequestCell)
tableView.register(UINib(nibName: "BasicTableViewCell", bundle: .main), forCellReuseIdentifier: unknownCell)
tableView.prefetchDataSource = self
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if !loaded {
loaded = true
override func loadInitialItems(completion: @escaping ([NotificationGroup]) -> Void) {
let request = Client.getNotifications(excludeTypes: excludedTypes)
mastodonController.run(request) { (response) in
guard case let .success(notifications, pagination) = response else { fatalError() }
let request = Client.getNotifications(excludeTypes: excludedTypes)
mastodonController.run(request) { result in
guard case let .success(notifications, pagination) = result else { fatalError() }
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
self.groups.append(contentsOf: groups)
self.newer = pagination?.newer
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
DispatchQueue.main.async {
self.tableView.reloadData()
}
}
let groups = NotificationGroup.createGroups(notifications: notifications, only: self.groupTypes)
self.newer = pagination?.newer
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(notifications: notifications) {
completion(groups)
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
pruneOffscreenRows()
}
private func pruneOffscreenRows() {
guard let lastVisibleRow = lastLastVisibleRow else {
override func loadOlder(completion: @escaping ([NotificationGroup]) -> Void) {
guard let older = older else {
completion([])
return
}
let lastRowIndex = groups.count - 1
if lastVisibleRow.row < lastRowIndex - pageSize {
// if there are more than 20 rows below the lats visible one
let request = Client.getNotifications(excludeTypes: excludedTypes, range: older)
mastodonController.run(request) { (response) in
guard case let .success(newNotifications, pagination) = response else { fatalError() }
let rowIndicesToRemove = (lastVisibleRow.row + pageSize)..<groups.count
self.older = pagination?.older
let groupsToRemove = groups[rowIndicesToRemove]
for group in groupsToRemove {
for notification in group.notifications {
if let id = notification.status?.id {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
}
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
groups.removeSubrange(rowIndicesToRemove)
let removedIndexPaths = rowIndicesToRemove.map { IndexPath(row: $0, section: 0) }
UIView.performWithoutAnimation {
tableView.deleteRows(at: removedIndexPaths, with: .none)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
completion(groups)
}
}
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
override func loadNewer(completion: @escaping ([NotificationGroup]) -> Void) {
guard let newer = newer else {
completion([])
return
}
let request = Client.getNotifications(excludeTypes: excludedTypes, range: newer)
mastodonController.run(request) { (response) in
guard case let .success(newNotifications, pagination) = response else { fatalError() }
self.newer = pagination?.newer
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
completion(groups)
}
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return groups.count
}
private func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
let group = DispatchGroup()
item(for: indexPath).notifications
.map { Pachyderm.Notification.dismiss(id: $0.id) }
.forEach { (request) in
group.enter()
mastodonController.run(request) { (_) in
group.leave()
}
}
group.notify(queue: .main) {
self.sections[indexPath.section].remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .automatic)
completion?()
}
}
// MARK: - UITableViewDataSource
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let group = groups[indexPath.row]
let group = item(for: indexPath)
switch group.kind {
case .mention:
@ -175,46 +162,8 @@ class NotificationsTableViewController: EnhancedTableViewController {
return cell
}
}
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
lastLastVisibleRow = tableView.indexPathsForVisibleRows?.last
if indexPath.row == groups.count - 1 {
guard let older = older else { return }
let request = Client.getNotifications(excludeTypes: excludedTypes, range: older)
mastodonController.run(request) { result in
guard case let .success(newNotifications, pagination) = result else { fatalError() }
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
let newIndexPaths = (self.groups.count..<(self.groups.count + groups.count)).map {
IndexPath(row: $0, section: 0)
}
self.groups.append(contentsOf: groups)
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
}
}
}
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
}
// MARK: - UITableViewDelegate
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let dismissAction = UIContextualAction(style: .destructive, title: NSLocalizedString("Dismiss", comment: "dismiss notification swipe action title")) { (action, view, completion) in
@ -223,7 +172,9 @@ class NotificationsTableViewController: EnhancedTableViewController {
}
}
dismissAction.image = UIImage(systemName: "clear.fill")
let cellConfiguration = (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
let config: UISwipeActionsConfiguration
if let cellConfiguration = cellConfiguration {
config = UISwipeActionsConfiguration(actions: cellConfiguration.actions + [dismissAction])
@ -237,78 +188,27 @@ class NotificationsTableViewController: EnhancedTableViewController {
override func getSuggestedContextMenuActions(tableView: UITableView, indexPath: IndexPath, point: CGPoint) -> [UIAction] {
return [
UIAction(title: "Dismiss Notification", image: UIImage(systemName: "clear.fill"), identifier: .init("dismissnotification"), discoverabilityTitle: nil, attributes: [], state: .off, handler: { (_) in
UIAction(title: "Dismiss Notification", image: UIImage(systemName: "clear.fill"), identifier: .init("dismissnotification"), handler: { (_) in
self.dismissNotificationsInGroup(at: indexPath)
})
]
}
func dismissNotificationsInGroup(at indexPath: IndexPath, completion: (() -> Void)? = nil) {
let group = DispatchGroup()
groups[indexPath.row].notifications
.map { Pachyderm.Notification.dismiss(id: $0.id) }
.forEach { (request) in
group.enter()
mastodonController.run(request) { (response) in
group.leave()
}
}
group.notify(queue: .main) {
self.groups.remove(at: indexPath.row)
self.tableView.deleteRows(at: [indexPath], with: .automatic)
completion?()
}
}
@objc func refreshNotifications() {
guard let newer = newer else { return }
let request = Client.getNotifications(excludeTypes: excludedTypes, range: newer)
mastodonController.run(request) { result in
guard case let .success(newNotifications, pagination) = result else { fatalError() }
let groups = NotificationGroup.createGroups(notifications: newNotifications, only: self.groupTypes)
self.groups.insert(contentsOf: groups, at: 0)
if let newer = pagination?.newer {
self.newer = newer
}
self.mastodonController.persistentContainer.addAll(notifications: newNotifications) {
DispatchQueue.main.async {
let newIndexPaths = (0..<groups.count).map {
IndexPath(row: $0, section: 0)
}
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
self.refreshControl?.endRefreshing()
// maintain the current position in the list (don't scroll to top)
self.tableView.scrollToRow(at: IndexPath(row: newNotifications.count, section: 0), at: .top, animated: false)
}
}
}
}
}
extension NotificationsTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension NotificationsTableViewController: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
cellHeightChanged()
}
}
extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
for notification in groups[indexPath.row].notifications {
// todo: this account object could be stale
for notification in item(for: indexPath).notifications {
_ = ImageCache.avatars.get(notification.account.avatar, completion: nil)
}
}
@ -316,21 +216,9 @@ extension NotificationsTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
for notification in groups[indexPath.row].notifications {
for notification in item(for: indexPath).notifications {
ImageCache.avatars.cancelWithoutCallback(notification.account.avatar)
}
}
}
}
extension NotificationsTableViewController: RefreshableViewController {
func refresh() {
refreshNotifications()
}
}
extension NotificationsTableViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
pruneOffscreenRows()
}
}

View File

@ -9,8 +9,8 @@
import UIKit
import Pachyderm
class ProfileStatusesViewController: EnhancedTableViewController {
class ProfileStatusesViewController: TimelineLikeTableViewController<TimelineEntry> {
weak var mastodonController: MastodonController!
private(set) var headerView: ProfileHeaderView!
@ -19,20 +19,15 @@ class ProfileStatusesViewController: EnhancedTableViewController {
let kind: Kind
private var pinnedStatuses: [(id: String, state: StatusState)] = []
private var timelineSegments: [[(id: String, state: StatusState)]] = []
private var older: RequestRange?
private var newer: RequestRange?
var loaded = false
init(accountID: String?, kind: Kind, mastodonController: MastodonController) {
self.accountID = accountID
self.kind = kind
self.mastodonController = mastodonController
super.init(style: .plain)
super.init()
}
required init?(coder: NSCoder) {
@ -41,76 +36,117 @@ class ProfileStatusesViewController: EnhancedTableViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .systemBackground
#if !targetEnvironment(macCatalyst)
refreshControl = UIRefreshControl()
refreshControl!.addTarget(self, action: #selector(refreshStatuses), for: .valueChanged)
#endif
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: NSLocalizedString("Refresh Statuses", comment: "refresh statuses command discoverability title")))
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
tableView.prefetchDataSource = self
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if !loaded,
let accountID = accountID,
let account = mastodonController.persistentContainer.account(for: accountID) {
updateUI(account: account)
}
}
func updateUI(account: AccountMO) {
guard !loaded else { return }
loaded = true
loadInitial()
}
override class func refreshCommandTitle() -> String {
return NSLocalizedString("Refresh Statuses", comment: "refresh statuses command discoverability title")
}
override func headerSectionsCount() -> Int {
return 1
}
override func loadInitial() {
guard accountID != nil else {
return
}
if kind == .statuses {
getPinnedStatuses { (response) in
guard case let .success(statuses, _) = response else {
// todo: error message
return
}
if statuses.isEmpty { return }
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.pinnedStatuses = statuses.map { ($0.id, .unknown) }
let indexPaths = (0..<statuses.count).map { IndexPath(row: $0, section: 0) }
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertRows(at: indexPaths, with: .none)
if !loaded {
loadPinnedStatuses()
}
super.loadInitial()
}
private func loadPinnedStatuses() {
guard kind == .statuses else {
return
}
getPinnedStatuses { (response) in
guard case let .success(statuses, _) = response,
!statuses.isEmpty else {
// todo: error message
return
}
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
let items = statuses.map { ($0.id, StatusState.unknown) }
DispatchQueue.main.async {
UIView.performWithoutAnimation {
if self.sections.count < 1 {
self.sections.append(items)
self.tableView.insertSections(IndexSet(integer: 0), with: .none)
} else {
self.sections[0] = items
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
}
}
}
}
}
}
override func loadInitialItems(completion: @escaping ([TimelineEntry]) -> Void) {
getStatuses { (response) in
guard case let .success(statuses, pagination) = response else {
guard case let .success(statuses, pagination) = response,
!statuses.isEmpty else {
// todo: error message
return
}
if statuses.isEmpty { return }
self.older = pagination?.older
self.newer = pagination?.newer
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
self.timelineSegments.append(statuses.map { ($0.id, .unknown) })
self.older = pagination?.older
self.newer = pagination?.newer
DispatchQueue.main.async {
UIView.performWithoutAnimation {
self.tableView.insertSections(IndexSet(integer: 1), with: .none)
}
}
completion(statuses.map { ($0.id, .unknown) })
}
}
}
override func loadOlder(completion: @escaping ([TimelineEntry]) -> Void) {
guard let older = older else {
completion([])
return
}
getStatuses(for: older) { (response) in
guard case let .success(statuses, pagination) = response else {
// todo: error message
completion([])
return
}
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
}
}
}
override func loadNewer(completion: @escaping ([TimelineEntry]) -> Void) {
guard let newer = newer else {
completion([])
return
}
getStatuses(for: newer) { (response) in
guard case let .success(statuses, pagination) = response else {
// todo: error message
completion([])
return
}
self.newer = pagination?.newer
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
}
}
}
@ -132,48 +168,20 @@ class ProfileStatusesViewController: EnhancedTableViewController {
let request = Account.getStatuses(accountID, range: .default, onlyMedia: false, pinned: true, excludeReplies: false)
mastodonController.run(request, completion: completion)
}
// MARK: Interaction
@objc func refreshStatuses() {
guard let newer = newer else { return }
getStatuses(for: newer) { (response) in
guard case let .success(newStatuses, pagination) = response else {
// todo: error message
return
}
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
// if there's no newer request range (because no statuses were returned),
// we don't want to change the current newer pagination, so that we can
// continue to load statuses newer than whatever was last loaded
if let newer = pagination?.newer {
self.newer = newer
}
let indexPaths = (0..<newStatuses.count).map { IndexPath(row: $0, section: 1) }
DispatchQueue.main.async {
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
UIView.performWithoutAnimation {
self.tableView.insertRows(at: indexPaths, with: .none)
}
self.refreshControl?.endRefreshing()
}
}
}
override func refresh() {
super.refresh()
if kind == .statuses {
getPinnedStatuses { (response) in
guard case let .success(newPinnedStatuses, _) = response else {
guard case let .success(newPinnedStatues, _) = response else {
// todo: error message
return
}
self.mastodonController.persistentContainer.addAll(statuses: newPinnedStatuses) {
let oldPinnedStatuses = self.pinnedStatuses
let pinnedStatuses = newPinnedStatuses.map { (status) -> (id: String, state: StatusState) in
self.mastodonController.persistentContainer.addAll(statuses: newPinnedStatues) {
let oldPinnedStatuses = self.sections[0]
let pinnedStatues = newPinnedStatues.map { (status) -> TimelineEntry in
let state: StatusState
if let (_, oldState) = oldPinnedStatuses.first(where: { $0.id == status.id }) {
state = oldState
@ -183,7 +191,11 @@ class ProfileStatusesViewController: EnhancedTableViewController {
return (status.id, state)
}
DispatchQueue.main.async {
self.pinnedStatuses = pinnedStatuses
if self.sections.count < 1 {
self.sections.append(pinnedStatues)
} else {
self.sections[0] = pinnedStatues
}
UIView.performWithoutAnimation {
self.tableView.reloadSections(IndexSet(integer: 0), with: .none)
}
@ -193,83 +205,19 @@ class ProfileStatusesViewController: EnhancedTableViewController {
}
}
// MARK: Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
// 1 for pinned, rest for timeline
return 1 + timelineSegments.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
if section == 0 {
return pinnedStatuses.count
} else {
return timelineSegments[section - 1].count
}
}
// MARK: - UITableViewDatasource
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as! TimelineStatusTableViewCell
cell.delegate = self
if indexPath.section == 0 {
cell.showPinned = true
let (id, state) = pinnedStatuses[indexPath.row]
cell.updateUI(statusID: id, state: state)
} else {
cell.showPinned = false
let (id, state) = timelineSegments[indexPath.section - 1][indexPath.row]
cell.updateUI(statusID: id, state: state)
}
cell.delegate = self
cell.showPinned = indexPath.section == 0
let (id, state) = item(for: indexPath)
cell.updateUI(statusID: id, state: state)
return cell
}
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// load older statuses if at bottom
if timelineSegments.count > 0,
indexPath.section == timelineSegments.count,
indexPath.row == timelineSegments[indexPath.section - 1].count - 1 {
guard let older = older else { return }
getStatuses(for: older) { (response) in
guard case let .success(newStatuses, pagination) = response else {
// todo: error message
return
}
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
// if there is no older request range, we want to set ours to nil
// otherwise we would end up loading the same statuses again
self.older = pagination?.older
DispatchQueue.main.async {
let start = self.timelineSegments[indexPath.section - 1].count
let indexPaths = (0..<newStatuses.count).map { IndexPath(row: start + $0, section: indexPath.section) }
self.timelineSegments[indexPath.section - 1].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
UIView.performWithoutAnimation {
self.tableView.insertRows(at: indexPaths, with: .none)
}
}
}
}
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
}
extension ProfileStatusesViewController {
@ -278,26 +226,25 @@ extension ProfileStatusesViewController {
}
}
extension ProfileStatusesViewController: StatusTableViewCellDelegate {
extension ProfileStatusesViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
}
extension ProfileStatusesViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
cellHeightChanged()
}
}
extension ProfileStatusesViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let statusID: String
if indexPath.section == 0 {
statusID = pinnedStatuses[indexPath.row].id
} else {
statusID = timelineSegments[indexPath.section - 1][indexPath.row].id
let statusID = item(for: indexPath).id
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
continue
}
guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue }
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
@ -307,13 +254,11 @@ extension ProfileStatusesViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let statusID: String
if indexPath.section == 0 {
statusID = pinnedStatuses[indexPath.row].id
} else {
statusID = timelineSegments[indexPath.section - 1][indexPath.row].id
let statusID = item(for: indexPath).id
guard let status = mastodonController.persistentContainer.status(for: statusID) else {
continue
}
guard let status = mastodonController.persistentContainer.status(for: statusID) else { continue }
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.avatars.cancelWithoutCallback(attachment.url)
@ -321,9 +266,3 @@ extension ProfileStatusesViewController: UITableViewDataSourcePrefetching {
}
}
}
extension ProfileStatusesViewController: RefreshableViewController {
func refresh() {
refreshStatuses()
}
}

View File

@ -1,5 +1,5 @@
//
// StatusesTableViewController.swift
// TimelineTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 8/15/18.
@ -9,41 +9,29 @@
import UIKit
import Pachyderm
class TimelineTableViewController: EnhancedTableViewController, StatusTableViewCellDelegate {
typealias TimelineEntry = (id: String, state: StatusState)
class TimelineTableViewController: TimelineLikeTableViewController<TimelineEntry> {
var timeline: Timeline!
let timeline: Timeline
weak var mastodonController: MastodonController!
private var loaded = false
var timelineSegments: [[(id: String, state: StatusState)]] = []
private let pageSize = 20
private var newer: RequestRange?
private var older: RequestRange?
private var lastLastVisibleRow: IndexPath?
init(for timeline: Timeline, mastodonController: MastodonController) {
self.timeline = timeline
self.mastodonController = mastodonController
super.init(style: .plain)
super.init()
title = timeline.title
tabBarItem.image = timeline.tabBarImage
#if !targetEnvironment(macCatalyst)
self.refreshControl = UIRefreshControl()
refreshControl!.addTarget(self, action: #selector(refreshStatuses), for: .valueChanged)
#endif
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: NSLocalizedString("Refresh Statuses", comment: "refresh statuses command discoverability title")))
userActivity = UserActivityManager.showTimelineActivity(timeline: timeline)
}
required init?(coder aDecoder: NSCoder) {
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@ -52,120 +40,89 @@ class TimelineTableViewController: EnhancedTableViewController, StatusTableViewC
// decrement reference counts of any statuses we still have
// if the app is currently being quit, this will not affect the persisted data because
// the persistent container would already have been saved in SceneDelegate.sceneDidEnterBackground(_:)
for segment in timelineSegments {
for (id, _) in segment {
for section in sections {
for (id, _) in section {
persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
}
func statusID(for indexPath: IndexPath) -> String {
return timelineSegments[indexPath.section][indexPath.row].id
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell")
tableView.prefetchDataSource = self
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: .main), forCellReuseIdentifier: "statusCell")
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadInitialStatuses()
override class func refreshCommandTitle() -> String {
return NSLocalizedString("Refresh Statuses", comment: "refresh status command discoverability title")
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
pruneOffscreenRows()
override func willRemoveRows(at indexPaths: [IndexPath]) {
for indexPath in indexPaths {
let id = item(for: indexPath).id
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
func loadInitialStatuses() {
guard !loaded else { return }
loaded = true
override func loadInitialItems(completion: @escaping ([TimelineEntry]) -> Void) {
let request = Client.getStatuses(timeline: timeline)
mastodonController.run(request) { response in
mastodonController?.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() }
// todo: possible race condition here? we update the underlying data before waiting to reload the table view
self.timelineSegments.insert(statuses.map { ($0.id, .unknown) }, at: 0)
self.newer = pagination?.newer
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(statuses: statuses) {
DispatchQueue.main.async {
self.tableView.reloadData()
}
self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
}
}
}
private func pruneOffscreenRows() {
guard let lastVisibleRow = lastLastVisibleRow else {
override func loadOlder(completion: @escaping ([TimelineEntry]) -> Void) {
guard let older = older else {
completion([])
return
}
let lastSectionIndex = timelineSegments.count - 1
if lastVisibleRow.section < lastSectionIndex {
// if there is a section below the last visible one
let request = Client.getStatuses(timeline: timeline, range: older)
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() }
let sectionsToRemove = (lastVisibleRow.section + 1)...lastSectionIndex
self.older = pagination?.older
for section in sectionsToRemove {
for (id, _) in timelineSegments.remove(at: section) {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
}
UIView.performWithoutAnimation {
tableView.deleteSections(IndexSet(sectionsToRemove), with: .none)
}
} else if lastVisibleRow.section == lastSectionIndex {
let lastSection = timelineSegments.last!
let lastRowIndex = lastSection.count - 1
if lastVisibleRow.row < lastRowIndex - 20 {
// if there are more than 20 rows in the current section below the last visible one
let rowIndicesInLastSectionToRemove = (lastVisibleRow.row + 20)..<lastSection.count
let statusesToRemove = lastSection[rowIndicesInLastSectionToRemove]
for (id, _) in statusesToRemove {
mastodonController.persistentContainer.status(for: id)?.decrementReferenceCount()
}
timelineSegments[lastSectionIndex].removeSubrange(rowIndicesInLastSectionToRemove)
let removedIndexPaths = rowIndicesInLastSectionToRemove.map { IndexPath(row: $0, section: lastSectionIndex) }
UIView.performWithoutAnimation {
tableView.deleteRows(at: removedIndexPaths, with: .none)
}
self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
}
}
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return timelineSegments.count
override func loadNewer(completion: @escaping ([TimelineEntry]) -> Void) {
guard let newer = newer else {
completion([])
return
}
let request = Client.getStatuses(timeline: timeline, range: newer)
mastodonController.run(request) { (response) in
guard case let .success(statuses, pagination) = response else { fatalError() }
self.newer = pagination?.newer
self.mastodonController?.persistentContainer.addAll(statuses: statuses) {
completion(statuses.map { ($0.id, .unknown) })
}
}
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return timelineSegments[section].count
}
// MARK: - UITableViewDataSource
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "statusCell", for: indexPath) as? TimelineStatusTableViewCell else { fatalError() }
let (id, state) = timelineSegments[indexPath.section][indexPath.row]
let (id, state) = item(for: indexPath)
cell.delegate = self
cell.updateUI(statusID: id, state: state)
@ -173,104 +130,24 @@ class TimelineTableViewController: EnhancedTableViewController, StatusTableViewC
return cell
}
// MARK: - Table view delegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// this assumes that indexPathsForVisibleRows is always in order
lastLastVisibleRow = tableView.indexPathsForVisibleRows?.last
// load older statuses, if necessary
if indexPath.section == timelineSegments.count - 1,
indexPath.row == timelineSegments[indexPath.section].count - 1 {
guard let older = older else { return }
let request = Client.getStatuses(timeline: timeline, range: older)
mastodonController.run(request) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
self.older = pagination?.older
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
DispatchQueue.main.async {
let newRows = self.timelineSegments.last!.count..<(self.timelineSegments.last!.count + newStatuses.count)
let newIndexPaths = newRows.map { IndexPath(row: $0, section: self.timelineSegments.count - 1) }
self.timelineSegments[self.timelineSegments.count - 1].append(contentsOf: newStatuses.map { ($0.id, .unknown) })
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .none)
}
}
}
}
}
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
// MARK: - Interaction
@objc func refreshStatuses() {
guard let newer = newer else { return }
let request = Client.getStatuses(timeline: timeline, range: newer)
mastodonController.run(request) { response in
guard case let .success(newStatuses, pagination) = response else { fatalError() }
// If there is no new newer pagination, don't reset it, so that the user can continue refreshing for more recent statuses
// Otherwise, when no new statuses were loaded, it would get reset and the the user would be unable to refresh
if let newer = pagination?.newer {
self.newer = newer
}
self.mastodonController.persistentContainer.addAll(statuses: newStatuses) {
DispatchQueue.main.async {
self.timelineSegments[0].insert(contentsOf: newStatuses.map { ($0.id, .unknown) }, at: 0)
let newIndexPaths = (0..<newStatuses.count).map {
IndexPath(row: $0, section: 0)
}
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .automatic)
}
self.refreshControl?.endRefreshing()
// maintain the current position in the list (don't scroll to the top)
self.tableView.scrollToRow(at: IndexPath(row: newStatuses.count, section: 0), at: .top, animated: false)
}
}
}
}
@objc func composePressed(_ sender: Any) {
compose()
}
// MARK: - TuskerNavigationDelegate
extension TimelineTableViewController: TuskerNavigationDelegate {
var apiController: MastodonController { mastodonController }
// MARK: - StatusTableViewCellDelegate
}
extension TimelineTableViewController: StatusTableViewCellDelegate {
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
cellHeightChanged()
}
}
extension TimelineTableViewController: UITableViewDataSourcePrefetching {
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
guard let status = mastodonController.persistentContainer.status(for: statusID(for: indexPath)) else { continue }
guard let status = mastodonController.persistentContainer.status(for: item(for: indexPath).id) else {
continue
}
_ = ImageCache.avatars.get(status.account.avatar, completion: nil)
for attachment in status.attachments where attachment.kind == .image {
_ = ImageCache.attachments.get(attachment.url, completion: nil)
@ -282,8 +159,11 @@ extension TimelineTableViewController: UITableViewDataSourcePrefetching {
for indexPath in indexPaths {
// todo: this means when removing cells, we can't cancel prefetching
// is this an issue?
guard indexPath.section < timelineSegments.count, indexPath.row < timelineSegments[indexPath.section].count,
let status = mastodonController.persistentContainer.status(for: statusID(for: indexPath)) else { continue }
guard indexPath.section < sections.count,
indexPath.row < sections[indexPath.section].count,
let status = mastodonController.persistentContainer.status(for: item(for: indexPath).id) else {
continue
}
ImageCache.avatars.cancelWithoutCallback(status.account.avatar)
for attachment in status.attachments where attachment.kind == .image {
ImageCache.attachments.cancelWithoutCallback(attachment.url)
@ -291,15 +171,3 @@ extension TimelineTableViewController: UITableViewDataSourcePrefetching {
}
}
}
extension TimelineTableViewController: RefreshableViewController {
func refresh() {
refreshStatuses()
}
}
extension TimelineTableViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
pruneOffscreenRows()
}
}

View File

@ -0,0 +1,243 @@
//
// TimelineLikeTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 11/15/20.
// Copyright © 2020 Shadowfacts. All rights reserved.
//
import UIKit
/// A table view controller that manages common functionality between timeline-like UIs.
// For example, this class handles loading new items when the user scrolls to the end,
// refreshing, and pruning offscreen rows automatically.
class TimelineLikeTableViewController<Item>: EnhancedTableViewController, RefreshableViewController {
private(set) var loaded = false
var sections: [[Item]] = []
private let pageSize = 20
private var lastLastVisibleRow: IndexPath?
init() {
super.init(style: .plain)
#if !targetEnvironment(macCatalyst)
self.refreshControl = UIRefreshControl()
self.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: Self.refreshCommandTitle()))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func item(for indexPath: IndexPath) -> Item {
return sections[indexPath.section][indexPath.row]
}
override func viewDidLoad() {
super.viewDidLoad()
tableView.rowHeight = UITableView.automaticDimension
tableView.estimatedRowHeight = 140
if let prefetchSource = self as? UITableViewDataSourcePrefetching {
tableView.prefetchDataSource = prefetchSource
}
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadInitial()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
pruneOffscreenRows()
}
func loadInitial() {
guard !loaded else { return }
loaded = true
loadInitialItems() { (items) in
guard items.count > 0 else { return }
DispatchQueue.main.async {
if self.sections.count < self.headerSectionsCount() {
self.sections.insert(contentsOf: Array(repeating: [], count: self.headerSectionsCount() - self.sections.count), at: 0)
}
self.sections.append(items)
self.tableView.reloadData()
}
}
}
func reloadInitialItems() {
loaded = false
sections = []
loadInitial()
}
func cellHeightChanged() {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
}
class func refreshCommandTitle() -> String {
return "Refresh"
}
func loadInitialItems(completion: @escaping ([Item]) -> Void) {
fatalError("loadInitialItems(completion:) must be implemented by subclasses")
}
func loadOlder(completion: @escaping ([Item]) -> Void) {
fatalError("loadOlder(completion:) must be implemented by subclasses")
}
func loadNewer(completion: @escaping ([Item]) -> Void) {
fatalError("loadNewer(completion:) must be implemented by subclasses")
}
func willRemoveRows(at indexPaths: [IndexPath]) {
}
func headerSectionsCount() -> Int {
return 0
}
private func pruneOffscreenRows() {
guard let lastVisibleRow = lastLastVisibleRow else {
return
}
let lastSectionIndex = sections.count - 1
if lastVisibleRow.section < lastSectionIndex {
// if there is a section below the last visible one
let sectionsToRemove = (lastVisibleRow.section + 1)...lastSectionIndex
let indexPathsToRemove = sectionsToRemove.flatMap { (section) in
sections[section].indices.map { (row) in
IndexPath(row: row, section: section)
}
}
willRemoveRows(at: indexPathsToRemove)
sections.removeSubrange(sectionsToRemove)
UIView.performWithoutAnimation {
tableView.deleteSections(IndexSet(sectionsToRemove), with: .none)
}
} else if lastVisibleRow.section == lastSectionIndex {
let lastSection = sections.last!
let lastRowIndex = lastSection.count - 1
if lastVisibleRow.row < lastRowIndex - pageSize {
// if there are more than pageSize rows in the current section below the last visible one
let rowIndicesInLastSectionToRemove = (lastVisibleRow.row + 20)..<lastSection.count
let indexPathsToRemove = rowIndicesInLastSectionToRemove.map {
IndexPath(row: $0, section: lastSectionIndex)
}
willRemoveRows(at: indexPathsToRemove)
sections[lastSectionIndex].removeSubrange(rowIndicesInLastSectionToRemove)
UIView.performWithoutAnimation {
tableView.deleteRows(at: indexPathsToRemove, with: .none)
}
}
}
}
// MARK: - UITableViewDataSource
override func numberOfSections(in tableView: UITableView) -> Int {
return sections.count
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return sections[section].count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
fatalError("tableView(_:cellForRowAt:) must be implemented by subclasses")
}
// MARK: - UITableViewDelegate
override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
// this assumes that indexPathsForVisibleRows is always in order
lastLastVisibleRow = tableView.indexPathsForVisibleRows?.last
if indexPath.section == sections.count - 1,
indexPath.row == sections[indexPath.section].count - 1 {
loadOlder() { (newItems) in
guard newItems.count > 0 else { return }
DispatchQueue.main.async {
let newRows = self.sections.last!.count..<(self.sections.last!.count + newItems.count)
let newIndexPaths = newRows.map { IndexPath(row: $0, section: self.sections.count - 1) }
self.sections[self.sections.count - 1].append(contentsOf: newItems)
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .none)
}
}
}
}
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
return true
}
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.leadingSwipeActionsConfiguration()
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return (tableView.cellForRow(at: indexPath) as? TableViewSwipeActionProvider)?.trailingSwipeActionsConfiguration()
}
// MARK: - RefreshableViewController
func refresh() {
loadNewer() { (newItems) in
DispatchQueue.main.async {
self.refreshControl?.endRefreshing()
guard newItems.count > 0 else { return }
let firstNonHeaderSection = self.headerSectionsCount()
self.sections[firstNonHeaderSection].insert(contentsOf: newItems, at: 0)
let newIndexPaths = (0..<newItems.count).map { IndexPath(row: $0, section: firstNonHeaderSection) }
UIView.performWithoutAnimation {
self.tableView.insertRows(at: newIndexPaths, with: .none)
}
// maintain the current position in the list (don't scroll to top)
self.tableView.scrollToRow(at: IndexPath(row: newItems.count, section: firstNonHeaderSection), at: .top, animated: false)
}
}
}
}
extension TimelineLikeTableViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
pruneOffscreenRows()
}
}