Tusker/Tusker/Screens/Conversation/ConversationCollectionViewC...

463 lines
22 KiB
Swift

//
// 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
private var mainStatusTranslation: Translation?
var statusIDToScrollToOnLoad: String
var showStatusesAutomatically = false
var collectionView: UICollectionView! {
view as? UICollectionView
}
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
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
}
let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
section.readableContentInset(in: environment)
return section
}
viewRespectsSystemMinimumLayoutMargins = false
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<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (String, CollapseState, Bool, Bool)> { [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<ConversationMainStatusCollectionViewCell, (String, CollapseState, Translation?, Bool)> { [unowned self] cell, indexPath, item in
cell.delegate = self
cell.translateStatus = { [unowned self] in
self.translateMainStatus()
}
cell.showStatusAutomatically = self.showStatusesAutomatically
cell.updateUI(statusID: item.0, state: item.1, translation: item.2)
cell.setShowThreadLinks(prev: item.3, next: false)
}
let expandThreadCell = UICollectionView.CellRegistration<ExpandThreadCollectionViewCell, ([ConversationNode], Bool)> { cell, indexPath, item in
cell.updateUI(childThreads: item.0, inline: item.1)
}
let loadingCell = UICollectionView.CellRegistration<LoadingCollectionViewCell, Void> { 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, self.mainStatusTranslation, 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<Section, Item>()
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<Section, Item>()
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)
// don't need to reconfigure main item, since when the refreshed copy was loaded
// it would have triggered a reconfigure via the status observer
// 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<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 {
let section = Section.childThread(firstStatusID: node.status.id)
snapshot.appendSections([section])
snapshot.appendItems([.status(id: node.status.id, node: node, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
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, node: next, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
}
}
}
func updateVisibleCellCollapseState() {
var snapshot = dataSource.snapshot()
var cellsToMask: [StatusCollectionViewCell] = []
for item in snapshot.itemIdentifiers {
guard case .status(id: _, node: _, state: let state, prevLink: _, nextLink: _) = item,
state.collapsible == true else {
continue
}
state.collapsed = !showStatusesAutomatically
snapshot.reconfigureItems([item])
if let indexPath = dataSource.indexPath(for: item),
let cell = collectionView.cellForItem(at: indexPath) as? StatusCollectionViewCell {
cellsToMask.append(cell)
}
}
for cell in cellsToMask {
cell.contentContainer.layer.masksToBounds = true
}
dataSource.apply(snapshot, animatingDifferences: true) {
// this is an absurdly long delay, I have no idea why it's necessary
// without it, the layer is not maksed during the animation
// unless there's only one cell
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
for cell in cellsToMask {
cell.contentContainer.layer.masksToBounds = false
}
}
}
}
@objc func refresh() {
Task {
await conversationViewController.refreshContext()
#if !targetEnvironment(macCatalyst)
self.collectionView.refreshControl!.endRefreshing()
#endif
}
}
private func translateMainStatus() {
Task { @MainActor in
let translation: Translation
do {
translation = try await mastodonController.run(Status.translate(mainStatusID)).0
} catch {
let config = ToastConfiguration(from: error, with: "Error Translating", in: self) { toast in
toast.dismissToast(animated: true)
self.translateMainStatus()
}
self.showToast(configuration: config, animated: true)
return
}
mainStatusTranslation = translation
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems(snapshot.itemIdentifiers(inSection: .mainStatus))
await MainActor.run {
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
}
extension ConversationCollectionViewController {
enum Section: Hashable {
case ancestors
case mainStatus
case childThread(firstStatusID: String)
}
enum Item: Hashable, Sendable {
case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool)
case expandThread(childThreads: [ConversationNode], inline: Bool)
case loadingIndicator
static func ==(lhs: Item, rhs: Item) -> 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.statusID == $1.statusID } && 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.statusID)
}
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..<indexPath.row].compactMap {
if case .status(id: _, node: let node, _, _, _) = $0 {
return node
} else {
return nil
}
}
return currentAncestors + currentMainStatus + parentsInCurrentSection
}
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)
}
#if os(visionOS)
func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
return self.collectionView(collectionView, shouldSelectItemAt: indexPath)
}
#endif
}
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
}
}