Compare commits
No commits in common. "7cadcf1e862b3580702f096c1694ce56e5bf1417" and "ec75906bc18cc368efa48a707789d3a68bf0a5ae" have entirely different histories.
7cadcf1e86
...
ec75906bc1
|
@ -297,7 +297,6 @@
|
||||||
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 */; };
|
||||||
|
@ -707,7 +706,6 @@
|
||||||
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>"; };
|
||||||
|
@ -1090,7 +1088,6 @@
|
||||||
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>";
|
||||||
|
@ -2028,7 +2025,6 @@
|
||||||
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 */,
|
||||||
|
|
|
@ -9,6 +9,16 @@
|
||||||
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
|
||||||
|
@ -45,15 +55,11 @@ 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
|
||||||
if case .ancestors = self.dataSource.sectionIdentifier(for: indexPath.section) {
|
config.bottomSeparatorVisibility = lastInSection ? .visible : .hidden
|
||||||
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
|
||||||
}
|
}
|
||||||
|
@ -93,7 +99,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, node: _, state: state, prevLink: prevLink, nextLink: nextLink):
|
case let .status(id: id, 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 {
|
||||||
|
@ -117,32 +123,45 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
||||||
loadViewIfNeeded()
|
loadViewIfNeeded()
|
||||||
|
|
||||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||||
snapshot.appendSections([.ancestors, .mainStatus])
|
snapshot.appendSections([.statuses])
|
||||||
|
|
||||||
if status.inReplyToID != nil {
|
if status.inReplyToID != nil {
|
||||||
snapshot.appendItems([.loadingIndicator], toSection: .ancestors)
|
snapshot.appendItems([.loadingIndicator], toSection: .statuses)
|
||||||
}
|
}
|
||||||
|
|
||||||
// this will be replace with the actual node in the tree once it's loaded
|
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: status.inReplyToID != nil, nextLink: false)
|
||||||
let tempMainNode = ConversationNode(status: status)
|
snapshot.appendItems([mainStatusItem], toSection: .statuses)
|
||||||
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 addTree(_ tree: ConversationTree, mainStatus: StatusMO) {
|
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()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.deleteItems([.loadingIndicator])
|
snapshot.deleteItems([.loadingIndicator])
|
||||||
let mainStatusItem = Item.status(id: mainStatusID, node: tree.mainStatus, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false)
|
let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false)
|
||||||
let parentItems = tree.ancestors.enumerated().map { index, node in
|
let parentItems = parentIDs.enumerated().map { index, id in
|
||||||
Item.status(id: node.status.id, node: node, state: .unknown, prevLink: index > 0, nextLink: true)
|
Item.status(id: id, state: .unknown, prevLink: index > 0, nextLink: true)
|
||||||
}
|
}
|
||||||
snapshot.appendItems(parentItems, toSection: .ancestors)
|
snapshot.insertItems(parentItems, beforeItem: mainStatusItem)
|
||||||
snapshot.reloadItems([mainStatusItem])
|
snapshot.reloadItems([mainStatusItem])
|
||||||
|
|
||||||
// convert sub-threads into items for section and add to snapshot
|
// fetch all descendant status managed objects
|
||||||
self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot)
|
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) {
|
self.dataSource.apply(snapshot, animatingDifferences: false) {
|
||||||
let item: Item
|
let item: Item
|
||||||
|
@ -152,7 +171,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
|
||||||
|
@ -168,6 +187,54 @@ 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
|
||||||
|
|
||||||
|
@ -181,7 +248,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, node: node, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
|
snapshot.appendItems([.status(id: node.status.id, state: .unknown, prevLink: false, nextLink: !node.children.isEmpty)], toSection: section)
|
||||||
|
|
||||||
var currentNode = node
|
var currentNode = node
|
||||||
while true {
|
while true {
|
||||||
|
@ -204,7 +271,7 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
||||||
}
|
}
|
||||||
|
|
||||||
currentNode = next
|
currentNode = next
|
||||||
snapshot.appendItems([.status(id: next.status.id, node: next, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
|
snapshot.appendItems([.status(id: next.status.id, state: .unknown, prevLink: true, nextLink: !next.children.isEmpty)], toSection: section)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -213,7 +280,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: _, node: _, state: let state, prevLink: _, nextLink: _) = item,
|
guard case .status(id: _, state: let state, prevLink: _, nextLink: _) = item,
|
||||||
state.collapsible == true else {
|
state.collapsible == true else {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -244,18 +311,17 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont
|
||||||
|
|
||||||
extension ConversationCollectionViewController {
|
extension ConversationCollectionViewController {
|
||||||
enum Section: Hashable {
|
enum Section: Hashable {
|
||||||
case ancestors
|
case statuses
|
||||||
case mainStatus
|
|
||||||
case childThread(firstStatusID: String)
|
case childThread(firstStatusID: String)
|
||||||
}
|
}
|
||||||
enum Item: Hashable {
|
enum Item: Hashable {
|
||||||
case status(id: String, node: ConversationNode, state: CollapseState, prevLink: Bool, nextLink: Bool)
|
case status(id: String, 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, node: _, state: _, prevLink: aPrev, nextLink: aNext), .status(id: b, node: _, state: _, prevLink: bPrev, nextLink: bNext)):
|
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
|
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
|
||||||
|
@ -268,7 +334,7 @@ extension ConversationCollectionViewController {
|
||||||
|
|
||||||
func hash(into hasher: inout Hasher) {
|
func hash(into hasher: inout Hasher) {
|
||||||
switch self {
|
switch self {
|
||||||
case let .status(id: id, node: _, state: _, prevLink: prevLink, nextLink: nextLink):
|
case let .status(id: id, state: _, prevLink: prevLink, nextLink: nextLink):
|
||||||
hasher.combine(0)
|
hasher.combine(0)
|
||||||
hasher.combine(id)
|
hasher.combine(id)
|
||||||
hasher.combine(prevLink)
|
hasher.combine(prevLink)
|
||||||
|
@ -289,7 +355,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, node: _, state: _, prevLink: _, nextLink: _):
|
case .status(id: let id, state: _, prevLink: _, nextLink: _):
|
||||||
return id != mainStatusID
|
return id != mainStatusID
|
||||||
case .expandThread(childThreads: _, inline: _):
|
case .expandThread(childThreads: _, inline: _):
|
||||||
return true
|
return true
|
||||||
|
@ -304,24 +370,12 @@ extension ConversationCollectionViewController: UICollectionViewDelegate {
|
||||||
break
|
break
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
break
|
break
|
||||||
case .status(id: let id, node: let node, state: let state, _, _):
|
case .status(id: let id, state: let state, _, _):
|
||||||
// we can only take the fast path if the user tapped on a descendant status.
|
selected(status: id, state: state.copy())
|
||||||
// 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: _, node: let node, state: let state, _, _) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
|
if case .status(id: let id, state: let state, _, _) = dataSource.itemIdentifier(for: IndexPath(row: indexPath.row - 1, section: indexPath.section)) {
|
||||||
let tree = ConversationTree(ancestors: buildNewAncestors(above: indexPath), mainStatus: node)
|
// todo: it would be nice to avoid re-fetching the context here, since we should have all the necessary information already
|
||||||
let conv = ConversationViewController(preloadedTree: tree, state: state.copy(), mastodonController: mastodonController)
|
let conv = ConversationViewController(for: id, 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)
|
||||||
|
@ -329,34 +383,6 @@ 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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,101 +0,0 @@
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -77,14 +77,6 @@ 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")
|
||||||
}
|
}
|
||||||
|
@ -123,15 +115,9 @@ class ConversationViewController: UIViewController {
|
||||||
override func viewWillAppear(_ animated: Bool) {
|
override func viewWillAppear(_ animated: Bool) {
|
||||||
super.viewWillAppear(animated)
|
super.viewWillAppear(animated)
|
||||||
|
|
||||||
if case .unloaded = state {
|
Task {
|
||||||
if case .preloaded(let tree) = mode {
|
if case .unloaded = state {
|
||||||
// when everything is preloaded, we're on the fast path and want to avoid any async work
|
await loadMainStatus()
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -156,20 +142,9 @@ class ConversationViewController: UIViewController {
|
||||||
|
|
||||||
// MARK: Loading
|
// MARK: Loading
|
||||||
|
|
||||||
@MainActor
|
|
||||||
private func loadMainStatus() async {
|
private func loadMainStatus() async {
|
||||||
let mainStatusID: String
|
guard let mainStatusID = await resolveStatusIfNecessary() else {
|
||||||
switch mode {
|
return
|
||||||
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
|
||||||
|
@ -191,7 +166,7 @@ class ConversationViewController: UIViewController {
|
||||||
Task {
|
Task {
|
||||||
await doLoadMainStatus()
|
await doLoadMainStatus()
|
||||||
}
|
}
|
||||||
mainStatusLoaded(cached)
|
await 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)
|
||||||
|
@ -199,94 +174,74 @@ class ConversationViewController: UIViewController {
|
||||||
state = .loading(indicator)
|
state = .loading(indicator)
|
||||||
|
|
||||||
if let status = await doLoadMainStatus() {
|
if let status = await doLoadMainStatus() {
|
||||||
mainStatusLoaded(status)
|
await mainStatusLoaded(status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func resolveStatus(url: URL) async -> String? {
|
private func resolveStatusIfNecessary() async -> String? {
|
||||||
let indicator = UIActivityIndicatorView(style: .medium)
|
switch mode {
|
||||||
indicator.startAnimating()
|
case .localID(let id):
|
||||||
state = .loading(indicator)
|
return id
|
||||||
|
case .resolve(let url):
|
||||||
let url = WebURL(url)!.serialized(excludingFragment: true)
|
let indicator = UIActivityIndicatorView(style: .medium)
|
||||||
let request = Client.search(query: url, types: [.statuses], resolve: true)
|
indicator.startAnimating()
|
||||||
do {
|
state = .loading(indicator)
|
||||||
let (results, _) = try await mastodonController.run(request)
|
|
||||||
guard let status = results.statuses.first(where: { $0.url?.serialized() == url }) else {
|
let url = WebURL(url)!.serialized(excludingFragment: true)
|
||||||
throw UnableToResolveError()
|
let request = Client.search(query: url, types: [.statuses], resolve: true)
|
||||||
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func mainStatusLoaded(_ mainStatus: StatusMO) {
|
@MainActor
|
||||||
|
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)
|
||||||
|
|
||||||
if case .preloaded(let tree) = mode {
|
await loadContext(for: mainStatus)
|
||||||
vc.addTree(tree, mainStatus: mainStatus)
|
|
||||||
} else {
|
|
||||||
Task { @MainActor in
|
|
||||||
await loadTree(for: mainStatus)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
private func loadTree(for mainStatus: StatusMO) async {
|
private func loadContext(for mainStatus: StatusMO) async {
|
||||||
guard case .displaying(_) = state,
|
guard case .displaying(_) = state else {
|
||||||
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)
|
||||||
return context
|
guard case .displaying(let vc) = state else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await vc.addContext(context, for: mainStatus)
|
||||||
} catch {
|
} catch {
|
||||||
guard case .displaying(_) = state else {
|
guard case .displaying(_) = state else {
|
||||||
return nil
|
return
|
||||||
}
|
}
|
||||||
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?.loadTree(for: mainStatus)
|
await self?.loadContext(for: mainStatus)
|
||||||
}
|
}
|
||||||
self.showToast(configuration: config, animated: true)
|
self.showToast(configuration: config, animated: true)
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -386,7 +341,6 @@ extension ConversationViewController {
|
||||||
enum Mode {
|
enum Mode {
|
||||||
case localID(String)
|
case localID(String)
|
||||||
case resolve(URL)
|
case resolve(URL)
|
||||||
case preloaded(ConversationTree)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,13 +9,13 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
|
|
||||||
class TrendingStatusesViewController: UIViewController, CollectionViewController {
|
class TrendingStatusesViewController: UIViewController {
|
||||||
|
|
||||||
weak var mastodonController: MastodonController!
|
weak var mastodonController: MastodonController!
|
||||||
let filterer: Filterer
|
let filterer: Filterer
|
||||||
|
|
||||||
var collectionView: UICollectionView! {
|
private var collectionView: UICollectionView {
|
||||||
view as? UICollectionView
|
view as! UICollectionView
|
||||||
}
|
}
|
||||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||||
|
|
||||||
|
@ -110,8 +110,6 @@ class TrendingStatusesViewController: UIViewController, CollectionViewController
|
||||||
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>()
|
||||||
|
|
Loading…
Reference in New Issue