// // ConversationCollectionViewController.swift // Tusker // // Created by Shadowfacts on 8/28/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Pachyderm class ConversationNode { let status: StatusMO var children: [ConversationNode] init(status: StatusMO) { self.status = status self.children = [] } } class ConversationCollectionViewController: UIViewController, CollectionViewController { private let mastodonController: MastodonController private let mainStatusID: String private let mainStatusState: CollapseState var statusIDToScrollToOnLoad: String var showStatusesAutomatically = false var collectionView: UICollectionView! { view as? UICollectionView } private var dataSource: UICollectionViewDiffableDataSource! init(for mainStatusID: String, state: CollapseState, mastodonController: MastodonController) { self.mainStatusID = mainStatusID self.mainStatusState = state self.statusIDToScrollToOnLoad = mainStatusID self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func loadView() { var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .secondarySystemBackground config.leadingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() } config.trailingSwipeActionsConfigurationProvider = { [unowned self] in (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() } config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in let rowsInSection = self.collectionView.numberOfItems(inSection: indexPath.section) let lastInSection = indexPath.row == rowsInSection - 1 var config = sectionConfig config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = lastInSection ? .visible : .hidden config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets return config } // we're not using contenetInsetsReference = .readableContent here because it always insets the cells even if // the collection view's actual width is narrow enough to fit in the readable width, resulting in a bit of the // background color always peaking through the edges let layout = UICollectionViewCompositionalLayout.list(using: config) view = UICollectionView(frame: .zero, collectionViewLayout: layout) // something about the autoresizing mask breaks resizing the vc view.translatesAutoresizingMaskIntoConstraints = false collectionView.delegate = self collectionView.dragDelegate = self collectionView.allowsFocus = true dataSource = createDataSource() } private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self cell.showStatusAutomatically = self.showStatusesAutomatically cell.showReplyIndicator = false cell.updateUI(statusID: item.0, state: item.1, filterResult: .allow, precomputedContent: nil) cell.setShowThreadLinks(prev: item.2, next: item.3) } let mainStatusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, item in cell.delegate = self cell.showStatusAutomatically = self.showStatusesAutomatically cell.updateUI(statusID: item.0, state: item.1) cell.setShowThreadLinks(prev: item.2, next: false) } let expandThreadCell = UICollectionView.CellRegistration { cell, indexPath, item in cell.updateUI(childThreads: item.0, inline: item.1) } let loadingCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in cell.indicator.startAnimating() } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { case let .status(id: id, state: state, prevLink: prevLink, nextLink: nextLink): if id == self.mainStatusID { return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink)) } else { return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, prevLink, nextLink)) } case .expandThread(childThreads: let childThreads, inline: let inline): return collectionView.dequeueConfiguredReusableCell(using: expandThreadCell, for: indexPath, item: (childThreads, inline)) case .loadingIndicator: return collectionView.dequeueConfiguredReusableCell(using: loadingCell, for: indexPath, item: ()) } } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) } func addMainStatus(_ status: StatusMO) { loadViewIfNeeded() var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.statuses]) if status.inReplyToID != nil { snapshot.appendItems([.loadingIndicator], toSection: .statuses) } let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false) snapshot.appendItems([mainStatusItem], toSection: .statuses) dataSource.apply(snapshot, animatingDifferences: false) } func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async { let parentIDs = getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context.ancestors) let parentStatuses = context.ancestors.filter { parentIDs.contains($0.id) } await mastodonController.persistentContainer.addAll(statuses: parentStatuses + context.descendants) var snapshot = dataSource.snapshot() snapshot.deleteItems([.loadingIndicator]) let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false) let parentItems = parentIDs.enumerated().map { index, id in Item.status(id: id, state: .unknown, prevLink: index > 0, nextLink: true) } snapshot.insertItems(parentItems, beforeItem: mainStatusItem) snapshot.reloadItems([mainStatusItem]) // fetch all descendant status managed objects let descendantIDs = context.descendants.map(\.id) let request = StatusMO.fetchRequest() request.predicate = NSPredicate(format: "id IN %@", descendantIDs) if let descendants = try? 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: UICollectionView.ScrollPosition if self.statusIDToScrollToOnLoad == self.mainStatusID { item = mainStatusItem position = .centeredVertically } else { item = snapshot.itemIdentifiers.first { if case .status(id: self.statusIDToScrollToOnLoad, _, _, _) = $0 { return true } else { return false } }! 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.collectionView.scrollToItem(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.. Bool { switch (lhs, rhs) { case let (.status(id: a, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, state: _, prevLink: bPrev, nextLink: bNext)): return a == b && aPrev == bPrev && aNext == bNext case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)): return a.count == b.count && zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline case (.loadingIndicator, .loadingIndicator): return true default: return false } } func hash(into hasher: inout Hasher) { switch self { case let .status(id: id, state: _, prevLink: prevLink, nextLink: nextLink): hasher.combine(0) hasher.combine(id) hasher.combine(prevLink) hasher.combine(nextLink) case .expandThread(childThreads: let childThreads, inline: let inline): hasher.combine(1) for thread in childThreads { hasher.combine(thread.status.id) } hasher.combine(inline) case .loadingIndicator: hasher.combine(2) } } } } extension ConversationCollectionViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { switch dataSource.itemIdentifier(for: indexPath) { case .status(id: let id, state: _, prevLink: _, nextLink: _): return id != mainStatusID case .expandThread(childThreads: _, inline: _): return true default: return false } } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { switch dataSource.itemIdentifier(for: indexPath) { case nil: break case .loadingIndicator: break case .status(id: let id, state: let state, _, _): selected(status: id, state: state.copy()) case .expandThread(childThreads: let childThreads, inline: _): if case .status(id: let id, state: let state, _, _) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) { // todo: it would be nice to avoid re-fetching the context here, since we should have all the necessary information already let conv = ConversationViewController(for: id, state: state.copy(), mastodonController: mastodonController) conv.statusIDToScrollToOnLoad = childThreads.first!.status.id conv.showStatusesAutomatically = showStatusesAutomatically show(conv) } } } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } } extension ConversationCollectionViewController: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.dragItemsForBeginning(session: session) ?? [] } } extension ConversationCollectionViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension ConversationCollectionViewController: MenuActionProvider { } extension ConversationCollectionViewController: StatusCollectionViewCellDelegate { func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { if let indexPath = collectionView.indexPath(for: cell) { var snapshot = dataSource.snapshot() snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) } } func statusCellShowFiltered(_ cell: StatusCollectionViewCell) { // todo: support filtering in conversations } } extension ConversationCollectionViewController: TabBarScrollableViewController { func tabBarScrollToTop() { collectionView.scrollToTop() } } extension ConversationCollectionViewController: StatusBarTappableViewController { func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult { collectionView.scrollToTop() return .stop } }