Compare commits

..

3 Commits

5 changed files with 274 additions and 147 deletions

View File

@ -297,6 +297,7 @@
D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; };
D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; }; D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */; };
D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; }; D6C3F4F7298ED7F70009FCFF /* FavoritesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */; };
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */; };
D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; }; D6C693EF216192C2007D6A6D /* TuskerNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */; };
D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; }; D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */; };
D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; }; D6C693FE2162FEEA007D6A6D /* UIViewController+Children.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */; };
@ -706,6 +707,7 @@
D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; }; D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusCardView.swift; sourceTree = "<group>"; };
D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; }; D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = "<group>"; };
D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; }; D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = "<group>"; };
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTree.swift; sourceTree = "<group>"; };
D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; }; D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = "<group>"; };
D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; }; D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = "<group>"; };
D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; }; D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = "<group>"; };
@ -1088,6 +1090,7 @@
D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */, D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */,
D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */, D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */,
D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */, D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */,
D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */,
); );
path = Conversation; path = Conversation;
sourceTree = "<group>"; sourceTree = "<group>";
@ -2025,6 +2028,7 @@
D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */, D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */,
D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */, D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */,
D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */, D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */,
D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */,
D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */, D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */,
D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */,
D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */, D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */,

View File

@ -9,16 +9,6 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class ConversationNode {
let status: StatusMO
var children: [ConversationNode]
init(status: StatusMO) {
self.status = status
self.children = []
}
}
class ConversationCollectionViewController: UIViewController, CollectionViewController { class ConversationCollectionViewController: UIViewController, CollectionViewController {
private let mastodonController: MastodonController private let mastodonController: MastodonController
@ -55,11 +45,15 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
(collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions()
} }
config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in config.itemSeparatorHandler = { [unowned self] indexPath, sectionConfig in
let rowsInSection = self.collectionView.numberOfItems(inSection: indexPath.section)
let lastInSection = indexPath.row == rowsInSection - 1
var config = sectionConfig var config = sectionConfig
config.topSeparatorVisibility = .hidden config.topSeparatorVisibility = .hidden
config.bottomSeparatorVisibility = lastInSection ? .visible : .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 config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets
return config return config
} }
@ -99,7 +93,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
} }
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier { switch itemIdentifier {
case let .status(id: id, state: state, prevLink: prevLink, nextLink: nextLink): case let .status(id: id, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
if id == self.mainStatusID { if id == self.mainStatusID {
return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink)) return collectionView.dequeueConfiguredReusableCell(using: mainStatusCell, for: indexPath, item: (id, state, prevLink))
} else { } else {
@ -123,45 +117,32 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
loadViewIfNeeded() loadViewIfNeeded()
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
snapshot.appendSections([.statuses]) snapshot.appendSections([.ancestors, .mainStatus])
if status.inReplyToID != nil { if status.inReplyToID != nil {
snapshot.appendItems([.loadingIndicator], toSection: .statuses) snapshot.appendItems([.loadingIndicator], toSection: .ancestors)
} }
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false) // this will be replace with the actual node in the tree once it's loaded
snapshot.appendItems([mainStatusItem], toSection: .statuses) 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) dataSource.apply(snapshot, animatingDifferences: false)
} }
func addContext(_ context: ConversationContext, for mainStatus: StatusMO) async { func addTree(_ tree: ConversationTree, mainStatus: StatusMO) {
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() var snapshot = dataSource.snapshot()
snapshot.deleteItems([.loadingIndicator]) snapshot.deleteItems([.loadingIndicator])
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false) let mainStatusItem = Item.status(id: mainStatusID, node: tree.mainStatus, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false)
let parentItems = parentIDs.enumerated().map { index, id in let parentItems = tree.ancestors.enumerated().map { index, node in
Item.status(id: id, state: .unknown, prevLink: index > 0, nextLink: true) Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true)
} }
snapshot.insertItems(parentItems, beforeItem: mainStatusItem) snapshot.appendItems(parentItems, toSection: .ancestors)
snapshot.reloadItems([mainStatusItem]) snapshot.reloadItems([mainStatusItem])
// fetch all descendant status managed objects // convert sub-threads into items for section and add to snapshot
let descendantIDs = context.descendants.map(\.id) self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot)
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) { self.dataSource.apply(snapshot, animatingDifferences: false) {
let item: Item let item: Item
@ -171,7 +152,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
position = .centeredVertically position = .centeredVertically
} else { } else {
item = snapshot.itemIdentifiers.first { item = snapshot.itemIdentifiers.first {
if case .status(id: self.statusIDToScrollToOnLoad, _, _, _) = $0 { if case .status(id: self.statusIDToScrollToOnLoad, _, _, _, _) = $0 {
return true return true
} else { } else {
return false return false
@ -187,54 +168,6 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
} }
} }
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>) { private func addFlattenedChildThreadsToSnapshot(_ childThreads: [ConversationNode], mainStatus: StatusMO, snapshot: inout NSDiffableDataSourceSnapshot<Section, Item>) {
var childThreads = childThreads var childThreads = childThreads
@ -248,7 +181,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
for node in childThreads { for node in childThreads {
let section = Section.childThread(firstStatusID: node.status.id) let section = Section.childThread(firstStatusID: node.status.id)
snapshot.appendSections([section]) snapshot.appendSections([section])
snapshot.appendItems([.status(id: node.status.id, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section) snapshot.appendItems([.status(id: node.status.id, node: node, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
var currentNode = node var currentNode = node
while true { while true {
@ -271,7 +204,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
} }
currentNode = next currentNode = next
snapshot.appendItems([.status(id: next.status.id, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section) snapshot.appendItems([.status(id: next.status.id, node: next, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
} }
} }
} }
@ -280,7 +213,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
var snapshot = dataSource.snapshot() var snapshot = dataSource.snapshot()
var cellsToMask: [StatusCollectionViewCell] = [] var cellsToMask: [StatusCollectionViewCell] = []
for item in snapshot.itemIdentifiers { for item in snapshot.itemIdentifiers {
guard case .status(id: _, state: let state, prevLink: _, nextLink: _) = item, guard case .status(id: _, node: _, state: let state, prevLink: _, nextLink: _) = item,
state.collapsible == true else { state.collapsible == true else {
continue continue
} }
@ -311,17 +244,18 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
extension ConversationCollectionViewController { extension ConversationCollectionViewController {
enum Section: Hashable { enum Section: Hashable {
case statuses case ancestors
case mainStatus
case childThread(firstStatusID: String) case childThread(firstStatusID: String)
} }
enum Item: Hashable { enum Item: Hashable {
case status(id: String, state: CollapseState, prevLink: Bool, nextLink: Bool) case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool)
case expandThread(childThreads: [ConversationNode], inline: Bool) case expandThread(childThreads: [ConversationNode], inline: Bool)
case loadingIndicator case loadingIndicator
static func ==(lhs: Item, rhs: Item) -> Bool { static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) { switch (lhs, rhs) {
case let (.status(id: a, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, state: _, prevLink: bPrev, nextLink: bNext)): 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 return a == b && aPrev == bPrev && aNext == bNext
case let (.expandThread(childThreads: a, inline: aInline), .expandThread(childThreads: b, inline: bInline)): 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 return a.count == b.count && zip(a, b).allSatisfy { $0.status.id == $1.status.id } && aInline == bInline
@ -334,7 +268,7 @@ extension ConversationCollectionViewController {
func hash(into hasher: inout Hasher) { func hash(into hasher: inout Hasher) {
switch self { switch self {
case let .status(id: id, state: _, prevLink: prevLink, nextLink: nextLink): case let .status(id: id, node: _, state: _, prevLink: prevLink, nextLink: nextLink):
hasher.combine(0) hasher.combine(0)
hasher.combine(id) hasher.combine(id)
hasher.combine(prevLink) hasher.combine(prevLink)
@ -355,7 +289,7 @@ extension ConversationCollectionViewController {
extension ConversationCollectionViewController: UICollectionViewDelegate { extension ConversationCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool {
switch dataSource.itemIdentifier(for: indexPath) { switch dataSource.itemIdentifier(for: indexPath) {
case .status(id: let id, state: _, prevLink: _, nextLink: _): case .status(id: let id, node: _, state: _, prevLink: _, nextLink: _):
return id != mainStatusID return id != mainStatusID
case .expandThread(childThreads: _, inline: _): case .expandThread(childThreads: _, inline: _):
return true return true
@ -370,12 +304,24 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
break break
case .loadingIndicator: case .loadingIndicator:
break break
case .status(id: let id, state: let state, _, _): case .status(id: let id, node: let node, state: let state, _, _):
selected(status: id, state: state.copy()) // 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: _): 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)) { if case .status(id: _, node: let node, 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 tree = ConversationTree(ancestors: buildNewAncestors(above: indexPath), mainStatus: node)
let conv = ConversationViewController(for: id, state: state.copy(), mastodonController: mastodonController) let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController)
conv.statusIDToScrollToOnLoad = childThreads.first!.status.id conv.statusIDToScrollToOnLoad = childThreads.first!.status.id
conv.showStatusesAutomatically = showStatusesAutomatically conv.showStatusesAutomatically = showStatusesAutomatically
show(conv) show(conv)
@ -383,6 +329,34 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
} }
} }
// 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? { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration() return (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.contextMenuConfiguration()
} }

View File

@ -0,0 +1,101 @@
//
// ConversationTree.swift
// Tusker
//
// Created by Shadowfacts on 2/4/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
class ConversationNode {
let status: StatusMO
var children: [ConversationNode]
init(status: StatusMO) {
self.status = status
self.children = []
}
}
struct ConversationTree {
let ancestors: [ConversationNode]
let mainStatus: ConversationNode
var descendants: [ConversationNode] {
mainStatus.children
}
init(ancestors: [ConversationNode], mainStatus: ConversationNode) {
self.ancestors = ancestors
self.mainStatus = mainStatus
}
static func build(for mainStatus: StatusMO, ancestors: [StatusMO], descendants: [StatusMO]) -> ConversationTree {
let mainStatusNode = ConversationNode(status: mainStatus)
let ancestors = buildAncestorNodes(mainStatusNode: mainStatusNode, ancestors: ancestors)
buildDescendantNodes(mainStatusNode: mainStatusNode, descendants: descendants)
return ConversationTree(ancestors: ancestors, mainStatus: mainStatusNode)
}
private static func buildAncestorNodes(mainStatusNode: ConversationNode, ancestors: [StatusMO]) -> [ConversationNode] {
var statuses = ancestors
var parents = [ConversationNode]()
var parentID: String? = mainStatusNode.status.inReplyToID
while let currentParentID = parentID,
let parentIndex = statuses.firstIndex(where: { $0.id == currentParentID }) {
let parentStatus = statuses.remove(at: parentIndex)
let node = ConversationNode(status: parentStatus)
parents.insert(node, at: 0)
parentID = parentStatus.inReplyToID
}
// once the parents list is built and in-order, then we walk through and set each node's children
for (index, node) in parents.enumerated() {
if index == parents.count - 1 {
// the last parent is the direct parent of the main status
node.children = [mainStatusNode]
} else {
// otherwise, it's the parent of the status that comes immediately after it in the parents list
node.children = [parents[index + 1]]
}
}
return parents
}
// doesn't return anything, since we're modifying the main status node in-place
private static func buildDescendantNodes(mainStatusNode: ConversationNode, descendants: [StatusMO]) {
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] = [
mainStatusNode.status.id: mainStatusNode
]
var idsToCheck = [mainStatusNode.status.id]
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)
}
}
}
}

View File

@ -77,6 +77,14 @@ class ConversationViewController: UIViewController {
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
} }
init(preloadedTree: ConversationTree, state mainStatusState: CollapseState, mastodonController: MastodonController) {
self.mode = .preloaded(preloadedTree)
self.mainStatusState = mainStatusState
self.mastodonController = mastodonController
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
@ -115,9 +123,15 @@ class ConversationViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
Task { if case .unloaded = state {
if case .unloaded = state { if case .preloaded(let tree) = mode {
await loadMainStatus() // when everything is preloaded, we're on the fast path and want to avoid any async work
// just kicking off a MainActor task causes a delay before the content appears, even if the task doesn't suspend
mainStatusLoaded(tree.mainStatus.status)
} else {
Task { @MainActor in
await loadMainStatus()
}
} }
} }
} }
@ -142,9 +156,20 @@ class ConversationViewController: UIViewController {
// MARK: Loading // MARK: Loading
@MainActor
private func loadMainStatus() async { private func loadMainStatus() async {
guard let mainStatusID = await resolveStatusIfNecessary() else { let mainStatusID: String
return switch mode {
case .localID(let id):
mainStatusID = id
case .resolve(let url):
if let id = await resolveStatus(url: url) {
mainStatusID = id
} else {
return
}
case .preloaded(_):
fatalError("unreachable")
} }
@MainActor @MainActor
@ -166,7 +191,7 @@ class ConversationViewController: UIViewController {
Task { Task {
await doLoadMainStatus() await doLoadMainStatus()
} }
await mainStatusLoaded(cached) mainStatusLoaded(cached)
} else { } else {
// otherwise, show a loading indicator while loading the main status // otherwise, show a loading indicator while loading the main status
let indicator = UIActivityIndicatorView(style: .medium) let indicator = UIActivityIndicatorView(style: .medium)
@ -174,74 +199,94 @@ class ConversationViewController: UIViewController {
state = .loading(indicator) state = .loading(indicator)
if let status = await doLoadMainStatus() { if let status = await doLoadMainStatus() {
await mainStatusLoaded(status) mainStatusLoaded(status)
} }
} }
} }
@MainActor @MainActor
private func resolveStatusIfNecessary() async -> String? { private func resolveStatus(url: URL) async -> String? {
switch mode { let indicator = UIActivityIndicatorView(style: .medium)
case .localID(let id): indicator.startAnimating()
return id state = .loading(indicator)
case .resolve(let url):
let indicator = UIActivityIndicatorView(style: .medium) let url = WebURL(url)!.serialized(excludingFragment: true)
indicator.startAnimating() let request = Client.search(query: url, types: [.statuses], resolve: true)
state = .loading(indicator) do {
let (results, _) = try await mastodonController.run(request)
let url = WebURL(url)!.serialized(excludingFragment: true) guard let status = results.statuses.first(where: { $0.url?.serialized() == url }) else {
let request = Client.search(query: url, types: [.statuses], resolve: true) throw UnableToResolveError()
do {
let (results, _) = try await mastodonController.run(request)
guard let status = results.statuses.first(where: { $0.url?.serialized() == url }) else {
throw UnableToResolveError()
}
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
mode = .localID(status.id)
return status.id
} catch {
state = .unableToResolve(error)
return nil
} }
_ = mastodonController.persistentContainer.addOrUpdateOnViewContext(status: status)
mode = .localID(status.id)
return status.id
} catch {
state = .unableToResolve(error)
return nil
} }
} }
@MainActor private func mainStatusLoaded(_ mainStatus: StatusMO) {
private func mainStatusLoaded(_ mainStatus: StatusMO) async {
let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController) let vc = ConversationCollectionViewController(for: mainStatus.id, state: mainStatusState, mastodonController: mastodonController)
vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id vc.statusIDToScrollToOnLoad = statusIDToScrollToOnLoad ?? mainStatus.id
vc.showStatusesAutomatically = showStatusesAutomatically vc.showStatusesAutomatically = showStatusesAutomatically
vc.addMainStatus(mainStatus) vc.addMainStatus(mainStatus)
state = .displaying(vc) state = .displaying(vc)
await loadContext(for: mainStatus) if case .preloaded(let tree) = mode {
vc.addTree(tree, mainStatus: mainStatus)
} else {
Task { @MainActor in
await loadTree(for: mainStatus)
}
}
} }
@MainActor @MainActor
private func loadContext(for mainStatus: StatusMO) async { private func loadTree(for mainStatus: StatusMO) async {
guard case .displaying(_) = state else { guard case .displaying(_) = state,
let context = await loadContext(for: mainStatus) else {
return return
} }
await mastodonController.persistentContainer.addAll(statuses: context.ancestors + context.descendants)
let ancestorIDs = context.ancestors.map(\.id)
let ancestorsReq = StatusMO.fetchRequest()
ancestorsReq.predicate = NSPredicate(format: "id in %@", ancestorIDs)
let ancestors = try? mastodonController.persistentContainer.viewContext.fetch(ancestorsReq)
let descendantIDs = context.descendants.map(\.id)
let descendantsReq = StatusMO.fetchRequest()
descendantsReq.predicate = NSPredicate(format: "id IN %@", descendantIDs)
let descendants = try? mastodonController.persistentContainer.viewContext.fetch(descendantsReq)
let tree = ConversationTree.build(for: mainStatus, ancestors: ancestors ?? [], descendants: descendants ?? [])
guard case .displaying(let vc) = state else {
return
}
vc.addTree(tree, mainStatus: mainStatus)
}
private func loadContext(for mainStatus: StatusMO) async -> ConversationContext? {
let request = Status.getContext(mainStatus.id) let request = Status.getContext(mainStatus.id)
do { do {
let (context, _) = try await mastodonController.run(request) let (context, _) = try await mastodonController.run(request)
guard case .displaying(let vc) = state else { return context
return
}
await vc.addContext(context, for: mainStatus)
} catch { } catch {
guard case .displaying(_) = state else { guard case .displaying(_) = state else {
return return nil
} }
let error = error as! Client.Error let error = error as! Client.Error
let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in let config = ToastConfiguration(from: error, with: "Error Loading Context", in: self) { [weak self] toast in
toast.dismissToast(animated: true) toast.dismissToast(animated: true)
await self?.loadContext(for: mainStatus) await self?.loadTree(for: mainStatus)
} }
self.showToast(configuration: config, animated: true) self.showToast(configuration: config, animated: true)
return nil
} }
} }
@ -341,6 +386,7 @@ extension ConversationViewController {
enum Mode { enum Mode {
case localID(String) case localID(String)
case resolve(URL) case resolve(URL)
case preloaded(ConversationTree)
} }
} }

View File

@ -9,13 +9,13 @@
import UIKit import UIKit
import Pachyderm import Pachyderm
class TrendingStatusesViewController: UIViewController { class TrendingStatusesViewController: UIViewController, CollectionViewController {
weak var mastodonController: MastodonController! weak var mastodonController: MastodonController!
let filterer: Filterer let filterer: Filterer
private var collectionView: UICollectionView { var collectionView: UICollectionView! {
view as! UICollectionView view as? UICollectionView
} }
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>! private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
@ -110,6 +110,8 @@ class TrendingStatusesViewController: UIViewController {
override func viewWillAppear(_ animated: Bool) { override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated) super.viewWillAppear(animated)
clearSelectionOnAppear(animated: animated)
if !loaded { if !loaded {
loaded = true loaded = true
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>() var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()