// // 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 { weak var mastodonController: MastodonController! let mainStatusID: String let mainStatusState: CollapseState var statusIDToScrollToOnLoad: String private(set) var dataSource: UITableViewDiffableDataSource! var showStatusesAutomatically = false init(for mainStatusID: String, state: CollapseState, 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() tableView.delegate = self tableView.dataSource = self tableView.prefetchDataSource = 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.allowsFocus = true 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 tableView.cellLayoutMarginsFollowReadableWidth = true dataSource = UITableViewDiffableDataSource(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(ViewTags.conversationBottomSeparator) == nil { let separator = UIView() separator.tag = ViewTags.conversationBottomSeparator 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(ViewTags.conversationBottomSeparator)?.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 } }) } func addMainStatus(_ status: StatusMO) { loadViewIfNeeded() let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) snapshot.appendItems([mainStatusItem], toSection: .statuses) dataSource.apply(snapshot, animatingDifferences: false) } func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async { let parentIDs = self.getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors) let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) } await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) 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.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) } } } 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) { 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.. (id: String, state: CollapseState)? { 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 conv.showStatusesAutomatically = showStatusesAutomatically 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() } func updateVisibleCellCollapseState() { 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() } } extension ConversationTableViewController { enum Section: Hashable { case statuses case childThread(firstStatusID: String) } enum Item: Hashable { case status(id: String, state: CollapseState) 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: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } func conversation(mainStatusID: String, state: CollapseState) -> ConversationViewController { let vc = ConversationViewController(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 { 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) } } extension ConversationTableViewController: ToastableViewController { }