463 lines
22 KiB
Swift
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
|
|
}
|
|
}
|