MastodonStatusThreadViewModel.swift 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. //
  2. // MastodonStatusThreadViewModel.swift
  3. // MastodonStatusThreadViewModel
  4. //
  5. // Created by Cirno MainasuK on 2021-9-6.
  6. // Copyright © 2021 Twidere. All rights reserved.
  7. //
  8. import os.log
  9. import Foundation
  10. import Combine
  11. import CoreData
  12. import CoreDataStack
  13. import MastodonSDK
  14. import MastodonCore
  15. import MastodonMeta
  16. final class MastodonStatusThreadViewModel {
  17. var disposeBag = Set<AnyCancellable>()
  18. // input
  19. let context: AppContext
  20. @Published private(set) var deletedObjectIDs: Set<NSManagedObjectID> = Set()
  21. // output
  22. @Published var __ancestors: [StatusItem] = []
  23. @Published var ancestors: [StatusItem] = []
  24. @Published var __descendants: [StatusItem] = []
  25. @Published var descendants: [StatusItem] = []
  26. init(context: AppContext) {
  27. self.context = context
  28. Publishers.CombineLatest(
  29. $__ancestors,
  30. $deletedObjectIDs
  31. )
  32. .sink { [weak self] items, deletedObjectIDs in
  33. guard let self = self else { return }
  34. let newItems = items.filter { item in
  35. switch item {
  36. case .thread(let thread):
  37. return !deletedObjectIDs.contains(thread.record.objectID)
  38. default:
  39. assertionFailure()
  40. return false
  41. }
  42. }
  43. self.ancestors = newItems
  44. }
  45. .store(in: &disposeBag)
  46. Publishers.CombineLatest(
  47. $__descendants,
  48. $deletedObjectIDs
  49. )
  50. .sink { [weak self] items, deletedObjectIDs in
  51. guard let self = self else { return }
  52. let newItems = items.filter { item in
  53. switch item {
  54. case .thread(let thread):
  55. return !deletedObjectIDs.contains(thread.record.objectID)
  56. default:
  57. assertionFailure()
  58. return false
  59. }
  60. }
  61. self.descendants = newItems
  62. }
  63. .store(in: &disposeBag)
  64. }
  65. deinit {
  66. os_log(.info, log: .debug, "%{public}s[%{public}ld], %{public}s", ((#file as NSString).lastPathComponent), #line, #function)
  67. }
  68. }
  69. extension MastodonStatusThreadViewModel {
  70. func appendAncestor(
  71. domain: String,
  72. nodes: [Node]
  73. ) {
  74. let ids = nodes.map { $0.statusID }
  75. var dictionary: [Status.ID: Status] = [:]
  76. do {
  77. let request = Status.sortedFetchRequest
  78. request.predicate = Status.predicate(domain: domain, ids: ids)
  79. let statuses = try self.context.managedObjectContext.fetch(request)
  80. for status in statuses {
  81. dictionary[status.id] = status
  82. }
  83. } catch {
  84. os_log("%{public}s[%{public}ld], %{public}s: fetch conversation fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
  85. return
  86. }
  87. var newItems: [StatusItem] = []
  88. for (i, node) in nodes.enumerated() {
  89. guard let status = dictionary[node.statusID] else { continue }
  90. let isLast = i == nodes.count - 1
  91. let record = ManagedObjectRecord<Status>(objectID: status.objectID)
  92. let context = StatusItem.Thread.Context(
  93. status: record,
  94. displayUpperConversationLink: !isLast,
  95. displayBottomConversationLink: true
  96. )
  97. let item = StatusItem.thread(.leaf(context: context))
  98. newItems.append(item)
  99. }
  100. let items = self.__ancestors + newItems
  101. self.__ancestors = items
  102. }
  103. func appendDescendant(
  104. domain: String,
  105. nodes: [Node]
  106. ) {
  107. let childrenIDs = nodes
  108. .map { node in [node.statusID, node.children.first?.statusID].compactMap { $0 } }
  109. .flatMap { $0 }
  110. var dictionary: [Status.ID: Status] = [:]
  111. do {
  112. let request = Status.sortedFetchRequest
  113. request.predicate = Status.predicate(domain: domain, ids: childrenIDs)
  114. let statuses = try self.context.managedObjectContext.fetch(request)
  115. for status in statuses {
  116. dictionary[status.id] = status
  117. }
  118. } catch {
  119. os_log("%{public}s[%{public}ld], %{public}s: fetch conversation fail: %s", ((#file as NSString).lastPathComponent), #line, #function, error.localizedDescription)
  120. return
  121. }
  122. var newItems: [StatusItem] = []
  123. for node in nodes {
  124. guard let status = dictionary[node.statusID] else { continue }
  125. // first tier
  126. let record = ManagedObjectRecord<Status>(objectID: status.objectID)
  127. let context = StatusItem.Thread.Context(
  128. status: record
  129. )
  130. let item = StatusItem.thread(.leaf(context: context))
  131. newItems.append(item)
  132. // second tier
  133. if let child = node.children.first {
  134. guard let secondaryStatus = dictionary[child.statusID] else { continue }
  135. let secondaryRecord = ManagedObjectRecord<Status>(objectID: secondaryStatus.objectID)
  136. let secondaryContext = StatusItem.Thread.Context(
  137. status: secondaryRecord,
  138. displayUpperConversationLink: true
  139. )
  140. let secondaryItem = StatusItem.thread(.leaf(context: secondaryContext))
  141. newItems.append(secondaryItem)
  142. // update first tier context
  143. context.displayBottomConversationLink = true
  144. }
  145. }
  146. var items = self.__descendants
  147. for item in newItems {
  148. guard !items.contains(item) else { continue }
  149. items.append(item)
  150. }
  151. self.__descendants = items
  152. }
  153. }
  154. extension MastodonStatusThreadViewModel {
  155. class Node {
  156. typealias ID = String
  157. let statusID: ID
  158. let children: [Node]
  159. init(
  160. statusID: ID,
  161. children: [MastodonStatusThreadViewModel.Node]
  162. ) {
  163. self.statusID = statusID
  164. self.children = children
  165. }
  166. }
  167. }
  168. extension MastodonStatusThreadViewModel.Node {
  169. static func replyToThread(
  170. for replyToID: Mastodon.Entity.Status.ID?,
  171. from statuses: [Mastodon.Entity.Status]
  172. ) -> [MastodonStatusThreadViewModel.Node] {
  173. guard let replyToID = replyToID else {
  174. return []
  175. }
  176. var dict: [Mastodon.Entity.Status.ID: Mastodon.Entity.Status] = [:]
  177. for status in statuses {
  178. dict[status.id] = status
  179. }
  180. var nextID: Mastodon.Entity.Status.ID? = replyToID
  181. var nodes: [MastodonStatusThreadViewModel.Node] = []
  182. while let _nextID = nextID {
  183. guard let status = dict[_nextID] else { break }
  184. nodes.append(MastodonStatusThreadViewModel.Node(
  185. statusID: _nextID,
  186. children: []
  187. ))
  188. nextID = status.inReplyToID
  189. }
  190. return nodes
  191. }
  192. }
  193. extension MastodonStatusThreadViewModel.Node {
  194. static func children(
  195. of statusID: ID,
  196. from statuses: [Mastodon.Entity.Status]
  197. ) -> [MastodonStatusThreadViewModel.Node] {
  198. var dictionary: [ID: Mastodon.Entity.Status] = [:]
  199. var mapping: [ID: Set<ID>] = [:]
  200. for status in statuses {
  201. dictionary[status.id] = status
  202. guard let replyToID = status.inReplyToID else { continue }
  203. if var set = mapping[replyToID] {
  204. set.insert(status.id)
  205. mapping[replyToID] = set
  206. } else {
  207. mapping[replyToID] = Set([status.id])
  208. }
  209. }
  210. var children: [MastodonStatusThreadViewModel.Node] = []
  211. let replies = Array(mapping[statusID] ?? Set())
  212. .compactMap { dictionary[$0] }
  213. .sorted(by: { $0.createdAt > $1.createdAt })
  214. for reply in replies {
  215. let child = child(of: reply.id, dictionary: dictionary, mapping: mapping)
  216. children.append(child)
  217. }
  218. return children
  219. }
  220. static func child(
  221. of statusID: ID,
  222. dictionary: [ID: Mastodon.Entity.Status],
  223. mapping: [ID: Set<ID>]
  224. ) -> MastodonStatusThreadViewModel.Node {
  225. let childrenIDs = mapping[statusID] ?? []
  226. let children = Array(childrenIDs)
  227. .compactMap { dictionary[$0] }
  228. .sorted(by: { $0.createdAt > $1.createdAt })
  229. .map { status in child(of: status.id, dictionary: dictionary, mapping: mapping) }
  230. return MastodonStatusThreadViewModel.Node(
  231. statusID: statusID,
  232. children: children
  233. )
  234. }
  235. }
  236. extension MastodonStatusThreadViewModel {
  237. func delete(objectIDs: [NSManagedObjectID]) {
  238. var set = deletedObjectIDs
  239. for objectID in objectIDs {
  240. set.insert(objectID)
  241. }
  242. self.deletedObjectIDs = set
  243. }
  244. }