Tusker/Tusker/Screens/Notifications/NotificationsTableViewController.swift
Shadowfacts 89b35fab6d
Move pruning of offscreen rows to when the VC disappears, instead of
during scrolling

Prevents race when removing and adding cells in the willDisplay table
view delegate method.
2020-10-26 22:55:58 -04:00

327 lines
14 KiB
Swift

//
// NotificationsTableViewController.swift
// Tusker
//
// Created by Shadowfacts on 9/2/18.
// Copyright © 2018 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
class NotificationsTableViewController: EnhancedTableViewController {
private let statusCell = "statusCell"
private let actionGroupCell = "actionGroupCell"
private let followGroupCell = "followGroupCell"
private let followRequestCell = "followRequestCell"
private let unknownCell = "unknownCell"
weak var mastodonController: MastodonController!
private let excludedTypes: [Pachyderm.Notification.Kind]
private let groupTypes = [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)
self.refreshControl = UIRefreshControl()
refreshControl!.addTarget(self, action: #selector(refreshNotifications(_:)), for: .valueChanged)
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
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
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()
}
}
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
pruneOffscreenRows()
}
private func pruneOffscreenRows() {
guard let lastVisibleRow = lastLastVisibleRow else {
return
}
let lastRowIndex = groups.count - 1
if lastVisibleRow.row < lastRowIndex - pageSize {
// if there are more than 20 rows below the lats visible one
let rowIndicesToRemove = (lastVisibleRow.row + pageSize)..<groups.count
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()
}
}
}
groups.removeSubrange(rowIndicesToRemove)
let removedIndexPaths = rowIndicesToRemove.map { IndexPath(row: $0, section: 0) }
UIView.performWithoutAnimation {
tableView.deleteRows(at: removedIndexPaths, with: .none)
}
}
}
// MARK: - Table view data source
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return groups.count
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let group = groups[indexPath.row]
switch group.kind {
case .mention:
guard let notification = group.notifications.first,
let cell = tableView.dequeueReusableCell(withIdentifier: statusCell, for: indexPath) as? TimelineStatusTableViewCell else {
fatalError()
}
cell.delegate = self
cell.updateUI(statusID: notification.status!.id, state: group.statusState!)
return cell
case .favourite, .reblog:
guard let cell = tableView.dequeueReusableCell(withIdentifier: actionGroupCell, for: indexPath) as? ActionNotificationGroupTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(group: group)
return cell
case .follow:
guard let cell = tableView.dequeueReusableCell(withIdentifier: followGroupCell, for: indexPath) as? FollowNotificationGroupTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(group: group)
return cell
case .followRequest:
guard let notification = group.notifications.first,
let cell = tableView.dequeueReusableCell(withIdentifier: followRequestCell, for: indexPath) as? FollowRequestNotificationTableViewCell else { fatalError() }
cell.delegate = self
cell.updateUI(notification: notification)
return cell
case .unknown:
let cell = tableView.dequeueReusableCell(withIdentifier: unknownCell, for: indexPath)
cell.textLabel!.text = NSLocalizedString("Unknown Notification", comment: "unknown notification fallback cell text")
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()
}
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
self.dismissNotificationsInGroup(at: indexPath) {
completion(true)
}
}
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])
config.performsFirstActionWithFullSwipe = cellConfiguration.performsFirstActionWithFullSwipe
} else {
config = UISwipeActionsConfiguration(actions: [dismissAction])
config.performsFirstActionWithFullSwipe = false
}
return config
}
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
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(_ sender: Any) {
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: StatusTableViewCellDelegate {
var apiController: MastodonController { mastodonController }
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
// causes the table view to recalculate the cell heights
tableView.beginUpdates()
tableView.endUpdates()
}
}
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
_ = ImageCache.avatars.get(notification.account.avatar, completion: nil)
}
}
}
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
for indexPath in indexPaths {
for notification in groups[indexPath.row].notifications {
ImageCache.avatars.cancelWithoutCallback(notification.account.avatar)
}
}
}
}
extension NotificationsTableViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
pruneOffscreenRows()
}
}