434 lines
20 KiB
Swift
434 lines
20 KiB
Swift
//
|
|
// 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<Section, Item>!
|
|
|
|
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<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, Bool)> { [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<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, 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<Section, Item>()
|
|
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<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, 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, 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: _, 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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
extension ConversationCollectionViewController {
|
|
enum Section: Hashable {
|
|
case statuses
|
|
case childThread(firstStatusID: String)
|
|
}
|
|
enum Item: Hashable {
|
|
case status(id: String, 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, 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
|
|
}
|
|
}
|