forked from shadowfacts/Tusker
478 lines
20 KiB
Swift
478 lines
20 KiB
Swift
//
|
|
// ConversationTableViewController.swift
|
|
// Tusker
|
|
//
|
|
// Created by Shadowfacts on 8/28/18.
|
|
// Copyright © 2018 Shadowfacts. All rights reserved.
|
|
//
|
|
|
|
import UIKit
|
|
import Pachyderm
|
|
import CoreData
|
|
|
|
class ConversationNode {
|
|
let status: StatusMO
|
|
var children: [ConversationNode]
|
|
|
|
init(status: StatusMO) {
|
|
self.status = status
|
|
self.children = []
|
|
}
|
|
}
|
|
|
|
class ConversationTableViewController: EnhancedTableViewController {
|
|
|
|
static let showPostsImage = UIImage(systemName: "eye.fill")!
|
|
static let hidePostsImage = UIImage(systemName: "eye.slash.fill")!
|
|
|
|
static let bottomSeparatorTag = 101
|
|
|
|
weak var mastodonController: MastodonController!
|
|
|
|
let mainStatusID: String
|
|
let mainStatusState: StatusState
|
|
var statusIDToScrollToOnLoad: String
|
|
private(set) var dataSource: UITableViewDiffableDataSource<Section, Item>!
|
|
|
|
var showStatusesAutomatically = false
|
|
var visibilityBarButtonItem: UIBarButtonItem!
|
|
|
|
private var loadingState = LoadingState.unloaded
|
|
|
|
init(for mainStatusID: String, state: StatusState = .unknown, mastodonController: MastodonController) {
|
|
self.mainStatusID = mainStatusID
|
|
self.mainStatusState = state
|
|
self.statusIDToScrollToOnLoad = mainStatusID
|
|
self.mastodonController = mastodonController
|
|
|
|
super.init(style: .plain)
|
|
|
|
dragEnabled = true
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
|
|
title = NSLocalizedString("Conversation", comment: "conversation screen title")
|
|
|
|
tableView.delegate = self
|
|
tableView.dataSource = self
|
|
|
|
tableView.register(UINib(nibName: "TimelineStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "statusCell")
|
|
tableView.register(UINib(nibName: "ConversationMainStatusTableViewCell", bundle: nil), forCellReuseIdentifier: "mainStatusCell")
|
|
tableView.register(UINib(nibName: "ExpandThreadTableViewCell", bundle: .main), forCellReuseIdentifier: "expandThreadCell")
|
|
|
|
tableView.prefetchDataSource = self
|
|
|
|
tableView.backgroundColor = .secondarySystemBackground
|
|
// separators are disabled on the table view so we can re-add them ourselves
|
|
// so they're not inserted in between statuses in the ame sub-thread
|
|
tableView.separatorStyle = .none
|
|
|
|
dataSource = UITableViewDiffableDataSource<Section, Item>(tableView: tableView, cellProvider: { (tableView, indexPath, item) -> UITableViewCell? in
|
|
switch item {
|
|
case let .status(id: id, state: state):
|
|
let rowsInSection = self.dataSource.tableView(tableView, numberOfRowsInSection: indexPath.section)
|
|
let firstInSection = indexPath.row == 0
|
|
let lastInSection = indexPath.row == rowsInSection - 1
|
|
|
|
let identifier = id == self.mainStatusID ? "mainStatusCell" : "statusCell"
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: identifier, for: indexPath) as! BaseStatusTableViewCell
|
|
|
|
if id == self.mainStatusID {
|
|
cell.selectionStyle = .none
|
|
}
|
|
|
|
cell.delegate = self
|
|
cell.showStatusAutomatically = self.showStatusesAutomatically
|
|
|
|
if let cell = cell as? TimelineStatusTableViewCell {
|
|
cell.showReplyIndicator = false
|
|
}
|
|
|
|
cell.updateUI(statusID: id, state: state)
|
|
|
|
cell.setShowThreadLinks(prev: !firstInSection, next: !lastInSection)
|
|
if lastInSection {
|
|
if cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag) == nil {
|
|
let separator = UIView()
|
|
separator.tag = ConversationTableViewController.bottomSeparatorTag
|
|
separator.translatesAutoresizingMaskIntoConstraints = false
|
|
separator.backgroundColor = tableView.separatorColor
|
|
cell.addSubview(separator)
|
|
NSLayoutConstraint.activate([
|
|
separator.heightAnchor.constraint(equalToConstant: 0.5),
|
|
separator.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
|
|
separator.leftAnchor.constraint(equalTo: cell.leftAnchor, constant: cell.separatorInset.left),
|
|
separator.rightAnchor.constraint(equalTo: cell.rightAnchor),
|
|
])
|
|
}
|
|
} else {
|
|
cell.viewWithTag(ConversationTableViewController.bottomSeparatorTag)?.removeFromSuperview()
|
|
}
|
|
|
|
return cell
|
|
|
|
case let .expandThread(childThreads: childThreads, inline: inline):
|
|
let cell = tableView.dequeueReusableCell(withIdentifier: "expandThreadCell", for: indexPath) as! ExpandThreadTableViewCell
|
|
cell.updateUI(childThreads: childThreads, inline: inline)
|
|
return cell
|
|
}
|
|
})
|
|
|
|
visibilityBarButtonItem = UIBarButtonItem(image: ConversationTableViewController.showPostsImage, style: .plain, target: self, action: #selector(toggleVisibilityButtonPressed))
|
|
visibilityBarButtonItem.isSelected = showStatusesAutomatically
|
|
navigationItem.rightBarButtonItem = visibilityBarButtonItem
|
|
// disable transparent background when scroll to top because it looks weird when items earlier in the thread load in
|
|
// (it remains transparent slightly too long, resulting in a flash of the content under the transparent bar)
|
|
let appearance = UINavigationBarAppearance()
|
|
appearance.configureWithDefaultBackground()
|
|
navigationItem.scrollEdgeAppearance = appearance
|
|
|
|
Task {
|
|
await loadMainStatus()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private func loadMainStatus() async {
|
|
guard loadingState == .unloaded else { return }
|
|
|
|
if let mainStatus = mastodonController.persistentContainer.status(for: mainStatusID) {
|
|
await mainStatusLoaded(mainStatus)
|
|
} else {
|
|
loadingState = .loadingMain
|
|
let req = Client.getStatus(id: mainStatusID)
|
|
do {
|
|
let (status, _) = try await mastodonController.run(req)
|
|
let statusMO = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
|
|
await mainStatusLoaded(statusMO)
|
|
} catch {
|
|
let error = error as! Client.Error
|
|
loadingState = .unloaded
|
|
let config = ToastConfiguration(from: error, with: "Error Loading Status", in: self) { [weak self] toast in
|
|
toast.dismissToast(animated: true)
|
|
await self?.loadMainStatus()
|
|
}
|
|
showToast(configuration: config, animated: true)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
|
|
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
|
|
|
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
|
snapshot.appendSections([.statuses])
|
|
snapshot.appendItems([mainStatusItem], toSection: .statuses)
|
|
await dataSource.apply(snapshot, animatingDifferences: false)
|
|
|
|
loadingState = .loadedMain
|
|
|
|
await loadContext(for: mainStatus)
|
|
}
|
|
|
|
@MainActor
|
|
private func loadContext(for mainStatus: StatusMO) async {
|
|
guard loadingState == .loadedMain else { return }
|
|
|
|
loadingState = .loadingContext
|
|
|
|
// save the id here because we can't access the MO from the whatever thread the network callback happens on
|
|
let mainStatusInReplyToID = mainStatus.inReplyToID
|
|
|
|
// todo: it would be nice to cache these contexts
|
|
let request = Status.getContext(mainStatusID)
|
|
do {
|
|
let (context, _) = try await mastodonController.run(request)
|
|
let parentIDs = self.getDirectParents(inReplyTo: mainStatusInReplyToID, from: context.ancestors)
|
|
let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) }
|
|
|
|
// todo: should this really be blindly adding all the descendants?
|
|
await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants)
|
|
self.contextLoaded(mainStatus: mainStatus, context: context, parentIDs: parentIDs)
|
|
|
|
} catch {
|
|
let error = error as! Client.Error
|
|
self.loadingState = .loadedMain
|
|
|
|
let config = ToastConfiguration(from: error, with: "Error Loading Content", in: self) { [weak self] (toast) in
|
|
toast.dismissToast(animated: true)
|
|
await self?.loadContext(for: mainStatus)
|
|
}
|
|
self.showToast(configuration: config, animated: true)
|
|
}
|
|
}
|
|
|
|
private func contextLoaded(mainStatus: StatusMO, context: ConversationContext, parentIDs: [String]) {
|
|
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState)
|
|
|
|
var snapshot = self.dataSource.snapshot()
|
|
snapshot.insertItems(parentIDs.map { .status(id: $0, state: .unknown) }, beforeItem: mainStatusItem)
|
|
|
|
// fetch all descendant status managed objects
|
|
let descendantIDs = context.descendants.map(\.id)
|
|
let request: NSFetchRequest<StatusMO> = StatusMO.fetchRequest()
|
|
request.predicate = NSPredicate(format: "id in %@", descendantIDs)
|
|
|
|
if let descendants = try? self.mastodonController.persistentContainer.viewContext.fetch(request) {
|
|
// convert array of descendant statuses into tree of sub-threads
|
|
let childThreads = self.getDescendantThreads(mainStatus: mainStatus, descendants: descendants)
|
|
|
|
// convert sub-threads into items for section and add to snapshot
|
|
self.addFlattenedChildThreadsToSnapshot(childThreads, mainStatus: mainStatus, snapshot: &snapshot)
|
|
}
|
|
|
|
self.dataSource.apply(snapshot, animatingDifferences: false) {
|
|
let item: Item
|
|
let position: UITableView.ScrollPosition
|
|
if self.statusIDToScrollToOnLoad == self.mainStatusID {
|
|
item = mainStatusItem
|
|
position = .middle
|
|
} else {
|
|
item = Item.status(id: self.statusIDToScrollToOnLoad, state: .unknown)
|
|
position = .top
|
|
}
|
|
// ensure that the status is on-screen after newly loaded statuses are added
|
|
// todo: should this not happen if the user has already started scrolling (e.g. because the main status is very long)?
|
|
if let indexPath = self.dataSource.indexPath(for: item) {
|
|
self.tableView.scrollToRow(at: indexPath, at: position, animated: false)
|
|
}
|
|
}
|
|
|
|
self.loadingState = .loadedAll
|
|
}
|
|
|
|
private func getDirectParents(inReplyTo inReplyToID: String?, from statuses: [Status]) -> [String] {
|
|
var statuses = statuses
|
|
var parents = [String]()
|
|
|
|
var parentID: String? = inReplyToID
|
|
|
|
while parentID != nil, let parentIndex = statuses.firstIndex(where: { $0.id == parentID }) {
|
|
let parentStatus = statuses.remove(at: parentIndex)
|
|
parents.insert(parentStatus.id, at: 0)
|
|
parentID = parentStatus.inReplyToID
|
|
}
|
|
|
|
return parents
|
|
}
|
|
|
|
private func getDescendantThreads(mainStatus: StatusMO, descendants: [StatusMO]) -> [ConversationNode] {
|
|
var descendants = descendants
|
|
|
|
func removeAllInReplyTo(id: String) -> [StatusMO] {
|
|
let statuses = descendants.filter { $0.inReplyToID == id }
|
|
descendants.removeAll { $0.inReplyToID == id }
|
|
return statuses
|
|
}
|
|
|
|
var nodes: [String: ConversationNode] = [
|
|
mainStatus.id: ConversationNode(status: mainStatus)
|
|
]
|
|
|
|
var idsToCheck = [mainStatusID]
|
|
|
|
while !idsToCheck.isEmpty {
|
|
let inReplyToID = idsToCheck.removeFirst()
|
|
let nodeForID = nodes[inReplyToID]!
|
|
|
|
let inReply = removeAllInReplyTo(id: inReplyToID)
|
|
for reply in inReply {
|
|
idsToCheck.append(reply.id)
|
|
|
|
let replyNode = ConversationNode(status: reply)
|
|
nodes[reply.id] = replyNode
|
|
|
|
nodeForID.children.append(replyNode)
|
|
}
|
|
}
|
|
|
|
return nodes[mainStatusID]!.children
|
|
}
|
|
|
|
private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
|
|
var childThreads = childThreads
|
|
|
|
// child threads by the same author as the main status come first
|
|
let pivotIndex = childThreads.partition(by: { $0.status.account.id != mainStatus.account.id })
|
|
|
|
// within each group, child threads are sorted chronologically
|
|
childThreads[0..<pivotIndex].sort(by: { $0.status.createdAt < $1.status.createdAt })
|
|
childThreads[pivotIndex...].sort(by: { $0.status.createdAt < $1.status.createdAt })
|
|
|
|
for node in childThreads {
|
|
snapshot.appendSections([.childThread(firstStatusID: node.status.id)])
|
|
snapshot.appendItems([.status(id: node.status.id, state: .unknown)])
|
|
|
|
var currentNode = node
|
|
while true {
|
|
let next: ConversationNode
|
|
|
|
if currentNode.children.count == 0 {
|
|
break
|
|
} else if currentNode.children.count == 1 {
|
|
next = currentNode.children[0]
|
|
} else {
|
|
let sameAuthorStatuses = currentNode.children.filter({ $0.status.account.id == node.status.account.id })
|
|
if sameAuthorStatuses.count == 1 {
|
|
next = sameAuthorStatuses[0]
|
|
let nonSameAuthorChildren = currentNode.children.filter { $0.status.id != sameAuthorStatuses[0].status.id }
|
|
snapshot.appendItems([.expandThread(childThreads: nonSameAuthorChildren, inline: true)])
|
|
} else {
|
|
snapshot.appendItems([.expandThread(childThreads: currentNode.children, inline: false)])
|
|
break
|
|
}
|
|
}
|
|
|
|
currentNode = next
|
|
snapshot.appendItems([.status(id: next.status.id, state: .unknown)])
|
|
}
|
|
}
|
|
}
|
|
|
|
func item(for indexPath: IndexPath) -> (id: String, state: StatusState)? {
|
|
return self.dataSource.itemIdentifier(for: indexPath).flatMap { (item) in
|
|
switch item {
|
|
case let .status(id: id, state: state):
|
|
return (id: id, state: state)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Table view delegate
|
|
|
|
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
|
if case let .expandThread(childThreads: childThreads, inline: _) = dataSource.itemIdentifier(for: indexPath),
|
|
case let .status(id: id, state: state) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
|
|
let conv = ConversationTableViewController(for: id, state: state, mastodonController: mastodonController)
|
|
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
|
|
show(conv)
|
|
} else {
|
|
super.tableView(tableView, didSelectRowAt: indexPath)
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
@objc func toggleVisibilityButtonPressed() {
|
|
showStatusesAutomatically = !showStatusesAutomatically
|
|
|
|
let snapshot = dataSource.snapshot()
|
|
for case let .status(id: _, state: state) in snapshot.itemIdentifiers where state.collapsible == true {
|
|
state.collapsed = !showStatusesAutomatically
|
|
}
|
|
|
|
for cell in tableView.visibleCells {
|
|
guard let cell = cell as? BaseStatusTableViewCell,
|
|
cell.collapsible else { continue }
|
|
cell.showStatusAutomatically = showStatusesAutomatically
|
|
cell.setCollapsed(!showStatusesAutomatically, animated: false)
|
|
}
|
|
|
|
// recalculate cell heights
|
|
tableView.beginUpdates()
|
|
tableView.endUpdates()
|
|
|
|
visibilityBarButtonItem.isSelected = showStatusesAutomatically
|
|
}
|
|
|
|
}
|
|
|
|
extension ConversationTableViewController {
|
|
enum Section: Hashable {
|
|
case statuses
|
|
case childThread(firstStatusID: String)
|
|
}
|
|
enum Item: Hashable {
|
|
case status(id: String, state: StatusState)
|
|
case expandThread(childThreads: [ConversationNode], inline: Bool)
|
|
|
|
static func == (lhs: Item, rhs: Item) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case let (.status(id: a, state: _), .status(id: b, state: _)):
|
|
return a == b
|
|
case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)):
|
|
return zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func hash(into hasher: inout Hasher) {
|
|
switch self {
|
|
case let .status(id: id, state: _):
|
|
hasher.combine("status")
|
|
hasher.combine(id)
|
|
case let .expandThread(childThreads: children, inline: inline):
|
|
hasher.combine("expandThread")
|
|
hasher.combine(children.map(\.status.id))
|
|
hasher.combine(inline)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
extension ConversationTableViewController {
|
|
private enum LoadingState: Equatable {
|
|
case unloaded
|
|
case loadingMain
|
|
case loadedMain
|
|
case loadingContext
|
|
case loadedAll
|
|
}
|
|
}
|
|
|
|
extension ConversationTableViewController: TuskerNavigationDelegate {
|
|
func conversation(mainStatusID: String, state: StatusState) -> ConversationTableViewController {
|
|
let vc = ConversationTableViewController(for: mainStatusID, state: state, mastodonController: mastodonController)
|
|
// transfer show statuses automatically state when showing new conversation
|
|
vc.showStatusesAutomatically = self.showStatusesAutomatically
|
|
return vc
|
|
}
|
|
}
|
|
|
|
extension ConversationTableViewController: MenuActionProvider {
|
|
}
|
|
|
|
extension ConversationTableViewController: StatusTableViewCellDelegate {
|
|
var apiController: MastodonController { mastodonController }
|
|
func statusCellCollapsedStateChanged(_ cell: BaseStatusTableViewCell) {
|
|
// causes the table view to recalculate the cell heights
|
|
tableView.beginUpdates()
|
|
tableView.endUpdates()
|
|
}
|
|
}
|
|
|
|
extension ConversationTableViewController: UITableViewDataSourcePrefetching, StatusTablePrefetching {
|
|
func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) {
|
|
let ids: [String] = indexPaths.compactMap { item(for: $0)?.id }
|
|
prefetchStatuses(with: ids)
|
|
}
|
|
|
|
func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) {
|
|
let ids: [String] = indexPaths.compactMap { item(for: $0)?.id }
|
|
cancelPrefetchingStatuses(with: ids)
|
|
}
|
|
}
|
|
|
|
extension ConversationTableViewController: ToastableViewController {
|
|
}
|