// // ConversationCollectionViewController.swift // Tusker // // Created by Shadowfacts on 8/28/18. // Copyright © 2018 Shadowfacts. All rights reserved. // import UIKit import Pachyderm class ConversationCollectionViewController: UIViewController, CollectionViewController, RefreshableViewController { private unowned let conversationViewController: ConversationViewController 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, conversationViewController: ConversationViewController) { self.mainStatusID = mainStatusID self.mainStatusState = state self.statusIDToScrollToOnLoad = mainStatusID self.conversationViewController = conversationViewController self.mastodonController = conversationViewController.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 = .appSecondaryBackground 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 var config = sectionConfig config.topSeparatorVisibility = .hidden if case .ancestors = self.dataSource.sectionIdentifier(for: indexPath.section) { config.bottomSeparatorVisibility = .hidden } else if indexPath.row == self.collectionView.numberOfItems(inSection: indexPath.section) - 1 { config.bottomSeparatorVisibility = .visible } else { config.bottomSeparatorVisibility = .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 peeking 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 #if !targetEnvironment(macCatalyst) collectionView.refreshControl = UIRefreshControl() collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) #endif 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, node: _, 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([.ancestors, .mainStatus]) if status.inReplyToID != nil { snapshot.appendItems([.loadingIndicator], toSection: .ancestors) } // this will be replace with the actual node in the tree once it's loaded let tempMainNode = ConversationNode(status: status) let mainStatusItem = Item.status(id: mainStatusID, node: tempMainNode, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false) snapshot.appendItems([mainStatusItem], toSection: .mainStatus) dataSource.apply(snapshot, animatingDifferences: false) } func addTree(_ tree: ConversationTree, mainStatus: StatusMO) { var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.ancestors, .mainStatus]) let mainStatusItem = Item.status(id: mainStatusID, node: tree.mainStatus, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false) snapshot.appendItems([mainStatusItem], toSection: .mainStatus) let parentItems = tree.ancestors.enumerated().map { index, node in Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true) } snapshot.appendItems(parentItems, toSection: .ancestors) snapshot.reloadItems([mainStatusItem]) // convert sub-threads into items for section and add to snapshot self.addFlattenedChildThreadsToSnapshot(tree.descendants, 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 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, node: _, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, node: _, 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, node: _, 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, node: _, 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, node: let node, state: let state, _, _): // we can only take the fast path if the user tapped on a descendant status. // if the current main status is C, or one of its descendants, and the user taps A, then B won't be loaded: // A // / \ // B C if case .childThread(_) = dataSource.sectionIdentifier(for: indexPath.section) { let tree = ConversationTree(ancestors: buildNewAncestors(above: indexPath), mainStatus: node) let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController) conv.showStatusesAutomatically = showStatusesAutomatically show(conv) } else { selected(status: id, state: state.copy()) } case .expandThread(childThreads: let childThreads, inline: _): let indexPathBeforeExpandThread = IndexPath(row: indexPath.row - 1, section: indexPath.section) if case .status(id: _, node: let node, state: let state, _, _) = dataSource.itemIdentifier(for: indexPathBeforeExpandThread) { let tree = ConversationTree(ancestors: buildNewAncestors(above: indexPathBeforeExpandThread), mainStatus: node) let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController) conv.statusIDToScrollToOnLoad = childThreads.first!.status.id conv.showStatusesAutomatically = showStatusesAutomatically show(conv) } } } // ConversationNode doesn't know about its parent, so we reconstruct that info from the data source private func buildNewAncestors(above indexPath: IndexPath) -> [ConversationNode] { let snapshot = dataSource.snapshot() let currentAncestors = snapshot.itemIdentifiers(inSection: .ancestors).compactMap { if case .status(id: _, node: let node, _, _, _) = $0 { return node } else { return nil } } let currentMainStatus = snapshot.itemIdentifiers(inSection: .mainStatus).compactMap { if case .status(id: _, node: let node, _, _, _) = $0 { return node } else { return nil } } let parentsInCurrentSection = snapshot.itemIdentifiers(inSection: dataSource.sectionIdentifier(for: indexPath.section)!)[0.. 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 } }