mastodon-ios/Mastodon/Scene/Thread/ThreadViewModel.swift

315 lines
13 KiB
Swift

//
// ThreadViewModel.swift
// Mastodon
//
// Created by MainasuK Cirno on 2021-4-12.
//
import os.log
import UIKit
import Combine
import CoreData
import CoreDataStack
import GameplayKit
import MastodonSDK
class ThreadViewModel {
var disposeBag = Set<AnyCancellable>()
var rootItemObserver: AnyCancellable?
// input
let context: AppContext
let rootNode: CurrentValueSubject<RootNode?, Never>
let rootItem: CurrentValueSubject<Item?, Never>
let cellFrameCache = NSCache<NSNumber, NSValue>()
let existStatusFetchedResultsController: StatusFetchedResultsController
weak var contentOffsetAdjustableTimelineViewControllerDelegate: ContentOffsetAdjustableTimelineViewControllerDelegate?
weak var tableView: UITableView?
// output
var diffableDataSource: UITableViewDiffableDataSource<StatusSection, Item>?
private(set) lazy var loadThreadStateMachine: GKStateMachine = {
let stateMachine = GKStateMachine(states: [
LoadThreadState.Initial(viewModel: self),
LoadThreadState.Loading(viewModel: self),
LoadThreadState.Fail(viewModel: self),
LoadThreadState.NoMore(viewModel: self),
])
stateMachine.enter(LoadThreadState.Initial.self)
return stateMachine
}()
let ancestorNodes = CurrentValueSubject<[ReplyNode], Never>([])
let ancestorItems = CurrentValueSubject<[Item], Never>([])
let descendantNodes = CurrentValueSubject<[LeafNode], Never>([])
let descendantItems = CurrentValueSubject<[Item], Never>([])
let navigationBarTitle: CurrentValueSubject<String?, Never>
init(context: AppContext, optionalStatus: Status?) {
self.context = context
self.rootNode = CurrentValueSubject(optionalStatus.flatMap { RootNode(domain: $0.domain, statusID: $0.id, replyToID: $0.inReplyToID) })
self.rootItem = CurrentValueSubject(optionalStatus.flatMap { Item.root(statusObjectID: $0.objectID, attribute: Item.StatusAttribute()) })
self.existStatusFetchedResultsController = StatusFetchedResultsController(managedObjectContext: context.managedObjectContext, domain: nil, additionalTweetPredicate: nil)
self.navigationBarTitle = CurrentValueSubject(
optionalStatus.flatMap { L10n.Scene.Thread.title($0.author.displayNameWithFallback) }
)
// bind fetcher domain
context.authenticationService.activeMastodonAuthenticationBox
.receive(on: RunLoop.main)
.sink { [weak self] box in
guard let self = self else { return }
self.existStatusFetchedResultsController.domain.value = box?.domain
}
.store(in: &disposeBag)
rootNode
.receive(on: DispatchQueue.main)
.sink { [weak self] rootNode in
guard let self = self else { return }
guard rootNode != nil else { return }
self.loadThreadStateMachine.enter(LoadThreadState.Loading.self)
}
.store(in: &disposeBag)
if optionalStatus == nil {
rootItem
.receive(on: DispatchQueue.main)
.sink { [weak self] rootItem in
guard let self = self else { return }
guard case let .root(objectID, _) = rootItem else { return }
self.context.managedObjectContext.perform {
guard let status = self.context.managedObjectContext.object(with: objectID) as? Status else {
return
}
self.rootNode.value = RootNode(domain: status.domain, statusID: status.id, replyToID: status.inReplyToID)
self.navigationBarTitle.value = L10n.Scene.Thread.title(status.author.displayNameWithFallback)
}
}
.store(in: &disposeBag)
}
rootItem
.receive(on: DispatchQueue.main)
.sink { [weak self] rootItem in
guard let self = self else { return }
guard case let .root(objectID, _) = rootItem else { return }
self.context.managedObjectContext.perform {
guard let status = self.context.managedObjectContext.object(with: objectID) as? Status else {
return
}
self.rootItemObserver = ManagedObjectObserver.observe(object: status)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { _ in
// do nothing
}, receiveValue: { [weak self] change in
guard let self = self else { return }
switch change.changeType {
case .delete:
self.rootItem.value = nil
default:
break
}
})
}
}
.store(in: &disposeBag)
ancestorNodes
.receive(on: DispatchQueue.main)
.compactMap { [weak self] nodes -> [Item]? in
guard let self = self else { return nil }
guard !nodes.isEmpty else { return [] }
guard let diffableDataSource = self.diffableDataSource else { return nil }
let oldSnapshot = diffableDataSource.snapshot()
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
for item in oldSnapshot.itemIdentifiers {
switch item {
case .reply(let objectID, let attribute):
oldSnapshotAttributeDict[objectID] = attribute
default:
break
}
}
var items: [Item] = []
for node in nodes {
let attribute = oldSnapshotAttributeDict[node.statusObjectID] ?? Item.StatusAttribute()
items.append(Item.reply(statusObjectID: node.statusObjectID, attribute: attribute))
}
return items.reversed()
}
.assign(to: \.value, on: ancestorItems)
.store(in: &disposeBag)
descendantNodes
.receive(on: DispatchQueue.main)
.compactMap { [weak self] nodes -> [Item]? in
guard let self = self else { return nil }
guard !nodes.isEmpty else { return [] }
guard let diffableDataSource = self.diffableDataSource else { return nil }
let oldSnapshot = diffableDataSource.snapshot()
var oldSnapshotAttributeDict: [NSManagedObjectID : Item.StatusAttribute] = [:]
for item in oldSnapshot.itemIdentifiers {
switch item {
case .leaf(let objectID, let attribute):
oldSnapshotAttributeDict[objectID] = attribute
default:
break
}
}
var items: [Item] = []
func buildThread(node: LeafNode) {
let attribute = oldSnapshotAttributeDict[node.objectID] ?? Item.StatusAttribute()
items.append(Item.leaf(statusObjectID: node.objectID, attribute: attribute))
// only expand the first child
if let firstChild = node.children.first {
if !node.isChildrenExpanded {
items.append(Item.leafBottomLoader(statusObjectID: node.objectID))
} else {
buildThread(node: firstChild)
}
}
}
for node in nodes {
buildThread(node: node)
}
return items
}
.assign(to: \.value, on: descendantItems)
.store(in: &disposeBag)
}
deinit {
os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
}
}
extension ThreadViewModel {
struct RootNode {
let domain: String
let statusID: Mastodon.Entity.Status.ID
let replyToID: Mastodon.Entity.Status.ID?
}
class ReplyNode {
let statusID: Mastodon.Entity.Status.ID
let statusObjectID: NSManagedObjectID
init(statusID: Mastodon.Entity.Status.ID, statusObjectID: NSManagedObjectID) {
self.statusID = statusID
self.statusObjectID = statusObjectID
}
static func replyToThread(
for replyToID: Mastodon.Entity.Status.ID?,
from statuses: [Mastodon.Entity.Status],
domain: String,
managedObjectContext: NSManagedObjectContext
) -> [ReplyNode] {
guard let replyToID = replyToID else {
return []
}
var nodes: [ReplyNode] = []
managedObjectContext.performAndWait {
let request = Status.sortedFetchRequest
request.predicate = Status.predicate(domain: domain, ids: statuses.map { $0.id })
request.fetchLimit = statuses.count
let objects = managedObjectContext.safeFetch(request)
var objectDict: [Mastodon.Entity.Status.ID: Status] = [:]
for object in objects {
objectDict[object.id] = object
}
var nextID: Mastodon.Entity.Status.ID? = replyToID
while let _nextID = nextID {
guard let object = objectDict[_nextID] else { break }
nodes.append(ThreadViewModel.ReplyNode(statusID: _nextID, statusObjectID: object.objectID))
nextID = object.inReplyToID
}
}
return nodes.reversed()
}
}
class LeafNode {
let statusID: Mastodon.Entity.Status.ID
let objectID: NSManagedObjectID
let repliesCount: Int
let children: [LeafNode]
var isChildrenExpanded: Bool = false // default collapsed
init(
statusID: Mastodon.Entity.Status.ID,
objectID: NSManagedObjectID,
repliesCount: Int,
children: [ThreadViewModel.LeafNode]
) {
self.statusID = statusID
self.objectID = objectID
self.repliesCount = repliesCount
self.children = children
}
static func tree(
for statusID: Mastodon.Entity.Status.ID,
from statuses: [Mastodon.Entity.Status],
domain: String,
managedObjectContext: NSManagedObjectContext
) -> [LeafNode] {
// make an cache collection
var objectDict: [Mastodon.Entity.Status.ID: Status] = [:]
managedObjectContext.performAndWait {
let request = Status.sortedFetchRequest
request.predicate = Status.predicate(domain: domain, ids: statuses.map { $0.id })
request.fetchLimit = statuses.count
let objects = managedObjectContext.safeFetch(request)
for object in objects {
objectDict[object.id] = object
}
}
var tree: [LeafNode] = []
let firstTierStatuses = statuses.filter { $0.inReplyToID == statusID }
for status in firstTierStatuses {
guard let node = node(of: status.id, objectDict: objectDict) else { continue }
tree.append(node)
}
return tree
}
static func node(
of statusID: Mastodon.Entity.Status.ID,
objectDict: [Mastodon.Entity.Status.ID: Status]
) -> LeafNode? {
guard let object = objectDict[statusID] else { return nil }
let replies = (object.replyFrom ?? Set()).sorted(
by: { $0.createdAt > $1.createdAt } // order by date
)
let children = replies.compactMap { node(of: $0.id, objectDict: objectDict) }
return LeafNode(
statusID: statusID,
objectID: object.objectID,
repliesCount: object.repliesCount?.intValue ?? 0,
children: children
)
}
}
}