diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 0486c58a..dc9b3fe2 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -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 = ""; }; D63661BF2381C144004B9E16 /* PreferencesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesNavigationController.swift; sourceTree = ""; }; D6370B9B24421FF30092A7FF /* Tusker.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = Tusker.xcdatamodel; sourceTree = ""; }; + D63A8D0A2561C27F00D9DFFF /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = ""; }; D63F9C6D241D2D85004C03CF /* CompositionAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompositionAttachment.swift; sourceTree = ""; }; D6403CC124A6B72D00E81C55 /* VisualEffectImageButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualEffectImageButton.swift; sourceTree = ""; }; D640D76822BAF5E6004FBE69 /* DomainBlocks.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = DomainBlocks.plist; sourceTree = ""; }; D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarScrollableViewController.swift; sourceTree = ""; }; D6412B0424B0227D00F5412E /* ProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewController.swift; sourceTree = ""; }; - D6412B0624B0237700F5412E /* ProfileStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStatusesViewController.swift; sourceTree = ""; }; D6412B0824B0291E00F5412E /* MyProfileViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyProfileViewController.swift; sourceTree = ""; }; D6412B0A24B0D4C600F5412E /* ProfileHeaderView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ProfileHeaderView.xib; sourceTree = ""; }; D6412B0C24B0D4CF00F5412E /* ProfileHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileHeaderView.swift; sourceTree = ""; }; - D641C772213CAA25004B4513 /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = ""; }; D641C77E213DC78A004B4513 /* InlineTextAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineTextAttachment.swift; sourceTree = ""; }; D6434EB2215B1856001A919A /* XCBRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBRequest.swift; sourceTree = ""; }; D646C955213B365700269FB5 /* LargeImageExpandAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageExpandAnimationController.swift; sourceTree = ""; }; @@ -492,6 +492,9 @@ D64D0AB02128D9AE005A6F37 /* OnboardingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingViewController.swift; sourceTree = ""; }; D64D8CA82463B494006B0BAA /* MultiThreadDictionary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiThreadDictionary.swift; sourceTree = ""; }; D64F80E1215875CC00BEF393 /* XCBActionType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCBActionType.swift; sourceTree = ""; }; + D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineLikeTableViewController.swift; sourceTree = ""; }; + D65234D225618EFA001AF9CF /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = ""; }; + D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsTableViewController.swift; sourceTree = ""; }; D6531DED246B81C9000F9538 /* GifvAttachmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentView.swift; sourceTree = ""; }; D6531DEF246B867E000F9538 /* GifvAttachmentViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GifvAttachmentViewController.swift; sourceTree = ""; }; D6538944214D6D7500E3CEFC /* TableViewSwipeActionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TableViewSwipeActionProvider.swift; sourceTree = ""; }; @@ -654,7 +657,6 @@ D6F1F84C2193B56E00F5FE67 /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = ""; }; D6F2E963249E8BFD005846BB /* CrashReporterViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrashReporterViewController.swift; sourceTree = ""; }; D6F2E964249E8BFD005846BB /* CrashReporterViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CrashReporterViewController.xib; sourceTree = ""; }; - D6F953EB212519E700CF0F2B /* TimelineTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineTableViewController.swift; sourceTree = ""; }; D6F953EF21251A2900CF0F2B /* MastodonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MastodonController.swift; sourceTree = ""; }; D6FF985F255C717400845181 /* AccountSwitchingContainerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSwitchingContainerViewController.swift; sourceTree = ""; }; /* 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 = ""; @@ -1372,6 +1374,7 @@ D6412B0224AFF6A600F5412E /* TabBarScrollableViewController.swift */, D65C6BF425478A9C00A6E89C /* BackgroundableViewController.swift */, D6B81F3B2560365300F6E31D /* RefreshableViewController.swift */, + D65234C8256189D0001AF9CF /* TimelineLikeTableViewController.swift */, ); path = Utilities; sourceTree = ""; @@ -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 */, diff --git a/Tusker/Screens/Lists/ListTimelineViewController.swift b/Tusker/Screens/Lists/ListTimelineViewController.swift index bf8dbac3..36af70eb 100644 --- a/Tusker/Screens/Lists/ListTimelineViewController.swift +++ b/Tusker/Screens/Lists/ListTimelineViewController.swift @@ -58,8 +58,7 @@ class ListTimelineViewController: TimelineTableViewController { dismiss(animated: true) // todo: show loading indicator - timelineSegments = [] - loadInitialStatuses() + reloadInitialItems() } } diff --git a/Tusker/Screens/Notifications/NotificationsTableViewController.swift b/Tusker/Screens/Notifications/NotificationsTableViewController.swift index d3b56778..02053d06 100644 --- a/Tusker/Screens/Notifications/NotificationsTableViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsTableViewController.swift @@ -9,8 +9,8 @@ import UIKit import Pachyderm -class NotificationsTableViewController: EnhancedTableViewController { - +class NotificationsTableViewController: TimelineLikeTableViewController { + 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).. 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.. { + 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.. 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.. (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.. 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() - } -} diff --git a/Tusker/Screens/Timeline/TimelineTableViewController.swift b/Tusker/Screens/Timeline/TimelineTableViewController.swift index ea4bdaf2..d350a221 100644 --- a/Tusker/Screens/Timeline/TimelineTableViewController.swift +++ b/Tusker/Screens/Timeline/TimelineTableViewController.swift @@ -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 { - 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).. 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..: 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).. 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..