From a314521b96b026734dac9ff8829beb8ab56b7290 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 4 Feb 2023 13:49:20 -0500 Subject: [PATCH] Extract out conversation tree-building code --- Tusker.xcodeproj/project.pbxproj | 4 + ...ConversationCollectionViewController.swift | 91 +++---------------- .../Conversation/ConversationTree.swift | 80 ++++++++++++++++ 3 files changed, 98 insertions(+), 77 deletions(-) create mode 100644 Tusker/Screens/Conversation/ConversationTree.swift diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 6b7c1f12..bb3a433b 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -297,6 +297,7 @@ D6C1B2082545D1EC00DAAA66 /* StatusCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C1B2072545D1EC00DAAA66 /* StatusCardView.swift */; }; D6C3F4F5298ED0890009FCFF /* LocalPredicateStatusesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.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 */; }; D6C693FC2162FE6F007D6A6D /* LoadingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C693FB2162FE6F007D6A6D /* LoadingViewController.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 = ""; }; D6C3F4F4298ED0890009FCFF /* LocalPredicateStatusesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalPredicateStatusesViewController.swift; sourceTree = ""; }; D6C3F4F6298ED7F70009FCFF /* FavoritesViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoritesViewController.swift; sourceTree = ""; }; + D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationTree.swift; sourceTree = ""; }; D6C693EE216192C2007D6A6D /* TuskerNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TuskerNavigationDelegate.swift; sourceTree = ""; }; D6C693FB2162FE6F007D6A6D /* LoadingViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingViewController.swift; sourceTree = ""; }; D6C693FD2162FEEA007D6A6D /* UIViewController+Children.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIViewController+Children.swift"; sourceTree = ""; }; @@ -1088,6 +1090,7 @@ D65B4B6329771EFF00DABDFB /* ConversationViewController.swift */, D601FA5C297B2E6F00A8E8B5 /* ConversationCollectionViewController.swift */, D601FA5E297B339100A8E8B5 /* ExpandThreadCollectionViewCell.swift */, + D6C3F4F8298EDBF20009FCFF /* ConversationTree.swift */, ); path = Conversation; sourceTree = ""; @@ -2025,6 +2028,7 @@ D6E77D0F286F773900D8B732 /* SplitNavigationController.swift in Sources */, D6B30E09254BAF63009CAEE5 /* ImageGrayscalifier.swift in Sources */, D6A6C10F25B62D2400298D0F /* DiskCache.swift in Sources */, + D6C3F4F9298EDBF20009FCFF /* ConversationTree.swift in Sources */, D6B81F3C2560365300F6E31D /* RefreshableViewController.swift in Sources */, D646C958213B367000269FB5 /* LargeImageShrinkAnimationController.swift in Sources */, D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */, diff --git a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift index 57915d12..42f24724 100644 --- a/Tusker/Screens/Conversation/ConversationCollectionViewController.swift +++ b/Tusker/Screens/Conversation/ConversationCollectionViewController.swift @@ -9,16 +9,6 @@ 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 @@ -136,32 +126,27 @@ class ConversationCollectionViewController: UIViewController, CollectionViewCont } 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]) + await mastodonController.persistentContainer.addAll(statuses: context.ancestors + context.descendants) // fetch all descendant status managed objects let descendantIDs = context.descendants.map(\.id) let request = StatusMO.fetchRequest() request.predicate = NSPredicate(format: "id IN %@", descendantIDs) + let descendants = try? mastodonController.persistentContainer.viewContext.fetch(request) - 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) + let tree = ConversationTree.build(from: context, mainStatus: mainStatus, descendants: descendants ?? []) + + var snapshot = dataSource.snapshot() + snapshot.deleteItems([.loadingIndicator]) + let mainStatusItem = Item.status(id: mainStatusID, state: mainStatusState, prevLink: mainStatus.inReplyToID != nil, nextLink: false) + let parentItems = tree.ancestors.enumerated().map { index, id in + Item.status(id: id, state: .unknown, prevLink: index > 0, nextLink: true) } + snapshot.insertItems(parentItems, beforeItem: mainStatusItem) + snapshot.reloadItems([mainStatusItem]) + + // convert sub-threads into items for section and add to snapshot + self.addFlattenedChildThreadsToSnapshot(tree.descendants, mainStatus: mainStatus, snapshot: &snapshot) self.dataSource.apply(snapshot, animatingDifferences: false) { let item: Item @@ -187,54 +172,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) { var childThreads = childThreads diff --git a/Tusker/Screens/Conversation/ConversationTree.swift b/Tusker/Screens/Conversation/ConversationTree.swift new file mode 100644 index 00000000..0f132df3 --- /dev/null +++ b/Tusker/Screens/Conversation/ConversationTree.swift @@ -0,0 +1,80 @@ +// +// 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: [String] + let descendants: [ConversationNode] + + static func build(from context: ConversationContext, mainStatus: StatusMO, descendants: [StatusMO]) -> ConversationTree { + let ancestors = getDirectParents(inReplyTo: mainStatus.inReplyToID, from: context) + let childThreads = getDescendantThreads(mainStatus: mainStatus, descendants: descendants) + return ConversationTree(ancestors: ancestors, descendants: childThreads) + } + + private static func getDirectParents(inReplyTo inReplyToID: String?, from context: ConversationContext) -> [String] { + var statuses = context.ancestors + var parents = [String]() + + var parentID: String? = inReplyToID + + while let currentParentID = parentID, + let parentIndex = statuses.firstIndex(where: { $0.id == currentParentID }) { + let parentStatus = statuses.remove(at: parentIndex) + parents.insert(parentStatus.id, at: 0) + parentID = parentStatus.inReplyToID + } + + return parents + } + + private static 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 + } + + let mainStatusNode = ConversationNode(status: mainStatus) + var nodes: [String: ConversationNode] = [ + mainStatus.id: mainStatusNode + ] + + var idsToCheck = [mainStatus.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) + } + } + + return mainStatusNode.children + } +}